feat(api): scope pe cont la GET-urile de listare /v1/* (PRD 3.2)
Inchide scurgerea cross-account pe GET /v1/prezentari(/{id}),
/v1/mapari(/pending) si /v1/audit/export. Toate primesc
Depends(resolve_account_id) + account_scope_clause (regula NULL->cont 1,
OV-2). Nomenclatorul ramane global (referinta partajata, fara PII).
- B3: 404 cross-account byte-identic cu 404 inexistent (fara oracol enumerare)
- B4: get_prezentare cu allowlist de campuri (nu mai expune rar_creds_enc/
payload_json/idempotency_key/rar_error)
- B1: pending_unmapped filtreaza in SQL; default None = global doar pentru web
- B2: helper account_scope_clause (DRY, doar pe submissions nullable)
- B5: index idx_submissions_account_status
- B8: regula de scope documentata in api-rar-contract.md
- TD-3.2: ?account_id != contul cheii -> 400
14 teste noi (cross-account, legacy NULL, B3, B4); suita 313 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ from ...db import get_connection
|
||||
from ...idempotency import build_key, canonicalize_row, idempotency_key
|
||||
from ...mapping import (
|
||||
account_or_default,
|
||||
account_scope_clause,
|
||||
has_no_auto_send,
|
||||
load_mapping_meta,
|
||||
pending_unmapped,
|
||||
@@ -130,36 +131,58 @@ def create_prezentari(
|
||||
|
||||
|
||||
@router.get("/prezentari")
|
||||
def list_prezentari(status: str | None = None, limit: int = 100) -> dict:
|
||||
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)
|
||||
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),
|
||||
f"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
|
||||
f"FROM submissions WHERE {scope_sql} AND status=? ORDER BY id DESC LIMIT ?",
|
||||
scope_params + [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,),
|
||||
f"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
|
||||
f"FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?",
|
||||
scope_params + [limit],
|
||||
).fetchall()
|
||||
return {"submissions": [dict(r) for r in rows]}
|
||||
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",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/prezentari/{submission_id}")
|
||||
def get_prezentare(submission_id: int) -> dict:
|
||||
def get_prezentare(
|
||||
submission_id: int,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> dict:
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM submissions WHERE id=?", (submission_id,)).fetchone()
|
||||
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")
|
||||
out = dict(row)
|
||||
out.pop("payload_json", None) # nu expunem payload-ul brut (PII) in listare
|
||||
return out
|
||||
row_dict = dict(row)
|
||||
return {k: v for k, v in row_dict.items() if k in _PREZENTARE_FIELDS}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -193,16 +216,20 @@ AUDIT_COLUMNS = [
|
||||
]
|
||||
|
||||
|
||||
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].
|
||||
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].
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
sql = "SELECT id, status, id_prezentare, account_id, payload_json, rar_status_code, created_at, updated_at, purge_after FROM submissions"
|
||||
where = []
|
||||
params: list = []
|
||||
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)
|
||||
@@ -212,8 +239,7 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str):
|
||||
if date_to:
|
||||
where.append("date(updated_at) <= date(?)")
|
||||
params.append(date_to)
|
||||
if where:
|
||||
sql += " WHERE " + " AND ".join(where)
|
||||
sql += " WHERE " + " AND ".join(where)
|
||||
sql += " ORDER BY id"
|
||||
|
||||
for r in conn.execute(sql, params).fetchall():
|
||||
@@ -230,7 +256,8 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str):
|
||||
"submission_id": r["id"],
|
||||
"status": r["status"],
|
||||
"id_prezentare": r["id_prezentare"] or "",
|
||||
"account_id": r["account_id"] 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 "",
|
||||
@@ -248,11 +275,12 @@ 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 cu ce s-a trimis (audit). Filtre optionale `date_from`/`date_to` (YYYY-MM-DD)
|
||||
"""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. Leaga re_tinerea 90 zile prin coloana
|
||||
`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()
|
||||
@@ -260,7 +288,7 @@ def audit_export(
|
||||
buf = io.StringIO()
|
||||
writer = csv.DictWriter(buf, fieldnames=AUDIT_COLUMNS)
|
||||
writer.writeheader()
|
||||
for row in _audit_rows(conn, date_from, date_to, status):
|
||||
for row in _audit_rows(conn, date_from, date_to, status, account_id):
|
||||
writer.writerow(row)
|
||||
data = buf.getvalue()
|
||||
finally:
|
||||
@@ -275,31 +303,43 @@ def audit_export(
|
||||
|
||||
|
||||
@router.get("/mapari")
|
||||
def get_mapari(account_id: int | None = None) -> dict:
|
||||
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:
|
||||
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()
|
||||
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() -> dict:
|
||||
def get_mapari_pending(
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> 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}]}.
|
||||
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)}
|
||||
return {"pending": pending_unmapped(conn, account_id=account_id)}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -77,6 +77,11 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_submissions_batch ON submissions(batch_id) "
|
||||
"WHERE batch_id IS NOT NULL"
|
||||
)
|
||||
if "idx_submissions_account_status" not in existing_idx:
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_submissions_account_status "
|
||||
"ON submissions(account_id, status)"
|
||||
)
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
|
||||
@@ -129,6 +129,19 @@ def account_or_default(account_id: int | None) -> int:
|
||||
return account_id if account_id is not None else DEFAULT_ACCOUNT_ID
|
||||
|
||||
|
||||
def account_scope_clause(account_id: int) -> tuple[str, list]:
|
||||
"""Fragment SQL + params pentru filtrarea pe cont in tabele cu account_id nullable.
|
||||
|
||||
Aplica regula: NULL apartine contului 1 (legacy/OV-2).
|
||||
Foloseste DOAR pe submissions (account_id NULLABLE).
|
||||
NU folosi pe operations_mapping (account_id NOT NULL) — acolo WHERE account_id=? simplu.
|
||||
"""
|
||||
return (
|
||||
"(account_id = ? OR (account_id IS NULL AND ? = 1))",
|
||||
[account_id, account_id],
|
||||
)
|
||||
|
||||
|
||||
def seed_nomenclator_if_empty(conn) -> int:
|
||||
"""Seed fallback (18 coduri din contract) DOAR daca nomenclator_rar e gol.
|
||||
|
||||
@@ -217,18 +230,28 @@ def has_no_auto_send(resolved: list[dict], mapping_meta: dict[str, dict]) -> boo
|
||||
return False
|
||||
|
||||
|
||||
def pending_unmapped(conn) -> list[dict]:
|
||||
def pending_unmapped(conn, account_id=None) -> list[dict]:
|
||||
"""Operatii distincte nemapate, agregate din submission-urile `needs_mapping`.
|
||||
|
||||
Pentru fiecare (account_id, cod_op_service) intoarce o denumire reprezentativa,
|
||||
nr. de submission-uri blocate si sugestiile fuzzy pe nomenclator. Sursa de
|
||||
adevar = payload_json (nu o tabela separata): un item nemapat are cod_prestatie
|
||||
null + cod_op_service setat.
|
||||
account_id=None (default): global — intentionat pentru web/routes.py (back-compat).
|
||||
Apelantii noi din API TREBUIE sa paseze account_id explicit; None global e
|
||||
footgun (scurge cross-account) si e rezervat exclusiv pentru dashboard-ul intern.
|
||||
|
||||
account_id=int: filtreaza in SQL pe cont inclusiv randuri legacy (account_id IS NULL
|
||||
apartine contului 1, OV-2). Filtrarea in SQL, nu post-hoc in Python.
|
||||
"""
|
||||
nomenclator = load_nomenclator(conn)
|
||||
rows = conn.execute(
|
||||
"SELECT id, account_id, payload_json FROM submissions WHERE status='needs_mapping'"
|
||||
).fetchall()
|
||||
if account_id is not None:
|
||||
scope_sql, scope_params = account_scope_clause(account_id)
|
||||
rows = conn.execute(
|
||||
f"SELECT id, account_id, payload_json FROM submissions "
|
||||
f"WHERE status='needs_mapping' AND {scope_sql}",
|
||||
scope_params,
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, account_id, payload_json FROM submissions WHERE status='needs_mapping'"
|
||||
).fetchall()
|
||||
|
||||
agg: dict[tuple[int, str], dict[str, Any]] = {}
|
||||
for r in rows:
|
||||
|
||||
@@ -74,6 +74,7 @@ CREATE TABLE IF NOT EXISTS submissions (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_submissions_status ON submissions(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_submissions_account_status ON submissions(account_id, status);
|
||||
-- Nota: idx_submissions_batch se creeaza in _migrate (dupa ALTER care adauga batch_id pe DB veche).
|
||||
|
||||
-- Mapare coloane fisier -> campuri canonice (retinuta per cont, semnatura coloane).
|
||||
|
||||
Reference in New Issue
Block a user