feat(5.19): auto-send toggle per cont + tinere manuala randuri (held)

Comutator accounts.auto_send_enabled per cont: Auto OFF (default) tine randurile
la ingestie (submissions.held=1), worker-ul (claim_one AND held=0) le sare pana la
eliberare umana (per rand/bulk/auto-release OFF->ON). Snapshot held prin chokepoint
unic held_for_account pe toate caile de ingestie (API, import, reresolve, reactivare).

- schema/migrare: coloana held + index partial idx_submissions_held; auto_send_enabled
- API: echo onest held+motiv (US-010), ruta /prezentari/{id}/trimite-acum
- web: toggle header, modal confirmare tipata, buton Trimite per rand + Trimite toate,
  banner coada tinuta imbatranita (L.142), contor "In asteptare (manual)"
- worker: expire_held (US-008, inchide gaura retentie PII), metrics held gauges
- ops: tools/carantina_held + runbook rollback (R4)

Nota review (/code-review high): re-snapshot held lipseste pe caile repune/corectie
(requeue_submission, post_corectie, bulk-fix) — de aliniat separat cu create_prezentari.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-07-06 08:34:40 +00:00
parent 2ec3292382
commit 3b5cf7a7d9
30 changed files with 2344 additions and 32 deletions

View File

@@ -19,6 +19,7 @@ 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
@@ -87,6 +88,20 @@ def _erori_nemapate(unmapped: list[dict]) -> list[dict]:
]
# PRD 5.19 US-010: 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.
@@ -107,18 +122,22 @@ def _motiv_clasificare(cl: dict) -> str | None:
return None
def _rezultat_enqueue(submission_id: int | None, cl: dict, **extra) -> SubmissionResult:
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.
pentru orice status != 'queued'. Aditiv: pe 'queued' toate raman goale/None, EXCEPTAND
randurile tinute manual (PRD 5.19 US-010: `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_clasificare(cl),
motiv=motiv,
held=tinut,
**extra,
)
@@ -240,6 +259,11 @@ def create_prezentari(
),
)
# PRD 5.19 US-002: 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).
@@ -254,7 +278,7 @@ def create_prezentari(
"odometru_final": canon["odometru_final"],
})
existing = conn.execute(
"SELECT id, status, id_prezentare FROM submissions WHERE idempotency_key=?",
"SELECT id, status, id_prezentare, held FROM submissions WHERE idempotency_key=?",
(key,),
).fetchone()
if existing:
@@ -267,13 +291,16 @@ def create_prezentari(
# on_unmapped_error=True: nu reactivam; randul ramane 'error'.
results.append(_rezultat_respins(existing["id"], cl, rar_env=env))
continue
# PRD 5.19 US-002 (Eng Finding A — bug de bypass): 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, "
"rar_env=?, updated_at=datetime('now') WHERE id=? AND status='error'",
"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, env, existing["id"]),
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
@@ -286,20 +313,25 @@ def create_prezentari(
_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, reactivated=True, rar_env=env))
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 FROM submissions WHERE id=?",
"SELECT id, status, id_prezentare, held FROM submissions WHERE id=?",
(existing["id"],),
).fetchone()
# Echo onest pe dedup (PRD 5.19 US-002/US-010): 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,
)
)
@@ -312,15 +344,17 @@ def create_prezentari(
# on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat).
results.append(_rezultat_respins(None, cl, rar_env=env))
continue
# PRD 5.19 US-002: `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) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc, env),
"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.
results.append(_rezultat_enqueue(sub_id, cl, rar_env=env))
# 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))
# Audit cerere API per cont. Doar metadate (count + distributie status),
# NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL).
@@ -433,7 +467,7 @@ def list_prezentari(
# rar_env inclus (US-005): badge mediu in lista.
cols = (
"id, status, id_prezentare, rar_status_code, retry_count, "
"created_at, updated_at, payload_json, rar_env"
"created_at, updated_at, payload_json, rar_env, held"
)
if status:
rows = conn.execute(
@@ -470,6 +504,8 @@ _PREZENTARE_FIELDS = frozenset({
"rar_error",
# US-005: mediul RAR tinta (Test/Productie) — necesar pentru badge + ecou API.
"rar_env",
# PRD 5.19 US-010: flag `held` (tinut manual) — onestitate GET (dev vede ca NU a plecat).
"held",
})
@@ -548,6 +584,38 @@ def repune_prezentare(
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 (PRD 5.19 US-010, 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()