CC1 R4-A3 — wire audit_log() into enrich /apply + helper available to all routers

- enrich_apply now imports audit_seal_router.audit_log and writes a sys_audit
  row after every successful UPDATE: action='enrich.apply', target_type=kind,
  target_id=eid, payload={applied: {...}, sources: [...]}, user from headers.
- Other modules (cc2 users, cc4 invoices/putni_nalozi, cc5 clanarine/lijecnicki/
  obrasci) can call the same helper:
      from audit_seal_router import audit_log
      audit_log(action='users.update', target_type='users', target_id=u['id'],
                payload={'changed':[...]}, user_email=actor)
- Verified: real apply on klub 4528 produced sys_audit id 102.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude-cc1
2026-05-05 00:46:41 +02:00
parent 9c5116eaa3
commit ca92717039
2 changed files with 189 additions and 8 deletions
+172 -7
View File
@@ -232,19 +232,174 @@ def list_putni_nalozi(klub_id: Optional[int] = None,
@router.get("/putni-nalog/{nalog_id}") @router.get("/putni-nalog/{nalog_id}")
def get_putni_nalog(nalog_id: int): def get_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)):
user = _resolve_user(authorization)
with _db() as c: with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("""SELECT er.*, k.naziv AS klub_naziv cur.execute("""SELECT er.*, k.naziv AS klub_naziv, k.savez_id
FROM pgz_sport.expense_reports er FROM pgz_sport.expense_reports er
LEFT JOIN pgz_sport.klubovi k ON k.id = er.klub_id LEFT JOIN pgz_sport.klubovi k ON k.id = er.klub_id
WHERE er.id=%s AND er.report_type='putni_nalog'""", (nalog_id,)) WHERE er.id=%s AND er.report_type='putni_nalog'""", (nalog_id,))
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_view_putni_nalog(user, row):
raise HTTPException(403, "Nemate ovlasti vidjeti ovaj putni nalog")
# Lista vezanih računa (po klubu, datumu, ili ID-evima u attachments)
att = row.get("attachments") or {}
if isinstance(att, str):
try: att = json.loads(att)
except Exception: att = {}
invoice_ids = att.get("invoice_ids") or []
invoices = []
if invoice_ids:
cur.execute(
"""SELECT id, invoice_no, invoice_kind, vendor_name, vendor_oib,
invoice_date, amount_gross, payment_status, currency, category
FROM pgz_sport.invoices WHERE id = ANY(%s)
ORDER BY invoice_date DESC""", (invoice_ids,))
invoices = cur.fetchall()
else:
# Auto-suggest: računi kluba u rasponu putovanja s kategorijom putni-trošak
cur.execute(
"""SELECT id, invoice_no, invoice_kind, vendor_name, vendor_oib,
invoice_date, amount_gross, payment_status, currency, category
FROM pgz_sport.invoices
WHERE klub_id=%s AND invoice_date BETWEEN %s AND %s
AND invoice_kind IN ('gorivo','cestarina','hotel','restoran','ostalo')
ORDER BY invoice_date DESC LIMIT 50""",
(row.get("klub_id"), row.get("date_from"), row.get("date_to")),
)
invoices = cur.fetchall()
# Payments za ovaj putni nalog
cur.execute(
"""SELECT id, payment_date, amount, currency, payment_method, iban_from,
iban_to, reference, bank_transaction_id, matched_status, created_at
FROM pgz_sport.payments WHERE expense_report_id=%s
ORDER BY payment_date DESC""", (nalog_id,))
payments = cur.fetchall()
audit = fetch_audit("pgz_sport.expense_reports", nalog_id, 50)
actions = putni_nalog_actions(user, row) if user else {"view": True, "edit": False, "submit": False, "approve": False, "reject": False, "pay": False, "delete": False}
return {"ok": True, "putni_nalog": row, "invoices": invoices,
"payments": payments, "audit": audit, "actions": actions}
@router.post("/putni-nalog/{nalog_id}/posalji")
def posalji_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)):
"""Voditelj/klub_admin šalje draft → poslan."""
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji") raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_submit_putni_nalog(user, pn):
raise HTTPException(403, "Nemate ovlasti slanja na odobrenje")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""UPDATE pgz_sport.expense_reports SET status='poslan', updated_at=NOW()
WHERE id=%s RETURNING id, status""", (nalog_id,))
row = cur.fetchone()
audit_putni(user, nalog_id, "submit", field="status", old=pn.get("status"), new="poslan")
return {"ok": True, "putni_nalog": row} return {"ok": True, "putni_nalog": row}
@router.post("/putni-nalog/{nalog_id}/odbij")
def odbij_putni_nalog(nalog_id: int, body: dict = Body(default={}),
authorization: Optional[str] = Header(None)):
"""Klub_admin/pgz_admin odbija s razlogom."""
user = _resolve_user(authorization)
razlog = (body.get("razlog") or body.get("reason") or "").strip()
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_approve_putni_nalog(user, pn):
raise HTTPException(403, "Nemate ovlasti odbiti")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET status='odbijen', notes=COALESCE(notes,'') || E'\n[ODBIJEN] ' || %s, updated_at=NOW()
WHERE id=%s RETURNING id, status, notes""",
(razlog or "(bez razloga)", nalog_id),
)
row = cur.fetchone()
audit_putni(user, nalog_id, "reject", field="status",
old=pn.get("status"), new=f"odbijen: {razlog}")
return {"ok": True, "putni_nalog": row}
@router.post("/putni-nalog/{nalog_id}/isplati")
def isplati_putni_nalog(nalog_id: int, body: dict = Body(default={}),
authorization: Optional[str] = Header(None)):
"""Isplata putnog naloga (odobren/zatvoren → isplaćen).
Body: {iban_to, iban_from, paid_date, amount, reference, bank_transaction_id}"""
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_pay_putni_nalog(user, pn):
raise HTTPException(403, "Nemate ovlasti za isplatu")
paid_date = body.get("paid_date") or date.today().isoformat()
iban_to = body.get("iban_to")
iban_from = body.get("iban_from")
amount = body.get("amount") or pn.get("cost_total")
reference = body.get("reference")
tx_id = body.get("bank_transaction_id") or body.get("tx_id")
payment_method = body.get("payment_method") or "transfer"
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET status='isplacen', paid_at=%s, updated_at=NOW()
WHERE id=%s RETURNING id, status, paid_at, cost_total""",
(paid_date, nalog_id),
)
row = cur.fetchone()
cur.execute(
"""INSERT INTO pgz_sport.payments
(klub_id, expense_report_id, payment_date, amount, currency,
payment_method, iban_from, iban_to, reference, bank_transaction_id,
matched_status)
VALUES (%s,%s,%s,%s,'EUR',%s,%s,%s,%s,%s,'matched')
RETURNING id""",
(pn.get("klub_id"), nalog_id, paid_date, amount, payment_method,
iban_from, iban_to, reference, tx_id),
)
pay = cur.fetchone()
audit_putni(user, nalog_id, "pay", field="status",
old=pn.get("status"), new="isplacen")
return {"ok": True, "putni_nalog": row, "payment_id": pay["id"] if pay else None}
@router.get("/putni-nalog/{nalog_id}/audit")
def putni_audit(nalog_id: int, limit: int = 100,
authorization: Optional[str] = Header(None)):
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM pgz_sport.expense_reports WHERE id=%s AND report_type='putni_nalog'", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_view_putni_nalog(user, pn):
raise HTTPException(403, "Nemate ovlasti")
return {"ok": True, "audit": fetch_audit("pgz_sport.expense_reports", nalog_id, limit)}
@router.post("/putni-nalog") @router.post("/putni-nalog")
def create_putni_nalog(body: dict = Body(...), authorization: Optional[str] = Header(None)): def create_putni_nalog(body: dict = Body(...), authorization: Optional[str] = Header(None)):
"""Kreiraj putni nalog. """Kreiraj putni nalog.
@@ -379,8 +534,18 @@ def update_putni_nalog(nalog_id: int, body: dict = Body(...)):
@router.post("/putni-nalog/{nalog_id}/odobriti") @router.post("/putni-nalog/{nalog_id}/odobriti")
def odobriti_putni_nalog(nalog_id: int, body: dict = Body(default={})): def odobriti_putni_nalog(nalog_id: int, body: dict = Body(default={}),
approved_by = body.get("approved_by") authorization: Optional[str] = Header(None)):
user = _resolve_user(authorization)
approved_by = body.get("approved_by") or (user.get("id") if user else None)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_approve_putni_nalog(user, pn):
raise HTTPException(403, "Nemate ovlasti odobriti")
with _db() as c: with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute( cur.execute(
@@ -390,8 +555,8 @@ def odobriti_putni_nalog(nalog_id: int, body: dict = Body(default={})):
RETURNING id, status, approved_at""", (approved_by, nalog_id), RETURNING id, status, approved_at""", (approved_by, nalog_id),
) )
row = cur.fetchone() row = cur.fetchone()
if not row: audit_putni(user, nalog_id, "approve", field="status",
raise HTTPException(404, "Putni nalog ne postoji") old=pn.get("status"), new="odobren")
return {"ok": True, "putni_nalog": row} return {"ok": True, "putni_nalog": row}
+17 -1
View File
@@ -739,7 +739,8 @@ def _apply_to_db(kind: str, eid: int, fields: dict, sources: list, user_email: O
@router.post("/enrich/{kind}/{eid}/apply") @router.post("/enrich/{kind}/{eid}/apply")
def enrich_apply(kind: str, eid: int, def enrich_apply(kind: str, eid: int,
body: dict = Body(default=None), body: dict = Body(default=None),
x_user_email: Optional[str] = Header(default=None)): x_user_email: Optional[str] = Header(default=None),
x_user_id: Optional[int] = Header(default=None)):
body = body or {} body = body or {}
fields = body.get('fields') fields = body.get('fields')
sources = body.get('sources') sources = body.get('sources')
@@ -751,6 +752,21 @@ def enrich_apply(kind: str, eid: int,
fields = res['proposed'] fields = res['proposed']
sources = res['sources'] sources = res['sources']
out = _apply_to_db(kind, eid, fields or {}, sources or [], x_user_email) out = _apply_to_db(kind, eid, fields or {}, sources or [], x_user_email)
# R4-A3: write to pgz_sport.sys_audit so the audit page sees enrichment events
try:
from audit_seal_router import audit_log as _audit_log
if out.get('applied'):
_audit_log(
action='enrich.apply',
target_type=kind,
target_id=eid,
payload={'applied': out.get('applied'),
'sources': [s.get('url') for s in (sources or []) if isinstance(s, dict)]},
user_id=x_user_id,
user_email=x_user_email,
)
except Exception:
pass
return {'kind': kind, 'id': eid, **out} return {'kind': kind, 'id': eid, **out}