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

@@ -53,6 +53,7 @@ from ..api.v1.import_router import (
apply_row_override,
EDIT_FIELDS,
)
from ..accounts import get_auto_send, set_auto_send
from ..config import get_settings
from ..crypto import decrypt_creds, encrypt_creds
from ..db import get_connection, read_app_events, read_heartbeat
@@ -245,6 +246,29 @@ def _status_counts(conn, account_id: int) -> dict[str, int]:
return counts
def _held_counts(conn, account_id: int) -> tuple[int, int]:
"""Contoare randuri tinute manual (PRD 5.19 US-006/US-007) pentru contul din sesiune.
Intoarce (manual_count, aged_count):
- manual_count = randuri `queued AND held=1` (contorul "In asteptare (manual)").
- aged_count = subset mai vechi de `held_warn_days` (banner de conformitate L.142).
Derivat, fara stare noua; varsta pe `created_at` (US-007 AC).
"""
days = int(get_settings().held_warn_days)
row = conn.execute(
"SELECT "
" COUNT(*) AS manual, "
" COUNT(CASE WHEN created_at < datetime('now', ?) THEN 1 END) AS aged "
"FROM submissions "
"WHERE status='queued' AND held=1 "
" AND (account_id = ? OR (? = 1 AND account_id IS NULL))",
(f"-{days} days", account_id, account_id),
).fetchone()
if not row:
return (0, 0)
return (int(row["manual"] or 0), int(row["aged"] or 0))
def _trimiteri_versiune(conn, account_id: int) -> str:
"""Semnatura ieftina a starii trimiterilor contului: numar randuri + cel mai recent
updated_at. Se schimba la orice insert/update/delete -> nudge-ul "Date noi" o compara
@@ -340,9 +364,17 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
counts = _status_counts(conn, account_id)
blocate_total = sum(counts.get(s, 0) for s in _BLOCKED)
# US-005 (5.19): buton bulk "Trimite toate (N)" — N = randuri tinute ale contului.
# env-ul tinta al eliberarii (badge in modalul de confirmare tipata).
held_manual_count, _held_aged = _held_counts(conn, account_id)
env_bulk = rar_env_efectiv_cont(conn, account_id) or "prod"
return {
"request": request,
"are_creds": are_creds,
# US-005: coada tinuta manual + mediul RAR tinta pentru butonul "Trimite toate".
"held_manual_count": held_manual_count,
"env_bulk": env_bulk,
"are_trimiteri": are_trimiteri,
"are_cheie_folosita": are_cheie_folosita,
"blocate_total": blocate_total,
@@ -703,6 +735,9 @@ def _layout_header_ctx(conn, account_id: int) -> dict:
"tier_label": tier_label,
"sanatate_ok": sanatate_ok,
"last_login": format_data_rar(hb["last_rar_login_ok"] if hb else None),
# US-004 (5.19): starea comutatorului "Trimite automat la RAR" pentru toggle-ul
# din clusterul de header (D1) — randat in afara swap-ului periodic al barei de status.
"auto_send_enabled": get_auto_send(conn, account_id),
}
# US-006 (5.17): context plan pentru linia detaliata din meniul burger.
ctx.update(_plan_ctx(conn, account_id))
@@ -939,6 +974,9 @@ def _build_status_ctx(request: Request, conn, account_id: int, *, oob: bool = Fa
medii_disp = medii_disponibile_cont(conn, account_id)
env_default = rar_env_efectiv_cont(conn, account_id) or "prod"
# US-006/US-007 (5.19): contor "In asteptare (manual)" + banner coada tinuta imbatranita.
held_manual_count, held_aged_count = _held_counts(conn, account_id)
status_ctx = {
"request": request,
"worker_lbl": worker_lbl,
@@ -963,6 +1001,11 @@ def _build_status_ctx(request: Request, conn, account_id: int, *, oob: bool = Fa
# US-011: indicator mediu RAR + toggle conditionat
"env_default": env_default,
"medii_disponibile": medii_disp,
# US-006/US-007 (5.19): contor manual + banner coada tinuta imbatranita.
"auto_send_enabled": get_auto_send(conn, account_id),
"held_manual_count": held_manual_count,
"held_aged_count": held_aged_count,
"held_aged_days": int(get_settings().held_warn_days),
"csrf_token": get_csrf_token(request),
}
# US-006 (5.17): context plan pentru linia de consum/trial in _status.html.
@@ -1043,13 +1086,20 @@ def _eticheta_problema(status: str, motiv: str) -> str:
def _submission_row_view(r) -> dict:
"""Imbogateste un rand de submission cu campuri afisabile umane."""
eticheta = eticheta_stare(r["status"])
# US-006 (PRD 5.19 / D5): camp derivat "tinut" calculat AICI (view-model), nu in
# template. `held` e boolean pur de coada; eticheta/pill de avertizare (amber) se
# obtin din labels.py cu held=True doar cand randul e `queued AND held=1`.
held = bool(r["held"]) if ("held" in r.keys() and r["held"] is not None) else False
tinut = held and r["status"] == "queued"
eticheta = eticheta_stare(r["status"], held=tinut)
motiv = motiv_uman(r["status"], r["rar_error"])
return {
"id": r["id"],
"status": r["status"],
# US-006: randul e tinut manual (buton "Trimite" apare DOAR pe el).
"tinut": tinut,
# pill = eticheta scurta; textul lung ramane ca tooltip (title=).
"stare_scurt": eticheta_scurta(r["status"]),
"stare_scurt": eticheta_scurta(r["status"], held=tinut),
"stare_text": eticheta[0],
"stare_css": eticheta[2],
"prez": prezentare_din_payload(r["payload_json"]),
@@ -1076,6 +1126,7 @@ def fragment_submissions(
vehicul: str | None = None,
data_de: str | None = None,
data_pana: str | None = None,
held: int | None = None,
page: int = 1,
) -> HTMLResponse:
"""Tabel Trimiteri, scoped pe cont, cu filtre optionale si paginare.
@@ -1090,7 +1141,7 @@ def fragment_submissions(
vehicul_q = (vehicul or "").strip().upper() or None
data_de = (data_de or "").strip() or None
data_pana = (data_pana or "").strip() or None
filtru_activ = bool(status or vehicul_q or data_de or data_pana)
filtru_activ = bool(status or vehicul_q or data_de or data_pana or held == 1)
filtru_python = bool(vehicul_q or data_de or data_pana) # filtru care necesita Python
page = max(1, page) # pre-clamp >= 1
@@ -1103,6 +1154,10 @@ def fragment_submissions(
if status:
where.append("status=?")
params.append(status)
# US-007 (PRD 5.19): deep-link banner "coada tinuta" -> lista filtrata pe tinute
# (queued AND held=1). Filtru derivat, fara stare noua.
if held == 1:
where.append("held=1")
where_sql = " AND ".join(where)
if filtru_python:
@@ -1110,7 +1165,7 @@ def fragment_submissions(
# FARA LIMIT — altfel paginile >8 ar disparea silentios.
rows_db = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
f"updated_at, payload_json, rar_env FROM submissions WHERE {where_sql} ORDER BY id DESC",
f"updated_at, payload_json, rar_env, held FROM submissions WHERE {where_sql} ORDER BY id DESC",
params,
).fetchall()
@@ -1151,7 +1206,7 @@ def fragment_submissions(
rows_db = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
f"updated_at, payload_json, rar_env FROM submissions WHERE {where_sql} ORDER BY id DESC "
f"updated_at, payload_json, rar_env, held FROM submissions WHERE {where_sql} ORDER BY id DESC "
"LIMIT ? OFFSET ?",
params + [_PAGE_SIZE, offset],
).fetchall()
@@ -1204,7 +1259,7 @@ def _render_submissions(request: Request, conn, account_id: int,
scope_sql, scope_params = account_scope_clause(account_id)
rows = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
f"updated_at, payload_json FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT 200",
f"updated_at, payload_json, rar_env, held FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT 200",
scope_params,
).fetchall()
view = [_submission_row_view(r) for r in rows]
@@ -1927,6 +1982,168 @@ async def post_sterge_bulk(request: Request) -> HTMLResponse:
conn.close()
# =========================================================================== #
# PRD 5.19 — comutator "Trimite automat la RAR" (US-004) + trimitere manuala #
# per rand / bulk (US-005). Toate scoped pe contul sesiunii (NICIODATA din #
# formular), CSRF obligatoriu, eliberare atomica (un singur UPDATE). #
# =========================================================================== #
def _auto_send_toggle_ctx(request: Request, conn, account_id: int) -> dict:
"""Context minimal pentru fragmentul toggle `_auto_send_toggle.html`.
Reflecta starea persistata `accounts.auto_send_enabled` a contului din sesiune.
"""
return {
"request": request,
"auto_send_enabled": get_auto_send(conn, account_id),
"csrf_token": get_csrf_token(request),
"confirmare_release": False,
"curata_modal": False,
}
@router.post("/auto-send", response_class=HTMLResponse)
async def post_auto_send(request: Request) -> HTMLResponse:
"""Comuta comutatorul "Trimite automat la RAR" (PRD 5.19 US-004), persistat pe cont.
`account_id` din SESIUNE (NICIODATA din formular). Non-optimist: raspunde cu toggle-ul
re-randat din starea reala din DB (OOB outerHTML pe #auto-send-toggle-wrap).
Garda de confirmare OFF->ON (F4/D4): daca exista N>0 randuri `queued AND held=1`, NU
comita ON pana la o confirmare tipata (modal) — intoarce toggle-ul pe starea veche +
modalul de confirmare (OOB in #modal-confirma-slot). Cu `confirma=1`: comita ON si
elibereaza randurile tinute printr-un SINGUR UPDATE atomic, scoped strict pe cont.
ON->OFF nu retrage randuri deja eliberate.
"""
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
enabled = str(form.get("enabled") or "") not in ("", "false", "0", "off")
confirma = str(form.get("confirma") or "") not in ("", "false", "0", "off")
conn = get_connection()
try:
eliberate = 0
if enabled:
manual_count, _aged = _held_counts(conn, account_id)
if manual_count > 0 and not confirma:
# Garda: NU comita ON; toggle ramane pe starea din DB (OFF) + modal OOB.
env_default = rar_env_efectiv_cont(conn, account_id) or "prod"
ctx = _auto_send_toggle_ctx(request, conn, account_id)
ctx.update({
"confirmare_release": True,
"release_count": manual_count,
"release_env": env_default,
})
return templates.TemplateResponse("_auto_send_toggle.html", ctx)
# Comita ON + eliberare atomica (single UPDATE scoped, NU loop).
set_auto_send(conn, account_id, True)
scope_sql, scope_params = account_scope_clause(account_id)
cur = conn.execute(
"UPDATE submissions SET held=0, updated_at=datetime('now') "
f"WHERE status='queued' AND held=1 AND {scope_sql}",
scope_params,
)
eliberate = int(cur.rowcount or 0)
conn.commit()
else:
set_auto_send(conn, account_id, False)
conn.commit()
# Audit (US-009): comutarea + eventuala eliberare in bloc.
log_event(
"auto_send_schimbat", account_id=account_or_default(account_id),
mesaj=f"auto_send -> {1 if enabled else 0}",
context={"enabled": 1 if enabled else 0, "eliberate": eliberate}, conn=conn,
)
if enabled and eliberate:
log_event(
"held_eliberat", account_id=account_or_default(account_id),
mesaj=f"eliberate {eliberate} (auto)",
context={"count": eliberate, "mod": "auto"}, conn=conn,
)
# OOB: toggle re-randat (starea noua) + curata slotul de modal + refresh status/lista.
ctx = _auto_send_toggle_ctx(request, conn, account_id)
ctx["curata_modal"] = True
resp = templates.TemplateResponse("_auto_send_toggle.html", ctx)
resp.headers["HX-Trigger"] = "trimiteriChanged"
return resp
finally:
conn.close()
@router.post("/trimitere/{submission_id}/trimite-acum", response_class=HTMLResponse)
async def post_trimite_acum(request: Request, submission_id: int) -> HTMLResponse:
"""Trimitere manuala per rand (PRD 5.19 US-005): elibereaza un rand tinut (held 1->0).
Scoped pe sesiune (404-before-leak pe id strain via `_fetch_submission_scoped`).
UPDATE include `AND status='queued' AND held=1` -> no-op sigur daca randul a fost deja
luat de worker (`sending`) sau eliberat intre afisaj si click (edge race). Worker-ul
preia randul la urmatorul poll (trimitere asincrona).
"""
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
conn = get_connection()
try:
row = _fetch_submission_scoped(conn, account_id, submission_id)
if not row:
raise HTTPException(status_code=404, detail="trimitere inexistenta")
scope_sql, scope_params = account_scope_clause(account_id)
cur = conn.execute(
"UPDATE submissions SET held=0, updated_at=datetime('now') "
f"WHERE id=? AND status='queued' AND held=1 AND {scope_sql}",
[submission_id] + scope_params,
)
conn.commit()
eliberate = int(cur.rowcount or 0)
if eliberate:
log_event(
"held_eliberat", account_id=account_or_default(account_id),
mesaj="eliberat 1 (rand)",
context={"count": eliberate, "mod": "rand", "submission_id": submission_id},
conn=conn,
)
resp = _render_submissions(request, conn, account_id)
resp.headers["HX-Trigger"] = "trimiteriChanged"
return resp
finally:
conn.close()
@router.post("/trimite-toate", response_class=HTMLResponse)
async def post_trimite_toate(request: Request) -> HTMLResponse:
"""Trimitere manuala in bloc (PRD 5.19 US-005): elibereaza TOATE randurile tinute ale
contului din sesiune (queued AND held=1 -> held=0) printr-un SINGUR UPDATE atomic scoped.
NU poate elibera randurile altui cont (scope strict pe sesiune). Worker-ul preia
randurile la urmatorul poll.
"""
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
conn = get_connection()
try:
scope_sql, scope_params = account_scope_clause(account_id)
cur = conn.execute(
"UPDATE submissions SET held=0, updated_at=datetime('now') "
f"WHERE status='queued' AND held=1 AND {scope_sql}",
scope_params,
)
conn.commit()
eliberate = int(cur.rowcount or 0)
if eliberate:
log_event(
"held_eliberat", account_id=account_or_default(account_id),
mesaj=f"eliberate {eliberate} (bulk)",
context={"count": eliberate, "mod": "bulk"}, conn=conn,
)
resp = _render_submissions(request, conn, account_id)
resp.headers["HX-Trigger"] = "trimiteriChanged"
return resp
finally:
conn.close()
# =========================================================================== #
# US-010 (PRD 5.15): Bulk-fix — aplica un cod RAR la selectia de randuri #
# blocate. Reuse form #bulk-trimiteri + validare cod din post_corectie. #