Files
rar-autopass/app/api/v1/router.py
Marius Mutu 1091745789 feat(api): filtru perioada data_de/data_pana pe GET /v1/prezentari
data_prestatie traieste in payload_json, nu e coloana SQL: filtrarea se aplica
in Python dupa derivarea prezentarii (fetch scope+status fara LIMIT, filtreaza,
apoi LIMIT dupa filtrare), acelasi pattern deja folosit de dashboard-ul web
(/_fragments/submissions). Extrage helper-ul iso_date_prefix in payload_view.py
ca sa fie partajat intre canalul web si cel API (elimina duplicarea).

Format invalid pe data_de/data_pana -> 422. Fara parametri, comportamentul
raspunsului ramane identic cu inainte.
2026-07-10 12:53:21 +00:00

978 lines
39 KiB
Python

"""API v1 — suprafata gateway.
Endpointuri:
- 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.
"""
from __future__ import annotations
import csv
import io
import json
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from ...accounts import held_for_account
from ...auth import require_api_access, 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 ...rar_env import MediuIndisponibil, rezolva_rar_env
from ...mapping import (
_emite_text_rule_hits,
account_or_default,
account_scope_clause,
classify_prezentare,
load_excluded_ops,
load_mapping_meta,
load_nomenclator,
load_nomenclator_codes,
load_text_rules,
pending_unmapped,
reresolve_account,
save_mapping,
sugestie_pentru_op,
)
from ...models import (
PrezentareRequest,
PrezentariResponse,
SubmissionResult,
SubmissionResultMinim,
ValidarePrezentariRequest,
ValidareResponse,
ValidareResult,
)
from ...observ import log_event
from ...payload_view import iso_date_prefix, 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, text_rules=None, excluded_ops=None) -> 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, text_rules, excluded_ops)
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)."""
return [
{**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} necunoscut/fara mapare RAR")}
for u in unmapped
]
def _ataseaza_sugestii(conn, cl: dict, nomenclator: list[dict]) -> None:
"""Adauga `sugestie` ({cod_prestatie, nume_prestatie, sursa} | None) pe fiecare
cod nemapat din clasificare. Suggestion-only: informeaza raspunsul API, nu
schimba statusul si nu se aplica automat."""
for u in cl.get("unmapped") or []:
u["sugestie"] = sugestie_pentru_op(
conn, u.get("denumire") or u.get("cod_op_service"), nomenclator
)
# Mesaj uman pentru un rand tinut manual (queued AND held=1).
# Reutilizeaza vocabularul existent AUTO_SEND_OPRIT (errors.py) — nu inventam al treilea
# vocabular "auto_send". Onestitate: raspunsul NU trebuie sa arate un "queued" curat fals.
MOTIV_HELD = (
"In asteptare — tinut pentru verificare; NU trimis la RAR (Auto OFF). "
"Elibereaza manual (trimite-acum) sau activeaza Trimiterea automata."
)
def _motiv_held(status: str, held: bool) -> str | None:
"""Motiv uman pentru un rand tinut manual; None cand nu e cazul (status!=queued sau held=0)."""
return MOTIV_HELD if (held and status == "queued") else None
def _motiv_clasificare(cl: dict) -> str | None:
"""Rezumat uman pe o linie pentru un rezultat de clasificare.
None cand status='queued'. Acopera ramurile de blocaj: erori de continut
(needs_data) si coduri fara mapare RAR (needs_mapping).
needs_mapping apare EXCLUSIV cand unmapped e non-gol.
"""
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"]:
parti = []
for u in cl["unmapped"]:
op = u.get("cod_op_service") or ""
s = u.get("sugestie")
if s and s.get("cod_prestatie"):
nume = f" {s['nume_prestatie']}" if s.get("nume_prestatie") else ""
parti.append(f"{op} (sugestie: {s['cod_prestatie']}{nume})")
else:
parti.append(op)
return f"Coduri fara mapare RAR: {', '.join(parti)}"
return None
def _rezultat_enqueue(submission_id: int | None, cl: dict, held: int = 0, **extra) -> SubmissionResult:
"""SubmissionResult onest dintr-un rezultat de clasificare.
Populeaza erori (validare continut), nemapate (coduri fara mapare) si motiv (uman)
pentru orice status != 'queued'. Aditiv: pe 'queued' toate raman goale/None, EXCEPTAND
randurile tinute manual (`held=1` -> held=true + motiv non-null).
"""
tinut = bool(held)
motiv = _motiv_clasificare(cl) or _motiv_held(cl["status"], tinut)
return SubmissionResult(
submission_id=submission_id,
status=cl["status"],
erori=list(cl["errors"]),
nemapate=_erori_nemapate(cl["unmapped"]),
motiv=motiv,
held=tinut,
**extra,
)
def _rezultat_respins(submission_id: int | None, cl: dict, rar_env: str = "test") -> 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),
rar_env=rar_env,
)
@router.post("/prezentari", response_model=PrezentariResponse)
def create_prezentari(
req: PrezentareRequest,
account_id: int = Depends(require_api_access),
) -> PrezentariResponse:
"""Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission.
Validarea de continut (app.validation) ruleaza inainte de enqueue: esecurile NU
resping cererea, ci enqueue-aza cu status `needs_data` + motiv. 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.
Cand rar_credentials lipseste, submission-ul intra fara creds efemere: worker-ul
cade pe creds-urile durabile ale contului (per-env: `accounts.rar_creds_{env}_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. 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:
# load_mapping_meta incarca maparea op->cod RAR; auto_send nu mai tine randuri.
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
# Nomenclator complet (cod+nume) pentru sugestiile atasate codurilor nemapate.
nomenclator = load_nomenclator(conn)
# Reguli text incarcate o data per cerere (seam partajat cu dry-run).
text_rules = load_text_rules(conn, acct)
excluded_ops = load_excluded_ops(conn, acct)
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
# Rezolva mediul RAR tinta (cerut > default cont > ancora globala).
# MediuIndisponibil -> 422 inainte de orice enqueue (respinge tot lotul).
try:
env = rezolva_rar_env(conn, acct, req.rar_env)
except MediuIndisponibil as e:
# Audit blocare mediu indisponibil (tip='rar_env_blocat').
log_event(
"rar_env_blocat",
nivel="WARNING",
account_id=acct,
context={"env": e.env},
conn=conn,
)
raise HTTPException(
status_code=422,
detail=err_eroare(
"RAR_MEDIU_INDISPONIBIL",
cauza=(
f"mediu cerut: {e.env}; disponibile: "
f"{', '.join(e.disponibile) or 'niciunul'}"
),
),
)
except ValueError:
# Pydantic Literal prinde valorile invalide inainte sa ajunga aici;
# ramura e defensiva pentru apeluri directe fara model Pydantic.
raise HTTPException(
status_code=422,
detail=err_eroare("RAR_MEDIU_INDISPONIBIL", cauza="valoare invalida pentru rar_env"),
)
# Enforce volum plan — INAINTE de build_key/enqueue (invariant idempotenta).
# Respingere TOTALA a lotului (nu enqueue partial tacut).
from ...config import get_settings as _get_settings
from ...plans import PLANS, effective_tier, monthly_usage
_settings = _get_settings()
if _settings.enforce_plans:
_acct_row = conn.execute(
"SELECT tier, trial_until FROM accounts WHERE id=?", (acct,)
).fetchone()
_now = datetime.now(timezone.utc)
_et = effective_tier(_acct_row, _now)
_plan_limit = PLANS[_et].get("monthly_limit")
if _plan_limit is not None:
_usage = monthly_usage(conn, acct, _now)
_nr_cerut = len(req.prezentari)
if _usage + _nr_cerut > _plan_limit:
_remaining = max(0, _plan_limit - _usage)
log_event(
"plan_limita_lunara_atinsa",
account_id=acct,
nivel="WARNING",
mesaj=f"Lot de {_nr_cerut} respins (usage={_usage}, limita={_plan_limit})",
context={
"nr_cerut": _nr_cerut, "usage": _usage,
"plan_limit": _plan_limit, "tier": _et,
},
conn=conn,
)
raise HTTPException(
status_code=422,
detail=err_eroare(
"PLAN_LIMITA_LUNARA",
cauza=(
f"Ai trimis {_usage}/{_plan_limit} prezentari luna aceasta;"
f" mai poti trimite {_remaining}."
),
),
)
# Snapshot `held` din comutatorul contului (chokepoint unic held_for_account).
# 0 daca Auto ON, 1 daca Auto OFF (tine randul). Depinde de
# cont, nu de rand -> calculat o data. NU intra in payload/idempotenta.
held_val = held_for_account(conn, acct)
for prez in req.prezentari:
content = prez.model_dump()
# 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, env)
# Aplica normalizarea si in content (odometru canonicalizat inainte de validare)
content.update({
"vin": canon["vin"],
"nr_inmatriculare": canon["nr_inmatriculare"],
"odometru_final": canon["odometru_final"],
})
existing = conn.execute(
"SELECT id, status, id_prezentare, held FROM submissions WHERE idempotency_key=?",
(key,),
).fetchone()
if existing:
# 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, text_rules, excluded_ops)
_ataseaza_sugestii(conn, cl, nomenclator)
if cl["blocked_error"]:
# on_unmapped_error=True: nu reactivam; randul ramane 'error'.
results.append(_rezultat_respins(existing["id"], cl, rar_env=env))
continue
# La reactivare re-snapshot-am `held` din comutatorul contului. Fara
# asta randul pastra held VECHI si se auto-trimitea desi contul e Auto OFF.
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, "
"held=?, rar_env=?, updated_at=datetime('now') WHERE id=? AND status='error'",
(cl["status"], json.dumps(cl["content"], ensure_ascii=False),
cl["rar_error"], creds_enc, held_val, env, existing["id"]),
)
if cur.rowcount == 1:
# Creds noi se propaga si in slotul durabil per-env al contului
# — ambele canale converg pe parola corectata (env-aware).
if req.rar_credentials is not None:
conn.execute(
f"UPDATE accounts SET rar_creds_{env}_enc=?, rar_{env}_enabled=1 WHERE id=?",
(encrypt_creds(req.rar_credentials.model_dump()), acct),
)
_emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"])
# Raspuns onest si la reactivare: daca re-clasificarea cade pe
# needs_data/needs_mapping, expune motivul (nu doar status).
results.append(_rezultat_enqueue(existing["id"], cl, held=held_val, reactivated=True, rar_env=env))
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, held FROM submissions WHERE id=?",
(existing["id"],),
).fetchone()
# Echo onest pe dedup: propaga `held` al randului
# existent — un rand tinut NU trebuie sa apara ca un "queued" curat fals.
dedup_held = bool(existing["held"])
results.append(
SubmissionResult(
submission_id=existing["id"],
status=existing["status"],
id_prezentare=existing["id_prezentare"],
deduped=True,
held=dedup_held,
motiv=_motiv_held(existing["status"], dedup_held),
rar_env=env,
)
)
continue
# Helper pur partajat cu dry-run: reproduce EXACT clasificarea
# (canonicalize + mapare op->cod + validare; fara gate auto_send).
cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules, excluded_ops)
_ataseaza_sugestii(conn, cl, nomenclator)
if cl["blocked_error"]:
# on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat).
results.append(_rezultat_respins(None, cl, rar_env=env))
continue
# `held` = snapshot comutator cont (chokepoint held_for_account).
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc, rar_env, held) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc, env, held_val),
)
sub_id = int(cur.lastrowid)
_emite_text_rule_hits(conn, acct, sub_id, cl["resolved"])
# Raspuns onest: pe needs_data/needs_mapping expune erori/nemapate/motiv;
# pe queued tinut manual (held=1) expune held=true + motiv.
results.append(_rezultat_enqueue(sub_id, cl, held=held_val, rar_env=env))
# Corelare raspuns<->cerere: fiecare prezentare produce EXACT un rezultat,
# in ordine, deci pozitia din lista e si indexul cererii. Ecoul vehiculului
# foloseste valorile normalizate de Pydantic (upper/strip).
for i, (rez, prez) in enumerate(zip(results, req.prezentari)):
rez.index = i
rez.vin = prez.vin
rez.nr_inmatriculare = prez.nr_inmatriculare
# Audit cerere API per cont. Doar metadate (count + distributie status),
# NICIUN camp de payload PII integral. Reuse conn (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()
# Forma implicita = minim (6 chei fixe per rezultat); formatul complet
# (erori/nemapate/deduped/held/...) doar la cerere explicita.
if req.raspuns == "complet":
return PrezentariResponse(results=list(results))
return PrezentariResponse(results=[
SubmissionResultMinim(
index=r.index if r.index is not None else 0,
submission_id=r.submission_id,
vehicul=r.nr_inmatriculare or "",
status=r.status,
motiv=r.motiv,
id_prezentare=r.id_prezentare,
)
for r in 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.
"""
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
nomenclator = load_nomenclator(conn)
# Acelasi seam ca trimiterea reala: dry-run trebuie sa vada aceleasi reguli text.
text_rules = load_text_rules(conn, acct)
excluded_ops = load_excluded_ops(conn, acct)
error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error)
# Rezolva env identic ca trimiterea reala si ecou-ieste in raspuns.
try:
env = rezolva_rar_env(conn, acct, req.rar_env)
except MediuIndisponibil as e:
# Audit blocare mediu indisponibil pe dry-run (tip='rar_env_blocat').
log_event(
"rar_env_blocat",
nivel="WARNING",
account_id=acct,
context={"env": e.env},
conn=conn,
)
raise HTTPException(
status_code=422,
detail=err_eroare(
"RAR_MEDIU_INDISPONIBIL",
cauza=(
f"mediu cerut: {e.env}; disponibile: "
f"{', '.join(e.disponibile) or 'niciunul'}"
),
),
)
except ValueError:
raise HTTPException(
status_code=422,
detail=err_eroare("RAR_MEDIU_INDISPONIBIL", cauza="valoare invalida pentru rar_env"),
)
for i, prez in enumerate(req.prezentari):
content = prez.model_dump()
res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules, excluded_ops)
_ataseaza_sugestii(conn, res, nomenclator)
if res["blocked_error"]:
res = {**res, "status": "error"}
# 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"],
rar_env=env,
erori=res["errors"],
nemapate=nemapate,
prestatii_rezolvate=res["resolved"],
))
finally:
conn.close()
return ValidareResponse(results=results)
def _valida_data_filtru(nume_param: str, valoare: str | None) -> str | None:
"""Valideaza formatul YYYY-MM-DD al unui parametru de filtrare pe perioada.
None/gol -> None (filtru inactiv). Format invalid -> 422, consecvent cu restul
rutelor API (vezi celelalte `status_code=422` din acest fisier).
"""
v = (valoare or "").strip()
if not v:
return None
try:
datetime.strptime(v, "%Y-%m-%d")
except ValueError:
raise HTTPException(
status_code=422,
detail=f"{nume_param} trebuie sa fie in format YYYY-MM-DD",
)
return v
@router.get("/prezentari")
def list_prezentari(
status: str | None = None,
data_de: str | None = None,
data_pana: str | None = None,
limit: int = 100,
account_id: int = Depends(resolve_account_id),
) -> dict:
data_de = _valida_data_filtru("data_de", data_de)
data_pana = _valida_data_filtru("data_pana", data_pana)
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, nu il expunem.
# rar_env inclus: badge mediu in lista.
cols = (
"id, status, id_prezentare, rar_status_code, retry_count, "
"created_at, updated_at, payload_json, rar_env, held"
)
where = [scope_sql]
params: list = list(scope_params)
if status:
where.append("status=?")
params.append(status)
where_sql = " AND ".join(where)
# data_prestatie traieste in payload_json, nu e coloana SQL (vezi
# payload_view.prezentare_din_payload) — nu poate intra in clauza WHERE.
# Cu filtru de perioada activ: fetch scope+status FARA LIMIT, filtreaza in
# Python dupa derivarea prezentarii, apoi LIMIT-eaza DUPA filtrare (altfel
# LIMIT SQL ar trunchia gresit inainte de filtru). Acelasi pattern ca
# /_fragments/submissions (dashboard web, app/web/routes.py).
filtru_perioada = bool(data_de or data_pana)
if filtru_perioada:
rows = conn.execute(
f"SELECT {cols} FROM submissions WHERE {where_sql} ORDER BY id DESC",
params,
).fetchall()
else:
rows = conn.execute(
f"SELECT {cols} FROM submissions WHERE {where_sql} ORDER BY id DESC LIMIT ?",
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.
prezentare = prezentare_din_payload(d.pop("payload_json", None))
if filtru_perioada:
d_prefix = iso_date_prefix(prezentare["data_prestatie"])
if d_prefix is None:
continue
if data_de and d_prefix < data_de:
continue
if data_pana and d_prefix > data_pana:
continue
d["prezentare"] = prezentare
out.append(d)
if filtru_perioada and len(out) >= limit:
break
return {"submissions": out}
finally:
conn.close()
# Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita.
# Exclude: rar_creds_enc, payload_json, idempotency_key, 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",
# 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",
# Mediul RAR tinta (Test/Productie) — necesar pentru badge + ecou API.
"rar_env",
# Flag `held` (tinut manual) — onestitate GET (dev vede ca NU a plecat).
"held",
})
@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:
# 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.
Raspuns 200 + body JSON (NU 204 — clienti VFP fac string-parse). Scope evaluat
INAINTEA starii: cross-account / inexistent -> 404 (acelasi mesaj);
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.
`error -> queued`, 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.post("/prezentari/{submission_id}/trimite-acum")
def trimite_acum_prezentare(
submission_id: int,
account_id: int = Depends(resolve_account_id),
) -> dict:
"""Elibereaza manual un rand tinut (paritate API cu /repune).
Scoped strict pe contul cheii API (account_id din sesiune/cheie, NICIODATA din body).
404-before-leak pe id strain/inexistent. `held: 1 -> 0` DOAR daca randul e
`queued AND held=1` (no-op sigur altfel: un rand deja `sending`/`sent` ramane neatins).
Worker-ul preia randul la urmatorul poll (trimitere asincrona).
"""
conn = get_connection()
try:
scope_sql, scope_params = account_scope_clause(account_id)
row = conn.execute(
f"SELECT id FROM submissions WHERE id=? AND {scope_sql}",
[submission_id] + scope_params,
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="submission inexistent")
cur = conn.execute(
f"UPDATE submissions SET held=0, updated_at=datetime('now') "
f"WHERE id=? AND {scope_sql} AND status='queued' AND held=1",
[submission_id] + scope_params,
)
conn.commit()
return {"ok": True, "eliberat": cur.rowcount == 1}
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",
# Mediul RAR tinta per trimitere.
"rar_env",
"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. 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, "
"rar_env, 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: 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 "",
# Mediul RAR tinta — coloana audit.
"rar_env": r["rar_env"] 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`. 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. 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, stocate criptat (Fernet) in slotul per-mediu.
`rar_target` selecteaza mediul: 'test' | 'prod'. Absent -> mediul ancorei globale
(AUTOPASS_RAR_ENV), implicit 'test'. Schimbare aditiva — clientii vechi care nu trimit
`rar_target` continua sa functioneze (comportament consistent cu ancora globala).
"""
email: str = Field(..., min_length=1)
password: str = Field(..., min_length=1, repr=False)
rar_target: str | None = None # 'test' | 'prod' | None -> ancora globala
@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, in slotul per-mediu (env-aware).
Slotul tinta: `req.rar_target` ('test'/'prod') sau ancora globala (AUTOPASS_RAR_ENV).
Activeaza mediul selectat (`rar_{env}_enabled=1`). Worker-ul le foloseste ca
fallback cand submission-ul nu mai are creds efemere. Contul vine din cheia API.
"""
from ...config import get_settings as _gs
_s = _gs()
env = req.rar_target if req.rar_target in ("test", "prod") else (
_s.rar_env if _s.rar_env in ("test", "prod") else "test"
)
acct = account_or_default(account_id)
enc = encrypt_creds({"email": req.email, "password": req.password})
conn = get_connection()
try:
conn.execute(
f"UPDATE accounts SET rar_creds_{env}_enc=?, rar_{env}_enabled=1 WHERE id=?",
(enc, acct),
)
return {"ok": True, "account_id": acct, "rar_env": env}
finally:
conn.close()
@router.delete("/conturi/rar-creds")
def delete_rar_creds(
account_id: int = Depends(resolve_account_id),
env: str | None = None,
) -> dict:
"""Sterge creds RAR durabile per-cont (revenire la modelul efemer per submission).
`env` (query param): 'test' | 'prod' -> sterge DOAR slotul acelui mediu + dezactiveaza-l.
Absent -> sterge AMBELE sloturi (revenire completa). Schimbare aditiva (back-compat).
"""
acct = account_or_default(account_id)
conn = get_connection()
try:
if env in ("test", "prod"):
conn.execute(
f"UPDATE accounts SET rar_creds_{env}_enc=NULL, rar_{env}_enabled=0 WHERE id=?",
(acct,),
)
else:
conn.execute(
"UPDATE accounts SET rar_creds_test_enc=NULL, rar_test_enabled=0, "
"rar_creds_prod_enc=NULL, rar_prod_enabled=0 WHERE id=?",
(acct,),
)
return {"ok": True, "account_id": acct}
finally:
conn.close()