From 3b5cf7a7d9398e68f5114f974451e0f7d9200928 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Mon, 6 Jul 2026 08:34:40 +0000 Subject: [PATCH] feat(5.19): auto-send toggle per cont + tinere manuala randuri (held) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/accounts.py | 34 ++ app/api/v1/import_router.py | 10 +- app/api/v1/router.py | 96 ++++- app/config.py | 6 + app/db.py | 21 ++ app/main.py | 12 + app/mapping.py | 9 +- app/models.py | 4 + app/schema.sql | 20 +- app/web/labels.py | 38 +- app/web/routes.py | 229 +++++++++++- app/web/templates/_auto_send_toggle.html | 54 +++ app/web/templates/_coada.html | 32 ++ app/web/templates/_integrare.html | 46 +++ .../templates/_modal_confirma_trimitere.html | 67 ++++ app/web/templates/_status.html | 39 +- app/web/templates/_submissions.html | 22 +- app/web/templates/base.html | 7 + app/worker/__main__.py | 35 ++ docs/runbook-rollback-5.19.md | 60 +++ tests/conftest.py | 68 ++++ tests/test_auto_send_schema.py | 150 ++++++++ tests/test_carantina_held.py | 99 +++++ tests/test_held_api_echo.py | 146 ++++++++ tests/test_held_ingestie.py | 259 +++++++++++++ tests/test_labels_held.py | 88 +++++ tests/test_metrics_held.py | 97 +++++ tests/test_web_auto_send.py | 347 ++++++++++++++++++ tests/test_worker_held.py | 204 ++++++++++ tools/carantina_held.py | 77 ++++ 30 files changed, 2344 insertions(+), 32 deletions(-) create mode 100644 app/web/templates/_auto_send_toggle.html create mode 100644 app/web/templates/_modal_confirma_trimitere.html create mode 100644 docs/runbook-rollback-5.19.md create mode 100644 tests/test_auto_send_schema.py create mode 100644 tests/test_carantina_held.py create mode 100644 tests/test_held_api_echo.py create mode 100644 tests/test_held_ingestie.py create mode 100644 tests/test_labels_held.py create mode 100644 tests/test_metrics_held.py create mode 100644 tests/test_web_auto_send.py create mode 100644 tests/test_worker_held.py create mode 100644 tools/carantina_held.py diff --git a/app/accounts.py b/app/accounts.py index 0fdb3d9..23ec5b8 100644 --- a/app/accounts.py +++ b/app/accounts.py @@ -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, diff --git a/app/api/v1/import_router.py b/app/api/v1/import_router.py index 61c566f..5935660 100644 --- a/app/api/v1/import_router.py +++ b/app/api/v1/import_router.py @@ -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: diff --git a/app/api/v1/router.py b/app/api/v1/router.py index cca26ad..6e1ea56 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -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() diff --git a/app/config.py b/app/config.py index 1d8cb2a..5444871 100644 --- a/app/config.py +++ b/app/config.py @@ -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 -> diff --git a/app/db.py b/app/db.py index 74367a3..70032e4 100644 --- a/app/db.py +++ b/app/db.py @@ -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: diff --git a/app/main.py b/app/main.py index c4ce7a2..cd5feda 100644 --- a/app/main.py +++ b/app/main.py @@ -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" diff --git a/app/mapping.py b/app/mapping.py index 28eeca2..9fe16d0 100644 --- a/app/mapping.py +++ b/app/mapping.py @@ -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 diff --git a/app/models.py b/app/models.py index d493e43..d5d001d 100644 --- a/app/models.py +++ b/app/models.py @@ -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 diff --git a/app/schema.sql b/app/schema.sql index 3da0ce2..1582cbf 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -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 ( diff --git a/app/web/labels.py b/app/web/labels.py index 9c7c060..80c728f 100644 --- a/app/web/labels.py +++ b/app/web/labels.py @@ -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: diff --git a/app/web/routes.py b/app/web/routes.py index f175ad8..0ff0895 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -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. # diff --git a/app/web/templates/_auto_send_toggle.html b/app/web/templates/_auto_send_toggle.html new file mode 100644 index 0000000..5608035 --- /dev/null +++ b/app/web/templates/_auto_send_toggle.html @@ -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). +#} + + + + + {% if confirmare_release|default(false) %} + {# OOB: modal de confirmare tipata pentru auto-release OFF->ON (garda F4/D4). #} + + {% 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' %} + + {% endif %} + + {% if curata_modal|default(false) %} + {# Curata slotul de modal dupa o comutare reusita (fara modal rezidual). #} + + {% endif %} + diff --git a/app/web/templates/_coada.html b/app/web/templates/_coada.html index 4c6d093..4acc4a7 100644 --- a/app/web/templates/_coada.html +++ b/app/web/templates/_coada.html @@ -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. #}

Trimiterile tale

+ + {# 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) %} +
+ + {{ held_manual_count }} + {{ 'prezentare tinuta' if held_manual_count == 1 else 'prezentari tinute' }} manual. + + +
+ {% 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 %}
+ {# 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. #} +
+

De ce nu ajunge la RAR?

+

+ Un POST /v1/prezentari poate intoarce 200 cu + status: "queued", dar randul sa NU plece automat la RAR. Cele trei motive: +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
MotivCe inseamnaCum deblochezi
held (Auto OFF)Contul are Trimiterea automata OPRITA → randul e tinut + (held=true), NU pleaca la RAR.Elibereaza manual din dashboard (butonul Trimite / Trimite toate) + sau activeaza Trimiterea automata. Verifici cu + GET /v1/prezentari/{id} (held=true).
needs_mappingCod operatie fara mapare la un cod RAR din nomenclator.Intra in editorul de mapare din dashboard; la salvarea maparii randul + se re-rezolva automat.
needs_dataValidare de continut esuata (VIN / data prestatie / odometru).Corectezi datele si repui prezentarea.
+
+
+ {# Card: Export & referinta #}

Export & referinta

diff --git a/app/web/templates/_modal_confirma_trimitere.html b/app/web/templates/_modal_confirma_trimitere.html new file mode 100644 index 0000000..e848fd3 --- /dev/null +++ b/app/web/templates/_modal_confirma_trimitere.html @@ -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) %} + diff --git a/app/web/templates/_status.html b/app/web/templates/_status.html index 0afd0be..88e8a79 100644 --- a/app/web/templates/_status.html +++ b/app/web/templates/_status.html @@ -14,10 +14,36 @@
{% 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 %} + + {% 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 %} - {# 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). #}
{{ counts_queued }}
In coada
+ {% if held_manual_count|default(0) %} +
+ {{ held_manual_count }} manual +
+ {% endif %}
{# De corectat (rosu daca >0, muted la 0; link catre lista) #} @@ -115,7 +148,7 @@
{{ counts_queued }}
-
Coada
+
Coada{% if held_manual_count|default(0) %} ·{{ held_manual_count }}m{% endif %}
{{ blocate_total }}
diff --git a/app/web/templates/_submissions.html b/app/web/templates/_submissions.html index d43d734..4f4f85c 100644 --- a/app/web/templates/_submissions.html +++ b/app/web/templates/_submissions.html @@ -136,13 +136,33 @@ {% endif %} - {# 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) #}
{{ r.stare_scurt }} {% if r.rar_env %} {% set _eb = eticheta_env(r.rar_env) %} {{ _eb[0] }} {% 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 %} +
+ + +
+ {% endif %}
{% endfor %} diff --git a/app/web/templates/base.html b/app/web/templates/base.html index 4f088a0..508d5b0 100644 --- a/app/web/templates/base.html +++ b/app/web/templates/base.html @@ -868,6 +868,9 @@ RAR blocat {% 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 @@
+ {# 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. #} +