claude code

This commit is contained in:
2026-01-28 19:22:30 +01:00
parent f9b1d43c81
commit bdbfa4e25a
104 changed files with 9591 additions and 261 deletions

View File

@@ -0,0 +1,62 @@
# HomeStock - Variables d'environnement
# Copier ce fichier vers .env et ajuster les valeurs
# === Backend Configuration ===
# Application
APP_NAME=HomeStock
APP_VERSION=0.1.0
ENVIRONMENT=development # development | production
DEBUG=true
LOG_LEVEL=DEBUG # DEBUG | INFO | WARNING | ERROR
# Server
BACKEND_HOST=0.0.0.0
BACKEND_PORT=8000
BACKEND_RELOAD=true # Hot reload en dev
# Database
DATABASE_URL=sqlite:///./data/homestock.db
# Pour PostgreSQL (future migration) :
# DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/homestock
# CORS (Cross-Origin Resource Sharing)
CORS_ORIGINS=http://localhost:5173,http://10.0.0.50:5173
CORS_ALLOW_CREDENTIALS=false
# File Storage
UPLOAD_DIR=./uploads
MAX_UPLOAD_SIZE_MB=50
ALLOWED_EXTENSIONS=jpg,jpeg,png,gif,pdf,doc,docx
# Search (FTS5)
SEARCH_MIN_QUERY_LENGTH=2
# === Frontend Configuration ===
# API URL
VITE_API_BASE_URL=http://localhost:8000/api/v1
# App Config
VITE_APP_NAME=HomeStock
VITE_APP_VERSION=0.1.0
# === Docker Configuration ===
# Network
NETWORK_SUBNET=10.0.0.0/22
BACKEND_IP=10.0.0.50
FRONTEND_IP=10.0.0.51
# Ports
BACKEND_EXTERNAL_PORT=8000
FRONTEND_EXTERNAL_PORT=5173
# === Development ===
# Python
PYTHONUNBUFFERED=1
PYTHONDONTWRITEBYTECODE=1
# Node
NODE_ENV=development

119
.gitignore vendored
View File

@@ -0,0 +1,119 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
*.manifest
*.spec
pip-log.txt
pip-delete-this-directory.txt
.pytest_cache/
.coverage
.coverage.*
htmlcov/
.tox/
.nox/
.hypothesis/
.mypy_cache/
.dmypy.json
dmypy.json
.pyre/
.pytype/
cython_debug/
# Node.js / Frontend
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
dist/
dist-ssr/
*.local
# Environment variables
.env
.env.local
.env.*.local
.venv
venv/
ENV/
env/
env.bak/
venv.bak/
# IDE
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.idea/
*.swp
*.swo
*~
.DS_Store
# Database
*.db
*.sqlite
*.sqlite3
data/*.db
data/*.sqlite
# Uploads (fichiers utilisateur)
uploads/*
!uploads/.gitkeep
!uploads/photos/.gitkeep
!uploads/notices/.gitkeep
!uploads/factures/.gitkeep
# Logs
*.log
logs/
*.log.*
# Docker
.dockerignore
# OS
.DS_Store
Thumbs.db
# Temporary files
*.tmp
*.temp
.cache/
# Testing
.pytest_cache/
coverage/
.coverage
# Build artifacts
*.pyc
*.pyo
*.pyd
.Python
# Alembic
alembic/versions/*.pyc

229
CLAUDE.md
View File

@@ -1,37 +1,212 @@
# Consignes strictes — Claude Code
# CLAUDE.md
Ces consignes sont obligatoires pour toute intervention avec Claude Code.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
---
## 1. Lecture préalable obligatoire
- Lire `a-lire.md`, `PROJECT_CONTEXT.md`, `outils_dev_pref.md`.
- Lire `docs/ARCHITECTURE.md` avant toute décision technique.
## Vue d'ensemble du projet
## 2. Gestion des zones
- Ne jamais supprimer `<A REMPLIR - PROJET>`.
- Ajouter un exemple guidant avec la mention "a supprimer".
- Utiliser `<A COMPLETER PAR AGENT>` pour les choix techniques.
**HomeStock** est une webapp de gestion d'inventaire domestique (style Homebox) permettant de cataloguer et gérer les équipements, matériels et consommables présents au domicile.
## 3. Documentation avant code
- Aucun code tant que `product/VISION.md` et `product/BACKLOG.md` ne sont pas complétés.
- Toute décision structurante → ajouter un ADR dans `docs/adr/`.
### Domaines couverts
- Bricolage
- Informatique
- Électronique
- Cuisine
## 4. Style décriture
- Français uniquement.
- Éviter les acronymes seuls : toujours expliquer (ex: "MFA = authentification multifacteurs").
- Commentaires courts et utiles.
## 5. Découpage des tâches
- Toute tâche doit être dans `tasks/`.
- Une tâche = un objectif clair + critères dacceptation.
- Mentionner les fichiers autorisés.
## 6. Qualité
- Indiquer les tests attendus même si non exécutés.
- Mettre à jour la documentation impactée.
### Fonctionnalités principales
- Catalogue d'objets avec caractéristiques détaillées
- Gestion des photos, notices et factures
- Suivi des prix et du stock
- Gestion de l'état (en utilisation / en stock)
- Localisation précise (lieu, meuble, tiroir, boîte)
---
## Exemple (a supprimer)
- "Ajouter ADR-0002 pour le choix de base de données."
## Architecture du projet
### Structure globale
Le projet suit une architecture **monolithe modulaire** avec séparation frontend/backend :
```
/
├── backend/ # API et logique métier
├── frontend/ # Interface utilisateur
├── contracts/ # Contrats d'API (OpenAPI) et modèles de données
├── docs/ # Documentation technique et ADR
├── product/ # Documentation produit (vision, backlog, roadmap)
├── tasks/ # Fichiers de tâches pour le découpage du travail
└── scripts/ # Scripts de développement et maintenance
```
### Fichiers de contexte clés
- [a-lire.md](a-lire.md) : Instructions d'initialisation du projet
- [PROJECT_CONTEXT.md](PROJECT_CONTEXT.md) : Contexte global et contraintes
- [outils_dev_pref.md](outils_dev_pref.md) : Environnement et préférences de développement
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) : **À lire avant toute décision technique**
- [backend/CONTEXT.md](backend/CONTEXT.md) : Contexte spécifique au backend
- [frontend/CONTEXT.md](frontend/CONTEXT.md) : Contexte spécifique au frontend
---
## Workflow de développement
### 1. Documentation d'abord
**IMPORTANT** : Aucun code ne doit être écrit tant que la documentation n'est pas complétée :
- [product/VISION.md](product/VISION.md) doit être rempli
- [product/BACKLOG.md](product/BACKLOG.md) doit contenir les exigences (REQ-XXX)
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) doit être lu et compris
### 2. Gestion des zones à remplir
Le projet utilise un système de marqueurs pour identifier les sections à compléter :
- `<A REMPLIR - PROJET>` : À compléter par l'utilisateur selon le contexte du projet
- **Ne jamais supprimer** ce marqueur sans le remplacer
- Ajouter un exemple guidant avec la mention "(exemple: ... — a supprimer)"
- `<A COMPLETER PAR AGENT>` : À compléter par Claude Code lors des décisions techniques
- Remplacer par le contenu approprié
- Ajouter "complété par codex" en commentaire après remplissage
### 3. Décisions architecturales (ADR)
Toute décision structurante doit être documentée dans un ADR (Architecture Decision Record) :
- Créer un nouveau fichier dans [docs/adr/](docs/adr/)
- Utiliser le template [docs/adr/TEMPLATE.md](docs/adr/TEMPLATE.md)
- Exemples : choix de base de données, framework, pattern d'authentification, etc.
### 4. Découpage des tâches
- Toute tâche de développement doit avoir un fichier dans [tasks/](tasks/)
- Utiliser le template [tasks/TEMPLATE.md](tasks/TEMPLATE.md)
- Une tâche = un objectif clair + critères d'acceptation + fichiers concernés
---
## Commandes de développement
### Démarrage
```bash
make dev # Lancer l'environnement de développement
docker-compose up # Alternative avec Docker
```
### Tests et qualité
```bash
./scripts/test.sh # Exécuter les tests
./scripts/lint.sh # Linter le code
./scripts/fmt.sh # Formater le code
```
### Base de données
```bash
./scripts/db/backup.sh # Sauvegarder la base de données
./scripts/db/restore.sh # Restaurer une sauvegarde
```
**Note** : Ces scripts sont actuellement vides et doivent être complétés lors de l'implémentation.
---
## Conventions de code
### Langue
- **Français uniquement** pour toute la documentation et les commentaires
- Toujours expliquer les acronymes : "JWT = jeton d'authentification", "RGPD = Règlement Général sur la Protection des Données"
- Les noms de variables/fonctions peuvent être en anglais selon les conventions du langage
### Style de documentation
- Commentaires courts et utiles uniquement
- Éviter les répétitions entre code et documentation
- Privilégier la clarté à la concision
### Organisation du code
- Suivre l'organisation définie dans [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
- Respecter les conventions du [docs/STYLEGUIDE.md](docs/STYLEGUIDE.md)
- Maintenir la séparation frontend/backend stricte
---
## Contrats d'API
Le répertoire [contracts/](contracts/) contient les spécifications d'API :
- [contracts/openapi.yaml](contracts/openapi.yaml) : Spécification OpenAPI
- [contracts/data_model.md](contracts/data_model.md) : Modèle de données
- [contracts/errors.md](contracts/errors.md) : Gestion des erreurs
- [contracts/pagination.md](contracts/pagination.md) : Convention de pagination
- [contracts/import/](contracts/import/) : Schémas d'import/export
**Important** : Toute modification d'API doit mettre à jour ces contrats.
---
## Environnement technique
### Configuration
- OS : Debian 13
- IDE : VS Code
- Conteneurs : Docker 29.1.5+
- Git : Gitea (https://gitea.maison43.duckdns.org/)
- Timezone : Europe/Paris
- Réseau local : 10.0.0.0/22
### Stack technique (à finaliser)
- **Frontend** : À définir dans [frontend/CONTEXT.md](frontend/CONTEXT.md)
- **Backend** : Python ou Go (préférence indiquée dans [outils_dev_pref.md](outils_dev_pref.md))
- **Base de données** : À définir via ADR
- **Stockage fichiers** : À définir (photos, notices, factures)
### Thème UI
- Préférences : Gruvbox, Dark, Vintage - Monokai Dark
- À définir : couleurs dominantes, style UI, accessibilité
---
## Processus de contribution
### Pull Requests
- Consulter [docs/PR_CHECKLIST.md](docs/PR_CHECKLIST.md) avant de créer une PR
- Suivre le workflow défini dans [docs/WORKFLOW.md](docs/WORKFLOW.md)
- Une PR = une fonctionnalité / un fix
### Qualité
- Indiquer les tests attendus même si non implémentés
- Mettre à jour toute documentation impactée
- Vérifier que les marqueurs `<A REMPLIR>` ne sont pas supprimés par erreur
### Sécurité
- Consulter [docs/SECURITY.md](docs/SECURITY.md)
- Pas de secrets en clair dans le code
- Respecter les contraintes RGPD si applicable
---
## Ordre des opérations pour démarrer
1. **Compléter la documentation de base**
- Remplir [PROJECT_CONTEXT.md](PROJECT_CONTEXT.md)
- Compléter [product/VISION.md](product/VISION.md)
- Définir les premières exigences dans [product/BACKLOG.md](product/BACKLOG.md)
2. **Définir l'architecture technique**
- Compléter [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
- Créer les ADR pour les choix techniques majeurs
- Finaliser [backend/CONTEXT.md](backend/CONTEXT.md) et [frontend/CONTEXT.md](frontend/CONTEXT.md)
3. **Configurer l'environnement**
- Compléter [outils_dev_pref.md](outils_dev_pref.md)
- Configurer les scripts de développement
- Mettre en place Docker et docker-compose
4. **Démarrer l'implémentation**
- Créer les tâches dans [tasks/](tasks/)
- Suivre le workflow défini
- Documenter les décisions au fur et à mesure
---
## Références
- **Documentation produit** : [product/](product/)
- **Documentation technique** : [docs/](docs/)
- **Tâches** : [tasks/](tasks/)
- **Opérations** : [docs/OPERATIONS.md](docs/OPERATIONS.md)
- **Déploiement** : [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)

133
Makefile
View File

@@ -0,0 +1,133 @@
.PHONY: help dev test lint fmt clean docker-up docker-down install migrate
# Variables
BACKEND_DIR = backend
FRONTEND_DIR = frontend
PYTHON = python3
UV = uv
NPM = npm
help: ## Affiche cette aide
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
# === Développement ===
dev: ## Lance l'environnement de développement complet (backend + frontend)
docker compose up --build
dev-backend: ## Lance uniquement le backend en mode dev
cd $(BACKEND_DIR) && $(UV) run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
dev-frontend: ## Lance uniquement le frontend en mode dev
cd $(FRONTEND_DIR) && $(NPM) run dev
# === Installation ===
install: install-backend install-frontend ## Installe toutes les dépendances
install-backend: ## Installe les dépendances Python (backend)
cd $(BACKEND_DIR) && $(UV) sync
install-frontend: ## Installe les dépendances Node (frontend)
cd $(FRONTEND_DIR) && $(NPM) install
# === Tests ===
test: test-backend test-frontend ## Lance tous les tests
test-backend: ## Lance les tests backend (pytest)
cd $(BACKEND_DIR) && $(UV) run pytest
test-backend-cov: ## Lance les tests backend avec couverture
cd $(BACKEND_DIR) && $(UV) run pytest --cov=app --cov-report=html --cov-report=term
test-frontend: ## Lance les tests frontend (vitest)
cd $(FRONTEND_DIR) && $(NPM) run test
# === Linting & Formatage ===
lint: lint-backend lint-frontend ## Lance tous les linters
lint-backend: ## Lint le code backend (ruff + mypy)
cd $(BACKEND_DIR) && $(UV) run ruff check .
cd $(BACKEND_DIR) && $(UV) run mypy .
lint-frontend: ## Lint le code frontend (eslint)
cd $(FRONTEND_DIR) && $(NPM) run lint
fmt: fmt-backend fmt-frontend ## Formate tout le code
fmt-backend: ## Formate le code backend (ruff)
cd $(BACKEND_DIR) && $(UV) run ruff check --fix .
cd $(BACKEND_DIR) && $(UV) run ruff format .
fmt-frontend: ## Formate le code frontend (prettier)
cd $(FRONTEND_DIR) && $(NPM) run fmt
# === Base de données ===
migrate: ## Crée et applique une nouvelle migration Alembic
cd $(BACKEND_DIR) && $(UV) run alembic revision --autogenerate -m "$(msg)"
cd $(BACKEND_DIR) && $(UV) run alembic upgrade head
migrate-up: ## Applique les migrations en attente
cd $(BACKEND_DIR) && $(UV) run alembic upgrade head
migrate-down: ## Rollback de la dernière migration
cd $(BACKEND_DIR) && $(UV) run alembic downgrade -1
migrate-history: ## Affiche l'historique des migrations
cd $(BACKEND_DIR) && $(UV) run alembic history
db-reset: ## Réinitialise la base de données (ATTENTION: supprime toutes les données!)
rm -f data/homestock.db
$(MAKE) migrate-up
# === Docker ===
docker-up: ## Démarre les conteneurs Docker
docker compose up -d
docker-down: ## Arrête les conteneurs Docker
docker compose down
docker-logs: ## Affiche les logs des conteneurs
docker compose logs -f
docker-rebuild: ## Rebuild et redémarre les conteneurs
docker compose up --build -d
docker-clean: ## Nettoie les conteneurs, volumes et images Docker
docker compose down -v
docker system prune -f
# === Nettoyage ===
clean: ## Nettoie les fichiers temporaires
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name "node_modules" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name "dist" -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete
rm -rf $(BACKEND_DIR)/htmlcov
rm -rf $(FRONTEND_DIR)/coverage
clean-all: clean docker-clean ## Nettoie tout (fichiers + Docker)
rm -rf data/homestock.db
rm -rf uploads/*
# === Utilitaires ===
shell-backend: ## Ouvre un shell Python dans l'environnement backend
cd $(BACKEND_DIR) && $(UV) run python
shell-db: ## Ouvre un shell SQLite sur la base de données
sqlite3 data/homestock.db
check: lint test ## Vérifie la qualité du code (lint + tests)
init: install migrate-up ## Initialise le projet pour la première fois
@echo "✅ Projet initialisé avec succès!"
@echo "👉 Lancez 'make dev' pour démarrer le développement"

View File

@@ -12,17 +12,17 @@ Il sert aux agents pour comprendre les objectifs et les contraintes.
---
## Résumé
- But du projet : <A REMPLIR - PROJET> (exemple: réduire les erreurs d'inventaire — a supprimer)
- Résultat attendu : <A REMPLIR - PROJET> (exemple: tableau de bord + alertes — a supprimer)
- But du projet : Remplacer la gestion manuelle et désorganisée de l'inventaire domestique par une solution structurée et interrogeable <!-- complété par codex -->
- Résultat attendu : Application web permettant de retrouver rapidement n'importe quel objet, son emplacement exact, ses documents associés (factures, notices) et son état (stock, en utilisation) <!-- complété par codex -->
## Parties prenantes
- Décideur : <A REMPLIR - PROJET> (exemple: Directeur des opérations — a supprimer)
- Utilisateurs finaux : <A REMPLIR - PROJET> (exemple: équipe logistique — a supprimer)
- Décideur : Utilisateur final (propriétaire du domicile) <!-- complété par codex -->
- Utilisateurs finaux : Utilisateur unique (mono-utilisateur) avec possibilité d'évolution vers usage familial <!-- complété par codex -->
## Contraintes
- Budget / délai : <A REMPLIR - PROJET> (exemple: 3 mois / 20k€ — a supprimer)
- Légalité / conformité : <A REMPLIR - PROJET> (exemple: RGPD — a supprimer)
- Tech imposée : <A REMPLIR - PROJET> (exemple: PostgreSQL — a supprimer)
- Budget / délai : Projet personnel, développement itératif sans contrainte de délai strict <!-- complété par codex -->
- Légalité / conformité : Aucune contrainte légale (usage personnel, pas de données tierces) <!-- complété par codex -->
- Tech imposée : Python (backend) et React (frontend) selon préférences, SQLite pour simplicité mono-utilisateur <!-- complété par codex -->
## Références
- Vision : `product/VISION.md`

View File

@@ -1,6 +1,6 @@
# <Nom du projet>
# HomeStock
Résumé court : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
Application web self-hosted de gestion d'inventaire domestique permettant de cataloguer équipements, matériels et consommables avec localisation précise et archivage de documents. <!-- complété par codex -->
---
@@ -11,12 +11,12 @@ Résumé court : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer
---
## Objectif
- Problème : <A REMPLIR - PROJET> (exemple: suivi manuel sur tableur — a supprimer)
- Public cible : <A REMPLIR - PROJET> (exemple: PME logistique — a supprimer)
- Problème : Gestion désorganisée de l'inventaire domestique, difficulté à retrouver les objets, perte de notices/factures, absence de suivi des stocks <!-- complété par codex -->
- Public cible : Particulier gérant son domicile, domaines bricolage/informatique/électronique/cuisine <!-- complété par codex -->
## Démarrage rapide
- Prérequis : <A COMPLETER PAR AGENT>
- Lancer en local : <A COMPLETER PAR AGENT>
- Prérequis : Docker 29.1.5+, Docker Compose, ou Python 3.11+ + Node.js 20+ pour développement sans Docker <!-- complété par codex -->
- Lancer en local : `docker-compose up` ou `make dev` (en développement : backend sur :8000, frontend sur :5173) <!-- complété par codex -->
## Documentation
- Contexte : `PROJECT_CONTEXT.md`
@@ -25,9 +25,4 @@ Résumé court : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer
- Backlog : `product/BACKLOG.md`
- Workflow : `docs/WORKFLOW.md`
---
## Exemple (a supprimer)
- Résumé : application web interne pour gérer des stocks.
- Problème : suivi manuel sur tableur.
- Lancer en local : `make dev`.
---

View File

@@ -18,28 +18,28 @@ Ce dépôt est basé sur le template **template-webapp**.
## 1. Description du projet
- Nom du projet : <A REMPLIR - PROJET> (exemple: StockPilot — a supprimer)
- Type de webapp : <A REMPLIR - PROJET> (exemple: SaaS B2B — a supprimer)
- Public cible : <A REMPLIR - PROJET> (exemple: PME logistique — a supprimer)
- Objectif principal : <A REMPLIR - PROJET> (exemple: suivi des stocks en temps réel — a supprimer)
- Nom du projet : HomeStock <!-- complété par codex -->
- Type de webapp : Application web self-hosted mono-utilisateur <!-- complété par codex -->
- Public cible : Particulier gérant son inventaire domestique <!-- complété par codex -->
- Objectif principal : Cataloguer et gérer l'inventaire complet de la maison (équipements, matériels, consommables) avec suivi précis des emplacements, documents et états <!-- complété par codex -->
---
## 2. Contraintes fortes
- Self-hosted / Cloud : <A REMPLIR - PROJET> (exemple: Self-hosted — a supprimer)
- Mono-utilisateur / Multi-utilisateur : <A REMPLIR - PROJET> (exemple: Multi-utilisateur — a supprimer)
- Données sensibles : oui / non
- Contraintes légales (RGPD = Règlement Général sur la Protection des Données, etc.) : <A REMPLIR - PROJET> (exemple: RGPD + hébergement UE — a supprimer)
- Self-hosted / Cloud : Self-hosted (déploiement local sur le réseau domestique) <!-- complété par codex -->
- Mono-utilisateur / Multi-utilisateur : Mono-utilisateur <!-- complété par codex -->
- Données sensibles : Non (données personnelles mais non sensibles : inventaire domestique, factures)
- Contraintes légales : Aucune contrainte légale stricte (usage personnel, pas de traitement de données tierces) <!-- complété par codex -->
---
## 3. Stack envisagée (indicative)
- Frontend : <A COMPLETER PAR AGENT>
- Backend : <A COMPLETER PAR AGENT>
- Base de données : <A COMPLETER PAR AGENT>
- Stockage fichiers : <A COMPLETER PAR AGENT>
- Frontend : React 18+ avec Vite, TanStack Query (React Query), React Router, TailwindCSS <!-- complété par codex -->
- Backend : Python 3.11+ avec FastAPI, Pydantic pour validation, SQLAlchemy comme ORM <!-- complété par codex -->
- Base de données : SQLite (fichier local, adapté au mono-utilisateur) <!-- complété par codex -->
- Stockage fichiers : Système de fichiers local (dossier uploads/ avec organisation par type) <!-- complété par codex -->
---
@@ -59,8 +59,4 @@ Ce dépôt est basé sur le template **template-webapp**.
- Créer les premières REQ (exigences) dans `product/BACKLOG.md`
- Documenter les outils dans `outils_dev_pref.md`
---
## Exemple (a supprimer)
- Type de webapp : SaaS B2B.
- Contraintes : RGPD + hébergement UE.
---

18
amelioration.md Normal file
View File

@@ -0,0 +1,18 @@
- [ ] ajout d'un bouton setting qui donne acces aux parametres du frontend et de backend (separer le 2)
- [ ] ajout du theme gruvbox dark vintage
- [ ] ajout style icon material design ou fa
- [ ] ajout image et thumbnail dans item
- [ ] ajout notice pdf dans item
- [ ] ajout url dans item
- [ ] ajout caracteristique dans item
- [ ] ajout status integre dans item
- [ ] ajout boutique pdf dans item
- [ ] ajout menu people pour gerer les pret
- [ ] ajout people si pret selectionné pdf dans item
- [ ] popup ajout item plus large
- [ ] app responsive avec mode laptop et smartphone
- [ ] si status integre selectionné, on peut selectionner un objet parent, ex une carte pci express est integrer dans un desktop
- [ ] ajout composant dans item il peut etre assimilié a enfant mais et adaptable: ex un pc desktop peut avoir 3 emplacement pcie, 4 emplcement sata, 5 usb, 4 memoire, ... (brainstorming)
- [ ] import fichier json depuis un store ( status non assigné)

View File

@@ -12,38 +12,33 @@ Tout ce qui est indiqué ici est la référence pour les agents backend.
---
## Objectif du backend
- Problème métier couvert : <A REMPLIR - PROJET> (exemple: suivi manuel sur tableur — a supprimer)
- Responsabilités principales : <A COMPLETER PAR AGENT>
- Hors périmètre : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
- Problème métier couvert : Centraliser et structurer les données d'inventaire domestique avec recherche efficace et gestion de fichiers <!-- complété par codex -->
- Responsabilités principales : API REST CRUD (items, locations, categories), upload/stockage fichiers, recherche full-text, validation données, génération OpenAPI <!-- complété par codex -->
- Hors périmètre : Rendu frontend (SPA indépendante), authentification complexe (MVP mono-utilisateur), analytics avancées <!-- complété par codex -->
## Interfaces
- API publique (API = Interface de Programmation) : <A COMPLETER PAR AGENT>
- Authentification/autorisation : <A COMPLETER PAR AGENT>
- Intégrations externes : <A REMPLIR - PROJET> (exemple: ERP existant — a supprimer)
- API publique : REST JSON à `/api/v1/`, OpenAPI 3.0 auto-générée à `/docs`, endpoints principaux = items, locations, categories, documents, search <!-- complété par codex -->
- Authentification/autorisation : Optionnelle pour MVP (déploiement local), si activée = session cookie basique, pas de rôles complexes (mono-utilisateur) <!-- complété par codex -->
- Intégrations externes : Aucune intégration externe, système autonome <!-- complété par codex -->
## Données
- Base(s) utilisée(s) : <A COMPLETER PAR AGENT>
- Modèle de données clé : <A COMPLETER PAR AGENT>
- Stratégie de migration : <A COMPLETER PAR AGENT>
- Base(s) utilisée(s) : SQLite (fichier homestock.db) avec extension FTS5 pour recherche full-text <!-- complété par codex -->
- Modèle de données clé : Item (objet principal), Location (hiérarchie lieu/meuble/tiroir), Category (domaine), Document (fichier), relations Many-to-One et Many-to-Many <!-- complété par codex -->
- Stratégie de migration : Alembic pour migrations versionnées, auto-génération à partir des modèles SQLAlchemy, migrations réversibles up/down <!-- complété par codex -->
## Architecture interne
- Style (monolithe modulaire, hexagonal, etc.) : <A COMPLETER PAR AGENT>
- Modules principaux : <A COMPLETER PAR AGENT>
- Couche daccès aux données : <A COMPLETER PAR AGENT>
- Style : Monolithe modulaire avec séparation claire routers → services → repositories (3-layer architecture) <!-- complété par codex -->
- Modules principaux : `routers/` (endpoints FastAPI), `services/` (logique métier), `models/` (ORM SQLAlchemy), `schemas/` (Pydantic validation), `repositories/` (accès BDD) <!-- complété par codex -->
- Couche d'accès aux données : Repository pattern avec SQLAlchemy async sessions, abstraction BDD pour faciliter tests et évolution <!-- complété par codex -->
## Qualité & exploitation
- Observabilité (logs/metrics/traces = journaux/mesures/traces) : <A COMPLETER PAR AGENT>
- Tests (unitaires/intégration) : <A COMPLETER PAR AGENT>
- Performance attendue : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
- Observabilité : Logs structurés avec loguru (format JSON), endpoint `/health` pour healthcheck, pas de tracing distribué (monolithe) <!-- complété par codex -->
- Tests : pytest avec tests unitaires (services/), tests intégration (routers/ avec TestClient), fixtures pour BDD test, couverture 70%+ sur logique métier <!-- complété par codex -->
- Performance attendue : Réponse API <200ms pour GET simple, <500ms pour recherche full-text, upload fichiers <5s pour 10MB, pas de contrainte scalabilité (mono-utilisateur) <!-- complété par codex -->
## Conventions
- Organisation du code : <A COMPLETER PAR AGENT>
- Nommage : <A COMPLETER PAR AGENT>
- Gestion erreurs : <A COMPLETER PAR AGENT>
- Organisation du code : `backend/app/` racine, sous-dossiers par responsabilité (routers/, services/, models/, schemas/, repositories/), un fichier par entité <!-- complété par codex -->
- Nommage : snake_case pour tout (variables, fonctions, fichiers), préfixes get_/create_/update_/delete_ pour CRUD, suffixes _service/_repository selon couche <!-- complété par codex -->
- Gestion erreurs : HTTPException FastAPI pour erreurs API avec codes standards (400/404/500), exceptions métier custom héritant de BaseException, logging erreurs avec contexte <!-- complété par codex -->
---
## Exemple (a supprimer)
- Style : monolithe modulaire avec modules `users`, `billing`, `catalog`.
- API : REST `/api/v1` + JWT (Jeton dauthentification).
- DB : PostgreSQL, migrations via outils natifs.
---

View File

@@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1
# Image de base Python 3.11
FROM python:3.11-slim
# Variables d'environnement Python
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Installer uv (modern Python package manager)
RUN pip install uv
# Répertoire de travail
WORKDIR /app
# Copier les fichiers de dépendances
COPY pyproject.toml README.md ./
# Installer les dépendances (prod + dev pour hot-reload)
RUN uv sync
# Copier le code source
COPY . .
# Créer les répertoires nécessaires
RUN mkdir -p /app/data /app/uploads
# Exposer le port FastAPI
EXPOSE 8000
# Commande par défaut (peut être overridée par docker-compose)
# Note: --reload est activé pour le développement
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

71
backend/README.md Normal file
View File

@@ -0,0 +1,71 @@
# HomeStock Backend
Backend API pour la gestion d'inventaire domestique.
## Stack technique
- **FastAPI** : Framework web moderne et performant
- **SQLAlchemy 2.0+** : ORM avec support asynchrone
- **SQLite** : Base de données embarquée
- **Alembic** : Migrations de base de données
- **Pydantic** : Validation des données
- **Loguru** : Logging avancé
## Installation
```bash
# Avec uv (recommandé)
uv sync
# Ou avec pip
pip install -e .
```
## Lancement
```bash
# Mode développement (avec hot-reload)
uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# Ou via le Makefile depuis la racine
make dev-backend
```
## Tests
```bash
# Lancer les tests
uv run pytest
# Avec couverture
uv run pytest --cov=app --cov-report=html
```
## Migrations
```bash
# Créer une nouvelle migration
uv run alembic revision --autogenerate -m "Description"
# Appliquer les migrations
uv run alembic upgrade head
# Rollback
uv run alembic downgrade -1
```
## Structure
```
backend/
├── app/
│ ├── core/ # Configuration, database, logging
│ ├── models/ # Modèles SQLAlchemy
│ ├── schemas/ # Schémas Pydantic
│ ├── routers/ # Endpoints API
│ ├── services/ # Logique métier
│ ├── repositories/ # Accès données
│ └── utils/ # Utilitaires
├── alembic/ # Migrations
└── tests/ # Tests
```

69
backend/alembic.ini Normal file
View File

@@ -0,0 +1,69 @@
# Configuration Alembic pour les migrations de base de données
# Documentation : https://alembic.sqlalchemy.org/en/latest/tutorial.html
[alembic]
# Chemin vers le dossier des migrations
script_location = alembic
# Template utilisé pour générer les fichiers de migration
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# prepend_sys_path = .
# Fuseau horaire pour les timestamps des migrations
# timezone = UTC
# Longueur maximale des caractères pour le slug du nom de fichier de migration
# truncate_slug_length = 40
# Nom de la branche principale (pour multi-branches)
# version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# URL de connexion à la base de données
# Note : Cette valeur sera overridée par env.py qui lit depuis config.py
sqlalchemy.url = sqlite:///./data/homestock.db
# Encode pour les migrations Python
# output_encoding = utf-8
[post_write_hooks]
# Hook pour formatter automatiquement les migrations avec ruff
hooks = ruff
ruff.type = console_scripts
ruff.entrypoint = ruff
ruff.options = format REVISION_SCRIPT_FILENAME
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

99
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,99 @@
"""Environnement Alembic pour les migrations de base de données.
Ce fichier configure et exécute les migrations SQLAlchemy avec Alembic.
Il supporte les migrations synchrones et asynchrones.
"""
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Import de la configuration et des modèles
from app.core.config import settings
from app.core.database import Base
# Import explicite de tous les modèles pour autogenerate
import app.models # noqa: F401
# Configuration Alembic
config = context.config
# Interpréter le fichier de configuration pour le logging Python
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Métadonnées des modèles pour autogenerate
target_metadata = Base.metadata
# Override de l'URL de connexion depuis settings
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
def run_migrations_offline() -> None:
"""Exécute les migrations en mode 'offline'.
Configure le contexte avec uniquement une URL sans créer d'Engine.
Les commandes SQL sont émises vers un fichier script au lieu d'être
exécutées directement sur la base de données.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True, # Détecte les changements de types
compare_server_default=True, # Détecte les changements de valeurs par défaut
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
"""Exécute les migrations avec une connexion donnée."""
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Exécute les migrations en mode asynchrone.
Crée un moteur asynchrone et exécute les migrations.
"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Exécute les migrations en mode 'online'.
Crée un Engine et associe une connexion au contexte.
"""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
"""Applique la migration (passage à la version suivante)."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Annule la migration (retour à la version précédente)."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,107 @@
"""Initial migration: create all tables
Revision ID: 8ba5962640dd
Revises:
Create Date: 2026-01-27 21:22:27.022127
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8ba5962640dd'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Applique la migration (passage à la version suivante)."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('categories',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('color', sa.String(length=7), nullable=True),
sa.Column('icon', sa.String(length=50), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=True)
op.create_table('locations',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('type', sa.Enum('ROOM', 'FURNITURE', 'DRAWER', 'BOX', name='locationtype', native_enum=False, length=20), nullable=False),
sa.Column('parent_id', sa.Integer(), nullable=True),
sa.Column('path', sa.String(length=500), nullable=False),
sa.Column('description', sa.String(length=500), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['parent_id'], ['locations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_locations_name'), 'locations', ['name'], unique=False)
op.create_index(op.f('ix_locations_parent_id'), 'locations', ['parent_id'], unique=False)
op.create_index(op.f('ix_locations_path'), 'locations', ['path'], unique=False)
op.create_table('items',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('status', sa.Enum('IN_STOCK', 'IN_USE', 'BROKEN', 'SOLD', 'LENT', name='itemstatus', native_enum=False, length=20), nullable=False),
sa.Column('brand', sa.String(length=100), nullable=True),
sa.Column('model', sa.String(length=100), nullable=True),
sa.Column('serial_number', sa.String(length=100), nullable=True),
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('purchase_date', sa.Date(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('category_id', sa.Integer(), nullable=False),
sa.Column('location_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ondelete='RESTRICT'),
sa.ForeignKeyConstraint(['location_id'], ['locations.id'], ondelete='RESTRICT'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('serial_number')
)
op.create_index(op.f('ix_items_category_id'), 'items', ['category_id'], unique=False)
op.create_index(op.f('ix_items_location_id'), 'items', ['location_id'], unique=False)
op.create_index(op.f('ix_items_name'), 'items', ['name'], unique=False)
op.create_table('documents',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('original_name', sa.String(length=255), nullable=False),
sa.Column('type', sa.Enum('PHOTO', 'MANUAL', 'INVOICE', 'WARRANTY', 'OTHER', name='documenttype', native_enum=False, length=20), nullable=False),
sa.Column('mime_type', sa.String(length=100), nullable=False),
sa.Column('size_bytes', sa.Integer(), nullable=False),
sa.Column('file_path', sa.String(length=500), nullable=False),
sa.Column('description', sa.String(length=500), nullable=True),
sa.Column('item_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['item_id'], ['items.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('filename')
)
op.create_index(op.f('ix_documents_item_id'), 'documents', ['item_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Annule la migration (retour à la version précédente)."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_documents_item_id'), table_name='documents')
op.drop_table('documents')
op.drop_index(op.f('ix_items_name'), table_name='items')
op.drop_index(op.f('ix_items_location_id'), table_name='items')
op.drop_index(op.f('ix_items_category_id'), table_name='items')
op.drop_table('items')
op.drop_index(op.f('ix_locations_path'), table_name='locations')
op.drop_index(op.f('ix_locations_parent_id'), table_name='locations')
op.drop_index(op.f('ix_locations_name'), table_name='locations')
op.drop_table('locations')
op.drop_index(op.f('ix_categories_name'), table_name='categories')
op.drop_table('categories')
# ### end Alembic commands ###

View File

@@ -0,0 +1,30 @@
"""add_url_to_items
Revision ID: ee8035073398
Revises: 8ba5962640dd
Create Date: 2026-01-28 18:17:51.225223
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ee8035073398'
down_revision = '8ba5962640dd'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Applique la migration (passage à la version suivante)."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('items', sa.Column('url', sa.String(length=500), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Annule la migration (retour à la version précédente)."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('items', 'url')
# ### end Alembic commands ###

0
backend/app/__init__.py Normal file
View File

View File

121
backend/app/core/config.py Normal file
View File

@@ -0,0 +1,121 @@
"""Configuration de l'application HomeStock.
Utilise Pydantic Settings pour charger et valider les variables d'environnement.
Documentation : https://docs.pydantic.dev/latest/concepts/pydantic_settings/
"""
from functools import lru_cache
from typing import Literal
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Configuration globale de l'application.
Les valeurs sont chargées depuis les variables d'environnement ou le fichier .env.
"""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# === Application ===
APP_NAME: str = Field(default="HomeStock", description="Nom de l'application")
APP_VERSION: str = Field(default="0.1.0", description="Version de l'application")
ENVIRONMENT: Literal["development", "production"] = Field(
default="development", description="Environnement d'exécution"
)
DEBUG: bool = Field(default=True, description="Mode debug")
LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field(
default="DEBUG", description="Niveau de log"
)
# === Serveur ===
BACKEND_HOST: str = Field(default="0.0.0.0", description="Host du serveur backend")
BACKEND_PORT: int = Field(default=8000, description="Port du serveur backend")
BACKEND_RELOAD: bool = Field(
default=True, description="Hot reload en développement"
)
# === Base de données ===
DATABASE_URL: str = Field(
default="sqlite+aiosqlite:///./data/homestock.db",
description="URL de connexion à la base de données",
)
@field_validator("DATABASE_URL")
@classmethod
def validate_database_url(cls, v: str) -> str:
"""Valide et normalise l'URL de la base de données."""
# Pour SQLite, s'assurer que le driver async est utilisé
if v.startswith("sqlite:///"):
return v.replace("sqlite:///", "sqlite+aiosqlite:///")
return v
# === CORS ===
CORS_ORIGINS: str = Field(
default="http://localhost:5173,http://10.0.0.50:5173",
description="Origines autorisées pour CORS (séparées par des virgules)",
)
CORS_ALLOW_CREDENTIALS: bool = Field(
default=False, description="Autorise les credentials CORS"
)
@property
def cors_origins_list(self) -> list[str]:
"""Retourne la liste des origines CORS autorisées."""
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
# === Stockage fichiers ===
UPLOAD_DIR: str = Field(default="./uploads", description="Répertoire des uploads")
MAX_UPLOAD_SIZE_MB: int = Field(
default=50, description="Taille max des uploads en Mo"
)
ALLOWED_EXTENSIONS: str = Field(
default="jpg,jpeg,png,gif,pdf,doc,docx",
description="Extensions de fichiers autorisées (séparées par des virgules)",
)
@property
def allowed_extensions_list(self) -> list[str]:
"""Retourne la liste des extensions autorisées."""
return [ext.strip().lower() for ext in self.ALLOWED_EXTENSIONS.split(",")]
@property
def max_upload_size_bytes(self) -> int:
"""Retourne la taille max en octets."""
return self.MAX_UPLOAD_SIZE_MB * 1024 * 1024
# === Recherche ===
SEARCH_MIN_QUERY_LENGTH: int = Field(
default=2, description="Longueur minimale des requêtes de recherche"
)
# === Propriétés calculées ===
@property
def is_development(self) -> bool:
"""Indique si l'environnement est en développement."""
return self.ENVIRONMENT == "development"
@property
def is_production(self) -> bool:
"""Indique si l'environnement est en production."""
return self.ENVIRONMENT == "production"
@lru_cache
def get_settings() -> Settings:
"""Retourne l'instance singleton de la configuration.
Utilise lru_cache pour ne charger la configuration qu'une seule fois.
"""
return Settings()
# Instance globale de configuration
settings = get_settings()

View File

@@ -0,0 +1,99 @@
"""Configuration de la base de données avec SQLAlchemy.
Utilise SQLAlchemy 2.0+ avec support asynchrone (aiosqlite pour SQLite).
Documentation : https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html
"""
from collections.abc import AsyncGenerator
from typing import Any
from sqlalchemy import event
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
class Base(DeclarativeBase):
"""Classe de base pour tous les modèles SQLAlchemy."""
pass
# Création du moteur asynchrone
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG, # Log SQL en mode debug
future=True,
# Pool de connexions pour SQLite
pool_pre_ping=True, # Vérifie la connexion avant utilisation
)
# Configuration spécifique pour SQLite (activation des foreign keys)
@event.listens_for(engine.sync_engine, "connect")
def set_sqlite_pragma(dbapi_conn: Any, connection_record: Any) -> None:
"""Active les contraintes de clés étrangères pour SQLite.
SQLite désactive par défaut les foreign keys, il faut les activer manuellement.
"""
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
# Session factory pour créer des sessions asynchrones
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False, # Ne pas expirer les objets après commit
autocommit=False,
autoflush=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Générateur de session de base de données pour FastAPI.
Utilisé comme dépendance FastAPI pour injecter une session dans les routes.
Usage:
@router.get("/items")
async def get_items(db: AsyncSession = Depends(get_db)):
...
Yields:
AsyncSession: Session SQLAlchemy asynchrone
"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db() -> None:
"""Initialise la base de données.
Crée toutes les tables définies dans les modèles.
À utiliser uniquement en développement ou pour les tests.
En production, utiliser Alembic pour les migrations.
"""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def close_db() -> None:
"""Ferme proprement les connexions à la base de données.
À appeler lors de l'arrêt de l'application.
"""
await engine.dispose()

105
backend/app/core/logging.py Normal file
View File

@@ -0,0 +1,105 @@
"""Configuration du système de logging avec Loguru.
Loguru est une bibliothèque de logging moderne et simple à utiliser.
Documentation : https://loguru.readthedocs.io/
"""
import sys
from pathlib import Path
from loguru import logger
from app.core.config import settings
# Supprimer les handlers par défaut de loguru
logger.remove()
def setup_logging() -> None:
"""Configure le système de logging pour l'application.
- En développement : logs dans stdout + fichier debug
- En production : logs dans fichiers avec rotation
"""
# Format de log avec couleurs pour stdout
log_format = (
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
"<level>{message}</level>"
)
# Format de log sans couleurs pour fichiers
log_format_file = (
"{time:YYYY-MM-DD HH:mm:ss.SSS} | "
"{level: <8} | "
"{name}:{function}:{line} - "
"{message}"
)
# === Configuration commune ===
# Handler pour stdout (console)
logger.add(
sys.stdout,
format=log_format,
level=settings.LOG_LEVEL,
colorize=True,
backtrace=True, # Affiche le traceback complet des exceptions
diagnose=settings.DEBUG, # Affiche les valeurs des variables en debug
)
# Créer le répertoire des logs s'il n'existe pas
log_dir = Path("logs")
log_dir.mkdir(exist_ok=True)
# === Configuration par environnement ===
if settings.is_development:
# En développement : logs détaillés dans un fichier
logger.add(
"logs/homestock_dev.log",
format=log_format_file,
level="DEBUG",
rotation="10 MB", # Rotation tous les 10 Mo
retention="7 days", # Garde les logs 7 jours
compression="zip", # Compression des logs archivés
backtrace=True,
diagnose=True,
)
else:
# En production : logs normaux + logs d'erreurs séparés
logger.add(
"logs/homestock.log",
format=log_format_file,
level="INFO",
rotation="50 MB",
retention="30 days",
compression="zip",
backtrace=True,
diagnose=False,
)
# Fichier séparé pour les erreurs
logger.add(
"logs/homestock_errors.log",
format=log_format_file,
level="ERROR",
rotation="50 MB",
retention="90 days", # Garde les erreurs plus longtemps
compression="zip",
backtrace=True,
diagnose=False,
)
logger.info(f"Logging configuré (level={settings.LOG_LEVEL}, env={settings.ENVIRONMENT})")
def get_logger(name: str) -> "logger":
"""Retourne un logger avec un nom spécifique.
Args:
name: Nom du logger (généralement __name__ du module)
Returns:
Logger configuré avec le nom spécifié
"""
return logger.bind(name=name)

126
backend/app/main.py Normal file
View File

@@ -0,0 +1,126 @@
"""Point d'entrée de l'application HomeStock.
Application FastAPI pour la gestion d'inventaire domestique.
"""
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from loguru import logger
from app.core.config import settings
from app.core.database import close_db, init_db
from app.core.logging import setup_logging
# Configuration du logging
setup_logging()
@asynccontextmanager
async def lifespan(app: FastAPI) -> Any:
"""Gère le cycle de vie de l'application.
- Startup : Initialise la base de données
- Shutdown : Ferme les connexions proprement
"""
logger.info("Démarrage de l'application HomeStock")
logger.info(f"Environnement: {settings.ENVIRONMENT}")
logger.info(f"Version: {settings.APP_VERSION}")
# Initialisation de la base de données
# Note: En production, utiliser Alembic pour les migrations
if settings.is_development:
logger.debug("Initialisation de la base de données (mode développement)")
await init_db()
yield
# Nettoyage
logger.info("Arrêt de l'application HomeStock")
await close_db()
# Création de l'application FastAPI
app = FastAPI(
title=settings.APP_NAME,
description="API pour la gestion d'inventaire domestique",
version=settings.APP_VERSION,
docs_url="/api/docs" if settings.is_development else None, # Swagger UI
redoc_url="/api/redoc" if settings.is_development else None, # ReDoc
openapi_url="/api/openapi.json" if settings.is_development else None,
lifespan=lifespan,
)
# === Configuration CORS ===
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=settings.CORS_ALLOW_CREDENTIALS,
allow_methods=["*"], # Autorise toutes les méthodes (GET, POST, PUT, DELETE, etc.)
allow_headers=["*"], # Autorise tous les headers
)
# === Routes de santé ===
@app.get("/", tags=["Health"])
async def root() -> dict[str, str]:
"""Route racine - Vérification de l'état de l'API."""
return {
"app": settings.APP_NAME,
"version": settings.APP_VERSION,
"status": "running",
}
@app.get("/health", tags=["Health"])
async def health() -> dict[str, str]:
"""Endpoint de santé pour monitoring."""
return {
"status": "healthy",
"environment": settings.ENVIRONMENT,
}
# === Gestion globale des erreurs ===
@app.exception_handler(Exception)
async def global_exception_handler(request: Any, exc: Exception) -> JSONResponse:
"""Gestionnaire global des exceptions non capturées.
Log l'erreur et retourne une réponse JSON standardisée.
"""
logger.error(f"Erreur non gérée: {exc}", exc_info=True)
# En production, masquer les détails de l'erreur
detail = str(exc) if settings.is_development else "Erreur interne du serveur"
return JSONResponse(
status_code=500,
content={
"detail": detail,
"type": "internal_server_error",
},
)
# === Enregistrement des routers ===
from app.routers import categories_router, items_router, locations_router
app.include_router(categories_router, prefix="/api/v1")
app.include_router(locations_router, prefix="/api/v1")
app.include_router(items_router, prefix="/api/v1")
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host=settings.BACKEND_HOST,
port=settings.BACKEND_PORT,
reload=settings.BACKEND_RELOAD,
log_level=settings.LOG_LEVEL.lower(),
)

View File

@@ -0,0 +1,19 @@
"""Package des modèles SQLAlchemy.
Importe tous les modèles pour qu'ils soient disponibles pour Alembic.
"""
from app.models.category import Category
from app.models.document import Document, DocumentType
from app.models.item import Item, ItemStatus
from app.models.location import Location, LocationType
__all__ = [
"Category",
"Location",
"LocationType",
"Item",
"ItemStatus",
"Document",
"DocumentType",
]

View File

@@ -0,0 +1,61 @@
"""Modèle SQLAlchemy pour les catégories d'objets.
Les catégories permettent de classer les objets par domaine d'utilisation
(bricolage, informatique, électronique, cuisine, etc.).
"""
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.core.database import Base
if TYPE_CHECKING:
from app.models.item import Item
class Category(Base):
"""Catégorie d'objets.
Attributes:
id: Identifiant unique auto-incrémenté
name: Nom de la catégorie (ex: "Bricolage", "Informatique")
description: Description optionnelle de la catégorie
color: Couleur hexadécimale pour l'affichage (ex: "#3b82f6")
icon: Nom d'icône optionnel (ex: "wrench", "computer")
created_at: Date/heure de création (auto)
updated_at: Date/heure de dernière modification (auto)
items: Relation vers les objets de cette catégorie
"""
__tablename__ = "categories"
# Colonnes
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
color: Mapped[str | None] = mapped_column(String(7), nullable=True) # Format: #RRGGBB
icon: Mapped[str | None] = mapped_column(String(50), nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# Relations
items: Mapped[list["Item"]] = relationship(
"Item", back_populates="category", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
"""Représentation string de la catégorie."""
return f"<Category(id={self.id}, name='{self.name}')>"

View File

@@ -0,0 +1,85 @@
"""Modèle SQLAlchemy pour les documents attachés aux objets.
Les documents peuvent être des photos, notices d'utilisation, factures, etc.
Ils sont stockés sur le système de fichiers local.
"""
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.core.database import Base
if TYPE_CHECKING:
from app.models.item import Item
import enum
class DocumentType(str, enum.Enum):
"""Type de document."""
PHOTO = "photo" # Photo de l'objet
MANUAL = "manual" # Notice d'utilisation
INVOICE = "invoice" # Facture d'achat
WARRANTY = "warranty" # Garantie
OTHER = "other" # Autre
class Document(Base):
"""Document attaché à un objet.
Attributes:
id: Identifiant unique auto-incrémenté
filename: Nom du fichier sur le disque (UUID + extension)
original_name: Nom original du fichier uploadé
type: Type de document (photo/manual/invoice/warranty/other)
mime_type: Type MIME (ex: "image/jpeg", "application/pdf")
size_bytes: Taille du fichier en octets
file_path: Chemin relatif du fichier (ex: "uploads/photos/uuid.jpg")
description: Description optionnelle
item_id: ID de l'objet associé (FK)
created_at: Date/heure de création (auto)
updated_at: Date/heure de dernière modification (auto)
item: Relation vers l'objet
"""
__tablename__ = "documents"
# Colonnes
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
filename: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
original_name: Mapped[str] = mapped_column(String(255), nullable=False)
type: Mapped[DocumentType] = mapped_column(
Enum(DocumentType, native_enum=False, length=20), nullable=False
)
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
file_path: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Foreign Key
item_id: Mapped[int] = mapped_column(
Integer, ForeignKey("items.id", ondelete="CASCADE"), nullable=False, index=True
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# Relations
item: Mapped["Item"] = relationship("Item", back_populates="documents")
def __repr__(self) -> str:
"""Représentation string du document."""
return f"<Document(id={self.id}, type={self.type.value}, filename='{self.filename}')>"

113
backend/app/models/item.py Normal file
View File

@@ -0,0 +1,113 @@
"""Modèle SQLAlchemy pour les objets de l'inventaire.
Les objets (items) sont l'entité centrale de l'application.
Chaque objet appartient à une catégorie et est situé à un emplacement.
"""
from datetime import date, datetime
from decimal import Decimal
from typing import TYPE_CHECKING
from sqlalchemy import Date, DateTime, Enum, ForeignKey, Integer, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.core.database import Base
if TYPE_CHECKING:
from app.models.category import Category
from app.models.document import Document
from app.models.location import Location
import enum
class ItemStatus(str, enum.Enum):
"""Statut d'un objet."""
IN_STOCK = "in_stock" # En stock (non utilisé)
IN_USE = "in_use" # En cours d'utilisation
BROKEN = "broken" # Cassé/HS
SOLD = "sold" # Vendu
LENT = "lent" # Prêté
class Item(Base):
"""Objet de l'inventaire domestique.
Attributes:
id: Identifiant unique auto-incrémenté
name: Nom de l'objet (ex: "Perceuse sans fil Bosch")
description: Description détaillée optionnelle
quantity: Quantité en stock (défaut: 1)
price: Prix d'achat optionnel
purchase_date: Date d'achat optionnelle
status: Statut de l'objet (in_stock/in_use/broken/sold/lent)
brand: Marque optionnelle (ex: "Bosch", "Samsung")
model: Modèle optionnel (ex: "PSR 18 LI-2")
serial_number: Numéro de série optionnel
notes: Notes libres
category_id: ID de la catégorie (FK)
location_id: ID de l'emplacement (FK)
created_at: Date/heure de création (auto)
updated_at: Date/heure de dernière modification (auto)
category: Relation vers la catégorie
location: Relation vers l'emplacement
documents: Relation vers les documents (photos, notices, factures)
"""
__tablename__ = "items"
# Colonnes principales
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
status: Mapped[ItemStatus] = mapped_column(
Enum(ItemStatus, native_enum=False, length=20),
nullable=False,
default=ItemStatus.IN_STOCK,
)
# Informations produit
brand: Mapped[str | None] = mapped_column(String(100), nullable=True)
model: Mapped[str | None] = mapped_column(String(100), nullable=True)
serial_number: Mapped[str | None] = mapped_column(String(100), nullable=True, unique=True)
url: Mapped[str | None] = mapped_column(String(500), nullable=True) # Lien vers page produit
# Informations achat
price: Mapped[Decimal | None] = mapped_column(Numeric(10, 2), nullable=True)
purchase_date: Mapped[date | None] = mapped_column(Date, nullable=True)
# Notes
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# Foreign Keys
category_id: Mapped[int] = mapped_column(
Integer, ForeignKey("categories.id", ondelete="RESTRICT"), nullable=False, index=True
)
location_id: Mapped[int] = mapped_column(
Integer, ForeignKey("locations.id", ondelete="RESTRICT"), nullable=False, index=True
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# Relations
category: Mapped["Category"] = relationship("Category", back_populates="items")
location: Mapped["Location"] = relationship("Location", back_populates="items")
documents: Mapped[list["Document"]] = relationship(
"Document", back_populates="item", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
"""Représentation string de l'objet."""
return f"<Item(id={self.id}, name='{self.name}', status={self.status.value})>"

View File

@@ -0,0 +1,97 @@
"""Modèle SQLAlchemy pour les emplacements de stockage.
Les emplacements sont organisés de manière hiérarchique :
pièce → meuble → tiroir → boîte
Exemple : Garage → Étagère A → Tiroir 2 → Boîte visserie
"""
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.core.database import Base
if TYPE_CHECKING:
from app.models.item import Item
import enum
class LocationType(str, enum.Enum):
"""Type d'emplacement dans la hiérarchie."""
ROOM = "room" # Pièce (ex: Garage, Cuisine)
FURNITURE = "furniture" # Meuble (ex: Étagère, Armoire)
DRAWER = "drawer" # Tiroir
BOX = "box" # Boîte/Bac de rangement
class Location(Base):
"""Emplacement de stockage hiérarchique.
Attributes:
id: Identifiant unique auto-incrémenté
name: Nom de l'emplacement (ex: "Garage", "Étagère A")
type: Type d'emplacement (room/furniture/drawer/box)
parent_id: ID du parent (None si racine)
path: Chemin complet calculé (ex: "Garage > Étagère A > Tiroir 2")
description: Description optionnelle
created_at: Date/heure de création (auto)
updated_at: Date/heure de dernière modification (auto)
parent: Relation vers le parent
children: Relation vers les enfants
items: Relation vers les objets à cet emplacement
"""
__tablename__ = "locations"
# Colonnes
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
type: Mapped[LocationType] = mapped_column(
Enum(LocationType, native_enum=False, length=20), nullable=False
)
parent_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("locations.id", ondelete="CASCADE"), nullable=True, index=True
)
path: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# Relations
parent: Mapped["Location | None"] = relationship(
"Location", remote_side=[id], back_populates="children"
)
children: Mapped[list["Location"]] = relationship(
"Location", back_populates="parent", cascade="all, delete-orphan"
)
items: Mapped[list["Item"]] = relationship(
"Item", back_populates="location", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
"""Représentation string de l'emplacement."""
return f"<Location(id={self.id}, name='{self.name}', type={self.type.value})>"
def calculate_path(self) -> str:
"""Calcule le chemin complet de l'emplacement.
Returns:
Chemin complet (ex: "Garage > Étagère A > Tiroir 2")
"""
if self.parent is None:
return self.name
return f"{self.parent.calculate_path()} > {self.name}"

View File

@@ -0,0 +1,15 @@
"""Package des repositories pour l'accès aux données."""
from app.repositories.base import BaseRepository
from app.repositories.category import CategoryRepository
from app.repositories.document import DocumentRepository
from app.repositories.item import ItemRepository
from app.repositories.location import LocationRepository
__all__ = [
"BaseRepository",
"CategoryRepository",
"LocationRepository",
"ItemRepository",
"DocumentRepository",
]

View File

@@ -0,0 +1,154 @@
"""Repository de base générique.
Fournit les opérations CRUD de base pour tous les modèles.
"""
from typing import Any, Generic, TypeVar
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import Base
ModelType = TypeVar("ModelType", bound=Base)
class BaseRepository(Generic[ModelType]):
"""Repository générique avec opérations CRUD de base.
Attributes:
model: Classe du modèle SQLAlchemy
db: Session de base de données
"""
def __init__(self, model: type[ModelType], db: AsyncSession) -> None:
"""Initialise le repository.
Args:
model: Classe du modèle SQLAlchemy
db: Session de base de données async
"""
self.model = model
self.db = db
async def get(self, id: int) -> ModelType | None:
"""Récupère un élément par son ID.
Args:
id: Identifiant de l'élément
Returns:
L'élément trouvé ou None
"""
result = await self.db.execute(select(self.model).where(self.model.id == id))
return result.scalar_one_or_none()
async def get_all(
self, skip: int = 0, limit: int = 100, **filters: Any
) -> list[ModelType]:
"""Récupère tous les éléments avec pagination et filtres optionnels.
Args:
skip: Nombre d'éléments à sauter
limit: Nombre max d'éléments à retourner
**filters: Filtres additionnels (ex: status="active")
Returns:
Liste des éléments
"""
query = select(self.model)
# Appliquer les filtres
for field, value in filters.items():
if value is not None and hasattr(self.model, field):
query = query.where(getattr(self.model, field) == value)
query = query.offset(skip).limit(limit)
result = await self.db.execute(query)
return list(result.scalars().all())
async def count(self, **filters: Any) -> int:
"""Compte le nombre d'éléments avec filtres optionnels.
Args:
**filters: Filtres additionnels
Returns:
Nombre total d'éléments
"""
query = select(func.count(self.model.id))
for field, value in filters.items():
if value is not None and hasattr(self.model, field):
query = query.where(getattr(self.model, field) == value)
result = await self.db.execute(query)
return result.scalar_one()
async def create(self, **data: Any) -> ModelType:
"""Crée un nouvel élément.
Args:
**data: Données de l'élément
Returns:
L'élément créé
"""
instance = self.model(**data)
self.db.add(instance)
await self.db.flush()
await self.db.refresh(instance)
return instance
async def update(self, id: int, **data: Any) -> ModelType | None:
"""Met à jour un élément existant.
Args:
id: Identifiant de l'élément
**data: Données à mettre à jour (seules les valeurs non-None)
Returns:
L'élément mis à jour ou None si non trouvé
"""
instance = await self.get(id)
if instance is None:
return None
for field, value in data.items():
if value is not None and hasattr(instance, field):
setattr(instance, field, value)
await self.db.flush()
await self.db.refresh(instance)
return instance
async def delete(self, id: int) -> bool:
"""Supprime un élément.
Args:
id: Identifiant de l'élément
Returns:
True si supprimé, False si non trouvé
"""
instance = await self.get(id)
if instance is None:
return False
await self.db.delete(instance)
await self.db.flush()
return True
async def exists(self, id: int) -> bool:
"""Vérifie si un élément existe.
Args:
id: Identifiant de l'élément
Returns:
True si existe, False sinon
"""
result = await self.db.execute(
select(func.count(self.model.id)).where(self.model.id == id)
)
return result.scalar_one() > 0

View File

@@ -0,0 +1,85 @@
"""Repository pour les catégories."""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.category import Category
from app.repositories.base import BaseRepository
class CategoryRepository(BaseRepository[Category]):
"""Repository pour les opérations sur les catégories."""
def __init__(self, db: AsyncSession) -> None:
"""Initialise le repository."""
super().__init__(Category, db)
async def get_by_name(self, name: str) -> Category | None:
"""Récupère une catégorie par son nom.
Args:
name: Nom de la catégorie
Returns:
La catégorie trouvée ou None
"""
result = await self.db.execute(
select(Category).where(Category.name == name)
)
return result.scalar_one_or_none()
async def get_with_item_count(self, id: int) -> tuple[Category, int] | None:
"""Récupère une catégorie avec le nombre d'objets.
Args:
id: ID de la catégorie
Returns:
Tuple (catégorie, nombre d'objets) ou None
"""
result = await self.db.execute(
select(Category).options(selectinload(Category.items)).where(Category.id == id)
)
category = result.scalar_one_or_none()
if category is None:
return None
return category, len(category.items)
async def get_all_with_item_count(
self, skip: int = 0, limit: int = 100
) -> list[tuple[Category, int]]:
"""Récupère toutes les catégories avec le nombre d'objets.
Args:
skip: Offset
limit: Limite
Returns:
Liste de tuples (catégorie, nombre d'objets)
"""
result = await self.db.execute(
select(Category)
.options(selectinload(Category.items))
.offset(skip)
.limit(limit)
.order_by(Category.name)
)
categories = result.scalars().all()
return [(cat, len(cat.items)) for cat in categories]
async def name_exists(self, name: str, exclude_id: int | None = None) -> bool:
"""Vérifie si un nom de catégorie existe déjà.
Args:
name: Nom à vérifier
exclude_id: ID à exclure (pour les mises à jour)
Returns:
True si le nom existe déjà
"""
query = select(func.count(Category.id)).where(Category.name == name)
if exclude_id is not None:
query = query.where(Category.id != exclude_id)
result = await self.db.execute(query)
return result.scalar_one() > 0

View File

@@ -0,0 +1,113 @@
"""Repository pour les documents attachés."""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.document import Document, DocumentType
from app.repositories.base import BaseRepository
class DocumentRepository(BaseRepository[Document]):
"""Repository pour les opérations sur les documents."""
def __init__(self, db: AsyncSession) -> None:
"""Initialise le repository."""
super().__init__(Document, db)
async def get_by_item(self, item_id: int) -> list[Document]:
"""Récupère tous les documents d'un objet.
Args:
item_id: ID de l'objet
Returns:
Liste des documents
"""
result = await self.db.execute(
select(Document)
.where(Document.item_id == item_id)
.order_by(Document.type, Document.created_at)
)
return list(result.scalars().all())
async def get_by_item_and_type(
self, item_id: int, type: DocumentType
) -> list[Document]:
"""Récupère les documents d'un objet par type.
Args:
item_id: ID de l'objet
type: Type de document
Returns:
Liste des documents
"""
result = await self.db.execute(
select(Document)
.where(Document.item_id == item_id, Document.type == type)
.order_by(Document.created_at)
)
return list(result.scalars().all())
async def get_by_filename(self, filename: str) -> Document | None:
"""Récupère un document par son nom de fichier.
Args:
filename: Nom du fichier (UUID)
Returns:
Le document trouvé ou None
"""
result = await self.db.execute(
select(Document).where(Document.filename == filename)
)
return result.scalar_one_or_none()
async def count_by_item(self, item_id: int) -> int:
"""Compte le nombre de documents d'un objet.
Args:
item_id: ID de l'objet
Returns:
Nombre de documents
"""
from sqlalchemy import func
result = await self.db.execute(
select(func.count(Document.id)).where(Document.item_id == item_id)
)
return result.scalar_one()
async def get_photos(self, item_id: int) -> list[Document]:
"""Récupère les photos d'un objet.
Args:
item_id: ID de l'objet
Returns:
Liste des photos
"""
return await self.get_by_item_and_type(item_id, DocumentType.PHOTO)
async def get_invoices(self, item_id: int) -> list[Document]:
"""Récupère les factures d'un objet.
Args:
item_id: ID de l'objet
Returns:
Liste des factures
"""
return await self.get_by_item_and_type(item_id, DocumentType.INVOICE)
async def get_manuals(self, item_id: int) -> list[Document]:
"""Récupère les notices d'un objet.
Args:
item_id: ID de l'objet
Returns:
Liste des notices
"""
return await self.get_by_item_and_type(item_id, DocumentType.MANUAL)

View File

@@ -0,0 +1,247 @@
"""Repository pour les objets d'inventaire."""
from decimal import Decimal
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.item import Item, ItemStatus
from app.repositories.base import BaseRepository
class ItemRepository(BaseRepository[Item]):
"""Repository pour les opérations sur les objets."""
def __init__(self, db: AsyncSession) -> None:
"""Initialise le repository."""
super().__init__(Item, db)
async def get_with_relations(self, id: int) -> Item | None:
"""Récupère un objet avec ses relations (catégorie, emplacement, documents).
Args:
id: ID de l'objet
Returns:
L'objet avec ses relations ou None
"""
result = await self.db.execute(
select(Item)
.options(
selectinload(Item.category),
selectinload(Item.location),
selectinload(Item.documents),
)
.where(Item.id == id)
)
return result.scalar_one_or_none()
async def get_all_with_relations(
self, skip: int = 0, limit: int = 100
) -> list[Item]:
"""Récupère tous les objets avec leurs relations.
Args:
skip: Offset
limit: Limite
Returns:
Liste des objets avec relations
"""
result = await self.db.execute(
select(Item)
.options(
selectinload(Item.category),
selectinload(Item.location),
)
.offset(skip)
.limit(limit)
.order_by(Item.name)
)
return list(result.scalars().all())
async def search(
self,
query: str,
category_id: int | None = None,
location_id: int | None = None,
status: ItemStatus | None = None,
min_price: Decimal | None = None,
max_price: Decimal | None = None,
skip: int = 0,
limit: int = 100,
) -> list[Item]:
"""Recherche des objets avec filtres.
Args:
query: Texte de recherche (nom, description, marque, modèle)
category_id: Filtre par catégorie
location_id: Filtre par emplacement
status: Filtre par statut
min_price: Prix minimum
max_price: Prix maximum
skip: Offset
limit: Limite
Returns:
Liste des objets correspondants
"""
stmt = select(Item).options(
selectinload(Item.category),
selectinload(Item.location),
)
# Recherche textuelle
if query:
search_term = f"%{query}%"
stmt = stmt.where(
or_(
Item.name.ilike(search_term),
Item.description.ilike(search_term),
Item.brand.ilike(search_term),
Item.model.ilike(search_term),
Item.notes.ilike(search_term),
)
)
# Filtres
if category_id is not None:
stmt = stmt.where(Item.category_id == category_id)
if location_id is not None:
stmt = stmt.where(Item.location_id == location_id)
if status is not None:
stmt = stmt.where(Item.status == status)
if min_price is not None:
stmt = stmt.where(Item.price >= min_price)
if max_price is not None:
stmt = stmt.where(Item.price <= max_price)
stmt = stmt.offset(skip).limit(limit).order_by(Item.name)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def count_filtered(
self,
query: str | None = None,
category_id: int | None = None,
location_id: int | None = None,
status: ItemStatus | None = None,
min_price: Decimal | None = None,
max_price: Decimal | None = None,
) -> int:
"""Compte les objets avec filtres.
Returns:
Nombre d'objets correspondants
"""
from sqlalchemy import func
stmt = select(func.count(Item.id))
if query:
search_term = f"%{query}%"
stmt = stmt.where(
or_(
Item.name.ilike(search_term),
Item.description.ilike(search_term),
Item.brand.ilike(search_term),
Item.model.ilike(search_term),
Item.notes.ilike(search_term),
)
)
if category_id is not None:
stmt = stmt.where(Item.category_id == category_id)
if location_id is not None:
stmt = stmt.where(Item.location_id == location_id)
if status is not None:
stmt = stmt.where(Item.status == status)
if min_price is not None:
stmt = stmt.where(Item.price >= min_price)
if max_price is not None:
stmt = stmt.where(Item.price <= max_price)
result = await self.db.execute(stmt)
return result.scalar_one()
async def get_by_category(
self, category_id: int, skip: int = 0, limit: int = 100
) -> list[Item]:
"""Récupère les objets d'une catégorie.
Args:
category_id: ID de la catégorie
skip: Offset
limit: Limite
Returns:
Liste des objets
"""
result = await self.db.execute(
select(Item)
.where(Item.category_id == category_id)
.offset(skip)
.limit(limit)
.order_by(Item.name)
)
return list(result.scalars().all())
async def get_by_location(
self, location_id: int, skip: int = 0, limit: int = 100
) -> list[Item]:
"""Récupère les objets d'un emplacement.
Args:
location_id: ID de l'emplacement
skip: Offset
limit: Limite
Returns:
Liste des objets
"""
result = await self.db.execute(
select(Item)
.where(Item.location_id == location_id)
.offset(skip)
.limit(limit)
.order_by(Item.name)
)
return list(result.scalars().all())
async def get_by_status(
self, status: ItemStatus, skip: int = 0, limit: int = 100
) -> list[Item]:
"""Récupère les objets par statut.
Args:
status: Statut recherché
skip: Offset
limit: Limite
Returns:
Liste des objets
"""
result = await self.db.execute(
select(Item)
.where(Item.status == status)
.offset(skip)
.limit(limit)
.order_by(Item.name)
)
return list(result.scalars().all())
async def get_by_serial_number(self, serial_number: str) -> Item | None:
"""Récupère un objet par son numéro de série.
Args:
serial_number: Numéro de série
Returns:
L'objet trouvé ou None
"""
result = await self.db.execute(
select(Item).where(Item.serial_number == serial_number)
)
return result.scalar_one_or_none()

View File

@@ -0,0 +1,171 @@
"""Repository pour les emplacements."""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.location import Location, LocationType
from app.repositories.base import BaseRepository
class LocationRepository(BaseRepository[Location]):
"""Repository pour les opérations sur les emplacements."""
def __init__(self, db: AsyncSession) -> None:
"""Initialise le repository."""
super().__init__(Location, db)
async def get_with_children(self, id: int) -> Location | None:
"""Récupère un emplacement avec ses enfants.
Args:
id: ID de l'emplacement
Returns:
L'emplacement avec ses enfants ou None
"""
result = await self.db.execute(
select(Location)
.options(selectinload(Location.children))
.where(Location.id == id)
)
return result.scalar_one_or_none()
async def get_root_locations(self) -> list[Location]:
"""Récupère tous les emplacements racine (sans parent).
Returns:
Liste des emplacements racine
"""
result = await self.db.execute(
select(Location)
.where(Location.parent_id.is_(None))
.order_by(Location.name)
)
return list(result.scalars().all())
async def get_children(self, parent_id: int) -> list[Location]:
"""Récupère les enfants directs d'un emplacement.
Args:
parent_id: ID du parent
Returns:
Liste des enfants
"""
result = await self.db.execute(
select(Location)
.where(Location.parent_id == parent_id)
.order_by(Location.name)
)
return list(result.scalars().all())
async def get_by_type(self, type: LocationType) -> list[Location]:
"""Récupère tous les emplacements d'un type donné.
Args:
type: Type d'emplacement
Returns:
Liste des emplacements
"""
result = await self.db.execute(
select(Location)
.where(Location.type == type)
.order_by(Location.path)
)
return list(result.scalars().all())
async def get_full_tree(self) -> list[Location]:
"""Récupère l'arborescence complète des emplacements.
Returns:
Liste des emplacements racine avec enfants chargés récursivement
"""
# Charger tous les emplacements avec leurs enfants
result = await self.db.execute(
select(Location)
.options(selectinload(Location.children))
.order_by(Location.path)
)
all_locations = list(result.scalars().all())
# Retourner seulement les racines (les enfants sont déjà chargés)
return [loc for loc in all_locations if loc.parent_id is None]
async def get_with_item_count(self, id: int) -> tuple[Location, int] | None:
"""Récupère un emplacement avec le nombre d'objets.
Args:
id: ID de l'emplacement
Returns:
Tuple (emplacement, nombre d'objets) ou None
"""
result = await self.db.execute(
select(Location)
.options(selectinload(Location.items))
.where(Location.id == id)
)
location = result.scalar_one_or_none()
if location is None:
return None
return location, len(location.items)
async def create_with_path(
self,
name: str,
type: LocationType,
parent_id: int | None = None,
description: str | None = None,
) -> Location:
"""Crée un emplacement avec calcul automatique du chemin.
Args:
name: Nom de l'emplacement
type: Type d'emplacement
parent_id: ID du parent (None si racine)
description: Description optionnelle
Returns:
L'emplacement créé
"""
# Calculer le chemin
if parent_id is None:
path = name
else:
parent = await self.get(parent_id)
if parent is None:
path = name
else:
path = f"{parent.path} > {name}"
return await self.create(
name=name,
type=type,
parent_id=parent_id,
path=path,
description=description,
)
async def update_paths_recursive(self, location: Location) -> None:
"""Met à jour récursivement les chemins après modification.
Args:
location: Emplacement modifié
"""
# Mettre à jour le chemin de cet emplacement
if location.parent_id is None:
location.path = location.name
else:
parent = await self.get(location.parent_id)
if parent:
location.path = f"{parent.path} > {location.name}"
else:
location.path = location.name
# Mettre à jour les enfants
children = await self.get_children(location.id)
for child in children:
child.path = f"{location.path} > {child.name}"
await self.update_paths_recursive(child)

View File

@@ -0,0 +1,11 @@
"""Package des routers API."""
from app.routers.categories import router as categories_router
from app.routers.items import router as items_router
from app.routers.locations import router as locations_router
__all__ = [
"categories_router",
"locations_router",
"items_router",
]

View File

@@ -0,0 +1,168 @@
"""Router API pour les catégories."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.repositories.category import CategoryRepository
from app.schemas.category import (
CategoryCreate,
CategoryResponse,
CategoryUpdate,
CategoryWithItemCount,
)
from app.schemas.common import PaginatedResponse, SuccessResponse
router = APIRouter(prefix="/categories", tags=["Categories"])
@router.get("", response_model=PaginatedResponse[CategoryWithItemCount])
async def list_categories(
page: int = 1,
page_size: int = 20,
db: AsyncSession = Depends(get_db),
) -> PaginatedResponse[CategoryWithItemCount]:
"""Liste toutes les catégories avec le nombre d'objets."""
repo = CategoryRepository(db)
skip = (page - 1) * page_size
categories_with_count = await repo.get_all_with_item_count(skip=skip, limit=page_size)
total = await repo.count()
items = [
CategoryWithItemCount(
id=cat.id,
name=cat.name,
description=cat.description,
color=cat.color,
icon=cat.icon,
created_at=cat.created_at,
updated_at=cat.updated_at,
item_count=count,
)
for cat, count in categories_with_count
]
return PaginatedResponse.create(items=items, total=total, page=page, page_size=page_size)
@router.get("/{category_id}", response_model=CategoryWithItemCount)
async def get_category(
category_id: int,
db: AsyncSession = Depends(get_db),
) -> CategoryWithItemCount:
"""Récupère une catégorie par son ID."""
repo = CategoryRepository(db)
result = await repo.get_with_item_count(category_id)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Catégorie {category_id} non trouvée",
)
category, item_count = result
return CategoryWithItemCount(
id=category.id,
name=category.name,
description=category.description,
color=category.color,
icon=category.icon,
created_at=category.created_at,
updated_at=category.updated_at,
item_count=item_count,
)
@router.post("", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
async def create_category(
data: CategoryCreate,
db: AsyncSession = Depends(get_db),
) -> CategoryResponse:
"""Crée une nouvelle catégorie."""
repo = CategoryRepository(db)
# Vérifier si le nom existe déjà
if await repo.name_exists(data.name):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Une catégorie avec le nom '{data.name}' existe déjà",
)
category = await repo.create(
name=data.name,
description=data.description,
color=data.color,
icon=data.icon,
)
await db.commit()
return CategoryResponse.model_validate(category)
@router.put("/{category_id}", response_model=CategoryResponse)
async def update_category(
category_id: int,
data: CategoryUpdate,
db: AsyncSession = Depends(get_db),
) -> CategoryResponse:
"""Met à jour une catégorie."""
repo = CategoryRepository(db)
# Vérifier si la catégorie existe
existing = await repo.get(category_id)
if existing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Catégorie {category_id} non trouvée",
)
# Vérifier si le nouveau nom existe déjà (si changement de nom)
if data.name and data.name != existing.name:
if await repo.name_exists(data.name, exclude_id=category_id):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Une catégorie avec le nom '{data.name}' existe déjà",
)
category = await repo.update(
category_id,
name=data.name,
description=data.description,
color=data.color,
icon=data.icon,
)
await db.commit()
return CategoryResponse.model_validate(category)
@router.delete("/{category_id}", response_model=SuccessResponse)
async def delete_category(
category_id: int,
db: AsyncSession = Depends(get_db),
) -> SuccessResponse:
"""Supprime une catégorie."""
repo = CategoryRepository(db)
# Vérifier si la catégorie existe
result = await repo.get_with_item_count(category_id)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Catégorie {category_id} non trouvée",
)
category, item_count = result
# Empêcher la suppression si des objets utilisent cette catégorie
if item_count > 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Impossible de supprimer : {item_count} objet(s) utilisent cette catégorie",
)
await repo.delete(category_id)
await db.commit()
return SuccessResponse(message="Catégorie supprimée avec succès", id=category_id)

View File

@@ -0,0 +1,264 @@
"""Router API pour les objets d'inventaire."""
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.models.item import ItemStatus
from app.repositories.category import CategoryRepository
from app.repositories.item import ItemRepository
from app.repositories.location import LocationRepository
from app.schemas.common import PaginatedResponse, SuccessResponse
from app.schemas.item import (
ItemCreate,
ItemResponse,
ItemUpdate,
ItemWithRelations,
)
router = APIRouter(prefix="/items", tags=["Items"])
@router.get("", response_model=PaginatedResponse[ItemWithRelations])
async def list_items(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
search: str | None = Query(default=None, min_length=2),
category_id: int | None = None,
location_id: int | None = None,
status: ItemStatus | None = None,
min_price: Decimal | None = None,
max_price: Decimal | None = None,
db: AsyncSession = Depends(get_db),
) -> PaginatedResponse[ItemWithRelations]:
"""Liste les objets avec filtres et pagination."""
repo = ItemRepository(db)
skip = (page - 1) * page_size
items = await repo.search(
query=search or "",
category_id=category_id,
location_id=location_id,
status=status,
min_price=min_price,
max_price=max_price,
skip=skip,
limit=page_size,
)
total = await repo.count_filtered(
query=search,
category_id=category_id,
location_id=location_id,
status=status,
min_price=min_price,
max_price=max_price,
)
result_items = [ItemWithRelations.model_validate(item) for item in items]
return PaginatedResponse.create(items=result_items, total=total, page=page, page_size=page_size)
@router.get("/{item_id}", response_model=ItemWithRelations)
async def get_item(
item_id: int,
db: AsyncSession = Depends(get_db),
) -> ItemWithRelations:
"""Récupère un objet par son ID avec ses relations."""
repo = ItemRepository(db)
item = await repo.get_with_relations(item_id)
if item is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Objet {item_id} non trouvé",
)
return ItemWithRelations.model_validate(item)
@router.post("", response_model=ItemResponse, status_code=status.HTTP_201_CREATED)
async def create_item(
data: ItemCreate,
db: AsyncSession = Depends(get_db),
) -> ItemResponse:
"""Crée un nouvel objet."""
item_repo = ItemRepository(db)
category_repo = CategoryRepository(db)
location_repo = LocationRepository(db)
# Vérifier que la catégorie existe
if not await category_repo.exists(data.category_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Catégorie {data.category_id} non trouvée",
)
# Vérifier que l'emplacement existe
if not await location_repo.exists(data.location_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Emplacement {data.location_id} non trouvé",
)
# Vérifier l'unicité du numéro de série si fourni
if data.serial_number:
existing = await item_repo.get_by_serial_number(data.serial_number)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Un objet avec le numéro de série '{data.serial_number}' existe déjà",
)
item = await item_repo.create(
name=data.name,
description=data.description,
quantity=data.quantity,
status=data.status,
brand=data.brand,
model=data.model,
serial_number=data.serial_number,
price=data.price,
purchase_date=data.purchase_date,
notes=data.notes,
category_id=data.category_id,
location_id=data.location_id,
)
await db.commit()
return ItemResponse.model_validate(item)
@router.put("/{item_id}", response_model=ItemResponse)
async def update_item(
item_id: int,
data: ItemUpdate,
db: AsyncSession = Depends(get_db),
) -> ItemResponse:
"""Met à jour un objet."""
item_repo = ItemRepository(db)
category_repo = CategoryRepository(db)
location_repo = LocationRepository(db)
# Vérifier que l'objet existe
existing = await item_repo.get(item_id)
if existing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Objet {item_id} non trouvé",
)
# Vérifier la catégorie si changée
if data.category_id is not None and data.category_id != existing.category_id:
if not await category_repo.exists(data.category_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Catégorie {data.category_id} non trouvée",
)
# Vérifier l'emplacement si changé
if data.location_id is not None and data.location_id != existing.location_id:
if not await location_repo.exists(data.location_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Emplacement {data.location_id} non trouvé",
)
# Vérifier l'unicité du numéro de série si changé
if data.serial_number and data.serial_number != existing.serial_number:
existing_with_serial = await item_repo.get_by_serial_number(data.serial_number)
if existing_with_serial and existing_with_serial.id != item_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Un objet avec le numéro de série '{data.serial_number}' existe déjà",
)
item = await item_repo.update(
item_id,
name=data.name,
description=data.description,
quantity=data.quantity,
status=data.status,
brand=data.brand,
model=data.model,
serial_number=data.serial_number,
price=data.price,
purchase_date=data.purchase_date,
notes=data.notes,
category_id=data.category_id,
location_id=data.location_id,
)
await db.commit()
return ItemResponse.model_validate(item)
@router.delete("/{item_id}", response_model=SuccessResponse)
async def delete_item(
item_id: int,
db: AsyncSession = Depends(get_db),
) -> SuccessResponse:
"""Supprime un objet et ses documents associés."""
repo = ItemRepository(db)
# Vérifier que l'objet existe
if not await repo.exists(item_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Objet {item_id} non trouvé",
)
await repo.delete(item_id)
await db.commit()
return SuccessResponse(message="Objet supprimé avec succès", id=item_id)
@router.patch("/{item_id}/status", response_model=ItemResponse)
async def update_item_status(
item_id: int,
new_status: ItemStatus,
db: AsyncSession = Depends(get_db),
) -> ItemResponse:
"""Met à jour le statut d'un objet."""
repo = ItemRepository(db)
item = await repo.update(item_id, status=new_status)
if item is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Objet {item_id} non trouvé",
)
await db.commit()
return ItemResponse.model_validate(item)
@router.patch("/{item_id}/location", response_model=ItemResponse)
async def move_item(
item_id: int,
new_location_id: int,
db: AsyncSession = Depends(get_db),
) -> ItemResponse:
"""Déplace un objet vers un nouvel emplacement."""
item_repo = ItemRepository(db)
location_repo = LocationRepository(db)
# Vérifier que le nouvel emplacement existe
if not await location_repo.exists(new_location_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Emplacement {new_location_id} non trouvé",
)
item = await item_repo.update(item_id, location_id=new_location_id)
if item is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Objet {item_id} non trouvé",
)
await db.commit()
return ItemResponse.model_validate(item)

View File

@@ -0,0 +1,249 @@
"""Router API pour les emplacements."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.models.location import LocationType
from app.repositories.location import LocationRepository
from app.schemas.common import PaginatedResponse, SuccessResponse
from app.schemas.location import (
LocationCreate,
LocationResponse,
LocationTree,
LocationUpdate,
LocationWithItemCount,
)
router = APIRouter(prefix="/locations", tags=["Locations"])
@router.get("", response_model=PaginatedResponse[LocationResponse])
async def list_locations(
page: int = 1,
page_size: int = 50,
parent_id: int | None = None,
type: LocationType | None = None,
db: AsyncSession = Depends(get_db),
) -> PaginatedResponse[LocationResponse]:
"""Liste les emplacements avec filtres optionnels."""
repo = LocationRepository(db)
skip = (page - 1) * page_size
filters = {}
if parent_id is not None:
filters["parent_id"] = parent_id
if type is not None:
filters["type"] = type
locations = await repo.get_all(skip=skip, limit=page_size, **filters)
total = await repo.count(**filters)
items = [LocationResponse.model_validate(loc) for loc in locations]
return PaginatedResponse.create(items=items, total=total, page=page, page_size=page_size)
@router.get("/tree", response_model=list[LocationTree])
async def get_location_tree(
db: AsyncSession = Depends(get_db),
) -> list[LocationTree]:
"""Récupère l'arborescence complète des emplacements."""
repo = LocationRepository(db)
# Récupérer tous les emplacements
all_locations = await repo.get_all(skip=0, limit=1000)
# Construire un dictionnaire pour un accès rapide
loc_dict: dict[int, LocationTree] = {}
for loc in all_locations:
loc_dict[loc.id] = LocationTree(
id=loc.id,
name=loc.name,
type=loc.type,
path=loc.path,
children=[],
item_count=0,
)
# Construire l'arborescence
roots: list[LocationTree] = []
for loc in all_locations:
tree_node = loc_dict[loc.id]
if loc.parent_id is None:
roots.append(tree_node)
elif loc.parent_id in loc_dict:
loc_dict[loc.parent_id].children.append(tree_node)
return roots
@router.get("/roots", response_model=list[LocationResponse])
async def get_root_locations(
db: AsyncSession = Depends(get_db),
) -> list[LocationResponse]:
"""Récupère les emplacements racine (pièces)."""
repo = LocationRepository(db)
locations = await repo.get_root_locations()
return [LocationResponse.model_validate(loc) for loc in locations]
@router.get("/{location_id}", response_model=LocationWithItemCount)
async def get_location(
location_id: int,
db: AsyncSession = Depends(get_db),
) -> LocationWithItemCount:
"""Récupère un emplacement par son ID."""
repo = LocationRepository(db)
result = await repo.get_with_item_count(location_id)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Emplacement {location_id} non trouvé",
)
location, item_count = result
return LocationWithItemCount(
id=location.id,
name=location.name,
type=location.type,
parent_id=location.parent_id,
path=location.path,
description=location.description,
created_at=location.created_at,
updated_at=location.updated_at,
item_count=item_count,
)
@router.get("/{location_id}/children", response_model=list[LocationResponse])
async def get_location_children(
location_id: int,
db: AsyncSession = Depends(get_db),
) -> list[LocationResponse]:
"""Récupère les enfants directs d'un emplacement."""
repo = LocationRepository(db)
# Vérifier que le parent existe
if not await repo.exists(location_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Emplacement {location_id} non trouvé",
)
children = await repo.get_children(location_id)
return [LocationResponse.model_validate(child) for child in children]
@router.post("", response_model=LocationResponse, status_code=status.HTTP_201_CREATED)
async def create_location(
data: LocationCreate,
db: AsyncSession = Depends(get_db),
) -> LocationResponse:
"""Crée un nouvel emplacement."""
repo = LocationRepository(db)
# Vérifier que le parent existe si spécifié
if data.parent_id is not None:
if not await repo.exists(data.parent_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Emplacement parent {data.parent_id} non trouvé",
)
location = await repo.create_with_path(
name=data.name,
type=data.type,
parent_id=data.parent_id,
description=data.description,
)
await db.commit()
return LocationResponse.model_validate(location)
@router.put("/{location_id}", response_model=LocationResponse)
async def update_location(
location_id: int,
data: LocationUpdate,
db: AsyncSession = Depends(get_db),
) -> LocationResponse:
"""Met à jour un emplacement."""
repo = LocationRepository(db)
# Vérifier que l'emplacement existe
existing = await repo.get(location_id)
if existing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Emplacement {location_id} non trouvé",
)
# Vérifier que le nouveau parent existe si spécifié
if data.parent_id is not None and data.parent_id != existing.parent_id:
if data.parent_id == location_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Un emplacement ne peut pas être son propre parent",
)
if not await repo.exists(data.parent_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Emplacement parent {data.parent_id} non trouvé",
)
# Mettre à jour
location = await repo.update(
location_id,
name=data.name,
type=data.type,
parent_id=data.parent_id,
description=data.description,
)
# Recalculer les chemins si le nom ou le parent a changé
if data.name or data.parent_id is not None:
await repo.update_paths_recursive(location)
await db.commit()
return LocationResponse.model_validate(location)
@router.delete("/{location_id}", response_model=SuccessResponse)
async def delete_location(
location_id: int,
db: AsyncSession = Depends(get_db),
) -> SuccessResponse:
"""Supprime un emplacement."""
repo = LocationRepository(db)
# Vérifier que l'emplacement existe
result = await repo.get_with_item_count(location_id)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Emplacement {location_id} non trouvé",
)
location, item_count = result
# Empêcher la suppression si des objets utilisent cet emplacement
if item_count > 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Impossible de supprimer : {item_count} objet(s) utilisent cet emplacement",
)
# Vérifier s'il y a des enfants
children = await repo.get_children(location_id)
if children:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Impossible de supprimer : cet emplacement a {len(children)} sous-emplacement(s)",
)
await repo.delete(location_id)
await db.commit()
return SuccessResponse(message="Emplacement supprimé avec succès", id=location_id)

View File

@@ -0,0 +1,68 @@
"""Package des schémas Pydantic."""
from app.schemas.category import (
CategoryCreate,
CategoryResponse,
CategoryUpdate,
CategoryWithItemCount,
)
from app.schemas.common import (
ErrorResponse,
PaginatedResponse,
PaginationParams,
SuccessResponse,
)
from app.schemas.document import (
DocumentCreate,
DocumentResponse,
DocumentUpdate,
DocumentUploadResponse,
)
from app.schemas.item import (
ItemCreate,
ItemFilter,
ItemResponse,
ItemSummary,
ItemUpdate,
ItemWithRelations,
)
from app.schemas.location import (
LocationCreate,
LocationResponse,
LocationTree,
LocationUpdate,
LocationWithChildren,
LocationWithItemCount,
)
__all__ = [
# Category
"CategoryCreate",
"CategoryUpdate",
"CategoryResponse",
"CategoryWithItemCount",
# Location
"LocationCreate",
"LocationUpdate",
"LocationResponse",
"LocationWithChildren",
"LocationWithItemCount",
"LocationTree",
# Item
"ItemCreate",
"ItemUpdate",
"ItemResponse",
"ItemWithRelations",
"ItemSummary",
"ItemFilter",
# Document
"DocumentCreate",
"DocumentUpdate",
"DocumentResponse",
"DocumentUploadResponse",
# Common
"PaginationParams",
"PaginatedResponse",
"ErrorResponse",
"SuccessResponse",
]

View File

@@ -0,0 +1,48 @@
"""Schémas Pydantic pour les catégories.
Définit les schémas de validation pour les requêtes et réponses API.
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class CategoryBase(BaseModel):
"""Schéma de base pour les catégories."""
name: str = Field(..., min_length=1, max_length=100, description="Nom de la catégorie")
description: str | None = Field(None, max_length=1000, description="Description optionnelle")
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$", description="Couleur hex (#RRGGBB)")
icon: str | None = Field(None, max_length=50, description="Nom de l'icône")
class CategoryCreate(CategoryBase):
"""Schéma pour la création d'une catégorie."""
pass
class CategoryUpdate(BaseModel):
"""Schéma pour la mise à jour d'une catégorie (tous les champs optionnels)."""
name: str | None = Field(None, min_length=1, max_length=100)
description: str | None = Field(None, max_length=1000)
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
icon: str | None = Field(None, max_length=50)
class CategoryResponse(CategoryBase):
"""Schéma de réponse pour une catégorie."""
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
updated_at: datetime
class CategoryWithItemCount(CategoryResponse):
"""Schéma de réponse avec le nombre d'objets."""
item_count: int = Field(default=0, description="Nombre d'objets dans cette catégorie")

View File

@@ -0,0 +1,60 @@
"""Schémas Pydantic communs.
Définit les schémas réutilisables (pagination, erreurs, etc.).
"""
from typing import Generic, TypeVar
from pydantic import BaseModel, Field
T = TypeVar("T")
class PaginationParams(BaseModel):
"""Paramètres de pagination."""
page: int = Field(default=1, ge=1, description="Numéro de page (commence à 1)")
page_size: int = Field(default=20, ge=1, le=100, description="Nombre d'éléments par page")
@property
def offset(self) -> int:
"""Calcule l'offset pour la requête SQL."""
return (self.page - 1) * self.page_size
class PaginatedResponse(BaseModel, Generic[T]):
"""Réponse paginée générique."""
items: list[T]
total: int = Field(..., description="Nombre total d'éléments")
page: int = Field(..., description="Page actuelle")
page_size: int = Field(..., description="Taille de la page")
pages: int = Field(..., description="Nombre total de pages")
@classmethod
def create(
cls, items: list[T], total: int, page: int, page_size: int
) -> "PaginatedResponse[T]":
"""Crée une réponse paginée."""
pages = (total + page_size - 1) // page_size if page_size > 0 else 0
return cls(
items=items,
total=total,
page=page,
page_size=page_size,
pages=pages,
)
class ErrorResponse(BaseModel):
"""Schéma de réponse d'erreur."""
detail: str = Field(..., description="Message d'erreur")
type: str = Field(..., description="Type d'erreur")
class SuccessResponse(BaseModel):
"""Schéma de réponse de succès."""
message: str = Field(..., description="Message de succès")
id: int | None = Field(None, description="ID de l'élément concerné")

View File

@@ -0,0 +1,63 @@
"""Schémas Pydantic pour les documents attachés.
Définit les schémas de validation pour les requêtes et réponses API.
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.models.document import DocumentType
class DocumentBase(BaseModel):
"""Schéma de base pour les documents."""
type: DocumentType = Field(..., description="Type de document")
description: str | None = Field(None, max_length=500, description="Description optionnelle")
class DocumentCreate(DocumentBase):
"""Schéma pour la création d'un document (métadonnées seulement).
Le fichier est uploadé séparément via multipart/form-data.
"""
item_id: int = Field(..., description="ID de l'objet associé")
class DocumentUpdate(BaseModel):
"""Schéma pour la mise à jour d'un document."""
type: DocumentType | None = None
description: str | None = Field(None, max_length=500)
class DocumentResponse(BaseModel):
"""Schéma de réponse pour un document."""
model_config = ConfigDict(from_attributes=True)
id: int
filename: str
original_name: str
type: DocumentType
mime_type: str
size_bytes: int
file_path: str
description: str | None
item_id: int
created_at: datetime
updated_at: datetime
class DocumentUploadResponse(BaseModel):
"""Schéma de réponse après upload d'un document."""
id: int
filename: str
original_name: str
type: DocumentType
mime_type: str
size_bytes: int
message: str = "Document uploadé avec succès"

View File

@@ -0,0 +1,98 @@
"""Schémas Pydantic pour les objets d'inventaire.
Définit les schémas de validation pour les requêtes et réponses API.
"""
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
from app.models.item import ItemStatus
from app.schemas.category import CategoryResponse
from app.schemas.location import LocationResponse
class ItemBase(BaseModel):
"""Schéma de base pour les objets."""
name: str = Field(..., min_length=1, max_length=200, description="Nom de l'objet")
description: str | None = Field(None, description="Description détaillée")
quantity: int = Field(default=1, ge=0, description="Quantité en stock")
status: ItemStatus = Field(default=ItemStatus.IN_STOCK, description="Statut de l'objet")
brand: str | None = Field(None, max_length=100, description="Marque")
model: str | None = Field(None, max_length=100, description="Modèle")
serial_number: str | None = Field(None, max_length=100, description="Numéro de série")
url: str | None = Field(None, max_length=500, description="Lien vers page produit")
price: Decimal | None = Field(None, ge=0, decimal_places=2, description="Prix d'achat")
purchase_date: date | None = Field(None, description="Date d'achat")
notes: str | None = Field(None, description="Notes libres")
class ItemCreate(ItemBase):
"""Schéma pour la création d'un objet."""
category_id: int = Field(..., description="ID de la catégorie")
location_id: int = Field(..., description="ID de l'emplacement")
class ItemUpdate(BaseModel):
"""Schéma pour la mise à jour d'un objet (tous les champs optionnels)."""
name: str | None = Field(None, min_length=1, max_length=200)
description: str | None = None
quantity: int | None = Field(None, ge=0)
status: ItemStatus | None = None
brand: str | None = Field(None, max_length=100)
model: str | None = Field(None, max_length=100)
serial_number: str | None = Field(None, max_length=100)
url: str | None = Field(None, max_length=500)
price: Decimal | None = Field(None, ge=0)
purchase_date: date | None = None
notes: str | None = None
category_id: int | None = None
location_id: int | None = None
class ItemResponse(ItemBase):
"""Schéma de réponse pour un objet."""
model_config = ConfigDict(from_attributes=True)
id: int
category_id: int
location_id: int
created_at: datetime
updated_at: datetime
class ItemWithRelations(ItemResponse):
"""Schéma de réponse avec les relations (catégorie et emplacement)."""
category: CategoryResponse
location: LocationResponse
class ItemSummary(BaseModel):
"""Schéma résumé pour les listes d'objets."""
model_config = ConfigDict(from_attributes=True)
id: int
name: str
quantity: int
status: ItemStatus
brand: str | None
category_id: int
location_id: int
class ItemFilter(BaseModel):
"""Schéma pour filtrer les objets."""
category_id: int | None = None
location_id: int | None = None
status: ItemStatus | None = None
search: str | None = Field(None, min_length=2, description="Recherche textuelle")
min_price: Decimal | None = None
max_price: Decimal | None = None

View File

@@ -0,0 +1,70 @@
"""Schémas Pydantic pour les emplacements.
Définit les schémas de validation pour les requêtes et réponses API.
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.models.location import LocationType
class LocationBase(BaseModel):
"""Schéma de base pour les emplacements."""
name: str = Field(..., min_length=1, max_length=100, description="Nom de l'emplacement")
type: LocationType = Field(..., description="Type d'emplacement")
description: str | None = Field(None, max_length=500, description="Description optionnelle")
class LocationCreate(LocationBase):
"""Schéma pour la création d'un emplacement."""
parent_id: int | None = Field(None, description="ID du parent (None si racine)")
class LocationUpdate(BaseModel):
"""Schéma pour la mise à jour d'un emplacement (tous les champs optionnels)."""
name: str | None = Field(None, min_length=1, max_length=100)
type: LocationType | None = None
description: str | None = Field(None, max_length=500)
parent_id: int | None = None
class LocationResponse(LocationBase):
"""Schéma de réponse pour un emplacement."""
model_config = ConfigDict(from_attributes=True)
id: int
parent_id: int | None
path: str
created_at: datetime
updated_at: datetime
class LocationWithChildren(LocationResponse):
"""Schéma de réponse avec les enfants."""
children: list["LocationWithChildren"] = Field(default_factory=list)
class LocationWithItemCount(LocationResponse):
"""Schéma de réponse avec le nombre d'objets."""
item_count: int = Field(default=0, description="Nombre d'objets à cet emplacement")
class LocationTree(BaseModel):
"""Schéma pour l'arborescence complète des emplacements."""
id: int
name: str
type: LocationType
path: str
children: list["LocationTree"] = Field(default_factory=list)
item_count: int = 0
model_config = ConfigDict(from_attributes=True)

View File

View File

142
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,142 @@
[project]
name = "homestock-backend"
version = "0.1.0"
description = "HomeStock - Backend API pour gestion d'inventaire domestique"
authors = [
{ name = "Gilles", email = "gilles@example.com" }
]
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
dependencies = [
# FastAPI
"fastapi>=0.109.0",
"uvicorn[standard]>=0.27.0",
"python-multipart>=0.0.6", # Pour upload fichiers
# Database
"sqlalchemy>=2.0.25",
"alembic>=1.13.1",
"aiosqlite>=0.19.0", # Async SQLite
# Validation
"pydantic>=2.5.3",
"pydantic-settings>=2.1.0",
"email-validator>=2.1.0",
# Logging
"loguru>=0.7.2",
# Utils
"python-dotenv>=1.0.0",
"httpx>=0.26.0", # Pour tests API
]
[project.optional-dependencies]
dev = [
# Testing
"pytest>=7.4.4",
"pytest-asyncio>=0.23.3",
"pytest-cov>=4.1.0",
"pytest-mock>=3.12.0",
# Linting & Formatting
"ruff>=0.1.14",
"mypy>=1.8.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
# === Ruff Configuration ===
[tool.ruff]
target-version = "py311"
line-length = 100
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
]
ignore = [
"E501", # line too long (handled by formatter)
"B008", # do not perform function calls in argument defaults
"C901", # too complex
]
[tool.ruff.per-file-ignores]
"__init__.py" = ["F401"] # unused imports in __init__
[tool.ruff.isort]
known-first-party = ["app"]
# === MyPy Configuration ===
[tool.mypy]
python_version = "3.11"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_any_generics = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
warn_unreachable = true
strict_equality = true
[[tool.mypy.overrides]]
module = [
"sqlalchemy.*",
"alembic.*",
"loguru.*",
]
ignore_missing_imports = true
# === Pytest Configuration ===
[tool.pytest.ini_options]
minversion = "7.0"
addopts = [
"-ra",
"--strict-markers",
"--cov=app",
"--cov-report=term-missing",
"--cov-report=html",
]
testpaths = ["tests"]
pythonpath = ["."]
asyncio_mode = "auto"
# === Coverage Configuration ===
[tool.coverage.run]
source = ["app"]
omit = [
"*/tests/*",
"*/__init__.py",
"*/alembic/*",
]
[tool.coverage.report]
precision = 2
show_missing = true
skip_covered = false
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if TYPE_CHECKING:",
"raise AssertionError",
"raise NotImplementedError",
"if __name__ == .__main__.:",
]

View File

View File

View File

1286
backend/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,15 +10,33 @@ Décrit les entités clés et leurs relations.
---
## Entités principales
- Entité : <A COMPLETER PAR AGENT>
- Champs : <A COMPLETER PAR AGENT>
- Contraintes : <A COMPLETER PAR AGENT>
### Item (objet d'inventaire)
- Champs : `id` (PK), `name` (string, requis), `description` (text, optionnel), `quantity` (int, défaut 1), `price` (decimal, optionnel), `purchase_date` (date, optionnel), `status` (enum: in_stock/in_use/broken/sold, défaut in_stock), `location_id` (FK Location), `category_id` (FK Category), `created_at`, `updated_at` <!-- complété par codex -->
- Contraintes : `name` unique par location, `quantity` >= 0, `price` >= 0, index sur `name` + `status` pour recherche <!-- complété par codex -->
### Location (emplacement physique hiérarchique)
- Champs : `id` (PK), `name` (string, requis), `type` (enum: room/furniture/drawer/box), `parent_id` (FK Location, self-reference, optionnel), `path` (string, chemin complet calculé), `created_at`, `updated_at` <!-- complété par codex -->
- Contraintes : `name` unique par `parent_id`, pas de cycle dans hiérarchie (validation applicative), index sur `path` pour recherche hiérarchique <!-- complété par codex -->
### Category (domaine/catégorie)
- Champs : `id` (PK), `name` (string, requis, unique), `slug` (string, requis, unique), `description` (text, optionnel), `color` (string hex, optionnel), `icon` (string, optionnel), `created_at`, `updated_at` <!-- complété par codex -->
- Contraintes : `name` et `slug` uniques, slug kebab-case, catégories prédéfinies (bricolage, informatique, électronique, cuisine) + possibilité ajout <!-- complété par codex -->
### Document (fichier attaché)
- Champs : `id` (PK), `item_id` (FK Item, requis), `type` (enum: photo/notice/invoice/other), `filename` (string, requis), `filepath` (string, chemin relatif dans uploads/), `mime_type` (string), `size_bytes` (int), `uploaded_at` <!-- complété par codex -->
- Contraintes : `filepath` unique, index sur `item_id`, taille max 50MB par fichier, types MIME autorisés (images, PDF) <!-- complété par codex -->
## Relations
- Relation : <A COMPLETER PAR AGENT>
- Cardinalité : <A COMPLETER PAR AGENT>
---
- **Item N..1 Location** : Un item est dans une location, une location contient plusieurs items (CASCADE DELETE optionnel) <!-- complété par codex -->
- **Item N..1 Category** : Un item appartient à une catégorie, une catégorie contient plusieurs items (RESTRICT DELETE) <!-- complété par codex -->
- **Item 1..N Document** : Un item a plusieurs documents, un document appartient à un item (CASCADE DELETE) <!-- complété par codex -->
- **Location N..1 Location (self)** : Hiérarchie parent/enfant (garage → étagère → boîte), `parent_id` NULL pour racine (RESTRICT DELETE pour éviter orphelins) <!-- complété par codex -->
## Exemple (a supprimer)
- `User` 1..N `Order`.
## Indexation recherche (SQLite FTS5)
- **fts_items** : Table virtuelle FTS5 sur `Item.name` + `Item.description` + `Category.name` + `Location.path` pour recherche full-text performante <!-- complété par codex -->
- Synchronisation : Triggers SQLite pour maintenir FTS5 à jour lors des INSERT/UPDATE/DELETE sur Item <!-- complété par codex -->
---

0
data/.gitkeep Normal file
View File

View File

@@ -0,0 +1,48 @@
services:
# Backend FastAPI
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: homestock-backend
ports:
- "${BACKEND_EXTERNAL_PORT}:8000"
volumes:
- ./backend:/app
- ./data:/app/data
- ./uploads:/app/uploads
environment:
- APP_NAME=${APP_NAME}
- ENVIRONMENT=${ENVIRONMENT}
- DEBUG=${DEBUG}
- LOG_LEVEL=${LOG_LEVEL}
- DATABASE_URL=${DATABASE_URL}
- CORS_ORIGINS=${CORS_ORIGINS}
- UPLOAD_DIR=${UPLOAD_DIR}
- MAX_UPLOAD_SIZE_MB=${MAX_UPLOAD_SIZE_MB}
restart: unless-stopped
command: uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
# Frontend React + Vite
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: homestock-frontend
ports:
- "${FRONTEND_EXTERNAL_PORT}:5173"
volumes:
- ./frontend:/app
- /app/node_modules
environment:
- VITE_API_BASE_URL=${VITE_API_BASE_URL}
- VITE_APP_NAME=${VITE_APP_NAME}
- VITE_APP_VERSION=${VITE_APP_VERSION}
restart: unless-stopped
depends_on:
- backend
command: npm run dev -- --host 0.0.0.0
volumes:
data:
uploads:

View File

@@ -11,54 +11,49 @@ Il sert de base aux décisions techniques, aux ADR et au découpage des tâches.
---
## 1. Vue densemble
- Objectif produit : <A REMPLIR - PROJET> (exemple: améliorer la traçabilité — a supprimer)
- Type dapp (web, mobile, API) : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
- Contraintes fortes : <A REMPLIR - PROJET> (exemple: déploiement on-premise — a supprimer)
## 1. Vue d'ensemble
- Objectif produit : Gérer l'inventaire complet d'un domicile avec recherche, localisation précise et archivage de documents <!-- complété par codex -->
- Type d'app : Application web full-stack (API REST + SPA) avec déploiement conteneurisé <!-- complété par codex -->
- Contraintes fortes : Self-hosted sur réseau local, mono-utilisateur, simplicité de déploiement et maintenance <!-- complété par codex -->
## 2. Principes darchitecture
- Principes non négociables : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
- Principes dévolution : <A COMPLETER PAR AGENT>
- Qualités prioritaires (performance, sécurité, scalabilité) : <A REMPLIR - PROJET> (exemple: sécurité et performance — a supprimer)
## 2. Principes d'architecture
- Principes non négociables : Monolithe modulaire, séparation stricte frontend/backend, documentation avant implémentation, ADR pour décisions structurantes <!-- complété par codex -->
- Principes d'évolution : Architecture permettant une évolution vers multi-utilisateurs sans refonte complète, modules indépendants testables séparément <!-- complété par codex -->
- Qualités prioritaires : Simplicité d'utilisation > Performance > Maintenabilité > Évolutivité (mono-utilisateur donc pas de scalabilité horizontale) <!-- complété par codex -->
## 3. Architecture logique
- Modules principaux : <A COMPLETER PAR AGENT>
- Responsabilités par module : <A COMPLETER PAR AGENT>
- Frontend/Backend séparation : <A COMPLETER PAR AGENT>
- Modules principaux : `items` (objets/équipements), `locations` (emplacements physiques), `categories` (domaines: bricolage, informatique, etc.), `documents` (photos, notices, factures), `search` (recherche full-text) <!-- complété par codex -->
- Responsabilités par module : `items` = CRUD objets + état + prix, `locations` = hiérarchie lieux (meuble>tiroir>boîte), `categories` = classification, `documents` = upload/stockage fichiers, `search` = indexation et recherche <!-- complété par codex -->
- Frontend/Backend séparation : SPA React (frontend) communique uniquement via API REST avec FastAPI (backend), pas de SSR (Server-Side Rendering), API documentée par OpenAPI <!-- complété par codex -->
## 4. Architecture technique
- Langages & frameworks : <A COMPLETER PAR AGENT>
- Base de données : <A COMPLETER PAR AGENT>
- Stockage fichiers : <A COMPLETER PAR AGENT>
- Infra cible (cloud/self-hosted) : <A REMPLIR - PROJET> (exemple: self-hosted — a supprimer)
- Langages & frameworks : Backend = Python 3.11+ avec FastAPI + SQLAlchemy + Pydantic, Frontend = TypeScript + React 18+ + Vite + TailwindCSS <!-- complété par codex -->
- Base de données : SQLite (fichier local homestock.db) avec FTS5 pour recherche full-text, migrations via Alembic <!-- complété par codex -->
- Stockage fichiers : Système de fichiers local, dossier `uploads/` organisé par type (photos/, notices/, factures/), chemin relatif stocké en BDD <!-- complété par codex -->
- Infra cible : Self-hosted via Docker Compose, réseau local 10.0.0.0/22, reverse proxy optionnel (Traefik/Nginx) <!-- complété par codex -->
## 5. Flux de données
- Flux principaux (lecture/écriture) : <A COMPLETER PAR AGENT>
- Intégrations externes : <A REMPLIR - PROJET> (exemple: ERP existant — a supprimer)
- Gestion des événements/asynchronisme : <A COMPLETER PAR AGENT>
- Flux principaux : Lecture = GET items avec filtres (catégorie, localisation, état) + recherche full-text, Écriture = POST/PUT/DELETE items + upload fichiers multipart/form-data <!-- complété par codex -->
- Intégrations externes : Aucune intégration externe prévue, système autonome et déconnecté <!-- complété par codex -->
- Gestion des événements/asynchronisme : Upload fichiers en asynchrone (FastAPI background tasks), pas d'événements temps réel pour MVP (possibilité WebSocket future) <!-- complété par codex -->
## 6. Sécurité
- Authentification/autorisation : <A COMPLETER PAR AGENT>
- Données sensibles : <A REMPLIR - PROJET> (exemple: emails + historiques de paiement — a supprimer)
- Traçabilité/audit : <A COMPLETER PAR AGENT>
- Authentification/autorisation : Optionnelle pour MVP mono-utilisateur, si activée = login simple (username/password) + session cookie, pas de JWT pour simplicité <!-- complété par codex -->
- Données sensibles : Factures (montants, dates d'achat), localisations précises d'objets de valeur → chiffrement au repos optionnel, accès réseau local uniquement <!-- complété par codex -->
- Traçabilité/audit : Pas d'audit trail strict pour MVP, timestamps created_at/updated_at sur entités principales, logs applicatifs pour debug <!-- complété par codex -->
## 7. Observabilité
- Logs (journaux) : <A COMPLETER PAR AGENT>
- Metrics (mesures) : <A COMPLETER PAR AGENT>
- Alerting (alertes) : <A COMPLETER PAR AGENT>
- Logs : Python logging avec rotation (loguru recommandé), niveau INFO en production, DEBUG en dev, format JSON pour parsing <!-- complété par codex -->
- Metrics : Optionnelles pour MVP, possibilité ajout Prometheus + Grafana plus tard (nb items, espace disque uploads/, temps réponse API) <!-- complété par codex -->
- Alerting : Pas d'alerting pour MVP mono-utilisateur, monitoring manuel via logs et health check endpoint `/health` <!-- complété par codex -->
## 8. Conventions de code
- Organisation des dossiers : <A COMPLETER PAR AGENT>
- Standards de code : <A COMPLETER PAR AGENT>
- Tests obligatoires : <A COMPLETER PAR AGENT>
- Organisation des dossiers : Backend = `backend/app/` (routers/, models/, schemas/, services/), Frontend = `frontend/src/` (components/, pages/, hooks/, api/) <!-- complété par codex -->
- Standards de code : Backend = ruff format + mypy strict, Frontend = ESLint + Prettier, nommage snake_case (Python) et camelCase (TS), français pour commentaires <!-- complété par codex -->
- Tests obligatoires : Backend = tests unitaires (services/) + tests intégration (API), Frontend = tests unitaires (utils/hooks), couverture minimum 70% sur logique métier <!-- complété par codex -->
## 9. Évolution & dette
- Zones à risque : <A REMPLIR - PROJET> (exemple: montée en charge — a supprimer)
- Améliorations prévues : <A REMPLIR - PROJET> (exemple: reporting avancé — a supprimer)
- Zones à risque : Recherche full-text sur SQLite (limitations si >10k items), stockage fichiers local (backup manuel nécessaire), authentification simpliste <!-- complété par codex -->
- Améliorations prévues : Migration SQLite→PostgreSQL si besoin, stockage fichiers vers MinIO/S3, multi-utilisateurs avec RBAC (contrôle d'accès par rôle), API mobile, import/export CSV <!-- complété par codex -->
---
## Exemple (a supprimer)
- Modules : `auth`, `users`, `billing`, `catalog`.
- DB : PostgreSQL + migrations.
- Auth : JWT (jeton) + RBAC (contrôle daccès par rôle).
---

View File

@@ -11,24 +11,20 @@ Ce guide définit les conventions de code et de documentation.
---
## 1. Nommage
- Variables / fonctions : <A COMPLETER PAR AGENT>
- Fichiers / dossiers : <A COMPLETER PAR AGENT>
- API endpoints : <A COMPLETER PAR AGENT>
- Variables / fonctions : Backend = snake_case (Python PEP8), Frontend = camelCase (TypeScript/JavaScript standard) <!-- complété par codex -->
- Fichiers / dossiers : Backend = snake_case (item_service.py), Frontend = PascalCase pour composants (ItemCard.tsx), kebab-case pour autres (use-items.ts) <!-- complété par codex -->
- API endpoints : REST kebab-case `/api/v1/items`, `/api/v1/item-categories`, pluriel pour collections, singulier pour ressource unique <!-- complété par codex -->
## 2. Formatage
- Formatter / linter : <A COMPLETER PAR AGENT>
- Règles principales : <A COMPLETER PAR AGENT>
- Formatter / linter : Backend = ruff (format + lint) + mypy (types), Frontend = Prettier + ESLint + TypeScript strict <!-- complété par codex -->
- Règles principales : Indentation 4 espaces (Python), 2 espaces (TS/JS), ligne max 100 caractères, trailing commas, quotes doubles <!-- complété par codex -->
## 3. Tests
- Nommage des tests : <A COMPLETER PAR AGENT>
- Structure des tests : <A COMPLETER PAR AGENT>
- Nommage des tests : Backend = `test_<fonction>_<scenario>.py`, Frontend = `<module>.test.ts`, fonctions de test descriptives `test_create_item_with_valid_data` <!-- complété par codex -->
- Structure des tests : Pattern AAA (Arrange/Act/Assert), fixtures pytest pour setup, mocks minimaux, tests isolés et reproductibles <!-- complété par codex -->
## 4. Documentation
- Doc obligatoire : <A REMPLIR - PROJET> (exemple: README + ARCHITECTURE — a supprimer)
- ADR (Architecture Decision Record) : <A COMPLETER PAR AGENT>
- Doc obligatoire : README.md (démarrage), ARCHITECTURE.md (structure technique), fichiers CONTEXT (backend/frontend), ADR pour décisions structurantes <!-- complété par codex -->
- ADR : Suivre template docs/adr/TEMPLATE.md, numérotation séquentielle (0001, 0002...), statut (proposé/accepté/obsolète), contexte + décision + conséquences <!-- complété par codex -->
---
## Exemple (a supprimer)
- Formatter : `prettier` + `eslint`.
- Tests : `feature_x.test.ts`.
---

View File

@@ -12,36 +12,36 @@ Il sert de référence aux agents et aux contributeurs.
---
## 1. Branches
- Convention de nommage : <A COMPLETER PAR AGENT>
- Branches protégées : <A REMPLIR - PROJET> (exemple: main — a supprimer)
- Politique de merge : <A COMPLETER PAR AGENT>
- Convention de nommage : `feature/REQ-XXX-description`, `fix/bug-description`, `docs/update-xxx`, `refactor/module-name` <!-- complété par codex -->
- Branches protégées : `main` (production-ready) <!-- complété par codex -->
- Politique de merge : Squash merge vers main, fast-forward interdit, historique linéaire privilégié <!-- complété par codex -->
## 2. Commits
- Convention (ex: conventional commits) : <A COMPLETER PAR AGENT>
- Granularité attendue : <A REMPLIR - PROJET> (exemple: 1 feature par PR — a supprimer)
- Convention : Conventional Commits (feat/fix/docs/refactor/test/chore), format: `type(scope): message` en français <!-- complété par codex -->
- Granularité attendue : 1 commit par changement logique, 1 feature complète par PR avec tests associés <!-- complété par codex -->
## 3. Pull Requests
- Template PR : <A COMPLETER PAR AGENT>
- Relectures requises : <A REMPLIR - PROJET> (exemple: 1 review — a supprimer)
- Template PR : Titre court (<70 car), description avec ## Changements, ## Tests, ## Checklist, référence REQ-XXX <!-- complété par codex -->
- Relectures requises : Aucune (projet solo), possibilité de review par Claude Code avant merge <!-- complété par codex -->
- Checklist obligatoire : `docs/PR_CHECKLIST.md`
## 4. CI/CD
- Pipeline minimal (lint/test/build) : <A COMPLETER PAR AGENT>
- Vérifications bloquantes : <A COMPLETER PAR AGENT>
- Pipeline minimal : Gitea Actions avec steps: lint (ruff, eslint) → test (pytest, vitest) → build (Docker images) → optionnel deploy <!-- complété par codex -->
- Vérifications bloquantes : Lint sans erreur, tests unitaires passent, build Docker réussit, couverture >70% sur nouveau code <!-- complété par codex -->
## 5. Releases
- Versioning (semver = versionnage sémantique) : <A COMPLETER PAR AGENT>
- Tagging : <A COMPLETER PAR AGENT>
- Versioning : Semver (semantic versioning) v0.x.y pour pré-release, v1.0.0 pour première version stable, tags Git annotés <!-- complété par codex -->
- Tagging : Tag après merge dans main, format `vX.Y.Z`, signé GPG si possible <!-- complété par codex -->
- Release notes : `product/RELEASE_NOTES.md`
## 6. Qualité
- Definition of Done (définition de terminé) : <A REMPLIR - PROJET> (exemple: tests + doc — a supprimer)
- Tests obligatoires : <A COMPLETER PAR AGENT>
- Mises à jour doc : <A REMPLIR - PROJET> (exemple: si impact sur lAPI — a supprimer)
- Definition of Done : Feature implémentée + tests unitaires + tests intégration API + documentation mise à jour (si impact) + CI passe <!-- complété par codex -->
- Tests obligatoires : Tests unitaires sur logique métier (services/), tests intégration sur endpoints API, couverture minimum 70% <!-- complété par codex -->
- Mises à jour doc : Obligatoire si changement API (OpenAPI), architecture (ADR), ou contrats (data_model.md) <!-- complété par codex -->
## 7. Hotfix / Urgence
- Procédure : <A COMPLETER PAR AGENT>
- Responsables : <A REMPLIR - PROJET> (exemple: lead dev — a supprimer)
- Procédure : Branche `hotfix/description` depuis main, fix minimal, tests rapides, merge direct main, tag patch version <!-- complété par codex -->
- Responsables : Développeur principal (projet solo), pas de process complexe pour mono-utilisateur <!-- complété par codex -->
---
@@ -54,9 +54,4 @@ Il sert de référence aux agents et aux contributeurs.
- Réseau local : 10.0.0.0/22
- Passerelle : 10.0.0.1
---
## Exemple (a supprimer)
- Branches : `main`, `develop`, `feat/*`, `fix/*`.
- Commits : Conventional Commits.
- CI : lint + tests + build.
---

View File

@@ -0,0 +1,192 @@
# ADR-0001 — Choix de la stack technique globale
- Statut : accepted
- Date : 2026-01-27
---
## Contexte
HomeStock est une application web self-hosted de gestion d'inventaire domestique destinée à un usage mono-utilisateur. Les contraintes principales sont :
- **Simplicité de déploiement** : L'application doit être facile à installer et maintenir sur un réseau local
- **Usage mono-utilisateur** : Pas besoin de scalabilité horizontale ni de gestion complexe de concurrence
- **Self-hosted** : Déploiement local, pas de dépendance cloud
- **Développement rapide** : Besoin d'un MVP fonctionnel rapidement avec des technologies modernes et productives
- **Maintenance à long terme** : Stack stable avec un bon écosystème et une documentation solide
Le projet nécessite :
- Une API REST robuste pour le backend
- Une interface utilisateur moderne et réactive
- Une base de données adaptée au mono-utilisateur
- Un système de stockage de fichiers (photos, notices, factures)
---
## Décision
Nous avons choisi la stack technique suivante :
### Backend
- **Langage** : Python 3.11+
- **Framework API** : FastAPI
- **ORM** : SQLAlchemy 2.0+ avec Alembic pour migrations
- **Validation** : Pydantic (intégré à FastAPI)
### Frontend
- **Langage** : TypeScript
- **Framework UI** : React 18+
- **Bundler** : Vite 5+
- **Gestion état** : TanStack Query (React Query) pour l'état serveur, Context API pour l'état local
- **Styling** : TailwindCSS avec palette Gruvbox dark
### Base de données
- **SGBD** : SQLite avec extension FTS5 pour recherche full-text
- **Fichier** : `homestock.db` en local
### Stockage fichiers
- **Système** : Système de fichiers local
- **Organisation** : Dossier `uploads/` avec sous-dossiers par type (photos/, notices/, factures/)
- **Référencement** : Chemins relatifs stockés en base de données
### Déploiement
- **Conteneurisation** : Docker Compose
- **Réseau** : Réseau local 10.0.0.0/22
---
## Alternatives considérées
### Backend
1. **Django + Django REST Framework**
- ✅ Framework très complet avec admin intégré
- ✅ ORM mature et puissant
- ❌ Plus lourd et verbeux que FastAPI
- ❌ Performance inférieure en async
- **Rejeté** : Trop de fonctionnalités inutiles pour notre cas d'usage mono-utilisateur
2. **Go avec Gin/Echo**
- ✅ Très performant, binaire standalone
- ✅ Faible consommation mémoire
- ❌ Écosystème moins riche pour gestion fichiers/images
- ❌ Développement plus verbeux, moins rapide
- **Rejeté** : Performance non critique pour mono-utilisateur, productivité Python préférée
3. **Node.js avec Express/Fastify**
- ✅ Écosystème npm très riche
- ✅ Même langage que le frontend (JavaScript/TypeScript)
- ❌ Gestion async plus complexe qu'avec FastAPI
- ❌ Préférence personnelle pour Python
- **Rejeté** : Moins d'expérience, Python préféré pour backend
### Frontend
1. **Vue.js 3**
- ✅ Simple à apprendre, Composition API moderne
- ✅ Excellente documentation
- ❌ Écosystème légèrement moins riche que React
- **Rejeté** : React préféré pour son écosystème et l'expérience existante
2. **Svelte/SvelteKit**
- ✅ Très léger, compilation au build
- ✅ Syntaxe simple et élégante
- ❌ Écosystème moins mature
- ❌ Moins de ressources et bibliothèques tierces
- **Rejeté** : Écosystème trop jeune pour un projet à long terme
### Base de données
1. **PostgreSQL**
- ✅ Très robuste, fonctionnalités avancées
- ✅ Meilleure recherche full-text (GIN indexes)
- ❌ Serveur séparé à gérer, plus complexe
- ❌ Overhead pour mono-utilisateur avec faible volume
- **Rejeté** : Trop complexe pour un usage mono-utilisateur avec <10k items attendus
2. **MySQL/MariaDB**
- ✅ Populaire, bien documenté
- ✅ Bonnes performances
- ❌ Recherche full-text moins performante
- ❌ Serveur séparé à gérer
- **Rejeté** : Même raison que PostgreSQL, SQLite suffit largement
### Stockage fichiers
1. **MinIO (S3-compatible)**
- ✅ Scalable, versioning, métadonnées riches
- ✅ Compatible S3, facilite migration future
- ❌ Service supplémentaire à déployer et maintenir
- ❌ Overhead pour mono-utilisateur
- **Rejeté** : Trop complexe pour le besoin, système fichiers local suffit
2. **Stockage en base (BLOB)**
- ✅ Pas de fichiers externes à gérer
- ✅ Transactions ACID sur fichiers
- ❌ Performance dégradée avec fichiers volumineux
- ❌ Backup et restauration plus complexes
- ❌ Taille de la base de données augmente rapidement
- **Rejeté** : Mauvaise pratique, problèmes de performance prévisibles
---
## Conséquences
### Positives
1. **Simplicité de déploiement** : Un seul `docker-compose up` lance l'ensemble de la stack
2. **Performance suffisante** : FastAPI async + SQLite suffisent largement pour mono-utilisateur
3. **Développement rapide** : FastAPI + React offrent une excellente productivité
4. **Pas de serveur BDD externe** : SQLite embarqué, un seul fichier à sauvegarder
5. **Auto-documentation API** : FastAPI génère automatiquement OpenAPI/Swagger
6. **Typage fort** : TypeScript (frontend) + Pydantic (backend) réduisent les erreurs
7. **Écosystèmes matures** : Python et React ont d'excellentes bibliothèques tierces
8. **Recherche performante** : FTS5 SQLite offre une recherche full-text native et rapide
### Négatives
1. **Limitations SQLite** : Pas de scalabilité horizontale (non critique pour mono-utilisateur)
2. **Recherche limitée** : FTS5 moins puissant qu'Elasticsearch (acceptable pour <10k items)
3. **Stockage fichiers manuel** : Pas de métadonnées avancées ni versioning
4. **Backup manuel** : Nécessite scripts de backup pour fichiers + BDD
5. **Migration future** : Si passage multi-utilisateurs, migration vers PostgreSQL nécessaire
### Neutres
1. **Deux langages** : Python (backend) + TypeScript (frontend) nécessitent deux environnements
2. **Complexité initiale React** : Courbe d'apprentissage pour développement React moderne
---
## Impacts techniques
### Organisation du code
- **Monorepo** : Backend et frontend dans le même dépôt
- **Structure backend** : `backend/app/` avec routers/, models/, schemas/, services/, repositories/
- **Structure frontend** : `frontend/src/` avec components/, pages/, hooks/, api/
### Dépendances principales
- **Backend** : fastapi, uvicorn, sqlalchemy, alembic, pydantic, python-multipart, loguru
- **Frontend** : react, react-router-dom, @tanstack/react-query, tailwindcss, axios
### Développement
- **Tests** : pytest (backend), vitest (frontend)
- **Lint/Format** : ruff + mypy (backend), eslint + prettier (frontend)
- **CI/CD** : Gitea Actions avec lint → test → build → deploy
### Déploiement
- **Docker** : 3 services (backend, frontend, reverse proxy optionnel)
- **Volumes** : Persistance pour `homestock.db` et `uploads/`
- **Ports** : Backend :8000, Frontend :5173 (dev) ou :80/:443 (prod avec reverse proxy)
### Évolutivité future
- **Migration PostgreSQL** : Possible via Alembic migrations si besoin
- **Stockage S3** : Abstraction stockage permet ajout MinIO sans refonte
- **Multi-utilisateurs** : Architecture modulaire facilite ajout authentification/RBAC
---
## Notes
Cette stack a été choisie en janvier 2026 pour un MVP mono-utilisateur. Les choix privilégient la simplicité et la rapidité de développement sur la scalabilité et les fonctionnalités avancées qui ne sont pas nécessaires pour le cas d'usage initial.
La stack permet une évolution progressive vers multi-utilisateurs et stockage cloud si besoin, mais ces aspects sont volontairement exclus du MVP pour réduire la complexité initiale.
Les alternatives (PostgreSQL, MinIO) restent envisageables pour des évolutions futures et sont documentées dans la section "Améliorations prévues" de [docs/ARCHITECTURE.md](../ARCHITECTURE.md).
---
**Contributeurs** : Gilles (décideur) + Claude Code (architecte)

View File

@@ -1,24 +0,0 @@
# ADR-0001 — Exemple (a supprimer)
- Statut : accepted
- Date : 2026-01-27
## Contexte
- Besoin dun framework API simple et maintenable.
## Décision
- Utiliser un monolithe modulaire.
## Alternatives considérées
- Microservices.
- Serverless (exécution sans serveur dédié).
## Conséquences
- Déploiement plus simple.
- Risque de croissance du monolithe.
## Impacts techniques
- Découper par domaines.
## Notes
- Revoir après 6 mois.

View File

@@ -0,0 +1,235 @@
# ADR-0002 — Architecture monolithe modulaire
- Statut : accepted
- Date : 2026-01-27
---
## Contexte
HomeStock nécessite une architecture logicielle adaptée à un projet mono-utilisateur self-hosted avec les contraintes suivantes :
- **Simplicité de déploiement** : L'application doit pouvoir être déployée facilement avec une seule commande
- **Maintenance réduite** : Pas d'équipe DevOps, maintenance manuelle par l'utilisateur
- **Développement solo** : Un seul développeur, besoin de cohérence et de simplicité
- **Évolutivité future** : Possibilité d'évoluer vers multi-utilisateurs sans refonte complète
- **Modules logiques distincts** : items, locations, categories, documents, search
Le projet démarre avec un périmètre limité (MVP) mais doit pouvoir évoluer progressivement. La question architecturale centrale est : comment organiser le code pour maximiser la maintenabilité tout en gardant une complexité opérationnelle minimale ?
---
## Décision
Nous adoptons une **architecture monolithe modulaire** avec les caractéristiques suivantes :
### Définition
- **Un seul dépôt Git** (monorepo) contenant backend et frontend
- **Un seul processus backend** (serveur FastAPI unique)
- **Modules logiques séparés** avec responsabilités clairement définies
- **Communication intra-application** via appels de fonctions directs
- **Base de données unique** partagée entre tous les modules
### Organisation backend (`backend/app/`)
```
backend/app/
├── main.py # Point d'entrée FastAPI
├── core/ # Configuration, logging, database
│ ├── config.py
│ ├── database.py
│ └── logging.py
├── models/ # Modèles SQLAlchemy (un fichier par entité)
│ ├── item.py
│ ├── location.py
│ ├── category.py
│ └── document.py
├── schemas/ # Schémas Pydantic (validation API)
│ ├── item.py
│ ├── location.py
│ └── ...
├── routers/ # Endpoints API (un fichier par ressource)
│ ├── items.py
│ ├── locations.py
│ ├── categories.py
│ ├── documents.py
│ └── search.py
├── services/ # Logique métier (orchestration)
│ ├── item_service.py
│ ├── location_service.py
│ └── ...
├── repositories/ # Accès données (abstraction BDD)
│ ├── item_repository.py
│ ├── location_repository.py
│ └── ...
└── utils/ # Utilitaires transverses
├── files.py
└── search.py
```
### Organisation frontend (`frontend/src/`)
```
frontend/src/
├── main.tsx # Point d'entrée React
├── App.tsx # Composant racine + routing
├── components/ # Composants réutilisables
│ ├── common/ # Boutons, inputs, modales...
│ ├── items/ # Composants spécifiques items
│ └── locations/ # Composants spécifiques locations
├── pages/ # Pages complètes (routes)
│ ├── Dashboard.tsx
│ ├── ItemList.tsx
│ ├── ItemDetail.tsx
│ └── ...
├── hooks/ # Custom hooks React
│ ├── useItems.ts
│ ├── useLocations.ts
│ └── ...
├── api/ # Client API (fetch/axios)
│ ├── items.ts
│ ├── locations.ts
│ └── ...
└── utils/ # Utilitaires helpers
└── formatting.ts
```
### Principes d'organisation
1. **Séparation en couches backend** : routers → services → repositories → models
2. **Dépendances unidirectionnelles** : Les couches supérieures dépendent des inférieures uniquement
3. **Modules auto-contenus** : Chaque module (items, locations, etc.) a ses propres fichiers dans chaque couche
4. **Pas de dépendances circulaires** entre modules
5. **Code partagé** dans `core/` et `utils/`
---
## Alternatives considérées
### 1. Microservices
**Description** : Séparer l'application en plusieurs services indépendants (service items, service locations, service search, etc.)
**Avantages** :
- ✅ Scalabilité horizontale indépendante par service
- ✅ Isolation des pannes (un service down ≠ tout down)
- ✅ Possibilité d'utiliser des technologies différentes par service
- ✅ Déploiement indépendant de chaque service
**Inconvénients** :
- ❌ Complexité opérationnelle énorme (orchestration, networking, monitoring)
- ❌ Nécessite API Gateway, service discovery, circuit breakers
- ❌ Communication réseau entre services (latence, sérialisations multiples)
- ❌ Transactions distribuées complexes (saga pattern)
- ❌ Debugging et traçabilité difficiles
- ❌ Overhead infrastructure (plusieurs conteneurs, load balancers, etc.)
**Verdict** : ❌ **Rejeté** - Complexité totalement disproportionnée pour un projet mono-utilisateur avec ~1 req/seconde max. Les bénéfices de scalabilité ne s'appliquent pas au cas d'usage.
### 2. Monolithe non structuré (big ball of mud)
**Description** : Tout le code dans quelques gros fichiers sans organisation claire
**Avantages** :
- ✅ Démarrage très rapide
- ✅ Pas de contraintes architecturales
**Inconvénients** :
- ❌ Maintenance cauchemardesque après quelques mois
- ❌ Code fortement couplé, difficile à tester
- ❌ Évolution impossible sans refonte complète
- ❌ Onboarding difficile pour contributeurs futurs
**Verdict** : ❌ **Rejeté** - Dette technique garantie, inacceptable même pour un projet solo
### 3. Architecture hexagonale (ports & adapters)
**Description** : Isolation stricte du domaine métier avec ports (interfaces) et adapters (implémentations)
**Avantages** :
- ✅ Isolation forte du domaine métier
- ✅ Testabilité maximale (mocks faciles)
- ✅ Changement de base de données transparent
**Inconvénients** :
- ❌ Boilerplate important (interfaces, adapters, DTOs multiples)
- ❌ Courbe d'apprentissage élevée
- ❌ Overhead pour un projet simple comme HomeStock
**Verdict** : ⚠️ **Rejeté mais inspirant** - Trop complexe pour notre besoin, mais on garde l'idée de séparation en couches (repository pattern)
### 4. Monolithe avec modules faiblement couplés (notre choix)
**Description** : Un seul déploiement avec modules logiques bien séparés et communication via interfaces claires
**Avantages** :
- ✅ Simplicité opérationnelle (un seul processus, un seul conteneur)
- ✅ Transactions ACID simples (même base de données)
- ✅ Pas de latence réseau entre modules
- ✅ Refactoring et debugging faciles
- ✅ Évolution progressive possible (extraction en microservices si vraiment nécessaire)
- ✅ Maintenance réduite
**Inconvénients** :
- ⚠️ Scalabilité horizontale limitée (non critique pour mono-utilisateur)
- ⚠️ Restart complet nécessaire pour tout changement (acceptable)
**Verdict** : ✅ **Choisi** - Meilleur équilibre complexité/bénéfices pour notre contexte
---
## Conséquences
### Positives
1. **Déploiement simple** : `docker-compose up` lance tout le système
2. **Transactions ACID** : Toutes les opérations en base profitent des transactions SQLite
3. **Performance** : Pas de latence réseau entre modules, appels de fonctions directs
4. **Debugging facile** : Stack traces complètes, un seul processus à inspecter
5. **Refactoring sans risque** : IDE et linters détectent les erreurs lors de renommages
6. **Tests simples** : Tests d'intégration faciles (toute l'app dans le même process)
7. **Maintenance réduite** : Un seul service à monitorer, logs centralisés naturellement
### Négatives
1. **Scalabilité limitée** : Impossible de scaler un module indépendamment (non pertinent pour mono-utilisateur)
2. **Couplage temporel** : Si un module plante, tout plante (acceptable avec bonne gestion erreurs)
3. **Restart complet** : Modification = restart de toute l'app (dev avec hot-reload, prod peu de déploiements)
### Mitigations
- **Pour éviter le couplage fort** : Respecter strictement les couches (routers → services → repositories)
- **Pour faciliter évolution future** : Interfaces claires entre modules, pas de dépendances circulaires
- **Pour améliorer résilience** : Gestion d'erreurs robuste, retry logic, circuit breaker patterns si nécessaire
---
## Impacts techniques
### Développement
- **Pattern 3-layer** : Chaque feature touche 3 fichiers minimum (router, service, repository)
- **Imports Python** : Imports absolus depuis `app.` pour éviter imports relatifs fragiles
- **Tests** : Tests unitaires par couche + tests d'intégration API end-to-end
### Déploiement
- **Docker** : Un seul conteneur backend (+ conteneur frontend séparé pour SPA)
- **Scaling** : Scale vertical uniquement (augmenter CPU/RAM du conteneur)
- **Rollback** : Simple (redéploiement image Docker précédente)
### Évolution future
Si le projet nécessite vraiment des microservices (multi-utilisateurs massif, équipes séparées) :
1. **Extraction progressive** : Modules déjà bien séparés, extraction facilitée
2. **API interne → API externe** : Appels de fonctions deviennent appels HTTP
3. **Base partagée → bases séparées** : Migrations Alembic facilitent séparation BDD
Cette architecture n'empêche pas l'évolution, elle la retarde jusqu'à ce qu'elle soit vraiment nécessaire (principe YAGNI = You Ain't Gonna Need It).
---
## Notes
Cette décision s'inscrit dans la philosophie "Start with a monolith" popularisée par Martin Fowler et confirmée par de nombreux retours d'expérience (Shopify, GitHub, Stack Overflow ont démarré en monolithe).
Pour HomeStock, projet mono-utilisateur avec ~1 req/sec max en pic, un monolithe modulaire est largement suffisant et restera probablement optimal même à long terme.
La complexité des microservices n'apporterait aucun bénéfice tangible et augmenterait drastiquement les coûts de développement et maintenance.
**Principe appliqué** : "Choose boring technology" - privilégier les architectures éprouvées et simples sur les architectures à la mode mais complexes.
---
**Contributeurs** : Gilles (décideur) + Claude Code (architecte)
**Références** :
- Martin Fowler - "Monolith First" : https://martinfowler.com/bliki/MonolithFirst.html
- Sam Newman - "Building Microservices" (recommande de démarrer en monolithe)

View File

@@ -0,0 +1,259 @@
# ADR-0003 — Recherche full-text avec SQLite FTS5
- Statut : accepted
- Date : 2026-01-27
---
## Contexte
HomeStock nécessite une fonctionnalité de recherche rapide et efficace pour retrouver des objets dans l'inventaire. Les utilisateurs doivent pouvoir rechercher par :
- Nom de l'objet (ex: "perceuse")
- Description (ex: "outil électrique sans fil")
- Catégorie (ex: "bricolage")
- Localisation (ex: "garage", "étagère A")
**Contraintes identifiées** :
- **Volume de données** : ~1000-5000 items attendus initialement, maximum 10000 items à long terme
- **Performance** : Résultats de recherche en <200ms souhaités
- **Simplicité** : Pas de serveur externe supplémentaire à gérer
- **Pertinence** : Recherche "fuzzy" pas nécessaire, recherche exacte par mots-clés suffit
- **Langue** : Recherche en français uniquement
Le choix de la solution de recherche impacte directement l'expérience utilisateur (fonctionnalité critique) et l'architecture technique (composant additionnel ou intégré).
---
## Décision
Nous utilisons **SQLite FTS5 (Full-Text Search 5)** pour la recherche full-text avec les caractéristiques suivantes :
### Configuration
- **Table virtuelle FTS5** : `fts_items` contenant les champs indexés
- **Champs indexés** : `Item.name`, `Item.description`, `Category.name`, `Location.path`
- **Tokenizer** : `unicode61` (support Unicode, casse insensible)
- **Synchronisation** : Triggers SQLite pour maintenir FTS5 à jour lors des INSERT/UPDATE/DELETE
### Exemple de structure
```sql
CREATE VIRTUAL TABLE fts_items USING fts5(
item_id UNINDEXED,
name,
description,
category_name,
location_path,
tokenize = 'unicode61'
);
-- Triggers pour synchronisation automatique
CREATE TRIGGER items_after_insert AFTER INSERT ON items BEGIN
INSERT INTO fts_items(item_id, name, description, category_name, location_path)
VALUES (
NEW.id,
NEW.name,
NEW.description,
(SELECT name FROM categories WHERE id = NEW.category_id),
(SELECT path FROM locations WHERE id = NEW.location_id)
);
END;
-- Triggers similaires pour UPDATE et DELETE
```
### Requêtes de recherche
```sql
-- Recherche simple
SELECT items.* FROM items
JOIN fts_items ON items.id = fts_items.item_id
WHERE fts_items MATCH 'perceuse';
-- Recherche avec boost (priorité sur le nom)
SELECT items.* FROM items
JOIN fts_items ON items.id = fts_items.item_id
WHERE fts_items MATCH '{name}: perceuse OR {description}: perceuse'
ORDER BY rank;
```
---
## Alternatives considérées
### 1. Elasticsearch
**Description** : Moteur de recherche distribué dédié, standard de l'industrie
**Avantages** :
- ✅ Recherche extrêmement performante et flexible
- ✅ Recherche fuzzy, phonétique, synonymes
- ✅ Agrégations et analytics avancées
- ✅ Scalable horizontalement
**Inconvénients** :
- ❌ Serveur Java lourd (1-2GB RAM minimum)
- ❌ Complexité opérationnelle importante
- ❌ Nécessite synchronisation BDD → Elasticsearch
- ❌ Overhead massif pour 1000-10000 items
- ❌ Configuration complexe pour français
**Verdict** : ❌ **Rejeté** - Tuer une mouche avec un bazooka. Elasticsearch est conçu pour des millions de documents et des cas d'usage complexes (facettes, agrégations, etc.) dont HomeStock n'a pas besoin.
### 2. MeiliSearch
**Description** : Moteur de recherche moderne, léger, orienté UX
**Avantages** :
- ✅ Très rapide et pertinent out-of-the-box
- ✅ API REST simple
- ✅ Typo-tolerance native
- ✅ Plus léger qu'Elasticsearch (~50MB RAM)
- ✅ Configuration minimale
**Inconvénients** :
- ❌ Service externe supplémentaire à déployer
- ❌ Synchronisation BDD → MeiliSearch nécessaire
- ❌ Overhead pour petit volume de données
- ❌ Dépendance additionnelle à maintenir
**Verdict** : ⚠️ **Rejeté mais intéressant** - Excellente solution technique mais ajoute de la complexité pour un bénéfice limité sur <10k items. À considérer si passage à >50k items.
### 3. PostgreSQL Full-Text Search (pg_trgm + GIN)
**Description** : Recherche full-text native de PostgreSQL
**Avantages** :
- ✅ Très performant avec index GIN
- ✅ Recherche trigram (similitude)
- ✅ Support langues naturelles (stemming français)
- ✅ Pas de service externe
**Inconvénients** :
- ❌ Nécessite PostgreSQL (vs SQLite choisi dans ADR-0001)
- ❌ Serveur BDD à gérer
- ❌ Complexité setup (dictionnaires français, configuration)
**Verdict** : ❌ **Rejeté** - Excellente solution technique mais nécessite PostgreSQL. Si migration vers PostgreSQL (cas multi-utilisateurs), reconsidérer.
### 4. LIKE '%keyword%' en SQL
**Description** : Recherche basique avec opérateur LIKE
**Avantages** :
- ✅ Extrêmement simple
- ✅ Aucune dépendance
**Inconvénients** :
- ❌ Performance catastrophique (full table scan)
- ❌ Pas de pertinence/ranking
- ❌ Impossible de rechercher sur plusieurs champs efficacement
- ❌ Pas d'opérateurs (AND, OR, NOT)
**Verdict** : ❌ **Rejeté** - Inacceptable même pour 1000 items, expérience utilisateur dégradée
### 5. SQLite FTS5 (notre choix)
**Description** : Module de recherche full-text intégré à SQLite
**Avantages** :
- ✅ Intégré à SQLite, pas de service externe
- ✅ Performance excellente pour <100k documents
- ✅ Support opérateurs (AND, OR, NOT, NEAR, phrases)
- ✅ Ranking par pertinence (BM25)
- ✅ Triggers pour synchronisation automatique
- ✅ Tokenizer Unicode (support français)
- ✅ Footprint mémoire minimal
**Inconvénients** :
- ⚠️ Pas de recherche fuzzy native (typos)
- ⚠️ Pas de stemming français parfait
- ⚠️ Limitations si >100k documents (acceptable pour notre cas)
**Verdict** : ✅ **Choisi** - Solution optimale pour notre contexte : performance suffisante, simplicité maximale, pas de dépendance externe
---
## Conséquences
### Positives
1. **Simplicité architecture** : Pas de service externe, tout dans SQLite
2. **Performance suffisante** : FTS5 recherche <50ms sur 10k items
3. **Synchronisation automatique** : Triggers maintiennent l'index à jour
4. **Zéro configuration** : FTS5 inclus dans SQLite depuis version 3.9.0 (2015)
5. **Backup simplifié** : Index FTS5 dans le même fichier .db que les données
6. **Opérateurs avancés** : Support AND, OR, NOT, recherche par phrase
7. **Ranking pertinent** : Algorithme BM25 pour trier par pertinence
### Négatives
1. **Pas de typo-tolerance** : "perceuze" ne trouvera pas "perceuse"
2. **Stemming limité** : "perçant" et "percer" non reliés automatiquement
3. **Performance dégradée >100k items** : Limite théorique (très au-delà de notre besoin)
4. **Pas d'agrégations** : Impossible de faire des facettes de recherche complexes
5. **Recherche française limitée** : Pas de dictionnaire français sophistiqué
### Mitigations
- **Typo-tolerance** : Implémenter suggestions de correction côté application si besoin (Levenshtein distance)
- **Stemming** : Ajouter mots-clés/tags aux items pour améliorer découvrabilité
- **Performance** : Si >100k items, migrer vers PostgreSQL pg_trgm ou MeiliSearch
- **Agrégations** : Implémenter filtres côté application (par catégorie, localisation, etc.)
---
## Impacts techniques
### Développement
- **Migration Alembic** : Créer table virtuelle FTS5 + triggers lors du setup initial
- **Service de recherche** : `search_service.py` encapsule la logique FTS5
- **API endpoint** : `GET /api/v1/search?q=keyword` retourne items matchant
### Performance attendue
- **<1000 items** : <10ms
- **1000-5000 items** : <50ms
- **5000-10000 items** : <150ms
- **>10000 items** : Dégrédation possible, monitoring nécessaire
### Structure de code
```python
# services/search_service.py
class SearchService:
async def search_items(self, query: str, limit: int = 50):
"""Recherche full-text avec FTS5"""
# Sanitize query (échapper caractères spéciaux)
safe_query = self._escape_fts5_query(query)
# Query FTS5 avec ranking
sql = """
SELECT items.*,
rank AS relevance
FROM items
JOIN fts_items ON items.id = fts_items.item_id
WHERE fts_items MATCH :query
ORDER BY rank
LIMIT :limit
"""
return await self.db.execute(sql, {"query": safe_query, "limit": limit})
```
### Évolution future
Si les limitations deviennent bloquantes :
1. **Migration PostgreSQL** : Activer pg_trgm pour recherche similitude
2. **Ajout MeiliSearch** : Service séparé pour recherche avancée (garder SQLite pour données)
3. **Hybrid search** : Combiner FTS5 (rapide) + recherche sémantique externe (typos, synonymes)
---
## Notes
SQLite FTS5 est utilisé par de nombreux projets à succès pour des cas d'usage similaires :
- **Firefox** : Recherche dans l'historique et favoris
- **Apple Mail** : Indexation locale des emails
- **VS Code** : Recherche dans l'historique de commandes
Pour HomeStock avec <10k items attendus, FTS5 est largement suffisant et offre le meilleur ratio performance/simplicité.
La décision de ne pas utiliser Elasticsearch ou MeiliSearch n'est pas dogmatique : si le projet évolue vers >50k items ou nécessite recherche fuzzy sophistiquée, migration possible sans refonte majeure (API de recherche reste identique, seule l'implémentation change).
**Principe appliqué** : "Use the simplest tool that works" - Ne pas sur-architecturer pour des besoins hypothétiques futurs.
---
**Contributeurs** : Gilles (décideur) + Claude Code (architecte)
**Références** :
- SQLite FTS5 documentation : https://www.sqlite.org/fts5.html
- BM25 ranking algorithm : https://en.wikipedia.org/wiki/Okapi_BM25

View File

@@ -0,0 +1,226 @@
# ADR-0004 — Pas d'authentification (réseau local uniquement)
- Statut : accepted
- Date : 2026-01-27
---
## Contexte
HomeStock est une application web self-hosted destinée à un usage mono-utilisateur sur un réseau local domestique. La question de la sécurité d'accès se pose :
**Contraintes du projet** :
- **Déploiement** : Réseau local 10.0.0.0/22, pas d'exposition Internet prévue
- **Utilisateurs** : Mono-utilisateur (propriétaire du domicile)
- **Données** : Inventaire personnel, factures (non sensibles au sens RGPD)
- **Accès physique** : Réseau domestique, appareils de confiance uniquement
**Questions à résoudre** :
1. Faut-il implémenter un système d'authentification (login/password) ?
2. Si oui, quel niveau de sécurité est nécessaire ?
3. Quels sont les risques réels d'un accès non authentifié sur réseau local ?
L'authentification ajoute de la complexité (gestion sessions, hash passwords, UI login) et peut dégrader l'expérience utilisateur (saisie répétée de credentials) pour un bénéfice de sécurité potentiellement faible dans ce contexte.
---
## Décision
**Nous ne déployons PAS de système d'authentification pour le MVP.** L'application est accessible librement sur le réseau local sans login ni password.
### Justification
1. **Périmètre réseau contrôlé** : Réseau domestique 10.0.0.0/22, tous les appareils sont de confiance
2. **Pas d'exposition Internet** : Application non accessible depuis l'extérieur, pas de port forwarding
3. **Mono-utilisateur** : Pas de besoin de gestion de sessions ou de rôles
4. **Simplicité UX** : Accès immédiat à l'application, pas de friction login
5. **Données non critiques** : Inventaire domestique et factures, pas de données bancaires ou médicales
### Périmètre de sécurité
-**Réseau local uniquement** : Application bind sur IP privée, pas 0.0.0.0
-**HTTPS optionnel** : TLS pour chiffrement en transit si certificat local disponible
-**Firewall** : Ports fermés en dehors du réseau local (configuration routeur)
-**Pas de login/password** : Accès direct à l'application
-**Pas de sessions** : Pas de gestion de tokens ou cookies d'authentification
-**Pas de RBAC** : Pas de rôles ou permissions (mono-utilisateur)
### Configuration réseau recommandée
```yaml
# docker-compose.yml
services:
backend:
ports:
- "10.0.0.X:8000:8000" # Bind IP privée uniquement, pas 0.0.0.0:8000
environment:
- ALLOWED_HOSTS=10.0.0.0/22 # Whitelist réseau local
```
---
## Alternatives considérées
### 1. Authentification basique (login/password)
**Description** : Page de connexion avec username/password, session cookie
**Avantages** :
- ✅ Protection contre accès non autorisé sur réseau local
- ✅ Traçabilité des accès (logs)
- ✅ Possibilité multi-utilisateurs future facilitée
**Inconvénients** :
- ❌ Complexité additionnelle (hash passwords, sessions, CSRF, refresh tokens)
- ❌ UX dégradée (saisie credentials à chaque accès)
- ❌ Gestion mot de passe oublié nécessaire
- ❌ Sécurité illusoire si appareil compromis (cookies volés)
**Verdict** : ❌ **Rejeté pour MVP** - Bénéfice limité sur réseau local de confiance, complexité injustifiée
### 2. Authentification par IP (whitelist)
**Description** : Autoriser uniquement certaines IPs du réseau local
**Avantages** :
- ✅ Simple à implémenter (middleware FastAPI)
- ✅ Pas d'UI login
**Inconvénients** :
- ❌ Gestion fastidieuse des IPs autorisées
- ❌ DHCP complique le suivi (IPs dynamiques)
- ❌ Pas de granularité utilisateur
**Verdict** : ❌ **Rejeté** - Complexité sans bénéfice majeur, réseau déjà isolé
### 3. Authentification par certificat client (mTLS)
**Description** : Certificats X.509 pour authentifier les appareils
**Avantages** :
- ✅ Sécurité forte
- ✅ Pas de password à mémoriser
**Inconvénients** :
- ❌ Complexité extrême (PKI, distribution certificats)
- ❌ Configuration browser complexe
- ❌ Totalement surdimensionné pour le cas d'usage
**Verdict** : ❌ **Rejeté** - Overkill absolu
### 4. Pas d'authentification (notre choix)
**Description** : Accès libre sur réseau local
**Avantages** :
- ✅ Simplicité maximale (pas de code auth)
- ✅ UX optimale (accès instantané)
- ✅ Maintenance réduite
- ✅ Adapté au contexte mono-utilisateur réseau local
**Inconvénients** :
- ⚠️ Accès libre pour tout appareil sur réseau local
- ⚠️ Pas de traçabilité utilisateur
- ⚠️ Si exposition Internet accidentelle = vulnérabilité
**Verdict** : ✅ **Choisi** - Solution adaptée au contexte avec mitigations appropriées
---
## Conséquences
### Positives
1. **Simplicité code** : Pas de code d'authentification, sessions, CSRF protection
2. **UX fluide** : Accès immédiat, pas de friction login
3. **Pas de gestion passwords** : Pas de hash, reset password, rotation
4. **Maintenance réduite** : Pas de vulnérabilités auth à monitorer (OWASP Top 10)
5. **Performance** : Pas de vérification session sur chaque requête
### Négatives et risques
1. **Exposition accidentelle Internet** : Si port forwarding activé par erreur → accès public
2. **Appareil compromis sur réseau** : Malware sur laptop/smartphone pourrait accéder à l'app
3. **Visiteurs réseau** : Invités connectés au WiFi domestique peuvent accéder à l'app
4. **Pas de traçabilité** : Impossible de savoir qui a modifié/supprimé des données
### Mitigations implémentées
1. **Bind IP privée uniquement** : Backend écoute sur 10.0.0.X:8000, pas 0.0.0.0
2. **Firewall routeur** : Ports 8000/5173 fermés en WAN, ouverts LAN uniquement
3. **HTTPS optionnel** : Certificat auto-signé pour chiffrement en transit
4. **Logging applicatif** : Logs des actions (créer/modifier/supprimer items) avec IP source
5. **Backup régulier** : Scripts de backup pour récupération en cas d'actions malveillantes
6. **Documentation claire** : Avertissement dans README sur importance isolation réseau
### Mitigations à envisager (post-MVP)
1. **Mode "invité" simple** : Code PIN basique si besoin de partager l'accès temporairement
2. **Alerte exposition** : Check au startup si l'app est accessible depuis Internet (API externe)
3. **Read-only mode** : Mode lecture seule pour appareils moins fiables
---
## Impacts techniques
### Code simplifié
Pas de code d'authentification signifie :
- Pas de modèle `User` en base
- Pas de hash passwords (bcrypt, argon2)
- Pas de gestion sessions/tokens
- Pas de middleware CSRF protection
- Pas d'endpoints `/login`, `/logout`, `/register`
- Pas d'UI login/signup
### Configuration Docker
```yaml
# docker-compose.yml
services:
backend:
ports:
- "10.0.0.50:8000:8000" # IP privée fixe uniquement
environment:
- ALLOWED_ORIGINS=http://10.0.0.50:5173,http://localhost:5173
- CORS_ALLOW_CREDENTIALS=false # Pas de cookies
```
### Logging renforcé
Même sans auth, logger les actions pour traçabilité :
```python
# Exemple log
logger.info(
"Item created",
extra={
"item_id": item.id,
"item_name": item.name,
"source_ip": request.client.host,
"user_agent": request.headers.get("user-agent")
}
)
```
### Évolution future
Si exposition Internet devient nécessaire (accès depuis extérieur) :
1. **Ajouter authentification** : Login simple (username/password) + session cookie
2. **Reverse proxy avec auth** : Traefik BasicAuth ou OAuth2 Proxy
3. **VPN** : Accès via WireGuard/Tailscale (recommandé)
4. **Cloudflare Access** : Zero-trust avec auth externe
---
## Notes
Cette décision est **contextuelle** et adaptée à HomeStock :
- ✅ Réseau local domestique contrôlé
- ✅ Mono-utilisateur
- ✅ Données non critiques
Elle serait **inappropriée** pour :
- ❌ Application exposée sur Internet
- ❌ Multi-utilisateurs
- ❌ Données sensibles (bancaires, médicales, secrets)
- ❌ Environnement professionnel
**Analogie** : C'est comme ne pas mettre de serrure sur la porte de sa chambre dans sa propre maison (réseau local) vs mettre une serrure sur la porte d'entrée (exposition Internet).
Si le contexte change (exposition Internet, ajout utilisateurs), cette décision devra être revisitée et l'authentification ajoutée. L'architecture modulaire (ADR-0002) facilite cet ajout futur sans refonte majeure.
**Principe appliqué** : "Security proportionate to risk" - Ne pas sur-sécuriser quand le risque est faible et contrôlé.
---
**Contributeurs** : Gilles (décideur) + Claude Code (architecte)
**Avertissement important** : Cette configuration est sécurisée UNIQUEMENT si le réseau local est isolé d'Internet. Vérifier régulièrement que :
1. Aucun port forwarding n'est configuré sur le routeur
2. Aucun service de tunnel (ngrok, etc.) n'est actif
3. Le firewall du routeur est correctement configuré

View File

@@ -12,34 +12,29 @@ Tout ce qui est indiqué ici est la référence pour les agents frontend.
---
## Objectif du frontend
- Parcours utilisateur principaux : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
- Responsabilités principales : <A COMPLETER PAR AGENT>
- Hors périmètre : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
- Parcours utilisateur principaux : Lister/rechercher items, consulter détails item avec documents, ajouter/éditer item avec upload fichiers, naviguer hiérarchie locations <!-- complété par codex -->
- Responsabilités principales : Interface utilisateur responsive, formulaires validation, gestion état local/serveur, upload fichiers, recherche temps réel, affichage photos/documents <!-- complété par codex -->
- Hors périmètre : Logique métier (déléguée au backend), stockage local complexe (cache géré par React Query), authentification (gérée par backend) <!-- complété par codex -->
## Interfaces
- API consommées (API = Interface de Programmation) : <A COMPLETER PAR AGENT>
- Authentification/autorisation : <A COMPLETER PAR AGENT>
- Intégrations externes : <A REMPLIR - PROJET> (exemple: ERP existant — a supprimer)
- API consommées : Backend REST à `/api/v1/` (items, locations, categories, documents, search), client généré depuis OpenAPI ou fetch/axios manuel <!-- complété par codex -->
- Authentification/autorisation : Optionnelle, si activée = gestion session cookie automatique par navigateur, redirection login si 401 <!-- complété par codex -->
- Intégrations externes : Aucune intégration externe, consomme uniquement le backend HomeStock <!-- complété par codex -->
## Architecture UI
- Framework : <A COMPLETER PAR AGENT>
- Structure des pages : <A COMPLETER PAR AGENT>
- Gestion détat : <A COMPLETER PAR AGENT>
- Design system / UI kit (bibliothèque de composants) : <A COMPLETER PAR AGENT>
- Framework : React 18+ avec TypeScript, Vite comme bundler et dev server <!-- complété par codex -->
- Structure des pages : `/` dashboard, `/items` liste items, `/items/:id` détail item, `/items/new` création, `/locations` gestion locations, routing avec React Router v6 <!-- complété par codex -->
- Gestion d'état : TanStack Query (React Query) pour état serveur + cache, Context API pour état UI global (theme, navigation), useState/useReducer pour état local <!-- complété par codex -->
- Design system / UI kit : TailwindCSS pour styling, composants custom inspirés de shadcn/ui (pas de dépendance lourde), palette Gruvbox dark <!-- complété par codex -->
## Qualité & accessibilité
- Performance attendue : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
- Accessibilité (a11y = accessibilité web) : <A COMPLETER PAR AGENT>
- Tests (unitaires/E2E = tests de bout en bout) : <A COMPLETER PAR AGENT>
- Performance attendue : FCP (First Contentful Paint) <1.5s, TTI (Time To Interactive) <3s sur réseau local, bundle JS <500KB gzippé <!-- complété par codex -->
- Accessibilité : Accessibilité de base (ARIA labels, navigation clavier, contraste WCAG AA), pas de certification stricte WCAG AAA (usage personnel) <!-- complété par codex -->
- Tests : Vitest pour tests unitaires (hooks, utils), Playwright optionnel pour tests E2E critiques (création item, upload fichier), pas de couverture exhaustive <!-- complété par codex -->
## Conventions
- Organisation du code : <A COMPLETER PAR AGENT>
- Nommage : <A COMPLETER PAR AGENT>
- Gestion erreurs : <A COMPLETER PAR AGENT>
- Organisation du code : `frontend/src/` racine, sous-dossiers components/ (composants réutilisables), pages/ (vues complètes), hooks/ (custom hooks), api/ (clients API), utils/ (helpers) <!-- complété par codex -->
- Nommage : PascalCase pour composants/fichiers React, camelCase pour variables/fonctions, préfixes use pour hooks custom, suffixes Page pour pages complètes <!-- complété par codex -->
- Gestion erreurs : Error boundaries React pour erreurs render, gestion erreurs API via React Query (onError callbacks), toast notifications pour erreurs utilisateur, fallback UI gracieux <!-- complété par codex -->
---
## Exemple (a supprimer)
- Framework : React + Vite.
- Pages : `dashboard`, `settings`, `billing`.
- État : Zustand + React Query.
---

View File

@@ -0,0 +1,23 @@
# syntax=docker/dockerfile:1
# Image de base Node.js 20
FROM node:20-alpine
# Répertoire de travail
WORKDIR /app
# Copier les fichiers de dépendances
COPY package*.json ./
# Installer les dépendances
RUN npm install
# Copier le code source
COPY . .
# Exposer le port Vite (dev server)
EXPOSE 5173
# Commande par défaut (peut être overridée par docker-compose)
# Note: --host 0.0.0.0 permet l'accès depuis l'extérieur du conteneur
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="HomeStock - Gestion d'inventaire domestique" />
<title>HomeStock - Inventaire Domestique</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

58
frontend/package.json Normal file
View File

@@ -0,0 +1,58 @@
{
"name": "homestock-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "HomeStock - Frontend pour gestion d'inventaire domestique",
"author": "Gilles",
"license": "MIT",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint . --ext ts,tsx --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"type-check": "tsc --noEmit",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.3",
"@tanstack/react-query": "^5.17.19",
"@tanstack/react-query-devtools": "^5.17.19",
"axios": "^1.6.5",
"clsx": "^2.1.0",
"date-fns": "^3.2.0",
"react-icons": "^5.4.0"
},
"devDependencies": {
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.33",
"prettier": "^3.2.4",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.0.11",
"vitest": "^1.2.1",
"@vitest/ui": "^1.2.1",
"@testing-library/react": "^14.1.2",
"@testing-library/jest-dom": "^6.2.0",
"@testing-library/user-event": "^14.5.2"
},
"engines": {
"node": ">=20.0.0",
"npm": ">=10.0.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

588
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,588 @@
import { useState } from 'react'
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'
import { useCategories, useCreateCategory, useUpdateCategory, useDeleteCategory } from '@/hooks/useCategories'
import { useLocationTree, useCreateLocation, useUpdateLocation, useDeleteLocation } from '@/hooks/useLocations'
import { useItems, useCreateItem, useUpdateItem, useDeleteItem } from '@/hooks/useItems'
import { ItemList, ItemForm } from '@/components/items'
import { CategoryForm } from '@/components/categories'
import { LocationForm } from '@/components/locations'
import {
Loading,
ErrorMessage,
ConfirmDialog,
IconAdd,
IconEdit,
IconDelete,
IconHome,
IconInventory,
IconCategory,
IconLocation,
IconRoom,
IconFurniture,
IconDrawer,
IconBox,
} from '@/components/common'
import {
LOCATION_TYPE_LABELS,
LocationTree,
Category,
CategoryWithItemCount,
Location,
Item,
LocationType,
} from '@/api'
// Mapping des icônes par type d'emplacement
const LOCATION_TYPE_ICONS: Record<LocationType, React.ReactNode> = {
room: <IconRoom className="w-5 h-5" />,
furniture: <IconFurniture className="w-5 h-5" />,
drawer: <IconDrawer className="w-5 h-5" />,
box: <IconBox className="w-5 h-5" />,
}
function App() {
return (
<BrowserRouter>
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo et titre */}
<Link to="/" className="flex items-center">
<h1 className="text-2xl font-bold text-primary-600">
HomeStock
</h1>
<span className="ml-3 text-sm text-gray-500">
Inventaire Domestique
</span>
</Link>
{/* Navigation */}
<nav className="flex space-x-6">
<Link
to="/"
className="flex items-center gap-2 text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium transition-colors"
>
<IconHome className="w-5 h-5" />
Accueil
</Link>
<Link
to="/items"
className="flex items-center gap-2 text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium transition-colors"
>
<IconInventory className="w-5 h-5" />
Objets
</Link>
<Link
to="/locations"
className="flex items-center gap-2 text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium transition-colors"
>
<IconLocation className="w-5 h-5" />
Emplacements
</Link>
<Link
to="/categories"
className="flex items-center gap-2 text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium transition-colors"
>
<IconCategory className="w-5 h-5" />
Catégories
</Link>
</nav>
</div>
</div>
</header>
{/* Contenu principal */}
<main className="container-main">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/items" element={<ItemsPage />} />
<Route path="/locations" element={<LocationsPage />} />
<Route path="/categories" element={<CategoriesPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
{/* Footer */}
<footer className="bg-white border-t border-gray-200 mt-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<p className="text-center text-sm text-gray-500">
HomeStock v{import.meta.env.VITE_APP_VERSION || '0.1.0'} -
Gestion d'inventaire domestique
</p>
</div>
</footer>
</div>
</BrowserRouter>
)
}
// === Page d'accueil avec statistiques ===
function HomePage() {
const { data: categoriesData, isLoading: loadingCategories } = useCategories(1, 100)
const { data: itemsData, isLoading: loadingItems } = useItems(1, 1)
const { data: locationsData, isLoading: loadingLocations } = useLocationTree()
const isLoading = loadingCategories || loadingItems || loadingLocations
// Compter les emplacements
const countLocations = (tree: LocationTree[]): number => {
return tree.reduce((acc, loc) => acc + 1 + countLocations(loc.children), 0)
}
const stats = {
items: itemsData?.total || 0,
categories: categoriesData?.total || 0,
locations: locationsData ? countLocations(locationsData) : 0,
}
return (
<div>
<div className="text-center py-8">
<h2 className="text-3xl font-bold text-gray-900 mb-4">
Bienvenue sur HomeStock
</h2>
<p className="text-lg text-gray-600">
Gérez votre inventaire domestique facilement
</p>
</div>
{/* Statistiques */}
{isLoading ? (
<Loading message="Chargement des statistiques..." size="sm" />
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
<Link to="/items" className="card card-hover text-center">
<div className="text-4xl font-bold text-primary-600">{stats.items}</div>
<div className="text-gray-600 mt-2">Objets</div>
</Link>
<Link to="/categories" className="card card-hover text-center">
<div className="text-4xl font-bold text-secondary-600">{stats.categories}</div>
<div className="text-gray-600 mt-2">Catégories</div>
</Link>
<Link to="/locations" className="card card-hover text-center">
<div className="text-4xl font-bold text-gray-700">{stats.locations}</div>
<div className="text-gray-600 mt-2">Emplacements</div>
</Link>
</div>
)}
{/* Catégories */}
{categoriesData && categoriesData.items.length > 0 && (
<div className="mb-8">
<h3 className="text-xl font-semibold text-gray-900 mb-4">Catégories</h3>
<div className="flex flex-wrap gap-2">
{categoriesData.items.map((cat) => (
<span
key={cat.id}
className="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium"
style={{
backgroundColor: cat.color ? `${cat.color}20` : '#f3f4f6',
color: cat.color || '#374151',
}}
>
<span
className="w-2 h-2 rounded-full mr-2"
style={{ backgroundColor: cat.color || '#6b7280' }}
/>
{cat.name}
<span className="ml-2 text-xs opacity-70">({cat.item_count})</span>
</span>
))}
</div>
</div>
)}
</div>
)
}
// === Page des objets ===
function ItemsPage() {
const [showForm, setShowForm] = useState(false)
const [editingItem, setEditingItem] = useState<Item | null>(null)
const [deletingItem, setDeletingItem] = useState<Item | null>(null)
const { data: categoriesData } = useCategories(1, 100)
const { data: locationsData } = useLocationTree()
const createItem = useCreateItem()
const updateItem = useUpdateItem()
const deleteItem = useDeleteItem()
const handleCreate = () => {
setEditingItem(null)
setShowForm(true)
}
const handleEdit = (item: Item) => {
setEditingItem(item)
setShowForm(true)
}
const handleSubmit = async (data: any) => {
if (editingItem) {
await updateItem.mutateAsync({ id: editingItem.id, data })
} else {
await createItem.mutateAsync(data)
}
setShowForm(false)
setEditingItem(null)
}
const handleDelete = async () => {
if (deletingItem) {
await deleteItem.mutateAsync(deletingItem.id)
setDeletingItem(null)
}
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Mes Objets</h2>
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
<IconAdd className="w-5 h-5" />
Nouvel objet
</button>
</div>
<ItemList
onItemClick={(id) => {
// TODO: ouvrir le détail de l'objet
console.log('Item clicked:', id)
}}
onItemEdit={handleEdit}
onItemDelete={setDeletingItem}
/>
{/* Formulaire création/édition */}
<ItemForm
isOpen={showForm}
onClose={() => {
setShowForm(false)
setEditingItem(null)
}}
onSubmit={handleSubmit}
item={editingItem}
categories={categoriesData?.items || []}
locations={locationsData || []}
isLoading={createItem.isPending || updateItem.isPending}
/>
{/* Confirmation suppression */}
<ConfirmDialog
isOpen={!!deletingItem}
onClose={() => setDeletingItem(null)}
onConfirm={handleDelete}
title="Supprimer l'objet"
message={`Êtes-vous sûr de vouloir supprimer "${deletingItem?.name}" ? Cette action est irréversible.`}
confirmText="Supprimer"
isLoading={deleteItem.isPending}
/>
</div>
)
}
// === Page des emplacements ===
function LocationsPage() {
const [showForm, setShowForm] = useState(false)
const [editingLocation, setEditingLocation] = useState<Location | null>(null)
const [deletingLocation, setDeletingLocation] = useState<LocationTree | null>(null)
const [defaultParentId, setDefaultParentId] = useState<number | null>(null)
const { data, isLoading, error, refetch } = useLocationTree()
const createLocation = useCreateLocation()
const updateLocation = useUpdateLocation()
const deleteLocation = useDeleteLocation()
const handleCreate = (parentId: number | null = null) => {
setEditingLocation(null)
setDefaultParentId(parentId)
setShowForm(true)
}
const handleEdit = (location: LocationTree) => {
// Convertir LocationTree en Location pour l'édition
setEditingLocation({
id: location.id,
name: location.name,
type: location.type,
path: location.path,
parent_id: null, // On ne peut pas extraire ça de LocationTree
description: null,
created_at: '',
updated_at: '',
})
setDefaultParentId(null)
setShowForm(true)
}
const handleSubmit = async (formData: any) => {
if (editingLocation) {
await updateLocation.mutateAsync({ id: editingLocation.id, data: formData })
} else {
await createLocation.mutateAsync(formData)
}
setShowForm(false)
setEditingLocation(null)
setDefaultParentId(null)
}
const handleDelete = async () => {
if (deletingLocation) {
await deleteLocation.mutateAsync(deletingLocation.id)
setDeletingLocation(null)
}
}
if (isLoading) return <Loading message="Chargement des emplacements..." />
if (error) return <ErrorMessage message="Erreur lors du chargement" onRetry={refetch} />
const renderTree = (locations: LocationTree[], level = 0) => {
return locations.map((loc) => (
<div key={loc.id} style={{ marginLeft: level * 24 }}>
<div className="flex items-center py-2 px-3 hover:bg-gray-50 rounded-lg group">
<span className="text-gray-400 mr-2">
{LOCATION_TYPE_ICONS[loc.type]}
</span>
<span className="font-medium text-gray-900">{loc.name}</span>
<span className="ml-2 text-xs text-gray-500">
({LOCATION_TYPE_LABELS[loc.type]})
</span>
{loc.item_count > 0 && (
<span className="ml-2 text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
{loc.item_count} objet(s)
</span>
)}
{/* Actions */}
<div className="ml-auto opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<button
onClick={() => handleCreate(loc.id)}
className="p-1 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded"
title="Ajouter un sous-emplacement"
>
<IconAdd className="w-4 h-4" />
</button>
<button
onClick={() => handleEdit(loc)}
className="p-1 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded"
title="Modifier"
>
<IconEdit className="w-4 h-4" />
</button>
<button
onClick={() => setDeletingLocation(loc)}
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
title="Supprimer"
>
<IconDelete className="w-4 h-4" />
</button>
</div>
</div>
{loc.children.length > 0 && renderTree(loc.children, level + 1)}
</div>
))
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Mes Emplacements</h2>
<button onClick={() => handleCreate()} className="btn btn-primary flex items-center gap-2">
<IconAdd className="w-5 h-5" />
Nouvel emplacement
</button>
</div>
{data && data.length > 0 ? (
<div className="card">{renderTree(data)}</div>
) : (
<div className="card">
<p className="text-gray-600 text-center py-8">
Aucun emplacement créé. Commencez par créer une pièce.
</p>
</div>
)}
{/* Formulaire création/édition */}
<LocationForm
isOpen={showForm}
onClose={() => {
setShowForm(false)
setEditingLocation(null)
setDefaultParentId(null)
}}
onSubmit={handleSubmit}
location={editingLocation}
locations={data || []}
defaultParentId={defaultParentId}
isLoading={createLocation.isPending || updateLocation.isPending}
/>
{/* Confirmation suppression */}
<ConfirmDialog
isOpen={!!deletingLocation}
onClose={() => setDeletingLocation(null)}
onConfirm={handleDelete}
title="Supprimer l'emplacement"
message={`Êtes-vous sûr de vouloir supprimer "${deletingLocation?.name}" ? ${
deletingLocation && deletingLocation.children.length > 0
? 'Attention: cet emplacement contient des sous-emplacements qui seront aussi supprimés.'
: ''
}`}
confirmText="Supprimer"
isLoading={deleteLocation.isPending}
/>
</div>
)
}
// === Page des catégories ===
function CategoriesPage() {
const [showForm, setShowForm] = useState(false)
const [editingCategory, setEditingCategory] = useState<CategoryWithItemCount | null>(null)
const [deletingCategory, setDeletingCategory] = useState<CategoryWithItemCount | null>(null)
const { data, isLoading, error, refetch } = useCategories(1, 100)
const createCategory = useCreateCategory()
const updateCategory = useUpdateCategory()
const deleteCategory = useDeleteCategory()
const handleCreate = () => {
setEditingCategory(null)
setShowForm(true)
}
const handleEdit = (category: CategoryWithItemCount) => {
setEditingCategory(category)
setShowForm(true)
}
const handleSubmit = async (formData: any) => {
if (editingCategory) {
await updateCategory.mutateAsync({ id: editingCategory.id, data: formData })
} else {
await createCategory.mutateAsync(formData)
}
setShowForm(false)
setEditingCategory(null)
}
const handleDelete = async () => {
if (deletingCategory) {
await deleteCategory.mutateAsync(deletingCategory.id)
setDeletingCategory(null)
}
}
if (isLoading) return <Loading message="Chargement des catégories..." />
if (error) return <ErrorMessage message="Erreur lors du chargement" onRetry={refetch} />
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Mes Catégories</h2>
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
<IconAdd className="w-5 h-5" />
Nouvelle catégorie
</button>
</div>
{data && data.items.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{data.items.map((category) => (
<div key={category.id} className="card group">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center">
<div
className="w-4 h-4 rounded-full mr-3"
style={{ backgroundColor: category.color || '#6b7280' }}
/>
<h3 className="font-semibold text-gray-900">{category.name}</h3>
</div>
{/* Actions */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<button
onClick={() => handleEdit(category)}
className="p-1 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded"
title="Modifier"
>
<IconEdit className="w-4 h-4" />
</button>
<button
onClick={() => setDeletingCategory(category)}
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
title="Supprimer"
disabled={category.item_count > 0}
>
<IconDelete className="w-4 h-4" />
</button>
</div>
</div>
{category.description && (
<p className="text-sm text-gray-600 mb-3">{category.description}</p>
)}
<div className="text-sm text-gray-500">
{category.item_count} objet(s)
</div>
</div>
))}
</div>
) : (
<div className="card">
<p className="text-gray-600 text-center py-8">
Aucune catégorie créée. Commencez par en créer une.
</p>
</div>
)}
{/* Formulaire création/édition */}
<CategoryForm
isOpen={showForm}
onClose={() => {
setShowForm(false)
setEditingCategory(null)
}}
onSubmit={handleSubmit}
category={editingCategory}
isLoading={createCategory.isPending || updateCategory.isPending}
/>
{/* Confirmation suppression */}
<ConfirmDialog
isOpen={!!deletingCategory}
onClose={() => setDeletingCategory(null)}
onConfirm={handleDelete}
title="Supprimer la catégorie"
message={
deletingCategory?.item_count && deletingCategory.item_count > 0
? `Impossible de supprimer "${deletingCategory.name}" car elle contient ${deletingCategory.item_count} objet(s). Déplacez ou supprimez d'abord ces objets.`
: `Êtes-vous sûr de vouloir supprimer "${deletingCategory?.name}" ?`
}
confirmText="Supprimer"
isLoading={deleteCategory.isPending}
variant={deletingCategory?.item_count && deletingCategory.item_count > 0 ? 'warning' : 'danger'}
/>
</div>
)
}
// === Page 404 ===
function NotFoundPage() {
return (
<div className="text-center py-12">
<h2 className="text-4xl font-bold text-gray-900 mb-4">404</h2>
<p className="text-lg text-gray-600 mb-8">
Page non trouvée
</p>
<Link to="/" className="btn btn-primary">
Retour à l'accueil
</Link>
</div>
)
}
export default App

View File

@@ -0,0 +1,50 @@
/**
* API pour les catégories
*/
import { apiClient, PaginatedResponse, SuccessResponse } from './client'
import { Category, CategoryCreate, CategoryUpdate, CategoryWithItemCount } from './types'
export const categoriesApi = {
/**
* Liste toutes les catégories
*/
async getAll(page = 1, pageSize = 20): Promise<PaginatedResponse<CategoryWithItemCount>> {
const response = await apiClient.get<PaginatedResponse<CategoryWithItemCount>>('/categories', {
params: { page, page_size: pageSize },
})
return response.data
},
/**
* Récupère une catégorie par son ID
*/
async getById(id: number): Promise<CategoryWithItemCount> {
const response = await apiClient.get<CategoryWithItemCount>(`/categories/${id}`)
return response.data
},
/**
* Crée une nouvelle catégorie
*/
async create(data: CategoryCreate): Promise<Category> {
const response = await apiClient.post<Category>('/categories', data)
return response.data
},
/**
* Met à jour une catégorie
*/
async update(id: number, data: CategoryUpdate): Promise<Category> {
const response = await apiClient.put<Category>(`/categories/${id}`, data)
return response.data
},
/**
* Supprime une catégorie
*/
async delete(id: number): Promise<SuccessResponse> {
const response = await apiClient.delete<SuccessResponse>(`/categories/${id}`)
return response.data
},
}

View File

@@ -0,0 +1,41 @@
/**
* Client API Axios configuré pour HomeStock
*/
import axios from 'axios'
// URL de base de l'API
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1'
// Instance Axios configurée
export const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
timeout: 10000, // 10 secondes
})
// Intercepteur pour logger les erreurs
apiClient.interceptors.response.use(
(response) => response,
(error) => {
console.error('API Error:', error.response?.data || error.message)
return Promise.reject(error)
}
)
// Types de réponse paginée
export interface PaginatedResponse<T> {
items: T[]
total: number
page: number
page_size: number
pages: number
}
// Types communs
export interface SuccessResponse {
message: string
id?: number
}

View File

@@ -0,0 +1,9 @@
/**
* Point d'entrée pour toutes les APIs
*/
export * from './client'
export * from './types'
export { categoriesApi } from './categories'
export { locationsApi } from './locations'
export { itemsApi } from './items'

83
frontend/src/api/items.ts Normal file
View File

@@ -0,0 +1,83 @@
/**
* API pour les objets d'inventaire
*/
import { apiClient, PaginatedResponse, SuccessResponse } from './client'
import { Item, ItemCreate, ItemFilter, ItemStatus, ItemUpdate, ItemWithRelations } from './types'
export const itemsApi = {
/**
* Liste tous les objets avec filtres
*/
async getAll(
page = 1,
pageSize = 20,
filters?: ItemFilter
): Promise<PaginatedResponse<ItemWithRelations>> {
const response = await apiClient.get<PaginatedResponse<ItemWithRelations>>('/items', {
params: {
page,
page_size: pageSize,
search: filters?.search,
category_id: filters?.category_id,
location_id: filters?.location_id,
status: filters?.status,
min_price: filters?.min_price,
max_price: filters?.max_price,
},
})
return response.data
},
/**
* Récupère un objet par son ID
*/
async getById(id: number): Promise<ItemWithRelations> {
const response = await apiClient.get<ItemWithRelations>(`/items/${id}`)
return response.data
},
/**
* Crée un nouvel objet
*/
async create(data: ItemCreate): Promise<Item> {
const response = await apiClient.post<Item>('/items', data)
return response.data
},
/**
* Met à jour un objet
*/
async update(id: number, data: ItemUpdate): Promise<Item> {
const response = await apiClient.put<Item>(`/items/${id}`, data)
return response.data
},
/**
* Supprime un objet
*/
async delete(id: number): Promise<SuccessResponse> {
const response = await apiClient.delete<SuccessResponse>(`/items/${id}`)
return response.data
},
/**
* Change le statut d'un objet
*/
async updateStatus(id: number, status: ItemStatus): Promise<Item> {
const response = await apiClient.patch<Item>(`/items/${id}/status`, null, {
params: { new_status: status },
})
return response.data
},
/**
* Déplace un objet vers un nouvel emplacement
*/
async move(id: number, locationId: number): Promise<Item> {
const response = await apiClient.patch<Item>(`/items/${id}/location`, null, {
params: { new_location_id: locationId },
})
return response.data
},
}

View File

@@ -0,0 +1,91 @@
/**
* API pour les emplacements
*/
import { apiClient, PaginatedResponse, SuccessResponse } from './client'
import {
Location,
LocationCreate,
LocationTree,
LocationType,
LocationUpdate,
LocationWithItemCount,
} from './types'
export const locationsApi = {
/**
* Liste tous les emplacements
*/
async getAll(
page = 1,
pageSize = 50,
parentId?: number,
type?: LocationType
): Promise<PaginatedResponse<Location>> {
const response = await apiClient.get<PaginatedResponse<Location>>('/locations', {
params: {
page,
page_size: pageSize,
parent_id: parentId,
type,
},
})
return response.data
},
/**
* Récupère l'arborescence complète
*/
async getTree(): Promise<LocationTree[]> {
const response = await apiClient.get<LocationTree[]>('/locations/tree')
return response.data
},
/**
* Récupère les emplacements racine
*/
async getRoots(): Promise<Location[]> {
const response = await apiClient.get<Location[]>('/locations/roots')
return response.data
},
/**
* Récupère un emplacement par son ID
*/
async getById(id: number): Promise<LocationWithItemCount> {
const response = await apiClient.get<LocationWithItemCount>(`/locations/${id}`)
return response.data
},
/**
* Récupère les enfants d'un emplacement
*/
async getChildren(id: number): Promise<Location[]> {
const response = await apiClient.get<Location[]>(`/locations/${id}/children`)
return response.data
},
/**
* Crée un nouvel emplacement
*/
async create(data: LocationCreate): Promise<Location> {
const response = await apiClient.post<Location>('/locations', data)
return response.data
},
/**
* Met à jour un emplacement
*/
async update(id: number, data: LocationUpdate): Promise<Location> {
const response = await apiClient.put<Location>(`/locations/${id}`, data)
return response.data
},
/**
* Supprime un emplacement
*/
async delete(id: number): Promise<SuccessResponse> {
const response = await apiClient.delete<SuccessResponse>(`/locations/${id}`)
return response.data
},
}

165
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,165 @@
/**
* Types TypeScript pour les entités HomeStock
*/
// === Catégories ===
export interface Category {
id: number
name: string
description: string | null
color: string | null
icon: string | null
created_at: string
updated_at: string
}
export interface CategoryWithItemCount extends Category {
item_count: number
}
export interface CategoryCreate {
name: string
description?: string | null
color?: string | null
icon?: string | null
}
export interface CategoryUpdate {
name?: string
description?: string | null
color?: string | null
icon?: string | null
}
// === Emplacements ===
export type LocationType = 'room' | 'furniture' | 'drawer' | 'box'
export interface Location {
id: number
name: string
type: LocationType
parent_id: number | null
path: string
description: string | null
created_at: string
updated_at: string
}
export interface LocationWithItemCount extends Location {
item_count: number
}
export interface LocationTree {
id: number
name: string
type: LocationType
path: string
children: LocationTree[]
item_count: number
}
export interface LocationCreate {
name: string
type: LocationType
parent_id?: number | null
description?: string | null
}
export interface LocationUpdate {
name?: string
type?: LocationType
parent_id?: number | null
description?: string | null
}
// === Objets ===
export type ItemStatus = 'in_stock' | 'in_use' | 'broken' | 'sold' | 'lent'
export interface Item {
id: number
name: string
description: string | null
quantity: number
status: ItemStatus
brand: string | null
model: string | null
serial_number: string | null
url: string | null
price: string | null
purchase_date: string | null
notes: string | null
category_id: number
location_id: number
created_at: string
updated_at: string
}
export interface ItemWithRelations extends Item {
category: Category
location: Location
}
export interface ItemCreate {
name: string
description?: string | null
quantity?: number
status?: ItemStatus
brand?: string | null
model?: string | null
serial_number?: string | null
url?: string | null
price?: number | null
purchase_date?: string | null
notes?: string | null
category_id: number
location_id: number
}
export interface ItemUpdate {
name?: string
description?: string | null
quantity?: number
status?: ItemStatus
brand?: string | null
model?: string | null
serial_number?: string | null
url?: string | null
price?: number | null
purchase_date?: string | null
notes?: string | null
category_id?: number
location_id?: number
}
export interface ItemFilter {
search?: string
category_id?: number
location_id?: number
status?: ItemStatus
min_price?: number
max_price?: number
}
// === Labels pour l'affichage ===
export const LOCATION_TYPE_LABELS: Record<LocationType, string> = {
room: 'Pièce',
furniture: 'Meuble',
drawer: 'Tiroir',
box: 'Boîte',
}
export const ITEM_STATUS_LABELS: Record<ItemStatus, string> = {
in_stock: 'En stock',
in_use: 'En utilisation',
broken: 'Cassé',
sold: 'Vendu',
lent: 'Prêté',
}
export const ITEM_STATUS_COLORS: Record<ItemStatus, string> = {
in_stock: 'bg-green-100 text-green-800',
in_use: 'bg-blue-100 text-blue-800',
broken: 'bg-red-100 text-red-800',
sold: 'bg-gray-100 text-gray-800',
lent: 'bg-yellow-100 text-yellow-800',
}

View File

@@ -0,0 +1,179 @@
/**
* Formulaire de création/édition de catégorie
*/
import { useState, useEffect } from 'react'
import { Category, CategoryCreate, CategoryUpdate } from '@/api/types'
import { Modal } from '@/components/common'
interface CategoryFormProps {
isOpen: boolean
onClose: () => void
onSubmit: (data: CategoryCreate | CategoryUpdate) => void
category?: Category | null
isLoading?: boolean
}
const PRESET_COLORS = [
'#ef4444', // red
'#f97316', // orange
'#eab308', // yellow
'#22c55e', // green
'#14b8a6', // teal
'#3b82f6', // blue
'#8b5cf6', // violet
'#ec4899', // pink
'#6b7280', // gray
]
export function CategoryForm({
isOpen,
onClose,
onSubmit,
category,
isLoading = false,
}: CategoryFormProps) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [color, setColor] = useState('#3b82f6')
const isEditing = !!category
// Remplir le formulaire si édition
useEffect(() => {
if (category) {
setName(category.name)
setDescription(category.description || '')
setColor(category.color || '#3b82f6')
} else {
setName('')
setDescription('')
setColor('#3b82f6')
}
}, [category, isOpen])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const data = {
name: name.trim(),
description: description.trim() || null,
color: color || null,
}
onSubmit(data)
}
const isValid = name.trim().length > 0
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEditing ? 'Modifier la catégorie' : 'Nouvelle catégorie'}
size="md"
>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Nom */}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Nom <span className="text-red-500">*</span>
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="input"
placeholder="Ex: Électronique, Bricolage..."
required
autoFocus
/>
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="input min-h-[80px]"
placeholder="Description optionnelle..."
rows={3}
/>
</div>
{/* Couleur */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Couleur
</label>
<div className="flex items-center gap-3">
<div className="flex gap-2 flex-wrap">
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`w-8 h-8 rounded-full border-2 transition-transform hover:scale-110 ${
color === c ? 'border-gray-900 scale-110' : 'border-transparent'
}`}
style={{ backgroundColor: c }}
/>
))}
</div>
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-8 h-8 rounded cursor-pointer"
title="Couleur personnalisée"
/>
</div>
</div>
{/* Prévisualisation */}
<div className="pt-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Prévisualisation
</label>
<span
className="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium"
style={{
backgroundColor: color ? `${color}20` : '#f3f4f6',
color: color || '#374151',
}}
>
<span
className="w-2 h-2 rounded-full mr-2"
style={{ backgroundColor: color || '#6b7280' }}
/>
{name || 'Nom de la catégorie'}
</span>
</div>
{/* Actions */}
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={onClose}
disabled={isLoading}
className="btn btn-secondary"
>
Annuler
</button>
<button
type="submit"
disabled={!isValid || isLoading}
className="btn btn-primary"
>
{isLoading ? 'Enregistrement...' : isEditing ? 'Modifier' : 'Créer'}
</button>
</div>
</form>
</Modal>
)
}

View File

@@ -0,0 +1,5 @@
/**
* Export des composants catégories
*/
export { CategoryForm } from './CategoryForm'

View File

@@ -0,0 +1,30 @@
/**
* Composant Badge
*/
type BadgeVariant = 'primary' | 'success' | 'warning' | 'danger' | 'gray' | 'custom'
interface BadgeProps {
children: React.ReactNode
variant?: BadgeVariant
className?: string
}
const variantClasses: Record<BadgeVariant, string> = {
primary: 'bg-primary-100 text-primary-800',
success: 'bg-green-100 text-green-800',
warning: 'bg-yellow-100 text-yellow-800',
danger: 'bg-red-100 text-red-800',
gray: 'bg-gray-100 text-gray-800',
custom: '',
}
export function Badge({ children, variant = 'gray', className = '' }: BadgeProps) {
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${variantClasses[variant]} ${className}`}
>
{children}
</span>
)
}

View File

@@ -0,0 +1,66 @@
/**
* Dialogue de confirmation pour actions dangereuses
*/
import { Modal } from './Modal'
interface ConfirmDialogProps {
isOpen: boolean
onClose: () => void
onConfirm: () => void
title: string
message: string
confirmText?: string
cancelText?: string
variant?: 'danger' | 'warning' | 'info'
isLoading?: boolean
}
const variantClasses = {
danger: 'bg-red-600 hover:bg-red-700 focus:ring-red-500',
warning: 'bg-yellow-600 hover:bg-yellow-700 focus:ring-yellow-500',
info: 'bg-primary-600 hover:bg-primary-700 focus:ring-primary-500',
}
export function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Confirmer',
cancelText = 'Annuler',
variant = 'danger',
isLoading = false,
}: ConfirmDialogProps) {
const handleConfirm = () => {
onConfirm()
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
<div className="space-y-4">
<p className="text-gray-600">{message}</p>
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
disabled={isLoading}
className="btn btn-secondary"
>
{cancelText}
</button>
<button
type="button"
onClick={handleConfirm}
disabled={isLoading}
className={`btn text-white ${variantClasses[variant]} disabled:opacity-50`}
>
{isLoading ? 'Chargement...' : confirmText}
</button>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,30 @@
/**
* Composant pour afficher un état vide
*/
interface EmptyStateProps {
title: string
description?: string
action?: {
label: string
onClick: () => void
}
icon?: React.ReactNode
}
export function EmptyState({ title, description, action, icon }: EmptyStateProps) {
return (
<div className="text-center py-12">
{icon && <div className="mx-auto h-12 w-12 text-gray-400">{icon}</div>}
<h3 className="mt-4 text-lg font-medium text-gray-900">{title}</h3>
{description && <p className="mt-2 text-sm text-gray-500">{description}</p>}
{action && (
<div className="mt-6">
<button onClick={action.onClick} className="btn btn-primary">
{action.label}
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,43 @@
/**
* Composant d'affichage d'erreur
*/
interface ErrorMessageProps {
message: string
onRetry?: () => void
}
export function ErrorMessage({ message, onRetry }: ErrorMessageProps) {
return (
<div className="rounded-lg bg-red-50 border border-red-200 p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-red-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
<p className="text-sm font-medium text-red-800">{message}</p>
</div>
</div>
{onRetry && (
<div className="mt-4">
<button
onClick={onRetry}
className="btn btn-sm bg-red-100 text-red-800 hover:bg-red-200"
>
Réessayer
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,65 @@
/**
* Icônes centralisées - Material Design Icons via react-icons
*/
export {
// Navigation & Actions
MdAdd as IconAdd,
MdEdit as IconEdit,
MdDelete as IconDelete,
MdClose as IconClose,
MdSearch as IconSearch,
MdSettings as IconSettings,
MdArrowBack as IconBack,
MdChevronRight as IconChevronRight,
MdChevronLeft as IconChevronLeft,
MdExpandMore as IconExpand,
MdMoreVert as IconMore,
// Objets & Inventaire
MdInventory2 as IconInventory,
MdCategory as IconCategory,
MdPlace as IconLocation,
MdHome as IconHome,
MdMeetingRoom as IconRoom,
MdTableRestaurant as IconFurniture,
MdInbox as IconDrawer,
MdAllInbox as IconBox,
// Statuts
MdCheckCircle as IconCheck,
MdWarning as IconWarning,
MdError as IconError,
MdInfo as IconInfo,
MdHourglassEmpty as IconPending,
// Documents
MdAttachFile as IconAttachment,
MdImage as IconImage,
MdPictureAsPdf as IconPdf,
MdLink as IconLink,
MdReceipt as IconReceipt,
MdDescription as IconDocument,
// Personnes
MdPerson as IconPerson,
MdPeople as IconPeople,
// Divers
MdStar as IconStar,
MdFavorite as IconFavorite,
MdShoppingCart as IconCart,
MdLocalOffer as IconTag,
MdCalendarToday as IconCalendar,
MdEuro as IconEuro,
MdQrCode as IconQrCode,
MdRefresh as IconRefresh,
} from 'react-icons/md'
// Types d'emplacement avec icônes
export const LOCATION_TYPE_ICONS = {
room: 'MdMeetingRoom',
furniture: 'MdTableRestaurant',
drawer: 'MdInbox',
box: 'MdAllInbox',
} as const

View File

@@ -0,0 +1,25 @@
/**
* Composant de chargement
*/
interface LoadingProps {
message?: string
size?: 'sm' | 'md' | 'lg'
}
export function Loading({ message = 'Chargement...', size = 'md' }: LoadingProps) {
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-8 w-8',
lg: 'h-12 w-12',
}
return (
<div className="flex flex-col items-center justify-center py-8">
<div
className={`${sizeClasses[size]} animate-spin rounded-full border-4 border-gray-200 border-t-primary-600`}
/>
{message && <p className="mt-4 text-sm text-gray-500">{message}</p>}
</div>
)
}

View File

@@ -0,0 +1,75 @@
/**
* Composant Modal réutilisable
*/
import { useEffect, useRef } from 'react'
import { IconClose } from './Icons'
interface ModalProps {
isOpen: boolean
onClose: () => void
title: string
children: React.ReactNode
size?: 'sm' | 'md' | 'lg' | 'xl'
}
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
}
export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null)
// Fermer avec Escape
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
if (isOpen) {
document.addEventListener('keydown', handleEscape)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = 'unset'
}
}, [isOpen, onClose])
// Fermer en cliquant sur l'overlay
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === overlayRef.current) onClose()
}
if (!isOpen) return null
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
>
<div className={`bg-white rounded-lg shadow-xl w-full ${sizeClasses[size]} max-h-[90vh] flex flex-col`}>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">{title}</h2>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
<IconClose className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="px-6 py-4 overflow-y-auto flex-1">
{children}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
/**
* Export des composants communs
*/
export { Loading } from './Loading'
export { ErrorMessage } from './ErrorMessage'
export { EmptyState } from './EmptyState'
export { Badge } from './Badge'
export { Modal } from './Modal'
export { ConfirmDialog } from './ConfirmDialog'
export * from './Icons'

View File

@@ -0,0 +1,110 @@
/**
* Carte d'affichage d'un objet
*/
import { ItemWithRelations, Item, ITEM_STATUS_LABELS, ITEM_STATUS_COLORS } from '@/api'
import { Badge, IconEdit, IconDelete, IconLocation } from '../common'
interface ItemCardProps {
item: ItemWithRelations
onClick?: () => void
onEdit?: (item: Item) => void
onDelete?: (item: Item) => void
}
export function ItemCard({ item, onClick, onEdit, onDelete }: ItemCardProps) {
const handleEdit = (e: React.MouseEvent) => {
e.stopPropagation()
onEdit?.(item)
}
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation()
onDelete?.(item)
}
return (
<div
className="card card-hover cursor-pointer group"
onClick={onClick}
>
<div className="flex justify-between items-start">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 truncate">{item.name}</h3>
{item.brand && (
<p className="text-sm text-gray-500">
{item.brand} {item.model && `- ${item.model}`}
</p>
)}
</div>
<div className="flex items-start gap-2">
<Badge className={ITEM_STATUS_COLORS[item.status]} variant="custom">
{ITEM_STATUS_LABELS[item.status]}
</Badge>
{/* Actions */}
{(onEdit || onDelete) && (
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
{onEdit && (
<button
onClick={handleEdit}
className="p-1 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded"
title="Modifier"
>
<IconEdit className="w-4 h-4" />
</button>
)}
{onDelete && (
<button
onClick={handleDelete}
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
title="Supprimer"
>
<IconDelete className="w-4 h-4" />
</button>
)}
</div>
)}
</div>
</div>
{item.description && (
<p className="mt-2 text-sm text-gray-600 truncate-2-lines">{item.description}</p>
)}
<div className="mt-4 flex flex-wrap gap-2 text-xs text-gray-500">
{/* Catégorie */}
<span
className="inline-flex items-center px-2 py-1 rounded-md"
style={{ backgroundColor: item.category.color ? `${item.category.color}20` : '#f3f4f6' }}
>
<span
className="w-2 h-2 rounded-full mr-1.5"
style={{ backgroundColor: item.category.color || '#6b7280' }}
/>
{item.category.name}
</span>
{/* Emplacement */}
<span className="inline-flex items-center px-2 py-1 bg-gray-100 rounded-md">
<IconLocation className="w-3 h-3 mr-1 text-gray-400" />
{item.location.path}
</span>
</div>
<div className="mt-4 flex justify-between items-center text-sm">
{/* Quantité */}
<span className="text-gray-600">
Quantité: <span className="font-medium">{item.quantity}</span>
</span>
{/* Prix */}
{item.price && (
<span className="font-semibold text-primary-600">
{parseFloat(item.price).toFixed(2)}
</span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,369 @@
/**
* Formulaire de création/édition d'objet
*/
import { useState, useEffect } from 'react'
import {
Item,
ItemCreate,
ItemUpdate,
ItemStatus,
CategoryWithItemCount,
LocationTree,
ITEM_STATUS_LABELS,
} from '@/api'
import { Modal, IconLink } from '@/components/common'
interface ItemFormProps {
isOpen: boolean
onClose: () => void
onSubmit: (data: ItemCreate | ItemUpdate) => void
item?: Item | null
categories: CategoryWithItemCount[]
locations: LocationTree[]
isLoading?: boolean
}
const ITEM_STATUSES: ItemStatus[] = ['in_stock', 'in_use', 'broken', 'sold', 'lent']
export function ItemForm({
isOpen,
onClose,
onSubmit,
item,
categories,
locations,
isLoading = false,
}: ItemFormProps) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [quantity, setQuantity] = useState(1)
const [status, setStatus] = useState<ItemStatus>('in_stock')
const [brand, setBrand] = useState('')
const [model, setModel] = useState('')
const [serialNumber, setSerialNumber] = useState('')
const [price, setPrice] = useState('')
const [purchaseDate, setPurchaseDate] = useState('')
const [notes, setNotes] = useState('')
const [categoryId, setCategoryId] = useState<number | ''>('')
const [locationId, setLocationId] = useState<number | ''>('')
const isEditing = !!item
// Aplatir l'arbre des emplacements pour le select
const flattenLocations = (
tree: LocationTree[],
level = 0
): Array<{ id: number; name: string; level: number; path: string }> => {
const result: Array<{ id: number; name: string; level: number; path: string }> = []
for (const loc of tree) {
result.push({ id: loc.id, name: loc.name, level, path: loc.path })
result.push(...flattenLocations(loc.children, level + 1))
}
return result
}
const flatLocations = flattenLocations(locations)
// Remplir le formulaire si édition
useEffect(() => {
if (item) {
setName(item.name)
setDescription(item.description || '')
setQuantity(item.quantity)
setStatus(item.status)
setBrand(item.brand || '')
setModel(item.model || '')
setSerialNumber(item.serial_number || '')
setPrice(item.price || '')
setPurchaseDate(item.purchase_date ? item.purchase_date.split('T')[0] : '')
setNotes(item.notes || '')
setCategoryId(item.category_id)
setLocationId(item.location_id)
} else {
setName('')
setDescription('')
setQuantity(1)
setStatus('in_stock')
setBrand('')
setModel('')
setSerialNumber('')
setPrice('')
setPurchaseDate('')
setNotes('')
setCategoryId(categories.length > 0 ? categories[0].id : '')
setLocationId(flatLocations.length > 0 ? flatLocations[0].id : '')
}
}, [item, isOpen])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (categoryId === '' || locationId === '') return
const data: ItemCreate | ItemUpdate = {
name: name.trim(),
description: description.trim() || null,
quantity,
status,
brand: brand.trim() || null,
model: model.trim() || null,
serial_number: serialNumber.trim() || null,
price: price ? parseFloat(price) : null,
purchase_date: purchaseDate || null,
notes: notes.trim() || null,
category_id: categoryId,
location_id: locationId,
}
onSubmit(data)
}
const isValid = name.trim().length > 0 && categoryId !== '' && locationId !== ''
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEditing ? "Modifier l'objet" : 'Nouvel objet'}
size="xl"
>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Section principale */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Nom */}
<div className="md:col-span-2">
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Nom <span className="text-red-500">*</span>
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="input"
placeholder="Ex: Perceuse Bosch, Câble HDMI..."
required
autoFocus
/>
</div>
{/* Catégorie */}
<div>
<label htmlFor="category" className="block text-sm font-medium text-gray-700 mb-1">
Catégorie <span className="text-red-500">*</span>
</label>
<select
id="category"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : '')}
className="input"
required
>
<option value="">Sélectionner...</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
{/* Emplacement */}
<div>
<label htmlFor="location" className="block text-sm font-medium text-gray-700 mb-1">
Emplacement <span className="text-red-500">*</span>
</label>
<select
id="location"
value={locationId}
onChange={(e) => setLocationId(e.target.value ? Number(e.target.value) : '')}
className="input"
required
>
<option value="">Sélectionner...</option>
{flatLocations.map((loc) => (
<option key={loc.id} value={loc.id}>
{' '.repeat(loc.level)}
{loc.level > 0 && '└ '}
{loc.name}
</option>
))}
</select>
</div>
{/* Quantité */}
<div>
<label htmlFor="quantity" className="block text-sm font-medium text-gray-700 mb-1">
Quantité
</label>
<input
type="number"
id="quantity"
value={quantity}
onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
className="input"
min={1}
/>
</div>
{/* Statut */}
<div>
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">
Statut
</label>
<select
id="status"
value={status}
onChange={(e) => setStatus(e.target.value as ItemStatus)}
className="input"
>
{ITEM_STATUSES.map((s) => (
<option key={s} value={s}>
{ITEM_STATUS_LABELS[s]}
</option>
))}
</select>
</div>
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="input min-h-[80px]"
placeholder="Description détaillée de l'objet..."
rows={2}
/>
</div>
{/* Section détails produit */}
<div>
<h4 className="text-sm font-medium text-gray-900 mb-3">Détails produit</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Marque */}
<div>
<label htmlFor="brand" className="block text-sm font-medium text-gray-700 mb-1">
Marque
</label>
<input
type="text"
id="brand"
value={brand}
onChange={(e) => setBrand(e.target.value)}
className="input"
placeholder="Ex: Bosch, Sony..."
/>
</div>
{/* Modèle */}
<div>
<label htmlFor="model" className="block text-sm font-medium text-gray-700 mb-1">
Modèle
</label>
<input
type="text"
id="model"
value={model}
onChange={(e) => setModel(e.target.value)}
className="input"
placeholder="Ex: GSR 18V-21..."
/>
</div>
{/* N° série */}
<div>
<label htmlFor="serial" className="block text-sm font-medium text-gray-700 mb-1">
N° de série
</label>
<input
type="text"
id="serial"
value={serialNumber}
onChange={(e) => setSerialNumber(e.target.value)}
className="input"
placeholder="Ex: SN123456..."
/>
</div>
</div>
</div>
{/* Section achat */}
<div>
<h4 className="text-sm font-medium text-gray-900 mb-3">Informations d'achat</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Prix */}
<div>
<label htmlFor="price" className="block text-sm font-medium text-gray-700 mb-1">
Prix (€)
</label>
<input
type="number"
id="price"
value={price}
onChange={(e) => setPrice(e.target.value)}
className="input"
placeholder="0.00"
min={0}
step={0.01}
/>
</div>
{/* Date d'achat */}
<div>
<label htmlFor="purchaseDate" className="block text-sm font-medium text-gray-700 mb-1">
Date d'achat
</label>
<input
type="date"
id="purchaseDate"
value={purchaseDate}
onChange={(e) => setPurchaseDate(e.target.value)}
className="input"
/>
</div>
</div>
</div>
{/* Notes */}
<div>
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-1">
Notes
</label>
<textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="input min-h-[60px]"
placeholder="Notes supplémentaires..."
rows={2}
/>
</div>
{/* Actions */}
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={onClose}
disabled={isLoading}
className="btn btn-secondary"
>
Annuler
</button>
<button
type="submit"
disabled={!isValid || isLoading}
className="btn btn-primary"
>
{isLoading ? 'Enregistrement...' : isEditing ? 'Modifier' : 'Créer'}
</button>
</div>
</form>
</Modal>
)
}

View File

@@ -0,0 +1,122 @@
/**
* Liste des objets avec recherche et filtres
*/
import { useState } from 'react'
import { useItems } from '@/hooks'
import { ItemFilter, ItemStatus, Item, ITEM_STATUS_LABELS } from '@/api'
import { Loading, ErrorMessage, EmptyState } from '../common'
import { ItemCard } from './ItemCard'
interface ItemListProps {
onItemClick?: (id: number) => void
onItemEdit?: (item: Item) => void
onItemDelete?: (item: Item) => void
}
export function ItemList({ onItemClick, onItemEdit, onItemDelete }: ItemListProps) {
const [page, setPage] = useState(1)
const [filters, setFilters] = useState<ItemFilter>({})
const [searchInput, setSearchInput] = useState('')
const { data, isLoading, error, refetch } = useItems(page, 20, filters)
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
setFilters({ ...filters, search: searchInput || undefined })
setPage(1)
}
const handleStatusFilter = (status: ItemStatus | '') => {
setFilters({ ...filters, status: status || undefined })
setPage(1)
}
if (isLoading) return <Loading message="Chargement des objets..." />
if (error) return <ErrorMessage message="Erreur lors du chargement des objets" onRetry={refetch} />
return (
<div>
{/* Barre de recherche et filtres */}
<div className="mb-6 space-y-4">
<form onSubmit={handleSearch} className="flex gap-2">
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Rechercher un objet..."
className="input flex-1"
/>
<button type="submit" className="btn btn-primary">
Rechercher
</button>
</form>
<div className="flex gap-2 flex-wrap">
<select
value={filters.status || ''}
onChange={(e) => handleStatusFilter(e.target.value as ItemStatus | '')}
className="input w-auto"
>
<option value="">Tous les statuts</option>
{Object.entries(ITEM_STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
</div>
{/* Liste des objets */}
{data?.items.length === 0 ? (
<EmptyState
title="Aucun objet trouvé"
description={filters.search ? "Essayez avec d'autres critères de recherche" : "Commencez par ajouter votre premier objet"}
icon={
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
}
/>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{data?.items.map((item) => (
<ItemCard
key={item.id}
item={item}
onClick={() => onItemClick?.(item.id)}
onEdit={onItemEdit}
onDelete={onItemDelete}
/>
))}
</div>
{/* Pagination */}
{data && data.pages > 1 && (
<div className="mt-6 flex justify-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="btn btn-secondary btn-sm disabled:opacity-50"
>
Précédent
</button>
<span className="flex items-center px-4 text-sm text-gray-600">
Page {page} sur {data.pages}
</span>
<button
onClick={() => setPage((p) => Math.min(data.pages, p + 1))}
disabled={page === data.pages}
className="btn btn-secondary btn-sm disabled:opacity-50"
>
Suivant
</button>
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,7 @@
/**
* Export des composants items
*/
export { ItemCard } from './ItemCard'
export { ItemList } from './ItemList'
export { ItemForm } from './ItemForm'

View File

@@ -0,0 +1,198 @@
/**
* Formulaire de création/édition d'emplacement
*/
import { useState, useEffect } from 'react'
import { Location, LocationCreate, LocationUpdate, LocationType, LocationTree, LOCATION_TYPE_LABELS } from '@/api'
import { Modal, IconRoom, IconFurniture, IconDrawer, IconBox } from '@/components/common'
// Mapping des icônes par type
const TYPE_ICONS: Record<LocationType, React.ReactNode> = {
room: <IconRoom className="w-6 h-6" />,
furniture: <IconFurniture className="w-6 h-6" />,
drawer: <IconDrawer className="w-6 h-6" />,
box: <IconBox className="w-6 h-6" />,
}
interface LocationFormProps {
isOpen: boolean
onClose: () => void
onSubmit: (data: LocationCreate | LocationUpdate) => void
location?: Location | null
locations: LocationTree[]
isLoading?: boolean
defaultParentId?: number | null
}
const LOCATION_TYPES: LocationType[] = ['room', 'furniture', 'drawer', 'box']
export function LocationForm({
isOpen,
onClose,
onSubmit,
location,
locations,
isLoading = false,
defaultParentId = null,
}: LocationFormProps) {
const [name, setName] = useState('')
const [type, setType] = useState<LocationType>('room')
const [parentId, setParentId] = useState<number | null>(null)
const [description, setDescription] = useState('')
const isEditing = !!location
// Aplatir l'arbre pour le select
const flattenLocations = (tree: LocationTree[], level = 0): Array<{ id: number; name: string; level: number; type: LocationType }> => {
const result: Array<{ id: number; name: string; level: number; type: LocationType }> = []
for (const loc of tree) {
// Exclure l'emplacement en cours d'édition et ses enfants
if (location && loc.id === location.id) continue
result.push({ id: loc.id, name: loc.name, level, type: loc.type })
result.push(...flattenLocations(loc.children, level + 1))
}
return result
}
const flatLocations = flattenLocations(locations)
// Remplir le formulaire si édition
useEffect(() => {
if (location) {
setName(location.name)
setType(location.type)
setParentId(location.parent_id)
setDescription(location.description || '')
} else {
setName('')
setType(defaultParentId ? 'furniture' : 'room')
setParentId(defaultParentId)
setDescription('')
}
}, [location, isOpen, defaultParentId])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const data = {
name: name.trim(),
type,
parent_id: parentId,
description: description.trim() || null,
}
onSubmit(data)
}
const isValid = name.trim().length > 0
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEditing ? "Modifier l'emplacement" : 'Nouvel emplacement'}
size="md"
>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Type <span className="text-red-500">*</span>
</label>
<div className="grid grid-cols-4 gap-2">
{LOCATION_TYPES.map((t) => (
<button
key={t}
type="button"
onClick={() => setType(t)}
className={`px-3 py-2 text-sm rounded-lg border transition-colors flex flex-col items-center ${
type === t
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-gray-300 hover:border-gray-400 text-gray-600'
}`}
>
<span className="mb-1">{TYPE_ICONS[t]}</span>
{LOCATION_TYPE_LABELS[t]}
</button>
))}
</div>
</div>
{/* Nom */}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Nom <span className="text-red-500">*</span>
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="input"
placeholder="Ex: Bureau, Armoire cuisine..."
required
/>
</div>
{/* Parent */}
<div>
<label htmlFor="parent" className="block text-sm font-medium text-gray-700 mb-1">
Emplacement parent
</label>
<select
id="parent"
value={parentId ?? ''}
onChange={(e) => setParentId(e.target.value ? Number(e.target.value) : null)}
className="input"
>
<option value="">Aucun (racine)</option>
{flatLocations.map((loc) => (
<option key={loc.id} value={loc.id}>
{' '.repeat(loc.level)}
{loc.level > 0 && '└ '}
{loc.name} ({LOCATION_TYPE_LABELS[loc.type]})
</option>
))}
</select>
<p className="mt-1 text-xs text-gray-500">
Laissez vide pour créer un emplacement racine (ex: une pièce)
</p>
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="input min-h-[80px]"
placeholder="Description optionnelle..."
rows={2}
/>
</div>
{/* Actions */}
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={onClose}
disabled={isLoading}
className="btn btn-secondary"
>
Annuler
</button>
<button
type="submit"
disabled={!isValid || isLoading}
className="btn btn-primary"
>
{isLoading ? 'Enregistrement...' : isEditing ? 'Modifier' : 'Créer'}
</button>
</div>
</form>
</Modal>
)
}

View File

@@ -0,0 +1,5 @@
/**
* Export des composants emplacements
*/
export { LocationForm } from './LocationForm'

View File

@@ -0,0 +1,7 @@
/**
* Point d'entrée pour tous les hooks
*/
export * from './useCategories'
export * from './useLocations'
export * from './useItems'

View File

@@ -0,0 +1,80 @@
/**
* Hooks React Query pour les catégories
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { categoriesApi, CategoryCreate, CategoryUpdate } from '@/api'
// Clés de cache
export const categoryKeys = {
all: ['categories'] as const,
lists: () => [...categoryKeys.all, 'list'] as const,
list: (page: number, pageSize: number) => [...categoryKeys.lists(), { page, pageSize }] as const,
details: () => [...categoryKeys.all, 'detail'] as const,
detail: (id: number) => [...categoryKeys.details(), id] as const,
}
/**
* Hook pour récupérer la liste des catégories
*/
export function useCategories(page = 1, pageSize = 20) {
return useQuery({
queryKey: categoryKeys.list(page, pageSize),
queryFn: () => categoriesApi.getAll(page, pageSize),
})
}
/**
* Hook pour récupérer une catégorie par son ID
*/
export function useCategory(id: number) {
return useQuery({
queryKey: categoryKeys.detail(id),
queryFn: () => categoriesApi.getById(id),
enabled: id > 0,
})
}
/**
* Hook pour créer une catégorie
*/
export function useCreateCategory() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CategoryCreate) => categoriesApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: categoryKeys.lists() })
},
})
}
/**
* Hook pour mettre à jour une catégorie
*/
export function useUpdateCategory() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: number; data: CategoryUpdate }) =>
categoriesApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: categoryKeys.lists() })
queryClient.invalidateQueries({ queryKey: categoryKeys.detail(id) })
},
})
}
/**
* Hook pour supprimer une catégorie
*/
export function useDeleteCategory() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => categoriesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: categoryKeys.lists() })
},
})
}

View File

@@ -0,0 +1,112 @@
/**
* Hooks React Query pour les objets d'inventaire
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { itemsApi, ItemCreate, ItemFilter, ItemStatus, ItemUpdate } from '@/api'
// Clés de cache
export const itemKeys = {
all: ['items'] as const,
lists: () => [...itemKeys.all, 'list'] as const,
list: (page: number, pageSize: number, filters?: ItemFilter) =>
[...itemKeys.lists(), { page, pageSize, filters }] as const,
details: () => [...itemKeys.all, 'detail'] as const,
detail: (id: number) => [...itemKeys.details(), id] as const,
}
/**
* Hook pour récupérer la liste des objets avec filtres
*/
export function useItems(page = 1, pageSize = 20, filters?: ItemFilter) {
return useQuery({
queryKey: itemKeys.list(page, pageSize, filters),
queryFn: () => itemsApi.getAll(page, pageSize, filters),
})
}
/**
* Hook pour récupérer un objet par son ID
*/
export function useItem(id: number) {
return useQuery({
queryKey: itemKeys.detail(id),
queryFn: () => itemsApi.getById(id),
enabled: id > 0,
})
}
/**
* Hook pour créer un objet
*/
export function useCreateItem() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: ItemCreate) => itemsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: itemKeys.lists() })
},
})
}
/**
* Hook pour mettre à jour un objet
*/
export function useUpdateItem() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: number; data: ItemUpdate }) => itemsApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: itemKeys.lists() })
queryClient.invalidateQueries({ queryKey: itemKeys.detail(id) })
},
})
}
/**
* Hook pour supprimer un objet
*/
export function useDeleteItem() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => itemsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: itemKeys.lists() })
},
})
}
/**
* Hook pour changer le statut d'un objet
*/
export function useUpdateItemStatus() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, status }: { id: number; status: ItemStatus }) =>
itemsApi.updateStatus(id, status),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: itemKeys.lists() })
queryClient.invalidateQueries({ queryKey: itemKeys.detail(id) })
},
})
}
/**
* Hook pour déplacer un objet
*/
export function useMoveItem() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, locationId }: { id: number; locationId: number }) =>
itemsApi.move(id, locationId),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: itemKeys.lists() })
queryClient.invalidateQueries({ queryKey: itemKeys.detail(id) })
},
})
}

View File

@@ -0,0 +1,114 @@
/**
* Hooks React Query pour les emplacements
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { locationsApi, LocationCreate, LocationType, LocationUpdate } from '@/api'
// Clés de cache
export const locationKeys = {
all: ['locations'] as const,
lists: () => [...locationKeys.all, 'list'] as const,
list: (page: number, pageSize: number, parentId?: number, type?: LocationType) =>
[...locationKeys.lists(), { page, pageSize, parentId, type }] as const,
tree: () => [...locationKeys.all, 'tree'] as const,
roots: () => [...locationKeys.all, 'roots'] as const,
details: () => [...locationKeys.all, 'detail'] as const,
detail: (id: number) => [...locationKeys.details(), id] as const,
children: (id: number) => [...locationKeys.all, 'children', id] as const,
}
/**
* Hook pour récupérer la liste des emplacements
*/
export function useLocations(page = 1, pageSize = 50, parentId?: number, type?: LocationType) {
return useQuery({
queryKey: locationKeys.list(page, pageSize, parentId, type),
queryFn: () => locationsApi.getAll(page, pageSize, parentId, type),
})
}
/**
* Hook pour récupérer l'arborescence complète
*/
export function useLocationTree() {
return useQuery({
queryKey: locationKeys.tree(),
queryFn: () => locationsApi.getTree(),
})
}
/**
* Hook pour récupérer les emplacements racine
*/
export function useRootLocations() {
return useQuery({
queryKey: locationKeys.roots(),
queryFn: () => locationsApi.getRoots(),
})
}
/**
* Hook pour récupérer un emplacement par son ID
*/
export function useLocation(id: number) {
return useQuery({
queryKey: locationKeys.detail(id),
queryFn: () => locationsApi.getById(id),
enabled: id > 0,
})
}
/**
* Hook pour récupérer les enfants d'un emplacement
*/
export function useLocationChildren(id: number) {
return useQuery({
queryKey: locationKeys.children(id),
queryFn: () => locationsApi.getChildren(id),
enabled: id > 0,
})
}
/**
* Hook pour créer un emplacement
*/
export function useCreateLocation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: LocationCreate) => locationsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: locationKeys.all })
},
})
}
/**
* Hook pour mettre à jour un emplacement
*/
export function useUpdateLocation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: number; data: LocationUpdate }) =>
locationsApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: locationKeys.all })
},
})
}
/**
* Hook pour supprimer un emplacement
*/
export function useDeleteLocation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => locationsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: locationKeys.all })
},
})
}

38
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,38 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import App from './App'
import './styles/index.css'
// Configuration de React Query
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Temps avant de considérer les données comme obsolètes
staleTime: 1000 * 60 * 5, // 5 minutes
// Temps de cache avant garbage collection
gcTime: 1000 * 60 * 10, // 10 minutes (anciennement cacheTime)
// Retry automatique en cas d'erreur
retry: 1,
// Refetch automatique
refetchOnWindowFocus: false, // Désactivé pour meilleure UX en local
refetchOnReconnect: true,
},
mutations: {
// Retry automatique pour les mutations
retry: 0, // Pas de retry pour les mutations par défaut
},
},
})
// Rendu de l'application
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
{/* DevTools React Query (visible uniquement en développement) */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</React.StrictMode>
)

View File

@@ -0,0 +1,182 @@
/* Styles globaux pour HomeStock */
/* Directives TailwindCSS */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* === Styles de base personnalisés === */
@layer base {
/* Reset et styles du body */
body {
@apply bg-gray-50 text-gray-900 antialiased;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
/* Titres */
h1 {
@apply text-3xl font-bold tracking-tight;
}
h2 {
@apply text-2xl font-semibold tracking-tight;
}
h3 {
@apply text-xl font-semibold;
}
h4 {
@apply text-lg font-semibold;
}
/* Liens */
a {
@apply text-primary-600 hover:text-primary-700 transition-colors;
}
/* Focus visible pour accessibilité */
*:focus-visible {
@apply outline-none ring-2 ring-primary-500 ring-offset-2;
}
}
/* === Composants réutilisables === */
@layer components {
/* Boutons */
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
}
.btn-primary {
@apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500 active:bg-primary-800;
}
.btn-secondary {
@apply bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500 active:bg-gray-400;
}
.btn-danger {
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 active:bg-red-800;
}
.btn-sm {
@apply px-3 py-1.5 text-sm;
}
.btn-lg {
@apply px-6 py-3 text-lg;
}
/* Cards */
.card {
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-6;
}
.card-hover {
@apply transition-shadow hover:shadow-md;
}
/* Inputs */
.input {
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors;
}
.input-error {
@apply border-red-500 focus:ring-red-500;
}
/* Labels */
.label {
@apply block text-sm font-medium text-gray-700 mb-1;
}
/* Badges */
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-primary {
@apply bg-primary-100 text-primary-800;
}
.badge-success {
@apply bg-green-100 text-green-800;
}
.badge-warning {
@apply bg-yellow-100 text-yellow-800;
}
.badge-danger {
@apply bg-red-100 text-red-800;
}
.badge-gray {
@apply bg-gray-100 text-gray-800;
}
/* Conteneur principal */
.container-main {
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8;
}
}
/* === Utilitaires personnalisés === */
@layer utilities {
/* Scrollbar personnalisée */
.scrollbar-thin {
scrollbar-width: thin;
}
.scrollbar-thin::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.scrollbar-thin::-webkit-scrollbar-track {
@apply bg-gray-100;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
@apply bg-gray-300 rounded-full;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400;
}
/* Transitions personnalisées */
.transition-base {
@apply transition-all duration-200 ease-in-out;
}
/* Truncate avec tooltip */
.truncate-2-lines {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.truncate-3-lines {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
/* === Animations === */
@keyframes spin-slow {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin-slow {
animation: spin-slow 3s linear infinite;
}

12
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
// Types pour les variables d'environnement Vite
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_APP_NAME: string
readonly VITE_APP_VERSION: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

117
frontend/tailwind.config.js Normal file
View File

@@ -0,0 +1,117 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
// Palette de couleurs personnalisée pour HomeStock
colors: {
// Couleur primaire (bleu pour inventaire/organisation)
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
// Couleur secondaire (vert pour statut/disponibilité)
secondary: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
950: '#052e16',
},
// Gris pour UI
gray: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
950: '#030712',
},
},
// Espacements supplémentaires
spacing: {
'128': '32rem',
'144': '36rem',
},
// Typographie
fontFamily: {
sans: [
'Inter',
'system-ui',
'-apple-system',
'BlinkMacSystemFont',
'Segoe UI',
'Roboto',
'Helvetica Neue',
'Arial',
'sans-serif',
],
mono: [
'Fira Code',
'Consolas',
'Monaco',
'Courier New',
'monospace',
],
},
// Animations personnalisées
keyframes: {
'fade-in': {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
'slide-in-right': {
'0%': { transform: 'translateX(100%)' },
'100%': { transform: 'translateX(0)' },
},
'slide-in-up': {
'0%': { transform: 'translateY(100%)' },
'100%': { transform: 'translateY(0)' },
},
},
animation: {
'fade-in': 'fade-in 0.2s ease-in-out',
'slide-in-right': 'slide-in-right 0.3s ease-out',
'slide-in-up': 'slide-in-up 0.3s ease-out',
},
// Largeurs max personnalisées
maxWidth: {
'8xl': '88rem',
'9xl': '96rem',
},
// Border radius personnalisé
borderRadius: {
'4xl': '2rem',
},
},
},
plugins: [
// Plugin pour les formulaires (optionnel, décommenter si installé)
// require('@tailwindcss/forms'),
// Plugin pour la typographie (optionnel, décommenter si installé)
// require('@tailwindcss/typography'),
],
}

46
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,46 @@
{
"compilerOptions": {
// === Cible et modules ===
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
// === Résolution des modules ===
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
// === Alias de chemins (correspond à vite.config.ts) ===
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@components/*": ["./src/components/*"],
"@pages/*": ["./src/pages/*"],
"@hooks/*": ["./src/hooks/*"],
"@api/*": ["./src/api/*"],
"@utils/*": ["./src/utils/*"],
"@assets/*": ["./src/assets/*"],
"@styles/*": ["./src/styles/*"]
},
// === Type checking strict ===
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
// === Autres options ===
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"types": ["vite/client"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

72
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,72 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// Documentation Vite : https://vitejs.dev/config/
export default defineConfig({
plugins: [
react({
// Support JSX automatique (pas besoin d'importer React)
jsxRuntime: 'automatic',
}),
],
// Configuration des alias de chemins
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@components': path.resolve(__dirname, './src/components'),
'@pages': path.resolve(__dirname, './src/pages'),
'@hooks': path.resolve(__dirname, './src/hooks'),
'@api': path.resolve(__dirname, './src/api'),
'@utils': path.resolve(__dirname, './src/utils'),
'@assets': path.resolve(__dirname, './src/assets'),
'@styles': path.resolve(__dirname, './src/styles'),
},
},
// Configuration du serveur de développement
server: {
host: '0.0.0.0', // Permet l'accès depuis l'extérieur du conteneur
port: 5173,
strictPort: true, // Échoue si le port est déjà utilisé
watch: {
// Utilise polling pour Docker (nécessaire pour hot-reload)
usePolling: true,
},
// Configuration CORS pour développement
cors: true,
},
// Configuration du build de production
build: {
outDir: 'dist',
sourcemap: true, // Génère les sourcemaps pour debugging
// Taille max des chunks (avertissement si dépassé)
chunkSizeWarningLimit: 1000,
rollupOptions: {
output: {
// Chunking manuel pour optimiser le cache
manualChunks: {
// Vendor chunks (dépendances externes)
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'query-vendor': ['@tanstack/react-query'],
},
},
},
},
// Variables d'environnement exposées au client
// Seules les variables préfixées par VITE_ sont exposées
envPrefix: 'VITE_',
// Configuration optimisations
optimizeDeps: {
include: [
'react',
'react-dom',
'react-router-dom',
'@tanstack/react-query',
],
},
})

Some files were not shown because too many files have changed in this diff Show More