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 @@ + + +
+ + +Connectez-vous pour continuer
+ + {% if erreur %} +| Nom d'utilisateur | +Rôle | +Statut | +Créé le | +Actions | +|
|---|---|---|---|---|---|
| + {{ 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 %} + | +
| Nom | +Ville | +SIRET | +Actions | +|
|---|---|---|---|---|
| {{ c.nom }} | +{{ c.code_postal }} {{ c.ville }} | +{{ c.email or "—" }} | +{{ c.siret or "—" }} | ++ Modifier + + | +
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 %} +| Description | +Qté | +PU HT | +Total 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 | +|||
Notes : {{ devis.notes }}
+ {% endif %} + {% if devis.conditions %} +Conditions : {{ devis.conditions }}
+ {% endif %} +| Numéro | +Date | +Client | +Montant HT | +Statut | +Actions | +
|---|---|---|---|---|---|
| {{ d.numero }} | +{{ d.date_emission | date_fr }} | +{{ d.client.nom }} | +{{ d.total_ht | montant }} | +{{ d.statut.value }} | ++ Voir + | +
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 %} + +{% 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 %} +| Description | +Qté | +PU HT | +Total 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 | +|||
Conditions de règlement : {{ facture.conditions_reglement }}
+ {% endif %} + {% if facture.notes %} +Notes : {{ facture.notes }}
+ {% endif %} +| Numéro | +Date | +Client | +Montant HT | +Statut | +Actions | +
|---|---|---|---|---|---|
| {{ f.numero }} | +{{ f.date_emission | date_fr }} | +{{ f.client.nom }} | +{{ f.total_ht | montant }} | +{{ f.statut.value }} | ++ Voir + PDF + | +
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_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 %}
+
+ {{ 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 %}
+
+ {{ 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 %}
+
Facture établie suite au devis {{ facture.devis_origine.numero }}
+ {% endif %} + + +| Désignation | +Qté | +Prix unitaire HT | +Total 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('.', ',') }} € | +
| TVA | +0,00 € | +
| Total TTC | +{{ "%.2f"|format(facture.total_ht) | replace('.', ',') }} € | +
TVA non applicable — art. 293B du CGI
+ + {% if facture.notes %} +