"""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 ...errors import eroare as err_eroare from ...idempotency import build_key, canonicalize_row from ...mapping import ( account_or_default, account_scope_clause, classify_prezentare, load_mapping_meta, load_nomenclator_codes, pending_unmapped, reresolve_account, save_mapping, ) from ...models import ( PrezentareRequest, PrezentariResponse, SubmissionResult, ValidarePrezentariRequest, ValidareResponse, ValidareResult, ) from ...observ import log_event from ...payload_view import prezentare_din_payload from ...submissions_admin import ( SubmissionNotFound, SubmissionStateConflict, delete_submission, requeue_submission, ) router = APIRouter(prefix="/v1", tags=["v1"]) def _effective_on_unmapped_error(conn, acct: int, req_value: bool | None) -> bool: """Modul efectiv la cod necunoscut/nemapat (True => respinge cererea, False => needs_mapping). Precedenta: override per-cerere > default cont (on_unmapped_error_default) > False. """ if req_value is not None: return req_value row = conn.execute("SELECT on_unmapped_error_default FROM accounts WHERE id=?", (acct,)).fetchone() return bool(row["on_unmapped_error_default"]) if row else False def _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode) -> dict: """classify_prezentare + aplicarea modului on_unmapped_error. Cand exista coduri nemapate si error_mode=True, marcheaza outcome-ul ca respingere (blocked_error=True): rutele NU mai fac enqueue, ci intorc o eroare per-element. """ cl = classify_prezentare(content, mapping, mapping_meta, valid_codes) cl["blocked_error"] = bool(cl["unmapped"]) and error_mode return cl def _erori_nemapate(unmapped: list[dict]) -> list[dict]: """Coduri nemapate imbogatite cu 3 niveluri (COD_NEMAPAT), pentru raspuns onest.""" return [ {**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} necunoscut/fara mapare RAR")} for u in unmapped ] def _motiv_clasificare(cl: dict) -> str | None: """Rezumat uman pe o linie pentru un rezultat de clasificare (PRD 5.7). None cand status='queued'. Acopera toate ramurile de blocaj: erori de continut (needs_data), coduri nemapate (needs_mapping) si auto_send oprit (needs_mapping). """ if cl["status"] == "queued": return None if cl["errors"]: return "; ".join( (e.get("problema") or e.get("message") or "") for e in cl["errors"] ).strip("; ") or "Date incomplete (respinse de RAR)." if cl["unmapped"]: coduri = ", ".join((u.get("cod_op_service") or "") for u in cl["unmapped"]) return f"Coduri fara mapare RAR: {coduri}" if cl["status"] == "needs_mapping": return "Cod cu trimitere automata oprita; confirmare manuala inainte de trimitere." return None def _rezultat_enqueue(submission_id: int | None, cl: dict, **extra) -> SubmissionResult: """SubmissionResult onest dintr-un rezultat de clasificare (PRD 5.7). Populeaza erori (validare continut), nemapate (coduri fara mapare) si motiv (uman) pentru orice status != 'queued'. Aditiv: pe 'queued' toate raman goale/None. """ return SubmissionResult( submission_id=submission_id, status=cl["status"], erori=list(cl["errors"]), nemapate=_erori_nemapate(cl["unmapped"]), motiv=_motiv_clasificare(cl), **extra, ) def _rezultat_respins(submission_id: int | None, cl: dict) -> SubmissionResult: """Rezultat pentru on_unmapped_error=True: status='error', fara enqueue/reactivare. `erori` pastreaza COD_NEMAPAT (compat clienti vechi); `nemapate` + `motiv` adaugate. """ nem = _erori_nemapate(cl["unmapped"]) return SubmissionResult( submission_id=submission_id, status="error", erori=nem, nemapate=nem, motiv=_motiv_clasificare(cl), ) @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. Cand rar_credentials lipseste, submission-ul intra fara creds efemere: worker-ul cade pe creds-urile durabile ale contului (`accounts.rar_creds_enc`). """ 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). Optional: cand lipsesc, # creds_enc=NULL si worker-ul foloseste creds-urile durabile ale contului. creds_enc = encrypt_creds(req.rar_credentials.model_dump()) if req.rar_credentials else None conn = get_connection() results: list[SubmissionResult] = [] try: # T6/OV-1: load_mapping_meta include auto_send per op (gate pentru coduri noi). mapping_meta = load_mapping_meta(conn, acct) mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} # Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat. # valid_codes gol (nomenclator nepopulat) -> None (nu validam, ca sa nu blocam tot). valid_codes = load_nomenclator_codes(conn) or None error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error) 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: # US-012: un rand `error` (ex. creds RAR gresite) NU mai blocheaza tacit # retrimiterea aceluiasi continut. Il RE-ACTIVAM (re-clasificam + actualizam # creds + reset), printr-un UPDATE compare-and-swap pe status='error'. if existing["status"] == "error": cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode) if cl["blocked_error"]: # on_unmapped_error=True: nu reactivam; randul ramane 'error'. results.append(_rezultat_respins(existing["id"], cl)) continue cur = conn.execute( "UPDATE submissions SET status=?, payload_json=?, rar_error=?, " "rar_creds_enc=COALESCE(?, rar_creds_enc), retry_count=0, " "next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, " "updated_at=datetime('now') WHERE id=? AND status='error'", (cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc, existing["id"]), ) if cur.rowcount == 1: # Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc, # decizie #17) — ambele canale converg pe parola corectata. if req.rar_credentials is not None: conn.execute( "UPDATE accounts SET rar_creds_enc=? WHERE id=?", (encrypt_creds(req.rar_credentials.model_dump()), acct), ) # Raspuns onest si la reactivare (PRD 5.7): daca re-clasificarea # cade pe needs_data/needs_mapping, expune motivul (nu doar status). results.append(_rezultat_enqueue(existing["id"], cl, reactivated=True)) continue # Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE # (rowcount==0) -> raspuns dedup pe starea CURENTA. existing = conn.execute( "SELECT id, status, id_prezentare FROM submissions WHERE id=?", (existing["id"],), ).fetchone() results.append( SubmissionResult( submission_id=existing["id"], status=existing["status"], id_prezentare=existing["id_prezentare"], deduped=True, ) ) continue # Helper pur partajat cu dry-run (PRD 5.2): reproduce EXACT clasificarea # (canonicalize + mapare op->cod + validare + auto_send gate). cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode) if cl["blocked_error"]: # on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat). results.append(_rezultat_respins(None, cl)) continue cur = conn.execute( "INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc) " "VALUES (?, ?, ?, ?, ?, ?)", (key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc), ) # Raspuns onest (PRD 5.7): pe needs_data/needs_mapping expune erori/nemapate/motiv. results.append(_rezultat_enqueue(int(cur.lastrowid), cl)) # US-004: audit cerere API per cont. Doar metadate (count + distributie status), # NICIUN camp de payload PII integral. Reuse conn (T4 — fara contentie WAL). dist: dict[str, int] = {} for r in results: if r.reactivated: cheie = "reactivated" elif r.deduped: cheie = "deduped" else: cheie = r.status dist[cheie] = dist.get(cheie, 0) + 1 log_event( "api_prezentari", account_id=acct, mesaj=f"{len(results)} prezentari procesate", context={"count": len(results), "distributie": dist}, conn=conn, ) finally: conn.close() return PrezentariResponse(results=results) @router.post("/prezentari/valideaza", response_model=ValidareResponse) def valideaza_prezentari( req: ValidarePrezentariRequest, account_id: int = Depends(resolve_account_id), ) -> ValidareResponse: """Dry-run: valideaza payload exact ca POST /prezentari, fara enqueue si fara efecte secundare. Intoarce pentru fiecare prezentare: verdictul (status_estimat), erorile de continut si codurile nemapate — exact ce ar obtine trimiterea reala pe acelasi payload + aceeasi mapare de cont. rar_credentials ignorat complet (PRD 5.2). """ acct = account_or_default(account_id) conn = get_connection() results: list[ValidareResult] = [] try: mapping_meta = load_mapping_meta(conn, acct) mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} valid_codes = load_nomenclator_codes(conn) or None error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error) for i, prez in enumerate(req.prezentari): content = prez.model_dump() res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode) if res["blocked_error"]: res = {**res, "status": "error"} # US-003: imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT nemapate = [ {**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} fara mapare RAR")} for u in res["unmapped"] ] results.append(ValidareResult( index=i, valid=(res["status"] == "queued"), status_estimat=res["status"], erori=res["errors"], nemapate=nemapate, prestatii_rezolvate=res["resolved"], )) finally: conn.close() return ValidareResponse(results=results) @router.get("/prezentari") def list_prezentari( status: str | None = None, limit: int = 100, account_id: int = Depends(resolve_account_id), ) -> dict: conn = get_connection() try: scope_sql, scope_params = account_scope_clause(account_id) # payload_json e plaintext (vezi submissions.payload_json); il citim doar ca # sa derivam campurile afisabile prin helper-ul partajat (US-003, DRY), nu il expunem. cols = ( "id, status, id_prezentare, rar_status_code, retry_count, " "created_at, updated_at, payload_json" ) if status: rows = conn.execute( f"SELECT {cols} FROM submissions WHERE {scope_sql} AND status=? " f"ORDER BY id DESC LIMIT ?", scope_params + [status, limit], ).fetchall() else: rows = conn.execute( f"SELECT {cols} FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?", scope_params + [limit], ).fetchall() out = [] for r in rows: d = dict(r) # Campuri afisabile derivate din payload (acelasi helper ca dashboardul web); # payload_json brut nu se intoarce in raspuns. d["prezentare"] = prezentare_din_payload(d.pop("payload_json", None)) out.append(d) return {"submissions": out} finally: conn.close() # Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita (B4). # Exclude: rar_creds_enc, payload_json, idempotency_key, rar_error, sending_since. _PREZENTARE_FIELDS = frozenset({ "id", "status", "id_prezentare", "rar_status_code", "retry_count", "next_attempt_at", "created_at", "updated_at", "account_id", "batch_id", "row_index", "purge_after", # T9: rar_error e SIGUR de expus — contine doar coduri/mesaje de validare RAR si # erori din catalog (niciodata creds, ex. RAR_CREDS_INVALIDE poarta doar cauza # "credentiale RAR invalide", fara parola). Face recovery-ul observabil prin API. "rar_error", }) @router.get("/prezentari/{submission_id}") def get_prezentare( submission_id: int, account_id: int = Depends(resolve_account_id), ) -> dict: conn = get_connection() try: scope_sql, scope_params = account_scope_clause(account_id) row = conn.execute( f"SELECT * FROM submissions WHERE id=? AND {scope_sql}", [submission_id] + scope_params, ).fetchone() if not row: # B3: acelasi mesaj indiferent daca randul exista dar apartine altui cont # sau nu exista deloc — nu confirmam existenta. raise HTTPException(status_code=404, detail="submission inexistent") row_dict = dict(row) return {k: v for k, v in row_dict.items() if k in _PREZENTARE_FIELDS} finally: conn.close() @router.delete("/prezentari/{submission_id}") def delete_prezentare( submission_id: int, account_id: int = Depends(resolve_account_id), ) -> dict: """Sterge o trimitere blocata a contului cheii API (US-010). Raspuns 200 + body JSON (NU 204 — clienti VFP fac string-parse). Scope evaluat INAINTEA starii (decizie /autoplan #20): cross-account / inexistent -> 404 (acelasi mesaj, B3); own-account `sent`/`sending` -> 409 (conflict de stare). """ conn = get_connection() try: try: res = delete_submission(conn, account_id, submission_id) except SubmissionNotFound: raise HTTPException(status_code=404, detail="submission inexistent") except SubmissionStateConflict as exc: raise HTTPException( status_code=409, detail=f"trimiterea nu se poate sterge in starea '{exc.status}'", ) return {"ok": True, **res} finally: conn.close() @router.post("/prezentari/{submission_id}/repune") def repune_prezentare( submission_id: int, account_id: int = Depends(resolve_account_id), ) -> dict: """Re-pune in coada o trimitere blocata a contului cheii API (US-010). `error -> queued` (peste helper US-009), re-ruleaza classify. Acelasi oracol de scope/stare ca DELETE (404 cross-account/inexistent, 409 sent/sending). """ conn = get_connection() try: try: res = requeue_submission(conn, account_id, submission_id) except SubmissionNotFound: raise HTTPException(status_code=404, detail="submission inexistent") except SubmissionStateConflict as exc: raise HTTPException( status_code=409, detail=f"trimiterea nu se poate re-pune in starea '{exc.status}'", ) return {"ok": True, **res} 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, account_id: int): """Randuri audit filtrate pe cont + data(updated_at) in [from, to]. account_id = contul cheii API (scope obligatoriu — PII in CSV). Randuri cu account_id IS NULL apartin contului 1 (legacy/OV-2). payload_json e text in schelet; b64_image NU intra in CSV. """ scope_sql, scope_params = account_scope_clause(account_id) sql = ( "SELECT id, status, id_prezentare, account_id, payload_json, rar_status_code, " "created_at, updated_at, purge_after FROM submissions" ) where = [scope_sql] params: list = list(scope_params) 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) 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 "", # NULL→cont 1 (OV-2): coloana reflecta invariantul de scope, nu "" ambiguu. "account_id": account_or_default(r["account_id"]), "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", account_id: int = Depends(resolve_account_id), ) -> StreamingResponse: """CSV audit scoped pe contul cheii API. 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 contului. Leaga retinerea 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, account_id): 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( key_account: int = Depends(resolve_account_id), account_id: int | None = None, ) -> dict: """Maparile operatie->cod ale contului curent. Parametrul `account_id` din query e pastrat pentru compatibilitate, dar contul efectiv vine MEREU din cheia API (TD-3.2). Daca e prezent si difera -> 400. """ if account_id is not None and account_id != key_account: raise HTTPException( status_code=400, detail="account_id din query nu corespunde contului cheii API", ) conn = get_connection() try: rows = conn.execute( "SELECT * FROM operations_mapping WHERE account_id=? ORDER BY cod_op_service", (key_account,), ).fetchall() return {"mapari": [dict(r) for r in rows]} finally: conn.close() @router.get("/mapari/pending") def get_mapari_pending( account_id: int = Depends(resolve_account_id), ) -> dict: """Operatii ROAAUTO nemapate (din submission-uri needs_mapping) + sugestii fuzzy. Filtrate pe contul cheii API. Fiecare intrare: {account_id, cod_op_service, denumire, blocked, suggestions:[{cod_prestatie, nume_prestatie, score}]}. """ conn = get_connection() try: return {"pending": pending_unmapped(conn, account_id=account_id)} 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() class RarCredsIn(BaseModel): """Creds RAR durabile per-cont (D4). Stocate criptate (Fernet) in accounts.rar_creds_enc.""" email: str = Field(..., min_length=1) password: str = Field(..., min_length=1, repr=False) @router.post("/conturi/rar-creds") def set_rar_creds( req: RarCredsIn, account_id: int = Depends(resolve_account_id), ) -> dict: """Seteaza creds RAR durabile per-cont (D4/T1). Criptate Fernet in accounts.rar_creds_enc. Worker-ul le foloseste ca fallback cand submission-ul nu mai are creds (canal web fara re-pusher, restart worker). Contul vine din cheia API. """ acct = account_or_default(account_id) enc = encrypt_creds({"email": req.email, "password": req.password}) conn = get_connection() try: conn.execute( "UPDATE accounts SET rar_creds_enc=? WHERE id=?", (enc, acct), ) return {"ok": True, "account_id": acct} finally: conn.close() @router.delete("/conturi/rar-creds") def delete_rar_creds( account_id: int = Depends(resolve_account_id), ) -> dict: """Sterge creds RAR durabile per-cont (revenire la modelul efemer Treapta 1).""" acct = account_or_default(account_id) conn = get_connection() try: conn.execute("UPDATE accounts SET rar_creds_enc=NULL WHERE id=?", (acct,)) return {"ok": True, "account_id": acct} finally: conn.close()