- 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
289 lines
15 KiB
Python
289 lines
15 KiB
Python
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)
|