Files
rar-autopass/app/rar_client.py
Claude Agent f1b5f1f80f 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>
2026-06-15 12:04:11 +00:00

131 lines
5.0 KiB
Python

"""Client RAR AUTOPASS — portare din rar_autopass.prg / rar-forms.prg.
Sursa de adevar pentru contract: docs/api-rar-contract.md (verificat live 2026-06-15).
Reguli care guverneaza acest client:
- TOATE apelurile trimit header User-Agent (altfel WAF da 403).
- login -> JWT (TTL 30h); token-ul se ataseaza ca `Authorization: Bearer`.
- postPrezentare: status mereu "FINALIZATA"; NU se trimite tipPrestatie.
- nomenclator: GET /nomenclator/getNomenclatorPrestatii (NU getPrestatiiNom -> 403).
- eroare validare RAR: HTTP 400, data = listă [{field, message}] (NU data.message).
"""
from __future__ import annotations
from typing import Any
import httpx
from .config import Settings, get_settings
class RarError(Exception):
"""Eroare la apel RAR. `status_code` = HTTP RAR; `field_errors` = lista [{field,message}] la 400."""
def __init__(self, message: str, *, status_code: int | None = None, field_errors: list[dict] | None = None):
super().__init__(message)
self.status_code = status_code
self.field_errors = field_errors or []
class RarAuthError(RarError):
"""Login esuat (401 / credentiale invalide). NU se face retry."""
class RarClient:
"""Client sincron httpx. Folosit din worker (proces separat).
Utilizare:
with RarClient() as rar:
token = rar.login(email, password)
data = rar.post_prezentare(token, payload)
"""
def __init__(self, settings: Settings | None = None):
self.settings = settings or get_settings()
self._client = httpx.Client(
base_url=self.settings.rar_base_url,
timeout=self.settings.http_timeout_s,
headers={"User-Agent": self.settings.http_user_agent}, # fix WAF 403
)
def __enter__(self) -> "RarClient":
return self
def __exit__(self, *exc: object) -> None:
self.close()
def close(self) -> None:
self._client.close()
# --- Autentificare ---
def login(self, email: str, password: str) -> str:
"""POST /public/login -> JWT (str). Ridica RarAuthError la 401."""
resp = self._client.post("/public/login", json={"email": email, "password": password})
if resp.status_code == 401:
raise RarAuthError("Credentiale RAR invalide", status_code=401)
if resp.status_code != 200:
raise RarError(f"Login esuat (HTTP {resp.status_code})", status_code=resp.status_code)
token = resp.json().get("token")
if not token:
raise RarError("Login fara token in raspuns", status_code=resp.status_code)
return token
# --- Nomenclator ---
def get_nomenclator(self, token: str) -> list[dict]:
"""GET /nomenclator/getNomenclatorPrestatii -> listă coduri prestatii."""
resp = self._client.get(
"/nomenclator/getNomenclatorPrestatii",
headers={"Authorization": f"Bearer {token}"},
)
if resp.status_code != 200:
raise RarError(f"Nomenclator esuat (HTTP {resp.status_code})", status_code=resp.status_code)
data = resp.json()
# Raspunsul poate fi listă directa sau {data: [...]}; normalizam.
return data.get("data", data) if isinstance(data, dict) else data
# --- Prezentari ---
def post_prezentare(self, token: str, payload: dict[str, Any]) -> dict:
"""POST /prezentari/postPrezentare. Intoarce `data` (obiect) la succes.
La 400 (validare) ridica RarError cu field_errors din `data` (listă).
Apelantul NU trebuie sa includa tipPrestatie; status trebuie "FINALIZATA".
"""
resp = self._client.post(
"/prezentari/postPrezentare",
json=payload,
headers={"Authorization": f"Bearer {token}"},
)
body = _safe_json(resp)
if resp.status_code == 200:
return body.get("data", {}) if isinstance(body, dict) else {}
if resp.status_code == 400 and isinstance(body, dict):
errors = body.get("data") if isinstance(body.get("data"), list) else []
msg = body.get("message", "Validare esuata la RAR")
raise RarError(msg, status_code=400, field_errors=errors)
raise RarError(f"postPrezentare esuat (HTTP {resp.status_code})", status_code=resp.status_code)
def get_finalizate(self, token: str) -> list[dict]:
"""Lista prezentarilor finalizate (pentru reconciliere — T2).
Atentie: pe mediul TEST raspunsul NU contine `prestatii` (vezi contract).
Portare din rar-forms.prg:720 / getAllPrezentariFinalizate.
"""
resp = self._client.get(
"/prezentari/getAllPrezentariFinalizate",
headers={"Authorization": f"Bearer {token}"},
)
if resp.status_code != 200:
raise RarError(f"getFinalizate esuat (HTTP {resp.status_code})", status_code=resp.status_code)
data = _safe_json(resp)
return data.get("data", data) if isinstance(data, dict) else data
def _safe_json(resp: httpx.Response) -> Any:
try:
return resp.json()
except ValueError:
return {"message": resp.text}