Compare commits

..

58 Commits

Author SHA1 Message Date
Julien Aldon
3cfa60507e [WIP] add styles 2026-03-03 17:58:33 +01:00
Julien Aldon
6679107b13 downgrade python version in tests
All checks were successful
Deploy Amap / deploy (push) Successful in 1m47s
2026-03-03 14:34:00 +01:00
Julien Aldon
20eba7f183 remove debug in router
Some checks failed
Deploy Amap / deploy (push) Failing after 11s
2026-03-03 11:37:40 +01:00
Julien Aldon
c6d75831c9 add debug for tests
Some checks failed
Deploy Amap / deploy (push) Failing after 11s
2026-03-03 11:37:23 +01:00
Julien Aldon
b2e2d02818 add debug for tests
Some checks failed
Deploy Amap / deploy (push) Failing after 10s
2026-03-03 11:36:22 +01:00
Julien Aldon
8cb7893aff add debug for tests
Some checks failed
Deploy Amap / deploy (push) Failing after 10s
2026-03-03 11:34:41 +01:00
Julien Aldon
015e09a980 add debug for tests
Some checks failed
Deploy Amap / deploy (push) Failing after 10s
2026-03-03 11:31:16 +01:00
Julien Aldon
a70ab5d3cb add debug for tests
Some checks failed
Deploy Amap / deploy (push) Failing after 10s
2026-03-03 11:29:57 +01:00
Julien Aldon
9d5dbd80cc add debug for tests
Some checks failed
Deploy Amap / deploy (push) Failing after 10s
2026-03-03 11:27:01 +01:00
Julien Aldon
1c6e810ec1 fix python test version
Some checks failed
Deploy Amap / deploy (push) Failing after 1m30s
2026-03-03 11:15:39 +01:00
Julien Aldon
8352097ffb fix pylint errors
Some checks failed
Deploy Amap / deploy (push) Failing after 16s
2026-03-03 11:08:08 +01:00
Julien Aldon
0e48d1bbaa fix test format 2026-03-02 16:33:52 +01:00
Julien Aldon
5dd9e19877 add partial contract tests
Some checks failed
Deploy Amap / deploy (push) Failing after 44s
2026-03-02 11:45:05 +01:00
Julien Aldon
4a4c1225dc fix python version for tests
All checks were successful
Deploy Amap / deploy (push) Successful in 7m25s
2026-02-27 13:45:33 +01:00
Julien Aldon
9f57b11fcf fix version dependencies
Some checks failed
Deploy Amap / deploy (push) Failing after 31s
2026-02-27 13:34:55 +01:00
Julien Aldon
e303e0723e add forms, shipments tests
Some checks failed
Deploy Amap / deploy (push) Failing after 13s
2026-02-27 12:29:07 +01:00
Julien Aldon
d28640711c add forms, shipments tests
Some checks failed
Deploy Amap / deploy (push) Failing after 52s
2026-02-27 12:21:50 +01:00
Julien Aldon
61cbbf0366 add tests forn forms, products, productors
All checks were successful
Deploy Amap / deploy (push) Successful in 3m45s
2026-02-25 16:39:12 +01:00
Julien Aldon
cfb8d435a8 Merge branch 'main' of gitea.aldon.fr:Mop/amap
All checks were successful
Deploy Amap / deploy (push) Successful in 35s
2026-02-23 15:38:45 +01:00
Julien Aldon
124b0700da add visible field to form 2026-02-23 15:38:29 +01:00
da22f24198 fix layout
All checks were successful
Deploy Amap / deploy (push) Successful in 9s
2026-02-20 18:51:45 +01:00
f4bb71a296 fix layout 2026-02-20 18:51:11 +01:00
71a62e6d19 fix type max
All checks were successful
Deploy Amap / deploy (push) Successful in 31s
2026-02-20 18:16:10 +01:00
41552a889f fix cheque validation
Some checks failed
Deploy Amap / deploy (push) Failing after 15s
2026-02-20 18:13:47 +01:00
Julien Aldon
85a70da07d add max payment method for cheque
All checks were successful
Deploy Amap / deploy (push) Successful in 34s
2026-02-20 17:36:32 +01:00
Julien Aldon
72f8005fbd fix recurent and layout none
All checks were successful
Deploy Amap / deploy (push) Successful in 9s
2026-02-20 16:26:19 +01:00
Julien Aldon
05480b44df add debug
All checks were successful
Deploy Amap / deploy (push) Successful in 9s
2026-02-20 16:19:43 +01:00
Julien Aldon
f009674b13 fix contract dto
All checks were successful
Deploy Amap / deploy (push) Successful in 9s
2026-02-20 16:02:51 +01:00
Julien Aldon
2177a77fcf fix eslint
All checks were successful
Deploy Amap / deploy (push) Successful in 34s
2026-02-20 15:54:47 +01:00
Julien Aldon
b662a6a1f0 add base template for manual fill
Some checks failed
Deploy Amap / deploy (push) Failing after 15s
2026-02-20 15:42:15 +01:00
Julien Aldon
12be0e5650 remove doublons
All checks were successful
Deploy Amap / deploy (push) Successful in 9s
2026-02-20 13:58:30 +01:00
Julien Aldon
ef5d9e8455 add default cheque quantity
All checks were successful
Deploy Amap / deploy (push) Successful in 29s
2026-02-20 13:49:10 +01:00
Julien Aldon
6dd5ade890 remove debug
All checks were successful
Deploy Amap / deploy (push) Successful in 9s
2026-02-20 13:37:03 +01:00
Julien Aldon
34b2436ca0 fix contract submission
All checks were successful
Deploy Amap / deploy (push) Successful in 33s
2026-02-20 12:05:41 +01:00
Julien Aldon
af4941c1b9 fix contract generation
All checks were successful
Deploy Amap / deploy (push) Successful in 9s
2026-02-20 11:17:20 +01:00
Julien Aldon
7a94f1c96a fix app crash
All checks were successful
Deploy Amap / deploy (push) Successful in 32s
2026-02-20 10:33:00 +01:00
e37fae439f fix role route
All checks were successful
Deploy Amap / deploy (push) Successful in 9s
2026-02-20 02:07:48 +01:00
6406009038 add checks
All checks were successful
Deploy Amap / deploy (push) Successful in 29s
2026-02-20 02:05:43 +01:00
b05276eeeb remove trailing
All checks were successful
Deploy Amap / deploy (push) Successful in 8s
2026-02-20 01:59:33 +01:00
c67e59d5db fix dockerfile
All checks were successful
Deploy Amap / deploy (push) Successful in 8s
2026-02-20 01:48:34 +01:00
754922cbf0 fix dockerfile
All checks were successful
Deploy Amap / deploy (push) Successful in 8s
2026-02-20 01:43:07 +01:00
9a7217a54e fix prefix
All checks were successful
Deploy Amap / deploy (push) Successful in 8s
2026-02-20 01:38:17 +01:00
0ff2d4bb01 fix dockerfile
All checks were successful
Deploy Amap / deploy (push) Successful in 9s
2026-02-20 01:36:22 +01:00
26f087ea8b fix typo
All checks were successful
Deploy Amap / deploy (push) Successful in 9s
2026-02-20 01:13:48 +01:00
a530e71103 add dockerfile
All checks were successful
Deploy Amap / deploy (push) Successful in 8s
2026-02-20 01:12:23 +01:00
53490c86f0 fix path
All checks were successful
Deploy Amap / deploy (push) Successful in 7s
2026-02-20 01:09:12 +01:00
63934e6287 fix nginx
All checks were successful
Deploy Amap / deploy (push) Successful in 29s
2026-02-20 00:52:57 +01:00
eb19efe225 fix nginx
All checks were successful
Deploy Amap / deploy (push) Successful in 29s
2026-02-20 00:48:39 +01:00
4fbc1e6621 fix nginx
All checks were successful
Deploy Amap / deploy (push) Successful in 9s
2026-02-20 00:40:42 +01:00
e478b26943 fix nginx conf
All checks were successful
Deploy Amap / deploy (push) Successful in 29s
2026-02-20 00:30:10 +01:00
63ea2ff806 fix nginx internal rooting
All checks were successful
Deploy Amap / deploy (push) Successful in 29s
2026-02-20 00:14:51 +01:00
b52ac35593 fix dockercompose
All checks were successful
Deploy Amap / deploy (push) Successful in 8s
2026-02-20 00:11:37 +01:00
a16c940452 fix alembic first migration
Some checks failed
Deploy Amap / deploy (push) Failing after 5s
2026-02-20 00:10:36 +01:00
f6101a251a fix app dockerfile
Some checks failed
Deploy Amap / deploy (push) Failing after 8s
2026-02-19 23:55:39 +01:00
a854fc028e fix generate contract path and add alembic to dependencies
Some checks failed
Deploy Amap / deploy (push) Failing after 22s
2026-02-19 23:50:00 +01:00
242e29c8a6 add workflow
Some checks failed
Deploy Amap / deploy (push) Has been cancelled
2026-02-19 23:38:26 +01:00
Julien Aldon
7574626e52 add docker compose 2026-02-19 17:34:15 +01:00
Julien Aldon
1bd0583c70 fix auth login / logout / refresh token 2026-02-19 16:20:45 +01:00
125 changed files with 5762 additions and 622 deletions

View File

@@ -0,0 +1,25 @@
name: Deploy Amap
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Test backend
uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: |
python -m pip install --upgrade pip
pip install -r backend/requirements.txt
pytest -sv
- name: Build & deploy
run: |
docker compose -f docker-compose.yaml up -d --build
docker compose -f docker-compose.yaml exec backend alembic upgrade head

View File

@@ -2,24 +2,14 @@
## backend\src\contracts\contracts.py ## backend\src\contracts\contracts.py
- Send contract to referer
- Extract recap - Extract recap
- Extract all contracts
- store total price ## Link products to a form
## Wording ## Wording
- all translations - all translations
## Draft / Publish form
- By default form is in draft mode
- Validate a form (button)
- check if productor
- check if shipments
- check products
- Publish
## Footer ## Footer
### Legal ### Legal
@@ -28,14 +18,9 @@
### Contact ### Contact
## Migrations ## Pagination
- use alembic for migration management ## Confirmation modal on suppression
### Show on cascade deletion
## Filter forms in home view ## Update contract after (without registration)
## Only show current season (if multiple form, only show the one with latest start date)
## Update contract after register
## Default filter

View File

@@ -0,0 +1,26 @@
default_language_version:
python: python3.13
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-added-large-files
- id: trailing-whitespace
- id: check-ast
- id: check-builtin-literals
- id: check-docstring-first
- id: check-yaml
- id: check-toml
- id: mixed-line-ending
- id: end-of-file-fixer
- repo: local
hooks:
- id: check-pylint
name: check-pylint
entry: pylint -d R0801,R0903,W0511,W0603,C0103,R0902
language: system
types: [python]
pass_filenames: false
args:
- backend

13
backend/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM python:3.12
WORKDIR /code
RUN apt update && apt install -y weasyprint
COPY ./backend/requirements.txt /code/requirements.txt
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
COPY ./backend /code
CMD ["fastapi", "run", "src/main.py", "--port", "8000", "--forwarded-allow-ips", "*", "--proxy-headers"]

View File

@@ -18,6 +18,29 @@ hatch shell
fastapi dev src/main.py fastapi dev src/main.py
``` ```
### Migration
This repository use `alembic` for migrations
#### Create migration
```console
alembic revision --autogenerate -m "message"
```
#### Apply migration
```console
alembic upgrade head
```
## Tests
```
hatch run pytest
hatch run pytest --cov=src -vv
```
## Autoformat
```console
find -type f -name '*.py' ! -path 'alembic/*' -exec autopep8 --in-place --aggressive --aggressive '{}' \;
pylint -d R0801,R0903,W0511,W0603,C0103,R0902 .
```
## License ## License
`backend` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. `backend` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.

147
backend/alembic.ini Normal file
View File

@@ -0,0 +1,147 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
backend/alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

82
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,82 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from sqlmodel import SQLModel
from src.settings import settings
from src.models import *
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = SQLModel.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
config.set_main_option(
"sqlalchemy.url", f'postgresql://{settings.db_user}:{settings.db_pass}@{settings.db_host}:5432/{settings.db_name}')
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,29 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
import sqlmodel.sql.sqltypes
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,38 @@
"""message
Revision ID: 7854064278ce
Revises: c0b1073a8394
Create Date: 2026-02-20 17:17:25.739406
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
# revision identifiers, used by Alembic.
revision: str = '7854064278ce'
down_revision: Union[str, Sequence[str], None] = 'c0b1073a8394'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
'paymentmethod',
sa.Column(
'max',
sa.Integer(),
nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('paymentmethod', 'max')
# ### end Alembic commands ###

View File

@@ -0,0 +1,159 @@
"""Initial repository
Revision ID: c0b1073a8394
Revises:
Create Date: 2026-02-20 00:09:35.920486
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
# revision identifiers, used by Alembic.
revision: str = 'c0b1073a8394'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
'contracttype',
sa.Column(
'id',
sa.Integer(),
nullable=False),
sa.Column(
'name',
sqlmodel.sql.sqltypes.AutoString(),
nullable=False),
sa.PrimaryKeyConstraint('id'))
op.create_table(
'productor', sa.Column(
'name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column(
'address', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column(
'type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column(
'id', sa.Integer(), nullable=False), sa.PrimaryKeyConstraint('id'))
op.create_table('template',
sa.Column('id', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table(
'user', sa.Column(
'name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column(
'email', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column(
'id', sa.Integer(), nullable=False), sa.PrimaryKeyConstraint('id'))
op.create_table('form',
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('productor_id', sa.Integer(), nullable=True),
sa.Column('referer_id', sa.Integer(), nullable=True),
sa.Column('season', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('start', sa.Date(), nullable=False),
sa.Column('end', sa.Date(), nullable=False),
sa.Column('minimum_shipment_value', sa.Float(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['productor_id'], ['productor.id'], ),
sa.ForeignKeyConstraint(['referer_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('paymentmethod',
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('details', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('productor_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['productor_id'], ['productor.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('product',
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('unit', sa.Enum('GRAMS', 'KILO', 'PIECE', name='unit'), nullable=False),
sa.Column('price', sa.Float(), nullable=True),
sa.Column('price_kg', sa.Float(), nullable=True),
sa.Column('quantity', sa.Float(), nullable=True),
sa.Column('quantity_unit', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('type', sa.Enum('OCCASIONAL', 'RECCURENT', name='producttype'), nullable=False),
sa.Column('productor_id', sa.Integer(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['productor_id'], ['productor.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table(
'usercontracttypelink', sa.Column(
'user_id', sa.Integer(), nullable=False), sa.Column(
'contract_type_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(
['contract_type_id'], ['contracttype.id'], ), sa.ForeignKeyConstraint(
['user_id'], ['user.id'], ), sa.PrimaryKeyConstraint(
'user_id', 'contract_type_id'))
op.create_table('contract',
sa.Column('firstname', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('lastname', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('phone', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('payment_method', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('cheque_quantity', sa.Integer(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('form_id', sa.Integer(), nullable=False),
sa.Column('file', sa.LargeBinary(), nullable=True),
sa.Column('total_price', sa.Float(), nullable=True),
sa.ForeignKeyConstraint(['form_id'], ['form.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('shipment',
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('form_id', sa.Integer(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['form_id'], ['form.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('cheque',
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('value', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('contract_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['contract_id'], ['contract.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('contractproduct',
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('shipment_id', sa.Integer(), nullable=True),
sa.Column('quantity', sa.Float(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('contract_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['contract_id'], ['contract.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['shipment_id'], ['shipment.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('shipmentproductlink',
sa.Column('shipment_id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
sa.ForeignKeyConstraint(['shipment_id'], ['shipment.id'], ),
sa.PrimaryKeyConstraint('shipment_id', 'product_id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('shipmentproductlink')
op.drop_table('contractproduct')
op.drop_table('cheque')
op.drop_table('shipment')
op.drop_table('contract')
op.drop_table('usercontracttypelink')
op.drop_table('product')
op.drop_table('paymentmethod')
op.drop_table('form')
op.drop_table('user')
op.drop_table('template')
op.drop_table('productor')
op.drop_table('contracttype')
# ### end Alembic commands ###

View File

@@ -0,0 +1,40 @@
"""message
Revision ID: e777ed5729ce
Revises: 7854064278ce
Create Date: 2026-02-23 13:53:09.999893
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
# revision identifiers, used by Alembic.
revision: str = 'e777ed5729ce'
down_revision: Union[str, Sequence[str], None] = '7854064278ce'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
'form',
sa.Column(
'visible',
sa.Boolean(),
nullable=False,
default=False,
server_default="False"))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('form', 'visible')
# ### end Alembic commands ###

View File

@@ -29,7 +29,14 @@ dependencies = [
"cryptography", "cryptography",
"requests", "requests",
"weasyprint", "weasyprint",
"odfdo" "odfdo",
"alembic",
"pytest",
"pytest-cov",
"pytest-mock",
"autopep8",
"prek",
"pylint",
] ]
[project.urls] [project.urls]

84
backend/requirements.txt Normal file
View File

@@ -0,0 +1,84 @@
alembic==1.18.4
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.12.1
astroid==4.0.4
autopep8==2.3.2
brotli==1.2.0
certifi==2026.2.25
cffi==2.0.0
charset-normalizer==3.4.4
click==8.3.1
coverage==7.13.4
cryptography==46.0.5
cssselect2==0.9.0
dill==0.4.1
dnspython==2.8.0
email-validator==2.3.0
fastapi==0.135.1
fastapi-cli==0.0.24
fastapi-cloud-cli==0.14.0
fastar==0.8.0
fonttools==4.61.1
greenlet==3.3.2
h11==0.16.0
httpcore==1.0.9
httptools==0.7.1
httpx==0.28.1
idna==3.11
iniconfig==2.3.0
isort==8.0.1
Jinja2==3.1.6
lxml==6.0.2
Mako==1.3.10
markdown-it-py==4.0.0
MarkupSafe==3.0.3
mccabe==0.7.0
mdurl==0.1.2
odfdo==3.21.0
packaging==26.0
pillow==12.1.1
platformdirs==4.9.2
pluggy==1.6.0
prek==0.3.4
psycopg2-binary==2.9.11
pycodestyle==2.14.0
pycparser==3.0
pydantic==2.12.5
pydantic-extra-types==2.11.0
pydantic-settings==2.13.1
pydantic_core==2.41.5
pydyf==0.12.1
Pygments==2.19.2
PyJWT==2.11.0
pylint==4.0.5
pyphen==0.17.2
pytest==9.0.2
pytest-cov==7.0.0
pytest-mock==3.15.1
python-dotenv==1.2.2
python-multipart==0.0.22
PyYAML==6.0.3
requests==2.32.5
rich==14.3.3
rich-toolkit==0.19.7
rignore==0.7.6
sentry-sdk==2.53.0
shellingham==1.5.4
SQLAlchemy==2.0.47
sqlmodel==0.0.37
starlette==0.52.1
tinycss2==1.5.1
tinyhtml5==2.0.0
tomlkit==0.14.0
typer==0.24.1
typing-inspection==0.4.2
typing_extensions==4.15.0
urllib3==2.6.3
uvicorn==0.41.0
uvloop==0.22.1
watchfiles==1.1.1
weasyprint==68.1
webencodings==0.5.1
websockets==16.0
zopfli==0.4.1

View File

@@ -1,3 +1,3 @@
# SPDX-FileCopyrightText: 2026-present Julien Aldon <julien.aldon@wanadoo.fr> # SPDX-FileCopyrightText: 2026-present Julien Aldon <julien.aldon@wanadoo.fr>
# #
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT

View File

@@ -21,10 +21,9 @@ router = APIRouter(prefix='/auth')
jwk_client = PyJWKClient(JWKS_URL) jwk_client = PyJWKClient(JWKS_URL)
security = HTTPBearer() security = HTTPBearer()
@router.get('/logout') @router.get('/logout')
def logout( def logout():
refresh_token: Annotated[str | None, Cookie()] = None,
):
params = { params = {
'client_id': settings.keycloak_client_id, 'client_id': settings.keycloak_client_id,
'post_logout_redirect_uri': settings.origins, 'post_logout_redirect_uri': settings.origins,
@@ -34,26 +33,20 @@ def logout(
key='access_token', key='access_token',
path='/', path='/',
secure=not settings.debug, secure=not settings.debug,
samesite='lax', samesite='strict',
) )
response.delete_cookie( response.delete_cookie(
key='refresh_token', key='refresh_token',
path='/', path='/',
secure=not settings.debug, secure=not settings.debug,
samesite='lax', samesite='strict',
) )
response.delete_cookie( response.delete_cookie(
key='id_token', key='id_token',
path='/', path='/',
secure=not settings.debug, secure=not settings.debug,
samesite='lax', samesite='strict',
) )
# if refresh_token:
# requests.post(LOGOUT_URL, data={
# 'client_id': settings.keycloak_client_id,
# 'client_secret': settings.keycloak_client_secret,
# 'refresh_token': refresh_token
# })
return response return response
@@ -67,9 +60,11 @@ def login():
'redirect_uri': settings.keycloak_redirect_uri, 'redirect_uri': settings.keycloak_redirect_uri,
'state': state, 'state': state,
} }
request_url = requests.Request('GET', AUTH_URL, params=params).prepare().url request_url = requests.Request(
'GET', AUTH_URL, params=params).prepare().url
return RedirectResponse(request_url) return RedirectResponse(request_url)
@router.get('/callback') @router.get('/callback')
def callback(code: str, session: Session = Depends(get_session)): def callback(code: str, session: Session = Depends(get_session)):
data = { data = {
@@ -85,15 +80,17 @@ def callback(code: str, session: Session = Depends(get_session)):
response = requests.post(TOKEN_URL, data=data, headers=headers) response = requests.post(TOKEN_URL, data=data, headers=headers)
if response.status_code != 200: if response.status_code != 200:
raise HTTPException( raise HTTPException(
status_code=400, status_code=404,
detail=messages.failtogettoken detail=messages.Messages.not_found('token')
) )
token_data = response.json() token_data = response.json()
id_token = token_data['id_token'] id_token = token_data['id_token']
decoded_token = jwt.decode(id_token, options={'verify_signature': False}) decoded_token = jwt.decode(id_token, options={'verify_signature': False})
decoded_access_token = jwt.decode(token_data['access_token'], options={'verify_signature': False}) decoded_access_token = jwt.decode(
token_data['access_token'], options={
'verify_signature': False})
resource_access = decoded_access_token.get('resource_access') resource_access = decoded_access_token.get('resource_access')
if not resource_access: if not resource_access:
data = { data = {
@@ -117,7 +114,7 @@ def callback(code: str, session: Session = Depends(get_session)):
user_create = UserCreate( user_create = UserCreate(
email=decoded_token.get('email'), email=decoded_token.get('email'),
name=decoded_token.get('preferred_username'), name=decoded_token.get('name'),
role_names=roles['roles'] role_names=roles['roles']
) )
service.get_or_create_user(session, user_create) service.get_or_create_user(session, user_create)
@@ -127,7 +124,7 @@ def callback(code: str, session: Session = Depends(get_session)):
value=token_data['access_token'], value=token_data['access_token'],
httponly=True, httponly=True,
secure=not settings.debug, secure=not settings.debug,
samesite='lax', samesite='strict',
max_age=settings.max_age max_age=settings.max_age
) )
response.set_cookie( response.set_cookie(
@@ -135,7 +132,7 @@ def callback(code: str, session: Session = Depends(get_session)):
value=token_data['refresh_token'] or '', value=token_data['refresh_token'] or '',
httponly=True, httponly=True,
secure=not settings.debug, secure=not settings.debug,
samesite='lax', samesite='strict',
max_age=30 * 24 * settings.max_age max_age=30 * 24 * settings.max_age
) )
response.set_cookie( response.set_cookie(
@@ -143,47 +140,57 @@ def callback(code: str, session: Session = Depends(get_session)):
value=token_data['id_token'], value=token_data['id_token'],
httponly=True, httponly=True,
secure=not settings.debug, secure=not settings.debug,
samesite='lax', samesite='strict',
max_age=settings.max_age max_age=settings.max_age
) )
return response return response
def verify_token(token: str): def verify_token(token: str):
try: try:
signing_key = jwk_client.get_signing_key_from_jwt(token) signing_key = jwk_client.get_signing_key_from_jwt(token)
decoded = jwt.decode(token, options={'verify_signature': False}) decoded = jwt.decode(
payload = jwt.decode(
token, token,
signing_key.key, signing_key.key,
algorithms=['RS256'], algorithms=['RS256'],
audience=settings.keycloak_client_id, audience=settings.keycloak_client_id,
issuer=ISSUER, issuer=ISSUER,
leeway=60,
) )
return payload return decoded
except jwt.ExpiredSignatureError: except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail=messages.tokenexipired) raise HTTPException(status_code=401,
detail=messages.Messages.tokenexipired)
except jwt.InvalidTokenError: except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail=messages.invalidtoken) raise HTTPException(
status_code=401,
detail=messages.Messages.invalidtoken)
def get_current_user(request: Request, session: Session = Depends(get_session)): def get_current_user(
request: Request,
session: Session = Depends(get_session)):
access_token = request.cookies.get('access_token') access_token = request.cookies.get('access_token')
if not access_token: if not access_token:
raise HTTPException(status_code=401, detail=messages.notauthenticated) raise HTTPException(status_code=401,
detail=messages.Messages.notauthenticated)
payload = verify_token(access_token) payload = verify_token(access_token)
if not payload: if not payload:
raise HTTPException(status_code=401, detail='aze') raise HTTPException(status_code=401, detail='aze')
email = payload.get('email') email = payload.get('email')
if not email: if not email:
raise HTTPException(status_code=401, detail=messages.notauthenticated) raise HTTPException(status_code=401,
detail=messages.Messages.notauthenticated)
user = session.exec(select(User).where(User.email == email)).first() user = session.exec(select(User).where(User.email == email)).first()
if not user: if not user:
raise HTTPException(status_code=401, detail=messages.usernotfound) raise HTTPException(status_code=401,
detail=messages.Messages.not_found('user'))
return user return user
@router.post('/refresh') @router.post('/refresh')
def refresh_token(refresh_token: Annotated[str | None, Cookie()] = None): def refresh_token(refresh_token: Annotated[str | None, Cookie()] = None):
refresh = refresh_token refresh = refresh_token
@@ -199,8 +206,8 @@ def refresh_token(refresh_token: Annotated[str | None, Cookie()] = None):
result = requests.post(TOKEN_URL, data=data, headers=headers) result = requests.post(TOKEN_URL, data=data, headers=headers)
if result.status_code != 200: if result.status_code != 200:
raise HTTPException( raise HTTPException(
status_code=400, status_code=404,
detail=messages.failtogettoken detail=messages.Messages.not_found('token')
) )
token_data = result.json() token_data = result.json()
@@ -210,7 +217,7 @@ def refresh_token(refresh_token: Annotated[str | None, Cookie()] = None):
value=token_data['access_token'], value=token_data['access_token'],
httponly=True, httponly=True,
secure=True if settings.debug == False else True, secure=True if settings.debug == False else True,
samesite='lax', samesite='strict',
max_age=settings.max_age max_age=settings.max_age
) )
response.set_cookie( response.set_cookie(
@@ -218,11 +225,20 @@ def refresh_token(refresh_token: Annotated[str | None, Cookie()] = None):
value=token_data['refresh_token'] or '', value=token_data['refresh_token'] or '',
httponly=True, httponly=True,
secure=True if settings.debug == False else True, secure=True if settings.debug == False else True,
samesite='lax', samesite='strict',
max_age=4 max_age=30 * 24 * settings.max_age
)
response.set_cookie(
key='id_token',
value=token_data['id_token'],
httponly=True,
secure=not settings.debug,
samesite='strict',
max_age=settings.max_age
) )
return response return response
@router.get('/user/me') @router.get('/user/me')
def me(user: UserPublic = Depends(get_current_user)): def me(user: UserPublic = Depends(get_current_user)):
if not user: if not user:
@@ -235,4 +251,4 @@ def me(user: UserPublic = Depends(get_current_user)):
'id': user.id, 'id': user.id,
'roles': [role.name for role in user.roles] 'roles': [role.name for role in user.roles]
} }
} }

View File

@@ -1,18 +1,27 @@
from fastapi import APIRouter, Depends, HTTPException, Query """Router for contract resource"""
from fastapi.responses import StreamingResponse
from src.database import get_session
from sqlmodel import Session
from src.contracts.generate_contract import generate_html_contract, generate_recap
from src.auth.auth import get_current_user
import src.models as models
import src.messages as messages
import src.contracts.service as service
import src.forms.service as form_service
import io import io
import zipfile import zipfile
import src.contracts.service as service
import src.forms.service as form_service
import src.messages as messages
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlmodel import Session
from src import models
from src.auth.auth import get_current_user
from src.contracts.generate_contract import (generate_html_contract,
generate_recap)
from src.database import get_session
router = APIRouter(prefix='/contracts') router = APIRouter(prefix='/contracts')
def compute_recurrent_prices(products_quantities: list[dict], nb_shipment: int):
def compute_recurrent_prices(
products_quantities: list[dict],
nb_shipment: int
):
"""Compute price for recurrent products"""
result = 0 result = 0
for product_quantity in products_quantities: for product_quantity in products_quantities:
product = product_quantity['product'] product = product_quantity['product']
@@ -20,30 +29,50 @@ def compute_recurrent_prices(products_quantities: list[dict], nb_shipment: int):
result += compute_product_price(product, quantity, nb_shipment) result += compute_product_price(product, quantity, nb_shipment)
return result return result
def compute_occasional_prices(occasionals: list[dict]): def compute_occasional_prices(occasionals: list[dict]):
"""Compute prices for occassional products"""
result = 0 result = 0
for occasional in occasionals: for occasional in occasionals:
result += occasional['price'] result += occasional['price']
return result return result
def compute_product_price(product: models.Product, quantity: int, nb_shipment: int = 1):
product_quantity_unit = 1 if product.unit == models.Unit.KILO else 1000 def compute_product_price(
final_quantity = quantity if product.price else quantity / product_quantity_unit product: models.Product,
final_price = product.price if product.price else product.price_kg quantity: int,
return final_price * final_quantity * nb_shipment nb_shipment: int = 1
):
"""Compute price for a product"""
product_quantity_unit = (
1 if product.unit == models.Unit.KILO else 1000
)
final_quantity = (
quantity if product.price else quantity / product_quantity_unit
)
final_price = (
product.price if product.price else product.price_kg
)
return final_price * final_quantity * nb_shipment
def find_dict_in_list(lst, key, value): def find_dict_in_list(lst, key, value):
"""Find the index of a dictionnary in a list of dictionnaries given a key
and a value.
"""
for i, dic in enumerate(lst): for i, dic in enumerate(lst):
if dic[key].id == value: if dic[key].id == value:
return i return i
return -1 return -1
def create_occasional_dict(contract_products: list[models.ContractProduct]): def create_occasional_dict(contract_products: list[models.ContractProduct]):
"""Create a dictionnary of occasional products"""
result = [] result = []
for contract_product in contract_products: for contract_product in contract_products:
existing_id = find_dict_in_list( existing_id = find_dict_in_list(
result, result,
'shipment', 'shipment',
contract_product.shipment.id contract_product.shipment.id
) )
if existing_id < 0: if existing_id < 0:
@@ -69,71 +98,183 @@ def create_occasional_dict(contract_products: list[models.ContractProduct]):
) )
return result return result
@router.post('/')
@router.post('')
async def create_contract( async def create_contract(
contract: models.ContractCreate, contract: models.ContractCreate,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Create contract route"""
new_contract = service.create_one(session, contract) new_contract = service.create_one(session, contract)
occasional_contract_products = list(filter(lambda contract_product: contract_product.product.type == models.ProductType.OCCASIONAL, new_contract.products)) occasional_contract_products = list(
filter(
lambda contract_product: (
contract_product.product.type == models.ProductType.OCCASIONAL
),
new_contract.products
)
)
occasionals = create_occasional_dict(occasional_contract_products) occasionals = create_occasional_dict(occasional_contract_products)
recurrents = list(map(lambda x: {"product": x.product, "quantity": x.quantity}, filter(lambda contract_product: contract_product.product.type == models.ProductType.RECCURENT, new_contract.products))) recurrents = list(
recurrent_price = compute_recurrent_prices(recurrents, len(new_contract.form.shipments)) map(
lambda x: {'product': x.product, 'quantity': x.quantity},
filter(
lambda contract_product: (
contract_product.product.type ==
models.ProductType.RECCURENT
),
new_contract.products
)
)
)
recurrent_price = compute_recurrent_prices(
recurrents,
len(new_contract.form.shipments)
)
price = recurrent_price + compute_occasional_prices(occasionals) price = recurrent_price + compute_occasional_prices(occasionals)
total_price = '{:10.2f}'.format(price) cheques = list(
cheques = list(map(lambda x: {"name": x.name, "value": x.value}, new_contract.cheques)) map(
# TODO: send contract to referer lambda x: {'name': x.name, 'value': x.value},
new_contract.cheques
)
)
try: try:
pdf_bytes = generate_html_contract( pdf_bytes = generate_html_contract(
new_contract, new_contract,
cheques, cheques,
occasionals, occasionals,
recurrents, recurrents,
recurrent_price, '{:10.2f}'.format(recurrent_price),
total_price '{:10.2f}'.format(price)
) )
pdf_file = io.BytesIO(pdf_bytes) pdf_file = io.BytesIO(pdf_bytes)
contract_id = f'{new_contract.firstname}_{new_contract.lastname}_{new_contract.form.productor.type}_{new_contract.form.season}' contract_id = (
f'{new_contract.firstname}_'
f'{new_contract.lastname}_'
f'{new_contract.form.productor.type}_'
f'{new_contract.form.season}'
)
service.add_contract_file(session, new_contract.id, pdf_bytes, price) service.add_contract_file(session, new_contract.id, pdf_bytes, price)
except Exception as e: except Exception as error:
print(e) raise HTTPException(
raise HTTPException(status_code=400, detail=messages.pdferror) status_code=400,
detail=messages.pdferror
) from error
return StreamingResponse( return StreamingResponse(
pdf_file, pdf_file,
media_type='application/pdf', media_type='application/pdf',
headers={ headers={
'Content-Disposition': f'attachment; filename=contract_{contract_id}.pdf' 'Content-Disposition': (
f'attachment; filename=contract_{contract_id}.pdf'
)
} }
) )
@router.get('/', response_model=list[models.ContractPublic])
@router.get('/{form_id}/base')
async def get_base_contract_template(
form_id: int,
session: Session = Depends(get_session),
):
"""Get contract template route"""
form = form_service.get_one(session, form_id)
recurrents = [
{'product': product, 'quantity': None}
for product in form.productor.products
if product.type == models.ProductType.RECCURENT
]
occasionals = [{
'shipment': sh,
'price': None,
'products': [{'product': pr, 'quantity': None} for pr in sh.products]
} for sh in form.shipments]
empty_contract = models.ContractPublic(
firstname='',
form=form,
lastname='',
email='',
phone='',
products=[],
payment_method='cheque',
cheque_quantity=3,
total_price=0,
id=1
)
cheques = [
{'name': None, 'value': None},
{'name': None, 'value': None},
{'name': None, 'value': None}
]
try:
pdf_bytes = generate_html_contract(
empty_contract,
cheques,
occasionals,
recurrents,
)
pdf_file = io.BytesIO(pdf_bytes)
contract_id = (
f'{empty_contract.form.productor.type}_'
f'{empty_contract.form.season}'
)
except Exception as error:
raise HTTPException(
status_code=400,
detail=messages.pdferror
) from error
return StreamingResponse(
pdf_file,
media_type='application/pdf',
headers={
'Content-Disposition': (
f'attachment; filename=contract_{contract_id}.pdf'
)
}
)
@router.get('', response_model=list[models.ContractPublic])
def get_contracts( def get_contracts(
forms: list[str] = Query([]), forms: list[str] = Query([]),
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: models.User = Depends(get_current_user) user: models.User = Depends(get_current_user)
): ):
"""Get all contracts route"""
return service.get_all(session, user, forms) return service.get_all(session, user, forms)
@router.get('/{id}/file')
@router.get('/{_id}/file')
def get_contract_file( def get_contract_file(
id: int, _id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: models.User = Depends(get_current_user) user: models.User = Depends(get_current_user)
): ):
if not service.is_allowed(session, user, id): """Get a contract file (in pdf) route"""
raise HTTPException(status_code=403, detail=messages.notallowed) if not service.is_allowed(session, user, _id):
contract = service.get_one(session, id) raise HTTPException(
status_code=403,
detail=messages.Messages.not_allowed('contract', 'get')
)
contract = service.get_one(session, _id)
if contract is None: if contract is None:
raise HTTPException(status_code=404, detail=messages.notfound) raise HTTPException(
filename = f'{contract.form.name.replace(' ', '_')}_{contract.form.season}_{contract.firstname}-{contract.lastname}' status_code=404,
detail=messages.Messages.not_found('contract')
)
filename = (
f'{contract.form.name.replace(' ', '_')}_'
f'{contract.form.season}_'
f'{contract.firstname}_'
f'{contract.lastname}'
)
return StreamingResponse( return StreamingResponse(
io.BytesIO(contract.file), io.BytesIO(contract.file),
media_type='application/pdf', media_type='application/pdf',
headers={ headers={
'Content-Disposition': f'attachment; filename={filename}.pdf' 'Content-Disposition': f'attachment; filename={filename}.pdf'
} }
) )
@router.get('/{form_id}/files') @router.get('/{form_id}/files')
def get_contract_files( def get_contract_files(
@@ -141,17 +282,30 @@ def get_contract_files(
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: models.User = Depends(get_current_user) user: models.User = Depends(get_current_user)
): ):
"""Get all contract files for a given form"""
if not form_service.is_allowed(session, user, form_id): if not form_service.is_allowed(session, user, form_id):
raise HTTPException(status_code=403, detail=messages.notallowed) raise HTTPException(
status_code=403,
detail=messages.Messages.not_allowed('contracts', 'get')
)
form = form_service.get_one(session, form_id=form_id) form = form_service.get_one(session, form_id=form_id)
contracts = service.get_all(session, user, forms=[form.name]) contracts = service.get_all(session, user, forms=[form.name])
zipped_contracts = io.BytesIO() zipped_contracts = io.BytesIO()
with zipfile.ZipFile(zipped_contracts, "a", zipfile.ZIP_DEFLATED, False) as zip_file: with zipfile.ZipFile(
zipped_contracts,
'a',
zipfile.ZIP_DEFLATED,
False
) as zip_file:
for contract in contracts: for contract in contracts:
contract_filename = f'{contract.form.name.replace(' ', '_')}_{contract.form.season}_{contract.firstname}-{contract.lastname}.pdf' contract_filename = (
f'{contract.form.name.replace(' ', '_')}_'
f'{contract.form.season}_'
f'{contract.firstname}_'
f'{contract.lastname}'
)
zip_file.writestr(contract_filename, contract.file) zip_file.writestr(contract_filename, contract.file)
filename = f'{form.name.replace(' ', '_')}_{form.season}'
filename = f'{form.name.replace(" ", "_")}_{form.season}'
return StreamingResponse( return StreamingResponse(
io.BytesIO(zipped_contracts.getvalue()), io.BytesIO(zipped_contracts.getvalue()),
media_type='application/zip', media_type='application/zip',
@@ -160,39 +314,69 @@ def get_contract_files(
} }
) )
@router.get('/{form_id}/recap') @router.get('/{form_id}/recap')
def get_contract_recap( def get_contract_recap(
form_id: int, form_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: models.User = Depends(get_current_user) user: models.User = Depends(get_current_user)
): ):
"""Get a contract recap for a given form"""
if not form_service.is_allowed(session, user, form_id): if not form_service.is_allowed(session, user, form_id):
raise HTTPException(status_code=403, detail=messages.notallowed) raise HTTPException(
status_code=403,
detail=messages.Messages.not_allowed('contract recap', 'get')
)
form = form_service.get_one(session, form_id=form_id) form = form_service.get_one(session, form_id=form_id)
contracts = service.get_all(session, user, forms=[form.name]) contracts = service.get_all(session, user, forms=[form.name])
return StreamingResponse( return StreamingResponse(
io.BytesIO(generate_recap(contracts, form)), io.BytesIO(generate_recap(contracts, form)),
media_type='application/zip', media_type='application/zip',
headers={ headers={
'Content-Disposition': f'attachment; filename=filename.ods' 'Content-Disposition': (
'attachment; filename=filename.ods'
)
} }
) )
@router.get('/{id}', response_model=models.ContractPublic)
def get_contract(id: int, session: Session = Depends(get_session), user: models.User = Depends(get_current_user)): @router.get('/{_id}', response_model=models.ContractPublic)
if not service.is_allowed(session, user, id): def get_contract(
raise HTTPException(status_code=403, detail=messages.notallowed) _id: int,
result = service.get_one(session, id) session: Session = Depends(get_session),
user: models.User = Depends(get_current_user)
):
"""Get a contract route"""
if not service.is_allowed(session, user, _id):
raise HTTPException(
status_code=403,
detail=messages.Messages.not_allowed('contract', 'get')
)
result = service.get_one(session, _id)
if result is None: if result is None:
raise HTTPException(status_code=404, detail=messages.notfound) raise HTTPException(
status_code=404,
detail=messages.Messages.not_found('contract')
)
return result return result
@router.delete('/{id}', response_model=models.ContractPublic)
def delete_contract(id: int, session: Session = Depends(get_session), user: models.User = Depends(get_current_user)): @router.delete('/{_id}', response_model=models.ContractPublic)
if not service.is_allowed(session, user, id): def delete_contract(
raise HTTPException(status_code=403, detail=messages.notallowed) _id: int,
result = service.delete_one(session, id) session: Session = Depends(get_session),
user: models.User = Depends(get_current_user)
):
"""Delete contract route"""
if not service.is_allowed(session, user, _id):
raise HTTPException(
status_code=403,
detail=messages.Messages.not_allowed('contract', 'delete')
)
result = service.delete_one(session, _id)
if result is None: if result is None:
raise HTTPException(status_code=404, detail=messages.notfound) raise HTTPException(
status_code=404,
detail=messages.Messages.not_found('contract')
)
return result return result

View File

@@ -1,21 +1,28 @@
import html
import io
import pathlib
import jinja2 import jinja2
import src.models as models import odfdo
import html # from odfdo import Cell, Document, Row, Style, Table
from odfdo.element import Element
from src import models
from weasyprint import HTML from weasyprint import HTML
import io
def generate_html_contract( def generate_html_contract(
contract: models.Contract, contract: models.Contract,
cheques: list[dict], cheques: list[dict],
occasionals: list[dict], occasionals: list[dict],
reccurents: list[dict], reccurents: list[dict],
recurrent_price: float, recurrent_price: float | None = None,
total_price: float total_price: float | None = None
): ):
template_dir = "./src/contracts/templates" template_dir = pathlib.Path("./src/contracts/templates").resolve()
template_loader = jinja2.FileSystemLoader(searchpath=template_dir) template_loader = jinja2.FileSystemLoader(searchpath=template_dir)
template_env = jinja2.Environment(loader=template_loader, autoescape=jinja2.select_autoescape(["html", "xml"])) template_env = jinja2.Environment(
loader=template_loader, autoescape=jinja2.select_autoescape(["html", "xml"]))
template_file = "layout.html" template_file = "layout.html"
template = template_env.get_template(template_file) template = template_env.get_template(template_file)
output_text = template.render( output_text = template.render(
@@ -26,95 +33,212 @@ def generate_html_contract(
referer_email=contract.form.referer.email, referer_email=contract.form.referer.email,
productor_name=contract.form.productor.name, productor_name=contract.form.productor.name,
productor_address=contract.form.productor.address, productor_address=contract.form.productor.address,
payment_methods_map={"cheque": "Ordre du chèque", "transfer": "virements"}, payment_methods_map={
"cheque": "Ordre du chèque",
"transfer": "virements"},
productor_payment_methods=contract.form.productor.payment_methods, productor_payment_methods=contract.form.productor.payment_methods,
member_name=f'{html.escape(contract.firstname)} {html.escape(contract.lastname)}', member_name=f'{
member_email=html.escape(contract.email), html.escape(
member_phone=html.escape(contract.phone), contract.firstname)} {
html.escape(
contract.lastname)}',
member_email=html.escape(
contract.email),
member_phone=html.escape(
contract.phone),
contract_start_date=contract.form.start, contract_start_date=contract.form.start,
contract_end_date=contract.form.end, contract_end_date=contract.form.end,
occasionals=occasionals, occasionals=occasionals,
recurrents=reccurents, recurrents=reccurents,
recurrent_price=recurrent_price, recurrent_price=recurrent_price,
total_price=total_price, total_price=total_price,
contract_payment_method={"cheque": "chèque", "transfer": "virements"}[contract.payment_method], contract_payment_method={
cheques=cheques "cheque": "chèque",
) "transfer": "virements"}[
options = { contract.payment_method],
'page-size': 'Letter', cheques=cheques)
'margin-top': '0.5in',
'margin-right': '0.5in',
'margin-bottom': '0.5in',
'margin-left': '0.5in',
'encoding': "UTF-8",
'print-media-type': True,
"disable-javascript": True,
"disable-external-links": True,
'enable-local-file-access': False,
"disable-local-file-access": True,
"no-images": True,
}
return HTML( return HTML(
string=output_text, string=output_text,
base_url=template_dir base_url=template_dir,
).write_pdf() ).write_pdf()
def flatten(xss): def flatten(xss):
return [x for xs in xss for x in xs] return [x for xs in xss for x in xs]
from odfdo import Document, Table, Row, Cell
from odfdo.element import Element def create_column_style_width(size: str) -> odfdo.Style:
"""Create a table columm style for a given width.
Paramenters:
size(str): size of the style (format <number><unit>) unit can be in, cm... see odfdo documentation.
Returns:
odfdo.Style with the correct column-width attribute.
"""
return odfdo.Element.from_tag(
'<style:style style:name="product-table.A" style:family="table-column">'
f'<style:table-column-properties style:column-width="{size}"/>'
'</style:style>'
)
def create_row_style_height(size: str) -> odfdo.Style:
"""Create a table height style for a given height.
Paramenters:
size(str): size of the style (format <number><unit>) unit can be in, cm... see odfdo documentation.
Returns:
odfdo.Style with the correct column-height attribute.
"""
return odfdo.Element.from_tag(
'<style:style style:name="product-table.A" style:family="table-row">'
f'<style:table-row-properties style:row-height="{size}"/>'
'</style:style>'
)
def create_center_cell_style(name: str = "centered-cell") -> odfdo.Style:
return odfdo.Element.from_tag(
f'<style:style style:name="{name}" style:family="table-cell">'
'<style:table-cell-properties style:vertical-align="middle" fo:wrap-option="wrap"/>'
'<style:paragraph-properties fo:text-align="center"/>'
'</style:style>'
)
def create_cell_style_with_font(name: str = "font", font_size="14pt", bold: bool = False) -> odfdo.Style:
return odfdo.Element.from_tag(
f'<style:style style:name="{name}" style:family="table-cell" '
f'xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0">'
'<style:table-cell-properties style:vertical-align="middle" fo:wrap-option="wrap"/>'
f'<style:paragraph-properties fo:text-align="center" fo:font-size="{font_size}" '
f'{"fo:font-weight=\"bold\"" if bold else ""}/>'
'</style:style>'
)
def apply_center_cell_style(document: odfdo.Document, row: odfdo.Row):
style = document.insert_style(
create_center_cell_style()
)
for cell in row.get_cells():
cell.style = style
def apply_column_height_style(document: odfdo.Document, row: odfdo.Row, height: str):
style = document.insert_style(
style=create_row_style_height(height), name=height, automatic=True
)
row.style = style
def apply_font_style(document: odfdo.Document, table: odfdo.Table, size: str = "14pt"):
style_header = document.insert_style(
style=create_cell_style_with_font(
'header_font', font_size=size, bold=True
)
)
style_body = document.insert_style(
style=create_cell_style_with_font(
'body_font', font_size=size, bold=False
)
)
for position in range(table.height):
row = table.get_row(position)
for cell in row.get_cells():
cell.style = style_header if position == 0 or position == 1 else style_body
for paragraph in cell.get_paragraphs():
paragraph.style = cell.style
def apply_column_width_style(document: odfdo.Document, table: odfdo.Table, widths: list[str]):
"""Apply column width style to a table.
Parameters:
document(odfdo.Document): Document where the table is located.
table(odfdo.Table): Table to apply columns widths.
widths(list[str]): list of width in format <number><unit> unit ca be in, cm... see odfdo documentation.
"""
styles = []
for w in widths:
styles.append(document.insert_style(
style=create_column_style_width(w), name=w, automatic=True))
for position in range(table.width):
col = table.get_column(position)
col.style = styles[position]
table.set_column(position, col)
def generate_recap( def generate_recap(
contracts: list[models.Contract], contracts: list[models.Contract],
form: models.Form, form: models.Form,
): ):
print(form.productor.products) recurrents = [pr.name for pr in form.productor.products if pr.type ==
recurrents = [pr.name for pr in form.productor.products if pr.type == models.ProductType.RECCURENT] models.ProductType.RECCURENT]
recurrents.sort() recurrents.sort()
occasionnals = [pr.name for pr in form.productor.products if pr.type == models.ProductType.OCCASIONAL] occasionnals = [pr.name for pr in form.productor.products if pr.type ==
models.ProductType.OCCASIONAL]
occasionnals.sort() occasionnals.sort()
shipments = form.shipments shipments = form.shipments
occasionnals_header = [occ for shipment in shipments for occ in occasionnals] occasionnals_header = [
shipment_header = flatten([[shipment.name] + ["" * len(occasionnals)] for shipment in shipments]) occ for shipment in shipments for occ in occasionnals]
shipment_header = flatten(
[[f'{shipment.name} - {shipment.date.strftime('%Y-%m-%d')}'] + ["" * len(occasionnals)] for shipment in shipments])
product_unit_map = { product_unit_map = {
"1": "g", "1": "g",
"2": "kg", "2": "kg",
"3": "p" "3": "p"
} }
header = (
["Nom", "Email"] +
["Tarif panier", "Total Paniers", "Total à payer"] +
["Cheque 1", "Cheque 2", "Cheque 3"] +
[f"Total {len(shipments)} livraisons + produits occasionnels"] +
recurrents +
occasionnals_header +
["Remarques", "Nom"]
)
data = [ data = [
["", ""] + ["" * len(recurrents)] + shipment_header, [""] * (9 + len(recurrents)) + shipment_header,
["nom", "email"] + recurrents + occasionnals_header + ["remarques", "name"], header,
*[ *[
[ [
f'{contract.firstname} {contract.lastname}', f'{contract.firstname} {contract.lastname}',
f'{contract.email}', f'{contract.email}',
*[f'{pr.quantity} {product_unit_map[pr.product.unit]}' for pr in sorted(contract.products, key=lambda x: x.product.name) if pr.product.type == models.ProductType.RECCURENT], *[f'{pr.quantity} {product_unit_map[pr.product.unit]}' for pr in sorted(
*[f'{pr.quantity} {product_unit_map[pr.product.unit]}' for pr in sorted(contract.products, key=lambda x: x.product.name) if pr.product.type == models.ProductType.OCCASIONAL], contract.products, key=lambda x: x.product.name) if pr.product.type == models.ProductType.RECCURENT],
"", *[f'{pr.quantity} {product_unit_map[pr.product.unit]}' for pr in sorted(
f'{contract.firstname} {contract.lastname}', contract.products, key=lambda x: x.product.name) if pr.product.type == models.ProductType.OCCASIONAL],
"",
f'{contract.firstname} {contract.lastname}',
] for contract in contracts ] for contract in contracts
] ]
] ]
doc = Document("spreadsheet") doc = odfdo.Document("spreadsheet")
sheet = Table(name="Recap") sheet = doc.body.get_sheet(0)
sheet.name = 'Recap'
sheet.set_values(data) sheet.set_values(data)
apply_column_width_style(doc, doc.body.get_table(0), ["4cm"] * len(header))
apply_column_height_style(
doc,
doc.body.get_table(0).get_rows((1, 1))[0],
"1.20cm"
)
apply_center_cell_style(doc, doc.body.get_table(0).get_rows((1, 1))[0])
apply_font_style(doc, doc.body.get_table(0))
index = 9 + len(recurrents)
for _ in enumerate(shipments):
startcol = index
endcol = index+len(occasionnals) - 1
sheet.set_span((startcol, 0, endcol, 0), merge=True)
index += len(occasionnals)
offset = 0
index = 2 + len(recurrents)
for i in range(len(shipments)):
index = index + offset
print(index, index+len(occasionnals) - 1)
sheet.set_span((index, 0, index+len(occasionnals) - 1, 0), merge=True)
offset += len(occasionnals)
doc.body.append(sheet) doc.body.append(sheet)
buffer = io.BytesIO() buffer = io.BytesIO()
doc.save(buffer) doc.save('test.ods')
return buffer.getvalue() return buffer.getvalue()

View File

@@ -1,28 +1,57 @@
"""Contract service responsible for read, create, update and delete contracts"""
from sqlalchemy.orm import selectinload
from sqlmodel import Session, select from sqlmodel import Session, select
import src.models as models from src import models
def get_all( def get_all(
session: Session, session: Session,
user: models.User, user: models.User,
forms: list[str] = [], forms: list[str] | None = None,
form_id: int | None = None, form_id: int | None = None,
) -> list[models.ContractPublic]: ) -> list[models.ContractPublic]:
statement = select(models.Contract)\ """Get all contracts"""
.join(models.Form, models.Contract.form_id == models.Form.id)\ statement = (
.join(models.Productor, models.Form.productor_id == models.Productor.id)\ select(models.Contract)
.where(models.Productor.type.in_([r.name for r in user.roles]))\ .join(
models.Form,
models.Contract.form_id == models.Form.id
)
.join(
models.Productor,
models.Form.productor_id == models.Productor.id
)
.where(
models.Productor.type.in_(
[r.name for r in user.roles]
)
)
.distinct() .distinct()
if len(forms) > 0: )
if forms:
statement = statement.where(models.Form.name.in_(forms)) statement = statement.where(models.Form.name.in_(forms))
if form_id: if form_id:
statement = statement.where(models.Form.id == form_id) statement = statement.where(models.Form.id == form_id)
return session.exec(statement.order_by(models.Contract.id)).all() return session.exec(statement.order_by(models.Contract.id)).all()
def get_one(session: Session, contract_id: int) -> models.ContractPublic:
def get_one(
session: Session,
contract_id: int
) -> models.ContractPublic:
"""Get one contract"""
return session.get(models.Contract, contract_id) return session.get(models.Contract, contract_id)
def create_one(session: Session, contract: models.ContractCreate) -> models.ContractPublic:
contract_create = contract.model_dump(exclude_unset=True, exclude=["products", "cheques"]) def create_one(
session: Session,
contract: models.ContractCreate
) -> models.ContractPublic:
"""Create one contract"""
contract_create = contract.model_dump(
exclude_unset=True,
exclude=["products", "cheques"]
)
new_contract = models.Contract(**contract_create) new_contract = models.Contract(**contract_create)
new_contract.cheques = [ new_contract.cheques = [
@@ -45,10 +74,27 @@ def create_one(session: Session, contract: models.ContractCreate) -> models.Cont
session.add(new_contract) session.add(new_contract)
session.commit() session.commit()
session.refresh(new_contract) session.refresh(new_contract)
return new_contract
def add_contract_file(session: Session, id: int, file: bytes, price: float): statement = (
statement = select(models.Contract).where(models.Contract.id == id) select(models.Contract)
.where(models.Contract.id == new_contract.id)
.options(
selectinload(models.Contract.form)
.selectinload(models.Form.productor)
)
)
return session.exec(statement).one()
def add_contract_file(
session: Session,
_id: int,
file: bytes,
price: float
):
"""Add a file to an existing contract"""
statement = select(models.Contract).where(models.Contract.id == _id)
result = session.exec(statement) result = session.exec(statement)
contract = result.first() contract = result.first()
contract.total_price = price contract.total_price = price
@@ -58,8 +104,14 @@ def add_contract_file(session: Session, id: int, file: bytes, price: float):
session.refresh(contract) session.refresh(contract)
return contract return contract
def update_one(session: Session, id: int, contract: models.ContractUpdate) -> models.ContractPublic:
statement = select(models.Contract).where(models.Contract.id == id) def update_one(
session: Session,
_id: int,
contract: models.ContractUpdate
) -> models.ContractPublic:
"""Update one contract"""
statement = select(models.Contract).where(models.Contract.id == _id)
result = session.exec(statement) result = session.exec(statement)
new_contract = result.first() new_contract = result.first()
if not new_contract: if not new_contract:
@@ -72,8 +124,13 @@ def update_one(session: Session, id: int, contract: models.ContractUpdate) -> mo
session.refresh(new_contract) session.refresh(new_contract)
return new_contract return new_contract
def delete_one(session: Session, id: int) -> models.ContractPublic:
statement = select(models.Contract).where(models.Contract.id == id) def delete_one(
session: Session,
_id: int
) -> models.ContractPublic:
"""Delete one contract"""
statement = select(models.Contract).where(models.Contract.id == _id)
result = session.exec(statement) result = session.exec(statement)
contract = result.first() contract = result.first()
if not contract: if not contract:
@@ -83,11 +140,29 @@ def delete_one(session: Session, id: int) -> models.ContractPublic:
session.commit() session.commit()
return result return result
def is_allowed(session: Session, user: models.User, id: int) -> bool:
statement = select(models.Contract)\ def is_allowed(
.join(models.Form, models.Contract.form_id == models.Form.id)\ session: Session,
.join(models.Productor, models.Form.productor_id == models.Productor.id)\ user: models.User,
.where(models.Contract.id == id)\ _id: int
.where(models.Productor.type.in_([r.name for r in user.roles]))\ ) -> bool:
"""Determine if a user is allowed to access a contract by id"""
statement = (
select(models.Contract)
.join(
models.Form,
models.Contract.form_id == models.Form.id
)
.join(
models.Productor,
models.Form.productor_id == models.Productor.id
)
.where(models.Contract.id == _id)
.where(
models.Productor.type.in_(
[r.name for r in user.roles]
)
)
.distinct() .distinct()
return len(session.exec(statement).all()) > 0 )
return len(session.exec(statement).all()) > 0

View File

@@ -151,10 +151,6 @@
<th>Saison du contrat</th> <th>Saison du contrat</th>
<td>{{contract_season}}</td> <td>{{contract_season}}</td>
</tr> </tr>
<tr>
<th>Type de contrat</th>
<td>{{contract_type}}</td>
</tr>
<tr> <tr>
<th>Référent·e</th> <th>Référent·e</th>
<td>{{referer_name}}</td> <td>{{referer_name}}</td>
@@ -278,14 +274,14 @@
else ""}} else ""}}
</td> </td>
<td> <td>
{{rec.quantity}}{{"g" if rec.product.unit == "1" else "kg" if {{rec.quantity if rec.quantity != None else ""}}{{"g" if rec.product.unit == "1" else "kg" if
rec.product.unit == "2" else "p" }} rec.product.unit == "2" else "p" }}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
<tr> <tr>
<th scope="row" colspan="4">Total</th> <th scope="row" colspan="4">Total</th>
<td>{{recurrent_price}}€</td> <td>{{recurrent_price if recurrent_price else ""}}€</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -321,14 +317,15 @@
product.product.quantity_unit != None else ""}} product.product.quantity_unit != None else ""}}
</td> </td>
<td> <td>
{{product.quantity}}{{"g" if product.product.unit == "1" else {{product.quantity if product.quantity != None
else ""}}{{"g" if product.product.unit == "1" else
"kg" if product.product.unit == "2" else "p" }} "kg" if product.product.unit == "2" else "p" }}
</td> </td>
</tr> </tr>
{% endfor%} {% endfor%}
<tr> <tr>
<th scope="row" colspan="4">Total</th> <th scope="row" colspan="4">Total</th>
<td>{{occasional.price}}€</td> <td>{{occasional.price if occasional.price else ""}}€</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -337,7 +334,7 @@
{% endif %} {% endif %}
<div class="total-box"> <div class="total-box">
<div class="total-label">Prix Total :</div> <div class="total-label">Prix Total :</div>
<div class="total-price">{{total_price}}€</div> <div class="total-price">{{total_price if total_price else ""}}€</div>
</div> </div>
<h4>Paiement par {{contract_payment_method}}</h4> <h4>Paiement par {{contract_payment_method}}</h4>
{% if contract_payment_method == "chèque" %} {% if contract_payment_method == "chèque" %}
@@ -346,14 +343,14 @@
<thead> <thead>
<tr> <tr>
{% for cheque in cheques %} {% for cheque in cheques %}
<th>Cheque n°{{cheque.name}}</th> <th>Cheque n°{{cheque.name if cheque.name else ""}}</th>
{% endfor %} {% endfor %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
{% for cheque in cheques %} {% for cheque in cheques %}
<td>{{cheque.value}}€</td> <td>{{cheque.value if cheque.value else ""}}€</td>
{% endfor %} {% endfor %}
</tr> </tr>
</tbody> </tbody>

View File

@@ -1,11 +1,14 @@
from sqlmodel import create_engine, SQLModel, Session from sqlmodel import Session, SQLModel, create_engine
from src.settings import settings from src.settings import settings
engine = create_engine(f'postgresql://{settings.db_user}:{settings.db_pass}@{settings.db_host}:54321/{settings.db_name}') engine = create_engine(
f'postgresql://{settings.db_user}:{settings.db_pass}@{settings.db_host}:5432/{settings.db_name}')
def get_session(): def get_session():
with Session(engine) as session: with Session(engine) as session:
yield session yield session
def create_all_tables(): def create_all_tables():
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)

View File

@@ -0,0 +1,26 @@
"""Forms module exceptions"""
import logging
class FormServiceError(Exception):
"""Form service exception"""
def __init__(self, message: str):
super().__init__(message)
logging.error('FormService : %s', message)
class UserNotFoundError(FormServiceError):
pass
class ProductorNotFoundError(FormServiceError):
pass
class FormNotFoundError(FormServiceError):
pass
class FormCreateError(FormServiceError):
def __init__(self, message: str, field: str | None = None):
super().__init__(message)
self.field = field

View File

@@ -1,55 +1,89 @@
from fastapi import APIRouter, HTTPException, Depends, Query import src.forms.exceptions as exceptions
import src.messages as messages
import src.models as models
from src.database import get_session
from sqlmodel import Session
import src.forms.service as service import src.forms.service as service
import src.messages as messages
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session
from src import models
from src.auth.auth import get_current_user from src.auth.auth import get_current_user
from src.database import get_session
router = APIRouter(prefix='/forms') router = APIRouter(prefix='/forms')
@router.get('/', response_model=list[models.FormPublic])
@router.get('', response_model=list[models.FormPublic])
async def get_forms( async def get_forms(
seasons: list[str] = Query([]), seasons: list[str] = Query([]),
productors: list[str] = Query([]), productors: list[str] = Query([]),
current_season: bool = False, current_season: bool = False,
session: Session = Depends(get_session) session: Session = Depends(get_session),
): ):
return service.get_all(session, seasons, productors, current_season) return service.get_all(session, seasons, productors, current_season)
@router.get('/{id}', response_model=models.FormPublic)
async def get_form(id: int, session: Session = Depends(get_session)): @router.get('/referents', response_model=list[models.FormPublic])
result = service.get_one(session, id) async def get_forms_filtered(
seasons: list[str] = Query([]),
productors: list[str] = Query([]),
current_season: bool = False,
session: Session = Depends(get_session),
user: models.User = Depends(get_current_user)
):
return service.get_all(session, seasons, productors, current_season, user)
@router.get('/{_id}', response_model=models.FormPublic)
async def get_form(_id: int, session: Session = Depends(get_session)):
result = service.get_one(session, _id)
if result is None: if result is None:
raise HTTPException(status_code=404, detail=messages.notfound) raise HTTPException(
status_code=404,
detail=messages.Messages.not_found('form')
)
return result return result
@router.post('/', response_model=models.FormPublic)
@router.post('', response_model=models.FormPublic)
async def create_form( async def create_form(
form: models.FormCreate, form: models.FormCreate,
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
return service.create_one(session, form) try:
form = service.create_one(session, form)
except exceptions.ProductorNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
except exceptions.UserNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
except exceptions.FormCreateError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
return form
@router.put('/{id}', response_model=models.FormPublic)
@router.put('/{_id}', response_model=models.FormPublic)
async def update_form( async def update_form(
id: int, form: models.FormUpdate, _id: int, form: models.FormUpdate,
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
result = service.update_one(session, id, form) try:
if result is None: result = service.update_one(session, _id, form)
raise HTTPException(status_code=404, detail=messages.notfound) except exceptions.FormNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
except exceptions.ProductorNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
except exceptions.UserNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
return result return result
@router.delete('/{id}', response_model=models.FormPublic)
@router.delete('/{_id}', response_model=models.FormPublic)
async def delete_form( async def delete_form(
id: int, _id: int,
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
result = service.delete_one(session, id) try:
if result is None: result = service.delete_one(session, _id)
raise HTTPException(status_code=404, detail=messages.notfound) except exceptions.FormNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
return result return result

View File

@@ -1,41 +1,69 @@
from sqlmodel import Session, select import src.forms.exceptions as exceptions
import src.models as models import src.messages as messages
from sqlalchemy import func from sqlalchemy import func
from sqlmodel import Session, select
from src import models
def get_all( def get_all(
session: Session, session: Session,
seasons: list[str], seasons: list[str],
productors: list[str], productors: list[str],
current_season: bool, current_season: bool,
user: models.User = None
) -> list[models.FormPublic]: ) -> list[models.FormPublic]:
statement = select(models.Form) statement = select(models.Form)
if user:
statement = statement .join(
models.Productor,
models.Form.productor_id == models.Productor.id) .where(
models.Productor.type.in_(
[
r.name for r in user.roles])) .distinct()
if len(seasons) > 0: if len(seasons) > 0:
statement = statement.where(models.Form.season.in_(seasons)) statement = statement.where(models.Form.season.in_(seasons))
if len(productors) > 0: if len(productors) > 0:
statement = statement.join(models.Productor).where(models.Productor.name.in_(productors)) statement = statement.join(
models.Productor).where(
models.Productor.name.in_(productors))
if not user:
statement = statement.where(models.Form.visible)
if current_season: if current_season:
subquery = ( subquery = (
select( select(
models.Productor.type, models.Productor.type,
func.max(models.Form.start).label("max_start") func.max(models.Form.start).label("max_start")
) )
.join(models.Form)\ .join(models.Form)
.group_by(models.Productor.type)\ .group_by(models.Productor.type)
.subquery() .subquery()
) )
statement = select(models.Form)\ statement = select(models.Form)\
.join(models.Productor)\ .join(models.Productor)\
.join(subquery, .join(subquery,
(models.Productor.type == subquery.c.type) & (models.Productor.type == subquery.c.type) &
(models.Form.start == subquery.c.max_start) (models.Form.start == subquery.c.max_start)
) )
if not user:
statement = statement.where(models.Form.visible)
return session.exec(statement.order_by(models.Form.name)).all() return session.exec(statement.order_by(models.Form.name)).all()
return session.exec(statement.order_by(models.Form.name)).all() return session.exec(statement.order_by(models.Form.name)).all()
def get_one(session: Session, form_id: int) -> models.FormPublic: def get_one(session: Session, form_id: int) -> models.FormPublic:
return session.get(models.Form, form_id) return session.get(models.Form, form_id)
def create_one(session: Session, form: models.FormCreate) -> models.FormPublic: def create_one(session: Session, form: models.FormCreate) -> models.FormPublic:
if not form:
raise exceptions.FormCreateError(
messages.Messages.invalid_input(
'form', 'input cannot be None'))
if not session.get(models.Productor, form.productor_id):
raise exceptions.ProductorNotFoundError(
messages.Messages.not_found('productor'))
if not session.get(models.User, form.referer_id):
raise exceptions.UserNotFoundError(messages.Messages.not_found('user'))
form_create = form.model_dump(exclude_unset=True) form_create = form.model_dump(exclude_unset=True)
new_form = models.Form(**form_create) new_form = models.Form(**form_create)
session.add(new_form) session.add(new_form)
@@ -43,12 +71,22 @@ def create_one(session: Session, form: models.FormCreate) -> models.FormPublic:
session.refresh(new_form) session.refresh(new_form)
return new_form return new_form
def update_one(session: Session, id: int, form: models.FormUpdate) -> models.FormPublic:
statement = select(models.Form).where(models.Form.id == id) def update_one(
session: Session,
_id: int,
form: models.FormUpdate) -> models.FormPublic:
statement = select(models.Form).where(models.Form.id == _id)
result = session.exec(statement) result = session.exec(statement)
new_form = result.first() new_form = result.first()
if not new_form: if not new_form:
return None raise exceptions.FormNotFoundError(messages.Messages.not_found('form'))
if form.productor_id and not session.get(
models.Productor, form.productor_id):
raise exceptions.ProductorNotFoundError(
messages.Messages.not_found('productor'))
if form.referer_id and not session.get(models.User, form.referer_id):
raise exceptions.UserNotFoundError(messages.Messages.not_found('user'))
form_updates = form.model_dump(exclude_unset=True) form_updates = form.model_dump(exclude_unset=True)
for key, value in form_updates.items(): for key, value in form_updates.items():
setattr(new_form, key, value) setattr(new_form, key, value)
@@ -57,21 +95,31 @@ def update_one(session: Session, id: int, form: models.FormUpdate) -> models.For
session.refresh(new_form) session.refresh(new_form)
return new_form return new_form
def delete_one(session: Session, id: int) -> models.FormPublic:
statement = select(models.Form).where(models.Form.id == id) def delete_one(session: Session, _id: int) -> models.FormPublic:
statement = select(models.Form).where(models.Form.id == _id)
result = session.exec(statement) result = session.exec(statement)
form = result.first() form = result.first()
if not form: if not form:
return None raise exceptions.FormNotFoundError(messages.Messages.not_found('form'))
result = models.FormPublic.model_validate(form) result = models.FormPublic.model_validate(form)
session.delete(form) session.delete(form)
session.commit() session.commit()
return result return result
def is_allowed(session: Session, user: models.User, id: int) -> bool:
statement = select(models.Form)\ def is_allowed(session: Session, user: models.User, _id: int) -> bool:
.join(models.Productor, models.Form.productor_id == models.Productor.id)\ statement = (
.where(models.Form.id == id)\ select(models.Form)
.where(models.Productor.type.in_([r.name for r in user.roles]))\ .join(
models.Productor,
models.Form.productor_id == models.Productor.id)
.where(models.Form.id == _id)
.where(
models.Productor.type.in_(
[r.name for r in user.roles]
)
)
.distinct() .distinct()
return len(session.exec(statement).all()) > 0 )
return len(session.exec(statement).all()) > 0

View File

@@ -1,21 +1,18 @@
from sqlmodel import SQLModel
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from src.auth.auth import router as auth_router
from src.templates.templates import router as template_router
from src.contracts.contracts import router as contracts_router from src.contracts.contracts import router as contracts_router
from src.forms.forms import router as forms_router from src.forms.forms import router as forms_router
from src.productors.productors import router as productors_router from src.productors.productors import router as productors_router
from src.products.products import router as products_router from src.products.products import router as products_router
from src.users.users import router as users_router
from src.auth.auth import router as auth_router
from src.shipments.shipments import router as shipment_router
from src.settings import settings from src.settings import settings
from src.database import engine from src.shipments.shipments import router as shipment_router
from src.templates.templates import router as template_router
from src.users.users import router as users_router
app = FastAPI() app = FastAPI()
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[ allow_origins=[
@@ -27,14 +24,11 @@ app.add_middleware(
expose_headers=['x-nbpage', 'Content-Disposition'] expose_headers=['x-nbpage', 'Content-Disposition']
) )
app.include_router(template_router, prefix="/api")
app.include_router(template_router) app.include_router(contracts_router, prefix="/api")
app.include_router(contracts_router) app.include_router(forms_router, prefix="/api")
app.include_router(forms_router) app.include_router(productors_router, prefix="/api")
app.include_router(productors_router) app.include_router(products_router, prefix="/api")
app.include_router(products_router) app.include_router(users_router, prefix="/api")
app.include_router(users_router) app.include_router(auth_router, prefix="/api")
app.include_router(auth_router) app.include_router(shipment_router, prefix="/api")
app.include_router(shipment_router)
SQLModel.metadata.create_all(engine)

View File

@@ -1,10 +1,20 @@
notfound = "Resource was not found." pdferror = 'An error occured during PDF generation please contact administrator'
pdferror = "An error occured during PDF generation please contact administrator"
tokenexipired = "Token expired"
invalidtoken = "Invalid token" class Messages:
notauthenticated = "Not authenticated" unauthorized = 'User is Unauthorized'
usernotfound = "User not found" notauthenticated = 'User is not authenticated'
userloggedout = "User logged out" tokenexipired = 'Token has expired'
failtogettoken = "Failed to get token" invalidtoken = 'Token is invalid'
unauthorized = "Unauthorized"
notallowed = "Not Allowed" @staticmethod
def not_found(resource: str) -> str:
return f'{resource.capitalize()} not found'
@staticmethod
def invalid_input(resource: str, reason: str = "") -> str:
return f'Invalid {resource} input {':' if reason else ""} {reason}'
@staticmethod
def not_allowed(resource: str, action: str) -> str:
return f'User is not allowed to {action} this {resource}'

View File

@@ -1,98 +1,136 @@
from sqlmodel import Field, SQLModel, Relationship, Column, LargeBinary import datetime
from enum import StrEnum from enum import StrEnum
from typing import Optional from typing import Optional
import datetime
from sqlmodel import Column, Field, LargeBinary, Relationship, SQLModel
class ContractType(SQLModel, table=True): class ContractType(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(
default=None,
primary_key=True
)
name: str name: str
class UserContractTypeLink(SQLModel, table=True): class UserContractTypeLink(SQLModel, table=True):
user_id: int = Field(foreign_key="user.id", primary_key=True) user_id: int = Field(
contract_type_id: int = Field(foreign_key="contracttype.id", primary_key=True) foreign_key='user.id',
primary_key=True
)
contract_type_id: int = Field(
foreign_key='contracttype.id',
primary_key=True
)
class UserBase(SQLModel): class UserBase(SQLModel):
name: str name: str
email: str email: str
class UserPublic(UserBase): class UserPublic(UserBase):
id: int id: int
roles: list[ContractType] roles: list[ContractType]
class User(UserBase, table=True): class User(UserBase, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
roles: list[ContractType] = Relationship( roles: list[ContractType] = Relationship(
link_model=UserContractTypeLink link_model=UserContractTypeLink
) )
class UserUpdate(SQLModel): class UserUpdate(SQLModel):
name: str | None name: str | None
email: str | None email: str | None
role_names: list[str] | None role_names: list[str] | None
class UserCreate(UserBase): class UserCreate(UserBase):
role_names: list[str] | None role_names: list[str] | None
class PaymentMethodBase(SQLModel): class PaymentMethodBase(SQLModel):
name: str name: str
details: str details: str
max: int | None
class PaymentMethod(PaymentMethodBase, table=True): class PaymentMethod(PaymentMethodBase, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
productor_id: int = Field(foreign_key="productor.id", ondelete="CASCADE") productor_id: int = Field(foreign_key='productor.id', ondelete='CASCADE')
productor: Optional["Productor"] = Relationship( productor: Optional['Productor'] = Relationship(
back_populates="payment_methods", back_populates='payment_methods',
) )
class PaymentMethodPublic(PaymentMethodBase): class PaymentMethodPublic(PaymentMethodBase):
id: int id: int
productor: Optional["Productor"] productor: Optional['Productor']
class ProductorBase(SQLModel): class ProductorBase(SQLModel):
name: str name: str
address: str address: str
type: str type: str
class ProductorPublic(ProductorBase): class ProductorPublic(ProductorBase):
id: int id: int
products: list["Product"] = [] products: list['Product'] = Field(default_factory=list)
payment_methods: list["PaymentMethod"] = [] payment_methods: list['PaymentMethod'] = Field(default_factory=list)
class Productor(ProductorBase, table=True): class Productor(ProductorBase, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
products: list["Product"] = Relationship( products: list['Product'] = Relationship(
back_populates='productor', back_populates='productor',
sa_relationship_kwargs={ sa_relationship_kwargs={
"order_by": "Product.name" 'order_by': 'Product.name'
}, },
) )
payment_methods: list["PaymentMethod"] = Relationship( payment_methods: list['PaymentMethod'] = Relationship(
back_populates="productor", back_populates='productor',
cascade_delete=True cascade_delete=True
) )
class ProductorUpdate(SQLModel): class ProductorUpdate(SQLModel):
name: str | None name: str | None
address: str | None address: str | None
payment_methods: list["PaymentMethod"] = [] payment_methods: list['PaymentMethod'] = Field(default_factory=list)
type: str | None type: str | None
class ProductorCreate(ProductorBase): class ProductorCreate(ProductorBase):
payment_methods: list["PaymentMethod"] = [] payment_methods: list['PaymentMethod'] = Field(default_factory=list)
class Unit(StrEnum): class Unit(StrEnum):
GRAMS = "1" GRAMS = '1'
KILO = "2" KILO = '2'
PIECE = "3" PIECE = '3'
class ProductType(StrEnum): class ProductType(StrEnum):
OCCASIONAL = "1" OCCASIONAL = '1'
RECCURENT = "2" RECCURENT = '2'
class ShipmentProductLink(SQLModel, table=True): class ShipmentProductLink(SQLModel, table=True):
shipment_id: Optional[int] = Field(default=None, foreign_key="shipment.id", primary_key=True) shipment_id: Optional[int] = Field(
product_id: Optional[int] = Field(default=None, foreign_key="product.id", primary_key=True) default=None,
foreign_key='shipment.id',
primary_key=True
)
product_id: Optional[int] = Field(
default=None,
foreign_key='product.id',
primary_key=True
)
class ProductBase(SQLModel): class ProductBase(SQLModel):
name: str name: str
@@ -102,17 +140,31 @@ class ProductBase(SQLModel):
quantity: float | None quantity: float | None
quantity_unit: str | None quantity_unit: str | None
type: ProductType type: ProductType
productor_id: int | None = Field(default=None, foreign_key="productor.id") productor_id: int | None = Field(
default=None,
foreign_key='productor.id'
)
class ProductPublic(ProductBase): class ProductPublic(ProductBase):
id: int id: int
productor: Productor | None productor: Productor | None
shipments: list["Shipment"] | None shipments: list['Shipment'] | None
class Product(ProductBase, table=True): class Product(ProductBase, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(
shipments: list["Shipment"] = Relationship(back_populates="products", link_model=ShipmentProductLink) default=None,
productor: Optional[Productor] = Relationship(back_populates="products") primary_key=True
)
shipments: list['Shipment'] = Relationship(
back_populates='products',
link_model=ShipmentProductLink
)
productor: Optional[Productor] = Relationship(
back_populates='products'
)
class ProductUpdate(SQLModel): class ProductUpdate(SQLModel):
name: str | None name: str | None
@@ -124,40 +176,46 @@ class ProductUpdate(SQLModel):
productor_id: int | None productor_id: int | None
type: ProductType | None type: ProductType | None
class ProductCreate(ProductBase): class ProductCreate(ProductBase):
pass pass
class FormBase(SQLModel): class FormBase(SQLModel):
name: str name: str
productor_id: int | None = Field(default=None, foreign_key="productor.id") productor_id: int | None = Field(default=None, foreign_key='productor.id')
referer_id: int | None = Field(default=None, foreign_key="user.id") referer_id: int | None = Field(default=None, foreign_key='user.id')
season: str season: str
start: datetime.date start: datetime.date
end: datetime.date end: datetime.date
minimum_shipment_value: float | None minimum_shipment_value: float | None
visible: bool
class FormPublic(FormBase): class FormPublic(FormBase):
id: int id: int
productor: ProductorPublic | None productor: ProductorPublic | None
referer: User | None referer: User | None
shipments: list["ShipmentPublic"] = [] shipments: list['ShipmentPublic'] = Field(default_factory=list)
class Form(FormBase, table=True): class Form(FormBase, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
productor: Optional['Productor'] = Relationship() productor: Optional['Productor'] = Relationship()
referer: Optional['User'] = Relationship() referer: Optional['User'] = Relationship()
shipments: list["Shipment"] = Relationship( shipments: list['Shipment'] = Relationship(
back_populates="form", back_populates='form',
cascade_delete=True, cascade_delete=True,
sa_relationship_kwargs={ sa_relationship_kwargs={
"order_by": "Shipment.name" 'order_by': 'Shipment.name'
}, },
) )
contracts: list["Contract"] = Relationship( contracts: list['Contract'] = Relationship(
back_populates="form", back_populates='form',
cascade_delete=True cascade_delete=True
) )
class FormUpdate(SQLModel): class FormUpdate(SQLModel):
name: str | None name: str | None
productor_id: int | None productor_id: int | None
@@ -166,36 +224,46 @@ class FormUpdate(SQLModel):
start: datetime.date | None start: datetime.date | None
end: datetime.date | None end: datetime.date | None
minimum_shipment_value: float | None minimum_shipment_value: float | None
visible: bool | None
class FormCreate(FormBase): class FormCreate(FormBase):
pass pass
class TemplateBase(SQLModel): class TemplateBase(SQLModel):
pass pass
class TemplatePublic(TemplateBase): class TemplatePublic(TemplateBase):
id: int id: int
class Template(TemplateBase, table=True): class Template(TemplateBase, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
class TemplateUpdate(SQLModel): class TemplateUpdate(SQLModel):
pass pass
class TemplateCreate(TemplateBase): class TemplateCreate(TemplateBase):
pass pass
class ChequeBase(SQLModel): class ChequeBase(SQLModel):
name: str name: str
value: str value: str
class Cheque(ChequeBase, table=True): class Cheque(ChequeBase, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
contract_id: int = Field(foreign_key="contract.id", ondelete="CASCADE") contract_id: int = Field(foreign_key='contract.id', ondelete='CASCADE')
contract: Optional["Contract"] = Relationship( contract: Optional['Contract'] = Relationship(
back_populates="cheques", back_populates='cheques',
) )
class ContractBase(SQLModel): class ContractBase(SQLModel):
firstname: str firstname: str
lastname: str lastname: str
@@ -204,105 +272,122 @@ class ContractBase(SQLModel):
payment_method: str payment_method: str
cheque_quantity: int cheque_quantity: int
class Contract(ContractBase, table=True): class Contract(ContractBase, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
form_id: int = Field( form_id: int = Field(
foreign_key="form.id", foreign_key='form.id',
nullable=False, nullable=False,
ondelete="CASCADE" ondelete='CASCADE'
) )
products: list["ContractProduct"] = Relationship( products: list['ContractProduct'] = Relationship(
back_populates="contract", back_populates='contract',
cascade_delete=True cascade_delete=True
) )
form: Optional[Form] = Relationship(back_populates="contracts") form: Form = Relationship(back_populates='contracts')
cheques: list[Cheque] = Relationship( cheques: list[Cheque] = Relationship(
back_populates="contract", back_populates='contract',
cascade_delete=True cascade_delete=True
) )
file: bytes = Field(sa_column=Column(LargeBinary)) file: bytes = Field(sa_column=Column(LargeBinary))
total_price: float | None total_price: float | None
class ContractCreate(ContractBase): class ContractCreate(ContractBase):
products: list["ContractProductCreate"] = [] products: list['ContractProductCreate'] = Field(default_factory=list)
cheques: list["Cheque"] = [] cheques: list['Cheque'] = Field(default_factory=list)
form_id: int form_id: int
class ContractUpdate(SQLModel): class ContractUpdate(SQLModel):
file: bytes file: bytes
class ContractPublic(ContractBase): class ContractPublic(ContractBase):
id: int id: int
products: list["ContractProduct"] = [] products: list['ContractProduct'] = Field(default_factory=list)
form: Form form: Form
total_price: float | None total_price: float | None
# file: bytes # file: bytes
class ContractProductBase(SQLModel): class ContractProductBase(SQLModel):
product_id: int = Field( product_id: int = Field(
foreign_key="product.id", foreign_key='product.id',
nullable=False, nullable=False,
ondelete="CASCADE" ondelete='CASCADE'
) )
shipment_id: int | None = Field( shipment_id: int | None = Field(
default=None, default=None,
foreign_key="shipment.id", foreign_key='shipment.id',
nullable=True, nullable=True,
ondelete="CASCADE" ondelete='CASCADE'
) )
quantity: float quantity: float
class ContractProduct(ContractProductBase, table=True): class ContractProduct(ContractProductBase, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
contract_id: int = Field( contract_id: int = Field(
foreign_key="contract.id", foreign_key='contract.id',
nullable=False, nullable=False,
ondelete="CASCADE" ondelete='CASCADE'
) )
contract: Optional["Contract"] = Relationship(back_populates="products") contract: Optional['Contract'] = Relationship(back_populates='products')
product: Optional["Product"] = Relationship() product: Optional['Product'] = Relationship()
shipment: Optional["Shipment"] = Relationship() shipment: Optional['Shipment'] = Relationship()
class ContractProductPublic(ContractProductBase): class ContractProductPublic(ContractProductBase):
id: int id: int
quantity: float quantity: float
contract: Contract contract: Contract
product: Product product: Product
shipment: Optional["Shipment"] shipment: Optional['Shipment']
class ContractProductCreate(ContractProductBase): class ContractProductCreate(ContractProductBase):
pass pass
class ContractProductUpdate(ContractProductBase): class ContractProductUpdate(ContractProductBase):
pass pass
class ShipmentBase(SQLModel): class ShipmentBase(SQLModel):
name: str name: str
date: datetime.date date: datetime.date
form_id: int | None = Field(default=None, foreign_key="form.id", ondelete="CASCADE") form_id: int | None = Field(
default=None,
foreign_key='form.id',
ondelete='CASCADE')
class ShipmentPublic(ShipmentBase): class ShipmentPublic(ShipmentBase):
id: int id: int
products: list[Product] = [] products: list[Product] = Field(default_factory=list)
form: Form | None form: Form | None
class Shipment(ShipmentBase, table=True): class Shipment(ShipmentBase, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
products: list[Product] = Relationship( products: list[Product] = Relationship(
back_populates="shipments", back_populates='shipments',
link_model=ShipmentProductLink, link_model=ShipmentProductLink,
sa_relationship_kwargs={ sa_relationship_kwargs={
"order_by": "Product.name" 'order_by': 'Product.name'
}, },
) )
form: Optional[Form] = Relationship(back_populates="shipments") form: Optional[Form] = Relationship(back_populates='shipments')
class ShipmentUpdate(SQLModel): class ShipmentUpdate(SQLModel):
name: str | None name: str | None
date: str | None date: datetime.date | None
product_ids: list[int] | None = [] product_ids: list[int] | None = Field(default_factory=list)
class ShipmentCreate(ShipmentBase): class ShipmentCreate(ShipmentBase):
product_ids: list[int] = [] product_ids: list[int] = Field(default_factory=list)
form_id: int form_id: int

View File

@@ -1,3 +1,3 @@
# SPDX-FileCopyrightText: 2026-present Julien Aldon <julien.aldon@wanadoo.fr> # SPDX-FileCopyrightText: 2026-present Julien Aldon <julien.aldon@wanadoo.fr>
# #
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT

View File

@@ -0,0 +1,17 @@
import logging
class ProductorServiceError(Exception):
def __init__(self, message: str):
super().__init__(message)
logging.error('ProductorService : %s', message)
class ProductorNotFoundError(ProductorServiceError):
pass
class ProductorCreateError(ProductorServiceError):
def __init__(self, message: str, field: str | None = None):
super().__init__(message)
self.field = field

View File

@@ -1,59 +1,74 @@
from fastapi import APIRouter, HTTPException, Depends, Query
import src.messages as messages import src.messages as messages
import src.models as models import src.productors.exceptions as exceptions
from src.database import get_session
from sqlmodel import Session
import src.productors.service as service import src.productors.service as service
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session
from src import models
from src.auth.auth import get_current_user from src.auth.auth import get_current_user
from src.database import get_session
router = APIRouter(prefix='/productors') router = APIRouter(prefix='/productors')
@router.get('/', response_model=list[models.ProductorPublic])
@router.get('', response_model=list[models.ProductorPublic])
def get_productors( def get_productors(
names: list[str] = Query([]), names: list[str] = Query([]),
types: list[str] = Query([]), types: list[str] = Query([]),
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
return service.get_all(session, names, types) return service.get_all(session, user, names, types)
@router.get('/{id}', response_model=models.ProductorPublic)
@router.get('/{_id}', response_model=models.ProductorPublic)
def get_productor( def get_productor(
id: int, _id: int,
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
result = service.get_one(session, id) result = service.get_one(session, _id)
if result is None: if result is None:
raise HTTPException(status_code=404, detail=messages.notfound) raise HTTPException(
status_code=404,
detail=messages.Messages.not_found('productor')
)
return result return result
@router.post('/', response_model=models.ProductorPublic)
@router.post('', response_model=models.ProductorPublic)
def create_productor( def create_productor(
productor: models.ProductorCreate, productor: models.ProductorCreate,
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
return service.create_one(session, productor) try:
result = service.create_one(session, productor)
except exceptions.ProductorCreateError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
return result
@router.put('/{id}', response_model=models.ProductorPublic)
@router.put('/{_id}', response_model=models.ProductorPublic)
def update_productor( def update_productor(
id: int, productor: models.ProductorUpdate, _id: int, productor: models.ProductorUpdate,
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
result = service.update_one(session, id, productor) try:
if result is None: result = service.update_one(session, _id, productor)
raise HTTPException(status_code=404, detail=messages.notfound) except exceptions.ProductorNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
return result return result
@router.delete('/{id}', response_model=models.ProductorPublic)
@router.delete('/{_id}', response_model=models.ProductorPublic)
def delete_productor( def delete_productor(
id: int, _id: int,
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
result = service.delete_one(session, id) try:
if result is None: result = service.delete_one(session, _id)
raise HTTPException(status_code=404, detail=messages.notfound) except exceptions.ProductorNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
return result return result

View File

@@ -1,23 +1,38 @@
import src.messages as messages
import src.productors.exceptions as exceptions
from sqlmodel import Session, select from sqlmodel import Session, select
import src.models as models from src import models
def get_all( def get_all(
session: Session, session: Session,
names: list[str], user: models.User,
names: list[str],
types: list[str] types: list[str]
) -> list[models.ProductorPublic]: ) -> list[models.ProductorPublic]:
statement = select(models.Productor) statement = select(models.Productor)\
.where(models.Productor.type.in_([r.name for r in user.roles]))\
.distinct()
if len(names) > 0: if len(names) > 0:
statement = statement.where(models.Productor.name.in_(names)) statement = statement.where(models.Productor.name.in_(names))
if len(types) > 0: if len(types) > 0:
statement = statement.where(models.Productor.type.in_(types)) statement = statement.where(models.Productor.type.in_(types))
return session.exec(statement.order_by(models.Productor.name)).all() return session.exec(statement.order_by(models.Productor.name)).all()
def get_one(session: Session, productor_id: int) -> models.ProductorPublic: def get_one(session: Session, productor_id: int) -> models.ProductorPublic:
return session.get(models.Productor, productor_id) return session.get(models.Productor, productor_id)
def create_one(session: Session, productor: models.ProductorCreate) -> models.ProductorPublic:
productor_create = productor.model_dump(exclude_unset=True, exclude="payment_methods") def create_one(
session: Session,
productor: models.ProductorCreate) -> models.ProductorPublic:
if not productor:
raise exceptions.ProductorCreateError(
messages.Messages.invalid_input(
'productor', 'input cannot be None'))
productor_create = productor.model_dump(
exclude_unset=True, exclude='payment_methods')
new_productor = models.Productor(**productor_create) new_productor = models.Productor(**productor_create)
new_productor.payment_methods = [ new_productor.payment_methods = [
@@ -32,25 +47,31 @@ def create_one(session: Session, productor: models.ProductorCreate) -> models.Pr
session.refresh(new_productor) session.refresh(new_productor)
return new_productor return new_productor
def update_one(session: Session, id: int, productor: models.ProductorUpdate) -> models.ProductorPublic:
def update_one(
session: Session,
id: int,
productor: models.ProductorUpdate) -> models.ProductorPublic:
statement = select(models.Productor).where(models.Productor.id == id) statement = select(models.Productor).where(models.Productor.id == id)
result = session.exec(statement) result = session.exec(statement)
new_productor = result.first() new_productor = result.first()
if not new_productor: if not new_productor:
return None raise exceptions.ProductorNotFoundError(
messages.Messages.not_found('productor'))
productor_updates = productor.model_dump(exclude_unset=True) productor_updates = productor.model_dump(exclude_unset=True)
if "payment_methods" in productor_updates: if 'payment_methods' in productor_updates:
new_productor.payment_methods.clear() new_productor.payment_methods.clear()
for pm in productor_updates["payment_methods"]: for pm in productor_updates['payment_methods']:
new_productor.payment_methods.append( new_productor.payment_methods.append(
models.PaymentMethod( models.PaymentMethod(
name=pm["name"], name=pm['name'],
details=pm["details"], details=pm['details'],
productor_id=id productor_id=id,
max=pm['max']
) )
) )
del productor_updates["payment_methods"] del productor_updates['payment_methods']
for key, value in productor_updates.items(): for key, value in productor_updates.items():
setattr(new_productor, key, value) setattr(new_productor, key, value)
@@ -59,12 +80,14 @@ def update_one(session: Session, id: int, productor: models.ProductorUpdate) ->
session.refresh(new_productor) session.refresh(new_productor)
return new_productor return new_productor
def delete_one(session: Session, id: int) -> models.ProductorPublic: def delete_one(session: Session, id: int) -> models.ProductorPublic:
statement = select(models.Productor).where(models.Productor.id == id) statement = select(models.Productor).where(models.Productor.id == id)
result = session.exec(statement) result = session.exec(statement)
productor = result.first() productor = result.first()
if not productor: if not productor:
return None raise exceptions.ProductorNotFoundError(
messages.Messages.not_found('productor'))
result = models.ProductorPublic.model_validate(productor) result = models.ProductorPublic.model_validate(productor)
session.delete(productor) session.delete(productor)
session.commit() session.commit()

View File

@@ -1,3 +1,3 @@
# SPDX-FileCopyrightText: 2026-present Julien Aldon <julien.aldon@wanadoo.fr> # SPDX-FileCopyrightText: 2026-present Julien Aldon <julien.aldon@wanadoo.fr>
# #
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT

View File

@@ -0,0 +1,17 @@
class ProductServiceError(Exception):
def __init__(self, message: str):
super().__init__(message)
class ProductorNotFoundError(ProductServiceError):
pass
class ProductNotFoundError(ProductServiceError):
pass
class ProductCreateError(ProductServiceError):
def __init__(self, message: str, field: str | None = None):
super().__init__(message)
self.field = field

View File

@@ -1,64 +1,83 @@
from fastapi import APIRouter, HTTPException, Depends, Query
import src.messages as messages import src.messages as messages
import src.models as models import src.products.exceptions as exceptions
from src.database import get_session
from sqlmodel import Session
import src.products.service as service import src.products.service as service
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session
from src import models
from src.auth.auth import get_current_user from src.auth.auth import get_current_user
from src.database import get_session
router = APIRouter(prefix='/products') router = APIRouter(prefix='/products')
#user=Depends(get_current_user)
@router.get('/', response_model=list[models.ProductPublic], )
@router.get('', response_model=list[models.ProductPublic], )
def get_products( def get_products(
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session), session: Session = Depends(get_session),
names: list[str] = Query([]), names: list[str] = Query([]),
types: list[str] = Query([]), types: list[str] = Query([]),
productors: list[str] = Query([]), productors: list[str] = Query([]),
): ):
return service.get_all( return service.get_all(
session, session,
names, user,
productors, names,
productors,
types, types,
) )
@router.get('/{id}', response_model=models.ProductPublic) @router.get('/{id}', response_model=models.ProductPublic)
def get_product( def get_product(
id: int, id: int,
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
result = service.get_one(session, id) result = service.get_one(session, id)
if result is None: if result is None:
raise HTTPException(status_code=404, detail=messages.notfound) raise HTTPException(status_code=404,
detail=messages.Messages.not_found('product'))
return result return result
@router.post('/', response_model=models.ProductPublic)
@router.post('', response_model=models.ProductPublic)
def create_product( def create_product(
product: models.ProductCreate, product: models.ProductCreate,
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
return service.create_one(session, product) try:
result = service.create_one(session, product)
except exceptions.ProductCreateError as error:
raise HTTPException(status_code=400, detail=str(error))
except exceptions.ProductorNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error))
return result
@router.put('/{id}', response_model=models.ProductPublic) @router.put('/{id}', response_model=models.ProductPublic)
def update_product( def update_product(
id: int, product: models.ProductUpdate, id: int, product: models.ProductUpdate,
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
result = service.update_one(session, id, product) try:
if result is None: result = service.update_one(session, id, product)
raise HTTPException(status_code=404, detail=messages.notfound) except exceptions.ProductNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error))
except exceptions.ProductorNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error))
return result return result
@router.delete('/{id}', response_model=models.ProductPublic) @router.delete('/{id}', response_model=models.ProductPublic)
def delete_product( def delete_product(
id: int, id: int,
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
result = service.delete_one(session, id) try:
if result is None: result = service.delete_one(session, id)
raise HTTPException(status_code=404, detail=messages.notfound) except exceptions.ProductNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error))
return result return result

View File

@@ -1,25 +1,46 @@
import src.messages as messages
import src.products.exceptions as exceptions
from sqlmodel import Session, select from sqlmodel import Session, select
import src.models as models from src import models
def get_all( def get_all(
session: Session, session: Session,
names: list[str], user: models.User,
names: list[str],
productors: list[str], productors: list[str],
types: list[str], types: list[str],
) -> list[models.ProductPublic]: ) -> list[models.ProductPublic]:
statement = select(models.Product) statement = select(
models.Product) .join(
models.Productor,
models.Product.productor_id == models.Productor.id) .where(
models.Productor.type.in_(
[
r.name for r in user.roles])) .distinct()
if len(names) > 0: if len(names) > 0:
statement = statement.where(models.Product.name.in_(names)) statement = statement.where(models.Product.name.in_(names))
if len(productors) > 0: if len(productors) > 0:
statement = statement.join(models.Productor).where(models.Productor.name.in_(productors)) statement = statement.where(models.Productor.name.in_(productors))
if len(types) > 0: if len(types) > 0:
statement = statement.where(models.Product.type.in_(types)) statement = statement.where(models.Product.type.in_(types))
return session.exec(statement.order_by(models.Product.name)).all() return session.exec(statement.order_by(models.Product.name)).all()
def get_one(session: Session, product_id: int) -> models.ProductPublic: def get_one(session: Session, product_id: int) -> models.ProductPublic:
return session.get(models.Product, product_id) return session.get(models.Product, product_id)
def create_one(session: Session, product: models.ProductCreate) -> models.ProductPublic:
def create_one(
session: Session,
product: models.ProductCreate) -> models.ProductPublic:
if not product:
raise exceptions.ProductCreateError(
messages.Messages.invalid_input(
'product', 'input cannot be None'))
if not session.get(models.Productor, product.productor_id):
raise exceptions.ProductorNotFoundError(
messages.Messages.not_found('productor'))
product_create = product.model_dump(exclude_unset=True) product_create = product.model_dump(exclude_unset=True)
new_product = models.Product(**product_create) new_product = models.Product(**product_create)
session.add(new_product) session.add(new_product)
@@ -27,12 +48,22 @@ def create_one(session: Session, product: models.ProductCreate) -> models.Produc
session.refresh(new_product) session.refresh(new_product)
return new_product return new_product
def update_one(session: Session, id: int, product: models.ProductUpdate) -> models.ProductPublic:
def update_one(
session: Session,
id: int,
product: models.ProductUpdate) -> models.ProductPublic:
statement = select(models.Product).where(models.Product.id == id) statement = select(models.Product).where(models.Product.id == id)
result = session.exec(statement) result = session.exec(statement)
new_product = result.first() new_product = result.first()
if not new_product: if not new_product:
return None raise exceptions.ProductNotFoundError(
messages.Messages.not_found('product'))
if product.productor_id and not session.get(
models.Productor, product.productor_id):
raise exceptions.ProductorNotFoundError(
messages.Messages.not_found('productor'))
product_updates = product.model_dump(exclude_unset=True) product_updates = product.model_dump(exclude_unset=True)
for key, value in product_updates.items(): for key, value in product_updates.items():
setattr(new_product, key, value) setattr(new_product, key, value)
@@ -42,12 +73,14 @@ def update_one(session: Session, id: int, product: models.ProductUpdate) -> mode
session.refresh(new_product) session.refresh(new_product)
return new_product return new_product
def delete_one(session: Session, id: int) -> models.ProductPublic: def delete_one(session: Session, id: int) -> models.ProductPublic:
statement = select(models.Product).where(models.Product.id == id) statement = select(models.Product).where(models.Product.id == id)
result = session.exec(statement) result = session.exec(statement)
product = result.first() product = result.first()
if not product: if not product:
return None raise exceptions.ProductNotFoundError(
messages.Messages.not_found('product'))
result = models.ProductPublic.model_validate(product) result = models.ProductPublic.model_validate(product)
session.delete(product) session.delete(product)
session.commit() session.commit()

View File

@@ -1,4 +1,5 @@
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
origins: str origins: str
@@ -16,13 +17,25 @@ class Settings(BaseSettings):
max_age: int max_age: int
debug: bool debug: bool
class Config: model_config = SettingsConfigDict(
env_file = "../.env" env_file='../.env'
)
settings = Settings() settings = Settings()
AUTH_URL = f"{settings.keycloak_server}/realms/{settings.keycloak_realm}/protocol/openid-connect/auth" AUTH_URL = (
TOKEN_URL = f"{settings.keycloak_server}/realms/{settings.keycloak_realm}/protocol/openid-connect/token" f'{settings.keycloak_server}/realms/'
ISSUER = f"{settings.keycloak_server}/realms/{settings.keycloak_realm}" f'{settings.keycloak_realm}/protocol/openid-connect/auth'
JWKS_URL = f"{ISSUER}/protocol/openid-connect/certs" )
LOGOUT_URL = f'{settings.keycloak_server}/realms/{settings.keycloak_realm}/protocol/openid-connect/logout' TOKEN_URL = (
f'{settings.keycloak_server}/realms/'
f'{settings.keycloak_realm}/protocol/openid-connect/token'
)
ISSUER = f'{settings.keycloak_server}/realms/{settings.keycloak_realm}'
JWKS_URL = f'{ISSUER}/protocol/openid-connect/certs'
LOGOUT_URL = (
f'{settings.keycloak_server}/realms/'
f'{settings.keycloak_realm}/protocol/openid-connect/logout'
)

View File

@@ -1,3 +1,3 @@
# SPDX-FileCopyrightText: 2026-present Julien Aldon <julien.aldon@wanadoo.fr> # SPDX-FileCopyrightText: 2026-present Julien Aldon <julien.aldon@wanadoo.fr>
# #
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT

View File

@@ -0,0 +1,17 @@
import logging
class ShipmentServiceError(Exception):
def __init__(self, message: str):
super().__init__(message)
logging.error('ShipmentService : %s', message)
class ShipmentNotFoundError(ShipmentServiceError):
pass
class ShipmentCreateError(ShipmentServiceError):
def __init__(self, message: str, field: str | None = None):
super().__init__(message)
self.field = field

View File

@@ -1,46 +1,111 @@
# pylint: disable=E1101
import datetime
import src.messages as messages
import src.shipments.exceptions as exceptions
from sqlmodel import Session, select from sqlmodel import Session, select
import src.models as models from src import models
def get_all( def get_all(
session: Session, session: Session,
names: list[str], user: models.User,
dates: list[str], names: list[str] = None,
forms: list[int] dates: list[str] = None,
forms: list[str] = None
) -> list[models.ShipmentPublic]: ) -> list[models.ShipmentPublic]:
statement = select(models.Shipment) statement = (
if len(names) > 0: select(models.Shipment)
.join(
models.Form,
models.Shipment.form_id == models.Form.id)
.join(
models.Productor,
models.Form.productor_id == models.Productor.id)
.where(
models.Productor.type.in_(
[r.name for r in user.roles]
)
)
.distinct()
)
if names and len(names) > 0:
statement = statement.where(models.Shipment.name.in_(names)) statement = statement.where(models.Shipment.name.in_(names))
if len(dates) > 0: if dates and len(dates) > 0:
statement = statement.where(models.Shipment.date.in_(list(map(lambda x: datetime.strptime(x, '%Y-%m-%d'), dates)))) statement = statement.where(
if len(forms) > 0: models.Shipment.date.in_(
statement = statement.join(models.Form).where(models.Form.name.in_(forms)) list(map(
lambda x: datetime.datetime.strptime(
x, '%Y-%m-%d').date(),
dates
))
)
)
if forms and len(forms) > 0:
statement = statement.where(models.Form.name.in_(forms))
return session.exec(statement.order_by(models.Shipment.name)).all() return session.exec(statement.order_by(models.Shipment.name)).all()
def get_one(session: Session, shipment_id: int) -> models.ShipmentPublic: def get_one(session: Session, shipment_id: int) -> models.ShipmentPublic:
return session.get(models.Shipment, shipment_id) return session.get(models.Shipment, shipment_id)
def create_one(session: Session, shipment: models.ShipmentCreate) -> models.ShipmentPublic:
products = session.exec(select(models.Product).where(models.Product.id.in_(shipment.product_ids))).all() def create_one(
shipment_create = shipment.model_dump(exclude_unset=True, exclude={'product_ids'}) session: Session,
shipment: models.ShipmentCreate) -> models.ShipmentPublic:
if shipment is None:
raise exceptions.ShipmentCreateError(
messages.Messages.invalid_input(
'shipment', 'input cannot be None'))
products = session.exec(
select(models.Product)
.where(
models.Product.id.in_(
shipment.product_ids
)
)
).all()
shipment_create = shipment.model_dump(
exclude_unset=True, exclude={'product_ids'}
)
new_shipment = models.Shipment(**shipment_create, products=products) new_shipment = models.Shipment(**shipment_create, products=products)
session.add(new_shipment) session.add(new_shipment)
session.commit() session.commit()
session.refresh(new_shipment) session.refresh(new_shipment)
return new_shipment return new_shipment
def update_one(session: Session, id: int, shipment: models.ShipmentUpdate) -> models.ShipmentPublic:
statement = select(models.Shipment).where(models.Shipment.id == id) def update_one(
session: Session,
_id: int,
shipment: models.ShipmentUpdate) -> models.ShipmentPublic:
if shipment is None:
raise exceptions.ShipmentCreateError(
messages.Messages.invalid_input(
'shipment', 'input cannot be None'))
statement = select(models.Shipment).where(models.Shipment.id == _id)
result = session.exec(statement) result = session.exec(statement)
new_shipment = result.first() new_shipment = result.first()
if not new_shipment: if not new_shipment:
return None raise exceptions.ShipmentNotFoundError(
messages.Messages.not_found('shipment'))
products_to_add = session.exec(select(models.Product).where(models.Product.id.in_(shipment.product_ids))).all() products_to_add = session.exec(
select(
models.Product
).where(
models.Product.id.in_(
shipment.product_ids
)
)
).all()
new_shipment.products.clear() new_shipment.products.clear()
for add in products_to_add: for add in products_to_add:
new_shipment.products.append(add) new_shipment.products.append(add)
shipment_updates = shipment.model_dump(exclude_unset=True, exclude={"product_ids"}) shipment_updates = shipment.model_dump(
exclude_unset=True, exclude={"product_ids"}
)
for key, value in shipment_updates.items(): for key, value in shipment_updates.items():
setattr(new_shipment, key, value) setattr(new_shipment, key, value)
@@ -49,13 +114,16 @@ def update_one(session: Session, id: int, shipment: models.ShipmentUpdate) -> mo
session.refresh(new_shipment) session.refresh(new_shipment)
return new_shipment return new_shipment
def delete_one(session: Session, id: int) -> models.ShipmentPublic:
statement = select(models.Shipment).where(models.Shipment.id == id) def delete_one(session: Session, _id: int) -> models.ShipmentPublic:
statement = select(models.Shipment).where(models.Shipment.id == _id)
result = session.exec(statement) result = session.exec(statement)
shipment = result.first() shipment = result.first()
if not shipment: if not shipment:
return None raise exceptions.ShipmentNotFoundError(
messages.Messages.not_found('shipment'))
result = models.ShipmentPublic.model_validate(shipment) result = models.ShipmentPublic.model_validate(shipment)
session.delete(shipment) session.delete(shipment)
session.commit() session.commit()
return result return result

View File

@@ -1,64 +1,82 @@
from fastapi import APIRouter, HTTPException, Depends, Query
import src.messages as messages import src.messages as messages
import src.models as models import src.shipments.exceptions as exceptions
from src.database import get_session
from sqlmodel import Session
import src.shipments.service as service import src.shipments.service as service
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session
from src import models
from src.auth.auth import get_current_user from src.auth.auth import get_current_user
from src.database import get_session
router = APIRouter(prefix='/shipments') router = APIRouter(prefix='/shipments')
@router.get('/', response_model=list[models.ShipmentPublic], )
@router.get('', response_model=list[models.ShipmentPublic], )
def get_shipments( def get_shipments(
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: models.User = Depends(get_current_user),
names: list[str] = Query([]), names: list[str] = Query([]),
dates: list[str] = Query([]), dates: list[str] = Query([]),
forms: list[str] = Query([]), forms: list[str] = Query([]),
): ):
return service.get_all( return service.get_all(
session, session,
user,
names, names,
dates, dates,
forms, forms,
) )
@router.get('/{id}', response_model=models.ShipmentPublic)
@router.get('/{_id}', response_model=models.ShipmentPublic)
def get_shipment( def get_shipment(
id: int, _id: int,
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
result = service.get_one(session, id) result = service.get_one(session, _id)
if result is None: if result is None:
raise HTTPException(status_code=404, detail=messages.notfound) raise HTTPException(
status_code=404,
detail=messages.Messages.not_found('shipment')
)
return result return result
@router.post('/', response_model=models.ShipmentPublic)
@router.post('', response_model=models.ShipmentPublic)
def create_shipment( def create_shipment(
shipment: models.ShipmentCreate, shipment: models.ShipmentCreate,
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
return service.create_one(session, shipment) try:
result = service.create_one(session, shipment)
except exceptions.ShipmentCreateError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
return result
@router.put('/{id}', response_model=models.ShipmentPublic)
@router.put('/{_id}', response_model=models.ShipmentPublic)
def update_shipment( def update_shipment(
id: int, shipment: models.ShipmentUpdate, _id: int,
shipment: models.ShipmentUpdate,
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
result = service.update_one(session, id, shipment) try:
if result is None: result = service.update_one(session, _id, shipment)
raise HTTPException(status_code=404, detail=messages.notfound) except exceptions.ShipmentNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
return result return result
@router.delete('/{id}', response_model=models.ShipmentPublic)
@router.delete('/{_id}', response_model=models.ShipmentPublic)
def delete_shipment( def delete_shipment(
id: int, _id: int,
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
result = service.delete_one(session, id) try:
if result is None: result = service.delete_one(session, _id)
raise HTTPException(status_code=404, detail=messages.notfound) except exceptions.ShipmentNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
return result return result

View File

@@ -1,3 +1,3 @@
# SPDX-FileCopyrightText: 2026-present Julien Aldon <julien.aldon@wanadoo.fr> # SPDX-FileCopyrightText: 2026-present Julien Aldon <julien.aldon@wanadoo.fr>
# #
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT

View File

@@ -1,14 +1,19 @@
from sqlmodel import Session, select from sqlmodel import Session, select
import src.models as models from src import models
def get_all(session: Session) -> list[models.TemplatePublic]: def get_all(session: Session) -> list[models.TemplatePublic]:
statement = select(models.Template) statement = select(models.Template)
return session.exec(statement.order_by(models.Template.name)).all() return session.exec(statement.order_by(models.Template.name)).all()
def get_one(session: Session, template_id: int) -> models.TemplatePublic: def get_one(session: Session, template_id: int) -> models.TemplatePublic:
return session.get(models.Template, template_id) return session.get(models.Template, template_id)
def create_one(session: Session, template: models.TemplateCreate) -> models.TemplatePublic:
def create_one(
session: Session,
template: models.TemplateCreate) -> models.TemplatePublic:
template_create = template.model_dump(exclude_unset=True) template_create = template.model_dump(exclude_unset=True)
new_template = models.Template(**template_create) new_template = models.Template(**template_create)
session.add(new_template) session.add(new_template)
@@ -16,7 +21,11 @@ def create_one(session: Session, template: models.TemplateCreate) -> models.Temp
session.refresh(new_template) session.refresh(new_template)
return new_template return new_template
def update_one(session: Session, id: int, template: models.TemplateUpdate) -> models.TemplatePublic:
def update_one(
session: Session,
id: int,
template: models.TemplateUpdate) -> models.TemplatePublic:
statement = select(models.Template).where(models.Template.id == id) statement = select(models.Template).where(models.Template.id == id)
result = session.exec(statement) result = session.exec(statement)
new_template = result.first() new_template = result.first()
@@ -30,6 +39,7 @@ def update_one(session: Session, id: int, template: models.TemplateUpdate) -> mo
session.refresh(new_template) session.refresh(new_template)
return new_template return new_template
def delete_one(session: Session, id: int) -> models.TemplatePublic: def delete_one(session: Session, id: int) -> models.TemplatePublic:
statement = select(models.Template).where(models.Template.id == id) statement = select(models.Template).where(models.Template.id == id)
result = session.exec(statement) result = session.exec(statement)

View File

@@ -1,57 +1,65 @@
from fastapi import APIRouter, HTTPException, Depends
import src.messages as messages import src.messages as messages
import src.models as models
from src.database import get_session
from sqlmodel import Session
import src.templates.service as service import src.templates.service as service
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from src import models
from src.auth.auth import get_current_user from src.auth.auth import get_current_user
from src.database import get_session
router = APIRouter(prefix='/templates') router = APIRouter(prefix='/templates')
@router.get('/', response_model=list[models.TemplatePublic])
@router.get('', response_model=list[models.TemplatePublic])
def get_templates( def get_templates(
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
return service.get_all(session) return service.get_all(session)
@router.get('/{id}', response_model=models.TemplatePublic) @router.get('/{id}', response_model=models.TemplatePublic)
def get_template( def get_template(
id: int, id: int,
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
result = service.get_one(session, id) result = service.get_one(session, id)
if result is None: if result is None:
raise HTTPException(status_code=404, detail=messages.notfound) raise HTTPException(status_code=404,
detail=messages.Messages.not_found('template'))
return result return result
@router.post('/', response_model=models.TemplatePublic)
@router.post('', response_model=models.TemplatePublic)
def create_template( def create_template(
template: models.TemplateCreate, template: models.TemplateCreate,
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
return service.create_one(session, template) return service.create_one(session, template)
@router.put('/{id}', response_model=models.TemplatePublic) @router.put('/{id}', response_model=models.TemplatePublic)
def update_template( def update_template(
id: int, template: models.TemplateUpdate, id: int, template: models.TemplateUpdate,
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
result = service.update_one(session, id, template) result = service.update_one(session, id, template)
if result is None: if result is None:
raise HTTPException(status_code=404, detail=messages.notfound) raise HTTPException(status_code=404,
detail=messages.Messages.not_found('template'))
return result return result
@router.delete('/{id}', response_model=models.TemplatePublic) @router.delete('/{id}', response_model=models.TemplatePublic)
def delete_template( def delete_template(
id: int, id: int,
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
result = service.delete_one(session, id) result = service.delete_one(session, id)
if result is None: if result is None:
raise HTTPException(status_code=404, detail=messages.notfound) raise HTTPException(status_code=404,
detail=messages.Messages.not_found('template'))
return result return result

View File

@@ -1,3 +1,3 @@
# SPDX-FileCopyrightText: 2026-present Julien Aldon <julien.aldon@wanadoo.fr> # SPDX-FileCopyrightText: 2026-present Julien Aldon <julien.aldon@wanadoo.fr>
# #
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT

View File

@@ -0,0 +1,17 @@
import logging
class UserServiceError(Exception):
def __init__(self, message: str):
super().__init__(message)
logging.error('UserService : %s', message)
class UserNotFoundError(UserServiceError):
pass
class UserCreateError(UserServiceError):
def __init__(self, message: str, field: str | None = None):
super().__init__(message)
self.field = field

View File

@@ -1,5 +1,8 @@
import src.messages as messages
import src.users.exceptions as exceptions
from sqlmodel import Session, select from sqlmodel import Session, select
import src.models as models from src import models
def get_all( def get_all(
session: Session, session: Session,
@@ -13,11 +16,15 @@ def get_all(
statement = statement.where(models.User.email.in_(emails)) statement = statement.where(models.User.email.in_(emails))
return session.exec(statement.order_by(models.User.name)).all() return session.exec(statement.order_by(models.User.name)).all()
def get_one(session: Session, user_id: int) -> models.UserPublic: def get_one(session: Session, user_id: int) -> models.UserPublic:
return session.get(models.User, user_id) return session.get(models.User, user_id)
def get_or_create_roles(session: Session, role_names: list[str]) -> list[models.ContractType]:
statement = select(models.ContractType).where(models.ContractType.name.in_(role_names)) def get_or_create_roles(session: Session,
role_names: list[str]) -> list[models.ContractType]:
statement = select(models.ContractType).where(
models.ContractType.name.in_(role_names))
existing = session.exec(statement).all() existing = session.exec(statement).all()
existing_roles = {role.name for role in existing} existing_roles = {role.name for role in existing}
missing_role = set(role_names) - existing_roles missing_role = set(role_names) - existing_roles
@@ -33,8 +40,11 @@ def get_or_create_roles(session: Session, role_names: list[str]) -> list[models.
session.refresh(role) session.refresh(role)
return existing + new_roles return existing + new_roles
def get_or_create_user(session: Session, user_create: models.UserCreate): def get_or_create_user(session: Session, user_create: models.UserCreate):
statement = select(models.User).where(models.User.email == user_create.email) statement = select(
models.User).where(
models.User.email == user_create.email)
user = session.exec(statement).first() user = session.exec(statement).first()
if user: if user:
user_role_names = [r.name for r in user.roles] user_role_names = [r.name for r in user.roles]
@@ -44,11 +54,17 @@ def get_or_create_user(session: Session, user_create: models.UserCreate):
user = create_one(session, user_create) user = create_one(session, user_create)
return user return user
def get_roles(session: Session): def get_roles(session: Session):
statement = select(models.ContractType) statement = select(models.ContractType)
return session.exec(statement.order_by(models.ContractType.name)).all() return session.exec(statement.order_by(models.ContractType.name)).all()
def create_one(session: Session, user: models.UserCreate) -> models.UserPublic: def create_one(session: Session, user: models.UserCreate) -> models.UserPublic:
if user is None:
raise exceptions.UserCreateError(
messages.Messages.invalid_input(
'user', 'input cannot be None'))
new_user = models.User( new_user = models.User(
name=user.name, name=user.name,
email=user.email email=user.email
@@ -62,32 +78,38 @@ def create_one(session: Session, user: models.UserCreate) -> models.UserPublic:
session.refresh(new_user) session.refresh(new_user)
return new_user return new_user
def update_one(session: Session, id: int, user: models.UserCreate) -> models.UserPublic:
def update_one(
session: Session,
id: int,
user: models.UserCreate) -> models.UserPublic:
if user is None:
raise exceptions.UserCreateError(
messages.s.invalid_input(
'user', 'input cannot be None'))
statement = select(models.User).where(models.User.id == id) statement = select(models.User).where(models.User.id == id)
result = session.exec(statement) result = session.exec(statement)
new_user = result.first() new_user = result.first()
if not new_user: if not new_user:
return None raise exceptions.UserNotFoundError(f'User {id} not found')
new_user.email = user.email
user_updates = user.model_dump(exclude="role_names") new_user.name = user.name
for key, value in user_updates.items():
setattr(new_user, key, value)
roles = get_or_create_roles(session, user.role_names) roles = get_or_create_roles(session, user.role_names)
new_user.roles = roles new_user.roles = roles
session.add(new_user) session.add(new_user)
session.commit() session.commit()
session.refresh(new_user) session.refresh(new_user)
return new_user return new_user
def delete_one(session: Session, id: int) -> models.UserPublic: def delete_one(session: Session, id: int) -> models.UserPublic:
statement = select(models.User).where(models.User.id == id) statement = select(models.User).where(models.User.id == id)
result = session.exec(statement) result = session.exec(statement)
user = result.first() user = result.first()
if not user: if not user:
return None raise exceptions.UserNotFoundError(f'User {id} not found')
result = models.UserPublic.model_validate(user) result = models.UserPublic.model_validate(user)
session.delete(user) session.delete(user)
session.commit() session.commit()
return result return result

View File

@@ -1,14 +1,16 @@
from fastapi import APIRouter, HTTPException, Depends, Query
import src.messages as messages import src.messages as messages
import src.models as models import src.users.exceptions as exceptions
from src.database import get_session
from sqlmodel import Session
import src.users.service as service import src.users.service as service
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session
from src import models
from src.auth.auth import get_current_user from src.auth.auth import get_current_user
from src.database import get_session
router = APIRouter(prefix='/users') router = APIRouter(prefix='/users')
@router.get('/', response_model=list[models.UserPublic])
@router.get('', response_model=list[models.UserPublic])
def get_users( def get_users(
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
@@ -21,6 +23,7 @@ def get_users(
emails, emails,
) )
@router.get('/roles', response_model=list[models.ContractType]) @router.get('/roles', response_model=list[models.ContractType])
def get_roles( def get_roles(
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
@@ -28,44 +31,57 @@ def get_roles(
): ):
return service.get_roles(session) return service.get_roles(session)
@router.get('/{id}', response_model=models.UserPublic) @router.get('/{id}', response_model=models.UserPublic)
def get_users( def get_users(
id: int, id: int,
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
result = service.get_one(session, id) result = service.get_one(session, id)
if result is None: if result is None:
raise HTTPException(status_code=404, detail=messages.notfound) raise HTTPException(status_code=404,
detail=messages.Messages.not_found('user'))
return result return result
@router.post('/', response_model=models.UserPublic)
@router.post('', response_model=models.UserPublic)
def create_user( def create_user(
user: models.UserCreate, user: models.UserCreate,
logged_user: models.User = Depends(get_current_user), logged_user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
return service.create_one(session, user) try:
user = service.create_one(session, user)
except exceptions.UserCreateError as error:
raise HTTPException(status_code=400, detail=str(error))
return user
@router.put('/{id}', response_model=models.UserPublic) @router.put('/{id}', response_model=models.UserPublic)
def update_user( def update_user(
id: int, id: int,
user: models.UserUpdate, user: models.UserUpdate,
logged_user: models.User = Depends(get_current_user), logged_user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
result = service.update_one(session, id, user) try:
if result is None: result = service.update_one(session, id, user)
raise HTTPException(status_code=404, detail=messages.notfound) except exceptions.UserNotFoundError as error:
raise HTTPException(status_code=404,
detail=messages.Messages.not_found('user'))
return result return result
@router.delete('/{id}', response_model=models.UserPublic) @router.delete('/{id}', response_model=models.UserPublic)
def delete_user( def delete_user(
id: int, id: int,
user: models.User = Depends(get_current_user), user: models.User = Depends(get_current_user),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
result = service.delete_one(session, id) try:
if result is None: result = service.delete_one(session, id)
raise HTTPException(status_code=404, detail=messages.notfound) except exceptions.UserNotFoundError as error:
raise HTTPException(status_code=404,
detail=messages.Messages.not_found('user'))
return result return result

62
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,62 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.pool import StaticPool
from sqlmodel import Session, SQLModel, create_engine
from src import models
from src.auth.auth import get_current_user
from src.database import get_session
from src.main import app
from .fixtures import *
@pytest.fixture
def mock_session(mocker):
session = mocker.Mock()
def override():
return session
app.dependency_overrides[get_session] = override
yield session
app.dependency_overrides.clear()
@pytest.fixture
def mock_user():
user = models.User(id=1, name='test user', email='test@user.com')
def override():
return user
app.dependency_overrides[get_current_user] = override
yield user
app.dependency_overrides.clear()
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture(name='session')
def session_fixture():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SQLModel.metadata.create_all(engine)
connection = engine.connect()
transaction = connection.begin()
session = Session(bind=connection)
try:
yield session
finally:
transaction.rollback()
session.close()
connection.close()
engine.dispose()

View File

@@ -0,0 +1,65 @@
import tests.factories.contracts as contract_factory
import tests.factories.products as product_factory
from src import models
def contract_product_factory(**kwargs):
contract = contract_factory.contract_factory(id=1)
product = product_factory.product_public_factory(
id=1, type=models.ProductType.RECCURENT)
data = dict(
product_id=1,
shipment_id=1,
quantity=1,
contract_id=1,
product=product,
contract=contract
)
data.update(kwargs)
return models.ContractProduct(**data)
def contract_product_public_factory(**kwargs):
contract = contract_factory.contract_factory(id=1)
product = product_factory.product_public_factory(id=1)
data = dict(
id=1,
product_id=1,
shipment_id=None,
contract=contract,
product=product,
shipment=None,
quantity=1
)
data.update(kwargs)
return models.ContractProductPublic(**data)
def contract_product_create_factory(**kwargs):
data = dict(
product_id=1,
shipment_id=1,
quantity=1,
)
data.update(kwargs)
return models.ContractProductCreate(**data)
def contract_product_update_factory(**kwargs):
data = dict(
product_id=1,
shipment_id=1,
quantity=1,
)
data.update(kwargs)
return models.ContractProductUpdate(**data)
def contract_product_body_factory(**kwargs):
data = dict(
product_id=1,
shipment_id=1,
quantity=1,
)
data.update(kwargs)
return data

View File

@@ -0,0 +1,82 @@
from src import models
from .forms import form_factory
def contract_factory(**kwargs):
data = dict(
id=1,
firstname="test",
lastname="test",
email="test@test.test",
phone="00000000",
payment_method="cheque",
cheque_quantity=1,
form_id=1,
products=[],
cheques=[],
)
data.update(kwargs)
return models.Contract(**data)
def contract_public_factory(**kwargs):
data = dict(
id=1,
firstname="test",
lastname="test",
email="test@test.test",
phone="00000000",
payment_method="cheque",
cheque_quantity=1,
total_price=10,
products=[],
form=form_factory()
)
data.update(kwargs)
return models.ContractPublic(**data)
def contract_create_factory(**kwargs):
data = dict(
firstname="test",
lastname="test",
email="test@test.test",
phone="00000000",
payment_method="cheque",
cheque_quantity=1,
products=[],
cheques=[],
form_id=1,
)
data.update(kwargs)
return models.ContractCreate(**data)
def contract_update_factory(**kwargs):
data = dict(
firstname="test",
lastname="test",
email="test@test.test",
phone="00000000",
payment_method="cheque",
cheque_quantity=1,
)
data.update(kwargs)
return models.ContractUpdate(**data)
def contract_body_factory(**kwargs):
data = dict(
firstname="test",
lastname="test",
email="test@test.test",
phone="00000000",
payment_method="cheque",
cheque_quantity=1,
products=[],
cheques=[],
form_id=1
)
data.update(kwargs)
return data

View File

@@ -0,0 +1,89 @@
import datetime
from src import models
from .productors import productor_public_factory
from .users import user_factory
def form_factory(**kwargs):
data = dict(
id=1,
name='form 1',
productor_id=1,
referer_id=1,
season='hiver-2026',
start=datetime.date(2025, 10, 10),
end=datetime.date(2025, 10, 10),
minimum_shipment_value=0,
visible=True,
referer=user_factory(),
shipments=[],
productor=productor_public_factory(),
)
data.update(kwargs)
return models.Form(**data)
def form_body_factory(**kwargs):
data = dict(
name='form 1',
productor_id=1,
referer_id=1,
season='hiver-2026',
start='2025-10-10',
end='2025-10-10',
minimum_shipment_value=0,
visible=True
)
data.update(kwargs)
return data
def form_create_factory(**kwargs):
data = dict(
name='form 1',
productor_id=1,
referer_id=1,
season='hiver-2026',
start=datetime.date(2025, 10, 10),
end=datetime.date(2025, 10, 10),
minimum_shipment_value=0,
visible=True
)
data.update(kwargs)
return models.FormCreate(**data)
def form_update_factory(**kwargs):
data = dict(
name='form 1',
productor_id=1,
referer_id=1,
season='hiver-2026',
start=datetime.date(2025, 10, 10),
end=datetime.date(2025, 10, 10),
minimum_shipment_value=0,
visible=True
)
data.update(kwargs)
return models.FormUpdate(**data)
def form_public_factory(form=None, shipments=[], **kwargs):
data = dict(
id=1,
name='form 1',
productor_id=1,
referer_id=1,
season='hiver-2026',
start=datetime.date(2025, 10, 10),
end=datetime.date(2025, 10, 10),
minimum_shipment_value=0,
visible=True,
referer=user_factory(),
shipments=[],
productor=productor_public_factory(),
)
data.update(kwargs)
return models.FormPublic(**data)

View File

@@ -0,0 +1,64 @@
from src import models
def productor_factory(**kwargs):
data = dict(
id=1,
name="test productor",
address="test address",
type="test type"
)
data.update(kwargs)
return models.Productor(**data)
def productor_public_factory(**kwargs):
data = dict(
id=1,
name="test productor",
address="test address",
type="test type",
products=[],
payment_methods=[],
)
data.update(kwargs)
return models.ProductorPublic(**data)
def productor_create_factory(**kwargs):
data = dict(
id=1,
name="test productor",
address="test address",
type="test type",
products=[],
payment_methods=[],
)
data.update(kwargs)
return models.ProductorCreate(**data)
def productor_update_factory(**kwargs):
data = dict(
id=1,
name="test productor",
address="test address",
type="test type",
products=[],
payment_methods=[],
)
data.update(kwargs)
return models.ProductorUpdate(**data)
def productor_body_factory(**kwargs):
data = dict(
id=1,
name="test productor",
address="test address",
type="test type",
products=[],
payment_methods=[],
)
data.update(kwargs)
return data

View File

@@ -0,0 +1,68 @@
from src import models
from .productors import productor_factory
def product_body_factory(**kwargs):
data = dict(
name='product test 1',
unit=models.Unit.PIECE,
price=10.2,
price_kg=20.4,
quantity=500,
quantity_unit='g',
type=models.ProductType.OCCASIONAL,
productor_id=1,
)
data.update(kwargs)
return data
def product_create_factory(**kwargs):
data = dict(
name='product test 1',
unit=models.Unit.PIECE,
price=10.2,
price_kg=20.4,
quantity=500,
quantity_unit='g',
type=models.ProductType.OCCASIONAL,
productor_id=1,
)
data.update(kwargs)
return models.ProductCreate(**data)
def product_update_factory(**kwargs):
data = dict(
name='product test 1',
unit=models.Unit.PIECE,
price=10.2,
price_kg=20.4,
quantity=500,
quantity_unit='g',
type=models.ProductType.OCCASIONAL,
productor_id=1,
)
data.update(kwargs)
return models.ProductUpdate(**data)
def product_public_factory(productor=None, shipments=[], **kwargs):
if productor is None:
productor = productor_factory()
data = dict(
id=1,
name='product test 1',
unit=models.Unit.PIECE,
price=10.2,
price_kg=20.4,
quantity=500,
quantity_unit='g',
type=models.ProductType.OCCASIONAL,
productor_id=1,
productor=productor,
shipments=shipments,
)
data.update(kwargs)
return models.ProductPublic(**data)

View File

@@ -0,0 +1,59 @@
import datetime
from src import models
def shipment_factory(**kwargs):
data = dict(
id=1,
name="test shipment",
date=datetime.date(2025, 10, 10),
form_id=1,
)
data.update(kwargs)
return models.Shipment(**data)
def shipment_public_factory(**kwargs):
data = dict(
id=1,
name="test shipment",
date=datetime.date(2025, 10, 10),
form_id=1,
products=[],
form=models.Form(id=1, name="test")
)
data.update(kwargs)
return models.ShipmentPublic(**data)
def shipment_create_factory(**kwargs):
data = dict(
name="test shipment",
form_id=1,
date='2025-10-10',
product_ids=[],
)
data.update(kwargs)
return models.ShipmentCreate(**data)
def shipment_update_factory(**kwargs):
data = dict(
name="test shipment",
form_id=1,
date='2025-10-10',
product_ids=[],
)
data.update(kwargs)
return models.ShipmentUpdate(**data)
def shipment_body_factory(**kwargs):
data = dict(
name="test shipment",
form_id=1,
date="2025-10-10",
)
data.update(kwargs)
return data

View File

@@ -0,0 +1,53 @@
from src import models
def user_factory(**kwargs):
data = dict(
id=1,
name="test user",
email="test.test@test.test",
roles=[]
)
data.update(kwargs)
return models.User(**data)
def user_public_factory(**kwargs):
data = dict(
id=1,
name="test user",
email="test.test@test.test",
roles=[]
)
data.update(kwargs)
return models.UserPublic(**data)
def user_create_factory(**kwargs):
data = dict(
name="test user",
email="test.test@test.test",
role_names=[],
)
data.update(kwargs)
return models.UserCreate(**data)
def user_update_factory(**kwargs):
data = dict(
name="test user",
email="test.test@test.test",
role_names=[],
)
data.update(kwargs)
return models.UserUpdate(**data)
def user_body_factory(**kwargs):
data = dict(
name="test user",
email="test.test@test.test",
role_names=[],
)
data.update(kwargs)
return data

184
backend/tests/fixtures.py Normal file
View File

@@ -0,0 +1,184 @@
import datetime
import pytest
import src.forms.service as forms_service
import src.productors.service as productors_service
import src.products.service as products_service
import src.shipments.service as shipments_service
import src.users.service as users_service
import tests.factories.forms as forms_factory
import tests.factories.productors as productors_factory
import tests.factories.products as products_factory
import tests.factories.shipments as shipments_factory
import tests.factories.users as users_factory
from sqlmodel import Session
from src import models
@pytest.fixture
def productor(session: Session) -> models.ProductorPublic:
productor = productors_service.create_one(
session,
productors_factory.productor_create_factory(
name='test productor',
type='Légumineuses',
)
)
return productor
@pytest.fixture
def productors(session: Session) -> models.ProductorPublic:
productors = [
productors_service.create_one(
session,
productors_factory.productor_create_factory(
name='test productor 1',
type='Légumineuses',
)
),
productors_service.create_one(
session,
productors_factory.productor_create_factory(
name='test productor 2',
type='Légumes',
)
)
]
return productors
@pytest.fixture
def products(session: Session,
productor: models.ProductorPublic) -> list[models.ProductPublic]:
products = [
products_service.create_one(
session,
products_factory.product_create_factory(
name='product 1 occasionnal',
type=models.ProductType.OCCASIONAL,
productor_id=productor.id
)
),
products_service.create_one(
session,
products_factory.product_create_factory(
name='product 2 recurrent',
type=models.ProductType.RECCURENT,
productor_id=productor.id
)
),
]
return products
@pytest.fixture
def user(session: Session) -> models.UserPublic:
user = users_service.create_one(
session,
users_factory.user_create_factory(
name='test user',
email='test@test.com',
role_names=['Légumineuses']
)
)
return user
@pytest.fixture
def users(session: Session) -> list[models.UserPublic]:
users = [
users_service.create_one(
session,
users_factory.user_create_factory(
name='test user 1 (admin)',
email='test1@test.com',
role_names=[
'Légumineuses',
'Légumes',
'Oeufs',
'Porc-Agneau',
'Vin',
'Fruits'])),
users_service.create_one(
session,
users_factory.user_create_factory(
name='test user 2',
email='test2@test.com',
role_names=['Légumineuses'])),
users_service.create_one(
session,
users_factory.user_create_factory(
name='test user 3',
email='test3@test.com',
role_names=['Porc-Agneau']))]
return users
@pytest.fixture
def referer(session: Session) -> models.UserPublic:
referer = users_service.create_one(
session,
users_factory.user_create_factory(
name='test referer',
email='test@test.com',
role_names=['Légumineuses'],
)
)
return referer
@pytest.fixture
def shipments(session: Session,
forms: list[models.FormPublic],
products: list[models.ProductPublic]):
shipments = [
shipments_service.create_one(
session,
shipments_factory.shipment_create_factory(
name='test shipment 1',
date=datetime.date(2025, 10, 10),
form_id=forms[0].id,
product_ids=[p.id for p in products]
)
),
shipments_service.create_one(
session,
shipments_factory.shipment_create_factory(
name='test shipment 2',
date=datetime.date(2025, 11, 10),
form_id=forms[0].id,
product_ids=[p.id for p in products]
)
),
]
return shipments
@pytest.fixture
def forms(
session: Session,
productor: models.ProductorPublic,
referer: models.UserPublic
) -> list[models.FormPublic]:
forms = [
forms_service.create_one(
session,
forms_factory.form_create_factory(
name='test form 1',
productor_id=productor.id,
referer_id=referer.id,
season='test season 1',
)
),
forms_service.create_one(
session,
forms_factory.form_create_factory(
name='test form 2',
productor_id=productor.id,
referer_id=referer.id,
season='test season 2',
)
)
]
return forms

View File

@@ -0,0 +1,207 @@
import src.contracts.service as service
import tests.factories.contract_products as contract_products_factory
import tests.factories.contracts as contract_factory
import tests.factories.forms as form_factory
from fastapi.exceptions import HTTPException
from src import models
from src.auth.auth import get_current_user
from src.main import app
class TestContracts:
def test_get_all(self, client, mocker, mock_session, mock_user):
mock_results = [
contract_factory.contract_public_factory(id=1),
contract_factory.contract_public_factory(id=2),
contract_factory.contract_public_factory(id=3),
]
mock = mocker.patch.object(
service,
'get_all',
return_value=mock_results
)
response = client.get('/api/contracts')
response_data = response.json()
assert response.status_code == 200
assert response_data[0]['id'] == 1
assert len(response_data) == len(mock_results)
mock.assert_called_once_with(
mock_session,
mock_user,
[],
)
def test_get_all_filters(self, client, mocker, mock_session, mock_user):
mock_results = [
contract_factory.contract_public_factory(id=2),
]
mock = mocker.patch.object(
service,
'get_all',
return_value=mock_results
)
response = client.get('/api/contracts?forms=form test')
response_data = response.json()
assert response.status_code == 200
assert response_data[0]['id'] == 2
assert len(response_data) == len(mock_results)
mock.assert_called_once_with(
mock_session,
mock_user,
['form test'],
)
def test_get_all_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.contracts.service.get_all')
response = client.get('/api/contracts')
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()
def test_get_one(self, client, mocker, mock_session, mock_user):
mock_result = contract_factory.contract_public_factory(id=2)
mock = mocker.patch.object(
service,
'get_one',
return_value=mock_result
)
mocker.patch.object(
service,
'is_allowed',
return_value=True
)
response = client.get('/api/contracts/2')
response_data = response.json()
assert response.status_code == 200
assert response_data['id'] == 2
mock.assert_called_once_with(
mock_session,
2
)
def test_get_one_notfound(self, client, mocker, mock_session, mock_user):
mock_result = None
mock = mocker.patch.object(
service,
'get_one',
return_value=mock_result
)
mocker.patch.object(
service,
'is_allowed',
return_value=True
)
response = client.get('/api/contracts/2')
assert response.status_code == 404
mock.assert_called_once_with(
mock_session,
2
)
def test_get_one_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.contracts.service.get_one')
response = client.get('/api/contracts/2')
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()
def test_delete_one(self, client, mocker, mock_session, mock_user):
contract_result = contract_factory.contract_public_factory()
mock = mocker.patch.object(
service,
'delete_one',
return_value=contract_result
)
mocker.patch.object(
service,
'is_allowed',
return_value=True
)
response = client.delete('/api/contracts/2')
assert response.status_code == 200
mock.assert_called_once_with(
mock_session,
2,
)
def test_delete_one_notfound(
self,
client,
mocker,
mock_session,
mock_user
):
contract_result = None
mock = mocker.patch.object(
service,
'delete_one',
return_value=contract_result
)
mocker.patch.object(
service,
'is_allowed',
return_value=True
)
response = client.delete('/api/contracts/2')
assert response.status_code == 404
mock.assert_called_once_with(
mock_session,
2,
)
def test_delete_one_unauthorized(
self,
client,
mocker,
mock_session,
mock_user
):
def unauthorized():
raise HTTPException(status_code=401)
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.contracts.service.delete_one')
response = client.delete('/api/contracts/2')
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()

View File

@@ -0,0 +1,355 @@
import src.forms.exceptions as forms_exceptions
import src.forms.service as service
import src.messages as messages
import tests.factories.forms as form_factory
from fastapi.exceptions import HTTPException
from src import models
from src.auth.auth import get_current_user
from src.main import app
class TestForms:
def test_get_all(self, client, mocker, mock_session, mock_user):
mock_results = [
form_factory.form_public_factory(name="test 1", id=1),
form_factory.form_public_factory(name="test 2", id=2),
form_factory.form_public_factory(name="test 3", id=3),
]
mock = mocker.patch.object(
service,
'get_all',
return_value=mock_results
)
response = client.get('/api/forms/referents')
response_data = response.json()
assert response.status_code == 200
assert response_data[0]['id'] == 1
assert len(response_data) == len(mock_results)
mock.assert_called_once_with(
mock_session,
[],
[],
False,
mock_user,
)
def test_get_all_filters(self, client, mocker, mock_session, mock_user):
mock_results = [
form_factory.form_public_factory(name="test 2", id=2),
]
mock = mocker.patch.object(
service,
'get_all',
return_value=mock_results
)
response = client.get(
'/api/forms/referents?current_season=true&seasons=hiver-2025&productors=test productor')
response_data = response.json()
assert response.status_code == 200
assert response_data[0]['id'] == 2
assert len(response_data) == len(mock_results)
mock.assert_called_once_with(
mock_session,
['hiver-2025'],
['test productor'],
True,
mock_user,
)
def test_get_all_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.forms.service.get_all')
response = client.get('/api/forms/referents')
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()
def test_get_one(self, client, mocker, mock_session, mock_user):
mock_result = form_factory.form_public_factory(name="test 2", id=2)
mock = mocker.patch.object(
service,
'get_one',
return_value=mock_result
)
response = client.get('/api/forms/2')
response_data = response.json()
assert response.status_code == 200
assert response_data['id'] == 2
mock.assert_called_once_with(
mock_session,
2
)
def test_get_one_notfound(self, client, mocker, mock_session, mock_user):
mock_result = None
mock = mocker.patch.object(
service,
'get_one',
return_value=mock_result
)
response = client.get('/api/forms/2')
response_data = response.json()
assert response.status_code == 404
mock.assert_called_once_with(
mock_session,
2
)
def test_create_one(self, client, mocker, mock_session, mock_user):
form_body = form_factory.form_body_factory(name='test form create')
form_create = form_factory.form_create_factory(name='test form create')
form_result = form_factory.form_public_factory(name='test form create')
mock = mocker.patch.object(
service,
'create_one',
return_value=form_result
)
response = client.post('/api/forms', json=form_body)
response_data = response.json()
assert response.status_code == 200
assert response_data['name'] == 'test form create'
mock.assert_called_once_with(
mock_session,
form_create
)
def test_create_one_referer_notfound(
self, client, mocker, mock_session, mock_user):
form_body = form_factory.form_body_factory(
name='test form create', referer_id=12312)
form_create = form_factory.form_create_factory(
name='test form create', referer_id=12312)
mock = mocker.patch.object(
service, 'create_one', side_effect=forms_exceptions.UserNotFoundError(
messages.Messages.not_found('referer')))
response = client.post('/api/forms', json=form_body)
response_data = response.json()
assert response.status_code == 404
mock.assert_called_once_with(
mock_session,
form_create
)
def test_create_one_productor_notfound(
self, client, mocker, mock_session, mock_user):
form_body = form_factory.form_body_factory(
name='test form create', productor_id=1231)
form_create = form_factory.form_create_factory(
name='test form create', productor_id=1231)
mock = mocker.patch.object(
service, 'create_one', side_effect=forms_exceptions.ProductorNotFoundError(
messages.Messages.not_found('productor')))
response = client.post('/api/forms', json=form_body)
response_data = response.json()
assert response.status_code == 404
mock.assert_called_once_with(
mock_session,
form_create
)
def test_create_one_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
form_body = form_factory.form_body_factory(name='test form create')
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.forms.service.create_one')
response = client.post('/api/forms', json=form_body)
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()
def test_update_one(self, client, mocker, mock_session, mock_user):
form_body = form_factory.form_body_factory(name='test form update')
form_update = form_factory.form_update_factory(name='test form update')
form_result = form_factory.form_public_factory(name='test form update')
mock = mocker.patch.object(
service,
'update_one',
return_value=form_result
)
response = client.put('/api/forms/2', json=form_body)
response_data = response.json()
assert response.status_code == 200
assert response_data['name'] == 'test form update'
mock.assert_called_once_with(
mock_session,
2,
form_update
)
def test_update_one_notfound(
self,
client,
mocker,
mock_session,
mock_user):
form_body = form_factory.form_body_factory(name='test form update')
form_update = form_factory.form_update_factory(name='test form update')
mock = mocker.patch.object(
service, 'update_one', side_effect=forms_exceptions.FormNotFoundError(
messages.Messages.not_found('form')))
response = client.put('/api/forms/2', json=form_body)
response_data = response.json()
assert response.status_code == 404
mock.assert_called_once_with(
mock_session,
2,
form_update
)
def test_update_one_referer_notfound(
self, client, mocker, mock_session, mock_user):
form_body = form_factory.form_body_factory(name='test form update')
form_update = form_factory.form_update_factory(name='test form update')
mock = mocker.patch.object(
service, 'update_one', side_effect=forms_exceptions.UserNotFoundError(
messages.Messages.not_found('referer')))
response = client.put('/api/forms/2', json=form_body)
response_data = response.json()
assert response.status_code == 404
mock.assert_called_once_with(
mock_session,
2,
form_update
)
def test_update_one_productor_notfound(
self, client, mocker, mock_session, mock_user):
form_body = form_factory.form_body_factory(name='test form update')
form_update = form_factory.form_update_factory(name='test form update')
mock = mocker.patch.object(
service, 'update_one', side_effect=forms_exceptions.ProductorNotFoundError(
messages.Messages.not_found('productor')))
response = client.put('/api/forms/2', json=form_body)
response_data = response.json()
assert response.status_code == 404
mock.assert_called_once_with(
mock_session,
2,
form_update
)
def test_update_one_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
form_body = form_factory.form_body_factory(name='test form update')
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.forms.service.update_one')
response = client.put('/api/forms/2', json=form_body)
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()
def test_delete_one(self, client, mocker, mock_session, mock_user):
form_result = form_factory.form_public_factory(name='test form delete')
mock = mocker.patch.object(
service,
'delete_one',
return_value=form_result
)
response = client.delete('/api/forms/2')
response_data = response.json()
assert response.status_code == 200
assert response_data['name'] == 'test form delete'
mock.assert_called_once_with(
mock_session,
2,
)
def test_delete_one_notfound(
self,
client,
mocker,
mock_session,
mock_user):
form_result = None
mock = mocker.patch.object(
service, 'delete_one', side_effect=forms_exceptions.FormNotFoundError(
messages.Messages.not_found('form')))
response = client.delete('/api/forms/2')
response_data = response.json()
assert response.status_code == 404
mock.assert_called_once_with(
mock_session,
2,
)
def test_delete_one_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.forms.service.delete_one')
response = client.delete('/api/forms/2')
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()

View File

@@ -0,0 +1,309 @@
import src.messages as messages
import src.productors.exceptions as exceptions
import src.productors.service as service
import tests.factories.productors as productor_factory
from fastapi.exceptions import HTTPException
from src import models
from src.auth.auth import get_current_user
from src.main import app
class TestProductors:
def test_get_all(self, client, mocker, mock_session, mock_user):
mock_results = [
productor_factory.productor_public_factory(name="test 1", id=1),
productor_factory.productor_public_factory(name="test 2", id=2),
productor_factory.productor_public_factory(name="test 3", id=3),
]
mock = mocker.patch.object(
service,
'get_all',
return_value=mock_results
)
response = client.get('/api/productors')
response_data = response.json()
assert response.status_code == 200
assert response_data[0]['id'] == 1
assert len(response_data) == len(mock_results)
mock.assert_called_once_with(
mock_session,
mock_user,
[],
[],
)
def test_get_all_filters(self, client, mocker, mock_session, mock_user):
mock_results = [
productor_factory.productor_public_factory(name="test 2", id=2),
]
mock = mocker.patch.object(
service,
'get_all',
return_value=mock_results
)
response = client.get(
'/api/productors?types=Légumineuses&names=test 2')
response_data = response.json()
assert response.status_code == 200
assert response_data[0]['id'] == 2
assert len(response_data) == len(mock_results)
mock.assert_called_once_with(
mock_session,
mock_user,
['test 2'],
['Légumineuses'],
)
def test_get_all_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.productors.service.get_all')
response = client.get('/api/productors')
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()
def test_get_one(self, client, mocker, mock_session, mock_user):
mock_result = productor_factory.productor_public_factory(
name="test 2", id=2)
mock = mocker.patch.object(
service,
'get_one',
return_value=mock_result
)
response = client.get('/api/productors/2')
response_data = response.json()
assert response.status_code == 200
assert response_data['id'] == 2
mock.assert_called_once_with(
mock_session,
2
)
def test_get_one_notfound(self, client, mocker, mock_session, mock_user):
mock_result = None
mock = mocker.patch.object(
service,
'get_one',
return_value=mock_result
)
response = client.get('/api/productors/2')
response_data = response.json()
assert response.status_code == 404
mock.assert_called_once_with(
mock_session,
2
)
def test_get_one_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.productors.service.get_one')
response = client.get('/api/productors/2')
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()
def test_create_one(self, client, mocker, mock_session, mock_user):
productor_body = productor_factory.productor_body_factory(
name='test productor create')
productor_create = productor_factory.productor_create_factory(
name='test productor create')
productor_result = productor_factory.productor_public_factory(
name='test productor create')
mock = mocker.patch.object(
service,
'create_one',
return_value=productor_result
)
response = client.post('/api/productors', json=productor_body)
response_data = response.json()
assert response.status_code == 200
assert response_data['name'] == 'test productor create'
mock.assert_called_once_with(
mock_session,
productor_create
)
def test_create_one_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
productor_body = productor_factory.productor_body_factory(
name='test productor create')
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.productors.service.create_one')
response = client.post('/api/productors', json=productor_body)
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()
def test_update_one(self, client, mocker, mock_session, mock_user):
productor_body = productor_factory.productor_body_factory(
name='test productor update')
productor_update = productor_factory.productor_update_factory(
name='test productor update')
productor_result = productor_factory.productor_public_factory(
name='test productor update')
mock = mocker.patch.object(
service,
'update_one',
return_value=productor_result
)
response = client.put('/api/productors/2', json=productor_body)
response_data = response.json()
assert response.status_code == 200
assert response_data['name'] == 'test productor update'
mock.assert_called_once_with(
mock_session,
2,
productor_update
)
def test_update_one_notfound(
self,
client,
mocker,
mock_session,
mock_user):
productor_body = productor_factory.productor_body_factory(
name='test productor update')
productor_update = productor_factory.productor_update_factory(
name='test productor update')
productor_result = None
mock = mocker.patch.object(
service, 'update_one', side_effect=exceptions.ProductorNotFoundError(
messages.Messages.not_found('productor')))
response = client.put('/api/productors/2', json=productor_body)
response_data = response.json()
assert response.status_code == 404
mock.assert_called_once_with(
mock_session,
2,
productor_update
)
def test_update_one_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
productor_body = productor_factory.productor_body_factory(
name='test productor update')
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.productors.service.update_one')
response = client.put('/api/productors/2', json=productor_body)
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()
def test_delete_one(self, client, mocker, mock_session, mock_user):
productor_result = productor_factory.productor_public_factory(
name='test productor delete')
mock = mocker.patch.object(
service,
'delete_one',
return_value=productor_result
)
response = client.delete('/api/productors/2')
response_data = response.json()
assert response.status_code == 200
assert response_data['name'] == 'test productor delete'
mock.assert_called_once_with(
mock_session,
2,
)
def test_delete_one_notfound(
self,
client,
mocker,
mock_session,
mock_user):
productor_result = None
mock = mocker.patch.object(
service, 'delete_one', side_effect=exceptions.ProductorNotFoundError(
messages.Messages.not_found('productor')))
response = client.delete('/api/productors/2')
response_data = response.json()
assert response.status_code == 404
mock.assert_called_once_with(
mock_session,
2,
)
def test_delete_one_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
productor_body = productor_factory.productor_body_factory(
name='test productor delete')
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.productors.service.delete_one')
response = client.delete('/api/productors/2')
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()

View File

@@ -0,0 +1,313 @@
import src.products.exceptions as exceptions
import src.products.service as service
import tests.factories.products as product_factory
from fastapi.exceptions import HTTPException
from src import models
from src.auth.auth import get_current_user
from src.main import app
class TestProducts:
def test_get_all(self, client, mocker, mock_session, mock_user):
mock_results = [
product_factory.product_public_factory(name="test 1", id=1),
product_factory.product_public_factory(name="test 2", id=2),
product_factory.product_public_factory(name="test 3", id=3),
]
mock = mocker.patch.object(
service,
'get_all',
return_value=mock_results
)
response = client.get('/api/products')
response_data = response.json()
assert response.status_code == 200
assert response_data[0]['id'] == 1
assert len(response_data) == len(mock_results)
mock.assert_called_once_with(
mock_session,
mock_user,
[],
[],
[]
)
def test_get_all_filters(self, client, mocker, mock_session, mock_user):
mock_results = [
product_factory.product_public_factory(name="test 2", id=2),
]
mock = mocker.patch.object(
service,
'get_all',
return_value=mock_results
)
response = client.get('/api/products?types=1&names=test 2')
response_data = response.json()
assert response.status_code == 200
assert response_data[0]['id'] == 2
assert len(response_data) == len(mock_results)
mock.assert_called_once_with(
mock_session,
mock_user,
['test 2'],
[],
['1'],
)
def test_get_all_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.products.service.get_all')
response = client.get('/api/products')
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()
def test_get_one(self, client, mocker, mock_session, mock_user):
mock_result = product_factory.product_public_factory(
name="test 2", id=2)
mock = mocker.patch.object(
service,
'get_one',
return_value=mock_result
)
response = client.get('/api/products/2')
response_data = response.json()
assert response.status_code == 200
assert response_data['id'] == 2
mock.assert_called_once_with(
mock_session,
2
)
def test_get_one_notfound(self, client, mocker, mock_session, mock_user):
mock_result = None
mock = mocker.patch.object(
service,
'get_one',
return_value=mock_result
)
response = client.get('/api/products/2')
response_data = response.json()
assert response.status_code == 404
mock.assert_called_once_with(
mock_session,
2
)
def test_get_one_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.products.service.get_one')
response = client.get('/api/products/2')
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()
def test_create_one(self, client, mocker, mock_session, mock_user):
product_body = product_factory.product_body_factory(
name='test product create')
product_create = product_factory.product_create_factory(
name='test product create')
product_result = product_factory.product_public_factory(
name='test product create')
mock = mocker.patch.object(
service,
'create_one',
return_value=product_result
)
response = client.post('/api/products', json=product_body)
response_data = response.json()
assert response.status_code == 200
assert response_data['name'] == 'test product create'
mock.assert_called_once_with(
mock_session,
product_create
)
def test_create_one_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
product_body = product_factory.product_body_factory(
name='test product create')
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.products.service.create_one')
response = client.post('/api/products', json=product_body)
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()
def test_update_one(self, client, mocker, mock_session, mock_user):
product_body = product_factory.product_body_factory(
name='test product update')
product_update = product_factory.product_update_factory(
name='test product update')
product_result = product_factory.product_public_factory(
name='test product update')
mock = mocker.patch.object(
service,
'update_one',
return_value=product_result
)
response = client.put('/api/products/2', json=product_body)
response_data = response.json()
assert response.status_code == 200
assert response_data['name'] == 'test product update'
mock.assert_called_once_with(
mock_session,
2,
product_update
)
def test_update_one_notfound(
self,
client,
mocker,
mock_session,
mock_user):
product_body = product_factory.product_body_factory(
name='test product update')
product_update = product_factory.product_update_factory(
name='test product update')
product_result = None
mock = mocker.patch.object(
service,
'update_one',
side_effect=exceptions.ProductNotFoundError('Product not found')
)
response = client.put('/api/products/2', json=product_body)
response_data = response.json()
assert response.status_code == 404
mock.assert_called_once_with(
mock_session,
2,
product_update
)
def test_update_one_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
product_body = product_factory.product_body_factory(
name='test product update')
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.products.service.update_one')
response = client.put('/api/products/2', json=product_body)
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()
def test_delete_one(self, client, mocker, mock_session, mock_user):
product_result = product_factory.product_public_factory(
name='test product delete')
mock = mocker.patch.object(
service,
'delete_one',
return_value=product_result
)
response = client.delete('/api/products/2')
response_data = response.json()
assert response.status_code == 200
assert response_data['name'] == 'test product delete'
mock.assert_called_once_with(
mock_session,
2,
)
def test_delete_one_notfound(
self,
client,
mocker,
mock_session,
mock_user):
product_result = None
mock = mocker.patch.object(
service,
'delete_one',
side_effect=exceptions.ProductNotFoundError('Product not found')
)
response = client.delete('/api/products/2')
response_data = response.json()
assert response.status_code == 404
mock.assert_called_once_with(
mock_session,
2,
)
def test_delete_one_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
product_body = product_factory.product_body_factory(
name='test product delete')
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.products.service.delete_one')
response = client.delete('/api/products/2')
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()

View File

@@ -0,0 +1,310 @@
import src.messages as messages
import src.shipments.exceptions as exceptions
import src.shipments.service as service
import tests.factories.shipments as shipment_factory
from fastapi.exceptions import HTTPException
from src import models
from src.auth.auth import get_current_user
from src.main import app
class TestShipments:
def test_get_all(self, client, mocker, mock_session, mock_user):
mock_results = [
shipment_factory.shipment_public_factory(name="test 1", id=1),
shipment_factory.shipment_public_factory(name="test 2", id=2),
shipment_factory.shipment_public_factory(name="test 3", id=3),
]
mock = mocker.patch.object(
service,
'get_all',
return_value=mock_results
)
response = client.get('/api/shipments')
response_data = response.json()
assert response.status_code == 200
assert response_data[0]['id'] == 1
assert len(response_data) == len(mock_results)
mock.assert_called_once_with(
mock_session,
mock_user,
[],
[],
[],
)
def test_get_all_filters(self, client, mocker, mock_session, mock_user):
mock_results = [
shipment_factory.shipment_public_factory(name="test 2", id=2),
]
mock = mocker.patch.object(
service,
'get_all',
return_value=mock_results
)
response = client.get(
'/api/shipments?dates=2025-10-10&names=test 2&forms=contract form 1')
response_data = response.json()
assert response.status_code == 200
assert response_data[0]['id'] == 2
assert len(response_data) == len(mock_results)
mock.assert_called_once_with(
mock_session,
mock_user,
['test 2'],
['2025-10-10'],
['contract form 1'],
)
def test_get_all_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.shipments.service.get_all')
response = client.get('/api/shipments')
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()
def test_get_one(self, client, mocker, mock_session, mock_user):
mock_result = shipment_factory.shipment_public_factory(
name="test 2", id=2)
mock = mocker.patch.object(
service,
'get_one',
return_value=mock_result
)
response = client.get('/api/shipments/2')
response_data = response.json()
assert response.status_code == 200
assert response_data['id'] == 2
mock.assert_called_once_with(
mock_session,
2
)
def test_get_one_notfound(self, client, mocker, mock_session, mock_user):
mock_result = None
mock = mocker.patch.object(
service,
'get_one',
return_value=mock_result
)
response = client.get('/api/shipments/2')
response_data = response.json()
assert response.status_code == 404
mock.assert_called_once_with(
mock_session,
2
)
def test_get_one_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.shipments.service.get_one')
response = client.get('/api/shipments/2')
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()
def test_create_one(self, client, mocker, mock_session, mock_user):
shipment_body = shipment_factory.shipment_body_factory(
name='test shipment create')
shipment_create = shipment_factory.shipment_create_factory(
name='test shipment create')
shipment_result = shipment_factory.shipment_public_factory(
name='test shipment create')
mock = mocker.patch.object(
service,
'create_one',
return_value=shipment_result
)
response = client.post('/api/shipments', json=shipment_body)
response_data = response.json()
assert response.status_code == 200
assert response_data['name'] == 'test shipment create'
mock.assert_called_once_with(
mock_session,
shipment_create
)
def test_create_one_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
shipment_body = shipment_factory.shipment_body_factory(
name='test shipment create')
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.shipments.service.create_one')
response = client.post('/api/shipments', json=shipment_body)
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()
def test_update_one(self, client, mocker, mock_session, mock_user):
shipment_body = shipment_factory.shipment_body_factory(
name='test shipment update')
shipment_update = shipment_factory.shipment_update_factory(
name='test shipment update')
shipment_result = shipment_factory.shipment_public_factory(
name='test shipment update')
mock = mocker.patch.object(
service,
'update_one',
return_value=shipment_result
)
response = client.put('/api/shipments/2', json=shipment_body)
response_data = response.json()
assert response.status_code == 200
assert response_data['name'] == 'test shipment update'
mock.assert_called_once_with(
mock_session,
2,
shipment_update
)
def test_update_one_notfound(
self,
client,
mocker,
mock_session,
mock_user):
shipment_body = shipment_factory.shipment_body_factory(
name='test shipment update')
shipment_update = shipment_factory.shipment_update_factory(
name='test shipment update')
mock = mocker.patch.object(
service, 'update_one', side_effect=exceptions.ShipmentNotFoundError(
messages.Messages.not_found('shipment')))
response = client.put('/api/shipments/2', json=shipment_body)
response_data = response.json()
assert response.status_code == 404
mock.assert_called_once_with(
mock_session,
2,
shipment_update
)
def test_update_one_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
shipment_body = shipment_factory.shipment_body_factory(
name='test shipment update')
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.shipments.service.update_one')
response = client.put('/api/shipments/2', json=shipment_body)
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()
def test_delete_one(self, client, mocker, mock_session, mock_user):
shipment_result = shipment_factory.shipment_public_factory(
name='test shipment delete')
mock = mocker.patch.object(
service,
'delete_one',
return_value=shipment_result
)
response = client.delete('/api/shipments/2')
response_data = response.json()
assert response.status_code == 200
assert response_data['name'] == 'test shipment delete'
mock.assert_called_once_with(
mock_session,
2,
)
def test_delete_one_notfound(
self,
client,
mocker,
mock_session,
mock_user):
shipment_result = None
mock = mocker.patch.object(
service, 'delete_one', side_effect=exceptions.ShipmentNotFoundError(
messages.Messages.not_found('shipment')))
response = client.delete('/api/shipments/2')
response_data = response.json()
assert response.status_code == 404
mock.assert_called_once_with(
mock_session,
2,
)
def test_delete_one_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
shipment_body = shipment_factory.shipment_body_factory(
name='test shipment delete')
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.shipments.service.delete_one')
response = client.delete('/api/shipments/2')
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()

View File

@@ -0,0 +1,296 @@
import src.users.exceptions as exceptions
import src.users.service as service
import tests.factories.users as user_factory
from fastapi.exceptions import HTTPException
from src import models
from src.auth.auth import get_current_user
from src.main import app
class TestUsers:
def test_get_all(self, client, mocker, mock_session, mock_user):
mock_results = [
user_factory.user_public_factory(name="test 1", id=1),
user_factory.user_public_factory(name="test 2", id=2),
user_factory.user_public_factory(name="test 3", id=3),
]
mock = mocker.patch.object(
service,
'get_all',
return_value=mock_results
)
response = client.get('/api/users')
response_data = response.json()
assert response.status_code == 200
assert response_data[0]['id'] == 1
assert len(response_data) == len(mock_results)
mock.assert_called_once_with(
mock_session,
[],
[],
)
def test_get_all_filters(self, client, mocker, mock_session, mock_user):
mock_results = [
user_factory.user_public_factory(name="test 2", id=2),
]
mock = mocker.patch.object(
service,
'get_all',
return_value=mock_results
)
response = client.get('/api/users?emails=test@test.test&names=test 2')
response_data = response.json()
assert response.status_code == 200
assert response_data[0]['id'] == 2
assert len(response_data) == len(mock_results)
mock.assert_called_once_with(
mock_session,
['test 2'],
['test@test.test'],
)
def test_get_all_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.users.service.get_all')
response = client.get('/api/users')
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()
def test_get_one(self, client, mocker, mock_session, mock_user):
mock_result = user_factory.user_public_factory(name="test 2", id=2)
mock = mocker.patch.object(
service,
'get_one',
return_value=mock_result
)
response = client.get('/api/users/2')
response_data = response.json()
assert response.status_code == 200
assert response_data['id'] == 2
mock.assert_called_once_with(
mock_session,
2
)
def test_get_one_notfound(self, client, mocker, mock_session, mock_user):
mock_result = None
mock = mocker.patch.object(
service,
'get_one',
return_value=mock_result
)
response = client.get('/api/users/2')
response_data = response.json()
assert response.status_code == 404
mock.assert_called_once_with(
mock_session,
2
)
def test_get_one_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.users.service.get_one')
response = client.get('/api/users/2')
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()
def test_create_one(self, client, mocker, mock_session, mock_user):
user_body = user_factory.user_body_factory(name='test user create')
user_create = user_factory.user_create_factory(name='test user create')
user_result = user_factory.user_public_factory(name='test user create')
mock = mocker.patch.object(
service,
'create_one',
return_value=user_result
)
response = client.post('/api/users', json=user_body)
response_data = response.json()
assert response.status_code == 200
assert response_data['name'] == 'test user create'
mock.assert_called_once_with(
mock_session,
user_create
)
def test_create_one_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
user_body = user_factory.user_body_factory(name='test user create')
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.users.service.create_one')
response = client.post('/api/users', json=user_body)
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()
def test_update_one(self, client, mocker, mock_session, mock_user):
user_body = user_factory.user_body_factory(name='test user update')
user_update = user_factory.user_update_factory(name='test user update')
user_result = user_factory.user_public_factory(name='test user update')
mock = mocker.patch.object(
service,
'update_one',
return_value=user_result
)
response = client.put('/api/users/2', json=user_body)
response_data = response.json()
assert response.status_code == 200
assert response_data['name'] == 'test user update'
mock.assert_called_once_with(
mock_session,
2,
user_update
)
def test_update_one_notfound(
self,
client,
mocker,
mock_session,
mock_user):
user_body = user_factory.user_body_factory(name='test user update')
user_update = user_factory.user_update_factory(name='test user update')
user_result = None
mock = mocker.patch.object(
service,
'update_one',
side_effect=exceptions.UserNotFoundError('User 2 not found')
)
response = client.put('/api/users/2', json=user_body)
response_data = response.json()
assert response.status_code == 404
mock.assert_called_once_with(
mock_session,
2,
user_update
)
def test_update_one_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
user_body = user_factory.user_body_factory(name='test user update')
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.users.service.update_one')
response = client.put('/api/users/2', json=user_body)
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()
def test_delete_one(self, client, mocker, mock_session, mock_user):
user_result = user_factory.user_public_factory(name='test user delete')
mock = mocker.patch.object(
service,
'delete_one',
return_value=user_result
)
response = client.delete('/api/users/2')
response_data = response.json()
assert response.status_code == 200
assert response_data['name'] == 'test user delete'
mock.assert_called_once_with(
mock_session,
2,
)
def test_delete_one_notfound(
self,
client,
mocker,
mock_session,
mock_user):
user_result = None
mock = mocker.patch.object(
service,
'delete_one',
side_effect=exceptions.UserNotFoundError('User 2 not found')
)
response = client.delete('/api/users/2')
response_data = response.json()
assert response.status_code == 404
mock.assert_called_once_with(
mock_session,
2,
)
def test_delete_one_unauthorized(
self,
client,
mocker,
mock_session,
mock_user):
def unauthorized():
raise HTTPException(status_code=401)
user_body = user_factory.user_body_factory(name='test user delete')
app.dependency_overrides[get_current_user] = unauthorized
mock = mocker.patch('src.users.service.delete_one')
response = client.delete('/api/users/2')
assert response.status_code == 401
mock.assert_not_called()
app.dependency_overrides.clear()

View File

@@ -0,0 +1,158 @@
import pytest
import src.forms.exceptions as forms_exceptions
import src.forms.service as forms_service
import tests.factories.forms as forms_factory
from sqlmodel import Session
from src import models
class TestFormsService:
def test_get_all_forms(self, session: Session,
forms: list[models.FormPublic]):
result = forms_service.get_all(session, [], [], False)
assert len(result) == 2
assert result == forms
def test_get_all_forms_filter_productors(
self, session: Session, forms: list[models.FormPublic]):
result = forms_service.get_all(session, [], ['test productor'], False)
assert len(result) == 2
assert result == forms
def test_get_all_forms_filter_season(
self, session: Session, forms: list[models.FormPublic]):
result = forms_service.get_all(session, ['test season 1'], [], False)
assert len(result) == 1
def test_get_all_forms_all_filters(
self, session: Session, forms: list[models.FormPublic]):
result = forms_service.get_all(
session, ['test season 1'], ['test productor'], True)
assert result == forms
def test_get_one_form(self, session: Session,
forms: list[models.FormPublic]):
result = forms_service.get_one(session, forms[0].id)
assert result == forms[0]
def test_get_one_form_notfound(self, session: Session):
result = forms_service.get_one(session, 122)
assert result is None
def test_create_form(
self,
session: Session,
productor: models.ProductorPublic,
referer: models.ProductorPublic
):
form_create = forms_factory.form_create_factory(
name="new test form",
productor_id=productor.id,
referer=referer.id,
season="new test season",
)
result = forms_service.create_one(session, form_create)
assert result.id is not None
assert result.name == "new test form"
assert result.productor.name == "test productor"
def test_create_form_invalidinput(
self,
session: Session,
productor: models.Productor
):
form_create = None
with pytest.raises(forms_exceptions.FormCreateError):
result = forms_service.create_one(session, form_create)
form_create = forms_factory.form_create_factory(productor_id=123)
with pytest.raises(forms_exceptions.ProductorNotFoundError):
result = forms_service.create_one(session, form_create)
form_create = forms_factory.form_create_factory(
productor_id=productor.id,
referer_id=123
)
with pytest.raises(forms_exceptions.UserNotFoundError):
result = forms_service.create_one(session, form_create)
def test_update_form(
self,
session: Session,
productor: models.ProductorPublic,
referer: models.ProductorPublic,
forms: list[models.FormPublic]
):
form_update = forms_factory.form_update_factory(
name='updated test form',
productor_id=productor.id,
referer_id=referer.id,
season='updated test season'
)
form_id = forms[0].id
result = forms_service.update_one(session, form_id, form_update)
assert result.id == form_id
assert result.name == 'updated test form'
assert result.season == 'updated test season'
def test_update_form_notfound(
self,
session: Session,
productor: models.ProductorPublic,
referer: models.ProductorPublic,
):
form_update = forms_factory.form_update_factory(
name='updated test form',
productor_id=productor.id,
referer_id=referer.id,
season='updated test season'
)
form_id = 123
with pytest.raises(forms_exceptions.FormNotFoundError):
result = forms_service.update_one(session, form_id, form_update)
def test_update_form_invalidinput(
self,
session: Session,
productor: models.ProductorPublic,
forms: list[models.FormPublic]
):
form_id = forms[0].id
form_update = forms_factory.form_update_factory(productor_id=123)
with pytest.raises(forms_exceptions.ProductorNotFoundError):
result = forms_service.update_one(session, form_id, form_update)
form_update = forms_factory.form_update_factory(
productor_id=productor.id,
referer_id=123
)
with pytest.raises(forms_exceptions.UserNotFoundError):
result = forms_service.update_one(session, form_id, form_update)
def test_delete_form(
self,
session: Session,
forms: list[models.FormPublic]
):
form_id = forms[0].id
result = forms_service.delete_one(session, form_id)
check = forms_service.get_one(session, form_id)
assert check is None
def test_delete_form_notfound(
self,
session: Session,
forms: list[models.FormPublic]
):
form_id = 123
with pytest.raises(forms_exceptions.FormNotFoundError):
result = forms_service.delete_one(session, form_id)

View File

@@ -0,0 +1,146 @@
import pytest
import src.productors.exceptions as productors_exceptions
import src.productors.service as productors_service
import tests.factories.productors as productors_factory
from sqlmodel import Session
from src import models
class TestProductorsService:
def test_get_all_productors(
self,
session: Session,
productors: list[models.ProductorPublic],
user: models.UserPublic
):
result = productors_service.get_all(session, user, [], [])
assert len(result) == 1
assert result == [productors[0]]
def test_get_all_productors_filter_names(
self,
session: Session,
productors: list[models.ProductorPublic],
user: models.UserPublic
):
result = productors_service.get_all(
session,
user,
['test productor 1'],
[]
)
assert len(result) == 1
def test_get_all_productors_filter_types(
self,
session: Session,
productors: list[models.ProductorPublic],
user: models.UserPublic
):
result = productors_service.get_all(
session,
user,
[],
['Légumineuses'],
)
assert len(result) == 1
def test_get_all_productors_all_filters(
self,
session: Session,
productors: list[models.ProductorPublic],
user: models.UserPublic
):
result = productors_service.get_all(
session,
user,
['test productor 1'],
['Légumineuses'],
)
assert len(result) == 1
def test_get_one_productor(self,
session: Session,
productors: list[models.ProductorPublic]):
result = productors_service.get_one(session, productors[0].id)
assert result == productors[0]
def test_get_one_productor_notfound(self, session: Session):
result = productors_service.get_one(session, 122)
assert result is None
def test_create_productor(
self,
session: Session,
referer: models.ProductorPublic
):
productor_create = productors_factory.productor_create_factory(
name="new test productor",
)
result = productors_service.create_one(session, productor_create)
assert result.id is not None
assert result.name == "new test productor"
def test_create_productor_invalidinput(
self,
session: Session,
):
productor_create = None
with pytest.raises(productors_exceptions.ProductorCreateError):
result = productors_service.create_one(session, productor_create)
def test_update_productor(
self,
session: Session,
referer: models.ProductorPublic,
productors: list[models.ProductorPublic]
):
productor_update = productors_factory.productor_update_factory(
name='updated test productor',
)
productor_id = productors[0].id
result = productors_service.update_one(
session, productor_id, productor_update)
assert result.id == productor_id
assert result.name == 'updated test productor'
def test_update_productor_notfound(
self,
session: Session,
referer: models.ProductorPublic,
):
productor_update = productors_factory.productor_update_factory(
name='updated test productor',
)
productor_id = 123
with pytest.raises(productors_exceptions.ProductorNotFoundError):
result = productors_service.update_one(
session, productor_id, productor_update)
def test_delete_productor(
self,
session: Session,
productors: list[models.ProductorPublic]
):
productor_id = productors[0].id
result = productors_service.delete_one(session, productor_id)
check = productors_service.get_one(session, productor_id)
assert check is None
def test_delete_productor_notfound(
self,
session: Session,
productors: list[models.ProductorPublic]
):
productor_id = 123
with pytest.raises(productors_exceptions.ProductorNotFoundError):
result = productors_service.delete_one(session, productor_id)

View File

@@ -0,0 +1,196 @@
import pytest
import src.products.exceptions as products_exceptions
import src.products.service as products_service
import tests.factories.products as products_factory
from sqlmodel import Session
from src import models
class TestProductsService:
def test_get_all_products(
self,
session: Session,
products: list[models.ProductPublic],
user: models.UserPublic
):
result = products_service.get_all(session, user, [], [], [])
assert len(result) == 2
assert result == products
def test_get_all_products_filter_productors(
self,
session: Session,
products: list[models.ProductPublic],
user: models.UserPublic
):
result = products_service.get_all(
session,
user,
[],
['test productor'],
[]
)
assert len(result) == 2
assert result == products
def test_get_all_products_filter_names(
self,
session: Session,
products: list[models.ProductPublic],
user: models.UserPublic
):
result = products_service.get_all(
session,
user,
['product 1 occasionnal'],
[],
[]
)
assert len(result) == 1
def test_get_all_products_filter_types(
self,
session: Session,
products: list[models.ProductPublic],
user: models.UserPublic
):
result = products_service.get_all(
session,
user,
[],
[],
['1']
)
assert len(result) == 1
def test_get_all_products_all_filters(
self,
session: Session,
products: list[models.ProductPublic],
user: models.UserPublic
):
result = products_service.get_all(
session,
user,
['product 1 occasionnal'],
['test productor'],
['1']
)
assert len(result) == 1
def test_get_one_product(self, session: Session,
products: list[models.ProductPublic]):
result = products_service.get_one(session, products[0].id)
assert result == products[0]
def test_get_one_product_notfound(self, session: Session):
result = products_service.get_one(session, 122)
assert result is None
def test_create_product(
self,
session: Session,
productor: models.ProductorPublic,
referer: models.ProductorPublic
):
product_create = products_factory.product_create_factory(
name="new test product",
productor_id=productor.id,
)
result = products_service.create_one(session, product_create)
assert result.id is not None
assert result.name == "new test product"
assert result.productor.name == "test productor"
def test_create_product_invalidinput(
self,
session: Session,
productor: models.Productor
):
product_create = None
with pytest.raises(products_exceptions.ProductCreateError):
result = products_service.create_one(session, product_create)
product_create = products_factory.product_create_factory(
productor_id=123)
with pytest.raises(products_exceptions.ProductorNotFoundError):
result = products_service.create_one(session, product_create)
def test_update_product(
self,
session: Session,
productor: models.ProductorPublic,
referer: models.ProductorPublic,
products: list[models.ProductPublic]
):
product_update = products_factory.product_update_factory(
name='updated test product',
productor_id=productor.id,
)
product_id = products[0].id
result = products_service.update_one(
session, product_id, product_update)
assert result.id == product_id
assert result.name == 'updated test product'
def test_update_product_notfound(
self,
session: Session,
productor: models.ProductorPublic,
referer: models.ProductorPublic,
):
product_update = products_factory.product_update_factory(
name='updated test product',
productor_id=productor.id,
)
product_id = 123
with pytest.raises(products_exceptions.ProductNotFoundError):
result = products_service.update_one(
session, product_id, product_update)
def test_update_product_invalidinput(
self,
session: Session,
productor: models.ProductorPublic,
products: list[models.ProductPublic]
):
product_id = products[0].id
product_update = products_factory.product_update_factory(
productor_id=123)
with pytest.raises(products_exceptions.ProductorNotFoundError):
result = products_service.update_one(
session, product_id, product_update)
product_update = products_factory.product_update_factory(
productor_id=productor.id,
referer_id=123
)
def test_delete_product(
self,
session: Session,
products: list[models.ProductPublic]
):
product_id = products[0].id
result = products_service.delete_one(session, product_id)
check = products_service.get_one(session, product_id)
assert check is None
def test_delete_product_notfound(
self,
session: Session,
products: list[models.ProductPublic]
):
product_id = 123
with pytest.raises(products_exceptions.ProductNotFoundError):
result = products_service.delete_one(session, product_id)

View File

@@ -0,0 +1,150 @@
import datetime
import pytest
import src.shipments.exceptions as shipments_exceptions
import src.shipments.service as shipments_service
import tests.factories.shipments as shipments_factory
from sqlmodel import Session
from src import models
class TestShipmentsService:
def test_get_all_shipments(
self,
session: Session,
shipments: list[models.ShipmentPublic],
user: models.UserPublic,
):
result = shipments_service.get_all(session, user, [], [], [])
assert len(result) == 2
assert result == shipments
def test_get_all_shipments_filter_names(
self,
session: Session,
shipments: list[models.ShipmentPublic],
user: models.UserPublic,
):
result = shipments_service.get_all(
session, user, ['test shipment 1'], [], [])
assert len(result) == 1
assert result == [shipments[0]]
def test_get_all_shipments_filter_dates(
self,
session: Session,
shipments: list[models.ShipmentPublic],
user: models.UserPublic,
):
result = shipments_service.get_all(
session, user, [], ['2025-10-10'], [])
assert len(result) == 1
def test_get_all_shipments_filter_forms(
self,
session: Session,
shipments: list[models.ShipmentPublic],
forms: list[models.FormPublic],
user: models.UserPublic,
):
result = shipments_service.get_all(
session, user, [], [], [forms[0].name])
assert len(result) == 2
def test_get_all_shipments_all_filters(
self,
session: Session,
shipments: list[models.ShipmentPublic],
forms: list[models.FormPublic],
user: models.UserPublic,
):
result = shipments_service.get_all(session, user, ['test shipment 1'], [
'2025-10-10'], [forms[0].name])
assert len(result) == 1
def test_get_one_shipment(self, session: Session,
shipments: list[models.ShipmentPublic]):
result = shipments_service.get_one(session, shipments[0].id)
assert result == shipments[0]
def test_get_one_shipment_notfound(self, session: Session):
result = shipments_service.get_one(session, 122)
assert result is None
def test_create_shipment(
self,
session: Session,
):
shipment_create = shipments_factory.shipment_create_factory(
name='new test shipment',
date='2025-10-10',
)
result = shipments_service.create_one(session, shipment_create)
assert result.id is not None
assert result.name == "new test shipment"
def test_create_shipment_invalidinput(
self,
session: Session,
):
shipment_create = None
with pytest.raises(shipments_exceptions.ShipmentCreateError):
result = shipments_service.create_one(session, shipment_create)
def test_update_shipment(
self,
session: Session,
shipments: list[models.ShipmentPublic]
):
shipment_update = shipments_factory.shipment_update_factory(
name='updated shipment 1',
date='2025-12-10',
)
shipment_id = shipments[0].id
result = shipments_service.update_one(
session, shipment_id, shipment_update)
assert result.id == shipment_id
assert result.name == 'updated shipment 1'
assert result.date == datetime.date(2025, 12, 10)
def test_update_shipment_notfound(
self,
session: Session,
):
shipment_update = shipments_factory.shipment_update_factory(
name='updated shipment 1',
date=datetime.date(2025, 10, 10),
)
shipment_id = 123
with pytest.raises(shipments_exceptions.ShipmentNotFoundError):
result = shipments_service.update_one(
session, shipment_id, shipment_update)
def test_delete_shipment(
self,
session: Session,
shipments: list[models.ShipmentPublic]
):
shipment_id = shipments[0].id
result = shipments_service.delete_one(session, shipment_id)
check = shipments_service.get_one(session, shipment_id)
assert check is None
def test_delete_shipment_notfound(
self,
session: Session,
shipments: list[models.ShipmentPublic]
):
shipment_id = 123
with pytest.raises(shipments_exceptions.ShipmentNotFoundError):
result = shipments_service.delete_one(session, shipment_id)

View File

@@ -0,0 +1,120 @@
import pytest
import src.users.exceptions as users_exceptions
import src.users.service as users_service
import tests.factories.users as users_factory
from sqlmodel import Session
from src import models
class TestUsersService:
def test_get_all_users(self, session: Session,
users: list[models.UserPublic]):
result = users_service.get_all(session, [], [])
assert len(result) == 3
assert result == users
def test_get_all_users_filter_names(
self, session: Session, users: list[models.UserPublic]):
result = users_service.get_all(session, ['test user 1 (admin)'], [])
assert len(result) == 1
assert result == [users[0]]
def test_get_all_users_filter_emails(
self, session: Session, users: list[models.UserPublic]):
result = users_service.get_all(session, [], ['test1@test.com'])
assert len(result) == 1
def test_get_all_users_all_filters(
self, session: Session, users: list[models.UserPublic]):
result = users_service.get_all(
session, ['test user 1 (admin)'], ['test1@test.com'])
assert len(result) == 1
def test_get_one_user(self, session: Session,
users: list[models.UserPublic]):
result = users_service.get_one(session, users[0].id)
assert result == users[0]
def test_get_one_user_notfound(self, session: Session):
result = users_service.get_one(session, 122)
assert result is None
def test_create_user(
self,
session: Session,
):
user_create = users_factory.user_create_factory(
name="new test user",
email='test@test.fr',
role_names=['test role']
)
result = users_service.create_one(session, user_create)
assert result.id is not None
assert result.name == "new test user"
assert result.email == "test@test.fr"
assert len(result.roles) == 1
def test_create_user_invalidinput(
self,
session: Session,
):
user_create = None
with pytest.raises(users_exceptions.UserCreateError):
result = users_service.create_one(session, user_create)
def test_update_user(
self,
session: Session,
users: list[models.UserPublic]
):
user_update = users_factory.user_update_factory(
name="updated test user",
email='test@testttt.fr',
role_names=['test role']
)
user_id = users[0].id
result = users_service.update_one(session, user_id, user_update)
assert result.id == user_id
assert result.name == 'updated test user'
assert result.email == 'test@testttt.fr'
def test_update_user_notfound(
self,
session: Session,
):
user_update = users_factory.user_update_factory(
name="updated test user",
email='test@testttt.fr',
role_names=['test role']
)
user_id = 123
with pytest.raises(users_exceptions.UserNotFoundError):
result = users_service.update_one(session, user_id, user_update)
def test_delete_user(
self,
session: Session,
users: list[models.UserPublic]
):
user_id = users[0].id
result = users_service.delete_one(session, user_id)
check = users_service.get_one(session, user_id)
assert check is None
def test_delete_user_notfound(
self,
session: Session,
users: list[models.UserPublic]
):
user_id = 123
with pytest.raises(users_exceptions.UserNotFoundError):
result = users_service.delete_one(session, user_id)

View File

@@ -1,6 +1,6 @@
{ {
"version": "1", "version": "1",
"name": "amapcontract", "name": "bruno",
"type": "collection", "type": "collection",
"ignore": [ "ignore": [
"node_modules", "node_modules",

56
docker-compose.dev.yaml Normal file
View File

@@ -0,0 +1,56 @@
services:
frontend:
build:
context: .
dockerfile: frontend/Dockerfile.dev
volumes:
- ./frontend:/app
- /app/node_modules
environment:
VITE_API_URL: ${VITE_API_URL}
ports:
- "5173:5173"
depends_on:
- backend
backend:
build:
context: .
dockerfile: backend/Dockerfile
volumes:
- ./backend:/code
command: >
sh -c "fastapi run src/main.py --reload --port 8000"
environment:
ORIGINS: ${ORIGINS}
DB_HOST: database
DB_USER: ${DB_USER}
DB_PASS: ${DB_PASS}
DB_NAME: ${DB_NAME}
SECRET_KEY: ${SECRET_KEY}
VITE_API_URL: ${VITE_API_URL}
KEYCLOAK_SERVER: ${KEYCLOAK_SERVER}
KEYCLOAK_REALM: ${KEYCLOAK_REALM}
KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID}
KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET}
KEYCLOAK_REDIRECT_URI: ${KEYCLOAK_REDIRECT_URI}
DEBUG: true
MAX_AGE: ${MAX_AGE}
ports:
- "8000:8000"
depends_on:
- database
database:
image: postgres
restart: always
shm_size: 128mb
volumes:
- db:/var/lib/postgresql
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: ${DB_NAME}
ports:
- 5432:5432
volumes:
db:

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