mirror of
https://github.com/ae-utbm/sith.git
synced 2026-05-22 17:00:19 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d03fcf6ea | |||
| a6ba65a494 | |||
| c90fcc838e |
@@ -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
|
||||
@@ -1,59 +1,46 @@
|
||||
name: "Setup project"
|
||||
description: "Setup Python and Poetry"
|
||||
inputs:
|
||||
full:
|
||||
description: >
|
||||
If true, do a full setup, else install
|
||||
only python, uv and non-xapian python deps
|
||||
required: false
|
||||
default: "false"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install apt packages
|
||||
if: ${{ inputs.full == 'true' }}
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
|
||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
with:
|
||||
packages: gettext
|
||||
version: 1.0 # increment to reset cache
|
||||
|
||||
- name: Install Redis
|
||||
if: ${{ inputs.full == 'true' }}
|
||||
uses: shogo82148/actions-setup-redis@v1
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
redis-version: "7.x"
|
||||
version: "0.5.14"
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
- name: "Set up Python"
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
version: "0.11.8"
|
||||
enable-cache: false
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
- name: Restore cached virtualenv
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
|
||||
path: .venv
|
||||
key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
|
||||
restore-keys: |
|
||||
uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
|
||||
uv-${{ runner.os }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked
|
||||
run: uv sync
|
||||
shell: bash
|
||||
|
||||
- name: Install Xapian
|
||||
if: ${{ inputs.full == 'true' }}
|
||||
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
|
||||
if: ${{ inputs.full == 'true' }}
|
||||
run: uv run ./manage.py compilemessages
|
||||
shell: bash
|
||||
|
||||
@@ -6,7 +6,7 @@ addAssignees: author
|
||||
|
||||
# A list of team reviewers to be added to pull requests (GitHub team slug)
|
||||
reviewers:
|
||||
- ae-utbm/developpeurs
|
||||
- ae-utbm/sith-3-developers
|
||||
|
||||
# Number of reviewers has no impact on GitHub teams
|
||||
# Set 0 to add all the reviewers (default: 0)
|
||||
|
||||
+4
-21
@@ -4,28 +4,11 @@
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
|
||||
multi-ecosystem-groups:
|
||||
common:
|
||||
directory: "/"
|
||||
updates:
|
||||
- package-ecosystem: "pip" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
target-branch: "taiste"
|
||||
commit-message:
|
||||
prefix: "[UPDATE] "
|
||||
|
||||
updates:
|
||||
- package-ecosystem: "uv"
|
||||
patterns: ["*"]
|
||||
multi-ecosystem-group: "common"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
patterns: ["*"]
|
||||
multi-ecosystem-group: "common"
|
||||
groups:
|
||||
# npm supports production and development groups, but not uv
|
||||
# cf. https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#dependency-type-groups
|
||||
main-deps:
|
||||
dependency-type: "production"
|
||||
dev-deps:
|
||||
dependency-type: "development"
|
||||
prefix: "[UPDATE] "
|
||||
@@ -7,19 +7,13 @@ on:
|
||||
branches: [master, taiste]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
SECRET_KEY: notTheRealOne
|
||||
DATABASE_URL: sqlite:///db.sqlite3
|
||||
CACHE_URL: redis://127.0.0.1:6379/0
|
||||
TASK_BROKER_URL: redis://127.0.0.1:6379/1
|
||||
|
||||
jobs:
|
||||
pre-commit:
|
||||
name: Launch pre-commits checks (ruff)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- uses: pre-commit/action@v3.0.1
|
||||
@@ -32,13 +26,11 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false # don't interrupt the other test processes
|
||||
matrix:
|
||||
pytest-mark: [not slow]
|
||||
pytest-mark: [slow, not slow]
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup_project
|
||||
with:
|
||||
full: true
|
||||
env:
|
||||
# To avoid race conditions on environment cache
|
||||
CACHE_SUFFIX: ${{ matrix.pytest-mark }}
|
||||
@@ -49,7 +41,7 @@ jobs:
|
||||
uv run coverage report
|
||||
uv run coverage html
|
||||
- name: Archive code coverage results
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-report-${{ matrix.pytest-mark }}
|
||||
name: coverage-report
|
||||
path: coverage_report
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: SSH Remote Commands
|
||||
uses: appleboy/ssh-action@v1.2.5
|
||||
uses: appleboy/ssh-action@v1.1.0
|
||||
with:
|
||||
# Proxy
|
||||
proxy_host : ${{secrets.PROXY_HOST}}
|
||||
@@ -29,6 +29,8 @@ jobs:
|
||||
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}}
|
||||
|
||||
@@ -2,17 +2,17 @@ name: deploy_docs
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- taiste
|
||||
- master
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup_project
|
||||
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
key: mkdocs-material-${{ env.cache_id }}
|
||||
path: .cache
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: SSH Remote Commands
|
||||
uses: appleboy/ssh-action@v1.2.5
|
||||
uses: appleboy/ssh-action@v1.1.0
|
||||
with:
|
||||
# Proxy
|
||||
proxy_host : ${{secrets.PROXY_HOST}}
|
||||
@@ -28,6 +28,8 @@ jobs:
|
||||
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}}
|
||||
|
||||
-11
@@ -18,17 +18,6 @@ sith/search_indexes/
|
||||
.coverage
|
||||
coverage_report/
|
||||
node_modules/
|
||||
.env
|
||||
*.pid
|
||||
|
||||
# compiled documentation
|
||||
site/
|
||||
|
||||
# rollup-bundle-visualizer report
|
||||
.bundle-size-report.html
|
||||
|
||||
### Redis ###
|
||||
|
||||
# Ignore redis binary dump (dump.rdb) files
|
||||
|
||||
*.rdb
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.15.13
|
||||
rev: v0.8.3
|
||||
hooks:
|
||||
- id: ruff-check # just check the code, and print the errors
|
||||
- id: ruff-check # actually fix the fixable errors, but print nothing
|
||||
- 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
|
||||
rev: "v0.1.0" # Use the sha / tag you want to point at
|
||||
hooks:
|
||||
- id: biome-check
|
||||
additional_dependencies: ["@biomejs/biome@2.4.6"]
|
||||
additional_dependencies: ["@biomejs/biome@1.9.3"]
|
||||
- repo: https://github.com/rtts/djhtml
|
||||
rev: 3.0.11
|
||||
rev: 3.0.7
|
||||
hooks:
|
||||
- id: djhtml
|
||||
name: format templates
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
redis: redis-server --port $REDIS_PORT
|
||||
celery: uv run celery -A sith worker --beat -l INFO
|
||||
@@ -1 +0,0 @@
|
||||
bundler: npm run serve
|
||||
@@ -0,0 +1,14 @@
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||
#
|
||||
# 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"
|
||||
#
|
||||
#
|
||||
@@ -0,0 +1,36 @@
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||
#
|
||||
# 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"
|
||||
#
|
||||
#
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from accounting.models import (
|
||||
AccountingType,
|
||||
BankAccount,
|
||||
ClubAccount,
|
||||
Company,
|
||||
GeneralJournal,
|
||||
Label,
|
||||
Operation,
|
||||
SimplifiedAccountingType,
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,23 @@
|
||||
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 accounting.models import ClubAccount, Company
|
||||
from accounting.schemas import ClubAccountSchema, CompanySchema
|
||||
from core.api_permissions import CanAccessLookup
|
||||
|
||||
|
||||
@api_controller("/lookup", permissions=[CanAccessLookup])
|
||||
class AccountingController(ControllerBase):
|
||||
@route.get("/club-account", response=PaginatedResponseSchema[ClubAccountSchema])
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
def search_club_account(self, search: Annotated[str, MinLen(1)]):
|
||||
return ClubAccount.objects.filter(name__icontains=search).values()
|
||||
|
||||
@route.get("/company", response=PaginatedResponseSchema[CompanySchema])
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
def search_company(self, search: Annotated[str, MinLen(1)]):
|
||||
return Company.objects.filter(name__icontains=search).values()
|
||||
@@ -0,0 +1,280 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import accounting.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AccountingType",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"code",
|
||||
models.CharField(
|
||||
max_length=16,
|
||||
verbose_name="code",
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
"^[0-9]*$",
|
||||
"An accounting type code contains only numbers",
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
("label", models.CharField(max_length=128, verbose_name="label")),
|
||||
(
|
||||
"movement_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("CREDIT", "Credit"),
|
||||
("DEBIT", "Debit"),
|
||||
("NEUTRAL", "Neutral"),
|
||||
],
|
||||
max_length=12,
|
||||
verbose_name="movement type",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "accounting type",
|
||||
"ordering": ["movement_type", "code"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BankAccount",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=30, verbose_name="name")),
|
||||
(
|
||||
"iban",
|
||||
models.CharField(max_length=255, blank=True, verbose_name="iban"),
|
||||
),
|
||||
(
|
||||
"number",
|
||||
models.CharField(
|
||||
max_length=255, blank=True, verbose_name="account number"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"verbose_name": "Bank account", "ordering": ["club", "name"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ClubAccount",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=30, verbose_name="name")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Club account",
|
||||
"ordering": ["bank_account", "name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Company",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=60, verbose_name="name")),
|
||||
],
|
||||
options={"verbose_name": "company"},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="GeneralJournal",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("start_date", models.DateField(verbose_name="start date")),
|
||||
(
|
||||
"end_date",
|
||||
models.DateField(
|
||||
null=True, verbose_name="end date", default=None, blank=True
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=40, verbose_name="name")),
|
||||
(
|
||||
"closed",
|
||||
models.BooleanField(verbose_name="is closed", default=False),
|
||||
),
|
||||
(
|
||||
"amount",
|
||||
accounting.models.CurrencyField(
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="amount",
|
||||
max_digits=12,
|
||||
),
|
||||
),
|
||||
(
|
||||
"effective_amount",
|
||||
accounting.models.CurrencyField(
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="effective_amount",
|
||||
max_digits=12,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"verbose_name": "General journal", "ordering": ["-start_date"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Operation",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("number", models.IntegerField(verbose_name="number")),
|
||||
(
|
||||
"amount",
|
||||
accounting.models.CurrencyField(
|
||||
decimal_places=2, max_digits=12, verbose_name="amount"
|
||||
),
|
||||
),
|
||||
("date", models.DateField(verbose_name="date")),
|
||||
("remark", models.CharField(max_length=128, verbose_name="comment")),
|
||||
(
|
||||
"mode",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("CHECK", "Check"),
|
||||
("CASH", "Cash"),
|
||||
("TRANSFERT", "Transfert"),
|
||||
("CARD", "Credit card"),
|
||||
],
|
||||
max_length=255,
|
||||
verbose_name="payment method",
|
||||
),
|
||||
),
|
||||
(
|
||||
"cheque_number",
|
||||
models.CharField(
|
||||
max_length=32,
|
||||
null=True,
|
||||
verbose_name="cheque number",
|
||||
default="",
|
||||
blank=True,
|
||||
),
|
||||
),
|
||||
("done", models.BooleanField(verbose_name="is done", default=False)),
|
||||
(
|
||||
"target_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("USER", "User"),
|
||||
("CLUB", "Club"),
|
||||
("ACCOUNT", "Account"),
|
||||
("COMPANY", "Company"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
max_length=10,
|
||||
verbose_name="target type",
|
||||
),
|
||||
),
|
||||
(
|
||||
"target_id",
|
||||
models.IntegerField(
|
||||
null=True, verbose_name="target id", blank=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"target_label",
|
||||
models.CharField(
|
||||
max_length=32,
|
||||
blank=True,
|
||||
verbose_name="target label",
|
||||
default="",
|
||||
),
|
||||
),
|
||||
(
|
||||
"accounting_type",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
related_name="operations",
|
||||
verbose_name="accounting type",
|
||||
to="accounting.AccountingType",
|
||||
blank=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"ordering": ["-number"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SimplifiedAccountingType",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("label", models.CharField(max_length=128, verbose_name="label")),
|
||||
(
|
||||
"accounting_type",
|
||||
models.ForeignKey(
|
||||
verbose_name="simplified accounting types",
|
||||
to="accounting.AccountingType",
|
||||
related_name="simplified_types",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "simplified type",
|
||||
"ordering": ["accounting_type__movement_type", "accounting_type__code"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("club", "0001_initial"),
|
||||
("accounting", "0001_initial"),
|
||||
("core", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="operation",
|
||||
name="invoice",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
related_name="operations",
|
||||
verbose_name="invoice",
|
||||
to="core.SithFile",
|
||||
blank=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="operation",
|
||||
name="journal",
|
||||
field=models.ForeignKey(
|
||||
verbose_name="journal",
|
||||
to="accounting.GeneralJournal",
|
||||
related_name="operations",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="operation",
|
||||
name="linked_operation",
|
||||
field=models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
blank=True,
|
||||
to="accounting.Operation",
|
||||
null=True,
|
||||
related_name="operation_linked_to",
|
||||
verbose_name="linked operation",
|
||||
default=None,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="operation",
|
||||
name="simpleaccounting_type",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
related_name="operations",
|
||||
verbose_name="simple type",
|
||||
to="accounting.SimplifiedAccountingType",
|
||||
blank=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="generaljournal",
|
||||
name="club_account",
|
||||
field=models.ForeignKey(
|
||||
verbose_name="club account",
|
||||
to="accounting.ClubAccount",
|
||||
related_name="journals",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="clubaccount",
|
||||
name="bank_account",
|
||||
field=models.ForeignKey(
|
||||
verbose_name="bank account",
|
||||
to="accounting.BankAccount",
|
||||
related_name="club_accounts",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="clubaccount",
|
||||
name="club",
|
||||
field=models.ForeignKey(
|
||||
verbose_name="club",
|
||||
to="club.Club",
|
||||
related_name="club_account",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="bankaccount",
|
||||
name="club",
|
||||
field=models.ForeignKey(
|
||||
verbose_name="club",
|
||||
to="club.Club",
|
||||
related_name="bank_accounts",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="operation", unique_together={("number", "journal")}
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import phonenumber_field.modelfields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounting", "0002_auto_20160824_2152")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="city",
|
||||
field=models.CharField(blank=True, verbose_name="city", max_length=60),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="country",
|
||||
field=models.CharField(blank=True, verbose_name="country", max_length=32),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="email",
|
||||
field=models.EmailField(blank=True, verbose_name="email", max_length=254),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="phone",
|
||||
field=phonenumber_field.modelfields.PhoneNumberField(
|
||||
blank=True, verbose_name="phone", max_length=128
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="postcode",
|
||||
field=models.CharField(blank=True, verbose_name="postcode", max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="street",
|
||||
field=models.CharField(blank=True, verbose_name="street", max_length=60),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="website",
|
||||
field=models.CharField(blank=True, verbose_name="website", max_length=64),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounting", "0003_auto_20160824_2203")]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Label",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
verbose_name="ID",
|
||||
primary_key=True,
|
||||
auto_created=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=64, verbose_name="label")),
|
||||
(
|
||||
"club_account",
|
||||
models.ForeignKey(
|
||||
related_name="labels",
|
||||
verbose_name="club account",
|
||||
to="accounting.ClubAccount",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="operation",
|
||||
name="label",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="operations",
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="label",
|
||||
to="accounting.Label",
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="label", unique_together={("name", "club_account")}
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
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"
|
||||
),
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,520 @@
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||
#
|
||||
# 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"
|
||||
#
|
||||
#
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import validators
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.template import defaultfilters
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
|
||||
from club.models import Club
|
||||
from core.models import SithFile, User
|
||||
|
||||
|
||||
class CurrencyField(models.DecimalField):
|
||||
"""Custom database field used for currency."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["max_digits"] = 12
|
||||
kwargs["decimal_places"] = 2
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def to_python(self, value):
|
||||
try:
|
||||
return super().to_python(value).quantize(Decimal("0.01"))
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
|
||||
if settings.TESTING:
|
||||
from model_bakery import baker
|
||||
|
||||
baker.generators.add(
|
||||
CurrencyField,
|
||||
lambda: baker.random_gen.gen_decimal(max_digits=8, decimal_places=2),
|
||||
)
|
||||
else: # pragma: no cover
|
||||
# baker is only used in tests, so we don't need coverage for this part
|
||||
pass
|
||||
|
||||
|
||||
# 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 __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("accounting:co_edit", kwargs={"co_id": self.id})
|
||||
|
||||
def get_display_name(self):
|
||||
return self.name
|
||||
|
||||
def is_owned_by(self, user):
|
||||
"""Check if that object can be edited by the given user."""
|
||||
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
"""Check if that object can be edited by the given user."""
|
||||
return user.memberships.filter(
|
||||
end_date=None, club__role=settings.SITH_CLUB_ROLES_ID["Treasurer"]
|
||||
).exists()
|
||||
|
||||
def can_be_viewed_by(self, user):
|
||||
"""Check if that object can be viewed by the given user."""
|
||||
return user.memberships.filter(
|
||||
end_date=None, club__role_gte=settings.SITH_CLUB_ROLES_ID["Treasurer"]
|
||||
).exists()
|
||||
|
||||
|
||||
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 __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("accounting:bank_details", kwargs={"b_account_id": self.id})
|
||||
|
||||
def is_owned_by(self, user):
|
||||
"""Check if that object can be edited by the given user."""
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
m = self.club.get_membership_for(user)
|
||||
return m is not None and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]
|
||||
|
||||
|
||||
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 __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("accounting:club_details", kwargs={"c_account_id": self.id})
|
||||
|
||||
def is_owned_by(self, user):
|
||||
"""Check if that object can be edited by the given user."""
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
"""Check if that object can be edited by the given user."""
|
||||
m = self.club.get_membership_for(user)
|
||||
return m and m.role == settings.SITH_CLUB_ROLES_ID["Treasurer"]
|
||||
|
||||
def can_be_viewed_by(self, user):
|
||||
"""Check if that object can be viewed by the given user."""
|
||||
m = self.club.get_membership_for(user)
|
||||
return m and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]
|
||||
|
||||
def has_open_journal(self):
|
||||
return self.journals.filter(closed=False).exists()
|
||||
|
||||
def get_open_journal(self):
|
||||
return self.journals.filter(closed=False).first()
|
||||
|
||||
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 __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("accounting:journal_details", kwargs={"j_id": self.id})
|
||||
|
||||
def is_owned_by(self, user):
|
||||
"""Check if that object can be edited by the given user."""
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
return self.club_account.can_be_edited_by(user)
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
"""Check if that object can be edited by the given user."""
|
||||
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
return self.club_account.can_be_edited_by(user)
|
||||
|
||||
def can_be_viewed_by(self, user):
|
||||
return self.club_account.can_be_viewed_by(user)
|
||||
|
||||
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 __str__(self):
|
||||
return f"{self.amount} € | {self.date} | {self.accounting_type} | {self.done}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.number is None:
|
||||
self.number = self.journal.operations.count() + 1
|
||||
super().save(*args, **kwargs)
|
||||
self.journal.update_amounts()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("accounting:journal_details", kwargs={"j_id": self.journal.id})
|
||||
|
||||
def __getattribute__(self, attr):
|
||||
if attr == "target":
|
||||
return self.get_target()
|
||||
else:
|
||||
return object.__getattribute__(self, attr)
|
||||
|
||||
def clean(self):
|
||||
super().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 is_owned_by(self, user):
|
||||
"""Check if that object can be edited by the given user."""
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
if self.journal.closed:
|
||||
return False
|
||||
m = self.journal.club_account.club.get_membership_for(user)
|
||||
return m is not None and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
"""Check if that object can be edited by the given user."""
|
||||
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
if self.journal.closed:
|
||||
return False
|
||||
m = self.journal.club_account.club.get_membership_for(user)
|
||||
return m is not None and m.role == settings.SITH_CLUB_ROLES_ID["Treasurer"]
|
||||
|
||||
|
||||
class AccountingType(models.Model):
|
||||
"""Accounting types.
|
||||
|
||||
Those 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 __str__(self):
|
||||
return self.code + " - " + self.get_movement_type_display() + " - " + self.label
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("accounting:type_list")
|
||||
|
||||
def is_owned_by(self, user):
|
||||
"""Check if that object can be edited by the given user."""
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
||||
|
||||
|
||||
class SimplifiedAccountingType(models.Model):
|
||||
"""Simplified version of `AccountingType`."""
|
||||
|
||||
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"]
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"{self.get_movement_type_display()} "
|
||||
f"- {self.accounting_type.code} - {self.label}"
|
||||
)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("accounting:simple_type_list")
|
||||
|
||||
@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()
|
||||
|
||||
|
||||
class Label(models.Model):
|
||||
"""Label allow a club to sort its operations."""
|
||||
|
||||
name = models.CharField(_("label"), max_length=64)
|
||||
club_account = models.ForeignKey(
|
||||
ClubAccount,
|
||||
related_name="labels",
|
||||
verbose_name=_("club account"),
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("name", "club_account")
|
||||
|
||||
def __str__(self):
|
||||
return "%s (%s)" % (self.name, self.club_account.name)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"accounting:label_list", kwargs={"clubaccount_id": self.club_account.id}
|
||||
)
|
||||
|
||||
def is_owned_by(self, user):
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
return self.club_account.is_owned_by(user)
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
return self.club_account.can_be_edited_by(user)
|
||||
|
||||
def can_be_viewed_by(self, user):
|
||||
return self.club_account.can_be_viewed_by(user)
|
||||
@@ -0,0 +1,15 @@
|
||||
from ninja import ModelSchema
|
||||
|
||||
from accounting.models import ClubAccount, Company
|
||||
|
||||
|
||||
class ClubAccountSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = ClubAccount
|
||||
fields = ["id", "name"]
|
||||
|
||||
|
||||
class CompanySchema(ModelSchema):
|
||||
class Meta:
|
||||
model = Company
|
||||
fields = ["id", "name"]
|
||||
@@ -0,0 +1,60 @@
|
||||
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 ClubAccountSchema,
|
||||
type CompanySchema,
|
||||
accountingSearchClubAccount,
|
||||
accountingSearchCompany,
|
||||
} from "#openapi";
|
||||
|
||||
@registerComponent("club-account-ajax-select")
|
||||
export class ClubAccountAjaxSelect extends AjaxSelect {
|
||||
protected valueField = "id";
|
||||
protected labelField = "name";
|
||||
protected searchField = ["code", "name"];
|
||||
|
||||
protected async search(query: string): Promise<TomOption[]> {
|
||||
const resp = await accountingSearchClubAccount({ query: { search: query } });
|
||||
if (resp.data) {
|
||||
return resp.data.results;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
protected renderOption(item: ClubAccountSchema, sanitize: typeof escape_html) {
|
||||
return `<div class="select-item">
|
||||
<span class="select-item-text">${sanitize(item.name)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
protected renderItem(item: ClubAccountSchema, sanitize: typeof escape_html) {
|
||||
return `<span>${sanitize(item.name)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
@registerComponent("company-ajax-select")
|
||||
export class CompanyAjaxSelect extends AjaxSelect {
|
||||
protected valueField = "id";
|
||||
protected labelField = "name";
|
||||
protected searchField = ["code", "name"];
|
||||
|
||||
protected async search(query: string): Promise<TomOption[]> {
|
||||
const resp = await accountingSearchCompany({ query: { search: query } });
|
||||
if (resp.data) {
|
||||
return resp.data.results;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
protected renderOption(item: CompanySchema, sanitize: typeof escape_html) {
|
||||
return `<div class="select-item">
|
||||
<span class="select-item-text">${sanitize(item.name)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
protected renderItem(item: CompanySchema, sanitize: typeof escape_html) {
|
||||
return `<span>${sanitize(item.name)}</span>`;
|
||||
}
|
||||
}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Bank account: {% endtrans %}{{ object.name }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
<p>
|
||||
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
|
||||
{{ object.name }}
|
||||
</p>
|
||||
<hr>
|
||||
<h2>{% trans %}Bank account: {% endtrans %}{{ object.name }}</h2>
|
||||
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) and not object.club_accounts.exists() %}
|
||||
<a href="{{ url('accounting:bank_delete', b_account_id=object.id) }}">{% trans %}Delete{% endtrans %}</a>
|
||||
{% endif %}
|
||||
<h4>{% trans %}Infos{% endtrans %}</h4>
|
||||
<ul>
|
||||
<li><strong>{% trans %}IBAN: {% endtrans %}</strong>{{ object.iban }}</li>
|
||||
<li><strong>{% trans %}Number: {% endtrans %}</strong>{{ object.number }}</li>
|
||||
</ul>
|
||||
<p><a href="{{ url('accounting:club_new') }}?parent={{ object.id }}">{% trans %}New club account{% endtrans %}</a></p>
|
||||
<ul>
|
||||
{% for c in object.club_accounts.all() %}
|
||||
<li><a href="{{ url('accounting:club_details', c_account_id=c.id) }}">{{ c }}</a>
|
||||
- <a href="{{ url('accounting:club_edit', c_account_id=c.id) }}">{% trans %}Edit{% endtrans %}</a>
|
||||
{% if c.journals.count() == 0 %}
|
||||
- <a href="{{ url('accounting:club_delete', c_account_id=c.id) }}">{% trans %}Delete{% endtrans %}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Bank account list{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
<h4>
|
||||
{% trans %}Accounting{% endtrans %}
|
||||
</h4>
|
||||
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
|
||||
<p><a href="{{ url('accounting:simple_type_list') }}">{% trans %}Manage simplified types{% endtrans %}</a></p>
|
||||
<p><a href="{{ url('accounting:type_list') }}">{% trans %}Manage accounting types{% endtrans %}</a></p>
|
||||
<p><a href="{{ url('accounting:bank_new') }}">{% trans %}New bank account{% endtrans %}</a></p>
|
||||
{% endif %}
|
||||
{% if bankaccount_list %}
|
||||
<h3>{% trans %}Bank account list{% endtrans %}</h3>
|
||||
<ul>
|
||||
{% for a in object_list %}
|
||||
<li><a href="{{ url('accounting:bank_details', b_account_id=a.id) }}">{{ a }}</a>
|
||||
- <a href="{{ url('accounting:bank_edit', b_account_id=a.id) }}">{% trans %}Edit{% endtrans %}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{% trans %}There is no accounts in this website.{% endtrans %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Club account:{% endtrans %} {{ object.name }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
<p>
|
||||
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
|
||||
<a href="{{ url('accounting:bank_details', b_account_id=object.bank_account.id) }}">{{object.bank_account }}</a> >
|
||||
{{ object }}
|
||||
</p>
|
||||
<hr>
|
||||
<h2>{% trans %}Club account:{% endtrans %} {{ object.name }}</h2>
|
||||
{% if user.is_root and not object.journals.exists() %}
|
||||
<a href="{{ url('accounting:club_delete', c_account_id=object.id) }}">{% trans %}Delete{% endtrans %}</a>
|
||||
{% endif %}
|
||||
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
|
||||
<p><a href="{{ url('accounting:label_new') }}?parent={{ object.id }}">{% trans %}New label{% endtrans %}</a></p>
|
||||
{% endif %}
|
||||
<p><a href="{{ url('accounting:label_list', clubaccount_id=object.id) }}">{% trans %}Label list{% endtrans %}</a></p>
|
||||
{% if not object.has_open_journal() %}
|
||||
<p><a href="{{ url('accounting:journal_new') }}?parent={{ object.id }}">{% trans %}New journal{% endtrans %}</a></p>
|
||||
{% else %}
|
||||
<p>{% trans %}You can not create new journal while you still have one opened{% endtrans %}</p>
|
||||
{% endif %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}Name{% endtrans %}</td>
|
||||
<td>{% trans %}Start{% endtrans %}</td>
|
||||
<td>{% trans %}End{% endtrans %}</td>
|
||||
<td>{% trans %}Amount{% endtrans %}</td>
|
||||
<td>{% trans %}Effective amount{% endtrans %}</td>
|
||||
<td>{% trans %}Closed{% endtrans %}</td>
|
||||
<td>{% trans %}Actions{% endtrans %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for j in object.journals.all() %}
|
||||
<tr>
|
||||
<td>{{ j.name }}</td>
|
||||
<td>{{ j.start_date }}</td>
|
||||
{% if j.end_date %}
|
||||
<td>{{ j.end_date }}</td>
|
||||
{% else %}
|
||||
<td> - </td>
|
||||
{% endif %}
|
||||
<td>{{ j.amount }} €</td>
|
||||
<td>{{ j.effective_amount }} €</td>
|
||||
{% if j.closed %}
|
||||
<td>{% trans %}Yes{% endtrans %}</td>
|
||||
{% else %}
|
||||
<td>{% trans %}No{% endtrans %}</td>
|
||||
{% endif %}
|
||||
<td> <a href="{{ url('accounting:journal_details', j_id=j.id) }}">{% trans %}View{% endtrans %}</a>
|
||||
<a href="{{ url('accounting:journal_edit', j_id=j.id) }}">{% trans %}Edit{% endtrans %}</a>
|
||||
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) and j.operations.count() == 0 %}
|
||||
<a href="{{ url('accounting:journal_delete', j_id=j.id) }}">{% trans %}Delete{% endtrans %}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Company list{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
{% if user.is_root
|
||||
or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
||||
%}
|
||||
<p><a href="{{ url('accounting:co_new') }}">{% trans %}Create new company{% endtrans %}</a></p>
|
||||
{% endif %}
|
||||
<br/>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}Companies{% endtrans %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for o in object_list %}
|
||||
<tr>
|
||||
<td><a href="{{ url('accounting:co_edit', co_id=o.id) }}">{{ o.get_display_name() }}</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,103 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}General journal:{% endtrans %} {{ object.name }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
<p>
|
||||
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
|
||||
<a href="{{ url('accounting:bank_details', b_account_id=object.club_account.bank_account.id) }}">{{object.club_account.bank_account }}</a> >
|
||||
<a href="{{ url('accounting:club_details', c_account_id=object.club_account.id) }}">{{ object.club_account }}</a> >
|
||||
{{ object.name }}
|
||||
</p>
|
||||
<hr>
|
||||
<h2>{% trans %}General journal:{% endtrans %} {{ object.name }}</h2>
|
||||
<p><a href="{{ url('accounting:label_new') }}?parent={{ object.club_account.id }}">{% trans %}New label{% endtrans %}</a></p>
|
||||
<p><a href="{{ url('accounting:label_list', clubaccount_id=object.club_account.id) }}">{% trans %}Label list{% endtrans %}</a></p>
|
||||
<p><a href="{{ url('accounting:co_list') }}">{% trans %}Company list{% endtrans %}</a></p>
|
||||
<p><strong>{% trans %}Amount: {% endtrans %}</strong>{{ object.amount }} € -
|
||||
<strong>{% trans %}Effective amount: {% endtrans %}</strong>{{ object.effective_amount }} €</p>
|
||||
{% if object.closed %}
|
||||
<p>{% trans %}Journal is closed, you can not create operation{% endtrans %}</p>
|
||||
{% else %}
|
||||
<p><a href="{{ url('accounting:op_new', j_id=object.id) }}">{% trans %}New operation{% endtrans %}</a></p>
|
||||
</br>
|
||||
{% endif %}
|
||||
<div class="journal-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}Nb{% endtrans %}</td>
|
||||
<td>{% trans %}Date{% endtrans %}</td>
|
||||
<td>{% trans %}Label{% endtrans %}</td>
|
||||
<td>{% trans %}Amount{% endtrans %}</td>
|
||||
<td>{% trans %}Payment mode{% endtrans %}</td>
|
||||
<td>{% trans %}Target{% endtrans %}</td>
|
||||
<td>{% trans %}Code{% endtrans %}</td>
|
||||
<td>{% trans %}Nature{% endtrans %}</td>
|
||||
<td>{% trans %}Done{% endtrans %}</td>
|
||||
<td>{% trans %}Comment{% endtrans %}</td>
|
||||
<td>{% trans %}File{% endtrans %}</td>
|
||||
<td>{% trans %}Actions{% endtrans %}</td>
|
||||
<td>{% trans %}PDF{% endtrans %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for o in object.operations.all() %}
|
||||
<tr>
|
||||
<td>{{ o.number }}</td>
|
||||
<td>{{ o.date }}</td>
|
||||
<td>{{ o.label or "" }}</td>
|
||||
{% if o.accounting_type.movement_type == "DEBIT" %}
|
||||
<td class="neg-amount"> {{ o.amount }} €</td>
|
||||
{% else %}
|
||||
<td class="pos-amount"> {{ o.amount }} €</td>
|
||||
{% endif %}
|
||||
<td>{{ o.get_mode_display() }}</td>
|
||||
{% if o.target_type == "OTHER" %}
|
||||
<td>{{ o.target_label }}</td>
|
||||
{% else %}
|
||||
<td><a href="{{ o.target.get_absolute_url() }}">{{ o.target.get_display_name() }}</a></td>
|
||||
{% endif %}
|
||||
<td>{{ o.accounting_type.code }}</td>
|
||||
<td>{{ o.accounting_type.label }}</td>
|
||||
{% if o.done %}
|
||||
<td>{% trans %}Yes{% endtrans %}</td>
|
||||
{% else %}
|
||||
<td>{% trans %}No{% endtrans %}</td>
|
||||
{% endif %}
|
||||
<td>{{ o.remark }}
|
||||
{% if not o.linked_operation and o.target_type == "ACCOUNT" and not o.target.has_open_journal() %}
|
||||
<p><strong>
|
||||
{% trans %}Warning: this operation has no linked operation because the targeted club account has no opened journal.{% endtrans %}
|
||||
</strong></p>
|
||||
<p><strong>
|
||||
{% trans url=o.target.get_absolute_url() %}Open a journal in <a href="{{ url }}">this club account</a>, then save this operation again to make the linked operation.{% endtrans %}
|
||||
</strong></p>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if o.invoice %}
|
||||
<td><a href="{{ url('core:download', file_id=o.invoice.id) }}">{{ o.invoice.name }}</a></td>
|
||||
{% else %}
|
||||
<td>-</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
{%
|
||||
if o.journal.club_account.bank_account.name not in ["AE TI", "TI"]
|
||||
or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
||||
%}
|
||||
{% if not o.journal.closed %}
|
||||
<a href="{{ url('accounting:op_edit', op_id=o.id) }}">{% trans %}Edit{% endtrans %}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><a href="{{ url('accounting:op_pdf', op_id=o.id) }}">{% trans %}Generate{% endtrans %}</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,33 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}General journal:{% endtrans %} {{ object.name }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
<h3>{% trans %}Accounting statement: {% endtrans %} {{ object.name }}</h3>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}Operation type{% endtrans %}</td>
|
||||
<td>{% trans %}Sum{% endtrans %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for k,v in statement.items() %}
|
||||
<tr>
|
||||
<td>{{ k }}</td>
|
||||
<td>{{ "%.2f" % v }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
<p><strong>{% trans %}Amount: {% endtrans %}</strong>{{ "%.2f" % object.amount }} €</p>
|
||||
<p><strong>{% trans %}Effective amount: {% endtrans %}</strong>{{ "%.2f" %object.effective_amount }} €</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}General journal:{% endtrans %} {{ object.name }}
|
||||
{% endblock %}
|
||||
|
||||
{% macro display_tables(dict) %}
|
||||
<div id="accounting">
|
||||
<h6>{% trans %}Credit{% endtrans %}</h6>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}Nature of operation{% endtrans %}</td>
|
||||
<td>{% trans %}Sum{% endtrans %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for k,v in dict['CREDIT'].items() %}
|
||||
<tr>
|
||||
<td>{{ k }}</td>
|
||||
<td>{{ "%.2f" % v }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% trans %}Total: {% endtrans %}{{ "%.2f" % dict['CREDIT_sum'] }}
|
||||
|
||||
<h6>{% trans %}Debit{% endtrans %}</h6>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}Nature of operation{% endtrans %}</td>
|
||||
<td>{% trans %}Sum{% endtrans %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for k,v in dict['DEBIT'].items() %}
|
||||
<tr>
|
||||
<td>{{ k }}</td>
|
||||
<td>{{ "%.2f" % v }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% trans %}Total: {% endtrans %}{{ "%.2f" % dict['DEBIT_sum'] }}
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<h3>{% trans %}Statement by nature: {% endtrans %} {{ object.name }}</h3>
|
||||
|
||||
{% for k,v in statement.items() %}
|
||||
<h4 style="background: lightblue; padding: 4px;">{{ k }} : {{ "%.2f" % (v['CREDIT_sum'] - v['DEBIT_sum']) }}</h4>
|
||||
{{ display_tables(v) }}
|
||||
<hr>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,68 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}General journal:{% endtrans %} {{ object.name }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
<h3>{% trans %}Statement by person: {% endtrans %} {{ object.name }}</h3>
|
||||
|
||||
<h4>{% trans %}Credit{% endtrans %}</h4>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}Target of the operation{% endtrans %}</td>
|
||||
<td>{% trans %}Sum{% endtrans %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key in credit_statement.keys() %}
|
||||
<tr>
|
||||
{% if key.target_type == "OTHER" %}
|
||||
<td>{{ o.target_label }}</td>
|
||||
{% elif key %}
|
||||
<td><a href="{{ key.get_absolute_url() }}">{{ key.get_display_name() }}</a></td>
|
||||
{% else %}
|
||||
<td></td>
|
||||
{% endif %}
|
||||
<td>{{ "%.2f" % credit_statement[key] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
<p>Total : {{ "%.2f" % total_credit }}</p>
|
||||
|
||||
<h4>{% trans %}Debit{% endtrans %}</h4>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}Target of the operation{% endtrans %}</td>
|
||||
<td>{% trans %}Sum{% endtrans %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key in debit_statement.keys() %}
|
||||
<tr>
|
||||
{% if key.target_type == "OTHER" %}
|
||||
<td>{{ o.target_label }}</td>
|
||||
{% elif key %}
|
||||
<td><a href="{{ key.get_absolute_url() }}">{{ key.get_display_name() }}</a></td>
|
||||
{% else %}
|
||||
<td></td>
|
||||
{% endif %}
|
||||
<td>{{ "%.2f" % debit_statement[key] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
<p>Total : {{ "%.2f" % total_debit }}</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Label list{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
<p>
|
||||
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
|
||||
<a href="{{ url('accounting:bank_details', b_account_id=object.bank_account.id) }}">{{object.bank_account }}</a> >
|
||||
<a href="{{ url('accounting:club_details', c_account_id=object.id) }}">{{ object }}</a>
|
||||
</p>
|
||||
<hr>
|
||||
<p><a href="{{ url('accounting:club_details', c_account_id=object.id) }}">{% trans %}Back to club account{% endtrans %}</a></p>
|
||||
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
|
||||
<p><a href="{{ url('accounting:label_new') }}?parent={{ object.id }}">{% trans %}New label{% endtrans %}</a></p>
|
||||
{% endif %}
|
||||
{% if object.labels.all() %}
|
||||
<h3>{% trans %}Label list{% endtrans %}</h3>
|
||||
<ul>
|
||||
{% for l in object.labels.all() %}
|
||||
<li><a href="{{ url('accounting:label_edit', label_id=l.id) }}">{{ l }}</a>
|
||||
{% if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
|
||||
-
|
||||
<a href="{{ url('accounting:label_delete', label_id=l.id) }}">{% trans %}Delete{% endtrans %}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{% trans %}There is no label in this club account.{% endtrans %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Edit operation{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
<p>
|
||||
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
|
||||
<a href="{{ url('accounting:bank_details', b_account_id=object.club_account.bank_account.id) }}">{{object.club_account.bank_account }}</a> >
|
||||
<a href="{{ url('accounting:club_details', c_account_id=object.club_account.id) }}">{{ object.club_account }}</a> >
|
||||
<a href="{{ url('accounting:journal_details', j_id=object.id) }}">{{ object.name }}</a> >
|
||||
{% trans %}Edit operation{% endtrans %}
|
||||
</p>
|
||||
<hr>
|
||||
<h2>{% trans %}Edit operation{% endtrans %}</h2>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.non_field_errors() }}
|
||||
{{ form.journal }}
|
||||
{{ form.target_id }}
|
||||
<p>{{ form.amount.errors }}<label for="{{ form.amount.name }}">{{ form.amount.label }}</label> {{ form.amount }}</p>
|
||||
<p>{{ form.remark.errors }}<label for="{{ form.remark.name }}">{{ form.remark.label }}</label> {{ form.remark }}</p>
|
||||
<br />
|
||||
<strong>{% trans %}Warning: if you select <em>Account</em>, the opposite operation will be created in the target account. If you don't want that, select <em>Club</em> instead of <em>Account</em>.{% endtrans %}</strong>
|
||||
<p>{{ form.target_type.errors }}<label for="{{ form.target_type.name }}">{{ form.target_type.label }}</label> {{ form.target_type }}</p>
|
||||
{{ form.user }}
|
||||
{{ form.club }}
|
||||
{{ form.club_account }}
|
||||
{{ form.company }}
|
||||
{{ form.target_label }}
|
||||
<span id="id_need_link_full"><label>{{ form.need_link.label }}</label> {{ form.need_link }}</span>
|
||||
<p>{{ form.date.errors }}<label for="{{ form.date.name }}">{{ form.date.label }}</label> {{ form.date }}</p>
|
||||
<p>{{ form.mode.errors }}<label for="{{ form.mode.name }}">{{ form.mode.label }}</label> {{ form.mode }}</p>
|
||||
<p>{{ form.cheque_number.errors }}<label for="{{ form.cheque_number.name }}">{{ form.cheque_number.label }}</label> {{
|
||||
form.cheque_number }}</p>
|
||||
<p>{{ form.invoice.errors }}<label for="{{ form.invoice.name }}">{{ form.invoice.label }}</label> {{ form.invoice }}</p>
|
||||
<p>{{ form.simpleaccounting_type.errors }}<label for="{{ form.simpleaccounting_type.name }}">{{
|
||||
form.simpleaccounting_type.label }}</label> {{ form.simpleaccounting_type }}</p>
|
||||
<p>{{ form.accounting_type.errors }}<label for="{{ form.accounting_type.name }}">{{ form.accounting_type.label }}</label> {{
|
||||
form.accounting_type }}</p>
|
||||
<p>{{ form.label.errors }}<label for="{{ form.label.name }}">{{ form.label.label }}</label> {{ form.label }}</p>
|
||||
<p>{{ form.done.errors }}<label for="{{ form.done.name }}">{{ form.done.label }}</label> {{ form.done }}</p>
|
||||
{% if form.instance.linked_operation %}
|
||||
{% set obj = form.instance.linked_operation %}
|
||||
<p><strong>{% trans %}Linked operation:{% endtrans %}</strong><br>
|
||||
<a href="{{ url('accounting:bank_details', b_account_id=obj.journal.club_account.bank_account.id) }}">
|
||||
{{obj.journal.club_account.bank_account }}</a> >
|
||||
<a href="{{ url('accounting:club_details', c_account_id=obj.journal.club_account.id) }}">{{ obj.journal.club_account }}</a> >
|
||||
<a href="{{ url('accounting:journal_details', j_id=obj.journal.id) }}">{{ obj.journal }}</a> >
|
||||
n°{{ obj.number }}
|
||||
</p>
|
||||
{% endif %}
|
||||
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
$( function() {
|
||||
var target_type = $('#id_target_type');
|
||||
var user = $('user-ajax-select');
|
||||
var club = $('club-ajax-select');
|
||||
var club_account = $('club-account-ajax-select');
|
||||
var company = $('company-ajax-select');
|
||||
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 %}
|
||||
|
||||
|
||||
+2
-2
@@ -5,12 +5,12 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Simplified type list{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
<p>
|
||||
<a href="{{ url('accounting:bank_list') }}">{% trans %}Accounting{% endtrans %}</a> >
|
||||
{% trans %}Simplified types{% endtrans %}
|
||||
</p>
|
||||
<hr>
|
||||
<p><a href="{{ url('accounting:simple_type_new') }}">{% trans %}New simplified type{% endtrans %}</a></p>
|
||||
{% if simplifiedaccountingtype_list %}
|
||||
<h3>{% trans %}Simplified type list{% endtrans %}</h3>
|
||||
<ul>
|
||||
{% for a in simplifiedaccountingtype_list %}
|
||||
<li><a href="{{ url('accounting:simple_type_edit', type_id=a.id) }}">{{ a }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{% trans %}There is no types in this website.{% endtrans %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||
#
|
||||
# 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"
|
||||
#
|
||||
#
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from accounting.models import (
|
||||
AccountingType,
|
||||
GeneralJournal,
|
||||
Label,
|
||||
Operation,
|
||||
SimplifiedAccountingType,
|
||||
)
|
||||
from core.models import User
|
||||
|
||||
|
||||
class TestRefoundAccount(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.skia = User.objects.get(username="skia")
|
||||
# reffil skia's account
|
||||
cls.skia.customer.amount = 800
|
||||
cls.skia.customer.save()
|
||||
cls.refound_account_url = reverse("accounting:refound_account")
|
||||
|
||||
def test_permission_denied(self):
|
||||
self.client.force_login(User.objects.get(username="guy"))
|
||||
response_post = self.client.post(
|
||||
self.refound_account_url, {"user": self.skia.id}
|
||||
)
|
||||
response_get = self.client.get(self.refound_account_url)
|
||||
assert response_get.status_code == 403
|
||||
assert response_post.status_code == 403
|
||||
|
||||
def test_root_granteed(self):
|
||||
self.client.force_login(User.objects.get(username="root"))
|
||||
response = self.client.post(self.refound_account_url, {"user": self.skia.id})
|
||||
self.assertRedirects(response, self.refound_account_url)
|
||||
self.skia.refresh_from_db()
|
||||
response = self.client.get(self.refound_account_url)
|
||||
assert response.status_code == 200
|
||||
assert '<form action="" method="post">' in str(response.content)
|
||||
assert self.skia.customer.amount == 0
|
||||
|
||||
def test_comptable_granteed(self):
|
||||
self.client.force_login(User.objects.get(username="comptable"))
|
||||
response = self.client.post(self.refound_account_url, {"user": self.skia.id})
|
||||
self.assertRedirects(response, self.refound_account_url)
|
||||
self.skia.refresh_from_db()
|
||||
response = self.client.get(self.refound_account_url)
|
||||
assert response.status_code == 200
|
||||
assert '<form action="" method="post">' in str(response.content)
|
||||
assert self.skia.customer.amount == 0
|
||||
|
||||
|
||||
class TestJournal(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.journal = GeneralJournal.objects.get(id=1)
|
||||
|
||||
def test_permission_granted(self):
|
||||
self.client.force_login(User.objects.get(username="comptable"))
|
||||
response_get = self.client.get(
|
||||
reverse("accounting:journal_details", args=[self.journal.id])
|
||||
)
|
||||
|
||||
assert response_get.status_code == 200
|
||||
assert "<td>M\\xc3\\xa9thode de paiement</td>" in str(response_get.content)
|
||||
|
||||
def test_permission_not_granted(self):
|
||||
self.client.force_login(User.objects.get(username="skia"))
|
||||
response_get = self.client.get(
|
||||
reverse("accounting:journal_details", args=[self.journal.id])
|
||||
)
|
||||
|
||||
assert response_get.status_code == 403
|
||||
assert "<td>M\xc3\xa9thode de paiement</td>" not in str(response_get.content)
|
||||
|
||||
|
||||
class TestOperation(TestCase):
|
||||
def setUp(self):
|
||||
self.tomorrow_formatted = (date.today() + timedelta(days=1)).strftime(
|
||||
"%d/%m/%Y"
|
||||
)
|
||||
self.journal = GeneralJournal.objects.filter(id=1).first()
|
||||
self.skia = User.objects.filter(username="skia").first()
|
||||
at = AccountingType(
|
||||
code="443", label="Ce code n'existe pas", movement_type="CREDIT"
|
||||
)
|
||||
at.save()
|
||||
label = Label.objects.create(club_account=self.journal.club_account, name="bob")
|
||||
self.client.force_login(User.objects.get(username="comptable"))
|
||||
self.op1 = Operation(
|
||||
journal=self.journal,
|
||||
date=date.today(),
|
||||
amount=1,
|
||||
remark="Test bilan",
|
||||
mode="CASH",
|
||||
done=True,
|
||||
label=label,
|
||||
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=label,
|
||||
accounting_type=at,
|
||||
target_type="USER",
|
||||
target_id=self.skia.id,
|
||||
)
|
||||
self.op2.save()
|
||||
|
||||
def test_new_operation(self):
|
||||
at = AccountingType.objects.get(code="604")
|
||||
response = self.client.post(
|
||||
reverse("accounting:op_new", args=[self.journal.id]),
|
||||
{
|
||||
"amount": 30,
|
||||
"remark": "Un gros test",
|
||||
"journal": self.journal.id,
|
||||
"target_type": "OTHER",
|
||||
"target_id": "",
|
||||
"target_label": "Le fantome de la nuit",
|
||||
"date": self.tomorrow_formatted,
|
||||
"mode": "CASH",
|
||||
"cheque_number": "",
|
||||
"invoice": "",
|
||||
"simpleaccounting_type": "",
|
||||
"accounting_type": at.id,
|
||||
"label": "",
|
||||
"done": False,
|
||||
},
|
||||
)
|
||||
self.assertFalse(response.status_code == 403)
|
||||
self.assertTrue(
|
||||
self.journal.operations.filter(
|
||||
target_label="Le fantome de la nuit"
|
||||
).exists()
|
||||
)
|
||||
response_get = self.client.get(
|
||||
reverse("accounting:journal_details", args=[self.journal.id])
|
||||
)
|
||||
self.assertTrue("<td>Le fantome de la nuit</td>" in str(response_get.content))
|
||||
|
||||
def test_bad_new_operation(self):
|
||||
AccountingType.objects.get(code="604")
|
||||
response = self.client.post(
|
||||
reverse("accounting:op_new", args=[self.journal.id]),
|
||||
{
|
||||
"amount": 30,
|
||||
"remark": "Un gros test",
|
||||
"journal": self.journal.id,
|
||||
"target_type": "OTHER",
|
||||
"target_id": "",
|
||||
"target_label": "Le fantome de la nuit",
|
||||
"date": self.tomorrow_formatted,
|
||||
"mode": "CASH",
|
||||
"cheque_number": "",
|
||||
"invoice": "",
|
||||
"simpleaccounting_type": "",
|
||||
"accounting_type": "",
|
||||
"label": "",
|
||||
"done": False,
|
||||
},
|
||||
)
|
||||
self.assertTrue(
|
||||
"Vous devez fournir soit un type comptable simplifi\\xc3\\xa9 ou un type comptable standard"
|
||||
in str(response.content)
|
||||
)
|
||||
|
||||
def test_new_operation_not_authorized(self):
|
||||
self.client.force_login(self.skia)
|
||||
at = AccountingType.objects.filter(code="604").first()
|
||||
response = self.client.post(
|
||||
reverse("accounting:op_new", args=[self.journal.id]),
|
||||
{
|
||||
"amount": 30,
|
||||
"remark": "Un gros test",
|
||||
"journal": self.journal.id,
|
||||
"target_type": "OTHER",
|
||||
"target_id": "",
|
||||
"target_label": "Le fantome du jour",
|
||||
"date": self.tomorrow_formatted,
|
||||
"mode": "CASH",
|
||||
"cheque_number": "",
|
||||
"invoice": "",
|
||||
"simpleaccounting_type": "",
|
||||
"accounting_type": at.id,
|
||||
"label": "",
|
||||
"done": False,
|
||||
},
|
||||
)
|
||||
self.assertTrue(response.status_code == 403)
|
||||
self.assertFalse(
|
||||
self.journal.operations.filter(target_label="Le fantome du jour").exists()
|
||||
)
|
||||
|
||||
def test_operation_simple_accounting(self):
|
||||
sat = SimplifiedAccountingType.objects.all().first()
|
||||
response = self.client.post(
|
||||
reverse("accounting:op_new", args=[self.journal.id]),
|
||||
{
|
||||
"amount": 23,
|
||||
"remark": "Un gros test",
|
||||
"journal": self.journal.id,
|
||||
"target_type": "OTHER",
|
||||
"target_id": "",
|
||||
"target_label": "Le fantome de l'aurore",
|
||||
"date": self.tomorrow_formatted,
|
||||
"mode": "CASH",
|
||||
"cheque_number": "",
|
||||
"invoice": "",
|
||||
"simpleaccounting_type": sat.id,
|
||||
"accounting_type": "",
|
||||
"label": "",
|
||||
"done": False,
|
||||
},
|
||||
)
|
||||
assert response.status_code != 403
|
||||
assert self.journal.operations.filter(amount=23).exists()
|
||||
response_get = self.client.get(
|
||||
reverse("accounting:journal_details", args=[self.journal.id])
|
||||
)
|
||||
assert "<td>Le fantome de l'aurore</td>" in str(response_get.content)
|
||||
|
||||
assert (
|
||||
self.journal.operations.filter(amount=23)
|
||||
.values("accounting_type")
|
||||
.first()["accounting_type"]
|
||||
== AccountingType.objects.filter(code=6).values("id").first()["id"]
|
||||
)
|
||||
|
||||
def test_nature_statement(self):
|
||||
response = self.client.get(
|
||||
reverse("accounting:journal_nature_statement", args=[self.journal.id])
|
||||
)
|
||||
self.assertContains(response, "bob (Troll Penché) : 3.00", status_code=200)
|
||||
|
||||
def test_person_statement(self):
|
||||
response = self.client.get(
|
||||
reverse("accounting:journal_person_statement", args=[self.journal.id])
|
||||
)
|
||||
self.assertContains(response, "Total : 5575.72", status_code=200)
|
||||
self.assertContains(response, "Total : 71.42")
|
||||
content = response.content.decode()
|
||||
self.assertInHTML(
|
||||
"""<td><a href="/user/1/">S' Kia</a></td><td>3.00</td>""", content
|
||||
)
|
||||
self.assertInHTML(
|
||||
"""<td><a href="/user/1/">S' Kia</a></td><td>823.00</td>""", content
|
||||
)
|
||||
|
||||
def test_accounting_statement(self):
|
||||
response = self.client.get(
|
||||
reverse("accounting:journal_accounting_statement", args=[self.journal.id])
|
||||
)
|
||||
assert response.status_code == 200
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<tr>
|
||||
<td>443 - Crédit - Ce code n'existe pas</td>
|
||||
<td>3.00</td>
|
||||
</tr>""",
|
||||
response.content.decode(),
|
||||
)
|
||||
self.assertContains(
|
||||
response,
|
||||
"""
|
||||
<p><strong>Montant : </strong>-5504.30 €</p>
|
||||
<p><strong>Montant effectif: </strong>-5504.30 €</p>""",
|
||||
)
|
||||
@@ -0,0 +1,173 @@
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||
#
|
||||
# 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"
|
||||
#
|
||||
#
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from accounting.views import (
|
||||
AccountingTypeCreateView,
|
||||
AccountingTypeEditView,
|
||||
AccountingTypeListView,
|
||||
BankAccountCreateView,
|
||||
BankAccountDeleteView,
|
||||
BankAccountDetailView,
|
||||
BankAccountEditView,
|
||||
BankAccountListView,
|
||||
ClubAccountCreateView,
|
||||
ClubAccountDeleteView,
|
||||
ClubAccountDetailView,
|
||||
ClubAccountEditView,
|
||||
CompanyCreateView,
|
||||
CompanyEditView,
|
||||
CompanyListView,
|
||||
JournalAccountingStatementView,
|
||||
JournalCreateView,
|
||||
JournalDeleteView,
|
||||
JournalDetailView,
|
||||
JournalEditView,
|
||||
JournalNatureStatementView,
|
||||
JournalPersonStatementView,
|
||||
LabelCreateView,
|
||||
LabelDeleteView,
|
||||
LabelEditView,
|
||||
LabelListView,
|
||||
OperationCreateView,
|
||||
OperationEditView,
|
||||
OperationPDFView,
|
||||
RefoundAccountView,
|
||||
SimplifiedAccountingTypeCreateView,
|
||||
SimplifiedAccountingTypeEditView,
|
||||
SimplifiedAccountingTypeListView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
# Accounting types
|
||||
path(
|
||||
"simple_type/",
|
||||
SimplifiedAccountingTypeListView.as_view(),
|
||||
name="simple_type_list",
|
||||
),
|
||||
path(
|
||||
"simple_type/create/",
|
||||
SimplifiedAccountingTypeCreateView.as_view(),
|
||||
name="simple_type_new",
|
||||
),
|
||||
path(
|
||||
"simple_type/<int:type_id>/edit/",
|
||||
SimplifiedAccountingTypeEditView.as_view(),
|
||||
name="simple_type_edit",
|
||||
),
|
||||
# Accounting types
|
||||
path("type/", AccountingTypeListView.as_view(), name="type_list"),
|
||||
path("type/create/", AccountingTypeCreateView.as_view(), name="type_new"),
|
||||
path(
|
||||
"type/<int:type_id>/edit/",
|
||||
AccountingTypeEditView.as_view(),
|
||||
name="type_edit",
|
||||
),
|
||||
# Bank accounts
|
||||
path("", BankAccountListView.as_view(), name="bank_list"),
|
||||
path("bank/create", BankAccountCreateView.as_view(), name="bank_new"),
|
||||
path(
|
||||
"bank/<int:b_account_id>/",
|
||||
BankAccountDetailView.as_view(),
|
||||
name="bank_details",
|
||||
),
|
||||
path(
|
||||
"bank/<int:b_account_id>/edit/",
|
||||
BankAccountEditView.as_view(),
|
||||
name="bank_edit",
|
||||
),
|
||||
path(
|
||||
"bank/<int:b_account_id>/delete/",
|
||||
BankAccountDeleteView.as_view(),
|
||||
name="bank_delete",
|
||||
),
|
||||
# Club accounts
|
||||
path("club/create/", ClubAccountCreateView.as_view(), name="club_new"),
|
||||
path(
|
||||
"club/<int:c_account_id>/",
|
||||
ClubAccountDetailView.as_view(),
|
||||
name="club_details",
|
||||
),
|
||||
path(
|
||||
"club/<int:c_account_id>/edit/",
|
||||
ClubAccountEditView.as_view(),
|
||||
name="club_edit",
|
||||
),
|
||||
path(
|
||||
"club/<int:c_account_id>/delete/",
|
||||
ClubAccountDeleteView.as_view(),
|
||||
name="club_delete",
|
||||
),
|
||||
# Journals
|
||||
path("journal/create/", JournalCreateView.as_view(), name="journal_new"),
|
||||
path(
|
||||
"journal/<int:j_id>/",
|
||||
JournalDetailView.as_view(),
|
||||
name="journal_details",
|
||||
),
|
||||
path(
|
||||
"journal/<int:j_id>/edit/",
|
||||
JournalEditView.as_view(),
|
||||
name="journal_edit",
|
||||
),
|
||||
path(
|
||||
"journal/<int:j_id>/delete/",
|
||||
JournalDeleteView.as_view(),
|
||||
name="journal_delete",
|
||||
),
|
||||
path(
|
||||
"journal/<int:j_id>/statement/nature/",
|
||||
JournalNatureStatementView.as_view(),
|
||||
name="journal_nature_statement",
|
||||
),
|
||||
path(
|
||||
"journal/<int:j_id>/statement/person/",
|
||||
JournalPersonStatementView.as_view(),
|
||||
name="journal_person_statement",
|
||||
),
|
||||
path(
|
||||
"journal/<int:j_id>/statement/accounting/",
|
||||
JournalAccountingStatementView.as_view(),
|
||||
name="journal_accounting_statement",
|
||||
),
|
||||
# Operations
|
||||
path(
|
||||
"operation/create/<int:j_id>/",
|
||||
OperationCreateView.as_view(),
|
||||
name="op_new",
|
||||
),
|
||||
path("operation/<int:op_id>/", OperationEditView.as_view(), name="op_edit"),
|
||||
path("operation/<int:op_id>/pdf/", OperationPDFView.as_view(), name="op_pdf"),
|
||||
# Companies
|
||||
path("company/list/", CompanyListView.as_view(), name="co_list"),
|
||||
path("company/create/", CompanyCreateView.as_view(), name="co_new"),
|
||||
path("company/<int:co_id>/", CompanyEditView.as_view(), name="co_edit"),
|
||||
# Labels
|
||||
path("label/new/", LabelCreateView.as_view(), name="label_new"),
|
||||
path(
|
||||
"label/<int:clubaccount_id>/",
|
||||
LabelListView.as_view(),
|
||||
name="label_list",
|
||||
),
|
||||
path("label/<int:label_id>/edit/", LabelEditView.as_view(), name="label_edit"),
|
||||
path(
|
||||
"label/<int:label_id>/delete/",
|
||||
LabelDeleteView.as_view(),
|
||||
name="label_delete",
|
||||
),
|
||||
# User account
|
||||
path("refound/account/", RefoundAccountView.as_view(), name="refound_account"),
|
||||
]
|
||||
@@ -0,0 +1,893 @@
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||
#
|
||||
# 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"
|
||||
#
|
||||
#
|
||||
|
||||
import collections
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import Sum
|
||||
from django.forms import HiddenInput
|
||||
from django.forms.models import modelform_factory
|
||||
from django.http import HttpResponse
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
|
||||
|
||||
from accounting.models import (
|
||||
AccountingType,
|
||||
BankAccount,
|
||||
ClubAccount,
|
||||
Company,
|
||||
GeneralJournal,
|
||||
Label,
|
||||
Operation,
|
||||
SimplifiedAccountingType,
|
||||
)
|
||||
from accounting.widgets.select import (
|
||||
AutoCompleteSelectClubAccount,
|
||||
AutoCompleteSelectCompany,
|
||||
)
|
||||
from club.models import Club
|
||||
from club.widgets.select import AutoCompleteSelectClub
|
||||
from core.models import User
|
||||
from core.views import (
|
||||
CanCreateMixin,
|
||||
CanEditMixin,
|
||||
CanEditPropMixin,
|
||||
CanViewMixin,
|
||||
TabedViewMixin,
|
||||
)
|
||||
from core.views.forms import SelectDate, SelectFile
|
||||
from core.views.widgets.select import AutoCompleteSelectUser
|
||||
from counter.models import Counter, Product, Selling
|
||||
|
||||
# Main accounting view
|
||||
|
||||
|
||||
class BankAccountListView(CanViewMixin, ListView):
|
||||
"""A list view for the admins."""
|
||||
|
||||
model = BankAccount
|
||||
template_name = "accounting/bank_account_list.jinja"
|
||||
ordering = ["name"]
|
||||
|
||||
|
||||
# Simplified accounting types
|
||||
|
||||
|
||||
class SimplifiedAccountingTypeListView(CanViewMixin, ListView):
|
||||
"""A list view for the admins."""
|
||||
|
||||
model = SimplifiedAccountingType
|
||||
template_name = "accounting/simplifiedaccountingtype_list.jinja"
|
||||
|
||||
|
||||
class SimplifiedAccountingTypeEditView(CanViewMixin, UpdateView):
|
||||
"""An edit view for the admins."""
|
||||
|
||||
model = SimplifiedAccountingType
|
||||
pk_url_kwarg = "type_id"
|
||||
fields = ["label", "accounting_type"]
|
||||
template_name = "core/edit.jinja"
|
||||
|
||||
|
||||
class SimplifiedAccountingTypeCreateView(CanCreateMixin, CreateView):
|
||||
"""Create an accounting type (for the admins)."""
|
||||
|
||||
model = SimplifiedAccountingType
|
||||
fields = ["label", "accounting_type"]
|
||||
template_name = "core/create.jinja"
|
||||
|
||||
|
||||
# Accounting types
|
||||
|
||||
|
||||
class AccountingTypeListView(CanViewMixin, ListView):
|
||||
"""A list view for the admins."""
|
||||
|
||||
model = AccountingType
|
||||
template_name = "accounting/accountingtype_list.jinja"
|
||||
|
||||
|
||||
class AccountingTypeEditView(CanViewMixin, UpdateView):
|
||||
"""An edit view for the admins."""
|
||||
|
||||
model = AccountingType
|
||||
pk_url_kwarg = "type_id"
|
||||
fields = ["code", "label", "movement_type"]
|
||||
template_name = "core/edit.jinja"
|
||||
|
||||
|
||||
class AccountingTypeCreateView(CanCreateMixin, CreateView):
|
||||
"""Create an accounting type (for the admins)."""
|
||||
|
||||
model = AccountingType
|
||||
fields = ["code", "label", "movement_type"]
|
||||
template_name = "core/create.jinja"
|
||||
|
||||
|
||||
# BankAccount views
|
||||
|
||||
|
||||
class BankAccountEditView(CanViewMixin, UpdateView):
|
||||
"""An edit view for the admins."""
|
||||
|
||||
model = BankAccount
|
||||
pk_url_kwarg = "b_account_id"
|
||||
fields = ["name", "iban", "number", "club"]
|
||||
template_name = "core/edit.jinja"
|
||||
|
||||
|
||||
class BankAccountDetailView(CanViewMixin, DetailView):
|
||||
"""A detail view, listing every club account."""
|
||||
|
||||
model = BankAccount
|
||||
pk_url_kwarg = "b_account_id"
|
||||
template_name = "accounting/bank_account_details.jinja"
|
||||
|
||||
|
||||
class BankAccountCreateView(CanCreateMixin, CreateView):
|
||||
"""Create a bank account (for the admins)."""
|
||||
|
||||
model = BankAccount
|
||||
fields = ["name", "club", "iban", "number"]
|
||||
template_name = "core/create.jinja"
|
||||
|
||||
|
||||
class BankAccountDeleteView(
|
||||
CanEditPropMixin, DeleteView
|
||||
): # TODO change Delete to Close
|
||||
"""Delete a bank account (for the admins)."""
|
||||
|
||||
model = BankAccount
|
||||
pk_url_kwarg = "b_account_id"
|
||||
template_name = "core/delete_confirm.jinja"
|
||||
success_url = reverse_lazy("accounting:bank_list")
|
||||
|
||||
|
||||
# ClubAccount views
|
||||
|
||||
|
||||
class ClubAccountEditView(CanViewMixin, UpdateView):
|
||||
"""An edit view for the admins."""
|
||||
|
||||
model = ClubAccount
|
||||
pk_url_kwarg = "c_account_id"
|
||||
fields = ["name", "club", "bank_account"]
|
||||
template_name = "core/edit.jinja"
|
||||
|
||||
|
||||
class ClubAccountDetailView(CanViewMixin, DetailView):
|
||||
"""A detail view, listing every journal."""
|
||||
|
||||
model = ClubAccount
|
||||
pk_url_kwarg = "c_account_id"
|
||||
template_name = "accounting/club_account_details.jinja"
|
||||
|
||||
|
||||
class ClubAccountCreateView(CanCreateMixin, CreateView):
|
||||
"""Create a club account (for the admins)."""
|
||||
|
||||
model = ClubAccount
|
||||
fields = ["name", "club", "bank_account"]
|
||||
template_name = "core/create.jinja"
|
||||
|
||||
def get_initial(self):
|
||||
ret = super().get_initial()
|
||||
if "parent" in self.request.GET:
|
||||
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):
|
||||
return [
|
||||
{
|
||||
"url": reverse(
|
||||
"accounting:journal_details", kwargs={"j_id": self.object.id}
|
||||
),
|
||||
"slug": "journal",
|
||||
"name": _("Journal"),
|
||||
},
|
||||
{
|
||||
"url": reverse(
|
||||
"accounting:journal_nature_statement",
|
||||
kwargs={"j_id": self.object.id},
|
||||
),
|
||||
"slug": "nature_statement",
|
||||
"name": _("Statement by nature"),
|
||||
},
|
||||
{
|
||||
"url": reverse(
|
||||
"accounting:journal_person_statement",
|
||||
kwargs={"j_id": self.object.id},
|
||||
),
|
||||
"slug": "person_statement",
|
||||
"name": _("Statement by person"),
|
||||
},
|
||||
{
|
||||
"url": reverse(
|
||||
"accounting:journal_accounting_statement",
|
||||
kwargs={"j_id": self.object.id},
|
||||
),
|
||||
"slug": "accounting_statement",
|
||||
"name": _("Accounting statement"),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
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().get_initial()
|
||||
if "parent" in self.request.GET:
|
||||
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().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 = forms.ModelChoiceField(
|
||||
help_text=None,
|
||||
required=False,
|
||||
widget=AutoCompleteSelectUser,
|
||||
queryset=User.objects.all(),
|
||||
)
|
||||
club_account = forms.ModelChoiceField(
|
||||
help_text=None,
|
||||
required=False,
|
||||
widget=AutoCompleteSelectClubAccount,
|
||||
queryset=ClubAccount.objects.all(),
|
||||
)
|
||||
club = forms.ModelChoiceField(
|
||||
help_text=None,
|
||||
required=False,
|
||||
widget=AutoCompleteSelectClub,
|
||||
queryset=Club.objects.all(),
|
||||
)
|
||||
company = forms.ModelChoiceField(
|
||||
help_text=None,
|
||||
required=False,
|
||||
widget=AutoCompleteSelectCompany,
|
||||
queryset=Company.objects.all(),
|
||||
)
|
||||
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().__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().clean()
|
||||
if "target_type" in self.cleaned_data:
|
||||
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().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().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().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().get_context_data(**kwargs)
|
||||
kwargs["object"] = self.object.journal
|
||||
return kwargs
|
||||
|
||||
|
||||
class OperationPDFView(CanViewMixin, DetailView):
|
||||
"""Display the PDF of a given operation."""
|
||||
|
||||
model = Operation
|
||||
pk_url_kwarg = "op_id"
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.lib.units import cm
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.platypus import Table, TableStyle
|
||||
|
||||
pdfmetrics.registerFont(TTFont("DejaVu", "DejaVuSerif.ttf"))
|
||||
|
||||
self.object = self.get_object()
|
||||
amount = self.object.amount
|
||||
remark = self.object.remark
|
||||
nature = self.object.accounting_type.movement_type
|
||||
num = self.object.number
|
||||
date = self.object.date
|
||||
mode = self.object.mode
|
||||
club_name = self.object.journal.club_account.name
|
||||
ti = self.object.journal.name
|
||||
op_label = self.object.label
|
||||
club_address = self.object.journal.club_account.club.address
|
||||
id_op = self.object.id
|
||||
|
||||
if self.object.target_type == "OTHER":
|
||||
target = self.object.target_label
|
||||
else:
|
||||
target = self.object.target.get_display_name()
|
||||
|
||||
response = HttpResponse(content_type="application/pdf")
|
||||
response["Content-Disposition"] = 'filename="op-%d(%s_on_%s).pdf"' % (
|
||||
num,
|
||||
ti,
|
||||
club_name,
|
||||
)
|
||||
p = canvas.Canvas(response)
|
||||
|
||||
p.setFont("DejaVu", 12)
|
||||
|
||||
p.setTitle("%s %d" % (_("Operation"), num))
|
||||
width, height = letter
|
||||
im = ImageReader("core/static/core/img/logo.jpg")
|
||||
iw, ih = im.getSize()
|
||||
p.drawImage(im, 40, height - 50, width=iw / 2, height=ih / 2)
|
||||
|
||||
labelStr = [["%s %s - %s %s" % (_("Journal"), ti, _("Operation"), num)]]
|
||||
|
||||
label = Table(labelStr, colWidths=[150], rowHeights=[20])
|
||||
|
||||
label.setStyle(TableStyle([("ALIGN", (0, 0), (-1, -1), "RIGHT")]))
|
||||
w, h = label.wrapOn(label, 0, 0)
|
||||
label.drawOn(p, width - 180, height)
|
||||
|
||||
p.drawString(
|
||||
90, height - 100, _("Financial proof: ") + "OP%010d" % (id_op)
|
||||
) # Justificatif du libellé
|
||||
p.drawString(
|
||||
90, height - 130, _("Club: %(club_name)s") % ({"club_name": club_name})
|
||||
)
|
||||
p.drawString(
|
||||
90,
|
||||
height - 160,
|
||||
_("Label: %(op_label)s")
|
||||
% {"op_label": op_label if op_label is not None else ""},
|
||||
)
|
||||
p.drawString(90, height - 190, _("Date: %(date)s") % {"date": date})
|
||||
|
||||
data = []
|
||||
|
||||
data += [
|
||||
["%s" % (_("Credit").upper() if nature == "CREDIT" else _("Debit").upper())]
|
||||
]
|
||||
|
||||
data += [[_("Amount: %(amount).2f €") % {"amount": amount}]]
|
||||
|
||||
payment_mode = ""
|
||||
for m in settings.SITH_ACCOUNTING_PAYMENT_METHOD:
|
||||
if m[0] == mode:
|
||||
payment_mode += "[\u00d7]"
|
||||
else:
|
||||
payment_mode += "[ ]"
|
||||
payment_mode += " %s\n" % (m[1])
|
||||
|
||||
data += [[payment_mode]]
|
||||
|
||||
data += [
|
||||
[
|
||||
"%s : %s"
|
||||
% (_("Debtor") if nature == "CREDIT" else _("Creditor"), target),
|
||||
"",
|
||||
]
|
||||
]
|
||||
|
||||
data += [["%s \n%s" % (_("Comment:"), remark)]]
|
||||
|
||||
t = Table(
|
||||
data, colWidths=[(width - 90 * 2) / 2] * 2, rowHeights=[20, 20, 70, 20, 80]
|
||||
)
|
||||
t.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (-2, -1), (-1, -1), "TOP"),
|
||||
("VALIGN", (0, 0), (-1, -2), "MIDDLE"),
|
||||
("INNERGRID", (0, 0), (-1, -1), 0.25, colors.black),
|
||||
("SPAN", (0, 0), (1, 0)), # line DEBIT/CREDIT
|
||||
("SPAN", (0, 1), (1, 1)), # line amount
|
||||
("SPAN", (-2, -1), (-1, -1)), # line comment
|
||||
("SPAN", (0, -2), (-1, -2)), # line creditor/debtor
|
||||
("SPAN", (0, 2), (1, 2)), # line payment_mode
|
||||
("ALIGN", (0, 2), (1, 2), "LEFT"), # line payment_mode
|
||||
("ALIGN", (-2, -1), (-1, -1), "LEFT"),
|
||||
("BOX", (0, 0), (-1, -1), 0.25, colors.black),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
signature = []
|
||||
signature += [[_("Signature:")]]
|
||||
|
||||
tSig = Table(signature, colWidths=[(width - 90 * 2)], rowHeights=[80])
|
||||
tSig.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("BOX", (0, 0), (-1, -1), 0.25, colors.black),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
w, h = tSig.wrapOn(p, 0, 0)
|
||||
tSig.drawOn(p, 90, 200)
|
||||
|
||||
w, h = t.wrapOn(p, 0, 0)
|
||||
|
||||
t.drawOn(p, 90, 350)
|
||||
|
||||
p.drawCentredString(10.5 * cm, 2 * cm, club_name)
|
||||
p.drawCentredString(10.5 * cm, 1 * cm, club_address)
|
||||
|
||||
p.showPage()
|
||||
p.save()
|
||||
return response
|
||||
|
||||
|
||||
class JournalNatureStatementView(JournalTabsMixin, CanViewMixin, DetailView):
|
||||
"""Display a statement sorted by labels."""
|
||||
|
||||
model = GeneralJournal
|
||||
pk_url_kwarg = "j_id"
|
||||
template_name = "accounting/journal_statement_nature.jinja"
|
||||
current_tab = "nature_statement"
|
||||
|
||||
def statement(self, queryset, movement_type):
|
||||
ret = collections.OrderedDict()
|
||||
statement = collections.OrderedDict()
|
||||
total_sum = 0
|
||||
for sat in [
|
||||
None,
|
||||
*list(SimplifiedAccountingType.objects.order_by("label")),
|
||||
]:
|
||||
amount = queryset.filter(
|
||||
accounting_type__movement_type=movement_type, simpleaccounting_type=sat
|
||||
).aggregate(amount_sum=Sum("amount"))["amount_sum"]
|
||||
label = sat.label if sat is not None else ""
|
||||
if amount:
|
||||
total_sum += amount
|
||||
statement[label] = amount
|
||||
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 label in labels:
|
||||
l_stmt = collections.OrderedDict()
|
||||
journals = self.object.operations.filter(label=label).all()
|
||||
l_stmt.update(self.statement(journals, "CREDIT"))
|
||||
l_stmt.update(self.statement(journals, "DEBIT"))
|
||||
statement[label] = l_stmt
|
||||
return statement
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add infos to the context."""
|
||||
kwargs = super().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().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().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().get_initial()
|
||||
if "parent" in self.request.GET:
|
||||
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 = forms.ModelChoiceField(
|
||||
label=_("Refound this account"),
|
||||
help_text=None,
|
||||
required=True,
|
||||
widget=AutoCompleteSelectUser,
|
||||
queryset=User.objects.all(),
|
||||
)
|
||||
|
||||
|
||||
class RefoundAccountView(FormView):
|
||||
"""Create a selling with the same amount than the current user money."""
|
||||
|
||||
template_name = "accounting/refound_account.jinja"
|
||||
form_class = CloseCustomerAccountForm
|
||||
|
||||
def permission(self, user):
|
||||
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
else:
|
||||
raise PermissionDenied
|
||||
|
||||
def dispatch(self, request, *arg, **kwargs):
|
||||
res = super().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().post(self, request, *arg, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
self.customer = form.cleaned_data["user"]
|
||||
self.create_selling()
|
||||
return super().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()
|
||||
@@ -0,0 +1,39 @@
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from accounting.models import ClubAccount, Company
|
||||
from accounting.schemas import ClubAccountSchema, CompanySchema
|
||||
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
|
||||
|
||||
_js = ["bundled/accounting/components/ajax-select-index.ts"]
|
||||
|
||||
|
||||
class AutoCompleteSelectClubAccount(AutoCompleteSelect):
|
||||
component_name = "club-account-ajax-select"
|
||||
model = ClubAccount
|
||||
adapter = TypeAdapter(list[ClubAccountSchema])
|
||||
|
||||
js = _js
|
||||
|
||||
|
||||
class AutoCompleteSelectMultipleClubAccount(AutoCompleteSelectMultiple):
|
||||
component_name = "club-account-ajax-select"
|
||||
model = ClubAccount
|
||||
adapter = TypeAdapter(list[ClubAccountSchema])
|
||||
|
||||
js = _js
|
||||
|
||||
|
||||
class AutoCompleteSelectCompany(AutoCompleteSelect):
|
||||
component_name = "company-ajax-select"
|
||||
model = Company
|
||||
adapter = TypeAdapter(list[CompanySchema])
|
||||
|
||||
js = _js
|
||||
|
||||
|
||||
class AutoCompleteSelectMultipleCompany(AutoCompleteSelectMultiple):
|
||||
component_name = "company-ajax-select"
|
||||
model = Company
|
||||
adapter = TypeAdapter(list[CompanySchema])
|
||||
|
||||
js = _js
|
||||
+8
-12
@@ -1,3 +1,5 @@
|
||||
import re
|
||||
|
||||
from django import forms
|
||||
from django.core.validators import EmailValidator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -5,18 +7,12 @@ 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]
|
||||
def run_validators(self, value: str):
|
||||
super().run_validators(value)
|
||||
# Domain part should exist since email validation is guaranteed to run first
|
||||
domain = re.search(EmailValidator.domain_regex, value)
|
||||
if ToxicDomain.objects.filter(domain=domain[0]).exists():
|
||||
raise forms.ValidationError(_("Email domain is not allowed."))
|
||||
|
||||
@@ -34,7 +34,7 @@ class Command(BaseCommand):
|
||||
f"Source {provider} responded with code {res.status_code}"
|
||||
)
|
||||
continue
|
||||
domains |= set(res.text.splitlines())
|
||||
domains |= set(res.content.decode().splitlines())
|
||||
return domains
|
||||
|
||||
def _update_domains(self, domains: set[str]):
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
from django.contrib import admin, messages
|
||||
from django.db.models import QuerySet
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from api.hashers import generate_key
|
||||
from api.models import ApiClient, ApiKey
|
||||
|
||||
|
||||
@admin.register(ApiClient)
|
||||
class ApiClientAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "owner", "created_at", "updated_at")
|
||||
search_fields = (
|
||||
"name",
|
||||
"owner__first_name",
|
||||
"owner__last_name",
|
||||
"owner__nick_name",
|
||||
)
|
||||
autocomplete_fields = ("owner", "groups", "client_permissions")
|
||||
|
||||
|
||||
@admin.register(ApiKey)
|
||||
class ApiKeyAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "client", "created_at", "revoked")
|
||||
list_filter = ("revoked",)
|
||||
date_hierarchy = "created_at"
|
||||
|
||||
readonly_fields = ("prefix", "hashed_key")
|
||||
actions = ("revoke_keys",)
|
||||
|
||||
def save_model(self, request: HttpRequest, obj: ApiKey, form, change):
|
||||
if not change:
|
||||
key, hashed = generate_key()
|
||||
obj.prefix = key[: ApiKey.PREFIX_LENGTH]
|
||||
obj.hashed_key = hashed
|
||||
self.message_user(
|
||||
request,
|
||||
_(
|
||||
"The API key for %(name)s is: %(key)s. "
|
||||
"Please store it somewhere safe: "
|
||||
"you will not be able to see it again."
|
||||
)
|
||||
% {"name": obj.name, "key": key},
|
||||
level=messages.WARNING,
|
||||
)
|
||||
return super().save_model(request, obj, form, change)
|
||||
|
||||
def get_readonly_fields(self, request, obj: ApiKey | None = None):
|
||||
if obj is None or obj.revoked:
|
||||
return ["revoked", *self.readonly_fields]
|
||||
return self.readonly_fields
|
||||
|
||||
@admin.action(description=_("Revoke selected API keys"))
|
||||
def revoke_keys(self, _request: HttpRequest, queryset: QuerySet[ApiKey]):
|
||||
queryset.update(revoked=True)
|
||||
@@ -1,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "api"
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
from django.http import HttpRequest
|
||||
from ninja.security import APIKeyHeader
|
||||
|
||||
from api.hashers import get_hasher
|
||||
from api.models import ApiClient, ApiKey
|
||||
|
||||
|
||||
class ApiKeyAuth(APIKeyHeader):
|
||||
"""Authentication through client api keys."""
|
||||
|
||||
param_name = "X-APIKey"
|
||||
|
||||
def authenticate(self, request: HttpRequest, key: str | None) -> ApiClient | None:
|
||||
if not key or len(key) != ApiKey.KEY_LENGTH:
|
||||
return None
|
||||
hasher = get_hasher()
|
||||
hashed_key = hasher.encode(key)
|
||||
try:
|
||||
key_obj = ApiKey.objects.get(revoked=False, hashed_key=hashed_key)
|
||||
except ApiKey.DoesNotExist:
|
||||
return None
|
||||
return key_obj.client
|
||||
@@ -1,43 +0,0 @@
|
||||
import functools
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from django.contrib.auth.hashers import BasePasswordHasher
|
||||
from django.utils.crypto import constant_time_compare
|
||||
|
||||
|
||||
class Sha512ApiKeyHasher(BasePasswordHasher):
|
||||
"""
|
||||
An API key hasher using the sha512 algorithm.
|
||||
|
||||
This hasher shouldn't be used in Django's `PASSWORD_HASHERS` setting.
|
||||
It is insecure for use in hashing passwords, but is safe for hashing
|
||||
high entropy, randomly generated API keys.
|
||||
"""
|
||||
|
||||
algorithm = "sha512"
|
||||
|
||||
def salt(self) -> str:
|
||||
# No need for a salt on a high entropy key.
|
||||
return ""
|
||||
|
||||
def encode(self, password: str, salt: str = "") -> str:
|
||||
hashed = hashlib.sha512(password.encode()).hexdigest()
|
||||
return f"{self.algorithm}$${hashed}"
|
||||
|
||||
def verify(self, password: str, encoded: str) -> bool:
|
||||
encoded_2 = self.encode(password, "")
|
||||
return constant_time_compare(encoded, encoded_2)
|
||||
|
||||
|
||||
@functools.cache
|
||||
def get_hasher():
|
||||
return Sha512ApiKeyHasher()
|
||||
|
||||
|
||||
def generate_key() -> tuple[str, str]:
|
||||
"""Generate a [key, hash] couple."""
|
||||
# this will result in key with a length of 72
|
||||
key = str(secrets.token_urlsafe(54))
|
||||
hasher = get_hasher()
|
||||
return key, hasher.encode(key)
|
||||
@@ -1,113 +0,0 @@
|
||||
# Generated by Django 5.2 on 2025-06-01 08:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
("core", "0046_permissionrights"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ApiClient",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=64, verbose_name="name")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"client_permissions",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Specific permissions for this api client.",
|
||||
related_name="clients",
|
||||
to="auth.permission",
|
||||
verbose_name="client permissions",
|
||||
),
|
||||
),
|
||||
(
|
||||
"groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="api_clients",
|
||||
to="core.group",
|
||||
verbose_name="groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="api_clients",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="owner",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "api client",
|
||||
"verbose_name_plural": "api clients",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ApiKey",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(blank=True, default="", verbose_name="name")),
|
||||
(
|
||||
"prefix",
|
||||
models.CharField(
|
||||
editable=False, max_length=5, verbose_name="prefix"
|
||||
),
|
||||
),
|
||||
(
|
||||
"hashed_key",
|
||||
models.CharField(
|
||||
db_index=True,
|
||||
editable=False,
|
||||
max_length=136,
|
||||
verbose_name="hashed key",
|
||||
),
|
||||
),
|
||||
("revoked", models.BooleanField(default=False, verbose_name="revoked")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"client",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="api_keys",
|
||||
to="api.apiclient",
|
||||
verbose_name="api client",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "api key",
|
||||
"verbose_name_plural": "api keys",
|
||||
"permissions": [("revoke_apikey", "Revoke API keys")],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,94 +0,0 @@
|
||||
from typing import Iterable
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import pgettext_lazy
|
||||
|
||||
from core.models import Group, User
|
||||
|
||||
|
||||
class ApiClient(models.Model):
|
||||
name = models.CharField(_("name"), max_length=64)
|
||||
owner = models.ForeignKey(
|
||||
User,
|
||||
verbose_name=_("owner"),
|
||||
related_name="api_clients",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
groups = models.ManyToManyField(
|
||||
Group, verbose_name=_("groups"), related_name="api_clients", blank=True
|
||||
)
|
||||
client_permissions = models.ManyToManyField(
|
||||
Permission,
|
||||
verbose_name=_("client permissions"),
|
||||
blank=True,
|
||||
help_text=_("Specific permissions for this api client."),
|
||||
related_name="clients",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
_perm_cache: set[str] | None = None
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("api client")
|
||||
verbose_name_plural = _("api clients")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def has_perm(self, perm: str):
|
||||
"""Return True if the client has the specified permission."""
|
||||
|
||||
if self._perm_cache is None:
|
||||
group_permissions = (
|
||||
Permission.objects.filter(group__group__in=self.groups.all())
|
||||
.values_list("content_type__app_label", "codename")
|
||||
.order_by()
|
||||
)
|
||||
client_permissions = self.client_permissions.values_list(
|
||||
"content_type__app_label", "codename"
|
||||
).order_by()
|
||||
self._perm_cache = {
|
||||
f"{content_type}.{name}"
|
||||
for content_type, name in (*group_permissions, *client_permissions)
|
||||
}
|
||||
return perm in self._perm_cache
|
||||
|
||||
def has_perms(self, perm_list):
|
||||
"""
|
||||
Return True if the client has each of the specified permissions. If
|
||||
object is passed, check if the client has all required perms for it.
|
||||
"""
|
||||
if not isinstance(perm_list, Iterable) or isinstance(perm_list, str):
|
||||
raise ValueError("perm_list must be an iterable of permissions.")
|
||||
return all(self.has_perm(perm) for perm in perm_list)
|
||||
|
||||
|
||||
class ApiKey(models.Model):
|
||||
PREFIX_LENGTH = 5
|
||||
KEY_LENGTH = 72
|
||||
HASHED_KEY_LENGTH = 136
|
||||
|
||||
name = models.CharField(_("name"), blank=True, default="")
|
||||
prefix = models.CharField(_("prefix"), max_length=PREFIX_LENGTH, editable=False)
|
||||
hashed_key = models.CharField(
|
||||
_("hashed key"), max_length=HASHED_KEY_LENGTH, db_index=True, editable=False
|
||||
)
|
||||
client = models.ForeignKey(
|
||||
ApiClient,
|
||||
verbose_name=_("api client"),
|
||||
related_name="api_keys",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
revoked = models.BooleanField(pgettext_lazy("api key", "revoked"), default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("api key")
|
||||
verbose_name_plural = _("api keys")
|
||||
permissions = [("revoke_apikey", "Revoke API keys")]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.prefix}***)"
|
||||
@@ -1,29 +0,0 @@
|
||||
import pytest
|
||||
from django.test import RequestFactory
|
||||
from model_bakery import baker
|
||||
|
||||
from api.auth import ApiKeyAuth
|
||||
from api.hashers import generate_key
|
||||
from api.models import ApiClient, ApiKey
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_api_key_auth():
|
||||
key, hashed = generate_key()
|
||||
client = baker.make(ApiClient)
|
||||
baker.make(ApiKey, client=client, hashed_key=hashed)
|
||||
auth = ApiKeyAuth()
|
||||
|
||||
assert auth.authenticate(RequestFactory().get(""), key) == client
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
("key", "hashed"), [(generate_key()[0], generate_key()[1]), (generate_key()[0], "")]
|
||||
)
|
||||
def test_api_key_auth_invalid(key, hashed):
|
||||
client = baker.make(ApiClient)
|
||||
baker.make(ApiKey, client=client, hashed_key=hashed)
|
||||
auth = ApiKeyAuth()
|
||||
|
||||
assert auth.authenticate(RequestFactory().get(""), key) is None
|
||||
@@ -1,48 +0,0 @@
|
||||
import pytest
|
||||
from django.test import Client
|
||||
from django.urls import path
|
||||
from model_bakery import baker
|
||||
from ninja import NinjaAPI
|
||||
from ninja.security import SessionAuth
|
||||
|
||||
from api.auth import ApiKeyAuth
|
||||
from api.hashers import generate_key
|
||||
from api.models import ApiClient, ApiKey
|
||||
|
||||
api = NinjaAPI()
|
||||
|
||||
|
||||
@api.post("", auth=[ApiKeyAuth(), SessionAuth()])
|
||||
def post_method(*args, **kwargs) -> None:
|
||||
"""Dummy POST route authenticated by either api key or session cookie."""
|
||||
pass
|
||||
|
||||
|
||||
urlpatterns = [path("", api.urls)]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.urls(__name__)
|
||||
@pytest.mark.parametrize("user_logged_in", [False, True])
|
||||
def test_csrf_token(user_logged_in):
|
||||
"""Test that CSRF check happens only when no api key is used."""
|
||||
client = Client(enforce_csrf_checks=True)
|
||||
key, hashed = generate_key()
|
||||
api_client = baker.make(ApiClient)
|
||||
baker.make(ApiKey, client=api_client, hashed_key=hashed)
|
||||
if user_logged_in:
|
||||
client.force_login(api_client.owner)
|
||||
|
||||
response = client.post("")
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "CSRF check Failed"
|
||||
|
||||
# if using a valid API key, CSRF check should not occur
|
||||
response = client.post("", headers={"X-APIKey": key})
|
||||
assert response.status_code == 200
|
||||
|
||||
# if using a wrong API key, ApiKeyAuth should fail,
|
||||
# leading to a fallback into SessionAuth and a CSRF check
|
||||
response = client.post("", headers={"X-APIKey": generate_key()[0]})
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "CSRF check Failed"
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
from ninja.security import SessionAuth
|
||||
from ninja_extra import NinjaExtraAPI
|
||||
|
||||
api = NinjaExtraAPI(
|
||||
title="PICON",
|
||||
description="Portail Interactif de Communication avec les Outils Numériques",
|
||||
version="0.2.0",
|
||||
urls_namespace="api",
|
||||
auth=[SessionAuth()],
|
||||
)
|
||||
api.auto_discover_controllers()
|
||||
+6
-20
@@ -7,37 +7,23 @@
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**/static/**", "vite.config.mts"]
|
||||
"ignore": ["*.min.*", "staticfiles/generated"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"lineWidth": 88
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"useNamingConvention": "error"
|
||||
},
|
||||
"performance": {
|
||||
"noNamespaceImport": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noConsole": {
|
||||
"level": "error",
|
||||
"options": { "allow": ["error", "warn"] }
|
||||
}
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "error",
|
||||
"noUndeclaredVariables": "error",
|
||||
"noUndeclaredDependencies": "error"
|
||||
}
|
||||
"all": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"globals": ["Alpine", "gettext", "interpolate"]
|
||||
"globals": ["Alpine", "$", "jQuery", "gettext", "interpolate"]
|
||||
}
|
||||
}
|
||||
|
||||
+3
-45
@@ -13,16 +13,14 @@
|
||||
#
|
||||
#
|
||||
from django.contrib import admin
|
||||
from django.forms.models import ModelForm
|
||||
from django.http import HttpRequest
|
||||
|
||||
from club.models import Club, ClubLink, ClubRole, LinkType, Membership
|
||||
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")
|
||||
list_display = ("name", "unix_name", "parent", "is_active")
|
||||
search_fields = ("name", "unix_name")
|
||||
autocomplete_fields = (
|
||||
"parent",
|
||||
"board_group",
|
||||
@@ -31,31 +29,6 @@ class ClubAdmin(admin.ModelAdmin):
|
||||
"page",
|
||||
)
|
||||
|
||||
def save_model(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
obj: Club,
|
||||
form: ModelForm,
|
||||
change: bool, # noqa: FBT001
|
||||
):
|
||||
super().save_model(request, obj, form, change)
|
||||
if not change:
|
||||
obj.create_default_roles()
|
||||
|
||||
|
||||
@admin.register(ClubRole)
|
||||
class ClubRoleAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "club", "is_board", "is_presidency")
|
||||
search_fields = ("name",)
|
||||
autocomplete_fields = ("club",)
|
||||
list_select_related = ("club",)
|
||||
list_filter = (
|
||||
"is_board",
|
||||
"is_presidency",
|
||||
("club", admin.RelatedOnlyFieldListFilter),
|
||||
)
|
||||
show_facets = admin.ModelAdmin.show_facets.ALWAYS
|
||||
|
||||
|
||||
@admin.register(Membership)
|
||||
class MembershipAdmin(admin.ModelAdmin):
|
||||
@@ -67,18 +40,3 @@ class MembershipAdmin(admin.ModelAdmin):
|
||||
"club__name",
|
||||
)
|
||||
autocomplete_fields = ("user",)
|
||||
|
||||
|
||||
@admin.register(LinkType)
|
||||
class LinkTypeAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "url_base", "icon")
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@admin.register(ClubLink)
|
||||
class ClubLinkAdmin(admin.ModelAdmin):
|
||||
list_display = ("link_type", "club", "url")
|
||||
list_select_related = ("link_type", "club")
|
||||
autocomplete_fields = ("link_type", "club")
|
||||
search_fields = ("link_type__name", "url")
|
||||
list_filter = ("link_type", ("club", admin.RelatedOnlyFieldListFilter))
|
||||
|
||||
+10
-52
@@ -1,64 +1,22 @@
|
||||
from django.db.models import Prefetch
|
||||
from ninja import Query
|
||||
from ninja.security import SessionAuth
|
||||
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 api.auth import ApiKeyAuth
|
||||
from api.permissions import CanView, HasPerm
|
||||
from club.models import Club, Membership
|
||||
from club.schemas import (
|
||||
ClubSchema,
|
||||
ClubSearchFilterSchema,
|
||||
SimpleClubSchema,
|
||||
UserMembershipSchema,
|
||||
)
|
||||
from core.models import User
|
||||
from club.models import Club
|
||||
from club.schemas import ClubSchema
|
||||
from core.api_permissions import CanAccessLookup
|
||||
|
||||
|
||||
@api_controller("/club")
|
||||
class ClubController(ControllerBase):
|
||||
@route.get(
|
||||
"/search",
|
||||
response=PaginatedResponseSchema[SimpleClubSchema],
|
||||
url_name="search_club",
|
||||
response=PaginatedResponseSchema[ClubSchema],
|
||||
permissions=[CanAccessLookup],
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
def search_club(self, filters: Query[ClubSearchFilterSchema]):
|
||||
return filters.filter(Club.objects.order_by("name")).values()
|
||||
|
||||
@route.get(
|
||||
"/{int:club_id}",
|
||||
response=ClubSchema,
|
||||
auth=[ApiKeyAuth(), SessionAuth()],
|
||||
permissions=[HasPerm("club.view_club")],
|
||||
url_name="fetch_club",
|
||||
)
|
||||
def fetch_club(self, club_id: int):
|
||||
prefetch = Prefetch(
|
||||
"members",
|
||||
queryset=Membership.objects.ongoing().select_related("user", "role"),
|
||||
)
|
||||
return self.get_object_or_exception(
|
||||
Club.objects.prefetch_related(prefetch, "links"), id=club_id
|
||||
)
|
||||
|
||||
|
||||
@api_controller("/user/{int:user_id}/club")
|
||||
class UserClubController(ControllerBase):
|
||||
@route.get(
|
||||
"",
|
||||
response=list[UserMembershipSchema],
|
||||
auth=[ApiKeyAuth(), SessionAuth()],
|
||||
permissions=[CanView],
|
||||
url_name="fetch_user_clubs",
|
||||
)
|
||||
def fetch_user_clubs(self, user_id: int):
|
||||
"""Get all the active memberships of the given user."""
|
||||
user = self.get_object_or_exception(User, id=user_id)
|
||||
return (
|
||||
Membership.objects.ongoing()
|
||||
.filter(user=user)
|
||||
.select_related("club", "user", "role")
|
||||
)
|
||||
def search_club(self, search: Annotated[str, MinLen(1)]):
|
||||
return Club.objects.filter(name__icontains=search).values()
|
||||
|
||||
+99
-257
@@ -23,78 +23,24 @@
|
||||
#
|
||||
|
||||
from django import forms
|
||||
from django.db.models import Exists, OuterRef, Q, QuerySet
|
||||
from django.db.models.functions import Lower
|
||||
from django.utils.functional import cached_property
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from club.models import (
|
||||
Club,
|
||||
ClubLink,
|
||||
ClubRole,
|
||||
Mailing,
|
||||
MailingSubscription,
|
||||
Membership,
|
||||
)
|
||||
from club.models import Club, Mailing, MailingSubscription, Membership
|
||||
from core.models import User
|
||||
from core.views.forms import SelectDateTime
|
||||
from core.views.widgets.ajax_select import (
|
||||
AutoCompleteSelectMultipleUser,
|
||||
AutoCompleteSelectUser,
|
||||
)
|
||||
from counter.models import Counter, Selling
|
||||
from counter.schemas import SaleFilterSchema
|
||||
|
||||
|
||||
class ClubLinkForm(forms.ModelForm):
|
||||
error_css_class = "error"
|
||||
required_css_class = "required"
|
||||
|
||||
class Meta:
|
||||
model = ClubLink
|
||||
fields = ["url", "name", "link_type"]
|
||||
widgets = {
|
||||
"url": forms.URLInput(
|
||||
{"pattern": "https://.*", "placeholder": "https://monlien.com"}
|
||||
),
|
||||
"link_type": forms.HiddenInput(),
|
||||
}
|
||||
|
||||
|
||||
ClubLinkFormSet = forms.inlineformset_factory(
|
||||
Club, ClubLink, ClubLinkForm, extra=0, can_delete_extra=False
|
||||
)
|
||||
from core.views.forms import SelectDate, SelectDateTime
|
||||
from core.views.widgets.select import AutoCompleteSelectMultipleUser
|
||||
from counter.models import Counter
|
||||
|
||||
|
||||
class ClubEditForm(forms.ModelForm):
|
||||
error_css_class = "error"
|
||||
required_css_class = "required"
|
||||
|
||||
class Meta:
|
||||
model = Club
|
||||
fields = ["address", "logo", "short_description"]
|
||||
widgets = {"short_description": forms.Textarea()}
|
||||
|
||||
def __init__(self, *args, prefix: str | None = None, instance=None, **kwargs):
|
||||
super().__init__(*args, prefix=prefix, instance=instance, **kwargs)
|
||||
self.link_formset = ClubLinkFormSet(
|
||||
*args, instance=self.instance, prefix="link", **kwargs
|
||||
)
|
||||
|
||||
def is_valid(self):
|
||||
return super().is_valid() and self.link_formset.is_valid()
|
||||
|
||||
def save(self, commit=True): # noqa: FBT002
|
||||
res = super().save(commit=commit)
|
||||
self.link_formset.save(commit=commit)
|
||||
return res
|
||||
|
||||
|
||||
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().__init__(*args, **kwargs)
|
||||
self.fields["short_description"].widget = forms.Textarea()
|
||||
|
||||
|
||||
class MailingForm(forms.Form):
|
||||
@@ -206,21 +152,12 @@ class SellingsForm(forms.Form):
|
||||
label=_("End date"), widget=SelectDateTime, 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)
|
||||
# postgres struggles really hard with a single query having three WHERE conditions,
|
||||
# but deals perfectly fine with UNION of multiple queryset with their own WHERE clause,
|
||||
# so we do this to get the ids, which we use to build another queryset that can be used by django.
|
||||
club_sales_subquery = Selling.objects.filter(counter=OuterRef("pk"), club=club)
|
||||
ids = (
|
||||
Counter.objects.filter(Q(club=club) | Q(products__club=club))
|
||||
.union(Counter.objects.filter(Exists(club_sales_subquery)))
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
counters_qs = Counter.objects.filter(id__in=ids).order_by(Lower("name"))
|
||||
self.fields["counters"] = forms.ModelMultipleChoiceField(
|
||||
counters_qs, label=_("Counter"), required=False
|
||||
)
|
||||
self.fields["products"] = forms.ModelMultipleChoiceField(
|
||||
club.products.order_by("name").filter(archived=False).all(),
|
||||
label=_("Products"),
|
||||
@@ -232,203 +169,108 @@ class SellingsForm(forms.Form):
|
||||
required=False,
|
||||
)
|
||||
|
||||
def to_filter_schema(self) -> SaleFilterSchema:
|
||||
products = (
|
||||
*self.cleaned_data["products"],
|
||||
*self.cleaned_data["archived_products"],
|
||||
)
|
||||
return SaleFilterSchema(
|
||||
after=self.cleaned_data["begin_date"],
|
||||
before=self.cleaned_data["end_date"],
|
||||
counters={c.id for c in self.cleaned_data["counters"]} or None,
|
||||
products={p.id for p in products} or None,
|
||||
)
|
||||
|
||||
|
||||
class ClubOldMemberForm(forms.Form):
|
||||
members_old = forms.ModelMultipleChoiceField(
|
||||
Membership.objects.none(),
|
||||
label=_("Mark as old"),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False,
|
||||
)
|
||||
|
||||
def __init__(self, *args, user: User, club: Club, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["members_old"].queryset = club.members.ongoing().editable_by(user)
|
||||
|
||||
|
||||
class ClubMemberForm(forms.ModelForm):
|
||||
"""Form to add a member to the club, as a board member."""
|
||||
class ClubMemberForm(forms.Form):
|
||||
"""Form handling the members of a club."""
|
||||
|
||||
error_css_class = "error"
|
||||
required_css_class = "required"
|
||||
|
||||
class Meta:
|
||||
model = Membership
|
||||
fields = ["role", "description"]
|
||||
users = forms.ModelMultipleChoiceField(
|
||||
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, club: Club, request_user: User, **kwargs):
|
||||
self.club = club
|
||||
self.request_user = request_user
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.club = kwargs.pop("club")
|
||||
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.filter(end_date=None).order_by("-role").all()
|
||||
)
|
||||
self.request_user_membership = self.club.get_membership_for(self.request_user)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["role"].queryset = self.available_roles
|
||||
self.instance.club = club
|
||||
|
||||
@property
|
||||
def available_roles(self) -> QuerySet[ClubRole]:
|
||||
"""The roles that will be obtainable with this form."""
|
||||
# this is unreachable, because it will be overridden by subclasses
|
||||
return ClubRole.objects.none() # pragma: no cover
|
||||
# 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
|
||||
# We also want the form to handle bulk deletion
|
||||
self.fields.update(
|
||||
forms.fields_for_model(
|
||||
Membership,
|
||||
fields=("role", "start_date", "description"),
|
||||
widgets={"start_date": SelectDate},
|
||||
)
|
||||
)
|
||||
|
||||
# Role is required only if users is specified
|
||||
self.fields["role"].required = False
|
||||
|
||||
class ClubAddMemberForm(ClubMemberForm):
|
||||
"""Form to add a member to the club, as a board member."""
|
||||
# Start date and description are never really required
|
||||
self.fields["start_date"].required = False
|
||||
self.fields["description"].required = False
|
||||
|
||||
class Meta(ClubMemberForm.Meta):
|
||||
fields = ["user", *ClubMemberForm.Meta.fields]
|
||||
widgets = {"user": AutoCompleteSelectUser}
|
||||
self.fields["users_old"] = forms.ModelMultipleChoiceField(
|
||||
User.objects.filter(
|
||||
id__in=[
|
||||
ms.user.id
|
||||
for ms in self.club_members
|
||||
if ms.can_be_edited_by(self.request_user)
|
||||
]
|
||||
).all(),
|
||||
label=_("Mark as old"),
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
)
|
||||
if not self.request_user.is_root:
|
||||
self.fields.pop("start_date")
|
||||
|
||||
@cached_property
|
||||
def available_roles(self):
|
||||
"""The roles that will be obtainable with this form.
|
||||
|
||||
Admins and the club president can attribute any role.
|
||||
Board members can attribute roles lower than their own.
|
||||
Other users cannot attribute roles with this form
|
||||
"""
|
||||
qs = self.club.roles.filter(is_active=True)
|
||||
if self.request_user.has_perm("club.add_membership"):
|
||||
return qs.all()
|
||||
membership = self.request_user_membership
|
||||
if membership is None or not membership.role.is_board:
|
||||
return ClubRole.objects.none()
|
||||
if membership.role.is_presidency:
|
||||
return qs.all()
|
||||
return qs.above_instance(membership.role)
|
||||
|
||||
def clean_user(self):
|
||||
"""Check that the user is not trying to add a user already in the club.
|
||||
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.
|
||||
"""
|
||||
user = self.cleaned_data["user"]
|
||||
if not user.is_subscribed:
|
||||
raise forms.ValidationError(
|
||||
_("User must be subscriber to take part to a club"), code="invalid"
|
||||
)
|
||||
if self.club.get_membership_for(user):
|
||||
raise forms.ValidationError(
|
||||
_("You can not add the same user twice"), code="invalid"
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
class JoinClubForm(ClubMemberForm):
|
||||
"""Form to join a club."""
|
||||
|
||||
def __init__(self, *args, club: Club, request_user: User, **kwargs):
|
||||
super().__init__(*args, club=club, request_user=request_user, **kwargs)
|
||||
self.instance.user = self.request_user
|
||||
|
||||
@cached_property
|
||||
def available_roles(self):
|
||||
return self.club.roles.filter(is_board=False, is_active=True)
|
||||
|
||||
def clean(self):
|
||||
"""Check that the user is subscribed and isn't already in the club."""
|
||||
if not self.request_user.is_subscribed:
|
||||
raise forms.ValidationError(
|
||||
_("You must be subscribed to join a club"), code="invalid"
|
||||
)
|
||||
if self.club.get_membership_for(self.request_user):
|
||||
raise forms.ValidationError(
|
||||
_("You are already a member of this club"), code="invalid"
|
||||
)
|
||||
return super().clean()
|
||||
|
||||
|
||||
class ClubSearchForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Club
|
||||
fields = ["name"]
|
||||
widgets = {"name": forms.SearchInput(attrs={"autocomplete": "off"})}
|
||||
|
||||
club_status = forms.NullBooleanField(
|
||||
label=_("Club status"),
|
||||
widget=forms.RadioSelect(
|
||||
choices=[(True, _("Active")), (False, _("Inactive")), ("", _("All clubs"))],
|
||||
),
|
||||
initial=True,
|
||||
)
|
||||
|
||||
def __init__(self, *args, data: dict | None = None, **kwargs):
|
||||
super().__init__(*args, data=data, **kwargs)
|
||||
if data is not None and "club_status" not in data:
|
||||
# if the key is missing, it is considered as None,
|
||||
# even though we want the default True value to be applied in such a case
|
||||
# so we enforce it.
|
||||
self.fields["club_status"].value = True
|
||||
self.fields["name"].required = False
|
||||
|
||||
|
||||
class ClubRoleForm(forms.ModelForm):
|
||||
error_css_class = "error"
|
||||
required_css_class = "required"
|
||||
|
||||
class Meta:
|
||||
model = ClubRole
|
||||
fields = ["name", "description", "is_presidency", "is_board", "is_active"]
|
||||
widgets = {
|
||||
"is_presidency": forms.HiddenInput(),
|
||||
"is_board": forms.HiddenInput(),
|
||||
"is_active": forms.CheckboxInput(attrs={"class": "switch"}),
|
||||
}
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
if "ORDER" in cleaned_data:
|
||||
self.instance.order = cleaned_data["ORDER"] - 1
|
||||
users = []
|
||||
for user in cleaned_data["users"]:
|
||||
if not user.is_subscribed:
|
||||
raise forms.ValidationError(
|
||||
_("User must be subscriber to take part to a club"), code="invalid"
|
||||
)
|
||||
if self.club.get_membership_for(user):
|
||||
raise forms.ValidationError(
|
||||
_("You can not add the same user twice"), code="invalid"
|
||||
)
|
||||
users.append(user)
|
||||
return users
|
||||
|
||||
def clean(self):
|
||||
"""Check user rights for adding an user."""
|
||||
cleaned_data = super().clean()
|
||||
|
||||
if "start_date" in cleaned_data and not cleaned_data["start_date"]:
|
||||
# Drop start_date if allowed to edition but not specified
|
||||
cleaned_data.pop("start_date")
|
||||
|
||||
if not cleaned_data.get("users"):
|
||||
# No user to add equals no check needed
|
||||
return cleaned_data
|
||||
|
||||
if cleaned_data.get("role", "") == "":
|
||||
# Role is required if users exists
|
||||
self.add_error("role", _("You should specify a role"))
|
||||
return cleaned_data
|
||||
|
||||
request_user = self.request_user
|
||||
membership = self.request_user_membership
|
||||
if not (
|
||||
cleaned_data["role"] <= settings.SITH_MAXIMUM_FREE_ROLE
|
||||
or (membership is not None and membership.role >= cleaned_data["role"])
|
||||
or request_user.is_board_member
|
||||
or request_user.is_root
|
||||
):
|
||||
raise forms.ValidationError(_("You do not have the permission to do that"))
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class ClubRoleCreateForm(forms.ModelForm):
|
||||
"""Form to create a club role.
|
||||
|
||||
Notes:
|
||||
For UX purposes, users are not meant to fill `is_presidency`
|
||||
and `is_board`, so those values are required by the form constructor
|
||||
in order to initialize the instance properly.
|
||||
"""
|
||||
|
||||
error_css_class = "error"
|
||||
required_css_class = "required"
|
||||
|
||||
class Meta:
|
||||
model = ClubRole
|
||||
fields = ["name", "description"]
|
||||
|
||||
def __init__(
|
||||
self, *args, club: Club, is_presidency: bool, is_board: bool, **kwargs
|
||||
):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.instance.club = club
|
||||
self.instance.is_presidency = is_presidency
|
||||
self.instance.is_board = is_board
|
||||
|
||||
|
||||
class ClubRoleBaseFormSet(forms.BaseInlineFormSet):
|
||||
ordering_widget = forms.HiddenInput()
|
||||
|
||||
|
||||
ClubRoleFormSet = forms.inlineformset_factory(
|
||||
Club,
|
||||
ClubRole,
|
||||
ClubRoleForm,
|
||||
ClubRoleBaseFormSet,
|
||||
can_delete=False,
|
||||
can_order=True,
|
||||
edit_only=True,
|
||||
extra=0,
|
||||
)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import club.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("club", "0010_auto_20170912_2028")]
|
||||
@@ -14,7 +15,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.get_default_owner_group,
|
||||
related_name="owned_club",
|
||||
to="core.Group",
|
||||
),
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
|
||||
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
|
||||
|
||||
# Before the club role rework, the maximum free role
|
||||
# was the hardcoded highest non-board role
|
||||
MAXIMUM_FREE_ROLE = 1
|
||||
|
||||
|
||||
def migrate_meta_groups(apps: StateApps, schema_editor):
|
||||
"""Attach the existing meta groups to the clubs.
|
||||
@@ -37,10 +34,12 @@ def migrate_meta_groups(apps: StateApps, schema_editor):
|
||||
clubs = list(Club.objects.all())
|
||||
for club in clubs:
|
||||
club.board_group = meta_groups.get_or_create(
|
||||
name=f"{club.unix_name}-bureau", defaults={"is_meta": True}
|
||||
name=club.unix_name + settings.SITH_BOARD_SUFFIX,
|
||||
defaults={"is_meta": True},
|
||||
)[0]
|
||||
club.members_group = meta_groups.get_or_create(
|
||||
name=f"{club.unix_name}-membres", defaults={"is_meta": True}
|
||||
name=club.unix_name + settings.SITH_MEMBER_SUFFIX,
|
||||
defaults={"is_meta": True},
|
||||
)[0]
|
||||
club.save()
|
||||
club.refresh_from_db()
|
||||
@@ -49,7 +48,10 @@ def migrate_meta_groups(apps: StateApps, schema_editor):
|
||||
).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=MAXIMUM_FREE_ROLE)]
|
||||
[
|
||||
m.user
|
||||
for m in memberships.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AddConstraint(
|
||||
model_name="membership",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(("end_date__gte", models.F("start_date"))),
|
||||
check=models.Q(("end_date__gte", models.F("start_date"))),
|
||||
name="end_after_start",
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# Generated by Django 4.2.17 on 2025-02-28 20:34
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import core.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0044_alter_userban_options"),
|
||||
("club", "0013_alter_club_board_group_alter_club_members_group_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(name="club", options={"ordering": ["name"]}),
|
||||
migrations.RenameField(
|
||||
model_name="club",
|
||||
old_name="unix_name",
|
||||
new_name="slug_name",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="club",
|
||||
name="name",
|
||||
field=models.CharField(unique=True, max_length=64, verbose_name="name"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="club",
|
||||
name="slug_name",
|
||||
field=models.SlugField(
|
||||
editable=False, max_length=30, unique=True, verbose_name="slug name"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="club",
|
||||
name="id",
|
||||
field=models.AutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="club",
|
||||
name="logo",
|
||||
field=core.fields.ResizedImageField(
|
||||
blank=True,
|
||||
force_format="WEBP",
|
||||
height=200,
|
||||
null=True,
|
||||
upload_to="club_logos",
|
||||
verbose_name="logo",
|
||||
width=200,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="club",
|
||||
name="page",
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="club",
|
||||
to="core.page",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="club",
|
||||
name="short_description",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="A summary of what your club does. This will be displayed on the club list page.",
|
||||
max_length=1000,
|
||||
verbose_name="short description",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,161 +0,0 @@
|
||||
# Generated by Django 5.2.3 on 2025-06-21 21:59
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
from django.db.migrations.state import StateApps
|
||||
from django.db.models import Case, When
|
||||
|
||||
PRESIDENCY_ROLES = [10, 9]
|
||||
MAXIMUM_FREE_ROLE = 1
|
||||
SITH_CLUB_ROLES = {
|
||||
10: "Président⸱e",
|
||||
9: "Vice-Président⸱e",
|
||||
7: "Trésorier⸱e",
|
||||
5: "Responsable communication",
|
||||
4: "Secrétaire",
|
||||
3: "Responsable info",
|
||||
2: "Membre du bureau",
|
||||
1: "Membre actif⸱ve",
|
||||
0: "Curieux⸱euse",
|
||||
}
|
||||
|
||||
|
||||
def migrate_roles(apps: StateApps, schema_editor):
|
||||
ClubRole = apps.get_model("club", "ClubRole")
|
||||
Membership = apps.get_model("club", "Membership")
|
||||
|
||||
updates = []
|
||||
for club_id, role in Membership.objects.values_list("club", "role").distinct():
|
||||
new_role = ClubRole.objects.create(
|
||||
name=SITH_CLUB_ROLES[role],
|
||||
is_board=role > MAXIMUM_FREE_ROLE,
|
||||
is_presidency=role in PRESIDENCY_ROLES,
|
||||
club_id=club_id,
|
||||
order=max(SITH_CLUB_ROLES) - role,
|
||||
)
|
||||
updates.append(When(club_id=club_id, role=role, then=new_role.id))
|
||||
# all updates must happen at the same time
|
||||
# otherwise, the 10 first created ClubRole would be
|
||||
# re-modified after their initial creation, and it would
|
||||
# result in an incoherent state.
|
||||
# To avoid that, all updates are wrapped in a single giant Case(When) statement
|
||||
# cf. https://docs.djangoproject.com/fr/stable/ref/models/conditional-expressions/#conditional-update
|
||||
Membership.objects.update(role=Case(*updates))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"),
|
||||
("core", "0047_alter_notification_date_alter_notification_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="club",
|
||||
name="page",
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="club",
|
||||
to="core.page",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ClubRole",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"order",
|
||||
models.PositiveIntegerField(
|
||||
db_index=True, editable=False, verbose_name="order"
|
||||
),
|
||||
),
|
||||
(
|
||||
"club",
|
||||
models.ForeignKey(
|
||||
help_text="The club with which this role is associated",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="roles",
|
||||
to="club.club",
|
||||
verbose_name="club",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=50, verbose_name="name")),
|
||||
(
|
||||
"description",
|
||||
models.TextField(
|
||||
default="", blank=True, verbose_name="description"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_board",
|
||||
models.BooleanField(default=False, verbose_name="Board role"),
|
||||
),
|
||||
(
|
||||
"is_presidency",
|
||||
models.BooleanField(default=False, verbose_name="Presidency role"),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text=(
|
||||
"If the role is inactive, people joining the club "
|
||||
"won't be able to get it."
|
||||
),
|
||||
verbose_name="is active",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ("order",),
|
||||
"verbose_name": "club role",
|
||||
"verbose_name_plural": "club roles",
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="club",
|
||||
name="board_group",
|
||||
field=models.OneToOneField(
|
||||
editable=False,
|
||||
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(
|
||||
editable=False,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="club",
|
||||
to="core.group",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="clubrole",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("is_presidency", False), ("is_board", True), _connector="OR"
|
||||
),
|
||||
name="clubrole_presidency_implies_board",
|
||||
violation_error_message=(
|
||||
"A role cannot be in the presidency while not being in the board"
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_roles, migrations.RunPython.noop),
|
||||
# because Postgres migrations run in a single transaction,
|
||||
# we cannot change the actual values of Membership.role
|
||||
# and apply the FOREIGN KEY constraint in the same migration.
|
||||
# The constraint is created in the next migration
|
||||
]
|
||||
@@ -1,25 +0,0 @@
|
||||
# Generated by Django 5.2.3 on 2025-09-27 09:57
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("club", "0015_clubrole_alter_membership_role")]
|
||||
|
||||
operations = [
|
||||
# because Postgres migrations run in a single transaction,
|
||||
# we cannot change the actual values of Membership.role
|
||||
# and apply the FOREIGN KEY constraint in the same migration.
|
||||
# The data migration was made in the previous migration.
|
||||
migrations.AlterField(
|
||||
model_name="membership",
|
||||
name="role",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="members",
|
||||
to="club.clubrole",
|
||||
verbose_name="role",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,105 +0,0 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-27 07:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("club", "0016_clubrole_alter_membership_role")]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="LinkType",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=40, verbose_name="name")),
|
||||
(
|
||||
"url_base",
|
||||
models.URLField(
|
||||
help_text=(
|
||||
"The base url that links with this type "
|
||||
"must respect (e.g. `https://www.instagram.com`)"
|
||||
),
|
||||
unique=True,
|
||||
verbose_name="url base",
|
||||
),
|
||||
),
|
||||
(
|
||||
"icon",
|
||||
models.CharField(
|
||||
help_text=(
|
||||
"The fontawesome class to use "
|
||||
"(e.g. `fa-brands fa-instagram`)"
|
||||
),
|
||||
max_length=40,
|
||||
verbose_name="icon",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"verbose_name": "link type", "verbose_name_plural": "link types"},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ClubLink",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(blank=True, max_length=40, verbose_name="name"),
|
||||
),
|
||||
("url", models.URLField(verbose_name="link url")),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="updated at"),
|
||||
),
|
||||
(
|
||||
"club",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="links",
|
||||
to="club.club",
|
||||
verbose_name="club",
|
||||
),
|
||||
),
|
||||
(
|
||||
"link_type",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="links",
|
||||
to="club.linktype",
|
||||
verbose_name="link type",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "club link",
|
||||
"verbose_name_plural": "club links",
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=["club", "url"],
|
||||
name="club_clublink_unique_club_url",
|
||||
violation_error_message="Duplicated url",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
+139
-336
@@ -26,58 +26,57 @@ from __future__ import annotations
|
||||
from typing import Iterable, Self
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import validators
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.validators import RegexValidator, validate_email
|
||||
from django.db import ProgrammingError, models, transaction
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Exists, F, OuterRef, Q
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.text import slugify
|
||||
from django.utils.timezone import localdate
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from ordered_model.models import OrderedModel
|
||||
|
||||
from core.fields import ResizedImageField
|
||||
from core.models import Group, Notification, Page, SithFile, User
|
||||
|
||||
# Create your models here.
|
||||
|
||||
class ClubQuerySet(models.QuerySet):
|
||||
def having_board_member(self, user: User) -> Self:
|
||||
"""Filter all club in which the given user is a board member."""
|
||||
active_memberships = user.memberships.board().ongoing()
|
||||
return self.filter(Exists(active_memberships.filter(club=OuterRef("pk"))))
|
||||
|
||||
# This function prevents generating migration upon settings change
|
||||
def get_default_owner_group():
|
||||
return settings.SITH_GROUP_ROOT_ID
|
||||
|
||||
|
||||
class Club(models.Model):
|
||||
"""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)
|
||||
home = models.OneToOneField(
|
||||
@@ -89,19 +88,17 @@ class Club(models.Model):
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
page = models.OneToOneField(
|
||||
Page, related_name="club", blank=True, on_delete=models.PROTECT
|
||||
Page, related_name="club", blank=True, null=True, on_delete=models.CASCADE
|
||||
)
|
||||
members_group = models.OneToOneField(
|
||||
Group, related_name="club", on_delete=models.PROTECT, editable=False
|
||||
Group, related_name="club", on_delete=models.PROTECT
|
||||
)
|
||||
board_group = models.OneToOneField(
|
||||
Group, related_name="club_board", on_delete=models.PROTECT, editable=False
|
||||
Group, related_name="club_board", on_delete=models.PROTECT
|
||||
)
|
||||
|
||||
objects = ClubQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
ordering = ["name", "unix_name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -109,12 +106,10 @@ class Club(models.Model):
|
||||
@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
|
||||
if self.unix_name != db_club.unix_name:
|
||||
self.home.name = self.unix_name
|
||||
self.home.save()
|
||||
if self.name != db_club.name:
|
||||
self.board_group.name = f"{self.name} - Bureau"
|
||||
@@ -128,9 +123,11 @@ class Club(models.Model):
|
||||
self.members_group = Group.objects.create(
|
||||
name=f"{self.name} - Membres", is_manually_manageable=False
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
if creation:
|
||||
self.make_home()
|
||||
self.make_page()
|
||||
super().save(*args, **kwargs)
|
||||
cache.set(f"sith_club_{self.unix_name}", self)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("club:club_view", kwargs={"club_id": self.id})
|
||||
@@ -138,7 +135,9 @@ class Club(models.Model):
|
||||
@cached_property
|
||||
def president(self) -> Membership | None:
|
||||
"""Fetch the membership of the current president of this club."""
|
||||
return self.members.filter(end_date=None).order_by("role__order").first()
|
||||
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."""
|
||||
@@ -156,68 +155,49 @@ class Club(models.Model):
|
||||
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
|
||||
)
|
||||
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) -> 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:
|
||||
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)
|
||||
|
||||
def create_default_roles(self):
|
||||
"""Create some roles that should exist by default for this club.
|
||||
|
||||
The created roles are : president, treasurer, active member and curious.
|
||||
|
||||
Warnings:
|
||||
When calling this method, no club must exist yet for this club.
|
||||
"""
|
||||
if self.roles.exists():
|
||||
raise ProgrammingError(
|
||||
"Default roles can be created only for clubs "
|
||||
"that don't have associated roles yet"
|
||||
)
|
||||
# The names are written in French, because there is no gettext involved
|
||||
# for strings stored in database, and the majority of users are french.
|
||||
roles = [
|
||||
ClubRole(name="Président⸱e", is_board=True, is_presidency=True),
|
||||
ClubRole(name="Trésorier⸱e", is_board=True, is_presidency=False),
|
||||
ClubRole(name="Membre actif⸱ve", is_board=False, is_presidency=False),
|
||||
ClubRole(
|
||||
name="Curieux⸱euse",
|
||||
description=(
|
||||
"Les gens qui suivent l'activité "
|
||||
"du club sans forcément y participer"
|
||||
),
|
||||
is_board=False,
|
||||
is_presidency=False,
|
||||
),
|
||||
]
|
||||
for i, role in enumerate(roles):
|
||||
role.club = self
|
||||
role.order = i
|
||||
ClubRole.objects.bulk_create(roles)
|
||||
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}")
|
||||
cache.delete(f"sith_club_{self.unix_name}")
|
||||
self.board_group.delete()
|
||||
self.members_group.delete()
|
||||
return super().delete(*args, **kwargs)
|
||||
@@ -238,120 +218,33 @@ class Club(models.Model):
|
||||
"""Method to see if that object can be edited by the given user."""
|
||||
return self.has_rights_in_club(user)
|
||||
|
||||
def can_roles_be_edited_by(self, user: User) -> bool:
|
||||
"""Return True if the given user can edit the roles of this club"""
|
||||
return user.is_authenticated and (
|
||||
user.has_perm("club.change_clubrole")
|
||||
or self.members.ongoing()
|
||||
.filter(user=user, role__is_presidency=True)
|
||||
.exists()
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def current_members(self) -> list[Membership]:
|
||||
return list(
|
||||
self.members.ongoing().select_related("user", "role").order_by("-role")
|
||||
)
|
||||
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 of the given user."""
|
||||
"""Return the current membership the given user.
|
||||
|
||||
Note:
|
||||
The result is cached.
|
||||
"""
|
||||
if user.is_anonymous:
|
||||
return None
|
||||
return next((m for m in self.current_members if m.user_id == user.id), 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 ClubRole(OrderedModel):
|
||||
club = models.ForeignKey(
|
||||
Club,
|
||||
verbose_name=_("club"),
|
||||
help_text=_("The club with which this role is associated"),
|
||||
related_name="roles",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
name = models.CharField(_("name"), max_length=50)
|
||||
description = models.TextField(_("description"), blank=True, default="")
|
||||
is_board = models.BooleanField(_("Board role"), default=False)
|
||||
is_presidency = models.BooleanField(_("Presidency role"), default=False)
|
||||
is_active = models.BooleanField(
|
||||
_("is active"),
|
||||
default=True,
|
||||
help_text=_(
|
||||
"If the role is inactive, people joining the club won't be able to get it."
|
||||
),
|
||||
)
|
||||
|
||||
order_with_respect_to = "club"
|
||||
|
||||
class Meta(OrderedModel.Meta):
|
||||
verbose_name = _("club role")
|
||||
verbose_name_plural = _("club roles")
|
||||
constraints = [
|
||||
# presidency IMPLIES board <=> NOT presidency OR board
|
||||
# cf. MT1 :)
|
||||
models.CheckConstraint(
|
||||
condition=Q(is_presidency=False) | Q(is_board=True),
|
||||
name="clubrole_presidency_implies_board",
|
||||
violation_error_message=_(
|
||||
"A role cannot be in the presidency while not being in the board"
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_display_name(self):
|
||||
return f"{self.name} - {self.club.name}"
|
||||
|
||||
def clean(self):
|
||||
errors = []
|
||||
roles = list(self.club.roles.all())
|
||||
if (
|
||||
self.is_board
|
||||
and self.order
|
||||
and any(r.order < self.order and not r.is_board for r in roles)
|
||||
):
|
||||
errors.append(
|
||||
ValidationError(
|
||||
_("Role %(role)s cannot be placed below a member role")
|
||||
% {"role": self.name}
|
||||
)
|
||||
)
|
||||
if (
|
||||
self.is_presidency
|
||||
and self.order
|
||||
and any(r.order < self.order and not r.is_presidency for r in roles)
|
||||
):
|
||||
errors.append(
|
||||
ValidationError(
|
||||
_("Role %(role)s cannot be placed below a non-presidency role")
|
||||
% {"role": self.name}
|
||||
)
|
||||
)
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
return super().clean()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
auto_order = self.order is None and self.is_board
|
||||
if not auto_order:
|
||||
super().save(*args, **kwargs)
|
||||
return
|
||||
# get the role that should be placed after the role we are dealing with.
|
||||
# So, if this is role is presidency, get the first board role ;
|
||||
# if it is a board role, get the first member role ;
|
||||
# and if it is a member role, get nothing (OrderedModel.save will
|
||||
# automatically put it in the last position anyway)
|
||||
filters = {"is_board": self.is_presidency, "is_presidency": False}
|
||||
next_role = self.club.roles.filter(**filters).order_by("order").first()
|
||||
super().save(*args, **kwargs)
|
||||
if next_role:
|
||||
self.above(next_role)
|
||||
|
||||
|
||||
class MembershipQuerySet(models.QuerySet):
|
||||
def ongoing(self) -> Self:
|
||||
"""Filter all memberships which are not finished yet."""
|
||||
@@ -364,68 +257,47 @@ class MembershipQuerySet(models.QuerySet):
|
||||
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 [MembershipQuerySet.ongoing][]
|
||||
queryset method
|
||||
mind combining this with the :meth:`ongoing` queryset method
|
||||
"""
|
||||
return self.filter(role__is_board=True)
|
||||
|
||||
def editable_by(self, user: User) -> Self:
|
||||
"""Filter Memberships that this user can edit.
|
||||
|
||||
Users with the `club.change_membership` permission can edit all Membership.
|
||||
The other users can edit :
|
||||
- their own membership
|
||||
- if they are board members, ongoing memberships with a role lower than their own
|
||||
|
||||
For example, let's suppose the following users :
|
||||
- A : board member
|
||||
- B : board member
|
||||
- C : simple member
|
||||
- D : curious
|
||||
- E : old member
|
||||
|
||||
A will be able to edit the memberships of A, C and D ;
|
||||
C and D will be able to edit only their own membership ;
|
||||
nobody will be able to edit E's membership.
|
||||
"""
|
||||
if user.has_perm("club.change_membership"):
|
||||
return self.all()
|
||||
return self.ongoing().filter(
|
||||
Q(user=user)
|
||||
| Exists(
|
||||
Membership.objects.ongoing().filter(
|
||||
user=user,
|
||||
club=OuterRef("club"),
|
||||
role__is_board=True,
|
||||
role__order__lt=OuterRef("role__order"),
|
||||
)
|
||||
)
|
||||
)
|
||||
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
||||
|
||||
def update(self, **kwargs) -> int:
|
||||
"""Remove users from club groups they are no more in
|
||||
"""Refresh the cache and edit group ownership.
|
||||
|
||||
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.
|
||||
one to retrieve the updated memberships,
|
||||
one to perform group removal and one to perform
|
||||
group attribution.
|
||||
"""
|
||||
nb_rows = super().update(**kwargs)
|
||||
if nb_rows == 0:
|
||||
# if no row was affected, no need to edit club groups
|
||||
# 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 also remove the concerned users from the club groups.
|
||||
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 :
|
||||
|
||||
@@ -441,6 +313,12 @@ class MembershipQuerySet(models.QuerySet):
|
||||
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
|
||||
|
||||
|
||||
@@ -459,21 +337,24 @@ class Membership(models.Model):
|
||||
User,
|
||||
verbose_name=_("user"),
|
||||
related_name="memberships",
|
||||
null=False,
|
||||
blank=False,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
club = models.ForeignKey(
|
||||
Club,
|
||||
verbose_name=_("club"),
|
||||
related_name="members",
|
||||
null=False,
|
||||
blank=False,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
start_date = models.DateField(_("start date"), default=timezone.now)
|
||||
end_date = models.DateField(_("end date"), null=True, blank=True)
|
||||
role = models.ForeignKey(
|
||||
ClubRole,
|
||||
verbose_name=_("role"),
|
||||
related_name="members",
|
||||
on_delete=models.PROTECT,
|
||||
role = models.IntegerField(
|
||||
_("role"),
|
||||
choices=sorted(settings.SITH_CLUB_ROLES.items()),
|
||||
default=sorted(settings.SITH_CLUB_ROLES.items())[0][0],
|
||||
)
|
||||
description = models.CharField(
|
||||
_("description"), max_length=128, null=False, blank=True
|
||||
@@ -484,14 +365,14 @@ class Membership(models.Model):
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=Q(end_date__gte=F("start_date")), name="end_after_start"
|
||||
check=Q(end_date__gte=F("start_date")), name="end_after_start"
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"{self.club.name} - {self.user.username} "
|
||||
f"- {self.role.name} "
|
||||
f"- {settings.SITH_CLUB_ROLES[self.role]} "
|
||||
f"- {str(_('past member')) if self.end_date is not None else ''}"
|
||||
)
|
||||
|
||||
@@ -505,6 +386,9 @@ class Membership(models.Model):
|
||||
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 get_absolute_url(self):
|
||||
return reverse("club:club_members", kwargs={"club_id": self.club_id})
|
||||
@@ -520,15 +404,12 @@ class Membership(models.Model):
|
||||
if user.is_root or user.is_board_member:
|
||||
return True
|
||||
membership = self.club.get_membership_for(user)
|
||||
if not membership:
|
||||
return False
|
||||
return membership.user_id == user.id or (
|
||||
membership.is_board and membership.role.order < self.role.order
|
||||
)
|
||||
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(
|
||||
@@ -600,7 +481,7 @@ class Membership(models.Model):
|
||||
group_id=membership.club.members_group_id,
|
||||
)
|
||||
)
|
||||
if membership.role.is_board:
|
||||
if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
|
||||
club_groups.append(
|
||||
User.groups.through(
|
||||
user_id=membership.user_id,
|
||||
@@ -773,81 +654,3 @@ class MailingSubscription(models.Model):
|
||||
|
||||
def fetch_format(self):
|
||||
return self.get_email + " "
|
||||
|
||||
|
||||
class LinkType(models.Model):
|
||||
"""A link type, in order to group links and give them icons.
|
||||
|
||||
Notes:
|
||||
Among all club links, there is a special one, with an empty base url
|
||||
and a default link icon.
|
||||
It is use as a fallback item when no actual link type can be found.
|
||||
|
||||
Danger:
|
||||
LinkType.icon is content that will be raw-rendered in the template.
|
||||
It is NOT safe to allow users to give it.
|
||||
The edition of this field must be reserved to trusted admins.
|
||||
"""
|
||||
|
||||
name = models.CharField(_("name"), max_length=40)
|
||||
url_base = models.URLField(
|
||||
"url base",
|
||||
unique=True,
|
||||
help_text=_(
|
||||
"The base url that links with this type must respect (e.g. `%(url)s`)"
|
||||
)
|
||||
% {"url": "https://www.instagram.com"},
|
||||
)
|
||||
icon = models.CharField(
|
||||
_("icon"),
|
||||
max_length=40,
|
||||
help_text=_("The fontawesome class to use (e.g. `fa-brands fa-instagram`)"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("link type")
|
||||
verbose_name_plural = _("link types")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class ClubLink(models.Model):
|
||||
link_type = models.ForeignKey(
|
||||
LinkType,
|
||||
verbose_name=_("link type"),
|
||||
on_delete=models.CASCADE,
|
||||
related_name="links",
|
||||
)
|
||||
name = models.CharField(_("name"), max_length=40, blank=True)
|
||||
url = models.URLField(_("link url"))
|
||||
club = models.ForeignKey(
|
||||
Club, verbose_name=_("club"), on_delete=models.CASCADE, related_name="links"
|
||||
)
|
||||
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("club link")
|
||||
verbose_name_plural = _("club links")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["club", "url"],
|
||||
name="club_clublink_unique_club_url",
|
||||
violation_error_message=_("Duplicated url"),
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.url
|
||||
|
||||
def save(self, **kwargs):
|
||||
if not self.name:
|
||||
self.name = self.link_type.name
|
||||
return super().save(**kwargs)
|
||||
|
||||
def clean(self):
|
||||
if not self.url.startswith(self.link_type.url_base):
|
||||
raise ValidationError(
|
||||
_("This link doesn't match with the url base of its type.")
|
||||
)
|
||||
|
||||
+3
-75
@@ -1,81 +1,9 @@
|
||||
from typing import Annotated
|
||||
from ninja import ModelSchema
|
||||
|
||||
from django.db.models import Q
|
||||
from ninja import FilterLookup, FilterSchema, ModelSchema
|
||||
from pydantic import HttpUrl
|
||||
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.schemas import NonEmptyStr, SimpleUserSchema
|
||||
|
||||
|
||||
class ClubSearchFilterSchema(FilterSchema):
|
||||
search: Annotated[NonEmptyStr | None, FilterLookup("name__icontains")] = None
|
||||
is_active: bool | None = None
|
||||
parent_id: int | None = None
|
||||
exclude_ids: set[int] | None = None
|
||||
|
||||
def filter_exclude_ids(self, value: set[int] | None):
|
||||
if value is None:
|
||||
return Q()
|
||||
return ~Q(id__in=value)
|
||||
|
||||
|
||||
class SimpleClubSchema(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", "is_active", "short_description"]
|
||||
|
||||
url: str
|
||||
|
||||
@staticmethod
|
||||
def resolve_url(obj: Club) -> str:
|
||||
return obj.get_absolute_url()
|
||||
|
||||
|
||||
class ClubRoleSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = ClubRole
|
||||
fields = ["id", "name", "is_presidency", "is_board"]
|
||||
|
||||
|
||||
class ClubMemberSchema(ModelSchema):
|
||||
"""A schema to represent all memberships in a club."""
|
||||
|
||||
class Meta:
|
||||
model = Membership
|
||||
fields = ["start_date", "end_date", "description"]
|
||||
|
||||
user: SimpleUserSchema
|
||||
role: ClubRoleSchema
|
||||
from club.models import Club
|
||||
|
||||
|
||||
class ClubSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = Club
|
||||
fields = ["id", "name", "logo", "is_active", "short_description", "address"]
|
||||
|
||||
members: list[ClubMemberSchema]
|
||||
links: list[HttpUrl]
|
||||
|
||||
@staticmethod
|
||||
def resolve_links(obj: Club):
|
||||
return [link.url for link in obj.links.all()]
|
||||
|
||||
|
||||
class UserMembershipSchema(ModelSchema):
|
||||
"""A schema to represent the active club memberships of a user."""
|
||||
|
||||
class Meta:
|
||||
model = Membership
|
||||
fields = ["id", "start_date", "description"]
|
||||
|
||||
club: SimpleClubSchema
|
||||
role: ClubRoleSchema
|
||||
fields = ["id", "name"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { AjaxSelect } from "#core:core/components/ajax-select-base.ts";
|
||||
import { registerComponent } from "#core:utils/web-components.ts";
|
||||
import { type ClubSchema, clubSearchClub } from "#openapi";
|
||||
|
||||
@registerComponent("club-ajax-select")
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { AlpineComponent } from "alpinejs";
|
||||
|
||||
interface RoleGroupData {
|
||||
isBoard: boolean;
|
||||
isPresidency: boolean;
|
||||
roleId: number;
|
||||
}
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("clubRoleList", (config: { userRoleId: number | null }) => ({
|
||||
confirmOnSubmit: false,
|
||||
|
||||
/**
|
||||
* Edit relevant item data after it has been moved by x-sort
|
||||
*/
|
||||
reorder(item: AlpineComponent<RoleGroupData>, conf: RoleGroupData) {
|
||||
item.isBoard = conf.isBoard;
|
||||
item.isPresidency = conf.isPresidency;
|
||||
// if the user has moved its own role outside the presidency,
|
||||
// submitting the form will require a confirmation
|
||||
this.confirmOnSubmit = config.userRoleId === item.roleId && !item.isPresidency;
|
||||
this.resetOrder();
|
||||
},
|
||||
/**
|
||||
* Reset the value of the ORDER input of all items in the list.
|
||||
* This is to be called after any reordering operation, in order to make sure
|
||||
* that the order that will be saved is coherent with what is displayed.
|
||||
*/
|
||||
resetOrder() {
|
||||
// When moving items with x-sort, the only information we truly have is
|
||||
// the end position in the target group, not the previous position nor
|
||||
// the position in the global list.
|
||||
// To overcome this, we loop through an enumeration of all inputs
|
||||
// that are in the form `roles-X-ORDER` and sequentially set the value of the field.
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>(
|
||||
"input[name^='roles'][name$='ORDER']",
|
||||
);
|
||||
for (const [i, elem] of inputs.entries()) {
|
||||
elem.value = (i + 1).toString();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* If the user moved its role out of the presidency, ask a confirmation
|
||||
* before submitting the form
|
||||
*/
|
||||
confirmSubmission(event: SubmitEvent) {
|
||||
if (
|
||||
this.confirmOnSubmit &&
|
||||
!confirm(
|
||||
gettext(
|
||||
"You're going to remove your own role from the presidency. " +
|
||||
"You may lock yourself out of this page. Do you want to continue ? ",
|
||||
),
|
||||
)
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
}));
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
#club-detail {
|
||||
img.club-logo {
|
||||
display: block;
|
||||
max-height: 200px;
|
||||
max-width: 200px;
|
||||
}
|
||||
#club-attributes {
|
||||
ul {
|
||||
list-style: none;
|
||||
margin-left: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .75rem;
|
||||
|
||||
li i {
|
||||
margin-right: .5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.has-links) {
|
||||
#club-attributes {
|
||||
float: right;
|
||||
margin: 1em 0 1em 2em;
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
margin-left: 1em;
|
||||
}
|
||||
@media screen and (max-width: 400px) {
|
||||
float: unset;
|
||||
img.club-logo {
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.has-links {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
gap: 2em;
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
#club-attributes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
min-width: 200px;
|
||||
@media screen and (max-width: 650px) {
|
||||
margin-top: 1em;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-end;
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
img.club-logo {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
#club-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2em;
|
||||
padding: 2em;
|
||||
|
||||
.card {
|
||||
display: block;
|
||||
background-color: unset;
|
||||
|
||||
.club-image {
|
||||
float: left;
|
||||
margin-right: 2rem;
|
||||
margin-bottom: .5rem;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 10%;
|
||||
background-color: rgba(173, 173, 173, 0.2);
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
i.club-image {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: block;
|
||||
text-align: justify;
|
||||
|
||||
h4 {
|
||||
margin-top: 0;
|
||||
margin-right: .5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
#club_members_table {
|
||||
tbody label {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#add_club_members_form {
|
||||
fieldset {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
column-gap: 2em;
|
||||
row-gap: 1em;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.errorlist {
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
.fa-grip-vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
margin-right: .5em;
|
||||
}
|
||||
@@ -1,63 +1,16 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
{% from 'core/macros.jinja' import user_profile_link %}
|
||||
|
||||
{% block title -%}
|
||||
{{ club.name }}
|
||||
{%- endblock %}
|
||||
|
||||
{% block description -%}
|
||||
{{ club.short_description }}
|
||||
{%- endblock %}
|
||||
|
||||
{% block metatags %}
|
||||
<meta property="og:url" content="{{ request.build_absolute_uri(club.get_absolute_url()) }}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="{{ club.name }}" />
|
||||
<meta property="og:description" content="{{ club.short_description }}" />
|
||||
{% if club.logo %}
|
||||
<meta property="og:image" content="{{ request.build_absolute_uri(club.logo.url) }}" />
|
||||
{% else %}
|
||||
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static("club/detail.scss") }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h3>{{ club.name }}</h3>
|
||||
<div id="club-detail" {% if links %}class="has-links"{% endif %}>
|
||||
<div id="club-attributes">
|
||||
{% if club.logo %}
|
||||
<img
|
||||
class="club-logo"
|
||||
src="{{ club.logo.url }}"
|
||||
alt="{{ club.name }}"
|
||||
width="{{ club.logo.width }}"
|
||||
height="{{ club.logo.height }}"
|
||||
>
|
||||
{% endif %}
|
||||
{% if links %}
|
||||
<div id="club-links">
|
||||
<h4>{% trans %}Links{% endtrans %}</h4>
|
||||
<ul>
|
||||
{% for link in links %}
|
||||
<li>
|
||||
<a href="{{ link.url }}" rel="noopener external" target="_blank">
|
||||
<i class="{{ link.link_type.icon }} fa-xl"></i>{{ link.name }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="club-page">
|
||||
{% if page_revision %}
|
||||
{{ page_revision|markdown }}
|
||||
{% 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 %}
|
||||
|
||||
|
||||
@@ -1,95 +1,48 @@
|
||||
{% if is_fragment %}
|
||||
{% extends "core/base_fragment.jinja" %}
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block metatags %}
|
||||
<meta property="og:url" content="{{ request.build_absolute_uri() }}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Liste des clubs et assos" />
|
||||
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
|
||||
{% endblock %}
|
||||
{% block title %}
|
||||
{% trans %}Club list{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{# Don't display tabs and errors #}
|
||||
{% block tabs %}
|
||||
{% endblock %}
|
||||
{% block errors %}
|
||||
{% endblock %}
|
||||
{% else %}
|
||||
{% extends "core/base.jinja" %}
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static("club/list.scss") }}">
|
||||
{% endblock %}
|
||||
{% block description -%}
|
||||
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
|
||||
{%- endblock %}
|
||||
{% block title -%}
|
||||
{% trans %}Club list{% endtrans %}
|
||||
{%- endblock %}
|
||||
{% endif %}
|
||||
{% macro display_club(club) -%}
|
||||
|
||||
{% from "core/macros.jinja" import paginate_htmx %}
|
||||
{% 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 %}
|
||||
|
||||
{% 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>
|
||||
{%- endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<h3>{% trans %}Filters{% endtrans %}</h3>
|
||||
<form
|
||||
id="club-list-filters"
|
||||
hx-get="{{ url("club:club_list") }}"
|
||||
hx-target="#content"
|
||||
hx-swap="outerHtml"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<div class="row gap-4x">
|
||||
{{ form }}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-blue margin-bottom">
|
||||
<i class="fa fa-magnifying-glass"></i>{% trans %}Search{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
{% if user.is_root %}
|
||||
<p><a href="{{ url('club:club_new') }}">{% trans %}New club{% endtrans %}</a></p>
|
||||
{% endif %}
|
||||
{% if club_list %}
|
||||
<h3>{% trans %}Club list{% endtrans %}</h3>
|
||||
{% if user.has_perm("club.add_club") %}
|
||||
<br>
|
||||
<a href="{{ url('club:club_new') }}" class="btn btn-blue">
|
||||
<i class="fa fa-plus"></i> {% trans %}New club{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<section class="aria-busy-grow" id="club-list">
|
||||
{% for club in object_list %}
|
||||
<div class="card">
|
||||
{% set club_url = club.get_absolute_url() %}
|
||||
<a href="{{ club_url }}">
|
||||
{% if club.logo %}
|
||||
<img class="club-image" src="{{ club.logo.url }}" alt="logo {{ club.name }}">
|
||||
{% else %}
|
||||
<i class="fa-regular fa-image fa-4x club-image"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
<div class="content">
|
||||
<a href="{{ club_url }}">
|
||||
<h4>
|
||||
{{ club.name }} {% if not club.is_active %}({% trans %}inactive{% endtrans %}){% endif %}
|
||||
</h4>
|
||||
</a>
|
||||
{% set links = club.links.all() %}
|
||||
{% if links %}
|
||||
<br>
|
||||
<div class="row gap-2x">
|
||||
{% for link in club.links.all() %}
|
||||
<a href="{{ link.url }}" rel="noopener external" target="_blank">
|
||||
<i class="{{ link.link_type.icon }} fa-xl"></i>
|
||||
<strong>{{ link.name }}</strong>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ club.short_description|markdown }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% if is_paginated %}
|
||||
{{ paginate_htmx(request, page_obj, paginator) }}
|
||||
{% endif %}
|
||||
</main>
|
||||
<ul>
|
||||
{%- for c in club_list.all().order_by('name') if c.parent is none %}
|
||||
{{ display_club(c) }}
|
||||
{%- endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{% trans %}There is no club in this website.{% endtrans %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
@@ -1,47 +1,24 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
{% from 'core/macros.jinja' import user_profile_link, select_all_checkbox %}
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static("club/members.scss") }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% block notifications %}
|
||||
{# Notifications are moved a little bit below #}
|
||||
{% endblock %}
|
||||
|
||||
<h2>{% trans %}Club members{% endtrans %}</h2>
|
||||
|
||||
{% if club.can_roles_be_edited_by(user) %}
|
||||
<a
|
||||
href="{{ url("club:club_roles", club_id=object.id) }}"
|
||||
class="btn btn-blue margin-bottom"
|
||||
>
|
||||
<i class="fa fa-users-gear"></i> {% trans %}Manage roles{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if add_member_fragment %}
|
||||
<br />
|
||||
{{ add_member_fragment }}
|
||||
<br />
|
||||
{% endif %}
|
||||
{% include "core/base/notifications.jinja" %}
|
||||
{% if members %}
|
||||
<form action="{{ url('club:club_members', club_id=club.id) }}" id="members_old" method="post">
|
||||
<form action="{{ url('club:club_members', club_id=club.id) }}" id="users_old" method="post">
|
||||
{% csrf_token %}
|
||||
{% if can_end_membership %}
|
||||
{{ select_all_checkbox("members_old") }}
|
||||
<br />
|
||||
{% 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">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}User{% endtrans %}</td>
|
||||
<td>{% trans %}Role{% endtrans %}</td>
|
||||
<td>{% trans %}Description{% endtrans %}</td>
|
||||
<td>{% trans %}Since{% endtrans %}</td>
|
||||
{% if can_end_membership %}
|
||||
{% if users_old %}
|
||||
<td>{% trans %}Mark as old{% endtrans %}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
@@ -50,27 +27,23 @@
|
||||
{% for m in members %}
|
||||
<tr>
|
||||
<td>{{ user_profile_link(m.user) }}</td>
|
||||
<td>{{ m.role.name }}</td>
|
||||
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
|
||||
<td>{{ m.description }}</td>
|
||||
<td>{{ m.start_date }}</td>
|
||||
{%- if can_end_membership -%}
|
||||
{% if users_old %}
|
||||
<td>
|
||||
{%- if m.is_editable -%}
|
||||
<label for="id_members_old_{{ loop.index }}"></label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="members_old"
|
||||
value="{{ m.id }}"
|
||||
id="id_members_old_{{ loop.index }}"
|
||||
>
|
||||
{%- endif -%}
|
||||
{% set user_old = users_old[m.user.get_display_name()] %}
|
||||
{% if user_old %}
|
||||
{{ user_old[0].tag() }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{%- endif -%}
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if can_end_membership %}
|
||||
{{ form.users_old.errors }}
|
||||
{% if users_old %}
|
||||
<p></p>
|
||||
<input type="submit" name="submit" value="{% trans %}Mark as old{% endtrans %}">
|
||||
{% endif %}
|
||||
@@ -78,4 +51,32 @@
|
||||
{% 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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,22 +5,20 @@
|
||||
<h2>{% trans %}Club old members{% endtrans %}</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
</tr>
|
||||
<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 member in old_members %}
|
||||
{% for m in club.members.exclude(end_date=None).order_by('-role', 'description', '-end_date').all() %}
|
||||
<tr>
|
||||
<td>{{ user_profile_link(member.user) }}</td>
|
||||
<td>{{ member.role.name }}</td>
|
||||
<td>{{ member.description }}</td>
|
||||
<td>{{ member.start_date }}</td>
|
||||
<td>{{ member.end_date }}</td>
|
||||
<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>
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block additional_js %}
|
||||
<script type="module" src="{{ static("bundled/club/role-list-index.ts") }}" xmlns="http://www.w3.org/1999/html"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static("club/roles.scss") }}">
|
||||
{% endblock %}
|
||||
|
||||
{% macro display_subform(subform) %}
|
||||
<div
|
||||
class="row"
|
||||
x-data="{
|
||||
isPresidency: {{ subform.is_presidency.value()|lower }},
|
||||
isBoard: {{ subform.is_board.value()|lower }},
|
||||
roleId: {{ subform.id.value() }},
|
||||
}"
|
||||
x-sort:item="$data"
|
||||
>
|
||||
{# hidden fields #}
|
||||
{{ subform.ORDER }}
|
||||
{{ subform.id }}
|
||||
{{ subform.club }}
|
||||
{{ subform.is_presidency|add_attr("x-model=isPresidency") }}
|
||||
{{ subform.is_board|add_attr("x-model=isBoard") }}
|
||||
<i class="fa fa-grip-vertical" x-sort:handle></i>
|
||||
<details class="accordion grow" {% if subform.errors %}open{% endif %}>
|
||||
<summary>
|
||||
{{ subform.name.value() }}
|
||||
{% if not subform.instance.is_active -%}
|
||||
({% trans %}inactive{% endtrans %})
|
||||
{%- endif %}
|
||||
</summary>
|
||||
<div class="accordion-content">
|
||||
{{ subform.non_field_errors() }}
|
||||
<div class="form-group">
|
||||
{{ subform.name.as_field_group() }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ subform.description.as_field_group() }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div>
|
||||
{{ subform.is_active }}
|
||||
{{ subform.is_active.label_tag() }}
|
||||
</div>
|
||||
<span class="helptext">
|
||||
{{ subform.is_active.help_text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<p>
|
||||
{% trans trimmed %}
|
||||
Roles give rights on the club.
|
||||
Higher roles grant more rights, and the members having them are displayed higher
|
||||
in the club members list.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
<p>
|
||||
{% trans trimmed %}
|
||||
On this page, you can edit their name and description, as well as their order.
|
||||
You can also drag roles from a category to another
|
||||
(e.g. a board role can be made into a presidency role).
|
||||
{% endtrans %}
|
||||
</p>
|
||||
<form
|
||||
method="post"
|
||||
x-data="clubRoleList({ userRoleId: {{ user_role or "null" }} })"
|
||||
@submit="confirmSubmission"
|
||||
>
|
||||
{% csrf_token %}
|
||||
{{ form.management_form }}
|
||||
{{ form.non_form_errors() }}
|
||||
<h3>{% trans %}Presidency{% endtrans %}</h3>
|
||||
<a class="btn btn-grey margin-bottom" href="{{ url("club:new_role_president", club_id=club.id) }}">
|
||||
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
|
||||
</a>
|
||||
<details class="clickable margin-bottom">
|
||||
<summary>{% trans %}Help{% endtrans %}</summary>
|
||||
{# The style we use for markdown rendering is quite nice for what we want to display,
|
||||
so we are just gonna reuse it. #}
|
||||
<div class="markdown">
|
||||
<p>{% trans %}Users with a presidency role can :{% endtrans %}</p>
|
||||
<ul>
|
||||
<li>{% trans %}create new club roles and edit existing ones{% endtrans %}</li>
|
||||
<li>{% trans %}manage the club counters{% endtrans %}</li>
|
||||
<li>{% trans %}add new members with any active role and end any membership{% endtrans %}</li>
|
||||
</ul>
|
||||
<p>{% trans %}They also have all the rights of the club board.{% endtrans %}</p>
|
||||
</div>
|
||||
</details>
|
||||
<div
|
||||
x-sort="reorder($item, { isBoard: true, isPresidency: true })"
|
||||
x-sort:group="roles"
|
||||
>
|
||||
{% for subform in form %}
|
||||
{% if subform.is_presidency.value() %}
|
||||
{{ display_subform(subform) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<br>
|
||||
<h3>{% trans %}Board{% endtrans %}</h3>
|
||||
<a class="btn btn-grey margin-bottom" href="{{ url("club:new_role_board", club_id=club.id) }}">
|
||||
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
|
||||
</a>
|
||||
<details class="clickable margin-bottom">
|
||||
<summary>{% trans %}Help{% endtrans %}</summary>
|
||||
<div class="markdown">
|
||||
<p>
|
||||
{% trans trimmed %}
|
||||
Board members can do most administrative actions in the club, including :
|
||||
{% endtrans %}
|
||||
</p>
|
||||
<ul>
|
||||
<li>{% trans %}manage the club posters{% endtrans %}</li>
|
||||
<li>{% trans %}create news for the club{% endtrans %}</li>
|
||||
<li>{% trans %}click users on the club's counters{% endtrans %}</li>
|
||||
<li>
|
||||
{% trans trimmed %}
|
||||
add new members and end active memberships
|
||||
for roles that are lower than their own.
|
||||
{% endtrans %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
<div
|
||||
x-sort="reorder($item, { isBoard: true, isPresidency: false })"
|
||||
x-sort:group="roles"
|
||||
>
|
||||
{% for subform in form %}
|
||||
{% if subform.is_board.value() and not subform.is_presidency.value() %}
|
||||
{{ display_subform(subform) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<br>
|
||||
<h3>{% trans %}Members{% endtrans %}</h3>
|
||||
<a class="btn btn-grey margin-bottom" href="{{ url("club:new_role_member", club_id=club.id) }}">
|
||||
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
|
||||
</a>
|
||||
<details class="clickable margin-bottom">
|
||||
<summary>{% trans %}Help{% endtrans %}</summary>
|
||||
<div class="markdown">
|
||||
<p>{% trans %}Simple members cannot perform administrative actions.{% endtrans %}</p>
|
||||
</div>
|
||||
</details>
|
||||
<div
|
||||
x-sort="reorder($item, { isBoard: false, isPresidency: false })"
|
||||
x-sort:group="roles"
|
||||
>
|
||||
{% for subform in form %}
|
||||
{% if not subform.is_board.value() %}
|
||||
{{ display_subform(subform) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<br>
|
||||
<p>
|
||||
<button type="submit" class="btn btn-blue">
|
||||
<i class="fa fa-check"></i>{% trans %}Save{% endtrans %}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
@@ -6,11 +6,11 @@ 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) %}
|
||||
{% set js = "formPagination(this)" %}
|
||||
{% 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 type="submit" onclick="{{ js }}" href="?page={{ page_obj.previous_page_number() }}">{% trans %}Previous{% endtrans %}</a>
|
||||
<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 %}
|
||||
@@ -18,11 +18,11 @@ TODO : rewrite the pagination used in this template an Alpine one
|
||||
{% if page_obj.number == i %}
|
||||
<span class="active">{{ i }} <span class="sr-only">({% trans %}current{% endtrans %})</span></span>
|
||||
{% else %}
|
||||
<a type="submit" onclick="{{ js }}" href="?page={{ i }}">{{ i }}</a>
|
||||
<a {% if js %} type="submit" onclick="{{ js }}" {% endif %} href="?page={{ i }}">{{ i }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if page_obj.has_next() %}
|
||||
<a type="submit" onclick="{{ js }}" href="?page={{ page_obj.next_page_number() }}">{% trans %}Next{% endtrans %}</a>
|
||||
<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 %}
|
||||
@@ -35,7 +35,7 @@ TODO : rewrite the pagination used in this template an Alpine one
|
||||
{% csrf_token %}
|
||||
{{ form }}
|
||||
<p><input type="submit" value="{% trans %}Show{% endtrans %}" /></p>
|
||||
<p><input type="submit" value="{% trans %}Download as CSV{% endtrans %}" formaction="{{ url('club:sellings_csv', club_id=object.id) }}"/></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/>
|
||||
@@ -81,18 +81,14 @@ TODO : rewrite the pagination used in this template an Alpine one
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ paginate(paginated_result, paginator) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script type="text/javascript">
|
||||
function formPagination(link){
|
||||
const form = document.getElementById("form")
|
||||
form.action = link.href;
|
||||
$("form").attr("action", link.href);
|
||||
link.href = "javascript:void(0)"; // block link action
|
||||
form.submit();
|
||||
$("form").submit();
|
||||
}
|
||||
</script>
|
||||
{{ paginate(paginated_result, paginator, "formPagination(this)") }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
@@ -5,19 +5,8 @@
|
||||
<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.can_roles_be_edited_by(user) %}
|
||||
<li><a href="{{ url("club:club_roles", club_id=object.id) }}"></a></li>
|
||||
{% endif %}
|
||||
<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 %}
|
||||
@@ -27,13 +16,30 @@
|
||||
</ul>
|
||||
<h4>{% trans %}Counters:{% endtrans %}</h4>
|
||||
<ul>
|
||||
{% for c in object.counters.filter(type="OFFICE") %}
|
||||
<li>{{ c }}:
|
||||
<a href="{{ url('counter:details', counter_id=c.id) }}">View</a>
|
||||
<a href="{{ url('counter:admin', counter_id=c.id) }}">Edit</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if object.unix_name == settings.SITH_LAUNDERETTE_MANAGER['unix_name'] %}
|
||||
{% for l in Launderette.objects.all() %}
|
||||
<li><a href="{{ url('launderette:main_click', launderette_id=l.id) }}">{{ l }}</a></li>
|
||||
{% endfor %}
|
||||
{% elif object.counters.filter(type="OFFICE")|count > 0 %}
|
||||
{% for c in object.counters.filter(type="OFFICE") %}
|
||||
<li>{{ c }}:
|
||||
<a href="{{ url('counter:details', counter_id=c.id) }}">View</a>
|
||||
<a href="{{ url('counter:admin', counter_id=c.id) }}">Edit</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% if object.club_account.exists() %}
|
||||
<h4>{% trans %}Accounting: {% endtrans %}</h4>
|
||||
<ul>
|
||||
{% for ca in object.club_account.all() %}
|
||||
<li><a href="{{ url('accounting:club_details', c_account_id=ca.id) }}">{{ ca.get_display_name() }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if object.unix_name == settings.SITH_LAUNDERETTE_MANAGER['unix_name'] %}
|
||||
<li><a href="{{ url('launderette:launderette_list') }}">{% trans %}Manage launderettes{% endtrans %}</a></li>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block additional_js %}
|
||||
<script type="module" src="{{ static("bundled/core/dynamic-formset-index.ts") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}
|
||||
{% trans name=object %}Edit {{ name }}{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% macro link_form(form) %}
|
||||
<fieldset
|
||||
{# set url in x-init rather than in x-data,
|
||||
in order to trigger the $watch on initial load #}
|
||||
x-data="{ url: '', linkType: { icon: '', id: 0 } }"
|
||||
x-init="() => {
|
||||
$watch('url', (u) => linkType = linkTypes.find((t) => u.startsWith(t.url)));
|
||||
url = '{{ form.url.value() or "" }}';
|
||||
}"
|
||||
>
|
||||
{{ form.non_field_errors() }}
|
||||
<div class="form-group row gap-2x">
|
||||
<div>
|
||||
{{ form.url.label_tag() }}
|
||||
{{ form.url.errors }}
|
||||
<span>
|
||||
{# we change the icon when the user change it and leave the input,
|
||||
or when it is pasted from the clipboard #}
|
||||
{{ form.url|add_attr("x-model.change=url,@paste.prevent=url = $event.clipboardData.getData('text')") }}
|
||||
<i
|
||||
:class="linkType.icon || 'fa fa-link'"
|
||||
tooltip="{% trans %}This icon will change according to the given url.{% endtrans %}"
|
||||
></i>
|
||||
</span>
|
||||
</div>
|
||||
<div>{{ form.name.as_field_group() }}</div>
|
||||
</div>
|
||||
{%- if form.DELETE -%}
|
||||
<div class="form-group row gap">
|
||||
{{ form.DELETE.as_field_group() }}
|
||||
</div>
|
||||
{%- else -%}
|
||||
<br>
|
||||
<button
|
||||
class="btn btn-grey"
|
||||
@click.prevent="removeForm($event.target.closest('fieldset'))"
|
||||
>
|
||||
<i class="fa fa-minus"></i> {% trans %}Remove link{% endtrans %}
|
||||
</button>
|
||||
{%- endif -%}
|
||||
{{ form.link_type|add_attr(":value=linkType.id") }}
|
||||
{%- for field in form.hidden_fields() -%}
|
||||
{%- if field != form.link_type -%}
|
||||
{{ field }}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
</fieldset>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% 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 those 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="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() }} #}
|
||||
{% do form.fields.pop(field_name) %}
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
|
||||
<h3>{% trans %}Club informations{% endtrans %}</h3>
|
||||
{% if form.admin_fields %}
|
||||
<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 %}
|
||||
<fieldset class="margin-bottom">
|
||||
{{ form.as_p() }}
|
||||
</fieldset>
|
||||
|
||||
<h3>{% trans %}Club links{% endtrans %}</h3>
|
||||
<div x-data="dynamicFormSet({ prefix: '{{ form.link_formset.prefix }}' })" class="margin-bottom">
|
||||
{{ form.link_formset.management_form }}
|
||||
<div x-ref="formContainer">
|
||||
{%- for f in form.link_formset.forms -%}
|
||||
{{ link_form(f) }}
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
<template x-ref="formTemplate">
|
||||
{{ link_form(form.link_formset.empty_form) }}
|
||||
</template>
|
||||
<p>
|
||||
<i>{% trans trimmed %}
|
||||
Note: if the icon of one of your links doesn't exist yet,
|
||||
you can ask the info team to add it.
|
||||
{% endtrans %}</i>
|
||||
</p>
|
||||
<br>
|
||||
<button @click.prevent="addForm()" class="btn btn-grey">
|
||||
<i class="fa fa-plus"></i>{% trans %}Add link{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
<hr>
|
||||
<button type="submit" class="btn btn-blue">
|
||||
<i class="fa fa-check"></i>{% trans %}Save{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
const linkTypes = [
|
||||
{%- for t in link_types -%}
|
||||
{ id: {{ t.id }}, url: '{{ t.url_base }}', icon: '{{ t.icon }}' },
|
||||
{%- endfor -%}
|
||||
];
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,46 +0,0 @@
|
||||
<section id="member-fragment-container">
|
||||
{% if form.user %}
|
||||
<h4>{% trans %}Add a new member{% endtrans %}</h4>
|
||||
{% else %}
|
||||
<h4>{% trans %}Join club{% endtrans %}</h4>
|
||||
{% endif %}
|
||||
|
||||
<form
|
||||
hx-post="{{ url('club:club_new_members', club_id=club.id) }}"
|
||||
hx-disabled-elt="find input[type='submit']"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#member-fragment-container"
|
||||
id="add_club_members_form"
|
||||
>
|
||||
{% csrf_token %}
|
||||
{{ form.non_field_errors() }}
|
||||
<fieldset>
|
||||
{% if form.user %}
|
||||
<div>
|
||||
{{ form.user.label_tag() }}
|
||||
<span class="helptext">{{ form.user.help_text }}</span>
|
||||
{{ form.user }}
|
||||
{{ form.user.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
{{ form.role.label_tag() }}
|
||||
{{ form.role }}
|
||||
{{ form.role.errors }}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.description.label_tag() }}
|
||||
{{ form.description }}
|
||||
{{ form.description.errors }}
|
||||
</div>
|
||||
</fieldset>
|
||||
<button type="submit" class="btn btn-blue">
|
||||
<i class="fa fa-user-plus"></i>
|
||||
{%- if form.user -%}
|
||||
{% trans %}Add{% endtrans %}
|
||||
{%- else -%}
|
||||
{% trans %}Join{% endtrans %}
|
||||
{%- endif -%}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
@@ -1,8 +1,12 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
{% from 'core/page/macros.jinja' import page_history %}
|
||||
{% from 'core/macros_pages.jinja' import page_history %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_history(club.page) }}
|
||||
{% if club.page %}
|
||||
{{ page_history(club.page) }}
|
||||
{% else %}
|
||||
{% trans %}No page existing for this club{% endtrans %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
{% from 'core/macros_pages.jinja' import page_edit_form %}
|
||||
|
||||
{% block content %}
|
||||
<h2>{% trans %}Edit page{% endtrans %}</h2>
|
||||
<form action="{{ url('club:club_edit_page', club_id=page.club.id) }}" method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p() }}
|
||||
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
||||
</form>
|
||||
{{ page_edit_form(page, form, url('club:club_edit_page', club_id=page.club.id), csrf_token) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
|
||||
+906
@@ -0,0 +1,906 @@
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||
#
|
||||
# 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"
|
||||
#
|
||||
#
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import localdate, localtime, now
|
||||
from django.utils.translation import gettext as _
|
||||
from model_bakery import baker
|
||||
|
||||
from club.forms import MailingForm
|
||||
from club.models import Club, Mailing, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import AnonymousUser, User
|
||||
from sith.settings import SITH_BAR_MANAGER, SITH_MAIN_CLUB_ID
|
||||
|
||||
|
||||
class TestClub(TestCase):
|
||||
"""Set up data for test cases related to clubs and membership.
|
||||
|
||||
The generated dataset is the one created by the populate command,
|
||||
plus the following modifications :
|
||||
|
||||
- `self.club` is a dummy club recreated for each test
|
||||
- `self.club` has two board members : skia (role 3) and comptable (role 10)
|
||||
- `self.club` has one regular member : richard
|
||||
- `self.club` has one former member : sli (who had role 2)
|
||||
- None of the `self.club` members are in the AE club.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
# subscribed users - initial members
|
||||
cls.skia = User.objects.get(username="skia")
|
||||
# by default, Skia is in the AE, which creates side effect
|
||||
cls.skia.memberships.all().delete()
|
||||
cls.richard = User.objects.get(username="rbatsbak")
|
||||
cls.comptable = User.objects.get(username="comptable")
|
||||
cls.sli = User.objects.get(username="sli")
|
||||
cls.root = User.objects.get(username="root")
|
||||
|
||||
# subscribed users - not initial members
|
||||
cls.krophil = User.objects.get(username="krophil")
|
||||
cls.subscriber = User.objects.get(username="subscriber")
|
||||
|
||||
# old subscriber
|
||||
cls.old_subscriber = User.objects.get(username="old_subscriber")
|
||||
|
||||
# not subscribed
|
||||
cls.public = User.objects.get(username="public")
|
||||
|
||||
cls.ae = Club.objects.filter(pk=SITH_MAIN_CLUB_ID)[0]
|
||||
cls.club = Club.objects.create(
|
||||
name="Fake Club",
|
||||
unix_name="fake-club",
|
||||
address="5 rue de la République, 90000 Belfort",
|
||||
)
|
||||
cls.members_url = reverse("club:club_members", kwargs={"club_id": cls.club.id})
|
||||
a_month_ago = now() - timedelta(days=30)
|
||||
yesterday = now() - timedelta(days=1)
|
||||
Membership.objects.create(
|
||||
club=cls.club, user=cls.skia, start_date=a_month_ago, role=3
|
||||
)
|
||||
Membership.objects.create(club=cls.club, user=cls.richard, role=1)
|
||||
Membership.objects.create(
|
||||
club=cls.club, user=cls.comptable, start_date=a_month_ago, role=10
|
||||
)
|
||||
|
||||
# sli was a member but isn't anymore
|
||||
Membership.objects.create(
|
||||
club=cls.club,
|
||||
user=cls.sli,
|
||||
start_date=a_month_ago,
|
||||
end_date=yesterday,
|
||||
role=2,
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
|
||||
class TestMembershipQuerySet(TestClub):
|
||||
def test_ongoing(self):
|
||||
"""Test that the ongoing queryset method returns the memberships that
|
||||
are not ended.
|
||||
"""
|
||||
current_members = list(self.club.members.ongoing().order_by("id"))
|
||||
expected = [
|
||||
self.skia.memberships.get(club=self.club),
|
||||
self.comptable.memberships.get(club=self.club),
|
||||
self.richard.memberships.get(club=self.club),
|
||||
]
|
||||
expected.sort(key=lambda i: i.id)
|
||||
assert current_members == expected
|
||||
|
||||
def test_ongoing_with_membership_ending_today(self):
|
||||
"""Test that a membership ending the present day is considered as ended."""
|
||||
today = localdate()
|
||||
self.richard.memberships.filter(club=self.club).update(end_date=today)
|
||||
current_members = list(self.club.members.ongoing().order_by("id"))
|
||||
expected = [
|
||||
self.skia.memberships.get(club=self.club),
|
||||
self.comptable.memberships.get(club=self.club),
|
||||
]
|
||||
expected.sort(key=lambda i: i.id)
|
||||
assert current_members == expected
|
||||
|
||||
def test_board(self):
|
||||
"""Test that the board queryset method returns the memberships
|
||||
of user in the club board.
|
||||
"""
|
||||
board_members = list(self.club.members.board().order_by("id"))
|
||||
expected = [
|
||||
self.skia.memberships.get(club=self.club),
|
||||
self.comptable.memberships.get(club=self.club),
|
||||
# sli is no more member, but he was in the board
|
||||
self.sli.memberships.get(club=self.club),
|
||||
]
|
||||
expected.sort(key=lambda i: i.id)
|
||||
assert board_members == expected
|
||||
|
||||
def test_ongoing_board(self):
|
||||
"""Test that combining ongoing and board returns users
|
||||
who are currently board members of the club.
|
||||
"""
|
||||
members = list(self.club.members.ongoing().board().order_by("id"))
|
||||
expected = [
|
||||
self.skia.memberships.get(club=self.club),
|
||||
self.comptable.memberships.get(club=self.club),
|
||||
]
|
||||
expected.sort(key=lambda i: i.id)
|
||||
assert members == expected
|
||||
|
||||
def test_update_invalidate_cache(self):
|
||||
"""Test that the `update` queryset method properly invalidate cache."""
|
||||
mem_skia = self.skia.memberships.get(club=self.club)
|
||||
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
|
||||
self.skia.memberships.update(end_date=localtime(now()).date())
|
||||
assert (
|
||||
cache.get(f"membership_{mem_skia.club_id}_{mem_skia.user_id}")
|
||||
== "not_member"
|
||||
)
|
||||
|
||||
mem_richard = self.richard.memberships.get(club=self.club)
|
||||
cache.set(
|
||||
f"membership_{mem_richard.club_id}_{mem_richard.user_id}", mem_richard
|
||||
)
|
||||
self.richard.memberships.update(role=5)
|
||||
new_mem = self.richard.memberships.get(club=self.club)
|
||||
assert new_mem != "not_member"
|
||||
assert new_mem.role == 5
|
||||
|
||||
def test_update_change_club_groups(self):
|
||||
"""Test that `update` set the user groups accordingly."""
|
||||
user = baker.make(User)
|
||||
membership = baker.make(Membership, end_date=None, user=user, role=5)
|
||||
members_group = membership.club.members_group
|
||||
board_group = membership.club.board_group
|
||||
assert user.groups.contains(members_group)
|
||||
assert user.groups.contains(board_group)
|
||||
|
||||
user.memberships.update(role=1) # from board to simple member
|
||||
assert user.groups.contains(members_group)
|
||||
assert not user.groups.contains(board_group)
|
||||
|
||||
user.memberships.update(role=5) # from member to board
|
||||
assert user.groups.contains(members_group)
|
||||
assert user.groups.contains(board_group)
|
||||
|
||||
user.memberships.update(end_date=localdate()) # end the membership
|
||||
assert not user.groups.contains(members_group)
|
||||
assert not user.groups.contains(board_group)
|
||||
|
||||
def test_delete_invalidate_cache(self):
|
||||
"""Test that the `delete` queryset properly invalidate cache."""
|
||||
mem_skia = self.skia.memberships.get(club=self.club)
|
||||
mem_comptable = self.comptable.memberships.get(club=self.club)
|
||||
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
|
||||
cache.set(
|
||||
f"membership_{mem_comptable.club_id}_{mem_comptable.user_id}", mem_comptable
|
||||
)
|
||||
|
||||
# should delete the subscriptions of skia and comptable
|
||||
self.club.members.ongoing().board().delete()
|
||||
|
||||
for membership in (mem_skia, mem_comptable):
|
||||
cached_mem = cache.get(
|
||||
f"membership_{membership.club_id}_{membership.user_id}"
|
||||
)
|
||||
assert cached_mem == "not_member"
|
||||
|
||||
def test_delete_remove_from_groups(self):
|
||||
"""Test that `delete` removes from club groups"""
|
||||
user = baker.make(User)
|
||||
memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2)
|
||||
club_groups = {
|
||||
memberships[0].club.members_group,
|
||||
memberships[1].club.members_group,
|
||||
memberships[1].club.board_group,
|
||||
}
|
||||
assert set(user.groups.all()) == club_groups
|
||||
user.memberships.all().delete()
|
||||
assert user.groups.all().count() == 0
|
||||
|
||||
|
||||
class TestClubModel(TestClub):
|
||||
def assert_membership_started_today(self, user: User, role: int):
|
||||
"""Assert that the given membership is active and started today."""
|
||||
membership = user.memberships.ongoing().filter(club=self.club).first()
|
||||
assert membership is not None
|
||||
assert localtime(now()).date() == membership.start_date
|
||||
assert membership.end_date is None
|
||||
assert membership.role == role
|
||||
assert membership.club.get_membership_for(user) == membership
|
||||
assert user.is_in_group(pk=self.club.members_group_id)
|
||||
assert user.is_in_group(pk=self.club.board_group_id)
|
||||
|
||||
def assert_membership_ended_today(self, user: User):
|
||||
"""Assert that the given user have a membership which ended today."""
|
||||
today = localtime(now()).date()
|
||||
assert user.memberships.filter(club=self.club, end_date=today).exists()
|
||||
assert self.club.get_membership_for(user) is None
|
||||
|
||||
def test_access_unauthorized(self):
|
||||
"""Test that users who never subscribed and anonymous users
|
||||
cannot see the page.
|
||||
"""
|
||||
response = self.client.post(self.members_url)
|
||||
assert response.status_code == 403
|
||||
|
||||
self.client.force_login(self.public)
|
||||
response = self.client.post(self.members_url)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_display(self):
|
||||
"""Test that a GET request return a page where the requested
|
||||
information are displayed.
|
||||
"""
|
||||
self.client.force_login(self.skia)
|
||||
response = self.client.get(self.members_url)
|
||||
assert response.status_code == 200
|
||||
expected_html = (
|
||||
"<table><thead><tr>"
|
||||
"<td>Utilisateur</td><td>Rôle</td><td>Description</td>"
|
||||
"<td>Depuis</td><td>Marquer comme ancien</td>"
|
||||
"</tr></thead><tbody>"
|
||||
)
|
||||
memberships = self.club.members.ongoing().order_by("-role")
|
||||
input_id = 0
|
||||
for membership in memberships.select_related("user"):
|
||||
user = membership.user
|
||||
expected_html += (
|
||||
f"<tr><td><a href=\"{reverse('core:user_profile', args=[user.id])}\">"
|
||||
f"{user.get_display_name()}</a></td>"
|
||||
f"<td>{settings.SITH_CLUB_ROLES[membership.role]}</td>"
|
||||
f"<td>{membership.description}</td>"
|
||||
f"<td>{membership.start_date}</td><td>"
|
||||
)
|
||||
if membership.role <= 3: # 3 is the role of skia
|
||||
expected_html += (
|
||||
'<input type="checkbox" name="users_old" '
|
||||
f'value="{user.id}" '
|
||||
f'id="id_users_old_{input_id}">'
|
||||
)
|
||||
input_id += 1
|
||||
expected_html += "</td></tr>"
|
||||
expected_html += "</tbody></table>"
|
||||
self.assertInHTML(expected_html, response.content.decode())
|
||||
|
||||
def test_root_add_one_club_member(self):
|
||||
"""Test that root users can add members to clubs, one at a time."""
|
||||
self.client.force_login(self.root)
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{"users": [self.subscriber.id], "role": 3},
|
||||
)
|
||||
self.assertRedirects(response, self.members_url)
|
||||
self.subscriber.refresh_from_db()
|
||||
self.assert_membership_started_today(self.subscriber, role=3)
|
||||
|
||||
def test_root_add_multiple_club_member(self):
|
||||
"""Test that root users can add multiple members at once to clubs."""
|
||||
self.client.force_login(self.root)
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{
|
||||
"users": (self.subscriber.id, self.krophil.id),
|
||||
"role": 3,
|
||||
},
|
||||
)
|
||||
self.assertRedirects(response, self.members_url)
|
||||
self.subscriber.refresh_from_db()
|
||||
self.assert_membership_started_today(self.subscriber, role=3)
|
||||
self.assert_membership_started_today(self.krophil, role=3)
|
||||
|
||||
def test_add_unauthorized_members(self):
|
||||
"""Test that users who are not currently subscribed
|
||||
cannot be members of clubs.
|
||||
"""
|
||||
self.client.force_login(self.root)
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{"users": self.public.id, "role": 1},
|
||||
)
|
||||
assert not self.public.memberships.filter(club=self.club).exists()
|
||||
assert '<ul class="errorlist"><li>' in response.content.decode()
|
||||
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{"users": self.old_subscriber.id, "role": 1},
|
||||
)
|
||||
assert not self.public.memberships.filter(club=self.club).exists()
|
||||
assert self.club.get_membership_for(self.public) is None
|
||||
assert '<ul class="errorlist"><li>' in response.content.decode()
|
||||
|
||||
def test_add_members_already_members(self):
|
||||
"""Test that users who are already members of a club
|
||||
cannot be added again to this club.
|
||||
"""
|
||||
self.client.force_login(self.root)
|
||||
current_membership = self.skia.memberships.ongoing().get(club=self.club)
|
||||
nb_memberships = self.skia.memberships.count()
|
||||
self.client.post(
|
||||
self.members_url,
|
||||
{"users": self.skia.id, "role": current_membership.role + 1},
|
||||
)
|
||||
self.skia.refresh_from_db()
|
||||
assert nb_memberships == self.skia.memberships.count()
|
||||
new_membership = self.skia.memberships.ongoing().get(club=self.club)
|
||||
assert current_membership == new_membership
|
||||
assert self.club.get_membership_for(self.skia) == new_membership
|
||||
|
||||
def test_add_not_existing_users(self):
|
||||
"""Test that not existing users cannot be added in clubs.
|
||||
If one user in the request is invalid, no membership creation at all
|
||||
can take place.
|
||||
"""
|
||||
self.client.force_login(self.root)
|
||||
nb_memberships = self.club.members.count()
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{"users": [9999], "role": 1},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert '<ul class="errorlist"><li>' in response.content.decode()
|
||||
self.club.refresh_from_db()
|
||||
assert self.club.members.count() == nb_memberships
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{
|
||||
"users": (self.subscriber.id, 9999),
|
||||
"start_date": "12/06/2016",
|
||||
"role": 3,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert '<ul class="errorlist"><li>' in response.content.decode()
|
||||
self.club.refresh_from_db()
|
||||
assert self.club.members.count() == nb_memberships
|
||||
|
||||
def test_president_add_members(self):
|
||||
"""Test that the president of the club can add members."""
|
||||
president = self.club.members.get(role=10).user
|
||||
nb_club_membership = self.club.members.count()
|
||||
nb_subscriber_memberships = self.subscriber.memberships.count()
|
||||
self.client.force_login(president)
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{"users": self.subscriber.id, "role": 9},
|
||||
)
|
||||
self.assertRedirects(response, self.members_url)
|
||||
self.club.refresh_from_db()
|
||||
self.subscriber.refresh_from_db()
|
||||
assert self.club.members.count() == nb_club_membership + 1
|
||||
assert self.subscriber.memberships.count() == nb_subscriber_memberships + 1
|
||||
self.assert_membership_started_today(self.subscriber, role=9)
|
||||
|
||||
def test_add_member_greater_role(self):
|
||||
"""Test that a member of the club member cannot create
|
||||
a membership with a greater role than its own.
|
||||
"""
|
||||
self.client.force_login(self.skia)
|
||||
nb_memberships = self.club.members.count()
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{"users": self.subscriber.id, "role": 10},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
self.assertInHTML(
|
||||
"<li>Vous n'avez pas la permission de faire cela</li>",
|
||||
response.content.decode(),
|
||||
)
|
||||
self.club.refresh_from_db()
|
||||
assert nb_memberships == self.club.members.count()
|
||||
assert not self.subscriber.memberships.filter(club=self.club).exists()
|
||||
|
||||
def test_add_member_without_role(self):
|
||||
"""Test that trying to add members without specifying their role fails."""
|
||||
self.client.force_login(self.root)
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{"users": self.subscriber.id, "start_date": "12/06/2016"},
|
||||
)
|
||||
assert (
|
||||
'<ul class="errorlist"><li>Vous devez choisir un r'
|
||||
in response.content.decode()
|
||||
)
|
||||
|
||||
def test_end_membership_self(self):
|
||||
"""Test that a member can end its own membership."""
|
||||
self.client.force_login(self.skia)
|
||||
self.client.post(
|
||||
self.members_url,
|
||||
{"users_old": self.skia.id},
|
||||
)
|
||||
self.skia.refresh_from_db()
|
||||
self.assert_membership_ended_today(self.skia)
|
||||
|
||||
def test_end_membership_lower_role(self):
|
||||
"""Test that board members of the club can end memberships
|
||||
of users with lower roles.
|
||||
"""
|
||||
# remainder : skia has role 3, comptable has role 10, richard has role 1
|
||||
self.client.force_login(self.skia)
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{"users_old": self.richard.id},
|
||||
)
|
||||
self.assertRedirects(response, self.members_url)
|
||||
self.club.refresh_from_db()
|
||||
self.assert_membership_ended_today(self.richard)
|
||||
|
||||
def test_end_membership_higher_role(self):
|
||||
"""Test that board members of the club cannot end memberships
|
||||
of users with higher roles.
|
||||
"""
|
||||
membership = self.comptable.memberships.filter(club=self.club).first()
|
||||
self.client.force_login(self.skia)
|
||||
self.client.post(
|
||||
self.members_url,
|
||||
{"users_old": self.comptable.id},
|
||||
)
|
||||
self.club.refresh_from_db()
|
||||
new_membership = self.club.get_membership_for(self.comptable)
|
||||
assert new_membership is not None
|
||||
assert new_membership == membership
|
||||
|
||||
membership = self.comptable.memberships.filter(club=self.club).first()
|
||||
assert membership.end_date is None
|
||||
|
||||
def test_end_membership_as_main_club_board(self):
|
||||
"""Test that board members of the main club can end the membership
|
||||
of anyone.
|
||||
"""
|
||||
# make subscriber a board member
|
||||
subscriber = subscriber_user.make()
|
||||
Membership.objects.create(club=self.ae, user=subscriber, role=3)
|
||||
|
||||
nb_memberships = self.club.members.ongoing().count()
|
||||
self.client.force_login(subscriber)
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{"users_old": self.comptable.id},
|
||||
)
|
||||
self.assertRedirects(response, self.members_url)
|
||||
self.assert_membership_ended_today(self.comptable)
|
||||
assert self.club.members.ongoing().count() == nb_memberships - 1
|
||||
|
||||
def test_end_membership_as_root(self):
|
||||
"""Test that root users can end the membership of anyone."""
|
||||
nb_memberships = self.club.members.ongoing().count()
|
||||
self.client.force_login(self.root)
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{"users_old": [self.comptable.id]},
|
||||
)
|
||||
self.assertRedirects(response, self.members_url)
|
||||
self.assert_membership_ended_today(self.comptable)
|
||||
assert self.club.members.ongoing().count() == nb_memberships - 1
|
||||
|
||||
def test_end_membership_as_foreigner(self):
|
||||
"""Test that users who are not in this club cannot end its memberships."""
|
||||
nb_memberships = self.club.members.count()
|
||||
membership = self.richard.memberships.filter(club=self.club).first()
|
||||
self.client.force_login(self.subscriber)
|
||||
self.client.post(
|
||||
self.members_url,
|
||||
{"users_old": [self.richard.id]},
|
||||
)
|
||||
# nothing should have changed
|
||||
new_mem = self.club.get_membership_for(self.richard)
|
||||
assert self.club.members.count() == nb_memberships
|
||||
assert membership == new_mem
|
||||
|
||||
def test_remove_from_club_group(self):
|
||||
"""Test that when a membership ends, the user is removed from club groups."""
|
||||
user = baker.make(User)
|
||||
baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
|
||||
assert user.groups.contains(self.club.members_group)
|
||||
assert user.groups.contains(self.club.board_group)
|
||||
user.memberships.update(end_date=localdate())
|
||||
assert not user.groups.contains(self.club.members_group)
|
||||
assert not user.groups.contains(self.club.board_group)
|
||||
|
||||
def test_add_to_club_group(self):
|
||||
"""Test that when a membership begins, the user is added to the club group."""
|
||||
assert not self.subscriber.groups.contains(self.club.members_group)
|
||||
assert not self.subscriber.groups.contains(self.club.board_group)
|
||||
baker.make(Membership, club=self.club, user=self.subscriber, role=3)
|
||||
assert self.subscriber.groups.contains(self.club.members_group)
|
||||
assert self.subscriber.groups.contains(self.club.board_group)
|
||||
|
||||
def test_change_position_in_club(self):
|
||||
"""Test that when moving from board to members, club group change"""
|
||||
membership = baker.make(
|
||||
Membership, club=self.club, user=self.subscriber, role=3
|
||||
)
|
||||
assert self.subscriber.groups.contains(self.club.members_group)
|
||||
assert self.subscriber.groups.contains(self.club.board_group)
|
||||
membership.role = 1
|
||||
membership.save()
|
||||
assert self.subscriber.groups.contains(self.club.members_group)
|
||||
assert not self.subscriber.groups.contains(self.club.board_group)
|
||||
|
||||
def test_club_owner(self):
|
||||
"""Test that a club is owned only by board members of the main club."""
|
||||
anonymous = AnonymousUser()
|
||||
assert not self.club.is_owned_by(anonymous)
|
||||
assert not self.club.is_owned_by(self.subscriber)
|
||||
|
||||
# make sli a board member
|
||||
self.sli.memberships.all().delete()
|
||||
Membership(club=self.ae, user=self.sli, role=3).save()
|
||||
assert self.club.is_owned_by(self.sli)
|
||||
|
||||
def test_change_club_name(self):
|
||||
"""Test that changing the club name doesn't break things."""
|
||||
members_group = self.club.members_group
|
||||
board_group = self.club.board_group
|
||||
initial_members = set(members_group.users.values_list("id", flat=True))
|
||||
initial_board = set(board_group.users.values_list("id", flat=True))
|
||||
self.club.name = "something else"
|
||||
self.club.save()
|
||||
self.club.refresh_from_db()
|
||||
|
||||
# The names should have changed, but not the ids nor the group members
|
||||
assert self.club.members_group.name == "something else - Membres"
|
||||
assert self.club.board_group.name == "something else - Bureau"
|
||||
assert self.club.members_group.id == members_group.id
|
||||
assert self.club.board_group.id == board_group.id
|
||||
new_members = set(self.club.members_group.users.values_list("id", flat=True))
|
||||
new_board = set(self.club.board_group.users.values_list("id", flat=True))
|
||||
assert new_members == initial_members
|
||||
assert new_board == initial_board
|
||||
|
||||
|
||||
class TestMailingForm(TestCase):
|
||||
"""Perform validation tests for MailingForm."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.skia = User.objects.get(username="skia")
|
||||
cls.rbatsbak = User.objects.get(username="rbatsbak")
|
||||
cls.krophil = User.objects.get(username="krophil")
|
||||
cls.comunity = User.objects.get(username="comunity")
|
||||
cls.root = User.objects.get(username="root")
|
||||
cls.bdf = Club.objects.get(unix_name=SITH_BAR_MANAGER["unix_name"])
|
||||
cls.mail_url = reverse("club:mailing", kwargs={"club_id": cls.bdf.id})
|
||||
|
||||
def setUp(self):
|
||||
Membership(
|
||||
user=self.rbatsbak,
|
||||
club=self.bdf,
|
||||
start_date=timezone.now(),
|
||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||
).save()
|
||||
|
||||
def test_mailing_list_add_no_moderation(self):
|
||||
# Test with Communication admin
|
||||
self.client.force_login(self.comunity)
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "foyer"},
|
||||
)
|
||||
self.assertRedirects(response, self.mail_url)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
assert "Liste de diffusion foyer@utbm.fr" in response.content.decode()
|
||||
|
||||
# Test with Root
|
||||
self.client.force_login(self.root)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
assert "Liste de diffusion mde@utbm.fr" in response.content.decode()
|
||||
|
||||
def test_mailing_list_add_moderation(self):
|
||||
self.client.force_login(self.rbatsbak)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "Liste de diffusion mde@utbm.fr" not in content
|
||||
assert "<p>Listes de diffusions en attente de modération</p>" in content
|
||||
assert "<li>mde@utbm.fr" in content
|
||||
|
||||
def test_mailing_list_forbidden(self):
|
||||
# With anonymous user
|
||||
response = self.client.get(self.mail_url)
|
||||
self.assertContains(response, "", status_code=403)
|
||||
|
||||
# With user not in club
|
||||
self.client.force_login(self.krophil)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_add_new_subscription_fail_not_moderated(self):
|
||||
self.client.force_login(self.rbatsbak)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.skia.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
assert "skia@git.an" not in response.content.decode()
|
||||
|
||||
def test_add_new_subscription_success(self):
|
||||
# Prepare mailing list
|
||||
self.client.force_login(self.comunity)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
|
||||
# Add single user
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.skia.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
assert "skia@git.an" in response.content.decode()
|
||||
|
||||
# Add multiple users
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": (self.comunity.id, self.rbatsbak.id),
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "richard@git.an" in content
|
||||
assert "comunity@git.an" in content
|
||||
assert "skia@git.an" in content
|
||||
|
||||
# Add arbitrary email
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_email": "arbitrary@git.an",
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "richard@git.an" in content
|
||||
assert "comunity@git.an" in content
|
||||
assert "skia@git.an" in content
|
||||
assert "arbitrary@git.an" in content
|
||||
|
||||
# Add user and arbitrary email
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_email": "more.arbitrary@git.an",
|
||||
"subscription_users": self.krophil.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "richard@git.an" in content
|
||||
assert "comunity@git.an" in content
|
||||
assert "skia@git.an" in content
|
||||
assert "arbitrary@git.an" in content
|
||||
assert "more.arbitrary@git.an" in content
|
||||
assert "krophil@git.an" in content
|
||||
|
||||
def test_add_new_subscription_fail_form_errors(self):
|
||||
# Prepare mailing list
|
||||
self.client.force_login(self.comunity)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
|
||||
# Neither email or email is specified
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
assert response.status_code
|
||||
self.assertInHTML(
|
||||
_("You must specify at least an user or an email address"),
|
||||
response.content.decode(),
|
||||
)
|
||||
|
||||
# No mailing specified
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.krophil.id,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert _("This field is required") in response.content.decode()
|
||||
|
||||
# One of the selected users doesn't exist
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": [789],
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
self.assertInHTML(
|
||||
_("You must specify at least an user or an email address"),
|
||||
response.content.decode(),
|
||||
)
|
||||
|
||||
# An user has no email address
|
||||
self.krophil.email = ""
|
||||
self.krophil.save()
|
||||
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.krophil.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
self.assertInHTML(
|
||||
_("One of the selected users doesn't have an email address"),
|
||||
response.content.decode(),
|
||||
)
|
||||
|
||||
self.krophil.email = "krophil@git.an"
|
||||
self.krophil.save()
|
||||
|
||||
# An user is added twice
|
||||
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.krophil.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.krophil.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
self.assertInHTML(
|
||||
_("This email is already suscribed in this mailing"),
|
||||
response.content.decode(),
|
||||
)
|
||||
|
||||
def test_remove_subscription_success(self):
|
||||
# Prepare mailing list
|
||||
self.client.force_login(self.comunity)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
mde = Mailing.objects.get(email="mde")
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": (
|
||||
self.comunity.id,
|
||||
self.rbatsbak.id,
|
||||
self.krophil.id,
|
||||
),
|
||||
"subscription_mailing": mde.id,
|
||||
},
|
||||
)
|
||||
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
|
||||
assert "comunity@git.an" in content
|
||||
assert "richard@git.an" in content
|
||||
assert "krophil@git.an" in content
|
||||
|
||||
# Delete one user
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_REMOVE_SUBSCRIPTION,
|
||||
"removal_%d" % mde.id: mde.subscriptions.get(user=self.krophil).id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
|
||||
assert "comunity@git.an" in content
|
||||
assert "richard@git.an" in content
|
||||
assert "krophil@git.an" not in content
|
||||
|
||||
# Delete multiple users
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_REMOVE_SUBSCRIPTION,
|
||||
"removal_%d" % mde.id: [
|
||||
user.id
|
||||
for user in mde.subscriptions.filter(
|
||||
user__in=[self.rbatsbak, self.comunity]
|
||||
).all()
|
||||
],
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
|
||||
assert "comunity@git.an" not in content
|
||||
assert "richard@git.an" not in content
|
||||
assert "krophil@git.an" not in content
|
||||
|
||||
|
||||
class TestClubSellingView(TestCase):
|
||||
"""Perform basics tests to ensure that the page is available."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.ae = Club.objects.get(unix_name="ae")
|
||||
cls.skia = User.objects.get(username="skia")
|
||||
|
||||
def test_page_not_internal_error(self):
|
||||
"""Test that the page does not return and internal error."""
|
||||
self.client.force_login(self.skia)
|
||||
response = self.client.get(
|
||||
reverse("club:club_sellings", kwargs={"club_id": self.ae.id})
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1,73 +0,0 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||
from core.models import User
|
||||
|
||||
|
||||
class TestClub(TestCase):
|
||||
"""Set up data for test cases related to clubs and membership.
|
||||
|
||||
The generated dataset is the one created by the populate command,
|
||||
plus the following modifications :
|
||||
|
||||
- `self.club` is a dummy club
|
||||
- `self.club` has two board members :
|
||||
simple_board_member (role 3) and president (role 10)
|
||||
- `self.club` has one regular member : richard
|
||||
- `self.club` has one former member : sli (who had role 2)
|
||||
- None of the `self.club` members are in the AE club.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
# subscribed users - initial members
|
||||
cls.president, cls.simple_board_member = subscriber_user.make(_quantity=2)
|
||||
cls.richard = User.objects.get(username="rbatsbak")
|
||||
cls.sli = User.objects.get(username="sli")
|
||||
cls.root = baker.make(User, is_superuser=True)
|
||||
cls.old_subscriber = old_subscriber_user.make()
|
||||
cls.public = baker.make(User)
|
||||
|
||||
# subscribed users - not initial member
|
||||
cls.krophil = User.objects.get(username="krophil")
|
||||
cls.subscriber = subscriber_user.make()
|
||||
|
||||
cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
|
||||
cls.club = baker.make(Club)
|
||||
cls.president_role = baker.make(
|
||||
ClubRole, club=cls.club, is_board=True, is_presidency=True, order=0
|
||||
)
|
||||
cls.board_role = baker.make(ClubRole, club=cls.club, is_board=True, order=1)
|
||||
cls.member_role = baker.make(ClubRole, club=cls.club, order=2)
|
||||
cls.new_members_url = reverse(
|
||||
"club:club_new_members", kwargs={"club_id": cls.club.id}
|
||||
)
|
||||
cls.members_url = reverse("club:club_members", kwargs={"club_id": cls.club.id})
|
||||
a_month_ago = now() - timedelta(days=30)
|
||||
yesterday = now() - timedelta(days=1)
|
||||
membership_recipe = Recipe(Membership, club=cls.club)
|
||||
membership_recipe.make(
|
||||
user=cls.simple_board_member, start_date=a_month_ago, role=cls.board_role
|
||||
)
|
||||
membership_recipe.make(user=cls.richard, role=cls.member_role)
|
||||
membership_recipe.make(
|
||||
user=cls.president, start_date=a_month_ago, role=cls.president_role
|
||||
)
|
||||
membership_recipe.make( # sli was a member but isn't anymore
|
||||
user=cls.sli,
|
||||
start_date=a_month_ago,
|
||||
end_date=yesterday,
|
||||
role=cls.board_role,
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
@@ -1,127 +0,0 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.db import ProgrammingError
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import localdate
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import User
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_club_queryset_having_board_member():
|
||||
clubs = baker.make(Club, _quantity=5)
|
||||
user = subscriber_user.make()
|
||||
membership_recipe = Recipe(
|
||||
Membership, user=user, start_date=localdate() - timedelta(days=3)
|
||||
)
|
||||
membership_recipe.make(
|
||||
club=clubs[0], role=baker.make(ClubRole, club=clubs[0], is_board=False)
|
||||
)
|
||||
membership_recipe.make(
|
||||
club=clubs[1], role=baker.make(ClubRole, club=clubs[1], is_board=True)
|
||||
)
|
||||
membership_recipe.make(
|
||||
club=clubs[2], role=baker.make(ClubRole, club=clubs[2], is_board=True)
|
||||
)
|
||||
membership_recipe.make(
|
||||
club=clubs[3],
|
||||
role=baker.make(ClubRole, club=clubs[3], is_board=True),
|
||||
end_date=localdate() - timedelta(days=1),
|
||||
)
|
||||
|
||||
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
|
||||
assert set(club_ids) == {clubs[1].id, clubs[2].id}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nb_additional_clubs", [10, 30])
|
||||
@pytest.mark.parametrize("is_fragment", [True, False])
|
||||
@pytest.mark.django_db
|
||||
def test_club_list(client: Client, nb_additional_clubs: int, is_fragment):
|
||||
client.force_login(baker.make(User))
|
||||
baker.make(Club, _quantity=nb_additional_clubs)
|
||||
headers = {"HX-Request": True} if is_fragment else {}
|
||||
res = client.get(reverse("club:club_list"), headers=headers)
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
def assert_club_created(club_name: str):
|
||||
club = Club.objects.last()
|
||||
assert club.name == club_name
|
||||
assert club.board_group.name == f"{club_name} - Bureau"
|
||||
assert club.members_group.name == f"{club_name} - Membres"
|
||||
# default roles should be added on club creation,
|
||||
# whether the creation happens on the admin site or on the user site
|
||||
assert list(club.roles.values("name", "is_presidency", "is_board")) == [
|
||||
{"name": "Président⸱e", "is_presidency": True, "is_board": True},
|
||||
{"name": "Trésorier⸱e", "is_presidency": False, "is_board": True},
|
||||
{"name": "Membre actif⸱ve", "is_presidency": False, "is_board": False},
|
||||
{"name": "Curieux⸱euse", "is_presidency": False, "is_board": False},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_view(admin_client: Client):
|
||||
"""Test that the club creation view works well"""
|
||||
res = admin_client.get(reverse("club:club_new"))
|
||||
assert res.status_code == 200
|
||||
res = admin_client.post(
|
||||
reverse("club:club_new"),
|
||||
data={"name": "foo", "parent": settings.SITH_MAIN_CLUB_ID},
|
||||
)
|
||||
club = Club.objects.last()
|
||||
assertRedirects(res, club.get_absolute_url())
|
||||
assert_club_created("foo")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_default_roles_for_club_with_roles_fails():
|
||||
"""Test that an Error is raised if trying to create
|
||||
default roles for a club that already has roles.
|
||||
"""
|
||||
club = baker.make(Club)
|
||||
baker.make(ClubRole, club=club)
|
||||
with pytest.raises(ProgrammingError):
|
||||
club.create_default_roles()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAdminInterface:
|
||||
def test_create(self, admin_client: Client):
|
||||
"""Test the creation of a club via the admin interface."""
|
||||
res = admin_client.post(
|
||||
reverse("admin:club_club_add"),
|
||||
data={
|
||||
"name": "foo",
|
||||
"parent": settings.SITH_MAIN_CLUB_ID,
|
||||
"address": "Rome",
|
||||
},
|
||||
)
|
||||
assertRedirects(res, reverse("admin:club_club_changelist"))
|
||||
assert_club_created("foo")
|
||||
|
||||
def test_change(self, admin_client: Client):
|
||||
"""Test the edition of a club via the admin interface."""
|
||||
club = baker.make(Club)
|
||||
res = admin_client.post(
|
||||
reverse("admin:club_club_change", kwargs={"object_id": club.id}),
|
||||
data={
|
||||
"name": "foo",
|
||||
"page": club.page_id,
|
||||
"home": club.home_id,
|
||||
"address": club.address,
|
||||
},
|
||||
)
|
||||
assertRedirects(res, reverse("admin:club_club_changelist"))
|
||||
club.refresh_from_db()
|
||||
assert club.name == "foo"
|
||||
# Club roles shouldn't be modified when editing the club on the admin interface
|
||||
# This club had no roles beforehand, therefore it shouldn't have roles now.
|
||||
assert not club.roles.exists()
|
||||
@@ -1,95 +0,0 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
from pytest_django.asserts import assertNumQueries
|
||||
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import Group, Page, User
|
||||
|
||||
|
||||
class TestClubSearch(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.url = reverse("api:search_club")
|
||||
cls.user = baker.make(
|
||||
User, user_permissions=[Permission.objects.get(codename="access_lookup")]
|
||||
)
|
||||
# delete existing clubs to avoid side effect
|
||||
groups = list(
|
||||
Group.objects.exclude(club=None, club_board=None).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
)
|
||||
Membership.objects.all().delete()
|
||||
ClubRole.objects.all().delete()
|
||||
Club.objects.all().delete()
|
||||
Page.objects.exclude(name=settings.SITH_CLUB_ROOT_PAGE).delete()
|
||||
Group.objects.filter(id__in=groups).delete()
|
||||
|
||||
cls.clubs = baker.make(
|
||||
Club,
|
||||
_quantity=5,
|
||||
name=iter(["AE", "ae 1", "Troll", "Dev AE", "pdf"]),
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
def test_inactive_club(self):
|
||||
self.client.force_login(self.user)
|
||||
inactive_ids = {self.clubs[0].id, self.clubs[2].id}
|
||||
Club.objects.filter(id__in=inactive_ids).update(is_active=False)
|
||||
response = self.client.get(self.url, {"is_active": False})
|
||||
assert response.status_code == 200
|
||||
assert {d["id"] for d in response.json()["results"]} == inactive_ids
|
||||
|
||||
def test_excluded_id(self):
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(self.url, {"exclude_ids": [self.clubs[1].id]})
|
||||
assert response.status_code == 200
|
||||
ids = {d["id"] for d in response.json()["results"]}
|
||||
assert ids == {c.id for c in [self.clubs[0], *self.clubs[2:]]}
|
||||
|
||||
def test_club_search(self):
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(self.url, {"search": "AE"})
|
||||
assert response.status_code == 200
|
||||
ids = {d["id"] for d in response.json()["results"]}
|
||||
assert ids == {c.id for c in [self.clubs[0], self.clubs[1], self.clubs[3]]}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestFetchClub:
|
||||
@pytest.fixture()
|
||||
def club(self):
|
||||
club = baker.make(Club)
|
||||
last_month = date.today() - timedelta(days=30)
|
||||
yesterday = date.today() - timedelta(days=1)
|
||||
membership_recipe = Recipe(Membership, club=club, start_date=last_month)
|
||||
membership_recipe.make(end_date=None, _quantity=10, _bulk_create=True)
|
||||
membership_recipe.make(end_date=yesterday, _quantity=10, _bulk_create=True)
|
||||
return club
|
||||
|
||||
def test_fetch_club_members(self, client: Client, club: Club):
|
||||
user = subscriber_user.make()
|
||||
client.force_login(user)
|
||||
res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id}))
|
||||
assert res.status_code == 200
|
||||
member_ids = {member["user"]["id"] for member in res.json()["members"]}
|
||||
assert member_ids == set(
|
||||
club.members.ongoing().values_list("user_id", flat=True)
|
||||
)
|
||||
|
||||
def test_fetch_club_nb_queries(self, client: Client, club: Club):
|
||||
user = subscriber_user.make()
|
||||
client.force_login(user)
|
||||
with assertNumQueries(7):
|
||||
# - 4 queries for authentication
|
||||
# - 3 queries for the actual data
|
||||
res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id}))
|
||||
assert res.status_code == 200
|
||||
@@ -1,253 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker, seq
|
||||
from model_bakery.recipe import Recipe
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.forms import ClubRoleFormSet
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import AnonymousUser, User
|
||||
|
||||
|
||||
def make_club():
|
||||
# unittest-style tests cannot use fixture, so we create a function
|
||||
# that will be callable either by a pytest fixture or inside
|
||||
# a TestCase.setUpTestData method.
|
||||
club = baker.make(Club)
|
||||
recipe = Recipe(ClubRole, club=club, name=seq("role "))
|
||||
recipe.make(
|
||||
is_board=iter([True, True, False]),
|
||||
is_presidency=iter([True, False, False]),
|
||||
order=iter([0, 1, 2]),
|
||||
_quantity=3,
|
||||
_bulk_create=True,
|
||||
)
|
||||
return club
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def club(db):
|
||||
"""A club with a presidency role, a board role and a member role"""
|
||||
return make_club()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_auto(club):
|
||||
"""Test that newly created roles are put in the right place."""
|
||||
roles = list(club.roles.all())
|
||||
# create new roles one by one (like they will be in prod)
|
||||
# each new role should be placed at the end of its category
|
||||
recipe = Recipe(ClubRole, club=club, name=seq("new role "))
|
||||
role_a = recipe.make(is_board=True, is_presidency=True, order=None)
|
||||
role_b = recipe.make(is_board=True, is_presidency=False, order=None)
|
||||
role_c = recipe.make(is_board=False, is_presidency=False, order=None)
|
||||
assert list(club.roles.order_by("order")) == [
|
||||
roles[0],
|
||||
role_a,
|
||||
roles[1],
|
||||
role_b,
|
||||
roles[2],
|
||||
role_c,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
("user_factory", "is_allowed"),
|
||||
[
|
||||
(
|
||||
lambda club: baker.make(
|
||||
User,
|
||||
user_permissions=[Permission.objects.get(codename="change_clubrole")],
|
||||
),
|
||||
True,
|
||||
),
|
||||
( # user with presidency roles can edit the club roles
|
||||
lambda club: subscriber_user.make(
|
||||
memberships=[
|
||||
baker.make(
|
||||
Membership,
|
||||
club=club,
|
||||
role=club.roles.filter(is_presidency=True).first(),
|
||||
)
|
||||
]
|
||||
),
|
||||
True,
|
||||
),
|
||||
( # user in the board but not in the presidency cannot edit roles
|
||||
lambda club: subscriber_user.make(
|
||||
memberships=[
|
||||
baker.make(
|
||||
Membership,
|
||||
club=club,
|
||||
role=club.roles.filter(
|
||||
is_presidency=False, is_board=True
|
||||
).first(),
|
||||
)
|
||||
]
|
||||
),
|
||||
False,
|
||||
),
|
||||
(lambda _: AnonymousUser(), False),
|
||||
],
|
||||
)
|
||||
def test_can_roles_be_edited_by(
|
||||
club: Club, user_factory: Callable[[Club], User], is_allowed
|
||||
):
|
||||
"""Test that `Club.can_roles_be_edited_by` return the right value"""
|
||||
user = user_factory(club)
|
||||
assert club.can_roles_be_edited_by(user) == is_allowed
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
["route", "is_presidency", "is_board"],
|
||||
[
|
||||
("club:new_role_president", True, True),
|
||||
("club:new_role_board", False, True),
|
||||
("club:new_role_member", False, False),
|
||||
],
|
||||
)
|
||||
def test_create_role_view(client: Client, route: str, is_presidency, is_board):
|
||||
"""Test that the role creation views work."""
|
||||
club = baker.make(Club)
|
||||
role = baker.make(ClubRole, club=club, is_presidency=True, is_board=True)
|
||||
user = subscriber_user.make()
|
||||
baker.make(Membership, club=club, role=role, user=user, end_date=None)
|
||||
url = reverse(route, kwargs={"club_id": club.id})
|
||||
client.force_login(user)
|
||||
|
||||
res = client.get(url)
|
||||
assert res.status_code == 200
|
||||
|
||||
res = client.post(url, data={"name": "foo"})
|
||||
assertRedirects(res, reverse("club:club_roles", kwargs={"club_id": club.id}))
|
||||
new_role = club.roles.last()
|
||||
assert new_role.name == "foo"
|
||||
assert new_role.is_presidency == is_presidency
|
||||
assert new_role.is_board == is_board
|
||||
|
||||
|
||||
class TestClubRoleUpdate(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.club = make_club()
|
||||
cls.roles = list(cls.club.roles.all())
|
||||
cls.user = subscriber_user.make()
|
||||
baker.make(
|
||||
Membership, club=cls.club, role=cls.roles[0], user=cls.user, end_date=None
|
||||
)
|
||||
cls.url = reverse("club:club_roles", kwargs={"club_id": cls.club.id})
|
||||
cls.redirect_url = reverse("club:club_members", kwargs={"club_id": cls.club.id})
|
||||
|
||||
def setUp(self):
|
||||
self.payload = {
|
||||
"roles-TOTAL_FORMS": 3,
|
||||
"roles-INITIAL_FORMS": 3,
|
||||
"roles-MIN_NUM_FORMS": 0,
|
||||
"roles-MAX_NUM_FORMS": 1000,
|
||||
"roles-0-ORDER": 1,
|
||||
"roles-0-id": self.roles[0].id,
|
||||
"roles-0-club": self.club.id,
|
||||
"roles-0-is_presidency": True,
|
||||
"roles-0-is_board": True,
|
||||
"roles-0-name": self.roles[0].name,
|
||||
"roles-0-description": self.roles[0].description,
|
||||
"roles-0-is_active": True,
|
||||
"roles-1-ORDER": 2,
|
||||
"roles-1-id": self.roles[1].id,
|
||||
"roles-1-club": self.club.id,
|
||||
"roles-1-is_presidency": False,
|
||||
"roles-1-is_board": True,
|
||||
"roles-1-name": self.roles[1].name,
|
||||
"roles-1-description": self.roles[1].description,
|
||||
"roles-1-is_active": True,
|
||||
"roles-2-ORDER": 3,
|
||||
"roles-2-id": self.roles[2].id,
|
||||
"roles-2-club": self.club.id,
|
||||
"roles-2-is_presidency": False,
|
||||
"roles-2-is_board": False,
|
||||
"roles-2-name": self.roles[2].name,
|
||||
"roles-2-description": self.roles[2].description,
|
||||
"roles-2-is_active": True,
|
||||
}
|
||||
|
||||
def test_view_ok(self):
|
||||
"""Basic test to check that the view works."""
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.get(self.url)
|
||||
assert res.status_code == 200
|
||||
self.payload["roles-2-name"] = "foo"
|
||||
res = self.client.post(self.url, data=self.payload)
|
||||
assertRedirects(res, self.redirect_url)
|
||||
self.roles[2].refresh_from_db()
|
||||
assert self.roles[2].name == "foo"
|
||||
|
||||
def test_incoherent_order(self):
|
||||
"""Test that placing a member role over a board role fails."""
|
||||
self.payload["roles-0-ORDER"] = 4
|
||||
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
|
||||
assert not formset.is_valid()
|
||||
assert formset.errors == [
|
||||
{
|
||||
"__all__": [
|
||||
f"Le rôle {self.roles[0].name} ne peut pas "
|
||||
"être placé en-dessous d'un rôle de membre.",
|
||||
f"Le rôle {self.roles[0].name} ne peut pas être placé "
|
||||
"en-dessous d'un rôle qui n'est pas de la présidence.",
|
||||
]
|
||||
},
|
||||
{},
|
||||
{},
|
||||
]
|
||||
|
||||
def test_change_order_ok(self):
|
||||
"""Test that changing order the intended way works"""
|
||||
self.payload["roles-1-ORDER"] = 3
|
||||
self.payload["roles-1-is_board"] = False
|
||||
self.payload["roles-2-ORDER"] = 2
|
||||
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
|
||||
assert formset.is_valid()
|
||||
formset.save()
|
||||
assert list(self.club.roles.order_by("order")) == [
|
||||
self.roles[0],
|
||||
self.roles[2],
|
||||
self.roles[1],
|
||||
]
|
||||
self.roles[1].refresh_from_db()
|
||||
assert not self.roles[1].is_board
|
||||
|
||||
def test_non_board_presidency_is_forbidden(self):
|
||||
"""Test that a role cannot be in the presidency without being in the board."""
|
||||
self.payload["roles-0-is_board"] = False
|
||||
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
|
||||
assert not formset.is_valid()
|
||||
assert formset.errors == [
|
||||
{
|
||||
"__all__": [
|
||||
"Un rôle ne peut pas appartenir à la présidence sans être dans le bureau",
|
||||
]
|
||||
},
|
||||
{},
|
||||
{},
|
||||
]
|
||||
|
||||
def test_president_moves_itself_out_of_the_presidency(self):
|
||||
"""Test that if the user moves its own role out of the presidency,
|
||||
then it's redirected to another page and loses access to the update page."""
|
||||
self.payload["roles-0-is_presidency"] = False
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.post(self.url, data=self.payload)
|
||||
assertRedirects(res, self.redirect_url)
|
||||
# When the user clicked that button, it still had the right to update roles,
|
||||
# so the modification should be applied
|
||||
self.roles[0].refresh_from_db()
|
||||
assert self.roles[0].is_presidency is False
|
||||
|
||||
res = self.client.get(self.url)
|
||||
assert res.status_code == 403
|
||||
@@ -1,54 +0,0 @@
|
||||
import pytest
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_club_board_member_cannot_edit_club_properties(client: Client):
|
||||
user = subscriber_user.make()
|
||||
club = baker.make(Club, name="old name", is_active=True, address="old address")
|
||||
baker.make(
|
||||
Membership,
|
||||
club=club,
|
||||
user=user,
|
||||
role=baker.make(ClubRole, club=club, is_board=True),
|
||||
)
|
||||
client.force_login(user)
|
||||
res = client.post(
|
||||
reverse("club:club_edit", kwargs={"club_id": club.id}),
|
||||
{
|
||||
"name": "new name",
|
||||
"is_active": False,
|
||||
"address": "new address",
|
||||
"link-TOTAL_FORMS": 0,
|
||||
"link-INITIAL_FORMS": 0,
|
||||
},
|
||||
)
|
||||
# The request should success,
|
||||
# but admin-only fields shouldn't be taken into account
|
||||
assertRedirects(res, club.get_absolute_url())
|
||||
club.refresh_from_db()
|
||||
assert club.name == "old name"
|
||||
assert club.is_active
|
||||
assert club.address == "new address"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_edit_club_page_doesnt_crash(client: Client):
|
||||
"""crash test for club:club_edit"""
|
||||
club = baker.make(Club)
|
||||
user = subscriber_user.make()
|
||||
baker.make(
|
||||
Membership,
|
||||
club=club,
|
||||
user=user,
|
||||
role=baker.make(ClubRole, club=club, is_board=True),
|
||||
)
|
||||
client.force_login(user)
|
||||
res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id}))
|
||||
assert res.status_code == 200
|
||||
@@ -1,328 +0,0 @@
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from model_bakery import baker
|
||||
|
||||
from club.forms import MailingForm
|
||||
from club.models import Club, ClubRole, Mailing, Membership
|
||||
from core.models import User
|
||||
|
||||
|
||||
class TestMailingForm(TestCase):
|
||||
"""Perform validation tests for MailingForm."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.skia = User.objects.get(username="skia")
|
||||
cls.rbatsbak = User.objects.get(username="rbatsbak")
|
||||
cls.krophil = User.objects.get(username="krophil")
|
||||
cls.comunity = User.objects.get(username="comunity")
|
||||
cls.root = User.objects.get(username="root")
|
||||
cls.club = Club.objects.get(id=settings.SITH_PDF_CLUB_ID)
|
||||
cls.mail_url = reverse("club:mailing", kwargs={"club_id": cls.club.id})
|
||||
Membership(
|
||||
user=cls.rbatsbak,
|
||||
club=cls.club,
|
||||
start_date=timezone.now(),
|
||||
role=baker.make(ClubRole, club=cls.club, is_board=True),
|
||||
).save()
|
||||
|
||||
def test_mailing_list_add_no_moderation(self):
|
||||
# Test with Communication admin
|
||||
self.client.force_login(self.comunity)
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "foyer"},
|
||||
)
|
||||
self.assertRedirects(response, self.mail_url)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
assert "Liste de diffusion foyer@utbm.fr" in response.text
|
||||
|
||||
# Test with Root
|
||||
self.client.force_login(self.root)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
assert "Liste de diffusion mde@utbm.fr" in response.text
|
||||
|
||||
def test_mailing_list_add_moderation(self):
|
||||
self.client.force_login(self.rbatsbak)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
assert "Liste de diffusion mde@utbm.fr" not in content
|
||||
assert "<p>Listes de diffusions en attente de modération</p>" in content
|
||||
assert "<li>mde@utbm.fr" in content
|
||||
|
||||
def test_mailing_list_forbidden(self):
|
||||
# With anonymous user
|
||||
response = self.client.get(self.mail_url)
|
||||
self.assertContains(response, "", status_code=403)
|
||||
|
||||
# With user not in club
|
||||
self.client.force_login(self.krophil)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_add_new_subscription_fail_not_moderated(self):
|
||||
self.client.force_login(self.rbatsbak)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.skia.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
assert "skia@git.an" not in response.text
|
||||
|
||||
def test_add_new_subscription_success(self):
|
||||
# Prepare mailing list
|
||||
self.client.force_login(self.comunity)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
|
||||
# Add single user
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.skia.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
assert "skia@git.an" in response.text
|
||||
|
||||
# Add multiple users
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": (self.comunity.id, self.rbatsbak.id),
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
assert "richard@git.an" in content
|
||||
assert "comunity@git.an" in content
|
||||
assert "skia@git.an" in content
|
||||
|
||||
# Add arbitrary email
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_email": "arbitrary@git.an",
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
assert "richard@git.an" in content
|
||||
assert "comunity@git.an" in content
|
||||
assert "skia@git.an" in content
|
||||
assert "arbitrary@git.an" in content
|
||||
|
||||
# Add user and arbitrary email
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_email": "more.arbitrary@git.an",
|
||||
"subscription_users": self.krophil.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
assert "richard@git.an" in content
|
||||
assert "comunity@git.an" in content
|
||||
assert "skia@git.an" in content
|
||||
assert "arbitrary@git.an" in content
|
||||
assert "more.arbitrary@git.an" in content
|
||||
assert "krophil@git.an" in content
|
||||
|
||||
def test_add_new_subscription_fail_form_errors(self):
|
||||
# Prepare mailing list
|
||||
self.client.force_login(self.comunity)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
|
||||
# Neither email or email is specified
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
assert response.status_code
|
||||
self.assertInHTML(
|
||||
_("You must specify at least an user or an email address"),
|
||||
response.text,
|
||||
)
|
||||
|
||||
# No mailing specified
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.krophil.id,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert _("This field is required") in response.text
|
||||
|
||||
# One of the selected users doesn't exist
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": [789],
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
self.assertInHTML(
|
||||
_("You must specify at least an user or an email address"),
|
||||
response.text,
|
||||
)
|
||||
|
||||
# An user has no email address
|
||||
self.krophil.email = ""
|
||||
self.krophil.save()
|
||||
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.krophil.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
self.assertInHTML(
|
||||
_("One of the selected users doesn't have an email address"),
|
||||
response.text,
|
||||
)
|
||||
|
||||
self.krophil.email = "krophil@git.an"
|
||||
self.krophil.save()
|
||||
|
||||
# An user is added twice
|
||||
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.krophil.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.krophil.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
self.assertInHTML(
|
||||
_("This email is already suscribed in this mailing"),
|
||||
response.text,
|
||||
)
|
||||
|
||||
def test_remove_subscription_success(self):
|
||||
# Prepare mailing list
|
||||
self.client.force_login(self.comunity)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
mde = Mailing.objects.get(email="mde")
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": (
|
||||
self.comunity.id,
|
||||
self.rbatsbak.id,
|
||||
self.krophil.id,
|
||||
),
|
||||
"subscription_mailing": mde.id,
|
||||
},
|
||||
)
|
||||
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
|
||||
assert "comunity@git.an" in content
|
||||
assert "richard@git.an" in content
|
||||
assert "krophil@git.an" in content
|
||||
|
||||
# Delete one user
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_REMOVE_SUBSCRIPTION,
|
||||
"removal_%d" % mde.id: mde.subscriptions.get(user=self.krophil).id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
|
||||
assert "comunity@git.an" in content
|
||||
assert "richard@git.an" in content
|
||||
assert "krophil@git.an" not in content
|
||||
|
||||
# Delete multiple users
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_REMOVE_SUBSCRIPTION,
|
||||
"removal_%d" % mde.id: [
|
||||
user.id
|
||||
for user in mde.subscriptions.filter(
|
||||
user__in=[self.rbatsbak, self.comunity]
|
||||
).all()
|
||||
],
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
|
||||
assert "comunity@git.an" not in content
|
||||
assert "richard@git.an" not in content
|
||||
assert "krophil@git.an" not in content
|
||||
@@ -1,720 +0,0 @@
|
||||
import itertools
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Max
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import localdate, localtime, now
|
||||
from model_bakery import baker, seq
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.forms import ClubAddMemberForm, JoinClubForm
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from club.tests.base import TestClub
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import AnonymousUser, User
|
||||
|
||||
|
||||
class TestMembershipQuerySet(TestClub):
|
||||
def test_ongoing(self):
|
||||
"""Test that the ongoing queryset method returns the memberships that
|
||||
are not ended.
|
||||
"""
|
||||
current_members = list(self.club.members.ongoing().order_by("id"))
|
||||
expected = [
|
||||
self.simple_board_member.memberships.get(club=self.club),
|
||||
self.president.memberships.get(club=self.club),
|
||||
self.richard.memberships.get(club=self.club),
|
||||
]
|
||||
expected.sort(key=lambda i: i.id)
|
||||
assert current_members == expected
|
||||
|
||||
def test_ongoing_with_membership_ending_today(self):
|
||||
"""Test that a membership ending the present day is considered as ended."""
|
||||
today = localdate()
|
||||
self.richard.memberships.filter(club=self.club).update(end_date=today)
|
||||
current_members = list(self.club.members.ongoing().order_by("id"))
|
||||
expected = [
|
||||
self.simple_board_member.memberships.get(club=self.club),
|
||||
self.president.memberships.get(club=self.club),
|
||||
]
|
||||
expected.sort(key=lambda i: i.id)
|
||||
assert current_members == expected
|
||||
|
||||
def test_board(self):
|
||||
"""Test that the board queryset method returns the memberships
|
||||
of user in the club board.
|
||||
"""
|
||||
board_members = list(self.club.members.board().order_by("id"))
|
||||
expected = [
|
||||
self.simple_board_member.memberships.get(club=self.club),
|
||||
self.president.memberships.get(club=self.club),
|
||||
# sli is no more member, but he was in the board
|
||||
self.sli.memberships.get(club=self.club),
|
||||
]
|
||||
expected.sort(key=lambda i: i.id)
|
||||
assert board_members == expected
|
||||
|
||||
def test_ongoing_board(self):
|
||||
"""Test that combining ongoing and board returns users
|
||||
who are currently board members of the club.
|
||||
"""
|
||||
members = list(self.club.members.ongoing().board().order_by("id"))
|
||||
expected = [
|
||||
self.simple_board_member.memberships.get(club=self.club),
|
||||
self.president.memberships.get(club=self.club),
|
||||
]
|
||||
expected.sort(key=lambda i: i.id)
|
||||
assert members == expected
|
||||
|
||||
def test_update_change_club_groups(self):
|
||||
"""Test that `update` set the user groups accordingly."""
|
||||
user = baker.make(User)
|
||||
board_role, member_role = baker.make(
|
||||
ClubRole, is_board=iter([True, False]), _quantity=2, _bulk_create=True
|
||||
)
|
||||
membership = baker.make(
|
||||
Membership, end_date=None, user=user, role=board_role, club=board_role.club
|
||||
)
|
||||
members_group = membership.club.members_group
|
||||
board_group = membership.club.board_group
|
||||
assert user.groups.contains(members_group)
|
||||
assert user.groups.contains(board_group)
|
||||
|
||||
user.memberships.update(role=member_role) # from board to simple member
|
||||
assert user.groups.contains(members_group)
|
||||
assert not user.groups.contains(board_group)
|
||||
|
||||
user.memberships.update(role=board_role) # from member to board
|
||||
assert user.groups.contains(members_group)
|
||||
assert user.groups.contains(board_group)
|
||||
|
||||
user.memberships.update(end_date=localdate()) # end the membership
|
||||
assert not user.groups.contains(members_group)
|
||||
assert not user.groups.contains(board_group)
|
||||
|
||||
def test_delete_remove_from_groups(self):
|
||||
"""Test that `delete` removes from club groups"""
|
||||
user = baker.make(User)
|
||||
club = baker.make(Club)
|
||||
roles = baker.make(
|
||||
ClubRole,
|
||||
is_board=iter([False, True]),
|
||||
club=club,
|
||||
_quantity=2,
|
||||
_bulk_create=True,
|
||||
)
|
||||
memberships = baker.make(
|
||||
Membership, club=club, role=iter(roles), user=user, _quantity=2
|
||||
)
|
||||
club_groups = {
|
||||
memberships[0].club.members_group,
|
||||
memberships[1].club.members_group,
|
||||
memberships[1].club.board_group,
|
||||
}
|
||||
assert set(user.groups.all()).issuperset(club_groups)
|
||||
user.memberships.all().delete()
|
||||
assert set(user.groups.all()).isdisjoint(club_groups)
|
||||
|
||||
|
||||
class TestMembershipEditableBy(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
Membership.objects.all().delete()
|
||||
cls.club_a, cls.club_b = baker.make(Club, _quantity=2)
|
||||
roles = baker.make(
|
||||
ClubRole,
|
||||
is_presidency=itertools.cycle([True, False, False, False]),
|
||||
is_board=itertools.cycle([True, True, True, False]),
|
||||
order=itertools.cycle(range(4)),
|
||||
club=iter(
|
||||
[*itertools.repeat(cls.club_a, 4), *itertools.repeat(cls.club_b, 4)]
|
||||
),
|
||||
_quantity=8,
|
||||
_bulk_create=True,
|
||||
)
|
||||
cls.memberships = [
|
||||
*baker.make(Membership, role=iter(roles[:4]), club=cls.club_a, _quantity=4),
|
||||
*baker.make(Membership, role=iter(roles[4:]), club=cls.club_b, _quantity=4),
|
||||
]
|
||||
|
||||
def test_admin_user(self):
|
||||
perm = Permission.objects.get(codename="change_membership")
|
||||
user = baker.make(User, user_permissions=[perm])
|
||||
qs = Membership.objects.editable_by(user).values_list("id", flat=True)
|
||||
assert set(qs) == set(Membership.objects.values_list("id", flat=True))
|
||||
|
||||
def test_simple_subscriber_user(self):
|
||||
user = subscriber_user.make()
|
||||
assert not Membership.objects.editable_by(user).exists()
|
||||
|
||||
def test_board_member(self):
|
||||
# a board member can end lower memberships and its own one
|
||||
user = self.memberships[2].user
|
||||
qs = Membership.objects.editable_by(user).values_list("id", flat=True)
|
||||
expected = {self.memberships[2].id, self.memberships[3].id}
|
||||
assert set(qs) == expected
|
||||
|
||||
|
||||
class TestMembership(TestClub):
|
||||
def assert_membership_started_today(self, user: User, role: ClubRole):
|
||||
"""Assert that the given membership is active and started today."""
|
||||
membership = user.memberships.ongoing().filter(club=self.club).first()
|
||||
assert membership is not None
|
||||
assert localtime(now()).date() == membership.start_date
|
||||
assert membership.end_date is None
|
||||
assert membership.role == role
|
||||
assert membership.club.get_membership_for(user) == membership
|
||||
assert user.is_in_group(pk=self.club.members_group_id)
|
||||
assert user.is_in_group(pk=self.club.board_group_id)
|
||||
|
||||
def assert_membership_ended_today(self, user: User):
|
||||
"""Assert that the given user have a membership which ended today."""
|
||||
today = localdate()
|
||||
assert user.memberships.filter(club=self.club, end_date=today).exists()
|
||||
assert self.club.get_membership_for(user) is None
|
||||
|
||||
def test_access_unauthorized(self):
|
||||
"""Test that users who never subscribed and anonymous users
|
||||
cannot see the page.
|
||||
"""
|
||||
response = self.client.post(self.members_url)
|
||||
assertRedirects(
|
||||
response, reverse("core:login", query={"next": self.members_url})
|
||||
)
|
||||
|
||||
self.client.force_login(self.public)
|
||||
response = self.client.post(self.members_url)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_display(self):
|
||||
"""Test that a GET request return a page where the requested
|
||||
information are displayed.
|
||||
"""
|
||||
self.client.force_login(self.simple_board_member)
|
||||
response = self.client.get(
|
||||
reverse("club:club_members", kwargs={"club_id": self.club.id})
|
||||
)
|
||||
assert response.status_code == 200
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
table = soup.find("table", id="club_members_table")
|
||||
assert [r.text for r in table.find("thead").find_all("td")] == [
|
||||
"Utilisateur",
|
||||
"Rôle",
|
||||
"Description",
|
||||
"Depuis",
|
||||
"Marquer comme ancien",
|
||||
]
|
||||
rows = table.find("tbody").find_all("tr")
|
||||
memberships = (
|
||||
self.club.members.ongoing()
|
||||
.order_by("role__order")
|
||||
.select_related("user", "role")
|
||||
)
|
||||
user_role = ClubRole.objects.get(members__user=self.simple_board_member)
|
||||
for row, membership in zip(rows, memberships, strict=False):
|
||||
user = membership.user
|
||||
user_url = reverse("core:user_profile", args=[user.id])
|
||||
cols = row.find_all("td")
|
||||
user_link = cols[0].find("a")
|
||||
assert user_link.attrs["href"] == user_url
|
||||
assert user_link.text == user.get_display_name()
|
||||
assert cols[1].text == membership.role.name
|
||||
assert cols[2].text == membership.description
|
||||
assert cols[3].text == str(membership.start_date)
|
||||
|
||||
if (
|
||||
membership.role.order > user_role.order
|
||||
or membership.user_id == self.simple_board_member.id
|
||||
):
|
||||
# 3 is the role of simple_board_member
|
||||
form_input = cols[4].find("input")
|
||||
expected_attrs = {
|
||||
"type": "checkbox",
|
||||
"name": "members_old",
|
||||
"value": str(membership.id),
|
||||
}
|
||||
assert form_input.attrs.items() >= expected_attrs.items()
|
||||
else:
|
||||
assert cols[4].find_all() == []
|
||||
|
||||
def test_root_add_one_club_member(self):
|
||||
"""Test that root users can add members to clubs"""
|
||||
self.client.force_login(self.root)
|
||||
response = self.client.post(
|
||||
self.new_members_url,
|
||||
{"user": self.subscriber.id, "role": self.board_role.id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers.get("HX-Redirect", "") == reverse(
|
||||
"club:club_members", kwargs={"club_id": self.club.id}
|
||||
)
|
||||
self.subscriber.refresh_from_db()
|
||||
self.assert_membership_started_today(self.subscriber, role=self.board_role)
|
||||
|
||||
def test_add_unauthorized_members(self):
|
||||
"""Test that users who are not currently subscribed
|
||||
cannot be members of clubs.
|
||||
"""
|
||||
for user in self.public, self.old_subscriber:
|
||||
form = ClubAddMemberForm(
|
||||
data={"user": user.id, "role": self.member_role},
|
||||
request_user=self.root,
|
||||
club=self.club,
|
||||
)
|
||||
|
||||
assert not form.is_valid()
|
||||
assert form.errors == {
|
||||
"user": ["L'utilisateur doit être cotisant pour faire partie d'un club"]
|
||||
}
|
||||
|
||||
def test_add_members_already_members(self):
|
||||
"""Test that users who are already members of a club
|
||||
cannot be added again to this club.
|
||||
"""
|
||||
self.client.force_login(self.root)
|
||||
current_membership = self.simple_board_member.memberships.ongoing().get(
|
||||
club=self.club
|
||||
)
|
||||
nb_memberships = self.simple_board_member.memberships.count()
|
||||
self.client.post(
|
||||
self.members_url,
|
||||
{"users": self.simple_board_member.id, "role": self.member_role},
|
||||
)
|
||||
self.simple_board_member.refresh_from_db()
|
||||
assert nb_memberships == self.simple_board_member.memberships.count()
|
||||
new_membership = self.simple_board_member.memberships.ongoing().get(
|
||||
club=self.club
|
||||
)
|
||||
assert current_membership == new_membership
|
||||
assert self.club.get_membership_for(self.simple_board_member) == new_membership
|
||||
|
||||
def test_add_not_existing_users(self):
|
||||
"""Test that not existing users cannot be added in clubs.
|
||||
If one user in the request is invalid, no membership creation at all
|
||||
can take place.
|
||||
"""
|
||||
nb_memberships = self.club.members.count()
|
||||
max_id = User.objects.aggregate(id=Max("id"))["id"]
|
||||
for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
|
||||
form = ClubAddMemberForm(
|
||||
data={"user": members, "role": self.member_role},
|
||||
request_user=self.root,
|
||||
club=self.club,
|
||||
)
|
||||
assert not form.is_valid()
|
||||
assert form.errors == {
|
||||
"user": [
|
||||
"Sélectionnez un choix valide. "
|
||||
"Ce choix ne fait pas partie de ceux disponibles."
|
||||
]
|
||||
}
|
||||
self.club.refresh_from_db()
|
||||
assert self.club.members.count() == nb_memberships
|
||||
|
||||
def test_president_add_members(self):
|
||||
"""Test that the president of the club can add members."""
|
||||
president = self.club.members.get(role=self.president_role).user
|
||||
nb_club_membership = self.club.members.count()
|
||||
nb_subscriber_memberships = self.subscriber.memberships.count()
|
||||
self.client.force_login(president)
|
||||
response = self.client.post(
|
||||
self.new_members_url,
|
||||
{"user": self.subscriber.id, "role": self.president_role.id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers.get("HX-Redirect", "") == reverse(
|
||||
"club:club_members", kwargs={"club_id": self.club.id}
|
||||
)
|
||||
self.club.refresh_from_db()
|
||||
self.subscriber.refresh_from_db()
|
||||
assert self.club.members.count() == nb_club_membership + 1
|
||||
assert self.subscriber.memberships.count() == nb_subscriber_memberships + 1
|
||||
self.assert_membership_started_today(self.subscriber, role=self.president_role)
|
||||
|
||||
def test_add_member_greater_role(self):
|
||||
"""Test that a member of the club member cannot create
|
||||
a membership with a greater role than its own.
|
||||
"""
|
||||
user_role = self.simple_board_member.memberships.first().role
|
||||
other_role = baker.make(ClubRole, club=user_role.club, is_board=True)
|
||||
other_role.above(user_role)
|
||||
form = ClubAddMemberForm(
|
||||
data={"user": self.subscriber.id, "role": other_role.id},
|
||||
request_user=self.simple_board_member,
|
||||
club=self.club,
|
||||
)
|
||||
nb_memberships = self.club.members.count()
|
||||
|
||||
assert not form.is_valid()
|
||||
assert form.errors == {
|
||||
"role": [
|
||||
"Sélectionnez un choix valide. "
|
||||
"Ce choix ne fait pas partie de ceux disponibles."
|
||||
]
|
||||
}
|
||||
self.club.refresh_from_db()
|
||||
assert nb_memberships == self.club.members.count()
|
||||
assert not self.subscriber.memberships.filter(club=self.club).exists()
|
||||
|
||||
def test_add_member_without_role(self):
|
||||
"""Test that trying to add members without specifying their role fails."""
|
||||
form = ClubAddMemberForm(
|
||||
data={"user": self.subscriber.id}, request_user=self.root, club=self.club
|
||||
)
|
||||
|
||||
assert not form.is_valid()
|
||||
assert form.errors == {"role": ["Ce champ est obligatoire."]}
|
||||
|
||||
def test_add_member_already_there(self):
|
||||
role = ClubRole.objects.get(members__user=self.simple_board_member)
|
||||
form = ClubAddMemberForm(
|
||||
data={"user": self.simple_board_member, "role": role.id},
|
||||
request_user=self.root,
|
||||
club=self.club,
|
||||
)
|
||||
assert not form.is_valid()
|
||||
assert form.errors == {
|
||||
"user": ["Vous ne pouvez pas ajouter deux fois le même utilisateur"]
|
||||
}
|
||||
|
||||
def test_add_other_member_forbidden(self):
|
||||
non_member = subscriber_user.make()
|
||||
simple_member = baker.make(
|
||||
Membership, club=self.club, role=self.member_role
|
||||
).user
|
||||
for user in non_member, simple_member:
|
||||
form = ClubAddMemberForm(
|
||||
data={"user": subscriber_user.make(), "role": self.member_role.id},
|
||||
request_user=user,
|
||||
club=self.club,
|
||||
)
|
||||
assert not form.is_valid()
|
||||
assert form.errors == {
|
||||
"role": [
|
||||
"Sélectionnez un choix valide. "
|
||||
"Ce choix ne fait pas partie de ceux disponibles."
|
||||
]
|
||||
}
|
||||
|
||||
def test_simple_members_dont_see_form_anymore(self):
|
||||
"""Test that simple club members don't see the form to add members"""
|
||||
user = subscriber_user.make()
|
||||
baker.make(Membership, club=self.club, user=user, role=self.member_role)
|
||||
self.client.force_login(user)
|
||||
res = self.client.get(self.members_url)
|
||||
assert res.status_code == 200
|
||||
soup = BeautifulSoup(res.text, "lxml")
|
||||
assert not soup.find(id="add_club_members_form")
|
||||
|
||||
def test_end_membership_self(self):
|
||||
"""Test that a member can end its own membership."""
|
||||
self.client.force_login(self.simple_board_member)
|
||||
membership = self.club.members.get(end_date=None, user=self.simple_board_member)
|
||||
self.client.post(self.members_url, {"members_old": [membership.id]})
|
||||
self.simple_board_member.refresh_from_db()
|
||||
self.assert_membership_ended_today(self.simple_board_member)
|
||||
|
||||
def test_end_membership_lower_role(self):
|
||||
"""Test that board members of the club can end memberships
|
||||
of users with lower roles.
|
||||
"""
|
||||
self.client.force_login(self.simple_board_member)
|
||||
role = baker.make(ClubRole, club=self.club, is_board=True)
|
||||
role.below(self.board_role)
|
||||
membership = baker.make(Membership, club=self.club, role=role)
|
||||
response = self.client.post(self.members_url, {"members_old": [membership.id]})
|
||||
self.assertRedirects(response, self.members_url)
|
||||
self.club.refresh_from_db()
|
||||
self.assert_membership_ended_today(membership.user)
|
||||
|
||||
def test_end_membership_higher_role(self):
|
||||
"""Test that board members of the club cannot end memberships
|
||||
of users with higher roles.
|
||||
"""
|
||||
membership = self.president.memberships.filter(
|
||||
club=self.club, end_date=None
|
||||
).first()
|
||||
self.client.force_login(self.simple_board_member)
|
||||
self.client.post(self.members_url, {"members_old": [membership.id]})
|
||||
self.club.refresh_from_db()
|
||||
new_membership = self.club.get_membership_for(self.president)
|
||||
assert new_membership is not None
|
||||
assert new_membership == membership
|
||||
|
||||
membership.refresh_from_db()
|
||||
assert membership.end_date is None
|
||||
|
||||
def test_end_membership_with_permission(self):
|
||||
"""Test that users with permission can end any membership."""
|
||||
# make subscriber a board member
|
||||
nb_memberships = self.club.members.ongoing().count()
|
||||
self.client.force_login(
|
||||
subscriber_user.make(
|
||||
user_permissions=[Permission.objects.get(codename="change_membership")]
|
||||
)
|
||||
)
|
||||
president_membership = self.club.president
|
||||
response = self.client.post(
|
||||
self.members_url, {"members_old": [president_membership.id]}
|
||||
)
|
||||
self.assertRedirects(response, self.members_url)
|
||||
self.assert_membership_ended_today(president_membership.user)
|
||||
assert self.club.members.ongoing().count() == nb_memberships - 1
|
||||
|
||||
def test_end_membership_as_foreigner(self):
|
||||
"""Test that users who are not in this club cannot end its memberships."""
|
||||
nb_memberships = self.club.members.count()
|
||||
membership = self.richard.memberships.filter(club=self.club).first()
|
||||
self.client.force_login(self.subscriber)
|
||||
self.client.post(self.members_url, {"members_old": [self.richard.id]})
|
||||
# nothing should have changed
|
||||
membership.refresh_from_db()
|
||||
assert self.club.members.count() == nb_memberships
|
||||
assert membership.end_date is None
|
||||
|
||||
def test_remove_from_club_group(self):
|
||||
"""Test that when a membership ends, the user is removed from club groups."""
|
||||
user = baker.make(User)
|
||||
baker.make(
|
||||
Membership, user=user, club=self.club, end_date=None, role=self.board_role
|
||||
)
|
||||
assert user.groups.contains(self.club.members_group)
|
||||
assert user.groups.contains(self.club.board_group)
|
||||
user.memberships.update(end_date=localdate())
|
||||
assert not user.groups.contains(self.club.members_group)
|
||||
assert not user.groups.contains(self.club.board_group)
|
||||
|
||||
def test_add_to_club_group(self):
|
||||
"""Test that when a membership begins, the user is added to the club group."""
|
||||
assert not self.subscriber.groups.contains(self.club.members_group)
|
||||
assert not self.subscriber.groups.contains(self.club.board_group)
|
||||
baker.make(
|
||||
Membership, club=self.club, user=self.subscriber, role=self.board_role
|
||||
)
|
||||
assert self.subscriber.groups.contains(self.club.members_group)
|
||||
assert self.subscriber.groups.contains(self.club.board_group)
|
||||
|
||||
def test_change_position_in_club(self):
|
||||
"""Test that when moving from board to members, club group change"""
|
||||
membership = baker.make(
|
||||
Membership, club=self.club, user=self.subscriber, role=self.board_role
|
||||
)
|
||||
assert self.subscriber.groups.contains(self.club.members_group)
|
||||
assert self.subscriber.groups.contains(self.club.board_group)
|
||||
membership.role = self.member_role
|
||||
membership.save()
|
||||
assert self.subscriber.groups.contains(self.club.members_group)
|
||||
assert not self.subscriber.groups.contains(self.club.board_group)
|
||||
|
||||
def test_club_owner(self):
|
||||
"""Test that a club is owned only by board members of the main club."""
|
||||
anonymous = AnonymousUser()
|
||||
assert not self.club.is_owned_by(anonymous)
|
||||
assert not self.club.is_owned_by(self.subscriber)
|
||||
|
||||
# make sli a board member
|
||||
self.sli.memberships.all().delete()
|
||||
Membership(
|
||||
club=self.ae,
|
||||
user=self.sli,
|
||||
role=baker.make(ClubRole, club=self.ae, is_board=True),
|
||||
).save()
|
||||
assert self.club.is_owned_by(self.sli)
|
||||
|
||||
def test_change_club_name(self):
|
||||
"""Test that changing the club name doesn't break things."""
|
||||
members_group = self.club.members_group
|
||||
board_group = self.club.board_group
|
||||
initial_members = set(members_group.users.values_list("id", flat=True))
|
||||
initial_board = set(board_group.users.values_list("id", flat=True))
|
||||
self.club.name = "something else"
|
||||
self.club.save()
|
||||
self.club.refresh_from_db()
|
||||
|
||||
# The names should have changed, but not the ids nor the group members
|
||||
assert self.club.members_group.name == "something else - Membres"
|
||||
assert self.club.board_group.name == "something else - Bureau"
|
||||
assert self.club.members_group.id == members_group.id
|
||||
assert self.club.board_group.id == board_group.id
|
||||
new_members = set(self.club.members_group.users.values_list("id", flat=True))
|
||||
new_board = set(self.club.board_group.users.values_list("id", flat=True))
|
||||
assert new_members == initial_members
|
||||
assert new_board == initial_board
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_membership_set_old(client: Client):
|
||||
membership = baker.make(Membership, end_date=None, user=subscriber_user.make())
|
||||
client.force_login(membership.user)
|
||||
response = client.post(
|
||||
reverse("club:membership_set_old", kwargs={"membership_id": membership.id})
|
||||
)
|
||||
assertRedirects(
|
||||
response, reverse("core:user_clubs", kwargs={"user_id": membership.user_id})
|
||||
)
|
||||
membership.refresh_from_db()
|
||||
assert membership.end_date == localdate()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_membership_delete(client: Client):
|
||||
user = baker.make(User, is_superuser=True)
|
||||
membership = baker.make(Membership)
|
||||
client.force_login(user)
|
||||
url = reverse("club:membership_delete", kwargs={"membership_id": membership.id})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
response = client.post(url)
|
||||
assertRedirects(
|
||||
response, reverse("core:user_clubs", kwargs={"user_id": membership.user_id})
|
||||
)
|
||||
assert not Membership.objects.filter(id=membership.id).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAddMemberForm(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.club = baker.make(Club)
|
||||
cls.roles = baker.make(
|
||||
ClubRole,
|
||||
club=cls.club,
|
||||
is_board=iter([True, True, True, True, False, False]),
|
||||
is_presidency=iter([True, True, False, False, False, False]),
|
||||
order=seq(0),
|
||||
_quantity=6,
|
||||
_bulk_create=True,
|
||||
)
|
||||
cls.roles[-1].is_active = False
|
||||
cls.roles[-1].save()
|
||||
|
||||
def test_admin(self):
|
||||
"""Test that admin users can give any active role."""
|
||||
user = baker.make(
|
||||
User, user_permissions=[Permission.objects.get(codename="add_membership")]
|
||||
)
|
||||
form = ClubAddMemberForm(request_user=user, club=self.club)
|
||||
assert list(form.fields["role"].queryset) == self.roles[:-1]
|
||||
|
||||
def test_president(self):
|
||||
"""Test that someone with a presidency role can give any active role."""
|
||||
user = baker.make(Membership, club=self.club, role=self.roles[0]).user
|
||||
form = ClubAddMemberForm(request_user=user, club=self.club)
|
||||
assert list(form.fields["role"].queryset) == self.roles[:-1]
|
||||
|
||||
def test_board_member(self):
|
||||
"""Test that someone with a board role can give lower active role."""
|
||||
user = baker.make(Membership, club=self.club, role=self.roles[2]).user
|
||||
form = ClubAddMemberForm(request_user=user, club=self.club)
|
||||
assert list(form.fields["role"].queryset) == self.roles[3:-1]
|
||||
|
||||
def test_simple_member(self):
|
||||
"""Test that someone with a non-board role cannot give roles."""
|
||||
user = baker.make(Membership, club=self.club, role=self.roles[4]).user
|
||||
form = ClubAddMemberForm(request_user=user, club=self.club)
|
||||
assert list(form.fields["role"].queryset) == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestJoinClub:
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_cache(self):
|
||||
cache.clear()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("user_factory", "board_role", "errors"),
|
||||
[
|
||||
(
|
||||
subscriber_user.make,
|
||||
True,
|
||||
{
|
||||
"role": [
|
||||
"Sélectionnez un choix valide. "
|
||||
"Ce choix ne fait pas partie de ceux disponibles."
|
||||
]
|
||||
},
|
||||
),
|
||||
(
|
||||
lambda: baker.make(User),
|
||||
False,
|
||||
{"__all__": ["Vous devez être cotisant pour faire partie d'un club"]},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_join_club_errors(
|
||||
self, user_factory: Callable[[], User], board_role, errors: dict
|
||||
):
|
||||
club = baker.make(Club)
|
||||
user = user_factory()
|
||||
role = baker.make(ClubRole, club=club, is_board=board_role)
|
||||
form = JoinClubForm(club=club, request_user=user, data={"role": role.id})
|
||||
assert not form.is_valid()
|
||||
assert form.errors == errors
|
||||
|
||||
def test_user_already_in_club(self):
|
||||
user = subscriber_user.make()
|
||||
role = baker.make(ClubRole, is_board=False)
|
||||
baker.make(Membership, user=user, club=role.club)
|
||||
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
|
||||
assert not form.is_valid()
|
||||
assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]}
|
||||
|
||||
def test_ok(self):
|
||||
user = subscriber_user.make()
|
||||
role = baker.make(ClubRole, is_board=False)
|
||||
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
|
||||
assert form.is_valid()
|
||||
form.save()
|
||||
assert Membership.objects.ongoing().filter(user=user, club=role.club).exists()
|
||||
|
||||
|
||||
class TestOldMembersView(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
club = baker.make(Club)
|
||||
roles = baker.make(
|
||||
ClubRole,
|
||||
club=club,
|
||||
is_board=itertools.cycle([True, True, False]),
|
||||
order=seq(0),
|
||||
_quantity=10,
|
||||
_bulk_create=True,
|
||||
)
|
||||
cls.memberships = baker.make(
|
||||
Membership,
|
||||
role=iter(roles),
|
||||
club=club,
|
||||
start_date=now() - timedelta(days=14),
|
||||
end_date=now() - timedelta(days=7),
|
||||
_quantity=len(roles),
|
||||
_bulk_create=True,
|
||||
)
|
||||
cls.url = reverse("club:club_old_members", kwargs={"club_id": club.id})
|
||||
|
||||
def test_ok(self):
|
||||
user = subscriber_user.make()
|
||||
self.client.force_login(user)
|
||||
res = self.client.get(self.url)
|
||||
assert res.status_code == 200
|
||||
|
||||
def test_access_forbidden(self):
|
||||
res = self.client.get(self.url)
|
||||
assertRedirects(res, reverse("core:login", query={"next": self.url}))
|
||||
|
||||
self.client.force_login(baker.make(User))
|
||||
res = self.client.get(self.url)
|
||||
assert res.status_code == 403
|
||||
|
||||
def test_context_data(self):
|
||||
# mark a membership as not ended, to make sure it is excluded from the result
|
||||
self.memberships[0].end_date = None
|
||||
self.memberships[0].save()
|
||||
self.client.force_login(subscriber_user.make())
|
||||
res = self.client.get(self.url)
|
||||
assert list(res.context_data.get("old_members")) == self.memberships[1:]
|
||||
@@ -1,76 +0,0 @@
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
||||
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.markdown import markdown
|
||||
from core.models import PageRev, User
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_page_display_on_club_main_page(client: Client):
|
||||
"""Test the club Page is properly displayed on the club main view"""
|
||||
club = baker.make(Club)
|
||||
content = "# foo\nLorem ipsum dolor sit amet"
|
||||
baker.make(PageRev, page=club.page, revision=1, content=content)
|
||||
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
|
||||
|
||||
assert res.status_code == 200
|
||||
soup = BeautifulSoup(res.text, "lxml")
|
||||
detail_html = soup.find(id="club-page").find(class_="markdown")
|
||||
assertHTMLEqual(detail_html.decode_contents(), markdown(content))
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_club_main_page_without_content(client: Client):
|
||||
"""Test the club view works, even if the club page is empty"""
|
||||
club = baker.make(Club)
|
||||
club.page.revisions.all().delete()
|
||||
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
|
||||
|
||||
assert res.status_code == 200
|
||||
soup = BeautifulSoup(res.text, "lxml")
|
||||
detail_html = soup.find(id="club-page")
|
||||
assert detail_html.find_all("markdown") == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_page_revision(client: Client):
|
||||
club = baker.make(Club)
|
||||
revisions = baker.make(
|
||||
PageRev, page=club.page, _quantity=3, content=iter(["foo", "bar", "baz"])
|
||||
)
|
||||
client.force_login(baker.make(User))
|
||||
url = reverse(
|
||||
"club:club_view_rev", kwargs={"club_id": club.id, "rev_id": revisions[1].id}
|
||||
)
|
||||
res = client.get(url)
|
||||
assert res.status_code == 200
|
||||
soup = BeautifulSoup(res.text, "lxml")
|
||||
detail_html = soup.find(class_="markdown")
|
||||
assertHTMLEqual(detail_html.decode_contents(), markdown(revisions[1].content))
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_edit_page(client: Client):
|
||||
club = baker.make(Club)
|
||||
user = subscriber_user.make()
|
||||
baker.make(
|
||||
Membership,
|
||||
user=user,
|
||||
club=club,
|
||||
role=baker.make(ClubRole, club=club, is_board=True),
|
||||
)
|
||||
client.force_login(user)
|
||||
url = reverse("club:club_edit_page", kwargs={"club_id": club.id})
|
||||
content = "# foo\nLorem ipsum dolor sit amet"
|
||||
|
||||
res = client.get(url)
|
||||
assert res.status_code == 200
|
||||
res = client.post(url, data={"content": content})
|
||||
assertRedirects(res, reverse("club:club_view", kwargs={"club_id": club.id}))
|
||||
assert club.page.revisions.last().content == content
|
||||
@@ -1,35 +0,0 @@
|
||||
import pytest
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
|
||||
from club.models import Club
|
||||
from com.models import Poster
|
||||
from core.baker_recipes import subscriber_user
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("route_url", ["club:poster_list", "club:poster_create"])
|
||||
def test_access(client: Client, route_url):
|
||||
club = baker.make(Club)
|
||||
user = subscriber_user.make()
|
||||
url = reverse(route_url, kwargs={"club_id": club.id})
|
||||
|
||||
client.force_login(user)
|
||||
assert client.get(url).status_code == 403
|
||||
club.board_group.users.add(user)
|
||||
assert client.get(url).status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("route_url", ["club:poster_edit", "club:poster_delete"])
|
||||
def test_access_specific_poster(client: Client, route_url):
|
||||
club = baker.make(Club)
|
||||
user = subscriber_user.make()
|
||||
poster = baker.make(Poster)
|
||||
url = reverse(route_url, kwargs={"club_id": club.id, "poster_id": poster.id})
|
||||
|
||||
client.force_login(user)
|
||||
assert client.get(url).status_code == 403
|
||||
club.board_group.users.add(user)
|
||||
assert client.get(url).status_code == 200
|
||||
@@ -1,104 +0,0 @@
|
||||
import csv
|
||||
import itertools
|
||||
|
||||
import pytest
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
|
||||
from club.forms import SellingsForm
|
||||
from club.models import Club
|
||||
from core.models import User
|
||||
from counter.baker_recipes import product_recipe, sale_recipe
|
||||
from counter.models import Counter, Customer, Product, Selling
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sales_page_doesnt_crash(client: Client):
|
||||
"""Basic crashtest on club sales view."""
|
||||
club = baker.make(Club)
|
||||
product = baker.make(Product, club=club)
|
||||
admin = baker.make(User, is_superuser=True)
|
||||
client.force_login(admin)
|
||||
url = reverse("club:club_sellings", kwargs={"club_id": club.id})
|
||||
assert client.get(url).status_code == 200
|
||||
assert client.post(url).status_code == 200
|
||||
assert client.post(url, data={"products": [product.id]}).status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sales_form_counter_filter():
|
||||
"""Test that counters are properly filtered in SellingsForm"""
|
||||
club = baker.make(Club)
|
||||
counters = baker.make(
|
||||
Counter, _quantity=5, _bulk_create=True, name=iter(["Z", "a", "B", "e", "f"])
|
||||
)
|
||||
counters[0].club = club
|
||||
counters[0].save()
|
||||
sale_recipe.make(
|
||||
counter=counters[1], club=club, unit_price=0, customer=baker.make(Customer)
|
||||
)
|
||||
product_recipe.make(counters=[counters[2]], club=club)
|
||||
|
||||
form = SellingsForm(club)
|
||||
form_counters = list(form.fields["counters"].queryset)
|
||||
assert form_counters == [counters[1], counters[2], counters[0]]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_club_sales_csv(client: Client):
|
||||
client.force_login(baker.make(User, is_superuser=True))
|
||||
club = baker.make(Club)
|
||||
counter = baker.make(Counter, club=club)
|
||||
product = product_recipe.make(club=club, counters=[counter], purchase_price=0.5)
|
||||
customers = baker.make(Customer, amount=100, _quantity=2, _bulk_create=True)
|
||||
sales: list[Selling] = sale_recipe.make(
|
||||
club=club,
|
||||
counter=counter,
|
||||
quantity=2,
|
||||
unit_price=1.5,
|
||||
product=iter([product, product, None]),
|
||||
customer=itertools.cycle(customers),
|
||||
_quantity=3,
|
||||
)
|
||||
url = reverse("club:sellings_csv", kwargs={"club_id": club.id})
|
||||
response = client.post(url, data={"counters": [counter.id]})
|
||||
assert response.status_code == 200
|
||||
reader = csv.reader(s.decode() for s in response.streaming_content)
|
||||
data = list(reader)
|
||||
sale_rows = [
|
||||
[
|
||||
str(s.date),
|
||||
str(counter),
|
||||
str(s.seller),
|
||||
s.customer.user.get_display_name(),
|
||||
s.label,
|
||||
"2",
|
||||
"1.50",
|
||||
"3.00",
|
||||
"Compte utilisateur",
|
||||
]
|
||||
for s in sales[::-1]
|
||||
]
|
||||
sale_rows[2].extend(["0.50", "1.00"])
|
||||
sale_rows[1].extend(["0.50", "1.00"])
|
||||
sale_rows[0].extend(["", ""])
|
||||
assert data == [
|
||||
["Quantité", "6"],
|
||||
["Total", "9"],
|
||||
["Bénéfice", "1"],
|
||||
[
|
||||
"Date",
|
||||
"Comptoir",
|
||||
"Barman",
|
||||
"Client",
|
||||
"Étiquette",
|
||||
"Quantité",
|
||||
"Prix unitaire",
|
||||
"Total",
|
||||
"Méthode de paiement",
|
||||
"Prix d'achat",
|
||||
"Bénéfice",
|
||||
],
|
||||
*sale_rows,
|
||||
]
|
||||
@@ -1,53 +0,0 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import localdate
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from club.schemas import UserMembershipSchema
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import Page
|
||||
|
||||
|
||||
class TestFetchClub(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = subscriber_user.make()
|
||||
pages = baker.make(Page, _quantity=3, _bulk_create=True)
|
||||
clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
|
||||
recipe = Recipe(
|
||||
Membership,
|
||||
user=cls.user,
|
||||
start_date=localdate() - timedelta(days=2),
|
||||
role=baker.make(ClubRole),
|
||||
)
|
||||
cls.members = Membership.objects.bulk_create(
|
||||
[
|
||||
recipe.prepare(club=clubs[0]),
|
||||
recipe.prepare(club=clubs[1], end_date=localdate() - timedelta(days=1)),
|
||||
recipe.prepare(club=clubs[1]),
|
||||
]
|
||||
)
|
||||
|
||||
def test_fetch_memberships(self):
|
||||
self.client.force_login(subscriber_user.make())
|
||||
res = self.client.get(
|
||||
reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id})
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert [UserMembershipSchema.model_validate(m) for m in res.json()] == [
|
||||
UserMembershipSchema.from_orm(m) for m in (self.members[0], self.members[2])
|
||||
]
|
||||
|
||||
def test_fetch_club_nb_queries(self):
|
||||
self.client.force_login(subscriber_user.make())
|
||||
with self.assertNumQueries(6):
|
||||
# - 5 queries for authentication
|
||||
# - 1 query for the actual data
|
||||
res = self.client.get(
|
||||
reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id})
|
||||
)
|
||||
assert res.status_code == 200
|
||||
+4
-26
@@ -25,8 +25,8 @@
|
||||
from django.urls import path
|
||||
|
||||
from club.views import (
|
||||
ClubAddMembersFragment,
|
||||
ClubCreateView,
|
||||
ClubEditPropView,
|
||||
ClubEditView,
|
||||
ClubListView,
|
||||
ClubMailingView,
|
||||
@@ -35,12 +35,9 @@ from club.views import (
|
||||
ClubPageEditView,
|
||||
ClubPageHistView,
|
||||
ClubRevView,
|
||||
ClubRoleBoardCreateView,
|
||||
ClubRoleMemberCreateView,
|
||||
ClubRolePresidencyCreateView,
|
||||
ClubRoleUpdateView,
|
||||
ClubSellingCSVView,
|
||||
ClubSellingView,
|
||||
ClubStatView,
|
||||
ClubToolsView,
|
||||
ClubView,
|
||||
MailingAutoGenerationView,
|
||||
@@ -57,6 +54,7 @@ from club.views import (
|
||||
urlpatterns = [
|
||||
path("", ClubListView.as_view(), name="club_list"),
|
||||
path("new/", ClubCreateView.as_view(), name="club_new"),
|
||||
path("stats/", ClubStatView.as_view(), name="club_stats"),
|
||||
path("<int:club_id>/", ClubView.as_view(), name="club_view"),
|
||||
path(
|
||||
"<int:club_id>/rev/<int:rev_id>/", ClubRevView.as_view(), name="club_view_rev"
|
||||
@@ -65,36 +63,16 @@ urlpatterns = [
|
||||
path("<int:club_id>/edit/", ClubEditView.as_view(), name="club_edit"),
|
||||
path("<int:club_id>/edit/page/", ClubPageEditView.as_view(), name="club_edit_page"),
|
||||
path("<int:club_id>/members/", ClubMembersView.as_view(), name="club_members"),
|
||||
path(
|
||||
"fragment/<int:club_id>/members/",
|
||||
ClubAddMembersFragment.as_view(),
|
||||
name="club_new_members",
|
||||
),
|
||||
path(
|
||||
"<int:club_id>/elderlies/",
|
||||
ClubOldMembersView.as_view(),
|
||||
name="club_old_members",
|
||||
),
|
||||
path("<int:club_id>/role/", ClubRoleUpdateView.as_view(), name="club_roles"),
|
||||
path(
|
||||
"<int:club_id>/role/new/president/",
|
||||
ClubRolePresidencyCreateView.as_view(),
|
||||
name="new_role_president",
|
||||
),
|
||||
path(
|
||||
"<int:club_id>/role/new/board/",
|
||||
ClubRoleBoardCreateView.as_view(),
|
||||
name="new_role_board",
|
||||
),
|
||||
path(
|
||||
"<int:club_id>/role/new/member/",
|
||||
ClubRoleMemberCreateView.as_view(),
|
||||
name="new_role_member",
|
||||
),
|
||||
path("<int:club_id>/sellings/", ClubSellingView.as_view(), name="club_sellings"),
|
||||
path(
|
||||
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
|
||||
),
|
||||
path("<int:club_id>/prop/", ClubEditPropView.as_view(), name="club_prop"),
|
||||
path("<int:club_id>/tools/", ClubToolsView.as_view(), name="tools"),
|
||||
path("<int:club_id>/mailing/", ClubMailingView.as_view(), name="mailing"),
|
||||
path(
|
||||
|
||||
+296
-507
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user