SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
13.0 KB · 330 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# web.py : API FastAPI (JSON) + service du frontend React (frontend/dist)5# -----------------------------------------------------------------------------6from __future__ import annotations78import json9import threading10from pathlib import Path1112from fastapi import BackgroundTasks, FastAPI, HTTPException, Query13from fastapi.middleware.cors import CORSMiddleware14from fastapi.responses import FileResponse15from fastapi.staticfiles import StaticFiles1617from . import db, ingest1819ROOT = Path(__file__).resolve().parent.parent20SOURCES_PATH = ROOT / "data" / "sources.json"21FRONTEND_DIST = ROOT / "frontend" / "dist"2223app = FastAPI(title="Lou-Ka API", version="1.0",24              description="Agrégateur de logements à louer — province de Québec")25app.add_middleware(CORSMiddleware, allow_origins=["*"],26                   allow_methods=["*"], allow_headers=["*"])2728_sync_lock = threading.Lock()293031def _row_to_dict(row) -> dict:32    d = dict(row)33    d["amenities"] = json.loads(d.get("amenities") or "[]")34    d["images"] = json.loads(d.get("images") or "[]")35    d["details"] = json.loads(d.get("details") or "{}")36    if d.get("furnished") is not None:37        d["furnished"] = bool(d["furnished"])38    return d394041@app.get("/api/listings")42def list_listings(43    city: str | None = None,44    sector: str | None = None,45    unit_type: str | None = None,46    source: str | None = None,47    price_max: float | None = None,48    price_min: float | None = None,49    pets: str | None = None,             # "oui" -> oui OU conditions50    furnished: int | None = None,        # 1 / 051    available_by: str | None = None,     # ISO : dispo maintenant ou avant date52    area_min: float | None = None,       # superficie minimale (pi²)53    q: str | None = None,54    active: int = 1,55    limit: int = Query(500, le=2000),56    offset: int = 0,57):58    con = db.connect()59    sql = "SELECT * FROM listings WHERE 1=1"60    args: list = []61    if active in (0, 1):62        sql += " AND active=?"; args.append(active)63    if city:64        sql += " AND city=?"; args.append(city)65    if sector:66        sql += " AND sector LIKE ?"; args.append(f"%{sector}%")67    if unit_type:68        sql += " AND unit_type=?"; args.append(unit_type)69    if source:70        sql += " AND source=?"; args.append(source)71    if price_max is not None:72        sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)73    if price_min is not None:74        sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)75    if pets == "oui":76        sql += " AND pets IN ('oui','conditions')"77    elif pets:78        sql += " AND pets=?"; args.append(pets)79    if furnished in (0, 1):80        sql += " AND furnished=?"; args.append(furnished)81    if available_by:82        # « now » trié après les dates ISO : comparaison explicite83        sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \84               " AND availability_date<=?))"85        args.append(available_by)86    if area_min is not None:87        sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)88    if q:89        sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"90        args += [f"%{q}%"] * 391    total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]92    sql += " ORDER BY price IS NULL, price ASC LIMIT ? OFFSET ?"93    args += [limit, offset]94    rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]95    con.close()96    return {"total": total, "count": len(rows), "listings": rows}979899@app.get("/api/listings.geojson")100def listings_geojson(101    city: str | None = None,102    sector: str | None = None,103    unit_type: str | None = None,104    source: str | None = None,105    price_max: float | None = None,106    price_min: float | None = None,107    pets: str | None = None,108    furnished: int | None = None,109    available_by: str | None = None,110    area_min: float | None = None,111    q: str | None = None,112):113    """Annonces géolocalisées au format GeoJSON (mêmes filtres que /api/listings).114115    Léger par conception : seuls les champs nécessaires aux marqueurs et aux116    fiches de la carte sont inclus (1 photo, pas de description).117    """118    con = db.connect()119    sql = ("SELECT uid, title, price, price_label, unit_type, availability_date,"120           " source, city, sector, area_sqft, images, lat, lng FROM listings"121           " WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL")122    args: list = []123    if city:124        sql += " AND city=?"; args.append(city)125    if sector:126        sql += " AND sector LIKE ?"; args.append(f"%{sector}%")127    if unit_type:128        sql += " AND unit_type=?"; args.append(unit_type)129    if source:130        sql += " AND source=?"; args.append(source)131    if price_max is not None:132        sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)133    if price_min is not None:134        sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)135    if pets == "oui":136        sql += " AND pets IN ('oui','conditions')"137    elif pets:138        sql += " AND pets=?"; args.append(pets)139    if furnished in (0, 1):140        sql += " AND furnished=?"; args.append(furnished)141    if available_by:142        sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \143               " AND availability_date<=?))"144        args.append(available_by)145    if area_min is not None:146        sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)147    if q:148        sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"149        args += [f"%{q}%"] * 3150151    features = []152    for r in con.execute(sql, args).fetchall():153        images = json.loads(r["images"] or "[]")154        features.append({155            "type": "Feature",156            "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]},157            "properties": {158                "uid": r["uid"], "title": r["title"], "price": r["price"],159                "price_label": r["price_label"], "unit_type": r["unit_type"],160                "availability_date": r["availability_date"],161                "source": r["source"], "city": r["city"], "sector": r["sector"],162                "area_sqft": r["area_sqft"],163                "image": images[0] if images else None,164            },165        })166    con.close()167    return {"type": "FeatureCollection", "features": features}168169170@app.get("/api/listings/{uid}/pdf")171def listing_pdf(uid: str):172    """Fiche de propriété PDF (photos, prix, quartier, QR) — voir pdfgen.py."""173    from fastapi.responses import Response174    from . import pdfgen175    data = pdfgen.fiche_pdf(uid)176    if data is None:177        raise HTTPException(404, "Annonce introuvable")178    nom = uid.replace(":", "-")179    return Response(content=data, media_type="application/pdf", headers={180        "Content-Disposition": f'attachment; filename="louka-fiche-{nom}.pdf"'})181182183@app.get("/api/stats/rapport.pdf")184def rapport_marche_pdf():185    """Rapport global du marché locatif (PDF multi-pages)."""186    from fastapi.responses import Response187    from . import pdfgen188    return Response(content=pdfgen.rapport_pdf(), media_type="application/pdf",189                    headers={"Content-Disposition":190                             'attachment; filename="louka-rapport-marche.pdf"'})191192193@app.get("/api/listings/{uid}")194def get_listing(uid: str):195    con = db.connect()196    row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()197    d = None198    if row is not None:199        d = _row_to_dict(row)200        # commodités de proximité (cache par immeuble, voir louka/poi.py)201        if d.get("lat") is not None and d.get("lng") is not None:202            key = f"{round(d['lat'], 4)},{round(d['lng'], 4)}"203            poi_row = con.execute(204                "SELECT pois FROM poi_cache WHERE coord_key=?", (key,)).fetchone()205            d["poi"] = json.loads(poi_row["pois"]) if poi_row else []206        else:207            d["poi"] = []208        # statistiques de quartier (recensement, proximité, chaleur, criminalité)209        from . import quartier210        dauid = d.get("dauid")211        d["quartier"] = quartier.fiche_quartier(212            d.get("lat"), d.get("lng"), d.get("city") or "",213            dauid if dauid and dauid != "hors-zone" else None)214        # description structurée + historique de prix215        d["digest"] = json.loads(d["digest"]) if d.get("digest") else None216        d["price_history"] = [dict(r) for r in con.execute(217            "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 6",218            (uid,)).fetchall()]219    con.close()220    if d is None:221        raise HTTPException(404, "Annonce introuvable")222    return d223224225@app.get("/api/facets")226def facets(city: str | None = None):227    """Valeurs distinctes pour construire les filtres du frontend.228229    `city` (optionnel) restreint la liste des quartiers à cette ville —230    utilisé par le sélecteur « Quartier » dépendant de « Ville ».231    """232    con = db.connect()233    sector_sql = "SELECT DISTINCT sector FROM listings WHERE active=1 AND sector<>''"234    sector_args: list = []235    if city:236        sector_sql += " AND city=?"237        sector_args.append(city)238    out = {239        "cities": [r["city"] for r in con.execute(240            "SELECT DISTINCT city FROM listings WHERE active=1 AND city<>'' ORDER BY city")],241        "sectors": [r["sector"] for r in con.execute(242            sector_sql + " ORDER BY sector", sector_args)],243        "unit_types": [r["unit_type"] for r in con.execute(244            "SELECT DISTINCT unit_type FROM listings WHERE active=1 AND unit_type<>'' ORDER BY unit_type")],245        "sources": [dict(r) for r in con.execute(246            "SELECT source, COUNT(*) n FROM listings WHERE active=1 GROUP BY source ORDER BY n DESC")],247    }248    con.close()249    return out250251252@app.get("/api/sources")253def sources():254    registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]255    con = db.connect()256    counts = {r["source"]: r["n"] for r in con.execute(257        "SELECT source, COUNT(*) n FROM listings WHERE active=1 GROUP BY source")}258    last = {r["source"]: r["ts"] for r in con.execute(259        "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}260    con.close()261    for s in registry:262        s["active_listings"] = counts.get(s["id"], 0)263        s["last_sync"] = last.get(s["id"])264    return {"sources": registry}265266267# Villes de la grande région de Montréal (le reste de la province est268# compté dans « autres » — Outaouais, Estrie, Mauricie, Est-du-Québec…)269_VILLES_GM = (270    "Montréal", "Montréal-Est", "Laval", "Longueuil", "Brossard", "Terrebonne",271    "Mascouche", "Repentigny", "Boucherville", "Saint-Lambert", "Westmount",272    "Côte Saint-Luc", "Dorval", "Pointe-Claire", "Verdun", "LaSalle", "Anjou",273    "Saint-Constant", "Delson", "Candiac", "Châteauguay", "Chambly",274    "Sainte-Julie", "Varennes", "Belœil", "McMasterville", "Mont-Saint-Hilaire",275    "Saint-Basile-le-Grand", "Saint-Bruno-de-Montarville", "Boisbriand",276    "Richelieu", "Beauharnois", "Salaberry-de-Valleyfield", "Vaudreuil-Dorion",277    "Kirkland", "Beaconsfield", "Dollard-des-Ormeaux", "Mont-Royal", "Outremont",278)279280281@app.get("/api/stats")282def stats():283    con = db.connect()284    gm = ",".join("?" * len(_VILLES_GM))285    row = con.execute(286        f"""SELECT COUNT(*) total,287                  SUM(CASE WHEN city='Québec' THEN 1 ELSE 0 END) quebec,288                  SUM(CASE WHEN city='Lévis' THEN 1 ELSE 0 END) levis,289                  SUM(CASE WHEN city IN ({gm}) THEN 1 ELSE 0 END) montreal,290                  SUM(CASE WHEN city NOT IN ('Québec','Lévis') AND city NOT IN ({gm})291                      THEN 1 ELSE 0 END) autres,292                  COUNT(DISTINCT source) sources,293                  AVG(price) avg_price294           FROM listings WHERE active=1""",295        list(_VILLES_GM) + list(_VILLES_GM)).fetchone()296    log = [dict(r) for r in con.execute(297        "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]298    con.close()299    return {**dict(row), "recent_syncs": log}300301302@app.get("/api/stats/detailed")303def stats_detailed():304    """Agrégats du marché (source unique : louka/marketstats.py)."""305    from . import marketstats306    return marketstats.compute()307308309310@app.post("/api/sync")311def trigger_sync(background: BackgroundTasks, source: str | None = None):312    """Déclenche une synchronisation (équivalent d'un webhook entrant)."""313    def _job():314        with _sync_lock:315            ingest.run([source] if source else None)316    background.add_task(_job)317    return {"status": "démarré", "source": source or "toutes"}318319320# --- Frontend React (build Vite) --------------------------------------------321if FRONTEND_DIST.exists():322    app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")323324    @app.get("/{full_path:path}")325    def spa(full_path: str):326        target = FRONTEND_DIST / full_path327        if full_path and target.is_file():328            return FileResponse(target)329        return FileResponse(FRONTEND_DIST / "index.html")330