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:
Claude Agent
2026-06-23 18:45:39 +00:00
parent f48346de5c
commit c842e3352a
40 changed files with 2851 additions and 64 deletions

View File

@@ -41,7 +41,14 @@ from ...models import (
ValidareResponse,
ValidareResult,
)
from ...observ import log_event
from ...payload_view import prezentare_din_payload
from ...submissions_admin import (
SubmissionNotFound,
SubmissionStateConflict,
delete_submission,
requeue_submission,
)
router = APIRouter(prefix="/v1", tags=["v1"])
@@ -93,6 +100,37 @@ def create_prezentari(
(key,),
).fetchone()
if existing:
# US-012: un rand `error` (ex. creds RAR gresite) NU mai blocheaza tacit
# retrimiterea aceluiasi continut. Il RE-ACTIVAM (re-clasificam + actualizam
# creds + reset), printr-un UPDATE compare-and-swap pe status='error'.
if existing["status"] == "error":
cl = classify_prezentare(content, mapping, mapping_meta)
cur = conn.execute(
"UPDATE submissions SET status=?, payload_json=?, rar_error=?, "
"rar_creds_enc=COALESCE(?, rar_creds_enc), retry_count=0, "
"next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, "
"updated_at=datetime('now') WHERE id=? AND status='error'",
(cl["status"], json.dumps(cl["content"], ensure_ascii=False),
cl["rar_error"], creds_enc, existing["id"]),
)
if cur.rowcount == 1:
# Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc,
# decizie #17) — ambele canale converg pe parola corectata.
if req.rar_credentials is not None:
conn.execute(
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
(encrypt_creds(req.rar_credentials.model_dump()), acct),
)
results.append(SubmissionResult(
submission_id=existing["id"], status=cl["status"], reactivated=True,
))
continue
# Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE
# (rowcount==0) -> raspuns dedup pe starea CURENTA.
existing = conn.execute(
"SELECT id, status, id_prezentare FROM submissions WHERE id=?",
(existing["id"],),
).fetchone()
results.append(
SubmissionResult(
submission_id=existing["id"],
@@ -112,6 +150,25 @@ def create_prezentari(
(key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc),
)
results.append(SubmissionResult(submission_id=int(cur.lastrowid), status=cl["status"]))
# US-004: audit cerere API per cont. Doar metadate (count + distributie status),
# NICIUN camp de payload PII integral. Reuse conn (T4 — fara contentie WAL).
dist: dict[str, int] = {}
for r in results:
if r.reactivated:
cheie = "reactivated"
elif r.deduped:
cheie = "deduped"
else:
cheie = r.status
dist[cheie] = dist.get(cheie, 0) + 1
log_event(
"api_prezentari",
account_id=acct,
mesaj=f"{len(results)} prezentari procesate",
context={"count": len(results), "distributie": dist},
conn=conn,
)
finally:
conn.close()
return PrezentariResponse(results=results)
@@ -199,6 +256,10 @@ _PREZENTARE_FIELDS = frozenset({
"id", "status", "id_prezentare", "rar_status_code", "retry_count",
"next_attempt_at", "created_at", "updated_at", "account_id",
"batch_id", "row_index", "purge_after",
# T9: rar_error e SIGUR de expus — contine doar coduri/mesaje de validare RAR si
# erori din catalog (niciodata creds, ex. RAR_CREDS_INVALIDE poarta doar cauza
# "credentiale RAR invalide", fara parola). Face recovery-ul observabil prin API.
"rar_error",
})
@@ -224,6 +285,59 @@ def get_prezentare(
conn.close()
@router.delete("/prezentari/{submission_id}")
def delete_prezentare(
submission_id: int,
account_id: int = Depends(resolve_account_id),
) -> dict:
"""Sterge o trimitere blocata a contului cheii API (US-010).
Raspuns 200 + body JSON (NU 204 — clienti VFP fac string-parse). Scope evaluat
INAINTEA starii (decizie /autoplan #20): cross-account / inexistent -> 404 (acelasi
mesaj, B3); own-account `sent`/`sending` -> 409 (conflict de stare).
"""
conn = get_connection()
try:
try:
res = delete_submission(conn, account_id, submission_id)
except SubmissionNotFound:
raise HTTPException(status_code=404, detail="submission inexistent")
except SubmissionStateConflict as exc:
raise HTTPException(
status_code=409,
detail=f"trimiterea nu se poate sterge in starea '{exc.status}'",
)
return {"ok": True, **res}
finally:
conn.close()
@router.post("/prezentari/{submission_id}/repune")
def repune_prezentare(
submission_id: int,
account_id: int = Depends(resolve_account_id),
) -> dict:
"""Re-pune in coada o trimitere blocata a contului cheii API (US-010).
`error -> queued` (peste helper US-009), re-ruleaza classify. Acelasi oracol de
scope/stare ca DELETE (404 cross-account/inexistent, 409 sent/sending).
"""
conn = get_connection()
try:
try:
res = requeue_submission(conn, account_id, submission_id)
except SubmissionNotFound:
raise HTTPException(status_code=404, detail="submission inexistent")
except SubmissionStateConflict as exc:
raise HTTPException(
status_code=409,
detail=f"trimiterea nu se poate re-pune in starea '{exc.status}'",
)
return {"ok": True, **res}
finally:
conn.close()
@router.get("/nomenclator")
def get_nomenclator() -> dict:
conn = get_connection()