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