mirror of
https://github.com/ae-utbm/sith.git
synced 2025-07-12 21:09:24 +00:00
Compare commits
3 Commits
invoice_ca
...
fix/licens
Author | SHA1 | Date | |
---|---|---|---|
6169d09ac0 | |||
f876bc3599 | |||
147b3619f5 |
18
.env.example
18
.env.example
@ -1,18 +0,0 @@
|
||||
HTTPS=off
|
||||
SITH_DEBUG=true
|
||||
|
||||
# This is not the real key used in prod
|
||||
SECRET_KEY=(4sjxvhz@m5$0a$j0_pqicnc$s!vbve)z+&++m%g%bjhlz4+g2
|
||||
|
||||
# comment the sqlite line and uncomment the postgres one to switch the dbms
|
||||
DATABASE_URL=sqlite:///db.sqlite3
|
||||
#DATABASE_URL=postgres://user:password@127.0.0.1:5432/sith
|
||||
|
||||
REDIS_PORT=7963
|
||||
CACHE_URL=redis://127.0.0.1:${REDIS_PORT}/0
|
||||
TASK_BROKER_URL=redis://127.0.0.1:${REDIS_PORT}/1
|
||||
|
||||
# Used to select which other services to run alongside
|
||||
# manage.py, pytest and runserver
|
||||
PROCFILE_STATIC=Procfile.static
|
||||
PROCFILE_SERVICE=Procfile.service
|
14
.envrc
14
.envrc
@ -1,6 +1,14 @@
|
||||
if [[ ! -d .venv ]]; then
|
||||
log_error 'No .venv folder found. Use `uv sync` to create one first.'
|
||||
if [[ ! -f pyproject.toml ]]; then
|
||||
log_error 'No pyproject.toml found. Use `poetry new` or `poetry init` to create one first.'
|
||||
exit 2
|
||||
fi
|
||||
|
||||
. .venv/bin/activate
|
||||
local VENV=$(poetry env list --full-path | cut -d' ' -f1)
|
||||
if [[ -z $VENV || ! -d $VENV/bin ]]; then
|
||||
log_error 'No poetry virtual environment found. Use `poetry install` to create one first.'
|
||||
exit 2
|
||||
fi
|
||||
|
||||
export VIRTUAL_ENV=$VENV
|
||||
export POETRY_ACTIVE=1
|
||||
PATH_add "$VENV/bin"
|
8
.github/actions/compile_messages/action.yml
vendored
Normal file
8
.github/actions/compile_messages/action.yml
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
name: "Compile messages"
|
||||
description: "Compile the gettext translation messages"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup project
|
||||
run: poetry run ./manage.py compilemessages
|
||||
shell: bash
|
62
.github/actions/setup_project/action.yml
vendored
62
.github/actions/setup_project/action.yml
vendored
@ -4,48 +4,54 @@ runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install apt packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
|
||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
with:
|
||||
packages: gettext
|
||||
packages: gettext libgraphviz-dev
|
||||
version: 1.0 # increment to reset cache
|
||||
|
||||
- name: Install Redis
|
||||
uses: shogo82148/actions-setup-redis@v1
|
||||
with:
|
||||
redis-version: "7.x"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install gettext libgraphviz-dev
|
||||
shell: bash
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
version: "0.5.14"
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
python-version: "3.10"
|
||||
|
||||
- name: "Set up Python"
|
||||
uses: actions/setup-python@v5
|
||||
- name: Load cached Poetry installation
|
||||
id: cached-poetry
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
path: ~/.local
|
||||
key: poetry-0 # increment to reset cache
|
||||
|
||||
- name: Restore cached virtualenv
|
||||
uses: actions/cache/restore@v4
|
||||
- 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:
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
|
||||
path: .venv
|
||||
path: ~/.cache/pypoetry
|
||||
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-poetry-
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync
|
||||
run: poetry install --with docs,tests
|
||||
shell: bash
|
||||
|
||||
- name: Install Xapian
|
||||
run: uv run ./manage.py install_xapian
|
||||
- name: Install xapian
|
||||
run: poetry 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: uv run ./manage.py compilemessages
|
||||
run: poetry run ./manage.py compilemessages
|
||||
shell: bash
|
||||
|
10
.github/actions/setup_xapian/action.yml
vendored
Normal file
10
.github/actions/setup_xapian/action.yml
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
name: "Setup xapian"
|
||||
description: "Setup the xapian indexes"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup xapian index
|
||||
run: |
|
||||
mkdir -p /dev/shm/search_indexes
|
||||
ln -s /dev/shm/search_indexes sith/search_indexes
|
||||
shell: bash
|
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@ -8,7 +8,11 @@ updates:
|
||||
- package-ecosystem: "pip" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "daily"
|
||||
# Raise pull requests for version updates
|
||||
# to pip against the `develop` branch
|
||||
target-branch: "taiste"
|
||||
reviewers:
|
||||
- "ae-utbm/developpers-v3"
|
||||
commit-message:
|
||||
prefix: "[UPDATE] "
|
41
.github/workflows/ci.yml
vendored
41
.github/workflows/ci.yml
vendored
@ -1,4 +1,4 @@
|
||||
name: Sith CI
|
||||
name: Sith 3 CI
|
||||
|
||||
on:
|
||||
push:
|
||||
@ -7,47 +7,36 @@ on:
|
||||
branches: [master, taiste]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
SECRET_KEY: notTheRealOne
|
||||
DATABASE_URL: sqlite:///db.sqlite3
|
||||
CACHE_URL: redis://127.0.0.1:6379/0
|
||||
TASK_BROKER_URL: redis://127.0.0.1:6379/1
|
||||
|
||||
jobs:
|
||||
pre-commit:
|
||||
name: Launch pre-commits checks (ruff)
|
||||
ruff:
|
||||
name: Ruff lint & format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- name: ruff format
|
||||
uses: chartboost/ruff-action@v1 # format
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- uses: pre-commit/action@v3.0.1
|
||||
with:
|
||||
extra_args: --all-files
|
||||
args: format --diff
|
||||
- name: ruff check
|
||||
uses: chartboost/ruff-action@v1 # lint
|
||||
|
||||
tests:
|
||||
name: Run tests and generate coverage report
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false # don't interrupt the other test processes
|
||||
matrix:
|
||||
pytest-mark: [not slow]
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup_project
|
||||
env:
|
||||
# To avoid race conditions on environment cache
|
||||
CACHE_SUFFIX: ${{ matrix.pytest-mark }}
|
||||
- uses: ./.github/actions/setup_xapian
|
||||
- uses: ./.github/actions/compile_messages
|
||||
- name: Run tests
|
||||
run: uv run coverage run -m pytest -m "${{ matrix.pytest-mark }}"
|
||||
run: poetry run coverage run -m pytest
|
||||
- name: Generate coverage report
|
||||
run: |
|
||||
uv run coverage report
|
||||
uv run coverage html
|
||||
poetry run coverage report
|
||||
poetry run coverage html
|
||||
- name: Archive code coverage results
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-report-${{ matrix.pytest-mark }}
|
||||
name: coverage-report
|
||||
path: coverage_report
|
||||
|
25
.github/workflows/deploy.yml
vendored
25
.github/workflows/deploy.yml
vendored
@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: SSH Remote Commands
|
||||
uses: appleboy/ssh-action@v1.1.0
|
||||
uses: appleboy/ssh-action@dce9d565de8d876c11d93fa4fe677c0285a66d78
|
||||
with:
|
||||
# Proxy
|
||||
proxy_host : ${{secrets.PROXY_HOST}}
|
||||
@ -31,18 +31,17 @@ jobs:
|
||||
|
||||
script_stop: true
|
||||
|
||||
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
|
||||
# See https://github.com/ae-utbm/sith3/wiki/GitHub-Actions#deployment-action
|
||||
script: |
|
||||
cd ${{secrets.SITH_PATH}}
|
||||
export PATH="/home/sith/.local/bin:$PATH"
|
||||
pushd ${{secrets.SITH_PATH}}
|
||||
|
||||
git fetch
|
||||
git reset --hard origin/master
|
||||
uv sync --group prod
|
||||
npm install
|
||||
uv run ./manage.py install_xapian
|
||||
uv run ./manage.py migrate
|
||||
uv run ./manage.py collectstatic --clear --noinput
|
||||
uv run ./manage.py compilemessages
|
||||
git pull
|
||||
poetry install
|
||||
poetry run ./manage.py migrate
|
||||
echo "yes" | poetry run ./manage.py collectstatic
|
||||
poetry run ./manage.py compilestatic
|
||||
poetry run ./manage.py compilemessages
|
||||
|
||||
sudo systemctl restart uwsgi
|
||||
|
||||
@ -52,10 +51,10 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
needs: deployment
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Sentry Release
|
||||
uses: getsentry/action-release@v1.7.0
|
||||
uses: getsentry/action-release@v1.2.0
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
|
25
.github/workflows/deploy_docs.yml
vendored
25
.github/workflows/deploy_docs.yml
vendored
@ -1,25 +0,0 @@
|
||||
name: deploy_docs
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
env:
|
||||
SECRET_KEY: notTheRealOne
|
||||
DATABASE_URL: sqlite:///db.sqlite3
|
||||
CACHE_URL: redis://127.0.0.1:6379/0
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup_project
|
||||
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
key: mkdocs-material-${{ env.cache_id }}
|
||||
path: .cache
|
||||
restore-keys: |
|
||||
mkdocs-material-
|
||||
- run: uv run mkdocs gh-deploy --force
|
41
.github/workflows/taiste.yml
vendored
41
.github/workflows/taiste.yml
vendored
@ -1,4 +1,4 @@
|
||||
name: Sith taiste
|
||||
name: Sith3 taiste
|
||||
|
||||
on:
|
||||
push:
|
||||
@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: SSH Remote Commands
|
||||
uses: appleboy/ssh-action@v1.1.0
|
||||
uses: appleboy/ssh-action@dce9d565de8d876c11d93fa4fe677c0285a66d78
|
||||
with:
|
||||
# Proxy
|
||||
proxy_host : ${{secrets.PROXY_HOST}}
|
||||
@ -30,17 +30,34 @@ jobs:
|
||||
|
||||
script_stop: true
|
||||
|
||||
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
|
||||
# See https://github.com/ae-utbm/sith3/wiki/GitHub-Actions#deployment-action
|
||||
script: |
|
||||
cd ${{secrets.SITH_PATH}}
|
||||
export PATH="$HOME/.poetry/bin:$PATH"
|
||||
pushd ${{secrets.SITH_PATH}}
|
||||
|
||||
git fetch
|
||||
git reset --hard origin/taiste
|
||||
uv sync --group prod
|
||||
npm install
|
||||
uv run ./manage.py install_xapian
|
||||
uv run ./manage.py migrate
|
||||
uv run ./manage.py collectstatic --clear --noinput
|
||||
uv run ./manage.py compilemessages
|
||||
git pull
|
||||
poetry install
|
||||
poetry run ./manage.py migrate
|
||||
echo "yes" | poetry run ./manage.py collectstatic
|
||||
poetry run ./manage.py compilestatic
|
||||
poetry run ./manage.py compilemessages
|
||||
|
||||
sudo systemctl restart uwsgi
|
||||
|
||||
sentry:
|
||||
runs-on: ubuntu-latest
|
||||
environment: taiste
|
||||
timeout-minutes: 30
|
||||
needs: deployment
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Sentry Release
|
||||
uses: getsentry/action-release@v1.2.0
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
with:
|
||||
environment: taiste
|
||||
|
17
.gitignore
vendored
17
.gitignore
vendored
@ -1,4 +1,4 @@
|
||||
*.sqlite3
|
||||
db.sqlite3
|
||||
*.log
|
||||
*.pyc
|
||||
*.mo
|
||||
@ -8,7 +8,7 @@ pyrightconfig.json
|
||||
dist/
|
||||
.vscode/
|
||||
.idea/
|
||||
.venv/
|
||||
env/
|
||||
doc/html
|
||||
data/
|
||||
galaxy/test_galaxy_state.json
|
||||
@ -17,15 +17,4 @@ sith/settings_custom.py
|
||||
sith/search_indexes/
|
||||
.coverage
|
||||
coverage_report/
|
||||
node_modules/
|
||||
.env
|
||||
*.pid
|
||||
|
||||
# compiled documentation
|
||||
site/
|
||||
|
||||
### Redis ###
|
||||
|
||||
# Ignore redis binary dump (dump.rdb) files
|
||||
|
||||
*.rdb
|
||||
doc/_build
|
||||
|
@ -1,26 +0,0 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.11.4
|
||||
hooks:
|
||||
- id: ruff # just check the code, and print the errors
|
||||
- id: ruff # actually fix the fixable errors, but print nothing
|
||||
args: ["--fix", "--silent"]
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/biomejs/pre-commit
|
||||
rev: v0.6.1
|
||||
hooks:
|
||||
- id: biome-check
|
||||
additional_dependencies: ["@biomejs/biome@1.9.4"]
|
||||
- repo: https://github.com/rtts/djhtml
|
||||
rev: 3.0.7
|
||||
hooks:
|
||||
- id: djhtml
|
||||
name: format templates
|
||||
entry: djhtml --tabwidth 2
|
||||
types: ["jinja"]
|
||||
- id: djcss
|
||||
name: format scss files
|
||||
entry: djcss --tabwidth 2
|
||||
types: ["scss"]
|
@ -1 +0,0 @@
|
||||
3.12
|
26
.readthedocs.yml
Normal file
26
.readthedocs.yml
Normal file
@ -0,0 +1,26 @@
|
||||
# Read the Docs configuration file
|
||||
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||
|
||||
# Required
|
||||
version: 2
|
||||
|
||||
# Allow installing xapian-bindings in pip
|
||||
build:
|
||||
apt_packages:
|
||||
- libxapian-dev
|
||||
|
||||
# Build documentation in the doc/ directory with Sphinx
|
||||
sphinx:
|
||||
configuration: doc/conf.py
|
||||
|
||||
# Optionally build your docs in additional formats such as PDF and ePub
|
||||
formats: all
|
||||
|
||||
# Optionally set the version of Python and requirements required to build your docs
|
||||
python:
|
||||
version: "3.8"
|
||||
install:
|
||||
- method: pip
|
||||
path: .
|
||||
extra_requirements:
|
||||
- docs
|
31
CONTRIBUTORS.md
Normal file
31
CONTRIBUTORS.md
Normal file
@ -0,0 +1,31 @@
|
||||
# Contributors
|
||||
|
||||
Thanks to everyone who has contributed to this project! We appreciate your time and effort.
|
||||
|
||||
## Copyright
|
||||
|
||||
> All contributions to this project are subject to the copyright owned by the Association des Étudiants de l'Université de Technologie de Belfort-Montbéliard (AE UTBM), for both past and future years. By making a contribution to this project, you acknowledge and agree that AE UTBM has the exclusive right to use, distribute, and modify your contribution, as well as to license others to do the same, in any way they see fit.
|
||||
|
||||
## License
|
||||
|
||||
This project was previously released under the MIT license, but it has since been changed to the GPL v3 license. Any contributions made to this project before the switch to GPL v3 are still subject to the MIT license, while contributions made after the switch are subject to the GPL v3 license.
|
||||
|
||||
## List of Contributors
|
||||
|
||||
- [@Hyask](https://github.com/Hyask) — Florent "Skia" Jacquet <skia@hya.sk>
|
||||
- [@klmp200](https://github.com/klmp200) — Antoine "Sli" Bartuccio <antoine@bartuccio.fr>
|
||||
- [@nab-os](https://github.com/nab-os) (AKA Gnikwo) — Sasha "Nabos" Ballet <nabos@glargh.fr>
|
||||
- [@Krophil](https://github.com/Krophil) — Pierre "Krophil'" Brunet <pierre.brunet@krophil.fr>
|
||||
- [@guillaume-renaud](https://github.com/guillaume-renaud) — Guillaume "Lo-J" RENAUD <renaudg779@gmail.com>
|
||||
- [@imperosol](https://github.com/imperosol) — Thomas "Maréchal" Girod <thgirod@hotmail.com>
|
||||
- [@TheoDurr](https://github.com/TheoDurr) — Théo "Ailé" Durr <git@theodurr.fr>
|
||||
- [@RTrioux](https://github.com/RTrioux) — Robin "Vial" Trioux
|
||||
- [@TheRolfFR](https://github.com/TheRolfFR) — Yann "Réseau" Le Vaguerès
|
||||
- [@Magador](https://github.com/Magador) — Lucie "Magador" Lenglet
|
||||
- [@lsacienne](https://github.com/lsacienne) — Alexandre "L'Sacienne" Viala
|
||||
- [@Juknum](https://github.com/Juknum) — Julien "Tinople" Constant
|
||||
- [@Tartofraise](https://github.com/Tartofraise) — Ryan "Soldat" Hadj-Mebarek
|
||||
- [@FrancescoWitz](https://github.com/FrancescoWitz) — Francesco "Och" WITZ
|
||||
- [@maxence-leblanc](https://github.com/maxence-leblanc) — Maxence "Juste" LEBLANC
|
||||
|
||||
_If you've contributed to this project and your name isn't on the list, please let us know so we can add you. And if you've contributed anonymously, thank you! We appreciate your contributions just as much as those from named contributors._
|
21
LICENSE.old
Normal file
21
LICENSE.old
Normal file
@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 Skia
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
@ -1,2 +0,0 @@
|
||||
redis: redis-server --port $REDIS_PORT
|
||||
celery: uv run celery -A sith worker --beat -l INFO
|
@ -1 +0,0 @@
|
||||
bundler: npm run serve
|
49
README.md
49
README.md
@ -1,21 +1,40 @@
|
||||
# Sith
|
||||
<p align="center">
|
||||
<a href="#">
|
||||
<img src="https://img.shields.io/badge/Code%20Style-Black-000000?style=for-the-badge">
|
||||
</a>
|
||||
<a href="#">
|
||||
<img src="https://img.shields.io/github/checks-status/ae-utbm/sith3/master?logo=github&style=for-the-badge&label=BUILD">
|
||||
</a>
|
||||
<a href="https://sith-ae.readthedocs.io/">
|
||||
<img src="https://img.shields.io/readthedocs/sith-ae?logo=readthedocs&style=for-the-badge">
|
||||
</a>
|
||||
<a href="https://discord.gg/XK9WfPsUFm">
|
||||
<img src="https://img.shields.io/discord/971448179075731476?label=Discord&logo=discord&style=for-the-badge">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
[](#)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[](#)
|
||||
[](https://ae-utbm.github.io/sith)
|
||||
[](https://squidfunk.github.io/mkdocs-material/)
|
||||
[](https://biomejs.dev)
|
||||
[](https://discord.gg/xk9wfpsufm)
|
||||
<h3 align="center">This is the source code of the UTBM's student association available at https://ae.utbm.fr/.</h3>
|
||||
|
||||
### This is the source code of the UTBM's student association available at [https://ae.utbm.fr/](https://ae.utbm.fr/).
|
||||
<p align="justify">All documentation is in the <code>docs</code> directory and online at https://sith-ae.readthedocs.io/. This documentation is written in French because it targets a French audience and it's too much work to maintain two versions. The code and code comments are strictly written in English.</p>
|
||||
|
||||
All documentation is in the `docs` directory and online at [https://ae-utbm.github.io/sith](https://ae-utbm.github.io/sith). This documentation is written in French because it targets a French audience and it's too much work to maintain two versions. The code and code comments are strictly written in English.
|
||||
<h4>If you want to contribute, here's how we recommend to read the docs:</h4>
|
||||
|
||||
#### If you want to contribute, here's how we recommend to read the docs:
|
||||
|
||||
* First, it's advised to read the about part of the project to understand the goals and the mindset of the current and previous maintainers and know what to expect to learn.
|
||||
* If in the first part you realize that you need more background about what we use, we provide some links to tutorials and documentation at the end of our documentation. Feel free to use it and complete it with what you found helpful.
|
||||
* Keep in mind that this documentation is thought to be read in order.
|
||||
<ul>
|
||||
<li>
|
||||
<p align="justify">
|
||||
First, it's advised to read the about part of the project to understand the goals and the mindset of the current and previous maintainers and know what to expect to learn.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p align="justify">
|
||||
If in the first part you realize that you need more background about what we use, we provide some links to tutorials and documentation at the end of our documentation. Feel free to use it and complete it with what you found helpful.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p align="justify">
|
||||
Keep in mind that this documentation is thought to be read in order.
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
> This project is licensed under GNU GPL, see the LICENSE file at the top of the repository for more details.
|
||||
|
19
accounting/__init__.py
Normal file
19
accounting/__init__.py
Normal file
@ -0,0 +1,19 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
32
accounting/admin.py
Normal file
32
accounting/admin.py
Normal file
@ -0,0 +1,32 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from accounting.models import *
|
||||
|
||||
admin.site.register(BankAccount)
|
||||
admin.site.register(ClubAccount)
|
||||
admin.site.register(GeneralJournal)
|
||||
admin.site.register(AccountingType)
|
||||
admin.site.register(SimplifiedAccountingType)
|
||||
admin.site.register(Operation)
|
||||
admin.site.register(Label)
|
||||
admin.site.register(Company)
|
281
accounting/migrations/0001_initial.py
Normal file
281
accounting/migrations/0001_initial.py
Normal file
@ -0,0 +1,281 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import accounting.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AccountingType",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"code",
|
||||
models.CharField(
|
||||
max_length=16,
|
||||
verbose_name="code",
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
"^[0-9]*$",
|
||||
"An accounting type code contains only numbers",
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
("label", models.CharField(max_length=128, verbose_name="label")),
|
||||
(
|
||||
"movement_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("CREDIT", "Credit"),
|
||||
("DEBIT", "Debit"),
|
||||
("NEUTRAL", "Neutral"),
|
||||
],
|
||||
max_length=12,
|
||||
verbose_name="movement type",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "accounting type",
|
||||
"ordering": ["movement_type", "code"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BankAccount",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=30, verbose_name="name")),
|
||||
(
|
||||
"iban",
|
||||
models.CharField(max_length=255, blank=True, verbose_name="iban"),
|
||||
),
|
||||
(
|
||||
"number",
|
||||
models.CharField(
|
||||
max_length=255, blank=True, verbose_name="account number"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"verbose_name": "Bank account", "ordering": ["club", "name"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ClubAccount",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=30, verbose_name="name")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Club account",
|
||||
"ordering": ["bank_account", "name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Company",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=60, verbose_name="name")),
|
||||
],
|
||||
options={"verbose_name": "company"},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="GeneralJournal",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("start_date", models.DateField(verbose_name="start date")),
|
||||
(
|
||||
"end_date",
|
||||
models.DateField(
|
||||
null=True, verbose_name="end date", default=None, blank=True
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=40, verbose_name="name")),
|
||||
(
|
||||
"closed",
|
||||
models.BooleanField(verbose_name="is closed", default=False),
|
||||
),
|
||||
(
|
||||
"amount",
|
||||
accounting.models.CurrencyField(
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="amount",
|
||||
max_digits=12,
|
||||
),
|
||||
),
|
||||
(
|
||||
"effective_amount",
|
||||
accounting.models.CurrencyField(
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="effective_amount",
|
||||
max_digits=12,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"verbose_name": "General journal", "ordering": ["-start_date"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Operation",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("number", models.IntegerField(verbose_name="number")),
|
||||
(
|
||||
"amount",
|
||||
accounting.models.CurrencyField(
|
||||
decimal_places=2, max_digits=12, verbose_name="amount"
|
||||
),
|
||||
),
|
||||
("date", models.DateField(verbose_name="date")),
|
||||
("remark", models.CharField(max_length=128, verbose_name="comment")),
|
||||
(
|
||||
"mode",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("CHECK", "Check"),
|
||||
("CASH", "Cash"),
|
||||
("TRANSFERT", "Transfert"),
|
||||
("CARD", "Credit card"),
|
||||
],
|
||||
max_length=255,
|
||||
verbose_name="payment method",
|
||||
),
|
||||
),
|
||||
(
|
||||
"cheque_number",
|
||||
models.CharField(
|
||||
max_length=32,
|
||||
null=True,
|
||||
verbose_name="cheque number",
|
||||
default="",
|
||||
blank=True,
|
||||
),
|
||||
),
|
||||
("done", models.BooleanField(verbose_name="is done", default=False)),
|
||||
(
|
||||
"target_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("USER", "User"),
|
||||
("CLUB", "Club"),
|
||||
("ACCOUNT", "Account"),
|
||||
("COMPANY", "Company"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
max_length=10,
|
||||
verbose_name="target type",
|
||||
),
|
||||
),
|
||||
(
|
||||
"target_id",
|
||||
models.IntegerField(
|
||||
null=True, verbose_name="target id", blank=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"target_label",
|
||||
models.CharField(
|
||||
max_length=32,
|
||||
blank=True,
|
||||
verbose_name="target label",
|
||||
default="",
|
||||
),
|
||||
),
|
||||
(
|
||||
"accounting_type",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
related_name="operations",
|
||||
verbose_name="accounting type",
|
||||
to="accounting.AccountingType",
|
||||
blank=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"ordering": ["-number"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SimplifiedAccountingType",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("label", models.CharField(max_length=128, verbose_name="label")),
|
||||
(
|
||||
"accounting_type",
|
||||
models.ForeignKey(
|
||||
verbose_name="simplified accounting types",
|
||||
to="accounting.AccountingType",
|
||||
related_name="simplified_types",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "simplified type",
|
||||
"ordering": ["accounting_type__movement_type", "accounting_type__code"],
|
||||
},
|
||||
),
|
||||
]
|
106
accounting/migrations/0002_auto_20160824_2152.py
Normal file
106
accounting/migrations/0002_auto_20160824_2152.py
Normal file
@ -0,0 +1,106 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("club", "0001_initial"),
|
||||
("accounting", "0001_initial"),
|
||||
("core", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="operation",
|
||||
name="invoice",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
related_name="operations",
|
||||
verbose_name="invoice",
|
||||
to="core.SithFile",
|
||||
blank=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="operation",
|
||||
name="journal",
|
||||
field=models.ForeignKey(
|
||||
verbose_name="journal",
|
||||
to="accounting.GeneralJournal",
|
||||
related_name="operations",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="operation",
|
||||
name="linked_operation",
|
||||
field=models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
blank=True,
|
||||
to="accounting.Operation",
|
||||
null=True,
|
||||
related_name="operation_linked_to",
|
||||
verbose_name="linked operation",
|
||||
default=None,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="operation",
|
||||
name="simpleaccounting_type",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
related_name="operations",
|
||||
verbose_name="simple type",
|
||||
to="accounting.SimplifiedAccountingType",
|
||||
blank=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="generaljournal",
|
||||
name="club_account",
|
||||
field=models.ForeignKey(
|
||||
verbose_name="club account",
|
||||
to="accounting.ClubAccount",
|
||||
related_name="journals",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="clubaccount",
|
||||
name="bank_account",
|
||||
field=models.ForeignKey(
|
||||
verbose_name="bank account",
|
||||
to="accounting.BankAccount",
|
||||
related_name="club_accounts",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="clubaccount",
|
||||
name="club",
|
||||
field=models.ForeignKey(
|
||||
verbose_name="club",
|
||||
to="club.Club",
|
||||
related_name="club_account",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="bankaccount",
|
||||
name="club",
|
||||
field=models.ForeignKey(
|
||||
verbose_name="club",
|
||||
to="club.Club",
|
||||
related_name="bank_accounts",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="operation", unique_together=set([("number", "journal")])
|
||||
),
|
||||
]
|
49
accounting/migrations/0003_auto_20160824_2203.py
Normal file
49
accounting/migrations/0003_auto_20160824_2203.py
Normal file
@ -0,0 +1,49 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import phonenumber_field.modelfields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounting", "0002_auto_20160824_2152")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="city",
|
||||
field=models.CharField(blank=True, verbose_name="city", max_length=60),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="country",
|
||||
field=models.CharField(blank=True, verbose_name="country", max_length=32),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="email",
|
||||
field=models.EmailField(blank=True, verbose_name="email", max_length=254),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="phone",
|
||||
field=phonenumber_field.modelfields.PhoneNumberField(
|
||||
blank=True, verbose_name="phone", max_length=128
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="postcode",
|
||||
field=models.CharField(blank=True, verbose_name="postcode", max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="street",
|
||||
field=models.CharField(blank=True, verbose_name="street", max_length=60),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="website",
|
||||
field=models.CharField(blank=True, verbose_name="website", max_length=64),
|
||||
),
|
||||
]
|
51
accounting/migrations/0004_auto_20161005_1505.py
Normal file
51
accounting/migrations/0004_auto_20161005_1505.py
Normal file
@ -0,0 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounting", "0003_auto_20160824_2203")]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Label",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
verbose_name="ID",
|
||||
primary_key=True,
|
||||
auto_created=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=64, verbose_name="label")),
|
||||
(
|
||||
"club_account",
|
||||
models.ForeignKey(
|
||||
related_name="labels",
|
||||
verbose_name="club account",
|
||||
to="accounting.ClubAccount",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="operation",
|
||||
name="label",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="operations",
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="label",
|
||||
to="accounting.Label",
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="label", unique_together=set([("name", "club_account")])
|
||||
),
|
||||
]
|
18
accounting/migrations/0005_auto_20170324_0917.py
Normal file
18
accounting/migrations/0005_auto_20170324_0917.py
Normal file
@ -0,0 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounting", "0004_auto_20161005_1505")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="operation",
|
||||
name="remark",
|
||||
field=models.CharField(
|
||||
null=True, max_length=128, blank=True, verbose_name="comment"
|
||||
),
|
||||
)
|
||||
]
|
579
accounting/models.py
Normal file
579
accounting/models.py
Normal file
@ -0,0 +1,579 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import validators
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.template import defaultfilters
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
|
||||
from club.models import Club
|
||||
from core.models import SithFile, User
|
||||
|
||||
|
||||
class CurrencyField(models.DecimalField):
|
||||
"""
|
||||
This is a custom database field used for currency
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["max_digits"] = 12
|
||||
kwargs["decimal_places"] = 2
|
||||
super(CurrencyField, self).__init__(*args, **kwargs)
|
||||
|
||||
def to_python(self, value):
|
||||
try:
|
||||
return super(CurrencyField, self).to_python(value).quantize(Decimal("0.01"))
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
|
||||
# Accounting classes
|
||||
|
||||
|
||||
class Company(models.Model):
|
||||
name = models.CharField(_("name"), max_length=60)
|
||||
street = models.CharField(_("street"), max_length=60, blank=True)
|
||||
city = models.CharField(_("city"), max_length=60, blank=True)
|
||||
postcode = models.CharField(_("postcode"), max_length=10, blank=True)
|
||||
country = models.CharField(_("country"), max_length=32, blank=True)
|
||||
phone = PhoneNumberField(_("phone"), blank=True)
|
||||
email = models.EmailField(_("email"), blank=True)
|
||||
website = models.CharField(_("website"), max_length=64, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("company")
|
||||
|
||||
def is_owned_by(self, user):
|
||||
"""
|
||||
Method to see if that object can be edited by the given user
|
||||
"""
|
||||
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
return False
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
"""
|
||||
Method to see if that object can be edited by the given user
|
||||
"""
|
||||
for club in user.memberships.filter(end_date=None).all():
|
||||
if club and club.role == settings.SITH_CLUB_ROLES_ID["Treasurer"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
def can_be_viewed_by(self, user):
|
||||
"""
|
||||
Method to see if that object can be viewed by the given user
|
||||
"""
|
||||
for club in user.memberships.filter(end_date=None).all():
|
||||
if club and club.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("accounting:co_edit", kwargs={"co_id": self.id})
|
||||
|
||||
def get_display_name(self):
|
||||
return self.name
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class BankAccount(models.Model):
|
||||
name = models.CharField(_("name"), max_length=30)
|
||||
iban = models.CharField(_("iban"), max_length=255, blank=True)
|
||||
number = models.CharField(_("account number"), max_length=255, blank=True)
|
||||
club = models.ForeignKey(
|
||||
Club,
|
||||
related_name="bank_accounts",
|
||||
verbose_name=_("club"),
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Bank account")
|
||||
ordering = ["club", "name"]
|
||||
|
||||
def is_owned_by(self, user):
|
||||
"""
|
||||
Method to see if that object can be edited by the given user
|
||||
"""
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
m = self.club.get_membership_for(user)
|
||||
if m is not None and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("accounting:bank_details", kwargs={"b_account_id": self.id})
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class ClubAccount(models.Model):
|
||||
name = models.CharField(_("name"), max_length=30)
|
||||
club = models.ForeignKey(
|
||||
Club,
|
||||
related_name="club_account",
|
||||
verbose_name=_("club"),
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
bank_account = models.ForeignKey(
|
||||
BankAccount,
|
||||
related_name="club_accounts",
|
||||
verbose_name=_("bank account"),
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Club account")
|
||||
ordering = ["bank_account", "name"]
|
||||
|
||||
def is_owned_by(self, user):
|
||||
"""
|
||||
Method to see if that object can be edited by the given user
|
||||
"""
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
return False
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
"""
|
||||
Method to see if that object can be edited by the given user
|
||||
"""
|
||||
m = self.club.get_membership_for(user)
|
||||
if m and m.role == settings.SITH_CLUB_ROLES_ID["Treasurer"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
def can_be_viewed_by(self, user):
|
||||
"""
|
||||
Method to see if that object can be viewed by the given user
|
||||
"""
|
||||
m = self.club.get_membership_for(user)
|
||||
if m and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_open_journal(self):
|
||||
for j in self.journals.all():
|
||||
if not j.closed:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_open_journal(self):
|
||||
return self.journals.filter(closed=False).first()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("accounting:club_details", kwargs={"c_account_id": self.id})
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_display_name(self):
|
||||
return _("%(club_account)s on %(bank_account)s") % {
|
||||
"club_account": self.name,
|
||||
"bank_account": self.bank_account,
|
||||
}
|
||||
|
||||
|
||||
class GeneralJournal(models.Model):
|
||||
"""
|
||||
Class storing all the operations for a period of time
|
||||
"""
|
||||
|
||||
start_date = models.DateField(_("start date"))
|
||||
end_date = models.DateField(_("end date"), null=True, blank=True, default=None)
|
||||
name = models.CharField(_("name"), max_length=40)
|
||||
closed = models.BooleanField(_("is closed"), default=False)
|
||||
club_account = models.ForeignKey(
|
||||
ClubAccount,
|
||||
related_name="journals",
|
||||
null=False,
|
||||
verbose_name=_("club account"),
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
amount = CurrencyField(_("amount"), default=0)
|
||||
effective_amount = CurrencyField(_("effective_amount"), default=0)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("General journal")
|
||||
ordering = ["-start_date"]
|
||||
|
||||
def is_owned_by(self, user):
|
||||
"""
|
||||
Method to see if that object can be edited by the given user
|
||||
"""
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
if self.club_account.can_be_edited_by(user):
|
||||
return True
|
||||
return False
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
"""
|
||||
Method to see if that object can be edited by the given user
|
||||
"""
|
||||
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
if self.club_account.can_be_edited_by(user):
|
||||
return True
|
||||
return False
|
||||
|
||||
def can_be_viewed_by(self, user):
|
||||
return self.club_account.can_be_viewed_by(user)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("accounting:journal_details", kwargs={"j_id": self.id})
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def update_amounts(self):
|
||||
self.amount = 0
|
||||
self.effective_amount = 0
|
||||
for o in self.operations.all():
|
||||
if o.accounting_type.movement_type == "CREDIT":
|
||||
if o.done:
|
||||
self.effective_amount += o.amount
|
||||
self.amount += o.amount
|
||||
else:
|
||||
if o.done:
|
||||
self.effective_amount -= o.amount
|
||||
self.amount -= o.amount
|
||||
self.save()
|
||||
|
||||
|
||||
class Operation(models.Model):
|
||||
"""
|
||||
An operation is a line in the journal, a debit or a credit
|
||||
"""
|
||||
|
||||
number = models.IntegerField(_("number"))
|
||||
journal = models.ForeignKey(
|
||||
GeneralJournal,
|
||||
related_name="operations",
|
||||
null=False,
|
||||
verbose_name=_("journal"),
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
amount = CurrencyField(_("amount"))
|
||||
date = models.DateField(_("date"))
|
||||
remark = models.CharField(_("comment"), max_length=128, null=True, blank=True)
|
||||
mode = models.CharField(
|
||||
_("payment method"),
|
||||
max_length=255,
|
||||
choices=settings.SITH_ACCOUNTING_PAYMENT_METHOD,
|
||||
)
|
||||
cheque_number = models.CharField(
|
||||
_("cheque number"), max_length=32, default="", null=True, blank=True
|
||||
)
|
||||
invoice = models.ForeignKey(
|
||||
SithFile,
|
||||
related_name="operations",
|
||||
verbose_name=_("invoice"),
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
done = models.BooleanField(_("is done"), default=False)
|
||||
simpleaccounting_type = models.ForeignKey(
|
||||
"SimplifiedAccountingType",
|
||||
related_name="operations",
|
||||
verbose_name=_("simple type"),
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
accounting_type = models.ForeignKey(
|
||||
"AccountingType",
|
||||
related_name="operations",
|
||||
verbose_name=_("accounting type"),
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
label = models.ForeignKey(
|
||||
"Label",
|
||||
related_name="operations",
|
||||
verbose_name=_("label"),
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
target_type = models.CharField(
|
||||
_("target type"),
|
||||
max_length=10,
|
||||
choices=[
|
||||
("USER", _("User")),
|
||||
("CLUB", _("Club")),
|
||||
("ACCOUNT", _("Account")),
|
||||
("COMPANY", _("Company")),
|
||||
("OTHER", _("Other")),
|
||||
],
|
||||
)
|
||||
target_id = models.IntegerField(_("target id"), null=True, blank=True)
|
||||
target_label = models.CharField(
|
||||
_("target label"), max_length=32, default="", blank=True
|
||||
)
|
||||
linked_operation = models.OneToOneField(
|
||||
"self",
|
||||
related_name="operation_linked_to",
|
||||
verbose_name=_("linked operation"),
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("number", "journal")
|
||||
ordering = ["-number"]
|
||||
|
||||
def __getattribute__(self, attr):
|
||||
if attr == "target":
|
||||
return self.get_target()
|
||||
else:
|
||||
return object.__getattribute__(self, attr)
|
||||
|
||||
def clean(self):
|
||||
super(Operation, self).clean()
|
||||
if self.date is None:
|
||||
raise ValidationError(_("The date must be set."))
|
||||
elif self.date < self.journal.start_date:
|
||||
raise ValidationError(
|
||||
_(
|
||||
"""The date can not be before the start date of the journal, which is
|
||||
%(start_date)s."""
|
||||
)
|
||||
% {
|
||||
"start_date": defaultfilters.date(
|
||||
self.journal.start_date, settings.DATE_FORMAT
|
||||
)
|
||||
}
|
||||
)
|
||||
if self.target_type != "OTHER" and self.get_target() is None:
|
||||
raise ValidationError(_("Target does not exists"))
|
||||
if self.target_type == "OTHER" and self.target_label == "":
|
||||
raise ValidationError(
|
||||
_("Please add a target label if you set no existing target")
|
||||
)
|
||||
if not self.accounting_type and not self.simpleaccounting_type:
|
||||
raise ValidationError(
|
||||
_(
|
||||
"You need to provide ether a simplified accounting type or a standard accounting type"
|
||||
)
|
||||
)
|
||||
if self.simpleaccounting_type:
|
||||
self.accounting_type = self.simpleaccounting_type.accounting_type
|
||||
|
||||
@property
|
||||
def target(self):
|
||||
return self.get_target()
|
||||
|
||||
def get_target(self):
|
||||
tar = None
|
||||
if self.target_type == "USER":
|
||||
tar = User.objects.filter(id=self.target_id).first()
|
||||
elif self.target_type == "CLUB":
|
||||
tar = Club.objects.filter(id=self.target_id).first()
|
||||
elif self.target_type == "ACCOUNT":
|
||||
tar = ClubAccount.objects.filter(id=self.target_id).first()
|
||||
elif self.target_type == "COMPANY":
|
||||
tar = Company.objects.filter(id=self.target_id).first()
|
||||
return tar
|
||||
|
||||
def save(self):
|
||||
if self.number is None:
|
||||
self.number = self.journal.operations.count() + 1
|
||||
super(Operation, self).save()
|
||||
self.journal.update_amounts()
|
||||
|
||||
def is_owned_by(self, user):
|
||||
"""
|
||||
Method to see if that object can be edited by the given user
|
||||
"""
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
if self.journal.closed:
|
||||
return False
|
||||
m = self.journal.club_account.club.get_membership_for(user)
|
||||
if m is not None and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
"""
|
||||
Method to see if that object can be edited by the given user
|
||||
"""
|
||||
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
if self.journal.closed:
|
||||
return False
|
||||
m = self.journal.club_account.club.get_membership_for(user)
|
||||
if m is not None and m.role == settings.SITH_CLUB_ROLES_ID["Treasurer"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("accounting:journal_details", kwargs={"j_id": self.journal.id})
|
||||
|
||||
def __str__(self):
|
||||
return "%d € | %s | %s | %s" % (
|
||||
self.amount,
|
||||
self.date,
|
||||
self.accounting_type,
|
||||
self.done,
|
||||
)
|
||||
|
||||
|
||||
class AccountingType(models.Model):
|
||||
"""
|
||||
Class describing the accounting types.
|
||||
|
||||
Thoses are numbers used in accounting to classify operations
|
||||
"""
|
||||
|
||||
code = models.CharField(
|
||||
_("code"),
|
||||
max_length=16,
|
||||
validators=[
|
||||
validators.RegexValidator(
|
||||
r"^[0-9]*$", _("An accounting type code contains only numbers")
|
||||
)
|
||||
],
|
||||
)
|
||||
label = models.CharField(_("label"), max_length=128)
|
||||
movement_type = models.CharField(
|
||||
_("movement type"),
|
||||
choices=[
|
||||
("CREDIT", _("Credit")),
|
||||
("DEBIT", _("Debit")),
|
||||
("NEUTRAL", _("Neutral")),
|
||||
],
|
||||
max_length=12,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("accounting type")
|
||||
ordering = ["movement_type", "code"]
|
||||
|
||||
def is_owned_by(self, user):
|
||||
"""
|
||||
Method to see if that object can be edited by the given user
|
||||
"""
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("accounting:type_list")
|
||||
|
||||
def __str__(self):
|
||||
return self.code + " - " + self.get_movement_type_display() + " - " + self.label
|
||||
|
||||
|
||||
class SimplifiedAccountingType(models.Model):
|
||||
"""
|
||||
Class describing the simplified accounting types.
|
||||
"""
|
||||
|
||||
label = models.CharField(_("label"), max_length=128)
|
||||
accounting_type = models.ForeignKey(
|
||||
AccountingType,
|
||||
related_name="simplified_types",
|
||||
verbose_name=_("simplified accounting types"),
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("simplified type")
|
||||
ordering = ["accounting_type__movement_type", "accounting_type__code"]
|
||||
|
||||
@property
|
||||
def movement_type(self):
|
||||
return self.accounting_type.movement_type
|
||||
|
||||
def get_movement_type_display(self):
|
||||
return self.accounting_type.get_movement_type_display()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("accounting:simple_type_list")
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
self.get_movement_type_display()
|
||||
+ " - "
|
||||
+ self.accounting_type.code
|
||||
+ " - "
|
||||
+ self.label
|
||||
)
|
||||
|
||||
|
||||
class Label(models.Model):
|
||||
"""Label allow a club to sort its operations"""
|
||||
|
||||
name = models.CharField(_("label"), max_length=64)
|
||||
club_account = models.ForeignKey(
|
||||
ClubAccount,
|
||||
related_name="labels",
|
||||
verbose_name=_("club account"),
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("name", "club_account")
|
||||
|
||||
def __str__(self):
|
||||
return "%s (%s)" % (self.name, self.club_account.name)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"accounting:label_list", kwargs={"clubaccount_id": self.club_account.id}
|
||||
)
|
||||
|
||||
def is_owned_by(self, user):
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
return self.club_account.is_owned_by(user)
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
return self.club_account.can_be_edited_by(user)
|
||||
|
||||
def can_be_viewed_by(self, user):
|
||||
return self.club_account.can_be_viewed_by(user)
|
27
accounting/templates/accounting/accountingtype_list.jinja
Normal file
27
accounting/templates/accounting/accountingtype_list.jinja
Normal file
@ -0,0 +1,27 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Accounting type list{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
<p>
|
||||
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
|
||||
{% trans %}Accounting types{% endtrans %}
|
||||
</p>
|
||||
<hr>
|
||||
<p><a href="{{ url('accounting:type_new') }}">{% trans %}New accounting type{% endtrans %}</a></p>
|
||||
{% if accountingtype_list %}
|
||||
<h3>{% trans %}Accounting type list{% endtrans %}</h3>
|
||||
<ul>
|
||||
{% for a in accountingtype_list %}
|
||||
<li><a href="{{ url('accounting:type_edit', type_id=a.id) }}">{{ a }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{% trans %}There is no types in this website.{% endtrans %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
38
accounting/templates/accounting/bank_account_details.jinja
Normal file
38
accounting/templates/accounting/bank_account_details.jinja
Normal file
@ -0,0 +1,38 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Bank account: {% endtrans %}{{ object.name }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
<p>
|
||||
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
|
||||
{{ object.name }}
|
||||
</p>
|
||||
<hr>
|
||||
<h2>{% trans %}Bank account: {% endtrans %}{{ object.name }}</h2>
|
||||
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) and not object.club_accounts.exists() %}
|
||||
<a href="{{ url('accounting:bank_delete', b_account_id=object.id) }}">{% trans %}Delete{% endtrans %}</a>
|
||||
{% endif %}
|
||||
<h4>{% trans %}Infos{% endtrans %}</h4>
|
||||
<ul>
|
||||
<li><strong>{% trans %}IBAN: {% endtrans %}</strong>{{ object.iban }}</li>
|
||||
<li><strong>{% trans %}Number: {% endtrans %}</strong>{{ object.number }}</li>
|
||||
</ul>
|
||||
<p><a href="{{ url('accounting:club_new') }}?parent={{ object.id }}">{% trans %}New club account{% endtrans %}</a></p>
|
||||
<ul>
|
||||
{% for c in object.club_accounts.all() %}
|
||||
<li><a href="{{ url('accounting:club_details', c_account_id=c.id) }}">{{ c }}</a>
|
||||
- <a href="{{ url('accounting:club_edit', c_account_id=c.id) }}">{% trans %}Edit{% endtrans %}</a>
|
||||
{% if c.journals.count() == 0 %}
|
||||
- <a href="{{ url('accounting:club_delete', c_account_id=c.id) }}">{% trans %}Delete{% endtrans %}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
33
accounting/templates/accounting/bank_account_list.jinja
Normal file
33
accounting/templates/accounting/bank_account_list.jinja
Normal file
@ -0,0 +1,33 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Bank account list{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
<h4>
|
||||
{% trans %}Accounting{% endtrans %}
|
||||
</h4>
|
||||
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
|
||||
<p><a href="{{ url('accounting:simple_type_list') }}">{% trans %}Manage simplified types{% endtrans %}</a></p>
|
||||
<p><a href="{{ url('accounting:type_list') }}">{% trans %}Manage accounting types{% endtrans %}</a></p>
|
||||
<p><a href="{{ url('accounting:bank_new') }}">{% trans %}New bank account{% endtrans %}</a></p>
|
||||
{% endif %}
|
||||
{% if bankaccount_list %}
|
||||
<h3>{% trans %}Bank account list{% endtrans %}</h3>
|
||||
<ul>
|
||||
{% for a in object_list %}
|
||||
<li><a href="{{ url('accounting:bank_details', b_account_id=a.id) }}">{{ a }}</a>
|
||||
- <a href="{{ url('accounting:bank_edit', b_account_id=a.id) }}">{% trans %}Edit{% endtrans %}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{% trans %}There is no accounts in this website.{% endtrans %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
68
accounting/templates/accounting/club_account_details.jinja
Normal file
68
accounting/templates/accounting/club_account_details.jinja
Normal file
@ -0,0 +1,68 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Club account:{% endtrans %} {{ object.name }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
<p>
|
||||
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
|
||||
<a href="{{ url('accounting:bank_details', b_account_id=object.bank_account.id) }}">{{object.bank_account }}</a> >
|
||||
{{ object }}
|
||||
</p>
|
||||
<hr>
|
||||
<h2>{% trans %}Club account:{% endtrans %} {{ object.name }}</h2>
|
||||
{% if user.is_root and not object.journals.exists() %}
|
||||
<a href="{{ url('accounting:club_delete', c_account_id=object.id) }}">{% trans %}Delete{% endtrans %}</a>
|
||||
{% endif %}
|
||||
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
|
||||
<p><a href="{{ url('accounting:label_new') }}?parent={{ object.id }}">{% trans %}New label{% endtrans %}</a></p>
|
||||
{% endif %}
|
||||
<p><a href="{{ url('accounting:label_list', clubaccount_id=object.id) }}">{% trans %}Label list{% endtrans %}</a></p>
|
||||
{% if not object.has_open_journal() %}
|
||||
<p><a href="{{ url('accounting:journal_new') }}?parent={{ object.id }}">{% trans %}New journal{% endtrans %}</a></p>
|
||||
{% else %}
|
||||
<p>{% trans %}You can not create new journal while you still have one opened{% endtrans %}</p>
|
||||
{% endif %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}Name{% endtrans %}</td>
|
||||
<td>{% trans %}Start{% endtrans %}</td>
|
||||
<td>{% trans %}End{% endtrans %}</td>
|
||||
<td>{% trans %}Amount{% endtrans %}</td>
|
||||
<td>{% trans %}Effective amount{% endtrans %}</td>
|
||||
<td>{% trans %}Closed{% endtrans %}</td>
|
||||
<td>{% trans %}Actions{% endtrans %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for j in object.journals.all() %}
|
||||
<tr>
|
||||
<td>{{ j.name }}</td>
|
||||
<td>{{ j.start_date }}</td>
|
||||
{% if j.end_date %}
|
||||
<td>{{ j.end_date }}</td>
|
||||
{% else %}
|
||||
<td> - </td>
|
||||
{% endif %}
|
||||
<td>{{ j.amount }} €</td>
|
||||
<td>{{ j.effective_amount }} €</td>
|
||||
{% if j.closed %}
|
||||
<td>{% trans %}Yes{% endtrans %}</td>
|
||||
{% else %}
|
||||
<td>{% trans %}No{% endtrans %}</td>
|
||||
{% endif %}
|
||||
<td> <a href="{{ url('accounting:journal_details', j_id=j.id) }}">{% trans %}View{% endtrans %}</a>
|
||||
<a href="{{ url('accounting:journal_edit', j_id=j.id) }}">{% trans %}Edit{% endtrans %}</a>
|
||||
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) and j.operations.count() == 0 %}
|
||||
<a href="{{ url('accounting:journal_delete', j_id=j.id) }}">{% trans %}Delete{% endtrans %}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
30
accounting/templates/accounting/co_list.jinja
Normal file
30
accounting/templates/accounting/co_list.jinja
Normal file
@ -0,0 +1,30 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Company list{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
{% if user.is_root
|
||||
or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
||||
%}
|
||||
<p><a href="{{ url('accounting:co_new') }}">{% trans %}Create new company{% endtrans %}</a></p>
|
||||
{% endif %}
|
||||
<br/>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}Companies{% endtrans %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for o in object_list %}
|
||||
<tr>
|
||||
<td><a href="{{ url('accounting:co_edit', co_id=o.id) }}">{{ o.get_display_name() }}</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
103
accounting/templates/accounting/journal_details.jinja
Normal file
103
accounting/templates/accounting/journal_details.jinja
Normal file
@ -0,0 +1,103 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}General journal:{% endtrans %} {{ object.name }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
<p>
|
||||
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
|
||||
<a href="{{ url('accounting:bank_details', b_account_id=object.club_account.bank_account.id) }}">{{object.club_account.bank_account }}</a> >
|
||||
<a href="{{ url('accounting:club_details', c_account_id=object.club_account.id) }}">{{ object.club_account }}</a> >
|
||||
{{ object.name }}
|
||||
</p>
|
||||
<hr>
|
||||
<h2>{% trans %}General journal:{% endtrans %} {{ object.name }}</h2>
|
||||
<p><a href="{{ url('accounting:label_new') }}?parent={{ object.club_account.id }}">{% trans %}New label{% endtrans %}</a></p>
|
||||
<p><a href="{{ url('accounting:label_list', clubaccount_id=object.club_account.id) }}">{% trans %}Label list{% endtrans %}</a></p>
|
||||
<p><a href="{{ url('accounting:co_list') }}">{% trans %}Company list{% endtrans %}</a></p>
|
||||
<p><strong>{% trans %}Amount: {% endtrans %}</strong>{{ object.amount }} € -
|
||||
<strong>{% trans %}Effective amount: {% endtrans %}</strong>{{ object.effective_amount }} €</p>
|
||||
{% if object.closed %}
|
||||
<p>{% trans %}Journal is closed, you can not create operation{% endtrans %}</p>
|
||||
{% else %}
|
||||
<p><a href="{{ url('accounting:op_new', j_id=object.id) }}">{% trans %}New operation{% endtrans %}</a></p>
|
||||
</br>
|
||||
{% endif %}
|
||||
<div class="journal-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}Nb{% endtrans %}</td>
|
||||
<td>{% trans %}Date{% endtrans %}</td>
|
||||
<td>{% trans %}Label{% endtrans %}</td>
|
||||
<td>{% trans %}Amount{% endtrans %}</td>
|
||||
<td>{% trans %}Payment mode{% endtrans %}</td>
|
||||
<td>{% trans %}Target{% endtrans %}</td>
|
||||
<td>{% trans %}Code{% endtrans %}</td>
|
||||
<td>{% trans %}Nature{% endtrans %}</td>
|
||||
<td>{% trans %}Done{% endtrans %}</td>
|
||||
<td>{% trans %}Comment{% endtrans %}</td>
|
||||
<td>{% trans %}File{% endtrans %}</td>
|
||||
<td>{% trans %}Actions{% endtrans %}</td>
|
||||
<td>{% trans %}PDF{% endtrans %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for o in object.operations.all() %}
|
||||
<tr>
|
||||
<td>{{ o.number }}</td>
|
||||
<td>{{ o.date }}</td>
|
||||
<td>{{ o.label or "" }}</td>
|
||||
{% if o.accounting_type.movement_type == "DEBIT" %}
|
||||
<td class="neg-amount"> {{ o.amount }} €</td>
|
||||
{% else %}
|
||||
<td class="pos-amount"> {{ o.amount }} €</td>
|
||||
{% endif %}
|
||||
<td>{{ o.get_mode_display() }}</td>
|
||||
{% if o.target_type == "OTHER" %}
|
||||
<td>{{ o.target_label }}</td>
|
||||
{% else %}
|
||||
<td><a href="{{ o.target.get_absolute_url() }}">{{ o.target.get_display_name() }}</a></td>
|
||||
{% endif %}
|
||||
<td>{{ o.accounting_type.code }}</td>
|
||||
<td>{{ o.accounting_type.label }}</td>
|
||||
{% if o.done %}
|
||||
<td>{% trans %}Yes{% endtrans %}</td>
|
||||
{% else %}
|
||||
<td>{% trans %}No{% endtrans %}</td>
|
||||
{% endif %}
|
||||
<td>{{ o.remark }}
|
||||
{% if not o.linked_operation and o.target_type == "ACCOUNT" and not o.target.has_open_journal() %}
|
||||
<p><strong>
|
||||
{% trans %}Warning: this operation has no linked operation because the targeted club account has no opened journal.{% endtrans %}
|
||||
</strong></p>
|
||||
<p><strong>
|
||||
{% trans url=o.target.get_absolute_url() %}Open a journal in <a href="{{ url }}">this club account</a>, then save this operation again to make the linked operation.{% endtrans %}
|
||||
</strong></p>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if o.invoice %}
|
||||
<td><a href="{{ url('core:download', file_id=o.invoice.id) }}">{{ o.invoice.name }}</a></td>
|
||||
{% else %}
|
||||
<td>-</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
{%
|
||||
if o.journal.club_account.bank_account.name not in ["AE TI", "TI"]
|
||||
or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
||||
%}
|
||||
{% if not o.journal.closed %}
|
||||
<a href="{{ url('accounting:op_edit', op_id=o.id) }}">{% trans %}Edit{% endtrans %}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><a href="{{ url('accounting:op_pdf', op_id=o.id) }}">{% trans %}Generate{% endtrans %}</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -0,0 +1,33 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}General journal:{% endtrans %} {{ object.name }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
<h3>{% trans %}Accounting statement: {% endtrans %} {{ object.name }}</h3>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}Operation type{% endtrans %}</td>
|
||||
<td>{% trans %}Sum{% endtrans %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for k,v in statement.items() %}
|
||||
<tr>
|
||||
<td>{{ k }}</td>
|
||||
<td>{{ "%.2f" % v }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
<p><strong>{% trans %}Amount: {% endtrans %}</strong>{{ "%.2f" % object.amount }} €</p>
|
||||
<p><strong>{% trans %}Effective amount: {% endtrans %}</strong>{{ "%.2f" %object.effective_amount }} €</p>
|
||||
</div>
|
||||
{% endblock %}
|
@ -0,0 +1,57 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}General journal:{% endtrans %} {{ object.name }}
|
||||
{% endblock %}
|
||||
|
||||
{% macro display_tables(dict) %}
|
||||
<div id="accounting">
|
||||
<h6>{% trans %}Credit{% endtrans %}</h6>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}Nature of operation{% endtrans %}</td>
|
||||
<td>{% trans %}Sum{% endtrans %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for k,v in dict['CREDIT'].items() %}
|
||||
<tr>
|
||||
<td>{{ k }}</td>
|
||||
<td>{{ "%.2f" % v }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% trans %}Total: {% endtrans %}{{ "%.2f" % dict['CREDIT_sum'] }}
|
||||
|
||||
<h6>{% trans %}Debit{% endtrans %}</h6>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}Nature of operation{% endtrans %}</td>
|
||||
<td>{% trans %}Sum{% endtrans %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for k,v in dict['DEBIT'].items() %}
|
||||
<tr>
|
||||
<td>{{ k }}</td>
|
||||
<td>{{ "%.2f" % v }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% trans %}Total: {% endtrans %}{{ "%.2f" % dict['DEBIT_sum'] }}
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<h3>{% trans %}Statement by nature: {% endtrans %} {{ object.name }}</h3>
|
||||
|
||||
{% for k,v in statement.items() %}
|
||||
<h4 style="background: lightblue; padding: 4px;">{{ k }} : {{ "%.2f" % (v['CREDIT_sum'] - v['DEBIT_sum']) }}</h4>
|
||||
{{ display_tables(v) }}
|
||||
<hr>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
@ -0,0 +1,68 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}General journal:{% endtrans %} {{ object.name }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
<h3>{% trans %}Statement by person: {% endtrans %} {{ object.name }}</h3>
|
||||
|
||||
<h4>{% trans %}Credit{% endtrans %}</h4>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}Target of the operation{% endtrans %}</td>
|
||||
<td>{% trans %}Sum{% endtrans %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key in credit_statement.keys() %}
|
||||
<tr>
|
||||
{% if key.target_type == "OTHER" %}
|
||||
<td>{{ o.target_label }}</td>
|
||||
{% elif key %}
|
||||
<td><a href="{{ key.get_absolute_url() }}">{{ key.get_display_name() }}</a></td>
|
||||
{% else %}
|
||||
<td></td>
|
||||
{% endif %}
|
||||
<td>{{ "%.2f" % credit_statement[key] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
<p>Total : {{ "%.2f" % total_credit }}</p>
|
||||
|
||||
<h4>{% trans %}Debit{% endtrans %}</h4>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}Target of the operation{% endtrans %}</td>
|
||||
<td>{% trans %}Sum{% endtrans %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key in debit_statement.keys() %}
|
||||
<tr>
|
||||
{% if key.target_type == "OTHER" %}
|
||||
<td>{{ o.target_label }}</td>
|
||||
{% elif key %}
|
||||
<td><a href="{{ key.get_absolute_url() }}">{{ key.get_display_name() }}</a></td>
|
||||
{% else %}
|
||||
<td></td>
|
||||
{% endif %}
|
||||
<td>{{ "%.2f" % debit_statement[key] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
<p>Total : {{ "%.2f" % total_debit }}</p>
|
||||
</div>
|
||||
{% endblock %}
|
36
accounting/templates/accounting/label_list.jinja
Normal file
36
accounting/templates/accounting/label_list.jinja
Normal file
@ -0,0 +1,36 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Label list{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
<p>
|
||||
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
|
||||
<a href="{{ url('accounting:bank_details', b_account_id=object.bank_account.id) }}">{{object.bank_account }}</a> >
|
||||
<a href="{{ url('accounting:club_details', c_account_id=object.id) }}">{{ object }}</a>
|
||||
</p>
|
||||
<hr>
|
||||
<p><a href="{{ url('accounting:club_details', c_account_id=object.id) }}">{% trans %}Back to club account{% endtrans %}</a></p>
|
||||
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
|
||||
<p><a href="{{ url('accounting:label_new') }}?parent={{ object.id }}">{% trans %}New label{% endtrans %}</a></p>
|
||||
{% endif %}
|
||||
{% if object.labels.all() %}
|
||||
<h3>{% trans %}Label list{% endtrans %}</h3>
|
||||
<ul>
|
||||
{% for l in object.labels.all() %}
|
||||
<li><a href="{{ url('accounting:label_edit', label_id=l.id) }}">{{ l }}</a>
|
||||
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
|
||||
-
|
||||
<a href="{{ url('accounting:label_delete', label_id=l.id) }}">{% trans %}Delete{% endtrans %}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{% trans %}There is no label in this club account.{% endtrans %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
123
accounting/templates/accounting/operation_edit.jinja
Normal file
123
accounting/templates/accounting/operation_edit.jinja
Normal file
@ -0,0 +1,123 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Edit operation{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
<p>
|
||||
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
|
||||
<a href="{{ url('accounting:bank_details', b_account_id=object.club_account.bank_account.id) }}">{{object.club_account.bank_account }}</a> >
|
||||
<a href="{{ url('accounting:club_details', c_account_id=object.club_account.id) }}">{{ object.club_account }}</a> >
|
||||
<a href="{{ url('accounting:journal_details', j_id=object.id) }}">{{ object.name }}</a> >
|
||||
{% trans %}Edit operation{% endtrans %}
|
||||
</p>
|
||||
<hr>
|
||||
<h2>{% trans %}Edit operation{% endtrans %}</h2>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.non_field_errors() }}
|
||||
{{ form.journal }}
|
||||
{{ form.target_id }}
|
||||
<p>{{ form.amount.errors }}<label for="{{ form.amount.name }}">{{ form.amount.label }}</label> {{ form.amount }}</p>
|
||||
<p>{{ form.remark.errors }}<label for="{{ form.remark.name }}">{{ form.remark.label }}</label> {{ form.remark }}</p>
|
||||
<br />
|
||||
<strong>{% trans %}Warning: if you select <em>Account</em>, the opposite operation will be created in the target account. If you don't want that, select <em>Club</em> instead of <em>Account</em>.{% endtrans %}</strong>
|
||||
<p>{{ form.target_type.errors }}<label for="{{ form.target_type.name }}">{{ form.target_type.label }}</label> {{ form.target_type }}</p>
|
||||
{{ form.user }}
|
||||
{{ form.club }}
|
||||
{{ form.club_account }}
|
||||
{{ form.company }}
|
||||
{{ form.target_label }}
|
||||
<span id="id_need_link_full"><label>{{ form.need_link.label }}</label> {{ form.need_link }}</span>
|
||||
<p>{{ form.date.errors }}<label for="{{ form.date.name }}">{{ form.date.label }}</label> {{ form.date }}</p>
|
||||
<p>{{ form.mode.errors }}<label for="{{ form.mode.name }}">{{ form.mode.label }}</label> {{ form.mode }}</p>
|
||||
<p>{{ form.cheque_number.errors }}<label for="{{ form.cheque_number.name }}">{{ form.cheque_number.label }}</label> {{
|
||||
form.cheque_number }}</p>
|
||||
<p>{{ form.invoice.errors }}<label for="{{ form.invoice.name }}">{{ form.invoice.label }}</label> {{ form.invoice }}</p>
|
||||
<p>{{ form.simpleaccounting_type.errors }}<label for="{{ form.simpleaccounting_type.name }}">{{
|
||||
form.simpleaccounting_type.label }}</label> {{ form.simpleaccounting_type }}</p>
|
||||
<p>{{ form.accounting_type.errors }}<label for="{{ form.accounting_type.name }}">{{ form.accounting_type.label }}</label> {{
|
||||
form.accounting_type }}</p>
|
||||
<p>{{ form.label.errors }}<label for="{{ form.label.name }}">{{ form.label.label }}</label> {{ form.label }}</p>
|
||||
<p>{{ form.done.errors }}<label for="{{ form.done.name }}">{{ form.done.label }}</label> {{ form.done }}</p>
|
||||
{% if form.instance.linked_operation %}
|
||||
{% set obj = form.instance.linked_operation %}
|
||||
<p><strong>{% trans %}Linked operation:{% endtrans %}</strong><br>
|
||||
<a href="{{ url('accounting:bank_details', b_account_id=obj.journal.club_account.bank_account.id) }}">
|
||||
{{obj.journal.club_account.bank_account }}</a> >
|
||||
<a href="{{ url('accounting:club_details', c_account_id=obj.journal.club_account.id) }}">{{ obj.journal.club_account }}</a> >
|
||||
<a href="{{ url('accounting:journal_details', j_id=obj.journal.id) }}">{{ obj.journal }}</a> >
|
||||
n°{{ obj.number }}
|
||||
</p>
|
||||
{% endif %}
|
||||
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
$( function() {
|
||||
var target_type = $('#id_target_type');
|
||||
var user = $('#id_user_wrapper');
|
||||
var club = $('#id_club_wrapper');
|
||||
var club_account = $('#id_club_account_wrapper');
|
||||
var company = $('#id_company_wrapper');
|
||||
var other = $('#id_target_label');
|
||||
var need_link = $('#id_need_link_full');
|
||||
function update_targets () {
|
||||
if (target_type.val() == "USER") {
|
||||
console.log(user);
|
||||
user.show();
|
||||
club.hide();
|
||||
club_account.hide();
|
||||
company.hide();
|
||||
other.hide();
|
||||
need_link.hide();
|
||||
} else if (target_type.val() == "ACCOUNT") {
|
||||
club_account.show();
|
||||
need_link.show();
|
||||
user.hide();
|
||||
club.hide();
|
||||
company.hide();
|
||||
other.hide();
|
||||
} else if (target_type.val() == "CLUB") {
|
||||
club.show();
|
||||
user.hide();
|
||||
club_account.hide();
|
||||
company.hide();
|
||||
other.hide();
|
||||
need_link.hide();
|
||||
} else if (target_type.val() == "COMPANY") {
|
||||
company.show();
|
||||
user.hide();
|
||||
club_account.hide();
|
||||
club.hide();
|
||||
other.hide();
|
||||
need_link.hide();
|
||||
} else if (target_type.val() == "OTHER") {
|
||||
other.show();
|
||||
user.hide();
|
||||
club.hide();
|
||||
club_account.hide();
|
||||
company.hide();
|
||||
need_link.hide();
|
||||
} else {
|
||||
company.hide();
|
||||
user.hide();
|
||||
club_account.hide();
|
||||
club.hide();
|
||||
other.hide();
|
||||
need_link.hide();
|
||||
}
|
||||
}
|
||||
update_targets();
|
||||
target_type.change(update_targets);
|
||||
} );
|
||||
</script>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
16
accounting/templates/accounting/refound_account.jinja
Normal file
16
accounting/templates/accounting/refound_account.jinja
Normal file
@ -0,0 +1,16 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Refound account{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
<h3>{% trans %}Refound account{% endtrans %}</h3>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p() }}
|
||||
<p><input type="submit" value="{% trans %}Refound{% endtrans %}" /></p>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
@ -0,0 +1,27 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Simplified type list{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
<p>
|
||||
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
|
||||
{% trans %}Simplified types{% endtrans %}
|
||||
</p>
|
||||
<hr>
|
||||
<p><a href="{{ url('accounting:simple_type_new') }}">{% trans %}New simplified type{% endtrans %}</a></p>
|
||||
{% if simplifiedaccountingtype_list %}
|
||||
<h3>{% trans %}Simplified type list{% endtrans %}</h3>
|
||||
<ul>
|
||||
{% for a in simplifiedaccountingtype_list %}
|
||||
<li><a href="{{ url('accounting:simple_type_edit', type_id=a.id) }}">{{ a }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{% trans %}There is no types in this website.{% endtrans %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
305
accounting/tests.py
Normal file
305
accounting/tests.py
Normal file
@ -0,0 +1,305 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from accounting.models import (
|
||||
AccountingType,
|
||||
GeneralJournal,
|
||||
Label,
|
||||
Operation,
|
||||
SimplifiedAccountingType,
|
||||
)
|
||||
from core.models import User
|
||||
|
||||
|
||||
class RefoundAccountTest(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.skia = User.objects.get(username="skia")
|
||||
# reffil skia's account
|
||||
cls.skia.customer.amount = 800
|
||||
cls.skia.customer.save()
|
||||
cls.refound_account_url = reverse("accounting:refound_account")
|
||||
|
||||
def test_permission_denied(self):
|
||||
self.client.force_login(User.objects.get(username="guy"))
|
||||
response_post = self.client.post(
|
||||
self.refound_account_url, {"user": self.skia.id}
|
||||
)
|
||||
response_get = self.client.get(self.refound_account_url)
|
||||
assert response_get.status_code == 403
|
||||
assert response_post.status_code == 403
|
||||
|
||||
def test_root_granteed(self):
|
||||
self.client.force_login(User.objects.get(username="root"))
|
||||
response = self.client.post(self.refound_account_url, {"user": self.skia.id})
|
||||
self.assertRedirects(response, self.refound_account_url)
|
||||
self.skia.refresh_from_db()
|
||||
response = self.client.get(self.refound_account_url)
|
||||
assert response.status_code == 200
|
||||
assert '<form action="" method="post">' in str(response.content)
|
||||
assert self.skia.customer.amount == 0
|
||||
|
||||
def test_comptable_granteed(self):
|
||||
self.client.force_login(User.objects.get(username="comptable"))
|
||||
response = self.client.post(self.refound_account_url, {"user": self.skia.id})
|
||||
self.assertRedirects(response, self.refound_account_url)
|
||||
self.skia.refresh_from_db()
|
||||
response = self.client.get(self.refound_account_url)
|
||||
assert response.status_code == 200
|
||||
assert '<form action="" method="post">' in str(response.content)
|
||||
assert self.skia.customer.amount == 0
|
||||
|
||||
|
||||
class JournalTest(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.journal = GeneralJournal.objects.get(id=1)
|
||||
|
||||
def test_permission_granted(self):
|
||||
self.client.force_login(User.objects.get(username="comptable"))
|
||||
response_get = self.client.get(
|
||||
reverse("accounting:journal_details", args=[self.journal.id])
|
||||
)
|
||||
|
||||
assert response_get.status_code == 200
|
||||
assert "<td>M\\xc3\\xa9thode de paiement</td>" in str(response_get.content)
|
||||
|
||||
def test_permission_not_granted(self):
|
||||
self.client.force_login(User.objects.get(username="skia"))
|
||||
response_get = self.client.get(
|
||||
reverse("accounting:journal_details", args=[self.journal.id])
|
||||
)
|
||||
|
||||
assert response_get.status_code == 403
|
||||
assert "<td>M\xc3\xa9thode de paiement</td>" not in str(response_get.content)
|
||||
|
||||
|
||||
class OperationTest(TestCase):
|
||||
def setUp(self):
|
||||
self.tomorrow_formatted = (date.today() + timedelta(days=1)).strftime(
|
||||
"%d/%m/%Y"
|
||||
)
|
||||
self.journal = GeneralJournal.objects.filter(id=1).first()
|
||||
self.skia = User.objects.filter(username="skia").first()
|
||||
at = AccountingType(
|
||||
code="443", label="Ce code n'existe pas", movement_type="CREDIT"
|
||||
)
|
||||
at.save()
|
||||
l = Label.objects.create(club_account=self.journal.club_account, name="bob")
|
||||
self.client.force_login(User.objects.get(username="comptable"))
|
||||
self.op1 = Operation(
|
||||
journal=self.journal,
|
||||
date=date.today(),
|
||||
amount=1,
|
||||
remark="Test bilan",
|
||||
mode="CASH",
|
||||
done=True,
|
||||
label=l,
|
||||
accounting_type=at,
|
||||
target_type="USER",
|
||||
target_id=self.skia.id,
|
||||
)
|
||||
self.op1.save()
|
||||
self.op2 = Operation(
|
||||
journal=self.journal,
|
||||
date=date.today(),
|
||||
amount=2,
|
||||
remark="Test bilan",
|
||||
mode="CASH",
|
||||
done=True,
|
||||
label=l,
|
||||
accounting_type=at,
|
||||
target_type="USER",
|
||||
target_id=self.skia.id,
|
||||
)
|
||||
self.op2.save()
|
||||
|
||||
def test_new_operation(self):
|
||||
at = AccountingType.objects.get(code="604")
|
||||
response = self.client.post(
|
||||
reverse("accounting:op_new", args=[self.journal.id]),
|
||||
{
|
||||
"amount": 30,
|
||||
"remark": "Un gros test",
|
||||
"journal": self.journal.id,
|
||||
"target_type": "OTHER",
|
||||
"target_id": "",
|
||||
"target_label": "Le fantome de la nuit",
|
||||
"date": self.tomorrow_formatted,
|
||||
"mode": "CASH",
|
||||
"cheque_number": "",
|
||||
"invoice": "",
|
||||
"simpleaccounting_type": "",
|
||||
"accounting_type": at.id,
|
||||
"label": "",
|
||||
"done": False,
|
||||
},
|
||||
)
|
||||
self.assertFalse(response.status_code == 403)
|
||||
self.assertTrue(
|
||||
self.journal.operations.filter(
|
||||
target_label="Le fantome de la nuit"
|
||||
).exists()
|
||||
)
|
||||
response_get = self.client.get(
|
||||
reverse("accounting:journal_details", args=[self.journal.id])
|
||||
)
|
||||
self.assertTrue("<td>Le fantome de la nuit</td>" in str(response_get.content))
|
||||
|
||||
def test_bad_new_operation(self):
|
||||
AccountingType.objects.get(code="604")
|
||||
response = self.client.post(
|
||||
reverse("accounting:op_new", args=[self.journal.id]),
|
||||
{
|
||||
"amount": 30,
|
||||
"remark": "Un gros test",
|
||||
"journal": self.journal.id,
|
||||
"target_type": "OTHER",
|
||||
"target_id": "",
|
||||
"target_label": "Le fantome de la nuit",
|
||||
"date": self.tomorrow_formatted,
|
||||
"mode": "CASH",
|
||||
"cheque_number": "",
|
||||
"invoice": "",
|
||||
"simpleaccounting_type": "",
|
||||
"accounting_type": "",
|
||||
"label": "",
|
||||
"done": False,
|
||||
},
|
||||
)
|
||||
self.assertTrue(
|
||||
"Vous devez fournir soit un type comptable simplifi\\xc3\\xa9 ou un type comptable standard"
|
||||
in str(response.content)
|
||||
)
|
||||
|
||||
def test_new_operation_not_authorized(self):
|
||||
self.client.force_login(self.skia)
|
||||
at = AccountingType.objects.filter(code="604").first()
|
||||
response = self.client.post(
|
||||
reverse("accounting:op_new", args=[self.journal.id]),
|
||||
{
|
||||
"amount": 30,
|
||||
"remark": "Un gros test",
|
||||
"journal": self.journal.id,
|
||||
"target_type": "OTHER",
|
||||
"target_id": "",
|
||||
"target_label": "Le fantome du jour",
|
||||
"date": self.tomorrow_formatted,
|
||||
"mode": "CASH",
|
||||
"cheque_number": "",
|
||||
"invoice": "",
|
||||
"simpleaccounting_type": "",
|
||||
"accounting_type": at.id,
|
||||
"label": "",
|
||||
"done": False,
|
||||
},
|
||||
)
|
||||
self.assertTrue(response.status_code == 403)
|
||||
self.assertFalse(
|
||||
self.journal.operations.filter(target_label="Le fantome du jour").exists()
|
||||
)
|
||||
|
||||
def test__operation_simple_accounting(self):
|
||||
sat = SimplifiedAccountingType.objects.all().first()
|
||||
response = self.client.post(
|
||||
reverse("accounting:op_new", args=[self.journal.id]),
|
||||
{
|
||||
"amount": 23,
|
||||
"remark": "Un gros test",
|
||||
"journal": self.journal.id,
|
||||
"target_type": "OTHER",
|
||||
"target_id": "",
|
||||
"target_label": "Le fantome de l'aurore",
|
||||
"date": self.tomorrow_formatted,
|
||||
"mode": "CASH",
|
||||
"cheque_number": "",
|
||||
"invoice": "",
|
||||
"simpleaccounting_type": sat.id,
|
||||
"accounting_type": "",
|
||||
"label": "",
|
||||
"done": False,
|
||||
},
|
||||
)
|
||||
self.assertFalse(response.status_code == 403)
|
||||
self.assertTrue(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(
|
||||
self.journal.operations.filter(amount=23)
|
||||
.values("accounting_type")
|
||||
.first()["accounting_type"]
|
||||
== AccountingType.objects.filter(code=6).values("id").first()["id"]
|
||||
)
|
||||
|
||||
def test_nature_statement(self):
|
||||
response = self.client.get(
|
||||
reverse("accounting:journal_nature_statement", args=[self.journal.id])
|
||||
)
|
||||
self.assertContains(response, "bob (Troll Penché) : 3.00", status_code=200)
|
||||
|
||||
def test_person_statement(self):
|
||||
response = self.client.get(
|
||||
reverse("accounting:journal_person_statement", args=[self.journal.id])
|
||||
)
|
||||
self.assertContains(response, "Total : 5575.72", status_code=200)
|
||||
self.assertContains(response, "Total : 71.42")
|
||||
self.assertContains(
|
||||
response,
|
||||
"""
|
||||
<td><a href="/user/1/">S' Kia</a></td>
|
||||
|
||||
<td>3.00</td>""",
|
||||
)
|
||||
self.assertContains(
|
||||
response,
|
||||
"""
|
||||
<td><a href="/user/1/">S' Kia</a></td>
|
||||
|
||||
<td>823.00</td>""",
|
||||
)
|
||||
|
||||
def test_accounting_statement(self):
|
||||
response = self.client.get(
|
||||
reverse("accounting:journal_accounting_statement", args=[self.journal.id])
|
||||
)
|
||||
self.assertContains(
|
||||
response,
|
||||
"""
|
||||
<tr>
|
||||
<td>443 - Crédit - Ce code n'existe pas</td>
|
||||
<td>3.00</td>
|
||||
</tr>""",
|
||||
status_code=200,
|
||||
)
|
||||
self.assertContains(
|
||||
response,
|
||||
"""
|
||||
<p><strong>Montant : </strong>-5504.30 €</p>
|
||||
<p><strong>Montant effectif: </strong>-5504.30 €</p>""",
|
||||
)
|
144
accounting/urls.py
Normal file
144
accounting/urls.py
Normal file
@ -0,0 +1,144 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from accounting.views import *
|
||||
|
||||
urlpatterns = [
|
||||
# Accounting types
|
||||
path(
|
||||
"simple_type/",
|
||||
SimplifiedAccountingTypeListView.as_view(),
|
||||
name="simple_type_list",
|
||||
),
|
||||
path(
|
||||
"simple_type/create/",
|
||||
SimplifiedAccountingTypeCreateView.as_view(),
|
||||
name="simple_type_new",
|
||||
),
|
||||
path(
|
||||
"simple_type/<int:type_id>/edit/",
|
||||
SimplifiedAccountingTypeEditView.as_view(),
|
||||
name="simple_type_edit",
|
||||
),
|
||||
# Accounting types
|
||||
path("type/", AccountingTypeListView.as_view(), name="type_list"),
|
||||
path("type/create/", AccountingTypeCreateView.as_view(), name="type_new"),
|
||||
path(
|
||||
"type/<int:type_id>/edit/",
|
||||
AccountingTypeEditView.as_view(),
|
||||
name="type_edit",
|
||||
),
|
||||
# Bank accounts
|
||||
path("", BankAccountListView.as_view(), name="bank_list"),
|
||||
path("bank/create", BankAccountCreateView.as_view(), name="bank_new"),
|
||||
path(
|
||||
"bank/<int:b_account_id>/",
|
||||
BankAccountDetailView.as_view(),
|
||||
name="bank_details",
|
||||
),
|
||||
path(
|
||||
"bank/<int:b_account_id>/edit/",
|
||||
BankAccountEditView.as_view(),
|
||||
name="bank_edit",
|
||||
),
|
||||
path(
|
||||
"bank/<int:b_account_id>/delete/",
|
||||
BankAccountDeleteView.as_view(),
|
||||
name="bank_delete",
|
||||
),
|
||||
# Club accounts
|
||||
path("club/create/", ClubAccountCreateView.as_view(), name="club_new"),
|
||||
path(
|
||||
"club/<int:c_account_id>/",
|
||||
ClubAccountDetailView.as_view(),
|
||||
name="club_details",
|
||||
),
|
||||
path(
|
||||
"club/<int:c_account_id>/edit/",
|
||||
ClubAccountEditView.as_view(),
|
||||
name="club_edit",
|
||||
),
|
||||
path(
|
||||
"club/<int:c_account_id>/delete/",
|
||||
ClubAccountDeleteView.as_view(),
|
||||
name="club_delete",
|
||||
),
|
||||
# Journals
|
||||
path("journal/create/", JournalCreateView.as_view(), name="journal_new"),
|
||||
path(
|
||||
"journal/<int:j_id>/",
|
||||
JournalDetailView.as_view(),
|
||||
name="journal_details",
|
||||
),
|
||||
path(
|
||||
"journal/<int:j_id>/edit/",
|
||||
JournalEditView.as_view(),
|
||||
name="journal_edit",
|
||||
),
|
||||
path(
|
||||
"journal/<int:j_id>/delete/",
|
||||
JournalDeleteView.as_view(),
|
||||
name="journal_delete",
|
||||
),
|
||||
path(
|
||||
"journal/<int:j_id>/statement/nature/",
|
||||
JournalNatureStatementView.as_view(),
|
||||
name="journal_nature_statement",
|
||||
),
|
||||
path(
|
||||
"journal/<int:j_id>/statement/person/",
|
||||
JournalPersonStatementView.as_view(),
|
||||
name="journal_person_statement",
|
||||
),
|
||||
path(
|
||||
"journal/<int:j_id>/statement/accounting/",
|
||||
JournalAccountingStatementView.as_view(),
|
||||
name="journal_accounting_statement",
|
||||
),
|
||||
# Operations
|
||||
path(
|
||||
"operation/create/<int:j_id>/",
|
||||
OperationCreateView.as_view(),
|
||||
name="op_new",
|
||||
),
|
||||
path("operation/<int:op_id>/", OperationEditView.as_view(), name="op_edit"),
|
||||
path("operation/<int:op_id>/pdf/", OperationPDFView.as_view(), name="op_pdf"),
|
||||
# Companies
|
||||
path("company/list/", CompanyListView.as_view(), name="co_list"),
|
||||
path("company/create/", CompanyCreateView.as_view(), name="co_new"),
|
||||
path("company/<int:co_id>/", CompanyEditView.as_view(), name="co_edit"),
|
||||
# Labels
|
||||
path("label/new/", LabelCreateView.as_view(), name="label_new"),
|
||||
path(
|
||||
"label/<int:clubaccount_id>/",
|
||||
LabelListView.as_view(),
|
||||
name="label_list",
|
||||
),
|
||||
path("label/<int:label_id>/edit/", LabelEditView.as_view(), name="label_edit"),
|
||||
path(
|
||||
"label/<int:label_id>/delete/",
|
||||
LabelDeleteView.as_view(),
|
||||
name="label_delete",
|
||||
),
|
||||
# User account
|
||||
path("refound/account/", RefoundAccountView.as_view(), name="refound_account"),
|
||||
]
|
938
accounting/views.py
Normal file
938
accounting/views.py
Normal file
@ -0,0 +1,938 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
||||
|
||||
import collections
|
||||
|
||||
from ajax_select.fields import AutoCompleteSelectField
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import Sum
|
||||
from django.forms import HiddenInput
|
||||
from django.forms.models import modelform_factory
|
||||
from django.http import HttpResponse
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
|
||||
|
||||
from accounting.models import (
|
||||
AccountingType,
|
||||
BankAccount,
|
||||
ClubAccount,
|
||||
Company,
|
||||
GeneralJournal,
|
||||
Label,
|
||||
Operation,
|
||||
SimplifiedAccountingType,
|
||||
)
|
||||
from core.views import (
|
||||
CanCreateMixin,
|
||||
CanEditMixin,
|
||||
CanEditPropMixin,
|
||||
CanViewMixin,
|
||||
TabedViewMixin,
|
||||
)
|
||||
from core.views.forms import SelectDate, SelectFile
|
||||
from counter.models import Counter, Product, Selling
|
||||
|
||||
# Main accounting view
|
||||
|
||||
|
||||
class BankAccountListView(CanViewMixin, ListView):
|
||||
"""
|
||||
A list view for the admins
|
||||
"""
|
||||
|
||||
model = BankAccount
|
||||
template_name = "accounting/bank_account_list.jinja"
|
||||
ordering = ["name"]
|
||||
|
||||
|
||||
# Simplified accounting types
|
||||
|
||||
|
||||
class SimplifiedAccountingTypeListView(CanViewMixin, ListView):
|
||||
"""
|
||||
A list view for the admins
|
||||
"""
|
||||
|
||||
model = SimplifiedAccountingType
|
||||
template_name = "accounting/simplifiedaccountingtype_list.jinja"
|
||||
|
||||
|
||||
class SimplifiedAccountingTypeEditView(CanViewMixin, UpdateView):
|
||||
"""
|
||||
An edit view for the admins
|
||||
"""
|
||||
|
||||
model = SimplifiedAccountingType
|
||||
pk_url_kwarg = "type_id"
|
||||
fields = ["label", "accounting_type"]
|
||||
template_name = "core/edit.jinja"
|
||||
|
||||
|
||||
class SimplifiedAccountingTypeCreateView(CanCreateMixin, CreateView):
|
||||
"""
|
||||
Create an accounting type (for the admins)
|
||||
"""
|
||||
|
||||
model = SimplifiedAccountingType
|
||||
fields = ["label", "accounting_type"]
|
||||
template_name = "core/create.jinja"
|
||||
|
||||
|
||||
# Accounting types
|
||||
|
||||
|
||||
class AccountingTypeListView(CanViewMixin, ListView):
|
||||
"""
|
||||
A list view for the admins
|
||||
"""
|
||||
|
||||
model = AccountingType
|
||||
template_name = "accounting/accountingtype_list.jinja"
|
||||
|
||||
|
||||
class AccountingTypeEditView(CanViewMixin, UpdateView):
|
||||
"""
|
||||
An edit view for the admins
|
||||
"""
|
||||
|
||||
model = AccountingType
|
||||
pk_url_kwarg = "type_id"
|
||||
fields = ["code", "label", "movement_type"]
|
||||
template_name = "core/edit.jinja"
|
||||
|
||||
|
||||
class AccountingTypeCreateView(CanCreateMixin, CreateView):
|
||||
"""
|
||||
Create an accounting type (for the admins)
|
||||
"""
|
||||
|
||||
model = AccountingType
|
||||
fields = ["code", "label", "movement_type"]
|
||||
template_name = "core/create.jinja"
|
||||
|
||||
|
||||
# BankAccount views
|
||||
|
||||
|
||||
class BankAccountEditView(CanViewMixin, UpdateView):
|
||||
"""
|
||||
An edit view for the admins
|
||||
"""
|
||||
|
||||
model = BankAccount
|
||||
pk_url_kwarg = "b_account_id"
|
||||
fields = ["name", "iban", "number", "club"]
|
||||
template_name = "core/edit.jinja"
|
||||
|
||||
|
||||
class BankAccountDetailView(CanViewMixin, DetailView):
|
||||
"""
|
||||
A detail view, listing every club account
|
||||
"""
|
||||
|
||||
model = BankAccount
|
||||
pk_url_kwarg = "b_account_id"
|
||||
template_name = "accounting/bank_account_details.jinja"
|
||||
|
||||
|
||||
class BankAccountCreateView(CanCreateMixin, CreateView):
|
||||
"""
|
||||
Create a bank account (for the admins)
|
||||
"""
|
||||
|
||||
model = BankAccount
|
||||
fields = ["name", "club", "iban", "number"]
|
||||
template_name = "core/create.jinja"
|
||||
|
||||
|
||||
class BankAccountDeleteView(
|
||||
CanEditPropMixin, DeleteView
|
||||
): # TODO change Delete to Close
|
||||
"""
|
||||
Delete a bank account (for the admins)
|
||||
"""
|
||||
|
||||
model = BankAccount
|
||||
pk_url_kwarg = "b_account_id"
|
||||
template_name = "core/delete_confirm.jinja"
|
||||
success_url = reverse_lazy("accounting:bank_list")
|
||||
|
||||
|
||||
# ClubAccount views
|
||||
|
||||
|
||||
class ClubAccountEditView(CanViewMixin, UpdateView):
|
||||
"""
|
||||
An edit view for the admins
|
||||
"""
|
||||
|
||||
model = ClubAccount
|
||||
pk_url_kwarg = "c_account_id"
|
||||
fields = ["name", "club", "bank_account"]
|
||||
template_name = "core/edit.jinja"
|
||||
|
||||
|
||||
class ClubAccountDetailView(CanViewMixin, DetailView):
|
||||
"""
|
||||
A detail view, listing every journal
|
||||
"""
|
||||
|
||||
model = ClubAccount
|
||||
pk_url_kwarg = "c_account_id"
|
||||
template_name = "accounting/club_account_details.jinja"
|
||||
|
||||
|
||||
class ClubAccountCreateView(CanCreateMixin, CreateView):
|
||||
"""
|
||||
Create a club account (for the admins)
|
||||
"""
|
||||
|
||||
model = ClubAccount
|
||||
fields = ["name", "club", "bank_account"]
|
||||
template_name = "core/create.jinja"
|
||||
|
||||
def get_initial(self):
|
||||
ret = super(ClubAccountCreateView, self).get_initial()
|
||||
if "parent" in self.request.GET.keys():
|
||||
obj = BankAccount.objects.filter(id=int(self.request.GET["parent"])).first()
|
||||
if obj is not None:
|
||||
ret["bank_account"] = obj.id
|
||||
return ret
|
||||
|
||||
|
||||
class ClubAccountDeleteView(
|
||||
CanEditPropMixin, DeleteView
|
||||
): # TODO change Delete to Close
|
||||
"""
|
||||
Delete a club account (for the admins)
|
||||
"""
|
||||
|
||||
model = ClubAccount
|
||||
pk_url_kwarg = "c_account_id"
|
||||
template_name = "core/delete_confirm.jinja"
|
||||
success_url = reverse_lazy("accounting:bank_list")
|
||||
|
||||
|
||||
# Journal views
|
||||
|
||||
|
||||
class JournalTabsMixin(TabedViewMixin):
|
||||
def get_tabs_title(self):
|
||||
return _("Journal")
|
||||
|
||||
def get_list_of_tabs(self):
|
||||
tab_list = []
|
||||
tab_list.append(
|
||||
{
|
||||
"url": reverse(
|
||||
"accounting:journal_details", kwargs={"j_id": self.object.id}
|
||||
),
|
||||
"slug": "journal",
|
||||
"name": _("Journal"),
|
||||
}
|
||||
)
|
||||
tab_list.append(
|
||||
{
|
||||
"url": reverse(
|
||||
"accounting:journal_nature_statement",
|
||||
kwargs={"j_id": self.object.id},
|
||||
),
|
||||
"slug": "nature_statement",
|
||||
"name": _("Statement by nature"),
|
||||
}
|
||||
)
|
||||
tab_list.append(
|
||||
{
|
||||
"url": reverse(
|
||||
"accounting:journal_person_statement",
|
||||
kwargs={"j_id": self.object.id},
|
||||
),
|
||||
"slug": "person_statement",
|
||||
"name": _("Statement by person"),
|
||||
}
|
||||
)
|
||||
tab_list.append(
|
||||
{
|
||||
"url": reverse(
|
||||
"accounting:journal_accounting_statement",
|
||||
kwargs={"j_id": self.object.id},
|
||||
),
|
||||
"slug": "accounting_statement",
|
||||
"name": _("Accounting statement"),
|
||||
}
|
||||
)
|
||||
return tab_list
|
||||
|
||||
|
||||
class JournalCreateView(CanCreateMixin, CreateView):
|
||||
"""
|
||||
Create a general journal
|
||||
"""
|
||||
|
||||
model = GeneralJournal
|
||||
form_class = modelform_factory(
|
||||
GeneralJournal,
|
||||
fields=["name", "start_date", "club_account"],
|
||||
widgets={"start_date": SelectDate},
|
||||
)
|
||||
template_name = "core/create.jinja"
|
||||
|
||||
def get_initial(self):
|
||||
ret = super(JournalCreateView, self).get_initial()
|
||||
if "parent" in self.request.GET.keys():
|
||||
obj = ClubAccount.objects.filter(id=int(self.request.GET["parent"])).first()
|
||||
if obj is not None:
|
||||
ret["club_account"] = obj.id
|
||||
return ret
|
||||
|
||||
|
||||
class JournalDetailView(JournalTabsMixin, CanViewMixin, DetailView):
|
||||
"""
|
||||
A detail view, listing every operation
|
||||
"""
|
||||
|
||||
model = GeneralJournal
|
||||
pk_url_kwarg = "j_id"
|
||||
template_name = "accounting/journal_details.jinja"
|
||||
current_tab = "journal"
|
||||
|
||||
|
||||
class JournalEditView(CanEditMixin, UpdateView):
|
||||
"""
|
||||
Update a general journal
|
||||
"""
|
||||
|
||||
model = GeneralJournal
|
||||
pk_url_kwarg = "j_id"
|
||||
fields = ["name", "start_date", "end_date", "club_account", "closed"]
|
||||
template_name = "core/edit.jinja"
|
||||
|
||||
|
||||
class JournalDeleteView(CanEditPropMixin, DeleteView):
|
||||
"""
|
||||
Delete a club account (for the admins)
|
||||
"""
|
||||
|
||||
model = GeneralJournal
|
||||
pk_url_kwarg = "j_id"
|
||||
template_name = "core/delete_confirm.jinja"
|
||||
success_url = reverse_lazy("accounting:club_details")
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
if self.object.operations.count() == 0:
|
||||
return super(JournalDeleteView, self).dispatch(request, *args, **kwargs)
|
||||
else:
|
||||
raise PermissionDenied
|
||||
|
||||
|
||||
# Operation views
|
||||
|
||||
|
||||
class OperationForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Operation
|
||||
fields = [
|
||||
"amount",
|
||||
"remark",
|
||||
"journal",
|
||||
"target_type",
|
||||
"target_id",
|
||||
"target_label",
|
||||
"date",
|
||||
"mode",
|
||||
"cheque_number",
|
||||
"invoice",
|
||||
"simpleaccounting_type",
|
||||
"accounting_type",
|
||||
"label",
|
||||
"done",
|
||||
]
|
||||
widgets = {
|
||||
"journal": HiddenInput,
|
||||
"target_id": HiddenInput,
|
||||
"date": SelectDate,
|
||||
"invoice": SelectFile,
|
||||
}
|
||||
|
||||
user = AutoCompleteSelectField("users", help_text=None, required=False)
|
||||
club_account = AutoCompleteSelectField(
|
||||
"club_accounts", help_text=None, required=False
|
||||
)
|
||||
club = AutoCompleteSelectField("clubs", help_text=None, required=False)
|
||||
company = AutoCompleteSelectField("companies", help_text=None, required=False)
|
||||
need_link = forms.BooleanField(
|
||||
label=_("Link this operation to the target account"),
|
||||
required=False,
|
||||
initial=False,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
club_account = kwargs.pop("club_account", None)
|
||||
super(OperationForm, self).__init__(*args, **kwargs)
|
||||
if club_account:
|
||||
self.fields["label"].queryset = club_account.labels.order_by("name").all()
|
||||
if self.instance.target_type == "USER":
|
||||
self.fields["user"].initial = self.instance.target_id
|
||||
elif self.instance.target_type == "ACCOUNT":
|
||||
self.fields["club_account"].initial = self.instance.target_id
|
||||
elif self.instance.target_type == "CLUB":
|
||||
self.fields["club"].initial = self.instance.target_id
|
||||
elif self.instance.target_type == "COMPANY":
|
||||
self.fields["company"].initial = self.instance.target_id
|
||||
|
||||
def clean(self):
|
||||
self.cleaned_data = super(OperationForm, self).clean()
|
||||
if "target_type" in self.cleaned_data.keys():
|
||||
if (
|
||||
self.cleaned_data.get("user") is None
|
||||
and self.cleaned_data.get("club") is None
|
||||
and self.cleaned_data.get("club_account") is None
|
||||
and self.cleaned_data.get("company") is None
|
||||
and self.cleaned_data.get("target_label") == ""
|
||||
):
|
||||
self.add_error(
|
||||
"target_type", ValidationError(_("The target must be set."))
|
||||
)
|
||||
else:
|
||||
if self.cleaned_data["target_type"] == "USER":
|
||||
self.cleaned_data["target_id"] = self.cleaned_data["user"].id
|
||||
elif self.cleaned_data["target_type"] == "ACCOUNT":
|
||||
self.cleaned_data["target_id"] = self.cleaned_data[
|
||||
"club_account"
|
||||
].id
|
||||
elif self.cleaned_data["target_type"] == "CLUB":
|
||||
self.cleaned_data["target_id"] = self.cleaned_data["club"].id
|
||||
elif self.cleaned_data["target_type"] == "COMPANY":
|
||||
self.cleaned_data["target_id"] = self.cleaned_data["company"].id
|
||||
|
||||
if self.cleaned_data.get("amount") is None:
|
||||
self.add_error("amount", ValidationError(_("The amount must be set.")))
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
def save(self):
|
||||
ret = super(OperationForm, self).save()
|
||||
if (
|
||||
self.instance.target_type == "ACCOUNT"
|
||||
and not self.instance.linked_operation
|
||||
and self.instance.target.has_open_journal()
|
||||
and self.cleaned_data["need_link"]
|
||||
):
|
||||
inst = self.instance
|
||||
club_account = inst.target
|
||||
acc_type = (
|
||||
AccountingType.objects.exclude(movement_type="NEUTRAL")
|
||||
.exclude(movement_type=inst.accounting_type.movement_type)
|
||||
.order_by("code")
|
||||
.first()
|
||||
) # Select a random opposite accounting type
|
||||
op = Operation(
|
||||
journal=club_account.get_open_journal(),
|
||||
amount=inst.amount,
|
||||
date=inst.date,
|
||||
remark=inst.remark,
|
||||
mode=inst.mode,
|
||||
cheque_number=inst.cheque_number,
|
||||
invoice=inst.invoice,
|
||||
done=False, # Has to be checked by hand
|
||||
simpleaccounting_type=None,
|
||||
accounting_type=acc_type,
|
||||
target_type="ACCOUNT",
|
||||
target_id=inst.journal.club_account.id,
|
||||
target_label="",
|
||||
linked_operation=inst,
|
||||
)
|
||||
op.save()
|
||||
self.instance.linked_operation = op
|
||||
self.save()
|
||||
return ret
|
||||
|
||||
|
||||
class OperationCreateView(CanCreateMixin, CreateView):
|
||||
"""
|
||||
Create an operation
|
||||
"""
|
||||
|
||||
model = Operation
|
||||
form_class = OperationForm
|
||||
template_name = "accounting/operation_edit.jinja"
|
||||
|
||||
def get_form(self, form_class=None):
|
||||
self.journal = GeneralJournal.objects.filter(id=self.kwargs["j_id"]).first()
|
||||
ca = self.journal.club_account if self.journal else None
|
||||
return self.form_class(club_account=ca, **self.get_form_kwargs())
|
||||
|
||||
def get_initial(self):
|
||||
ret = super(OperationCreateView, self).get_initial()
|
||||
if self.journal is not None:
|
||||
ret["journal"] = self.journal.id
|
||||
return ret
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add journal to the context"""
|
||||
kwargs = super(OperationCreateView, self).get_context_data(**kwargs)
|
||||
if self.journal:
|
||||
kwargs["object"] = self.journal
|
||||
return kwargs
|
||||
|
||||
|
||||
class OperationEditView(CanEditMixin, UpdateView):
|
||||
"""
|
||||
An edit view, working as detail for the moment
|
||||
"""
|
||||
|
||||
model = Operation
|
||||
pk_url_kwarg = "op_id"
|
||||
form_class = OperationForm
|
||||
template_name = "accounting/operation_edit.jinja"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add journal to the context"""
|
||||
kwargs = super(OperationEditView, self).get_context_data(**kwargs)
|
||||
kwargs["object"] = self.object.journal
|
||||
return kwargs
|
||||
|
||||
|
||||
class OperationPDFView(CanViewMixin, DetailView):
|
||||
"""
|
||||
Display the PDF of a given operation
|
||||
"""
|
||||
|
||||
model = Operation
|
||||
pk_url_kwarg = "op_id"
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.lib.units import cm
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.platypus import Table, TableStyle
|
||||
|
||||
pdfmetrics.registerFont(TTFont("DejaVu", "DejaVuSerif.ttf"))
|
||||
|
||||
self.object = self.get_object()
|
||||
amount = self.object.amount
|
||||
remark = self.object.remark
|
||||
nature = self.object.accounting_type.movement_type
|
||||
num = self.object.number
|
||||
date = self.object.date
|
||||
mode = self.object.mode
|
||||
club_name = self.object.journal.club_account.name
|
||||
ti = self.object.journal.name
|
||||
op_label = self.object.label
|
||||
club_address = self.object.journal.club_account.club.address
|
||||
id_op = self.object.id
|
||||
|
||||
if self.object.target_type == "OTHER":
|
||||
target = self.object.target_label
|
||||
else:
|
||||
target = self.object.target.get_display_name()
|
||||
|
||||
response = HttpResponse(content_type="application/pdf")
|
||||
response["Content-Disposition"] = 'filename="op-%d(%s_on_%s).pdf"' % (
|
||||
num,
|
||||
ti,
|
||||
club_name,
|
||||
)
|
||||
p = canvas.Canvas(response)
|
||||
|
||||
p.setFont("DejaVu", 12)
|
||||
|
||||
p.setTitle("%s %d" % (_("Operation"), num))
|
||||
width, height = letter
|
||||
im = ImageReader("core/static/core/img/logo.jpg")
|
||||
iw, ih = im.getSize()
|
||||
p.drawImage(im, 40, height - 50, width=iw / 2, height=ih / 2)
|
||||
|
||||
labelStr = [["%s %s - %s %s" % (_("Journal"), ti, _("Operation"), num)]]
|
||||
|
||||
label = Table(labelStr, colWidths=[150], rowHeights=[20])
|
||||
|
||||
label.setStyle(TableStyle([("ALIGN", (0, 0), (-1, -1), "RIGHT")]))
|
||||
w, h = label.wrapOn(label, 0, 0)
|
||||
label.drawOn(p, width - 180, height)
|
||||
|
||||
p.drawString(
|
||||
90, height - 100, _("Financial proof: ") + "OP%010d" % (id_op)
|
||||
) # Justificatif du libellé
|
||||
p.drawString(
|
||||
90, height - 130, _("Club: %(club_name)s") % ({"club_name": club_name})
|
||||
)
|
||||
p.drawString(
|
||||
90,
|
||||
height - 160,
|
||||
_("Label: %(op_label)s")
|
||||
% {"op_label": op_label if op_label is not None else ""},
|
||||
)
|
||||
p.drawString(90, height - 190, _("Date: %(date)s") % {"date": date})
|
||||
|
||||
data = []
|
||||
|
||||
data += [
|
||||
["%s" % (_("Credit").upper() if nature == "CREDIT" else _("Debit").upper())]
|
||||
]
|
||||
|
||||
data += [[_("Amount: %(amount).2f €") % {"amount": amount}]]
|
||||
|
||||
payment_mode = ""
|
||||
for m in settings.SITH_ACCOUNTING_PAYMENT_METHOD:
|
||||
if m[0] == mode:
|
||||
payment_mode += "[\u00d7]"
|
||||
else:
|
||||
payment_mode += "[ ]"
|
||||
payment_mode += " %s\n" % (m[1])
|
||||
|
||||
data += [[payment_mode]]
|
||||
|
||||
data += [
|
||||
[
|
||||
"%s : %s"
|
||||
% (_("Debtor") if nature == "CREDIT" else _("Creditor"), target),
|
||||
"",
|
||||
]
|
||||
]
|
||||
|
||||
data += [["%s \n%s" % (_("Comment:"), remark)]]
|
||||
|
||||
t = Table(
|
||||
data, colWidths=[(width - 90 * 2) / 2] * 2, rowHeights=[20, 20, 70, 20, 80]
|
||||
)
|
||||
t.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (-2, -1), (-1, -1), "TOP"),
|
||||
("VALIGN", (0, 0), (-1, -2), "MIDDLE"),
|
||||
("INNERGRID", (0, 0), (-1, -1), 0.25, colors.black),
|
||||
("SPAN", (0, 0), (1, 0)), # line DEBIT/CREDIT
|
||||
("SPAN", (0, 1), (1, 1)), # line amount
|
||||
("SPAN", (-2, -1), (-1, -1)), # line comment
|
||||
("SPAN", (0, -2), (-1, -2)), # line creditor/debtor
|
||||
("SPAN", (0, 2), (1, 2)), # line payment_mode
|
||||
("ALIGN", (0, 2), (1, 2), "LEFT"), # line payment_mode
|
||||
("ALIGN", (-2, -1), (-1, -1), "LEFT"),
|
||||
("BOX", (0, 0), (-1, -1), 0.25, colors.black),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
signature = []
|
||||
signature += [[_("Signature:")]]
|
||||
|
||||
tSig = Table(signature, colWidths=[(width - 90 * 2)], rowHeights=[80])
|
||||
tSig.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("BOX", (0, 0), (-1, -1), 0.25, colors.black),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
w, h = tSig.wrapOn(p, 0, 0)
|
||||
tSig.drawOn(p, 90, 200)
|
||||
|
||||
w, h = t.wrapOn(p, 0, 0)
|
||||
|
||||
t.drawOn(p, 90, 350)
|
||||
|
||||
p.drawCentredString(10.5 * cm, 2 * cm, club_name)
|
||||
p.drawCentredString(10.5 * cm, 1 * cm, club_address)
|
||||
|
||||
p.showPage()
|
||||
p.save()
|
||||
return response
|
||||
|
||||
|
||||
class JournalNatureStatementView(JournalTabsMixin, CanViewMixin, DetailView):
|
||||
"""
|
||||
Display a statement sorted by labels
|
||||
"""
|
||||
|
||||
model = GeneralJournal
|
||||
pk_url_kwarg = "j_id"
|
||||
template_name = "accounting/journal_statement_nature.jinja"
|
||||
current_tab = "nature_statement"
|
||||
|
||||
def statement(self, queryset, movement_type):
|
||||
ret = collections.OrderedDict()
|
||||
statement = collections.OrderedDict()
|
||||
total_sum = 0
|
||||
for sat in [None] + list(
|
||||
SimplifiedAccountingType.objects.order_by("label").all()
|
||||
):
|
||||
sum = queryset.filter(
|
||||
accounting_type__movement_type=movement_type, simpleaccounting_type=sat
|
||||
).aggregate(amount_sum=Sum("amount"))["amount_sum"]
|
||||
if sat:
|
||||
sat = sat.label
|
||||
else:
|
||||
sat = ""
|
||||
if sum:
|
||||
total_sum += sum
|
||||
statement[sat] = sum
|
||||
ret[movement_type] = statement
|
||||
ret[movement_type + "_sum"] = total_sum
|
||||
return ret
|
||||
|
||||
def big_statement(self):
|
||||
label_list = (
|
||||
self.object.operations.order_by("label").values_list("label").distinct()
|
||||
)
|
||||
labels = Label.objects.filter(id__in=label_list).all()
|
||||
statement = collections.OrderedDict()
|
||||
gen_statement = collections.OrderedDict()
|
||||
no_label_statement = collections.OrderedDict()
|
||||
gen_statement.update(self.statement(self.object.operations.all(), "CREDIT"))
|
||||
gen_statement.update(self.statement(self.object.operations.all(), "DEBIT"))
|
||||
statement[_("General statement")] = gen_statement
|
||||
no_label_statement.update(
|
||||
self.statement(self.object.operations.filter(label=None).all(), "CREDIT")
|
||||
)
|
||||
no_label_statement.update(
|
||||
self.statement(self.object.operations.filter(label=None).all(), "DEBIT")
|
||||
)
|
||||
statement[_("No label operations")] = no_label_statement
|
||||
for l in labels:
|
||||
l_stmt = collections.OrderedDict()
|
||||
l_stmt.update(
|
||||
self.statement(self.object.operations.filter(label=l).all(), "CREDIT")
|
||||
)
|
||||
l_stmt.update(
|
||||
self.statement(self.object.operations.filter(label=l).all(), "DEBIT")
|
||||
)
|
||||
statement[l] = l_stmt
|
||||
return statement
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add infos to the context"""
|
||||
kwargs = super(JournalNatureStatementView, self).get_context_data(**kwargs)
|
||||
kwargs["statement"] = self.big_statement()
|
||||
return kwargs
|
||||
|
||||
|
||||
class JournalPersonStatementView(JournalTabsMixin, CanViewMixin, DetailView):
|
||||
"""
|
||||
Calculate a dictionary with operation target and sum of operations
|
||||
"""
|
||||
|
||||
model = GeneralJournal
|
||||
pk_url_kwarg = "j_id"
|
||||
template_name = "accounting/journal_statement_person.jinja"
|
||||
current_tab = "person_statement"
|
||||
|
||||
def sum_by_target(self, target_id, target_type, movement_type):
|
||||
return self.object.operations.filter(
|
||||
accounting_type__movement_type=movement_type,
|
||||
target_id=target_id,
|
||||
target_type=target_type,
|
||||
).aggregate(amount_sum=Sum("amount"))["amount_sum"]
|
||||
|
||||
def statement(self, movement_type):
|
||||
statement = collections.OrderedDict()
|
||||
for op in (
|
||||
self.object.operations.filter(accounting_type__movement_type=movement_type)
|
||||
.order_by("target_type", "target_id")
|
||||
.distinct()
|
||||
):
|
||||
statement[op.target] = self.sum_by_target(
|
||||
op.target_id, op.target_type, movement_type
|
||||
)
|
||||
return statement
|
||||
|
||||
def total(self, movement_type):
|
||||
return sum(self.statement(movement_type).values())
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add journal to the context"""
|
||||
kwargs = super(JournalPersonStatementView, self).get_context_data(**kwargs)
|
||||
kwargs["credit_statement"] = self.statement("CREDIT")
|
||||
kwargs["debit_statement"] = self.statement("DEBIT")
|
||||
kwargs["total_credit"] = self.total("CREDIT")
|
||||
kwargs["total_debit"] = self.total("DEBIT")
|
||||
return kwargs
|
||||
|
||||
|
||||
class JournalAccountingStatementView(JournalTabsMixin, CanViewMixin, DetailView):
|
||||
"""
|
||||
Calculate a dictionary with operation type and sum of operations
|
||||
"""
|
||||
|
||||
model = GeneralJournal
|
||||
pk_url_kwarg = "j_id"
|
||||
template_name = "accounting/journal_statement_accounting.jinja"
|
||||
current_tab = "accounting_statement"
|
||||
|
||||
def statement(self):
|
||||
statement = collections.OrderedDict()
|
||||
for at in AccountingType.objects.order_by("code").all():
|
||||
sum_by_type = self.object.operations.filter(
|
||||
accounting_type__code__startswith=at.code
|
||||
).aggregate(amount_sum=Sum("amount"))["amount_sum"]
|
||||
if sum_by_type:
|
||||
statement[at] = sum_by_type
|
||||
return statement
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add journal to the context"""
|
||||
kwargs = super(JournalAccountingStatementView, self).get_context_data(**kwargs)
|
||||
kwargs["statement"] = self.statement()
|
||||
return kwargs
|
||||
|
||||
|
||||
# Company views
|
||||
|
||||
|
||||
class CompanyListView(CanViewMixin, ListView):
|
||||
model = Company
|
||||
template_name = "accounting/co_list.jinja"
|
||||
|
||||
|
||||
class CompanyCreateView(CanCreateMixin, CreateView):
|
||||
"""
|
||||
Create a company
|
||||
"""
|
||||
|
||||
model = Company
|
||||
fields = ["name"]
|
||||
template_name = "core/create.jinja"
|
||||
success_url = reverse_lazy("accounting:co_list")
|
||||
|
||||
|
||||
class CompanyEditView(CanCreateMixin, UpdateView):
|
||||
"""
|
||||
Edit a company
|
||||
"""
|
||||
|
||||
model = Company
|
||||
pk_url_kwarg = "co_id"
|
||||
fields = ["name"]
|
||||
template_name = "core/edit.jinja"
|
||||
success_url = reverse_lazy("accounting:co_list")
|
||||
|
||||
|
||||
# Label views
|
||||
|
||||
|
||||
class LabelListView(CanViewMixin, DetailView):
|
||||
model = ClubAccount
|
||||
pk_url_kwarg = "clubaccount_id"
|
||||
template_name = "accounting/label_list.jinja"
|
||||
|
||||
|
||||
class LabelCreateView(
|
||||
CanCreateMixin, CreateView
|
||||
): # FIXME we need to check the rights before creating the object
|
||||
model = Label
|
||||
form_class = modelform_factory(
|
||||
Label, fields=["name", "club_account"], widgets={"club_account": HiddenInput}
|
||||
)
|
||||
template_name = "core/create.jinja"
|
||||
|
||||
def get_initial(self):
|
||||
ret = super(LabelCreateView, self).get_initial()
|
||||
if "parent" in self.request.GET.keys():
|
||||
obj = ClubAccount.objects.filter(id=int(self.request.GET["parent"])).first()
|
||||
if obj is not None:
|
||||
ret["club_account"] = obj.id
|
||||
return ret
|
||||
|
||||
|
||||
class LabelEditView(CanEditMixin, UpdateView):
|
||||
model = Label
|
||||
pk_url_kwarg = "label_id"
|
||||
fields = ["name"]
|
||||
template_name = "core/edit.jinja"
|
||||
|
||||
|
||||
class LabelDeleteView(CanEditMixin, DeleteView):
|
||||
model = Label
|
||||
pk_url_kwarg = "label_id"
|
||||
template_name = "core/delete_confirm.jinja"
|
||||
|
||||
def get_success_url(self):
|
||||
return self.object.get_absolute_url()
|
||||
|
||||
|
||||
class CloseCustomerAccountForm(forms.Form):
|
||||
user = AutoCompleteSelectField(
|
||||
"users", label=_("Refound this account"), help_text=None, required=True
|
||||
)
|
||||
|
||||
|
||||
class RefoundAccountView(FormView):
|
||||
"""
|
||||
Create a selling with the same amount than the current user money
|
||||
"""
|
||||
|
||||
template_name = "accounting/refound_account.jinja"
|
||||
form_class = CloseCustomerAccountForm
|
||||
|
||||
def permission(self, user):
|
||||
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
else:
|
||||
raise PermissionDenied
|
||||
|
||||
def dispatch(self, request, *arg, **kwargs):
|
||||
res = super(RefoundAccountView, self).dispatch(request, *arg, **kwargs)
|
||||
if self.permission(request.user):
|
||||
return res
|
||||
|
||||
def post(self, request, *arg, **kwargs):
|
||||
self.operator = request.user
|
||||
if self.permission(request.user):
|
||||
return super(RefoundAccountView, self).post(self, request, *arg, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
self.customer = form.cleaned_data["user"]
|
||||
self.create_selling()
|
||||
return super(RefoundAccountView, self).form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("accounting:refound_account")
|
||||
|
||||
def create_selling(self):
|
||||
with transaction.atomic():
|
||||
uprice = self.customer.customer.amount
|
||||
refound_club_counter = Counter.objects.get(
|
||||
id=settings.SITH_COUNTER_REFOUND_ID
|
||||
)
|
||||
refound_club = refound_club_counter.club
|
||||
s = Selling(
|
||||
label=_("Refound account"),
|
||||
unit_price=uprice,
|
||||
quantity=1,
|
||||
seller=self.operator,
|
||||
customer=self.customer.customer,
|
||||
club=refound_club,
|
||||
counter=refound_club_counter,
|
||||
product=Product.objects.get(id=settings.SITH_PRODUCT_REFOUND_ID),
|
||||
)
|
||||
s.save()
|
@ -1,10 +0,0 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from antispam.models import ToxicDomain
|
||||
|
||||
|
||||
@admin.register(ToxicDomain)
|
||||
class ToxicDomainAdmin(admin.ModelAdmin):
|
||||
list_display = ("domain", "is_externally_managed", "created")
|
||||
search_fields = ("domain", "is_externally_managed", "created")
|
||||
list_filter = ("is_externally_managed",)
|
@ -1,7 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AntispamConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
verbose_name = "antispam"
|
||||
name = "antispam"
|
@ -1,22 +0,0 @@
|
||||
from django import forms
|
||||
from django.core.validators import EmailValidator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from antispam.models import ToxicDomain
|
||||
|
||||
|
||||
class AntiSpamEmailValidator(EmailValidator):
|
||||
def __call__(self, value: str):
|
||||
super().__call__(value)
|
||||
domain_part = value.rsplit("@", 1)[1]
|
||||
if ToxicDomain.objects.filter(domain=domain_part).exists():
|
||||
raise forms.ValidationError(_("Email domain is not allowed."))
|
||||
|
||||
|
||||
validate_antispam_email = AntiSpamEmailValidator()
|
||||
|
||||
|
||||
class AntiSpamEmailField(forms.EmailField):
|
||||
"""An email field that email addresses with a known toxic domain."""
|
||||
|
||||
default_validators = [validate_antispam_email]
|
@ -1,69 +0,0 @@
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.management import BaseCommand
|
||||
from django.db.models import Max
|
||||
from django.utils import timezone
|
||||
|
||||
from antispam.models import ToxicDomain
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Update blocked ips/mails database"""
|
||||
|
||||
help = "Update blocked ips/mails database"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--force", action="store_true", help="Force re-creation even if up to date"
|
||||
)
|
||||
|
||||
def _should_update(self, *, force: bool = False) -> bool:
|
||||
if force:
|
||||
return True
|
||||
oldest = ToxicDomain.objects.filter(is_externally_managed=True).aggregate(
|
||||
res=Max("created")
|
||||
)["res"]
|
||||
return not (oldest and timezone.now() < (oldest + timezone.timedelta(days=1)))
|
||||
|
||||
def _download_domains(self, providers: list[str]) -> set[str]:
|
||||
domains = set()
|
||||
for provider in providers:
|
||||
res = requests.get(provider)
|
||||
if not res.ok:
|
||||
self.stderr.write(
|
||||
f"Source {provider} responded with code {res.status_code}"
|
||||
)
|
||||
continue
|
||||
domains |= set(res.text.splitlines())
|
||||
return domains
|
||||
|
||||
def _update_domains(self, domains: set[str]):
|
||||
# Cleanup database
|
||||
ToxicDomain.objects.filter(is_externally_managed=True).delete()
|
||||
|
||||
# Create database
|
||||
ToxicDomain.objects.bulk_create(
|
||||
[
|
||||
ToxicDomain(domain=domain, is_externally_managed=True)
|
||||
for domain in domains
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
self.stdout.write("Domain database updated")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if not self._should_update(force=options["force"]):
|
||||
self.stdout.write("Domain database is up to date")
|
||||
return
|
||||
self.stdout.write("Updating domain database")
|
||||
|
||||
domains = self._download_domains(settings.TOXIC_DOMAINS_PROVIDERS)
|
||||
|
||||
if not domains:
|
||||
self.stderr.write(
|
||||
"No domains could be fetched from settings.TOXIC_DOMAINS_PROVIDERS. "
|
||||
"Please, have a look at your settings."
|
||||
)
|
||||
return
|
||||
|
||||
self._update_domains(domains)
|
@ -1,35 +0,0 @@
|
||||
# Generated by Django 4.2.14 on 2024-08-03 23:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ToxicDomain",
|
||||
fields=[
|
||||
(
|
||||
"domain",
|
||||
models.URLField(
|
||||
max_length=253,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="domain",
|
||||
),
|
||||
),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"is_externally_managed",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="True if kept up-to-date using external toxic domain providers, else False",
|
||||
verbose_name="is externally managed",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
@ -1,19 +0,0 @@
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class ToxicDomain(models.Model):
|
||||
"""Domain marked as spam in public databases"""
|
||||
|
||||
domain = models.URLField(_("domain"), max_length=253, primary_key=True)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
is_externally_managed = models.BooleanField(
|
||||
_("is externally managed"),
|
||||
default=False,
|
||||
help_text=_(
|
||||
"True if kept up-to-date using external toxic domain providers, else False"
|
||||
),
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.domain
|
19
api/__init__.py
Normal file
19
api/__init__.py
Normal file
@ -0,0 +1,19 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
21
api/admin.py
Normal file
21
api/admin.py
Normal file
@ -0,0 +1,21 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
||||
|
||||
# Register your models here.
|
21
api/models.py
Normal file
21
api/models.py
Normal file
@ -0,0 +1,21 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
||||
|
||||
# Create your models here.
|
21
api/tests.py
Normal file
21
api/tests.py
Normal file
@ -0,0 +1,21 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
||||
|
||||
# Create your tests here.
|
54
api/urls.py
Normal file
54
api/urls.py
Normal file
@ -0,0 +1,54 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
||||
|
||||
from django.urls import include, path, re_path
|
||||
from rest_framework import routers
|
||||
|
||||
from api.views import *
|
||||
|
||||
# Router config
|
||||
router = routers.DefaultRouter()
|
||||
router.register(r"counter", CounterViewSet, basename="api_counter")
|
||||
router.register(r"user", UserViewSet, basename="api_user")
|
||||
router.register(r"club", ClubViewSet, basename="api_club")
|
||||
router.register(r"group", GroupViewSet, basename="api_group")
|
||||
|
||||
# Launderette
|
||||
router.register(
|
||||
r"launderette/place", LaunderettePlaceViewSet, basename="api_launderette_place"
|
||||
)
|
||||
router.register(
|
||||
r"launderette/machine",
|
||||
LaunderetteMachineViewSet,
|
||||
basename="api_launderette_machine",
|
||||
)
|
||||
router.register(
|
||||
r"launderette/token", LaunderetteTokenViewSet, basename="api_launderette_token"
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
# API
|
||||
re_path(r"^", include(router.urls)),
|
||||
re_path(r"^login/", include("rest_framework.urls", namespace="rest_framework")),
|
||||
re_path(r"^markdown$", RenderMarkdown, name="api_markdown"),
|
||||
re_path(r"^mailings$", FetchMailingLists, name="mailings_fetch"),
|
||||
re_path(r"^uv$", uv_endpoint, name="uv_endpoint"),
|
||||
path("sas/<int:user>", all_pictures_of_user_endpoint, name="all_pictures_of_user"),
|
||||
]
|
77
api/views/__init__.py
Normal file
77
api/views/__init__.py
Normal file
@ -0,0 +1,77 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models.query import QuerySet
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from core.views import can_edit, can_view
|
||||
|
||||
|
||||
def check_if(obj, user, test):
|
||||
"""
|
||||
Detect if it's a single object or a queryset
|
||||
aply a given test on individual object and return global permission
|
||||
"""
|
||||
if isinstance(obj, QuerySet):
|
||||
for o in obj:
|
||||
if test(o, user) is False:
|
||||
return False
|
||||
return True
|
||||
else:
|
||||
return test(obj, user)
|
||||
|
||||
|
||||
class ManageModelMixin:
|
||||
@action(detail=True)
|
||||
def id(self, request, pk=None):
|
||||
"""
|
||||
Get by id (api/v1/router/{pk}/id/)
|
||||
"""
|
||||
self.queryset = get_object_or_404(self.queryset.filter(id=pk))
|
||||
serializer = self.get_serializer(self.queryset)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class RightModelViewSet(ManageModelMixin, viewsets.ModelViewSet):
|
||||
def dispatch(self, request, *arg, **kwargs):
|
||||
res = super(RightModelViewSet, self).dispatch(request, *arg, **kwargs)
|
||||
obj = self.queryset
|
||||
user = self.request.user
|
||||
try:
|
||||
if request.method == "GET" and check_if(obj, user, can_view):
|
||||
return res
|
||||
if request.method != "GET" and check_if(obj, user, can_edit):
|
||||
return res
|
||||
except:
|
||||
pass # To prevent bug with Anonymous user
|
||||
raise PermissionDenied
|
||||
|
||||
|
||||
from .api import *
|
||||
from .club import *
|
||||
from .counter import *
|
||||
from .group import *
|
||||
from .launderette import *
|
||||
from .sas import *
|
||||
from .user import *
|
||||
from .uv import *
|
38
api/views/api.py
Normal file
38
api/views/api.py
Normal file
@ -0,0 +1,38 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
||||
|
||||
from rest_framework.decorators import api_view, renderer_classes
|
||||
from rest_framework.renderers import StaticHTMLRenderer
|
||||
from rest_framework.response import Response
|
||||
|
||||
from core.templatetags.renderer import markdown
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@renderer_classes((StaticHTMLRenderer,))
|
||||
def RenderMarkdown(request):
|
||||
"""
|
||||
Render Markdown
|
||||
"""
|
||||
try:
|
||||
data = markdown(request.POST["text"])
|
||||
except:
|
||||
data = "Error"
|
||||
return Response(data)
|
58
api/views/club.py
Normal file
58
api/views/club.py
Normal file
@ -0,0 +1,58 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from rest_framework import serializers
|
||||
from rest_framework.decorators import api_view, renderer_classes
|
||||
from rest_framework.renderers import StaticHTMLRenderer
|
||||
from rest_framework.response import Response
|
||||
|
||||
from api.views import RightModelViewSet
|
||||
from club.models import Club, Mailing
|
||||
|
||||
|
||||
class ClubSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Club
|
||||
fields = ("id", "name", "unix_name", "address", "members")
|
||||
|
||||
|
||||
class ClubViewSet(RightModelViewSet):
|
||||
"""
|
||||
Manage Clubs (api/v1/club/)
|
||||
"""
|
||||
|
||||
serializer_class = ClubSerializer
|
||||
queryset = Club.objects.all()
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@renderer_classes((StaticHTMLRenderer,))
|
||||
def FetchMailingLists(request):
|
||||
key = request.GET.get("key", "")
|
||||
if key != settings.SITH_MAILING_FETCH_KEY:
|
||||
raise PermissionDenied
|
||||
data = ""
|
||||
for mailing in Mailing.objects.filter(
|
||||
is_moderated=True, club__is_active=True
|
||||
).all():
|
||||
data += mailing.fetch_format() + "\n"
|
||||
return Response(data)
|
55
api/views/counter.py
Normal file
55
api/views/counter.py
Normal file
@ -0,0 +1,55 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from api.views import RightModelViewSet
|
||||
from counter.models import Counter
|
||||
|
||||
|
||||
class CounterSerializer(serializers.ModelSerializer):
|
||||
is_open = serializers.BooleanField(read_only=True)
|
||||
barman_list = serializers.ListField(
|
||||
child=serializers.IntegerField(), read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Counter
|
||||
fields = ("id", "name", "type", "club", "products", "is_open", "barman_list")
|
||||
|
||||
|
||||
class CounterViewSet(RightModelViewSet):
|
||||
"""
|
||||
Manage Counters (api/v1/counter/)
|
||||
"""
|
||||
|
||||
serializer_class = CounterSerializer
|
||||
queryset = Counter.objects.all()
|
||||
|
||||
@action(detail=False)
|
||||
def bar(self, request):
|
||||
"""
|
||||
Return all bars (api/v1/counter/bar/)
|
||||
"""
|
||||
self.queryset = self.queryset.filter(type="BAR")
|
||||
serializer = self.get_serializer(self.queryset, many=True)
|
||||
return Response(serializer.data)
|
38
api/views/group.py
Normal file
38
api/views/group.py
Normal file
@ -0,0 +1,38 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from api.views import RightModelViewSet
|
||||
from core.models import RealGroup
|
||||
|
||||
|
||||
class GroupSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = RealGroup
|
||||
|
||||
|
||||
class GroupViewSet(RightModelViewSet):
|
||||
"""
|
||||
Manage Groups (api/v1/group/)
|
||||
"""
|
||||
|
||||
serializer_class = GroupSerializer
|
||||
queryset = RealGroup.objects.all()
|
131
api/views/launderette.py
Normal file
131
api/views/launderette.py
Normal file
@ -0,0 +1,131 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from api.views import RightModelViewSet
|
||||
from launderette.models import Launderette, Machine, Token
|
||||
|
||||
|
||||
class LaunderettePlaceSerializer(serializers.ModelSerializer):
|
||||
machine_list = serializers.ListField(
|
||||
child=serializers.IntegerField(), read_only=True
|
||||
)
|
||||
token_list = serializers.ListField(child=serializers.IntegerField(), read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Launderette
|
||||
fields = (
|
||||
"id",
|
||||
"name",
|
||||
"counter",
|
||||
"machine_list",
|
||||
"token_list",
|
||||
"get_absolute_url",
|
||||
)
|
||||
|
||||
|
||||
class LaunderetteMachineSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Machine
|
||||
fields = ("id", "name", "type", "is_working", "launderette")
|
||||
|
||||
|
||||
class LaunderetteTokenSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Token
|
||||
fields = (
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"launderette",
|
||||
"borrow_date",
|
||||
"user",
|
||||
"is_avaliable",
|
||||
)
|
||||
|
||||
|
||||
class LaunderettePlaceViewSet(RightModelViewSet):
|
||||
"""
|
||||
Manage Launderette (api/v1/launderette/place/)
|
||||
"""
|
||||
|
||||
serializer_class = LaunderettePlaceSerializer
|
||||
queryset = Launderette.objects.all()
|
||||
|
||||
|
||||
class LaunderetteMachineViewSet(RightModelViewSet):
|
||||
"""
|
||||
Manage Washing Machines (api/v1/launderette/machine/)
|
||||
"""
|
||||
|
||||
serializer_class = LaunderetteMachineSerializer
|
||||
queryset = Machine.objects.all()
|
||||
|
||||
|
||||
class LaunderetteTokenViewSet(RightModelViewSet):
|
||||
"""
|
||||
Manage Launderette's tokens (api/v1/launderette/token/)
|
||||
"""
|
||||
|
||||
serializer_class = LaunderetteTokenSerializer
|
||||
queryset = Token.objects.all()
|
||||
|
||||
@action(detail=False)
|
||||
def washing(self, request):
|
||||
"""
|
||||
Return all washing tokens (api/v1/launderette/token/washing)
|
||||
"""
|
||||
self.queryset = self.queryset.filter(type="WASHING")
|
||||
serializer = self.get_serializer(self.queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False)
|
||||
def drying(self, request):
|
||||
"""
|
||||
Return all drying tokens (api/v1/launderette/token/drying)
|
||||
"""
|
||||
self.queryset = self.queryset.filter(type="DRYING")
|
||||
serializer = self.get_serializer(self.queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False)
|
||||
def avaliable(self, request):
|
||||
"""
|
||||
Return all avaliable tokens (api/v1/launderette/token/avaliable)
|
||||
"""
|
||||
self.queryset = self.queryset.filter(
|
||||
borrow_date__isnull=True, user__isnull=True
|
||||
)
|
||||
serializer = self.get_serializer(self.queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False)
|
||||
def unavaliable(self, request):
|
||||
"""
|
||||
Return all unavaliable tokens (api/v1/launderette/token/unavaliable)
|
||||
"""
|
||||
self.queryset = self.queryset.filter(
|
||||
borrow_date__isnull=False, user__isnull=False
|
||||
)
|
||||
serializer = self.get_serializer(self.queryset, many=True)
|
||||
return Response(serializer.data)
|
43
api/views/sas.py
Normal file
43
api/views/sas.py
Normal file
@ -0,0 +1,43 @@
|
||||
from typing import List
|
||||
|
||||
from rest_framework.decorators import api_view, renderer_classes
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.generics import get_object_or_404
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from core.models import User
|
||||
from core.views import can_edit
|
||||
from sas.models import Picture
|
||||
|
||||
|
||||
def all_pictures_of_user(user: User) -> List[Picture]:
|
||||
return [
|
||||
relation.picture
|
||||
for relation in user.pictures.exclude(picture=None)
|
||||
.order_by("-picture__parent__date", "id")
|
||||
.select_related("picture__parent")
|
||||
]
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@renderer_classes((JSONRenderer,))
|
||||
def all_pictures_of_user_endpoint(request: Request, user: int):
|
||||
requested_user: User = get_object_or_404(User, pk=user)
|
||||
if not can_edit(requested_user, request.user):
|
||||
raise PermissionDenied
|
||||
|
||||
return Response(
|
||||
[
|
||||
{
|
||||
"name": f"{picture.parent.name} - {picture.name}",
|
||||
"date": picture.date,
|
||||
"author": str(picture.owner),
|
||||
"full_size_url": picture.get_download_url(),
|
||||
"compressed_url": picture.get_download_compressed_url(),
|
||||
"thumb_url": picture.get_download_thumb_url(),
|
||||
}
|
||||
for picture in all_pictures_of_user(requested_user)
|
||||
]
|
||||
)
|
63
api/views/user.py
Normal file
63
api/views/user.py
Normal file
@ -0,0 +1,63 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
||||
|
||||
import datetime
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from api.views import RightModelViewSet
|
||||
from core.models import User
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = (
|
||||
"id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email",
|
||||
"date_of_birth",
|
||||
"nick_name",
|
||||
"is_active",
|
||||
"date_joined",
|
||||
)
|
||||
|
||||
|
||||
class UserViewSet(RightModelViewSet):
|
||||
"""
|
||||
Manage Users (api/v1/user/)
|
||||
Only show active users
|
||||
"""
|
||||
|
||||
serializer_class = UserSerializer
|
||||
queryset = User.objects.filter(is_active=True)
|
||||
|
||||
@action(detail=False)
|
||||
def birthday(self, request):
|
||||
"""
|
||||
Return all users born today (api/v1/user/birstdays)
|
||||
"""
|
||||
date = datetime.datetime.today()
|
||||
self.queryset = self.queryset.filter(date_of_birth=date)
|
||||
serializer = self.get_serializer(self.queryset, many=True)
|
||||
return Response(serializer.data)
|
128
api/views/uv.py
Normal file
128
api/views/uv.py
Normal file
@ -0,0 +1,128 @@
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from rest_framework import serializers
|
||||
from rest_framework.decorators import api_view, renderer_classes
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pedagogy.views import CanCreateUVFunctionMixin
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@renderer_classes((JSONRenderer,))
|
||||
def uv_endpoint(request):
|
||||
if not CanCreateUVFunctionMixin.can_create_uv(request.user):
|
||||
raise PermissionDenied
|
||||
|
||||
params = request.query_params
|
||||
if "year" not in params or "code" not in params:
|
||||
raise serializers.ValidationError("Missing query parameter")
|
||||
|
||||
short_uv, full_uv = find_uv("fr", params["year"], params["code"])
|
||||
if short_uv is None or full_uv is None:
|
||||
return Response(status=204)
|
||||
|
||||
return Response(make_clean_uv(short_uv, full_uv))
|
||||
|
||||
|
||||
def find_uv(lang, year, code):
|
||||
"""
|
||||
Uses the UTBM API to find an UV.
|
||||
short_uv is the UV entry in the UV list. It is returned as it contains
|
||||
information which are not in full_uv.
|
||||
full_uv is the detailed representation of an UV.
|
||||
"""
|
||||
# query the UV list
|
||||
uvs_url = settings.SITH_PEDAGOGY_UTBM_API + "/uvs/{}/{}".format(lang, year)
|
||||
response = urllib.request.urlopen(uvs_url)
|
||||
uvs = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
try:
|
||||
# find the first UV which matches the code
|
||||
short_uv = next(uv for uv in uvs if uv["code"] == code)
|
||||
except StopIteration:
|
||||
return (None, None)
|
||||
|
||||
# get detailed information about the UV
|
||||
uv_url = settings.SITH_PEDAGOGY_UTBM_API + "/uv/{}/{}/{}/{}".format(
|
||||
lang, year, code, short_uv["codeFormation"]
|
||||
)
|
||||
response = urllib.request.urlopen(uv_url)
|
||||
full_uv = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
return (short_uv, full_uv)
|
||||
|
||||
|
||||
def make_clean_uv(short_uv, full_uv):
|
||||
"""
|
||||
Cleans the data up so that it corresponds to our data representation.
|
||||
"""
|
||||
res = {}
|
||||
|
||||
res["credit_type"] = short_uv["codeCategorie"]
|
||||
|
||||
# probably wrong on a few UVs as we pick the first UV we find but
|
||||
# availability depends on the formation
|
||||
semesters = {
|
||||
(True, True): "AUTUMN_AND_SPRING",
|
||||
(True, False): "AUTUMN",
|
||||
(False, True): "SPRING",
|
||||
}
|
||||
res["semester"] = semesters.get(
|
||||
(short_uv["ouvertAutomne"], short_uv["ouvertPrintemps"]), "CLOSED"
|
||||
)
|
||||
|
||||
langs = {"es": "SP", "en": "EN", "de": "DE"}
|
||||
res["language"] = langs.get(full_uv["codeLangue"], "FR")
|
||||
|
||||
if full_uv["departement"] == "Pôle Humanités":
|
||||
res["department"] = "HUMA"
|
||||
else:
|
||||
departments = {
|
||||
"AL": "IMSI",
|
||||
"AE": "EE",
|
||||
"GI": "GI",
|
||||
"GC": "EE",
|
||||
"GM": "MC",
|
||||
"TC": "TC",
|
||||
"GP": "IMSI",
|
||||
"ED": "EDIM",
|
||||
"AI": "GI",
|
||||
"AM": "MC",
|
||||
}
|
||||
res["department"] = departments.get(full_uv["codeFormation"], "NA")
|
||||
|
||||
res["credits"] = full_uv["creditsEcts"]
|
||||
|
||||
activities = ("CM", "TD", "TP", "THE", "TE")
|
||||
for activity in activities:
|
||||
res["hours_{}".format(activity)] = 0
|
||||
for activity in full_uv["activites"]:
|
||||
if activity["code"] in activities:
|
||||
res["hours_{}".format(activity["code"])] += activity["nbh"] // 60
|
||||
|
||||
# wrong if the manager changes depending on the semester
|
||||
semester = full_uv.get("automne", None)
|
||||
if not semester:
|
||||
semester = full_uv.get("printemps", {})
|
||||
res["manager"] = semester.get("responsable", "")
|
||||
|
||||
res["title"] = full_uv["libelle"]
|
||||
|
||||
descriptions = {
|
||||
"objectives": "objectifs",
|
||||
"program": "programme",
|
||||
"skills": "acquisitionCompetences",
|
||||
"key_concepts": "acquisitionNotions",
|
||||
}
|
||||
|
||||
for res_key, full_uv_key in descriptions.items():
|
||||
res[res_key] = full_uv[full_uv_key]
|
||||
# if not found or the API did not return a string
|
||||
if type(res[res_key]) != str:
|
||||
res[res_key] = ""
|
||||
|
||||
return res
|
29
biome.json
29
biome.json
@ -1,29 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"ignore": ["*.min.*", "staticfiles/generated"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"lineWidth": 88
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"all": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"globals": ["Alpine", "$", "jQuery", "gettext", "interpolate"]
|
||||
}
|
||||
}
|
@ -1,14 +1,19 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
||||
|
@ -1,17 +1,23 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
||||
from ajax_select import make_ajax_form
|
||||
from django.contrib import admin
|
||||
|
||||
from club.models import Club, Membership
|
||||
@ -19,15 +25,7 @@ from club.models import Club, Membership
|
||||
|
||||
@admin.register(Club)
|
||||
class ClubAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "slug_name", "parent", "is_active")
|
||||
search_fields = ("name", "slug_name")
|
||||
autocomplete_fields = (
|
||||
"parent",
|
||||
"board_group",
|
||||
"members_group",
|
||||
"home",
|
||||
"page",
|
||||
)
|
||||
list_display = ("name", "unix_name", "parent", "is_active")
|
||||
|
||||
|
||||
@admin.register(Membership)
|
||||
@ -39,4 +37,4 @@ class MembershipAdmin(admin.ModelAdmin):
|
||||
"user__last_name",
|
||||
"club__name",
|
||||
)
|
||||
autocomplete_fields = ("user",)
|
||||
form = make_ajax_form(Membership, {"user": "users"})
|
||||
|
22
club/api.py
22
club/api.py
@ -1,22 +0,0 @@
|
||||
from typing import Annotated
|
||||
|
||||
from annotated_types import MinLen
|
||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||
from ninja_extra.schemas import PaginatedResponseSchema
|
||||
|
||||
from club.models import Club
|
||||
from club.schemas import ClubSchema
|
||||
from core.auth.api_permissions import CanAccessLookup
|
||||
|
||||
|
||||
@api_controller("/club")
|
||||
class ClubController(ControllerBase):
|
||||
@route.get(
|
||||
"/search",
|
||||
response=PaginatedResponseSchema[ClubSchema],
|
||||
permissions=[CanAccessLookup],
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
def search_club(self, search: Annotated[str, MinLen(1)]):
|
||||
return Club.objects.filter(name__icontains=search).values()
|
141
club/forms.py
141
club/forms.py
@ -1,74 +1,62 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2016,2017
|
||||
# - Skia <skia@libskia.so>
|
||||
# - Sli <antoine@bartuccio.fr>
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
|
||||
# http://ae.utbm.fr.
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the GNU General Public License a published by the Free Software
|
||||
# Foundation; either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
||||
|
||||
from ajax_select.fields import AutoCompleteSelectMultipleField
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.db.models.functions import Lower
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from club.models import Club, Mailing, MailingSubscription, Membership
|
||||
from core.models import User
|
||||
from core.views.forms import SelectDate, SelectDateTime
|
||||
from core.views.widgets.ajax_select import AutoCompleteSelectMultipleUser
|
||||
from counter.models import Counter, Selling
|
||||
from core.views.forms import SelectDate, TzAwareDateTimeField
|
||||
from counter.models import Counter
|
||||
|
||||
|
||||
class ClubEditForm(forms.ModelForm):
|
||||
error_css_class = "error"
|
||||
required_css_class = "required"
|
||||
|
||||
class Meta:
|
||||
model = Club
|
||||
fields = ["address", "logo", "short_description"]
|
||||
widgets = {"short_description": forms.Textarea()}
|
||||
|
||||
|
||||
class ClubAdminEditForm(ClubEditForm):
|
||||
admin_fields = ["name", "parent", "is_active"]
|
||||
|
||||
class Meta(ClubEditForm.Meta):
|
||||
fields = ["name", "parent", "is_active", *ClubEditForm.Meta.fields]
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(ClubEditForm, self).__init__(*args, **kwargs)
|
||||
self.fields["short_description"].widget = forms.Textarea()
|
||||
|
||||
|
||||
class MailingForm(forms.Form):
|
||||
"""Form handling mailing lists right."""
|
||||
"""
|
||||
Form handling mailing lists right
|
||||
"""
|
||||
|
||||
ACTION_NEW_MAILING = 1
|
||||
ACTION_NEW_SUBSCRIPTION = 2
|
||||
ACTION_REMOVE_SUBSCRIPTION = 3
|
||||
|
||||
subscription_users = forms.ModelMultipleChoiceField(
|
||||
subscription_users = AutoCompleteSelectMultipleField(
|
||||
"users",
|
||||
label=_("Users to add"),
|
||||
help_text=_("Search users to add (one or more)."),
|
||||
required=False,
|
||||
widget=AutoCompleteSelectMultipleUser,
|
||||
queryset=User.objects.all(),
|
||||
)
|
||||
|
||||
def __init__(self, club_id, user_id, mailings, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
super(MailingForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.fields["action"] = forms.TypedChoiceField(
|
||||
choices=(
|
||||
@ -113,15 +101,24 @@ class MailingForm(forms.Form):
|
||||
)
|
||||
|
||||
def check_required(self, cleaned_data, field):
|
||||
"""If the given field doesn't exist or has no value, add a required error on it."""
|
||||
"""
|
||||
If the given field doesn't exist or has no value, add a required error on it
|
||||
"""
|
||||
if not cleaned_data.get(field, None):
|
||||
self.add_error(field, _("This field is required"))
|
||||
|
||||
def clean_subscription_users(self):
|
||||
"""Convert given users into real users and check their validity."""
|
||||
cleaned_data = super().clean()
|
||||
"""
|
||||
Convert given users into real users and check their validity
|
||||
"""
|
||||
cleaned_data = super(MailingForm, self).clean()
|
||||
users = []
|
||||
for user in cleaned_data["subscription_users"]:
|
||||
user = User.objects.filter(id=user).first()
|
||||
if not user:
|
||||
raise forms.ValidationError(
|
||||
_("One of the selected users doesn't exist"), code="invalid"
|
||||
)
|
||||
if not user.email:
|
||||
raise forms.ValidationError(
|
||||
_("One of the selected users doesn't have an email address"),
|
||||
@ -131,9 +128,9 @@ class MailingForm(forms.Form):
|
||||
return users
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
cleaned_data = super(MailingForm, self).clean()
|
||||
|
||||
if "action" not in cleaned_data:
|
||||
if not "action" in cleaned_data:
|
||||
# If there is no action provided, we can stop here
|
||||
raise forms.ValidationError(_("An action is required"), code="invalid")
|
||||
|
||||
@ -154,27 +151,15 @@ class MailingForm(forms.Form):
|
||||
|
||||
|
||||
class SellingsForm(forms.Form):
|
||||
begin_date = forms.DateTimeField(
|
||||
label=_("Begin date"), widget=SelectDateTime, required=False
|
||||
)
|
||||
end_date = forms.DateTimeField(
|
||||
label=_("End date"), widget=SelectDateTime, required=False
|
||||
begin_date = TzAwareDateTimeField(label=_("Begin date"), required=False)
|
||||
end_date = TzAwareDateTimeField(label=_("End date"), required=False)
|
||||
|
||||
counters = forms.ModelMultipleChoiceField(
|
||||
Counter.objects.order_by("name").all(), label=_("Counter"), required=False
|
||||
)
|
||||
|
||||
def __init__(self, club, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
counters_qs = (
|
||||
Counter.objects.filter(
|
||||
Q(club=club)
|
||||
| Q(products__club=club)
|
||||
| Exists(Selling.objects.filter(counter=OuterRef("pk"), club=club))
|
||||
)
|
||||
.distinct()
|
||||
.order_by(Lower("name"))
|
||||
)
|
||||
self.fields["counters"] = forms.ModelMultipleChoiceField(
|
||||
counters_qs, label=_("Counter"), required=False
|
||||
)
|
||||
super(SellingsForm, self).__init__(*args, **kwargs)
|
||||
self.fields["products"] = forms.ModelMultipleChoiceField(
|
||||
club.products.order_by("name").filter(archived=False).all(),
|
||||
label=_("Products"),
|
||||
@ -188,17 +173,18 @@ class SellingsForm(forms.Form):
|
||||
|
||||
|
||||
class ClubMemberForm(forms.Form):
|
||||
"""Form handling the members of a club."""
|
||||
"""
|
||||
Form handling the members of a club
|
||||
"""
|
||||
|
||||
error_css_class = "error"
|
||||
required_css_class = "required"
|
||||
|
||||
users = forms.ModelMultipleChoiceField(
|
||||
users = AutoCompleteSelectMultipleField(
|
||||
"users",
|
||||
label=_("Users to add"),
|
||||
help_text=_("Search users to add (one or more)."),
|
||||
required=False,
|
||||
widget=AutoCompleteSelectMultipleUser,
|
||||
queryset=User.objects.all(),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -206,9 +192,11 @@ class ClubMemberForm(forms.Form):
|
||||
self.request_user = kwargs.pop("request_user")
|
||||
self.club_members = kwargs.pop("club_members", None)
|
||||
if not self.club_members:
|
||||
self.club_members = self.club.members.ongoing().order_by("-role").all()
|
||||
self.club_members = (
|
||||
self.club.members.filter(end_date=None).order_by("-role").all()
|
||||
)
|
||||
self.request_user_membership = self.club.get_membership_for(self.request_user)
|
||||
super().__init__(*args, **kwargs)
|
||||
super(ClubMemberForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Using a ModelForm binds too much the form with the model and we don't want that
|
||||
# We want the view to process the model creation since they are multiple users
|
||||
@ -244,13 +232,18 @@ class ClubMemberForm(forms.Form):
|
||||
self.fields.pop("start_date")
|
||||
|
||||
def clean_users(self):
|
||||
"""Check that the user is not trying to add an user already in the club.
|
||||
|
||||
Also check that the user is valid and has a valid subscription.
|
||||
"""
|
||||
cleaned_data = super().clean()
|
||||
Check that the user is not trying to add an user already in the club
|
||||
Also check that the user is valid and has a valid subscription
|
||||
"""
|
||||
cleaned_data = super(ClubMemberForm, self).clean()
|
||||
users = []
|
||||
for user in cleaned_data["users"]:
|
||||
for user_id in cleaned_data["users"]:
|
||||
user = User.objects.filter(id=user_id).first()
|
||||
if not user:
|
||||
raise forms.ValidationError(
|
||||
_("One of the selected users doesn't exist"), code="invalid"
|
||||
)
|
||||
if not user.is_subscribed:
|
||||
raise forms.ValidationError(
|
||||
_("User must be subscriber to take part to a club"), code="invalid"
|
||||
@ -263,8 +256,10 @@ class ClubMemberForm(forms.Form):
|
||||
return users
|
||||
|
||||
def clean(self):
|
||||
"""Check user rights for adding an user."""
|
||||
cleaned_data = super().clean()
|
||||
"""
|
||||
Check user rights for adding an user
|
||||
"""
|
||||
cleaned_data = super(ClubMemberForm, self).clean()
|
||||
|
||||
if "start_date" in cleaned_data and not cleaned_data["start_date"]:
|
||||
# Drop start_date if allowed to edition but not specified
|
||||
|
@ -1,3 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
|
@ -1,3 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
|
@ -1,3 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
@ -1,3 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
|
@ -1,3 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
|
@ -1,3 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.utils.timezone
|
||||
|
@ -1,3 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
@ -1,3 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
@ -1,3 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import re
|
||||
@ -109,6 +110,6 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="mailingsubscription",
|
||||
unique_together={("user", "email", "mailing")},
|
||||
unique_together=set([("user", "email", "mailing")]),
|
||||
),
|
||||
]
|
||||
|
@ -1,8 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
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")]
|
||||
@ -35,4 +49,11 @@ 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"
|
||||
),
|
||||
]
|
||||
|
@ -1,3 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
@ -1,9 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import club.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("club", "0010_auto_20170912_2028")]
|
||||
@ -14,7 +16,7 @@ class Migration(migrations.Migration):
|
||||
name="owner_group",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
default=lambda: settings.SITH_ROOT_USER_ID,
|
||||
default=club.models.Club.get_default_owner_group,
|
||||
related_name="owned_club",
|
||||
to="core.Group",
|
||||
),
|
||||
|
@ -1,106 +0,0 @@
|
||||
# Generated by Django 4.2.16 on 2024-11-20 17:08
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.db.models.functions.datetime
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
from django.db.migrations.state import StateApps
|
||||
from django.db.models import Q
|
||||
from django.utils.timezone import localdate
|
||||
|
||||
|
||||
def migrate_meta_groups(apps: StateApps, schema_editor):
|
||||
"""Attach the existing meta groups to the clubs.
|
||||
|
||||
Until now, the meta groups were not attached to the clubs,
|
||||
nor to the users.
|
||||
This creates actual foreign relationships between the clubs
|
||||
and theirs groups and the users and theirs groups.
|
||||
|
||||
Warnings:
|
||||
When the meta groups associated with the clubs aren't found,
|
||||
they are created.
|
||||
Thus the migration shouldn't fail, and all the clubs will
|
||||
have their groups.
|
||||
However, there will probably be some groups that have
|
||||
not been found but exist nonetheless,
|
||||
so there will be duplicates and dangling groups.
|
||||
There must be a manual cleanup after this migration.
|
||||
"""
|
||||
Group = apps.get_model("core", "Group")
|
||||
Club = apps.get_model("club", "Club")
|
||||
|
||||
meta_groups = Group.objects.filter(is_meta=True)
|
||||
clubs = list(Club.objects.all())
|
||||
for club in clubs:
|
||||
club.board_group = meta_groups.get_or_create(
|
||||
name=club.unix_name + settings.SITH_BOARD_SUFFIX,
|
||||
defaults={"is_meta": True},
|
||||
)[0]
|
||||
club.members_group = meta_groups.get_or_create(
|
||||
name=club.unix_name + settings.SITH_MEMBER_SUFFIX,
|
||||
defaults={"is_meta": True},
|
||||
)[0]
|
||||
club.save()
|
||||
club.refresh_from_db()
|
||||
memberships = club.members.filter(
|
||||
Q(end_date=None) | Q(end_date__gt=localdate())
|
||||
).select_related("user")
|
||||
club.members_group.users.set([m.user for m in memberships])
|
||||
club.board_group.users.set(
|
||||
[
|
||||
m.user
|
||||
for m in memberships.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# steps of the migration :
|
||||
# - Create a nullable field for the board group and the member group
|
||||
# - Edit those new fields to make them point to currently existing meta groups
|
||||
# - When this data migration is done, make the fields non-nullable
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0040_alter_user_options_user_user_permissions_and_more"),
|
||||
("club", "0011_auto_20180426_2013"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="club",
|
||||
name="edit_groups",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="club",
|
||||
name="owner_group",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="club",
|
||||
name="view_groups",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="club",
|
||||
name="board_group",
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="club_board",
|
||||
to="core.group",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="club",
|
||||
name="members_group",
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="club",
|
||||
to="core.group",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
migrate_meta_groups, reverse_code=migrations.RunPython.noop, elidable=True
|
||||
),
|
||||
]
|
@ -1,36 +0,0 @@
|
||||
# Generated by Django 4.2.17 on 2025-01-04 16:46
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("club", "0012_club_board_group_club_members_group")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="club",
|
||||
name="board_group",
|
||||
field=models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="club_board",
|
||||
to="core.group",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="club",
|
||||
name="members_group",
|
||||
field=models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="club",
|
||||
to="core.group",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="membership",
|
||||
constraint=models.CheckConstraint(
|
||||
check=models.Q(("end_date__gte", models.F("start_date"))),
|
||||
name="end_after_start",
|
||||
),
|
||||
),
|
||||
]
|
@ -1,75 +0,0 @@
|
||||
# Generated by Django 4.2.17 on 2025-02-28 20:34
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import core.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0044_alter_userban_options"),
|
||||
("club", "0013_alter_club_board_group_alter_club_members_group_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(name="club", options={"ordering": ["name"]}),
|
||||
migrations.RenameField(
|
||||
model_name="club",
|
||||
old_name="unix_name",
|
||||
new_name="slug_name",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="club",
|
||||
name="name",
|
||||
field=models.CharField(unique=True, max_length=64, verbose_name="name"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="club",
|
||||
name="slug_name",
|
||||
field=models.SlugField(
|
||||
editable=False, max_length=30, unique=True, verbose_name="slug name"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="club",
|
||||
name="id",
|
||||
field=models.AutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="club",
|
||||
name="logo",
|
||||
field=core.fields.ResizedImageField(
|
||||
blank=True,
|
||||
force_format="WEBP",
|
||||
height=200,
|
||||
null=True,
|
||||
upload_to="club_logos",
|
||||
verbose_name="logo",
|
||||
width=200,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="club",
|
||||
name="page",
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="club",
|
||||
to="core.page",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="club",
|
||||
name="short_description",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="A summary of what your club does. This will be displayed on the club list page.",
|
||||
max_length=1000,
|
||||
verbose_name="short description",
|
||||
),
|
||||
),
|
||||
]
|
618
club/models.py
618
club/models.py
@ -1,78 +1,91 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2016,2017
|
||||
# - Skia <skia@libskia.so>
|
||||
# - Sli <antoine@bartuccio.fr>
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
|
||||
# http://ae.utbm.fr.
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the GNU General Public License a published by the Free Software
|
||||
# Foundation; either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Self
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import validators
|
||||
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 Exists, F, OuterRef, Q
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.text import slugify
|
||||
from django.utils.timezone import localdate
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.fields import ResizedImageField
|
||||
from core.models import Group, Notification, Page, SithFile, User
|
||||
from core.models import Group, MetaGroup, Notification, Page, RealGroup, SithFile, User
|
||||
|
||||
# Create your models here.
|
||||
|
||||
|
||||
class Club(models.Model):
|
||||
"""The Club class, made as a tree to allow nice tidy organization."""
|
||||
"""
|
||||
The Club class, made as a tree to allow nice tidy organization
|
||||
"""
|
||||
|
||||
name = models.CharField(_("name"), unique=True, max_length=64)
|
||||
id = models.AutoField(primary_key=True, db_index=True)
|
||||
name = models.CharField(_("name"), max_length=64)
|
||||
parent = models.ForeignKey(
|
||||
"Club", related_name="children", null=True, blank=True, on_delete=models.CASCADE
|
||||
)
|
||||
slug_name = models.SlugField(
|
||||
_("slug name"), max_length=30, unique=True, editable=False
|
||||
unix_name = models.CharField(
|
||||
_("unix name"),
|
||||
max_length=30,
|
||||
unique=True,
|
||||
validators=[
|
||||
validators.RegexValidator(
|
||||
r"^[a-z0-9][a-z0-9._-]*[a-z0-9]$",
|
||||
_(
|
||||
"Enter a valid unix name. This value may contain only "
|
||||
"letters, numbers ./-/_ characters."
|
||||
),
|
||||
)
|
||||
logo = ResizedImageField(
|
||||
upload_to="club_logos",
|
||||
verbose_name=_("logo"),
|
||||
null=True,
|
||||
blank=True,
|
||||
force_format="WEBP",
|
||||
height=200,
|
||||
width=200,
|
||||
],
|
||||
error_messages={"unique": _("A club with that unix name already exists.")},
|
||||
)
|
||||
logo = models.ImageField(
|
||||
upload_to="club_logos", verbose_name=_("logo"), null=True, blank=True
|
||||
)
|
||||
is_active = models.BooleanField(_("is active"), default=True)
|
||||
short_description = models.CharField(
|
||||
_("short description"),
|
||||
max_length=1000,
|
||||
default="",
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"A summary of what your club does. "
|
||||
"This will be displayed on the club list page."
|
||||
),
|
||||
_("short description"), max_length=1000, default="", blank=True, null=True
|
||||
)
|
||||
address = models.CharField(_("address"), max_length=254)
|
||||
|
||||
# This function prevents generating migration upon settings change
|
||||
def get_default_owner_group():
|
||||
return settings.SITH_GROUP_ROOT_ID
|
||||
|
||||
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",
|
||||
@ -82,59 +95,20 @@ class Club(models.Model):
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
page = models.OneToOneField(
|
||||
Page, related_name="club", blank=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
|
||||
Page, related_name="club", blank=True, null=True, on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@transaction.atomic()
|
||||
def save(self, *args, **kwargs):
|
||||
creation = self._state.adding
|
||||
if (slug := slugify(self.name)[:30]) != self.slug_name:
|
||||
self.slug_name = slug
|
||||
if not creation:
|
||||
db_club = Club.objects.get(id=self.id)
|
||||
if self.name != db_club.name:
|
||||
self.home.name = self.slug_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
|
||||
)
|
||||
self.make_home()
|
||||
self.make_page()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("club:club_view", kwargs={"club_id": self.id})
|
||||
ordering = ["name", "unix_name"]
|
||||
|
||||
@cached_property
|
||||
def president(self) -> Membership | None:
|
||||
"""Fetch the membership of the current president of this club."""
|
||||
def president(self):
|
||||
return self.members.filter(
|
||||
role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
|
||||
).first()
|
||||
|
||||
def check_loop(self):
|
||||
"""Raise a validation error when a loop is found within the parent list."""
|
||||
"""Raise a validation error when a loop is found within the parent list"""
|
||||
objs = []
|
||||
cur = self
|
||||
while cur.parent is not None:
|
||||
@ -146,68 +120,132 @@ class Club(models.Model):
|
||||
def clean(self):
|
||||
self.check_loop()
|
||||
|
||||
def make_home(self) -> None:
|
||||
if self.home:
|
||||
return
|
||||
home_root = SithFile.objects.get(parent=None, name="clubs")
|
||||
root = User.objects.get(id=settings.SITH_ROOT_USER_ID)
|
||||
self.home = SithFile.objects.create(
|
||||
parent=home_root, name=self.slug_name, owner=root
|
||||
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_page(self) -> None:
|
||||
page_name = self.slug_name
|
||||
if not self.page_id:
|
||||
# Club.page is a OneToOneField, so if we are inside this condition
|
||||
# then self._meta.state.adding is True.
|
||||
club_root = Page.objects.get(name=settings.SITH_CLUB_ROOT_PAGE)
|
||||
public = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)
|
||||
p = Page(name=page_name, parent=club_root)
|
||||
if self.home:
|
||||
self.home.name = new_name
|
||||
self.home.save()
|
||||
|
||||
else:
|
||||
raise ValidationError(_("A club with that unix_name already exists"))
|
||||
|
||||
def make_home(self):
|
||||
if not self.home:
|
||||
home_root = SithFile.objects.filter(parent=None, name="clubs").first()
|
||||
root = User.objects.filter(username="root").first()
|
||||
if home_root and root:
|
||||
home = SithFile(parent=home_root, name=self.unix_name, owner=root)
|
||||
home.save()
|
||||
self.home = home
|
||||
self.save()
|
||||
|
||||
def make_page(self):
|
||||
root = User.objects.filter(username="root").first()
|
||||
if not self.page:
|
||||
club_root = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first()
|
||||
if root and club_root:
|
||||
public = Group.objects.filter(id=settings.SITH_GROUP_PUBLIC_ID).first()
|
||||
p = Page(name=self.unix_name)
|
||||
p.parent = club_root
|
||||
p.save(force_lock=True)
|
||||
if public:
|
||||
p.view_groups.add(public)
|
||||
if self.parent and self.parent.page_id:
|
||||
p.parent_id = self.parent.page_id
|
||||
p.save(force_lock=True)
|
||||
if self.parent and self.parent.page:
|
||||
p.parent = self.parent.page
|
||||
self.page = p
|
||||
return
|
||||
self.save()
|
||||
elif self.page and self.page.name != self.unix_name:
|
||||
self.page.unset_lock()
|
||||
self.page.name = self.unix_name
|
||||
self.page.save(force_lock=True)
|
||||
elif (
|
||||
self.page
|
||||
and self.parent
|
||||
and self.parent.page
|
||||
and self.page.parent != self.parent.page
|
||||
):
|
||||
self.page.unset_lock()
|
||||
if self.page.name != page_name:
|
||||
self.page.name = page_name
|
||||
elif self.parent and self.parent.page and self.page.parent != self.parent.page:
|
||||
self.page.parent = self.parent.page
|
||||
self.page.save(force_lock=True)
|
||||
|
||||
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
|
||||
@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)
|
||||
super(Club, self).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)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
# 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}")
|
||||
self.board_group.delete()
|
||||
self.members_group.delete()
|
||||
return super().delete(*args, **kwargs)
|
||||
cache.delete(f"sith_club_{self.unix_name}")
|
||||
|
||||
def get_display_name(self) -> str:
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def is_owned_by(self, user: User) -> bool:
|
||||
"""Method to see if that object can be super edited by the given user."""
|
||||
def get_absolute_url(self):
|
||||
return reverse("club:club_view", kwargs={"club_id": self.id})
|
||||
|
||||
def get_display_name(self):
|
||||
return self.name
|
||||
|
||||
def is_owned_by(self, user):
|
||||
"""
|
||||
Method to see if that object can be super edited by the given user
|
||||
"""
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
return user.is_root or user.is_board_member
|
||||
return user.is_board_member
|
||||
|
||||
def get_full_logo_url(self) -> str:
|
||||
return f"https://{settings.SITH_URL}{self.logo.url}"
|
||||
def get_full_logo_url(self):
|
||||
return "https://%s%s" % (settings.SITH_URL, self.logo.url)
|
||||
|
||||
def can_be_edited_by(self, user: User) -> bool:
|
||||
"""Method to see if that object can be edited by the given user."""
|
||||
def can_be_edited_by(self, user):
|
||||
"""
|
||||
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: User) -> bool:
|
||||
"""Method to see if that object can be seen by the given user."""
|
||||
return user.was_subscribed
|
||||
def can_be_viewed_by(self, user):
|
||||
"""
|
||||
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
|
||||
|
||||
def get_membership_for(self, user: User) -> Membership | None:
|
||||
"""Return the current membership the given user.
|
||||
|
||||
Note:
|
||||
def get_membership_for(self, user: User) -> Optional["Membership"]:
|
||||
"""
|
||||
Return the current membership the given user.
|
||||
The result is cached.
|
||||
"""
|
||||
if user.is_anonymous:
|
||||
@ -223,17 +261,22 @@ class Club(models.Model):
|
||||
cache.set(f"membership_{self.id}_{user.id}", membership)
|
||||
return membership
|
||||
|
||||
def has_rights_in_club(self, user: User) -> bool:
|
||||
return user.is_in_group(pk=self.board_group_id)
|
||||
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
|
||||
|
||||
|
||||
class MembershipQuerySet(models.QuerySet):
|
||||
def ongoing(self) -> Self:
|
||||
"""Filter all memberships which are not finished yet."""
|
||||
return self.filter(Q(end_date=None) | Q(end_date__gt=localdate()))
|
||||
def ongoing(self) -> "MembershipQuerySet":
|
||||
"""
|
||||
Filter all memberships which are not finished yet
|
||||
"""
|
||||
# noinspection PyTypeChecker
|
||||
return self.filter(Q(end_date=None) | Q(end_date__gte=timezone.now()))
|
||||
|
||||
def board(self) -> Self:
|
||||
"""Filter all memberships where the user is/was in the board.
|
||||
def board(self) -> "MembershipQuerySet":
|
||||
"""
|
||||
Filter all memberships where the user is/was in the board.
|
||||
|
||||
Be aware that users who were in the board in the past
|
||||
are included, even if there are no more members.
|
||||
@ -241,71 +284,51 @@ class MembershipQuerySet(models.QuerySet):
|
||||
If you want to get the users who are currently in the board,
|
||||
mind combining this with the :meth:`ongoing` queryset method
|
||||
"""
|
||||
# noinspection PyTypeChecker
|
||||
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
||||
|
||||
def update(self, **kwargs) -> int:
|
||||
"""Refresh the cache and edit group ownership.
|
||||
def update(self, **kwargs):
|
||||
"""
|
||||
Work just like the default Django's update() method,
|
||||
but add a cache refresh for the elements of the queryset.
|
||||
|
||||
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 three db queries :
|
||||
one to retrieve the updated memberships,
|
||||
one to perform group removal and one to perform
|
||||
group attribution.
|
||||
Be aware that this adds a db query to retrieve the updated objects
|
||||
"""
|
||||
nb_rows = super().update(**kwargs)
|
||||
if nb_rows == 0:
|
||||
# if no row was affected, no need to refresh the cache
|
||||
return 0
|
||||
|
||||
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,
|
||||
and a removal of the user from the club groups.
|
||||
|
||||
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.
|
||||
"""
|
||||
memberships = set(self.all())
|
||||
nb_rows, rows_counts = super().delete()
|
||||
if nb_rows > 0:
|
||||
Membership._remove_club_groups(memberships)
|
||||
cache.set_many(
|
||||
{
|
||||
f"membership_{m.club_id}_{m.user_id}": "not_member"
|
||||
for m in memberships
|
||||
}
|
||||
# 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",
|
||||
)
|
||||
return nb_rows, rows_counts
|
||||
else:
|
||||
cache.set(
|
||||
f"membership_{membership.club_id}_{membership.user_id}",
|
||||
membership,
|
||||
)
|
||||
|
||||
def delete(self):
|
||||
"""
|
||||
Work just like the default Django's delete() method,
|
||||
but add a cache invalidation for the elements of the queryset
|
||||
before the deletion.
|
||||
|
||||
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.
|
||||
"""
|
||||
ids = list(self.values_list("club_id", "user_id"))
|
||||
nb_rows, _ = super().delete()
|
||||
if nb_rows > 0:
|
||||
for club_id, user_id in ids:
|
||||
cache.set(f"membership_{club_id}_{user_id}", "not_member")
|
||||
|
||||
|
||||
class Membership(models.Model):
|
||||
"""The Membership class makes the connection between User and Clubs.
|
||||
"""
|
||||
The Membership class makes the connection between User and Clubs
|
||||
|
||||
Both Users and Clubs can have many Membership objects:
|
||||
- a user can be a member of many clubs at a time
|
||||
@ -344,142 +367,54 @@ 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} "
|
||||
f"- {settings.SITH_CLUB_ROLES[self.role]} "
|
||||
f"- {str(_('past member')) if self.end_date is not None else ''}"
|
||||
self.club.name
|
||||
+ " - "
|
||||
+ self.user.username
|
||||
+ " - "
|
||||
+ str(settings.SITH_CLUB_ROLES[self.role])
|
||||
+ str(" - " + str(_("past member")) if self.end_date is not None else "")
|
||||
)
|
||||
|
||||
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")
|
||||
def is_owned_by(self, user):
|
||||
"""
|
||||
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
|
||||
|
||||
def can_be_edited_by(self, user: User) -> bool:
|
||||
"""
|
||||
Check if that object can be edited by the given user
|
||||
"""
|
||||
if user.is_root or user.is_board_member:
|
||||
return True
|
||||
membership = self.club.get_membership_for(user)
|
||||
if membership is not None and membership.role >= self.role:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("club:club_members", kwargs={"club_id": self.club_id})
|
||||
|
||||
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_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."""
|
||||
if user.is_root or user.is_board_member:
|
||||
return True
|
||||
membership = self.club.get_membership_for(user)
|
||||
return membership is not None and membership.role >= self.role
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
if self.end_date is None:
|
||||
cache.set(f"membership_{self.club_id}_{self.user_id}", self)
|
||||
else:
|
||||
cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member")
|
||||
|
||||
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.
|
||||
|
||||
Warning:
|
||||
Remember that mailing lists should be validated by UTBM.
|
||||
"""
|
||||
This class correspond to a mailing list
|
||||
Remember that mailing lists should be validated by UTBM
|
||||
"""
|
||||
|
||||
club = models.ForeignKey(
|
||||
@ -512,25 +447,6 @@ class Mailing(models.Model):
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return "%s - %s" % (self.club, self.email_full)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.is_moderated:
|
||||
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],
|
||||
):
|
||||
Notification(
|
||||
user=user,
|
||||
url=reverse("com:mailing_admin"),
|
||||
type="MAILING_MODERATION",
|
||||
).save(*args, **kwargs)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
if Mailing.objects.filter(email=self.email).exists():
|
||||
raise ValidationError(_("This mailing list already exists."))
|
||||
@ -538,7 +454,7 @@ class Mailing(models.Model):
|
||||
self.is_moderated = True
|
||||
else:
|
||||
self.moderator = None
|
||||
super().clean()
|
||||
super(Mailing, self).clean()
|
||||
|
||||
@property
|
||||
def email_full(self):
|
||||
@ -560,15 +476,39 @@ class Mailing(models.Model):
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
self.subscriptions.all().delete()
|
||||
super().delete()
|
||||
super(Mailing, self).delete()
|
||||
|
||||
def fetch_format(self):
|
||||
destination = "".join(s.fetch_format() for s in self.subscriptions.all())
|
||||
return f"{self.email}: {destination}"
|
||||
resp = self.email + ": "
|
||||
for sub in self.subscriptions.all():
|
||||
resp += sub.fetch_format()
|
||||
return resp
|
||||
|
||||
def save(self):
|
||||
if not self.is_moderated:
|
||||
for user in (
|
||||
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
||||
.first()
|
||||
.users.all()
|
||||
):
|
||||
if not user.notifications.filter(
|
||||
type="MAILING_MODERATION", viewed=False
|
||||
).exists():
|
||||
Notification(
|
||||
user=user,
|
||||
url=reverse("com:mailing_admin"),
|
||||
type="MAILING_MODERATION",
|
||||
).save()
|
||||
super(Mailing, self).save()
|
||||
|
||||
def __str__(self):
|
||||
return "%s - %s" % (self.club, self.email_full)
|
||||
|
||||
|
||||
class MailingSubscription(models.Model):
|
||||
"""Link between user and mailing list."""
|
||||
"""
|
||||
This class makes the link between user and mailing list
|
||||
"""
|
||||
|
||||
mailing = models.ForeignKey(
|
||||
Mailing,
|
||||
@ -591,9 +531,6 @@ class MailingSubscription(models.Model):
|
||||
class Meta:
|
||||
unique_together = (("user", "email", "mailing"),)
|
||||
|
||||
def __str__(self):
|
||||
return "(%s) - %s : %s" % (self.mailing, self.get_username, self.email)
|
||||
|
||||
def clean(self):
|
||||
if not self.user and not self.email:
|
||||
raise ValidationError(_("At least user or email is required"))
|
||||
@ -608,7 +545,7 @@ class MailingSubscription(models.Model):
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
super().clean()
|
||||
super(MailingSubscription, self).clean()
|
||||
|
||||
def is_owned_by(self, user):
|
||||
if user.is_anonymous:
|
||||
@ -636,3 +573,6 @@ class MailingSubscription(models.Model):
|
||||
|
||||
def fetch_format(self):
|
||||
return self.get_email + " "
|
||||
|
||||
def __str__(self):
|
||||
return "(%s) - %s : %s" % (self.mailing, self.get_username, self.email)
|
||||
|
@ -1,23 +0,0 @@
|
||||
from ninja import ModelSchema
|
||||
|
||||
from club.models import Club
|
||||
|
||||
|
||||
class ClubSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = Club
|
||||
fields = ["id", "name"]
|
||||
|
||||
|
||||
class ClubProfileSchema(ModelSchema):
|
||||
"""The infos needed to display a simple club profile."""
|
||||
|
||||
class Meta:
|
||||
model = Club
|
||||
fields = ["id", "name", "logo"]
|
||||
|
||||
url: str
|
||||
|
||||
@staticmethod
|
||||
def resolve_url(obj: Club) -> str:
|
||||
return obj.get_absolute_url()
|
@ -1,30 +0,0 @@
|
||||
import { AjaxSelect } from "#core:core/components/ajax-select-base";
|
||||
import { registerComponent } from "#core:utils/web-components";
|
||||
import type { TomOption } from "tom-select/dist/types/types";
|
||||
import type { escape_html } from "tom-select/dist/types/utils";
|
||||
import { type ClubSchema, clubSearchClub } from "#openapi";
|
||||
|
||||
@registerComponent("club-ajax-select")
|
||||
export class ClubAjaxSelect extends AjaxSelect {
|
||||
protected valueField = "id";
|
||||
protected labelField = "name";
|
||||
protected searchField = ["code", "name"];
|
||||
|
||||
protected async search(query: string): Promise<TomOption[]> {
|
||||
const resp = await clubSearchClub({ query: { search: query } });
|
||||
if (resp.data) {
|
||||
return resp.data.results;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
protected renderOption(item: ClubSchema, sanitize: typeof escape_html) {
|
||||
return `<div class="select-item">
|
||||
<span class="select-item-text">${sanitize(item.name)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
protected renderItem(item: ClubSchema, sanitize: typeof escape_html) {
|
||||
return `<span>${sanitize(item.name)}</span>`;
|
||||
}
|
||||
}
|
@ -4,7 +4,7 @@
|
||||
{% block content %}
|
||||
<div id="club_detail">
|
||||
{% if club.logo %}
|
||||
<div class="club_logo"><img src="{{ club.logo.url }}" alt="{{ club.name }}"></div>
|
||||
<div class="club_logo"><img src="{{ club.logo.url }}" alt="{{ club.unix_name }}"></div>
|
||||
{% endif %}
|
||||
{% if page_revision %}
|
||||
{{ page_revision|markdown }}
|
||||
|
@ -1,7 +1,7 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Club list{% endtrans %}
|
||||
{% trans %}Club list{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% macro display_club(club) -%}
|
||||
|
@ -11,7 +11,7 @@
|
||||
{{ select_all_checkbox("users_old") }}
|
||||
<p></p>
|
||||
{% endif %}
|
||||
<table id="club_members_table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}User{% endtrans %}</td>
|
||||
|
@ -1,48 +1,20 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
{% from 'core/macros.jinja' import user_profile_link %}
|
||||
|
||||
{# This page uses a custom macro instead of the core `paginate_jinja` and `paginate_alpine`
|
||||
because it works with a somewhat dynamic form,
|
||||
but was written before Alpine was introduced in the project.
|
||||
TODO : rewrite the pagination used in this template an Alpine one
|
||||
#}
|
||||
{% macro paginate(page_obj, paginator, js_action) %}
|
||||
{% set js = js_action|default('') %}
|
||||
{% if page_obj.has_previous() or page_obj.has_next() %}
|
||||
{% if page_obj.has_previous() %}
|
||||
<a {% if js %} type="submit" onclick="{{ js }}" {% endif %} href="?page={{ page_obj.previous_page_number() }}">{% trans %}Previous{% endtrans %}</a>
|
||||
{% else %}
|
||||
<span class="disabled">{% trans %}Previous{% endtrans %}</span>
|
||||
{% endif %}
|
||||
{% for i in paginator.page_range %}
|
||||
{% if page_obj.number == i %}
|
||||
<span class="active">{{ i }} <span class="sr-only">({% trans %}current{% endtrans %})</span></span>
|
||||
{% else %}
|
||||
<a {% if js %} type="submit" onclick="{{ js }}" {% endif %} href="?page={{ i }}">{{ i }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if page_obj.has_next() %}
|
||||
<a {% if js %} type="submit" onclick="{{ js }}" {% endif %} href="?page={{ page_obj.next_page_number() }}">{% trans %}Next{% endtrans %}</a>
|
||||
{% else %}
|
||||
<span class="disabled">{% trans %}Next{% endtrans %}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
{% from 'core/macros.jinja' import user_profile_link, paginate %}
|
||||
|
||||
{% block content %}
|
||||
<h3>{% trans %}Sales{% endtrans %}</h3>
|
||||
<form id="form" action="?page=1" method="post">
|
||||
<h3>{% trans %}Sales{% endtrans %}</h3>
|
||||
<form id="form" action="?page=1" method="post">
|
||||
{% csrf_token %}
|
||||
{{ form }}
|
||||
<p><input type="submit" value="{% trans %}Show{% endtrans %}" /></p>
|
||||
<p><input type="submit" value="{% trans %}Download as cvs{% endtrans %}" formaction="{{ url('club:sellings_csv', club_id=object.id) }}"/></p>
|
||||
</form>
|
||||
<p>
|
||||
{% trans %}Quantity: {% endtrans %}{{ total_quantity }} {% trans %}units{% endtrans %}<br/>
|
||||
{% trans %}Total: {% endtrans %}{{ total }} €<br/>
|
||||
{% trans %}Benefit: {% endtrans %}{{ benefit }} €
|
||||
</p>
|
||||
<table>
|
||||
</form>
|
||||
<p>
|
||||
{% trans %}Quantity: {% endtrans %}{{ total_quantity }} {% trans %}units{% endtrans %}<br/>
|
||||
{% trans %}Total: {% endtrans %}{{ total }} €<br/>
|
||||
{% trans %}Benefit: {% endtrans %}{{ benefit }} €
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}Date{% endtrans %}</td>
|
||||
@ -80,15 +52,15 @@ TODO : rewrite the pagination used in this template an Alpine one
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<script type="text/javascript">
|
||||
</table>
|
||||
<script type="text/javascript">
|
||||
function formPagination(link){
|
||||
$("form").attr("action", link.href);
|
||||
link.href = "javascript:void(0)"; // block link action
|
||||
$("form").submit();
|
||||
}
|
||||
</script>
|
||||
{{ paginate(paginated_result, paginator, "formPagination(this)") }}
|
||||
</script>
|
||||
{{ paginate(paginated_result, paginator, "formPagination(this)") }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<h3>{% trans %}Club tools{% endtrans %}</h3>
|
||||
<div>
|
||||
<h3>{% trans %}Club tools{% endtrans %}</h3>
|
||||
<div>
|
||||
<h4>{% trans %}Communication:{% endtrans %}</h4>
|
||||
<ul>
|
||||
<li> <a href="{{ url('com:news_new') }}?club={{ object.id }}">{% trans %}Create a news{% endtrans %}</a></li>
|
||||
@ -16,14 +16,31 @@
|
||||
</ul>
|
||||
<h4>{% trans %}Counters:{% endtrans %}</h4>
|
||||
<ul>
|
||||
{% if object.unix_name == settings.SITH_LAUNDERETTE_MANAGER['unix_name'] %}
|
||||
{% for l in Launderette.objects.all() %}
|
||||
<li><a href="{{ url('launderette:main_click', launderette_id=l.id) }}">{{ l }}</a></li>
|
||||
{% endfor %}
|
||||
{% elif object.counters.filter(type="OFFICE")|count > 0 %}
|
||||
{% for c in object.counters.filter(type="OFFICE") %}
|
||||
<li>{{ c }}:
|
||||
<a href="{{ url('counter:details', counter_id=c.id) }}">View</a>
|
||||
<a href="{{ url('counter:admin', counter_id=c.id) }}">Edit</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% if object.club_account.exists() %}
|
||||
<h4>{% trans %}Accounting: {% endtrans %}</h4>
|
||||
<ul>
|
||||
{% for ca in object.club_account.all() %}
|
||||
<li><a href="{{ url('accounting:club_details', c_account_id=ca.id) }}">{{ ca.get_display_name() }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if object.unix_name == settings.SITH_LAUNDERETTE_MANAGER['unix_name'] %}
|
||||
<li><a href="{{ url('launderette:launderette_list') }}">{% trans %}Manage launderettes{% endtrans %}</a></li>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
@ -1,54 +0,0 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans name=object %}Edit {{ name }}{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>{% trans name=object %}Edit {{ name }}{% endtrans %}</h2>
|
||||
|
||||
<form action="" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
{{ form.non_field_errors() }}
|
||||
|
||||
{% if form.admin_fields %}
|
||||
{# If the user is admin, display the admin fields,
|
||||
and explicitly separate them from the non-admin ones,
|
||||
with some help text.
|
||||
Non-admin users will only see the regular form fields,
|
||||
so they don't need thoses explanations #}
|
||||
<h3>{% trans %}Club properties{% endtrans %}</h3>
|
||||
<p class="helptext">
|
||||
{% trans trimmed %}
|
||||
The following form fields are linked to the core properties of a club.
|
||||
Only admin users can see and edit them.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
<fieldset class="required margin-bottom">
|
||||
{% for field_name in form.admin_fields %}
|
||||
{% set field = form[field_name] %}
|
||||
<div class="form-group">
|
||||
{{ field.errors }}
|
||||
{{ field.label_tag() }}
|
||||
{{ field }}
|
||||
</div>
|
||||
{# Remove the the admin fields from the form.
|
||||
The remaining non-admin fields will be rendered
|
||||
at once with a simple {{ form.as_p() }} #}
|
||||
{% set _ = form.fields.pop(field_name) %}
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
|
||||
<h3>{% trans %}Club informations{% endtrans %}</h3>
|
||||
<p class="helptext">
|
||||
{% trans trimmed %}
|
||||
The following form fields are linked to the basic description of a club.
|
||||
All board members of this club can see and edit them.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{{ form.as_p() }}
|
||||
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
||||
</form>
|
||||
{% endblock content %}
|
@ -2,7 +2,7 @@
|
||||
{% from 'core/macros.jinja' import select_all_checkbox %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Mailing lists{% endtrans %}
|
||||
{% trans %}Mailing lists{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
@ -2,7 +2,7 @@
|
||||
{% from 'core/macros_pages.jinja' import page_edit_form %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_edit_form(page, form, url('club:club_edit_page', club_id=page.club.id), csrf_token) }}
|
||||
{{ page_edit_form(page, form, url('club:club_edit_page', club_id=page.club.id), csrf_token) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
49
club/templates/club/stats.jinja
Normal file
49
club/templates/club/stats.jinja
Normal file
@ -0,0 +1,49 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Club stats{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if club_list %}
|
||||
<h3>{% trans %}Club stats{% endtrans %}</h3>
|
||||
<form action="" method="GET">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
<select name="branch">
|
||||
{% for b in settings.SITH_PROFILE_DEPARTMENTS %}
|
||||
<option value="{{ b[0] }}">{{ b[0] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</p>
|
||||
<p><input type="submit" value="{% trans %}Show{% endtrans %}" /></p>
|
||||
</form>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Club</td>
|
||||
<td>Member number</td>
|
||||
<td>Old member number</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in club_list.order_by('id') %}
|
||||
{% set members = c.members.all() %}
|
||||
{% if request.GET['branch'] %}
|
||||
{% set members = members.filter(user__department=request.GET['branch']) %}
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td>{{ c.get_display_name() }}</td>
|
||||
<td>{{ members.filter(end_date=None, role__gt=settings.SITH_MAXIMUM_FREE_ROLE).count() }}</td>
|
||||
<td>{{ members.exclude(end_date=None, role__gt=settings.SITH_MAXIMUM_FREE_ROLE).count() }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
{% trans %}There is no club in this website.{% endtrans %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
897
club/tests.py
Normal file
897
club/tests.py
Normal file
@ -0,0 +1,897 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
# All contributors are listed in the CONTRIBUTORS file.
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the whole source code at https://github.com/ae-utbm/sith3
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
# PREVIOUSLY LICENSED UNDER THE MIT LICENSE,
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE.old
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE.old"
|
||||
#
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import localtime, now
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from club.forms import MailingForm
|
||||
from club.models import Club, Mailing, Membership
|
||||
from core.models import AnonymousUser, User
|
||||
from sith.settings import SITH_BAR_MANAGER, SITH_MAIN_CLUB_ID
|
||||
|
||||
|
||||
class ClubTest(TestCase):
|
||||
"""
|
||||
Set up data for test cases related to clubs and membership
|
||||
The generated dataset is the one created by the populate command,
|
||||
plus the following modifications :
|
||||
|
||||
- `self.club` is a dummy club recreated for each test
|
||||
- `self.club` has two board members : skia (role 3) and comptable (role 10)
|
||||
- `self.club` has one regular member : richard
|
||||
- `self.club` has one former member : sli (who had role 2)
|
||||
- None of the `self.club` members are in the AE club.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
# subscribed users - initial members
|
||||
cls.skia = User.objects.get(username="skia")
|
||||
cls.richard = User.objects.get(username="rbatsbak")
|
||||
cls.comptable = User.objects.get(username="comptable")
|
||||
cls.sli = User.objects.get(username="sli")
|
||||
cls.root = User.objects.get(username="root")
|
||||
|
||||
# subscribed users - not initial members
|
||||
cls.krophil = User.objects.get(username="krophil")
|
||||
cls.subscriber = User.objects.get(username="subscriber")
|
||||
|
||||
# old subscriber
|
||||
cls.old_subscriber = User.objects.get(username="old_subscriber")
|
||||
|
||||
# not subscribed
|
||||
cls.public = User.objects.get(username="public")
|
||||
|
||||
cls.ae = Club.objects.filter(pk=SITH_MAIN_CLUB_ID)[0]
|
||||
|
||||
def setUp(self):
|
||||
# by default, Skia is in the AE, which creates side effect
|
||||
self.skia.memberships.all().delete()
|
||||
|
||||
# create a fake club
|
||||
self.club = Club.objects.create(
|
||||
name="Fake Club",
|
||||
unix_name="fake-club",
|
||||
address="5 rue de la République, 90000 Belfort",
|
||||
)
|
||||
self.members_url = reverse(
|
||||
"club:club_members", kwargs={"club_id": self.club.id}
|
||||
)
|
||||
a_month_ago = now() - timedelta(days=30)
|
||||
yesterday = now() - timedelta(days=1)
|
||||
Membership.objects.create(
|
||||
club=self.club, user=self.skia, start_date=a_month_ago, role=3
|
||||
)
|
||||
Membership.objects.create(club=self.club, user=self.richard, role=1)
|
||||
Membership.objects.create(
|
||||
club=self.club, user=self.comptable, start_date=a_month_ago, role=10
|
||||
)
|
||||
|
||||
# sli was a member but isn't anymore
|
||||
Membership.objects.create(
|
||||
club=self.club,
|
||||
user=self.sli,
|
||||
start_date=a_month_ago,
|
||||
end_date=yesterday,
|
||||
role=2,
|
||||
)
|
||||
cache.clear()
|
||||
|
||||
|
||||
class MembershipQuerySetTest(ClubTest):
|
||||
def test_ongoing(self):
|
||||
"""
|
||||
Test that the ongoing queryset method returns the memberships that
|
||||
are not ended.
|
||||
"""
|
||||
current_members = list(self.club.members.ongoing().order_by("id"))
|
||||
expected = [
|
||||
self.skia.memberships.get(club=self.club),
|
||||
self.comptable.memberships.get(club=self.club),
|
||||
self.richard.memberships.get(club=self.club),
|
||||
]
|
||||
expected.sort(key=lambda i: i.id)
|
||||
assert current_members == expected
|
||||
|
||||
def test_board(self):
|
||||
"""
|
||||
Test that the board queryset method returns the memberships
|
||||
of user in the club board
|
||||
"""
|
||||
board_members = list(self.club.members.board().order_by("id"))
|
||||
expected = [
|
||||
self.skia.memberships.get(club=self.club),
|
||||
self.comptable.memberships.get(club=self.club),
|
||||
# sli is no more member, but he was in the board
|
||||
self.sli.memberships.get(club=self.club),
|
||||
]
|
||||
expected.sort(key=lambda i: i.id)
|
||||
assert board_members == expected
|
||||
|
||||
def test_ongoing_board(self):
|
||||
"""
|
||||
Test that combining ongoing and board returns users
|
||||
who are currently board members of the club
|
||||
"""
|
||||
members = list(self.club.members.ongoing().board().order_by("id"))
|
||||
expected = [
|
||||
self.skia.memberships.get(club=self.club),
|
||||
self.comptable.memberships.get(club=self.club),
|
||||
]
|
||||
expected.sort(key=lambda i: i.id)
|
||||
assert members == expected
|
||||
|
||||
def test_update_invalidate_cache(self):
|
||||
"""
|
||||
Test that the `update` queryset method properly invalidate cache
|
||||
"""
|
||||
mem_skia = self.skia.memberships.get(club=self.club)
|
||||
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
|
||||
self.skia.memberships.update(end_date=localtime(now()).date())
|
||||
assert (
|
||||
cache.get(f"membership_{mem_skia.club_id}_{mem_skia.user_id}")
|
||||
== "not_member"
|
||||
)
|
||||
|
||||
mem_richard = self.richard.memberships.get(club=self.club)
|
||||
cache.set(
|
||||
f"membership_{mem_richard.club_id}_{mem_richard.user_id}", mem_richard
|
||||
)
|
||||
self.richard.memberships.update(role=5)
|
||||
new_mem = self.richard.memberships.get(club=self.club)
|
||||
assert new_mem != "not_member"
|
||||
assert new_mem.role == 5
|
||||
|
||||
def test_delete_invalidate_cache(self):
|
||||
"""
|
||||
Test that the `delete` queryset properly invalidate cache
|
||||
"""
|
||||
|
||||
mem_skia = self.skia.memberships.get(club=self.club)
|
||||
mem_comptable = self.comptable.memberships.get(club=self.club)
|
||||
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
|
||||
cache.set(
|
||||
f"membership_{mem_comptable.club_id}_{mem_comptable.user_id}", mem_comptable
|
||||
)
|
||||
|
||||
# should delete the subscriptions of skia and comptable
|
||||
self.club.members.ongoing().board().delete()
|
||||
|
||||
assert (
|
||||
cache.get(f"membership_{mem_skia.club_id}_{mem_skia.user_id}")
|
||||
== "not_member"
|
||||
)
|
||||
assert (
|
||||
cache.get(f"membership_{mem_comptable.club_id}_{mem_comptable.user_id}")
|
||||
== "not_member",
|
||||
)
|
||||
|
||||
|
||||
class ClubModelTest(ClubTest):
|
||||
def assert_membership_started_today(self, user: User, role: int):
|
||||
"""
|
||||
Assert that the given membership is active and started today
|
||||
"""
|
||||
membership = user.memberships.ongoing().filter(club=self.club).first()
|
||||
assert membership is not None
|
||||
assert localtime(now()).date() == membership.start_date
|
||||
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)
|
||||
|
||||
def assert_membership_ended_today(self, user: User):
|
||||
"""
|
||||
Assert that the given user have a membership which ended today
|
||||
"""
|
||||
today = localtime(now()).date()
|
||||
assert user.memberships.filter(club=self.club, end_date=today).exists()
|
||||
assert self.club.get_membership_for(user) is None
|
||||
|
||||
def test_access_unauthorized(self):
|
||||
"""
|
||||
Test that users who never subscribed and anonymous users
|
||||
cannot see the page
|
||||
"""
|
||||
response = self.client.post(self.members_url)
|
||||
assert response.status_code == 403
|
||||
|
||||
self.client.force_login(self.public)
|
||||
response = self.client.post(self.members_url)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_display(self):
|
||||
"""
|
||||
Test that a GET request return a page where the requested
|
||||
information are displayed.
|
||||
"""
|
||||
self.client.force_login(self.skia)
|
||||
response = self.client.get(self.members_url)
|
||||
assert response.status_code == 200
|
||||
expected_html = (
|
||||
"<table><thead><tr>"
|
||||
"<td>Utilisateur</td><td>Rôle</td><td>Description</td>"
|
||||
"<td>Depuis</td><td>Marquer comme ancien</td>"
|
||||
"</tr></thead><tbody>"
|
||||
)
|
||||
memberships = self.club.members.ongoing().order_by("-role")
|
||||
input_id = 0
|
||||
for membership in memberships.select_related("user"):
|
||||
user = membership.user
|
||||
expected_html += (
|
||||
f"<tr><td><a href=\"{reverse('core:user_profile', args=[user.id])}\">"
|
||||
f"{user.get_display_name()}</a></td>"
|
||||
f"<td>{settings.SITH_CLUB_ROLES[membership.role]}</td>"
|
||||
f"<td>{membership.description}</td>"
|
||||
f"<td>{membership.start_date}</td><td>"
|
||||
)
|
||||
if membership.role <= 3: # 3 is the role of skia
|
||||
expected_html += (
|
||||
'<input type="checkbox" name="users_old" '
|
||||
f'value="{user.id}" '
|
||||
f'id="id_users_old_{input_id}">'
|
||||
)
|
||||
input_id += 1
|
||||
expected_html += "</td></tr>"
|
||||
expected_html += "</tbody></table>"
|
||||
self.assertInHTML(expected_html, response.content.decode())
|
||||
|
||||
def test_root_add_one_club_member(self):
|
||||
"""
|
||||
Test that root users can add members to clubs, one at a time
|
||||
"""
|
||||
self.client.force_login(self.root)
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{"users": self.subscriber.id, "role": 3},
|
||||
)
|
||||
self.assertRedirects(response, self.members_url)
|
||||
self.subscriber.refresh_from_db()
|
||||
self.assert_membership_started_today(self.subscriber, role=3)
|
||||
|
||||
def test_root_add_multiple_club_member(self):
|
||||
"""
|
||||
Test that root users can add multiple members at once to clubs
|
||||
"""
|
||||
self.client.force_login(self.root)
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{
|
||||
"users": f"|{self.subscriber.id}|{self.krophil.id}|",
|
||||
"role": 3,
|
||||
},
|
||||
)
|
||||
self.assertRedirects(response, self.members_url)
|
||||
self.subscriber.refresh_from_db()
|
||||
self.assert_membership_started_today(self.subscriber, role=3)
|
||||
self.assert_membership_started_today(self.krophil, role=3)
|
||||
|
||||
def test_add_unauthorized_members(self):
|
||||
"""
|
||||
Test that users who are not currently subscribed
|
||||
cannot be members of clubs.
|
||||
"""
|
||||
self.client.force_login(self.root)
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{"users": self.public.id, "role": 1},
|
||||
)
|
||||
assert not self.public.memberships.filter(club=self.club).exists()
|
||||
assert '<ul class="errorlist"><li>' in response.content.decode()
|
||||
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{"users": self.old_subscriber.id, "role": 1},
|
||||
)
|
||||
assert not self.public.memberships.filter(club=self.club).exists()
|
||||
assert self.club.get_membership_for(self.public) is None
|
||||
assert '<ul class="errorlist"><li>' in response.content.decode()
|
||||
|
||||
def test_add_members_already_members(self):
|
||||
"""
|
||||
Test that users who are already members of a club
|
||||
cannot be added again to this club
|
||||
"""
|
||||
self.client.force_login(self.root)
|
||||
current_membership = self.skia.memberships.ongoing().get(club=self.club)
|
||||
nb_memberships = self.skia.memberships.count()
|
||||
self.client.post(
|
||||
self.members_url,
|
||||
{"users": self.skia.id, "role": current_membership.role + 1},
|
||||
)
|
||||
self.skia.refresh_from_db()
|
||||
assert nb_memberships == self.skia.memberships.count()
|
||||
new_membership = self.skia.memberships.ongoing().get(club=self.club)
|
||||
assert current_membership == new_membership
|
||||
assert self.club.get_membership_for(self.skia) == new_membership
|
||||
|
||||
def test_add_not_existing_users(self):
|
||||
"""
|
||||
Test that not existing users cannot be added in clubs.
|
||||
If one user in the request is invalid, no membership creation at all
|
||||
can take place.
|
||||
"""
|
||||
self.client.force_login(self.root)
|
||||
nb_memberships = self.club.members.count()
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{"users": [9999], "role": 1},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert '<ul class="errorlist"><li>' in response.content.decode()
|
||||
self.club.refresh_from_db()
|
||||
assert self.club.members.count() == nb_memberships
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{
|
||||
"users": f"|{self.subscriber.id}|{9999}|",
|
||||
"start_date": "12/06/2016",
|
||||
"role": 3,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert '<ul class="errorlist"><li>' in response.content.decode()
|
||||
self.club.refresh_from_db()
|
||||
assert self.club.members.count() == nb_memberships
|
||||
|
||||
def test_president_add_members(self):
|
||||
"""
|
||||
Test that the president of the club can add members
|
||||
"""
|
||||
president = self.club.members.get(role=10).user
|
||||
nb_club_membership = self.club.members.count()
|
||||
nb_subscriber_memberships = self.subscriber.memberships.count()
|
||||
self.client.force_login(president)
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{"users": self.subscriber.id, "role": 9},
|
||||
)
|
||||
self.assertRedirects(response, self.members_url)
|
||||
self.club.refresh_from_db()
|
||||
self.subscriber.refresh_from_db()
|
||||
assert self.club.members.count() == nb_club_membership + 1
|
||||
assert self.subscriber.memberships.count() == nb_subscriber_memberships + 1
|
||||
self.assert_membership_started_today(self.subscriber, role=9)
|
||||
|
||||
def test_add_member_greater_role(self):
|
||||
"""
|
||||
Test that a member of the club member cannot create
|
||||
a membership with a greater role than its own.
|
||||
"""
|
||||
self.client.force_login(self.skia)
|
||||
nb_memberships = self.club.members.count()
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{"users": self.subscriber.id, "role": 10},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
self.assertInHTML(
|
||||
"<li>Vous n'avez pas la permission de faire cela</li>",
|
||||
response.content.decode(),
|
||||
)
|
||||
self.club.refresh_from_db()
|
||||
assert nb_memberships == self.club.members.count()
|
||||
assert not self.subscriber.memberships.filter(club=self.club).exists()
|
||||
|
||||
def test_add_member_without_role(self):
|
||||
"""
|
||||
Test that trying to add members without specifying their role fails
|
||||
"""
|
||||
self.client.force_login(self.root)
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{"users": self.subscriber.id, "start_date": "12/06/2016"},
|
||||
)
|
||||
assert (
|
||||
'<ul class="errorlist"><li>Vous devez choisir un r'
|
||||
in response.content.decode()
|
||||
)
|
||||
|
||||
def test_end_membership_self(self):
|
||||
"""
|
||||
Test that a member can end its own membership
|
||||
"""
|
||||
self.client.force_login(self.skia)
|
||||
self.client.post(
|
||||
self.members_url,
|
||||
{"users_old": self.skia.id},
|
||||
)
|
||||
self.skia.refresh_from_db()
|
||||
self.assert_membership_ended_today(self.skia)
|
||||
|
||||
def test_end_membership_lower_role(self):
|
||||
"""
|
||||
Test that board members of the club can end memberships
|
||||
of users with lower roles
|
||||
"""
|
||||
# remainder : skia has role 3, comptable has role 10, richard has role 1
|
||||
self.client.force_login(self.skia)
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{"users_old": self.richard.id},
|
||||
)
|
||||
self.assertRedirects(response, self.members_url)
|
||||
self.club.refresh_from_db()
|
||||
self.assert_membership_ended_today(self.richard)
|
||||
|
||||
def test_end_membership_higher_role(self):
|
||||
"""
|
||||
Test that board members of the club cannot end memberships
|
||||
of users with higher roles
|
||||
"""
|
||||
membership = self.comptable.memberships.filter(club=self.club).first()
|
||||
self.client.force_login(self.skia)
|
||||
self.client.post(
|
||||
self.members_url,
|
||||
{"users_old": self.comptable.id},
|
||||
)
|
||||
self.club.refresh_from_db()
|
||||
new_membership = self.club.get_membership_for(self.comptable)
|
||||
assert new_membership is not None
|
||||
assert new_membership == membership
|
||||
|
||||
membership = self.comptable.memberships.filter(club=self.club).first()
|
||||
assert membership.end_date is None
|
||||
|
||||
def test_end_membership_as_main_club_board(self):
|
||||
"""
|
||||
Test that board members of the main club can end the membership
|
||||
of anyone
|
||||
"""
|
||||
# make subscriber a board member
|
||||
self.subscriber.memberships.all().delete()
|
||||
Membership.objects.create(club=self.ae, user=self.subscriber, role=3)
|
||||
|
||||
nb_memberships = self.club.members.count()
|
||||
self.client.force_login(self.subscriber)
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{"users_old": self.comptable.id},
|
||||
)
|
||||
self.assertRedirects(response, self.members_url)
|
||||
self.assert_membership_ended_today(self.comptable)
|
||||
assert self.club.members.ongoing().count() == nb_memberships - 1
|
||||
|
||||
def test_end_membership_as_root(self):
|
||||
"""
|
||||
Test that root users can end the membership of anyone
|
||||
"""
|
||||
nb_memberships = self.club.members.count()
|
||||
self.client.force_login(self.root)
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{"users_old": [self.comptable.id]},
|
||||
)
|
||||
self.assertRedirects(response, self.members_url)
|
||||
self.assert_membership_ended_today(self.comptable)
|
||||
assert self.club.members.ongoing().count() == nb_memberships - 1
|
||||
assert self.club.members.count() == nb_memberships
|
||||
|
||||
def test_end_membership_as_foreigner(self):
|
||||
"""
|
||||
Test that users who are not in this club cannot end its memberships
|
||||
"""
|
||||
nb_memberships = self.club.members.count()
|
||||
membership = self.richard.memberships.filter(club=self.club).first()
|
||||
self.client.force_login(self.subscriber)
|
||||
self.client.post(
|
||||
self.members_url,
|
||||
{"users_old": [self.richard.id]},
|
||||
)
|
||||
# nothing should have changed
|
||||
new_mem = self.club.get_membership_for(self.richard)
|
||||
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
|
||||
|
||||
self.club.delete()
|
||||
for user in users:
|
||||
assert not user.is_in_group(name=meta_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_club_owner(self):
|
||||
"""
|
||||
Test that a club is owned only by board members of the main club
|
||||
"""
|
||||
anonymous = AnonymousUser()
|
||||
assert not self.club.is_owned_by(anonymous)
|
||||
assert not self.club.is_owned_by(self.subscriber)
|
||||
|
||||
# make sli a board member
|
||||
self.sli.memberships.all().delete()
|
||||
Membership(club=self.ae, user=self.sli, role=3).save()
|
||||
assert self.club.is_owned_by(self.sli)
|
||||
|
||||
|
||||
class MailingFormTest(TestCase):
|
||||
"""Perform validation tests for MailingForm"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.skia = User.objects.get(username="skia")
|
||||
cls.rbatsbak = User.objects.get(username="rbatsbak")
|
||||
cls.krophil = User.objects.get(username="krophil")
|
||||
cls.comunity = User.objects.get(username="comunity")
|
||||
cls.root = User.objects.get(username="root")
|
||||
cls.bdf = Club.objects.get(unix_name=SITH_BAR_MANAGER["unix_name"])
|
||||
cls.mail_url = reverse("club:mailing", kwargs={"club_id": cls.bdf.id})
|
||||
|
||||
def setUp(self):
|
||||
Membership(
|
||||
user=self.rbatsbak,
|
||||
club=self.bdf,
|
||||
start_date=timezone.now(),
|
||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||
).save()
|
||||
|
||||
def test_mailing_list_add_no_moderation(self):
|
||||
# Test with Communication admin
|
||||
self.client.force_login(self.comunity)
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "foyer"},
|
||||
)
|
||||
self.assertRedirects(response, self.mail_url)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
assert "Liste de diffusion foyer@utbm.fr" in response.content.decode()
|
||||
|
||||
# Test with Root
|
||||
self.client.force_login(self.root)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
assert "Liste de diffusion mde@utbm.fr" in response.content.decode()
|
||||
|
||||
def test_mailing_list_add_moderation(self):
|
||||
self.client.force_login(self.rbatsbak)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "Liste de diffusion mde@utbm.fr" not in content
|
||||
assert "<p>Listes de diffusions en attente de modération</p>" in content
|
||||
assert "<li>mde@utbm.fr" in content
|
||||
|
||||
def test_mailing_list_forbidden(self):
|
||||
# With anonymous user
|
||||
response = self.client.get(self.mail_url)
|
||||
self.assertContains(response, "", status_code=403)
|
||||
|
||||
# With user not in club
|
||||
self.client.force_login(self.krophil)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_add_new_subscription_fail_not_moderated(self):
|
||||
self.client.force_login(self.rbatsbak)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.skia.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
assert "skia@git.an" not in response.content.decode()
|
||||
|
||||
def test_add_new_subscription_success(self):
|
||||
# Prepare mailing list
|
||||
self.client.force_login(self.comunity)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
|
||||
# Add single user
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.skia.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
assert "skia@git.an" in response.content.decode()
|
||||
|
||||
# Add multiple users
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": "|%s|%s|" % (self.comunity.id, self.rbatsbak.id),
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "richard@git.an" in content
|
||||
assert "comunity@git.an" in content
|
||||
assert "skia@git.an" in content
|
||||
|
||||
# Add arbitrary email
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_email": "arbitrary@git.an",
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "richard@git.an" in content
|
||||
assert "comunity@git.an" in content
|
||||
assert "skia@git.an" in content
|
||||
assert "arbitrary@git.an" in content
|
||||
|
||||
# Add user and arbitrary email
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_email": "more.arbitrary@git.an",
|
||||
"subscription_users": self.krophil.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "richard@git.an" in content
|
||||
assert "comunity@git.an" in content
|
||||
assert "skia@git.an" in content
|
||||
assert "arbitrary@git.an" in content
|
||||
assert "more.arbitrary@git.an" in content
|
||||
assert "krophil@git.an" in content
|
||||
|
||||
def test_add_new_subscription_fail_form_errors(self):
|
||||
# Prepare mailing list
|
||||
self.client.force_login(self.comunity)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
|
||||
# Neither email or email is specified
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
assert response.status_code
|
||||
self.assertInHTML(
|
||||
_("You must specify at least an user or an email address"),
|
||||
response.content.decode(),
|
||||
)
|
||||
|
||||
# No mailing specified
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.krophil.id,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert _("This field is required") in response.content.decode()
|
||||
|
||||
# One of the selected users doesn't exist
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": "|789|",
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
self.assertInHTML(
|
||||
_("One of the selected users doesn't exist"), response.content.decode()
|
||||
)
|
||||
|
||||
# An user has no email adress
|
||||
self.krophil.email = ""
|
||||
self.krophil.save()
|
||||
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.krophil.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
self.assertInHTML(
|
||||
_("One of the selected users doesn't have an email address"),
|
||||
response.content.decode(),
|
||||
)
|
||||
|
||||
self.krophil.email = "krophil@git.an"
|
||||
self.krophil.save()
|
||||
|
||||
# An user is added twice
|
||||
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.krophil.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.krophil.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
self.assertInHTML(
|
||||
_("This email is already suscribed in this mailing"),
|
||||
response.content.decode(),
|
||||
)
|
||||
|
||||
def test_remove_subscription_success(self):
|
||||
# Prepare mailing list
|
||||
self.client.force_login(self.comunity)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
mde = Mailing.objects.get(email="mde")
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": "|%s|%s|%s|"
|
||||
% (self.comunity.id, self.rbatsbak.id, self.krophil.id),
|
||||
"subscription_mailing": mde.id,
|
||||
},
|
||||
)
|
||||
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
|
||||
assert "comunity@git.an" in content
|
||||
assert "richard@git.an" in content
|
||||
assert "krophil@git.an" in content
|
||||
|
||||
# Delete one user
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_REMOVE_SUBSCRIPTION,
|
||||
"removal_%d" % mde.id: mde.subscriptions.get(user=self.krophil).id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
|
||||
assert "comunity@git.an" in content
|
||||
assert "richard@git.an" in content
|
||||
assert "krophil@git.an" not in content
|
||||
|
||||
# Delete multiple users
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_REMOVE_SUBSCRIPTION,
|
||||
"removal_%d" % mde.id: [
|
||||
user.id
|
||||
for user in mde.subscriptions.filter(
|
||||
user__in=[self.rbatsbak, self.comunity]
|
||||
).all()
|
||||
],
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
|
||||
assert "comunity@git.an" not in content
|
||||
assert "richard@git.an" not in content
|
||||
assert "krophil@git.an" not in content
|
||||
|
||||
|
||||
class ClubSellingViewTest(TestCase):
|
||||
"""
|
||||
Perform basics tests to ensure that the page is available
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.ae = Club.objects.get(unix_name="ae")
|
||||
cls.skia = User.objects.get(username="skia")
|
||||
|
||||
def test_page_not_internal_error(self):
|
||||
"""
|
||||
Test that the page does not return and internal error
|
||||
"""
|
||||
self.client.force_login(self.skia)
|
||||
response = self.client.get(
|
||||
reverse("club:club_sellings", kwargs={"club_id": self.ae.id})
|
||||
)
|
||||
assert response.status_code == 200
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user