5649ab02c7
Build & Push / Pipeline Tests (push) Failing after 1m31s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 55s
Test / API Unit Tests (push) Successful in 1m8s
Test / Homepage Unit Tests (push) Successful in 47s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Failing after 46s
Test / API Integration Tests (push) Successful in 1m7s
268 lines
12 KiB
Python
268 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate an A4 French/Arabic vehicle rental contract as a print-ready PDF.
|
|
|
|
Usage:
|
|
python generate_rental_contract.py
|
|
|
|
Edit the CONTRACT dictionary below, or import generate_contract() in your app.
|
|
Requires: weasyprint (pip install weasyprint)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, asdict
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from html import escape
|
|
|
|
from weasyprint import HTML
|
|
|
|
OUTPUT_DIR = Path(__file__).resolve().parent
|
|
|
|
CONTRACT: dict[str, Any] = {
|
|
"company_name": "CAR LOCATION",
|
|
"company_address": "Adresse de la société, Midelt, Maroc",
|
|
"delivery_place": "Midelt",
|
|
"return_place": "Midelt",
|
|
"issued_at": "Midelt le 06/06/2021 11:35",
|
|
"driver": {
|
|
"first_name": "Prénom",
|
|
"last_name": "Nom",
|
|
"birth_date": "01/01/1990",
|
|
"nationality": "Marocain",
|
|
"address": "Adresse du client",
|
|
"phone": "+212 6 00 00 00 00",
|
|
"cin": "AB123456",
|
|
"passport": "",
|
|
"license": "P123456",
|
|
"license_issued": "09/03/2004",
|
|
},
|
|
"second_driver": None,
|
|
"vehicle": {
|
|
"make_model": "Dacia Dokker",
|
|
"registration": "12345-A-6",
|
|
"departure": "06/06/2021 11:35",
|
|
"return": "08/06/2021 11:35",
|
|
"duration": "2 jour(s)",
|
|
"fuel_type": "Diesel",
|
|
"extension": False,
|
|
"extension_return": "",
|
|
"spare_wheel": True,
|
|
"radio_cd": False,
|
|
"fuel_level": "1/4", # 0, 1/4, 1/2, 3/4 or 1
|
|
},
|
|
"notes": "",
|
|
}
|
|
|
|
|
|
def h(value: Any) -> str:
|
|
return escape("" if value is None else str(value))
|
|
|
|
|
|
def check(value: bool) -> str:
|
|
return "☒" if value else "☐"
|
|
|
|
|
|
def field(label_fr: str, label_ar: str, value: Any) -> str:
|
|
return (
|
|
'<div class="field">'
|
|
f'<span class="label-fr">{h(label_fr)}</span>'
|
|
f'<span class="value">{h(value)}</span>'
|
|
f'<span class="label-ar" dir="rtl">{h(label_ar)}</span>'
|
|
'</div>'
|
|
)
|
|
|
|
|
|
def driver_block(driver: dict[str, Any] | None, title: str) -> str:
|
|
if not driver:
|
|
return f'''<section class="box driver-box">
|
|
<div class="section-title">{h(title)}</div>
|
|
<div class="empty-driver">Prénom: ***** AUCUN ***** <span dir="rtl">لا أحد</span><br>
|
|
Nom: *****<br>Date de naissance: *****<br>N° de C.I.N.: *****<br>N° de Permis: *****<br>Délivrée le: *****</div>
|
|
</section>'''
|
|
return f'''<section class="box driver-box">
|
|
<div class="section-title">{h(title)}</div>
|
|
<div class="fields">
|
|
{field("Prénom:", "الاسم", driver.get("first_name"))}
|
|
{field("Nom:", "النسب", driver.get("last_name"))}
|
|
{field("Date de naissance:", "تاريخ الازدياد", driver.get("birth_date"))}
|
|
{field("Nationalité:", "الجنسية", driver.get("nationality"))}
|
|
{field("Adresse:", "العنوان", driver.get("address"))}
|
|
{field("N° téléphone:", "رقم الهاتف", driver.get("phone"))}
|
|
{field("N° de C.I.N.:", "رقم البطاقة الوطنية", driver.get("cin"))}
|
|
{field("N° de Passeport:", "رقم جواز السفر", driver.get("passport"))}
|
|
{field("N° de Permis:", "رقم رخصة السياقة", driver.get("license"))}
|
|
{field("Délivrée le:", "سلمت بتاريخ", driver.get("license_issued"))}
|
|
</div>
|
|
</section>'''
|
|
|
|
|
|
def vehicle_diagram() -> str:
|
|
return '''
|
|
<svg class="vehicle-diagram" viewBox="0 0 560 250" xmlns="http://www.w3.org/2000/svg" aria-label="Schéma état du véhicule">
|
|
<g fill="none" stroke="#222" stroke-width="3">
|
|
<!-- top view -->
|
|
<rect x="185" y="78" width="190" height="94" rx="35"/>
|
|
<path d="M210 92 Q280 55 350 92 M210 158 Q280 195 350 158"/>
|
|
<path d="M232 80 L230 170 M328 80 L330 170"/>
|
|
<rect x="150" y="92" width="38" height="65" rx="8"/>
|
|
<rect x="372" y="92" width="38" height="65" rx="8"/>
|
|
<!-- front/back -->
|
|
<path d="M55 52 Q105 16 155 52 L166 91 L45 91 Z"/>
|
|
<circle cx="66" cy="90" r="13"/><circle cx="145" cy="90" r="13"/>
|
|
<path d="M405 52 Q455 16 505 52 L516 91 L395 91 Z"/>
|
|
<circle cx="416" cy="90" r="13"/><circle cx="495" cy="90" r="13"/>
|
|
<!-- side views -->
|
|
<path d="M38 210 L78 175 L160 169 L206 210 Z"/>
|
|
<path d="M85 177 L107 145 L154 145 L180 173"/>
|
|
<circle cx="78" cy="210" r="18"/><circle cx="170" cy="210" r="18"/>
|
|
<path d="M354 210 L394 175 L476 169 L522 210 Z"/>
|
|
<path d="M401 177 L423 145 L470 145 L496 173"/>
|
|
<circle cx="394" cy="210" r="18"/><circle cx="486" cy="210" r="18"/>
|
|
</g>
|
|
<g font-family="DejaVu Sans" font-size="13" fill="#222">
|
|
<text x="6" y="56">R: Rayures</text><text x="6" y="83">B: Bosses</text>
|
|
<text x="6" y="110">E: Éclats</text><text x="6" y="137">C: Cassures</text>
|
|
</g>
|
|
</svg>'''
|
|
|
|
|
|
def render_html(data: dict[str, Any]) -> str:
|
|
v = data["vehicle"]
|
|
levels = ["0", "1/4", "1/2", "3/4", "1"]
|
|
fuel_marks = "".join(
|
|
f'<div class="fuel-option"><span>{x}</span><b>{"X" if v.get("fuel_level") == x else ""}</b></div>'
|
|
for x in levels
|
|
)
|
|
extension_yes = bool(v.get("extension"))
|
|
return f'''<!doctype html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Contrat de location</title>
|
|
<style>
|
|
@page {{ size: A4; margin: 8mm; }}
|
|
* {{ box-sizing: border-box; }}
|
|
body {{ margin: 0; font-family: "DejaVu Sans", Arial, sans-serif; color: #111; font-size: 9.2pt; }}
|
|
.page {{ width: 100%; }}
|
|
.header {{ display:grid; grid-template-columns: 31% 69%; align-items:start; margin-bottom:4px; }}
|
|
.logo {{ height:70px; display:flex; align-items:center; justify-content:center; font-size:19pt; font-weight:700; font-style:italic; letter-spacing:1px; }}
|
|
.logo::before, .logo::after {{ content:""; display:block; width:45px; border-top:3px solid #111; margin:0 8px; }}
|
|
h1 {{ margin: 0; text-align:center; font-family: Georgia, serif; font-size:28pt; text-decoration: underline; font-style:italic; }}
|
|
.issued {{ text-align:center; font-weight:700; margin-top:10px; }}
|
|
table {{ border-collapse: collapse; width:100%; }}
|
|
.places td {{ border:1.3px solid #111; padding:6px; font-weight:700; text-align:center; }}
|
|
.columns {{ display:grid; grid-template-columns: 52% 48%; gap:4px; margin-top:4px; }}
|
|
.box {{ border:1.3px solid #111; margin-bottom:4px; break-inside:avoid; }}
|
|
.section-title {{ text-align:center; font-weight:700; font-size:12pt; padding:4px; border-bottom:1.3px solid #111; background:#e5e5e5; font-family:Georgia, serif; }}
|
|
.fields {{ padding:6px 7px 5px; }}
|
|
.field {{ display:grid; grid-template-columns: 34% 43% 23%; min-height:24px; align-items:center; gap:3px; }}
|
|
.label-fr, .label-ar {{ font-weight:600; }}
|
|
.label-ar {{ text-align:right; font-family:"Noto Sans Arabic", "DejaVu Sans", sans-serif; }}
|
|
.value {{ min-height:18px; border-bottom:1px dotted #777; padding:1px 4px; font-weight:700; }}
|
|
.empty-driver {{ padding:10px; line-height:1.75; min-height:160px; }}
|
|
.vehicle-details {{ padding:7px; }}
|
|
.vehicle-details .row {{ display:grid; grid-template-columns:42% 58%; min-height:28px; align-items:center; }}
|
|
.vehicle-details .row span:first-child {{ font-weight:700; }}
|
|
.vehicle-details .row span:last-child {{ font-weight:700; border-bottom:1px dotted #777; }}
|
|
.equipment {{ display:grid; grid-template-columns:1fr 1fr; gap:4px; }}
|
|
.mini {{ border:1.3px solid #111; text-align:center; }}
|
|
.mini-title {{ background:#e5e5e5; font-weight:700; padding:5px; border-bottom:1.3px solid #111; }}
|
|
.mini-value {{ padding:7px; font-size:11pt; }}
|
|
.fuel {{ border:1.3px solid #111; margin:4px 0; display:grid; grid-template-columns:30% 70%; }}
|
|
.fuel-label {{ background:#e5e5e5; font-weight:700; display:flex; align-items:center; justify-content:center; border-right:1.3px solid #111; }}
|
|
.fuel-options {{ display:grid; grid-template-columns:repeat(5,1fr); padding:4px 5px; }}
|
|
.fuel-option {{ text-align:center; border-bottom:1px solid #111; min-height:34px; }}
|
|
.fuel-option span {{ display:block; font-weight:700; }}
|
|
.fuel-option b {{ display:block; font-size:13pt; line-height:14px; }}
|
|
.diagram-box {{ border:1.3px solid #111; }}
|
|
.vehicle-diagram {{ width:100%; height:200px; display:block; padding:2px; }}
|
|
.notice {{ border:1.3px solid #111; background:#d8d8d8; text-align:center; font-weight:700; text-decoration:underline; font-size:11pt; padding:8px 20px; margin:4px 0; }}
|
|
.signatures {{ display:grid; grid-template-columns:1fr 1fr; border:1.3px solid #111; min-height:124px; }}
|
|
.signature {{ padding:8px; text-align:center; font-weight:700; text-decoration:underline; }}
|
|
.signature + .signature {{ border-left:1.3px solid #111; }}
|
|
.signature-space {{ height:80px; margin-top:6px; }}
|
|
.footer-logo {{ margin:13px auto 5px; text-align:center; font-size:15pt; font-weight:700; font-style:italic; }}
|
|
.footer {{ border-top:1px solid #777; padding-top:6px; font-weight:600; }}
|
|
.notes {{ white-space:pre-wrap; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="page">
|
|
<header class="header">
|
|
<div class="logo">{h(data['company_name'])}</div>
|
|
<div><h1>Contrat de location</h1><div class="issued">{h(data['issued_at'])}</div></div>
|
|
</header>
|
|
|
|
<table class="places"><tr>
|
|
<td>Lieu de livraison : {h(data['delivery_place'])}</td>
|
|
<td>Lieu de reprise : {h(data['return_place'])}</td>
|
|
</tr></table>
|
|
|
|
<main class="columns">
|
|
<div>
|
|
{driver_block(data.get('driver'), 'Conducteur I')}
|
|
{driver_block(data.get('second_driver'), 'Conducteur II')}
|
|
<div class="notice">Le client est seul responsable des délits, contraventions et infractions au code de la route.</div>
|
|
</div>
|
|
|
|
<div>
|
|
<section class="box">
|
|
<div class="section-title">Véhicule</div>
|
|
<div class="vehicle-details">
|
|
<div class="row"><span>Marque :</span><span>{h(v.get('make_model'))}</span></div>
|
|
<div class="row"><span>Matricule :</span><span>{h(v.get('registration'))}</span></div>
|
|
<div class="row"><span>Date de départ :</span><span>{h(v.get('departure'))}</span></div>
|
|
<div class="row"><span>Date de retour :</span><span>{h(v.get('return'))}</span></div>
|
|
<div class="row"><span>Durée de location :</span><span>{h(v.get('duration'))}</span></div>
|
|
<div class="row"><span>Carburant :</span><span>{h(v.get('fuel_type'))}</span></div>
|
|
<div class="row"><span>Prolongation :</span><span>{check(extension_yes)} OUI {check(not extension_yes)} NON</span></div>
|
|
<div class="row"><span>Retour en cas de prolongation :</span><span>{h(v.get('extension_return'))}</span></div>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="equipment">
|
|
<div class="mini"><div class="mini-title">Roue de secours</div><div class="mini-value">{check(bool(v.get('spare_wheel')))} OUI {check(not bool(v.get('spare_wheel')))} NON</div></div>
|
|
<div class="mini"><div class="mini-title">Poste radio et CD</div><div class="mini-value">{check(bool(v.get('radio_cd')))} OUI {check(not bool(v.get('radio_cd')))} NON</div></div>
|
|
</div>
|
|
|
|
<div class="fuel"><div class="fuel-label">Niveau carburant</div><div class="fuel-options">{fuel_marks}</div></div>
|
|
|
|
<section class="diagram-box">
|
|
<div class="section-title">État général à la réception du client</div>
|
|
{vehicle_diagram()}
|
|
</section>
|
|
</div>
|
|
</main>
|
|
|
|
<section class="signatures">
|
|
<div class="signature">Signature du client : lu et approuvé des conditions<div class="signature-space"></div></div>
|
|
<div class="signature">Cachet et signature de la société<div class="signature-space"></div></div>
|
|
</section>
|
|
|
|
<div class="footer-logo">{h(data['company_name'])}</div>
|
|
<footer class="footer">Adresse : {h(data['company_address'])}<div class="notes">{h(data.get('notes'))}</div></footer>
|
|
</div>
|
|
</body>
|
|
</html>'''
|
|
|
|
|
|
def generate_contract(data: dict[str, Any], output_pdf: str | Path, output_html: str | Path | None = None) -> Path:
|
|
"""Render contract data to a print-ready PDF and optionally save the HTML preview."""
|
|
output_pdf = Path(output_pdf)
|
|
output_pdf.parent.mkdir(parents=True, exist_ok=True)
|
|
html = render_html(data)
|
|
if output_html:
|
|
output_html = Path(output_html)
|
|
output_html.write_text(html, encoding="utf-8")
|
|
HTML(string=html, base_url=str(OUTPUT_DIR)).write_pdf(str(output_pdf))
|
|
return output_pdf
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pdf = generate_contract(
|
|
CONTRACT,
|
|
OUTPUT_DIR / "rental_contract.pdf",
|
|
OUTPUT_DIR / "rental_contract_preview.html",
|
|
)
|
|
print(f"Generated: {pdf}")
|