commit 8bb5c9406a3a190eed2d0575ea9949aa9afe52fc Author: maxsoch Date: Tue Jul 14 18:38:59 2026 +0200 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. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..3a8663d --- /dev/null +++ b/.claude/settings.json @@ -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" + } + ] + } + ] + } +} diff --git a/.claude/skills/seed-demo-data/SKILL.md b/.claude/skills/seed-demo-data/SKILL.md new file mode 100644 index 0000000..511b4df --- /dev/null +++ b/.claude/skills/seed-demo-data/SKILL.md @@ -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. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..04154f1 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8ad589 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.pyc +data/*.db +.env +.pytest_cache/ +.ruff_cache/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..578bfe0 --- /dev/null +++ b/CLAUDE.md @@ -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 `` seul dans un `
` + 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 `` ne peut pas entourer plusieurs `` 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 `` +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f200db9 --- /dev/null +++ b/README.md @@ -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) +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..8537eff --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..134e12e --- /dev/null +++ b/app/database.py @@ -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 diff --git a/app/email_alerts.py b/app/email_alerts.py new file mode 100644 index 0000000..a6b2eac --- /dev/null +++ b/app/email_alerts.py @@ -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) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..10e0ede --- /dev/null +++ b/app/main.py @@ -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") diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..29de0fb --- /dev/null +++ b/app/models.py @@ -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) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/categories.py b/app/routers/categories.py new file mode 100644 index 0000000..16121ae --- /dev/null +++ b/app/routers/categories.py @@ -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) diff --git a/app/routers/destinataires.py b/app/routers/destinataires.py new file mode 100644 index 0000000..66eb949 --- /dev/null +++ b/app/routers/destinataires.py @@ -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) diff --git a/app/routers/materiels.py b/app/routers/materiels.py new file mode 100644 index 0000000..118cd1d --- /dev/null +++ b/app/routers/materiels.py @@ -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) diff --git a/app/routers/scan.py b/app/routers/scan.py new file mode 100644 index 0000000..21285c1 --- /dev/null +++ b/app/routers/scan.py @@ -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 dans un : +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} + ) diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..9a0634a --- /dev/null +++ b/app/static/style.css @@ -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; +} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..9bdd9a2 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,20 @@ + + + + + + {% block titre %}Gestion de stock IT{% endblock %} + + + + +
+ {% block contenu %}{% endblock %} +
+ + diff --git a/app/templates/categories.html b/app/templates/categories.html new file mode 100644 index 0000000..fe58fef --- /dev/null +++ b/app/templates/categories.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block titre %}Catégories - Gestion de stock IT{% endblock %} +{% block contenu %} +

Catégories

+ + + + +
+ +{% for categorie in categories %} +
+
+

{{ categorie.nom }}

+ {% if not categorie.sous_categories %} +
+ +
+ {% endif %} +
+ + + +
+ + +
+
+{% else %} +

Aucune catégorie pour l'instant.

+{% endfor %} +{% endblock %} diff --git a/app/templates/destinataires.html b/app/templates/destinataires.html new file mode 100644 index 0000000..8d8bc83 --- /dev/null +++ b/app/templates/destinataires.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block titre %}Alertes - Gestion de stock IT{% endblock %} +{% block contenu %} +

Destinataires des alertes de stock bas

+ + + +
+ + +
+{% endblock %} diff --git a/app/templates/materiels.html b/app/templates/materiels.html new file mode 100644 index 0000000..b235de4 --- /dev/null +++ b/app/templates/materiels.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% block titre %}Matériels - Gestion de stock IT{% endblock %} +{% block contenu %} +

Matériels

+ + + + + + + + + + + + + + {% for materiel in materiels %} + + + + + + + + + {% else %} + + {% endfor %} + +
NomSous-catégorieCode-barreQuantitéSeuil d'alerte
{{ materiel.sous_categorie.categorie.nom }} / {{ materiel.sous_categorie.nom }} + + +
Aucun matériel pour l'instant.
+ +{# Un
par matériel, hors du tableau : un ne peut pas + entourer plusieurs en HTML valide. Les champs du tableau ci-dessus + s'y rattachent via l'attribut form="materiel-{id}" (HTML5). #} +{% for materiel in materiels %} +
+{% endfor %} + +

Ajouter un matériel

+{% if sous_categories %} +
+ + + + + + +
+{% else %} +

Créez d'abord une catégorie et une sous-catégorie avant d'ajouter un matériel.

+{% endif %} +{% endblock %} diff --git a/app/templates/scan.html b/app/templates/scan.html new file mode 100644 index 0000000..a72b63b --- /dev/null +++ b/app/templates/scan.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block titre %}Scanner - Gestion de stock IT{% endblock %} +{% block contenu %} +

Scanner

+ +
+ +
+ +{% if erreur %} +

{{ erreur }}

+{% endif %} + +{% if materiel %} +
+

{{ materiel.nom }}

+

{{ materiel.sous_categorie.categorie.nom }} / {{ materiel.sous_categorie.nom }}

+

Quantité en stock : {{ materiel.quantite }}

+ +
+
+ + +
+
+ + +
+
+
+{% endif %} + + +{% endblock %} diff --git a/app/templates_engine.py b/app/templates_engine.py new file mode 100644 index 0000000..7d77f9c --- /dev/null +++ b/app/templates_engine.py @@ -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") diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5af0167 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..df6ee69 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest>=8.3 +httpx>=0.27 +ruff>=0.7 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5ae8cc3 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scripts/seed.py b/scripts/seed.py new file mode 100644 index 0000000..6d4b755 --- /dev/null +++ b/scripts/seed.py @@ -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() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..894e6b8 --- /dev/null +++ b/tests/conftest.py @@ -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() diff --git a/tests/test_materiels.py b/tests/test_materiels.py new file mode 100644 index 0000000..f0f299a --- /dev/null +++ b/tests/test_materiels.py @@ -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