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

@@ -150,6 +150,40 @@ def set_status(conn: sqlite3.Connection, account_id: int, status: str) -> None:
)
def get_auto_send(conn: sqlite3.Connection, account_id: int) -> bool:
"""Comutatorul Auto per cont (PRD 5.19 US-001): `accounts.auto_send_enabled` ca bool.
Cont inexistent -> False (Auto OFF: sigur, tine randurile). Nu comita (ca set_active).
"""
row = conn.execute(
"SELECT auto_send_enabled FROM accounts WHERE id=?", (account_id,)
).fetchone()
if not row:
return False
return bool(row["auto_send_enabled"])
def set_auto_send(conn: sqlite3.Connection, account_id: int, enabled: bool) -> None:
"""Comuta `accounts.auto_send_enabled` (PRD 5.19 US-001). Idempotent (set pe aceeasi
valoare nu arunca). Scoped pe cont. Nu comita — respecta conventia fisierului (apelantul
comita, ca la set_active/set_status). Cont inexistent -> UPDATE fara efect (no-op sigur).
"""
conn.execute(
"UPDATE accounts SET auto_send_enabled=? WHERE id=?",
(1 if enabled else 0, account_id),
)
def held_for_account(conn: sqlite3.Connection, account_id: int) -> int:
"""Snapshot-ul `held` la ingestie (PRD 5.19 US-001). SURSA UNICA (chokepoint) folosita de
TOATE situ-rile de ingestie care scriu `status='queued'` (US-002).
Intoarce 0 (nu tine) daca `accounts.auto_send_enabled=1`, altfel 1 (tine randul). Cont
inexistent -> 1 (sigur: tine randul, nu-l trimite fara comutator explicit).
"""
return 0 if get_auto_send(conn, account_id) else 1
def set_tier(
conn: sqlite3.Connection,
account_id: int,

View File

@@ -32,6 +32,7 @@ from pydantic import BaseModel, Field
from datetime import datetime, timezone
from ... import errors
from ...accounts import held_for_account
from ...auth import require_api_access, resolve_account_id
from ...crypto import decrypt_creds, encrypt_creds
from ...db import get_connection
@@ -1125,6 +1126,9 @@ def commit_import(
conn.execute("BEGIN IMMEDIATE")
try:
purge_after_sql = "datetime('now', '+90 days')"
# PRD 5.19 US-002: snapshot `held` din comutatorul contului (chokepoint unic
# held_for_account) — 0 daca Auto ON, 1 daca Auto OFF. Per-cont -> o data.
held_val = held_for_account(conn, acct)
for ok_row in ok_rows:
row_dict = ok_row["data"]
@@ -1208,9 +1212,9 @@ def commit_import(
# INSERT ON CONFLICT DO NOTHING (TOCTOU)
cur = conn.execute(
"INSERT OR IGNORE INTO submissions "
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after, rar_env) "
"VALUES (?, ?, 'queued', ?, ?, ?, " + purge_after_sql + ", ?)",
(key, acct, payload_json, import_id, row_index, env),
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after, rar_env, held) "
"VALUES (?, ?, 'queued', ?, ?, ?, " + purge_after_sql + ", ?, ?)",
(key, acct, payload_json, import_id, row_index, env, held_val),
)
if cur.rowcount == 0:

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()

View File

@@ -35,6 +35,12 @@ class Settings(BaseSettings):
# Retentie randuri blocate (error/needs_data/needs_mapping). Mai scurt decat 90z
# ale `sent` — un blocat n-are valoare de audit.
blocked_retention_days: int = 30
# Prag zile (PRD 5.19 US-007): randurile tinute manual (`queued AND held=1`) mai vechi de
# atat declanseaza bannerul de avertizare L.142 (declararea are termen legal).
held_warn_days: int = 7
# Retentie randuri tinute manual (PRD 5.19 US-008): un `queued AND held=1` mai vechi de atat
# expira la `error` (`TINUT_EXPIRAT`) + purge_after direct. Aliniat retentiei `sent` (90z).
held_retention_days: int = 90
# --- Securitate ---
# Enforcement auth API-key pe /v1/* protejat. False (dev/test): fara cheie ->

View File

@@ -83,6 +83,13 @@ def _migrate(conn: sqlite3.Connection) -> None:
"CHECK (rar_env IN ('test', 'prod'))"
)
_backfill_submissions_rar_env(conn)
if "held" not in sub_cols:
# PRD 5.19 US-001: flag rand tinut manual. Default 0 (randurile pre-migrare nu erau
# tinute — pastreaza comportamentul; comutatorul de cont e OFF/held la ingestie NOUA).
conn.execute(
"ALTER TABLE submissions ADD COLUMN held INTEGER NOT NULL DEFAULT 0 "
"CHECK (held IN (0, 1))"
)
# Coloane accounts
acc_cols = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
@@ -113,6 +120,14 @@ def _migrate(conn: sqlite3.Connection) -> None:
"ALTER TABLE accounts ADD COLUMN on_unmapped_error_default INTEGER NOT NULL DEFAULT 0 "
"CHECK (on_unmapped_error_default IN (0, 1))"
)
if "auto_send_enabled" not in acc_cols:
# PRD 5.19 US-001: comutator Auto per cont. Default 0 (Auto OFF) — conturile existente
# trec pe „tine randurile" la ingestie noua pana cand operatorul porneste Auto explicit.
# Contul id=1 ramane pe default (0), fara tratament special.
conn.execute(
"ALTER TABLE accounts ADD COLUMN auto_send_enabled INTEGER NOT NULL DEFAULT 0 "
"CHECK (auto_send_enabled IN (0, 1))"
)
if "email" not in acc_cols:
# Email canonic de contact al firmei (US-001, PRD 5.12). Nullable pt. conturi legacy.
conn.execute("ALTER TABLE accounts ADD COLUMN email TEXT")
@@ -178,6 +193,12 @@ def _migrate(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_submissions_account_status "
"ON submissions(account_id, status)"
)
if "idx_submissions_held" not in existing_idx:
# PRD 5.19 US-001 (Eng MEDIUM): CREATE TABLE IF NOT EXISTS nu se declanseaza pe DB
# existent -> indexul partial trebuie creat si aici, nu doar in schema.sql.
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_submissions_held ON submissions(held) WHERE held=1"
)
def _migrate_accounts_medii(conn: sqlite3.Connection, acc_cols: set[str]) -> None:

View File

@@ -177,9 +177,21 @@ def metrics() -> str:
conn = get_connection()
try:
rows = conn.execute("SELECT status, COUNT(*) AS n FROM submissions GROUP BY status").fetchall()
# Coada tinuta (PRD 5.19 US-007): observabilitate ops pt. default Auto OFF.
# Gauge-uri DERIVATE (zero stare noua): total randuri tinute + varsta celui
# mai vechi (din created_at), scoped global. Semnaleaza esecul silentios
# cand prezentari raman nedeclarate tacit (risc L.142).
held = conn.execute(
"SELECT COUNT(*) AS n, "
"COALESCE(MAX(strftime('%s','now') - strftime('%s', created_at)), 0) AS oldest "
"FROM submissions WHERE status='queued' AND held=1"
).fetchone()
finally:
conn.close()
lines = ["# submissions pe status"]
for r in rows:
lines.append(f'autopass_submissions{{status="{r["status"]}"}} {r["n"]}')
# randuri tinute (queued AND held=1)
lines.append(f'autopass_held_submissions {held["n"]}')
lines.append(f'autopass_held_oldest_age_seconds {held["oldest"]}')
return "\n".join(lines) + "\n"

View File

@@ -23,6 +23,7 @@ from typing import Any
from rapidfuzz import fuzz, process
from . import errors as err_mod
from .accounts import held_for_account
from .nomenclator_seed import FALLBACK_NOMENCLATOR
from .validation import validate_prezentare
@@ -891,10 +892,14 @@ def reresolve_account(conn, account_id: int | None, batch_id: int | None = None)
)
stats["needs_data"] += 1
else:
# PRD 5.19 US-002: re-snapshot `held` din comutatorul contului la trecerea
# needs_mapping -> queued (chokepoint held_for_account, `acct` deja normalizat
# prin account_or_default la intrarea in reresolve_account). Altfel un rand
# deblocat din needs_mapping ar pleca automat desi contul e Auto OFF (R2).
conn.execute(
"UPDATE submissions SET status='queued', payload_json=?, rar_error=NULL, "
"retry_count=0, next_attempt_at=NULL, updated_at=datetime('now') WHERE id=?",
(payload_json, r["id"]),
"retry_count=0, next_attempt_at=NULL, held=?, updated_at=datetime('now') WHERE id=?",
(payload_json, held_for_account(conn, acct), r["id"]),
)
stats["requeued"] += 1
return stats

View File

@@ -109,6 +109,10 @@ class SubmissionResult(BaseModel):
# RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit. `deduped` pastreaza
# semantica actuala (clientii vechi care testeaza `deduped` nu se sparg).
reactivated: bool = False
# Ecou PUR de coada (PRD 5.19 US-010): True cand randul e `queued AND held=1`
# (tinut manual, cont Auto OFF) -> NU a plecat la RAR, asteapta eliberarea umana.
# NU influenteaza payload/idempotenta; onestitate fata de integratorul API (invariant 5.7).
held: bool = False
# Mediul RAR tinta efectiv (ecou din DB / rezolvat la ingestie).
rar_env: str = "test"
# Raspuns ONEST pentru randuri blocate: orice status != 'queued' isi expune

View File

@@ -33,6 +33,14 @@ CREATE TABLE IF NOT EXISTS accounts (
-- 1 (respinge cererea fara enqueue). Override per-cerere via PrezentareRequest.on_unmapped_error.
on_unmapped_error_default INTEGER NOT NULL DEFAULT 0
CHECK (on_unmapped_error_default IN (0, 1)),
-- Comutator Auto per cont (PRD 5.19 US-001). AL TREILEA `auto_send` din schema (R6):
-- DISTINCT de operations_mapping.auto_send si operation_text_rules.auto_send, care sunt
-- PER-OPERATIE. Acesta e PER-CONT si guverneaza DOAR: (a) valoarea implicita a lui
-- submissions.held la ingestie (held = 0 daca 1, altfel 1 — vezi accounts.held_for_account);
-- (b) eliberarea in bloc a randurilor tinute la comutarea OFF -> ON. NU e per-operatie si
-- NU intra in payload/idempotenta/RAR. Default 0 = Auto OFF (randuri noi tinute manual).
auto_send_enabled INTEGER NOT NULL DEFAULT 0
CHECK (auto_send_enabled IN (0, 1)),
-- Plan de cont (5.17). Tier de baza al contului (admin aloca manual via CLI set-tier).
-- trial_until: daca != NULL si > now -> effective_tier() intoarce 'pro' (trial Pro activ).
-- Cont nou primeste tier='free' + trial_until=now+30z via create_account.
@@ -110,13 +118,23 @@ CREATE TABLE IF NOT EXISTS submissions (
purge_after TEXT, -- sent + 90z (T16)
batch_id INTEGER, -- import batch (T7; NULL = canal API)
row_index INTEGER, -- rand in batch (T7; NULL = canal API)
-- Randuri tinute manual (PRD 5.19 US-001). rand `queued AND held=1` = "In asteptare
-- (manual)": tinut pana la eliberare umana (trimitere manuala per rand/bulk: held 1 -> 0).
-- Worker-ul (claim_one) ia doar `queued AND held=0`. held NU intra in payload/idempotenta
-- si NU pleaca la RAR — e pur control de coada (ca import_rows.reviewed). Snapshot la
-- ingestie din accounts.auto_send_enabled (vezi accounts.held_for_account).
held INTEGER NOT NULL DEFAULT 0 CHECK (held IN (0, 1)),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
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).
-- Nota: idx_submissions_batch si idx_submissions_held se creeaza in _migrate, NU aici. `init_db`
-- ruleaza executescript(schema.sql) INAINTE de _migrate; pe un DB existent pre-`held`/`batch_id`
-- coloana inca nu exista in momentul executarii schemei, deci un CREATE INDEX pe ea ar esua
-- (no such column). Indexul partial `idx_submissions_held ON submissions(held) WHERE held=1`
-- (PRD 5.19 US-001) traieste doar in _migrate, ca `idx_submissions_batch`.
-- Mapare coloane fisier -> campuri canonice (retinuta per cont, semnatura coloane).
CREATE TABLE IF NOT EXISTS column_mappings (

View File

@@ -71,13 +71,40 @@ ETICHETE_SCURTE: dict[str, str] = {
}
def eticheta_scurta(status: str) -> str:
# ---------------------------------------------------------------------------
# Randuri tinute manual (held) — US-006, PRD 5.19
#
# `held` NU e o stare noua in masina de stari: un rand cu `status='queued' AND
# held=1` este in coada, dar tinut pana la eliberarea umana. Il afisam distinct
# ("In asteptare (manual)") cu culoare de AVERTIZARE (--warn / amber) — asteptare
# benigna, NU eroare. Reutilizam clasa `s-needs_review` (definita in base.html ca
# `color:var(--warn)`); NU folosim clasele needs_* rosii (s-needs_data /
# s-needs_mapping = --err). CHECK-ul din schema.sql ramane neatins.
# ---------------------------------------------------------------------------
ETICHETA_HELD: Eticheta = (
"In asteptare (manual)",
"Tinuta pentru confirmarea ta; nu pleaca spre RAR pana nu o eliberezi.",
"s-needs_review",
)
ETICHETA_HELD_SCURTA = "Manual"
def eticheta_scurta(status: str, held: bool = False) -> str:
"""
Returneaza eticheta compacta (pill) pentru o stare de submission.
Cand `status='queued'` si `held` este adevarat, randul e tinut manual si
intoarce "Manual" (distinct de "In coada") — vezi PRD 5.19 D5. Pentru orice
alta stare `held` este ignorat, iar apelul fara `held` ramane identic cu cel
dinainte de US-006.
Arunca KeyError daca starea nu este mapata — intentionat, ca sa prinda
stari noi adaugate in schema fara mapare corespunzatoare.
"""
if held and status == "queued":
return ETICHETA_HELD_SCURTA
try:
return ETICHETE_SCURTE[status]
except KeyError:
@@ -87,13 +114,20 @@ def eticheta_scurta(status: str) -> str:
)
def eticheta_stare(status: str) -> Eticheta:
def eticheta_stare(status: str, held: bool = False) -> Eticheta:
"""
Returneaza (text, subtext, css_class) pentru o stare de submission.
Cand `status='queued'` si `held` este adevarat, randul e tinut manual si
intoarce eticheta "In asteptare (manual)" cu clasa de avertizare (amber /
--warn) — vezi PRD 5.19 US-006/D5. Pentru orice alta stare `held` este
ignorat, iar apelul fara `held` ramane identic cu cel dinainte de US-006.
Arunca KeyError daca starea nu este mapata — intentionat, ca sa prinda
stari noi adaugate in schema fara mapare corespunzatoare.
"""
if held and status == "queued":
return ETICHETA_HELD
try:
return STARI_SUBMISSION[status]
except KeyError:

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. #

View File

@@ -0,0 +1,54 @@
{#
_auto_send_toggle.html — comutatorul "Trimite automat la RAR" (PRD 5.19 US-004).
Plasat in clusterul de header (base.html, langa .rar-chip) — D1. NU face parte din
swap-ul periodic al #status-bar (every 15s) -> fara flicker / pierdere focus (D3).
Non-optimist (D2): la esec POST bifa revine + toast eroare (hx-on::response-error).
account_id vine din SESIUNE server-side (ruta /auto-send), niciodata din formular.
Raspunsul POST re-randeaza acest fragment (outerHTML pe #auto-send-toggle-wrap):
- confirmare_release=True -> injecteaza modalul de confirmare OOB in #modal-confirma-slot;
- curata_modal=True -> goleste slotul de modal (dupa activare reusita).
#}
<span id="auto-send-toggle-wrap" class="auto-send-wrap"
style="display:inline-flex; align-items:center; gap:6px;">
<input type="hidden" id="auto-send-csrf" name="csrf_token" value="{{ csrf_token | default('') }}">
<label class="auto-send-label"
title="Trimite automat la RAR. Debifat: prezentarile asteapta confirmarea ta."
style="display:inline-flex; align-items:center; gap:6px; cursor:pointer;
font-size:var(--fs-sm); white-space:nowrap; user-select:none;">
<input type="checkbox" name="enabled" value="1"
{% if auto_send_enabled|default(false) %}checked{% endif %}
hx-post="/auto-send"
hx-include="#auto-send-csrf"
hx-target="#auto-send-toggle-wrap"
hx-swap="outerHTML"
hx-on::response-error="this.checked = !this.checked; if(window.arataToast) window.arataToast('Nu am putut salva setarea Auto-send', 's-error');"
aria-label="Trimite automat la RAR"
style="width:16px; height:16px; cursor:pointer;">
<span class="auto-send-text">Trimite automat la RAR</span>
</label>
{% if confirmare_release|default(false) %}
{# OOB: modal de confirmare tipata pentru auto-release OFF->ON (garda F4/D4). #}
<span hx-swap-oob="innerHTML:#modal-confirma-slot">
{% set cf_id = 'modal-auto-release' %}
{% set cf_titlu = 'Activezi trimiterea automata?' %}
{% set cf_count = release_count %}
{% set cf_env = release_env %}
{% set cf_url = '/auto-send' %}
{% set cf_vals = '{"enabled":"1","confirma":"1"}' %}
{% set cf_target = '#auto-send-toggle-wrap' %}
{% set cf_swap = 'outerHTML' %}
{% set cf_confirm_label = 'Activez si trimit ' ~ release_count %}
{% set cf_deschis = true %}
{% include '_modal_confirma_trimitere.html' %}
</span>
{% endif %}
{% if curata_modal|default(false) %}
{# Curata slotul de modal dupa o comutare reusita (fara modal rezidual). #}
<span hx-swap-oob="innerHTML:#modal-confirma-slot"></span>
{% endif %}
</span>

View File

@@ -10,6 +10,38 @@
lista incepe direct sub filtre. Heading pastrat sr-only pentru a11y (section
aria-labelledby). Badge-ul de atentie + export CSV stau intr-un rand discret. #}
<h2 id="trimiteri-heading" class="sr-only">Trimiterile tale</h2>
{# US-005 (PRD 5.19): bulk "Trimite toate (N)" — N = randuri tinute (queued AND held=1).
Ascuns cand N=0. Confirmare tipata prin modal (count + mediu RAR), NU hx-confirm. #}
{% if held_manual_count|default(0) %}
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 10px;
padding:8px 10px; border-left:3px solid var(--warn);
background:color-mix(in srgb, var(--warn) 10%, var(--card)); border-radius:6px;">
<span style="font-size:var(--fs-sm);">
<strong>{{ held_manual_count }}</strong>
{{ 'prezentare tinuta' if held_manual_count == 1 else 'prezentari tinute' }} manual.
</span>
<button type="button"
onclick="var m=document.getElementById('modal-trimite-toate'); if(m){m.hidden=false;}"
style="margin-left:auto; padding:4px 12px; font-size:13px; border-radius:6px;
border:1px solid var(--accent); background:var(--accent); color:#fff;
font-weight:600; cursor:pointer;">
Trimite toate ({{ held_manual_count }})
</button>
</div>
{% set cf_id = 'modal-trimite-toate' %}
{% set cf_titlu = 'Trimiti toate prezentarile tinute?' %}
{% set cf_count = held_manual_count %}
{% set cf_env = env_bulk | default('prod') %}
{% set cf_url = '/trimite-toate' %}
{% set cf_vals = '{}' %}
{% set cf_target = '#submissions-wrap' %}
{% set cf_swap = 'innerHTML' %}
{% set cf_confirm_label = 'Trimite toate (' ~ held_manual_count ~ ')' %}
{% set cf_deschis = false %}
{% include '_modal_confirma_trimitere.html' %}
{% endif %}
{% if blocate_total %}
<div style="display:flex; align-items:center; gap:6px; flex-wrap:wrap; margin:0 0 10px;">
<span class="tab-badge" title="{{ blocate_total }} necesita atentie"

View File

@@ -216,6 +216,52 @@
</div>
</div>
{# Card: De ce nu ajunge la RAR? (PRD 5.19 US-010 — onestitate canal API) #}
{# Un POST /v1/prezentari poate intoarce 200 dar randul sa NU plece la RAR. #}
<div class="card" style="margin-bottom:16px;">
<h3 style="margin:0 0 6px; font-size:15px;">De ce nu ajunge la RAR?</h3>
<p class="muted" style="font-size:12px; margin:0 0 12px;">
Un <code>POST /v1/prezentari</code> poate intoarce <strong>200</strong> cu
<code>status: "queued"</code>, dar randul sa NU plece automat la RAR. Cele trei motive:
</p>
<div class="banner warn" role="note" style="margin:0 0 12px; font-size:12.5px;">
<strong>Conturile noi pornesc cu Trimiterea automata OPRITA (Auto OFF).</strong>
Primul <code>POST</code> da 200/<code>queued</code>, dar randul e tinut
(<code>held=true</code>) si asteapta eliberare manuala. Verifica bifa
<em>„Trimite automat la RAR"</em> din dashboard.
</div>
<div class="tablewrap">
<table>
<thead><tr>
<th>Motiv</th>
<th>Ce inseamna</th>
<th>Cum deblochezi</th>
</tr></thead>
<tbody>
<tr>
<td><span class="pill">held</span> <span class="muted">(Auto OFF)</span></td>
<td>Contul are Trimiterea automata OPRITA &rarr; randul e tinut
(<code>held=true</code>), NU pleaca la RAR.</td>
<td>Elibereaza manual din dashboard (butonul <em>Trimite</em> / <em>Trimite toate</em>)
sau activeaza Trimiterea automata. Verifici cu
<code>GET /v1/prezentari/{id}</code> (<code>held=true</code>).</td>
</tr>
<tr>
<td><span class="pill">needs_mapping</span></td>
<td>Cod operatie fara mapare la un cod RAR din nomenclator.</td>
<td>Intra in editorul de mapare din dashboard; la salvarea maparii randul
se re-rezolva automat.</td>
</tr>
<tr>
<td><span class="pill">needs_data</span></td>
<td>Validare de continut esuata (VIN / data prestatie / odometru).</td>
<td>Corectezi datele si repui prezentarea.</td>
</tr>
</tbody>
</table>
</div>
</div>
{# Card: Export & referinta #}
<div class="card" style="margin-bottom:16px;">
<h3 style="margin:0 0 12px; font-size:15px;">Export &amp; referinta</h3>

View File

@@ -0,0 +1,67 @@
{#
_modal_confirma_trimitere.html — modal REAL de confirmare tipata (PRD 5.19 D4/F4/F5).
`hx-confirm` nativ (doar OK/Cancel) NU e suficient pentru o actiune ireversibila care
trimite N prezentari catre RAR. Acest modal arata numarul + mediul tinta (Testare /
PRODUCTIE) si — pentru PRODUCTIE — cere o confirmare tipata ("TRIMIT") inainte de a
activa butonul. Reutilizat din:
- _auto_send_toggle.html (auto-release OFF->ON), injectat OOB in #modal-confirma-slot;
- _coada.html (bulk "Trimite toate (N)"), randat inline ascuns si aratat la click.
Parametri (context):
cf_id, cf_titlu, cf_count, cf_env ('prod'/'test'), cf_url, cf_vals (json string),
cf_target, cf_swap, cf_confirm_label, cf_deschis (bool), csrf_token.
#}
{% set _prod = (cf_env == 'prod') %}
{% set _eb = eticheta_env(cf_env) %}
<div id="{{ cf_id }}" class="modal-overlay" role="dialog" aria-modal="true"
aria-labelledby="{{ cf_id }}-titlu"{% if not cf_deschis %} hidden{% endif %}>
<div class="modal-backdrop"
onclick="this.closest('.modal-overlay').hidden=true"></div>
<div class="modal-dialog" role="document" style="max-width:440px;">
<button type="button" class="modal-close" aria-label="Anuleaza"
onclick="this.closest('.modal-overlay').hidden=true">&times;</button>
<h2 id="{{ cf_id }}-titlu" style="margin:0 0 10px; font-size:var(--fs-lg);">{{ cf_titlu }}</h2>
<p style="margin:0 0 8px; font-size:var(--fs-sm);">
Se trimit imediat <strong>{{ cf_count }}</strong>
{{ 'prezentare' if cf_count == 1 else 'prezentari' }} catre
<span class="{{ _eb[1] }}">{{ _eb[0] }}</span>.
</p>
<p style="margin:0 0 12px; font-size:var(--fs-sm); color:var(--warn); font-weight:600;">
Declararea la RAR este ireversibila (FINALIZATA nu se poate anula).
</p>
{% if _prod %}
{# Type-to-confirm DOAR pe PRODUCTIE: butonul ramane dezactivat pana se scrie TRIMIT. #}
<label for="{{ cf_id }}-typ" style="display:block; font-size:var(--fs-xs); color:var(--muted); margin-bottom:4px;">
Scrie <strong>TRIMIT</strong> pentru a confirma:
</label>
<input type="text" id="{{ cf_id }}-typ" autocomplete="off" autocapitalize="characters"
style="width:100%; margin-bottom:12px; font-size:14px; padding:5px 8px;
border:1px solid var(--line); border-radius:5px; background:var(--card2); color:var(--ink);"
oninput="document.getElementById('{{ cf_id }}-ok').disabled = (this.value.trim().toUpperCase() !== 'TRIMIT');">
{% endif %}
<div style="display:flex; gap:8px; justify-content:flex-end; flex-wrap:wrap;">
<button type="button"
onclick="this.closest('.modal-overlay').hidden=true"
style="background:var(--card); color:var(--muted); border:1px solid var(--line);
padding:6px 14px; border-radius:6px; cursor:pointer; font-size:13px;">
Anuleaza
</button>
<button type="button" id="{{ cf_id }}-ok"{% if _prod %} disabled{% endif %}
hx-post="{{ cf_url }}"
hx-vals='{{ cf_vals }}'
hx-target="{{ cf_target }}"
hx-swap="{{ cf_swap }}"
hx-include="#{{ cf_id }}-csrf"
onclick="this.closest('.modal-overlay').hidden=true"
style="background:{% if _prod %}var(--err){% else %}var(--accent){% endif %};
color:#fff; border:none; padding:6px 14px; border-radius:6px;
cursor:pointer; font-size:13px; font-weight:600;">
{{ cf_confirm_label }}
</button>
<input type="hidden" id="{{ cf_id }}-csrf" name="csrf_token" value="{{ csrf_token }}">
</div>
</div>
</div>

View File

@@ -14,10 +14,36 @@
</div>
{% endif %}
{# === US-007 (PRD 5.19): banner coada tinuta imbatranita — mitigare OBLIGATORIE pt. default OFF.
Cand exista randuri `queued AND held=1` mai vechi de held_aged_days, semnalul de conformitate
L.142 (declarare obligatorie). Derivat (zero stare noua), varsta pe created_at.
Prioritate bannere (D7 — un-singur-banner "notice", ca sa nu impinga contoarele sub fold pe
mobil): cont inactiv (sus) + strip RAR jos raman separate; intre notice-uri, held-aged >
trial (risc legal > notita trial) -> trial e suprimat cand held-aged e activ. #}
{% set _held_aged_activ = (held_aged_count|default(0) > 0) and account_active %}
{% if _held_aged_activ %}
<div id="banner-held-aged" role="alert"
style="margin-bottom:10px; padding:8px 12px; border-left:3px solid var(--warn);
background:color-mix(in srgb, var(--warn) 12%, var(--card)); border-radius:6px;
font-size:var(--fs-sm); display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
<span><strong>{{ held_aged_count }}</strong>
{{ 'prezentare tinuta' if held_aged_count == 1 else 'prezentari tinute' }}
de peste {{ held_aged_days }} zile — declarare obligatorie (L.142).</span>
<button type="button"
hx-get="/_fragments/submissions?held=1"
hx-target="#submissions-wrap" hx-swap="innerHTML"
style="margin-left:auto; padding:2px 10px; font-size:var(--fs-xs); border-radius:99px;
border:1px solid var(--warn); background:transparent; color:var(--warn);
cursor:pointer; font-weight:600;">
Vezi tinute
</button>
</div>
{% endif %}
{# US-006 (5.17) — Banner one-time trial->Gratuit (T-DES-1): afisat la prima incarcare
dupa expirarea trial-ului. Discret, non-blocant; dismissibil via sessionStorage.
Nu acopera stripul de sanatate (apare inainte de health strip, la acelasi nivel). #}
{% if trial_expirat_recent|default(false) %}
{% if trial_expirat_recent|default(false) and not _held_aged_activ %}
<div id="banner-trial-expirat"
role="status"
style="margin-bottom:10px; padding:7px 12px;
@@ -83,10 +109,17 @@
<div class="contor-label">Azi</div>
</div>
{# In coada #}
{# In coada — contorul "In asteptare (manual)" se pliaza aici (US-006/D6): nu adaugam a 6-a
celula, ci o sub-linie amber cand exista randuri tinute (queued AND held=1). #}
<div class="contor-card" style="flex:1; min-width:80px;">
<div class="contor-cifra s-queued">{{ counts_queued }}</div>
<div class="contor-label">In coada</div>
{% if held_manual_count|default(0) %}
<div class="contor-sub" style="font-size:var(--fs-xs); color:var(--warn); font-weight:600; margin-top:2px;"
title="Prezentari tinute manual — asteapta eliberarea ta">
{{ held_manual_count }} manual
</div>
{% endif %}
</div>
{# De corectat (rosu daca >0, muted la 0; link catre lista) #}
@@ -115,7 +148,7 @@
</div>
<div class="compact-item">
<div class="compact-nr s-queued">{{ counts_queued }}</div>
<div class="compact-lbl">Coada</div>
<div class="compact-lbl">Coada{% if held_manual_count|default(0) %} <span style="color:var(--warn); font-weight:700;">·{{ held_manual_count }}m</span>{% endif %}</div>
</div>
<a class="compact-item" href="/" style="text-decoration:none; color:inherit;">
<div class="compact-nr {{ 's-error' if blocate_total else 'muted' }}">{{ blocate_total }}</div>

View File

@@ -136,13 +136,33 @@
{% endif %}
</div>
{# Zona dreapta: pill stare + badge mediu RAR (US-010 PRD 5.20) #}
{# Zona dreapta: pill stare + badge mediu RAR (US-010 PRD 5.20) + buton Trimite (US-005) #}
<div style="flex:0 0 auto; display:flex; flex-direction:column; align-items:flex-end; gap:3px; white-space:nowrap;">
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}">{{ r.stare_scurt }}</span>
{% if r.rar_env %}
{% set _eb = eticheta_env(r.rar_env) %}
<span class="{{ _eb[1] }}">{{ _eb[0] }}</span>
{% endif %}
{# US-005 (PRD 5.19 / D6): buton "Trimite" DOAR pe randurile tinute (queued AND held=1).
Afordanta dedicata cu stopPropagation (randul e role=button) — NU declanseaza modalul
de detaliu. Confirmare 1-linie prin hx-confirm (per rand); ireversibilitate in microcopy. #}
{% if r.tinut %}
<form hx-post="/trimitere/{{ r.id }}/trimite-acum"
hx-target="#submissions-wrap" hx-swap="innerHTML"
hx-confirm="Trimit aceasta prezentare la RAR? Declararea este ireversibila (FINALIZATA)."
onclick="event.stopPropagation();"
onkeydown="event.stopPropagation();"
style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-trimite-rand"
onclick="event.stopPropagation();"
style="margin-top:2px; padding:2px 10px; font-size:var(--fs-xs);
border:1px solid var(--accent); border-radius:99px; cursor:pointer;
background:var(--accent); color:#fff; font-weight:600;">
Trimite
</button>
</form>
{% endif %}
</div>
</li>
{% endfor %}

View File

@@ -868,6 +868,9 @@
<span class="rar-tx">RAR blocat</span>
</div>
{% endif %}
{# US-004 (PRD 5.19 / D1): comutatorul "Trimite automat la RAR" langa semnalul RAR.
In afara swap-ului periodic al #status-bar (D3). #}
{% include '_auto_send_toggle.html' %}
{% endif %}
{# US-011 (PRD 5.16): selector tema = pill cu icon FIX (acelasi SVG ca landing) +
eticheta temei curente. Eticheta ascunsa pe <=560px via CSS. JS actualizeaza
@@ -951,6 +954,10 @@
<div id="detaliu-modal-body"></div>
</div>
</div>
{# Slot pentru modalul de confirmare tipata (PRD 5.19 D4): auto-release OFF->ON injecteaza
aici modalul via OOB (#modal-confirma-slot). Bulk "Trimite toate" isi randeaza propriul
modal inline in _coada.html. #}
<div id="modal-confirma-slot"></div>
<script>
// Comutator tema ciclic (DRY E2 — PRD 5.15): config traieste intr-o singura structura
// sursa-de-adevar THEMES din care se DERIVA CYCLE/VALID/LABELS/NEXT.

View File

@@ -142,6 +142,30 @@ def purge_expired(conn) -> dict[str, int]:
}
def expire_held(conn, settings: Settings) -> int:
"""Expira randurile tinute manual (`queued AND held=1`) mai vechi de `held_retention_days`.
PRD 5.19 US-008 (inchide gaura GDPR/L.142): un `queued held=1` nu e nici `sent` nici
blocat -> altfel NU primeste `purge_after` -> PII criptat ar sta la nesfarsit.
Le trece la `error`/`TINUT_EXPIRAT` (terminal) si seteaza `purge_after` DIRECT la
momentul expirarii (NU lasa retentia de 30z a randurilor blocate sa se adauge; altfel
viata reala = 90 + 30 = 120 zile, nu 90). Randul devine imediat purjabil de
`purge_expired` (purge_after < now) la urmatorul ciclu.
Intoarce numarul de randuri expirate.
"""
# Numar de zile validat (int) — construim string-ul '-N days' fara input extern.
days = int(settings.held_retention_days)
cutoff = f"-{days} days"
cur = conn.execute(
"UPDATE submissions SET status='error', rar_error='TINUT_EXPIRAT', "
"purge_after=datetime('now'), sending_since=NULL, updated_at=datetime('now') "
"WHERE status='queued' AND held=1 AND created_at < datetime('now', ?)",
(cutoff,),
)
return cur.rowcount
def requeue_with_backoff(conn, settings: Settings, submission_id: int, *, reason: str) -> None:
"""Re-pune randul in coada cu retry++ si next_attempt_at = now + backoff."""
row = conn.execute("SELECT retry_count FROM submissions WHERE id=?", (submission_id,)).fetchone()
@@ -171,6 +195,10 @@ def claim_one(conn) -> dict | None:
"SELECT s.id, s.account_id, s.payload_json, s.rar_creds_enc, s.rar_env "
"FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id "
"WHERE s.status='queued' "
# PRD 5.19 US-003: randurile tinute manual (held=1) NU se revendica —
# asteapta eliberarea umana (manual/auto). Se aplica DOAR la claim din
# 'queued'; randurile deja 'sending' (orfani/reconciliere) sunt neafectate.
"AND s.held = 0 "
"AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) "
# Gate pe stare de cont: doar 'active' trimite. Derivam defensiv din `active`
# cand `status` lipseste (DB veche pre-migrare), pastrand active=1 <=> 'active'.
@@ -533,6 +561,13 @@ def run() -> int:
# Purjare periodica (odata pe ora) — NU mai frecvent.
now_ts = time.time()
if now_ts - _last_purge_time >= _PURGE_INTERVAL_S:
# Expira intai randurile tinute imbatranite (US-008), apoi purjeaza:
# un rand expirat cu purge_after=now devine imediat purjabil in acelasi ciclu.
expired = expire_held(conn, settings)
if expired:
_wlog(conn, "held_expirat",
f"{expired} randuri tinute expirate (>{settings.held_retention_days}z) -> error/TINUT_EXPIRAT",
nivel="WARNING", context={"expirate": expired})
stats = purge_expired(conn)
if stats["submissions_purged"] or stats["batches_purged"] or stats["events_purged"]:
print(