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:
33
app/web/middleware.py
Normal file
33
app/web/middleware.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Middleware HTTP: request_id per cerere (PRD 5.6 US-002).
|
||||
|
||||
Fiecare raspuns primeste un header `X-Request-ID` (generat daca clientul nu trimite
|
||||
unul). Pe durata cererii, id-ul e disponibil prin `observ.request_id_var` (contextvar)
|
||||
in handlerul de erori (US-001) si in `log_event` (US-003) — fara a polua semnaturile.
|
||||
|
||||
Format opac, fara PII: `secrets.token_hex(8)` (16 hex). Daca clientul trimite un
|
||||
`X-Request-ID`, il pastram (corelare end-to-end), dar il scurtam defensiv (max 64).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
|
||||
from ..observ import request_id_var
|
||||
|
||||
|
||||
class RequestIDMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
incoming = request.headers.get("X-Request-ID")
|
||||
request_id = (incoming.strip()[:64] if incoming and incoming.strip() else secrets.token_hex(8))
|
||||
token = request_id_var.set(request_id)
|
||||
# Expune si pe request.state pentru handlerele care prefera accesul explicit.
|
||||
request.state.request_id = request_id
|
||||
try:
|
||||
response = await call_next(request)
|
||||
finally:
|
||||
request_id_var.reset(token)
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
return response
|
||||
@@ -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)."""
|
||||
|
||||
@@ -30,14 +30,17 @@
|
||||
style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;">
|
||||
<div>
|
||||
<label for="f-status" class="muted" style="display:block; font-size:12px;">Stare</label>
|
||||
{# US-014/T13: status_filtru (din deep-link ?tab=acasa&status=) pre-selecteaza
|
||||
starea, iar submissions-wrap (hx-include #filtre-trimiteri) o incarca filtrat. #}
|
||||
{% set sf = status_filtru | default('') %}
|
||||
<select id="f-status" name="status">
|
||||
<option value="">toate</option>
|
||||
<option value="queued">in asteptare</option>
|
||||
<option value="sent">declarate la RAR</option>
|
||||
<option value="needs_mapping">lipsa cod</option>
|
||||
<option value="needs_data">date incomplete</option>
|
||||
<option value="error">eroare</option>
|
||||
<option value="sending">se trimite</option>
|
||||
<option value="" {% if not sf %}selected{% endif %}>toate</option>
|
||||
<option value="queued" {% if sf == 'queued' %}selected{% endif %}>in asteptare</option>
|
||||
<option value="sent" {% if sf == 'sent' %}selected{% endif %}>declarate la RAR</option>
|
||||
<option value="needs_mapping" {% if sf == 'needs_mapping' %}selected{% endif %}>lipsa cod</option>
|
||||
<option value="needs_data" {% if sf == 'needs_data' %}selected{% endif %}>date incomplete</option>
|
||||
<option value="error" {% if sf == 'error' %}selected{% endif %}>eroare</option>
|
||||
<option value="sending" {% if sf == 'sending' %}selected{% endif %}>se trimite</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -57,7 +60,8 @@
|
||||
|
||||
<!-- Poll aliniat la 15s ca status-ul (M5: nu doua timere perpetue pe pagina mereu deschisa) -->
|
||||
<div id="submissions-wrap"
|
||||
hx-get="/_fragments/submissions" hx-trigger="load, every 15s"
|
||||
hx-get="/_fragments/submissions"
|
||||
hx-trigger="load, every 15s, trimiteriChanged from:body"
|
||||
hx-include="#filtre-trimiteri" hx-swap="innerHTML">
|
||||
<div class="empty">se incarca…</div>
|
||||
</div>
|
||||
|
||||
106
app/web/templates/_jurnal.html
Normal file
106
app/web/templates/_jurnal.html
Normal file
@@ -0,0 +1,106 @@
|
||||
{# _jurnal.html — tab Jurnal de aplicatie (US-006, PRD 5.6).
|
||||
Lista paginata de evenimente (app_events), redactate la scriere. Filtre tip/nivel/
|
||||
data + (admin) cont. Stil consistent cu tabelele PRD 5.5 (.tablewrap). #}
|
||||
<section id="jurnal-section" aria-labelledby="jurnal-heading">
|
||||
<div class="card">
|
||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
|
||||
<h2 id="jurnal-heading" style="font-size:15px; margin:0;">Jurnal de aplicatie</h2>
|
||||
{% if is_admin %}
|
||||
<span class="pill s-sent" style="font-size:11px;">admin: toate conturile</span>
|
||||
{% else %}
|
||||
<span class="muted" style="font-size:12px;">doar evenimentele contului tau</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form id="filtre-jurnal"
|
||||
hx-get="/_fragments/jurnal"
|
||||
hx-target="#jurnal-wrap"
|
||||
hx-swap="innerHTML"
|
||||
hx-trigger="submit, change"
|
||||
style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;">
|
||||
<div>
|
||||
<label for="j-tip" class="muted" style="display:block; font-size:12px;">Tip eveniment</label>
|
||||
<select id="j-tip" name="tip">
|
||||
<option value="">toate</option>
|
||||
{% for t in tipuri %}
|
||||
<option value="{{ t }}" {% if f_tip == t %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="j-nivel" class="muted" style="display:block; font-size:12px;">Nivel</label>
|
||||
<select id="j-nivel" name="nivel">
|
||||
<option value="">toate</option>
|
||||
{% for nv in ("INFO", "WARNING", "ERROR", "CRITICAL", "DEBUG") %}
|
||||
<option value="{{ nv }}" {% if f_nivel == nv %}selected{% endif %}>{{ nv }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="j-data-de" class="muted" style="display:block; font-size:12px;">Data de la</label>
|
||||
<input id="j-data-de" type="date" name="data_de" value="{{ f_data_de }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="j-data-pana" class="muted" style="display:block; font-size:12px;">pana la</label>
|
||||
<input id="j-data-pana" type="date" name="data_pana" value="{{ f_data_pana }}">
|
||||
</div>
|
||||
{% if is_admin %}
|
||||
<div>
|
||||
<label for="j-cont" class="muted" style="display:block; font-size:12px;">Cont (id)</label>
|
||||
<input id="j-cont" type="number" name="cont" value="{{ f_cont }}" placeholder="toate" style="max-width:100px;">
|
||||
</div>
|
||||
{% endif %}
|
||||
<button type="submit">Filtreaza</button>
|
||||
</form>
|
||||
|
||||
<div id="jurnal-wrap">
|
||||
{% if evenimente %}
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Cand</th>
|
||||
<th>Sursa</th>
|
||||
<th>Tip</th>
|
||||
<th>Nivel</th>
|
||||
{% if is_admin %}<th>Cont</th>{% endif %}
|
||||
<th>Cod</th>
|
||||
<th>Mesaj</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for e in evenimente %}
|
||||
<tr>
|
||||
<td class="muted" style="white-space:nowrap;">{{ e.ts }}</td>
|
||||
<td>{{ e.sursa }}</td>
|
||||
<td>{{ e.tip }}</td>
|
||||
<td>
|
||||
<span class="{% if e.nivel in ('ERROR','CRITICAL') %}s-error{% elif e.nivel == 'WARNING' %}s-needs_data{% else %}muted{% endif %}">{{ e.nivel }}</span>
|
||||
</td>
|
||||
{% if is_admin %}<td class="muted">{{ e.account_id if e.account_id is not none else '—' }}</td>{% endif %}
|
||||
<td class="muted">{{ e.cod or '—' }}</td>
|
||||
<td style="white-space:normal; max-width:360px;">{{ e.mesaj or '' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{# Paginare: prev/next pe acelasi set de filtre #}
|
||||
{% if prev_page is not none or next_page is not none %}
|
||||
<div style="display:flex; gap:10px; margin-top:12px; align-items:center;">
|
||||
{% if prev_page is not none %}
|
||||
<a href="#" hx-get="/_fragments/jurnal?page={{ prev_page }}&tip={{ f_tip }}&nivel={{ f_nivel }}&data_de={{ f_data_de }}&data_pana={{ f_data_pana }}&cont={{ f_cont }}"
|
||||
hx-target="#jurnal-wrap" hx-swap="innerHTML">‹ mai noi</a>
|
||||
{% endif %}
|
||||
<span class="muted" style="font-size:12px;">pagina {{ page + 1 }}</span>
|
||||
{% if next_page is not none %}
|
||||
<a href="#" hx-get="/_fragments/jurnal?page={{ next_page }}&tip={{ f_tip }}&nivel={{ f_nivel }}&data_de={{ f_data_de }}&data_pana={{ f_data_pana }}&cont={{ f_cont }}"
|
||||
hx-target="#jurnal-wrap" hx-swap="innerHTML">mai vechi ›</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="empty">Niciun eveniment pe filtrul curent.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -47,21 +47,35 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Defalcare blocate pe motiv (doar daca exista) -->
|
||||
{% if blocate_defalcat %}
|
||||
<!-- Necesita atentia ta (US-014): categorii actionabile — link la lista filtrata
|
||||
+ identificatorii primelor randuri blocate. Se randeaza DOAR daca exista randuri
|
||||
blocate; cand contorul ajunge 0 (sters/re-pus/purjat), sectiunea dispare. -->
|
||||
{% if blocate_actionabil %}
|
||||
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
|
||||
<div style="font-size:13px; font-weight:600; margin-bottom:6px;">Necesita atentia ta</div>
|
||||
<div style="display:flex; gap:16px; flex-wrap:wrap;">
|
||||
{% for eticheta, n in blocate_defalcat %}
|
||||
{% if n > 0 %}
|
||||
<div>
|
||||
<span class="{{ eticheta[2] }}" style="font-size:13px;">{{ eticheta[0] }}</span>
|
||||
<span class="muted" style="font-size:13px; margin-left:4px;">({{ n }})</span>
|
||||
{% if eticheta[1] %}
|
||||
<div class="muted" style="font-size:13px; max-width:240px;">{{ eticheta[1] }}</div>
|
||||
{% endif %}
|
||||
<div style="font-size:13px; font-weight:600; margin-bottom:8px;">Necesita atentia ta</div>
|
||||
<div style="display:flex; gap:18px; flex-wrap:wrap;">
|
||||
{% for cat in blocate_actionabil %}
|
||||
<div style="min-width:200px;">
|
||||
{# Link: filtreaza lista Trimiteri pe aceasta stare (HTMX in-place) cu fallback
|
||||
deep-link server-side (?tab=acasa&status=...). #}
|
||||
<a class="{{ cat.eticheta[2] }}" style="font-size:13px; font-weight:600; text-decoration:none;"
|
||||
href="/?tab=acasa&status={{ cat.status }}"
|
||||
hx-get="/_fragments/submissions?status={{ cat.status }}"
|
||||
hx-target="#submissions-wrap" hx-swap="innerHTML"
|
||||
onclick="var s=document.getElementById('trimiteri-section'); if(s) s.scrollIntoView({behavior:'smooth'});">
|
||||
{{ cat.eticheta[0] }} ({{ cat.n }}) ›
|
||||
</a>
|
||||
<ul style="list-style:none; margin:6px 0 0; padding:0;">
|
||||
{% for r in cat.randuri %}
|
||||
<li class="muted" style="font-size:12px;">
|
||||
#{{ r.id }} {{ r.vin }}{% if r.nr %} / {{ r.nr }}{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if cat.rest %}
|
||||
<li class="muted" style="font-size:12px;">…si inca {{ cat.rest }}</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
{% if rows %}
|
||||
{# US-011: form de stergere bulk. Selectia opereaza DOAR pe randuri blocate
|
||||
(gestionabil); sent/sending/queued nu au checkbox (read-only). #}
|
||||
<form id="bulk-trimiteri"
|
||||
hx-post="/trimiteri/sterge-bulk"
|
||||
hx-target="#submissions-wrap"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Stergi definitiv trimiterile selectate?"
|
||||
hx-disinherit="hx-confirm"
|
||||
style="margin:0;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div style="display:flex; justify-content:flex-end; margin-bottom:8px;">
|
||||
<button type="submit" id="bulk-sterge-btn"
|
||||
style="background:var(--card); color:var(--err); border-color:var(--err); font-size:13px; padding:4px 10px;">
|
||||
Sterge selectate
|
||||
</button>
|
||||
</div>
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th style="width:28px;"><span class="muted" title="Selecteaza randuri blocate">✓</span></th>
|
||||
<th>#</th>
|
||||
<th>Stare</th>
|
||||
<th>Vehicul</th>
|
||||
@@ -19,6 +36,12 @@
|
||||
hx-swap="innerHTML"
|
||||
style="cursor:pointer;"
|
||||
title="Click pentru detaliul complet">
|
||||
<td onclick="event.stopPropagation();">
|
||||
{% if r.gestionabil %}
|
||||
<input type="checkbox" name="submission_id" value="{{ r.id }}"
|
||||
aria-label="Selecteaza trimiterea #{{ r.id }} pentru stergere">
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="muted">{{ r.id }}</td>
|
||||
<td><span class="pill {{ r.stare_css }}">{{ r.stare_text }}</span></td>
|
||||
<td>
|
||||
@@ -37,6 +60,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
{% elif filtru_activ %}
|
||||
<div class="empty">
|
||||
Nimic pe filtrul curent.
|
||||
|
||||
@@ -46,6 +46,25 @@
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{# === Lifecycle (US-011): sterge / re-pune in coada — doar randuri blocate === #}
|
||||
{% if gestionabil %}
|
||||
<div style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line); display:flex; gap:10px; flex-wrap:wrap;">
|
||||
<form hx-post="/trimitere/{{ id }}/repune"
|
||||
hx-target="#trimitere-detaliu" hx-swap="innerHTML" style="margin:0;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">Re-pune in coada</button>
|
||||
</form>
|
||||
<form hx-post="/trimitere/{{ id }}/sterge"
|
||||
hx-target="#trimitere-detaliu" hx-swap="innerHTML"
|
||||
hx-confirm="Stergi definitiv aceasta trimitere din coada?" style="margin:0;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" style="background:var(--card); color:var(--err); border-color:var(--err);">
|
||||
Sterge
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# === Corectie inline (US-010): doar randuri ne-trimise blocate === #}
|
||||
{% if editabil %}
|
||||
{% set err_map = {} %}
|
||||
|
||||
@@ -184,6 +184,7 @@
|
||||
<a role="menuitem" href="/?tab=cont">Cont</a>
|
||||
<a role="menuitem" href="/?tab=integrare">Integrare</a>
|
||||
<a role="menuitem" href="/?tab=nomenclator">Nomenclator</a>
|
||||
<a role="menuitem" href="/?tab=jurnal">Jurnal</a>
|
||||
{% if is_admin|default(false) %}<a role="menuitem" href="/admin">Conturi clienti</a>{% endif %}
|
||||
<hr>
|
||||
<form method="post" action="/logout">
|
||||
|
||||
Reference in New Issue
Block a user