merge: PRD 5.19 auto-send toggle + tinere manuala randuri (held)
This commit is contained in:
9
TODOS.md
9
TODOS.md
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
Elemente deferate din review-uri. Negrupte de un PRD curent; de promovat cand devin prioritare.
|
Elemente deferate din review-uri. Negrupte de un PRD curent; de promovat cand devin prioritare.
|
||||||
|
|
||||||
|
## Din hardening 80/20 (/autoplan, 2026-07-03)
|
||||||
|
|
||||||
|
- [ ] **Traefik IP-allowlist pe /v1 + /metrics in fereastra de lansare** (T4) — cu zero clienti API,
|
||||||
|
suprafata anonima de ingestie n-are apelant legitim extern (ROAAUTO propriu vine de la IP cunoscut).
|
||||||
|
O regula Traefik declarativa (allowlist IP ROAAUTO + admin) elimina complet ingestia anonima in
|
||||||
|
perioada cea mai riscanta. Deferat: P0-1 (auth obligatoriu pe /v1) + T3 (startup guard) acopera deja
|
||||||
|
gaura principala. De activat cand: primesc trafic de scanare pe /v1 SAU inainte de expunere cu presa.
|
||||||
|
Effort: S (config Traefik). Depinde de: acces la config Traefik in Dokploy.
|
||||||
|
|
||||||
## Din PRD 5.12 (2026-06-26)
|
## Din PRD 5.12 (2026-06-26)
|
||||||
|
|
||||||
- [ ] **Mai multi utilizatori per firma (flux de invitatie / alaturare la cont)** — azi CUI e unic, deci
|
- [ ] **Mai multi utilizatori per firma (flux de invitatie / alaturare la cont)** — azi CUI e unic, deci
|
||||||
|
|||||||
@@ -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(
|
def set_tier(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
account_id: int,
|
account_id: int,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from pydantic import BaseModel, Field
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from ... import errors
|
from ... import errors
|
||||||
|
from ...accounts import held_for_account
|
||||||
from ...auth import require_api_access, resolve_account_id
|
from ...auth import require_api_access, resolve_account_id
|
||||||
from ...crypto import decrypt_creds, encrypt_creds
|
from ...crypto import decrypt_creds, encrypt_creds
|
||||||
from ...db import get_connection
|
from ...db import get_connection
|
||||||
@@ -1125,6 +1126,9 @@ def commit_import(
|
|||||||
conn.execute("BEGIN IMMEDIATE")
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
try:
|
try:
|
||||||
purge_after_sql = "datetime('now', '+90 days')"
|
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:
|
for ok_row in ok_rows:
|
||||||
row_dict = ok_row["data"]
|
row_dict = ok_row["data"]
|
||||||
@@ -1208,9 +1212,9 @@ def commit_import(
|
|||||||
# INSERT ON CONFLICT DO NOTHING (TOCTOU)
|
# INSERT ON CONFLICT DO NOTHING (TOCTOU)
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT OR IGNORE INTO submissions "
|
"INSERT OR IGNORE INTO submissions "
|
||||||
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after, rar_env) "
|
"(idempotency_key, account_id, status, payload_json, batch_id, row_index, purge_after, rar_env, held) "
|
||||||
"VALUES (?, ?, 'queued', ?, ?, ?, " + purge_after_sql + ", ?)",
|
"VALUES (?, ?, 'queued', ?, ?, ?, " + purge_after_sql + ", ?, ?)",
|
||||||
(key, acct, payload_json, import_id, row_index, env),
|
(key, acct, payload_json, import_id, row_index, env, held_val),
|
||||||
)
|
)
|
||||||
|
|
||||||
if cur.rowcount == 0:
|
if cur.rowcount == 0:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from ...accounts import held_for_account
|
||||||
from ...auth import require_api_access, resolve_account_id
|
from ...auth import require_api_access, resolve_account_id
|
||||||
from ...crypto import encrypt_creds
|
from ...crypto import encrypt_creds
|
||||||
from ...db import get_connection
|
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:
|
def _motiv_clasificare(cl: dict) -> str | None:
|
||||||
"""Rezumat uman pe o linie pentru un rezultat de clasificare.
|
"""Rezumat uman pe o linie pentru un rezultat de clasificare.
|
||||||
|
|
||||||
@@ -107,18 +122,22 @@ def _motiv_clasificare(cl: dict) -> str | None:
|
|||||||
return 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.
|
"""SubmissionResult onest dintr-un rezultat de clasificare.
|
||||||
|
|
||||||
Populeaza erori (validare continut), nemapate (coduri fara mapare) si motiv (uman)
|
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(
|
return SubmissionResult(
|
||||||
submission_id=submission_id,
|
submission_id=submission_id,
|
||||||
status=cl["status"],
|
status=cl["status"],
|
||||||
erori=list(cl["errors"]),
|
erori=list(cl["errors"]),
|
||||||
nemapate=_erori_nemapate(cl["unmapped"]),
|
nemapate=_erori_nemapate(cl["unmapped"]),
|
||||||
motiv=_motiv_clasificare(cl),
|
motiv=motiv,
|
||||||
|
held=tinut,
|
||||||
**extra,
|
**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:
|
for prez in req.prezentari:
|
||||||
content = prez.model_dump()
|
content = prez.model_dump()
|
||||||
# canonicalize_row inaintea build_key (odometru strip ".0", VIN upper).
|
# canonicalize_row inaintea build_key (odometru strip ".0", VIN upper).
|
||||||
@@ -254,7 +278,7 @@ def create_prezentari(
|
|||||||
"odometru_final": canon["odometru_final"],
|
"odometru_final": canon["odometru_final"],
|
||||||
})
|
})
|
||||||
existing = conn.execute(
|
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,),
|
(key,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
@@ -267,13 +291,16 @@ def create_prezentari(
|
|||||||
# on_unmapped_error=True: nu reactivam; randul ramane 'error'.
|
# on_unmapped_error=True: nu reactivam; randul ramane 'error'.
|
||||||
results.append(_rezultat_respins(existing["id"], cl, rar_env=env))
|
results.append(_rezultat_respins(existing["id"], cl, rar_env=env))
|
||||||
continue
|
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(
|
cur = conn.execute(
|
||||||
"UPDATE submissions SET status=?, payload_json=?, rar_error=?, "
|
"UPDATE submissions SET status=?, payload_json=?, rar_error=?, "
|
||||||
"rar_creds_enc=COALESCE(?, rar_creds_enc), retry_count=0, "
|
"rar_creds_enc=COALESCE(?, rar_creds_enc), retry_count=0, "
|
||||||
"next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, "
|
"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["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:
|
if cur.rowcount == 1:
|
||||||
# Creds noi se propaga si in slotul durabil per-env al contului
|
# 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"])
|
_emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"])
|
||||||
# Raspuns onest si la reactivare: daca re-clasificarea cade pe
|
# Raspuns onest si la reactivare: daca re-clasificarea cade pe
|
||||||
# needs_data/needs_mapping, expune motivul (nu doar status).
|
# 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
|
continue
|
||||||
# Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE
|
# Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE
|
||||||
# (rowcount==0) -> raspuns dedup pe starea CURENTA.
|
# (rowcount==0) -> raspuns dedup pe starea CURENTA.
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
"SELECT id, status, id_prezentare FROM submissions WHERE id=?",
|
"SELECT id, status, id_prezentare, held FROM submissions WHERE id=?",
|
||||||
(existing["id"],),
|
(existing["id"],),
|
||||||
).fetchone()
|
).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(
|
results.append(
|
||||||
SubmissionResult(
|
SubmissionResult(
|
||||||
submission_id=existing["id"],
|
submission_id=existing["id"],
|
||||||
status=existing["status"],
|
status=existing["status"],
|
||||||
id_prezentare=existing["id_prezentare"],
|
id_prezentare=existing["id_prezentare"],
|
||||||
deduped=True,
|
deduped=True,
|
||||||
|
held=dedup_held,
|
||||||
|
motiv=_motiv_held(existing["status"], dedup_held),
|
||||||
rar_env=env,
|
rar_env=env,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -312,15 +344,17 @@ def create_prezentari(
|
|||||||
# on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat).
|
# on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat).
|
||||||
results.append(_rezultat_respins(None, cl, rar_env=env))
|
results.append(_rezultat_respins(None, cl, rar_env=env))
|
||||||
continue
|
continue
|
||||||
|
# PRD 5.19 US-002: `held` = snapshot comutator cont (chokepoint held_for_account).
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc, rar_env) "
|
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc, rar_env, held) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc, env),
|
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc, env, held_val),
|
||||||
)
|
)
|
||||||
sub_id = int(cur.lastrowid)
|
sub_id = int(cur.lastrowid)
|
||||||
_emite_text_rule_hits(conn, acct, sub_id, cl["resolved"])
|
_emite_text_rule_hits(conn, acct, sub_id, cl["resolved"])
|
||||||
# Raspuns onest: pe needs_data/needs_mapping expune erori/nemapate/motiv.
|
# Raspuns onest: pe needs_data/needs_mapping expune erori/nemapate/motiv;
|
||||||
results.append(_rezultat_enqueue(sub_id, cl, rar_env=env))
|
# 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),
|
# Audit cerere API per cont. Doar metadate (count + distributie status),
|
||||||
# NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL).
|
# 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.
|
# rar_env inclus (US-005): badge mediu in lista.
|
||||||
cols = (
|
cols = (
|
||||||
"id, status, id_prezentare, rar_status_code, retry_count, "
|
"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:
|
if status:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -470,6 +504,8 @@ _PREZENTARE_FIELDS = frozenset({
|
|||||||
"rar_error",
|
"rar_error",
|
||||||
# US-005: mediul RAR tinta (Test/Productie) — necesar pentru badge + ecou API.
|
# US-005: mediul RAR tinta (Test/Productie) — necesar pentru badge + ecou API.
|
||||||
"rar_env",
|
"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()
|
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")
|
@router.get("/nomenclator")
|
||||||
def get_nomenclator() -> dict:
|
def get_nomenclator() -> dict:
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ class Settings(BaseSettings):
|
|||||||
# Retentie randuri blocate (error/needs_data/needs_mapping). Mai scurt decat 90z
|
# Retentie randuri blocate (error/needs_data/needs_mapping). Mai scurt decat 90z
|
||||||
# ale `sent` — un blocat n-are valoare de audit.
|
# ale `sent` — un blocat n-are valoare de audit.
|
||||||
blocked_retention_days: int = 30
|
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 ---
|
# --- Securitate ---
|
||||||
# Enforcement auth API-key pe /v1/* protejat. False (dev/test): fara cheie ->
|
# 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'))"
|
"CHECK (rar_env IN ('test', 'prod'))"
|
||||||
)
|
)
|
||||||
_backfill_submissions_rar_env(conn)
|
_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
|
# Coloane accounts
|
||||||
acc_cols = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
|
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 "
|
"ALTER TABLE accounts ADD COLUMN on_unmapped_error_default INTEGER NOT NULL DEFAULT 0 "
|
||||||
"CHECK (on_unmapped_error_default IN (0, 1))"
|
"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:
|
if "email" not in acc_cols:
|
||||||
# Email canonic de contact al firmei (US-001, PRD 5.12). Nullable pt. conturi legacy.
|
# Email canonic de contact al firmei (US-001, PRD 5.12). Nullable pt. conturi legacy.
|
||||||
conn.execute("ALTER TABLE accounts ADD COLUMN email TEXT")
|
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 "
|
"CREATE INDEX IF NOT EXISTS idx_submissions_account_status "
|
||||||
"ON submissions(account_id, 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:
|
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()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
rows = conn.execute("SELECT status, COUNT(*) AS n FROM submissions GROUP BY status").fetchall()
|
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:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
lines = ["# submissions pe status"]
|
lines = ["# submissions pe status"]
|
||||||
for r in rows:
|
for r in rows:
|
||||||
lines.append(f'autopass_submissions{{status="{r["status"]}"}} {r["n"]}')
|
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"
|
return "\n".join(lines) + "\n"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from typing import Any
|
|||||||
from rapidfuzz import fuzz, process
|
from rapidfuzz import fuzz, process
|
||||||
|
|
||||||
from . import errors as err_mod
|
from . import errors as err_mod
|
||||||
|
from .accounts import held_for_account
|
||||||
from .nomenclator_seed import FALLBACK_NOMENCLATOR
|
from .nomenclator_seed import FALLBACK_NOMENCLATOR
|
||||||
from .validation import validate_prezentare
|
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
|
stats["needs_data"] += 1
|
||||||
else:
|
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(
|
conn.execute(
|
||||||
"UPDATE submissions SET status='queued', payload_json=?, rar_error=NULL, "
|
"UPDATE submissions SET status='queued', payload_json=?, rar_error=NULL, "
|
||||||
"retry_count=0, next_attempt_at=NULL, updated_at=datetime('now') WHERE id=?",
|
"retry_count=0, next_attempt_at=NULL, held=?, updated_at=datetime('now') WHERE id=?",
|
||||||
(payload_json, r["id"]),
|
(payload_json, held_for_account(conn, acct), r["id"]),
|
||||||
)
|
)
|
||||||
stats["requeued"] += 1
|
stats["requeued"] += 1
|
||||||
return stats
|
return stats
|
||||||
|
|||||||
@@ -109,6 +109,10 @@ class SubmissionResult(BaseModel):
|
|||||||
# RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit. `deduped` pastreaza
|
# RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit. `deduped` pastreaza
|
||||||
# semantica actuala (clientii vechi care testeaza `deduped` nu se sparg).
|
# semantica actuala (clientii vechi care testeaza `deduped` nu se sparg).
|
||||||
reactivated: bool = False
|
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).
|
# Mediul RAR tinta efectiv (ecou din DB / rezolvat la ingestie).
|
||||||
rar_env: str = "test"
|
rar_env: str = "test"
|
||||||
# Raspuns ONEST pentru randuri blocate: orice status != 'queued' isi expune
|
# 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.
|
-- 1 (respinge cererea fara enqueue). Override per-cerere via PrezentareRequest.on_unmapped_error.
|
||||||
on_unmapped_error_default INTEGER NOT NULL DEFAULT 0
|
on_unmapped_error_default INTEGER NOT NULL DEFAULT 0
|
||||||
CHECK (on_unmapped_error_default IN (0, 1)),
|
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).
|
-- 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).
|
-- 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.
|
-- 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)
|
purge_after TEXT, -- sent + 90z (T16)
|
||||||
batch_id INTEGER, -- import batch (T7; NULL = canal API)
|
batch_id INTEGER, -- import batch (T7; NULL = canal API)
|
||||||
row_index INTEGER, -- rand in 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')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_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_status ON submissions(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_submissions_account_status ON submissions(account_id, 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).
|
-- Mapare coloane fisier -> campuri canonice (retinuta per cont, semnatura coloane).
|
||||||
CREATE TABLE IF NOT EXISTS column_mappings (
|
CREATE TABLE IF NOT EXISTS column_mappings (
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from .accounts import held_for_account
|
||||||
from .mapping import (
|
from .mapping import (
|
||||||
account_or_default,
|
account_or_default,
|
||||||
account_scope_clause,
|
account_scope_clause,
|
||||||
@@ -103,11 +104,15 @@ def requeue_submission(conn, account_id: int, sid: int) -> dict:
|
|||||||
valid_codes = load_nomenclator_codes(conn) or None
|
valid_codes = load_nomenclator_codes(conn) or None
|
||||||
cl = classify_prezentare(content, mapping, mapping_meta, valid_codes)
|
cl = classify_prezentare(content, mapping, mapping_meta, valid_codes)
|
||||||
|
|
||||||
|
# PRD 5.19 US-002: re-snapshot `held` din comutatorul contului la re-punere in coada
|
||||||
|
# (paritate cu create_prezentari/reresolve_account). Fara asta un rand repus pastra
|
||||||
|
# `held` VECHI si s-ar auto-trimite desi contul e Auto OFF (sau ar ramane blocat cand e ON).
|
||||||
|
held_val = held_for_account(conn, account_or_default(account_id))
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE submissions SET status=?, payload_json=?, rar_error=?, retry_count=0, "
|
"UPDATE submissions SET status=?, payload_json=?, rar_error=?, retry_count=0, "
|
||||||
"next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, updated_at=datetime('now') "
|
"next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, held=?, updated_at=datetime('now') "
|
||||||
"WHERE id=?",
|
"WHERE id=?",
|
||||||
(cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], sid),
|
(cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], held_val, sid),
|
||||||
)
|
)
|
||||||
log_event(
|
log_event(
|
||||||
"submission_repus",
|
"submission_repus",
|
||||||
|
|||||||
@@ -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.
|
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
|
Arunca KeyError daca starea nu este mapata — intentionat, ca sa prinda
|
||||||
stari noi adaugate in schema fara mapare corespunzatoare.
|
stari noi adaugate in schema fara mapare corespunzatoare.
|
||||||
"""
|
"""
|
||||||
|
if held and status == "queued":
|
||||||
|
return ETICHETA_HELD_SCURTA
|
||||||
try:
|
try:
|
||||||
return ETICHETE_SCURTE[status]
|
return ETICHETE_SCURTE[status]
|
||||||
except KeyError:
|
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.
|
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
|
Arunca KeyError daca starea nu este mapata — intentionat, ca sa prinda
|
||||||
stari noi adaugate in schema fara mapare corespunzatoare.
|
stari noi adaugate in schema fara mapare corespunzatoare.
|
||||||
"""
|
"""
|
||||||
|
if held and status == "queued":
|
||||||
|
return ETICHETA_HELD
|
||||||
try:
|
try:
|
||||||
return STARI_SUBMISSION[status]
|
return STARI_SUBMISSION[status]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ from ..api.v1.import_router import (
|
|||||||
apply_row_override,
|
apply_row_override,
|
||||||
EDIT_FIELDS,
|
EDIT_FIELDS,
|
||||||
)
|
)
|
||||||
|
from ..accounts import get_auto_send, held_for_account, set_auto_send
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from ..crypto import decrypt_creds, encrypt_creds
|
from ..crypto import decrypt_creds, encrypt_creds
|
||||||
from ..db import get_connection, read_app_events, read_heartbeat
|
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
|
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:
|
def _trimiteri_versiune(conn, account_id: int) -> str:
|
||||||
"""Semnatura ieftina a starii trimiterilor contului: numar randuri + cel mai recent
|
"""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
|
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)
|
counts = _status_counts(conn, account_id)
|
||||||
blocate_total = sum(counts.get(s, 0) for s in _BLOCKED)
|
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 {
|
return {
|
||||||
"request": request,
|
"request": request,
|
||||||
"are_creds": are_creds,
|
"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_trimiteri": are_trimiteri,
|
||||||
"are_cheie_folosita": are_cheie_folosita,
|
"are_cheie_folosita": are_cheie_folosita,
|
||||||
"blocate_total": blocate_total,
|
"blocate_total": blocate_total,
|
||||||
@@ -703,6 +735,9 @@ def _layout_header_ctx(conn, account_id: int) -> dict:
|
|||||||
"tier_label": tier_label,
|
"tier_label": tier_label,
|
||||||
"sanatate_ok": sanatate_ok,
|
"sanatate_ok": sanatate_ok,
|
||||||
"last_login": format_data_rar(hb["last_rar_login_ok"] if hb else None),
|
"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.
|
# US-006 (5.17): context plan pentru linia detaliata din meniul burger.
|
||||||
ctx.update(_plan_ctx(conn, account_id))
|
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)
|
medii_disp = medii_disponibile_cont(conn, account_id)
|
||||||
env_default = rar_env_efectiv_cont(conn, account_id) or "prod"
|
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 = {
|
status_ctx = {
|
||||||
"request": request,
|
"request": request,
|
||||||
"worker_lbl": worker_lbl,
|
"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
|
# US-011: indicator mediu RAR + toggle conditionat
|
||||||
"env_default": env_default,
|
"env_default": env_default,
|
||||||
"medii_disponibile": medii_disp,
|
"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),
|
"csrf_token": get_csrf_token(request),
|
||||||
}
|
}
|
||||||
# US-006 (5.17): context plan pentru linia de consum/trial in _status.html.
|
# 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:
|
def _submission_row_view(r) -> dict:
|
||||||
"""Imbogateste un rand de submission cu campuri afisabile umane."""
|
"""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"])
|
motiv = motiv_uman(r["status"], r["rar_error"])
|
||||||
return {
|
return {
|
||||||
"id": r["id"],
|
"id": r["id"],
|
||||||
"status": r["status"],
|
"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=).
|
# 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_text": eticheta[0],
|
||||||
"stare_css": eticheta[2],
|
"stare_css": eticheta[2],
|
||||||
"prez": prezentare_din_payload(r["payload_json"]),
|
"prez": prezentare_din_payload(r["payload_json"]),
|
||||||
@@ -1076,6 +1126,7 @@ def fragment_submissions(
|
|||||||
vehicul: str | None = None,
|
vehicul: str | None = None,
|
||||||
data_de: str | None = None,
|
data_de: str | None = None,
|
||||||
data_pana: str | None = None,
|
data_pana: str | None = None,
|
||||||
|
held: int | None = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""Tabel Trimiteri, scoped pe cont, cu filtre optionale si paginare.
|
"""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
|
vehicul_q = (vehicul or "").strip().upper() or None
|
||||||
data_de = (data_de or "").strip() or None
|
data_de = (data_de or "").strip() or None
|
||||||
data_pana = (data_pana 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
|
filtru_python = bool(vehicul_q or data_de or data_pana) # filtru care necesita Python
|
||||||
|
|
||||||
page = max(1, page) # pre-clamp >= 1
|
page = max(1, page) # pre-clamp >= 1
|
||||||
@@ -1103,6 +1154,10 @@ def fragment_submissions(
|
|||||||
if status:
|
if status:
|
||||||
where.append("status=?")
|
where.append("status=?")
|
||||||
params.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)
|
where_sql = " AND ".join(where)
|
||||||
|
|
||||||
if filtru_python:
|
if filtru_python:
|
||||||
@@ -1110,7 +1165,7 @@ def fragment_submissions(
|
|||||||
# FARA LIMIT — altfel paginile >8 ar disparea silentios.
|
# FARA LIMIT — altfel paginile >8 ar disparea silentios.
|
||||||
rows_db = conn.execute(
|
rows_db = conn.execute(
|
||||||
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
|
"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,
|
params,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
@@ -1151,7 +1206,7 @@ def fragment_submissions(
|
|||||||
|
|
||||||
rows_db = conn.execute(
|
rows_db = conn.execute(
|
||||||
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
|
"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 ?",
|
"LIMIT ? OFFSET ?",
|
||||||
params + [_PAGE_SIZE, offset],
|
params + [_PAGE_SIZE, offset],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -1204,7 +1259,7 @@ def _render_submissions(request: Request, conn, account_id: int,
|
|||||||
scope_sql, scope_params = account_scope_clause(account_id)
|
scope_sql, scope_params = account_scope_clause(account_id)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
|
"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,
|
scope_params,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
view = [_submission_row_view(r) for r in rows]
|
view = [_submission_row_view(r) for r in rows]
|
||||||
@@ -1691,11 +1746,12 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# PRD 5.19 US-002: re-snapshot `held` din comutatorul contului la re-punere in coada.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, "
|
"UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, "
|
||||||
"rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), "
|
"rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), "
|
||||||
"updated_at=datetime('now') WHERE id=?",
|
"held=?, updated_at=datetime('now') WHERE id=?",
|
||||||
(new_key, payload_json, row["id"]),
|
(new_key, payload_json, held_for_account(conn, account_or_default(account_id)), row["id"]),
|
||||||
)
|
)
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
# Plasa de siguranta pentru cursa TOCTOU pe UNIQUE(idempotency_key):
|
# Plasa de siguranta pentru cursa TOCTOU pe UNIQUE(idempotency_key):
|
||||||
@@ -1828,11 +1884,12 @@ async def post_repune_trimitere(request: Request, submission_id: int) -> HTMLRes
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
# PRD 5.19 US-002: re-snapshot `held` din comutatorul contului la re-punere in coada.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, "
|
"UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, "
|
||||||
"rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), "
|
"rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), "
|
||||||
"updated_at=datetime('now') WHERE id=? AND account_id=?",
|
"held=?, updated_at=datetime('now') WHERE id=? AND account_id=?",
|
||||||
(new_key, payload_json, row["id"], account_id),
|
(new_key, payload_json, held_for_account(conn, account_or_default(account_id)), row["id"], account_id),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
@@ -1927,6 +1984,168 @@ async def post_sterge_bulk(request: Request) -> HTMLResponse:
|
|||||||
conn.close()
|
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 #
|
# 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. #
|
# blocate. Reuse form #bulk-trimiteri + validare cod din post_corectie. #
|
||||||
@@ -1980,6 +2199,9 @@ async def post_bulk_fix(request: Request) -> HTMLResponse:
|
|||||||
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
|
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
|
||||||
valid_codes = load_nomenclator_codes(conn) or None
|
valid_codes = load_nomenclator_codes(conn) or None
|
||||||
text_rules = load_text_rules(conn, account_id)
|
text_rules = load_text_rules(conn, account_id)
|
||||||
|
# PRD 5.19 US-002: snapshot `held` din comutatorul contului (nu depinde de rand)
|
||||||
|
# re-aplicat la re-punerea in coada — paritate cu create_prezentari/requeue_submission.
|
||||||
|
held_val = held_for_account(conn, account_or_default(account_id))
|
||||||
|
|
||||||
for raw in ids:
|
for raw in ids:
|
||||||
try:
|
try:
|
||||||
@@ -2073,8 +2295,8 @@ async def post_bulk_fix(request: Request) -> HTMLResponse:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, "
|
"UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, "
|
||||||
"rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), "
|
"rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), "
|
||||||
"updated_at=datetime('now') WHERE id=?",
|
"held=?, updated_at=datetime('now') WHERE id=?",
|
||||||
(new_key, payload_json, sid),
|
(new_key, payload_json, held_val, sid),
|
||||||
)
|
)
|
||||||
reusite += 1
|
reusite += 1
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
|
|||||||
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
|
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. #}
|
aria-labelledby). Badge-ul de atentie + export CSV stau intr-un rand discret. #}
|
||||||
<h2 id="trimiteri-heading" class="sr-only">Trimiterile tale</h2>
|
<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 %}
|
{% if blocate_total %}
|
||||||
<div style="display:flex; align-items:center; gap:6px; flex-wrap:wrap; margin:0 0 10px;">
|
<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"
|
<span class="tab-badge" title="{{ blocate_total }} necesita atentie"
|
||||||
|
|||||||
@@ -216,6 +216,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 #}
|
{# Card: Export & referinta #}
|
||||||
<div class="card" style="margin-bottom:16px;">
|
<div class="card" style="margin-bottom:16px;">
|
||||||
<h3 style="margin:0 0 12px; font-size:15px;">Export & referinta</h3>
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% 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
|
{# 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.
|
dupa expirarea trial-ului. Discret, non-blocant; dismissibil via sessionStorage.
|
||||||
Nu acopera stripul de sanatate (apare inainte de health strip, la acelasi nivel). #}
|
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"
|
<div id="banner-trial-expirat"
|
||||||
role="status"
|
role="status"
|
||||||
style="margin-bottom:10px; padding:7px 12px;
|
style="margin-bottom:10px; padding:7px 12px;
|
||||||
@@ -83,10 +109,17 @@
|
|||||||
<div class="contor-label">Azi</div>
|
<div class="contor-label">Azi</div>
|
||||||
</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-card" style="flex:1; min-width:80px;">
|
||||||
<div class="contor-cifra s-queued">{{ counts_queued }}</div>
|
<div class="contor-cifra s-queued">{{ counts_queued }}</div>
|
||||||
<div class="contor-label">In coada</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>
|
</div>
|
||||||
|
|
||||||
{# De corectat (rosu daca >0, muted la 0; link catre lista) #}
|
{# De corectat (rosu daca >0, muted la 0; link catre lista) #}
|
||||||
@@ -115,7 +148,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="compact-item">
|
<div class="compact-item">
|
||||||
<div class="compact-nr s-queued">{{ counts_queued }}</div>
|
<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>
|
</div>
|
||||||
<a class="compact-item" href="/" style="text-decoration:none; color:inherit;">
|
<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>
|
<div class="compact-nr {{ 's-error' if blocate_total else 'muted' }}">{{ blocate_total }}</div>
|
||||||
|
|||||||
@@ -136,13 +136,33 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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;">
|
<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>
|
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}">{{ r.stare_scurt }}</span>
|
||||||
{% if r.rar_env %}
|
{% if r.rar_env %}
|
||||||
{% set _eb = eticheta_env(r.rar_env) %}
|
{% set _eb = eticheta_env(r.rar_env) %}
|
||||||
<span class="{{ _eb[1] }}">{{ _eb[0] }}</span>
|
<span class="{{ _eb[1] }}">{{ _eb[0] }}</span>
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -868,6 +868,9 @@
|
|||||||
<span class="rar-tx">RAR blocat</span>
|
<span class="rar-tx">RAR blocat</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endif %}
|
||||||
{# US-011 (PRD 5.16): selector tema = pill cu icon FIX (acelasi SVG ca landing) +
|
{# 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
|
eticheta temei curente. Eticheta ascunsa pe <=560px via CSS. JS actualizeaza
|
||||||
@@ -951,6 +954,10 @@
|
|||||||
<div id="detaliu-modal-body"></div>
|
<div id="detaliu-modal-body"></div>
|
||||||
</div>
|
</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>
|
<script>
|
||||||
// Comutator tema ciclic (DRY E2 — PRD 5.15): config traieste intr-o singura structura
|
// 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.
|
// 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:
|
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."""
|
"""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()
|
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 "
|
"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 "
|
"FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id "
|
||||||
"WHERE s.status='queued' "
|
"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 <= ?) "
|
"AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) "
|
||||||
# Gate pe stare de cont: doar 'active' trimite. Derivam defensiv din `active`
|
# Gate pe stare de cont: doar 'active' trimite. Derivam defensiv din `active`
|
||||||
# cand `status` lipseste (DB veche pre-migrare), pastrand active=1 <=> '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.
|
# Purjare periodica (odata pe ora) — NU mai frecvent.
|
||||||
now_ts = time.time()
|
now_ts = time.time()
|
||||||
if now_ts - _last_purge_time >= _PURGE_INTERVAL_S:
|
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)
|
stats = purge_expired(conn)
|
||||||
if stats["submissions_purged"] or stats["batches_purged"] or stats["events_purged"]:
|
if stats["submissions_purged"] or stats["batches_purged"] or stats["events_purged"]:
|
||||||
print(
|
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")
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def _reset_embeddings_singleton():
|
def _reset_embeddings_singleton():
|
||||||
"""Reseteaza singleton-ul global de embeddings intre teste (izolare de ordine).
|
"""Reseteaza singleton-ul global de embeddings intre teste (izolare de ordine).
|
||||||
@@ -69,3 +132,8 @@ def pytest_configure(config):
|
|||||||
config.addinivalue_line(
|
config.addinivalue_line(
|
||||||
"markers", "live: test live pe RAR test (necesita AUTOPASS_LIVE_RAR=1 + creds reale)"
|
"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
|
||||||
89
tests/test_held_requeue_snapshot.py
Normal file
89
tests/test_held_requeue_snapshot.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""Regresie PRD 5.19 US-002 — re-snapshot `held` pe caile de re-punere in coada.
|
||||||
|
|
||||||
|
Bug (gasit la /code-review high): create_prezentari (reactivare) si reresolve_account
|
||||||
|
re-calculau `held` din comutatorul contului la tranzitia -> queued, dar caile de
|
||||||
|
re-punere din dashboard/admin (requeue_submission, corectie, repune-cu-cod, bulk-fix)
|
||||||
|
lasau `held` pe valoarea VECHE. Consecinta: un rand ingerat pe Auto ON (held=0) care a
|
||||||
|
esuat, apoi contul trecut pe Auto OFF, la re-punere pastra held=0 -> worker-ul il
|
||||||
|
auto-trimitea la RAR (FINALIZATA ireversibil) desi contul e Auto OFF.
|
||||||
|
|
||||||
|
Testam sursa comuna requeue_submission (backing API /repune + web) pe ambele directii.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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"}], "sistem_reparat": "null",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_error(conn, account_id, held):
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO submissions (idempotency_key, status, payload_json, account_id, held) "
|
||||||
|
"VALUES (?, 'error', ?, ?, ?)",
|
||||||
|
(f"key-{os.urandom(4).hex()}", json.dumps(_CONTENT), account_id, held),
|
||||||
|
)
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
def _held(conn, sid):
|
||||||
|
return conn.execute("SELECT held FROM submissions WHERE id=?", (sid,)).fetchone()["held"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_requeue_tine_randul_cand_cont_auto_off(env):
|
||||||
|
"""Auto OFF + rand held=0 (ingerat pe Auto ON) -> requeue re-snapshot -> held=1.
|
||||||
|
|
||||||
|
Fara fix worker-ul l-ar auto-trimite desi contul e Auto OFF.
|
||||||
|
"""
|
||||||
|
from app.accounts import create_account, set_auto_send
|
||||||
|
from app.submissions_admin import requeue_submission
|
||||||
|
|
||||||
|
conn = env
|
||||||
|
acct = create_account(conn, "Service AutoOff", active=True)
|
||||||
|
set_auto_send(conn, acct, False) # cont Auto OFF
|
||||||
|
sid = _insert_error(conn, acct, held=0) # rand cu held vechi (de pe Auto ON)
|
||||||
|
|
||||||
|
requeue_submission(conn, acct, sid)
|
||||||
|
|
||||||
|
assert _held(conn, sid) == 1, "rand repus pe cont Auto OFF trebuie TINUT (held=1)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_requeue_elibereaza_randul_cand_cont_auto_on(env):
|
||||||
|
"""Auto ON + rand held=1 (ingerat pe Auto OFF) -> requeue re-snapshot -> held=0.
|
||||||
|
|
||||||
|
Altfel randul ar ramane blocat (worker sare peste held=1) dupa ce contul e Auto ON.
|
||||||
|
"""
|
||||||
|
from app.accounts import create_account, set_auto_send
|
||||||
|
from app.submissions_admin import requeue_submission
|
||||||
|
|
||||||
|
conn = env
|
||||||
|
acct = create_account(conn, "Service AutoOn", active=True)
|
||||||
|
set_auto_send(conn, acct, True) # cont Auto ON
|
||||||
|
sid = _insert_error(conn, acct, held=1) # rand cu held vechi (de pe Auto OFF)
|
||||||
|
|
||||||
|
requeue_submission(conn, acct, sid)
|
||||||
|
|
||||||
|
assert _held(conn, sid) == 0, "rand repus pe cont Auto ON NU trebuie tinut (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