"""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 csv import io import json from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from ...auth import resolve_account_id from ...crypto import encrypt_creds from ...db import get_connection from ...idempotency import build_key, canonicalize_row, idempotency_key from ...mapping import ( account_or_default, load_mapping, pending_unmapped, reresolve_account, resolve_prestatii, save_mapping, ) from ...models import PrezentareRequest, PrezentariResponse, SubmissionResult from ...validation import validate_prezentare router = APIRouter(prefix="/v1", tags=["v1"]) @router.post("/prezentari", response_model=PrezentariResponse) def create_prezentari( req: PrezentareRequest, account_id: int = Depends(resolve_account_id), ) -> PrezentariResponse: """Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission. Validarea de continut (T3, app.validation) ruleaza inainte de enqueue: esecurile NU resping cererea, ci enqueue-aza cu status `needs_data` + motiv (plan.md sect. 3). JSON malformat -> 422 din Pydantic (validare de shape). account_id vine din cheia API (resolve_account_id): cont real cu cheie, implicit id=1 in dev fara cheie, 401 fara cheie valida in prod. Nota: rar_credentials NU se persista (zero-storage) — worker-ul le va primi pe alt canal (T2); in schelet enqueue-ul doar stocheaza prezentarea. """ acct = account_or_default(account_id) # Creds RAR efemere: criptate si lipite de fiecare submission nou pana la # primul login reusit pentru cont (worker le sterge atunci). Zero-storage at # rest — niciodata in clar in DB/loguri (plan sect. 5). creds_enc = encrypt_creds(req.rar_credentials.model_dump()) conn = get_connection() results: list[SubmissionResult] = [] try: mapping = load_mapping(conn, acct) for prez in req.prezentari: content = prez.model_dump() # T9/OV-2: canonicalize_row inaintea build_key (odometru strip ".0", VIN upper). # build_key aplica account_or_default(account_id) inainte de hash: # None si 1 colapseaza la aceeasi cheie (canal API + canal import). canon = canonicalize_row(content) key = build_key(account_id, canon) # Aplica normalizarea si in content (odometru canonicalizat inainte de validare, §3.4bis) content.update({ "vin": canon["vin"], "nr_inmatriculare": canon["nr_inmatriculare"], "odometru_final": canon["odometru_final"], }) 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 # Mapare op->cod RAR (hibrid): codul RAR direct trece neatins; codul # intern ROAAUTO se traduce. Op nemapata -> needs_mapping (nu se trimite), # apare in editorul web. Codul rezolvat se scrie inapoi in payload, deci # validarea T3 + payload builder + worker raman code-driven. resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping) content["prestatii"] = resolved if unmapped: status = "needs_mapping" rar_error = json.dumps({"unmapped": unmapped}, ensure_ascii=False) else: # T3: validare de continut -> queued daca e curat, altfel needs_data + motiv. errors = validate_prezentare(content) if errors: status, rar_error = "needs_data", json.dumps(errors, ensure_ascii=False) else: status, rar_error = "queued", None cur = conn.execute( "INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc) " "VALUES (?, ?, ?, ?, ?, ?)", (key, acct, status, json.dumps(content, ensure_ascii=False), rar_error, creds_enc), ) results.append(SubmissionResult(submission_id=int(cur.lastrowid), status=status)) 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() AUDIT_COLUMNS = [ "submission_id", "status", "id_prezentare", "account_id", "vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "prestatii", "rar_status_code", "created_at", "updated_at", "purge_after", ] def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str): """Randuri audit (sent implicit) filtrate pe data(updated_at) in [from, to]. payload_json e text in schelet (criptarea PII e P2); citim campurile-cheie pentru audit. b64_image NU intra in CSV (mare). Daca P2 cripteaza payload-ul, aici se decripteaza inainte de a construi randul. """ sql = "SELECT id, status, id_prezentare, account_id, payload_json, rar_status_code, created_at, updated_at, purge_after FROM submissions" where = [] params: list = [] if status != "all": where.append("status=?") params.append(status) if date_from: where.append("date(updated_at) >= date(?)") params.append(date_from) if date_to: where.append("date(updated_at) <= date(?)") params.append(date_to) if where: sql += " WHERE " + " AND ".join(where) sql += " ORDER BY id" for r in conn.execute(sql, params).fetchall(): try: p = json.loads(r["payload_json"]) if r["payload_json"] else {} except (ValueError, TypeError): p = {} codes = ",".join( (it.get("cod_prestatie") or it.get("cod_op_service") or "") for it in (p.get("prestatii") or []) if isinstance(it, dict) ) yield { "submission_id": r["id"], "status": r["status"], "id_prezentare": r["id_prezentare"] or "", "account_id": r["account_id"] or "", "vin": p.get("vin") or "", "nr_inmatriculare": p.get("nr_inmatriculare") or "", "data_prestatie": p.get("data_prestatie") or "", "odometru_final": p.get("odometru_final") or "", "prestatii": codes, "rar_status_code": r["rar_status_code"] or "", "created_at": r["created_at"], "updated_at": r["updated_at"], "purge_after": r["purge_after"] or "", } @router.get("/audit/export") def audit_export( date_from: str | None = None, date_to: str | None = None, status: str = "sent", ) -> StreamingResponse: """CSV cu ce s-a trimis (audit). Filtre optionale `date_from`/`date_to` (YYYY-MM-DD) pe data(updated_at). `status` implicit `sent` (ce a ajuns efectiv la RAR); `status=all` exporta toata coada. Leaga re_tinerea 90 zile prin coloana `purge_after` (plan.md sect. 4 + 8). b64_image nu se exporta. """ conn = get_connection() try: buf = io.StringIO() writer = csv.DictWriter(buf, fieldnames=AUDIT_COLUMNS) writer.writeheader() for row in _audit_rows(conn, date_from, date_to, status): writer.writerow(row) data = buf.getvalue() finally: conn.close() fname = f"audit_{status}_{date_from or 'inceput'}_{date_to or 'azi'}.csv" return StreamingResponse( iter([data]), media_type="text/csv", headers={"Content-Disposition": f'attachment; filename="{fname}"'}, ) @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() @router.get("/mapari/pending") def get_mapari_pending() -> dict: """Operatii ROAAUTO nemapate (din submission-uri needs_mapping) + sugestii fuzzy. Alimenteaza editorul web. Fiecare intrare: {account_id, cod_op_service, denumire, blocked, suggestions:[{cod_prestatie, nume_prestatie, score}]}. """ conn = get_connection() try: return {"pending": pending_unmapped(conn)} finally: conn.close() class MapareIn(BaseModel): cod_op_service: str = Field(..., min_length=1) cod_prestatie: str = Field(..., min_length=1) auto_send: bool = True @router.post("/mapari") def create_mapare( req: MapareIn, account_id: int = Depends(resolve_account_id), ) -> dict: """Salveaza/actualizeaza o mapare op->cod si re-rezolva submission-urile blocate. Contul vine din cheia API (NU din body) — un cont nu poate edita maparile altuia. Verifica intai ca `cod_prestatie` exista in nomenclator (nu lasam mapari catre coduri inexistente). Apoi upsert + re-rezolvare `needs_mapping`. """ conn = get_connection() try: cod = req.cod_prestatie.strip().upper() exists = conn.execute( "SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,) ).fetchone() if not exists: raise HTTPException(status_code=422, detail=f"cod_prestatie '{cod}' nu exista in nomenclator") save_mapping(conn, account_id, req.cod_op_service, cod, req.auto_send) stats = reresolve_account(conn, account_id) return {"saved": {"cod_op_service": req.cod_op_service.strip(), "cod_prestatie": cod}, "reresolve": stats} finally: conn.close()