Initial scaffold: gestion de stock IT pour le département IT
App FastAPI + SQLite + SQLModel + Jinja2 pour gérer le stock de matériel IT (catégories/sous-catégories/matériels), avec recherche par scan de code-barres et alertes email de stock bas.
This commit is contained in:
commit
8bb5c9406a
15
.claude/settings.json
Normal file
15
.claude/settings.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Write|Edit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "jq -r '.tool_input.file_path' | { read -r f; case \"$f\" in *.py) /Users/maxence/Documents/Projects/stock/.venv/bin/ruff format \"$f\" && /Users/maxence/Documents/Projects/stock/.venv/bin/ruff check --fix \"$f\" ;; esac; } 2>/dev/null || true"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
19
.claude/skills/seed-demo-data/SKILL.md
Normal file
19
.claude/skills/seed-demo-data/SKILL.md
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
---
|
||||||
|
name: seed-demo-data
|
||||||
|
description: Populates the local SQLite database with demo categories, subcategories, and materials (with barcodes and alert thresholds) so scanning and email-alert flows can be tested locally without manual data entry. Use when the user wants to test the app locally and the stock database is empty, or asks to seed/reset demo data.
|
||||||
|
---
|
||||||
|
|
||||||
|
Run `python scripts/seed.py` (with the project's venv activated) to populate
|
||||||
|
`data/stock.db` with sample data: 3 categories, 6 materials with barcodes
|
||||||
|
(some already below their alert threshold, to verify alerts trigger), and
|
||||||
|
one alert recipient (`it-dept@clinique.local`).
|
||||||
|
|
||||||
|
The script is idempotent — it does nothing if the database already has
|
||||||
|
materials in it, so it's safe to invoke repeatedly.
|
||||||
|
|
||||||
|
Barcodes to use for manual testing on `/scan`: `1111111111111` (PC Dell XPS
|
||||||
|
13, already below threshold), `2222222222221` (Câble RJ45 2m), `3333333333331`
|
||||||
|
(Souris USB, already below threshold).
|
||||||
|
|
||||||
|
To reset and reseed from scratch, delete `data/stock.db` first (or the whole
|
||||||
|
`data/` directory content), then rerun the script.
|
||||||
12
.env.example
Normal file
12
.env.example
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Copier ce fichier vers .env et remplir avec les vraies valeurs du relais
|
||||||
|
# SMTP interne de la clinique.
|
||||||
|
#
|
||||||
|
# Si SMTP_HOST est laissé vide, les alertes de stock bas ne sont pas
|
||||||
|
# envoyées : elles sont juste affichées dans la console. Pratique pour
|
||||||
|
# développer et tester en local sans avoir de serveur mail sous la main.
|
||||||
|
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=25
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM=stock-it@clinique.local
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
data/*.db
|
||||||
|
.env
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
87
CLAUDE.md
Normal file
87
CLAUDE.md
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Projet
|
||||||
|
|
||||||
|
Application web interne pour le département IT d'une clinique privée : gestion
|
||||||
|
du stock de matériel informatique (catégories / sous-catégories / matériels),
|
||||||
|
avec recherche de matériel par scan de code-barres et alertes email quand le
|
||||||
|
stock passe sous un seuil défini.
|
||||||
|
|
||||||
|
Contraintes du domaine, volontairement strictes :
|
||||||
|
- **Pas d'authentification dans l'app** : l'accès est restreint par un
|
||||||
|
mécanisme externe (réseau interne, proxy...). Ne pas ajouter de login/session.
|
||||||
|
- **Pas de suivi d'exemplaires individuels.** Un `Materiel` est un *type*
|
||||||
|
d'objet (ex: "PC Dell XPS 13 modèle 9310", "Câble RJ45 2m") avec juste une
|
||||||
|
quantité en stock. Pas de numéro de série, pas d'historique par unité.
|
||||||
|
- **Douchette code-barres = clavier.** Les douchettes USB/Bluetooth émulent un
|
||||||
|
clavier (elles tapent le code + Entrée). Aucune intégration matérielle
|
||||||
|
n'est nécessaire côté app : un simple `<input>` seul dans un `<form>`
|
||||||
|
suffit, le navigateur soumet au Entrée.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
Python + FastAPI + SQLModel (ORM) + SQLite (fichier `data/stock.db`) +
|
||||||
|
Jinja2 (rendu HTML côté serveur, formulaires classiques, pas de bundler JS).
|
||||||
|
|
||||||
|
Tout vit dans ce dossier : pas de service externe requis pour développer et
|
||||||
|
tester en local. Les emails d'alerte sont simulés dans la console tant
|
||||||
|
qu'aucun SMTP n'est configuré (voir `app/email_alerts.py` et `.env.example`).
|
||||||
|
|
||||||
|
## Commandes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate # créer avec: python3 -m venv .venv
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
uvicorn app.main:app --reload # lancer le serveur de dev (http://127.0.0.1:8000)
|
||||||
|
pytest # lancer les tests
|
||||||
|
ruff check . # linter
|
||||||
|
```
|
||||||
|
|
||||||
|
Les tests utilisent une base SQLite **en mémoire** (voir `tests/conftest.py`),
|
||||||
|
jamais le fichier `data/stock.db` du développement.
|
||||||
|
|
||||||
|
## Convention : commentaires détaillés
|
||||||
|
|
||||||
|
Contrairement à la préférence par défaut (commentaires minimaux), ce projet
|
||||||
|
doit rester compréhensible par d'autres membres du département IT qui n'ont
|
||||||
|
pas suivi son développement. **Commenter en français, de façon détaillée**,
|
||||||
|
en particulier le "pourquoi" des décisions non évidentes (ex: pourquoi la
|
||||||
|
douchette ne nécessite pas d'intégration, pourquoi les emails sont simulés en
|
||||||
|
l'absence de SMTP_HOST). Ne pas se limiter au commentaire minimal habituel.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
main.py point d'entrée FastAPI, montage des routers
|
||||||
|
config.py lecture des variables d'environnement (SMTP...)
|
||||||
|
database.py connexion SQLite + dependency get_session()
|
||||||
|
models.py modèles SQLModel : Categorie, SousCategorie, Materiel, DestinataireAlerte
|
||||||
|
email_alerts.py vérifie le seuil d'alerte et envoie/simule l'email
|
||||||
|
templates_engine.py instance Jinja2Templates partagée par tous les routers
|
||||||
|
routers/ une route FastAPI par ressource (categories, materiels, scan, destinataires)
|
||||||
|
templates/ pages HTML (Jinja2, formulaires HTML classiques)
|
||||||
|
static/ CSS
|
||||||
|
tests/ pytest + TestClient FastAPI + base SQLite en mémoire
|
||||||
|
data/ base SQLite locale, ignorée par git
|
||||||
|
```
|
||||||
|
|
||||||
|
Point d'attention Starlette : `Jinja2Templates.TemplateResponse()` prend
|
||||||
|
`request` en premier argument positionnel (`templates.TemplateResponse(request,
|
||||||
|
"page.html", {...})`), **pas** `{"request": request, ...}` dans le contexte —
|
||||||
|
c'est l'API de la version de Starlette installée ici.
|
||||||
|
|
||||||
|
Un `<form>` ne peut pas entourer plusieurs `<td>` en HTML valide : pour les
|
||||||
|
lignes de tableau éditables (`app/templates/materiels.html`), les champs
|
||||||
|
utilisent l'attribut `form="materiel-{id}"` pour se rattacher à un `<form>`
|
||||||
|
déclaré séparément après le tableau.
|
||||||
|
|
||||||
|
## Emails d'alerte
|
||||||
|
|
||||||
|
Seuil configurable par matériel (`Materiel.seuil_alerte`), liste globale de
|
||||||
|
destinataires (`DestinataireAlerte`, gérée sur `/destinataires`). La logique
|
||||||
|
d'envoi est dans `app/email_alerts.py` : si `SMTP_HOST` n'est pas défini, rien
|
||||||
|
n'est envoyé, l'email est juste affiché dans la console du serveur — pratique
|
||||||
|
pour tester le déclenchement des alertes sans serveur mail réel.
|
||||||
88
README.md
Normal file
88
README.md
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
# Gestion de stock IT
|
||||||
|
|
||||||
|
Application web pour le département IT d'une clinique, permettant de gérer
|
||||||
|
le stock de matériel informatique (catégories / sous-catégories /
|
||||||
|
matériels), avec scan de codes-barres et alertes email de stock bas.
|
||||||
|
|
||||||
|
Pas de système d'authentification dans l'app : l'accès est censé être
|
||||||
|
restreint par un mécanisme externe (réseau interne, proxy, etc.).
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Python + FastAPI** : serveur web, un seul processus.
|
||||||
|
- **SQLite** : base de données fichier (`data/stock.db`), aucun serveur à installer.
|
||||||
|
- **Jinja2** : pages HTML rendues côté serveur, formulaires classiques (pas de JS de build).
|
||||||
|
- **SQLModel** : ORM (combine SQLAlchemy + validation Pydantic).
|
||||||
|
|
||||||
|
Tout vit dans ce dossier : pas de service externe requis pour développer
|
||||||
|
et tester en local (les emails d'alerte sont simulés dans la console tant
|
||||||
|
qu'aucun SMTP n'est configuré, voir plus bas).
|
||||||
|
|
||||||
|
## Démarrer en local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate # Windows : .venv\Scripts\activate
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
cp .env.example .env # optionnel en local, voir ci-dessous
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
L'app est accessible sur http://127.0.0.1:8000. La base SQLite est créée
|
||||||
|
automatiquement au premier démarrage dans `data/stock.db`.
|
||||||
|
|
||||||
|
## Lancer les tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lancer le linter
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate
|
||||||
|
ruff check .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration des emails d'alerte
|
||||||
|
|
||||||
|
Les alertes de stock bas sont envoyées par email via le relais SMTP interne
|
||||||
|
de la clinique, configuré par variables d'environnement (voir `.env.example`) :
|
||||||
|
|
||||||
|
```
|
||||||
|
SMTP_HOST=...
|
||||||
|
SMTP_PORT=25
|
||||||
|
SMTP_USER=...
|
||||||
|
SMTP_PASSWORD=...
|
||||||
|
SMTP_FROM=stock-it@clinique.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Si `SMTP_HOST` n'est pas renseigné (cas du dev local), les alertes ne sont
|
||||||
|
pas réellement envoyées : elles sont affichées dans la console du serveur
|
||||||
|
(`--- [EMAIL SIMULÉ] ---`). Ça permet de développer et tester tout le flux
|
||||||
|
d'alerte sans avoir de vrai serveur mail sous la main.
|
||||||
|
|
||||||
|
## Douchette code-barres
|
||||||
|
|
||||||
|
Aucune intégration logicielle particulière : une douchette USB (ou
|
||||||
|
Bluetooth) émule un clavier, elle "tape" le code puis Entrée. La page
|
||||||
|
`/scan` a un simple champ de texte auto-focus dans un formulaire ; Entrée
|
||||||
|
soumet automatiquement le formulaire.
|
||||||
|
|
||||||
|
## Structure du projet
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
main.py point d'entrée FastAPI
|
||||||
|
config.py lecture des variables d'environnement
|
||||||
|
database.py connexion SQLite
|
||||||
|
models.py modèles SQLModel (Categorie, SousCategorie, Materiel, DestinataireAlerte)
|
||||||
|
email_alerts.py logique d'envoi/simulation des alertes email
|
||||||
|
templates_engine.py instance Jinja2Templates partagée
|
||||||
|
routers/ une route FastAPI par ressource (categories, materiels, scan, destinataires)
|
||||||
|
templates/ pages HTML (Jinja2)
|
||||||
|
static/ CSS
|
||||||
|
tests/ tests pytest (client de test FastAPI + base SQLite en mémoire)
|
||||||
|
data/ base SQLite locale (ignorée par git)
|
||||||
|
```
|
||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
32
app/config.py
Normal file
32
app/config.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""
|
||||||
|
Configuration de l'application, lue depuis les variables d'environnement.
|
||||||
|
|
||||||
|
En local, copier .env.example vers .env et remplir les valeurs utiles ;
|
||||||
|
python-dotenv charge automatiquement ce fichier .env au démarrage, donc pas
|
||||||
|
besoin d'exporter les variables manuellement dans le shell.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class Settings:
|
||||||
|
"""Regroupe tous les réglages venant de l'environnement, avec des
|
||||||
|
valeurs par défaut raisonnables pour le développement local."""
|
||||||
|
|
||||||
|
smtp_host: str | None = os.getenv("SMTP_HOST") or None
|
||||||
|
smtp_port: int = int(os.getenv("SMTP_PORT", "25"))
|
||||||
|
smtp_user: str | None = os.getenv("SMTP_USER") or None
|
||||||
|
smtp_password: str | None = os.getenv("SMTP_PASSWORD") or None
|
||||||
|
smtp_from: str = os.getenv("SMTP_FROM", "stock-it@clinique.local")
|
||||||
|
|
||||||
|
# Si aucun SMTP_HOST n'est renseigné (typiquement en dev local, sans
|
||||||
|
# accès au relais mail de la clinique), les alertes sont simplement
|
||||||
|
# affichées dans la console au lieu d'être envoyées. Voir email_alerts.py.
|
||||||
|
smtp_configured: bool = bool(smtp_host)
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
33
app/database.py
Normal file
33
app/database.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""
|
||||||
|
Connexion à la base de données.
|
||||||
|
|
||||||
|
La base est un simple fichier SQLite (data/stock.db) : pas de serveur de
|
||||||
|
base de données à installer ou lancer, tout vit dans ce dossier comme
|
||||||
|
demandé pour pouvoir développer et tester en local sans setup particulier.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlmodel import Session, SQLModel, create_engine
|
||||||
|
|
||||||
|
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||||
|
DATA_DIR.mkdir(exist_ok=True)
|
||||||
|
DATABASE_URL = f"sqlite:///{DATA_DIR / 'stock.db'}"
|
||||||
|
|
||||||
|
# check_same_thread=False : nécessaire car FastAPI/Starlette peut exécuter
|
||||||
|
# les routes synchrones dans un thread différent du thread principal, alors
|
||||||
|
# que SQLite refuse par défaut de partager une connexion entre threads.
|
||||||
|
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
"""Crée les tables si elles n'existent pas encore. Appelé au démarrage
|
||||||
|
de l'app ; ne fait rien si le schéma existe déjà (pas de perte de données)."""
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|
||||||
|
def get_session():
|
||||||
|
"""Dependency FastAPI : fournit une session DB à une route, et la ferme
|
||||||
|
proprement une fois la requête terminée."""
|
||||||
|
with Session(engine) as session:
|
||||||
|
yield session
|
||||||
67
app/email_alerts.py
Normal file
67
app/email_alerts.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""
|
||||||
|
Envoi des emails d'alerte de stock bas.
|
||||||
|
|
||||||
|
En local, si aucun SMTP_HOST n'est configuré (voir .env.example et
|
||||||
|
app/config.py), les alertes ne sont pas réellement envoyées : elles sont
|
||||||
|
juste affichées dans la console. Ça permet de développer et tester tout le
|
||||||
|
flux d'alerte sans avoir besoin d'un vrai serveur mail sous la main.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import smtplib
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.models import DestinataireAlerte, Materiel
|
||||||
|
|
||||||
|
|
||||||
|
def verifier_et_alerter(materiel: Materiel, session: Session) -> None:
|
||||||
|
"""À appeler après toute modification de la quantité d'un matériel.
|
||||||
|
|
||||||
|
Envoie un email à tous les destinataires configurés si le stock vient
|
||||||
|
de passer sous le seuil d'alerte défini sur ce matériel.
|
||||||
|
"""
|
||||||
|
if materiel.seuil_alerte is None:
|
||||||
|
return
|
||||||
|
if materiel.quantite >= materiel.seuil_alerte:
|
||||||
|
return
|
||||||
|
|
||||||
|
destinataires = session.exec(select(DestinataireAlerte)).all()
|
||||||
|
if not destinataires:
|
||||||
|
return
|
||||||
|
|
||||||
|
sujet = f"[Stock IT] Stock bas : {materiel.nom}"
|
||||||
|
corps = (
|
||||||
|
f"Le stock de « {materiel.nom} » est passé sous le seuil d'alerte.\n\n"
|
||||||
|
f"Quantité actuelle : {materiel.quantite}\n"
|
||||||
|
f"Seuil d'alerte : {materiel.seuil_alerte}\n"
|
||||||
|
)
|
||||||
|
_envoyer_email(sujet, corps, [d.email for d in destinataires])
|
||||||
|
|
||||||
|
|
||||||
|
def _envoyer_email(sujet: str, corps: str, destinataires: list[str]) -> None:
|
||||||
|
if not settings.smtp_configured:
|
||||||
|
# Pas de SMTP configuré (typiquement en dev local) : on affiche
|
||||||
|
# l'email dans la console plutôt que de l'envoyer, pour pouvoir
|
||||||
|
# tester le déclenchement des alertes sans serveur mail réel.
|
||||||
|
print(
|
||||||
|
"--- [EMAIL SIMULÉ] ---\n"
|
||||||
|
f"À: {', '.join(destinataires)}\n"
|
||||||
|
f"Sujet: {sujet}\n\n"
|
||||||
|
f"{corps}"
|
||||||
|
"----------------------"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
message = EmailMessage()
|
||||||
|
message["Subject"] = sujet
|
||||||
|
message["From"] = settings.smtp_from
|
||||||
|
message["To"] = ", ".join(destinataires)
|
||||||
|
message.set_content(corps)
|
||||||
|
|
||||||
|
with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as smtp:
|
||||||
|
if settings.smtp_user and settings.smtp_password:
|
||||||
|
smtp.starttls()
|
||||||
|
smtp.login(settings.smtp_user, settings.smtp_password)
|
||||||
|
smtp.send_message(message)
|
||||||
37
app/main.py
Normal file
37
app/main.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""
|
||||||
|
Point d'entrée de l'application FastAPI.
|
||||||
|
|
||||||
|
Lancement en local : uvicorn app.main:app --reload
|
||||||
|
"""
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from app.database import init_db
|
||||||
|
from app.routers import categories, destinataires, materiels, scan
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Crée les tables SQLite au démarrage si elles n'existent pas encore."""
|
||||||
|
init_db()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Gestion de stock IT", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
|
|
||||||
|
app.include_router(categories.router)
|
||||||
|
app.include_router(materiels.router)
|
||||||
|
app.include_router(scan.router)
|
||||||
|
app.include_router(destinataires.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def racine() -> RedirectResponse:
|
||||||
|
"""La page d'accueil redirige directement vers la liste des matériels."""
|
||||||
|
return RedirectResponse(url="/materiels")
|
||||||
66
app/models.py
Normal file
66
app/models.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
"""
|
||||||
|
Modèles de données du stock IT.
|
||||||
|
|
||||||
|
Principe volontairement simple, demandé par le département IT : on ne suit
|
||||||
|
PAS des exemplaires individuels (pas de numéro de série, pas d'historique
|
||||||
|
détaillé par unité). On suit juste, pour chaque *type* de matériel, une
|
||||||
|
quantité en stock. Ex: "PC Dell XPS 13 modèle 9310" -> quantite=5,
|
||||||
|
"Câble RJ45 2m" -> quantite=42.
|
||||||
|
|
||||||
|
Hiérarchie : Categorie -> SousCategorie -> Materiel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlmodel import Field, Relationship, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class Categorie(SQLModel, table=True):
|
||||||
|
"""Catégorie de haut niveau (ex: 'Ordinateurs', 'Réseau', 'Périphériques')."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
nom: str = Field(index=True, unique=True)
|
||||||
|
|
||||||
|
sous_categories: list["SousCategorie"] = Relationship(back_populates="categorie")
|
||||||
|
|
||||||
|
|
||||||
|
class SousCategorie(SQLModel, table=True):
|
||||||
|
"""Sous-catégorie rattachée à une catégorie (ex: 'Portables', 'Câbles')."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
nom: str
|
||||||
|
categorie_id: int = Field(foreign_key="categorie.id")
|
||||||
|
|
||||||
|
categorie: Optional[Categorie] = Relationship(back_populates="sous_categories")
|
||||||
|
materiels: list["Materiel"] = Relationship(back_populates="sous_categorie")
|
||||||
|
|
||||||
|
|
||||||
|
class Materiel(SQLModel, table=True):
|
||||||
|
"""Un type de matériel suivi en stock.
|
||||||
|
|
||||||
|
code_barre identifie le *type* de matériel (le "SKU"), pas un
|
||||||
|
exemplaire précis : scanner deux PC XPS 13 identiques renvoie le même
|
||||||
|
matériel, et chaque scan ajuste juste la quantité globale.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
nom: str
|
||||||
|
code_barre: Optional[str] = Field(default=None, index=True, unique=True)
|
||||||
|
quantite: int = Field(default=0)
|
||||||
|
# En dessous de ce seuil, une alerte email est envoyée (voir email_alerts.py).
|
||||||
|
# None = pas d'alerte configurée pour ce matériel.
|
||||||
|
seuil_alerte: Optional[int] = Field(default=None)
|
||||||
|
sous_categorie_id: int = Field(foreign_key="souscategorie.id")
|
||||||
|
|
||||||
|
sous_categorie: Optional[SousCategorie] = Relationship(back_populates="materiels")
|
||||||
|
|
||||||
|
|
||||||
|
class DestinataireAlerte(SQLModel, table=True):
|
||||||
|
"""Adresse email qui reçoit les alertes de stock bas.
|
||||||
|
|
||||||
|
Liste globale simple (pas de destinataires par matériel/catégorie) :
|
||||||
|
c'est suffisant pour une petite équipe IT et ça reste facile à gérer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
email: str = Field(unique=True)
|
||||||
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
65
app/routers/categories.py
Normal file
65
app/routers/categories.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
"""Routes pour la gestion des catégories et sous-catégories de matériel."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Form
|
||||||
|
from fastapi.requests import Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.database import get_session
|
||||||
|
from app.models import Categorie, SousCategorie
|
||||||
|
from app.templates_engine import templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/categories")
|
||||||
|
def lister_categories(request: Request, session: Session = Depends(get_session)):
|
||||||
|
"""Affiche toutes les catégories avec leurs sous-catégories imbriquées."""
|
||||||
|
categories = session.exec(select(Categorie)).all()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "categories.html", {"categories": categories}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/categories")
|
||||||
|
def creer_categorie(nom: str = Form(...), session: Session = Depends(get_session)):
|
||||||
|
"""Crée une nouvelle catégorie de haut niveau (ex: 'Ordinateurs')."""
|
||||||
|
session.add(Categorie(nom=nom))
|
||||||
|
session.commit()
|
||||||
|
return RedirectResponse(url="/categories", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/categories/{categorie_id}/supprimer")
|
||||||
|
def supprimer_categorie(categorie_id: int, session: Session = Depends(get_session)):
|
||||||
|
"""Supprime une catégorie.
|
||||||
|
|
||||||
|
Refuse silencieusement si elle contient encore des sous-catégories,
|
||||||
|
pour éviter de supprimer par erreur des matériels devenus orphelins.
|
||||||
|
"""
|
||||||
|
categorie = session.get(Categorie, categorie_id)
|
||||||
|
if categorie and not categorie.sous_categories:
|
||||||
|
session.delete(categorie)
|
||||||
|
session.commit()
|
||||||
|
return RedirectResponse(url="/categories", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/categories/{categorie_id}/sous-categories")
|
||||||
|
def creer_sous_categorie(
|
||||||
|
categorie_id: int, nom: str = Form(...), session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Crée une sous-catégorie rattachée à une catégorie existante."""
|
||||||
|
session.add(SousCategorie(nom=nom, categorie_id=categorie_id))
|
||||||
|
session.commit()
|
||||||
|
return RedirectResponse(url="/categories", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sous-categories/{sous_categorie_id}/supprimer")
|
||||||
|
def supprimer_sous_categorie(
|
||||||
|
sous_categorie_id: int, session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Supprime une sous-catégorie si elle ne contient plus aucun matériel."""
|
||||||
|
sous_categorie = session.get(SousCategorie, sous_categorie_id)
|
||||||
|
if sous_categorie and not sous_categorie.materiels:
|
||||||
|
session.delete(sous_categorie)
|
||||||
|
session.commit()
|
||||||
|
return RedirectResponse(url="/categories", status_code=303)
|
||||||
41
app/routers/destinataires.py
Normal file
41
app/routers/destinataires.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""Routes pour gérer la liste des emails qui reçoivent les alertes de stock bas."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Form
|
||||||
|
from fastapi.requests import Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.database import get_session
|
||||||
|
from app.models import DestinataireAlerte
|
||||||
|
from app.templates_engine import templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/destinataires")
|
||||||
|
def lister_destinataires(request: Request, session: Session = Depends(get_session)):
|
||||||
|
"""Affiche la liste des emails configurés pour recevoir les alertes."""
|
||||||
|
destinataires = session.exec(select(DestinataireAlerte)).all()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "destinataires.html", {"destinataires": destinataires}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/destinataires")
|
||||||
|
def ajouter_destinataire(email: str = Form(...), session: Session = Depends(get_session)):
|
||||||
|
"""Ajoute un email à la liste des destinataires des alertes."""
|
||||||
|
session.add(DestinataireAlerte(email=email))
|
||||||
|
session.commit()
|
||||||
|
return RedirectResponse(url="/destinataires", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/destinataires/{destinataire_id}/supprimer")
|
||||||
|
def supprimer_destinataire(
|
||||||
|
destinataire_id: int, session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Retire un email de la liste des destinataires."""
|
||||||
|
destinataire = session.get(DestinataireAlerte, destinataire_id)
|
||||||
|
if destinataire:
|
||||||
|
session.delete(destinataire)
|
||||||
|
session.commit()
|
||||||
|
return RedirectResponse(url="/destinataires", status_code=303)
|
||||||
87
app/routers/materiels.py
Normal file
87
app/routers/materiels.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
"""
|
||||||
|
Routes pour la gestion des matériels : le cœur du stock.
|
||||||
|
|
||||||
|
Un "matériel" représente un type d'objet suivi en stock (nom + code-barre
|
||||||
|
optionnel + quantité), pas un exemplaire individuel — voir app/models.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Form
|
||||||
|
from fastapi.requests import Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.database import get_session
|
||||||
|
from app.email_alerts import verifier_et_alerter
|
||||||
|
from app.models import Materiel, SousCategorie
|
||||||
|
from app.templates_engine import templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/materiels")
|
||||||
|
def lister_materiels(request: Request, session: Session = Depends(get_session)):
|
||||||
|
"""Liste tous les matériels, avec les sous-catégories pour le formulaire de création."""
|
||||||
|
materiels = session.exec(select(Materiel)).all()
|
||||||
|
sous_categories = session.exec(select(SousCategorie)).all()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"materiels.html",
|
||||||
|
{"materiels": materiels, "sous_categories": sous_categories},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/materiels")
|
||||||
|
def creer_materiel(
|
||||||
|
nom: str = Form(...),
|
||||||
|
sous_categorie_id: int = Form(...),
|
||||||
|
code_barre: str = Form(""),
|
||||||
|
quantite: int = Form(0),
|
||||||
|
seuil_alerte: str = Form(""),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Crée un nouveau type de matériel dans le stock."""
|
||||||
|
materiel = Materiel(
|
||||||
|
nom=nom,
|
||||||
|
sous_categorie_id=sous_categorie_id,
|
||||||
|
code_barre=code_barre or None,
|
||||||
|
quantite=quantite,
|
||||||
|
seuil_alerte=int(seuil_alerte) if seuil_alerte else None,
|
||||||
|
)
|
||||||
|
session.add(materiel)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(materiel)
|
||||||
|
verifier_et_alerter(materiel, session)
|
||||||
|
return RedirectResponse(url="/materiels", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/materiels/{materiel_id}/modifier")
|
||||||
|
def modifier_materiel(
|
||||||
|
materiel_id: int,
|
||||||
|
nom: str = Form(...),
|
||||||
|
code_barre: str = Form(""),
|
||||||
|
quantite: int = Form(0),
|
||||||
|
seuil_alerte: str = Form(""),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Met à jour un matériel existant (nom, code-barre, quantité, seuil d'alerte)."""
|
||||||
|
materiel = session.get(Materiel, materiel_id)
|
||||||
|
if materiel:
|
||||||
|
materiel.nom = nom
|
||||||
|
materiel.code_barre = code_barre or None
|
||||||
|
materiel.quantite = quantite
|
||||||
|
materiel.seuil_alerte = int(seuil_alerte) if seuil_alerte else None
|
||||||
|
session.add(materiel)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(materiel)
|
||||||
|
verifier_et_alerter(materiel, session)
|
||||||
|
return RedirectResponse(url="/materiels", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/materiels/{materiel_id}/supprimer")
|
||||||
|
def supprimer_materiel(materiel_id: int, session: Session = Depends(get_session)):
|
||||||
|
"""Supprime définitivement un matériel du stock."""
|
||||||
|
materiel = session.get(Materiel, materiel_id)
|
||||||
|
if materiel:
|
||||||
|
session.delete(materiel)
|
||||||
|
session.commit()
|
||||||
|
return RedirectResponse(url="/materiels", status_code=303)
|
||||||
63
app/routers/scan.py
Normal file
63
app/routers/scan.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""
|
||||||
|
Routes pour l'écran de scan à la douchette code-barres.
|
||||||
|
|
||||||
|
La douchette émule un clavier : elle "tape" le code puis un retour à la
|
||||||
|
ligne (touche Entrée). Il suffit donc d'un unique <input> dans un <form> :
|
||||||
|
le navigateur soumet automatiquement le formulaire quand Entrée est
|
||||||
|
pressée, sans qu'on ait besoin d'écrire de JavaScript spécifique pour
|
||||||
|
capter le scan.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Form
|
||||||
|
from fastapi.requests import Request
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.database import get_session
|
||||||
|
from app.email_alerts import verifier_et_alerter
|
||||||
|
from app.models import Materiel
|
||||||
|
from app.templates_engine import templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scan")
|
||||||
|
def page_scan(request: Request):
|
||||||
|
"""Affiche le champ de scan, vide."""
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "scan.html", {"materiel": None, "erreur": None}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scan")
|
||||||
|
def rechercher_par_code_barre(
|
||||||
|
request: Request, code_barre: str = Form(...), session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Cherche le matériel correspondant au code-barre scanné."""
|
||||||
|
materiel = session.exec(
|
||||||
|
select(Materiel).where(Materiel.code_barre == code_barre)
|
||||||
|
).first()
|
||||||
|
erreur = None if materiel else f"Aucun matériel avec le code-barre « {code_barre} »."
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "scan.html", {"materiel": materiel, "erreur": erreur}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scan/{materiel_id}/ajuster")
|
||||||
|
def ajuster_quantite(
|
||||||
|
request: Request,
|
||||||
|
materiel_id: int,
|
||||||
|
delta: int = Form(...),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Ajuste la quantité en stock d'un matériel (+1 en réception, -1 en
|
||||||
|
sortie) puis vérifie si une alerte de stock bas doit être envoyée."""
|
||||||
|
materiel = session.get(Materiel, materiel_id)
|
||||||
|
if materiel:
|
||||||
|
materiel.quantite = max(0, materiel.quantite + delta)
|
||||||
|
session.add(materiel)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(materiel)
|
||||||
|
verifier_et_alerter(materiel, session)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "scan.html", {"materiel": materiel, "erreur": None}
|
||||||
|
)
|
||||||
95
app/static/style.css
Normal file
95
app/static/style.css
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
/* Styles minimaux, volontairement simples. */
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #2563eb;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.stock-bas {
|
||||||
|
background-color: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-entete {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formulaire-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formulaire-grille {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bouton-danger {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
border: 1px solid #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-erreur {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantite-actuelle {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#champ-scan {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, button {
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
20
app/templates/base.html
Normal file
20
app/templates/base.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block titre %}Gestion de stock IT{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="/materiels">Matériels</a>
|
||||||
|
<a href="/categories">Catégories</a>
|
||||||
|
<a href="/scan">Scanner</a>
|
||||||
|
<a href="/destinataires">Alertes</a>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
{% block contenu %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
43
app/templates/categories.html
Normal file
43
app/templates/categories.html
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block titre %}Catégories - Gestion de stock IT{% endblock %}
|
||||||
|
{% block contenu %}
|
||||||
|
<h1>Catégories</h1>
|
||||||
|
|
||||||
|
<form method="post" action="/categories" class="formulaire-inline">
|
||||||
|
<input type="text" name="nom" placeholder="Nouvelle catégorie (ex: Ordinateurs)" required>
|
||||||
|
<button type="submit">Ajouter</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% for categorie in categories %}
|
||||||
|
<section class="carte">
|
||||||
|
<div class="carte-entete">
|
||||||
|
<h2>{{ categorie.nom }}</h2>
|
||||||
|
{% if not categorie.sous_categories %}
|
||||||
|
<form method="post" action="/categories/{{ categorie.id }}/supprimer">
|
||||||
|
<button type="submit" class="bouton-danger">Supprimer</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{% for sous_categorie in categorie.sous_categories %}
|
||||||
|
<li>
|
||||||
|
{{ sous_categorie.nom }}
|
||||||
|
{% if not sous_categorie.materiels %}
|
||||||
|
<form method="post" action="/sous-categories/{{ sous_categorie.id }}/supprimer" class="formulaire-inline">
|
||||||
|
<button type="submit" class="bouton-danger">Supprimer</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form method="post" action="/categories/{{ categorie.id }}/sous-categories" class="formulaire-inline">
|
||||||
|
<input type="text" name="nom" placeholder="Nouvelle sous-catégorie" required>
|
||||||
|
<button type="submit">Ajouter</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<p>Aucune catégorie pour l'instant.</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
23
app/templates/destinataires.html
Normal file
23
app/templates/destinataires.html
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block titre %}Alertes - Gestion de stock IT{% endblock %}
|
||||||
|
{% block contenu %}
|
||||||
|
<h1>Destinataires des alertes de stock bas</h1>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{% for destinataire in destinataires %}
|
||||||
|
<li>
|
||||||
|
{{ destinataire.email }}
|
||||||
|
<form method="post" action="/destinataires/{{ destinataire.id }}/supprimer" class="formulaire-inline">
|
||||||
|
<button type="submit" class="bouton-danger">Supprimer</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li>Aucun destinataire configuré : aucune alerte ne sera envoyée.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form method="post" action="/destinataires" class="formulaire-inline">
|
||||||
|
<input type="email" name="email" placeholder="email@clinique.local" required>
|
||||||
|
<button type="submit">Ajouter</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
62
app/templates/materiels.html
Normal file
62
app/templates/materiels.html
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block titre %}Matériels - Gestion de stock IT{% endblock %}
|
||||||
|
{% block contenu %}
|
||||||
|
<h1>Matériels</h1>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nom</th>
|
||||||
|
<th>Sous-catégorie</th>
|
||||||
|
<th>Code-barre</th>
|
||||||
|
<th>Quantité</th>
|
||||||
|
<th>Seuil d'alerte</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for materiel in materiels %}
|
||||||
|
<tr class="{{ 'stock-bas' if materiel.seuil_alerte is not none and materiel.quantite < materiel.seuil_alerte else '' }}">
|
||||||
|
<td><input type="text" name="nom" value="{{ materiel.nom }}" form="materiel-{{ materiel.id }}" required></td>
|
||||||
|
<td>{{ materiel.sous_categorie.categorie.nom }} / {{ materiel.sous_categorie.nom }}</td>
|
||||||
|
<td><input type="text" name="code_barre" value="{{ materiel.code_barre or '' }}" form="materiel-{{ materiel.id }}"></td>
|
||||||
|
<td><input type="number" name="quantite" value="{{ materiel.quantite }}" min="0" form="materiel-{{ materiel.id }}" required></td>
|
||||||
|
<td><input type="number" name="seuil_alerte" value="{{ materiel.seuil_alerte if materiel.seuil_alerte is not none else '' }}" min="0" form="materiel-{{ materiel.id }}"></td>
|
||||||
|
<td>
|
||||||
|
<button type="submit" form="materiel-{{ materiel.id }}">Enregistrer</button>
|
||||||
|
<button type="submit" form="materiel-{{ materiel.id }}" formaction="/materiels/{{ materiel.id }}/supprimer" class="bouton-danger">Supprimer</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6">Aucun matériel pour l'instant.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{# Un <form> par matériel, hors du tableau : un <form> ne peut pas
|
||||||
|
entourer plusieurs <td> en HTML valide. Les champs du tableau ci-dessus
|
||||||
|
s'y rattachent via l'attribut form="materiel-{id}" (HTML5). #}
|
||||||
|
{% for materiel in materiels %}
|
||||||
|
<form id="materiel-{{ materiel.id }}" method="post" action="/materiels/{{ materiel.id }}/modifier" hidden></form>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<h2>Ajouter un matériel</h2>
|
||||||
|
{% if sous_categories %}
|
||||||
|
<form method="post" action="/materiels" class="formulaire-grille">
|
||||||
|
<label>Nom <input type="text" name="nom" required></label>
|
||||||
|
<label>Sous-catégorie
|
||||||
|
<select name="sous_categorie_id" required>
|
||||||
|
{% for sous_categorie in sous_categories %}
|
||||||
|
<option value="{{ sous_categorie.id }}">{{ sous_categorie.categorie.nom }} / {{ sous_categorie.nom }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Code-barre <input type="text" name="code_barre"></label>
|
||||||
|
<label>Quantité initiale <input type="number" name="quantite" value="0" min="0"></label>
|
||||||
|
<label>Seuil d'alerte <input type="number" name="seuil_alerte" min="0"></label>
|
||||||
|
<button type="submit">Créer</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p>Créez d'abord une <a href="/categories">catégorie et une sous-catégorie</a> avant d'ajouter un matériel.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
38
app/templates/scan.html
Normal file
38
app/templates/scan.html
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block titre %}Scanner - Gestion de stock IT{% endblock %}
|
||||||
|
{% block contenu %}
|
||||||
|
<h1>Scanner</h1>
|
||||||
|
|
||||||
|
<form method="post" action="/scan" class="formulaire-inline">
|
||||||
|
<input type="text" name="code_barre" id="champ-scan" placeholder="Scannez un code-barre..." autocomplete="off" autofocus required>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if erreur %}
|
||||||
|
<p class="message-erreur">{{ erreur }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if materiel %}
|
||||||
|
<section class="carte">
|
||||||
|
<h2>{{ materiel.nom }}</h2>
|
||||||
|
<p>{{ materiel.sous_categorie.categorie.nom }} / {{ materiel.sous_categorie.nom }}</p>
|
||||||
|
<p class="quantite-actuelle">Quantité en stock : <strong>{{ materiel.quantite }}</strong></p>
|
||||||
|
|
||||||
|
<div class="formulaire-inline">
|
||||||
|
<form method="post" action="/scan/{{ materiel.id }}/ajuster">
|
||||||
|
<input type="hidden" name="delta" value="1">
|
||||||
|
<button type="submit">+ 1 (réception)</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/scan/{{ materiel.id }}/ajuster">
|
||||||
|
<input type="hidden" name="delta" value="-1">
|
||||||
|
<button type="submit">- 1 (sortie)</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Remet le focus sur le champ de scan à chaque affichage de la page,
|
||||||
|
// pour pouvoir enchaîner les scans sans avoir à cliquer dans le champ.
|
||||||
|
document.getElementById("champ-scan").focus();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
5
app/templates_engine.py
Normal file
5
app/templates_engine.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Instance partagée du moteur de templates Jinja2, importée par chaque routeur."""
|
||||||
|
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
5
pyproject.toml
Normal file
5
pyproject.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I"]
|
||||||
4
requirements-dev.txt
Normal file
4
requirements-dev.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
-r requirements.txt
|
||||||
|
pytest>=8.3
|
||||||
|
httpx>=0.27
|
||||||
|
ruff>=0.7
|
||||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
fastapi>=0.115
|
||||||
|
uvicorn[standard]>=0.32
|
||||||
|
sqlmodel>=0.0.22
|
||||||
|
jinja2>=3.1
|
||||||
|
python-dotenv>=1.0
|
||||||
|
python-multipart>=0.0.12
|
||||||
96
scripts/seed.py
Normal file
96
scripts/seed.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
"""
|
||||||
|
Peuple la base locale (data/stock.db) avec des catégories/sous-catégories/
|
||||||
|
matériels de démonstration, incluant des codes-barres, pour pouvoir tester
|
||||||
|
l'écran de scan et le déclenchement des alertes email sans tout recréer à
|
||||||
|
la main à chaque fois.
|
||||||
|
|
||||||
|
Ne fait rien si la base contient déjà des matériels (pour ne pas dupliquer
|
||||||
|
les données à chaque exécution).
|
||||||
|
|
||||||
|
Usage : python scripts/seed.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Permet de lancer "python scripts/seed.py" directement : ajoute la racine
|
||||||
|
# du projet à sys.path pour que "import app" fonctionne quel que soit le
|
||||||
|
# répertoire courant d'où le script est appelé.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from sqlmodel import Session, select # noqa: E402
|
||||||
|
|
||||||
|
from app.database import engine, init_db # noqa: E402
|
||||||
|
from app.models import Categorie, DestinataireAlerte, Materiel, SousCategorie # noqa: E402
|
||||||
|
|
||||||
|
# (categorie, sous_categorie, [(nom, code_barre, quantite, seuil_alerte), ...])
|
||||||
|
# Les quantités sont volontairement sous le seuil pour certains matériels,
|
||||||
|
# afin de pouvoir vérifier immédiatement que les alertes se déclenchent bien.
|
||||||
|
DONNEES_DEMO = [
|
||||||
|
(
|
||||||
|
"Ordinateurs",
|
||||||
|
"Portables",
|
||||||
|
[
|
||||||
|
("PC Dell XPS 13 (modèle 9310)", "1111111111111", 3, 5),
|
||||||
|
("PC Lenovo ThinkPad T14", "1111111111112", 8, 3),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Réseau",
|
||||||
|
"Câbles",
|
||||||
|
[
|
||||||
|
("Câble RJ45 2m", "2222222222221", 42, 10),
|
||||||
|
("Câble RJ45 5m", "2222222222222", 15, 10),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Périphériques",
|
||||||
|
"Souris/claviers",
|
||||||
|
[
|
||||||
|
("Souris USB filaire", "3333333333331", 2, 5),
|
||||||
|
("Clavier USB AZERTY", "3333333333332", 6, 3),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
DESTINATAIRE_DEMO = "it-dept@clinique.local"
|
||||||
|
|
||||||
|
|
||||||
|
def peupler() -> None:
|
||||||
|
init_db()
|
||||||
|
with Session(engine) as session:
|
||||||
|
if session.exec(select(Materiel)).first():
|
||||||
|
print("La base contient déjà des matériels, rien à faire.")
|
||||||
|
return
|
||||||
|
|
||||||
|
for nom_categorie, nom_sous_categorie, materiels in DONNEES_DEMO:
|
||||||
|
categorie = Categorie(nom=nom_categorie)
|
||||||
|
session.add(categorie)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(categorie)
|
||||||
|
|
||||||
|
sous_categorie = SousCategorie(
|
||||||
|
nom=nom_sous_categorie, categorie_id=categorie.id
|
||||||
|
)
|
||||||
|
session.add(sous_categorie)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(sous_categorie)
|
||||||
|
|
||||||
|
for nom, code_barre, quantite, seuil_alerte in materiels:
|
||||||
|
session.add(
|
||||||
|
Materiel(
|
||||||
|
nom=nom,
|
||||||
|
code_barre=code_barre,
|
||||||
|
quantite=quantite,
|
||||||
|
seuil_alerte=seuil_alerte,
|
||||||
|
sous_categorie_id=sous_categorie.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(DestinataireAlerte(email=DESTINATAIRE_DEMO))
|
||||||
|
session.commit()
|
||||||
|
print("Données de démo créées : 3 catégories, 6 matériels, 1 destinataire.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
peupler()
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
34
tests/conftest.py
Normal file
34
tests/conftest.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""
|
||||||
|
Fixtures pytest partagées : une base SQLite en mémoire, isolée pour chaque
|
||||||
|
test, et un client de test FastAPI branché dessus (au lieu du fichier
|
||||||
|
data/stock.db utilisé en développement normal).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlmodel import Session, SQLModel, create_engine
|
||||||
|
from sqlmodel.pool import StaticPool
|
||||||
|
|
||||||
|
from app.database import get_session
|
||||||
|
from app.main import 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)
|
||||||
|
with Session(engine) as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="client")
|
||||||
|
def client_fixture(session: Session):
|
||||||
|
def get_session_override():
|
||||||
|
return session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_session] = get_session_override
|
||||||
|
with TestClient(app) as client:
|
||||||
|
yield client
|
||||||
|
app.dependency_overrides.clear()
|
||||||
113
tests/test_materiels.py
Normal file
113
tests/test_materiels.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""
|
||||||
|
Tests du flux principal : créer une catégorie/sous-catégorie, créer un
|
||||||
|
matériel, le retrouver par scan, ajuster son stock, et vérifier que
|
||||||
|
l'alerte de stock bas se déclenche (elle est simplement affichée dans la
|
||||||
|
console tant qu'aucun SMTP n'est configuré, voir app/email_alerts.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.models import Categorie, DestinataireAlerte, Materiel, SousCategorie
|
||||||
|
|
||||||
|
|
||||||
|
def _creer_sous_categorie(session: Session) -> SousCategorie:
|
||||||
|
categorie = Categorie(nom="Ordinateurs")
|
||||||
|
session.add(categorie)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(categorie)
|
||||||
|
|
||||||
|
sous_categorie = SousCategorie(nom="Portables", categorie_id=categorie.id)
|
||||||
|
session.add(sous_categorie)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(sous_categorie)
|
||||||
|
return sous_categorie
|
||||||
|
|
||||||
|
|
||||||
|
def test_creation_materiel(client, session):
|
||||||
|
sous_categorie = _creer_sous_categorie(session)
|
||||||
|
|
||||||
|
reponse = client.post(
|
||||||
|
"/materiels",
|
||||||
|
data={
|
||||||
|
"nom": "PC Dell XPS 13",
|
||||||
|
"sous_categorie_id": sous_categorie.id,
|
||||||
|
"code_barre": "1234567890123",
|
||||||
|
"quantite": "5",
|
||||||
|
"seuil_alerte": "2",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert reponse.status_code == 303
|
||||||
|
materiel = session.exec(select(Materiel)).first()
|
||||||
|
assert materiel is not None
|
||||||
|
assert materiel.quantite == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_trouve_le_materiel_par_code_barre(client, session):
|
||||||
|
sous_categorie = _creer_sous_categorie(session)
|
||||||
|
session.add(
|
||||||
|
Materiel(
|
||||||
|
nom="Câble RJ45 2m",
|
||||||
|
sous_categorie_id=sous_categorie.id,
|
||||||
|
code_barre="9998887776665",
|
||||||
|
quantite=10,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
reponse = client.post("/scan", data={"code_barre": "9998887776665"})
|
||||||
|
|
||||||
|
assert reponse.status_code == 200
|
||||||
|
assert "Câble RJ45 2m" in reponse.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_code_inconnu_affiche_une_erreur(client, session):
|
||||||
|
_creer_sous_categorie(session)
|
||||||
|
|
||||||
|
reponse = client.post("/scan", data={"code_barre": "0000000000000"})
|
||||||
|
|
||||||
|
assert reponse.status_code == 200
|
||||||
|
assert "Aucun matériel" in reponse.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_alerte_declenchee_sous_le_seuil(client, session, capsys):
|
||||||
|
sous_categorie = _creer_sous_categorie(session)
|
||||||
|
materiel = Materiel(
|
||||||
|
nom="Souris USB",
|
||||||
|
sous_categorie_id=sous_categorie.id,
|
||||||
|
quantite=3,
|
||||||
|
seuil_alerte=5,
|
||||||
|
)
|
||||||
|
session.add(materiel)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(materiel)
|
||||||
|
session.add(DestinataireAlerte(email="it-dept@clinique.local"))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
reponse = client.post(f"/scan/{materiel.id}/ajuster", data={"delta": "-1"})
|
||||||
|
|
||||||
|
assert reponse.status_code == 200
|
||||||
|
sortie = capsys.readouterr().out
|
||||||
|
assert "EMAIL SIMULÉ" in sortie
|
||||||
|
assert "Souris USB" in sortie
|
||||||
|
|
||||||
|
|
||||||
|
def test_pas_alerte_si_stock_suffisant(client, session, capsys):
|
||||||
|
sous_categorie = _creer_sous_categorie(session)
|
||||||
|
materiel = Materiel(
|
||||||
|
nom="Clavier USB",
|
||||||
|
sous_categorie_id=sous_categorie.id,
|
||||||
|
quantite=10,
|
||||||
|
seuil_alerte=5,
|
||||||
|
)
|
||||||
|
session.add(materiel)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(materiel)
|
||||||
|
session.add(DestinataireAlerte(email="it-dept@clinique.local"))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
client.post(f"/scan/{materiel.id}/ajuster", data={"delta": "-1"})
|
||||||
|
|
||||||
|
sortie = capsys.readouterr().out
|
||||||
|
assert "EMAIL SIMULÉ" not in sortie
|
||||||
Loading…
Reference in a new issue