feat(5.6): observabilitate + jurnal aplicatie + lifecycle trimiteri blocate
Implementeaza PRD 5.6 complet (14 stories, TDD). Doua axe:
Lifecycle trimiteri blocate (Val A):
- submissions_admin.py: sterge/repune scoped (404 cross-account inaintea lui 409 stare)
- reactivare dedup peste `error` cu CAS (WHERE id=? AND status='error'), creds noi in
submissions + accounts.rar_creds_enc; worker invalideaza sesiunea RAR la creds proaspete
(JWT 30h vechi nu mai trimite cu parola gresita); camp aditiv `reactivated:true`
- retentie randuri blocate 30z; purge_expired exclude queued/sending; purge_after curatat
la reactivare/requeue
- API DELETE /v1/prezentari/{id} + /repune (200+JSON); UI butoane + bulk + banner actionabil
Observabilitate:
- app/observ.py log_event: dublu canal app_events (DB) + RotatingFileHandler per-proces,
redactare creds/PII la scriere (redact_pii/vin_partial)
- request_id middleware + X-Request-ID pe toate raspunsurile
- handler global excepții -> 500 envelope 6-chei + request_id (traceback doar in jurnal)
- audit cerere API (api_prezentari/api_auth_esuat) + audit worker (rar_login/tranzitii)
- tab "Jurnal" filtrabil scoped (non-admin doar contul sau); retentie jurnal 90z
- rar_error expus in GET /v1/prezentari/{id} (recovery observabil)
pytest -q: 741 passed, 0 failed. Docs: PRD raport VERIFY, contract endpointuri noi, ROADMAP.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
117
app/submissions_admin.py
Normal file
117
app/submissions_admin.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Lifecycle trimiteri blocate: sterge / re-pune in coada (PRD 5.6 US-009).
|
||||
|
||||
Inchide lacuna descoperita live: un rand `error` (creds RAR gresite) ramane altfel
|
||||
permanent si nereparabil. Aceste helpere adauga DOUA tranzitii controlate —
|
||||
stergere de randuri ne-sent si `blocate -> queued` (re-clasificat) — fara a atinge
|
||||
logica de trimitere a worker-ului.
|
||||
|
||||
Invariante (decizii §2 + /autoplan #20):
|
||||
- Opereaza DOAR pe `error`/`needs_data`/`needs_mapping`. `sent` (dovada de trimitere
|
||||
la RAR, audit) si `sending` (lease worker in zbor) sunt INTERZISE.
|
||||
- Scope-ul (apartenenta la cont) se evalueaza INAINTEA starii: un rand inexistent SAU
|
||||
al altui cont -> SubmissionNotFound (404, nu confirmam existenta, B3). Doar pe randuri
|
||||
proprii in stare gresita -> SubmissionStateConflict (409).
|
||||
- Ambele emit eveniment in jurnal (US-003): `submission_sters` / `submission_repus`.
|
||||
|
||||
Functii cu `conn` (persistenta). Apelate din API (US-010) si din web (US-011).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from .mapping import (
|
||||
account_or_default,
|
||||
account_scope_clause,
|
||||
classify_prezentare,
|
||||
load_mapping_meta,
|
||||
)
|
||||
from .observ import log_event
|
||||
|
||||
# Stari pe care le putem sterge / re-pune in coada (ne-sent, ne-in-zbor).
|
||||
_GESTIONABILE = ("error", "needs_data", "needs_mapping")
|
||||
|
||||
|
||||
class SubmissionNotFound(Exception):
|
||||
"""Randul nu exista SAU apartine altui cont (acelasi mesaj — nu confirmam existenta)."""
|
||||
|
||||
|
||||
class SubmissionStateConflict(Exception):
|
||||
"""Randul exista si e al contului, dar e intr-o stare protejata (sent/sending)."""
|
||||
|
||||
def __init__(self, status: str):
|
||||
super().__init__(f"stare protejata: {status}")
|
||||
self.status = status
|
||||
|
||||
|
||||
def _fetch_scoped(conn, account_id: int, sid: int):
|
||||
scope_sql, scope_params = account_scope_clause(account_id)
|
||||
return conn.execute(
|
||||
f"SELECT * FROM submissions WHERE id=? AND {scope_sql}",
|
||||
[sid] + scope_params,
|
||||
).fetchone()
|
||||
|
||||
|
||||
def delete_submission(conn, account_id: int, sid: int) -> dict:
|
||||
"""Sterge un rand ne-sent al contului. Ridica SubmissionNotFound / SubmissionStateConflict.
|
||||
|
||||
Intoarce {"submission_id", "status_anterior"} la succes.
|
||||
"""
|
||||
row = _fetch_scoped(conn, account_id, sid)
|
||||
if row is None:
|
||||
raise SubmissionNotFound()
|
||||
status = row["status"]
|
||||
if status not in _GESTIONABILE:
|
||||
raise SubmissionStateConflict(status)
|
||||
conn.execute("DELETE FROM submissions WHERE id=?", (sid,))
|
||||
log_event(
|
||||
"submission_sters",
|
||||
account_id=account_or_default(account_id),
|
||||
mesaj=f"trimitere #{sid} stearsa din {status}",
|
||||
context={"submission_id": sid, "status_anterior": status},
|
||||
conn=conn,
|
||||
)
|
||||
return {"submission_id": sid, "status_anterior": status}
|
||||
|
||||
|
||||
def requeue_submission(conn, account_id: int, sid: int) -> dict:
|
||||
"""Re-pune in coada un rand blocat al contului: re-ruleaza classify pe payload.
|
||||
|
||||
`error -> queued` (cand continutul e valid) sau ramane `needs_data`/`needs_mapping`
|
||||
daca clasificarea o cere. Reseteaza retry_count/next_attempt_at/sending_since si
|
||||
CURATA `purge_after` (randul redevine activ, nu mai e candidat la purjare — US-013).
|
||||
Ridica SubmissionNotFound / SubmissionStateConflict. Intoarce
|
||||
{"submission_id", "status_anterior", "status_nou"}.
|
||||
"""
|
||||
row = _fetch_scoped(conn, account_id, sid)
|
||||
if row is None:
|
||||
raise SubmissionNotFound()
|
||||
status = row["status"]
|
||||
if status not in _GESTIONABILE:
|
||||
raise SubmissionStateConflict(status)
|
||||
|
||||
try:
|
||||
content = json.loads(row["payload_json"]) if row["payload_json"] else {}
|
||||
if not isinstance(content, dict):
|
||||
content = {}
|
||||
except (ValueError, TypeError):
|
||||
content = {}
|
||||
|
||||
mapping_meta = load_mapping_meta(conn, account_id)
|
||||
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
|
||||
cl = classify_prezentare(content, mapping, mapping_meta)
|
||||
|
||||
conn.execute(
|
||||
"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') "
|
||||
"WHERE id=?",
|
||||
(cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], sid),
|
||||
)
|
||||
log_event(
|
||||
"submission_repus",
|
||||
account_id=account_or_default(account_id),
|
||||
mesaj=f"trimitere #{sid} re-pusa: {status} -> {cl['status']}",
|
||||
context={"submission_id": sid, "status_anterior": status, "status_nou": cl["status"]},
|
||||
conn=conn,
|
||||
)
|
||||
return {"submission_id": sid, "status_anterior": status, "status_nou": cl["status"]}
|
||||
Reference in New Issue
Block a user