# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # web.py : API FastAPI (JSON) + service du frontend React (frontend/dist) # ----------------------------------------------------------------------------- from __future__ import annotations import json import threading from pathlib import Path from fastapi import BackgroundTasks, FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from . import db, ingest ROOT = Path(__file__).resolve().parent.parent SOURCES_PATH = ROOT / "data" / "sources.json" FRONTEND_DIST = ROOT / "frontend" / "dist" app = FastAPI(title="Lou-Ka API", version="1.0", description="Agrégateur de logements à louer — province de Québec") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) _sync_lock = threading.Lock() def _row_to_dict(row) -> dict: d = dict(row) d["amenities"] = json.loads(d.get("amenities") or "[]") d["images"] = json.loads(d.get("images") or "[]") d["details"] = json.loads(d.get("details") or "{}") if d.get("furnished") is not None: d["furnished"] = bool(d["furnished"]) return d @app.get("/api/listings") def list_listings( city: str | None = None, sector: str | None = None, unit_type: str | None = None, source: str | None = None, price_max: float | None = None, price_min: float | None = None, pets: str | None = None, # "oui" -> oui OU conditions furnished: int | None = None, # 1 / 0 available_by: str | None = None, # ISO : dispo maintenant ou avant date area_min: float | None = None, # superficie minimale (pi²) q: str | None = None, active: int = 1, limit: int = Query(500, le=2000), offset: int = 0, ): con = db.connect() sql = "SELECT * FROM listings WHERE 1=1" args: list = [] if active in (0, 1): sql += " AND active=?"; args.append(active) if city: sql += " AND city=?"; args.append(city) if sector: sql += " AND sector LIKE ?"; args.append(f"%{sector}%") if unit_type: sql += " AND unit_type=?"; args.append(unit_type) if source: sql += " AND source=?"; args.append(source) if price_max is not None: sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max) if price_min is not None: sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min) if pets == "oui": sql += " AND pets IN ('oui','conditions')" elif pets: sql += " AND pets=?"; args.append(pets) if furnished in (0, 1): sql += " AND furnished=?"; args.append(furnished) if available_by: # « now » trié après les dates ISO : comparaison explicite sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \ " AND availability_date<=?))" args.append(available_by) if area_min is not None: sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min) if q: sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)" args += [f"%{q}%"] * 3 total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"] sql += " ORDER BY price IS NULL, price ASC LIMIT ? OFFSET ?" args += [limit, offset] rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()] con.close() return {"total": total, "count": len(rows), "listings": rows} @app.get("/api/listings.geojson") def listings_geojson( city: str | None = None, sector: str | None = None, unit_type: str | None = None, source: str | None = None, price_max: float | None = None, price_min: float | None = None, pets: str | None = None, furnished: int | None = None, available_by: str | None = None, area_min: float | None = None, q: str | None = None, ): """Annonces géolocalisées au format GeoJSON (mêmes filtres que /api/listings). Léger par conception : seuls les champs nécessaires aux marqueurs et aux fiches de la carte sont inclus (1 photo, pas de description). """ con = db.connect() sql = ("SELECT uid, title, price, price_label, unit_type, availability_date," " source, city, sector, area_sqft, images, lat, lng FROM listings" " WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL") args: list = [] if city: sql += " AND city=?"; args.append(city) if sector: sql += " AND sector LIKE ?"; args.append(f"%{sector}%") if unit_type: sql += " AND unit_type=?"; args.append(unit_type) if source: sql += " AND source=?"; args.append(source) if price_max is not None: sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max) if price_min is not None: sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min) if pets == "oui": sql += " AND pets IN ('oui','conditions')" elif pets: sql += " AND pets=?"; args.append(pets) if furnished in (0, 1): sql += " AND furnished=?"; args.append(furnished) if available_by: sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \ " AND availability_date<=?))" args.append(available_by) if area_min is not None: sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min) if q: sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)" args += [f"%{q}%"] * 3 features = [] for r in con.execute(sql, args).fetchall(): images = json.loads(r["images"] or "[]") features.append({ "type": "Feature", "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]}, "properties": { "uid": r["uid"], "title": r["title"], "price": r["price"], "price_label": r["price_label"], "unit_type": r["unit_type"], "availability_date": r["availability_date"], "source": r["source"], "city": r["city"], "sector": r["sector"], "area_sqft": r["area_sqft"], "image": images[0] if images else None, }, }) con.close() return {"type": "FeatureCollection", "features": features} @app.get("/api/listings/{uid}/pdf") def listing_pdf(uid: str): """Fiche de propriété PDF (photos, prix, quartier, QR) — voir pdfgen.py.""" from fastapi.responses import Response from . import pdfgen data = pdfgen.fiche_pdf(uid) if data is None: raise HTTPException(404, "Annonce introuvable") nom = uid.replace(":", "-") return Response(content=data, media_type="application/pdf", headers={ "Content-Disposition": f'attachment; filename="louka-fiche-{nom}.pdf"'}) @app.get("/api/stats/rapport.pdf") def rapport_marche_pdf(): """Rapport global du marché locatif (PDF multi-pages).""" from fastapi.responses import Response from . import pdfgen return Response(content=pdfgen.rapport_pdf(), media_type="application/pdf", headers={"Content-Disposition": 'attachment; filename="louka-rapport-marche.pdf"'}) @app.get("/api/listings/{uid}") def get_listing(uid: str): con = db.connect() row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone() d = None if row is not None: d = _row_to_dict(row) # commodités de proximité (cache par immeuble, voir louka/poi.py) if d.get("lat") is not None and d.get("lng") is not None: key = f"{round(d['lat'], 4)},{round(d['lng'], 4)}" poi_row = con.execute( "SELECT pois FROM poi_cache WHERE coord_key=?", (key,)).fetchone() d["poi"] = json.loads(poi_row["pois"]) if poi_row else [] else: d["poi"] = [] # statistiques de quartier (recensement, proximité, chaleur, criminalité) from . import quartier dauid = d.get("dauid") d["quartier"] = quartier.fiche_quartier( d.get("lat"), d.get("lng"), d.get("city") or "", dauid if dauid and dauid != "hors-zone" else None) # description structurée + historique de prix d["digest"] = json.loads(d["digest"]) if d.get("digest") else None d["price_history"] = [dict(r) for r in con.execute( "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 6", (uid,)).fetchall()] con.close() if d is None: raise HTTPException(404, "Annonce introuvable") return d @app.get("/api/facets") def facets(city: str | None = None): """Valeurs distinctes pour construire les filtres du frontend. `city` (optionnel) restreint la liste des quartiers à cette ville — utilisé par le sélecteur « Quartier » dépendant de « Ville ». """ con = db.connect() sector_sql = "SELECT DISTINCT sector FROM listings WHERE active=1 AND sector<>''" sector_args: list = [] if city: sector_sql += " AND city=?" sector_args.append(city) out = { "cities": [r["city"] for r in con.execute( "SELECT DISTINCT city FROM listings WHERE active=1 AND city<>'' ORDER BY city")], "sectors": [r["sector"] for r in con.execute( sector_sql + " ORDER BY sector", sector_args)], "unit_types": [r["unit_type"] for r in con.execute( "SELECT DISTINCT unit_type FROM listings WHERE active=1 AND unit_type<>'' ORDER BY unit_type")], "sources": [dict(r) for r in con.execute( "SELECT source, COUNT(*) n FROM listings WHERE active=1 GROUP BY source ORDER BY n DESC")], } con.close() return out @app.get("/api/sources") def sources(): registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"] con = db.connect() counts = {r["source"]: r["n"] for r in con.execute( "SELECT source, COUNT(*) n FROM listings WHERE active=1 GROUP BY source")} last = {r["source"]: r["ts"] for r in con.execute( "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")} con.close() for s in registry: s["active_listings"] = counts.get(s["id"], 0) s["last_sync"] = last.get(s["id"]) return {"sources": registry} # Villes de la grande région de Montréal (le reste de la province est # compté dans « autres » — Outaouais, Estrie, Mauricie, Est-du-Québec…) _VILLES_GM = ( "Montréal", "Montréal-Est", "Laval", "Longueuil", "Brossard", "Terrebonne", "Mascouche", "Repentigny", "Boucherville", "Saint-Lambert", "Westmount", "Côte Saint-Luc", "Dorval", "Pointe-Claire", "Verdun", "LaSalle", "Anjou", "Saint-Constant", "Delson", "Candiac", "Châteauguay", "Chambly", "Sainte-Julie", "Varennes", "Belœil", "McMasterville", "Mont-Saint-Hilaire", "Saint-Basile-le-Grand", "Saint-Bruno-de-Montarville", "Boisbriand", "Richelieu", "Beauharnois", "Salaberry-de-Valleyfield", "Vaudreuil-Dorion", "Kirkland", "Beaconsfield", "Dollard-des-Ormeaux", "Mont-Royal", "Outremont", ) @app.get("/api/stats") def stats(): con = db.connect() gm = ",".join("?" * len(_VILLES_GM)) row = con.execute( f"""SELECT COUNT(*) total, SUM(CASE WHEN city='Québec' THEN 1 ELSE 0 END) quebec, SUM(CASE WHEN city='Lévis' THEN 1 ELSE 0 END) levis, SUM(CASE WHEN city IN ({gm}) THEN 1 ELSE 0 END) montreal, SUM(CASE WHEN city NOT IN ('Québec','Lévis') AND city NOT IN ({gm}) THEN 1 ELSE 0 END) autres, COUNT(DISTINCT source) sources, AVG(price) avg_price FROM listings WHERE active=1""", list(_VILLES_GM) + list(_VILLES_GM)).fetchone() log = [dict(r) for r in con.execute( "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")] con.close() return {**dict(row), "recent_syncs": log} @app.get("/api/stats/detailed") def stats_detailed(): """Agrégats du marché (source unique : louka/marketstats.py).""" from . import marketstats return marketstats.compute() @app.post("/api/sync") def trigger_sync(background: BackgroundTasks, source: str | None = None): """Déclenche une synchronisation (équivalent d'un webhook entrant).""" def _job(): with _sync_lock: ingest.run([source] if source else None) background.add_task(_job) return {"status": "démarré", "source": source or "toutes"} # --- Frontend React (build Vite) -------------------------------------------- if FRONTEND_DIST.exists(): app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") @app.get("/{full_path:path}") def spa(full_path: str): target = FRONTEND_DIST / full_path if full_path and target.is_file(): return FileResponse(target) return FileResponse(FRONTEND_DIST / "index.html")