diff --git a/CLAUDE.md b/CLAUDE.md index 25877b8..9d49dc1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,6 +91,24 @@ complet d'un matériel ne s'affiche que dans la ligne dépliée (voir `.formulaire-edition` dans `style.css`), pas sur toutes les lignes en permanence. +`/materiels` (aussi servie sur `/`, voir `main.py`) combine recherche +(`?q=`), filtre par catégorie — elle et toutes ses sous-catégories, via +`ids_sous_arbre()` — filtre stock bas (`?stock_bas=1`) et tri (`?tri=`), +tous combinables. `tri=categorie` trie par chemin de catégorie puis nom, +ce qui rend les matériels d'une même catégorie consécutifs : le +regroupement visuel (en-têtes `.groupe-categorie-entete`) vient de ce tri, +pas d'une logique de regroupement séparée à maintenir. + +L'écran de scan (`/scan`) gère 3 cas après un scan : matériel trouvé +(étiquette + contrôles de stock), code inconnu (formulaire de création +identique à celui de `/materiels`, avec le code-barre pré-rempli, pour +créer sans changer de page), ou matériel venant d'être créé (même rendu +que "trouvé"). Deux façons distinctes d'agir sur le stock, volontairement +séparées pour ne jamais laisser d'ambiguïté sur ce qu'un nombre représente : +`/scan/{id}/ajuster` applique un delta choisi (pas fixé à ±1) via un champ +« sens » (ajouter/retirer), `/scan/{id}/definir` fixe la quantité absolue +(utile après un inventaire physique). + ## Emails d'alerte Seuil configurable par matériel (`Materiel.seuil_alerte`), liste globale de diff --git a/app/categories_arbre.py b/app/categories_arbre.py index bc68b95..0c747b6 100644 --- a/app/categories_arbre.py +++ b/app/categories_arbre.py @@ -23,3 +23,14 @@ def aplatir(categories: list[Categorie], profondeur: int = 0) -> list[tuple[int, resultat.append((profondeur, categorie)) resultat.extend(aplatir(categorie.enfants, profondeur + 1)) return resultat + + +def ids_sous_arbre(categorie: Categorie) -> set[int]: + """L'id de cette catégorie et de tous ses descendants, à n'importe + quelle profondeur. Sert au filtre "cette catégorie" sur /materiels : + on veut aussi les matériels rattachés à ses sous-catégories, pas + seulement à elle-même.""" + ids = {categorie.id} + for enfant in categorie.enfants: + ids |= ids_sous_arbre(enfant) + return ids diff --git a/app/main.py b/app/main.py index 10e0ede..50be78c 100644 --- a/app/main.py +++ b/app/main.py @@ -7,7 +7,6 @@ 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 @@ -26,12 +25,9 @@ app = FastAPI(title="Gestion de stock IT", lifespan=lifespan) app.mount("/static", StaticFiles(directory="app/static"), name="static") app.include_router(categories.router) +# "/" est enregistrée directement sur le routeur materiels (voir +# lister_materiels) pour afficher la liste des matériels sans redirection +# HTTP : l'URL reste "/" plutôt que de rebondir vers "/materiels". 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/routers/materiels.py b/app/routers/materiels.py index ddbc9e6..bbc0456 100644 --- a/app/routers/materiels.py +++ b/app/routers/materiels.py @@ -5,42 +5,97 @@ 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 itertools import groupby + from fastapi import APIRouter, Depends, Form from fastapi.requests import Request from fastapi.responses import RedirectResponse from sqlmodel import Session, select -from app.categories_arbre import aplatir, categories_racines +from app.categories_arbre import aplatir, categories_racines, ids_sous_arbre from app.database import get_session from app.email_alerts import verifier_et_alerter -from app.models import Materiel +from app.models import Categorie, Materiel from app.templates_engine import templates router = APIRouter() +# Chaque clé de tri associe une fonction de clé de tri Python et un +# indicateur d'ordre inverse. "categorie" trie par chemin complet puis nom +# : les matériels d'une même catégorie se retrouvent ainsi consécutifs, +# ce qui permet de les regrouper sous un même en-tête dans le template +# sans logique de regroupement séparée à maintenir. +CLES_TRI = { + "nom_asc": (lambda m: m.nom.lower(), False), + "nom_desc": (lambda m: m.nom.lower(), True), + "quantite_asc": (lambda m: m.quantite, False), + "quantite_desc": (lambda m: m.quantite, True), + "categorie": (lambda m: (m.categorie.chemin().lower(), m.nom.lower()), False), +} + +@router.get("/") @router.get("/materiels") -def lister_materiels(request: Request, q: str = "", session: Session = Depends(get_session)): - """Liste les matériels, filtrés par nom ou code-barre si ?q=... est - fourni (recherche insensible à la casse). Indispensable dès que la - liste devient longue : pas de pagination, juste un filtre texte.""" - tous = session.exec(select(Materiel)).all() +def lister_materiels( + request: Request, + q: str = "", + categorie_id: str = "", + stock_bas: bool = False, + tri: str = "nom_asc", + session: Session = Depends(get_session), +): + """Liste les matériels avec recherche, filtre par catégorie (elle et + ses sous-catégories), filtre "stock bas uniquement" et tri — tous + combinables, indispensable dès que la liste devient longue. + + Tout est fait côté serveur en Python (pas de pagination, juste des + filtres) : à l'échelle d'un stock IT d'un département, ça reste très + largement suffisant et évite d'avoir à gérer un état côté client. + """ + materiels = list(session.exec(select(Materiel)).all()) + if q: q_normalise = q.lower() materiels = [ m - for m in tous + for m in materiels if q_normalise in m.nom.lower() or (m.code_barre and q_normalise in m.code_barre.lower()) ] - else: - materiels = tous + + if categorie_id: + categorie_selectionnee = session.get(Categorie, int(categorie_id)) + ids_valides = ids_sous_arbre(categorie_selectionnee) if categorie_selectionnee else set() + materiels = [m for m in materiels if m.categorie_id in ids_valides] + + if stock_bas: + materiels = [ + m for m in materiels if m.seuil_alerte is not None and m.quantite < m.seuil_alerte + ] + + cle, inverse = CLES_TRI.get(tri, CLES_TRI["nom_asc"]) + materiels.sort(key=cle, reverse=inverse) + + groupes = None + if tri == "categorie": + groupes = [ + (chemin, list(items)) + for chemin, items in groupby(materiels, key=lambda m: m.categorie.chemin()) + ] categories_aplaties = aplatir(categories_racines(session)) return templates.TemplateResponse( request, "materiels.html", - {"materiels": materiels, "categories_aplaties": categories_aplaties, "q": q}, + { + "materiels": materiels, + "groupes": groupes, + "categories_aplaties": categories_aplaties, + "q": q, + "categorie_id": categorie_id, + "stock_bas": stock_bas, + "tri": tri, + }, ) diff --git a/app/routers/scan.py b/app/routers/scan.py index 21285c1..5841fa0 100644 --- a/app/routers/scan.py +++ b/app/routers/scan.py @@ -12,6 +12,7 @@ from fastapi import APIRouter, Depends, Form from fastapi.requests import Request from sqlmodel import Session, select +from app.categories_arbre import aplatir, categories_racines from app.database import get_session from app.email_alerts import verifier_et_alerter from app.models import Materiel @@ -20,44 +21,112 @@ from app.templates_engine import templates router = APIRouter() +def _contexte(session: Session, **kwargs) -> dict: + """Contexte commun à toutes les réponses de l'écran de scan. + + categories_aplaties est toujours nécessaire : que le matériel scanné + existe ou non, on peut avoir besoin du - {% if q %}Effacer{% endif %} - {# + Macro pour une ligne de matériel : utilisée à la fois en affichage plat + et en affichage groupé par catégorie (tri=categorie), pour ne pas + dupliquer le HTML entre les deux. + Chaque ligne est repliée par défaut (juste le résumé, pas les champs d'édition) : avec beaucoup de matériels, afficher 5 par ligne en permanence rend la liste illisible et pousse le contenu hors de l'écran. On ne dépense la largeur d'un vrai formulaire que sur la ligne qu'on déplie. Voir aussi CLAUDE.md pour le contexte de ce choix. #} -