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:
maxsoch 2026-07-14 18:38:59 +02:00
commit 8bb5c9406a
32 changed files with 1263 additions and 0 deletions

15
.claude/settings.json Normal file
View 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"
}
]
}
]
}
}

View 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
View 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
View file

@ -0,0 +1,7 @@
.venv/
__pycache__/
*.pyc
data/*.db
.env
.pytest_cache/
.ruff_cache/

87
CLAUDE.md Normal file
View 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
View 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
View file

32
app/config.py Normal file
View 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
View 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
View 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
View 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
View 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
View file

65
app/routers/categories.py Normal file
View 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)

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

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

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

View 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
View 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
View 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
View file

5
pyproject.toml Normal file
View file

@ -0,0 +1,5 @@
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I"]

4
requirements-dev.txt Normal file
View file

@ -0,0 +1,4 @@
-r requirements.txt
pytest>=8.3
httpx>=0.27
ruff>=0.7

6
requirements.txt Normal file
View 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
View 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
View file

34
tests/conftest.py Normal file
View 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
View 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