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

@@ -49,11 +49,17 @@ from ..api.v1.import_router import (
)
from ..config import get_settings
from ..crypto import decrypt_creds, encrypt_creds
from ..db import get_connection, read_heartbeat
from ..db import get_connection, read_app_events, read_heartbeat
from ..idempotency import build_key, canonicalize_row
from ..validation import validate_prezentare
from ..import_parse import FileTooLarge, HeaderError, MultipleSheets, parse_date_value, parse_file
from ..users import is_account_admin
from ..submissions_admin import (
SubmissionNotFound,
SubmissionStateConflict,
delete_submission,
requeue_submission,
)
from ..mapping import (
DEFAULT_ACCOUNT_ID,
account_or_default,
@@ -138,7 +144,7 @@ def _rar_state(hb, worker_alive: bool) -> str:
# cade pe Acasa (tab invalid -> fallback "acasa" in dashboard()), fara 404.
# US-003 (3.6): "coada" (Trimiteri) nu mai e tab — Trimiterile sunt sectiune pe Acasa.
# ?tab=coada cade tot pe Acasa (fallback), fara 404, fara fragment orfan.
_TABS_VALIDE = {"acasa", "mapari", "cont", "nomenclator", "integrare"}
_TABS_VALIDE = {"acasa", "mapari", "cont", "nomenclator", "integrare", "jurnal"}
def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
@@ -186,13 +192,18 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
}
def _render_panel_acasa(request: Request, conn=None, account_id: int = 1) -> str:
"""Randeaza panoul Acasa ca string HTML."""
def _render_panel_acasa(request: Request, conn=None, account_id: int = 1, status: str | None = None) -> str:
"""Randeaza panoul Acasa ca string HTML.
`status` (US-014/T13): deep-link `?tab=acasa&status=error` pre-selecteaza filtrul de
stare in sectiunea Trimiteri, astfel ca lista se incarca direct filtrata (nu dead-end).
"""
if conn is None:
return templates.get_template("_acasa.html").render(
{"request": request, "csrf_token": get_csrf_token(request)}
)
ctx = _get_acasa_context(request, conn, account_id)
ctx["status_filtru"] = status
return templates.get_template("_acasa.html").render(ctx)
@@ -286,10 +297,88 @@ def _render_integrare(request: Request, conn, account_id: int) -> str:
})
def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str) -> str:
_JURNAL_PAGE_SIZE = 50
def _jurnal_context(
request: Request, conn, account_id: int, *,
tip: str | None = None, nivel: str | None = None,
data_de: str | None = None, data_pana: str | None = None,
cont: str | None = None, page: int = 0,
) -> dict:
"""Context pentru tab-ul Jurnal (US-006): evenimente paginate + filtre + scope.
Admin -> vede TOT, cu filtru optional pe cont. Non-admin -> DOAR evenimentele
contului sau (regula NULL->cont 1, ca restul UI-ului). Decizie §5.
"""
admin = is_account_admin(conn, account_id)
tip = (tip or "").strip() or None
nivel = (nivel or "").strip() or None
data_de = (data_de or "").strip() or None
data_pana = (data_pana or "").strip() or None
page = max(0, page)
if admin:
cont_filtru = None
if cont and str(cont).strip():
try:
cont_filtru = int(cont)
except (ValueError, TypeError):
cont_filtru = None
scope_account = cont_filtru # None = toate conturile
else:
scope_account = account_or_default(account_id)
offset = page * _JURNAL_PAGE_SIZE
rows = read_app_events(
conn, account_id=scope_account, tip=tip, nivel=nivel,
date_from=data_de, date_to=data_pana,
limit=_JURNAL_PAGE_SIZE + 1, offset=offset,
)
has_more = len(rows) > _JURNAL_PAGE_SIZE
rows = rows[:_JURNAL_PAGE_SIZE]
evenimente = []
for r in rows:
evenimente.append({
"ts": format_data_rar(r["ts"]),
"sursa": r["sursa"],
"tip": r["tip"],
"nivel": r["nivel"],
"account_id": r["account_id"],
"cod": r["cod"],
"mesaj": r["mesaj"],
})
tipuri = [r["tip"] for r in conn.execute("SELECT DISTINCT tip FROM app_events ORDER BY tip").fetchall()]
return {
"request": request,
"evenimente": evenimente,
"tipuri": tipuri,
"is_admin": admin,
"f_tip": tip or "",
"f_nivel": nivel or "",
"f_data_de": data_de or "",
"f_data_pana": data_pana or "",
"f_cont": (cont or "") if admin else "",
"page": page,
"has_more": has_more,
"prev_page": page - 1 if page > 0 else None,
"next_page": page + 1 if has_more else None,
}
def _render_panel_jurnal(request: Request, conn, account_id: int) -> str:
"""Randeaza panoul Jurnal ca string HTML (US-006)."""
return templates.get_template("_jurnal.html").render(_jurnal_context(request, conn, account_id))
def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str, status: str | None = None) -> str:
"""Randeaza panoul corespunzator unui tab ca string HTML."""
if tab == "acasa":
return _render_panel_acasa(request, conn, account_id)
return _render_panel_acasa(request, conn, account_id, status=status)
if tab == "jurnal":
return _render_panel_jurnal(request, conn, account_id)
if tab == "import":
return _render_panel_import(request)
if tab == "coada":
@@ -306,18 +395,19 @@ def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str) ->
@router.get("/", response_class=HTMLResponse)
def dashboard(request: Request, tab: str = "acasa") -> HTMLResponse:
def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -> HTMLResponse:
"""Dashboard principal cu tab-uri (US-003).
Parametrul ?tab= permite deep-link pe orice sectiune; panoul activ e randat
server-side la full load (fara palpaiere la refresh, degradare gratiosa fara JS).
Tab invalid -> fallback la 'acasa'.
Tab invalid -> fallback la 'acasa'. `?status=` (US-014/T13) pre-filtreaza lista
Trimiteri de pe Acasa (deep-link din banner-ul "Necesita atentia ta").
"""
account_id = require_login(request)
active_tab = tab if tab in _TABS_VALIDE else "acasa"
conn = get_connection()
try:
panel_html = _render_panel_for_tab(request, conn, account_id, active_tab)
panel_html = _render_panel_for_tab(request, conn, account_id, active_tab, status=status)
# Badge contoare pe tab-uri (US-011): needs_mapping -> Mapari. Blocatele
# (fost badge "coada") se reflecta acum in heading-ul sectiunii Trimiteri (US-003).
counts = _status_counts(conn, account_id)
@@ -400,6 +490,32 @@ def fragment_integrare(request: Request) -> HTMLResponse:
conn.close()
@router.get("/_fragments/jurnal", response_class=HTMLResponse)
def fragment_jurnal(
request: Request,
tip: str | None = None,
nivel: str | None = None,
data_de: str | None = None,
data_pana: str | None = None,
cont: str | None = None,
page: int = 0,
) -> HTMLResponse:
"""Tab Jurnal (US-006): evenimente app_events paginate + filtre, scoped pe cont.
Admin vede tot (filtru optional pe cont); non-admin doar evenimentele proprii.
"""
account_id = require_login(request)
conn = get_connection()
try:
ctx = _jurnal_context(
request, conn, account_id,
tip=tip, nivel=nivel, data_de=data_de, data_pana=data_pana, cont=cont, page=page,
)
return templates.TemplateResponse("_jurnal.html", ctx)
finally:
conn.close()
@router.get("/_fragments/banner", response_class=HTMLResponse)
def fragment_banner(request: Request) -> HTMLResponse:
account_id = require_login(request)
@@ -430,6 +546,45 @@ def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]:
return rezultat
# Cate randuri blocate identificam nominal sub fiecare categorie din banner (US-014).
_BLOCATE_SAMPLE = 3
def _blocate_actionabil(conn, account_id: int) -> list[dict]:
"""Categorii blocate cu identificatorii primelor randuri + deep-link (US-014).
Pentru fiecare stare blocata cu n>0: eticheta umana, contorul, primii N identificatori
(VIN partial + nr inmatriculare + #id — PII doar partial, ca jurnalul) si cati raman.
Scoped pe cont (regula NULL->1). Lista goala -> banner-ul nu se randeaza (se stinge).
"""
from ..security import vin_partial
scope_sql, scope_params = account_scope_clause(account_id)
out: list[dict] = []
for status in ("needs_mapping", "needs_data", "error"):
rows = conn.execute(
f"SELECT id, payload_json FROM submissions WHERE {scope_sql} AND status=? ORDER BY id DESC",
scope_params + [status],
).fetchall()
if not rows:
continue
sample = []
for r in rows[:_BLOCATE_SAMPLE]:
prez = prezentare_din_payload(r["payload_json"])
sample.append({
"id": r["id"],
"vin": vin_partial(prez.get("vin") or ""),
"nr": prez.get("vehicul_nr") or "",
})
out.append({
"status": status,
"eticheta": eticheta_stare(status),
"n": len(rows),
"randuri": sample,
"rest": max(0, len(rows) - len(sample)),
})
return out
@router.get("/_fragments/status", response_class=HTMLResponse)
def fragment_status(request: Request) -> HTMLResponse:
"""Bara de status persistenta cu etichete umane (US-002, PRD 3.4).
@@ -466,6 +621,7 @@ def fragment_status(request: Request) -> HTMLResponse:
"counts_sent": counts.get("sent", 0),
"blocate_total": blocate_total,
"blocate_defalcat": _blocate_defalcat(counts),
"blocate_actionabil": _blocate_actionabil(conn, account_id),
"account_active": _account_active(conn, account_id),
})
finally:
@@ -496,6 +652,9 @@ def _submission_row_view(r) -> dict:
"id_prezentare": r["id_prezentare"],
"updated_at": format_data_rar(r["updated_at"]),
"motiv": motiv_uman(r["status"], r["rar_error"]),
# US-011: randurile blocate (error/needs_data/needs_mapping) sunt selectabile
# pentru stergere bulk; sent/sending/queued raman read-only (fara checkbox).
"gestionabil": r["status"] in _GESTIONABILE_WEB,
}
@@ -566,6 +725,7 @@ def fragment_submissions(
"request": request,
"rows": view,
"filtru_activ": filtru_activ,
"csrf_token": get_csrf_token(request),
})
finally:
conn.close()
@@ -573,6 +733,25 @@ def fragment_submissions(
# Stari ne-trimise blocate pe care le putem corecta inline (US-010).
_CORECTABILE = ("needs_data", "needs_mapping")
# Stari gestionabile prin lifecycle web (US-011): sterge / re-pune in coada.
_GESTIONABILE_WEB = ("error", "needs_data", "needs_mapping")
def _render_submissions(request: Request, conn, account_id: int) -> HTMLResponse:
"""Re-randeaza lista Trimiteri (fara filtre) — folosit dupa actiuni bulk (US-011)."""
scope_sql, scope_params = account_scope_clause(account_id)
rows = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
f"updated_at, payload_json FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT 200",
scope_params,
).fetchall()
view = [_submission_row_view(r) for r in rows]
return templates.TemplateResponse("_submissions.html", {
"request": request,
"rows": view,
"filtru_activ": False,
"csrf_token": get_csrf_token(request),
})
def _payload_form_values(payload_json) -> dict:
@@ -616,6 +795,8 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
"next_attempt_at": format_data_rar(row["next_attempt_at"]),
# randuri ne-trimise blocate sunt corectabile (US-010); sent/sending nu
"editabil": row["status"] in _CORECTABILE,
# US-011: error/needs_data/needs_mapping pot fi sterse / re-puse in coada
"gestionabil": row["status"] in _GESTIONABILE_WEB,
"corectie_msg": message,
"corectie_error": error,
"corectie_errors": corectie_errors or [],
@@ -792,6 +973,92 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
conn.close()
# =========================================================================== #
# US-011 — Lifecycle trimiteri blocate din dashboard: sterge / re-pune in coada #
# Peste helper-ul US-009 (submissions_admin). CSRF enforce; scoped pe sesiune. #
# =========================================================================== #
@router.post("/trimitere/{submission_id}/repune", response_class=HTMLResponse)
async def post_repune_trimitere(request: Request, submission_id: int) -> HTMLResponse:
"""Re-pune in coada un rand blocat (error/needs_data/needs_mapping) din dashboard.
Scoped pe sesiune (404 cross-account/inexistent, 409 sent/sending). Re-randeaza
panoul de detaliu cu starea noua + nudge `trimiteriChanged` pentru lista.
"""
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
conn = get_connection()
try:
try:
requeue_submission(conn, account_id, submission_id)
except SubmissionNotFound:
raise HTTPException(status_code=404, detail="trimitere inexistenta")
except SubmissionStateConflict:
raise HTTPException(status_code=409, detail="trimitere read-only (deja procesata)")
row = _fetch_submission_scoped(conn, account_id, submission_id)
resp = templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(request, row, message="Re-pus in coada — pleaca la urmatoarea trimitere."),
)
resp.headers["HX-Trigger"] = "trimiteriChanged"
return resp
finally:
conn.close()
@router.post("/trimitere/{submission_id}/sterge", response_class=HTMLResponse)
async def post_sterge_trimitere(request: Request, submission_id: int) -> HTMLResponse:
"""Sterge un rand blocat din dashboard. Scoped pe sesiune; sent/sending interzis (409)."""
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
conn = get_connection()
try:
try:
delete_submission(conn, account_id, submission_id)
except SubmissionNotFound:
raise HTTPException(status_code=404, detail="trimitere inexistenta")
except SubmissionStateConflict:
raise HTTPException(status_code=409, detail="trimitere read-only (deja procesata)")
resp = HTMLResponse(
'<div class="flash" style="margin:0;">Trimitere stearsa.</div>'
)
resp.headers["HX-Trigger"] = "trimiteriChanged"
return resp
finally:
conn.close()
@router.post("/trimiteri/sterge-bulk", response_class=HTMLResponse)
async def post_sterge_bulk(request: Request) -> HTMLResponse:
"""Sterge in bloc trimiterile selectate (doar blocate, scoped pe sesiune).
Sare peste randuri sent/sending (read-only) si cross-account (inexistente) fara a
opri operatia — pe modelul panoului admin (PRD 5.5). Re-randeaza lista Trimiteri.
"""
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
ids = form.getlist("submission_id")
conn = get_connection()
try:
for raw in ids:
try:
sid = int(str(raw))
except (ValueError, TypeError):
continue
try:
delete_submission(conn, account_id, sid)
except (SubmissionNotFound, SubmissionStateConflict):
continue # doar blocate ale contului; restul sarite
resp = _render_submissions(request, conn, account_id)
resp.headers["HX-Trigger"] = "trimiteriChanged"
return resp
finally:
conn.close()
def _load_saved_op_mappings(conn, account_id: int) -> list[dict]:
"""Mapari operatie->cod salvate (operations_mapping) ale contului, cu numele
prestatiei jonctionat din nomenclator (US-005). Scoped pe cont (NOT NULL → simplu)."""