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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user