# ============================================ # Projet : API-KA # Fichier : src/api/routes/services.py # Node : m3u96b # Author : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Date : 2026-08-16 # ============================================ """Routes /api/v1/{service} : données paginées, latest, par date, stats.""" from __future__ import annotations import datetime from typing import Any from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy import func, select from sqlalchemy.orm import Session from src.api.routes import envelope from src.config import SERVICES from src.database.db import get_db from src.database.models import DATA_MODELS, CollectionRun router = APIRouter(prefix="/api/v1", tags=["services"]) MAX_LIMIT = 500 def _model_or_404(service: str) -> type: """Retourne le modèle du service ou lève un 404 si le service est inconnu.""" if service not in SERVICES: raise HTTPException( status_code=404, detail=f"Service inconnu : {service}. Services valides : {', '.join(SERVICES)}", ) return DATA_MODELS[service] def _serialize(row: Any) -> dict[str, Any]: """Sérialise un enregistrement de données en JSON.""" return { "id": row.id, "source": row.source, "date_key": row.date_key.isoformat(), "collected_at": row.collected_at.isoformat() if row.collected_at else None, "checksum": row.checksum, "payload": row.payload, } @router.get("/{service}/latest") def get_latest( service: str = Path(..., description="Service KA"), db: Session = Depends(get_db), ) -> dict[str, Any]: """Dernière collecte du service (toutes les lignes de la date la plus récente).""" model = _model_or_404(service) max_date = db.execute( select(func.max(model.date_key)).where(model.source == service) ).scalar() if max_date is None: return envelope([], total=0, extra_meta={"service": service, "date_key": None}) rows = ( db.execute( select(model) .where(model.source == service, model.date_key == max_date) .order_by(model.collected_at.desc()) ) .scalars() .all() ) return envelope( [_serialize(r) for r in rows], total=len(rows), extra_meta={"service": service, "date_key": max_date.isoformat()}, ) @router.get("/{service}/date/{date_str}") def get_by_date( service: str = Path(..., description="Service KA"), date_str: str = Path(..., description="Date au format YYYY-MM-DD"), page: int = Query(1, ge=1), limit: int = Query(100, ge=1, le=MAX_LIMIT), db: Session = Depends(get_db), ) -> dict[str, Any]: """Données d'un service pour une date précise, paginées.""" model = _model_or_404(service) try: date_key = datetime.date.fromisoformat(date_str) except ValueError as exc: raise HTTPException( status_code=422, detail=f"Date invalide : {date_str} (format YYYY-MM-DD)" ) from exc base = select(model).where(model.source == service, model.date_key == date_key) total = db.execute(select(func.count()).select_from(base.subquery())).scalar() or 0 rows = ( db.execute( base.order_by(model.collected_at.desc()) .offset((page - 1) * limit) .limit(limit) ) .scalars() .all() ) return envelope( [_serialize(r) for r in rows], page=page, limit=limit, total=total, extra_meta={"service": service, "date_key": date_key.isoformat()}, ) @router.get("/{service}/stats") def get_stats( service: str = Path(..., description="Service KA"), db: Session = Depends(get_db), ) -> dict[str, Any]: """Nombre d'enregistrements par jour et dernière collecte réussie.""" model = _model_or_404(service) counts = db.execute( select(model.date_key, func.count()) .where(model.source == service) .group_by(model.date_key) .order_by(model.date_key.desc()) ).all() last_success = ( db.execute( select(CollectionRun) .where( CollectionRun.service == service, CollectionRun.status.in_(("success", "retried")), ) .order_by(CollectionRun.finished_at.desc()) .limit(1) ) .scalars() .first() ) data = { "service": service, "total_records": sum(count for _, count in counts), "days": [ {"date_key": day.isoformat(), "records": count} for day, count in counts ], "last_success": ( { "date_key": last_success.date_key.isoformat(), "status": last_success.status, "records_count": last_success.records_count, "finished_at": last_success.finished_at.isoformat(), } if last_success else None ), } return envelope(data, extra_meta={"service": service}) @router.get("/{service}") def list_service_data( service: str = Path(..., description="Service KA"), page: int = Query(1, ge=1), limit: int = Query(100, ge=1, le=MAX_LIMIT), db: Session = Depends(get_db), ) -> dict[str, Any]: """Données paginées d'un service, des plus récentes aux plus anciennes.""" model = _model_or_404(service) total = ( db.execute( select(func.count()).select_from(model).where(model.source == service) ).scalar() or 0 ) rows = ( db.execute( select(model) .where(model.source == service) .order_by(model.date_key.desc(), model.collected_at.desc()) .offset((page - 1) * limit) .limit(limit) ) .scalars() .all() ) return envelope( [_serialize(r) for r in rows], page=page, limit=limit, total=total, extra_meta={"service": service}, ) @router.get("/louka/fairvalue/{uid}") def louka_fairvalue(uid: str = Path(..., description="uid d'annonce lou-ka")): """Juste valeur locative Lou-Ka (fair value) EN DIRECT : valeur estimée, fourchette, indice de confiance, classification sous/dans/au-dessus du marché et distribution du segment — proxifié depuis l'API lou-ka (framework louka/fairvalue.py). Utilisé par l'app iOS KA.""" import os import httpx from src.config import source_url as _live_source_url base = (_live_source_url("louka") or os.environ.get("LOUKA_SOURCE_URL") or "http://127.0.0.1:8095/api/listings").rsplit("/api/", 1)[0] try: resp = httpx.get(f"{base}/api/fairvalue/{uid}", timeout=10) except httpx.HTTPError as exc: raise HTTPException(status_code=502, detail=f"lou-ka injoignable: {exc}") if resp.status_code == 404: raise HTTPException(status_code=404, detail="Pas d'estimation pour cette annonce") resp.raise_for_status() return envelope([resp.json()], extra_meta={"service": "louka", "kind": "fairvalue"})