commit 97ae64caf06aafd16d5f84595465cae75b98fb56 Author: perco Date: Sun Mar 15 12:40:50 2026 +0100 feat: GarageManager v2.1 - véhicules, interventions, pièces - Application FastAPI avec SQLite et Jinja2 - Gestion des véhicules (VIN, immatriculation, infos libres) - Gestion des interventions avec statut (en_attente/en_cours/termine) - Gestion des pièces détachées avec photos - Tableau de bord avec statistiques - Déploiement Docker + Traefik (HTTPS) - README complet en français diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28e0710 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +data/ +__pycache__/ +*.pyc +.env +*.db +venv/ +.venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7bd7016 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app/ ./app/ +COPY templates/ ./templates/ +VOLUME ["/data"] +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..01ddb3d --- /dev/null +++ b/README.md @@ -0,0 +1,224 @@ +# 🔧 GarageManager + +> Application web de gestion de garage automobile — véhicules, interventions, pièces détachées. + +[![FastAPI](https://img.shields.io/badge/FastAPI-0.109-009688?style=flat&logo=fastapi)](https://fastapi.tiangolo.com) +[![Python](https://img.shields.io/badge/Python-3.12-3776AB?style=flat&logo=python)](https://python.org) +[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?style=flat&logo=docker)](https://docker.com) +[![SQLite](https://img.shields.io/badge/SQLite-léger-003B57?style=flat&logo=sqlite)](https://sqlite.org) + +🌐 **URL de production** : [https://garage.nas.percolouco.com](https://garage.nas.percolouco.com) + +--- + +## 📋 Description + +**GarageManager** est une application web légère permettant de gérer un garage automobile personnel ou professionnel. Elle permet de suivre les véhicules, leurs interventions mécaniques et les pièces utilisées, avec un tableau de bord synthétique. + +Pensée pour être self-hostée, elle tourne dans un conteneur Docker derrière un reverse proxy Traefik. + +--- + +## 🛠️ Stack technique + +| Composant | Technologie | Rôle | +|-----------|-------------|------| +| **Backend** | [FastAPI](https://fastapi.tiangolo.com) 0.109 | API REST + rendu SSR | +| **Base de données** | [SQLite](https://sqlite.org) | Stockage persistant, fichier unique | +| **Templates** | [Jinja2](https://jinja.palletsprojects.com) 3.1 | Rendu HTML côté serveur | +| **CSS** | [Tailwind CSS](https://tailwindcss.com) (CDN) | Interface responsive moderne | +| **Serveur ASGI** | [Uvicorn](https://www.uvicorn.org) | Serveur HTTP haute performance | +| **Conteneur** | [Docker](https://docker.com) + Compose | Déploiement clé en main | +| **Reverse proxy** | [Traefik](https://traefik.io) | HTTPS automatique (Let's Encrypt) | + +--- + +## ✨ Fonctionnalités + +### 🚗 Gestion des véhicules + +- Ajout de véhicules avec les informations clés : + - **Immatriculation** (clé unique, mise en majuscules automatiquement) + - **Marque**, **modèle**, **année**, **kilométrage** + - **Numéro VIN** (Vehicle Identification Number) +- **Infos libres** par véhicule : notes texte, liens URL, photos — système extensible +- Recherche par immatriculation, marque, modèle ou VIN +- Compteur d'interventions par véhicule +- Suppression en cascade (interventions + pièces supprimées automatiquement) + +### 🔩 Gestion des interventions + +- Création d'interventions liées à un véhicule +- Champs disponibles : + - **Description** de l'intervention + - **Statut** : `En attente` / `En cours` / `Terminé` + - **Coût main-d'œuvre** + - **Technicien** responsable + - **Notes** internes +- Suivi du coût total (main-d'œuvre + pièces) +- Filtrage par statut + +### 🔧 Gestion des pièces détachées + +- Association de pièces à chaque intervention : + - **Nom** de la pièce + - **Référence** constructeur + - **Marque** de la pièce + - **Prix unitaire** et **quantité** + - **Photo** (upload d'image) +- Calcul automatique du coût total par intervention + +### 📊 Tableau de bord + +- Vue synthétique en temps réel : + - Nombre de véhicules enregistrés + - Interventions par statut (en attente / en cours / terminées) + - Coût total des interventions terminées (pièces + main-d'œuvre) +- 5 dernières interventions avec accès rapide + +--- + +## 📁 Structure du projet + +``` +garagemanager/ +├── app/ +│ ├── __init__.py +│ └── main.py # Application FastAPI (routes, DB, logique) +├── templates/ +│ ├── base.html # Template de base (nav, layout) +│ ├── dashboard.html # Tableau de bord +│ ├── vehicules.html # Liste des véhicules +│ ├── vehicule_form.html # Formulaire ajout/édition véhicule +│ ├── vehicule_detail.html # Détail véhicule + interventions + infos +│ ├── interventions.html # Liste des interventions +│ ├── intervention_form.html # Formulaire ajout/édition intervention +│ └── intervention_detail.html # Détail intervention + pièces +├── data/ # Volume persistant (ignoré par git) +│ └── garage.db # Base SQLite +├── Dockerfile # Image Docker Python 3.12-slim +├── docker-compose.yml # Déploiement avec Traefik +├── requirements.txt # Dépendances Python +└── README.md +``` + +--- + +## 🚀 Installation + +### Prérequis + +- Docker & Docker Compose installés +- (Optionnel) Traefik configuré pour HTTPS automatique + +### 1. Cloner le dépôt + +```bash +git clone http://192.168.1.29:3500/perco/garagemanager.git +cd garagemanager +``` + +### 2. Démarrer avec Docker Compose + +**Avec Traefik (production)** : + +```bash +docker compose up -d +``` + +L'application sera accessible via `https://garage.nas.percolouco.com` si Traefik est configuré avec le réseau `proxy`. + +**Sans Traefik (local/dev)** : + +Modifier `docker-compose.yml` pour exposer un port directement : + +```yaml +ports: + - "8000:8000" +``` + +Puis : + +```bash +docker compose up -d +``` + +Accessible sur : `http://localhost:8000` + +### 3. Développement local (sans Docker) + +```bash +# Créer un environnement virtuel +python3 -m venv venv +source venv/bin/activate + +# Installer les dépendances +pip install -r requirements.txt + +# Lancer le serveur avec rechargement automatique +DB_PATH=./data/garage.db uvicorn app.main:app --reload --port 8000 +``` + +Accessible sur : `http://localhost:8000` + +--- + +## 🗄️ Base de données + +La base SQLite est initialisée automatiquement au démarrage. Le schéma comprend 4 tables : + +| Table | Description | +|-------|-------------| +| `vehicules` | Véhicules (immat, marque, modèle, VIN, km) | +| `vehicule_infos` | Infos libres par véhicule (texte/url/image) | +| `interventions` | Interventions mécaniques avec statut et coûts | +| `pieces` | Pièces détachées liées aux interventions | + +Les suppressions en cascade sont gérées par des contraintes SQLite (`ON DELETE CASCADE`). + +--- + +## 🐳 Variables d'environnement + +| Variable | Défaut | Description | +|----------|--------|-------------| +| `DB_PATH` | `/data/garage.db` | Chemin de la base SQLite | +| `UPLOAD_DIR` | `/data/uploads` | Dossier de stockage des photos | + +--- + +## 🔄 Mise à jour + +```bash +# Arrêter le conteneur +docker compose down + +# Récupérer les dernières modifications +git pull + +# Reconstruire l'image et redémarrer +docker compose up -d --build +``` + +--- + +## 📝 Notes + +- Les données sont persistées dans le dossier `./data/` (volume Docker) +- Les photos uploadées sont stockées dans `./data/uploads/` +- Le dossier `data/` est exclu du dépôt git (`.gitignore`) +- L'application ne gère pas d'authentification — à protéger via Traefik middleware si nécessaire + +--- + +## 🏷️ Versions + +| Version | Notes | +|---------|-------| +| v2.1 | Infos libres véhicule (texte/url/image), upload photos pièces | +| v2.0 | Ajout gestion des pièces détachées | +| v1.0 | Gestion véhicules + interventions | + +--- + +*Développé avec ❤️ pour la gestion de garage automobile* diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..39d6100 --- /dev/null +++ b/app/main.py @@ -0,0 +1,288 @@ +from fastapi import FastAPI, Request, Form, HTTPException, UploadFile, File +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from fastapi.staticfiles import StaticFiles +import sqlite3, os, shutil, uuid +from typing import Optional, List + +app = FastAPI(title="GarageManager") +templates = Jinja2Templates(directory="templates") +DB_PATH = os.environ.get("DB_PATH", "/data/garage.db") +UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "/data/uploads") + +os.makedirs(UPLOAD_DIR, exist_ok=True) +app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads") + +def get_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + +def init_db(): + conn = get_db() + conn.executescript(""" + CREATE TABLE IF NOT EXISTS vehicules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + immatriculation TEXT NOT NULL UNIQUE, + marque TEXT NOT NULL, + modele TEXT NOT NULL, + annee INTEGER, + vin TEXT, + kilometrage INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS vehicule_infos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + vehicule_id INTEGER NOT NULL REFERENCES vehicules(id) ON DELETE CASCADE, + type TEXT NOT NULL DEFAULT 'texte' CHECK(type IN ('texte','url','image')), + titre TEXT, + contenu TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS interventions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + vehicule_id INTEGER NOT NULL REFERENCES vehicules(id) ON DELETE CASCADE, + description TEXT NOT NULL, + statut TEXT NOT NULL DEFAULT 'en_attente' CHECK(statut IN ('en_attente','en_cours','termine')), + cout_main_oeuvre REAL DEFAULT 0, + technicien TEXT, + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS pieces ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + intervention_id INTEGER NOT NULL REFERENCES interventions(id) ON DELETE CASCADE, + nom TEXT NOT NULL, + reference TEXT, + marque TEXT, + prix REAL DEFAULT 0, + quantite INTEGER DEFAULT 1, + photo TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + conn.commit() + conn.close() + +@app.on_event("startup") +async def startup(): + init_db() + +# DASHBOARD +@app.get("/", response_class=HTMLResponse) +async def dashboard(request: Request): + conn = get_db() + stats = { + "nb_vehicules": conn.execute("SELECT COUNT(*) FROM vehicules").fetchone()[0], + "nb_en_attente": conn.execute("SELECT COUNT(*) FROM interventions WHERE statut='en_attente'").fetchone()[0], + "nb_en_cours": conn.execute("SELECT COUNT(*) FROM interventions WHERE statut='en_cours'").fetchone()[0], + "nb_termine": conn.execute("SELECT COUNT(*) FROM interventions WHERE statut='termine'").fetchone()[0], + "cout_pieces": conn.execute("SELECT COALESCE(SUM(p.prix*p.quantite),0) FROM pieces p JOIN interventions i ON i.id=p.intervention_id WHERE i.statut='termine'").fetchone()[0], + "cout_mo": conn.execute("SELECT COALESCE(SUM(cout_main_oeuvre),0) FROM interventions WHERE statut='termine'").fetchone()[0], + } + stats["cout_total"] = stats["cout_pieces"] + stats["cout_mo"] + recent = conn.execute(""" + SELECT i.id, i.description, i.statut, i.created_at, i.cout_main_oeuvre, + v.immatriculation, v.marque, v.modele, v.id as vehicule_id, + COALESCE(SUM(p.prix*p.quantite),0) as cout_pieces + FROM interventions i + JOIN vehicules v ON i.vehicule_id = v.id + LEFT JOIN pieces p ON p.intervention_id = i.id + GROUP BY i.id + ORDER BY i.created_at DESC LIMIT 5 + """).fetchall() + conn.close() + return templates.TemplateResponse("dashboard.html", {"request": request, "stats": stats, "recent": recent, "page": "dashboard"}) + +# VEHICULES +@app.get("/vehicules", response_class=HTMLResponse) +async def vehicules_list(request: Request, q: str = ""): + conn = get_db() + if q: + rows = conn.execute( + "SELECT v.*, COUNT(i.id) as nb_interventions FROM vehicules v LEFT JOIN interventions i ON i.vehicule_id=v.id WHERE v.immatriculation LIKE ? OR v.marque LIKE ? OR v.modele LIKE ? OR v.vin LIKE ? GROUP BY v.id ORDER BY v.marque", + (f"%{q}%", f"%{q}%", f"%{q}%", f"%{q}%") + ).fetchall() + else: + rows = conn.execute("SELECT v.*, COUNT(i.id) as nb_interventions FROM vehicules v LEFT JOIN interventions i ON i.vehicule_id=v.id GROUP BY v.id ORDER BY v.marque").fetchall() + conn.close() + return templates.TemplateResponse("vehicules.html", {"request": request, "vehicules": rows, "q": q, "page": "vehicules"}) + +@app.get("/vehicules/new", response_class=HTMLResponse) +async def vehicule_new(request: Request): + return templates.TemplateResponse("vehicule_form.html", {"request": request, "vehicule": None, "page": "vehicules"}) + +@app.post("/vehicules/new") +async def vehicule_create(immatriculation: str = Form(...), marque: str = Form(...), modele: str = Form(...), annee: Optional[int] = Form(None), vin: str = Form(""), kilometrage: Optional[int] = Form(None)): + conn = get_db() + conn.execute("INSERT INTO vehicules (immatriculation, marque, modele, annee, vin, kilometrage) VALUES (?,?,?,?,?,?)", + (immatriculation.upper().strip(), marque.strip(), modele.strip(), annee, vin.strip(), kilometrage)) + conn.commit(); conn.close() + return RedirectResponse("/vehicules", status_code=303) + +@app.get("/vehicules/{id}", response_class=HTMLResponse) +async def vehicule_detail(request: Request, id: int): + conn = get_db() + vehicule = conn.execute("SELECT * FROM vehicules WHERE id=?", (id,)).fetchone() + if not vehicule: raise HTTPException(404) + interventions = conn.execute(""" + SELECT i.*, COALESCE(SUM(p.prix*p.quantite),0) as cout_pieces, COUNT(p.id) as nb_pieces + FROM interventions i LEFT JOIN pieces p ON p.intervention_id=i.id + WHERE i.vehicule_id=? GROUP BY i.id ORDER BY i.created_at DESC + """, (id,)).fetchall() + infos = conn.execute("SELECT * FROM vehicule_infos WHERE vehicule_id=? ORDER BY created_at DESC", (id,)).fetchall() + conn.close() + return templates.TemplateResponse("vehicule_detail.html", {"request": request, "vehicule": vehicule, "interventions": interventions, "infos": infos, "page": "vehicules"}) + +@app.get("/vehicules/{id}/edit", response_class=HTMLResponse) +async def vehicule_edit(request: Request, id: int): + conn = get_db() + vehicule = conn.execute("SELECT * FROM vehicules WHERE id=?", (id,)).fetchone() + conn.close() + return templates.TemplateResponse("vehicule_form.html", {"request": request, "vehicule": vehicule, "page": "vehicules"}) + +@app.post("/vehicules/{id}/edit") +async def vehicule_update(id: int, immatriculation: str = Form(...), marque: str = Form(...), modele: str = Form(...), annee: Optional[int] = Form(None), vin: str = Form(""), kilometrage: Optional[int] = Form(None)): + conn = get_db() + conn.execute("UPDATE vehicules SET immatriculation=?,marque=?,modele=?,annee=?,vin=?,kilometrage=? WHERE id=?", + (immatriculation.upper().strip(), marque.strip(), modele.strip(), annee, vin.strip(), kilometrage, id)) + conn.commit(); conn.close() + return RedirectResponse(f"/vehicules/{id}", status_code=303) + +@app.post("/vehicules/{id}/delete") +async def vehicule_delete(id: int): + conn = get_db() + conn.execute("DELETE FROM vehicules WHERE id=?", (id,)) + conn.commit(); conn.close() + return RedirectResponse("/vehicules", status_code=303) + +# VEHICULE INFOS +@app.post("/vehicules/{id}/infos/add") +async def vehicule_info_add(id: int, type: str = Form("texte"), titre: str = Form(""), contenu: str = Form(...), photo: UploadFile = File(None)): + conn = get_db() + contenu_final = contenu.strip() + if type == "image" and photo and photo.filename: + ext = os.path.splitext(photo.filename)[1] + filename = f"{uuid.uuid4()}{ext}" + path = os.path.join(UPLOAD_DIR, filename) + with open(path, "wb") as f: + shutil.copyfileobj(photo.file, f) + contenu_final = f"/uploads/{filename}" + conn.execute("INSERT INTO vehicule_infos (vehicule_id, type, titre, contenu) VALUES (?,?,?,?)", + (id, type, titre.strip(), contenu_final)) + conn.commit(); conn.close() + return RedirectResponse(f"/vehicules/{id}", status_code=303) + +@app.post("/vehicules/{vid}/infos/{iid}/delete") +async def vehicule_info_delete(vid: int, iid: int): + conn = get_db() + conn.execute("DELETE FROM vehicule_infos WHERE id=? AND vehicule_id=?", (iid, vid)) + conn.commit(); conn.close() + return RedirectResponse(f"/vehicules/{vid}", status_code=303) + +# INTERVENTIONS +@app.get("/interventions", response_class=HTMLResponse) +async def interventions_list(request: Request, statut: str = ""): + conn = get_db() + if statut: + rows = conn.execute(""" + SELECT i.*, v.immatriculation, v.marque, v.modele, + COALESCE(SUM(p.prix*p.quantite),0) as cout_pieces + FROM interventions i JOIN vehicules v ON v.id=i.vehicule_id + LEFT JOIN pieces p ON p.intervention_id=i.id + WHERE i.statut=? GROUP BY i.id ORDER BY i.created_at DESC + """, (statut,)).fetchall() + else: + rows = conn.execute(""" + SELECT i.*, v.immatriculation, v.marque, v.modele, + COALESCE(SUM(p.prix*p.quantite),0) as cout_pieces + FROM interventions i JOIN vehicules v ON v.id=i.vehicule_id + LEFT JOIN pieces p ON p.intervention_id=i.id + GROUP BY i.id ORDER BY i.created_at DESC + """).fetchall() + conn.close() + return templates.TemplateResponse("interventions.html", {"request": request, "interventions": rows, "statut": statut, "page": "interventions"}) + +@app.get("/interventions/new", response_class=HTMLResponse) +async def intervention_new(request: Request, vehicule_id: Optional[int] = None): + conn = get_db() + vehicules = conn.execute("SELECT id, immatriculation||' - '||marque||' '||modele as label FROM vehicules ORDER BY marque").fetchall() + conn.close() + return templates.TemplateResponse("intervention_form.html", {"request": request, "intervention": None, "vehicules": vehicules, "preselect_vehicule": vehicule_id, "page": "interventions"}) + +@app.post("/interventions/new") +async def intervention_create(vehicule_id: int = Form(...), description: str = Form(...), statut: str = Form("en_attente"), cout_main_oeuvre: float = Form(0), technicien: str = Form(""), notes: str = Form("")): + conn = get_db() + conn.execute("INSERT INTO interventions (vehicule_id, description, statut, cout_main_oeuvre, technicien, notes) VALUES (?,?,?,?,?,?)", + (vehicule_id, description.strip(), statut, cout_main_oeuvre, technicien.strip(), notes.strip())) + conn.commit() + iid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + conn.close() + return RedirectResponse(f"/interventions/{iid}", status_code=303) + +@app.get("/interventions/{id}", response_class=HTMLResponse) +async def intervention_detail(request: Request, id: int): + conn = get_db() + intervention = conn.execute(""" + SELECT i.*, v.immatriculation, v.marque, v.modele, v.id as vid, + COALESCE(SUM(p.prix*p.quantite),0) as cout_pieces + FROM interventions i JOIN vehicules v ON v.id=i.vehicule_id + LEFT JOIN pieces p ON p.intervention_id=i.id + WHERE i.id=? GROUP BY i.id + """, (id,)).fetchone() + if not intervention: raise HTTPException(404) + pieces = conn.execute("SELECT * FROM pieces WHERE intervention_id=? ORDER BY created_at", (id,)).fetchall() + conn.close() + return templates.TemplateResponse("intervention_detail.html", {"request": request, "intervention": intervention, "pieces": pieces, "page": "interventions"}) + +@app.get("/interventions/{id}/edit", response_class=HTMLResponse) +async def intervention_edit(request: Request, id: int): + conn = get_db() + intervention = conn.execute("SELECT * FROM interventions WHERE id=?", (id,)).fetchone() + vehicules = conn.execute("SELECT id, immatriculation||' - '||marque||' '||modele as label FROM vehicules ORDER BY marque").fetchall() + conn.close() + return templates.TemplateResponse("intervention_form.html", {"request": request, "intervention": intervention, "vehicules": vehicules, "preselect_vehicule": None, "page": "interventions"}) + +@app.post("/interventions/{id}/edit") +async def intervention_update(id: int, vehicule_id: int = Form(...), description: str = Form(...), statut: str = Form("en_attente"), cout_main_oeuvre: float = Form(0), technicien: str = Form(""), notes: str = Form("")): + conn = get_db() + conn.execute("UPDATE interventions SET vehicule_id=?,description=?,statut=?,cout_main_oeuvre=?,technicien=?,notes=?,updated_at=CURRENT_TIMESTAMP WHERE id=?", + (vehicule_id, description.strip(), statut, cout_main_oeuvre, technicien.strip(), notes.strip(), id)) + conn.commit(); conn.close() + return RedirectResponse(f"/interventions/{id}", status_code=303) + +@app.post("/interventions/{id}/delete") +async def intervention_delete(id: int): + conn = get_db() + row = conn.execute("SELECT vehicule_id FROM interventions WHERE id=?", (id,)).fetchone() + vid = row["vehicule_id"] if row else None + conn.execute("DELETE FROM interventions WHERE id=?", (id,)) + conn.commit(); conn.close() + return RedirectResponse(f"/vehicules/{vid}" if vid else "/vehicules", status_code=303) + +# PIECES +@app.post("/interventions/{id}/pieces/add") +async def piece_add(id: int, nom: str = Form(...), reference: str = Form(""), marque: str = Form(""), prix: float = Form(0), quantite: int = Form(1), photo: UploadFile = File(None)): + conn = get_db() + photo_path = None + if photo and photo.filename: + ext = os.path.splitext(photo.filename)[1] + filename = f"{uuid.uuid4()}{ext}" + path = os.path.join(UPLOAD_DIR, filename) + with open(path, "wb") as f: + shutil.copyfileobj(photo.file, f) + photo_path = f"/uploads/{filename}" + conn.execute("INSERT INTO pieces (intervention_id, nom, reference, marque, prix, quantite, photo) VALUES (?,?,?,?,?,?,?)", + (id, nom.strip(), reference.strip(), marque.strip(), prix, quantite, photo_path)) + conn.commit(); conn.close() + return RedirectResponse(f"/interventions/{id}", status_code=303) + +@app.post("/interventions/{iid}/pieces/{pid}/delete") +async def piece_delete(iid: int, pid: int): + conn = get_db() + conn.execute("DELETE FROM pieces WHERE id=? AND intervention_id=?", (pid, iid)) + conn.commit(); conn.close() + return RedirectResponse(f"/interventions/{iid}", status_code=303) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f84ca0c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: "3.8" +services: + garagemanager: + build: . + container_name: garagemanager + restart: unless-stopped + volumes: + - ./data:/data + environment: + - DB_PATH=/data/garage.db + networks: + - proxy + labels: + - "traefik.enable=true" + - "traefik.http.routers.garage.rule=Host(`garage.nas.percolouco.com`)" + - "traefik.http.routers.garage.entrypoints=websecure" + - "traefik.http.routers.garage.tls=true" + - "traefik.http.routers.garage.tls.certresolver=letsencrypt" + - "traefik.http.services.garage.loadbalancer.server.port=8000" +networks: + proxy: + external: true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8e88212 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +jinja2==3.1.3 +python-multipart==0.0.9 diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..f2a8943 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,34 @@ + + + + + + {% block title %}GarageManager{% endblock %} + + + + +
+
+

+ GarageManager +

+
+ +
GarageManager v2.1
+
+
+ {% block content %}{% endblock %} +
+ + diff --git a/templates/client_detail.html b/templates/client_detail.html new file mode 100644 index 0000000..42bdc26 --- /dev/null +++ b/templates/client_detail.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}{{ client.nom }} {{ client.prenom }} - GarageManager{% endblock %} +{% block content %} +
+ Retour aux clients +
+
+

{{ client.nom }} {{ client.prenom }}

+
+ {% if client.telephone %}{{ client.telephone }}{% endif %} + {% if client.email %}{{ client.email }}{% endif %} + {% if client.adresse %}{{ client.adresse }}{% endif %} +
+
+
+ Modifier +
+ +
+
+
+
+
+
+

Véhicules ({{ vehicules|length }})

+ + Ajouter un véhicule +
+ {% if vehicules %} +
+ {% for v in vehicules %} +
+
+ {{ v.immatriculation }} + {{ v.marque }} {{ v.modele }} + {% if v.annee %}({{ v.annee }}){% endif %} + {% if v.kilometrage %}— {{ v.kilometrage }} km{% endif %} +
+
+ {{ v.nb_interventions }} intervention(s) + Voir → +
+
+ {% endfor %} +
+ {% else %} +
Aucun véhicule enregistré pour ce client.
+ {% endif %} +
+{% endblock %} diff --git a/templates/client_form.html b/templates/client_form.html new file mode 100644 index 0000000..9771c85 --- /dev/null +++ b/templates/client_form.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}{% if client %}Modifier{% else %}Nouveau{% endif %} client - GarageManager{% endblock %} +{% block content %} +
+ Retour +

{% if client %}Modifier le client{% else %}Nouveau client{% endif %}

+
+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + Annuler +
+
+
+{% endblock %} diff --git a/templates/clients.html b/templates/clients.html new file mode 100644 index 0000000..fc5bb7f --- /dev/null +++ b/templates/clients.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block title %}Clients - GarageManager{% endblock %} +{% block content %} +
+

Clients

{{ clients|length }} client(s)

+ Nouveau client +
+
+
+
+ + + {% if q %}{% endif %} +
+
+
+ {% if clients %} + + + + + + + + + + + + {% for c in clients %} + + + + + + + + {% endfor %} + +
NomTéléphoneEmailVéhiculesActions
{{ c.nom }} {{ c.prenom }}{{ c.telephone or '-' }}{{ c.email or '-' }}{{ c.nb_vehicules }} + Voir + Modifier +
+ +
+
+ {% else %} +
+

Aucun client trouvé

+ Ajouter le premier → +
+ {% endif %} +
+
+{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..083d6f7 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,89 @@ +{% extends "base.html" %} +{% block title %}Dashboard - GarageManager{% endblock %} +{% block content %} +
+

Dashboard

+

Vue d'ensemble de votre garage

+
+
+
+
+

Véhicules

{{ stats.nb_vehicules }}

+
+
+ Voir tous → +
+
+
+

En attente

{{ stats.nb_en_attente }}

+
+
+ Voir → +
+
+
+

En cours

{{ stats.nb_en_cours }}

+
+
+ Voir → +
+
+
+

Terminées

{{ stats.nb_termine }}

+
+
+ Voir → +
+
+
+

Coût pièces

{{ "%.0f"|format(stats.cout_pieces) }} €

+
+
+
+
+
+

Total dépensé

{{ "%.0f"|format(stats.cout_total) }} €

+
+
+
+
+
+
+

Interventions récentes

+ + Nouvelle +
+ {% if recent %} +
+ + + + + + + + + + + {% for i in recent %} + + + + + + + {% endfor %} + +
VéhiculeDescriptionStatutCoût total
{{ i.immatriculation }}
{{ i.marque }} {{ i.modele }}
{{ i.description[:60] }}{% if i.description|length > 60 %}...{% endif %} + {% if i.statut == 'en_attente' %}En attente + {% elif i.statut == 'en_cours' %}En cours + {% else %}Terminé{% endif %} + {{ "%.0f"|format(i.cout_main_oeuvre + i.cout_pieces) }} €
+
+ {% else %} +
+

Aucune intervention pour l'instant

+ Créer la première → +
+ {% endif %} +
+{% endblock %} diff --git a/templates/intervention_detail.html b/templates/intervention_detail.html new file mode 100644 index 0000000..032ce0c --- /dev/null +++ b/templates/intervention_detail.html @@ -0,0 +1,142 @@ +{% extends "base.html" %} +{% block title %}Intervention #{{ intervention.id }} - GarageManager{% endblock %} +{% block content %} +
+ Retour au véhicule +
+
+

{{ intervention.description }}

+

{{ intervention.immatriculation }} — {{ intervention.marque }} {{ intervention.modele }}

+

{{ intervention.created_at[:10] }}{% if intervention.technicien %} • {{ intervention.technicien }}{% endif %}

+
+
+ {% if intervention.statut == 'en_attente' %}⏳ En attente + {% elif intervention.statut == 'en_cours' %}🔧 En cours + {% else %}✅ Terminé{% endif %} + Modifier +
+ +
+
+
+
+ + +
+
+

Main d'œuvre

+

{{ "%.2f"|format(intervention.cout_main_oeuvre) }} €

+
+
+

Pièces

+

{{ "%.2f"|format(intervention.cout_pieces) }} €

+
+
+

Total

+

{{ "%.2f"|format(intervention.cout_main_oeuvre + intervention.cout_pieces) }} €

+
+
+ +{% if intervention.notes %} +
+

📋 Notes

+

{{ intervention.notes }}

+
+{% endif %} + + +
+
+

🔩 Pièces utilisées ({{ pieces|length }})

+ +
+ + + + + + {% if pieces %} +
+ + + + + + + + + + + + + + + {% for p in pieces %} + + + + + + + + + + + {% endfor %} + + + + + +
PhotoPièceRéférenceMarqueQtéPrix unit.Total
+ {% if p.photo %} + {{ p.nom }} + {% else %} +
+ {% endif %} +
{{ p.nom }}{{ p.reference or '-' }}{{ p.marque or '-' }}{{ p.quantite }}{{ "%.2f"|format(p.prix) }} €{{ "%.2f"|format(p.prix * p.quantite) }} € +
+ +
+
Total pièces{{ "%.2f"|format(intervention.cout_pieces) }} €
+
+ {% else %} +
+ Aucune pièce enregistrée.
+ +
+ {% endif %} +
+{% endblock %} diff --git a/templates/intervention_form.html b/templates/intervention_form.html new file mode 100644 index 0000000..ea8aad9 --- /dev/null +++ b/templates/intervention_form.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}{% if intervention %}Modifier{% else %}Nouvelle{% endif %} intervention - GarageManager{% endblock %} +{% block content %} +
+ Retour +

{% if intervention %}Modifier l'intervention{% else %}Nouvelle intervention{% endif %}

+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + Annuler +
+
+
+{% endblock %} diff --git a/templates/interventions.html b/templates/interventions.html new file mode 100644 index 0000000..63968a3 --- /dev/null +++ b/templates/interventions.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% block title %}Interventions - GarageManager{% endblock %} +{% block content %} +
+

Interventions

{{ interventions|length }} intervention(s)

+ Nouvelle intervention +
+
+ Toutes + ⏳ En attente + 🔧 En cours + ✅ Terminées +
+
+ {% if interventions %} + + + + + + + + + + + + + + {% for i in interventions %} + + + + + + + + + + {% endfor %} + +
VéhiculeDescriptionStatutPiècesTotalDateActions
+ {{ i.immatriculation }}
{{ i.marque }} {{ i.modele }}
+
+ {{ i.description[:60] }}{% if i.description|length > 60 %}...{% endif %} + + {% if i.statut == 'en_attente' %}En attente + {% elif i.statut == 'en_cours' %}En cours + {% else %}Terminé{% endif %} + {{ "%.2f"|format(i.cout_pieces) }} €{{ "%.0f"|format(i.cout_main_oeuvre + i.cout_pieces) }} €{{ i.created_at[:10] }} + Voir + Modifier +
+ +
+
+ {% else %} +
+

Aucune intervention trouvée

+ Créer la première → +
+ {% endif %} +
+{% endblock %} diff --git a/templates/vehicule_detail.html b/templates/vehicule_detail.html new file mode 100644 index 0000000..6411e44 --- /dev/null +++ b/templates/vehicule_detail.html @@ -0,0 +1,128 @@ +{% extends "base.html" %} +{% block title %}{{ vehicule.immatriculation }} - GarageManager{% endblock %} +{% block content %} +
+ Retour aux véhicules +
+
+

{{ vehicule.immatriculation }}

+

{{ vehicule.marque }} {{ vehicule.modele }}{% if vehicule.annee %} ({{ vehicule.annee }}){% endif %}{% if vehicule.kilometrage %} • {{ vehicule.kilometrage }} km{% endif %}

+ {% if vehicule.vin %}

VIN : {{ vehicule.vin }}

{% endif %} +
+
+ + Intervention + Modifier +
+ +
+
+
+
+ + +
+
+

📎 Infos & Notes

+ +
+ + {% if infos %} +
+ {% for info in infos %} +
+
+ {% if info.titre %}

{{ info.titre }}

{% endif %} + {% if info.type == 'texte' %} +

{{ info.contenu }}

+ {% elif info.type == 'url' %} + 🔗 {{ info.contenu }} + {% elif info.type == 'image' %} + {{ info.titre or 'image' }} + {% endif %} +

{{ info.created_at[:10] }}

+
+
+ +
+
+ {% endfor %} +
+ {% else %} +
Aucune info pour l'instant.
+ {% endif %} +
+ + +
+
+

🔧 Interventions ({{ interventions|length }})

+ + Nouvelle intervention +
+ {% if interventions %} +
+ {% for i in interventions %} +
+
+
+ {{ i.description }} + {% if i.technicien %}

{{ i.technicien }}

{% endif %} +

{{ i.created_at[:10] }} • {{ i.nb_pieces }} pièce(s)

+
+
+ {{ "%.0f"|format(i.cout_main_oeuvre + i.cout_pieces) }} € + {% if i.statut == 'en_attente' %}En attente + {% elif i.statut == 'en_cours' %}En cours + {% else %}Terminé{% endif %} +
+
+
+ {% endfor %} +
+ {% else %} +
+ Aucune intervention.
+ Créer la première → +
+ {% endif %} +
+ + +{% endblock %} diff --git a/templates/vehicule_form.html b/templates/vehicule_form.html new file mode 100644 index 0000000..d562bf0 --- /dev/null +++ b/templates/vehicule_form.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}{% if vehicule %}Modifier{% else %}Nouveau{% endif %} véhicule - GarageManager{% endblock %} +{% block content %} +
+ Retour +

{% if vehicule %}Modifier le véhicule{% else %}Nouveau véhicule{% endif %}

+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + Annuler +
+
+
+{% endblock %} diff --git a/templates/vehicules.html b/templates/vehicules.html new file mode 100644 index 0000000..2faebb0 --- /dev/null +++ b/templates/vehicules.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% block title %}Véhicules - GarageManager{% endblock %} +{% block content %} +
+

Véhicules

{{ vehicules|length }} véhicule(s)

+ Nouveau véhicule +
+
+
+
+ + + {% if q %}{% endif %} +
+
+
+ {% if vehicules %} + + + + + + + + + + + + + + {% for v in vehicules %} + + + + + + + + + + {% endfor %} + +
ImmatriculationMarque / ModèleAnnéeVINKilométrageInterventionsActions
{{ v.immatriculation }}{{ v.marque }} {{ v.modele }}{{ v.annee or '-' }}{{ v.vin or '-' }}{{ (v.kilometrage|string + ' km') if v.kilometrage else '-' }}{{ v.nb_interventions }} + Voir + Modifier +
+ +
+
+ {% else %} +
+

Aucun véhicule trouvé

+ Ajouter le premier → +
+ {% endif %} +
+
+{% endblock %}