2 Commits

Author SHA1 Message Date
858199e476 Modification of the weekmail template 2022-09-25 22:56:39 +02:00
f6ecbd899d Add of some modifications in the weekmail model 2022-09-25 22:55:39 +02:00
1016 changed files with 79673 additions and 64179 deletions

View File

@ -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
View File

@ -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"

View File

@ -1,51 +0,0 @@
name: "Setup project"
description: "Setup Python and Poetry"
runs:
using: composite
steps:
- name: Install apt packages
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
with:
packages: gettext
version: 1.0 # increment to reset cache
- name: Install Redis
uses: shogo82148/actions-setup-redis@v1
with:
redis-version: "7.x"
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
version: "0.5.14"
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: "Set up Python"
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Restore cached virtualenv
uses: actions/cache/restore@v4
with:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
path: .venv
- name: Install dependencies
run: uv sync
shell: bash
- name: Install Xapian
run: uv run ./manage.py install_xapian
shell: bash
- name: Save cached virtualenv
uses: actions/cache/save@v4
with:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
path: .venv
- name: Compile gettext messages
run: uv run ./manage.py compilemessages
shell: bash

View File

@ -1,14 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
target-branch: "taiste"
commit-message:
prefix: "[UPDATE] "

View File

@ -1,53 +0,0 @@
name: Sith CI
on:
push:
branches: [master, taiste]
pull_request:
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)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- uses: pre-commit/action@v3.0.1
with:
extra_args: --all-files
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 }}
- name: Run tests
run: uv run coverage run -m pytest -m "${{ matrix.pytest-mark }}"
- name: Generate coverage report
run: |
uv run coverage report
uv run coverage html
- name: Archive code coverage results
uses: actions/upload-artifact@v4
with:
name: coverage-report-${{ matrix.pytest-mark }}
path: coverage_report

View File

@ -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/.poetry/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 update
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,14 +51,14 @@ 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 }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_URL: ${{ secrets.SENTRY_URL }}
with:
environment: production
environment: production

View File

@ -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

View File

@ -1,46 +0,0 @@
name: Sith taiste
on:
push:
branches: [taiste]
workflow_dispatch:
jobs:
deployment:
runs-on: ubuntu-latest
environment: taiste
timeout-minutes: 30
steps:
- name: SSH Remote Commands
uses: appleboy/ssh-action@v1.1.0
with:
# Proxy
proxy_host : ${{secrets.PROXY_HOST}}
proxy_port : ${{secrets.PROXY_PORT}}
proxy_username : ${{secrets.PROXY_USER}}
proxy_passphrase: ${{secrets.PROXY_PASSPHRASE}}
proxy_key: ${{secrets.PROXY_KEY}}
# Serveur web
host: ${{secrets.HOST}}
port : ${{secrets.PORT}}
username : ${{secrets.USER}}
key: ${{secrets.KEY}}
script_stop: true
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
script: |
cd ${{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
sudo systemctl restart uwsgi

83
.github/workflows/unittests.yml vendored Normal file
View File

@ -0,0 +1,83 @@
name: Sith3 CI
on:
pull_request:
branches: [ master ]
push:
branches: [ master ]
jobs:
unittests:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v3
# Skip unit testing if no diff on .py files
- name: Check file diff
uses: technote-space/get-diff-action@v6
id: git-diff
with:
PATTERNS: |
**/*.py
- name: Set up python
if: steps.git-diff.outputs.diff
uses: actions/setup-python@v4
with:
python-version: '3.8'
- name: Install dependencies
if: steps.git-diff.outputs.diff
run: |
sudo apt-get update
sudo apt-get install gettext libxapian-dev libgraphviz-dev
- name: Install poetry
if: steps.git-diff.outputs.diff
run: |
python -m pip install --upgrade pip
python -m pip install poetry
- name: Checking pyproject.toml syntax
if: steps.git-diff.outputs.diff
run: poetry check
- name: Install project
if: steps.git-diff.outputs.diff
run: poetry install -E testing
- name: Setup xapian index
if: steps.git-diff.outputs.diff
run: |
mkdir -p /dev/shm/search_indexes
ln -s /dev/shm/search_indexes sith/search_indexes
- name: Setup project
if: steps.git-diff.outputs.diff
run: poetry run ./manage.py compilemessages
- name: Launch tests and generate coverage report
if: steps.git-diff.outputs.diff
run: |
poetry run coverage run ./manage.py test
poetry run coverage report
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up python
uses: actions/setup-python@v4
with:
python-version: '3.8'
- name: Install black
run: |
python -m pip install --upgrade pip
python -m pip install black==22.6.0
- name: Check linting
run: black --check .

19
.gitignore vendored
View File

@ -1,4 +1,4 @@
*.sqlite3
db.sqlite3
*.log
*.pyc
*.mo
@ -7,25 +7,12 @@
pyrightconfig.json
dist/
.vscode/
.idea/
.venv/
env/
doc/html
data/
galaxy/test_galaxy_state.json
/static/
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

View File

@ -15,5 +15,4 @@ Vial <robin.trioux@utbm.fr>
Zar <antoine.charmeau@utbm.fr> <antoine.charmeau@laposte.net>
root <root@localhost.localdomain>
tleb <tleb@openmailbox.org> <theo.lebrun@live.fr>
tleb <tleb@openmailbox.org> <theo.lebrun@utbm.fr>
Maréchal <thgirod@hotmail.com>
tleb <tleb@openmailbox.org> <theo.lebrun@utbm.fr>

1
.npmrc
View File

@ -1 +0,0 @@
@jsr:registry=https://npm.jsr.io

View File

@ -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"]

View File

@ -1 +0,0 @@
3.12

26
.readthedocs.yml Normal file
View 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

21
LICENSE.old Normal file
View 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.

View File

@ -1,2 +0,0 @@
redis: redis-server --port $REDIS_PORT
celery: uv run celery -A sith worker --beat -l INFO

View File

@ -1 +0,0 @@
bundler: npm run serve

View File

@ -1,21 +1,41 @@
# 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/889796155523874847?label=Discord&logo=discord&style=for-the-badge">
</a>
</p>
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](#)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![CI status](https://github.com/ae-utbm/sith/actions/workflows/ci.yml/badge.svg)](#)
[![Docs status](https://github.com/ae-utbm/sith/actions/workflows/deploy_docs.yml/badge.svg)](https://ae-utbm.github.io/sith)
[![Built with Material for MkDocs](https://img.shields.io/badge/Material_for_MkDocs-526CFE?style=default&logo=MaterialForMkDocs&logoColor=white)](https://squidfunk.github.io/mkdocs-material/)
[![Checked with Biome](https://img.shields.io/badge/Checked_with-Biome-60a5fa?style=flat&logo=biome)](https://biomejs.dev)
[![discord](https://img.shields.io/discord/971448179075731476?label=discord&logo=discord&style=default)](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:
<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>
* 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.
> This project is licenced under GNU GPL, see the LICENSE file at the top of the repository for more details.
> This project is licensed under GNU GPL, see the LICENSE file at the top of the repository for more details.

23
accounting/__init__.py Normal file
View File

@ -0,0 +1,23 @@
# -*- coding:utf-8 -*
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#

37
accounting/admin.py Normal file
View File

@ -0,0 +1,37 @@
# -*- coding:utf-8 -*
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from 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)

View File

@ -0,0 +1,281 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.core.validators
import accounting.models
import django.db.models.deletion
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"],
},
),
]

View File

@ -0,0 +1,107 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
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")])
),
]

View File

@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import phonenumber_field.modelfields
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),
),
]

View File

@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
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")])
),
]

View File

@ -0,0 +1,19 @@
# -*- 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"
),
)
]

571
accounting/models.py Normal file
View File

@ -0,0 +1,571 @@
# -*- coding:utf-8 -*
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.urls import reverse
from django.core.exceptions import ValidationError
from django.core import validators
from django.db import models
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from django.template import defaultfilters
from phonenumber_field.modelfields import PhoneNumberField
from decimal import Decimal
from core.models import User, SithFile
from club.models import Club
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(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_in_group(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_in_group(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_in_group(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(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_in_group(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(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_in_group(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):
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)

View 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 %}

View 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(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 %}

View 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(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 %}

View 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(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(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 %}

View File

@ -0,0 +1,29 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Company list{% endtrans %}
{% endblock %}
{% block content %}
<div id="accounting">
{% if user.is_in_group(settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) or user.is_root %}
<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 %}

View File

@ -0,0 +1,100 @@
{% 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">&nbsp;{{ o.amount }}&nbsp;€</td>
{% else %}
<td class="pos-amount">&nbsp;{{ o.amount }}&nbsp;€</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 != "AE TI" and o.journal.club_account.bank_account.name != "TI" or user.is_in_group(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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View 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(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(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 %}

View 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> >
{{ 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 %}

View 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 %}

View File

@ -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 %}

324
accounting/tests.py Normal file
View File

@ -0,0 +1,324 @@
# -*- coding:utf-8 -*
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.test import TestCase
from django.urls import reverse
from django.core.management import call_command
from datetime import date, timedelta
from core.models import User
from accounting.models import (
GeneralJournal,
Operation,
Label,
AccountingType,
SimplifiedAccountingType,
)
class RefoundAccountTest(TestCase):
def setUp(self):
call_command("populate")
self.skia = User.objects.filter(username="skia").first()
# reffil skia's account
self.skia.customer.amount = 800
self.skia.customer.save()
def test_permission_denied(self):
self.client.login(username="guy", password="plop")
response_post = self.client.post(
reverse("accounting:refound_account"), {"user": self.skia.id}
)
response_get = self.client.get(reverse("accounting:refound_account"))
self.assertTrue(response_get.status_code == 403)
self.assertTrue(response_post.status_code == 403)
def test_root_granteed(self):
self.client.login(username="root", password="plop")
response_post = self.client.post(
reverse("accounting:refound_account"), {"user": self.skia.id}
)
self.skia = User.objects.filter(username="skia").first()
response_get = self.client.get(reverse("accounting:refound_account"))
self.assertFalse(response_get.status_code == 403)
self.assertTrue('<form action="" method="post">' in str(response_get.content))
self.assertFalse(response_post.status_code == 403)
self.assertTrue(self.skia.customer.amount == 0)
def test_comptable_granteed(self):
self.client.login(username="comptable", password="plop")
response_post = self.client.post(
reverse("accounting:refound_account"), {"user": self.skia.id}
)
self.skia = User.objects.filter(username="skia").first()
response_get = self.client.get(reverse("accounting:refound_account"))
self.assertFalse(response_get.status_code == 403)
self.assertTrue('<form action="" method="post">' in str(response_get.content))
self.assertFalse(response_post.status_code == 403)
self.assertTrue(self.skia.customer.amount == 0)
class JournalTest(TestCase):
def setUp(self):
call_command("populate")
self.journal = GeneralJournal.objects.filter(id=1).first()
def test_permission_granted(self):
self.client.login(username="comptable", password="plop")
response_get = self.client.get(
reverse("accounting:journal_details", args=[self.journal.id])
)
self.assertTrue(response_get.status_code == 200)
self.assertTrue(
"<td>M\\xc3\\xa9thode de paiement</td>" in str(response_get.content)
)
def test_permission_not_granted(self):
self.client.login(username="skia", password="plop")
response_get = self.client.get(
reverse("accounting:journal_details", args=[self.journal.id])
)
self.assertTrue(response_get.status_code == 403)
self.assertFalse(
"<td>M\xc3\xa9thode de paiement</td>" in str(response_get.content)
)
class OperationTest(TestCase):
def setUp(self):
call_command("populate")
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(club_account=self.journal.club_account, name="bob")
l.save()
self.client.login(username="comptable", password="plop")
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):
self.client.login(username="comptable", password="plop")
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 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):
self.client.login(username="comptable", password="plop")
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 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.login(username="skia", password="plop")
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):
self.client.login(username="comptable", password="plop")
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&#39;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):
self.client.login(username="comptable", password="plop")
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):
self.client.login(username="comptable", password="plop")
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&#39; Kia</a></td>
<td>3.00</td>""",
)
self.assertContains(
response,
"""
<td><a href="/user/1/">S&#39; Kia</a></td>
<td>823.00</td>""",
)
def test_accounting_statement(self):
self.client.login(username="comptable", password="plop")
response = self.client.get(
reverse("accounting:journal_accounting_statement", args=[self.journal.id])
)
self.assertContains(
response,
"""
<tr>
<td>443 - Crédit - Ce code n&#39;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>""",
)

154
accounting/urls.py Normal file
View File

@ -0,0 +1,154 @@
# -*- coding:utf-8 -*
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.urls import re_path
from accounting.views import *
urlpatterns = [
# Accounting types
re_path(
r"^simple_type$",
SimplifiedAccountingTypeListView.as_view(),
name="simple_type_list",
),
re_path(
r"^simple_type/create$",
SimplifiedAccountingTypeCreateView.as_view(),
name="simple_type_new",
),
re_path(
r"^simple_type/(?P<type_id>[0-9]+)/edit$",
SimplifiedAccountingTypeEditView.as_view(),
name="simple_type_edit",
),
# Accounting types
re_path(r"^type$", AccountingTypeListView.as_view(), name="type_list"),
re_path(r"^type/create$", AccountingTypeCreateView.as_view(), name="type_new"),
re_path(
r"^type/(?P<type_id>[0-9]+)/edit$",
AccountingTypeEditView.as_view(),
name="type_edit",
),
# Bank accounts
re_path(r"^$", BankAccountListView.as_view(), name="bank_list"),
re_path(r"^bank/create$", BankAccountCreateView.as_view(), name="bank_new"),
re_path(
r"^bank/(?P<b_account_id>[0-9]+)$",
BankAccountDetailView.as_view(),
name="bank_details",
),
re_path(
r"^bank/(?P<b_account_id>[0-9]+)/edit$",
BankAccountEditView.as_view(),
name="bank_edit",
),
re_path(
r"^bank/(?P<b_account_id>[0-9]+)/delete$",
BankAccountDeleteView.as_view(),
name="bank_delete",
),
# Club accounts
re_path(r"^club/create$", ClubAccountCreateView.as_view(), name="club_new"),
re_path(
r"^club/(?P<c_account_id>[0-9]+)$",
ClubAccountDetailView.as_view(),
name="club_details",
),
re_path(
r"^club/(?P<c_account_id>[0-9]+)/edit$",
ClubAccountEditView.as_view(),
name="club_edit",
),
re_path(
r"^club/(?P<c_account_id>[0-9]+)/delete$",
ClubAccountDeleteView.as_view(),
name="club_delete",
),
# Journals
re_path(r"^journal/create$", JournalCreateView.as_view(), name="journal_new"),
re_path(
r"^journal/(?P<j_id>[0-9]+)$",
JournalDetailView.as_view(),
name="journal_details",
),
re_path(
r"^journal/(?P<j_id>[0-9]+)/edit$",
JournalEditView.as_view(),
name="journal_edit",
),
re_path(
r"^journal/(?P<j_id>[0-9]+)/delete$",
JournalDeleteView.as_view(),
name="journal_delete",
),
re_path(
r"^journal/(?P<j_id>[0-9]+)/statement/nature$",
JournalNatureStatementView.as_view(),
name="journal_nature_statement",
),
re_path(
r"^journal/(?P<j_id>[0-9]+)/statement/person$",
JournalPersonStatementView.as_view(),
name="journal_person_statement",
),
re_path(
r"^journal/(?P<j_id>[0-9]+)/statement/accounting$",
JournalAccountingStatementView.as_view(),
name="journal_accounting_statement",
),
# Operations
re_path(
r"^operation/create/(?P<j_id>[0-9]+)$",
OperationCreateView.as_view(),
name="op_new",
),
re_path(
r"^operation/(?P<op_id>[0-9]+)$", OperationEditView.as_view(), name="op_edit"
),
re_path(
r"^operation/(?P<op_id>[0-9]+)/pdf$", OperationPDFView.as_view(), name="op_pdf"
),
# Companies
re_path(r"^company/list$", CompanyListView.as_view(), name="co_list"),
re_path(r"^company/create$", CompanyCreateView.as_view(), name="co_new"),
re_path(r"^company/(?P<co_id>[0-9]+)$", CompanyEditView.as_view(), name="co_edit"),
# Labels
re_path(r"^label/new$", LabelCreateView.as_view(), name="label_new"),
re_path(
r"^label/(?P<clubaccount_id>[0-9]+)$",
LabelListView.as_view(),
name="label_list",
),
re_path(
r"^label/(?P<label_id>[0-9]+)/edit$", LabelEditView.as_view(), name="label_edit"
),
re_path(
r"^label/(?P<label_id>[0-9]+)/delete$",
LabelDeleteView.as_view(),
name="label_delete",
),
# User account
re_path(r"^refound/account$", RefoundAccountView.as_view(), name="refound_account"),
]

942
accounting/views.py Normal file
View File

@ -0,0 +1,942 @@
# -*- coding:utf-8 -*
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.views.generic import ListView, DetailView
from django.views.generic.edit import UpdateView, CreateView, DeleteView, FormView
from django.urls import reverse_lazy, reverse
from django.utils.translation import gettext_lazy as _
from django.forms.models import modelform_factory
from django.core.exceptions import PermissionDenied, ValidationError
from django.forms import HiddenInput
from django.db import transaction
from django.db.models import Sum
from django.conf import settings
from django import forms
from django.http import HttpResponse
import collections
from ajax_select.fields import AutoCompleteSelectField
from core.views import (
CanViewMixin,
CanEditMixin,
CanEditPropMixin,
CanCreateMixin,
TabedViewMixin,
)
from core.views.forms import SelectFile, SelectDate
from accounting.models import (
BankAccount,
ClubAccount,
GeneralJournal,
Operation,
AccountingType,
Company,
SimplifiedAccountingType,
Label,
)
from counter.models import Counter, Selling, Product
# 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.pdfgen import canvas
from reportlab.lib.units import cm
from reportlab.platypus import Table, TableStyle
from reportlab.lib import colors
from reportlab.lib.pagesizes import letter
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfbase import pdfmetrics
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(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()

View File

@ -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",)

View File

@ -1,7 +0,0 @@
from django.apps import AppConfig
class AntispamConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
verbose_name = "antispam"
name = "antispam"

View File

@ -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]

View File

@ -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)

View File

@ -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",
),
),
],
),
]

View File

@ -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

23
api/__init__.py Normal file
View File

@ -0,0 +1,23 @@
# -*- coding:utf-8 -*
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#

View File

@ -1,6 +1,7 @@
# -*- coding:utf-8 -*
#
# Copyright 2023
# - Skia <skia@hya.sk>
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
@ -21,9 +22,6 @@
#
#
from django.apps import AppConfig
from django.contrib import admin
class GalaxyConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "galaxy"
# Register your models here.

View File

@ -1,6 +1,7 @@
# -*- coding:utf-8 -*
#
# Copyright 2023
# - Skia <skia@hya.sk>
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
@ -21,11 +22,6 @@
#
#
from django.urls import path
from django.db import models
from galaxy.views import GalaxyDataView, GalaxyUserView
urlpatterns = [
path("<int:user_id>/", GalaxyUserView.as_view(), name="user"),
path("data.json", GalaxyDataView.as_view(), name="data"),
]
# Create your models here.

27
api/tests.py Normal file
View File

@ -0,0 +1,27 @@
# -*- coding:utf-8 -*
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.test import TestCase
# Create your tests here.

58
api/urls.py Normal file
View File

@ -0,0 +1,58 @@
# -*- coding:utf-8 -*
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.urls import re_path, path, include
from api.views import *
from rest_framework import routers
# 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"),
]

81
api/views/__init__.py Normal file
View File

@ -0,0 +1,81 @@
# -*- coding:utf-8 -*
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from rest_framework.response import Response
from rest_framework import viewsets
from django.core.exceptions import PermissionDenied
from rest_framework.decorators import action
from django.db.models.query import QuerySet
from core.views import can_view, can_edit
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 .counter import *
from .user import *
from .club import *
from .group import *
from .launderette import *
from .uv import *
from .sas import *

42
api/views/api.py Normal file
View File

@ -0,0 +1,42 @@
# -*- coding:utf-8 -*
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from rest_framework.response import Response
from rest_framework.decorators import api_view, renderer_classes
from rest_framework.renderers import StaticHTMLRenderer
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)

64
api/views/club.py Normal file
View File

@ -0,0 +1,64 @@
# -*- coding:utf-8 -*
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from rest_framework.response import Response
from rest_framework import serializers
from rest_framework.decorators import api_view, renderer_classes
from rest_framework.renderers import StaticHTMLRenderer
from django.conf import settings
from django.core.exceptions import PermissionDenied
from club.models import Club, Mailing
from api.views import RightModelViewSet
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)

61
api/views/counter.py Normal file
View File

@ -0,0 +1,61 @@
# -*- coding:utf-8 -*
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from rest_framework import serializers
from rest_framework.response import Response
from rest_framework.decorators import action
from counter.models import Counter
from api.views import RightModelViewSet
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)

View File

@ -1,6 +1,7 @@
# -*- coding:utf-8 -*
#
# Copyright 2022
# - Maréchal <thgirod@hotmail.com
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
@ -18,20 +19,25 @@
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from rest_framework import serializers
from core.models import RealGroup
from api.views import RightModelViewSet
class PaymentResultConverter:
"""Converter used for url mapping of the `eboutic.views.payment_result` view.
class GroupSerializer(serializers.ModelSerializer):
class Meta:
model = RealGroup
It's meant to build an url that can match
either `/eboutic/pay/success/` or `/eboutic/pay/failure/`
but nothing else.
class GroupViewSet(RightModelViewSet):
"""
Manage Groups (api/v1/group/)
"""
regex = "(success|failure)"
def to_python(self, value):
return str(value)
def to_url(self, value):
return str(value)
serializer_class = GroupSerializer
queryset = RealGroup.objects.all()

137
api/views/launderette.py Normal file
View File

@ -0,0 +1,137 @@
# -*- coding:utf-8 -*
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from rest_framework import serializers
from rest_framework.response import Response
from rest_framework.decorators import action
from launderette.models import Launderette, Machine, Token
from api.views import RightModelViewSet
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)

42
api/views/sas.py Normal file
View File

@ -0,0 +1,42 @@
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.views import can_edit
from core.models import User
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)
]
)

68
api/views/user.py Normal file
View File

@ -0,0 +1,68 @@
# -*- coding:utf-8 -*
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import datetime
from rest_framework import serializers
from rest_framework.response import Response
from rest_framework.decorators import action
from core.models import User
from api.views import RightModelViewSet
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)

127
api/views/uv.py Normal file
View File

@ -0,0 +1,127 @@
from rest_framework.response import Response
from rest_framework.decorators import api_view, renderer_classes
from rest_framework.renderers import JSONRenderer
from django.core.exceptions import PermissionDenied
from django.conf import settings
from rest_framework import serializers
import urllib.request
import json
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

View File

@ -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"]
}
}

View File

@ -1,14 +1,23 @@
# -*- coding:utf-8 -*
#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith
# 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.
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
# 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.
#
#

View File

@ -1,42 +1,31 @@
# -*- coding:utf-8 -*
#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith
# 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.
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.contrib import admin
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",
)
@admin.register(Membership)
class MembershipAdmin(admin.ModelAdmin):
list_display = ("user", "club", "role", "start_date", "end_date")
search_fields = (
"user__username",
"user__first_name",
"user__last_name",
"club__name",
)
autocomplete_fields = ("user",)
admin.site.register(Club)
admin.site.register(Membership)

View File

@ -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()

View File

@ -1,3 +1,4 @@
# -*- coding:utf-8 -*
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
@ -22,53 +23,48 @@
#
#
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 import forms
from django.utils.translation import gettext_lazy as _
from club.models import Club, Mailing, MailingSubscription, Membership
from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField
from club.models import Mailing, MailingSubscription, Club, 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 counter.models import Counter
from core.views.forms import TzAwareDateTimeField
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 +109,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 +136,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 +159,16 @@ 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 +182,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 +201,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
@ -233,7 +230,9 @@ class ClubMemberForm(forms.Form):
id__in=[
ms.user.id
for ms in self.club_members
if ms.can_be_edited_by(self.request_user)
if ms.can_be_edited_by(
self.request_user, self.request_user_membership
)
]
).all(),
label=_("Mark as old"),
@ -244,13 +243,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 +267,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

View File

@ -1,11 +1,13 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = []
operations = [

View File

@ -1,11 +1,13 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.conf import settings
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("club", "0001_initial"),

View File

@ -1,9 +1,11 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("club", "0002_auto_20160824_2152")]
operations = [

View File

@ -1,11 +1,13 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.conf import settings
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [("club", "0003_auto_20160902_2042")]
operations = [

View File

@ -1,10 +1,12 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [("club", "0004_auto_20160915_1057")]
operations = [

View File

@ -1,10 +1,12 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import django.utils.timezone
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [("club", "0005_auto_20161120_1149")]
operations = [

View File

@ -1,9 +1,11 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("club", "0006_auto_20161229_0040")]
operations = [

View File

@ -1,9 +1,11 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("club", "0007_auto_20170324_0917")]
operations = [

View File

@ -1,14 +1,15 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from django.conf import settings
import re
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("club", "0008_auto_20170515_2214"),
@ -109,6 +110,6 @@ class Migration(migrations.Migration):
),
migrations.AlterUniqueTogether(
name="mailingsubscription",
unique_together={("user", "email", "mailing")},
unique_together=set([("user", "email", "mailing")]),
),
]

View File

@ -1,10 +1,25 @@
# -*- 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
import django.db.models.deletion
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")]
operations = [
@ -35,4 +50,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"
),
]

View File

@ -1,9 +1,11 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("club", "0009_auto_20170822_2232")]
operations = [

View File

@ -1,11 +1,13 @@
# -*- 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
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [("club", "0010_auto_20170912_2028")]
operations = [
@ -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",
),

View File

@ -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
),
]

View File

@ -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",
),
),
]

View File

@ -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",
),
),
]

View File

@ -1,3 +1,4 @@
# -*- coding:utf-8 -*
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
@ -21,58 +22,72 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from __future__ import annotations
from typing import Iterable, Self
from django.db import models
from django.core import validators
from django.conf import settings
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.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError, ObjectDoesNotExist
from django.db import transaction
from django.urls import reverse
from django.utils import timezone
from django.core.validators import RegexValidator, validate_email
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 User, MetaGroup, Group, SithFile, RealGroup, Notification, Page
# 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."
),
)
],
error_messages={"unique": _("A club with that unix name already exists.")},
)
logo = ResizedImageField(
upload_to="club_logos",
verbose_name=_("logo"),
null=True,
blank=True,
force_format="WEBP",
height=200,
width=200,
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 +97,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,166 +122,137 @@ 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, new_name):
c = Club.objects.filter(unix_name=new_name).first()
if c is None:
if self.home:
self.home.name = new_name
self.home.save()
else:
raise ValidationError(_("A club with that unix_name already exists"))
def make_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)
p.save(force_lock=True)
p.view_groups.add(public)
if self.parent and self.parent.page_id:
p.parent_id = self.parent.page_id
self.page = p
return
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:
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)
p.save(force_lock=True)
if self.parent and self.parent.page:
p.parent = self.parent.page
self.page = p
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()
self.page.parent = self.parent.page
self.page.save(force_lock=True)
self.page.save(force_lock=True)
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
# 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)
def save(self, *args, **kwargs):
with transaction.atomic():
creation = False
old = Club.objects.filter(id=self.id).first()
if not old:
creation = True
else:
if 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()
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."""
if user.is_anonymous:
return False
return user.is_root or user.is_board_member
def get_absolute_url(self):
return reverse("club:club_view", kwargs={"club_id": self.id})
def get_full_logo_url(self) -> str:
return f"https://{settings.SITH_URL}{self.logo.url}"
def get_display_name(self):
return self.name
def can_be_edited_by(self, user: User) -> bool:
"""Method to see if that object can be edited by the given user."""
def is_owned_by(self, user):
"""
Method to see if that object can be super edited by the given user
"""
return user.is_in_group(settings.SITH_MAIN_BOARD_GROUP)
def get_full_logo_url(self):
return "https://%s%s" % (settings.SITH_URL, self.logo.url)
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 get_membership_for(self, user: User) -> Membership | None:
"""Return the current membership the given user.
Note:
The result is cached.
def can_be_viewed_by(self, user):
"""
if user.is_anonymous:
return None
membership = cache.get(f"membership_{self.id}_{user.id}")
if membership == "not_member":
return None
if membership is None:
membership = self.members.filter(user=user, end_date=None).first()
if membership is None:
cache.set(f"membership_{self.id}_{user.id}", "not_member")
else:
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)
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 board(self) -> Self:
"""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.
If you want to get the users who are currently in the board,
mind combining this with the :meth:`ongoing` queryset method
Method to see if that object can be seen by the given user
"""
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
sub = User.objects.filter(pk=user.pk).first()
if sub is None:
return False
return sub.was_subscribed
def update(self, **kwargs) -> int:
"""Refresh the cache and edit group ownership.
_memberships = {}
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.
def get_membership_for(self, user):
"""
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.
Returns the current membership the given user
"""
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
}
)
return nb_rows, rows_counts
try:
return Club._memberships[self.id][user.id]
except:
m = self.members.filter(user=user.id).filter(end_date=None).first()
try:
Club._memberships[self.id][user.id] = m
except:
Club._memberships[self.id] = {}
Club._memberships[self.id][user.id] = m
return m
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 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
@ -342,144 +289,44 @@ class Membership(models.Model):
_("description"), max_length=128, null=False, blank=True
)
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
"""
return user.is_in_group(settings.SITH_MAIN_BOARD_GROUP)
def can_be_edited_by(self, user, membership=None):
"""
Method to see if that object can be edited by the given user
"""
if user.memberships:
if membership: # This is for optimisation purpose
ms = membership
else:
ms = user.memberships.filter(club=self.club, end_date=None).first()
return (ms and ms.role >= self.role) or user.is_in_group(
settings.SITH_MAIN_BOARD_GROUP
)
return user.is_in_group(settings.SITH_MAIN_BOARD_GROUP)
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 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
)
return reverse("club:club_members", kwargs={"club_id": self.club.id})
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 +359,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,19 +366,21 @@ class Mailing(models.Model):
self.is_moderated = True
else:
self.moderator = None
super().clean()
super(Mailing, self).clean()
@property
def email_full(self):
return self.email + "@" + settings.SITH_MAILING_DOMAIN
def can_moderate(self, user):
return user.is_root or user.is_com_admin
return user.is_root or user.is_in_group(settings.SITH_GROUP_COM_ADMIN_ID)
def is_owned_by(self, user):
if user.is_anonymous:
return False
return user.is_root or user.is_com_admin
return (
user.is_in_group(self)
or user.is_root
or user.is_in_group(settings.SITH_GROUP_COM_ADMIN_ID)
)
def can_view(self, user):
return self.club.has_rights_in_club(user)
@ -558,17 +388,42 @@ class Mailing(models.Model):
def can_be_edited_by(self, user):
return self.club.has_rights_in_club(user)
def delete(self, *args, **kwargs):
self.subscriptions.all().delete()
super().delete()
def delete(self):
for sub in self.subscriptions.all():
sub.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 +446,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,15 +460,13 @@ class MailingSubscription(models.Model):
)
except ObjectDoesNotExist:
pass
super().clean()
super(MailingSubscription, self).clean()
def is_owned_by(self, user):
if user.is_anonymous:
return False
return (
self.mailing.club.has_rights_in_club(user)
or user.is_root
or self.user.is_com_admin
or self.user.is_in_group(settings.SITH_GROUP_COM_ADMIN_ID)
)
def can_be_edited_by(self, user):
@ -636,3 +486,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)

View File

@ -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()

View File

@ -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>`;
}
}

View File

@ -2,16 +2,16 @@
{% from 'core/macros.jinja' import user_profile_link %}
{% block content %}
<div id="club_detail">
{% if club.logo %}
<div class="club_logo"><img src="{{ club.logo.url }}" alt="{{ club.name }}"></div>
{% endif %}
{% if page_revision %}
{{ page_revision|markdown }}
{% else %}
<h3>{% trans %}Club{% endtrans %}</h3>
{% endif %}
</div>
<div id="club_detail">
{% if club.logo %}
<div class="club_logo"><img src="{{ club.logo.url }}" alt="{{ club.unix_name }}"></div>
{% endif %}
{% if page_revision %}
{{ page_revision|markdown }}
{% else %}
<h3>{% trans %}Club{% endtrans %}</h3>
{% endif %}
</div>
{% endblock %}

View File

@ -1,48 +1,48 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Club list{% endtrans %}
{% trans %}Club list{% endtrans %}
{% endblock %}
{% macro display_club(club) -%}
{% if club.is_active or user.is_root %}
{% if club.is_active or user.is_root %}
<li><a href="{{ url('club:club_view', club_id=club.id) }}">{{ club.name }}</a>
{% if not club.is_active %}
({% trans %}inactive{% endtrans %})
{% endif %}
<li><a href="{{ url('club:club_view', club_id=club.id) }}">{{ club.name }}</a>
{% if club.president %} - <a href="{{ url('core:user_profile', user_id=club.president.user.id) }}">{{ club.president.user }}</a>{% endif %}
{% if club.short_description %}<p>{{ club.short_description|markdown }}</p>{% endif %}
{% endif %}
{% if not club.is_active %}
({% trans %}inactive{% endtrans %})
{% endif %}
{% if club.president %} - <a href="{{ url('core:user_profile', user_id=club.president.user.id) }}">{{ club.president.user }}</a>{% endif %}
{% if club.short_description %}<p>{{ club.short_description|markdown }}</p>{% endif %}
{% endif %}
{%- if club.children.all()|length != 0 %}
<ul>
{%- for c in club.children.order_by('name') %}
{{ display_club(c) }}
{%- endfor %}
</ul>
{%- endif -%}
</li>
{%- if club.children.all()|length != 0 %}
<ul>
{%- for c in club.children.order_by('name') %}
{{ display_club(c) }}
{%- endfor %}
</ul>
{%- endif -%}
</li>
{%- endmacro %}
{% block content %}
{% if user.is_root %}
{% if user.is_root %}
<p><a href="{{ url('club:club_new') }}">{% trans %}New club{% endtrans %}</a></p>
{% endif %}
{% if club_list %}
{% endif %}
{% if club_list %}
<h3>{% trans %}Club list{% endtrans %}</h3>
<ul>
{%- for c in club_list.all().order_by('name') if c.parent is none %}
{%- for c in club_list.all().order_by('name') if c.parent is none %}
{{ display_club(c) }}
{%- endfor %}
{%- endfor %}
</ul>
{% else %}
{% else %}
{% trans %}There is no club in this website.{% endtrans %}
{% endif %}
{% endif %}
{% endblock %}

View File

@ -2,81 +2,79 @@
{% from 'core/macros.jinja' import user_profile_link, select_all_checkbox %}
{% block content %}
<h2>{% trans %}Club members{% endtrans %}</h2>
{% if members %}
<h2>{% trans %}Club members{% endtrans %}</h2>
{% if members %}
<form action="{{ url('club:club_members', club_id=club.id) }}" id="users_old" method="post">
{% csrf_token %}
{% set users_old = dict(form.users_old | groupby("choice_label")) %}
{% if users_old %}
{{ select_all_checkbox("users_old") }}
<p></p>
{% endif %}
<table id="club_members_table">
<thead>
<tr>
<td>{% trans %}User{% endtrans %}</td>
<td>{% trans %}Role{% endtrans %}</td>
<td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}Since{% endtrans %}</td>
{% if users_old %}
<td>{% trans %}Mark as old{% endtrans %}</td>
{% endif %}
</tr>
</thead>
<tbody>
{% for m in members %}
<tr>
<td>{{ user_profile_link(m.user) }}</td>
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
<td>{{ m.description }}</td>
<td>{{ m.start_date }}</td>
{% if users_old %}
<td>
{% set user_old = users_old[m.user.get_display_name()] %}
{% if user_old %}
{{ user_old[0].tag() }}
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{{ form.users_old.errors }}
{% if users_old %}
<p></p>
<input type="submit" name="submit" value="{% trans %}Mark as old{% endtrans %}">
{% endif %}
{% csrf_token %}
{% set users_old = dict(form.users_old | groupby("choice_label")) %}
{% if users_old %}
{{ select_all_checkbox("users_old") }}
<p></p>
{% endif %}
<table>
<thead>
<td>{% trans %}User{% endtrans %}</td>
<td>{% trans %}Role{% endtrans %}</td>
<td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}Since{% endtrans %}</td>
{% if users_old %}
<td>{% trans %}Mark as old{% endtrans %}</td>
{% endif %}
</thead>
<tbody>
{% for m in members %}
<tr>
<td>{{ user_profile_link(m.user) }}</td>
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
<td>{{ m.description }}</td>
<td>{{ m.start_date }}</td>
{% if users_old %}
<td>
{% set user_old = users_old[m.user.get_display_name()] %}
{% if user_old %}
{{ user_old[0].tag() }}
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{{ form.users_old.errors }}
{% if users_old %}
<p></p>
<input type="submit" name="submit" value="{% trans %}Mark as old{% endtrans %}">
{% endif %}
</form>
{% else %}
{% else %}
<p>{% trans %}There are no members in this club.{% endtrans %}</p>
{% endif %}
<form action="{{ url('club:club_members', club_id=club.id) }}" id="add_users" method="post">
{% csrf_token %}
{{ form.non_field_errors() }}
<p>
{{ form.users.errors }}
<label for="{{ form.users.id_for_label }}">{{ form.users.label }} :</label>
{{ form.users }}
<span class="helptext">{{ form.users.help_text }}</span>
</p>
<p>
{{ form.role.errors }}
<label for="{{ form.role.id_for_label }}">{{ form.role.label }} :</label>
{{ form.role }}
</p>
{% if form.start_date %}
<p>
{{ form.start_date.errors }}
<label for="{{ form.start_date.id_for_label }}">{{ form.start_date.label }} :</label>
{{ form.start_date }}
</p>
{% endif %}
<p>
{{ form.description.errors }}
<label for="{{ form.description.id_for_label }}">{{ form.description.label }} :</label>
{{ form.description }}
</p>
<p><input type="submit" value="{% trans %}Add{% endtrans %}" /></p>
</form>
<form action="{{ url('club:club_members', club_id=club.id) }}" id="add_users" method="post">
{% csrf_token %}
{{ form.non_field_errors() }}
<p>
{{ form.users.errors }}
<label for="{{ form.users.id_for_label }}">{{ form.users.label }} :</label>
{{ form.users }}
<span class="helptext">{{ form.users.help_text }}</span>
</p>
<p>
{{ form.role.errors }}
<label for="{{ form.role.id_for_label }}">{{ form.role.label }} :</label>
{{ form.role }}
</p>
{% if form.start_date %}
<p>
{{ form.start_date.errors }}
<label for="{{ form.start_date.id_for_label }}">{{ form.start_date.label }} :</label>
{{ form.start_date }}
</p>
{% endif %}
<p>
{{ form.description.errors }}
<label for="{{ form.description.id_for_label }}">{{ form.description.label }} :</label>
{{ form.description }}
</p>
<p><input type="submit" value="{% trans %}Add{% endtrans %}" /></p>
</form>
{% endblock %}

View File

@ -2,27 +2,27 @@
{% from 'core/macros.jinja' import user_profile_link %}
{% block content %}
<h2>{% trans %}Club old members{% endtrans %}</h2>
<table>
<thead>
<td>{% trans %}User{% endtrans %}</td>
<td>{% trans %}Role{% endtrans %}</td>
<td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}From{% endtrans %}</td>
<td>{% trans %}To{% endtrans %}</td>
</thead>
<tbody>
{% for m in club.members.exclude(end_date=None).order_by('-role', 'description', '-end_date').all() %}
<tr>
<td>{{ user_profile_link(m.user) }}</td>
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
<td>{{ m.description }}</td>
<td>{{ m.start_date }}</td>
<td>{{ m.end_date }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h2>{% trans %}Club old members{% endtrans %}</h2>
<table>
<thead>
<td>{% trans %}User{% endtrans %}</td>
<td>{% trans %}Role{% endtrans %}</td>
<td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}From{% endtrans %}</td>
<td>{% trans %}To{% endtrans %}</td>
</thead>
<tbody>
{% for m in club.members.exclude(end_date=None).order_by('-role', 'description', '-end_date').all() %}
<tr>
<td>{{ user_profile_link(m.user) }}</td>
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
<td>{{ m.description }}</td>
<td>{{ m.start_date }}</td>
<td>{{ m.end_date }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@ -1,94 +1,66 @@
{% 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 %}Sellings{% 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>
<td>{% trans %}Counter{% endtrans %}</td>
<td>{% trans %}Barman{% endtrans %}</td>
<td>{% trans %}Customer{% endtrans %}</td>
<td>{% trans %}Label{% endtrans %}</td>
<td>{% trans %}Quantity{% endtrans %}</td>
<td>{% trans %}Total{% endtrans %}</td>
<td>{% trans %}Payment method{% endtrans %}</td>
</tr>
<tr>
<td>{% trans %}Date{% endtrans %}</td>
<td>{% trans %}Counter{% endtrans %}</td>
<td>{% trans %}Barman{% endtrans %}</td>
<td>{% trans %}Customer{% endtrans %}</td>
<td>{% trans %}Label{% endtrans %}</td>
<td>{% trans %}Quantity{% endtrans %}</td>
<td>{% trans %}Total{% endtrans %}</td>
<td>{% trans %}Payment method{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for s in paginated_result %}
{% for s in paginated_result %}
<tr>
<td>{{ s.date|localtime|date(DATETIME_FORMAT) }} {{ s.date|localtime|time(DATETIME_FORMAT) }}</td>
<td>{{ s.counter }}</td>
{% if s.seller %}
<td>{{ s.date|localtime|date(DATETIME_FORMAT) }} {{ s.date|localtime|time(DATETIME_FORMAT) }}</td>
<td>{{ s.counter }}</td>
{% if s.seller %}
<td><a href="{{ s.seller.get_absolute_url() }}">{{ s.seller.get_display_name() }}</a></td>
{% else %}
{% else %}
<td></td>
{% endif %}
{% if s.customer %}
{% endif %}
{% if s.customer %}
<td><a href="{{ s.customer.user.get_absolute_url() }}">{{ s.customer.user.get_display_name() }}</a></td>
{% else %}
{% else %}
<td></td>
{% endif %}
<td>{{ s.label }}</td>
<td>{{ s.quantity }}</td>
<td>{{ s.quantity * s.unit_price }} €</td>
<td>{{ s.get_payment_method_display() }}</td>
{% if s.is_owned_by(user) %}
<td><a href="{{ url('counter:selling_delete', selling_id=s.id) }}">{% trans %}Delete{% endtrans %}</a></td>
{% endif %}
{% endif %}
<td>{{ s.label }}</td>
<td>{{ s.quantity }}</td>
<td>{{ s.quantity * s.unit_price }} €</td>
<td>{{ s.get_payment_method_display() }}</td>
{% if s.is_owned_by(user) %}
<td><a href="{{ url('counter:selling_delete', selling_id=s.id) }}">{% trans %}Delete{% endtrans %}</a></td>
{% endif %}
</tr>
{% endfor %}
{% 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();
$("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 %}

View File

@ -1,29 +1,46 @@
{% 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>
<li> <a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">{% trans %}Post in the Weekmail{% endtrans %}</a></li>
{% if object.trombi %}
<li> <a href="{{ url('com:news_new') }}?club={{ object.id }}">{% trans %}Create a news{% endtrans %}</a></li>
<li> <a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">{% trans %}Post in the Weekmail{% endtrans %}</a></li>
{% if object.trombi %}
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li>
{% else %}
{% else %}
<li> <a href="{{ url('trombi:create', club_id=object.id) }}">{% trans %}New Trombi{% endtrans %}</a></li>
<li> <a href="{{ url('club:poster_list', club_id=object.id) }}">{% trans %}Posters{% endtrans %}</a></li>
{% endif %}
{% endif %}
</ul>
<h4>{% trans %}Counters:{% endtrans %}</h4>
<ul>
{% for c in object.counters.filter(type="OFFICE") %}
{% 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>
<a href="{{ url('counter:details', counter_id=c.id) }}">View</a>
<a href="{{ url('counter:admin', counter_id=c.id) }}">Edit</a>
</li>
{% endfor %}
{% endfor %}
{% endif %}
</ul>
</div>
{% if object.club_account.exists() %}
<h4>{% trans %}Accouting: {% 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 %}

View File

@ -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 %}

View File

@ -2,107 +2,107 @@
{% from 'core/macros.jinja' import select_all_checkbox %}
{% block title %}
{% trans %}Mailing lists{% endtrans %}
{% trans %}Mailing lists{% endtrans %}
{% endblock %}
{% block content %}
<b>{% trans %}Remember : mailing lists need to be moderated, if your new created list is not shown wait until moderation takes action{% endtrans %}</b>
<b>{% trans %}Remember : mailing lists need to be moderated, if your new created list is not shown wait until moderation takes action{% endtrans %}</b>
{% if mailings_not_moderated %}
<p>{% trans %}Mailing lists waiting for moderation{% endtrans %}</p>
<ul>
{% for mailing in mailings_not_moderated %}
<li>{{ mailing.email_full }}<a href="{{ url('club:mailing_delete', mailing_id=mailing.id) }}"> - {% trans %}Delete{% endtrans %}</a></li>
{% endfor %}
</ul>
{% endif %}
{% if mailings_not_moderated %}
<p>{% trans %}Mailing lists waiting for moderation{% endtrans %}</p>
<ul>
{% for mailing in mailings_not_moderated %}
<li>{{ mailing.email_full }}<a href="{{ url('club:mailing_delete', mailing_id=mailing.id) }}"> - {% trans %}Delete{% endtrans %}</a></li>
{% endfor %}
</ul>
{% endif %}
{% if mailings_moderated %}
{% if mailings_moderated %}
{% for mailing in mailings_moderated %}
<h2>{% trans %}Mailing{% endtrans %} {{ mailing.email_full }}
<h2>{% trans %}Mailing{% endtrans %} {{ mailing.email_full }}
{%- if user.is_owner(mailing) -%}
<a href="{{ url('club:mailing_delete', mailing_id=mailing.id) }}"> - {% trans %}Delete{% endtrans %}</a>
<a href="{{ url('club:mailing_delete', mailing_id=mailing.id) }}"> - {% trans %}Delete{% endtrans %}</a>
{%- endif -%}
</h2>
<form method="GET" action="{{ url('club:mailing_generate', mailing_id=mailing.id) }}" style="display:inline-block;">
</h2>
<form method="GET" action="{{ url('club:mailing_generate', mailing_id=mailing.id) }}" style="display:inline-block;">
<input type="submit" name="generateMalingList" value="{% trans %}Generate mailing list{% endtrans %}">
</form>
{% set form_mailing_removal = form["removal_" + mailing.id|string] %}
{% if form_mailing_removal.field.choices %}
{% set ms = dict(mailing.subscriptions.all() | groupby('id')) %}
<form action="{{ url('club:mailing', club_id=club.id) }}" id="{{ form_mailing_removal.auto_id }}" method="post" enctype="multipart/form-data">
<p style="margin-bottom: 1em;">{{ select_all_checkbox(form_mailing_removal.auto_id) }}</p>
{% csrf_token %}
<input hidden type="number" name="{{ form.action.name }}" value="{{ form_actions.REMOVE_SUBSCRIPTION }}" />
<table>
</form>
{% set form_mailing_removal = form["removal_" + mailing.id|string] %}
{% if form_mailing_removal.field.choices %}
{% set ms = dict(mailing.subscriptions.all() | groupby('id')) %}
<form action="{{ url('club:mailing', club_id=club.id) }}" id="{{ form_mailing_removal.auto_id }}" method="post" enctype="multipart/form-data">
<p style="margin-bottom: 1em;">{{ select_all_checkbox(form_mailing_removal.auto_id) }}</p>
{% csrf_token %}
<input hidden type="number" name="{{ form.action.name }}" value="{{ form_actions.REMOVE_SUBSCRIPTION }}" />
<table>
<thead>
<tr>
<td>{% trans %}User{% endtrans %}</td>
<td>{% trans %}Email{% endtrans %}</td>
<td>{% trans %}Delete{% endtrans %}</td>
</tr>
<tr>
<td>{% trans %}User{% endtrans %}</td>
<td>{% trans %}Email{% endtrans %}</td>
<td>{% trans %}Delete{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for widget in form_mailing_removal.subwidgets %}
{% for widget in form_mailing_removal.subwidgets %}
{% set user = ms[widget.data.value.value][0] %}
<tr>
<td>{{ user.get_username }}</td>
<td>{{ user.get_email }}</td>
<td>{{ widget.tag() }}</td>
<td>{{ user.get_username }}</td>
<td>{{ user.get_email }}</td>
<td>{{ widget.tag() }}</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
{{ form_mailing_removal.errors }}
<p><input type="submit" value="{% trans %}Remove from mailing list{% endtrans %}" /></p>
</form>
</table>
{{ form_mailing_removal.errors }}
<p><input type="submit" value="{% trans %}Remove from mailing list{% endtrans %}" /></p>
</form>
{% else %}
{% else %}
<p><b>{% trans %}There is no subscriber for this mailing list{% endtrans %}</b></p>
{% endif %}
{% endif %}
{% endfor %}
{% else %}
{% else %}
<p>{% trans %}No mailing list existing for this club{% endtrans %}</p>
{% endif %}
{% endif %}
<p>{{ form.non_field_errors() }}</p>
{% if mailings_moderated %}
<h2>{% trans %}New member{% endtrans %}</h2>
<p>{{ form.non_field_errors() }}</p>
{% if mailings_moderated %}
<h2>{% trans %}New member{% endtrans %}</h2>
<form action="{{ url('club:mailing', club_id=club.id) }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<p>
{{ form.subscription_mailing.errors }}
<label for="{{ form.subscription_mailing.id_for_label }}">{{ form.subscription_mailing.label }}</label>
{{ form.subscription_mailing }}
</p>
<p>
{{ form.subscription_users.errors }}
<label for="{{ form.subscription_users.id_for_label }}">{{ form.subscription_users.label }}</label>
{{ form.subscription_users }}
<span class="helptext">{{ form.subscription_users.help_text }}</span>
</p>
<p>
{{ form.subscription_email.errors }}
<label for="{{ form.subscription_email.id_for_label }}">{{ form.subscription_email.label }}</label>
{{ form.subscription_email }}
</p>
<input hidden type="number" name="{{ form.action.name }}" value="{{ form_actions.NEW_SUBSCRIPTION }}" />
<p><input type="submit" value="{% trans %}Add to mailing list{% endtrans %}" /></p>
</form>
{% endif %}
<h2>{% trans %}New mailing{% endtrans %}</h2>
<form action="{{ url('club:mailing', club_id=club.id) }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<p>
{{ form.subscription_mailing.errors }}
<label for="{{ form.subscription_mailing.id_for_label }}">{{ form.subscription_mailing.label }}</label>
{{ form.subscription_mailing }}
</p>
<p>
{{ form.subscription_users.errors }}
<label for="{{ form.subscription_users.id_for_label }}">{{ form.subscription_users.label }}</label>
{{ form.subscription_users }}
<span class="helptext">{{ form.subscription_users.help_text }}</span>
</p>
<p>
{{ form.subscription_email.errors }}
<label for="{{ form.subscription_email.id_for_label }}">{{ form.subscription_email.label }}</label>
{{ form.subscription_email }}
</p>
<input hidden type="number" name="{{ form.action.name }}" value="{{ form_actions.NEW_SUBSCRIPTION }}" />
<p><input type="submit" value="{% trans %}Add to mailing list{% endtrans %}" /></p>
{% csrf_token %}
<p>
{{ form.mailing_email.errors }}
<label for="{{ form.mailing_email.id_for_label }}">{{ form.mailing_email.label }}</label>
{{ form.mailing_email }}
</p>
<input hidden type="number" name="{{ form.action.name }}" value="{{ form_actions.NEW_MALING }}" />
<p><input type="submit" value="{% trans %}Create mailing list{% endtrans %}" /></p>
</form>
{% endif %}
<h2>{% trans %}New mailing{% endtrans %}</h2>
<form action="{{ url('club:mailing', club_id=club.id) }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<p>
{{ form.mailing_email.errors }}
<label for="{{ form.mailing_email.id_for_label }}">{{ form.mailing_email.label }}</label>
{{ form.mailing_email }}
</p>
<input hidden type="number" name="{{ form.action.name }}" value="{{ form_actions.NEW_MALING }}" />
<p><input type="submit" value="{% trans %}Create mailing list{% endtrans %}" /></p>
</form>
{% endblock %}

View File

@ -2,11 +2,11 @@
{% from 'core/macros_pages.jinja' import page_history %}
{% block content %}
{% if club.page %}
{{ page_history(club.page) }}
{% else %}
{% trans %}No page existing for this club{% endtrans %}
{% endif %}
{% if club.page %}
{{ page_history(club.page) }}
{% else %}
{% trans %}No page existing for this club{% endtrans %}
{% endif %}
{% endblock %}

View File

@ -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 %}

View 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 %}

719
club/tests.py Normal file
View File

@ -0,0 +1,719 @@
# -*- coding:utf-8 -*
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.conf import settings
from django.test import TestCase
from django.utils import timezone, html
from django.utils.translation import gettext as _
from django.urls import reverse
from django.core.management import call_command
from django.core.exceptions import ValidationError, NON_FIELD_ERRORS
from core.models import User
from club.models import Club, Membership, Mailing
from club.forms import MailingForm
# Create your tests here.
class ClubTest(TestCase):
def setUp(self):
call_command("populate")
self.skia = User.objects.filter(username="skia").first()
self.rbatsbak = User.objects.filter(username="rbatsbak").first()
self.guy = User.objects.filter(username="guy").first()
self.bdf = Club.objects.filter(unix_name="bdf").first()
def test_create_add_user_to_club_from_root_ok(self):
self.client.login(username="root", password="plop")
self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users": self.skia.id, "start_date": "12/06/2016", "role": 3},
)
response = self.client.get(
reverse("club:club_members", kwargs={"club_id": self.bdf.id})
)
self.assertTrue(response.status_code == 200)
self.assertTrue(
"S&#39; Kia</a></td>\\n <td>Responsable info</td>"
in str(response.content)
)
def test_create_add_multiple_user_to_club_from_root_ok(self):
self.client.login(username="root", password="plop")
self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{
"users": "|%d|%d|" % (self.skia.id, self.rbatsbak.id),
"start_date": "12/06/2016",
"role": 3,
},
)
response = self.client.get(
reverse("club:club_members", kwargs={"club_id": self.bdf.id})
)
self.assertTrue(response.status_code == 200)
content = str(response.content)
self.assertTrue(
"S&#39; Kia</a></td>\\n <td>Responsable info</td>"
in content
)
self.assertTrue(
"Richard Batsbak</a></td>\\n <td>Responsable info</td>"
in content
)
def test_create_add_user_to_club_from_root_fail_not_subscriber(self):
self.client.login(username="root", password="plop")
response = self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users": self.guy.id, "start_date": "12/06/2016", "role": 3},
)
self.assertTrue(response.status_code == 200)
self.assertTrue('<ul class="errorlist"><li>' in str(response.content))
response = self.client.get(
reverse("club:club_members", kwargs={"club_id": self.bdf.id})
)
self.assertFalse(
"Guy Carlier</a></td>\\n <td>Responsable info</td>"
in str(response.content)
)
def test_create_add_user_to_club_from_root_fail_already_in_club(self):
self.client.login(username="root", password="plop")
self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users": self.skia.id, "start_date": "12/06/2016", "role": 3},
)
response = self.client.get(
reverse("club:club_members", kwargs={"club_id": self.bdf.id})
)
self.assertTrue(
"S&#39; Kia</a></td>\\n <td>Responsable info</td>"
in str(response.content)
)
response = self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users": self.skia.id, "start_date": "12/06/2016", "role": 4},
)
self.assertTrue(response.status_code == 200)
self.assertFalse(
"S&#39; Kia</a></td>\\n <td>Secrétaire</td>"
in str(response.content)
)
def test_create_add_user_non_existent_to_club_from_root_fail(self):
self.client.login(username="root", password="plop")
response = self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users": [9999], "start_date": "12/06/2016", "role": 3},
)
self.assertTrue(response.status_code == 200)
content = str(response.content)
self.assertTrue('<ul class="errorlist"><li>' in content)
self.assertFalse("<td>Responsable info</td>" in content)
self.client.login(username="root", password="plop")
response = self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{
"users": "|%d|%d|" % (self.skia.id, 9999),
"start_date": "12/06/2016",
"role": 3,
},
)
self.assertTrue(response.status_code == 200)
content = str(response.content)
self.assertTrue('<ul class="errorlist"><li>' in content)
self.assertFalse("<td>Responsable info</td>" in content)
def test_create_add_user_to_club_from_skia_ok(self):
self.client.login(username="root", password="plop")
self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users": self.skia.id, "start_date": "12/06/2016", "role": 10},
)
self.client.login(username="skia", password="plop")
self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users": self.rbatsbak.id, "start_date": "12/06/2016", "role": 9},
)
response = self.client.get(
reverse("club:club_members", kwargs={"club_id": self.bdf.id})
)
self.assertEqual(response.status_code, 200)
self.assertIn(
"""Richard Batsbak</a></td>\n <td>Vice-Président⸱e</td>""",
response.content.decode(),
)
def test_create_add_user_to_club_from_richard_fail(self):
self.client.login(username="root", password="plop")
self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users": self.rbatsbak.id, "start_date": "12/06/2016", "role": 3},
)
self.client.login(username="rbatsbak", password="plop")
response = self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users": self.skia.id, "start_date": "12/06/2016", "role": 10},
)
self.assertTrue(response.status_code == 200)
self.assertTrue(
"<li>Vous n&#x27;avez pas la permission de faire cela</li>"
in str(response.content)
)
def test_role_required_if_users_specified(self):
self.client.login(username="root", password="plop")
response = self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users": self.rbatsbak.id, "start_date": "12/06/2016"},
)
self.assertTrue(
'<ul class="errorlist"><li>Vous devez choisir un r' in str(response.content)
)
def test_mark_old_user_to_club_from_skia_ok(self):
self.client.login(username="root", password="plop")
self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{
"users": "|%d|%d|" % (self.skia.id, self.rbatsbak.id),
"start_date": "12/06/2016",
"role": 3,
},
)
self.client.login(username="skia", password="plop")
response = self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users_old": self.rbatsbak.id},
)
self.assertTrue(response.status_code == 302)
response = self.client.get(
reverse("club:club_members", kwargs={"club_id": self.bdf.id})
)
self.assertTrue(response.status_code == 200)
content = str(response.content)
self.assertFalse(
"Richard Batsbak</a></td>\\n <td>Responsable info</td>"
in content
)
self.assertTrue(
"S&#39; Kia</a></td>\\n <td>Responsable info</td>"
in content
)
# Skia is board member so he should be able to mark as old even without being in the club
response = self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users_old": self.skia.id},
)
self.client.login(username="root", password="plop")
self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users": self.rbatsbak.id, "start_date": "12/06/2016", "role": 3},
)
self.client.login(username="skia", password="plop")
response = self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users_old": self.rbatsbak.id},
)
self.assertFalse(
"Richard Batsbak</a></td>\\n <td>Responsable info</td>"
in str(response.content)
)
def test_mark_old_multiple_users_from_skia_ok(self):
self.client.login(username="root", password="plop")
self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{
"users": "|%d|%d|" % (self.skia.id, self.rbatsbak.id),
"start_date": "12/06/2016",
"role": 3,
},
)
self.client.login(username="skia", password="plop")
response = self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users_old": [self.rbatsbak.id, self.skia.id]},
)
self.assertTrue(response.status_code == 302)
response = self.client.get(
reverse("club:club_members", kwargs={"club_id": self.bdf.id})
)
self.assertTrue(response.status_code == 200)
content = str(response.content)
self.assertFalse(
"Richard Batsbak</a></td>\\n <td>Responsable info</td>"
in content
)
self.assertFalse(
"S&#39; Kia</a></td>\\n <td>Responsable info</td>"
in content
)
def test_mark_old_user_to_club_from_richard_ok(self):
self.client.login(username="root", password="plop")
self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{
"users": "|%d|%d|" % (self.skia.id, self.rbatsbak.id),
"start_date": "12/06/2016",
"role": 3,
},
)
# Test with equal rights
self.client.login(username="rbatsbak", password="plop")
response = self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users_old": self.skia.id},
)
self.assertTrue(response.status_code == 302)
response = self.client.get(
reverse("club:club_members", kwargs={"club_id": self.bdf.id})
)
self.assertTrue(response.status_code == 200)
content = str(response.content)
self.assertTrue(
"Richard Batsbak</a></td>\\n <td>Responsable info</td>"
in content
)
self.assertFalse(
"S&#39; Kia</a></td>\\n <td>Responsable info</td>"
in content
)
# Test with lower rights
self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users": self.skia.id, "start_date": "12/06/2016", "role": 0},
)
self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users_old": self.skia.id},
)
response = self.client.get(
reverse("club:club_members", kwargs={"club_id": self.bdf.id})
)
self.assertTrue(response.status_code == 200)
content = str(response.content)
self.assertTrue(
"Richard Batsbak</a></td>\\n <td>Responsable info</td>"
in content
)
self.assertFalse(
"S&#39; Kia</a></td>\\n <td>Curieux</td>" in content
)
def test_mark_old_user_to_club_from_richard_fail(self):
self.client.login(username="root", password="plop")
self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users": self.skia.id, "start_date": "12/06/2016", "role": 3},
)
# Test with richard outside of the club
self.client.login(username="rbatsbak", password="plop")
response = self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users_old": self.skia.id},
)
self.assertTrue(response.status_code == 200)
response = self.client.get(
reverse("club:club_members", kwargs={"club_id": self.bdf.id})
)
self.assertTrue(response.status_code == 200)
self.assertTrue(
"S&#39; Kia</a></td>\\n <td>Responsable info</td>"
in str(response.content)
)
# Test with lower rights
self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users": self.rbatsbak.id, "start_date": "12/06/2016", "role": 0},
)
self.client.post(
reverse("club:club_members", kwargs={"club_id": self.bdf.id}),
{"users_old": self.skia.id},
)
response = self.client.get(
reverse("club:club_members", kwargs={"club_id": self.bdf.id})
)
self.assertEqual(response.status_code, 200)
content = response.content.decode()
self.assertIn(
"Richard Batsbak</a></td>\n <td>Curieux⸱euse</td>",
content,
)
self.assertIn(
"S&#39; Kia</a></td>\n <td>Responsable info</td>",
content,
)
class MailingFormTest(TestCase):
"""Perform validation tests for MailingForm"""
def setUp(self):
call_command("populate")
self.skia = User.objects.filter(username="skia").first()
self.rbatsbak = User.objects.filter(username="rbatsbak").first()
self.krophil = User.objects.filter(username="krophil").first()
self.comunity = User.objects.filter(username="comunity").first()
self.bdf = Club.objects.filter(unix_name="bdf").first()
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.login(username="comunity", password="plop")
self.client.post(
reverse("club:mailing", kwargs={"club_id": self.bdf.id}),
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "foyer"},
)
response = self.client.get(
reverse("club:mailing", kwargs={"club_id": self.bdf.id})
)
self.assertContains(response, text="Liste de diffusion foyer@utbm.fr")
# Test with Root
self.client.login(username="root", password="plop")
self.client.post(
reverse("club:mailing", kwargs={"club_id": self.bdf.id}),
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
response = self.client.get(
reverse("club:mailing", kwargs={"club_id": self.bdf.id})
)
self.assertContains(response, text="Liste de diffusion mde@utbm.fr")
def test_mailing_list_add_moderation(self):
self.client.login(username="rbatsbak", password="plop")
self.client.post(
reverse("club:mailing", kwargs={"club_id": self.bdf.id}),
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
response = self.client.get(
reverse("club:mailing", kwargs={"club_id": self.bdf.id})
)
self.assertNotContains(response, text="Liste de diffusion mde@utbm.fr")
self.assertContains(
response, text="<p>Listes de diffusions en attente de modération</p>"
)
self.assertContains(response, "<li>mde@utbm.fr")
def test_mailing_list_forbidden(self):
# With anonymous user
response = self.client.get(
reverse("club:mailing", kwargs={"club_id": self.bdf.id})
)
self.assertContains(response, "", status_code=403)
# With user not in club
self.client.login(username="krophil", password="plop")
response = self.client.get(
reverse("club:mailing", kwargs={"club_id": self.bdf.id})
)
self.assertContains(response, "", status_code=403)
def test_add_new_subscription_fail_not_moderated(self):
self.client.login(username="rbatsbak", password="plop")
self.client.post(
reverse("club:mailing", kwargs={"club_id": self.bdf.id}),
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
self.client.post(
reverse("club:mailing", kwargs={"club_id": self.bdf.id}),
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.skia.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(
reverse("club:mailing", kwargs={"club_id": self.bdf.id})
)
self.assertNotContains(response, "skia@git.an")
def test_add_new_subscription_success(self):
# Prepare mailing list
self.client.login(username="comunity", password="plop")
self.client.post(
reverse("club:mailing", kwargs={"club_id": self.bdf.id}),
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
# Add single user
self.client.post(
reverse("club:mailing", kwargs={"club_id": self.bdf.id}),
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.skia.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(
reverse("club:mailing", kwargs={"club_id": self.bdf.id})
)
self.assertContains(response, "skia@git.an")
# Add multiple users
self.client.post(
reverse("club:mailing", kwargs={"club_id": self.bdf.id}),
{
"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(
reverse("club:mailing", kwargs={"club_id": self.bdf.id})
)
self.assertContains(response, "richard@git.an")
self.assertContains(response, "comunity@git.an")
self.assertContains(response, "skia@git.an")
# Add arbitrary email
self.client.post(
reverse("club:mailing", kwargs={"club_id": self.bdf.id}),
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_email": "arbitrary@git.an",
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(
reverse("club:mailing", kwargs={"club_id": self.bdf.id})
)
self.assertContains(response, "richard@git.an")
self.assertContains(response, "comunity@git.an")
self.assertContains(response, "skia@git.an")
self.assertContains(response, "arbitrary@git.an")
# Add user and arbitrary email
self.client.post(
reverse("club:mailing", kwargs={"club_id": self.bdf.id}),
{
"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(
reverse("club:mailing", kwargs={"club_id": self.bdf.id})
)
self.assertContains(response, "richard@git.an")
self.assertContains(response, "comunity@git.an")
self.assertContains(response, "skia@git.an")
self.assertContains(response, "arbitrary@git.an")
self.assertContains(response, "more.arbitrary@git.an")
self.assertContains(response, "krophil@git.an")
def test_add_new_subscription_fail_form_errors(self):
# Prepare mailing list
self.client.login(username="comunity", password="plop")
self.client.post(
reverse("club:mailing", kwargs={"club_id": self.bdf.id}),
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
# Neither email or email is specified
response = self.client.post(
reverse("club:mailing", kwargs={"club_id": self.bdf.id}),
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
self.assertContains(
response, text=_("You must specify at least an user or an email address")
)
# No mailing specified
response = self.client.post(
reverse("club:mailing", kwargs={"club_id": self.bdf.id}),
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
},
)
self.assertContains(response, text=_("This field is required"))
# One of the selected users doesn't exist
response = self.client.post(
reverse("club:mailing", kwargs={"club_id": self.bdf.id}),
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": "|789|",
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
self.assertContains(
response, text=html.escape(_("One of the selected users doesn't exist"))
)
# An user has no email adress
self.krophil.email = ""
self.krophil.save()
response = self.client.post(
reverse("club:mailing", kwargs={"club_id": self.bdf.id}),
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
self.assertContains(
response,
text=html.escape(
_("One of the selected users doesn't have an email address")
),
)
self.krophil.email = "krophil@git.an"
self.krophil.save()
# An user is added twice
self.client.post(
reverse("club:mailing", kwargs={"club_id": self.bdf.id}),
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.post(
reverse("club:mailing", kwargs={"club_id": self.bdf.id}),
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
self.assertContains(
response,
text=html.escape(_("This email is already suscribed in this mailing")),
)
def test_remove_subscription_success(self):
# Prepare mailing list
self.client.login(username="comunity", password="plop")
self.client.post(
reverse("club:mailing", kwargs={"club_id": self.bdf.id}),
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
mde = Mailing.objects.get(email="mde")
self.client.post(
reverse("club:mailing", kwargs={"club_id": self.bdf.id}),
{
"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(
reverse("club:mailing", kwargs={"club_id": self.bdf.id})
)
self.assertContains(response, "comunity@git.an")
self.assertContains(response, "richard@git.an")
self.assertContains(response, "krophil@git.an")
# Delete one user
self.client.post(
reverse("club:mailing", kwargs={"club_id": self.bdf.id}),
{
"action": MailingForm.ACTION_REMOVE_SUBSCRIPTION,
"removal_%d" % mde.id: mde.subscriptions.get(user=self.krophil).id,
},
)
response = self.client.get(
reverse("club:mailing", kwargs={"club_id": self.bdf.id})
)
self.assertContains(response, "comunity@git.an")
self.assertContains(response, "richard@git.an")
self.assertNotContains(response, "krophil@git.an")
# Delete multiple users
self.client.post(
reverse("club:mailing", kwargs={"club_id": self.bdf.id}),
{
"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(
reverse("club:mailing", kwargs={"club_id": self.bdf.id})
)
self.assertNotContains(response, "comunity@git.an")
self.assertNotContains(response, "richard@git.an")
self.assertNotContains(response, "krophil@git.an")
class ClubSellingViewTest(TestCase):
"""
Perform basics tests to ensure that the page is available
"""
def setUp(self):
call_command("populate")
self.ae = Club.objects.filter(unix_name="ae").first()
def test_page_not_internal_error(self):
"""
Test that the page does not return and internal error
"""
self.client.login(username="skia", password="plop")
response = self.client.get(
reverse("club:club_sellings", kwargs={"club_id": self.ae.id})
)
self.assertFalse(response.status_code == 500)

Some files were not shown because too many files have changed in this diff Show More