SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%

feat(pdf): fiche PDF complète — prix + verdict Vrai-Prix, photos, description, inclusions, caractéristiques, pièces, jauge Vrai-Prix + rôle foncier, historique de prix, inondation, air, hydro, commerces/transport, essence, quartier, références + QR

Nouveau immoka/pdffiche.py (reportlab canvas, style « éditorial sharp »,
pagination dynamique saut_page()/place(), sections conditionnelles _safe,
photos compressées Pillow 1000px q78) + endpoint GET /api/listings/{uid}/pdf.
reportlab + qrcode épinglés dans requirements.txt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 24, 2026) parent fc02d70

3 changed files +967 −0

added immoka/pdffiche.py +949 −0
@@ -0,0 +1,949 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# pdffiche.py : fiche PDF complète d'une propriété — reflète TOUS les blocs de
5 +# la fiche web : prix + verdict Vrai-Prix, photos, description, inclusions,
6 +# caractéristiques, pièces, analyse Vrai-Prix (jauge + rôle foncier),
7 +# historique de prix, inondation, qualité de l'air, coût d'électricité,
8 +# commerces/transport, essence, quartier, références + QR.
9 +# Style « éditorial sharp » (bordures encre, ombres décalées dures, tuiles
10 +# KPI, micro-étiquettes Courier) — palette du site : crème #f5f3ee, encre
11 +# #141814, accent cerise #e23744. Pagination dynamique : saut_page()/place(),
12 +# aucune section coupée entre deux pages.
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import io
17 +import json
18 +import time
19 +
20 +import qrcode
21 +import requests
22 +from reportlab.lib.colors import HexColor
23 +from reportlab.lib.pagesizes import letter
24 +from reportlab.lib.utils import ImageReader
25 +from reportlab.pdfgen import canvas as rl_canvas
26 +
27 +from . import db
28 +
29 +# --- palette Immo-Ka (identité officielle du site) ----------------------------
30 +PAPER = HexColor("#f5f3ee")
31 +SURFACE = HexColor("#ffffff")
32 +INK = HexColor("#141814")
33 +INK2 = HexColor("#4d5551")
34 +INK3 = HexColor("#8b928c")
35 +ACCENT = HexColor("#e23744") # cerise
36 +ACCENT_SOFT = HexColor("#fbe0e2")
37 +ACCENT_DEEP = HexColor("#a8232e")
38 +GREEN_FG = HexColor("#1c5c41") # verdict « sous l'estimation » (site .vp-under)
39 +GREEN_BG = HexColor("#e2f0e6")
40 +RED_FG = HexColor("#8a2b2b") # verdict « au-dessus » (site .vp-over)
41 +RED_BG = HexColor("#f6e2e2")
42 +AMBER_FG = HexColor("#8a5a12")
43 +AMBER_BG = HexColor("#faf0dc")
44 +
45 +PAGE_W, PAGE_H = letter
46 +M = 40 # marge
47 +IMG_TIMEOUT = 6
48 +UA = "ImmoKaBot/1.0 (+https://www.immo-ka.com; contact@spboucher.ai)"
49 +
50 +NBSP = " " # espace fine insécable (mise en forme française)
51 +
52 +# clés de details jamais affichées (mêmes exclusions que la fiche web)
53 +DETAIL_HIDDEN = {
54 + "pieces", "price_from", "cover_thumb", "photo_captions", "img_audited",
55 + "needs_image_review", "postal_code", "region", "transaction",
56 + "prix_pi2", "prix_m2", "listing_origin_url",
57 +}
58 +
59 +PROX_LABELS = [
60 + ("prox_epicerie", "Épiceries"), ("prox_transport", "Transport en commun"),
61 + ("prox_parc", "Parcs"), ("prox_ecole_prim", "Écoles primaires"),
62 + ("prox_sante", "Soins de santé"), ("prox_pharmacie", "Pharmacies"),
63 +]
64 +
65 +_MOIS = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet",
66 + "août", "septembre", "octobre", "novembre", "décembre"]
67 +
68 +_PIED_FICHE = ("Données de l'annonce publiée par l'agence — Immo-Ka est un "
69 + "agrégateur indépendant. Enrichissements fournis à titre "
70 + "indicatif, sans valeur légale.")
71 +
72 +
73 +def _fmt_money(v) -> str:
74 + return f"{v:,.0f}".replace(",", f"{NBSP}") + f"{NBSP}$" if v is not None else "—"
75 +
76 +
77 +def _fmt_dist(m_: float) -> str:
78 + return (f"{round(m_ / 10) * 10}{NBSP}m" if m_ < 1000
79 + else f"{m_ / 1000:.1f}".replace(".", ",") + f"{NBSP}km")
80 +
81 +
82 +def _fmt_mois(d) -> str:
83 + """« 2017-11-04 » ou epoch → « novembre 2017 » (sinon valeur telle quelle)."""
84 + if d is None:
85 + return "—"
86 + try:
87 + if isinstance(d, (int, float)):
88 + st = time.localtime(d)
89 + return f"{_MOIS[st.tm_mon - 1]} {st.tm_year}"
90 + an, mois = str(d)[:7].split("-")
91 + return f"{_MOIS[int(mois) - 1]} {an}"
92 + except Exception:
93 + return str(d)
94 +
95 +
96 +def _fmt_jour(ts: float) -> str:
97 + """epoch → « 4 novembre 2024 » (« 1er » pour le premier du mois)."""
98 + st = time.localtime(ts)
99 + j = "1er" if st.tm_mday == 1 else str(st.tm_mday)
100 + return f"{j} {_MOIS[st.tm_mon - 1]} {st.tm_year}"
101 +
102 +
103 +def _fetch_image(url: str) -> ImageReader | None:
104 + try:
105 + r = requests.get(url, timeout=IMG_TIMEOUT, headers={"User-Agent": UA})
106 + r.raise_for_status()
107 + raw = r.content
108 + try:
109 + # les photos source dépassent parfois 5 Mo chacune : sans
110 + # redimensionnement le PDF grimpe à plusieurs dizaines de Mo
111 + from PIL import Image
112 + im = Image.open(io.BytesIO(raw))
113 + im.thumbnail((1000, 1000))
114 + buf = io.BytesIO()
115 + im.convert("RGB").save(buf, "JPEG", quality=78)
116 + buf.seek(0)
117 + return ImageReader(buf)
118 + except Exception:
119 + return ImageReader(io.BytesIO(raw))
120 + except Exception:
121 + return None
122 +
123 +
124 +class _Style:
125 + """Petits composants du style « éditorial sharp » sur un canvas."""
126 +
127 + def __init__(self, c: rl_canvas.Canvas):
128 + self.c = c
129 +
130 + def fond(self):
131 + self.c.setFillColor(PAPER)
132 + self.c.rect(0, 0, PAGE_W, PAGE_H, stroke=0, fill=1)
133 +
134 + def logo(self, x: float, y: float, taille: float = 22):
135 + """Wordmark Immo·Ka : « Immo » encre + boîte encre avec « Ka » cerise."""
136 + c = self.c
137 + c.setFont("Helvetica-Bold", taille)
138 + c.setFillColor(INK)
139 + c.drawString(x, y, "Immo")
140 + w = c.stringWidth("Immo", "Helvetica-Bold", taille)
141 + bw = c.stringWidth("Ka", "Helvetica-Bold", taille) + 8
142 + c.saveState()
143 + c.translate(x + w + 3 + bw / 2, y + taille * 0.32)
144 + c.rotate(-3)
145 + c.setFillColor(INK)
146 + c.roundRect(-bw / 2, -taille * 0.62, bw, taille * 1.15, 4, stroke=0, fill=1)
147 + c.setFillColor(ACCENT)
148 + c.drawCentredString(0, -taille * 0.30, "Ka")
149 + c.restoreState()
150 + return x + w + 6 + bw
151 +
152 + def entete(self, titre: str):
153 + c = self.c
154 + self.logo(M, PAGE_H - M - 16)
155 + c.setFont("Courier-Bold", 8)
156 + c.setFillColor(INK3)
157 + c.drawRightString(PAGE_W - M, PAGE_H - M - 6, titre.upper())
158 + c.drawRightString(PAGE_W - M, PAGE_H - M - 16,
159 + time.strftime("GÉNÉRÉ LE %Y-%m-%d · WWW.IMMO-KA.COM"))
160 + c.setStrokeColor(INK)
161 + c.setLineWidth(2)
162 + c.line(M, PAGE_H - M - 26, PAGE_W - M, PAGE_H - M - 26)
163 + return PAGE_H - M - 40 # y de départ du contenu
164 +
165 + def pied(self, texte: str, page: int | None = None):
166 + c = self.c
167 + c.setStrokeColor(INK)
168 + c.setLineWidth(1)
169 + c.line(M, M + 16, PAGE_W - M, M + 16)
170 + c.setFont("Helvetica", 6.5)
171 + c.setFillColor(INK3)
172 + c.drawString(M, M + 6, texte[:150])
173 + if page:
174 + c.setFont("Courier-Bold", 7)
175 + c.drawRightString(PAGE_W - M, M + 6, f"P.{page}")
176 +
177 + def pilule(self, x: float, y: float, texte: str, fg=ACCENT, bg=INK,
178 + stroke=None, taille: float = 8) -> float:
179 + """Chip arrondie ; retourne le x suivant."""
180 + c = self.c
181 + w = c.stringWidth(texte, "Helvetica-Bold", taille) + 14
182 + c.setFillColor(bg)
183 + if stroke:
184 + c.setStrokeColor(stroke)
185 + c.setLineWidth(1.2)
186 + c.roundRect(x, y, w, taille + 9, (taille + 9) / 2,
187 + stroke=1 if stroke else 0, fill=1)
188 + c.setFillColor(fg)
189 + c.setFont("Helvetica-Bold", taille)
190 + c.drawString(x + 7, y + 5, texte)
191 + return x + w + 6
192 +
193 + def titre_section(self, y: float, texte: str) -> float:
194 + c = self.c
195 + # carré accent à ombre dure (signature de la passe éditoriale du site)
196 + c.setFillColor(INK)
197 + c.rect(M + 2, y - 1.5, 9, 9, stroke=0, fill=1)
198 + c.setFillColor(ACCENT)
199 + c.setStrokeColor(INK)
200 + c.setLineWidth(1)
201 + c.rect(M, y + 0.5, 9, 9, stroke=1, fill=1)
202 + c.setFont("Helvetica-Bold", 12.5)
203 + c.setFillColor(INK)
204 + c.drawString(M + 16, y, texte)
205 + c.setStrokeColor(ACCENT)
206 + c.setLineWidth(3)
207 + w = c.stringWidth(texte, "Helvetica-Bold", 12.5)
208 + c.line(M + 16, y - 4, M + 16 + w, y - 4)
209 + return y - 18
210 +
211 + def paragraphe(self, x: float, y: float, texte: str, largeur: float,
212 + taille: float = 8.5, couleur=INK2, interligne: float = 11.5,
213 + max_lignes: int = 100) -> float:
214 + """Texte multi-lignes avec découpe aux mots ; retourne le y suivant."""
215 + c = self.c
216 + c.setFont("Helvetica", taille)
217 + c.setFillColor(couleur)
218 + mots = texte.split()
219 + ligne = ""
220 + n = 0
221 + for mot in mots:
222 + essai = (ligne + " " + mot).strip()
223 + if c.stringWidth(essai, "Helvetica", taille) <= largeur:
224 + ligne = essai
225 + else:
226 + c.drawString(x, y, ligne)
227 + y -= interligne
228 + n += 1
229 + ligne = mot
230 + if n >= max_lignes - 1:
231 + ligne += " …"
232 + break
233 + if ligne:
234 + c.drawString(x, y, ligne)
235 + y -= interligne
236 + return y
237 +
238 + def nb_lignes(self, texte: str, largeur: float, taille: float = 8.5,
239 + max_lignes: int = 100) -> int:
240 + """Nombre de lignes qu'occupera `paragraphe` (pour réserver la place)."""
241 + c = self.c
242 + mots = texte.split()
243 + ligne = ""
244 + n = 1
245 + for mot in mots:
246 + essai = (ligne + " " + mot).strip()
247 + if c.stringWidth(essai, "Helvetica", taille) <= largeur:
248 + ligne = essai
249 + else:
250 + n += 1
251 + ligne = mot
252 + if n >= max_lignes:
253 + break
254 + return min(n, max_lignes)
255 +
256 + def tuile(self, x: float, y: float, w: float, h: float,
257 + val: str, lab: str):
258 + """Tuile KPI blanche à ombre décalée (x, y = coin inférieur gauche)."""
259 + c = self.c
260 + c.setFillColor(INK)
261 + c.roundRect(x + 2.5, y - 2.5, w, h, 5, stroke=0, fill=1)
262 + c.setFillColor(SURFACE)
263 + c.setStrokeColor(INK)
264 + c.setLineWidth(1.2)
265 + c.roundRect(x, y, w, h, 5, stroke=1, fill=1)
266 + c.setFont("Helvetica-Bold", 12)
267 + c.setFillColor(INK)
268 + c.drawString(x + 8, y + h - 20, str(val))
269 + c.setFont("Courier", 6.2)
270 + c.setFillColor(INK3)
271 + c.drawString(x + 8, y + 7, lab.upper()[: max(4, int(w / 4.2))])
272 +
273 +
274 +def _titre_droite(c, y, x, texte):
275 + c.setFillColor(INK)
276 + c.rect(x + 2, y - 1.5, 9, 9, stroke=0, fill=1)
277 + c.setFillColor(ACCENT)
278 + c.setStrokeColor(INK)
279 + c.setLineWidth(1)
280 + c.rect(x, y + 0.5, 9, 9, stroke=1, fill=1)
281 + c.setFont("Helvetica-Bold", 12.5)
282 + c.setFillColor(INK)
283 + c.drawString(x + 16, y, texte)
284 + c.setStrokeColor(ACCENT)
285 + c.setLineWidth(3)
286 + c.line(x + 16, y - 4, x + 16 + c.stringWidth(texte, "Helvetica-Bold", 12.5), y - 4)
287 + return y - 18
288 +
289 +
290 +def _verdict_vraiprix(price, vp):
291 + """Pastille verdict identique au site : ±% vs estimation, masqué si
292 + |pct| > 60 (prix non comparable : terrain, loyer…)."""
293 + if not price or not vp or vp.get("value") in (None, 0):
294 + return None
295 + pct = round((price - vp["value"]) / vp["value"] * 100)
296 + if abs(pct) > 60:
297 + return None
298 + if pct >= 1:
299 + return (f"▲{NBSP}{pct}{NBSP}% au-dessus de l'estimation",
300 + RED_FG, RED_BG, ACCENT_DEEP)
301 + if pct <= -1:
302 + return (f"▼{NBSP}{abs(pct)}{NBSP}% sous l'estimation",
303 + GREEN_FG, GREEN_BG, GREEN_FG)
304 + return (f"≈{NBSP}Prix aligné sur l'estimation", INK2, SURFACE, INK)
305 +
306 +
307 +def fiche_pdf(uid: str) -> bytes | None:
308 + """PDF de la fiche de la propriété `uid` — reflète tous les blocs de la
309 + fiche web ; None si introuvable."""
310 + con = db.connect()
311 + row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()
312 + if row is None:
313 + con.close()
314 + return None
315 + l = dict(row)
316 + l["features"] = json.loads(l.get("features") or "[]")
317 + l["images"] = json.loads(l.get("images") or "[]")
318 + l["details"] = json.loads(l.get("details") or "{}")
319 + try:
320 + l["vraiprix"] = json.loads(l["vraiprix"]) or None if l.get("vraiprix") else None
321 + except (ValueError, TypeError):
322 + l["vraiprix"] = None
323 + price_history = [dict(r) for r in con.execute(
324 + "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 10",
325 + (uid,)).fetchall()]
326 + quartier = None
327 + lat, lng = l.get("lat"), l.get("lng")
328 + geo = lat is not None and lng is not None
329 + if geo:
330 + from . import quartier as qmod
331 + dauid = l.get("dauid")
332 + try:
333 + quartier = qmod.fiche_quartier(
334 + lat, lng, l.get("city") or "",
335 + dauid if dauid and dauid != "hors-zone" else None)
336 + except Exception:
337 + quartier = None
338 + try:
339 + reg = json.loads(
340 + (db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"]
341 + src_name = next((s["name"] for s in reg if s["id"] == l["source"]),
342 + l["source"])
343 + except Exception:
344 + src_name = l["source"]
345 + con.close()
346 +
347 + # --- enrichissements (mêmes modules que les blocs de la fiche web) -------
348 + def _safe(fn, *a, **k):
349 + try:
350 + return fn(*a, **k)
351 + except Exception:
352 + return None
353 +
354 + from . import air as air_mod
355 + from . import commerces as com_mod
356 + from . import gaz as gaz_mod
357 + from . import hydro as hydro_mod
358 + from . import inondation as inon_mod
359 + inon = _safe(inon_mod.lookup, lat, lng) if geo else None
360 + air_d = _safe(air_mod.lookup, lat, lng) if geo else None
361 + gaz_d = _safe(gaz_mod.nearby, lat, lng) if geo else None
362 + com = _safe(com_mod.nearby, lat, lng) if geo else None
363 + hyd = _safe(hydro_mod.estimate, adresse=l.get("address") or "",
364 + uid=uid, lat=lat, lng=lng, solve=False)
365 +
366 + d = l["details"]
367 + vp = l.get("vraiprix")
368 + location = (d.get("transaction") == "location")
369 +
370 + buf = io.BytesIO()
371 + c = rl_canvas.Canvas(buf, pagesize=letter)
372 + c.setTitle(f"Immo-Ka — {l.get('address') or l.get('title') or uid}")
373 + s = _Style(c)
374 + s.fond()
375 + y = s.entete("Fiche propriété")
376 + page = [1]
377 +
378 + def saut_page() -> float:
379 + s.pied(_PIED_FICHE, page[0])
380 + c.showPage()
381 + page[0] += 1
382 + s.fond()
383 + return s.entete("Fiche propriété · suite")
384 +
385 + def place(y_: float, besoin: float) -> float:
386 + """Saute de page si `besoin` points ne tiennent plus sous y_."""
387 + return saut_page() if (y_ - besoin) < M + 34 else y_
388 +
389 + # --- héro : prix + pastille verdict Vrai-Prix -----------------------------
390 + prix_txt = (_fmt_money(l["price"]) + (f"{NBSP}/ mois" if location else "")) \
391 + if l.get("price") else (l.get("price_label") or "Prix sur demande")
392 + c.setFont("Helvetica-Bold", 30)
393 + c.setFillColor(INK)
394 + c.drawString(M, y - 24, prix_txt)
395 + bx = M + c.stringWidth(prix_txt, "Helvetica-Bold", 30) + 14
396 + verd = _verdict_vraiprix(l.get("price"), vp)
397 + if verd:
398 + txt, fg, bg, stk = verd
399 + bx = s.pilule(bx, y - 22, txt, fg, bg, stk)
400 + if l.get("property_type"):
401 + s.pilule(bx, y - 22, l["property_type"], ACCENT, INK)
402 + y -= 44
403 +
404 + # --- titre + adresse -------------------------------------------------------
405 + titre = l.get("address") or l.get("title") or ""
406 + c.setFont("Helvetica-Bold", 13)
407 + c.setFillColor(INK)
408 + c.drawString(M, y, titre[:80])
409 + y -= 14
410 + loc = " · ".join(x for x in (l.get("sector"), l.get("city"), l.get("region"))
411 + if x)
412 + c.setFont("Helvetica", 9)
413 + c.setFillColor(INK2)
414 + c.drawString(M, y, loc[:110])
415 + y -= 20
416 +
417 + # --- chips clés (mêmes specs que le héro du site) --------------------------
418 + chips = []
419 + if l.get("bedrooms") is not None:
420 + chips.append(f"{l['bedrooms']}{NBSP}ch.")
421 + if l.get("bathrooms") is not None:
422 + chips.append(f"{l['bathrooms']}{NBSP}sdb")
423 + if l.get("powder_rooms"):
424 + chips.append(f"{l['powder_rooms']}{NBSP}s. d'eau")
425 + if l.get("area_sqft"):
426 + chips.append(f"{round(l['area_sqft']):,}".replace(",", NBSP) + f"{NBSP}pi²")
427 + if l.get("lot_sqft"):
428 + chips.append(f"Terrain {round(l['lot_sqft']):,}".replace(",", NBSP)
429 + + f"{NBSP}pi²")
430 + if l.get("year_built"):
431 + chips.append(f"Construit en {l['year_built']}")
432 + if l.get("price") and l.get("area_sqft") and l["area_sqft"] > 200:
433 + chips.append(f"{round(l['price'] / l['area_sqft'])}{NBSP}$/pi²")
434 + if l.get("mls"):
435 + chips.append(f"MLS {l['mls']}")
436 + if l.get("first_seen"):
437 + dom = max(0, int((time.time() - l["first_seen"]) // 86400))
438 + chips.append(f"{dom}{NBSP}j sur Immo-Ka")
439 + x = M
440 + for chip in chips[:8]:
441 + x = s.pilule(x, y - 6, chip)
442 + y -= 30
443 +
444 + # --- photos : 1 grande + 3 vignettes ---------------------------------------
445 + imgs = [im for im in (_fetch_image(u) for u in l["images"][:4]) if im]
446 + if imgs:
447 + big_h = 170
448 + big_w = big_h * 16 / 10.5
449 + c.setStrokeColor(INK)
450 + c.setLineWidth(1.5)
451 + try:
452 + c.drawImage(imgs[0], M, y - big_h, big_w, big_h,
453 + preserveAspectRatio=True, anchor="c", mask="auto")
454 + except Exception:
455 + pass
456 + c.rect(M, y - big_h, big_w, big_h, stroke=1, fill=0)
457 + tx = M + big_w + 10
458 + th = (big_h - 16) / 3
459 + for i, im in enumerate(imgs[1:4]):
460 + ty = y - (i + 1) * th - i * 8
461 + try:
462 + c.drawImage(im, tx, ty, PAGE_W - M - tx, th,
463 + preserveAspectRatio=True, anchor="c", mask="auto")
464 + c.rect(tx, ty, PAGE_W - M - tx, th, stroke=1, fill=0)
465 + except Exception:
466 + pass
467 + y -= big_h + 18
468 +
469 + # --- baisse/hausse de prix (bandeau, comme le site) -------------------------
470 + ph = [p for p in price_history if p.get("price")]
471 + if len(ph) >= 2 and ph[0]["price"] != ph[1]["price"]:
472 + y = place(y, 34)
473 + de, a = ph[1]["price"], ph[0]["price"]
474 + bas = a < de
475 + c.setFillColor(GREEN_BG if bas else RED_BG)
476 + c.setStrokeColor(GREEN_FG if bas else RED_FG)
477 + c.setLineWidth(1.2)
478 + c.roundRect(M, y - 20, PAGE_W - 2 * M, 24, 5, stroke=1, fill=1)
479 + c.setFont("Helvetica-Bold", 9)
480 + c.setFillColor(GREEN_FG if bas else RED_FG)
481 + c.drawString(M + 10, y - 12,
482 + f"{'▼' if bas else '▲'} Prix passé de {_fmt_money(de)} à "
483 + f"{_fmt_money(a)} le {_fmt_jour(ph[0]['ts'])}")
484 + y -= 34
485 +
486 + # --- description | inclusions (2 colonnes, comme le site) -------------------
487 + col_g = PAGE_W * 0.56 - M
488 + feats = [str(f) for f in (l.get("features") or []) if f]
489 + desc = (l.get("description") or "").strip()
490 + if desc or feats:
491 + besoin = 30 + max(
492 + s.nb_lignes(desc, col_g, max_lignes=26) * 11.5 if desc else 0,
493 + min(len(feats), 18) * 12)
494 + y = place(y, min(besoin, 340))
495 + y_desc = y
496 + if desc:
497 + y_desc = s.titre_section(y, "Description")
498 + y_desc = s.paragraphe(M, y_desc, desc, col_g, max_lignes=26)
499 + y_incl = y
500 + if feats:
501 + xr = M + col_g + 24
502 + y_incl = _titre_droite(c, y, xr, "Inclusions et caractéristiques")
503 + c.setFont("Helvetica", 8.5)
504 + for f in feats[:18]:
505 + c.setFillColor(ACCENT_DEEP)
506 + c.drawString(xr, y_incl, "✓")
507 + c.setFillColor(INK2)
508 + c.drawString(xr + 12, y_incl, f[:42])
509 + y_incl -= 12
510 + if len(feats) > 18:
511 + c.setFillColor(INK3)
512 + c.drawString(xr + 12, y_incl, f"+ {len(feats) - 18} autres…")
513 + y_incl -= 12
514 + y = min(y_desc, y_incl) - 16
515 +
516 + # --- caractéristiques (details, mêmes exclusions que le site) ---------------
517 + dets = [(str(k), str(v)) for k, v in d.items()
518 + if k not in DETAIL_HIDDEN and v not in (None, "", [], {})
519 + and str(v).strip("-— ") != "" and not isinstance(v, (dict, list))]
520 + if dets:
521 + col_w = PAGE_W - 2 * M
522 + besoin = 40 + sum(
523 + s.nb_lignes(v, col_w - 190, taille=8, max_lignes=4) * 11 + 3
524 + for _, v in dets)
525 + y = place(y, min(besoin, 420))
526 + y = s.titre_section(y, "Caractéristiques")
527 + c.setFont("Courier-Bold", 7)
528 + c.setFillColor(INK3)
529 + c.drawString(M, y, "DÉTAIL")
530 + c.drawString(M + 180, y, "VALEUR")
531 + y -= 4
532 + c.setStrokeColor(INK)
533 + c.setLineWidth(1.5)
534 + c.line(M, y, PAGE_W - M, y)
535 + y -= 12
536 + for i, (k, v) in enumerate(dets):
537 + nl = s.nb_lignes(v, col_w - 190, taille=8, max_lignes=4)
538 + y = place(y, nl * 11 + 6)
539 + if i % 2 == 0: # zébrage éditorial
540 + c.setFillColor(SURFACE)
541 + c.rect(M - 3, y - (nl - 1) * 11 - 3, col_w + 6, nl * 11 + 2,
542 + stroke=0, fill=1)
543 + c.setFont("Helvetica-Bold", 8)
544 + c.setFillColor(INK)
545 + c.drawString(M, y, k[:38])
546 + y = s.paragraphe(M + 180, y, v, col_w - 190, taille=8,
547 + interligne=11, max_lignes=4)
548 + y -= 3
549 + y -= 10
550 +
551 + # --- pièces (details.pieces) -------------------------------------------------
552 + pieces = d.get("pieces") or []
553 + if isinstance(pieces, list) and pieces:
554 + rows = pieces[:24]
555 + y = place(y, 46 + len(rows) * 12)
556 + y = s.titre_section(y, "Pièces")
557 + c.setFont("Courier-Bold", 7)
558 + c.setFillColor(INK3)
559 + for lab, xoff in (("PIÈCE", 0), ("NIVEAU", 190), ("DIMENSIONS", 330),
560 + ("REVÊTEMENT", 430)):
561 + c.drawString(M + xoff, y, lab)
562 + y -= 4
563 + c.setStrokeColor(INK)
564 + c.setLineWidth(1.5)
565 + c.line(M, y, PAGE_W - M, y)
566 + y -= 11
567 + for i, p in enumerate(rows):
568 + if i % 2 == 0:
569 + c.setFillColor(SURFACE)
570 + c.rect(M - 3, y - 3, PAGE_W - 2 * M + 6, 12, stroke=0, fill=1)
571 + c.setFont("Helvetica-Bold", 8)
572 + c.setFillColor(INK)
573 + c.drawString(M, y, str(p.get("nom") or "—")[:36])
574 + c.setFont("Helvetica", 8)
575 + c.setFillColor(INK2)
576 + c.drawString(M + 190, y, str(p.get("niveau") or "—")[:24])
577 + c.drawString(M + 330, y, str(p.get("dimensions") or "—")[:18])
578 + c.drawString(M + 430, y, str(p.get("revetement") or "—")[:22])
579 + y -= 12
580 + y -= 10
581 +
582 + # --- analyse Vrai-Prix (valeur, jauge, rôle foncier) -------------------------
583 + if vp and vp.get("value") is not None:
584 + role = [(lab, vp.get(k)) for k, lab in
585 + (("valeur_role", "Évaluation au rôle"),
586 + ("valeur_terrain", "Valeur du terrain"),
587 + ("valeur_batiment", "Valeur du bâtiment"))
588 + if vp.get(k)]
589 + besoin = 96 + (56 if vp.get("low") is not None else 0) \
590 + + (62 if role else 0) + 18
591 + y = place(y, besoin)
592 + y = s.titre_section(y, "Analyse Vrai-Prix")
593 + val_txt = _fmt_money(round(vp["value"]))
594 + c.setFont("Helvetica-Bold", 17)
595 + c.setFillColor(INK)
596 + c.drawString(M, y - 10, val_txt)
597 + c.setFont("Helvetica", 8.5)
598 + c.setFillColor(INK2)
599 + c.drawString(M + c.stringWidth(val_txt, "Helvetica-Bold", 17) + 8,
600 + y - 10, "— valeur marchande estimée par le modèle Vrai-Prix")
601 + meta = []
602 + if vp.get("confidence"):
603 + meta.append(f"confiance {vp['confidence']}"
604 + + (f" ({vp['confidence_pct']}{NBSP}%)"
605 + if vp.get("confidence_pct") else ""))
606 + if vp.get("annee_construction_role"):
607 + meta.append(f"construction {vp['annee_construction_role']} (rôle)")
608 + if vp.get("superficie_terrain_role_m2"):
609 + meta.append(
610 + f"terrain {round(vp['superficie_terrain_role_m2']):,}".replace(",", NBSP)
611 + + f"{NBSP}m² (rôle)")
612 + if meta:
613 + c.setFont("Helvetica", 8)
614 + c.setFillColor(INK3)
615 + c.drawString(M, y - 24, " · ".join(meta)[:115])
616 + verd = _verdict_vraiprix(l.get("price"), vp)
617 + if verd:
618 + txt, fg, bg, stk = verd
619 + s.pilule(M, y - 48, txt, fg, bg, stk)
620 + y -= 58
621 + lo, hi = vp.get("low"), vp.get("high")
622 + if lo is not None and hi is not None and hi > lo:
623 + larg = PAGE_W - 2 * M
624 + base = y - 22
625 + span = hi - lo
626 + c.setFillColor(ACCENT_SOFT)
627 + c.setStrokeColor(INK)
628 + c.setLineWidth(1)
629 + c.roundRect(M, base, larg, 12, 6, stroke=1, fill=1)
630 +
631 + def _pos(v):
632 + return M + larg * max(0.02, min(0.98, (v - lo) / span))
633 +
634 + px = _pos(vp["value"])
635 + c.setStrokeColor(ACCENT_DEEP)
636 + c.setLineWidth(2.5)
637 + c.line(px, base - 3, px, base + 15)
638 + c.setFont("Courier-Bold", 6.5)
639 + c.setFillColor(ACCENT_DEEP)
640 + c.drawString(min(max(px - 30, M), PAGE_W - M - 100), base + 18,
641 + f"ESTIMATION · {_fmt_money(round(vp['value']))}")
642 + if l.get("price") and lo * 0.7 <= l["price"] <= hi * 1.3:
643 + ax = _pos(l["price"])
644 + c.setStrokeColor(INK)
645 + c.setLineWidth(2)
646 + c.line(ax, base - 3, ax, base + 15)
647 + c.setFont("Courier-Bold", 6.5)
648 + c.setFillColor(INK)
649 + c.drawString(min(max(ax - 30, M), PAGE_W - M - 116), base - 12,
650 + f"PRIX DEMANDÉ · {_fmt_money(l['price'])}")
651 + c.setFont("Helvetica", 6.5)
652 + c.setFillColor(INK3)
653 + c.drawString(M, base - 24, _fmt_money(round(lo)))
654 + c.drawRightString(PAGE_W - M, base - 24,
655 + _fmt_money(round(hi)) + " — fourchette d'estimation")
656 + y = base - 36
657 + if role:
658 + tw = (PAGE_W - 2 * M - (len(role) - 1) * 10) / len(role)
659 + for i, (lab, v) in enumerate(role):
660 + s.tuile(M + i * (tw + 10), y - 44, tw, 38,
661 + _fmt_money(round(v)), lab)
662 + y -= 56
663 + c.setFont("Helvetica", 6.5)
664 + c.setFillColor(INK3)
665 + c.drawString(M, y - 2, "Estimation automatisée Vrai-Prix (vrai-prix.ca) "
666 + "croisée au rôle d'évaluation foncière — ne "
667 + "remplace pas une évaluation professionnelle.")
668 + y -= 16
669 +
670 + # --- historique de prix -------------------------------------------------------
671 + if len(ph) >= 2:
672 + rows = ph[:8]
673 + y = place(y, 46 + len(rows) * 12)
674 + y = s.titre_section(y, "Historique du prix demandé")
675 + c.setFont("Courier-Bold", 7)
676 + c.setFillColor(INK3)
677 + for lab, xoff in (("DATE", 0), ("PRIX AFFICHÉ", 190), ("VARIATION", 330)):
678 + c.drawString(M + xoff, y, lab)
679 + y -= 4
680 + c.setStrokeColor(INK)
681 + c.setLineWidth(1.5)
682 + c.line(M, y, PAGE_W - M, y)
683 + y -= 11
684 + for i, p in enumerate(rows):
685 + prec = rows[i + 1]["price"] if i + 1 < len(rows) else None
686 + c.setFont("Helvetica", 8)
687 + c.setFillColor(INK2)
688 + c.drawString(M, y, _fmt_jour(p["ts"]))
689 + c.setFont("Helvetica-Bold", 8)
690 + c.setFillColor(INK)
691 + c.drawString(M + 190, y, _fmt_money(p["price"]))
692 + if prec and p.get("price"):
693 + delta = p["price"] - prec
694 + c.setFont("Courier-Bold", 7.5)
695 + c.setFillColor(GREEN_FG if delta < 0
696 + else (RED_FG if delta > 0 else INK3))
697 + sig = "▼" if delta < 0 else ("▲" if delta > 0 else "=")
698 + c.drawString(M + 330, y,
699 + f"{sig} {_fmt_money(abs(delta))}" if delta else "=")
700 + y -= 12
701 + y -= 10
702 +
703 + # --- risque d'inondation (BDZI) ------------------------------------------------
704 + if inon:
705 + zones = (inon.get("zones") or [])[:3]
706 + y = place(y, 56 + len(zones) * 12)
707 + y = s.titre_section(y, "Risque d'inondation")
708 + statut = inon.get("statut")
709 + sev = {"eleve": "récurrence élevée", "modere": "récurrence modérée",
710 + "present": "zone cartographiée"}.get(inon.get("severite") or "", "")
711 + if statut == "en_zone":
712 + s.pilule(M, y - 12, f"En zone inondable ({sev})" if sev
713 + else "En zone inondable", RED_FG, RED_BG, RED_FG)
714 + elif statut == "a_proximite":
715 + d0 = zones[0]["distance_m"] if zones else "?"
716 + s.pilule(M, y - 12, f"Zone inondable à ~{d0} m",
717 + AMBER_FG, AMBER_BG, AMBER_FG)
718 + elif statut == "hors_zone":
719 + s.pilule(M, y - 12, "Hors des zones inondables cartographiées",
720 + GREEN_FG, GREEN_BG, GREEN_FG)
721 + else:
722 + s.pilule(M, y - 12, "Secteur non couvert par la cartographie BDZI",
723 + INK2, SURFACE, INK)
724 + y -= 26
725 + for z in zones:
726 + c.setFont("Helvetica", 8)
727 + c.setFillColor(INK2)
728 + c.drawString(M, y, f"{z.get('type') or 'Zone'} — à {z.get('distance_m')} m"
729 + + (f" (rapport {z['date_rapport']})"
730 + if z.get("date_rapport") else ""))
731 + y -= 12
732 + y -= 8
733 +
734 + # --- qualité de l'air (RSQAQ) ---------------------------------------------------
735 + if air_d and air_d.get("station"):
736 + mes = air_d.get("mesures") or {}
737 + y = place(y, 44 + len(mes) * 11)
738 + y = s.titre_section(y, "Qualité de l'air")
739 + c.setFont("Helvetica", 8.5)
740 + c.setFillColor(INK2)
741 + c.drawString(M, y - 2,
742 + f"Station {air_d['station']} ({air_d['ville']}), à "
743 + + f"{air_d['distance_km']}".replace(".", ",") + f"{NBSP}km "
744 + "de la propriété — moyennes annuelles :")
745 + y -= 16
746 + for pol, m_ in list(mes.items())[:5]:
747 + c.setFont("Helvetica-Bold", 8)
748 + c.setFillColor(INK)
749 + c.drawString(M, y, pol)
750 + c.setFont("Helvetica", 8)
751 + c.setFillColor(INK2)
752 + val = (f"{m_['moyenne']:.1f}".replace(".", ",")
753 + if m_.get("moyenne") is not None else "—")
754 + txt = f"{val}{NBSP}{m_.get('unite', '')} ({m_.get('annee', '')})"
755 + if m_.get("ref"):
756 + txt += f" · référence {m_.get('ref_nom', '')} : {m_['ref']}"
757 + c.drawString(M + 56, y, txt[:95])
758 + y -= 11
759 + y -= 8
760 +
761 + # --- coût d'électricité (Hydro-Québec) -------------------------------------------
762 + if hyd and hyd.get("disponible"):
763 + y = place(y, 52)
764 + y = s.titre_section(y, "Coût d'électricité (Hydro-Québec)")
765 + c.setFont("Helvetica-Bold", 15)
766 + c.setFillColor(INK)
767 + mens = f"≈{NBSP}{_fmt_money(round(hyd['cout_mensuel']))} / mois"
768 + c.drawString(M, y - 8, mens)
769 + c.setFont("Helvetica", 8.5)
770 + c.setFillColor(INK2)
771 + suite = f"{_fmt_money(round(hyd['cout_annuel']))} / an"
772 + if hyd.get("kwh_annuel"):
773 + suite += (f" · {round(hyd['kwh_annuel']):,}".replace(",", NBSP)
774 + + f"{NBSP}kWh/an")
775 + suite += " — estimation officielle Hydro-Québec pour cette adresse."
776 + c.drawString(M + c.stringWidth(mens, "Helvetica-Bold", 15) + 10, y - 8, suite)
777 + y -= 26
778 +
779 + # --- commerces et transport --------------------------------------------------------
780 + if com and (com.get("commerces") or com.get("transit")):
781 + entrees = ([(t["commerce"], t["nom"], t["dist_m"])
782 + for t in (com.get("transit") or [])[:4]]
783 + + [(i["commerce"], i["nom"], i["dist_m"])
784 + for i in (com.get("commerces") or [])[:12]])
785 + lignes = (len(entrees) + 1) // 2
786 + y = place(y, 36 + lignes * 13)
787 + y = s.titre_section(y, "Commerces et transport")
788 + col_w = (PAGE_W - 2 * M) / 2
789 + for i, (cat, nom, dist) in enumerate(entrees):
790 + px = M if i % 2 == 0 else M + col_w + 10
791 + py = y - (i // 2) * 13
792 + c.setFont("Helvetica", 8)
793 + c.setFillColor(INK2)
794 + nomc = nom if len(nom) <= 22 else nom[:21] + "…"
795 + c.drawString(px, py, f"{cat} — {nomc}")
796 + c.setFont("Courier-Bold", 7.5)
797 + c.setFillColor(INK)
798 + c.drawRightString(px + col_w - 14, py, _fmt_dist(dist))
799 + y -= lignes * 13 + 12
800 +
801 + # --- essence à proximité --------------------------------------------------------------
802 + if gaz_d and gaz_d.get("stations"):
803 + sts = gaz_d["stations"][:3]
804 + y = place(y, 40 + len(sts) * 11)
805 + y = s.titre_section(y, "Essence à proximité")
806 + if gaz_d.get("mediane_regulier"):
807 + c.setFont("Helvetica", 8.5)
808 + c.setFillColor(INK2)
809 + c.drawString(M, y - 2, "Prix médian de l'essence régulière à <5 km : "
810 + + f"{gaz_d['mediane_regulier']}".replace(".", ",")
811 + + f"{NBSP}¢/L")
812 + y -= 14
813 + for st in sts:
814 + c.setFont("Helvetica", 8)
815 + c.setFillColor(INK2)
816 + reg_p = (f"{st['regulier']}".replace(".", ",") + f"{NBSP}¢/L"
817 + if st.get("regulier") else "—")
818 + c.drawString(M, y, f"{st['nom']} — {_fmt_dist(st['dist_m'])} : {reg_p}"
819 + + (" · la moins chère" if st.get("moins_chere") else ""))
820 + y -= 11
821 + y -= 8
822 +
823 + # --- le quartier -------------------------------------------------------------------------
824 + if quartier and (quartier.get("demographie") or quartier.get("proximite")
825 + or quartier.get("chaleur") or quartier.get("crime")):
826 + demo = quartier.get("demographie") or {}
827 + prox = quartier.get("proximite") or {}
828 + prox_aff = [(k, lab) for k, lab in PROX_LABELS
829 + if isinstance(prox.get(k), (int, float))]
830 + besoin = 44 + (94 if demo else 0) + len(prox_aff) * 14 + 44
831 + y = place(y, besoin)
832 + y = s.titre_section(y, "Le quartier")
833 + c.setFont("Helvetica", 7.5)
834 + c.setFillColor(INK3)
835 + c.drawString(M, y, "Secteur immédiat de la propriété (aire de diffusion "
836 + "du recensement 2021, ± 500 habitants).")
837 + y -= 14
838 + if demo:
839 + tuiles = [("Revenu médian", _fmt_money(demo.get("revenu_median"))),
840 + ("Ménages locataires",
841 + f"{round(demo['pct_locataires'])}{NBSP}%"
842 + if demo.get("pct_locataires") is not None else "—"),
843 + ("Loyer moyen secteur", _fmt_money(demo.get("loyer_moyen"))),
844 + ("Âge médian",
845 + f"{round(demo['age_median'])} ans"
846 + if demo.get("age_median") is not None else "—"),
847 + ("Français à la maison",
848 + f"{round(demo['pct_francais'])}{NBSP}%"
849 + if demo.get("pct_francais") is not None else "—"),
850 + ("Diplôme universitaire",
851 + f"{round(demo['pct_univ'])}{NBSP}%"
852 + if demo.get("pct_univ") is not None else "—")]
853 + tw = (PAGE_W - 2 * M - 20) / 3
854 + for i, (lab, val) in enumerate(tuiles):
855 + s.tuile(M + (i % 3) * (tw + 10), y - 34 - (i // 3) * 44,
856 + tw, 38, val, lab)
857 + y -= 34 + 44 + 16
858 + for k, lab in prox_aff:
859 + v = max(0.0, min(1.0, prox[k]))
860 + c.setFont("Helvetica", 8)
861 + c.setFillColor(INK2)
862 + c.drawString(M, y, lab)
863 + bx0, bw_ = M + 130, PAGE_W - 2 * M - 160
864 + c.setFillColor(SURFACE)
865 + c.setStrokeColor(INK)
866 + c.setLineWidth(0.8)
867 + c.roundRect(bx0, y - 1, bw_, 7, 3.5, stroke=1, fill=1)
868 + c.setFillColor(ACCENT)
869 + c.roundRect(bx0, y - 1, max(3, bw_ * v), 7, 3.5, stroke=0, fill=1)
870 + c.setFont("Courier-Bold", 7.5)
871 + c.setFillColor(INK)
872 + c.drawRightString(PAGE_W - M, y, str(round(v * 100)))
873 + y -= 14
874 + if prox_aff:
875 + c.setFont("Helvetica", 6.5)
876 + c.setFillColor(INK3)
877 + c.drawString(M, y, "Accessibilité 0–100 — mesures de proximité de "
878 + "Statistique Canada.")
879 + y -= 12
880 + badges_q = []
881 + ch = quartier.get("chaleur")
882 + if ch:
883 + if ch.get("classe", 5) <= 3:
884 + badges_q.append(("Îlot de fraîcheur", GREEN_FG, GREEN_BG, GREEN_FG))
885 + elif ch.get("classe", 5) >= 7:
886 + ec = (f" (+{ch['ecart']:.1f} °C)".replace(".", ",")
887 + if ch.get("ecart") is not None else "")
888 + badges_q.append((f"Îlot de chaleur{ec}",
889 + AMBER_FG, AMBER_BG, AMBER_FG))
890 + cr = quartier.get("crime")
891 + if cr and cr.get("type") == "igc" and cr.get("indice_canada"):
892 + dlt = round(100 * (cr["indice"] - cr["indice_canada"])
893 + / cr["indice_canada"])
894 + badges_q.append((f"Criminalité {abs(dlt)}{NBSP}% "
895 + f"{'sous' if dlt <= 0 else 'au-dessus de'} "
896 + "la moyenne canadienne", INK2, SURFACE, INK))
897 + elif cr and cr.get("type") == "points":
898 + badges_q.append((f"{cr['douze_mois']} actes criminels à <500 m "
899 + "(12 mois)", INK2, SURFACE, INK))
900 + if badges_q:
901 + x = M
902 + for txt, fg, bg, stk in badges_q:
903 + x = s.pilule(x, y - 8, txt, fg, bg, stk)
904 + y -= 26
905 + y -= 6
906 +
907 + # --- références : QR + courtier + source ------------------------------------------------
908 + y = place(y, 140)
909 + y = s.titre_section(y, "Références")
910 + qr = qrcode.make(f"https://www.immo-ka.com/propriete/{uid}",
911 + box_size=4, border=1)
912 + qb = io.BytesIO()
913 + qr.save(qb, format="PNG")
914 + c.drawImage(ImageReader(io.BytesIO(qb.getvalue())), M, y - 74, 70, 70)
915 + yy = y - 14
916 + c.setFont("Helvetica-Bold", 9)
917 + c.setFillColor(INK)
918 + courtier = l.get("broker_name") or d.get("Courtier inscripteur")
919 + if courtier:
920 + ligne = f"Courtier : {courtier}"
921 + if l.get("broker_phone"):
922 + ligne += f" · {l['broker_phone']}"
923 + c.drawString(M + 82, yy, ligne[:95])
924 + yy -= 13
925 + c.drawString(M + 82, yy, f"Agence : {src_name}")
926 + yy -= 14
927 + c.setFont("Helvetica", 8)
928 + c.setFillColor(INK2)
929 + c.drawString(M + 82, yy, "Annonce originale :")
930 + yy -= 11
931 + c.setFillColor(ACCENT_DEEP)
932 + c.drawString(M + 82, yy,
933 + (l.get("url") or d.get("listing_origin_url") or "")[:90])
934 + yy -= 14
935 + c.setFillColor(INK2)
936 + c.drawString(M + 82, yy, "Fiche à jour en ligne (scannez le code) :")
937 + yy -= 11
938 + c.setFillColor(ACCENT_DEEP)
939 + c.drawString(M + 82, yy, f"www.immo-ka.com/propriete/{uid}")
940 + y -= 90
941 + s.paragraphe(M, y, "Sources : annonce de l'agence, Vrai-Prix (vrai-prix.ca), "
942 + "rôle d'évaluation foncière, Statistique Canada (2021), "
943 + "INSPQ (CC-BY 4.0), Ville de Montréal (CC-BY 4.0), BDZI, "
944 + "RSQAQ, Hydro-Québec, gazquebec.ca, OpenStreetMap.",
945 + PAGE_W - 2 * M, taille=6.5, couleur=INK3, interligne=9)
946 +
947 + s.pied(_PIED_FICHE, page[0])
948 + c.save()
949 + return buf.getvalue()
modified immoka/web.py +16 −0
@@ -211,6 +211,22 @@ def get_listing(uid: str):
211 211 return d
212 212
213 213
214 +@app.get("/api/listings/{uid}/pdf")
215 +def listing_pdf(uid: str):
216 + """Fiche PDF complète de la propriété (immoka/pdffiche.py) — reflète tous
217 + les blocs de la fiche web : prix + verdict Vrai-Prix, photos, description,
218 + caractéristiques, pièces, analyses, environnement, quartier, QR."""
219 + from . import pdffiche
220 + data = pdffiche.fiche_pdf(uid)
221 + if data is None:
222 + raise HTTPException(404, "Propriété introuvable")
223 + safe = uid.replace(":", "-").replace("/", "-")
224 + return Response(
225 + content=data, media_type="application/pdf",
226 + headers={"Content-Disposition":
227 + f'attachment; filename="immo-ka_fiche_{safe}.pdf"'})
228 +
229 +
214 230 @app.get("/api/listings.geojson")
215 231 def listings_geojson(
216 232 city: str | None = None,
modified requirements.txt +2 −0
@@ -3,3 +3,5 @@ beautifulsoup4>=4.12
3 3 fastapi>=0.110
4 4 uvicorn>=0.29
5 5 fpdf2>=2.8
6 +reportlab>=4.0
7 +qrcode>=7.4
6 8