From b7046b125cce158b403115f3f55afcdc1058e59b Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 21 Feb 2026 23:26:50 +0100 Subject: [PATCH] Init project --- .env.example | 24 +++ README.md | 132 ++++++++++++++ auth.py | 73 ++++++++ config.py | 33 ++++ database.py | 26 +++ main.py | 111 ++++++++++++ models.py | 131 ++++++++++++++ numerotation.py | 44 +++++ requirements.txt | 10 ++ routers/auth.py | 162 +++++++++++++++++ routers/clients.py | 95 ++++++++++ routers/devis.py | 186 +++++++++++++++++++ routers/factures.py | 189 ++++++++++++++++++++ static/css/style.css | 192 ++++++++++++++++++++ template_helper.py | 19 ++ templates/auth/login.html | 62 +++++++ templates/auth/user_form.html | 40 +++++ templates/auth/utilisateurs.html | 57 ++++++ templates/base.html | 33 ++++ templates/clients/form.html | 45 +++++ templates/clients/liste.html | 41 +++++ templates/devis/detail.html | 94 ++++++++++ templates/devis/form.html | 131 ++++++++++++++ templates/devis/liste.html | 39 ++++ templates/erreur.html | 10 ++ templates/factures/detail.html | 98 ++++++++++ templates/factures/form.html | 139 +++++++++++++++ templates/factures/liste.html | 40 +++++ templates/pdf/facture.html | 297 +++++++++++++++++++++++++++++++ 29 files changed, 2553 insertions(+) create mode 100644 .env.example create mode 100644 README.md create mode 100644 auth.py create mode 100644 config.py create mode 100644 database.py create mode 100644 main.py create mode 100644 models.py create mode 100644 numerotation.py create mode 100644 requirements.txt create mode 100644 routers/auth.py create mode 100644 routers/clients.py create mode 100644 routers/devis.py create mode 100644 routers/factures.py create mode 100644 static/css/style.css create mode 100644 template_helper.py create mode 100644 templates/auth/login.html create mode 100644 templates/auth/user_form.html create mode 100644 templates/auth/utilisateurs.html create mode 100644 templates/base.html create mode 100644 templates/clients/form.html create mode 100644 templates/clients/liste.html create mode 100644 templates/devis/detail.html create mode 100644 templates/devis/form.html create mode 100644 templates/devis/liste.html create mode 100644 templates/erreur.html create mode 100644 templates/factures/detail.html create mode 100644 templates/factures/form.html create mode 100644 templates/factures/liste.html create mode 100644 templates/pdf/facture.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a086b5e --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Informations de l'association +ASSO_NOM=HAUM - Hackerspace Au Mans +ASSO_ADRESSE=1 rue de l'Innovation +ASSO_CODE_POSTAL=72000 +ASSO_VILLE=Le Mans +ASSO_EMAIL=contact@haum.org +ASSO_TELEPHONE= +ASSO_RNA=W722012345 +ASSO_SIRET= +ASSO_OBJET=Hackerspace et promotion de la culture libre + +# Coordonnées bancaires (pour le PDF) +ASSO_IBAN=FR76 0000 0000 0000 0000 0000 000 +ASSO_BIC=XXXXXXXX + +# Base de données +DATABASE_URL=sqlite:///./factures.db + +# Préfixes numérotation +DEVIS_PREFIX=DEV + +# Sécurité (générer avec: python3 -c "import secrets; print(secrets.token_hex(32))") +SECRET_KEY=changez-moi-en-production +# SESSION_MAX_AGE=604800 # 7 jours en secondes diff --git a/README.md b/README.md new file mode 100644 index 0000000..3df2c0c --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# Facturation Association + +Application de facturation légale française pour associations (loi 1901), +non assujetties à la TVA (art. 293B du CGI). + +## Fonctionnalités + +- Gestion des clients +- Devis avec numérotation automatique (DEV-AAAA-XXXX) +- Factures avec numérotation chronologique (AAAA-XXXX) +- Conversion devis → facture +- Génération PDF avec toutes les mentions légales françaises +- Suivi des statuts (émise / payée / annulée) + +## Stack + +- **FastAPI** + Jinja2 (interface web) +- **SQLite** + SQLAlchemy (stockage) +- **WeasyPrint** (génération PDF) + +## Installation + +```bash +# Cloner / copier le projet +cd factures/ + +# Créer un environnement virtuel +python3 -m venv venv +source venv/bin/activate + +# Installer les dépendances +pip install -r requirements.txt + +# Configurer l'association +cp .env.example .env +nano .env # remplir les informations de l'association + +# Lancer en développement +uvicorn main:app --reload --host 0.0.0.0 --port 8000 +``` + +## Déploiement VPS (production) + +### Avec systemd + +Créer `/etc/systemd/system/facturation.service` : + +```ini +[Unit] +Description=Facturation Association +After=network.target + +[Service] +Type=simple +User=www-data +WorkingDirectory=/opt/facturation +Environment="PATH=/opt/facturation/venv/bin" +ExecStart=/opt/facturation/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000 +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl enable facturation +sudo systemctl start facturation +``` + +### Reverse proxy nginx + +```nginx +server { + listen 80; + server_name factures.monasso.fr; + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +### HTTPS avec Certbot + +```bash +sudo certbot --nginx -d factures.monasso.fr +``` + +### Dépendances système pour WeasyPrint (Debian/Ubuntu) + +```bash +sudo apt install python3-dev python3-pip libpango-1.0-0 libpangoft2-1.0-0 \ + libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libffi-dev shared-mime-info +``` + +## Configuration (.env) + +| Variable | Description | +|---|---| +| `ASSO_NOM` | Nom de l'association | +| `ASSO_ADRESSE` | Adresse | +| `ASSO_CODE_POSTAL` | Code postal | +| `ASSO_VILLE` | Ville | +| `ASSO_EMAIL` | Email de contact | +| `ASSO_RNA` | Numéro RNA (W...) | +| `ASSO_SIRET` | SIRET si disponible | +| `ASSO_IBAN` | IBAN pour le pied de page PDF | +| `ASSO_BIC` | BIC | +| `DEVIS_PREFIX` | Préfixe devis (défaut: DEV) | + +## Mentions légales incluses dans les PDF + +- Numéro de facture chronologique unique +- Date d'émission et d'échéance +- Identification complète émetteur et destinataire +- Détail des lignes HT +- Mention TVA non applicable art. 293B CGI +- Conditions de règlement +- Pénalités de retard légales (art. L.441-10 C. com.) +- Indemnité forfaitaire recouvrement 40€ +- Coordonnées bancaires + +## Sauvegarde + +La base de données est le fichier `factures.db`. Il suffit de le copier régulièrement. + +```bash +# Exemple cron quotidien +0 2 * * * cp /opt/facturation/factures.db /backup/factures-$(date +\%Y\%m\%d).db +``` diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..318837c --- /dev/null +++ b/auth.py @@ -0,0 +1,73 @@ +import bcrypt +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired +from fastapi import Request, HTTPException, Depends +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session + +from config import settings +from database import get_db + + +# ── Serializer de session ────────────────────────────────────────────────────── + +_serializer = URLSafeTimedSerializer(settings.secret_key) + +COOKIE_NAME = "session" + + +def creer_session(user_id: int) -> str: + return _serializer.dumps({"user_id": user_id}) + + +def lire_session(token: str) -> int | None: + try: + data = _serializer.loads(token, max_age=settings.session_max_age) + return data["user_id"] + except (BadSignature, SignatureExpired, KeyError): + return None + + +# ── Mots de passe ────────────────────────────────────────────────────────────── + +def hasher_mot_de_passe(mdp: str) -> str: + return bcrypt.hashpw(mdp.encode(), bcrypt.gensalt()).decode() + + +def verifier_mot_de_passe(mdp: str, hash_: str) -> bool: + return bcrypt.checkpw(mdp.encode(), hash_.encode()) + + +# ── Dépendances FastAPI ──────────────────────────────────────────────────────── + +def get_current_user(request: Request, db: Session = Depends(get_db)): + """Retourne l'utilisateur connecté ou lève une redirection vers /login.""" + from models import User + token = request.cookies.get(COOKIE_NAME) + if not token: + raise HTTPException(status_code=302, headers={"Location": "/login"}) + user_id = lire_session(token) + if not user_id: + raise HTTPException(status_code=302, headers={"Location": "/login"}) + user = db.query(User).filter(User.id == user_id, User.actif == True).first() + if not user: + raise HTTPException(status_code=302, headers={"Location": "/login"}) + return user + + +def get_current_admin(user=Depends(get_current_user)): + """Retourne l'utilisateur uniquement s'il est admin.""" + if not user.is_admin: + raise HTTPException(status_code=403, detail="Accès réservé aux administrateurs.") + return user + + +def redirect_if_not_logged(request: Request, db: Session): + """Variante utilisable hors Depends pour les routes avec gestion manuelle.""" + from models import User + token = request.cookies.get(COOKIE_NAME) + if not token: + return None + user_id = lire_session(token) + if not user_id: + return None + return db.query(User).filter(User.id == user_id, User.actif == True).first() diff --git a/config.py b/config.py new file mode 100644 index 0000000..a16997c --- /dev/null +++ b/config.py @@ -0,0 +1,33 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + app_name: str = "Facturation Association" + database_url: str = "sqlite:///./factures.db" + + # Informations de l'association (modifiables via .env) + asso_nom: str = "Mon Association" + asso_adresse: str = "1 rue de la Paix" + asso_code_postal: str = "75001" + asso_ville: str = "Paris" + asso_email: str = "contact@association.fr" + asso_telephone: str = "" + asso_siret: str = "" + asso_rna: str = "" # Numéro RNA (W...) + asso_objet: str = "" + asso_iban: str = "" + asso_bic: str = "" + + # Numérotation + facture_prefix: str = "" # vide = format AAAA-XXXX + devis_prefix: str = "DEV" + + # Sécurité sessions (générer avec: python3 -c "import secrets; print(secrets.token_hex(32))") + secret_key: str = "changez-moi-en-production" + session_max_age: int = 86400 * 7 # 7 jours + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/database.py b/database.py new file mode 100644 index 0000000..9bc925f --- /dev/null +++ b/database.py @@ -0,0 +1,26 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +from config import settings + +engine = create_engine( + settings.database_url, + connect_args={"check_same_thread": False} # nécessaire pour SQLite +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + from models import Client, Devis, Facture, LigneDevis, LigneFacture # noqa + Base.metadata.create_all(bind=engine) diff --git a/main.py b/main.py new file mode 100644 index 0000000..9da4463 --- /dev/null +++ b/main.py @@ -0,0 +1,111 @@ +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from fastapi.exceptions import HTTPException +from starlette.middleware.base import BaseHTTPMiddleware + +from database import init_db +from routers import clients, devis, factures +from routers import auth as auth_router +from config import settings + +app = FastAPI(title=settings.app_name) + +app.mount("/static", StaticFiles(directory="static"), name="static") +templates = Jinja2Templates(directory="templates") + +app.include_router(auth_router.router) +app.include_router(clients.router) +app.include_router(devis.router) +app.include_router(factures.router) + + +# ── Middleware : current_user dans request.state ─────────────────────────────── + +class AuthMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + from database import SessionLocal + from auth import redirect_if_not_logged + db = SessionLocal() + try: + request.state.current_user = redirect_if_not_logged(request, db) + except Exception: + request.state.current_user = None + finally: + db.close() + try: + return await call_next(request) + except Exception: + request.state.current_user = None + raise + +app.add_middleware(AuthMiddleware) + + +# ── Startup ──────────────────────────────────────────────────────────────────── + +@app.on_event("startup") +def startup(): + init_db() + _creer_admin_si_absent() + + +def _creer_admin_si_absent(): + from database import SessionLocal + from models import User + from auth import hasher_mot_de_passe + db = SessionLocal() + try: + if db.query(User).count() == 0: + admin = User( + username="admin", + hashed_password=hasher_mot_de_passe("admin"), + is_admin=True, + ) + db.add(admin) + db.commit() + print("⚠️ Compte admin créé : admin / admin — À changer immédiatement !") + finally: + db.close() + + +# ── Gestion erreurs ──────────────────────────────────────────────────────────── + +@app.exception_handler(HTTPException) +async def http_exception_handler(request: Request, exc: HTTPException): + if exc.status_code == 302: + return RedirectResponse(url=exc.headers["Location"]) + return templates.TemplateResponse("erreur.html", { + "request": request, + "status_code": exc.status_code, + "detail": exc.detail, + "current_user": getattr(request.state, "current_user", None), + }, status_code=exc.status_code) + + +@app.get("/", response_class=HTMLResponse) +def accueil(): + return RedirectResponse("/factures/") + + +# ── Filtres Jinja2 ───────────────────────────────────────────────────────────── + +def format_montant(valeur): + if valeur is None: + return "0,00 €" + return f"{valeur:,.2f} €".replace(",", " ").replace(".", ",") + + +def format_date_fr(d): + if d is None: + return "" + mois = ["janvier", "février", "mars", "avril", "mai", "juin", + "juillet", "août", "septembre", "octobre", "novembre", "décembre"] + return f"{d.day} {mois[d.month - 1]} {d.year}" + + +for tpl in [templates, clients.templates, devis.templates, + factures.templates, auth_router.templates]: + tpl.env.filters["montant"] = format_montant + tpl.env.filters["date_fr"] = format_date_fr diff --git a/models.py b/models.py new file mode 100644 index 0000000..0db86e5 --- /dev/null +++ b/models.py @@ -0,0 +1,131 @@ +from datetime import date +from sqlalchemy import ( + Column, Integer, String, Float, Date, Boolean, + ForeignKey, Enum, Text +) +from sqlalchemy.orm import relationship +import enum + +from database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String(50), unique=True, nullable=False, index=True) + email = Column(String(200)) + hashed_password = Column(String(200), nullable=False) + is_admin = Column(Boolean, default=False) + actif = Column(Boolean, default=True) + created_at = Column(Date, default=date.today) + + +class StatutDevis(str, enum.Enum): + brouillon = "brouillon" + envoye = "envoye" + accepte = "accepte" + refuse = "refuse" + + +class StatutFacture(str, enum.Enum): + emise = "emise" + payee = "payee" + annulee = "annulee" + + +class Client(Base): + __tablename__ = "clients" + + id = Column(Integer, primary_key=True, index=True) + nom = Column(String(200), nullable=False) + adresse = Column(String(300), nullable=False) + code_postal = Column(String(10), nullable=False) + ville = Column(String(100), nullable=False) + email = Column(String(200)) + telephone = Column(String(20)) + siret = Column(String(14)) + actif = Column(Boolean, default=True) + + devis = relationship("Devis", back_populates="client") + factures = relationship("Facture", back_populates="client") + + +class LigneDevis(Base): + __tablename__ = "lignes_devis" + + id = Column(Integer, primary_key=True, index=True) + devis_id = Column(Integer, ForeignKey("devis.id"), nullable=False) + description = Column(Text, nullable=False) + quantite = Column(Float, nullable=False, default=1.0) + prix_unitaire_ht = Column(Float, nullable=False) + ordre = Column(Integer, default=0) + + devis = relationship("Devis", back_populates="lignes") + + @property + def total_ht(self): + return self.quantite * self.prix_unitaire_ht + + +class Devis(Base): + __tablename__ = "devis" + + id = Column(Integer, primary_key=True, index=True) + numero = Column(String(20), unique=True, nullable=False) + date_emission = Column(Date, nullable=False, default=date.today) + date_validite = Column(Date, nullable=False) + client_id = Column(Integer, ForeignKey("clients.id"), nullable=False) + statut = Column(Enum(StatutDevis), default=StatutDevis.brouillon) + notes = Column(Text) + conditions = Column(Text) + + client = relationship("Client", back_populates="devis") + lignes = relationship("LigneDevis", back_populates="devis", + order_by="LigneDevis.ordre", cascade="all, delete-orphan") + factures = relationship("Facture", back_populates="devis_origine") + + @property + def total_ht(self): + return sum(l.total_ht for l in self.lignes) + + +class LigneFacture(Base): + __tablename__ = "lignes_factures" + + id = Column(Integer, primary_key=True, index=True) + facture_id = Column(Integer, ForeignKey("factures.id"), nullable=False) + description = Column(Text, nullable=False) + quantite = Column(Float, nullable=False, default=1.0) + prix_unitaire_ht = Column(Float, nullable=False) + ordre = Column(Integer, default=0) + + facture = relationship("Facture", back_populates="lignes") + + @property + def total_ht(self): + return self.quantite * self.prix_unitaire_ht + + +class Facture(Base): + __tablename__ = "factures" + + id = Column(Integer, primary_key=True, index=True) + numero = Column(String(20), unique=True, nullable=False) + date_emission = Column(Date, nullable=False, default=date.today) + date_echeance = Column(Date, nullable=False) + client_id = Column(Integer, ForeignKey("clients.id"), nullable=False) + devis_id = Column(Integer, ForeignKey("devis.id"), nullable=True) + statut = Column(Enum(StatutFacture), default=StatutFacture.emise) + notes = Column(Text) + conditions_reglement = Column(Text, default="Paiement à réception de facture.") + date_paiement = Column(Date, nullable=True) + + client = relationship("Client", back_populates="factures") + devis_origine = relationship("Devis", back_populates="factures") + lignes = relationship("LigneFacture", back_populates="facture", + order_by="LigneFacture.ordre", cascade="all, delete-orphan") + + @property + def total_ht(self): + return sum(l.total_ht for l in self.lignes) diff --git a/numerotation.py b/numerotation.py new file mode 100644 index 0000000..7834b88 --- /dev/null +++ b/numerotation.py @@ -0,0 +1,44 @@ +from datetime import date +from sqlalchemy.orm import Session + + +def generer_numero_facture(db: Session) -> str: + from models import Facture + annee = date.today().year + # Cherche le dernier numéro de l'année en cours + factures = db.query(Facture).filter( + Facture.numero.like(f"{annee}-%") + ).all() + if not factures: + seq = 1 + else: + sequences = [] + for f in factures: + try: + sequences.append(int(f.numero.split("-")[-1])) + except (ValueError, IndexError): + pass + seq = max(sequences) + 1 if sequences else 1 + return f"{annee}-{seq:04d}" + + +def generer_numero_devis(db: Session) -> str: + from models import Devis + from config import settings + annee = date.today().year + prefix = settings.devis_prefix + pattern = f"{prefix}-{annee}-%" + devis = db.query(Devis).filter( + Devis.numero.like(pattern) + ).all() + if not devis: + seq = 1 + else: + sequences = [] + for d in devis: + try: + sequences.append(int(d.numero.split("-")[-1])) + except (ValueError, IndexError): + pass + seq = max(sequences) + 1 if sequences else 1 + return f"{prefix}-{annee}-{seq:04d}" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5f509cc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +sqlalchemy==2.0.35 +jinja2==3.1.4 +python-multipart==0.0.12 +weasyprint>=63.0 +pydantic==2.9.2 +pydantic-settings==2.5.2 +bcrypt==4.2.0 +itsdangerous==2.2.0 diff --git a/routers/auth.py b/routers/auth.py new file mode 100644 index 0000000..63de437 --- /dev/null +++ b/routers/auth.py @@ -0,0 +1,162 @@ +from template_helper import render +from fastapi import APIRouter, Depends, Request, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse, Response +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from database import get_db +from models import User +from auth import ( + hasher_mot_de_passe, verifier_mot_de_passe, + creer_session, get_current_admin, COOKIE_NAME +) +from config import settings + +router = APIRouter(tags=["auth"]) +templates = Jinja2Templates(directory="templates") + + +# ── Login / Logout ───────────────────────────────────────────────────────────── + +@router.get("/login", response_class=HTMLResponse) +def login_form(request: Request): + return render(templates, "auth/login.html", request, { + "erreur": None + }) + + +@router.post("/login") +def login( + request: Request, + username: str = Form(...), + password: str = Form(...), + db: Session = Depends(get_db) +): + user = db.query(User).filter( + User.username == username, User.actif == True + ).first() + + if not user or not verifier_mot_de_passe(password, user.hashed_password): + return render(templates, "auth/login.html", request, { + "erreur": "Identifiants incorrects." + }, status_code=401) + + token = creer_session(user.id) + response = RedirectResponse("/factures/", status_code=303) + response.set_cookie( + key=COOKIE_NAME, + value=token, + max_age=settings.session_max_age, + httponly=True, + samesite="lax", + secure=False # Passer à True si HTTPS (recommandé en prod) + ) + return response + + +@router.post("/logout") +def logout(): + response = RedirectResponse("/login", status_code=303) + response.delete_cookie(COOKIE_NAME) + return response + + +# ── Gestion utilisateurs (admin) ─────────────────────────────────────────────── + +@router.get("/admin/utilisateurs", response_class=HTMLResponse) +def liste_utilisateurs( + request: Request, + db: Session = Depends(get_db), + current_user=Depends(get_current_admin) +): + users = db.query(User).order_by(User.username).all() + return render(templates, "auth/utilisateurs.html", request, { + "users": users, "current_user": current_user + }) + + +@router.get("/admin/utilisateurs/nouveau", response_class=HTMLResponse) +def nouveau_user_form(request: Request, current_user=Depends(get_current_admin)): + return render(templates, "auth/user_form.html", request, { + "user": None, + "titre": "Nouvel utilisateur", "current_user": current_user + }) + + +@router.post("/admin/utilisateurs/nouveau") +def creer_user( + username: str = Form(...), + email: str = Form(""), + password: str = Form(...), + is_admin: bool = Form(False), + db: Session = Depends(get_db), + current_user=Depends(get_current_admin) +): + if db.query(User).filter(User.username == username).first(): + raise HTTPException(status_code=400, detail="Ce nom d'utilisateur existe déjà.") + user = User( + username=username, + email=email, + hashed_password=hasher_mot_de_passe(password), + is_admin=is_admin + ) + db.add(user) + db.commit() + return RedirectResponse("/admin/utilisateurs", status_code=303) + + +@router.get("/admin/utilisateurs/{user_id}/modifier", response_class=HTMLResponse) +def modifier_user_form( + request: Request, + user_id: int, + db: Session = Depends(get_db), + current_user=Depends(get_current_admin) +): + user = db.query(User).get(user_id) + if not user: + raise HTTPException(status_code=404) + return render(templates, "auth/user_form.html", request, { + "user": user, + "titre": "Modifier l'utilisateur", "current_user": current_user + }) + + +@router.post("/admin/utilisateurs/{user_id}/modifier") +def modifier_user( + user_id: int, + username: str = Form(...), + email: str = Form(""), + password: str = Form(""), + is_admin: bool = Form(False), + db: Session = Depends(get_db), + current_user=Depends(get_current_admin) +): + user = db.query(User).get(user_id) + if not user: + raise HTTPException(status_code=404) + # Empêcher de se retirer les droits admin à soi-même + if user.id == current_user.id and not is_admin: + raise HTTPException(status_code=400, detail="Vous ne pouvez pas retirer vos propres droits admin.") + user.username = username + user.email = email + user.is_admin = is_admin + if password: + user.hashed_password = hasher_mot_de_passe(password) + db.commit() + return RedirectResponse("/admin/utilisateurs", status_code=303) + + +@router.post("/admin/utilisateurs/{user_id}/desactiver") +def desactiver_user( + user_id: int, + db: Session = Depends(get_db), + current_user=Depends(get_current_admin) +): + user = db.query(User).get(user_id) + if not user: + raise HTTPException(status_code=404) + if user.id == current_user.id: + raise HTTPException(status_code=400, detail="Impossible de se désactiver soi-même.") + user.actif = not user.actif + db.commit() + return RedirectResponse("/admin/utilisateurs", status_code=303) diff --git a/routers/clients.py b/routers/clients.py new file mode 100644 index 0000000..48360e3 --- /dev/null +++ b/routers/clients.py @@ -0,0 +1,95 @@ +from fastapi import APIRouter, Depends, Request, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from database import get_db +from models import Client +from auth import get_current_user +from template_helper import render + +router = APIRouter(prefix="/clients", tags=["clients"], + dependencies=[Depends(get_current_user)]) +templates = Jinja2Templates(directory="templates") + + +@router.get("/", response_class=HTMLResponse) +def liste_clients(request: Request, db: Session = Depends(get_db)): + clients = db.query(Client).filter(Client.actif == True).order_by(Client.nom).all() + return render(templates, "clients/liste.html", request, { + "clients": clients + }) + + +@router.get("/nouveau", response_class=HTMLResponse) +def nouveau_client_form(request: Request): + return render(templates, "clients/form.html", request, { + "client": None, "titre": "Nouveau client" + }) + + +@router.post("/nouveau") +def creer_client( + request: Request, + nom: str = Form(...), + adresse: str = Form(...), + code_postal: str = Form(...), + ville: str = Form(...), + email: str = Form(""), + telephone: str = Form(""), + siret: str = Form(""), + db: Session = Depends(get_db) +): + client = Client( + nom=nom, adresse=adresse, code_postal=code_postal, + ville=ville, email=email, telephone=telephone, siret=siret + ) + db.add(client) + db.commit() + return RedirectResponse("/clients/", status_code=303) + + +@router.get("/{client_id}/modifier", response_class=HTMLResponse) +def modifier_client_form(request: Request, client_id: int, db: Session = Depends(get_db)): + client = db.query(Client).get(client_id) + if not client: + raise HTTPException(status_code=404) + return render(templates, "clients/form.html", request, { + "client": client, "titre": "Modifier le client" + }) + + +@router.post("/{client_id}/modifier") +def modifier_client( + client_id: int, + nom: str = Form(...), + adresse: str = Form(...), + code_postal: str = Form(...), + ville: str = Form(...), + email: str = Form(""), + telephone: str = Form(""), + siret: str = Form(""), + db: Session = Depends(get_db) +): + client = db.query(Client).get(client_id) + if not client: + raise HTTPException(status_code=404) + client.nom = nom + client.adresse = adresse + client.code_postal = code_postal + client.ville = ville + client.email = email + client.telephone = telephone + client.siret = siret + db.commit() + return RedirectResponse("/clients/", status_code=303) + + +@router.post("/{client_id}/supprimer") +def supprimer_client(client_id: int, db: Session = Depends(get_db)): + client = db.query(Client).get(client_id) + if not client: + raise HTTPException(status_code=404) + client.actif = False + db.commit() + return RedirectResponse("/clients/", status_code=303) diff --git a/routers/devis.py b/routers/devis.py new file mode 100644 index 0000000..514cac0 --- /dev/null +++ b/routers/devis.py @@ -0,0 +1,186 @@ +import json +from datetime import date, timedelta +from fastapi import APIRouter, Depends, Request, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from database import get_db +from models import Devis, LigneDevis, Client, StatutDevis +from numerotation import generer_numero_devis +from auth import get_current_user +from template_helper import render + +router = APIRouter(prefix="/devis", tags=["devis"], dependencies=[Depends(get_current_user)]) +templates = Jinja2Templates(directory="templates") + + +@router.get("/", response_class=HTMLResponse) +def liste_devis(request: Request, db: Session = Depends(get_db)): + devis = db.query(Devis).order_by(Devis.date_emission.desc()).all() + return render(templates, "devis/liste.html", request, { + "devis": devis + }) + + +@router.get("/nouveau", response_class=HTMLResponse) +def nouveau_devis_form(request: Request, db: Session = Depends(get_db)): + clients = db.query(Client).filter(Client.actif == True).order_by(Client.nom).all() + return render(templates, "devis/form.html", request, { + "devis": None, + "clients": clients, + "titre": "Nouveau devis", + "date_aujourd_hui": date.today().isoformat(), + "date_validite_defaut": (date.today() + timedelta(days=30)).isoformat(), + }) + + +@router.post("/nouveau") +def creer_devis( + request: Request, + client_id: int = Form(...), + date_emission: str = Form(...), + date_validite: str = Form(...), + notes: str = Form(""), + conditions: str = Form(""), + lignes_json: str = Form(...), + db: Session = Depends(get_db) +): + numero = generer_numero_devis(db) + devis = Devis( + numero=numero, + client_id=client_id, + date_emission=date.fromisoformat(date_emission), + date_validite=date.fromisoformat(date_validite), + notes=notes, + conditions=conditions, + ) + db.add(devis) + db.flush() + + lignes = json.loads(lignes_json) + for i, l in enumerate(lignes): + ligne = LigneDevis( + devis_id=devis.id, + description=l["description"], + quantite=float(l["quantite"]), + prix_unitaire_ht=float(l["prix_unitaire_ht"]), + ordre=i + ) + db.add(ligne) + + db.commit() + return RedirectResponse(f"/devis/{devis.id}", status_code=303) + + +@router.get("/{devis_id}", response_class=HTMLResponse) +def voir_devis(request: Request, devis_id: int, db: Session = Depends(get_db)): + devis = db.query(Devis).get(devis_id) + if not devis: + raise HTTPException(status_code=404) + return render(templates, "devis/detail.html", request, { + "devis": devis, "StatutDevis": StatutDevis + }) + + +@router.get("/{devis_id}/modifier", response_class=HTMLResponse) +def modifier_devis_form(request: Request, devis_id: int, db: Session = Depends(get_db)): + devis = db.query(Devis).get(devis_id) + if not devis: + raise HTTPException(status_code=404) + clients = db.query(Client).filter(Client.actif == True).order_by(Client.nom).all() + return render(templates, "devis/form.html", request, { + "devis": devis, + "clients": clients, + "titre": "Modifier le devis", + "date_aujourd_hui": date.today().isoformat(), + "date_validite_defaut": devis.date_validite.isoformat(), + }) + + +@router.post("/{devis_id}/modifier") +def modifier_devis( + devis_id: int, + client_id: int = Form(...), + date_emission: str = Form(...), + date_validite: str = Form(...), + notes: str = Form(""), + conditions: str = Form(""), + lignes_json: str = Form(...), + db: Session = Depends(get_db) +): + devis = db.query(Devis).get(devis_id) + if not devis: + raise HTTPException(status_code=404) + devis.client_id = client_id + devis.date_emission = date.fromisoformat(date_emission) + devis.date_validite = date.fromisoformat(date_validite) + devis.notes = notes + devis.conditions = conditions + + for ligne in devis.lignes: + db.delete(ligne) + db.flush() + + lignes = json.loads(lignes_json) + for i, l in enumerate(lignes): + ligne = LigneDevis( + devis_id=devis.id, + description=l["description"], + quantite=float(l["quantite"]), + prix_unitaire_ht=float(l["prix_unitaire_ht"]), + ordre=i + ) + db.add(ligne) + + db.commit() + return RedirectResponse(f"/devis/{devis_id}", status_code=303) + + +@router.post("/{devis_id}/statut") +def changer_statut_devis( + devis_id: int, + statut: str = Form(...), + db: Session = Depends(get_db) +): + devis = db.query(Devis).get(devis_id) + if not devis: + raise HTTPException(status_code=404) + devis.statut = StatutDevis(statut) + db.commit() + return RedirectResponse(f"/devis/{devis_id}", status_code=303) + + +@router.post("/{devis_id}/convertir") +def convertir_en_facture(devis_id: int, db: Session = Depends(get_db)): + from models import Facture, LigneFacture + from numerotation import generer_numero_facture + devis = db.query(Devis).get(devis_id) + if not devis: + raise HTTPException(status_code=404) + + numero = generer_numero_facture(db) + facture = Facture( + numero=numero, + client_id=devis.client_id, + devis_id=devis.id, + date_emission=date.today(), + date_echeance=date.today() + timedelta(days=30), + notes=devis.notes, + ) + db.add(facture) + db.flush() + + for i, l in enumerate(devis.lignes): + ligne = LigneFacture( + facture_id=facture.id, + description=l.description, + quantite=l.quantite, + prix_unitaire_ht=l.prix_unitaire_ht, + ordre=i + ) + db.add(ligne) + + devis.statut = StatutDevis.accepte + db.commit() + return RedirectResponse(f"/factures/{facture.id}", status_code=303) diff --git a/routers/factures.py b/routers/factures.py new file mode 100644 index 0000000..ca010f9 --- /dev/null +++ b/routers/factures.py @@ -0,0 +1,189 @@ +import json +from datetime import date, timedelta +from fastapi import APIRouter, Depends, Request, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse, Response +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from database import get_db +from models import Facture, LigneFacture, Client, StatutFacture +from numerotation import generer_numero_facture +from config import settings +from auth import get_current_user +from template_helper import render + +router = APIRouter(prefix="/factures", tags=["factures"], dependencies=[Depends(get_current_user)]) +templates = Jinja2Templates(directory="templates") + + +@router.get("/", response_class=HTMLResponse) +def liste_factures(request: Request, db: Session = Depends(get_db)): + factures = db.query(Facture).order_by(Facture.date_emission.desc()).all() + return render(templates, "factures/liste.html", request, { + "factures": factures + }) + + +@router.get("/nouvelle", response_class=HTMLResponse) +def nouvelle_facture_form(request: Request, db: Session = Depends(get_db)): + clients = db.query(Client).filter(Client.actif == True).order_by(Client.nom).all() + return render(templates, "factures/form.html", request, { + "facture": None, + "clients": clients, + "titre": "Nouvelle facture", + "date_aujourd_hui": date.today().isoformat(), + "date_echeance_defaut": (date.today() + timedelta(days=30)).isoformat(), + }) + + +@router.post("/nouvelle") +def creer_facture( + client_id: int = Form(...), + date_emission: str = Form(...), + date_echeance: str = Form(...), + notes: str = Form(""), + conditions_reglement: str = Form("Paiement à réception de facture."), + lignes_json: str = Form(...), + db: Session = Depends(get_db) +): + numero = generer_numero_facture(db) + facture = Facture( + numero=numero, + client_id=client_id, + date_emission=date.fromisoformat(date_emission), + date_echeance=date.fromisoformat(date_echeance), + notes=notes, + conditions_reglement=conditions_reglement, + ) + db.add(facture) + db.flush() + + lignes = json.loads(lignes_json) + for i, l in enumerate(lignes): + ligne = LigneFacture( + facture_id=facture.id, + description=l["description"], + quantite=float(l["quantite"]), + prix_unitaire_ht=float(l["prix_unitaire_ht"]), + ordre=i + ) + db.add(ligne) + + db.commit() + return RedirectResponse(f"/factures/{facture.id}", status_code=303) + + +@router.get("/{facture_id}", response_class=HTMLResponse) +def voir_facture(request: Request, facture_id: int, db: Session = Depends(get_db)): + facture = db.query(Facture).get(facture_id) + if not facture: + raise HTTPException(status_code=404) + return render(templates, "factures/detail.html", request, { + "facture": facture, "StatutFacture": StatutFacture + }) + + +@router.get("/{facture_id}/modifier", response_class=HTMLResponse) +def modifier_facture_form(request: Request, facture_id: int, db: Session = Depends(get_db)): + facture = db.query(Facture).get(facture_id) + if not facture: + raise HTTPException(status_code=404) + if facture.statut != StatutFacture.emise: + raise HTTPException(status_code=400, detail="Seules les factures émises peuvent être modifiées.") + clients = db.query(Client).filter(Client.actif == True).order_by(Client.nom).all() + return render(templates, "factures/form.html", request, { + "facture": facture, + "clients": clients, + "titre": "Modifier la facture", + "date_aujourd_hui": facture.date_emission.isoformat(), + "date_echeance_defaut": facture.date_echeance.isoformat(), + }) + + +@router.post("/{facture_id}/modifier") +def modifier_facture( + facture_id: int, + client_id: int = Form(...), + date_emission: str = Form(...), + date_echeance: str = Form(...), + notes: str = Form(""), + conditions_reglement: str = Form(""), + lignes_json: str = Form(...), + db: Session = Depends(get_db) +): + facture = db.query(Facture).get(facture_id) + if not facture: + raise HTTPException(status_code=404) + facture.client_id = client_id + facture.date_emission = date.fromisoformat(date_emission) + facture.date_echeance = date.fromisoformat(date_echeance) + facture.notes = notes + facture.conditions_reglement = conditions_reglement + + for ligne in facture.lignes: + db.delete(ligne) + db.flush() + + lignes = json.loads(lignes_json) + for i, l in enumerate(lignes): + ligne = LigneFacture( + facture_id=facture.id, + description=l["description"], + quantite=float(l["quantite"]), + prix_unitaire_ht=float(l["prix_unitaire_ht"]), + ordre=i + ) + db.add(ligne) + + db.commit() + return RedirectResponse(f"/factures/{facture_id}", status_code=303) + + +@router.post("/{facture_id}/statut") +def changer_statut_facture( + facture_id: int, + statut: str = Form(...), + date_paiement: str = Form(""), + db: Session = Depends(get_db) +): + facture = db.query(Facture).get(facture_id) + if not facture: + raise HTTPException(status_code=404) + facture.statut = StatutFacture(statut) + if statut == "payee" and date_paiement: + facture.date_paiement = date.fromisoformat(date_paiement) + db.commit() + return RedirectResponse(f"/factures/{facture_id}", status_code=303) + + +@router.get("/{facture_id}/pdf") +def telecharger_pdf(facture_id: int, db: Session = Depends(get_db)): + from weasyprint import HTML + facture = db.query(Facture).get(facture_id) + if not facture: + raise HTTPException(status_code=404) + + html_content = templates.get_template("pdf/facture.html").render({ + "facture": facture, + "settings": settings, + }) + + pdf_bytes = HTML(string=html_content, base_url=".").write_pdf() + + filename = f"facture-{facture.numero}.pdf" + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'} + ) + + +@router.get("/{facture_id}/apercu-pdf", response_class=HTMLResponse) +def apercu_pdf(request: Request, facture_id: int, db: Session = Depends(get_db)): + facture = db.query(Facture).get(facture_id) + if not facture: + raise HTTPException(status_code=404) + return render(templates, "pdf/facture.html", request, { + "facture": facture, + "settings": settings, + }) diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..27558d1 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,192 @@ +:root { + --primary: #2c3e50; + --accent: #3498db; + --danger: #e74c3c; + --success: #27ae60; + --warning: #f39c12; + --bg: #f5f6fa; + --card: #ffffff; + --border: #e0e0e0; + --text: #2c3e50; + --muted: #7f8c8d; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--bg); + color: var(--text); + font-size: 14px; + line-height: 1.5; +} + +/* NAV */ +nav { + background: var(--primary); + color: white; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 2rem; + height: 56px; + position: sticky; + top: 0; + z-index: 100; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); +} +.nav-brand { font-size: 1.1rem; font-weight: 700; letter-spacing: 0.5px; } +.nav-links { list-style: none; display: flex; gap: 0.5rem; } +.nav-links a { + color: rgba(255,255,255,0.85); + text-decoration: none; + padding: 0.4em 0.9em; + border-radius: 4px; + transition: background 0.15s; +} +.nav-links a:hover { background: rgba(255,255,255,0.15); color: white; } + +/* MAIN */ +main { max-width: 1000px; margin: 2rem auto; padding: 0 1.5rem; } + +/* PAGE HEADER */ +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; + flex-wrap: wrap; + gap: 0.5rem; +} +.page-header h1 { font-size: 1.6rem; color: var(--primary); } +.btn-group { display: flex; gap: 0.5rem; flex-wrap: wrap; } + +/* BUTTONS */ +.btn { + display: inline-block; + padding: 0.45em 1em; + border-radius: 5px; + border: 1px solid var(--border); + background: white; + color: var(--text); + font-size: 0.875rem; + cursor: pointer; + text-decoration: none; + transition: all 0.15s; + white-space: nowrap; +} +.btn:hover { background: var(--bg); border-color: #bbb; } +.btn-primary { background: var(--accent); border-color: var(--accent); color: white; } +.btn-primary:hover { background: #2980b9; border-color: #2980b9; } +.btn-danger { background: var(--danger); border-color: var(--danger); color: white; } +.btn-danger:hover { background: #c0392b; } +.btn-sm { padding: 0.25em 0.7em; font-size: 0.8rem; } + +/* TABLE */ +table { width: 100%; border-collapse: collapse; background: white; + border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,0.07); } +thead { background: var(--primary); color: white; } +th { padding: 0.75em 1em; text-align: left; font-weight: 600; font-size: 0.85rem; } +td { padding: 0.75em 1em; border-bottom: 1px solid var(--border); } +tr:last-child td { border-bottom: none; } +tbody tr:hover { background: #f8fafc; } +tfoot td { background: #f8fafc; font-size: 0.9rem; } +.tva-mention { font-size: 0.8rem; color: var(--muted); font-style: italic; } + +/* BADGES */ +.badge { + display: inline-block; + padding: 0.2em 0.7em; + border-radius: 20px; + font-size: 0.78rem; + font-weight: 600; + text-transform: capitalize; +} +.badge-emise { background: #dbeafe; color: #1e40af; } +.badge-payee { background: #dcfce7; color: #166534; } +.badge-annulee { background: #fee2e2; color: #991b1b; } +.badge-brouillon { background: #f3f4f6; color: #374151; } +.badge-envoye { background: #fef9c3; color: #854d0e; } +.badge-accepte { background: #dcfce7; color: #166534; } +.badge-refuse { background: #fee2e2; color: #991b1b; } + +/* CARDS */ +.form-card, .detail-card { + background: white; + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 1px 4px rgba(0,0,0,0.07); + margin-bottom: 1.5rem; +} +.form-card h2 { font-size: 1.1rem; margin-bottom: 1rem; color: var(--primary); + border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; } + +/* FORM */ +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 1.5rem; +} +.form-group { display: flex; flex-direction: column; gap: 0.3rem; } +.form-group.full { grid-column: 1 / -1; } +label { font-size: 0.82rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.3px; } +input[type="text"], input[type="email"], input[type="date"], input[type="number"], +select, textarea { + padding: 0.5em 0.75em; + border: 1px solid var(--border); + border-radius: 5px; + font-size: 0.9rem; + font-family: inherit; + transition: border-color 0.15s; + background: white; +} +input:focus, select:focus, textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(52,152,219,0.15); +} +textarea { resize: vertical; } +.form-actions { display: flex; gap: 0.75rem; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border); } + +/* TABLE LIGNES (formulaire) */ +#table-lignes { margin-bottom: 1rem; } +#table-lignes thead { background: #f1f5f9; } +#table-lignes th { color: var(--primary); } +#table-lignes .ligne-desc { width: 100%; min-width: 200px; } +#table-lignes .ligne-qte { width: 70px; } +#table-lignes .ligne-pu { width: 110px; } +.ligne-total { text-align: right; font-weight: 500; } + +.total-bloc { + text-align: right; + padding: 0.75rem 1rem; + background: #f8fafc; + border-radius: 5px; + margin: 1rem 0; + font-size: 1rem; +} +.total-bloc small { color: var(--muted); font-style: italic; } + +/* DETAIL */ +.detail-meta { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + margin-bottom: 1.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--border); +} +.detail-meta label { font-size: 0.78rem; font-weight: 700; text-transform: uppercase; + color: var(--muted); display: block; margin-bottom: 0.4rem; } + +/* MISC */ +.empty-state { color: var(--muted); padding: 2rem; text-align: center; background: white; + border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.07); } +.empty-state a { color: var(--accent); } + +@media (max-width: 640px) { + .form-grid { grid-template-columns: 1fr; } + .detail-meta { grid-template-columns: 1fr; } + .page-header { flex-direction: column; align-items: flex-start; } +} diff --git a/template_helper.py b/template_helper.py new file mode 100644 index 0000000..3dd3c16 --- /dev/null +++ b/template_helper.py @@ -0,0 +1,19 @@ +""" +Helper pour injecter automatiquement current_user dans le contexte Jinja2. +""" +from fastapi import Request +from fastapi.templating import Jinja2Templates +from starlette.responses import HTMLResponse + + +def render(templates: Jinja2Templates, template_name: str, + request: Request, context: dict = None, + status_code: int = 200) -> HTMLResponse: + ctx = context or {} + ctx["request"] = request + if "current_user" not in ctx: + ctx["current_user"] = getattr(request.state, "current_user", None) + # Compatible avec toutes les versions de Starlette/FastAPI + response = templates.TemplateResponse(template_name, ctx) + response.status_code = status_code + return response diff --git a/templates/auth/login.html b/templates/auth/login.html new file mode 100644 index 0000000..c8eeefe --- /dev/null +++ b/templates/auth/login.html @@ -0,0 +1,62 @@ + + + + + + Connexion — Facturation + + + + +
+ +

Facturation

+

Connectez-vous pour continuer

+ + {% if erreur %} +
{{ erreur }}
+ {% endif %} + +
+
+ + +
+
+ + +
+ +
+
+ + diff --git a/templates/auth/user_form.html b/templates/auth/user_form.html new file mode 100644 index 0000000..214f07a --- /dev/null +++ b/templates/auth/user_form.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}{{ titre }}{% endblock %} +{% block content %} + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + Annuler +
+
+{% endblock %} diff --git a/templates/auth/utilisateurs.html b/templates/auth/utilisateurs.html new file mode 100644 index 0000000..bafc32c --- /dev/null +++ b/templates/auth/utilisateurs.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}Utilisateurs{% endblock %} +{% block content %} + + + + + + + + + + + + + + + {% for u in users %} + + + + + + + + + {% endfor %} + +
Nom d'utilisateurEmailRôleStatutCréé leActions
+ {{ u.username }} + {% if u.id == current_user.id %}(vous){% endif %} + {{ u.email or "—" }} + {% if u.is_admin %} + Admin + {% else %} + Utilisateur + {% endif %} + + {% if u.actif %} + Actif + {% else %} + Inactif + {% endif %} + {{ u.created_at | date_fr }} + Modifier + {% if u.id != current_user.id %} +
+ +
+ {% endif %} +
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..ca82ae7 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,33 @@ + + + + + + {% block title %}Facturation{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/templates/clients/form.html b/templates/clients/form.html new file mode 100644 index 0000000..2ab2fa8 --- /dev/null +++ b/templates/clients/form.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}{{ titre }}{% endblock %} +{% block content %} + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + Annuler +
+
+{% endblock %} diff --git a/templates/clients/liste.html b/templates/clients/liste.html new file mode 100644 index 0000000..07b59af --- /dev/null +++ b/templates/clients/liste.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}Clients{% endblock %} +{% block content %} + + +{% if clients %} + + + + + + + + + + + + {% for c in clients %} + + + + + + + + {% endfor %} + +
NomVilleEmailSIRETActions
{{ c.nom }}{{ c.code_postal }} {{ c.ville }}{{ c.email or "—" }}{{ c.siret or "—" }} + Modifier +
+ +
+
+{% else %} +

Aucun client. Créer le premier.

+{% endif %} +{% endblock %} diff --git a/templates/devis/detail.html b/templates/devis/detail.html new file mode 100644 index 0000000..c77ec12 --- /dev/null +++ b/templates/devis/detail.html @@ -0,0 +1,94 @@ +{% extends "base.html" %} +{% block title %}Devis {{ devis.numero }}{% endblock %} +{% block content %} + + +
+
+
+ + {{ devis.client.nom }}
+ {{ devis.client.adresse }}
+ {{ devis.client.code_postal }} {{ devis.client.ville }} +
+
+ + Émission : {{ devis.date_emission | date_fr }}
+ Validité : {{ devis.date_validite | date_fr }} +
+
+ + {{ devis.statut.value }} +
+
+ + + + + + + + + + + + {% for l in devis.lignes %} + + + + + + + {% endfor %} + + + + + + + + + + +
DescriptionQtéPU HTTotal HT
{{ l.description }}{{ l.quantite | int if l.quantite == l.quantite | int else l.quantite }}{{ l.prix_unitaire_ht | montant }}{{ l.total_ht | montant }}
Total HT{{ devis.total_ht | montant }}
TVA non applicable — art. 293B du CGI
+ + {% if devis.notes %} +

Notes : {{ devis.notes }}

+ {% endif %} + {% if devis.conditions %} +

Conditions : {{ devis.conditions }}

+ {% endif %} +
+ +
+

Changer le statut

+
+
+ + +
+ +
+
+{% endblock %} diff --git a/templates/devis/form.html b/templates/devis/form.html new file mode 100644 index 0000000..77c62bb --- /dev/null +++ b/templates/devis/form.html @@ -0,0 +1,131 @@ +{% extends "base.html" %} +{% block title %}{{ titre }}{% endblock %} +{% block content %} + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

Lignes

+ + + + + + + + + + + + {% if devis and devis.lignes %} + {% for l in devis.lignes %} + + + + + + + + {% endfor %} + {% endif %} + +
DescriptionQtéPU HT (€)Total HT
{{ (l.quantite * l.prix_unitaire_ht) | round(2) }} €
+ + +
+ Total HT : 0,00 € +
TVA non applicable — art. 293B du CGI +
+ + + +
+ + Annuler +
+
+ + +{% endblock %} diff --git a/templates/devis/liste.html b/templates/devis/liste.html new file mode 100644 index 0000000..984a541 --- /dev/null +++ b/templates/devis/liste.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}Devis{% endblock %} +{% block content %} + + +{% if devis %} + + + + + + + + + + + + + {% for d in devis %} + + + + + + + + + {% endfor %} + +
NuméroDateClientMontant HTStatutActions
{{ d.numero }}{{ d.date_emission | date_fr }}{{ d.client.nom }}{{ d.total_ht | montant }}{{ d.statut.value }} + Voir +
+{% else %} +

Aucun devis. Créer le premier.

+{% endif %} +{% endblock %} diff --git a/templates/erreur.html b/templates/erreur.html new file mode 100644 index 0000000..ff713d2 --- /dev/null +++ b/templates/erreur.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block title %}Erreur {{ status_code }}{% endblock %} +{% block content %} +
+

⚠️

+

Erreur {{ status_code }}

+

{{ detail or "Une erreur est survenue." }}

+ Retour à l'accueil +
+{% endblock %} diff --git a/templates/factures/detail.html b/templates/factures/detail.html new file mode 100644 index 0000000..3d5131d --- /dev/null +++ b/templates/factures/detail.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} +{% block title %}Facture {{ facture.numero }}{% endblock %} +{% block content %} + + +
+
+
+ + {{ facture.client.nom }}
+ {{ facture.client.adresse }}
+ {{ facture.client.code_postal }} {{ facture.client.ville }} + {% if facture.client.siret %}
SIRET : {{ facture.client.siret }}{% endif %} +
+
+ + Émission : {{ facture.date_emission | date_fr }}
+ Échéance : {{ facture.date_echeance | date_fr }}
+ {% if facture.date_paiement %} + Paiement : {{ facture.date_paiement | date_fr }} + {% endif %} +
+
+ + {{ facture.statut.value }} + {% if facture.devis_origine %} +
Issu du devis {{ facture.devis_origine.numero }} + {% endif %} +
+
+ + + + + + + + + + + + {% for l in facture.lignes %} + + + + + + + {% endfor %} + + + + + + + + + + +
DescriptionQtéPU HTTotal HT
{{ l.description }}{{ l.quantite | int if l.quantite == l.quantite | int else l.quantite }}{{ l.prix_unitaire_ht | montant }}{{ l.total_ht | montant }}
Total HT{{ facture.total_ht | montant }}
TVA non applicable — art. 293B du CGI
+ + {% if facture.conditions_reglement %} +

Conditions de règlement : {{ facture.conditions_reglement }}

+ {% endif %} + {% if facture.notes %} +

Notes : {{ facture.notes }}

+ {% endif %} +
+ +
+

Changer le statut

+
+
+ + +
+
+ + +
+ +
+
+{% endblock %} diff --git a/templates/factures/form.html b/templates/factures/form.html new file mode 100644 index 0000000..f800fa5 --- /dev/null +++ b/templates/factures/form.html @@ -0,0 +1,139 @@ +{% extends "base.html" %} +{% block title %}{{ titre }}{% endblock %} +{% block content %} + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

Lignes

+
+ + + + + + + + + + + + {% if facture and facture.lignes %} + {% for l in facture.lignes %} + + + + + + + + {% endfor %} + {% endif %} + +
DescriptionQtéPU HT (€)Total HT
{{ (l.quantite * l.prix_unitaire_ht) | round(2) }} €
+ +
+ +
+ Total HT : 0,00 € +
TVA non applicable — art. 293B du CGI +
+ + + +
+ + Annuler +
+
+ + +{% endblock %} diff --git a/templates/factures/liste.html b/templates/factures/liste.html new file mode 100644 index 0000000..99ed8bf --- /dev/null +++ b/templates/factures/liste.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}Factures{% endblock %} +{% block content %} + + +{% if factures %} + + + + + + + + + + + + + {% for f in factures %} + + + + + + + + + {% endfor %} + +
NuméroDateClientMontant HTStatutActions
{{ f.numero }}{{ f.date_emission | date_fr }}{{ f.client.nom }}{{ f.total_ht | montant }}{{ f.statut.value }} + Voir + PDF +
+{% else %} +

Aucune facture. Créer la première.

+{% endif %} +{% endblock %} diff --git a/templates/pdf/facture.html b/templates/pdf/facture.html new file mode 100644 index 0000000..80a787c --- /dev/null +++ b/templates/pdf/facture.html @@ -0,0 +1,297 @@ + + + + + + + + + +
+
+

{{ settings.asso_nom }}

+

+ {{ settings.asso_adresse }}
+ {{ settings.asso_code_postal }} {{ settings.asso_ville }}
+ {% if settings.asso_email %}{{ settings.asso_email }}
{% endif %} + {% if settings.asso_telephone %}{{ settings.asso_telephone }}
{% endif %} + {% if settings.asso_rna %}RNA : {{ settings.asso_rna }}
{% endif %} + {% if settings.asso_siret %}SIRET : {{ settings.asso_siret }}{% endif %} +

+
+
+

Facture

+
N° {{ facture.numero }}
+
+ Date d'émission : {{ facture.date_emission.strftime('%d/%m/%Y') }}
+ Date d'échéance : {{ facture.date_echeance.strftime('%d/%m/%Y') }} + {% if facture.statut.value == 'payee' and facture.date_paiement %} +
Date de paiement : {{ facture.date_paiement.strftime('%d/%m/%Y') }} + {% endif %} +
+
+
+ + +
+
+

Émetteur

+

+ {{ settings.asso_nom }}
+ {{ settings.asso_adresse }}
+ {{ settings.asso_code_postal }} {{ settings.asso_ville }}
+ {% if settings.asso_rna %}RNA : {{ settings.asso_rna }}
{% endif %} + {% if settings.asso_siret %}SIRET : {{ settings.asso_siret }}{% endif %} +

+
+
+

Destinataire

+

+ {{ facture.client.nom }}
+ {{ facture.client.adresse }}
+ {{ facture.client.code_postal }} {{ facture.client.ville }} + {% if facture.client.siret %}
SIRET : {{ facture.client.siret }}{% endif %} + {% if facture.client.email %}
{{ facture.client.email }}{% endif %} +

+
+
+ + {% if facture.devis_origine %} +

Facture établie suite au devis {{ facture.devis_origine.numero }}

+ {% endif %} + + + + + + + + + + + + + {% for l in facture.lignes %} + + + + + + + {% endfor %} + +
DésignationQtéPrix unitaire HTTotal HT
{{ l.description }} + {% if l.quantite == l.quantite | int %}{{ l.quantite | int }}{% else %}{{ l.quantite }}{% endif %} + + {{ "%.2f"|format(l.prix_unitaire_ht) | replace('.', ',') }} € + {{ "%.2f"|format(l.total_ht) | replace('.', ',') }} €
+ + +
+ + + + + + + + + + + + + +
Montant HT{{ "%.2f"|format(facture.total_ht) | replace('.', ',') }} €
TVA0,00 €
Total TTC{{ "%.2f"|format(facture.total_ht) | replace('.', ',') }} €
+
+

TVA non applicable — art. 293B du CGI

+ + {% if facture.notes %} +
{{ facture.notes }}
+ {% endif %} + + + + + +