Refonte des listings et arborescence de catégories à profondeur illimitée

Catégorie/SousCategorie fusionnées en un seul modèle auto-référencé
(parent_id), pour permettre une imbrication sans limite de niveaux. Les
listings matériels/catégories/destinataires passent en lignes repliées
(<details>) avec recherche côté serveur, ce qui corrige le débordement
horizontal du tableau tout-en-input et reste utilisable sur de longues
listes. Style de l'écran de scan également retravaillé (frontend-design).
This commit is contained in:
maxsoch 2026-07-14 19:06:37 +02:00
parent 8bb5c9406a
commit 7ce6540924
14 changed files with 1020 additions and 249 deletions

View file

@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 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),
du stock de matériel informatique (arborescence de 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.
@ -19,6 +19,15 @@ Contraintes du domaine, volontairement strictes :
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.
- **Catégories à profondeur illimitée.** `Categorie` est auto-référencée
(`parent_id`), pas un modèle Catégorie/Sous-catégorie à deux niveaux fixes :
une catégorie peut avoir des sous-catégories qui en ont elles-mêmes, sans
limite (ex: Réseau > Câbles > RJ45 > 2m). Voir `Categorie.chemin()`.
- **Listes potentiellement longues.** Les pages Matériels/Catégories/
Destinataires sont conçues pour rester utilisables avec beaucoup
d'éléments : recherche texte côté serveur (`?q=`), lignes repliées par
défaut (`<details>`), jamais de tableau avec un `<input>` par colonne
affiché sur toutes les lignes à la fois (ça déborde horizontalement).
## Stack
@ -58,7 +67,8 @@ 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
models.py modèles SQLModel : Categorie (arborescence auto-référencée), Materiel, DestinataireAlerte
categories_arbre.py parcours de l'arborescence (racines, aplatissement pour un <select> indenté)
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)
@ -73,10 +83,13 @@ Point d'attention Starlette : `Jinja2Templates.TemplateResponse()` prend
"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.
Les listings (`materiels.html`, `categories.html`) utilisent `<details>`/
`<summary>` pour replier les lignes/branches par défaut plutôt qu'un
tableau tout-en-input : c'est ce qui évite le débordement horizontal et
garde la page utilisable avec beaucoup d'éléments. Le formulaire d'édition
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.
## Emails d'alerte

25
app/categories_arbre.py Normal file
View file

@ -0,0 +1,25 @@
"""
Utilitaires de parcours de l'arborescence de catégories (profondeur
illimitée). Partagés entre le routeur categories (affichage de l'arbre) et
le routeur materiels (construction du <select> indenté).
"""
from sqlmodel import Session, select
from app.models import Categorie
def categories_racines(session: Session) -> list[Categorie]:
"""Catégories de premier niveau (sans parent)."""
return list(session.exec(select(Categorie).where(Categorie.parent_id.is_(None))).all())
def aplatir(categories: list[Categorie], profondeur: int = 0) -> list[tuple[int, Categorie]]:
"""Aplatit l'arborescence en une liste (profondeur, categorie), dans
l'ordre d'affichage (chaque nœud juste avant ses enfants). Sert à
construire un <select> indenté sans avoir besoin de JavaScript."""
resultat: list[tuple[int, Categorie]] = []
for categorie in categories:
resultat.append((profondeur, categorie))
resultat.extend(aplatir(categorie.enfants, profondeur + 1))
return resultat

View file

@ -7,7 +7,11 @@ 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.
Hiérarchie : Categorie (arborescence à profondeur illimitée, auto-
référencée via parent_id) -> Materiel. Une catégorie peut avoir des
sous-catégories, qui peuvent elles-mêmes en avoir, etc. (ex: "Réseau" >
"Câbles" > "RJ45" > "2m"). Un matériel se rattache à n'importe quel nœud
de l'arborescence, pas forcément une feuille.
"""
from typing import Optional
@ -16,23 +20,35 @@ from sqlmodel import Field, Relationship, SQLModel
class Categorie(SQLModel, table=True):
"""Catégorie de haut niveau (ex: 'Ordinateurs', 'Réseau', 'Périphériques')."""
"""Nœud d'une arborescence de catégories à profondeur illimitée.
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')."""
parent_id est vide pour une catégorie racine. La relation "parent" est
auto-référencée (une Categorie pointe vers une autre Categorie) : c'est
ce qui permet une profondeur arbitraire, contrairement à un modèle
Categorie/SousCategorie à deux niveaux fixes.
"""
id: Optional[int] = Field(default=None, primary_key=True)
nom: str
categorie_id: int = Field(foreign_key="categorie.id")
parent_id: Optional[int] = Field(default=None, foreign_key="categorie.id")
categorie: Optional[Categorie] = Relationship(back_populates="sous_categories")
materiels: list["Materiel"] = Relationship(back_populates="sous_categorie")
parent: Optional["Categorie"] = Relationship(
back_populates="enfants",
sa_relationship_kwargs={"remote_side": "Categorie.id"},
)
enfants: list["Categorie"] = Relationship(back_populates="parent")
materiels: list["Materiel"] = Relationship(back_populates="categorie")
def chemin(self) -> str:
"""Chemin complet depuis la racine, ex: 'Réseau / Câbles / RJ45'.
Utilisé partout on affiche une catégorie hors de son arbre
(liste de matériels, résultat de scan, recherche) pour garder le
contexte sans avoir à afficher l'arborescence entière.
"""
if self.parent is None:
return self.nom
return f"{self.parent.chemin()} / {self.nom}"
class Materiel(SQLModel, table=True):
@ -50,9 +66,9 @@ class Materiel(SQLModel, table=True):
# 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")
categorie_id: int = Field(foreign_key="categorie.id")
sous_categorie: Optional[SousCategorie] = Relationship(back_populates="materiels")
categorie: Optional[Categorie] = Relationship(back_populates="materiels")
class DestinataireAlerte(SQLModel, table=True):

View file

@ -1,65 +1,65 @@
"""Routes pour la gestion des catégories et sous-catégories de matériel."""
"""
Routes pour la gestion de l'arborescence de catégories : profondeur
illimitée, une catégorie peut avoir des sous-catégories qui peuvent
elles-mêmes en avoir, etc. (Categorie.parent_id auto-référencé).
"""
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 categories_racines
from app.database import get_session
from app.models import Categorie, SousCategorie
from app.models import Categorie
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()
def lister_categories(request: Request, q: str = "", session: Session = Depends(get_session)):
"""Sans recherche : affiche l'arborescence complète, repliable.
Avec ?q=..., bascule sur une liste plate des catégories dont le nom
correspond, chacune affichée avec son chemin complet (ex: "Réseau /
Câbles") — plus rapide pour retrouver une catégorie précise que de
déplier un arbre profond ou une longue liste de racines.
"""
if q:
toutes = session.exec(select(Categorie)).all()
q_normalise = q.lower()
resultats = [c for c in toutes if q_normalise in c.nom.lower()]
return templates.TemplateResponse(
request, "categories.html", {"categories": categories}
request, "categories.html", {"q": q, "resultats": resultats, "racines": []}
)
racines = categories_racines(session)
return templates.TemplateResponse(
request, "categories.html", {"q": "", "resultats": [], "racines": racines}
)
@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))
def creer_categorie(
nom: str = Form(...), parent_id: str = Form(""), session: Session = Depends(get_session)
):
"""Crée une catégorie : racine si parent_id est vide, sous-catégorie
(à n'importe quelle profondeur) sinon. Un seul endpoint pour les deux
cas, le formulaire d'ajout de chaque nœud de l'arbre poste juste son
propre id comme parent_id."""
session.add(Categorie(nom=nom, parent_id=int(parent_id) if parent_id else None))
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.
"""
"""Supprime une catégorie. Refuse silencieusement si elle a encore des
sous-catégories ou des matériels rattachés, pour éviter de perdre des
données par erreur en un clic."""
categorie = session.get(Categorie, categorie_id)
if categorie and not categorie.sous_categories:
if categorie and not categorie.enfants and not categorie.materiels:
session.delete(categorie)
session.commit()
return RedirectResponse(url="/categories", status_code=303)
@router.post("/categories/{categorie_id}/sous-categories")
def creer_sous_categorie(
categorie_id: int, nom: str = Form(...), session: Session = Depends(get_session)
):
"""Crée une sous-catégorie rattachée à une catégorie existante."""
session.add(SousCategorie(nom=nom, categorie_id=categorie_id))
session.commit()
return RedirectResponse(url="/categories", status_code=303)
@router.post("/sous-categories/{sous_categorie_id}/supprimer")
def supprimer_sous_categorie(
sous_categorie_id: int, session: Session = Depends(get_session)
):
"""Supprime une sous-catégorie si elle ne contient plus aucun matériel."""
sous_categorie = session.get(SousCategorie, sous_categorie_id)
if sous_categorie and not sous_categorie.materiels:
session.delete(sous_categorie)
session.commit()
return RedirectResponse(url="/categories", status_code=303)

View file

@ -13,11 +13,14 @@ 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()
def lister_destinataires(request: Request, q: str = "", session: Session = Depends(get_session)):
"""Affiche la liste des emails configurés pour recevoir les alertes,
filtrée par ?q=... si fourni (même comportement que /materiels, pour
rester cohérent si la liste grandit)."""
tous = session.exec(select(DestinataireAlerte)).all()
destinataires = [d for d in tous if q.lower() in d.email.lower()] if q else tous
return templates.TemplateResponse(
request, "destinataires.html", {"destinataires": destinataires}
request, "destinataires.html", {"destinataires": destinataires, "q": q}
)
@ -30,9 +33,7 @@ def ajouter_destinataire(email: str = Form(...), session: Session = Depends(get_
@router.post("/destinataires/{destinataire_id}/supprimer")
def supprimer_destinataire(
destinataire_id: int, session: Session = Depends(get_session)
):
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:

View file

@ -10,30 +10,44 @@ 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.database import get_session
from app.email_alerts import verifier_et_alerter
from app.models import Materiel, SousCategorie
from app.models import Materiel
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()
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()
if q:
q_normalise = q.lower()
materiels = [
m
for m in tous
if q_normalise in m.nom.lower()
or (m.code_barre and q_normalise in m.code_barre.lower())
]
else:
materiels = tous
categories_aplaties = aplatir(categories_racines(session))
return templates.TemplateResponse(
request,
"materiels.html",
{"materiels": materiels, "sous_categories": sous_categories},
{"materiels": materiels, "categories_aplaties": categories_aplaties, "q": q},
)
@router.post("/materiels")
def creer_materiel(
nom: str = Form(...),
sous_categorie_id: int = Form(...),
categorie_id: int = Form(...),
code_barre: str = Form(""),
quantite: int = Form(0),
seuil_alerte: str = Form(""),
@ -42,7 +56,7 @@ def creer_materiel(
"""Crée un nouveau type de matériel dans le stock."""
materiel = Materiel(
nom=nom,
sous_categorie_id=sous_categorie_id,
categorie_id=categorie_id,
code_barre=code_barre or None,
quantite=quantite,
seuil_alerte=int(seuil_alerte) if seuil_alerte else None,
@ -58,15 +72,17 @@ def creer_materiel(
def modifier_materiel(
materiel_id: int,
nom: str = Form(...),
categorie_id: int = 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)."""
"""Met à jour un matériel existant."""
materiel = session.get(Materiel, materiel_id)
if materiel:
materiel.nom = nom
materiel.categorie_id = categorie_id
materiel.code_barre = code_barre or None
materiel.quantite = quantite
materiel.seuil_alerte = int(seuil_alerte) if seuil_alerte else None

View file

@ -1,54 +1,314 @@
/* Styles minimaux, volontairement simples. */
/*
* Système de tokens : le langage visuel de l'app s'inspire des étiquettes
* d'inventaire et du geste du scanner code-barres (la ligne "laser" sous
* le champ de scan, les rayures de code-barres en motif de rupture).
*
* Pas de police externe (Google Fonts, etc.) : le poste peut tourner sur
* le réseau interne de la clinique, potentiellement sans accès internet.
* Le "caractère" typographique vient du poids, du tracking et de
* l'échelle plutôt que d'une police téléchargée.
*/
:root {
--papier: #f5f3ec; /* fond, blanc "étiquette" chaud */
--papier-creux: #ebe8de; /* zones en creux (input, table stripe) */
--encre: #14181c; /* texte principal, quasi noir */
--acier: #5b6470; /* texte secondaire, bordures */
--acier-clair: #d8dbdf; /* bordures discrètes */
--brique: #b8402a; /* alerte stock bas */
--labo: #2e7d6b; /* stock ok / validation */
--ambre: #c67a17; /* accent focus / scan actif */
--police-corps: -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
--police-donnee: ui-monospace, "SF Mono", "Cascadia Code", "Consolas",
"Liberation Mono", monospace;
}
* {
box-sizing: border-box;
}
body {
font-family: system-ui, sans-serif;
font-family: var(--police-corps);
max-width: 900px;
margin: 2rem auto;
margin: 0 auto 4rem;
padding: 0 1rem;
color: #1a1a1a;
color: var(--encre);
background: var(--papier);
}
h1, h2 {
font-family: var(--police-corps);
letter-spacing: -0.01em;
}
/* Focus clavier visible partout, indépendamment du style propre à chaque
contrôle (accessibilité : ne jamais s'appuyer sur la seule couleur). */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible {
outline: 2px solid var(--ambre);
outline-offset: 2px;
}
/* Motif de rupture "code-barres", réutilisé en petite touche (nav, en-tête
du scanner, étiquette de résultat) : des barres de largeurs irrégulières
en dégradé répété, sans image ni police à charger. */
.rayures-code-barre {
height: 6px;
background-image: repeating-linear-gradient(
90deg,
var(--encre) 0px, var(--encre) 2px,
transparent 2px, transparent 5px,
var(--encre) 5px, var(--encre) 6px,
transparent 6px, transparent 12px,
var(--encre) 12px, var(--encre) 15px,
transparent 15px, transparent 19px
);
opacity: 0.85;
}
nav {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
border-bottom: 1px solid #ddd;
padding-bottom: 0.75rem;
gap: 1.5rem;
align-items: center;
margin: 0 -1rem 2.5rem;
padding: 1rem 1rem 0.85rem;
background: var(--encre);
}
nav a {
text-decoration: none;
color: #2563eb;
color: var(--papier);
font-weight: 600;
font-size: 0.85rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 2rem;
nav a:hover {
color: var(--ambre);
}
th, td {
text-align: left;
padding: 0.5rem;
border-bottom: 1px solid #eee;
/*
* Disclosure native (<details>/<summary>) : utilisée partout pour replier
* par défaut ce qui n'est pas immédiatement utile (branches de
* l'arborescence, formulaire d'édition d'un matériel). Zéro JavaScript,
* et ça évite d'afficher d'un coup des centaines de champs de formulaire
* quand une liste devient longue.
*/
details > summary {
list-style: none;
cursor: pointer;
}
tr.stock-bas {
background-color: #fef2f2;
details > summary::-webkit-details-marker {
display: none;
}
.carte {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
.noeud-chevron {
flex: none;
width: 0.55em;
height: 0.55em;
border-right: 2px solid var(--acier);
border-bottom: 2px solid var(--acier);
transform: rotate(-45deg);
transition: transform 0.15s ease;
}
.carte-entete {
details[open] > summary .noeud-chevron {
transform: rotate(45deg);
}
.barre-recherche {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.barre-recherche input[type="search"] {
flex: 1;
max-width: 360px;
background: var(--papier-creux);
border: none;
}
.lien-effacer {
font-size: 0.85rem;
color: var(--acier);
white-space: nowrap;
}
.liste-vide {
color: var(--acier);
font-style: italic;
padding: 1rem 0.25rem;
}
/* Liste plate (résultats de recherche de catégories, destinataires). */
.liste-resultats {
list-style: none;
margin: 0 0 1.5rem;
padding: 0;
border-top: 1px solid var(--acier-clair);
}
.liste-resultats li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.6rem 0.25rem;
border-bottom: 1px solid var(--acier-clair);
}
.chemin-categorie {
font-family: var(--police-donnee);
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---------------------------------------------------------------------
Arborescence de catégories : profondeur illimitée, repliée par défaut.
------------------------------------------------------------------ */
.arbre-categories,
.arbre-categories ul {
list-style: none;
margin: 0;
padding: 0;
}
.arbre-categories ul {
margin-left: 0.9rem;
padding-left: 0.85rem;
border-left: 1px solid var(--acier-clair);
}
.noeud-categorie {
margin: 0.1rem 0;
}
.noeud-categorie > details > summary {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.5rem;
border-radius: 4px;
}
.noeud-categorie > details > summary:hover {
background: var(--papier-creux);
}
.noeud-nom {
font-weight: 600;
}
.noeud-compte {
font-family: var(--police-donnee);
font-size: 0.75rem;
color: var(--acier);
}
.noeud-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
padding: 0.4rem 0.5rem 0.6rem 1.9rem;
}
.formulaire-racine {
margin-bottom: 1.5rem;
}
/* ---------------------------------------------------------------------
Liste des matériels : lignes compactes en lecture seule, le formulaire
d'édition complet ne s'affiche qu'une fois la ligne dépliée. C'est ce
qui évite le débordement horizontal d'un tableau avec un <input> par
colonne sur chaque ligne, y compris avec une longue liste.
------------------------------------------------------------------ */
.liste-materiels {
list-style: none;
margin: 0 0 2rem;
padding: 0;
border-top: 1px solid var(--acier-clair);
}
.liste-materiels > li {
border-bottom: 1px solid var(--acier-clair);
}
.ligne-materiel > summary {
display: grid;
grid-template-columns: auto minmax(0, 2fr) minmax(0, 1.3fr) minmax(0, 1fr) auto;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.5rem;
}
.ligne-materiel > summary:hover {
background: var(--papier-creux);
}
.materiel-nom,
.materiel-categorie,
.materiel-code {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.materiel-nom {
font-weight: 600;
}
.materiel-categorie,
.materiel-code {
font-size: 0.8rem;
color: var(--acier);
}
.materiel-code {
font-family: var(--police-donnee);
}
.materiel-quantite {
font-family: var(--police-donnee);
font-weight: 700;
text-align: right;
min-width: 2.5ch;
}
.materiel-quantite.quantite-basse {
color: var(--brique);
}
.formulaire-edition {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem;
padding: 0.25rem 0.5rem 1.25rem 1.9rem;
}
.formulaire-edition label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--acier);
}
.formulaire-actions {
display: flex;
gap: 0.5rem;
align-items: end;
}
.formulaire-inline {
@ -65,31 +325,254 @@ tr.stock-bas {
}
.bouton-danger {
background: #fee2e2;
color: #b91c1c;
border: 1px solid #fca5a5;
background: color-mix(in srgb, var(--brique) 12%, white);
color: var(--brique);
border: 1px solid color-mix(in srgb, var(--brique) 40%, white);
}
.message-erreur {
color: #b91c1c;
}
.quantite-actuelle {
font-size: 1.25rem;
}
#champ-scan {
font-size: 1.5rem;
padding: 0.75rem;
width: 100%;
max-width: 400px;
color: var(--brique);
}
input, select, button {
font-family: inherit;
font-size: 1rem;
padding: 0.4rem;
padding: 0.5rem 0.6rem;
border: 1px solid var(--acier-clair);
border-radius: 4px;
}
button {
cursor: pointer;
border-color: var(--encre);
background: white;
}
button:hover {
background: var(--encre);
color: var(--papier);
}
/* ---------------------------------------------------------------------
Écran de scan : le seul endroit on prend un vrai risque visuel.
Le reste de l'app reste sobre pour ne pas diluer cette signature.
------------------------------------------------------------------ */
.scanner {
max-width: 480px;
margin: 0 auto;
}
.scanner-entete {
text-align: center;
margin-bottom: 2rem;
}
.scanner-eyebrow {
font-family: var(--police-donnee);
font-size: 0.75rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--acier);
margin: 0 0 0.25rem;
}
.scanner-entete h1 {
font-size: 1.75rem;
font-weight: 800;
margin: 0 0 1rem;
}
.scanner-formulaire {
position: relative;
margin-bottom: 1.5rem;
}
.scanner-label {
display: block;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--acier);
margin-bottom: 0.4rem;
}
.scanner-champ {
width: 100%;
font-family: var(--police-donnee);
font-size: 1.5rem;
letter-spacing: 0.05em;
padding: 0.9rem 1rem;
background: var(--papier-creux);
border: none;
border-radius: 4px 4px 0 0;
}
.scanner-champ::placeholder {
color: var(--acier);
opacity: 0.7;
}
/*
* La "ligne laser" : un rappel direct du geste physique du scan (le
* faisceau rouge d'une douchette code-barres). Immobile au repos, elle ne
* s'anime que quand le champ a le focus, via le sélecteur adjacent
* input:focus + .laser-ligne (pas besoin de JavaScript).
*/
.laser-ligne {
height: 3px;
background: var(--acier-clair);
border-radius: 0 0 4px 4px;
overflow: hidden;
}
.scanner-champ:focus + .laser-ligne {
background: var(--ambre);
animation: balayage-laser 1.6s ease-in-out infinite;
}
@keyframes balayage-laser {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.etiquette-erreur {
color: var(--brique);
font-weight: 600;
text-align: center;
margin-bottom: 1.5rem;
}
/*
* L'étiquette de résultat : pensée comme une vraie étiquette d'inventaire
* qu'on viendrait de scanner, avec les rayures de code-barres en tête et
* une courte animation d'apparition ("tampon" qui vient de s'imprimer).
*/
.etiquette-resultat {
background: white;
border: 1px solid var(--acier-clair);
border-radius: 4px;
overflow: hidden;
animation: apparition-etiquette 0.25s ease-out;
}
@keyframes apparition-etiquette {
from { opacity: 0; transform: scale(0.97) translateY(4px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.etiquette-rayures {
height: 10px;
}
.etiquette-resultat .etiquette-fil,
.etiquette-resultat .etiquette-nom,
.etiquette-resultat .etiquette-quantite,
.etiquette-resultat .etiquette-statut {
padding-left: 1.25rem;
padding-right: 1.25rem;
}
.etiquette-fil {
font-size: 0.75rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--acier);
margin: 1rem 0 0.15rem;
}
.etiquette-nom {
font-size: 1.3rem;
font-weight: 800;
margin: 0 0 1rem;
}
.etiquette-quantite {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.etiquette-nombre {
font-family: var(--police-donnee);
font-size: 3rem;
font-weight: 700;
line-height: 1;
}
.etiquette-unite {
color: var(--acier);
}
.etiquette-statut {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--acier);
margin: 0.6rem 0 0;
}
.statut-puce {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.statut-ok {
background: var(--labo);
}
.statut-bas {
background: var(--brique);
}
.etiquette-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--acier-clair);
margin-top: 1.25rem;
}
.etiquette-actions form {
display: contents;
}
.bouton-scan {
width: 100%;
border: none;
border-radius: 0;
padding: 0.9rem;
font-weight: 700;
letter-spacing: 0.03em;
background: white;
}
.bouton-scan:hover {
background: var(--papier-creux);
color: var(--encre);
}
.bouton-scan:active {
transform: translateY(1px);
}
.bouton-reception {
color: var(--labo);
}
.bouton-sortie {
color: var(--brique);
}
/* Respecte la préférence système : pas d'animation pour qui la désactive. */
@media (prefers-reduced-motion: reduce) {
.scanner-champ:focus + .laser-ligne {
animation: none;
}
.etiquette-resultat {
animation: none;
}
}

View file

@ -1,43 +1,87 @@
{% 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>
{#
Macro récursive : un nœud de l'arborescence (profondeur illimitée) et
ses enfants. <details> est replié par défaut pour qu'un arbre profond
ou avec beaucoup de branches reste lisible sans tout afficher d'un coup.
#}
{% macro noeud_categorie(categorie) %}
<li class="noeud-categorie">
<details>
<summary>
<span class="noeud-chevron" aria-hidden="true"></span>
<span class="noeud-nom">{{ categorie.nom }}</span>
<span class="noeud-compte">
{%- if categorie.enfants %}{{ categorie.enfants|length }} sous-cat.{% endif -%}
{%- if categorie.enfants and categorie.materiels %} · {% endif -%}
{%- if categorie.materiels %}{{ categorie.materiels|length }} matériel(s){% endif -%}
</span>
</summary>
<div class="noeud-actions">
<form method="post" action="/categories" class="formulaire-inline">
<input type="hidden" name="parent_id" value="{{ categorie.id }}">
<input type="text" name="nom" placeholder="Ajouter une sous-catégorie" 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">
</form>
{% if not categorie.enfants and not categorie.materiels %}
<form method="post" action="/categories/{{ categorie.id }}/supprimer" class="formulaire-inline">
<button type="submit" class="bouton-danger">Supprimer</button>
</form>
{% endif %}
</div>
{% if categorie.enfants %}
<ul>
{% for sous_categorie in categorie.sous_categories %}
{% for enfant in categorie.enfants %}
{{ noeud_categorie(enfant) }}
{% endfor %}
</ul>
{% endif %}
</details>
</li>
{% endmacro %}
{% block contenu %}
<h1>Catégories</h1>
<form method="get" action="/categories" class="barre-recherche">
<input type="search" name="q" value="{{ q }}" placeholder="Rechercher une catégorie…">
{% if q %}<a href="/categories" class="lien-effacer">Effacer</a>{% endif %}
</form>
<form method="post" action="/categories" class="formulaire-inline formulaire-racine">
<input type="text" name="nom" placeholder="Nouvelle catégorie racine" required>
<button type="submit">Ajouter</button>
</form>
{% if q %}
{% if resultats %}
<ul class="liste-resultats">
{% for categorie in resultats %}
<li>
{{ sous_categorie.nom }}
{% if not sous_categorie.materiels %}
<form method="post" action="/sous-categories/{{ sous_categorie.id }}/supprimer" class="formulaire-inline">
<span class="chemin-categorie">{{ categorie.chemin() }}</span>
{% if not categorie.enfants and not categorie.materiels %}
<form method="post" action="/categories/{{ 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 class="liste-vide">Aucune catégorie ne correspond à « {{ q }} ».</p>
{% endif %}
{% else %}
<p>Aucune catégorie pour l'instant.</p>
{% endfor %}
{% if racines %}
<ul class="arbre-categories">
{% for racine in racines %}
{{ noeud_categorie(racine) }}
{% endfor %}
</ul>
{% else %}
<p class="liste-vide">Aucune catégorie pour l'instant.</p>
{% endif %}
{% endif %}
{% endblock %}

View file

@ -3,16 +3,23 @@
{% block contenu %}
<h1>Destinataires des alertes de stock bas</h1>
<ul>
<form method="get" action="/destinataires" class="barre-recherche">
<input type="search" name="q" value="{{ q }}" placeholder="Rechercher un email…">
{% if q %}<a href="/destinataires" class="lien-effacer">Effacer</a>{% endif %}
</form>
<ul class="liste-resultats">
{% for destinataire in destinataires %}
<li>
{{ destinataire.email }}
<span>{{ destinataire.email }}</span>
<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>
<li class="liste-vide">
{% if q %}Aucun destinataire ne correspond à « {{ q }} ».{% else %}Aucun destinataire configuré : aucune alerte ne sera envoyée.{% endif %}
</li>
{% endfor %}
</ul>

View file

@ -3,51 +3,75 @@
{% 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>
<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>
{# 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 %}
{#
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>
<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>
{% else %}
<li class="liste-vide">
{% if q %}Aucun matériel ne correspond à « {{ q }} ».{% else %}Aucun matériel pour l'instant.{% endif %}
</li>
{% endfor %}
</ul>
<h2>Ajouter un matériel</h2>
{% if sous_categories %}
{% if categories_aplaties %}
<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>
<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>
@ -57,6 +81,6 @@
<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>
<p>Créez d'abord une <a href="/categories">catégorie</a> avant d'ajouter un matériel.</p>
{% endif %}
{% endblock %}

View file

@ -1,34 +1,75 @@
{% extends "base.html" %}
{% block titre %}Scanner - Gestion de stock IT{% endblock %}
{% block contenu %}
<h1>Scanner</h1>
<section class="scanner">
<header class="scanner-entete">
<p class="scanner-eyebrow">Poste de scan</p>
<h1>Identifier un matériel</h1>
</header>
<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>
{#
Un seul champ dans un <form> : la douchette tape le code puis Entrée,
ce qui soumet le formulaire nativement (aucun JavaScript nécessaire
pour capter le scan). La "ligne laser" sous le champ ne s'anime que
lorsqu'il a le focus (voir .scanner-champ:focus + .laser-ligne dans
style.css) : un clin d'œil au faisceau de la douchette physique.
#}
<form method="post" action="/scan" class="scanner-formulaire">
<label for="champ-scan" class="scanner-label">Code-barre</label>
<input
type="text"
name="code_barre"
id="champ-scan"
class="scanner-champ"
placeholder="En attente de scan…"
autocomplete="off"
autofocus
required
>
<div class="laser-ligne" aria-hidden="true"></div>
</form>
{% if erreur %}
<p class="etiquette-erreur">{{ erreur }}</p>
{% endif %}
{% if materiel %}
{% set sous_seuil = materiel.seuil_alerte is not none and materiel.quantite < materiel.seuil_alerte %}
<article class="etiquette-resultat">
<div class="rayures-code-barre etiquette-rayures" aria-hidden="true"></div>
<p class="etiquette-fil">{{ materiel.categorie.chemin() }}</p>
<h2 class="etiquette-nom">{{ materiel.nom }}</h2>
<div class="etiquette-quantite">
<span class="etiquette-nombre">{{ materiel.quantite }}</span>
<span class="etiquette-unite">en stock</span>
</div>
{% if materiel.seuil_alerte is not none %}
<p class="etiquette-statut">
<span class="statut-puce {{ 'statut-bas' if sous_seuil else 'statut-ok' }}"></span>
{% if sous_seuil %}
Sous le seuil d'alerte ({{ materiel.seuil_alerte }})
{% else %}
Seuil d'alerte : {{ materiel.seuil_alerte }}
{% endif %}
</p>
{% endif %}
<div class="etiquette-actions">
<form method="post" action="/scan/{{ materiel.id }}/ajuster">
<input type="hidden" name="delta" value="-1">
<button type="submit">- 1 (sortie)</button>
<button type="submit" class="bouton-scan bouton-sortie"> Sortie</button>
</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>
</div>
</article>
{% endif %}
</section>
{% endif %}
<script>
// Remet le focus sur le champ de scan à chaque affichage de la page,

View file

@ -1,8 +1,8 @@
"""
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.
Peuple la base locale (data/stock.db) avec une arborescence de catégories
et des 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).
@ -21,31 +21,31 @@ 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
from app.models import Categorie, DestinataireAlerte, Materiel # noqa: E402
# (categorie, sous_categorie, [(nom, code_barre, quantite, seuil_alerte), ...])
# Chaque branche est une liste de noms de catégories du plus général au
# plus spécifique (ex: "Réseau" > "Câbles" > "RJ45"), suivie des matériels
# rattachés au dernier niveau. La branche Réseau va jusqu'à 3 niveaux pour
# montrer que la profondeur n'est pas limitée à catégorie/sous-catégorie.
# 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",
["Ordinateurs", "Portables"],
[
("PC Dell XPS 13 (modèle 9310)", "1111111111111", 3, 5),
("PC Lenovo ThinkPad T14", "1111111111112", 8, 3),
],
),
(
"Réseau",
"Câbles",
["Réseau", "Câbles", "RJ45"],
[
("Câble RJ45 2m", "2222222222221", 42, 10),
("Câble RJ45 5m", "2222222222222", 15, 10),
],
),
(
"Périphériques",
"Souris/claviers",
["Périphériques", "Souris/claviers"],
[
("Souris USB filaire", "3333333333331", 2, 5),
("Clavier USB AZERTY", "3333333333332", 6, 3),
@ -56,6 +56,24 @@ DONNEES_DEMO = [
DESTINATAIRE_DEMO = "it-dept@clinique.local"
def _obtenir_ou_creer_branche(session: Session, noms: list[str]) -> Categorie:
"""Crée (ou réutilise) une chaîne de catégories parent -> enfant à
partir d'une liste de noms, et renvoie le dernier niveau."""
parent_id = None
categorie = None
for nom in noms:
categorie = session.exec(
select(Categorie).where(Categorie.nom == nom, Categorie.parent_id == parent_id)
).first()
if categorie is None:
categorie = Categorie(nom=nom, parent_id=parent_id)
session.add(categorie)
session.commit()
session.refresh(categorie)
parent_id = categorie.id
return categorie
def peupler() -> None:
init_db()
with Session(engine) as session:
@ -63,19 +81,8 @@ def peupler() -> None:
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 chemin_categories, materiels in DONNEES_DEMO:
categorie = _obtenir_ou_creer_branche(session, chemin_categories)
for nom, code_barre, quantite, seuil_alerte in materiels:
session.add(
Materiel(
@ -83,13 +90,13 @@ def peupler() -> None:
code_barre=code_barre,
quantite=quantite,
seuil_alerte=seuil_alerte,
sous_categorie_id=sous_categorie.id,
categorie_id=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.")
print("Données de démo créées : arborescence de catégories, 6 matériels, 1 destinataire.")
if __name__ == "__main__":

91
tests/test_categories.py Normal file
View file

@ -0,0 +1,91 @@
"""
Tests de l'arborescence de catégories à profondeur illimitée : création
sur plusieurs niveaux, chemin complet, et règles de suppression.
"""
from sqlmodel import select
from app.models import Categorie, Materiel
def test_creation_categorie_racine(client, session):
reponse = client.post("/categories", data={"nom": "Réseau"}, follow_redirects=False)
assert reponse.status_code == 303
categorie = session.exec(select(Categorie)).first()
assert categorie.nom == "Réseau"
assert categorie.parent_id is None
def test_arborescence_profondeur_illimitee(client, session):
"""Réseau > Câbles > RJ45 : 3 niveaux, pas juste catégorie/sous-catégorie."""
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)
reponse = client.post(
"/categories", data={"nom": "RJ45", "parent_id": cables.id}, follow_redirects=False
)
assert reponse.status_code == 303
rj45 = session.exec(select(Categorie).where(Categorie.nom == "RJ45")).first()
assert rj45 is not None
assert rj45.parent_id == cables.id
assert rj45.chemin() == "Réseau / Câbles / RJ45"
def test_suppression_refusee_si_enfants(client, session):
parent = Categorie(nom="Réseau")
session.add(parent)
session.commit()
session.refresh(parent)
session.add(Categorie(nom="Câbles", parent_id=parent.id))
session.commit()
client.post(f"/categories/{parent.id}/supprimer", data={})
assert session.get(Categorie, parent.id) is not None
def test_suppression_refusee_si_materiels(client, session):
categorie = Categorie(nom="Réseau")
session.add(categorie)
session.commit()
session.refresh(categorie)
session.add(Materiel(nom="Câble RJ45 2m", categorie_id=categorie.id, quantite=1))
session.commit()
client.post(f"/categories/{categorie.id}/supprimer", data={})
assert session.get(Categorie, categorie.id) is not None
def test_suppression_acceptee_si_vide(client, session):
categorie = Categorie(nom="Temporaire")
session.add(categorie)
session.commit()
session.refresh(categorie)
client.post(f"/categories/{categorie.id}/supprimer", data={})
assert session.get(Categorie, categorie.id) is None
def test_recherche_retourne_le_chemin_complet(client, session):
reseau = Categorie(nom="Réseau")
session.add(reseau)
session.commit()
session.refresh(reseau)
session.add(Categorie(nom="Câbles", parent_id=reseau.id))
session.commit()
reponse = client.get("/categories", params={"q": "Câbles"})
assert reponse.status_code == 200
assert "Réseau / Câbles" in reponse.text

View file

@ -1,5 +1,5 @@
"""
Tests du flux principal : créer une catégorie/sous-catégorie, créer un
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).
@ -7,20 +7,23 @@ 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
from app.models import Categorie, DestinataireAlerte, Materiel
def _creer_sous_categorie(session: Session) -> SousCategorie:
categorie = Categorie(nom="Ordinateurs")
session.add(categorie)
def _creer_sous_categorie(session: Session) -> Categorie:
"""Crée une petite arborescence à 2 niveaux (Ordinateurs > Portables)
pour les tests qui n'ont pas besoin de tester la profondeur illimitée
elle-même voir test_categories.py pour ça."""
racine = Categorie(nom="Ordinateurs")
session.add(racine)
session.commit()
session.refresh(categorie)
session.refresh(racine)
sous_categorie = SousCategorie(nom="Portables", categorie_id=categorie.id)
session.add(sous_categorie)
enfant = Categorie(nom="Portables", parent_id=racine.id)
session.add(enfant)
session.commit()
session.refresh(sous_categorie)
return sous_categorie
session.refresh(enfant)
return enfant
def test_creation_materiel(client, session):
@ -30,7 +33,7 @@ def test_creation_materiel(client, session):
"/materiels",
data={
"nom": "PC Dell XPS 13",
"sous_categorie_id": sous_categorie.id,
"categorie_id": sous_categorie.id,
"code_barre": "1234567890123",
"quantite": "5",
"seuil_alerte": "2",
@ -49,7 +52,7 @@ def test_scan_trouve_le_materiel_par_code_barre(client, session):
session.add(
Materiel(
nom="Câble RJ45 2m",
sous_categorie_id=sous_categorie.id,
categorie_id=sous_categorie.id,
code_barre="9998887776665",
quantite=10,
)
@ -75,7 +78,7 @@ 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,
categorie_id=sous_categorie.id,
quantite=3,
seuil_alerte=5,
)
@ -97,7 +100,7 @@ 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,
categorie_id=sous_categorie.id,
quantite=10,
seuil_alerte=5,
)