Implementeaza app/api/v1/import_router.py (router nou, montat in app): POST /v1/import — upload xlsx/csv, staging in import_batches/import_rows, PII criptat Fernet (Issue 5a), BEGIN IMMEDIATE+executemany (Issue 6), purge_after 90z (T16), sugestii fuzzy coloane DRY (Issue 5b/Eng#4), detectie drift semnatura (T4/D3), multisheet support GET/POST /v1/import/{id}/column-mapping — mapare coloane per cont cu semnatura + drift detection GET /v1/import/{id}/preview — 6 stari per rand (ok/needs_mapping/needs_data/ needs_review/already_sent/duplicate_in_file), already_sent batch lookup nu N+1 (Eng#5), intra-batch collision EXCLUSIV preview (OV-3/T11) POST /v1/import/{id}/commit — gate HARD N confirmat (T5/D3), atestare pe valori (Voce#1), INSERT ON CONFLICT DO NOTHING TOCTOU (Issue 1/T12), import_attestations rows_hash+n_confirmed (Voce#9), batch_id/row_index (T7) GET /v1/import/{id}/export-failed — CSV randuri esuate cu motiv (T8) Teste: 36 cazuri noi in tests/test_import_api.py; 243 total, toate verzi. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
112 lines
3.9 KiB
Python
112 lines
3.9 KiB
Python
"""Aplicatia FastAPI: API v1 + dashboard web + /healthz + /metrics.
|
|
|
|
Worker-ul ruleaza ca PROCES SEPARAT (python -m app.worker), NU ca task aici
|
|
(plan.md sect. 4: un worker mort nu trebuie sa lase containerul "sanatos").
|
|
|
|
Pornire dev: uvicorn app.main:app --reload
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from . import __version__
|
|
from .api.v1.import_router import router as import_v1_router
|
|
from .api.v1.router import router as api_v1_router
|
|
from .config import get_settings
|
|
from .db import get_connection, init_db, queue_depth, read_heartbeat
|
|
from .security import install_log_redaction
|
|
from .web.routes import router as web_router
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
install_log_redaction()
|
|
init_db()
|
|
yield
|
|
|
|
|
|
app = FastAPI(title="Gateway RAR AUTOPASS", version=__version__, lifespan=lifespan)
|
|
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
"""422 fara echo de credentiale.
|
|
|
|
Pydantic include implicit `input` (+ uneori `ctx`) in fiecare eroare — pe
|
|
/v1/prezentari asta ar reflecta inapoi `rar_credentials.password`. Pastram
|
|
type/loc/msg (clientul stie ce camp e gresit) si DROP-am input/ctx. Defense
|
|
in depth pe TOATE rutele, nu doar prezentari.
|
|
"""
|
|
cleaned = [{"type": e.get("type"), "loc": e.get("loc"), "msg": e.get("msg")} for e in exc.errors()]
|
|
return JSONResponse(status_code=422, content={"detail": cleaned})
|
|
|
|
|
|
# Assets servite local (htmx vendorizat), NU din CDN: gateway-ul ruleaza
|
|
# offline (LXC/VPS, Cloudflare Tunnel). Fara asta, dashboard-ul ramane static
|
|
# (zero polling banner/coada) cand unpkg e blocat/inaccesibil. Aceeasi decizie
|
|
# offline-first ca fontul UI (fara dependinta CDN).
|
|
_STATIC_DIR = Path(__file__).resolve().parent / "web" / "static"
|
|
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
|
|
|
app.include_router(api_v1_router)
|
|
app.include_router(import_v1_router)
|
|
app.include_router(web_router)
|
|
|
|
|
|
@app.get("/healthz")
|
|
def healthz() -> dict:
|
|
"""Sanatate: worker viu + ultimul login RAR reusit + adancime coada.
|
|
|
|
Pica (200 cu ok=False / sau folosit de orchestrator) cand worker-ul e mort
|
|
-> semnal de restart (plan.md sect. 8). Intoarce 200 mereu cu detalii;
|
|
orchestratorul decide pe campul `worker_alive`.
|
|
"""
|
|
settings = get_settings()
|
|
conn = get_connection()
|
|
try:
|
|
hb = read_heartbeat(conn)
|
|
depth = queue_depth(conn)
|
|
finally:
|
|
conn.close()
|
|
|
|
worker_alive = False
|
|
last_beat = hb["last_beat"] if hb else None
|
|
if last_beat:
|
|
try:
|
|
age = (datetime.now(timezone.utc) - datetime.fromisoformat(last_beat)).total_seconds()
|
|
worker_alive = age <= settings.worker_heartbeat_stale_s
|
|
except ValueError:
|
|
worker_alive = False
|
|
|
|
return {
|
|
"ok": True,
|
|
"version": __version__,
|
|
"rar_env": settings.rar_env,
|
|
"worker_alive": worker_alive,
|
|
"last_beat": last_beat,
|
|
"last_rar_login_ok": hb["last_rar_login_ok"] if hb else None,
|
|
"queue_depth": depth,
|
|
}
|
|
|
|
|
|
@app.get("/metrics", response_class=PlainTextResponse)
|
|
def metrics() -> str:
|
|
"""Metrici text simplu (submissions pe status + backlog). Format Prometheus-lite."""
|
|
conn = get_connection()
|
|
try:
|
|
rows = conn.execute("SELECT status, COUNT(*) AS n FROM submissions GROUP BY status").fetchall()
|
|
finally:
|
|
conn.close()
|
|
lines = ["# submissions pe status"]
|
|
for r in rows:
|
|
lines.append(f'autopass_submissions{{status="{r["status"]}"}} {r["n"]}')
|
|
return "\n".join(lines) + "\n"
|