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(

View File

@@ -0,0 +1,60 @@
# Runbook — Rollback sigur al feature-ului "Auto send / coada tinuta" (PRD 5.19)
Procedura operationala pentru cazul in care trebuie sa dai **revert pe cod** care
atinge `claim_one` / logica `held` din PRD 5.19.
## De ce e periculos revertul (Riscul R4)
Feature-ul 5.19 tine randuri manual: un rand `queued AND held=1` NU pleaca la RAR
pentru ca `claim_one` (worker) filtreaza cu `AND held=0`.
Daca dai revert pe codul worker-ului **DUPA** ce baza contine deja randuri cu
`held=1`, worker-ul revenit **pierde filtrul** `AND held=0` -> ar prelua si trimite
la RAR TOATE randurile tinute. **RAR FINALIZATA e IREVERSIBILA** (fara anulare prin
API) — declari real, definitiv, randuri care fusesera puse deoparte intentionat.
## Procedura (copy-paste, in ordine)
### 1. INAINTE de orice revert — carantineaza randurile tinute
Pe masina gateway, cu acelasi mediu ca aplicatia (acelasi `AUTOPASS_DB_PATH`):
```bash
# Verifica intai cate randuri sunt tinute (nu scrie nimic):
python3 -m tools.carantina_held --dry-run
# Apoi carantineaza efectiv:
python3 -m tools.carantina_held
```
Optional, opreste worker-ul in timp ce faci operatia, ca sa nu concureze:
```bash
./start.sh stop
```
### 2. Ce face carantina
`tools/carantina_held.py` ruleaza, atomic:
```sql
UPDATE submissions
SET status='error', rar_error='ROLLBACK_QUARANTINE', updated_at=datetime('now')
WHERE held=1 AND status='queued';
```
Adica scoate randurile tinute din coada (`queued` -> `error`) cu mesajul
`ROLLBACK_QUARANTINE`. Un worker fara filtrul `held` **nu mai poate prelua** un rand
`error` -> randurile NU pleaca la RAR pe timpul revertului. `sending` / `sent` NU se
ating (FINALIZATA e terminal la RAR). Tool-ul afiseaza cate randuri a carantinat.
### 3. DUPA revert (sau dupa ce revii pe codul 5.19) — reactiveaza randurile
Randurile carantinate sunt acum `error` / `ROLLBACK_QUARANTINE`. Ele NU sunt trimise
si NU se re-incearca automat. Cand vrei sa le declari la RAR, **repune-le manual din
dashboard** (butonul de repunere pe rand -> revine `queued`) si trimite-le controlat
(per rand sau "Trimite toate"), dupa ce te-ai asigurat ca filtrul `held` este din nou
in cod (revenire pe 5.19) sau ca trimiterea lor e intentionata.
> Nu exista un "un-quarantine" automat: reactivarea e deliberat manuala, ca sa nu
> repui accidental in coada exact randurile pe care le protejai.

View File

@@ -28,6 +28,69 @@ os.environ.setdefault("AUTOPASS_EMBEDDINGS_ENABLED", "false")
os.environ.setdefault("AUTOPASS_SEED_OPERATII_ENABLED", "false")
# Referinta la init_db-ul REAL (nemodificat), capturata O SINGURA DATA la prima rulare a
# fixturii de mai jos. Wrapper-ul o refoloseste ca sa nu se auto-imbrace recursiv.
_REAL_INIT_DB = None
@pytest.fixture(autouse=True)
def _auto_send_on_default_account(request, monkeypatch):
"""PRD 5.19 US-009: contul default id=1 (seed schema.sql, INSERT OR IGNORE) porneste pe
`auto_send_enabled=0` (Auto OFF) — corect in productie (nimic nu pleaca fara confirmare).
Dar suita exercita lantul `POST -> claim_one -> sent` pe contul id=1
(test_import_e2e, test_creds_delivery, test_live_rar): cu Auto OFF, ingestia ar marca
`held=1` (US-002) si worker-ul (`claim_one ... AND held=0`, US-003) nu ar mai lua randul
-> testele ar stagna tacit. E un fix de STARE DB, nu env var (coloana e per-rand in
`accounts`), deci il aplicam wraparind `init_db` (contul id=1 NU e creat de `create_account`
-> un patch pe factory NU l-ar acoperi — Eng HIGH).
Wrapam `init_db` astfel incat orice DB de test creat sa aiba id=1 pe Auto ON (held=0 la
ingestie), pastrand suita verde ca inainte de 5.19. Conturile create explicit de teste
(`create_account`) raman pe default OFF -> testele care VOR Auto OFF il primesc corect.
Testele care verifica DEFAULT-ul brut (id=1 == 0 imediat dupa init_db) se exclud cu
markerul `no_auto_send_seed`.
"""
global _REAL_INIT_DB
import sys
import app.db as _db
if _REAL_INIT_DB is None:
_REAL_INIT_DB = _db.init_db
if request.node.get_closest_marker("no_auto_send_seed"):
yield
return
def _init_db_auto_on():
_REAL_INIT_DB()
conn = _db.get_connection()
try:
conn.execute("UPDATE accounts SET auto_send_enabled=1 WHERE id=1")
conn.commit()
finally:
conn.close()
# app.db.init_db: acopera fixturile care fac `from app.db import init_db` in corpul lor
# (importul ruleaza DUPA acest patch -> primeste wrapper-ul) + apelurile `app.db.init_db()`.
monkeypatch.setattr(_db, "init_db", _init_db_auto_on)
# app.main.init_db: lifespan-ul TestClient cheama numele importat la nivel de modul
# (`from .db import init_db`); daca app.main e deja importat, patch-uim si referinta lui.
# Daca NU e inca importat, primul `from app.main import app` din test il va lega la
# wrapper-ul de mai sus automat (citeste app.db.init_db la momentul importului).
_main = sys.modules.get("app.main")
if _main is not None:
monkeypatch.setattr(_main, "init_db", _init_db_auto_on, raising=False)
yield
# Anti-leak: daca app.main a fost importat IN timpul testului (legand wrapper-ul la
# import, nu prin monkeypatch), readu-l la init_db-ul real ca sa nu ramana wrapat global.
_main = sys.modules.get("app.main")
if _main is not None and getattr(_main, "init_db", None) is _init_db_auto_on:
_main.init_db = _REAL_INIT_DB
@pytest.fixture(autouse=True)
def _reset_embeddings_singleton():
"""Reseteaza singleton-ul global de embeddings intre teste (izolare de ordine).
@@ -69,3 +132,8 @@ def pytest_configure(config):
config.addinivalue_line(
"markers", "live: test live pe RAR test (necesita AUTOPASS_LIVE_RAR=1 + creds reale)"
)
config.addinivalue_line(
"markers",
"no_auto_send_seed: dezactiveaza fixtura autouse care pune id=1 pe Auto ON "
"(teste care verifica default-ul brut auto_send_enabled=0 al contului id=1)",
)

View File

@@ -0,0 +1,150 @@
"""US-001 (PRD 5.19): schema comutator Auto per cont + flag `held` pe submission.
Acopera: migrarea aditiva idempotenta (coloane + index partial pe DB vechi), helperii
`get_auto_send`/`set_auto_send`/`held_for_account` din app/accounts.py si default-ul 0.
"""
from __future__ import annotations
import os
import sqlite3
import tempfile
import pytest
@pytest.fixture()
def fresh_conn(monkeypatch):
"""DB nou cu schema curenta (init_db)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
c = get_connection()
yield c
c.close()
get_settings.cache_clear()
def _old_db(path: str) -> sqlite3.Connection:
"""DB in forma PRE-5.19: accounts fara auto_send_enabled, submissions fara held."""
conn = sqlite3.connect(path, isolation_level=None)
conn.row_factory = sqlite3.Row
conn.execute(
"CREATE TABLE accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, "
"cui TEXT)"
)
conn.execute(
"CREATE TABLE submissions (id INTEGER PRIMARY KEY AUTOINCREMENT, "
"idempotency_key TEXT NOT NULL UNIQUE, account_id INTEGER, status TEXT, "
"payload_json TEXT NOT NULL)"
)
return conn
def _migrate_old(path: str, monkeypatch) -> sqlite3.Connection:
from app.config import get_settings
get_settings.cache_clear()
conn = sqlite3.connect(path, isolation_level=None)
conn.row_factory = sqlite3.Row
from app.db import _migrate
_migrate(conn)
return conn
def test_migrare_adauga_coloane_si_index(tmp_path, monkeypatch):
"""Pe DB vechi (fara coloane) migrarea adauga held, auto_send_enabled si indexul partial."""
path = str(tmp_path / "old.db")
_old_db(path).close()
conn = _migrate_old(path, monkeypatch)
sub = {r["name"] for r in conn.execute("PRAGMA table_info(submissions)").fetchall()}
assert "held" in sub
acc = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
assert "auto_send_enabled" in acc
idx = {r["name"] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='submissions'"
).fetchall()}
assert "idx_submissions_held" in idx
conn.close()
def test_migrare_idempotenta_a_doua_rulare(tmp_path, monkeypatch):
"""A doua rulare _migrate pe DB deja migrat nu arunca si nu dubleaza nimic."""
path = str(tmp_path / "old.db")
_old_db(path).close()
conn = _migrate_old(path, monkeypatch)
from app.db import _migrate
_migrate(conn) # nu arunca
acc = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
assert "auto_send_enabled" in acc
conn.close()
def test_migrare_idempotenta_pe_db_fresh(fresh_conn):
"""_migrate re-rulat pe schema curenta (init_db) nu strica nimic."""
from app.db import _migrate
_migrate(fresh_conn)
sub = {r["name"] for r in fresh_conn.execute("PRAGMA table_info(submissions)").fetchall()}
assert "held" in sub
def test_held_for_account_auto_on(fresh_conn):
from app.accounts import create_account, held_for_account, set_auto_send
aid = create_account(fresh_conn, "Auto ON")
set_auto_send(fresh_conn, aid, True)
assert held_for_account(fresh_conn, aid) == 0
def test_held_for_account_auto_off(fresh_conn):
from app.accounts import create_account, held_for_account
aid = create_account(fresh_conn, "Auto OFF")
assert held_for_account(fresh_conn, aid) == 1
def test_held_for_account_cont_inexistent(fresh_conn):
from app.accounts import held_for_account
assert held_for_account(fresh_conn, 99999) == 1
def test_get_set_auto_send_round_trip(fresh_conn):
from app.accounts import create_account, get_auto_send, set_auto_send
aid = create_account(fresh_conn, "Round trip")
assert get_auto_send(fresh_conn, aid) is False
set_auto_send(fresh_conn, aid, True)
assert get_auto_send(fresh_conn, aid) is True
# Idempotent: acelasi set nu arunca si pastreaza valoarea.
set_auto_send(fresh_conn, aid, True)
assert get_auto_send(fresh_conn, aid) is True
set_auto_send(fresh_conn, aid, False)
assert get_auto_send(fresh_conn, aid) is False
def test_get_auto_send_cont_inexistent(fresh_conn):
from app.accounts import get_auto_send
assert get_auto_send(fresh_conn, 99999) is False
@pytest.mark.no_auto_send_seed # verifica default-ul BRUT id=1==0; nu wrapa init_db (conftest)
def test_default_auto_send_zero(fresh_conn):
"""Cont nou (create_account) si contul id=1 pornesc pe auto_send_enabled=0 (Auto OFF)."""
from app.accounts import create_account
aid = create_account(fresh_conn, "Nou")
row = fresh_conn.execute(
"SELECT auto_send_enabled FROM accounts WHERE id=?", (aid,)
).fetchone()
assert row["auto_send_enabled"] == 0
row1 = fresh_conn.execute(
"SELECT auto_send_enabled FROM accounts WHERE id=1"
).fetchone()
assert row1["auto_send_enabled"] == 0
def test_config_held_zile():
from app.config import Settings
s = Settings()
assert s.held_warn_days == 7
assert s.held_retention_days == 90

View File

@@ -0,0 +1,99 @@
"""Teste PRD 5.19 R4 — tools/carantina_held.py (atenuare hazard de rollback).
Carantineaza randurile `queued AND held=1` (-> `error`/`ROLLBACK_QUARANTINE`)
ca sa nu fie trimise de un worker fara filtrul `AND held=0` dupa un revert.
`--dry-run` doar numara, fara sa scrie.
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
conn = get_connection()
yield conn
conn.close()
get_settings.cache_clear()
_CONTENT = {
"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
def _insert(conn, status="queued", held=0):
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, status, payload_json, account_id, held) "
"VALUES (?, ?, ?, ?, ?)",
(f"key-{os.urandom(4).hex()}", status, json.dumps(_CONTENT), 1, held),
)
return int(cur.lastrowid)
def _row(conn, sid):
return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
def _run(argv):
from tools.carantina_held import main
return main(argv)
def test_carantineaza_rand_tinut(env, capsys):
conn = env
sid = _insert(conn, status="queued", held=1)
rc = _run([])
out = capsys.readouterr().out
assert rc == 0
assert "1 randuri carantinate" in out
r = _row(conn, sid)
assert r["status"] == "error"
assert r["rar_error"] == "ROLLBACK_QUARANTINE"
def test_dry_run_nu_modifica(env, capsys):
conn = env
sid = _insert(conn, status="queued", held=1)
rc = _run(["--dry-run"])
out = capsys.readouterr().out
assert rc == 0
assert "dry-run" in out
assert "1 randuri" in out
r = _row(conn, sid)
assert r["status"] == "queued" # neschimbat
assert r["rar_error"] is None
def test_nu_atinge_randuri_ne_tinute_sau_ne_queued(env, capsys):
conn = env
sid_libera = _insert(conn, status="queued", held=0)
sid_sent = _insert(conn, status="sent", held=1) # held dar deja plecat
sid_tinuta = _insert(conn, status="queued", held=1)
rc = _run([])
capsys.readouterr()
assert rc == 0
assert _row(conn, sid_libera)["status"] == "queued"
assert _row(conn, sid_sent)["status"] == "sent"
assert _row(conn, sid_tinuta)["status"] == "error"
def test_fara_randuri_tinute(env, capsys):
_insert(env, status="queued", held=0)
rc = _run([])
out = capsys.readouterr().out
assert rc == 0
assert "Niciun rand tinut" in out

146
tests/test_held_api_echo.py Normal file
View File

@@ -0,0 +1,146 @@
"""Teste PRD 5.19 US-010 — onestitate + observabilitate `held` pe canalul API.
- `held` in raspunsul enqueue (SubmissionResult) + motiv non-null.
- `held` in proiectiile GET /v1/prezentari si /v1/prezentari/{id}.
- endpoint de eliberare API POST /v1/prezentari/{id}/trimite-acum (paritate cu /repune).
"""
from __future__ import annotations
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "echo.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def _set_auto_send(enabled: bool, account_id: int = 1) -> None:
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"UPDATE accounts SET auto_send_enabled=? WHERE id=?",
(1 if enabled else 0, account_id),
)
conn.commit()
finally:
conn.close()
def _held(sid: int) -> int:
from app.db import get_connection
conn = get_connection()
try:
return conn.execute("SELECT held FROM submissions WHERE id=?", (sid,)).fetchone()["held"]
finally:
conn.close()
def _body(**over) -> dict:
prez = {
"vin": "WVWZZZ1KZAW000123",
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
prez.update(over)
return {"prezentari": [prez]}
def _enqueue_held(client) -> int:
_set_auto_send(False)
r = client.post("/v1/prezentari", json=_body())
return r.json()["results"][0]["submission_id"]
# --------------------------------------------------------------------------- #
# GET proiectii #
# --------------------------------------------------------------------------- #
def test_get_prezentare_expune_held(client):
sid = _enqueue_held(client)
r = client.get(f"/v1/prezentari/{sid}")
assert r.status_code == 200, r.text
body = r.json()
assert "held" in body
assert body["held"] in (1, True)
def test_list_prezentari_expune_held(client):
sid = _enqueue_held(client)
r = client.get("/v1/prezentari")
assert r.status_code == 200, r.text
rows = {row["id"]: row for row in r.json()["submissions"]}
assert sid in rows
assert "held" in rows[sid]
assert rows[sid]["held"] in (1, True)
# --------------------------------------------------------------------------- #
# POST /v1/prezentari/{id}/trimite-acum #
# --------------------------------------------------------------------------- #
def test_trimite_acum_elibereaza(client):
"""Rand tinut -> trimite-acum -> held=0."""
sid = _enqueue_held(client)
assert _held(sid) == 1
r = client.post(f"/v1/prezentari/{sid}/trimite-acum")
assert r.status_code == 200, r.text
assert r.json()["ok"] is True
assert _held(sid) == 0
def test_trimite_acum_id_inexistent_404(client):
r = client.post("/v1/prezentari/999999/trimite-acum")
assert r.status_code == 404
def test_trimite_acum_rand_nequeued_noop(client):
"""Rand non-queued (sent) -> no-op sigur, ramane neschimbat, raspuns 200."""
_set_auto_send(True)
sid = client.post("/v1/prezentari", json=_body()).json()["results"][0]["submission_id"]
from app.db import get_connection
conn = get_connection()
try:
conn.execute("UPDATE submissions SET status='sent', held=1 WHERE id=?", (sid,))
conn.commit()
finally:
conn.close()
r = client.post(f"/v1/prezentari/{sid}/trimite-acum")
assert r.status_code == 200, r.text
# held nemodificat (nu era queued)
assert _held(sid) == 1
def test_trimite_acum_cont_strain_404(client):
"""Un rand al altui cont -> 404-before-leak (nu elibereaza cross-account)."""
sid = _enqueue_held(client)
from app.accounts import create_account
from app.db import get_connection
conn = get_connection()
try:
# Cont real strain (id>=2) + muta randul pe el (FK valid).
other = create_account(conn, "Alt cont")
conn.execute("UPDATE submissions SET account_id=? WHERE id=?", (other, sid))
conn.commit()
finally:
conn.close()
# Clientul (dev, fara cheie) e pe contul id=1 -> randul altui cont e 404-before-leak.
r = client.post(f"/v1/prezentari/{sid}/trimite-acum")
assert r.status_code == 404
assert _held(sid) == 1

259
tests/test_held_ingestie.py Normal file
View File

@@ -0,0 +1,259 @@
"""Teste PRD 5.19 US-002 — snapshot `held` la ingestie din comutatorul de cont.
Acopera TOATE situ-rile care scriu `status='queued'` prin ingestie (chokepoint
`held_for_account`): enqueue API, reactivare error->queued, dedup echo, commit import,
reresolve `needs_mapping`. `held` NU intra in idempotency_key.
Contul default id=1 e fortat pe Auto ON de fixtura autouse din conftest; testele care
vor Auto OFF il pun explicit pe 0 (`_set_auto_send(client, False)`).
"""
from __future__ import annotations
import io
import os
import tempfile
import openpyxl
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "held.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def _set_auto_send(enabled: bool, account_id: int = 1) -> None:
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"UPDATE accounts SET auto_send_enabled=? WHERE id=?",
(1 if enabled else 0, account_id),
)
conn.commit()
finally:
conn.close()
def _row(sid: int) -> dict:
from app.db import get_connection
conn = get_connection()
try:
r = conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
return dict(r) if r else {}
finally:
conn.close()
def _force_status(sid: int, status: str) -> None:
from app.db import get_connection
conn = get_connection()
try:
conn.execute("UPDATE submissions SET status=? WHERE id=?", (status, sid))
conn.commit()
finally:
conn.close()
def _body(**over) -> dict:
prez = {
"vin": "WVWZZZ1KZAW000123",
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
prez.update(over)
return {"prezentari": [prez]}
# --------------------------------------------------------------------------- #
# Enqueue API #
# --------------------------------------------------------------------------- #
def test_auto_off_ingestie_held(client):
"""Cont Auto OFF -> POST valid -> rand queued, held=1; raspuns held=true + motiv non-null."""
_set_auto_send(False)
r = client.post("/v1/prezentari", json=_body())
assert r.status_code == 200, r.text
res = r.json()["results"][0]
assert res["status"] == "queued"
assert res["held"] is True
assert res["motiv"], "motiv trebuie sa fie non-null cand randul e tinut"
assert _row(res["submission_id"])["held"] == 1
def test_auto_on_ingestie_not_held(client):
"""Cont Auto ON -> POST valid -> rand queued, held=0; raspuns held=false, motiv None."""
_set_auto_send(True)
r = client.post("/v1/prezentari", json=_body())
assert r.status_code == 200, r.text
res = r.json()["results"][0]
assert res["status"] == "queued"
assert res["held"] is False
assert res["motiv"] is None
assert _row(res["submission_id"])["held"] == 0
# --------------------------------------------------------------------------- #
# Reactivare error->queued (bug de bypass Eng Finding A) #
# --------------------------------------------------------------------------- #
def test_reactivare_respecta_auto_off(client):
"""Un rand `error` re-POST-at pe cont Auto OFF re-intra queued CU held=1 (nu pastreaza vechiul)."""
_set_auto_send(True)
r = client.post("/v1/prezentari", json=_body())
sid = r.json()["results"][0]["submission_id"]
assert _row(sid)["held"] == 0
# Contul trece pe OFF si randul cade in error; re-POST-ul trebuie sa-l tina.
_set_auto_send(False)
_force_status(sid, "error")
r2 = client.post("/v1/prezentari", json=_body())
res = r2.json()["results"][0]
assert res["submission_id"] == sid
assert res["reactivated"] is True
assert res["held"] is True
assert res["motiv"]
assert _row(sid)["held"] == 1
# --------------------------------------------------------------------------- #
# Dedup echo #
# --------------------------------------------------------------------------- #
def test_dedup_echo_held(client):
"""Re-POST acelasi continut (Auto OFF) -> deduped, dar held propagat corect (nu queued curat fals)."""
_set_auto_send(False)
r1 = client.post("/v1/prezentari", json=_body())
sid = r1.json()["results"][0]["submission_id"]
r2 = client.post("/v1/prezentari", json=_body())
res = r2.json()["results"][0]
assert res["submission_id"] == sid
assert res["deduped"] is True
assert res["held"] is True
assert res["motiv"]
# --------------------------------------------------------------------------- #
# Idempotenta: held nu schimba cheia #
# --------------------------------------------------------------------------- #
def test_held_nu_schimba_idempotency_key(client):
"""Acelasi continut Auto ON vs OFF -> aceeasi idempotency_key (held e pur coada)."""
_set_auto_send(True)
sid_on = client.post("/v1/prezentari", json=_body()).json()["results"][0]["submission_id"]
key_on = _row(sid_on)["idempotency_key"]
# Simulam un al doilea DB logic: acelasi continut, cont OFF -> dedup pe aceeasi cheie.
_set_auto_send(False)
r2 = client.post("/v1/prezentari", json=_body())
res2 = r2.json()["results"][0]
assert res2["deduped"] is True
assert res2["submission_id"] == sid_on
assert _row(res2["submission_id"])["idempotency_key"] == key_on
# --------------------------------------------------------------------------- #
# Reresolve needs_mapping -> queued #
# --------------------------------------------------------------------------- #
def test_reresolve_held(client):
"""Cont Auto OFF, submission needs_mapping -> reresolve dupa mapare -> queued cu held=1."""
_set_auto_send(False)
# Cod intern necunoscut -> needs_mapping (nu se trimite).
body = _body(prestatii=[{"cod_op_service": "REV-INTERN", "denumire": "Revizie interna"}])
r = client.post("/v1/prezentari", json=body)
res = r.json()["results"][0]
sid = res["submission_id"]
assert _row(sid)["status"] == "needs_mapping"
# Salvare mapare -> reresolve automat (POST /v1/mapari cheama reresolve_account).
rc = client.post("/v1/mapari", json={
"cod_op_service": "REV-INTERN", "cod_prestatie": "OE-1", "auto_send": True,
})
assert rc.status_code == 200, rc.text
row = _row(sid)
assert row["status"] == "queued"
assert row["held"] == 1
def test_reresolve_held_auto_on(client):
"""Cont Auto ON, reresolve -> queued cu held=0."""
_set_auto_send(True)
body = _body(prestatii=[{"cod_op_service": "REV-INTERN", "denumire": "Revizie interna"}])
sid = client.post("/v1/prezentari", json=body).json()["results"][0]["submission_id"]
assert _row(sid)["status"] == "needs_mapping"
client.post("/v1/mapari", json={
"cod_op_service": "REV-INTERN", "cod_prestatie": "OE-1", "auto_send": True,
})
row = _row(sid)
assert row["status"] == "queued"
assert row["held"] == 0
# --------------------------------------------------------------------------- #
# Commit import #
# --------------------------------------------------------------------------- #
_HEADER = ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"]
_ROW = ["WVWZZZ1KZAW001111", "B100TST", "2026-06-15", "123456", "Revizie"]
_COLMAP = {
"VIN": "vin", "Nr inmatriculare": "nr_inmatriculare",
"Data prestatie": "data_prestatie", "Odometru final": "odometru_final",
"Operatie": "operatie",
}
def _make_xlsx(rows: list[list]) -> bytes:
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Sheet1"
for row in rows:
ws.append(row)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def _commit_import(client) -> int:
data = _make_xlsx([_HEADER, _ROW])
import_id = client.post(
"/v1/import",
files={"file": ("t.xlsx", io.BytesIO(data), "application/octet-stream")},
).json()["import_id"]
client.post(f"/v1/import/{import_id}/column-mapping", json={"json_mapare": _COLMAP})
client.post("/v1/mapari", json={"cod_op_service": "Revizie", "cod_prestatie": "OE-1", "auto_send": True})
rp = client.get(f"/v1/import/{import_id}/preview")
assert rp.status_code == 200 and rp.json()["summary"].get("ok", 0) == 1, rp.text
rc = client.post(f"/v1/import/{import_id}/commit", json={
"n_confirmat": 1, "reviewed_rows": [], "confirmed_by": "t@e2e.ro",
})
assert rc.status_code == 200, rc.text
return rc.json()["submissions"][0]["submission_id"]
def test_import_commit_held_auto_off(client):
"""Commit import pe cont Auto OFF -> rand queued cu held=1."""
_set_auto_send(False)
sid = _commit_import(client)
row = _row(sid)
assert row["status"] == "queued"
assert row["held"] == 1
def test_import_commit_not_held_auto_on(client):
"""Commit import pe cont Auto ON -> rand queued cu held=0."""
_set_auto_send(True)
sid = _commit_import(client)
row = _row(sid)
assert row["status"] == "queued"
assert row["held"] == 0

88
tests/test_labels_held.py Normal file
View File

@@ -0,0 +1,88 @@
"""
Teste pentru eticheta randurilor tinute manual (held) — US-006, PRD 5.19.
RED intai: scrise inainte de a extinde labels.py cu parametrul `held`.
Regula de afisaj (D5, PRD 5.19):
- `status='queued' AND held=1` -> "In asteptare (manual)" + clasa CSS de
AVERTIZARE (amber, `--warn`) — asteptare benigna, NU eroare (rosu).
- `held=0` (sau lipsa) -> comportament neschimbat pentru TOATE starile.
- `held` NU e stare noua in masina de stari: CHECK-ul din schema ramane neatins.
Fisiere atinse: app/web/labels.py, tests/test_labels_held.py.
"""
import pytest
from app.web.labels import eticheta_scurta, eticheta_stare
TEXT_HELD = "In asteptare (manual)"
# Clasa CSS amber (--warn) reutilizata din base.html (.s-needs_review{color:var(--warn)}).
# NU e o clasa needs_* rosie (s-needs_data / s-needs_mapping sunt --err).
CLASA_WARN_HELD = "s-needs_review"
# Toate starile non-queued: held nu trebuie sa le schimbe niciodata.
STARI_NEAFECTATE = ["sending", "sent", "needs_mapping", "needs_data", "error"]
# ---------------------------------------------------------------------------
# eticheta_stare (text lung + subtext + clasa)
# ---------------------------------------------------------------------------
def test_queued_held_are_eticheta_manuala_si_clasa_warn():
text, subtext, css_class = eticheta_stare("queued", held=True)
assert text == TEXT_HELD, f"asteptam {TEXT_HELD!r}, got {text!r}"
assert css_class == CLASA_WARN_HELD, (
f"randul tinut trebuie amber (--warn) prin {CLASA_WARN_HELD!r}, got {css_class!r}"
)
# Nu trebuie sa fie o clasa de eroare rosie.
assert css_class not in ("s-error", "s-needs_data", "s-needs_mapping"), (
"held e asteptare benigna — nu trebuie sa foloseasca o clasa rosie de eroare"
)
assert isinstance(subtext, str)
def test_queued_held_false_ramane_neschimbat():
"""queued fara held == comportamentul dinainte de US-006 (PRD 5.19)."""
fara_held = eticheta_stare("queued")
held_false = eticheta_stare("queued", held=False)
assert fara_held == held_false, "held=False trebuie identic cu apelul fara held"
text, _subtext, css_class = held_false
assert "In asteptare" in text
assert text != TEXT_HELD, "queued normal NU trebuie sa afiseze varianta manuala"
assert css_class == "s-queued"
@pytest.mark.parametrize("status", STARI_NEAFECTATE)
def test_alte_stari_ignora_held(status):
"""held nu are efect pe nicio stare in afara de queued."""
assert eticheta_stare(status, held=True) == eticheta_stare(status, held=False)
assert eticheta_stare(status, held=True) == eticheta_stare(status)
def test_apel_pozitional_compat():
"""Semnatura pastreaza compatibilitatea: apelul vechi cu un singur arg merge."""
assert eticheta_stare("sent") == eticheta_stare("sent", held=False)
# ---------------------------------------------------------------------------
# eticheta_scurta (pill) — pill-ul trebuie sa distinga held de queued normal (D5)
# ---------------------------------------------------------------------------
def test_pill_queued_held_diferit_de_queued_normal():
pill_held = eticheta_scurta("queued", held=True)
pill_normal = eticheta_scurta("queued", held=False)
assert pill_held != pill_normal, (
"pill-ul trebuie sa distinga randul tinut (D5: altfel randa 'In coada' identic)"
)
assert pill_normal == "In coada"
def test_pill_alte_stari_ignora_held():
for status in STARI_NEAFECTATE:
assert eticheta_scurta(status, held=True) == eticheta_scurta(status, held=False)
def test_pill_apel_pozitional_compat():
assert eticheta_scurta("queued") == eticheta_scurta("queued", held=False)

View File

@@ -0,0 +1,97 @@
"""Teste PRD 5.19 US-007 — gauge-uri /metrics pentru coada tinuta.
/metrics expune, pe langa `autopass_submissions{status=...}`, doua gauge-uri
DERIVATE (zero stare noua), scoped global:
- autopass_held_submissions = COUNT(queued AND held=1)
- autopass_held_oldest_age_seconds = varsta (s) a celui mai vechi rand tinut
TDD: fixtura izoleaza DB per test (pattern din test_worker_held.py).
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
conn = get_connection()
yield conn
conn.close()
get_settings.cache_clear()
def _client():
from app.main import app
return TestClient(app)
_CONTENT = {
"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
def _insert(conn, status="queued", held=0, created_at=None):
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, status, payload_json, account_id, held) "
"VALUES (?, ?, ?, ?, ?)",
(f"key-{os.urandom(4).hex()}", status, json.dumps(_CONTENT), 1, held),
)
sid = int(cur.lastrowid)
if created_at is not None:
conn.execute("UPDATE submissions SET created_at=? WHERE id=?", (created_at, sid))
return sid
def _metric(text, name):
for line in text.splitlines():
if line.startswith(name + " "):
return line[len(name) + 1:].strip()
return None
def test_held_gauges_numara_si_varsta(env):
conn = env
# doua randuri tinute (unul vechi de ~10 zile) + unul ne-tinut
_insert(conn, status="queued", held=1, created_at="2026-06-25 00:00:00")
_insert(conn, status="queued", held=1)
_insert(conn, status="queued", held=0)
body = _client().get("/metrics").text
assert _metric(body, "autopass_held_submissions") == "2"
oldest = int(_metric(body, "autopass_held_oldest_age_seconds"))
assert oldest > 0
def test_held_gauge_zero_fara_randuri_tinute(env):
conn = env
_insert(conn, status="queued", held=0)
_insert(conn, status="sent", held=0)
body = _client().get("/metrics").text
assert _metric(body, "autopass_held_submissions") == "0"
# varsta = 0 cand nu exista randuri tinute (COALESCE)
assert _metric(body, "autopass_held_oldest_age_seconds") == "0"
def test_held_gauge_ignora_tinut_ne_queued(env):
conn = env
# held=1 dar status != queued (ex. deja carantinat) NU se numara
_insert(conn, status="error", held=1)
_insert(conn, status="queued", held=1)
body = _client().get("/metrics").text
assert _metric(body, "autopass_held_submissions") == "1"

347
tests/test_web_auto_send.py Normal file
View File

@@ -0,0 +1,347 @@
"""Teste PRD 5.19 strat WEB: toggle "Trimite automat la RAR" (US-004), trimitere
manuala per rand + bulk (US-005), afisaj held (US-006 UI), banner coada tinuta
imbatranita + contor manual (US-007), audit app_events (US-009).
Model: TestClient + login web (ca tests/test_web_lifecycle.py). NU atinge conftest.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "as.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def _account_user(email, name="Service", password="parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
aid = create_account(conn, name, active=True)
create_user(conn, aid, email, password)
return aid
finally:
conn.close()
def _login(client, email, password="parolasecreta10"):
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303, resp.text[:200]
return _csrf(client)
def _csrf(client):
resp = client.get("/?tab=acasa")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m, "csrf_token negasit dupa login"
return m.group(1)
def _ins(account_id, status="queued", held=0, created_at=None):
from app.db import get_connection
conn = get_connection()
try:
content = {"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}]}
if created_at is None:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, held) "
"VALUES (?, ?, ?, ?, ?)",
(f"k-{os.urandom(6).hex()}", account_id, status, json.dumps(content), held),
)
else:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, held, created_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(f"k-{os.urandom(6).hex()}", account_id, status, json.dumps(content), held, created_at),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _get_auto_send(account_id):
from app.db import get_connection
conn = get_connection()
try:
r = conn.execute("SELECT auto_send_enabled FROM accounts WHERE id=?", (account_id,)).fetchone()
return int(r["auto_send_enabled"])
finally:
conn.close()
def _held(sid):
from app.db import get_connection
conn = get_connection()
try:
return int(conn.execute("SELECT held FROM submissions WHERE id=?", (sid,)).fetchone()["held"])
finally:
conn.close()
# --------------------------------------------------------------------------- #
# US-004 — toggle Auto: persistenta + CSRF + scope #
# --------------------------------------------------------------------------- #
def test_auto_send_persista(client):
aid = _account_user("a1@test.com")
csrf = _login(client, "a1@test.com")
assert _get_auto_send(aid) == 0
r = client.post("/auto-send", data={"enabled": "1", "csrf_token": csrf})
assert r.status_code == 200
assert _get_auto_send(aid) == 1
# Debifare
r = client.post("/auto-send", data={"csrf_token": csrf}) # enabled absent = OFF
assert r.status_code == 200
assert _get_auto_send(aid) == 0
def test_auto_send_csrf(client):
aid = _account_user("a2@test.com")
_login(client, "a2@test.com")
r = client.post("/auto-send", data={"enabled": "1", "csrf_token": "gresit"})
assert r.status_code == 403
assert _get_auto_send(aid) == 0
def test_auto_send_scoped_nu_atinge_alt_cont(client):
aid = _account_user("a3@test.com", name="A3")
other = _account_user("a3b@test.com", name="A3B")
csrf = _login(client, "a3@test.com")
r = client.post("/auto-send", data={"enabled": "1", "csrf_token": csrf})
assert r.status_code == 200
assert _get_auto_send(aid) == 1
# contul celalalt ramane OFF (account_id vine din sesiune, nu din formular)
assert _get_auto_send(other) == 0
def test_auto_send_scoped_ignora_account_id_din_formular(client):
aid = _account_user("a4@test.com", name="A4")
other = _account_user("a4b@test.com", name="A4B")
csrf = _login(client, "a4@test.com")
# incearca sa forteze alt cont prin formular -> trebuie ignorat
r = client.post("/auto-send", data={"enabled": "1", "account_id": str(other), "csrf_token": csrf})
assert r.status_code == 200
assert _get_auto_send(aid) == 1
assert _get_auto_send(other) == 0
# --------------------------------------------------------------------------- #
# US-004 — auto-release OFF->ON cu garda de confirmare #
# --------------------------------------------------------------------------- #
def test_off_to_on_fara_confirmare_nu_elibereaza(client):
aid = _account_user("g1@test.com")
csrf = _login(client, "g1@test.com")
sid = _ins(aid, "queued", held=1)
r = client.post("/auto-send", data={"enabled": "1", "csrf_token": csrf})
assert r.status_code == 200
# fara confirmare: NU comita ON, NU elibereaza
assert _get_auto_send(aid) == 0
assert _held(sid) == 1
# raspunsul cere confirmare (modal)
assert "confirm" in r.text.lower() or "trimit" in r.text.lower()
def test_off_to_on_cu_confirmare_elibereaza_scoped(client):
aid = _account_user("g2@test.com", name="G2")
other = _account_user("g2b@test.com", name="G2B")
csrf = _login(client, "g2@test.com")
s1 = _ins(aid, "queued", held=1)
s2 = _ins(aid, "queued", held=1)
s_other = _ins(other, "queued", held=1)
r = client.post("/auto-send", data={"enabled": "1", "confirma": "1", "csrf_token": csrf})
assert r.status_code == 200
assert _get_auto_send(aid) == 1
assert _held(s1) == 0
assert _held(s2) == 0
# cross-account izolare: randul altui cont ramane tinut
assert _held(s_other) == 1
def test_off_to_on_fara_randuri_tinute_comite_direct(client):
aid = _account_user("g3@test.com")
csrf = _login(client, "g3@test.com")
# niciun rand tinut -> nu are nevoie de confirmare
r = client.post("/auto-send", data={"enabled": "1", "csrf_token": csrf})
assert r.status_code == 200
assert _get_auto_send(aid) == 1
# --------------------------------------------------------------------------- #
# US-005 — trimitere manuala per rand + bulk #
# --------------------------------------------------------------------------- #
def test_trimite_acum_rand_tinut(client):
aid = _account_user("t1@test.com")
csrf = _login(client, "t1@test.com")
sid = _ins(aid, "queued", held=1)
r = client.post(f"/trimitere/{sid}/trimite-acum", data={"csrf_token": csrf})
assert r.status_code == 200
assert _held(sid) == 0
def test_trimite_acum_cross_account_404(client):
aid = _account_user("t2@test.com", name="T2")
other = _account_user("t2b@test.com", name="T2B")
csrf = _login(client, "t2@test.com")
sid_other = _ins(other, "queued", held=1)
r = client.post(f"/trimitere/{sid_other}/trimite-acum", data={"csrf_token": csrf})
assert r.status_code == 404
assert _held(sid_other) == 1
def test_trimite_acum_non_queued_noop(client):
aid = _account_user("t3@test.com")
csrf = _login(client, "t3@test.com")
sid = _ins(aid, "sent", held=0)
r = client.post(f"/trimitere/{sid}/trimite-acum", data={"csrf_token": csrf})
assert r.status_code == 200
from app.db import get_connection
conn = get_connection()
try:
assert conn.execute("SELECT status FROM submissions WHERE id=?", (sid,)).fetchone()["status"] == "sent"
finally:
conn.close()
def test_trimite_acum_csrf(client):
aid = _account_user("t4@test.com")
_login(client, "t4@test.com")
sid = _ins(aid, "queued", held=1)
r = client.post(f"/trimitere/{sid}/trimite-acum", data={"csrf_token": "gresit"})
assert r.status_code == 403
assert _held(sid) == 1
def test_trimite_toate_scoped(client):
aid = _account_user("t5@test.com", name="T5")
other = _account_user("t5b@test.com", name="T5B")
csrf = _login(client, "t5@test.com")
s1 = _ins(aid, "queued", held=1)
s2 = _ins(aid, "queued", held=1)
s_other = _ins(other, "queued", held=1)
r = client.post("/trimite-toate", data={"csrf_token": csrf})
assert r.status_code == 200
assert _held(s1) == 0
assert _held(s2) == 0
assert _held(s_other) == 1
# --------------------------------------------------------------------------- #
# US-006 (UI) — eticheta held + buton Trimite in lista #
# --------------------------------------------------------------------------- #
def test_rand_tinut_eticheta_si_buton(client):
aid = _account_user("u1@test.com")
_login(client, "u1@test.com")
sid_tinut = _ins(aid, "queued", held=1)
html = client.get("/_fragments/submissions").text
assert "In asteptare (manual)" in html or "Manual" in html
assert f"/trimitere/{sid_tinut}/trimite-acum" in html
def test_rand_ne_tinut_fara_buton(client):
aid = _account_user("u2@test.com")
_login(client, "u2@test.com")
sid = _ins(aid, "queued", held=0)
html = client.get("/_fragments/submissions").text
assert f"/trimitere/{sid}/trimite-acum" not in html
# --------------------------------------------------------------------------- #
# US-007 — banner coada tinuta imbatranita + contor manual #
# --------------------------------------------------------------------------- #
def test_banner_aged_apare_pt_rand_vechi(client):
aid = _account_user("b1@test.com")
_login(client, "b1@test.com")
_ins(aid, "queued", held=1, created_at="2026-06-01 08:00:00") # >7 zile fata de 2026-07-05
html = client.get("/_fragments/status").text
assert "declarare obligatorie" in html.lower() or "tinute de" in html.lower()
def test_banner_aged_absent_pt_rand_recent(client):
aid = _account_user("b2@test.com")
_login(client, "b2@test.com")
from datetime import datetime, timezone
_ins(aid, "queued", held=1, created_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"))
html = client.get("/_fragments/status").text
assert "declarare obligatorie" not in html.lower()
def test_contor_manual_apare(client):
aid = _account_user("b3@test.com")
_login(client, "b3@test.com")
_ins(aid, "queued", held=1)
_ins(aid, "queued", held=1)
html = client.get("/_fragments/status").text
assert "manual" in html.lower()
# --------------------------------------------------------------------------- #
# US-009 — audit app_events #
# --------------------------------------------------------------------------- #
def _events(account_id, tip):
from app.db import get_connection
conn = get_connection()
try:
rows = conn.execute(
"SELECT * FROM app_events WHERE tip=? AND account_id=?", (tip, account_id)
).fetchall()
return rows
finally:
conn.close()
def test_audit_auto_send_schimbat(client):
aid = _account_user("j1@test.com")
csrf = _login(client, "j1@test.com")
client.post("/auto-send", data={"enabled": "1", "csrf_token": csrf})
assert len(_events(aid, "auto_send_schimbat")) >= 1
def test_audit_held_eliberat_rand(client):
aid = _account_user("j2@test.com")
csrf = _login(client, "j2@test.com")
sid = _ins(aid, "queued", held=1)
client.post(f"/trimitere/{sid}/trimite-acum", data={"csrf_token": csrf})
assert len(_events(aid, "held_eliberat")) >= 1
def test_audit_held_eliberat_bulk(client):
aid = _account_user("j3@test.com")
csrf = _login(client, "j3@test.com")
_ins(aid, "queued", held=1)
_ins(aid, "queued", held=1)
client.post("/trimite-toate", data={"csrf_token": csrf})
evs = _events(aid, "held_eliberat")
assert len(evs) >= 1

204
tests/test_worker_held.py Normal file
View File

@@ -0,0 +1,204 @@
"""Teste PRD 5.19 US-003 + US-008 — worker respecta flag `held`.
US-003: claim_one sare peste randurile `queued AND held=1` (tinute manual).
US-008: expire_held expira randurile tinute mai vechi de `held_retention_days`
(default 90) -> status 'error', rar_error='TINUT_EXPIRAT', purge_after
DIRECT la momentul expirarii (NU +30z retentie blocati -> nu 120z total),
apoi purge_expired le sterge (PII purjat).
TDD: testele se scriu INAINTE de modificarea worker-ului.
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
# --- Fixture DB (pattern din test_worker_active_gate.py) ---
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
conn = get_connection()
yield conn, get_settings()
conn.close()
get_settings.cache_clear()
# --- Helpers ---
_CONTENT = {
"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}], "sistem_reparat": "null",
}
def _insert(conn, account_id=None, status="queued", held=0, content=None, created_at=None):
content = content or _CONTENT
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, status, payload_json, account_id, held) "
"VALUES (?, ?, ?, ?, ?)",
(f"key-{os.urandom(4).hex()}", status, json.dumps(content), account_id, held),
)
sid = int(cur.lastrowid)
if created_at is not None:
conn.execute("UPDATE submissions SET created_at=? WHERE id=?", (created_at, sid))
return sid
def _row(conn, sid):
return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
def _row_status(conn, sid):
r = _row(conn, sid)
return r["status"] if r else None
# --- US-003: claim_one sare peste randurile tinute ---
def test_claim_sare_rand_tinut(env):
"""queued, held=1, cont activ, send on -> claim_one intoarce None; ramane queued."""
from app.accounts import create_account
from app.worker.__main__ import claim_one
conn, _ = env
acct_id = create_account(conn, "Service Tinut", active=True)
sid = _insert(conn, account_id=acct_id, held=1)
result = claim_one(conn)
assert result is None, "claim_one trebuia sa sara peste randul tinut (held=1)"
assert _row_status(conn, sid) == "queued", "randul tinut trebuia sa ramana queued"
def test_claim_ia_rand_neted(env):
"""Acelasi rand cu held=0 -> claim_one il ia si il marcheaza sending."""
from app.accounts import create_account
from app.worker.__main__ import claim_one
conn, _ = env
acct_id = create_account(conn, "Service Liber", active=True)
sid = _insert(conn, account_id=acct_id, held=0)
result = claim_one(conn)
assert result is not None, "claim_one trebuia sa ridice randul held=0"
assert result["id"] == sid
assert _row_status(conn, sid) == "sending"
def test_eliberarea_deblocheaza_claim(env):
"""held=1 -> claim_one None; dupa held=0 -> claim_one il ridica."""
from app.accounts import create_account
from app.worker.__main__ import claim_one
conn, _ = env
acct_id = create_account(conn, "Service Eliberat", active=True)
sid = _insert(conn, account_id=acct_id, held=1)
assert claim_one(conn) is None
assert _row_status(conn, sid) == "queued"
conn.execute("UPDATE submissions SET held=0 WHERE id=?", (sid,))
result = claim_one(conn)
assert result is not None and result["id"] == sid
assert _row_status(conn, sid) == "sending"
# --- US-008: expirare randuri tinute ---
def test_expire_held_expira_rand_vechi(env):
"""queued, held=1, created_at vechi (>90z) -> error + TINUT_EXPIRAT + purge_after ~now."""
from app.accounts import create_account
from app.worker.__main__ import expire_held
conn, settings = env
acct_id = create_account(conn, "Service Vechi", active=True)
sid = _insert(conn, account_id=acct_id, held=1,
created_at="datetime('now','-100 days')")
# created_at trebuie sa fie o valoare literala, nu expresie SQL text -> setam explicit.
conn.execute("UPDATE submissions SET created_at=datetime('now','-100 days') WHERE id=?", (sid,))
n = expire_held(conn, settings)
assert n == 1, "expire_held trebuia sa expire exact 1 rand"
row = _row(conn, sid)
assert row["status"] == "error"
assert row["rar_error"] == "TINUT_EXPIRAT"
assert row["purge_after"] is not None
# purge_after ~ now (NU +30z): verificam ca e <= acum + o mica marja (sub 1 zi).
aproape_acum = conn.execute(
"SELECT purge_after <= datetime('now', '+1 day') AS ok FROM submissions WHERE id=?",
(sid,),
).fetchone()["ok"]
assert aproape_acum == 1, "purge_after trebuia setat ~now, nu +30z"
assert row["sending_since"] is None
def test_expire_held_ignora_rand_recent(env):
"""queued, held=1 recent (created_at now) -> neatins de expire_held."""
from app.accounts import create_account
from app.worker.__main__ import expire_held
conn, settings = env
acct_id = create_account(conn, "Service Recent", active=True)
sid = _insert(conn, account_id=acct_id, held=1)
n = expire_held(conn, settings)
assert n == 0
assert _row_status(conn, sid) == "queued"
def test_expire_held_ignora_rand_neted_vechi(env):
"""queued, held=0, vechi -> NU e tinut -> neatins de expire_held."""
from app.accounts import create_account
from app.worker.__main__ import expire_held
conn, settings = env
acct_id = create_account(conn, "Service NetedVechi", active=True)
sid = _insert(conn, account_id=acct_id, held=0)
conn.execute("UPDATE submissions SET created_at=datetime('now','-100 days') WHERE id=?", (sid,))
n = expire_held(conn, settings)
assert n == 0
assert _row_status(conn, sid) == "queued"
def test_expire_held_apoi_purge_sterge_randul(env):
"""Lant complet: expire_held (purge_after ~now) -> purge_expired sterge randul (PII purjat)."""
from app.accounts import create_account
from app.worker.__main__ import expire_held, purge_expired
conn, settings = env
acct_id = create_account(conn, "Service Purjat", active=True)
sid = _insert(conn, account_id=acct_id, held=1)
conn.execute("UPDATE submissions SET created_at=datetime('now','-100 days') WHERE id=?", (sid,))
assert expire_held(conn, settings) == 1
# purge_after=datetime('now') (moment expirarii). La URMATORUL ciclu de purjare
# (o data pe ora) purge_after < now -> se sterge. Simulam trecerea timpului
# (>= 1s) verificand ca purge_after e deja in trecutul acelui ciclu ulterior.
assert conn.execute(
"SELECT purge_after < datetime('now','+1 second') AS ok FROM submissions WHERE id=?",
(sid,),
).fetchone()["ok"] == 1
conn.execute(
"UPDATE submissions SET purge_after=datetime('now','-1 second') WHERE id=?", (sid,)
)
stats = purge_expired(conn)
assert stats["submissions_purged"] >= 1
assert _row(conn, sid) is None, "randul expirat trebuia sters de purge_expired la ciclul ulterior"

77
tools/carantina_held.py Normal file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""CLI carantina randuri tinute — atenuare hazard de rollback (PRD 5.19 Riscul R4).
Daca se face revert pe cod DUPA ce randuri au primit `held=1`, worker-ul pierde
filtrul `AND held=0` din `claim_one` -> ar trimite TOATE randurile tinute la RAR
(FINALIZATA e IREVERSIBILA). Acest helper carantineaza randurile tinute INAINTE
de un asemenea revert: le scoate din coada (`queued` -> `error`) cu mesajul
`ROLLBACK_QUARANTINE`, ca sa nu poata fi preluate de un worker fara filtrul held.
Adminul ruleaza pe masina gateway — nicio suprafata HTTP. Vezi runbook-ul
`docs/runbook-rollback-5.19.md` pentru procedura completa (inainte/dupa revert).
Utilizare:
python -m tools.carantina_held # carantineaza (scrie)
python -m tools.carantina_held --dry-run # doar numara, NU scrie
"""
from __future__ import annotations
import argparse
import sqlite3
import sys
from app.db import get_connection, init_db
# Randurile vizate: tinute manual, inca in coada. `sending`/`sent` NU se ating
# (FINALIZATA e terminal la RAR), iar `error`/`needs_*` nu pleaca oricum.
_WHERE = "held=1 AND status='queued'"
def _count(conn: sqlite3.Connection) -> int:
return int(conn.execute(f"SELECT COUNT(*) AS n FROM submissions WHERE {_WHERE}").fetchone()["n"])
def carantina(conn: sqlite3.Connection, dry_run: bool = False) -> int:
"""Carantineaza randurile tinute. Intoarce numarul de randuri afectate.
Cu `dry_run=True` doar numara (nu modifica nimic).
"""
n = _count(conn)
if dry_run or n == 0:
return n
conn.execute(
"UPDATE submissions SET status='error', rar_error='ROLLBACK_QUARANTINE', "
f"updated_at=datetime('now') WHERE {_WHERE}"
)
return n
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Carantineaza randurile tinute (queued AND held=1) inainte de un revert de cod (PRD 5.19 R4)."
)
parser.add_argument(
"--dry-run", action="store_true",
help="doar numara randurile tinute, fara sa scrie in DB"
)
args = parser.parse_args(argv)
init_db() # asigura schema (submissions.held)
conn = get_connection()
try:
n = carantina(conn, dry_run=args.dry_run)
finally:
conn.close()
if args.dry_run:
print(f"[dry-run] {n} randuri tinute (queued AND held=1) ar fi carantinate. Nimic scris.")
elif n == 0:
print("Niciun rand tinut de carantinat (queued AND held=1).")
else:
print(f"{n} randuri carantinate: status='error', rar_error='ROLLBACK_QUARANTINE'.")
return 0
if __name__ == "__main__":
sys.exit(main())