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:
@@ -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)."""
|
||||
|
||||
Reference in New Issue
Block a user