feat: schelet gateway FastAPI (API v1 + worker + dashboard + SQLite WAL)
Structura repo conform plan.md sect. 4, booteaza cu /healthz verde:
- app/main.py: FastAPI (lifespan init_db), /healthz (worker viu + last login + queue), /metrics
- app/api/v1: POST /v1/prezentari (enqueue + dedup idempotency UNIQUE), GET prezentari/{id}, nomenclator, mapari
- app/rar_client.py: client RAR real (login/JWT, nomenclator, postPrezentare, getFinalizate) cu User-Agent obligatoriu (fix WAF 403)
- app/worker: proces separat, claim atomic BEGIN IMMEDIATE, heartbeat, login+send (send dezactivat by default)
- app/web: dashboard Jinja2+HTMX (coada, banner alerta blocate, worker viu/mort, stari empty)
- app/db.py + schema.sql: SQLite WAL, tabele accounts/api_keys/operations_mapping/nomenclator_rar/submissions/worker_heartbeat
- app/idempotency.py + payload.py: hash continut canonic + builder payload (status FINALIZATA, fara tipPrestatie)
- Dockerfile + docker-compose.yml (api+worker, volum SQLite persistent, restart:always)
- tools/import_dbf.py: stub T5
Verificat live: login prin rar_client OK (token 259), nomenclator 18 coduri, worker heartbeat -> /healthz worker_alive=True.
Ramas: T3 validare Pydantic, T4 snapshot payload, T2 reconciliere/retry worker, T5 import DBF, auth API-key, middleware redactare creds, criptare PII.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
127
app/api/v1/router.py
Normal file
127
app/api/v1/router.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""API v1 — suprafata gateway (schelet).
|
||||
|
||||
Endpointuri din plan.md sect. 4. In schelet:
|
||||
- POST /v1/prezentari: enqueue cu idempotenta (dedup pe idempotency_key UNIQUE).
|
||||
- GET /v1/prezentari, /v1/prezentari/{id}: monitorizare coada.
|
||||
- GET /v1/nomenclator: cache local.
|
||||
- GET /v1/mapari: listare mapari cont.
|
||||
Validarea completa (T3), maparea op->cod, auth API-key, redactarea creds in
|
||||
middleware (CORE) si exportul CSV vin ulterior — marcate TODO unde lipsesc.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from ...db import get_connection
|
||||
from ...idempotency import idempotency_key
|
||||
from ...models import PrezentareRequest, PrezentariResponse, SubmissionResult
|
||||
|
||||
router = APIRouter(prefix="/v1", tags=["v1"])
|
||||
|
||||
|
||||
@router.post("/prezentari", response_model=PrezentariResponse)
|
||||
def create_prezentari(req: PrezentareRequest) -> PrezentariResponse:
|
||||
"""Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission.
|
||||
|
||||
TODO(T3): validare Pydantic completa inainte de enqueue (VIN/data/nrInm),
|
||||
ruteaza needs_data/needs_mapping.
|
||||
TODO(auth): rezolva account_id din API key (acum None).
|
||||
Nota: rar_credentials NU se persista (zero-storage) — worker-ul le va primi
|
||||
pe alt canal (T2); in schelet enqueue-ul doar stocheaza prezentarea.
|
||||
"""
|
||||
account_id = None # TODO(auth): din API key
|
||||
conn = get_connection()
|
||||
results: list[SubmissionResult] = []
|
||||
try:
|
||||
for prez in req.prezentari:
|
||||
content = prez.model_dump()
|
||||
key = idempotency_key(account_id, content)
|
||||
existing = conn.execute(
|
||||
"SELECT id, status, id_prezentare FROM submissions WHERE idempotency_key=?",
|
||||
(key,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
results.append(
|
||||
SubmissionResult(
|
||||
submission_id=existing["id"],
|
||||
status=existing["status"],
|
||||
id_prezentare=existing["id_prezentare"],
|
||||
deduped=True,
|
||||
)
|
||||
)
|
||||
continue
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||
"VALUES (?, ?, 'queued', ?)",
|
||||
(key, account_id, json.dumps(content, ensure_ascii=False)),
|
||||
)
|
||||
results.append(SubmissionResult(submission_id=int(cur.lastrowid), status="queued"))
|
||||
finally:
|
||||
conn.close()
|
||||
return PrezentariResponse(results=results)
|
||||
|
||||
|
||||
@router.get("/prezentari")
|
||||
def list_prezentari(status: str | None = None, limit: int = 100) -> dict:
|
||||
conn = get_connection()
|
||||
try:
|
||||
if status:
|
||||
rows = conn.execute(
|
||||
"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
|
||||
"FROM submissions WHERE status=? ORDER BY id DESC LIMIT ?",
|
||||
(status, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
|
||||
"FROM submissions ORDER BY id DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return {"submissions": [dict(r) for r in rows]}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/prezentari/{submission_id}")
|
||||
def get_prezentare(submission_id: int) -> dict:
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM submissions WHERE id=?", (submission_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="submission inexistent")
|
||||
out = dict(row)
|
||||
out.pop("payload_json", None) # nu expunem payload-ul brut (PII) in listare
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/nomenclator")
|
||||
def get_nomenclator() -> dict:
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT cod_prestatie, nume_prestatie, updated_at FROM nomenclator_rar ORDER BY cod_prestatie"
|
||||
).fetchall()
|
||||
return {"nomenclator": [dict(r) for r in rows]}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/mapari")
|
||||
def get_mapari(account_id: int | None = None) -> dict:
|
||||
conn = get_connection()
|
||||
try:
|
||||
if account_id is not None:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM operations_mapping WHERE account_id=? ORDER BY cod_op_service",
|
||||
(account_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM operations_mapping ORDER BY account_id, cod_op_service").fetchall()
|
||||
return {"mapari": [dict(r) for r in rows]}
|
||||
finally:
|
||||
conn.close()
|
||||
Reference in New Issue
Block a user