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:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
21
app/db.py
21
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:
|
||||
|
||||
12
app/main.py
12
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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. #
|
||||
|
||||
54
app/web/templates/_auto_send_toggle.html
Normal file
54
app/web/templates/_auto_send_toggle.html
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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 → 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 & referinta</h3>
|
||||
|
||||
67
app/web/templates/_modal_confirma_trimitere.html
Normal file
67
app/web/templates/_modal_confirma_trimitere.html
Normal 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">×</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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
60
docs/runbook-rollback-5.19.md
Normal file
60
docs/runbook-rollback-5.19.md
Normal 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.
|
||||
@@ -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)",
|
||||
)
|
||||
|
||||
150
tests/test_auto_send_schema.py
Normal file
150
tests/test_auto_send_schema.py
Normal 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
|
||||
99
tests/test_carantina_held.py
Normal file
99
tests/test_carantina_held.py
Normal 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
146
tests/test_held_api_echo.py
Normal 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
259
tests/test_held_ingestie.py
Normal 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
88
tests/test_labels_held.py
Normal 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)
|
||||
97
tests/test_metrics_held.py
Normal file
97
tests/test_metrics_held.py
Normal 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
347
tests/test_web_auto_send.py
Normal 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
204
tests/test_worker_held.py
Normal 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
77
tools/carantina_held.py
Normal 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())
|
||||
Reference in New Issue
Block a user