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:
+291
@@ -816,6 +816,297 @@ def invoices_pay(invoice_id: int, body: dict = Body(default={}),
|
||||
return {"ok": True, "invoice": row, "payment_id": pay["id"] if pay else None}
|
||||
|
||||
|
||||
# ── R5.3 BULK OPERATIONS ──────────────────────────────────────────────
|
||||
@router.post("/invoices/bulk-pay")
|
||||
def invoices_bulk_pay(body: dict = Body(...), authorization: Optional[str] = Header(None)):
|
||||
"""Bulk označi listu računa kao plaćene.
|
||||
Body: {ids: [int], paid_date?, payment_method?, iban_from?, iban_to?, reference?, tx_id?}"""
|
||||
user = _resolve_user(authorization)
|
||||
ids = body.get("ids") or []
|
||||
if not ids or not isinstance(ids, list):
|
||||
raise HTTPException(400, "ids je obavezna ne-prazna lista")
|
||||
paid_date = body.get("paid_date") or date.today().isoformat()
|
||||
payment_method = body.get("payment_method") or "transfer"
|
||||
iban_from = body.get("iban_from")
|
||||
iban_to = body.get("iban_to")
|
||||
reference = body.get("reference")
|
||||
tx_id = body.get("bank_transaction_id") or body.get("tx_id")
|
||||
|
||||
results = {"paid": [], "skipped": [], "forbidden": [], "errors": []}
|
||||
with _db() as c:
|
||||
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
"""SELECT i.*, k.savez_id FROM pgz_sport.invoices i
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id
|
||||
WHERE i.id = ANY(%s)""", (ids,))
|
||||
rows = cur.fetchall()
|
||||
for inv in rows:
|
||||
if (inv.get("payment_status") or "").lower() == "paid":
|
||||
results["skipped"].append(inv["id"]); continue
|
||||
if user and not can_pay_invoice(user, inv):
|
||||
results["forbidden"].append(inv["id"]); continue
|
||||
try:
|
||||
with _db() as c:
|
||||
cur = c.cursor()
|
||||
cur.execute(
|
||||
"""UPDATE pgz_sport.invoices
|
||||
SET payment_status='paid', paid_date=%s,
|
||||
payment_method=COALESCE(%s,payment_method),
|
||||
iban_from=COALESCE(%s,iban_from),
|
||||
iban_to=COALESCE(%s,iban_to),
|
||||
updated_at=NOW()
|
||||
WHERE id=%s""",
|
||||
(paid_date, payment_method, iban_from, iban_to, inv["id"]),
|
||||
)
|
||||
cur.execute(
|
||||
"""INSERT INTO pgz_sport.payments
|
||||
(klub_id, invoice_id, payment_date, amount, currency, payment_method,
|
||||
iban_from, iban_to, reference, bank_transaction_id, matched_status)
|
||||
VALUES (%s,%s,%s,%s,COALESCE(%s,'EUR'),%s,%s,%s,%s,%s,'matched')""",
|
||||
(inv.get("klub_id"), inv["id"], paid_date, inv.get("amount_gross"),
|
||||
inv.get("currency"), payment_method, iban_from, iban_to, reference, tx_id),
|
||||
)
|
||||
audit_invoice(user, inv["id"], "bulk_pay",
|
||||
field="payment_status", old=inv.get("payment_status"), new="paid")
|
||||
results["paid"].append(inv["id"])
|
||||
except Exception as e:
|
||||
results["errors"].append({"id": inv["id"], "err": str(e)[:200]})
|
||||
return {"ok": True, "summary": {k: len(v) for k, v in results.items()}, "details": results}
|
||||
|
||||
|
||||
@router.post("/invoices/bulk-cancel")
|
||||
def invoices_bulk_cancel(body: dict = Body(...), authorization: Optional[str] = Header(None)):
|
||||
"""Bulk otkaži (status='cancelled') — samo pgz_admin ili klub_admin svog kluba."""
|
||||
user = _resolve_user(authorization)
|
||||
ids = body.get("ids") or []
|
||||
razlog = body.get("razlog") or body.get("reason") or "(bulk cancel)"
|
||||
if not ids:
|
||||
raise HTTPException(400, "ids je obavezna ne-prazna lista")
|
||||
results = {"cancelled": [], "skipped": [], "forbidden": [], "errors": []}
|
||||
with _db() as c:
|
||||
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
"""SELECT i.*, k.savez_id FROM pgz_sport.invoices i
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id
|
||||
WHERE i.id = ANY(%s)""", (ids,))
|
||||
rows = cur.fetchall()
|
||||
for inv in rows:
|
||||
if (inv.get("payment_status") or "").lower() in ("paid", "cancelled"):
|
||||
results["skipped"].append(inv["id"]); continue
|
||||
if user and not can_edit_invoice(user, inv):
|
||||
results["forbidden"].append(inv["id"]); continue
|
||||
try:
|
||||
with _db() as c:
|
||||
c.cursor().execute(
|
||||
"""UPDATE pgz_sport.invoices
|
||||
SET payment_status='cancelled',
|
||||
notes = COALESCE(notes,'') || E'\n[CANCEL] ' || %s,
|
||||
updated_at=NOW() WHERE id=%s""",
|
||||
(razlog, inv["id"]),
|
||||
)
|
||||
audit_invoice(user, inv["id"], "bulk_cancel",
|
||||
field="payment_status", old=inv.get("payment_status"),
|
||||
new=f"cancelled: {razlog}")
|
||||
results["cancelled"].append(inv["id"])
|
||||
except Exception as e:
|
||||
results["errors"].append({"id": inv["id"], "err": str(e)[:200]})
|
||||
return {"ok": True, "summary": {k: len(v) for k, v in results.items()}, "details": results}
|
||||
|
||||
|
||||
# ── R5.4 XLSX EXPORT ───────────────────────────────────────────────────
|
||||
@router.get("/invoices/export.xlsx")
|
||||
def invoices_export_xlsx(
|
||||
tenant_id: Optional[int] = Query(None),
|
||||
klub_id: Optional[int] = Query(None),
|
||||
od: Optional[str] = Query(None, description="datum od YYYY-MM-DD"),
|
||||
do: Optional[str] = Query(None, description="datum do YYYY-MM-DD"),
|
||||
status: Optional[str] = None,
|
||||
kind: Optional[str] = None,
|
||||
authorization: Optional[str] = Header(None),
|
||||
):
|
||||
"""XLSX export računa za knjigovodstvo. Stupci: ID, datum, vrsta, broj,
|
||||
izdavatelj, OIB, klub, neto, PDV, brutto, valuta, status, IBAN, opis."""
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
from io import BytesIO
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
user = _resolve_user(authorization)
|
||||
sql = """SELECT i.id, i.invoice_date, i.invoice_kind, i.invoice_no,
|
||||
i.vendor_name, i.vendor_oib, i.customer_oib,
|
||||
i.amount_net, i.amount_vat, i.amount_gross, i.vat_rate,
|
||||
i.currency, i.payment_status, i.payment_method,
|
||||
i.iban_to, i.description, i.category,
|
||||
i.paid_date, i.tenant_id, i.klub_id,
|
||||
k.naziv AS klub_naziv, k.savez_id
|
||||
FROM pgz_sport.invoices i
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id
|
||||
WHERE 1=1"""
|
||||
args: list = []
|
||||
if tenant_id is not None: sql += " AND i.tenant_id=%s"; args.append(tenant_id)
|
||||
if klub_id is not None: sql += " AND i.klub_id=%s"; args.append(klub_id)
|
||||
if od: sql += " AND i.invoice_date >= %s"; args.append(od)
|
||||
if do: sql += " AND i.invoice_date <= %s"; args.append(do)
|
||||
if status: sql += " AND i.payment_status=%s"; args.append(status)
|
||||
if kind: sql += " AND i.invoice_kind=%s"; args.append(kind)
|
||||
sql += " ORDER BY i.invoice_date DESC, i.id DESC"
|
||||
with _db() as c:
|
||||
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(sql, args)
|
||||
rows = cur.fetchall()
|
||||
|
||||
# Filter po user permissions
|
||||
if user and not is_pgz_admin(user):
|
||||
rows = [r for r in rows if can_view_invoice(user, r)]
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Računi"
|
||||
headers = ["ID", "Datum", "Vrsta", "Broj računa", "Izdavatelj", "OIB",
|
||||
"Klub", "Iznos neto", "PDV", "Brutto", "Stopa PDV",
|
||||
"Valuta", "Status", "Datum uplate", "IBAN primatelja",
|
||||
"Opis", "Kategorija"]
|
||||
bold = Font(bold=True, color="FFFFFF")
|
||||
fill = PatternFill("solid", fgColor="003087")
|
||||
for col_idx, h in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col_idx, value=h)
|
||||
cell.font = bold; cell.fill = fill
|
||||
cell.alignment = Alignment(horizontal="center")
|
||||
for r_idx, r in enumerate(rows, 2):
|
||||
ws.cell(row=r_idx, column=1, value=r.get("id"))
|
||||
ws.cell(row=r_idx, column=2, value=str(r.get("invoice_date") or ""))
|
||||
ws.cell(row=r_idx, column=3, value=r.get("invoice_kind"))
|
||||
ws.cell(row=r_idx, column=4, value=r.get("invoice_no"))
|
||||
ws.cell(row=r_idx, column=5, value=r.get("vendor_name"))
|
||||
ws.cell(row=r_idx, column=6, value=r.get("vendor_oib"))
|
||||
ws.cell(row=r_idx, column=7, value=r.get("klub_naziv"))
|
||||
ws.cell(row=r_idx, column=8, value=float(r["amount_net"]) if r.get("amount_net") is not None else None)
|
||||
ws.cell(row=r_idx, column=9, value=float(r["amount_vat"]) if r.get("amount_vat") is not None else None)
|
||||
ws.cell(row=r_idx, column=10, value=float(r["amount_gross"]) if r.get("amount_gross") is not None else None)
|
||||
ws.cell(row=r_idx, column=11, value=float(r["vat_rate"]) if r.get("vat_rate") is not None else None)
|
||||
ws.cell(row=r_idx, column=12, value=r.get("currency"))
|
||||
ws.cell(row=r_idx, column=13, value=r.get("payment_status"))
|
||||
ws.cell(row=r_idx, column=14, value=str(r.get("paid_date") or ""))
|
||||
ws.cell(row=r_idx, column=15, value=r.get("iban_to"))
|
||||
ws.cell(row=r_idx, column=16, value=r.get("description"))
|
||||
ws.cell(row=r_idx, column=17, value=r.get("category"))
|
||||
# Auto width
|
||||
widths = [6, 12, 12, 18, 28, 14, 24, 12, 12, 12, 8, 6, 11, 12, 22, 30, 12]
|
||||
for i, w in enumerate(widths, 1):
|
||||
ws.column_dimensions[ws.cell(row=1, column=i).column_letter].width = w
|
||||
ws.freeze_panes = "A2"
|
||||
|
||||
buf = BytesIO()
|
||||
wb.save(buf); buf.seek(0)
|
||||
fname = f"racuni_{date.today().isoformat()}.xlsx"
|
||||
return StreamingResponse(
|
||||
buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
|
||||
)
|
||||
|
||||
|
||||
# ── R5.6 STATS ─────────────────────────────────────────────────────────
|
||||
@router.get("/stats")
|
||||
def erp_stats(
|
||||
klub_id: Optional[int] = Query(None),
|
||||
tenant_id: Optional[int] = Query(None),
|
||||
authorization: Optional[str] = Header(None),
|
||||
):
|
||||
"""Statistika ERP-a: ukupno troškova mjesec/kvartal/godina po klubu/savezu,
|
||||
breakdown po vrstama (gorivo/cestarina/hotel/oprema/ostalo)."""
|
||||
user = _resolve_user(authorization)
|
||||
today = date.today()
|
||||
month_start = today.replace(day=1).isoformat()
|
||||
qmonth = ((today.month - 1) // 3) * 3 + 1
|
||||
quarter_start = today.replace(month=qmonth, day=1).isoformat()
|
||||
year_start = today.replace(month=1, day=1).isoformat()
|
||||
|
||||
where = ["1=1"]; args: list = []
|
||||
if klub_id is not None:
|
||||
where.append("klub_id=%s"); args.append(klub_id)
|
||||
if tenant_id is not None:
|
||||
where.append("tenant_id=%s"); args.append(tenant_id)
|
||||
where_sql = " AND ".join(where)
|
||||
|
||||
def q_sum(date_from):
|
||||
with _db() as c:
|
||||
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
f"""SELECT COUNT(*) AS n,
|
||||
COALESCE(SUM(amount_gross),0)::float AS total,
|
||||
COALESCE(SUM(CASE WHEN payment_status='paid' THEN amount_gross END),0)::float AS paid,
|
||||
COALESCE(SUM(CASE WHEN payment_status<>'paid' THEN amount_gross END),0)::float AS unpaid
|
||||
FROM pgz_sport.invoices
|
||||
WHERE {where_sql} AND invoice_date >= %s""",
|
||||
args + [date_from],
|
||||
)
|
||||
return cur.fetchone()
|
||||
|
||||
def q_breakdown(date_from):
|
||||
with _db() as c:
|
||||
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
f"""SELECT invoice_kind, COUNT(*) AS n,
|
||||
COALESCE(SUM(amount_gross),0)::float AS total
|
||||
FROM pgz_sport.invoices
|
||||
WHERE {where_sql} AND invoice_date >= %s
|
||||
GROUP BY invoice_kind ORDER BY total DESC""",
|
||||
args + [date_from],
|
||||
)
|
||||
return cur.fetchall()
|
||||
|
||||
def q_top(date_from):
|
||||
with _db() as c:
|
||||
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
f"""SELECT i.klub_id, k.naziv AS klub_naziv,
|
||||
COUNT(*) AS n, COALESCE(SUM(i.amount_gross),0)::float AS total
|
||||
FROM pgz_sport.invoices i
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id
|
||||
WHERE {where_sql} AND i.invoice_date >= %s
|
||||
GROUP BY i.klub_id, k.naziv ORDER BY total DESC LIMIT 10""",
|
||||
args + [date_from],
|
||||
)
|
||||
return cur.fetchall()
|
||||
|
||||
# Putni nalozi totals
|
||||
def q_pn(date_from):
|
||||
with _db() as c:
|
||||
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
pn_where = ["report_type='putni_nalog'"]; pn_args: list = []
|
||||
if klub_id is not None:
|
||||
pn_where.append("klub_id=%s"); pn_args.append(klub_id)
|
||||
if tenant_id is not None:
|
||||
pn_where.append("tenant_id=%s"); pn_args.append(tenant_id)
|
||||
cur.execute(
|
||||
f"""SELECT COUNT(*) AS n,
|
||||
COALESCE(SUM(cost_total),0)::float AS total,
|
||||
COALESCE(SUM(dnevnice_amount),0)::float AS dnevnice,
|
||||
COALESCE(SUM(cost_transport),0)::float AS transport
|
||||
FROM pgz_sport.expense_reports
|
||||
WHERE {' AND '.join(pn_where)} AND date_from >= %s""",
|
||||
pn_args + [date_from],
|
||||
)
|
||||
return cur.fetchone()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"as_of": today.isoformat(),
|
||||
"filters": {"klub_id": klub_id, "tenant_id": tenant_id},
|
||||
"invoices": {
|
||||
"month": {"since": month_start, **q_sum(month_start), "by_kind": q_breakdown(month_start)},
|
||||
"quarter": {"since": quarter_start, **q_sum(quarter_start), "by_kind": q_breakdown(quarter_start)},
|
||||
"year": {"since": year_start, **q_sum(year_start), "by_kind": q_breakdown(year_start)},
|
||||
},
|
||||
"top_klubovi_godina": q_top(year_start),
|
||||
"putni_nalozi": {
|
||||
"month": {"since": month_start, **q_pn(month_start)},
|
||||
"quarter": {"since": quarter_start, **q_pn(quarter_start)},
|
||||
"year": {"since": year_start, **q_pn(year_start)},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/invoices/uploads/list")
|
||||
def uploads_list(klub_id: Optional[int] = None, status: Optional[str] = None, limit: int = 50):
|
||||
sql = """SELECT id, klub_id, file_name, file_size, mime, ocr_status, ocr_engine,
|
||||
|
||||
Reference in New Issue
Block a user