SPB Git forge

spb/api-ka

Public

API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com

48commits 1branches 0releases
5.9 MBsize
maindefault branch
19 days agolast push
Python 60.9% HTML 21% TypeScript 7.3% JavaScript 5.2% CSS 4.8% Shell 0.8%
7.1 KB · 221 lines python
Raw Blame History
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