API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com
Python 60.9%
HTML 21%
TypeScript 7.3%
JavaScript 5.2%
CSS 4.8%
Shell 0.8%
1# ============================================2# Projet : API-KA3# Fichier : src/api/routes/services.py4# Node : m3u96b5# Author : Simon-Pierre Boucher6# Contact : contact@spboucher.ai7# Date : 2026-08-168# ============================================9"""Routes /api/v1/{service} : données paginées, latest, par date, stats."""1011from __future__ import annotations1213import datetime14from typing import Any1516from fastapi import APIRouter, Depends, HTTPException, Path, Query17from sqlalchemy import func, select18from sqlalchemy.orm import Session1920from src.api.routes import envelope21from src.config import SERVICES22from src.database.db import get_db23from src.database.models import DATA_MODELS, CollectionRun2425router = APIRouter(prefix="/api/v1", tags=["services"])2627MAX_LIMIT = 500282930def _model_or_404(service: str) -> type:31 """Retourne le modèle du service ou lève un 404 si le service est inconnu."""32 if service not in SERVICES:33 raise HTTPException(34 status_code=404,35 detail=f"Service inconnu : {service}. Services valides : {', '.join(SERVICES)}",36 )37 return DATA_MODELS[service]383940def _serialize(row: Any) -> dict[str, Any]:41 """Sérialise un enregistrement de données en JSON."""42 return {43 "id": row.id,44 "source": row.source,45 "date_key": row.date_key.isoformat(),46 "collected_at": row.collected_at.isoformat() if row.collected_at else None,47 "checksum": row.checksum,48 "payload": row.payload,49 }505152@router.get("/{service}/latest")53def get_latest(54 service: str = Path(..., description="Service KA"),55 db: Session = Depends(get_db),56) -> dict[str, Any]:57 """Dernière collecte du service (toutes les lignes de la date la plus récente)."""58 model = _model_or_404(service)59 max_date = db.execute(60 select(func.max(model.date_key)).where(model.source == service)61 ).scalar()62 if max_date is None:63 return envelope([], total=0, extra_meta={"service": service, "date_key": None})64 rows = (65 db.execute(66 select(model)67 .where(model.source == service, model.date_key == max_date)68 .order_by(model.collected_at.desc())69 )70 .scalars()71 .all()72 )73 return envelope(74 [_serialize(r) for r in rows],75 total=len(rows),76 extra_meta={"service": service, "date_key": max_date.isoformat()},77 )787980@router.get("/{service}/date/{date_str}")81def get_by_date(82 service: str = Path(..., description="Service KA"),83 date_str: str = Path(..., description="Date au format YYYY-MM-DD"),84 page: int = Query(1, ge=1),85 limit: int = Query(100, ge=1, le=MAX_LIMIT),86 db: Session = Depends(get_db),87) -> dict[str, Any]:88 """Données d'un service pour une date précise, paginées."""89 model = _model_or_404(service)90 try:91 date_key = datetime.date.fromisoformat(date_str)92 except ValueError as exc:93 raise HTTPException(94 status_code=422, detail=f"Date invalide : {date_str} (format YYYY-MM-DD)"95 ) from exc96 base = select(model).where(model.source == service, model.date_key == date_key)97 total = db.execute(select(func.count()).select_from(base.subquery())).scalar() or 098 rows = (99 db.execute(100 base.order_by(model.collected_at.desc())101 .offset((page - 1) * limit)102 .limit(limit)103 )104 .scalars()105 .all()106 )107 return envelope(108 [_serialize(r) for r in rows],109 page=page,110 limit=limit,111 total=total,112 extra_meta={"service": service, "date_key": date_key.isoformat()},113 )114115116@router.get("/{service}/stats")117def get_stats(118 service: str = Path(..., description="Service KA"),119 db: Session = Depends(get_db),120) -> dict[str, Any]:121 """Nombre d'enregistrements par jour et dernière collecte réussie."""122 model = _model_or_404(service)123 counts = db.execute(124 select(model.date_key, func.count())125 .where(model.source == service)126 .group_by(model.date_key)127 .order_by(model.date_key.desc())128 ).all()129 last_success = (130 db.execute(131 select(CollectionRun)132 .where(133 CollectionRun.service == service,134 CollectionRun.status.in_(("success", "retried")),135 )136 .order_by(CollectionRun.finished_at.desc())137 .limit(1)138 )139 .scalars()140 .first()141 )142 data = {143 "service": service,144 "total_records": sum(count for _, count in counts),145 "days": [146 {"date_key": day.isoformat(), "records": count} for day, count in counts147 ],148 "last_success": (149 {150 "date_key": last_success.date_key.isoformat(),151 "status": last_success.status,152 "records_count": last_success.records_count,153 "finished_at": last_success.finished_at.isoformat(),154 }155 if last_success156 else None157 ),158 }159 return envelope(data, extra_meta={"service": service})160161162@router.get("/{service}")163def list_service_data(164 service: str = Path(..., description="Service KA"),165 page: int = Query(1, ge=1),166 limit: int = Query(100, ge=1, le=MAX_LIMIT),167 db: Session = Depends(get_db),168) -> dict[str, Any]:169 """Données paginées d'un service, des plus récentes aux plus anciennes."""170 model = _model_or_404(service)171 total = (172 db.execute(173 select(func.count()).select_from(model).where(model.source == service)174 ).scalar()175 or 0176 )177 rows = (178 db.execute(179 select(model)180 .where(model.source == service)181 .order_by(model.date_key.desc(), model.collected_at.desc())182 .offset((page - 1) * limit)183 .limit(limit)184 )185 .scalars()186 .all()187 )188 return envelope(189 [_serialize(r) for r in rows],190 page=page,191 limit=limit,192 total=total,193 extra_meta={"service": service},194 )195196197@router.get("/louka/fairvalue/{uid}")198def louka_fairvalue(uid: str = Path(..., description="uid d'annonce lou-ka")):199 """Juste valeur locative Lou-Ka (fair value) EN DIRECT : valeur estimée,200 fourchette, indice de confiance, classification sous/dans/au-dessus du201 marché et distribution du segment — proxifié depuis l'API lou-ka202 (framework louka/fairvalue.py). Utilisé par l'app iOS KA."""203 import os204205 import httpx206207 from src.config import source_url as _live_source_url208209 base = (_live_source_url("louka") or os.environ.get("LOUKA_SOURCE_URL")210 or "http://127.0.0.1:8095/api/listings").rsplit("/api/", 1)[0]211 try:212 resp = httpx.get(f"{base}/api/fairvalue/{uid}", timeout=10)213 except httpx.HTTPError as exc:214 raise HTTPException(status_code=502, detail=f"lou-ka injoignable: {exc}")215 if resp.status_code == 404:216 raise HTTPException(status_code=404,217 detail="Pas d'estimation pour cette annonce")218 resp.raise_for_status()219 return envelope([resp.json()],220 extra_meta={"service": "louka", "kind": "fairvalue"})221