CC2 R6: middleware-wide JWT, avatar demo mode, mock mailer, login rate limit
#1 JWT middleware extended: - Was: /api/admin/* only - Now: any POST/PUT/PATCH/DELETE under /api/* requires Bearer JWT - Whitelist (still anonymous): /api/auth/login, /refresh, /forgot-password, /password/reset, /reset-password, /setup-password, /google; /api/gdpr/consent; any path ending /avatar - 14 mutating endpoints verified to return 401 without token #2 Avatar upload demo mode (routers/clan_panel_router.py): - Anonymous → returns {demo_mode:true, slika_url:null, message:'Demo mode — slika nije spremljena. Prijavite se za pravu pohranu.'}, no FS write, no DB write - Authenticated (valid JWT, allowed role) → real save as before - Auth check now uses auth.auth_v2.decode_token (proper secret + revocation) instead of the broken local _resolve_role #3 Mock mailer (auth/mailer.py): - send_email writes RFC 822 .eml to /tmp/pgz_mailbox + appends to INDEX.jsonl - send_password_reset, send_invite helpers with HR text + HTML alt - Real SMTP active when PGZ_SMTP_HOST is set (env-driven, off by default) - forgot-password and admin invite both call mailer; audit logs mail status #5 Rate limiting on /api/auth/login: - Per-user: 5 wrong attempts → 5-minute DB-backed lockout (was 5 → 15 min). Configurable via PGZ_LOGIN_LOCK_THRESHOLD/MINUTES. - Per-IP: 10 fails / 5-min sliding window in-memory → HTTP 429 Configurable via PGZ_LOGIN_IP_THRESHOLD/WINDOW_SEC. Successful login clears the IP counter. - Failed attempts respond '(N/5) — račun je zaključan na 5 minuta' - New audit actions: login.ratelimit.ip; login.fail meta now includes fails count, locked, lock_minutes #4 Live test report: 46/46 across 6 demo users — login, JWT gate on 14 mutating endpoints, public path whitelist, demo-mode avatar + real save, forgot-password e-mail to mailbox, no-leak unknown email, 5-fail lockout, 423 during lockout, audit coverage.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
# erp/notifications.py — PGŽ Sport ERP mock e-mail notifikacije (R6)
|
||||
# Author: Damir Radulić <damir@rinet.one> / dradulic@outlook.com
|
||||
# Date: 2026-05-04
|
||||
# Description: Mock e-mail / channel='email' notifikacije pri promjeni statusa
|
||||
# ERP entiteta. Upisuje u pgz_sport.notifications + log line.
|
||||
# U produkciji se može zamijeniti pravim SMTP/Mailgun adapterom.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional, Iterable
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
|
||||
password="R1net2026!SecureDB#v7")
|
||||
|
||||
LOG_PATH = os.environ.get("ERP_NOTIFY_LOG", "/var/log/pgz-sport-erp-notify.log")
|
||||
logger = logging.getLogger("erp.notifications")
|
||||
if not logger.handlers:
|
||||
logger.setLevel(logging.INFO)
|
||||
try:
|
||||
fh = logging.FileHandler(LOG_PATH)
|
||||
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
|
||||
logger.addHandler(fh)
|
||||
except Exception:
|
||||
pass
|
||||
sh = logging.StreamHandler()
|
||||
sh.setFormatter(logging.Formatter("[ERP-NOTIFY] %(message)s"))
|
||||
logger.addHandler(sh)
|
||||
|
||||
|
||||
def _db():
|
||||
c = psycopg2.connect(**DB); c.autocommit = True; return c
|
||||
|
||||
|
||||
def _resolve_recipients(klub_id: Optional[int], user_id: Optional[int]) -> list[dict]:
|
||||
"""Vrati listu primatelja: voditelj putovanja (user_id), klub_admin svog kluba,
|
||||
+ pgz_admin kao info kopija."""
|
||||
out: list[dict] = []
|
||||
seen = set()
|
||||
try:
|
||||
with _db() as c:
|
||||
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
if user_id:
|
||||
cur.execute(
|
||||
"SELECT id, email, COALESCE(full_name, ime||' '||prezime, email) AS name "
|
||||
"FROM pgz_sport.users WHERE id=%s AND status='active'", (user_id,))
|
||||
r = cur.fetchone()
|
||||
if r and r["email"] and r["id"] not in seen:
|
||||
out.append({**r, "rola": "voditelj"}); seen.add(r["id"])
|
||||
if klub_id:
|
||||
cur.execute(
|
||||
"""SELECT id, email, COALESCE(full_name, ime||' '||prezime, email) AS name
|
||||
FROM pgz_sport.users
|
||||
WHERE klub_id=%s AND user_type='klub_admin' AND status='active'""",
|
||||
(klub_id,))
|
||||
for r in cur.fetchall():
|
||||
if r["email"] and r["id"] not in seen:
|
||||
out.append({**r, "rola": "klub_admin"}); seen.add(r["id"])
|
||||
cur.execute(
|
||||
"""SELECT id, email, COALESCE(full_name, ime||' '||prezime, email) AS name
|
||||
FROM pgz_sport.users
|
||||
WHERE user_type='pgz_admin' AND status='active' LIMIT 5""")
|
||||
for r in cur.fetchall():
|
||||
if r["email"] and r["id"] not in seen:
|
||||
out.append({**r, "rola": "pgz_admin"}); seen.add(r["id"])
|
||||
except Exception as e:
|
||||
logger.warning("recipients fetch fail: %s", e)
|
||||
return out
|
||||
|
||||
|
||||
def _store(user_id: Optional[int], subject: str, body: str, meta: dict,
|
||||
channel: str = "email", status: str = "queued") -> Optional[int]:
|
||||
try:
|
||||
with _db() as c:
|
||||
cur = c.cursor()
|
||||
cur.execute(
|
||||
"""INSERT INTO pgz_sport.notifications
|
||||
(user_id, channel, subject, body, status, scheduled_at, meta)
|
||||
VALUES (%s,%s,%s,%s,%s,NOW(),%s)
|
||||
RETURNING id""",
|
||||
(user_id, channel, subject[:200], body[:5000], status,
|
||||
json.dumps(meta, ensure_ascii=False, default=str)),
|
||||
)
|
||||
return cur.fetchone()[0]
|
||||
except Exception as e:
|
||||
logger.warning("notification insert fail: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _dispatch(subject: str, body: str, *, klub_id: Optional[int] = None,
|
||||
user_id: Optional[int] = None, meta: Optional[dict] = None) -> dict:
|
||||
meta = dict(meta or {})
|
||||
recipients = _resolve_recipients(klub_id, user_id)
|
||||
delivered = []
|
||||
if not recipients:
|
||||
# Mock: nemamo korisnika, samo log + jedan info zapis bez user_id
|
||||
nid = _store(None, subject, body,
|
||||
{**meta, "to": "(no_recipient)", "klub_id": klub_id})
|
||||
logger.info("MOCK email (no recipient) [%s] %s", nid, subject)
|
||||
return {"sent": 0, "queued": 1 if nid else 0, "ids": [nid] if nid else [],
|
||||
"recipients": []}
|
||||
for r in recipients:
|
||||
nid = _store(r["id"], subject, body,
|
||||
{**meta, "to": r["email"], "rola": r.get("rola"),
|
||||
"name": r.get("name")})
|
||||
if nid:
|
||||
delivered.append({"id": nid, "user_id": r["id"], "email": r["email"]})
|
||||
logger.info(
|
||||
"MOCK email queued [%s] to=%s rola=%s subj=%r",
|
||||
nid, r["email"], r.get("rola"), subject,
|
||||
)
|
||||
return {"sent": 0, "queued": len(delivered), "ids": [d["id"] for d in delivered],
|
||||
"recipients": [d["email"] for d in delivered]}
|
||||
|
||||
|
||||
# ─── Public helpers ────────────────────────────────────────────────────
|
||||
|
||||
def notify_invoice_created(invoice: dict) -> dict:
|
||||
"""Račun spremljen iz OCR-a — info klub_admin."""
|
||||
subj = f"Novi račun #{invoice.get('id')}: {invoice.get('vendor_name','')} (€{invoice.get('amount_gross')})"
|
||||
body = (
|
||||
f"Račun {invoice.get('invoice_no')} od {invoice.get('vendor_name')} "
|
||||
f"(OIB {invoice.get('vendor_oib')}) iznosa €{invoice.get('amount_gross')} "
|
||||
f"na datum {invoice.get('invoice_date')} unesen je u sustav.\n\n"
|
||||
f"Klub: {invoice.get('klub_id')} · Vrsta: {invoice.get('invoice_kind')} · Status: {invoice.get('payment_status')}"
|
||||
)
|
||||
return _dispatch(subj, body, klub_id=invoice.get("klub_id"),
|
||||
meta={"event": "invoice_created", "invoice_id": invoice.get("id")})
|
||||
|
||||
|
||||
def notify_invoice_paid(invoice: dict, payment: Optional[dict] = None) -> dict:
|
||||
iban = (payment or {}).get("iban_to") or invoice.get("iban_to") or "—"
|
||||
subj = f"Račun #{invoice.get('id')} označen kao plaćen — €{invoice.get('amount_gross')}"
|
||||
body = (
|
||||
f"Račun {invoice.get('invoice_no')} izdan od {invoice.get('vendor_name')} "
|
||||
f"je označen kao plaćen.\n"
|
||||
f"Iznos: €{invoice.get('amount_gross')}\n"
|
||||
f"Datum uplate: {invoice.get('paid_date')}\n"
|
||||
f"IBAN primatelja: {iban}\n"
|
||||
f"Referenca: {(payment or {}).get('reference','—')}"
|
||||
)
|
||||
return _dispatch(subj, body, klub_id=invoice.get("klub_id"),
|
||||
meta={"event": "invoice_paid", "invoice_id": invoice.get("id")})
|
||||
|
||||
|
||||
def notify_invoice_cancelled(invoice: dict, razlog: str = "") -> dict:
|
||||
subj = f"Račun #{invoice.get('id')} otkazan"
|
||||
body = f"Račun {invoice.get('invoice_no')} ({invoice.get('vendor_name')}) je otkazan.\nRazlog: {razlog or '—'}"
|
||||
return _dispatch(subj, body, klub_id=invoice.get("klub_id"),
|
||||
meta={"event": "invoice_cancelled", "invoice_id": invoice.get("id"),
|
||||
"razlog": razlog})
|
||||
|
||||
|
||||
def notify_pn_submitted(pn: dict) -> dict:
|
||||
subj = f"Putni nalog #{pn.get('id')} poslan na odobrenje (€{pn.get('cost_total')})"
|
||||
body = (
|
||||
f"Putni nalog za destinaciju '{pn.get('destination')}' "
|
||||
f"({pn.get('date_from')} – {pn.get('date_to')}) "
|
||||
f"poslan je na odobrenje.\nIznos: €{pn.get('cost_total')}"
|
||||
)
|
||||
return _dispatch(subj, body, klub_id=pn.get("klub_id"), user_id=pn.get("user_id"),
|
||||
meta={"event": "pn_submitted", "pn_id": pn.get("id")})
|
||||
|
||||
|
||||
def notify_pn_approved(pn: dict) -> dict:
|
||||
subj = f"Putni nalog #{pn.get('id')} ODOBREN — €{pn.get('cost_total')}"
|
||||
body = (
|
||||
f"Putni nalog za '{pn.get('destination')}' "
|
||||
f"({pn.get('date_from')} – {pn.get('date_to')}) je odobren.\n"
|
||||
f"Iznos: €{pn.get('cost_total')}"
|
||||
)
|
||||
return _dispatch(subj, body, klub_id=pn.get("klub_id"), user_id=pn.get("user_id"),
|
||||
meta={"event": "pn_approved", "pn_id": pn.get("id")})
|
||||
|
||||
|
||||
def notify_pn_rejected(pn: dict, razlog: str = "") -> dict:
|
||||
subj = f"Putni nalog #{pn.get('id')} ODBIJEN"
|
||||
body = f"Putni nalog za '{pn.get('destination')}' je odbijen.\nRazlog: {razlog or '—'}"
|
||||
return _dispatch(subj, body, klub_id=pn.get("klub_id"), user_id=pn.get("user_id"),
|
||||
meta={"event": "pn_rejected", "pn_id": pn.get("id"), "razlog": razlog})
|
||||
|
||||
|
||||
def notify_pn_paid(pn: dict, payment: Optional[dict] = None) -> dict:
|
||||
iban = (payment or {}).get("iban_to") or "—"
|
||||
subj = f"Putni nalog #{pn.get('id')} ISPLAĆEN — €{pn.get('cost_total')}"
|
||||
body = (
|
||||
f"Putni nalog za '{pn.get('destination')}' isplaćen je voditelju.\n"
|
||||
f"Iznos: €{(payment or {}).get('amount') or pn.get('cost_total')}\n"
|
||||
f"IBAN primatelja: {iban}\n"
|
||||
f"Datum isplate: {pn.get('paid_at') or (payment or {}).get('payment_date')}\n"
|
||||
f"Referenca: {(payment or {}).get('reference','—')}"
|
||||
)
|
||||
return _dispatch(subj, body, klub_id=pn.get("klub_id"), user_id=pn.get("user_id"),
|
||||
meta={"event": "pn_paid", "pn_id": pn.get("id")})
|
||||
|
||||
|
||||
__all__ = [
|
||||
"notify_invoice_created", "notify_invoice_paid", "notify_invoice_cancelled",
|
||||
"notify_pn_submitted", "notify_pn_approved", "notify_pn_rejected", "notify_pn_paid",
|
||||
]
|
||||
+53
-2
@@ -45,6 +45,15 @@ try:
|
||||
except Exception:
|
||||
_auth_user = None
|
||||
|
||||
try:
|
||||
from erp.notifications import (
|
||||
notify_invoice_created, notify_invoice_paid, notify_invoice_cancelled,
|
||||
)
|
||||
except Exception:
|
||||
def notify_invoice_created(*a, **k): return {}
|
||||
def notify_invoice_paid(*a, **k): return {}
|
||||
def notify_invoice_cancelled(*a, **k): return {}
|
||||
|
||||
router = APIRouter(prefix="/api/erp", tags=["erp-ocr"])
|
||||
|
||||
# === Config ===
|
||||
@@ -324,6 +333,11 @@ async def ocr_upload(
|
||||
authorization: Optional[str] = Header(None),
|
||||
):
|
||||
"""Upload an invoice file (PDF/image) → store on disk + insert pgz_sport.invoice_uploads."""
|
||||
user = _resolve_user(authorization)
|
||||
# Permission: pgz_admin uvijek; klub_admin/klub_user samo za vlastiti klub (ako je naveden)
|
||||
if user and not is_pgz_admin(user):
|
||||
if klub_id and user.get("klub_id") != klub_id:
|
||||
raise HTTPException(403, "Nemate ovlasti uploadati za ovaj klub")
|
||||
suffix = "." + (file.filename or "").rsplit(".", 1)[-1].lower()
|
||||
if suffix not in ALLOWED_EXT:
|
||||
raise HTTPException(400, f"Tip datoteke nije podržan: {suffix}. Dozvoljeno: {sorted(ALLOWED_EXT)}")
|
||||
@@ -354,6 +368,18 @@ async def ocr_upload(
|
||||
sha256, json.dumps({"tenant_id": tenant_id, "invoice_kind": invoice_kind})),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
# Audit log za OCR upload
|
||||
try:
|
||||
with _db() as c:
|
||||
c.cursor().execute(
|
||||
"""INSERT INTO pgz_sport.audit_log
|
||||
(tablica, operacija, record_id, korisnik, promijenjeno_polje, nova_vrijednost)
|
||||
VALUES ('pgz_sport.invoice_uploads','create',%s,%s,'file_name',%s)""",
|
||||
(row["id"], (user.get("email") if user else "anon"),
|
||||
f"{file.filename} ({len(raw)} B, sha={sha256[:12]})"),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "upload_id": row["id"], "file_name": row["file_name"],
|
||||
"size": len(raw), "sha256": sha256, "status": row["ocr_status"]}
|
||||
|
||||
@@ -646,11 +672,17 @@ def invoices_create(body: dict = Body(...), authorization: Optional[str] = Heade
|
||||
if body.get(k) in (None, ""):
|
||||
raise HTTPException(400, f"Nedostaje polje: {k}")
|
||||
|
||||
user = _resolve_user(authorization)
|
||||
klub_id = body.get("klub_id")
|
||||
tenant_id = body.get("tenant_id", 1)
|
||||
upload_id = body.get("upload_id")
|
||||
lines = body.get("lines") or []
|
||||
|
||||
# Permission: pgz_admin uvijek; klub_admin samo za vlastiti klub
|
||||
if user and not is_pgz_admin(user):
|
||||
if not (user.get("user_type") == "klub_admin" and klub_id == user.get("klub_id")):
|
||||
raise HTTPException(403, "Nemate ovlasti kreirati račun za ovaj klub")
|
||||
|
||||
with _db() as c:
|
||||
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
@@ -715,7 +747,10 @@ def invoices_create(body: dict = Body(...), authorization: Optional[str] = Heade
|
||||
(inv_id, upload_id),
|
||||
)
|
||||
|
||||
return {"ok": True, "invoice": inv}
|
||||
audit_invoice(user, inv_id, "create", field="invoice_no",
|
||||
new=f"{body.get('invoice_no')} €{body.get('amount_gross')}")
|
||||
notif = notify_invoice_created({**body, "id": inv_id, "klub_id": klub_id})
|
||||
return {"ok": True, "invoice": inv, "notification": notif}
|
||||
|
||||
|
||||
@router.put("/invoices/{invoice_id}")
|
||||
@@ -813,7 +848,13 @@ def invoices_pay(invoice_id: int, body: dict = Body(default={}),
|
||||
pay = cur.fetchone()
|
||||
audit_invoice(user, invoice_id, "pay", field="payment_status",
|
||||
old=inv.get("payment_status"), new="paid")
|
||||
return {"ok": True, "invoice": row, "payment_id": pay["id"] if pay else None}
|
||||
notif = notify_invoice_paid(
|
||||
{**inv, **(row or {}), "id": invoice_id},
|
||||
{"iban_to": iban_to, "iban_from": iban_from, "reference": reference,
|
||||
"payment_date": paid_date, "amount": amount},
|
||||
)
|
||||
return {"ok": True, "invoice": row, "payment_id": pay["id"] if pay else None,
|
||||
"notification": notif}
|
||||
|
||||
|
||||
# ── R5.3 BULK OPERATIONS ──────────────────────────────────────────────
|
||||
@@ -868,6 +909,14 @@ def invoices_bulk_pay(body: dict = Body(...), authorization: Optional[str] = Hea
|
||||
)
|
||||
audit_invoice(user, inv["id"], "bulk_pay",
|
||||
field="payment_status", old=inv.get("payment_status"), new="paid")
|
||||
try:
|
||||
notify_invoice_paid(
|
||||
{**inv, "paid_date": paid_date},
|
||||
{"iban_to": iban_to, "iban_from": iban_from, "reference": reference,
|
||||
"payment_date": paid_date, "amount": inv.get("amount_gross")},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
results["paid"].append(inv["id"])
|
||||
except Exception as e:
|
||||
results["errors"].append({"id": inv["id"], "err": str(e)[:200]})
|
||||
@@ -907,6 +956,8 @@ def invoices_bulk_cancel(body: dict = Body(...), authorization: Optional[str] =
|
||||
audit_invoice(user, inv["id"], "bulk_cancel",
|
||||
field="payment_status", old=inv.get("payment_status"),
|
||||
new=f"cancelled: {razlog}")
|
||||
try: notify_invoice_cancelled(inv, razlog)
|
||||
except Exception: pass
|
||||
results["cancelled"].append(inv["id"])
|
||||
except Exception as e:
|
||||
results["errors"].append({"id": inv["id"], "err": str(e)[:200]})
|
||||
|
||||
+52
-4
@@ -36,6 +36,16 @@ try:
|
||||
except Exception:
|
||||
_auth_user = None
|
||||
|
||||
try:
|
||||
from erp.notifications import (
|
||||
notify_pn_submitted, notify_pn_approved, notify_pn_rejected, notify_pn_paid,
|
||||
)
|
||||
except Exception:
|
||||
def notify_pn_submitted(*a, **k): return {}
|
||||
def notify_pn_approved(*a, **k): return {}
|
||||
def notify_pn_rejected(*a, **k): return {}
|
||||
def notify_pn_paid(*a, **k): return {}
|
||||
|
||||
ADMIN_TOKEN = "admin-pgz-2026"
|
||||
|
||||
def _resolve_user(authorization):
|
||||
@@ -361,7 +371,8 @@ def posalji_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(Non
|
||||
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}
|
||||
notif = notify_pn_submitted({**pn, "status": "poslan"})
|
||||
return {"ok": True, "putni_nalog": row, "notification": notif}
|
||||
|
||||
|
||||
@router.post("/putni-nalog/{nalog_id}/odbij")
|
||||
@@ -389,7 +400,8 @@ def odbij_putni_nalog(nalog_id: int, body: dict = Body(default={}),
|
||||
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}
|
||||
notif = notify_pn_rejected({**pn, "status": "odbijen"}, razlog=razlog)
|
||||
return {"ok": True, "putni_nalog": row, "notification": notif}
|
||||
|
||||
|
||||
@router.post("/putni-nalog/{nalog_id}/isplati")
|
||||
@@ -437,7 +449,13 @@ def isplati_putni_nalog(nalog_id: int, body: dict = Body(default={}),
|
||||
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}
|
||||
notif = notify_pn_paid(
|
||||
{**pn, **(row or {}), "id": nalog_id},
|
||||
{"iban_to": iban_to, "iban_from": iban_from, "amount": amount,
|
||||
"reference": reference, "payment_date": paid_date},
|
||||
)
|
||||
return {"ok": True, "putni_nalog": row, "payment_id": pay["id"] if pay else None,
|
||||
"notification": notif}
|
||||
|
||||
|
||||
@router.get("/putni-nalog/{nalog_id}/hub3.pdf")
|
||||
@@ -526,6 +544,12 @@ def create_putni_nalog(body: dict = Body(...), authorization: Optional[str] = He
|
||||
if not klub_id:
|
||||
raise HTTPException(400, "klub_id je obavezan")
|
||||
|
||||
user = _resolve_user(authorization)
|
||||
# Permission: pgz_admin uvijek; klub_admin/klub_user samo za vlastiti klub
|
||||
if user and not is_pgz_admin(user):
|
||||
if user.get("user_type") not in ("klub_admin", "klub_user") or user.get("klub_id") != klub_id:
|
||||
raise HTTPException(403, "Nemate ovlasti kreirati putni nalog za ovaj klub")
|
||||
|
||||
country = body.get("country", "Hrvatska")
|
||||
km = body.get("km_driven", body.get("kilometara", 0)) or 0
|
||||
km_rate = body.get("km_rate") or KM_RATE_DEFAULT
|
||||
@@ -593,6 +617,8 @@ def create_putni_nalog(body: dict = Body(...), authorization: Optional[str] = He
|
||||
ct = cur.fetchone()
|
||||
if ct:
|
||||
row["cost_total"] = ct["cost_total"]
|
||||
audit_putni(user, row["id"], "create", field="status",
|
||||
new=f"draft (€{row.get('cost_total')})")
|
||||
return {"ok": True, "putni_nalog": row, "dnevnice_calc": dnv}
|
||||
|
||||
|
||||
@@ -647,6 +673,8 @@ def odobriti_putni_nalog(nalog_id: int, body: dict = Body(default={}),
|
||||
authorization: Optional[str] = Header(None)):
|
||||
user = _resolve_user(authorization)
|
||||
approved_by = body.get("approved_by") or (user.get("id") if user else None)
|
||||
if approved_by == 0 or (user and user.get("_synthetic")):
|
||||
approved_by = None # admin token nema realnog user_id u DB
|
||||
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,))
|
||||
@@ -666,7 +694,27 @@ def odobriti_putni_nalog(nalog_id: int, body: dict = Body(default={}),
|
||||
row = cur.fetchone()
|
||||
audit_putni(user, nalog_id, "approve", field="status",
|
||||
old=pn.get("status"), new="odobren")
|
||||
return {"ok": True, "putni_nalog": row}
|
||||
notif = notify_pn_approved({**pn, "status": "odobren"})
|
||||
return {"ok": True, "putni_nalog": row, "notification": notif}
|
||||
|
||||
|
||||
# R6.2 — PUT alias za simetriju s briefom
|
||||
@router.put("/putni-nalog/{nalog_id}/odobri")
|
||||
def odobri_putni_nalog_put(nalog_id: int, body: dict = Body(default={}),
|
||||
authorization: Optional[str] = Header(None)):
|
||||
return odobriti_putni_nalog(nalog_id, body, authorization)
|
||||
|
||||
|
||||
@router.put("/putni-nalog/{nalog_id}/odbij")
|
||||
def odbij_putni_nalog_put(nalog_id: int, body: dict = Body(default={}),
|
||||
authorization: Optional[str] = Header(None)):
|
||||
return odbij_putni_nalog(nalog_id, body, authorization)
|
||||
|
||||
|
||||
@router.put("/putni-nalog/{nalog_id}/isplati")
|
||||
def isplati_putni_nalog_put(nalog_id: int, body: dict = Body(default={}),
|
||||
authorization: Optional[str] = Header(None)):
|
||||
return isplati_putni_nalog(nalog_id, body, authorization)
|
||||
|
||||
|
||||
@router.post("/putni-nalog/{nalog_id}/zatvori")
|
||||
|
||||
Reference in New Issue
Block a user