Création rapide depuis le scan, ajustement de stock flexible, filtres matériels

Un code-barre inconnu scanné propose désormais le même formulaire de
création que /materiels au lieu d'un simple message d'erreur. La
quantité peut être ajustée d'un delta choisi librement (pas figé à ±1)
ou définie directement à une valeur absolue après un inventaire. La
racine "/" sert directement la liste des matériels sans redirection.
La page Matériels gagne un filtre par catégorie, un filtre stock bas,
et un tri incluant un regroupement par catégorie.
This commit is contained in:
maxsoch 2026-07-14 19:17:27 +02:00
parent 7ce6540924
commit 5088182f20
10 changed files with 609 additions and 111 deletions

View file

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

View file

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

View file

@ -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")

View file

@ -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,
},
)

View file

@ -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 <select> de catégorie (menu de
création rapide si le code est inconnu).
"""
contexte = {
"materiel": None,
"code_barre_introuvable": None,
"categories_aplaties": aplatir(categories_racines(session)),
}
contexte.update(kwargs)
return contexte
@router.get("/scan")
def page_scan(request: Request):
def page_scan(request: Request, session: Session = Depends(get_session)):
"""Affiche le champ de scan, vide."""
return templates.TemplateResponse(
request, "scan.html", {"materiel": None, "erreur": None}
)
return templates.TemplateResponse(request, "scan.html", _contexte(session))
@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} »."
"""Cherche le matériel correspondant au code-barre scanné.
S'il n'existe pas encore, on ne se contente pas d'un message d'erreur :
on propose directement le même formulaire de création que sur
/materiels, avec le code-barre déjà rempli, pour ne pas avoir à quitter
l'écran de scan (voir code_barre_introuvable dans scan.html).
"""
materiel = session.exec(select(Materiel).where(Materiel.code_barre == code_barre)).first()
if materiel:
return templates.TemplateResponse(
request, "scan.html", _contexte(session, materiel=materiel)
)
return templates.TemplateResponse(
request, "scan.html", {"materiel": materiel, "erreur": erreur}
request, "scan.html", _contexte(session, code_barre_introuvable=code_barre)
)
@router.post("/scan/creer")
def creer_depuis_scan(
request: Request,
nom: str = Form(...),
code_barre: str = Form(""),
categorie_id: int = Form(...),
quantite: int = Form(0),
seuil_alerte: str = Form(""),
session: Session = Depends(get_session),
):
"""Crée le matériel correspondant à un code-barre scanné qui n'existait
pas encore, puis affiche directement son étiquette (comme s'il venait
d'être scanné avec succès) — sans repasser par /materiels."""
materiel = Materiel(
nom=nom,
code_barre=code_barre or None,
categorie_id=categorie_id,
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 templates.TemplateResponse(request, "scan.html", _contexte(session, materiel=materiel))
@router.post("/scan/{materiel_id}/ajuster")
def ajuster_quantite(
request: Request,
materiel_id: int,
delta: int = Form(...),
quantite: int = Form(...),
sens: str = 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."""
"""Ajuste la quantité en stock d'un nombre choisi librement par
l'utilisateur (pas un pas fixe de ±1) : "sens" indique si on ajoute
(réception) ou retire (sortie) cette quantité."""
materiel = session.get(Materiel, materiel_id)
if materiel:
delta = quantite if sens == "ajouter" else -quantite
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}
)
return templates.TemplateResponse(request, "scan.html", _contexte(session, materiel=materiel))
@router.post("/scan/{materiel_id}/definir")
def definir_quantite(
request: Request,
materiel_id: int,
quantite: int = Form(...),
session: Session = Depends(get_session),
):
"""Fixe directement la quantité en stock à une valeur donnée, pour
corriger un écart d'inventaire sans avoir à calculer un delta."""
materiel = session.get(Materiel, materiel_id)
if materiel:
materiel.quantite = max(0, quantite)
session.add(materiel)
session.commit()
session.refresh(materiel)
verifier_et_alerter(materiel, session)
return templates.TemplateResponse(request, "scan.html", _contexte(session, materiel=materiel))

View file

@ -121,20 +121,55 @@ details[open] > summary .noeud-chevron {
transform: rotate(45deg);
}
.barre-recherche {
.barre-recherche,
.barre-filtres {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.barre-recherche input[type="search"] {
.barre-recherche input[type="search"],
.barre-filtres input[type="search"] {
flex: 1;
min-width: 200px;
max-width: 360px;
background: var(--papier-creux);
border: none;
}
.barre-filtres select {
background: var(--papier-creux);
border: none;
}
.case-stock-bas {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--acier);
white-space: nowrap;
}
.case-stock-bas input {
width: auto;
padding: 0;
}
/* En-tête de groupe quand la liste des matériels est regroupée par
catégorie (tri=categorie) : un simple séparateur textuel, pas une carte
à part entière, pour rester léger sur une longue liste. */
.groupe-categorie-entete {
font-family: var(--police-donnee);
font-size: 0.75rem;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--acier);
padding: 0.9rem 0.5rem 0.3rem;
}
.lien-effacer {
font-size: 0.85rem;
color: var(--acier);
@ -436,11 +471,21 @@ button:hover {
50% { opacity: 1; }
}
.etiquette-erreur {
/* Code scanné mais inconnu : même carte "étiquette" que le résultat d'un
scan trouvé, mais avec un formulaire de création à la place du détail
du stock pour créer le matériel sans quitter l'écran de scan. */
.etiquette-creation .etiquette-nom {
color: var(--brique);
font-weight: 600;
text-align: center;
margin-bottom: 1.5rem;
}
.etiquette-sous-titre {
padding: 0 1.25rem 1.25rem;
color: var(--acier);
}
.etiquette-formulaire {
padding: 0 1.25rem 1.25rem;
max-width: none;
}
/*
@ -528,16 +573,54 @@ button:hover {
background: var(--brique);
}
/*
* Deux gestes distincts sur le stock : "ajuster" (formulaire-ajustement,
* un delta relatif pas forcément ±1) et "définir" (formulaire-definir,
* une valeur absolue après un inventaire physique). Volontairement deux
* formulaires séparés pour ne jamais laisser un doute sur ce qu'un chiffre
* saisi représente.
*/
.etiquette-controles {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 1.25rem;
padding: 1rem 1.25rem 1.25rem;
background: var(--papier-creux);
}
.etiquette-champ-quantite {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.75rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--acier);
}
.etiquette-champ-quantite input {
font-family: var(--police-donnee);
font-size: 1.1rem;
background: white;
}
.formulaire-definir {
display: flex;
align-items: end;
gap: 0.75rem;
}
.formulaire-definir .etiquette-champ-quantite {
flex: 1;
}
.etiquette-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--acier-clair);
margin-top: 1.25rem;
}
.etiquette-actions form {
display: contents;
margin-top: 0.5rem;
}
.bouton-scan {

View file

@ -1,67 +1,112 @@
{% extends "base.html" %}
{% block titre %}Matériels - Gestion de stock IT{% endblock %}
{% block contenu %}
<h1>Matériels</h1>
<form method="get" action="/materiels" class="barre-recherche">
<input type="search" name="q" value="{{ q }}" placeholder="Rechercher par nom ou code-barre…">
{% if q %}<a href="/materiels" class="lien-effacer">Effacer</a>{% endif %}
</form>
{#
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 <input> 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.
#}
<ul class="liste-materiels">
{% for materiel in materiels %}
{% set sous_seuil = materiel.seuil_alerte is not none and materiel.quantite < materiel.seuil_alerte %}
<li>
<details class="ligne-materiel">
<summary>
<span class="noeud-chevron" aria-hidden="true"></span>
<span class="materiel-nom">{{ materiel.nom }}</span>
<span class="materiel-categorie">{{ materiel.categorie.chemin() }}</span>
<span class="materiel-code">{{ materiel.code_barre or "—" }}</span>
<span class="materiel-quantite {{ 'quantite-basse' if sous_seuil else '' }}">{{ materiel.quantite }}</span>
</summary>
{% macro ligne_materiel(materiel, categories_aplaties) %}
{% set sous_seuil = materiel.seuil_alerte is not none and materiel.quantite < materiel.seuil_alerte %}
<li>
<details class="ligne-materiel">
<summary>
<span class="noeud-chevron" aria-hidden="true"></span>
<span class="materiel-nom">{{ materiel.nom }}</span>
<span class="materiel-categorie">{{ materiel.categorie.chemin() }}</span>
<span class="materiel-code">{{ materiel.code_barre or "—" }}</span>
<span class="materiel-quantite {{ 'quantite-basse' if sous_seuil else '' }}">{{ materiel.quantite }}</span>
</summary>
<form method="post" action="/materiels/{{ materiel.id }}/modifier" class="formulaire-edition">
<label>Nom
<input type="text" name="nom" value="{{ materiel.nom }}" required>
</label>
<label>Catégorie
<select name="categorie_id" required>
{% for profondeur, categorie in categories_aplaties %}
<option value="{{ categorie.id }}" {{ "selected" if categorie.id == materiel.categorie_id else "" }}>
{{ "— " * profondeur }}{{ categorie.nom }}
</option>
{% endfor %}
</select>
</label>
<label>Code-barre
<input type="text" name="code_barre" value="{{ materiel.code_barre or '' }}">
</label>
<label>Quantité
<input type="number" name="quantite" value="{{ materiel.quantite }}" min="0" required>
</label>
<label>Seuil d'alerte
<input type="number" name="seuil_alerte" value="{{ materiel.seuil_alerte if materiel.seuil_alerte is not none else '' }}" min="0">
</label>
<div class="formulaire-actions">
<button type="submit">Enregistrer</button>
<button type="submit" formaction="/materiels/{{ materiel.id }}/supprimer" class="bouton-danger">Supprimer</button>
</div>
</form>
</details>
</li>
<form method="post" action="/materiels/{{ materiel.id }}/modifier" class="formulaire-edition">
<label>Nom
<input type="text" name="nom" value="{{ materiel.nom }}" required>
</label>
<label>Catégorie
<select name="categorie_id" required>
{% for profondeur, categorie in categories_aplaties %}
<option value="{{ categorie.id }}" {{ "selected" if categorie.id == materiel.categorie_id else "" }}>
{{ "— " * profondeur }}{{ categorie.nom }}
</option>
{% endfor %}
</select>
</label>
<label>Code-barre
<input type="text" name="code_barre" value="{{ materiel.code_barre or '' }}">
</label>
<label>Quantité
<input type="number" name="quantite" value="{{ materiel.quantite }}" min="0" required>
</label>
<label>Seuil d'alerte
<input type="number" name="seuil_alerte" value="{{ materiel.seuil_alerte if materiel.seuil_alerte is not none else '' }}" min="0">
</label>
<div class="formulaire-actions">
<button type="submit">Enregistrer</button>
<button type="submit" formaction="/materiels/{{ materiel.id }}/supprimer" class="bouton-danger">Supprimer</button>
</div>
</form>
</details>
</li>
{% endmacro %}
{% block contenu %}
<h1>Matériels</h1>
<form method="get" action="/materiels" class="barre-filtres">
<input type="search" name="q" value="{{ q }}" placeholder="Rechercher par nom ou code-barre…">
<select name="categorie_id">
<option value="">Toutes les catégories</option>
{% for profondeur, categorie in categories_aplaties %}
<option value="{{ categorie.id }}" {{ "selected" if categorie_id == categorie.id|string else "" }}>
{{ "— " * profondeur }}{{ categorie.nom }}
</option>
{% endfor %}
</select>
<select name="tri">
<option value="nom_asc" {{ "selected" if tri == "nom_asc" else "" }}>Nom (A → Z)</option>
<option value="nom_desc" {{ "selected" if tri == "nom_desc" else "" }}>Nom (Z → A)</option>
<option value="quantite_asc" {{ "selected" if tri == "quantite_asc" else "" }}>Quantité (croissant)</option>
<option value="quantite_desc" {{ "selected" if tri == "quantite_desc" else "" }}>Quantité (décroissant)</option>
<option value="categorie" {{ "selected" if tri == "categorie" else "" }}>Regrouper par catégorie</option>
</select>
<label class="case-stock-bas">
<input type="checkbox" name="stock_bas" value="1" {{ "checked" if stock_bas else "" }}>
Stock bas uniquement
</label>
<button type="submit">Filtrer</button>
{% if q or categorie_id or stock_bas or tri != "nom_asc" %}
<a href="/materiels" class="lien-effacer">Réinitialiser</a>
{% endif %}
</form>
<ul class="liste-materiels">
{% if groupes %}
{% for chemin, groupe in groupes %}
<li class="groupe-categorie-entete">{{ chemin }}</li>
{% for materiel in groupe %}
{{ ligne_materiel(materiel, categories_aplaties) }}
{% endfor %}
{% endfor %}
{% else %}
{% for materiel in materiels %}
{{ ligne_materiel(materiel, categories_aplaties) }}
{% endfor %}
{% endif %}
{% if not materiels %}
<li class="liste-vide">
{% if q %}Aucun matériel ne correspond à « {{ q }} ».{% else %}Aucun matériel pour l'instant.{% endif %}
{% if q or categorie_id or stock_bas %}Aucun matériel ne correspond à ces filtres.{% else %}Aucun matériel pour l'instant.{% endif %}
</li>
{% endfor %}
{% endif %}
</ul>
<h2>Ajouter un matériel</h2>

View file

@ -29,8 +29,39 @@
<div class="laser-ligne" aria-hidden="true"></div>
</form>
{% if erreur %}
<p class="etiquette-erreur">{{ erreur }}</p>
{% if code_barre_introuvable %}
{#
Code scanné mais aucun matériel ne le porte : au lieu d'un simple
message d'erreur, on propose le même formulaire de création rapide que
sur /materiels, avec le code-barre déjà rempli, pour ne pas avoir à
quitter l'écran de scan.
#}
<article class="etiquette-resultat etiquette-creation">
<div class="rayures-code-barre etiquette-rayures" aria-hidden="true"></div>
<p class="etiquette-fil">Code inconnu</p>
<h2 class="etiquette-nom">« {{ code_barre_introuvable }} »</h2>
{% if categories_aplaties %}
<form method="post" action="/scan/creer" class="formulaire-grille etiquette-formulaire">
<input type="hidden" name="code_barre" value="{{ code_barre_introuvable }}">
<label>Nom <input type="text" name="nom" required autofocus></label>
<label>Catégorie
<select name="categorie_id" required>
{% for profondeur, categorie in categories_aplaties %}
<option value="{{ categorie.id }}">{{ "— " * profondeur }}{{ categorie.nom }}</option>
{% endfor %}
</select>
</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 et ajouter au stock</button>
</form>
{% else %}
<p class="etiquette-sous-titre">
Créez d'abord une <a href="/categories">catégorie</a> avant de pouvoir créer ce matériel.
</p>
{% endif %}
</article>
{% endif %}
{% if materiel %}
@ -57,14 +88,30 @@
</p>
{% endif %}
<div class="etiquette-actions">
<form method="post" action="/scan/{{ materiel.id }}/ajuster">
<input type="hidden" name="delta" value="-1">
<button type="submit" class="bouton-scan bouton-sortie"> Sortie</button>
{#
Deux façons distinctes d'agir sur le stock, pour ne pas mélanger les
deux gestes : "ajuster" applique un delta (une quantité reçue ou
sortie, pas forcément 1) ; "définir" corrige directement le total
(utile après un inventaire physique, sans calculer d'écart).
#}
<div class="etiquette-controles">
<form method="post" action="/scan/{{ materiel.id }}/ajuster" class="formulaire-ajustement">
<label class="etiquette-champ-quantite">
Quantité à ajuster
<input type="number" name="quantite" value="1" min="1" required>
</label>
<div class="etiquette-actions">
<button type="submit" name="sens" value="retirer" class="bouton-scan bouton-sortie"> Retirer</button>
<button type="submit" name="sens" value="ajouter" class="bouton-scan bouton-reception">+ Ajouter</button>
</div>
</form>
<form method="post" action="/scan/{{ materiel.id }}/ajuster">
<input type="hidden" name="delta" value="1">
<button type="submit" class="bouton-scan bouton-reception">+ Réception</button>
<form method="post" action="/scan/{{ materiel.id }}/definir" class="formulaire-definir">
<label class="etiquette-champ-quantite">
Définir le stock à
<input type="number" name="quantite" value="{{ materiel.quantite }}" min="0" required>
</label>
<button type="submit">Enregistrer</button>
</form>
</div>
</article>

View file

@ -0,0 +1,113 @@
"""
Tests du filtre/tri de /materiels : recherche, filtre par catégorie (avec
ses sous-catégories), filtre stock bas, et tri (dont le regroupement par
catégorie), combinables entre eux.
"""
from sqlmodel import Session
from app.models import Categorie, Materiel
def _arborescence(session: Session) -> dict[str, Categorie]:
"""Ordinateurs > Portables, Réseau > Câbles — deux branches pour
tester le filtre par catégorie et le regroupement."""
ordinateurs = Categorie(nom="Ordinateurs")
session.add(ordinateurs)
session.commit()
session.refresh(ordinateurs)
portables = Categorie(nom="Portables", parent_id=ordinateurs.id)
session.add(portables)
session.commit()
session.refresh(portables)
reseau = Categorie(nom="Réseau")
session.add(reseau)
session.commit()
session.refresh(reseau)
cables = Categorie(nom="Câbles", parent_id=reseau.id)
session.add(cables)
session.commit()
session.refresh(cables)
return {"ordinateurs": ordinateurs, "portables": portables, "reseau": reseau, "cables": cables}
def _materiels_demo(session: Session, categories: dict[str, Categorie]) -> None:
session.add_all(
[
Materiel(
nom="PC Dell XPS 13",
categorie_id=categories["portables"].id,
quantite=2,
seuil_alerte=5,
),
Materiel(
nom="PC Lenovo T14",
categorie_id=categories["portables"].id,
quantite=8,
seuil_alerte=3,
),
Materiel(
nom="Câble RJ45 2m",
categorie_id=categories["cables"].id,
quantite=40,
seuil_alerte=10,
),
]
)
session.commit()
def test_filtre_par_categorie_inclut_les_sous_categories(client, session):
categories = _arborescence(session)
_materiels_demo(session, categories)
reponse = client.get("/materiels", params={"categorie_id": categories["ordinateurs"].id})
assert "PC Dell XPS 13" in reponse.text
assert "PC Lenovo T14" in reponse.text
assert "Câble RJ45 2m" not in reponse.text
def test_filtre_stock_bas(client, session):
categories = _arborescence(session)
_materiels_demo(session, categories)
reponse = client.get("/materiels", params={"stock_bas": "1"})
assert "PC Dell XPS 13" in reponse.text # 2 < seuil 5
assert "PC Lenovo T14" not in reponse.text # 8 >= seuil 3
assert "Câble RJ45 2m" not in reponse.text # 40 >= seuil 10
def test_tri_par_quantite_decroissant(client, session):
categories = _arborescence(session)
_materiels_demo(session, categories)
reponse = client.get("/materiels", params={"tri": "quantite_desc"})
position_cable = reponse.text.index("Câble RJ45 2m")
position_lenovo = reponse.text.index("PC Lenovo T14")
position_dell = reponse.text.index("PC Dell XPS 13")
assert position_cable < position_lenovo < position_dell
def test_tri_categorie_regroupe_les_materiels(client, session):
categories = _arborescence(session)
_materiels_demo(session, categories)
reponse = client.get("/materiels", params={"tri": "categorie"})
assert "groupe-categorie-entete" in reponse.text
assert "Ordinateurs / Portables" in reponse.text
assert "Réseau / Câbles" in reponse.text
def test_recherche_et_filtre_categorie_combines(client, session):
categories = _arborescence(session)
_materiels_demo(session, categories)
reponse = client.get("/materiels", params={"q": "PC", "categorie_id": categories["reseau"].id})
assert "Aucun matériel ne correspond" in reponse.text

View file

@ -1,8 +1,9 @@
"""
Tests du flux principal : créer une arborescence de catégories, 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).
matériel, le retrouver (ou le créer) par scan, ajuster son stock de deux
façons différentes, 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
@ -47,6 +48,14 @@ def test_creation_materiel(client, session):
assert materiel.quantite == 5
def test_racine_affiche_la_liste_des_materiels(client, session):
""" "/" doit afficher directement les matériels, pas rediriger."""
reponse = client.get("/", follow_redirects=False)
assert reponse.status_code == 200
assert "Matériels" in reponse.text
def test_scan_trouve_le_materiel_par_code_barre(client, session):
sous_categorie = _creer_sous_categorie(session)
session.add(
@ -65,13 +74,64 @@ def test_scan_trouve_le_materiel_par_code_barre(client, session):
assert "Câble RJ45 2m" in reponse.text
def test_scan_code_inconnu_affiche_une_erreur(client, session):
def test_scan_code_inconnu_propose_la_creation(client, session):
"""Un code-barre sans matériel correspondant doit proposer le
formulaire de création rapide, pas juste un message d'erreur."""
_creer_sous_categorie(session)
reponse = client.post("/scan", data={"code_barre": "0000000000000"})
assert reponse.status_code == 200
assert "Aucun matériel" in reponse.text
assert "0000000000000" in reponse.text
assert 'name="categorie_id"' in reponse.text
def test_creation_depuis_le_scan(client, session):
sous_categorie = _creer_sous_categorie(session)
reponse = client.post(
"/scan/creer",
data={
"nom": "Clavier sans fil",
"code_barre": "5551112223334",
"categorie_id": sous_categorie.id,
"quantite": "4",
},
)
assert reponse.status_code == 200
assert "Clavier sans fil" in reponse.text
materiel = session.exec(select(Materiel).where(Materiel.code_barre == "5551112223334")).first()
assert materiel is not None
assert materiel.quantite == 4
def test_ajuster_quantite_avec_un_delta_choisi(client, session):
sous_categorie = _creer_sous_categorie(session)
materiel = Materiel(nom="Écran 24 pouces", categorie_id=sous_categorie.id, quantite=10)
session.add(materiel)
session.commit()
session.refresh(materiel)
reponse = client.post(f"/scan/{materiel.id}/ajuster", data={"quantite": "4", "sens": "retirer"})
assert reponse.status_code == 200
session.refresh(materiel)
assert materiel.quantite == 6
def test_definir_quantite_directement(client, session):
sous_categorie = _creer_sous_categorie(session)
materiel = Materiel(nom="Écran 24 pouces", categorie_id=sous_categorie.id, quantite=10)
session.add(materiel)
session.commit()
session.refresh(materiel)
reponse = client.post(f"/scan/{materiel.id}/definir", data={"quantite": "17"})
assert reponse.status_code == 200
session.refresh(materiel)
assert materiel.quantite == 17
def test_alerte_declenchee_sous_le_seuil(client, session, capsys):
@ -88,7 +148,7 @@ def test_alerte_declenchee_sous_le_seuil(client, session, capsys):
session.add(DestinataireAlerte(email="it-dept@clinique.local"))
session.commit()
reponse = client.post(f"/scan/{materiel.id}/ajuster", data={"delta": "-1"})
reponse = client.post(f"/scan/{materiel.id}/ajuster", data={"quantite": "1", "sens": "retirer"})
assert reponse.status_code == 200
sortie = capsys.readouterr().out
@ -110,7 +170,8 @@ def test_pas_alerte_si_stock_suffisant(client, session, capsys):
session.add(DestinataireAlerte(email="it-dept@clinique.local"))
session.commit()
client.post(f"/scan/{materiel.id}/ajuster", data={"delta": "-1"})
reponse = client.post(f"/scan/{materiel.id}/ajuster", data={"quantite": "1", "sens": "retirer"})
assert reponse.status_code == 200
sortie = capsys.readouterr().out
assert "EMAIL SIMULÉ" not in sortie