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