CC2 R5: defense-in-depth JWT + invite/reset token flows + audit

#1 JWT middleware:
- pgz_sport_api.py: starlette middleware require_jwt_on_admin runs before
  every /api/admin/* route. Even routes that lack Depends(require_user)
  cannot be reached without a valid Bearer token (verifies signature,
  exp, typ='access', revocation via user_sessions). OPTIONS passes for CORS.

#2 Invitation flow:
- pgz_sport.user_action_tokens table (token_hash, user_id, kind, expires_at,
  used_at, created_by, ip, meta). Single-use, raw token never persisted.
- POST /api/admin/users/{id}/invite — issues 'invite' token (TTL 7d),
  marks must_change_pwd, revokes existing sessions, returns invite_link.
- GET  /api/auth/setup-password?token=X — preflight (no consume).
- POST /api/auth/setup-password — consumes token, sets password, sets
  email_verified=true.

#3 Password reset flow:
- POST /api/auth/forgot-password — generic 'ako račun postoji' response;
  issues 'reset' token (TTL 2h) only for active users. Token returned in
  response only on localhost or if PGZ_REVEAL_RESET_TOKEN=1.
- GET  /api/auth/reset-password?token=X — preflight.
- POST /api/auth/reset-password — consumes token, sets new password,
  revokes all active sessions.

#4 Audit coverage (auth events):
- login.ok, login.fail (with reason), login.locked, login.2fa_required,
  login.2fa_fail, logout, auth.refresh, password.change, password.reset.ok,
  password.reset.fail, password.forgot.issue, password.forgot.miss,
  invite.consume.ok, invite.consume.fail, user.invite, user.create,
  user.update, user.delete, user.role.change, user.suspend, user.unsuspend,
  user.password.reset, 2fa.verify.ok, 2fa.verify.fail, 2fa.disable.

#5 Live tests: 41/41 across 6 demo users (incl. fresh invited+deleted user).
   Phase 2 verifies 14 endpoints reject no-auth and accept valid Bearer.
This commit is contained in:
Damir Radulić
2026-05-05 01:28:29 +02:00
parent 8dce58c5f9
commit 0046b8d695
24 changed files with 15419 additions and 72 deletions
@@ -0,0 +1,615 @@
#!/usr/bin/env python3
# erp/putni_nalozi.py — PGŽ Sport ERP putni nalozi (M6)
# Author: Damir Radulić <damir@rinet.one> / dradulic@outlook.com
# Date: 2026-05-04
# Description: CRUD putnih naloga + obračun dnevnica (HR pravilnik 2025).
from __future__ import annotations
import json
from datetime import datetime, date, timedelta
from typing import Optional, Any
import psycopg2
import psycopg2.extras
from fastapi import APIRouter, Body, HTTPException, Query, Header
try:
from erp.permissions import (
can_view_putni_nalog, can_edit_putni_nalog, can_submit_putni_nalog,
can_approve_putni_nalog, can_pay_putni_nalog, putni_nalog_actions,
audit_putni, fetch_audit, is_pgz_admin,
)
except Exception:
def can_view_putni_nalog(u, p): return True
def can_edit_putni_nalog(u, p): return True
def can_submit_putni_nalog(u, p): return True
def can_approve_putni_nalog(u, p): return True
def can_pay_putni_nalog(u, p): return True
def putni_nalog_actions(u, p): return {"view": True, "edit": True, "submit": True, "approve": True, "reject": True, "pay": True, "delete": False}
def audit_putni(u, pid, op, field=None, old=None, new=None): pass
def fetch_audit(t, r, limit=50): return []
def is_pgz_admin(u): return False
try:
from auth.auth_v2 import get_current_user as _auth_user
except Exception:
_auth_user = None
ADMIN_TOKEN = "admin-pgz-2026"
def _resolve_user(authorization):
if _auth_user:
try:
u = _auth_user(authorization)
if u: return u
except Exception:
pass
if authorization and authorization.replace("Bearer ", "").strip() == ADMIN_TOKEN:
return {"id": 0, "email": "admin@token", "user_type": "pgz_admin",
"klub_id": None, "savez_id": None, "_synthetic": True}
return None
router = APIRouter(prefix="/api/erp", tags=["erp-putni-nalozi"])
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
password="R1net2026!SecureDB#v7")
# === HR pravilnik 2025 — dnevnice ===
# Domaće: 26.54 € (puna) za put >8h, 13.27 € za 5-8h, 0 € za <5h.
# Izvor: NN — Pravilnik o porezu na dohodak, neoporezivi iznosi 2025 (200 kn ≈ 26.54 €).
DNEVNICA_DOM_FULL = 26.54 # EUR
DNEVNICA_DOM_HALF = 13.27 # EUR
KM_RATE_DEFAULT = 0.50 # EUR/km (vlastiti automobil)
# Inozemne dnevnice (Uredba o izdacima službenih putovanja u inozemstvo).
DNEVNICE_INO = {
"Italija": 35.00,
"Italy": 35.00,
"Slovenija": 30.00,
"Slovenia": 30.00,
"Austrija": 35.00,
"Austria": 35.00,
"Mađarska": 30.00,
"Madarska": 30.00,
"Hungary": 30.00,
"Bosna i Hercegovina": 30.00,
"BiH": 30.00,
"Bosnia": 30.00,
"Srbija": 30.00,
"Serbia": 30.00,
"Crna Gora": 30.00,
"Montenegro": 30.00,
"Njemačka": 50.00,
"Germany": 50.00,
"Francuska": 50.00,
"France": 50.00,
"Švicarska": 60.00,
"Switzerland": 60.00,
"SAD": 70.00,
"USA": 70.00,
}
def _db():
c = psycopg2.connect(**DB)
c.autocommit = True
return c
def _parse_dt(v) -> Optional[datetime]:
if v is None or v == "":
return None
if isinstance(v, datetime):
return v
s = str(v).strip().replace("Z", "+00:00")
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M", "%Y-%m-%d"):
try:
return datetime.strptime(s[:len(fmt) + 5].rstrip("ZZ"), fmt)
except Exception:
continue
try:
return datetime.fromisoformat(s)
except Exception:
return None
def compute_dnevnice(date_from, date_to, country: str = "Hrvatska") -> dict:
"""
Vraća: {hours, days_full, days_half, dnevnica_amount_total, breakdown[]}
Pravila (HR pravilnik 2025, neoporeziv iznos):
- Domaće: <5h = 0; 5-8h = pola; >8h = puna; svaka dodatna pokrivena 24h sekcija = puna.
- Inozemne: pune dnevnice po zemlji (DNEVNICE_INO), inače fallback 50 €.
- Više dana: zaokružujemo po 24h segmentima; završetak <8h = 0, 8-12 = puna (po pravilu zaokruživanja na cijele dane), no koristimo konzervativni izračun po segmentima.
Implementacija (jednostavna, transparentna):
1) ukupne sate računaj kao razliku.
2) full_segments = sati // 24
3) ostatak_sati = sati - full_segments*24
4) ako ostatak >= 8 → +1 puna; ako 5 <= ostatak < 8 → +0.5; ako <5 → +0.
5) puna dnevnica = pun_iznos po zemlji; pola = polovica.
"""
df = _parse_dt(date_from)
dt = _parse_dt(date_to)
if not df or not dt or dt < df:
return {"error": "neispravni datumi", "hours": 0,
"days_full": 0, "days_half": 0,
"dnevnica_amount_total": 0.0, "breakdown": []}
delta = dt - df
hours = round(delta.total_seconds() / 3600, 2)
full_segments = int(delta.total_seconds() // (24 * 3600))
remainder_h = (delta.total_seconds() - full_segments * 24 * 3600) / 3600.0
days_full = full_segments
days_half = 0.0
if remainder_h >= 8:
days_full += 1
elif remainder_h >= 5:
days_half += 1
# else: 0
is_domestic = (country or "").strip().lower() in ("hrvatska", "croatia", "hr")
if is_domestic:
full_amt = DNEVNICA_DOM_FULL
half_amt = DNEVNICA_DOM_HALF
else:
full_amt = DNEVNICE_INO.get(country.strip(), 50.00)
half_amt = full_amt / 2.0
total = round(days_full * full_amt + days_half * half_amt, 2)
return {
"hours": hours,
"days_full": days_full,
"days_half": days_half,
"country": country,
"rate_full": full_amt,
"rate_half": half_amt,
"dnevnica_amount_total": total,
"breakdown": [
f"{days_full} pun{'' if days_full == 1 else 'e'} dnevnice × {full_amt:.2f} €",
f"{days_half} pola dnevnice × {full_amt:.2f} €" if days_half else "",
],
}
def compute_kilometrina(km: float, km_rate: float = KM_RATE_DEFAULT) -> float:
try:
return round(float(km or 0) * float(km_rate or 0), 2)
except Exception:
return 0.0
# === Endpoints ===
@router.get("/putni-nalog/dnevnice/preview")
def preview_dnevnice(date_from: str, date_to: str, country: str = "Hrvatska",
km: float = 0.0, km_rate: float = KM_RATE_DEFAULT):
"""Preview dnevnica + kilometrine bez upisa u DB. Koristi UI za live preview."""
d = compute_dnevnice(date_from, date_to, country)
km_amt = compute_kilometrina(km, km_rate)
d["km_amount"] = km_amt
d["km_driven"] = km
d["km_rate"] = km_rate
d["total_estimated"] = round((d.get("dnevnica_amount_total") or 0) + km_amt, 2)
return {"ok": True, "preview": d}
@router.get("/putni-nalog")
def list_putni_nalozi(klub_id: Optional[int] = None,
status: Optional[str] = None,
limit: int = Query(100, le=500),
offset: int = 0):
sql = """SELECT er.id, er.klub_id, k.naziv AS klub_naziv,
er.user_id, er.clan_id, er.report_type, er.report_no,
er.destination, er.purpose,
er.date_from, er.date_to,
er.vehicle_type, er.vehicle_plate,
er.km_driven, er.km_rate,
er.cost_transport, er.cost_lodging, er.cost_meals,
er.cost_other, er.cost_total,
er.dnevnice_count, er.dnevnice_amount,
er.status, er.approved_at, er.paid_at,
er.created_at, er.tenant_id, er.notes
FROM pgz_sport.expense_reports er
LEFT JOIN pgz_sport.klubovi k ON k.id = er.klub_id
WHERE er.report_type='putni_nalog'"""
args: list = []
if klub_id is not None:
sql += " AND er.klub_id=%s"; args.append(klub_id)
if status:
sql += " AND er.status=%s"; args.append(status)
sql += " ORDER BY er.date_from DESC NULLS LAST, er.id DESC LIMIT %s OFFSET %s"
args += [limit, offset]
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, args)
rows = cur.fetchall()
return {"ok": True, "rows": rows, "count": len(rows)}
@router.get("/putni-nalog/{nalog_id}")
def get_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)):
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("""SELECT er.*, k.naziv AS klub_naziv, 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,))
row = cur.fetchone()
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")
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}
@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")
def create_putni_nalog(body: dict = Body(...), authorization: Optional[str] = Header(None)):
"""Kreiraj putni nalog.
Polja: klub_id, user_id, clan_id, voditelj_ime, putnici[],
svrha (purpose), od_grada, do_grada (destination),
datum_polaska (date_from), datum_povratka (date_to),
registracija_vozila (vehicle_plate), vehicle_type,
kilometara (km_driven), km_rate,
predviđeni_troškovi (cost_estimate), country, notes."""
df = body.get("date_from") or body.get("datum_polaska")
dt = body.get("date_to") or body.get("datum_povratka")
if not df or not dt:
raise HTTPException(400, "Datum polaska i povratka su obavezni")
klub_id = body.get("klub_id")
if not klub_id:
raise HTTPException(400, "klub_id je obavezan")
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
dnv = compute_dnevnice(df, dt, country)
dnevnice_count = (dnv.get("days_full") or 0) + 0.5 * (dnv.get("days_half") or 0)
dnevnice_amount = dnv.get("dnevnica_amount_total") or 0
cost_transport = compute_kilometrina(km, km_rate) + (body.get("cost_transport") or 0)
od = body.get("od_grada") or body.get("from_city")
do = body.get("do_grada") or body.get("to_city") or body.get("destination")
destination = " → ".join([x for x in [od, do] if x]) or do
putnici = body.get("putnici") or []
voditelj = body.get("voditelj_ime") or body.get("voditelj")
purpose = body.get("svrha") or body.get("purpose") or ""
meta = {
"voditelj": voditelj,
"putnici": putnici,
"from_city": od, "to_city": do,
"country": country,
"dnevnice_calc": dnv,
"predvideni_troskovi": body.get("predvideni_troskovi") or body.get("cost_estimate") or [],
}
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""INSERT INTO pgz_sport.expense_reports
(klub_id, user_id, clan_id, report_type, report_no, destination, purpose,
date_from, date_to, vehicle_type, vehicle_plate, km_driven, km_rate,
cost_transport, cost_lodging, cost_meals, cost_other,
dnevnice_count, dnevnice_amount, status, attachments, notes, tenant_id)
VALUES (%s, %s, %s, 'putni_nalog', %s, %s, %s,
%s, %s, %s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, COALESCE(%s,'draft'), %s, %s, %s)
RETURNING id, klub_id, status, dnevnice_count, dnevnice_amount,
cost_transport, date_from, date_to, destination""",
(
klub_id, body.get("user_id"), body.get("clan_id"),
body.get("report_no"), destination, purpose,
df, dt, body.get("vehicle_type"), body.get("vehicle_plate") or body.get("registracija_vozila"),
float(km or 0), float(km_rate or 0),
cost_transport,
body.get("cost_lodging") or 0, body.get("cost_meals") or 0,
body.get("cost_other") or 0,
dnevnice_count, dnevnice_amount,
body.get("status"),
json.dumps(meta, ensure_ascii=False, default=str),
body.get("notes"),
body.get("tenant_id", 1),
),
)
row = cur.fetchone()
# cost_total via trigger maybe; recompute here
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0)
+COALESCE(cost_meals,0)+COALESCE(cost_other,0)
+COALESCE(dnevnice_amount,0)
WHERE id=%s
RETURNING cost_total""", (row["id"],),
)
ct = cur.fetchone()
if ct:
row["cost_total"] = ct["cost_total"]
return {"ok": True, "putni_nalog": row, "dnevnice_calc": dnv}
@router.put("/putni-nalog/{nalog_id}")
def update_putni_nalog(nalog_id: int, body: dict = Body(...)):
"""Update polja putnog naloga (osim odobrenja/zatvaranja - oni imaju vlastite endpointe)."""
cols = []
args: list = []
for col in ("destination", "purpose", "date_from", "date_to", "vehicle_type",
"vehicle_plate", "km_driven", "km_rate", "cost_transport",
"cost_lodging", "cost_meals", "cost_other", "notes",
"dnevnice_count", "dnevnice_amount"):
if col in body:
cols.append(f"{col}=%s"); args.append(body[col])
# Recompute dnevnice if dates provided
if "date_from" in body or "date_to" in body or "country" in body:
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT date_from, date_to, attachments FROM pgz_sport.expense_reports WHERE id=%s", (nalog_id,))
cur_row = cur.fetchone()
if cur_row:
df = body.get("date_from") or cur_row["date_from"]
dt = body.get("date_to") or cur_row["date_to"]
country = body.get("country") or (cur_row["attachments"] or {}).get("country", "Hrvatska")
d = compute_dnevnice(df, dt, country)
cols += ["dnevnice_count=%s", "dnevnice_amount=%s"]
args += [(d.get("days_full") or 0) + 0.5 * (d.get("days_half") or 0),
d.get("dnevnica_amount_total") or 0]
if not cols:
raise HTTPException(400, "Nema polja za izmjenu")
cols.append("updated_at=NOW()")
args.append(nalog_id)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(f"UPDATE pgz_sport.expense_reports SET {','.join(cols)} WHERE id=%s AND report_type='putni_nalog' RETURNING *", args)
row = cur.fetchone()
if row:
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0)
+COALESCE(cost_meals,0)+COALESCE(cost_other,0)
+COALESCE(dnevnice_amount,0)
WHERE id=%s""", (nalog_id,),
)
if not row:
raise HTTPException(404, "Putni nalog ne postoji")
return {"ok": True, "putni_nalog": row}
@router.post("/putni-nalog/{nalog_id}/odobriti")
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)
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:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET status='odobren', approved_by=%s, approved_at=NOW(), updated_at=NOW()
WHERE id=%s AND report_type='putni_nalog'
RETURNING id, status, approved_at""", (approved_by, nalog_id),
)
row = cur.fetchone()
audit_putni(user, nalog_id, "approve", field="status",
old=pn.get("status"), new="odobren")
return {"ok": True, "putni_nalog": row}
@router.post("/putni-nalog/{nalog_id}/zatvori")
def zatvori_putni_nalog(nalog_id: int, body: dict = Body(default={})):
"""Zatvori putni nalog: priloži račune i konačan obračun."""
invoice_ids = body.get("invoice_ids") or []
cost_lodging = body.get("cost_lodging")
cost_meals = body.get("cost_meals")
cost_other = body.get("cost_other")
notes = body.get("notes")
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,))
cur_row = cur.fetchone()
if not cur_row:
raise HTTPException(404, "Putni nalog ne postoji")
# Aggregiraj iznose iz računa (ako su poslani)
if invoice_ids:
cur.execute(
"SELECT COALESCE(SUM(amount_gross),0) AS total FROM pgz_sport.invoices WHERE id = ANY(%s)",
(invoice_ids,),
)
invs_total = float(cur.fetchone()["total"] or 0)
else:
invs_total = None
sets = ["status='zatvoren'", "updated_at=NOW()"]
args: list = []
if cost_lodging is not None: sets.append("cost_lodging=%s"); args.append(cost_lodging)
if cost_meals is not None: sets.append("cost_meals=%s"); args.append(cost_meals)
if cost_other is not None: sets.append("cost_other=%s"); args.append(cost_other)
if notes: sets.append("notes=%s"); args.append(notes)
# Pohrani povezane račune u attachments
atts = cur_row["attachments"] or {}
if isinstance(atts, str):
try: atts = json.loads(atts)
except Exception: atts = {}
atts["invoice_ids"] = invoice_ids
if invs_total is not None:
atts["invoices_total"] = invs_total
sets.append("attachments=%s"); args.append(json.dumps(atts, ensure_ascii=False, default=str))
args.append(nalog_id)
cur.execute(f"UPDATE pgz_sport.expense_reports SET {','.join(sets)} WHERE id=%s RETURNING *", args)
row = cur.fetchone()
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0)
+COALESCE(cost_meals,0)+COALESCE(cost_other,0)
+COALESCE(dnevnice_amount,0)
WHERE id=%s RETURNING cost_total""", (nalog_id,),
)
ct = cur.fetchone()
if ct: row["cost_total"] = ct["cost_total"]
return {"ok": True, "putni_nalog": row}