PDF link target=_blank + nginx timeouts + priority filteri (samo s podacima)

nginx (sport.rinet.one):
- proxy_read_timeout 60s → 300s
- proxy_send_timeout 300s
- proxy_buffering off (PDF stream)
- client_max_body_size 50M → 100M

Endpoints:
- /api/v2/klubovi/financirani: +with_data filter (samo s potporama/godišnjakom/HNS)
- /api/v2/sportasi/filtered: +samo_priority +samo_s_hns

Frontend:
- PDF link target=_blank rel=noopener
- window._klub_only_priority = true (default)
- window._sportas_only_priority = true (default)

DB View:
- pgz_sport.v_nogomet_priority (prima_potpore, u_godisnjaku, ima_hns_roster)
This commit is contained in:
2026-05-05 13:51:07 +02:00
parent c6a5ec62aa
commit f7b5114f58
289 changed files with 37204 additions and 363 deletions
+493
View File
@@ -953,3 +953,496 @@ def delete_case(cid: int, user=Depends(_require_user)):
cur.execute("DELETE FROM pgz_sport.crm_cases WHERE id=%s", (cid,))
cn.commit()
return {"ok": True, "id": cid}
# ═══════════════════════════════════════════════════════════════════
# AGENT F — Salesforce-Lite extra tabs (Članarine / Liječnički / Obrasci)
# Added: 2026-05-05 by dradulic@outlook.com / damir@rinet.one
# Tables: pgz_sport.clanarine, pgz_sport.lijecnicki_pregledi,
# pgz_sport.form_templates, pgz_sport.form_submissions
# ═══════════════════════════════════════════════════════════════════
import json as _json
CLANARINA_STATUSES = ['nepodmireno', 'djelomicno', 'podmireno', 'storno']
SUBMISSION_STATUSES = ['draft', 'submitted', 'approved', 'rejected']
# ─────────── ČLANARINE ───────────
class ClanarinaIn(BaseModel):
clan_id: Optional[int] = None
klub_id: Optional[int] = None
godina: int
razdoblje: Optional[str] = None
iznos_propisan: float
iznos_placen: Optional[float] = 0
datum_uplate: Optional[str] = None
nacin_uplate: Optional[str] = None
referenca: Optional[str] = None
racun_broj: Optional[str] = None
status: str = 'nepodmireno'
napomena: Optional[str] = None
@router.get("/clanarine")
def list_clanarine(klub_id: Optional[int] = None, clan_id: Optional[int] = None,
godina: Optional[int] = None, status: Optional[str] = None,
limit: int = 500, user=Depends(_require_user)):
where, args = ["1=1"], []
if klub_id: where.append("c.klub_id=%s"); args.append(klub_id)
if clan_id: where.append("c.clan_id=%s"); args.append(clan_id)
if godina: where.append("c.godina=%s"); args.append(godina)
if status: where.append("c.status=%s"); args.append(status)
args.append(limit)
sql = f"""
SELECT c.*,
(cl.ime||' '||cl.prezime) AS clan_naziv,
k.naziv AS klub_naziv
FROM pgz_sport.clanarine c
LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE {' AND '.join(where)}
ORDER BY c.godina DESC, c.id DESC
LIMIT %s
"""
with _conn() as cn, cn.cursor() as cur:
cur.execute(sql, args)
items = _rows(cur.fetchall())
return {"items": items, "count": len(items)}
@router.get("/clanarine/{cid}")
def get_clanarina(cid: int, user=Depends(_require_user)):
with _conn() as cn, cn.cursor() as cur:
cur.execute("""
SELECT c.*,
(cl.ime||' '||cl.prezime) AS clan_naziv,
k.naziv AS klub_naziv
FROM pgz_sport.clanarine c
LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE c.id=%s
""", (cid,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "not found")
return _row(row)
@router.post("/clanarine")
def create_clanarina(req: ClanarinaIn, user=Depends(_require_user)):
if req.status not in CLANARINA_STATUSES:
raise HTTPException(400, "invalid status")
with _conn() as cn, cn.cursor() as cur:
cur.execute("""
INSERT INTO pgz_sport.clanarine
(clan_id, klub_id, godina, razdoblje, iznos_propisan, iznos_placen,
datum_uplate, nacin_uplate, referenca, racun_broj, status, napomena)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""", (req.clan_id, req.klub_id, req.godina, req.razdoblje,
req.iznos_propisan, req.iznos_placen or 0,
req.datum_uplate, req.nacin_uplate, req.referenca,
req.racun_broj, req.status, req.napomena))
cn.commit()
return _row(cur.fetchone())
@router.put("/clanarine/{cid}")
def update_clanarina(cid: int, req: ClanarinaIn, user=Depends(_require_user)):
if req.status not in CLANARINA_STATUSES:
raise HTTPException(400, "invalid status")
with _conn() as cn, cn.cursor() as cur:
cur.execute("SELECT id FROM pgz_sport.clanarine WHERE id=%s", (cid,))
if not cur.fetchone():
raise HTTPException(404, "not found")
cur.execute("""
UPDATE pgz_sport.clanarine
SET clan_id=%s, klub_id=%s, godina=%s, razdoblje=%s,
iznos_propisan=%s, iznos_placen=%s, datum_uplate=%s,
nacin_uplate=%s, referenca=%s, racun_broj=%s,
status=%s, napomena=%s, updated_at=now()
WHERE id=%s
RETURNING *
""", (req.clan_id, req.klub_id, req.godina, req.razdoblje,
req.iznos_propisan, req.iznos_placen or 0,
req.datum_uplate, req.nacin_uplate, req.referenca,
req.racun_broj, req.status, req.napomena, cid))
cn.commit()
return _row(cur.fetchone())
@router.delete("/clanarine/{cid}")
def delete_clanarina(cid: int, user=Depends(_require_user)):
if not _is_admin(user):
raise HTTPException(403, "admin only")
with _conn() as cn, cn.cursor() as cur:
cur.execute("DELETE FROM pgz_sport.clanarine WHERE id=%s", (cid,))
cn.commit()
return {"ok": True, "id": cid}
# ─────────── LIJEČNIČKI PREGLEDI ───────────
class LijecnickiIn(BaseModel):
clan_id: int
klub_id: Optional[int] = None
datum_pregleda: str
vrijedi_do: Optional[str] = None
vrsta_pregleda: Optional[str] = None
ustanova: Optional[str] = None
lijecnik: Optional[str] = None
spreman_za_natjecanje: Optional[bool] = True
ekg: Optional[bool] = False
krv: Optional[bool] = False
spirometrija: Optional[bool] = False
nalaz: Optional[str] = None
komentar_lijecnika: Optional[str] = None
preporuke: Optional[str] = None
iznos: Optional[float] = None
iznos_zzjz: Optional[float] = 0
iznos_klub: Optional[float] = 0
iznos_clan: Optional[float] = 0
datum_placanja: Optional[str] = None
placeno: Optional[bool] = False
racun_broj: Optional[str] = None
nacin_placanja: Optional[str] = None
napomena: Optional[str] = None
@router.get("/lijecnicki")
def list_lijecnicki(klub_id: Optional[int] = None, clan_id: Optional[int] = None,
expiring: Optional[bool] = None, limit: int = 500,
user=Depends(_require_user)):
"""expiring=true → vrijedi_do u idućih 30d ili manje (ili prošlo)."""
where, args = ["1=1"], []
if klub_id: where.append("l.klub_id=%s"); args.append(klub_id)
if clan_id: where.append("l.clan_id=%s"); args.append(clan_id)
if expiring:
where.append("(l.vrijedi_do IS NULL OR l.vrijedi_do <= current_date + interval '30 days')")
args.append(limit)
sql = f"""
SELECT l.*,
(cl.ime||' '||cl.prezime) AS clan_naziv,
k.naziv AS klub_naziv
FROM pgz_sport.lijecnicki_pregledi l
LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
WHERE {' AND '.join(where)}
ORDER BY l.datum_pregleda DESC, l.id DESC
LIMIT %s
"""
with _conn() as cn, cn.cursor() as cur:
cur.execute(sql, args)
items = _rows(cur.fetchall())
return {"items": items, "count": len(items)}
@router.get("/lijecnicki/{lid}")
def get_lijecnicki(lid: int, user=Depends(_require_user)):
with _conn() as cn, cn.cursor() as cur:
cur.execute("""
SELECT l.*,
(cl.ime||' '||cl.prezime) AS clan_naziv,
k.naziv AS klub_naziv
FROM pgz_sport.lijecnicki_pregledi l
LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
WHERE l.id=%s
""", (lid,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "not found")
return _row(row)
@router.post("/lijecnicki")
def create_lijecnicki(req: LijecnickiIn, user=Depends(_require_user)):
with _conn() as cn, cn.cursor() as cur:
cur.execute("""
INSERT INTO pgz_sport.lijecnicki_pregledi
(clan_id, klub_id, datum_pregleda, vrijedi_do, vrsta_pregleda,
ustanova, lijecnik, spreman_za_natjecanje, ekg, krv, spirometrija,
nalaz, komentar_lijecnika, preporuke,
iznos, iznos_zzjz, iznos_klub, iznos_clan,
datum_placanja, placeno, racun_broj, nacin_placanja, napomena)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,
%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""", (req.clan_id, req.klub_id, req.datum_pregleda, req.vrijedi_do,
req.vrsta_pregleda, req.ustanova, req.lijecnik,
req.spreman_za_natjecanje, req.ekg, req.krv, req.spirometrija,
req.nalaz, req.komentar_lijecnika, req.preporuke,
req.iznos, req.iznos_zzjz or 0, req.iznos_klub or 0, req.iznos_clan or 0,
req.datum_placanja, req.placeno, req.racun_broj,
req.nacin_placanja, req.napomena))
cn.commit()
return _row(cur.fetchone())
@router.put("/lijecnicki/{lid}")
def update_lijecnicki(lid: int, req: LijecnickiIn, user=Depends(_require_user)):
with _conn() as cn, cn.cursor() as cur:
cur.execute("SELECT id FROM pgz_sport.lijecnicki_pregledi WHERE id=%s", (lid,))
if not cur.fetchone():
raise HTTPException(404, "not found")
cur.execute("""
UPDATE pgz_sport.lijecnicki_pregledi
SET clan_id=%s, klub_id=%s, datum_pregleda=%s, vrijedi_do=%s,
vrsta_pregleda=%s, ustanova=%s, lijecnik=%s,
spreman_za_natjecanje=%s, ekg=%s, krv=%s, spirometrija=%s,
nalaz=%s, komentar_lijecnika=%s, preporuke=%s,
iznos=%s, iznos_zzjz=%s, iznos_klub=%s, iznos_clan=%s,
datum_placanja=%s, placeno=%s, racun_broj=%s,
nacin_placanja=%s, napomena=%s, updated_at=now()
WHERE id=%s
RETURNING *
""", (req.clan_id, req.klub_id, req.datum_pregleda, req.vrijedi_do,
req.vrsta_pregleda, req.ustanova, req.lijecnik,
req.spreman_za_natjecanje, req.ekg, req.krv, req.spirometrija,
req.nalaz, req.komentar_lijecnika, req.preporuke,
req.iznos, req.iznos_zzjz or 0, req.iznos_klub or 0, req.iznos_clan or 0,
req.datum_placanja, req.placeno, req.racun_broj,
req.nacin_placanja, req.napomena, lid))
cn.commit()
return _row(cur.fetchone())
@router.delete("/lijecnicki/{lid}")
def delete_lijecnicki(lid: int, user=Depends(_require_user)):
if not _is_admin(user):
raise HTTPException(403, "admin only")
with _conn() as cn, cn.cursor() as cur:
cur.execute("DELETE FROM pgz_sport.lijecnicki_pregledi WHERE id=%s", (lid,))
cn.commit()
return {"ok": True, "id": lid}
# ─────────── OBRASCI (templates + submissions) ───────────
class SubmissionIn(BaseModel):
template_id: Optional[int] = None
template_code: Optional[str] = None
klub_id: Optional[int] = None
clan_id: Optional[int] = None
data: Dict[str, Any] = Field(default_factory=dict)
status: str = 'draft' # draft / submitted
reference_no: Optional[str] = None
class SubmissionStatusIn(BaseModel):
status: str # submitted / approved / rejected
rejected_reason: Optional[str] = None
@router.get("/obrasci")
def list_obrasci_templates(kategorija: Optional[str] = None,
active: bool = True, user=Depends(_require_user)):
where, args = [], []
if active:
where.append("active=true")
if kategorija:
where.append("kategorija=%s"); args.append(kategorija)
sql = "SELECT id, code, naziv, kategorija, opis, schema_json, required_role, active FROM pgz_sport.form_templates"
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY kategorija NULLS LAST, naziv"
with _conn() as cn, cn.cursor() as cur:
cur.execute(sql, args)
items = _rows(cur.fetchall())
return {"items": items, "count": len(items)}
@router.get("/obrasci/submission")
def list_obrasci_submissions(status: Optional[str] = None,
klub_id: Optional[int] = None,
template_code: Optional[str] = None,
clan_id: Optional[int] = None,
limit: int = 500,
user=Depends(_require_user)):
where, args = ["1=1"], []
if status: where.append("s.status=%s"); args.append(status)
if klub_id: where.append("s.klub_id=%s"); args.append(klub_id)
if template_code: where.append("s.template_code=%s"); args.append(template_code)
if clan_id: where.append("s.clan_id=%s"); args.append(clan_id)
args.append(limit)
sql = f"""
SELECT s.id, s.template_id, s.template_code, s.klub_id, s.clan_id,
s.user_id, s.data, s.status, s.reference_no,
s.submitted_at, s.reviewed_at, s.approved_at, s.rejected_reason,
s.created_at, s.updated_at,
t.naziv AS template_naziv, t.kategorija,
k.naziv AS klub_naziv,
(cl.ime||' '||cl.prezime) AS clan_naziv,
u.email AS submitter_email
FROM pgz_sport.form_submissions s
LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id
LEFT JOIN pgz_sport.klubovi k ON k.id = s.klub_id
LEFT JOIN pgz_sport.clanovi cl ON cl.id = s.clan_id
LEFT JOIN pgz_sport.users u ON u.id = s.user_id
WHERE {' AND '.join(where)}
ORDER BY s.created_at DESC
LIMIT %s
"""
with _conn() as cn, cn.cursor() as cur:
cur.execute(sql, args)
items = _rows(cur.fetchall())
return {"items": items, "count": len(items)}
@router.get("/obrasci/submission/{sid}")
def get_obrazac_submission(sid: int, user=Depends(_require_user)):
with _conn() as cn, cn.cursor() as cur:
cur.execute("""
SELECT s.*,
t.naziv AS template_naziv, t.kategorija, t.schema_json,
k.naziv AS klub_naziv,
(cl.ime||' '||cl.prezime) AS clan_naziv,
u.email AS submitter_email
FROM pgz_sport.form_submissions s
LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id
LEFT JOIN pgz_sport.klubovi k ON k.id = s.klub_id
LEFT JOIN pgz_sport.clanovi cl ON cl.id = s.clan_id
LEFT JOIN pgz_sport.users u ON u.id = s.user_id
WHERE s.id=%s
""", (sid,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "not found")
return _row(row)
@router.post("/obrasci/submission")
def create_obrazac_submission(req: SubmissionIn, user=Depends(_require_user)):
if req.status not in ('draft', 'submitted'):
raise HTTPException(400, "invalid status (draft|submitted)")
tid = req.template_id
tcode = req.template_code
with _conn() as cn, cn.cursor() as cur:
if tid and not tcode:
cur.execute("SELECT code FROM pgz_sport.form_templates WHERE id=%s", (tid,))
r = cur.fetchone()
if not r:
raise HTTPException(400, "unknown template_id")
tcode = r["code"]
elif tcode and not tid:
cur.execute("SELECT id FROM pgz_sport.form_templates WHERE code=%s", (tcode,))
r = cur.fetchone()
if not r:
raise HTTPException(400, "unknown template_code")
tid = r["id"]
elif not tid and not tcode:
raise HTTPException(400, "template_id or template_code required")
submitted_at = "now()" if req.status == 'submitted' else "NULL"
cur.execute(f"""
INSERT INTO pgz_sport.form_submissions
(template_id, template_code, klub_id, user_id, clan_id,
data, status, reference_no, submitted_at)
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s,%s,{submitted_at})
RETURNING *
""", (tid, tcode, req.klub_id, user["user_id"], req.clan_id,
_json.dumps(req.data or {}), req.status, req.reference_no))
cn.commit()
return _row(cur.fetchone())
@router.put("/obrasci/submission/{sid}")
def update_obrazac_submission(sid: int, req: SubmissionIn, user=Depends(_require_user)):
"""Submitter can update their own draft."""
with _conn() as cn, cn.cursor() as cur:
cur.execute("SELECT user_id, status FROM pgz_sport.form_submissions WHERE id=%s", (sid,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "not found")
if not _is_admin(user) and row["user_id"] != user["user_id"]:
raise HTTPException(403, "not your submission")
if row["status"] not in ('draft', 'submitted') and not _is_admin(user):
raise HTTPException(400, "submission already finalized")
cur.execute("""
UPDATE pgz_sport.form_submissions
SET klub_id=%s, clan_id=%s, data=%s::jsonb,
status=%s, reference_no=%s,
submitted_at = CASE WHEN %s='submitted' AND submitted_at IS NULL THEN now() ELSE submitted_at END,
updated_at=now()
WHERE id=%s
RETURNING *
""", (req.klub_id, req.clan_id, _json.dumps(req.data or {}),
req.status, req.reference_no, req.status, sid))
cn.commit()
return _row(cur.fetchone())
@router.put("/obrasci/submission/{sid}/status")
def set_submission_status(sid: int, req: SubmissionStatusIn, user=Depends(_require_user)):
"""Approve/reject (admin) or self-submit a draft (owner)."""
if req.status not in SUBMISSION_STATUSES:
raise HTTPException(400, "invalid status")
with _conn() as cn, cn.cursor() as cur:
cur.execute("SELECT user_id, status FROM pgz_sport.form_submissions WHERE id=%s", (sid,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "not found")
if req.status in ('approved', 'rejected'):
if not _is_admin(user):
raise HTTPException(403, "admin only")
elif req.status == 'submitted':
if row["user_id"] != user["user_id"] and not _is_admin(user):
raise HTTPException(403, "not your submission")
if req.status == 'approved':
cur.execute("""
UPDATE pgz_sport.form_submissions
SET status='approved', approved_by=%s, approved_at=now(),
reviewed_by=%s, reviewed_at=now(), updated_at=now()
WHERE id=%s RETURNING *
""", (user["user_id"], user["user_id"], sid))
elif req.status == 'rejected':
cur.execute("""
UPDATE pgz_sport.form_submissions
SET status='rejected', rejected_reason=%s,
reviewed_by=%s, reviewed_at=now(), updated_at=now()
WHERE id=%s RETURNING *
""", (req.rejected_reason, user["user_id"], sid))
elif req.status == 'submitted':
cur.execute("""
UPDATE pgz_sport.form_submissions
SET status='submitted',
submitted_at = COALESCE(submitted_at, now()),
updated_at=now()
WHERE id=%s RETURNING *
""", (sid,))
else: # draft
cur.execute("""
UPDATE pgz_sport.form_submissions
SET status='draft', updated_at=now()
WHERE id=%s RETURNING *
""", (sid,))
cn.commit()
return _row(cur.fetchone())
@router.delete("/obrasci/submission/{sid}")
def delete_obrazac_submission(sid: int, user=Depends(_require_user)):
with _conn() as cn, cn.cursor() as cur:
cur.execute("SELECT user_id, status FROM pgz_sport.form_submissions WHERE id=%s", (sid,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "not found")
if not _is_admin(user) and row["user_id"] != user["user_id"]:
raise HTTPException(403, "not your submission")
cur.execute("DELETE FROM pgz_sport.form_submissions WHERE id=%s", (sid,))
cn.commit()
return {"ok": True, "id": sid}
@router.get("/obrasci/{tid}")
def get_obrazac_template(tid: int, user=Depends(_require_user)):
with _conn() as cn, cn.cursor() as cur:
cur.execute("SELECT * FROM pgz_sport.form_templates WHERE id=%s", (tid,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "not found")
return _row(row)
+51 -3
View File
@@ -1,27 +1,38 @@
#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════
# Fajl: routers/erp_full_router.py | v1.0.0 | 05.05.2026
# Fajl: routers/erp_full_router.py | v1.1.0 | 05.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
# Lokacija: /opt/pgz-sport/routers/erp_full_router.py
# Svrha: FULL ERP (SAP-Lite) — kontni plan, dnevnik, glavna knjiga,
# partneri, ulazni/izlazni računi (+ FINA e-Račun XML import),
# PDV, plaće, izvještaji (Bilanca/PnL/Cashflow), PDF/XLSX export.
# PDV, plaće, izvještaji (Bilanca/PnL/Cashflow), PDF/XLSX export,
# invoice_uploads (OCR), expense_reports (Putni nalozi), payments.
# v1.1.0 (2026-05-05): + POST /invoice-uploads multipart upload (Agent E).
# Mount: /api/v2/erp/*
# ═══════════════════════════════════════════════════════════════════
from __future__ import annotations
import hashlib
import os
import re
from datetime import date, datetime
from decimal import Decimal
from io import BytesIO
from pathlib import Path
from typing import Optional, List
from xml.etree import ElementTree as ET
import psycopg2
import psycopg2.extras
from fastapi import APIRouter, HTTPException, Query, Body, UploadFile, File, Depends, Header
from fastapi import APIRouter, HTTPException, Query, Body, UploadFile, File, Depends, Header, Form
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
# ── Upload destination (relative to web root /uploads/...) ──────────
UPLOAD_BASE = Path("/opt/pgz-sport/uploads")
INVOICE_UPLOAD_DIR = UPLOAD_BASE / "invoices"
INVOICE_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
router = APIRouter(prefix="/api/v2/erp", tags=["erp_full"])
DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7')
@@ -1172,6 +1183,43 @@ def racuni_ulazni_uploads(rid: int):
return {"count": len(rows), "rows": rows}
# ── Upload new invoice file (multipart) ─────────────────────────────
@router.post("/invoice-uploads")
async def invoice_uploads_create(
file: UploadFile = File(...),
klub_id: Optional[int] = Form(None),
invoice_id: Optional[int] = Form(None),
):
"""Accepts PDF/JPG/PNG of an invoice. Stores file under /uploads/invoices/
and inserts a row into invoice_uploads with ocr_status='pending'.
Returns the new id; OCR/AI extraction runs separately."""
raw = await file.read()
if not raw:
raise HTTPException(400, "Empty file")
if len(raw) > 25 * 1024 * 1024:
raise HTTPException(413, "File > 25 MB")
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", file.filename or "upload.bin")[:120]
sha = hashlib.sha256(raw).hexdigest()
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
rel = f"invoices/{ts}_{sha[:10]}_{safe}"
abs_path = UPLOAD_BASE / rel
abs_path.parent.mkdir(parents=True, exist_ok=True)
with open(abs_path, "wb") as f:
f.write(raw)
new_id = db_exec(
"INSERT INTO pgz_sport.invoice_uploads "
"(klub_id, file_name, file_path, file_size, mime, sha256, "
" ocr_status, invoice_id, uploaded_at) "
"VALUES (%s,%s,%s,%s,%s,%s,'pending',%s, now()) RETURNING id",
(klub_id, file.filename, rel, len(raw),
file.content_type or "application/octet-stream",
sha, invoice_id),
returning=True,
)
return {"ok": True, "id": new_id, "file_path": rel,
"file_size": len(raw), "sha256": sha}
# ═══════════════════════════════════════════════════════════════════
# 12) PUTNI NALOZI / EXPENSE REPORTS
# ═══════════════════════════════════════════════════════════════════
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env python3
# ===================================================================
# Fajl: routers/kalendar_router.py | v1.0.0 | 05.05.2026
# Autor: Damir Radulic <dradulic@outlook.com> / damir@rinet.one
# Lokacija: /opt/pgz-sport/routers/kalendar_router.py
# Svrha: Kalendar CRUD (eventi/manifestacije/sastanci/termini) za /app#kalendar.
# Endpoints under /api/v2/kalendar:
# GET /events?from=&to=&klub_id=&savez_id=
# POST /events
# GET /events/{id}
# PUT /events/{id}
# DELETE /events/{id}
# ===================================================================
"""Kalendar v2 router.
Prefix: /api/v2/kalendar
Backed by table pgz_sport.kalendar_events.
"""
from __future__ import annotations
import hashlib
from datetime import date, datetime
from typing import Optional, List, Dict, Any
import psycopg2
from psycopg2.extras import RealDictCursor
from fastapi import APIRouter, HTTPException, Query, Body, Depends, Header
from pydantic import BaseModel, Field
router = APIRouter(prefix="/api/v2/kalendar", tags=["kalendar-v2"])
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
ALLOWED_TYPES = {"event", "meeting", "manif", "training", "medical", "other"}
ALLOWED_COLORS = {"a", "b", "g", "r"}
# --------- DB helpers ---------
def _conn():
return psycopg2.connect(DSN, cursor_factory=RealDictCursor)
def _conv(v):
if isinstance(v, (date, datetime)):
return v.isoformat()
return v
def _row(d):
if d is None:
return None
return {k: _conv(v) for k, v in dict(d).items()}
def _rows(seq):
return [_row(r) for r in (seq or [])]
# --------- Auth ---------
# Supports both auth styles found in this codebase:
# 1) JWT (auth.auth_v2): token decoded, jti checked in user_sessions
# 2) Legacy opaque token: sha256(token) lookup in user_sessions
# Tries JWT first (canonical), falls back to legacy.
try:
from auth.auth_v2 import get_current_user as _jwt_current_user # type: ignore
except Exception:
_jwt_current_user = None # type: ignore
def _current_user(authorization: Optional[str] = Header(None)) -> Optional[Dict[str, Any]]:
# Prefer JWT path (auth_v2)
if _jwt_current_user is not None:
try:
u = _jwt_current_user(authorization)
if u:
# Normalize key name used downstream
if "user_id" not in u and "id" in u:
u = dict(u)
u["user_id"] = u.get("id")
return u
except Exception:
pass
# Legacy opaque-token fallback
if not authorization:
return None
tok = authorization.replace("Bearer ", "").strip()
if not tok:
return None
th = hashlib.sha256(tok.encode()).hexdigest()
try:
with _conn() as cn, cn.cursor() as cur:
cur.execute(
"""
SELECT s.user_id, u.email, u.user_type, u.klub_id, u.savez_id, u.aktivan,
u.ime, u.prezime
FROM pgz_sport.user_sessions s
JOIN pgz_sport.users u ON u.id = s.user_id
WHERE s.token_hash = %s
AND s.revoked = false
AND s.expires_at > now()
LIMIT 1
""",
(th,),
)
r = cur.fetchone()
if not r:
return None
d = _row(r)
if d.get("aktivan") is False:
return None
return d
except Exception:
return None
def _is_admin(user: Dict) -> bool:
return bool(user) and user.get("user_type") in ("super_admin", "pgz_admin")
def _require_user_optional(user=Depends(_current_user)):
"""Auth optional for GET (so /app dashboard works even pre-login).
Returns user dict or None."""
return user
def _require_user(user=Depends(_current_user)):
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
return user
# --------- Models ---------
class EventIn(BaseModel):
title: str = Field(..., min_length=1, max_length=400)
start_at: str # ISO timestamp
end_at: Optional[str] = None
location: Optional[str] = None
description: Optional[str] = None
event_type: Optional[str] = "event"
color: Optional[str] = "b"
klub_id: Optional[int] = None
savez_id: Optional[int] = None
class EventPatch(BaseModel):
title: Optional[str] = None
start_at: Optional[str] = None
end_at: Optional[str] = None
location: Optional[str] = None
description: Optional[str] = None
event_type: Optional[str] = None
color: Optional[str] = None
klub_id: Optional[int] = None
savez_id: Optional[int] = None
def _validate_payload(p: Dict[str, Any]) -> None:
et = p.get("event_type")
if et is not None and et not in ALLOWED_TYPES:
raise HTTPException(400, f"event_type must be one of {sorted(ALLOWED_TYPES)}")
co = p.get("color")
if co is not None and co not in ALLOWED_COLORS:
raise HTTPException(400, f"color must be one of {sorted(ALLOWED_COLORS)}")
# --------- Endpoints ---------
@router.get("/events")
def list_events(
from_: Optional[str] = Query(None, alias="from"),
to: Optional[str] = None,
klub_id: Optional[int] = None,
savez_id: Optional[int] = None,
limit: int = Query(500, ge=1, le=2000),
user=Depends(_require_user_optional),
):
"""List events in [from, to). Defaults: no bound."""
where = ["1=1"]
params: List[Any] = []
if from_:
where.append("start_at >= %s")
params.append(from_)
if to:
where.append("start_at < %s")
params.append(to)
if klub_id is not None:
where.append("klub_id = %s")
params.append(klub_id)
if savez_id is not None:
where.append("savez_id = %s")
params.append(savez_id)
sql = (
"SELECT id, title, start_at, end_at, location, description, event_type, color, "
"klub_id, savez_id, created_by, created_at, updated_at "
"FROM pgz_sport.kalendar_events "
"WHERE " + " AND ".join(where) + " "
"ORDER BY start_at ASC LIMIT %s"
)
params.append(limit)
with _conn() as cn, cn.cursor() as cur:
cur.execute(sql, params)
rows = _rows(cur.fetchall())
return {"rows": rows, "count": len(rows)}
@router.get("/events/{eid}")
def get_event(eid: int, user=Depends(_require_user_optional)):
with _conn() as cn, cn.cursor() as cur:
cur.execute(
"SELECT * FROM pgz_sport.kalendar_events WHERE id=%s",
(eid,),
)
r = cur.fetchone()
if not r:
raise HTTPException(404, "Event not found")
return _row(r)
@router.post("/events")
def create_event(payload: EventIn, user=Depends(_require_user)):
p = payload.dict()
_validate_payload(p)
with _conn() as cn, cn.cursor() as cur:
cur.execute(
"""
INSERT INTO pgz_sport.kalendar_events
(title, start_at, end_at, location, description, event_type, color,
klub_id, savez_id, created_by)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id, title, start_at, end_at, location, description, event_type, color,
klub_id, savez_id, created_by, created_at, updated_at
""",
(
p["title"],
p["start_at"],
p.get("end_at"),
p.get("location"),
p.get("description"),
p.get("event_type") or "event",
p.get("color") or "b",
p.get("klub_id"),
p.get("savez_id"),
user.get("user_id"),
),
)
new_row = cur.fetchone()
cn.commit()
return _row(new_row)
@router.put("/events/{eid}")
def update_event(eid: int, payload: EventPatch, user=Depends(_require_user)):
p = {k: v for k, v in payload.dict().items() if v is not None}
if not p:
raise HTTPException(400, "No fields to update")
_validate_payload(p)
with _conn() as cn, cn.cursor() as cur:
# Permission: admins can edit anything, others only their own
cur.execute(
"SELECT created_by FROM pgz_sport.kalendar_events WHERE id=%s",
(eid,),
)
existing = cur.fetchone()
if not existing:
raise HTTPException(404, "Event not found")
if not _is_admin(user) and existing["created_by"] not in (user.get("user_id"), None):
raise HTTPException(403, "Insufficient privileges")
sets = ", ".join(f"{k}=%s" for k in p.keys())
params = list(p.values()) + [eid]
cur.execute(
f"UPDATE pgz_sport.kalendar_events SET {sets} WHERE id=%s "
f"RETURNING id, title, start_at, end_at, location, description, event_type, color, "
f"klub_id, savez_id, created_by, created_at, updated_at",
params,
)
out = cur.fetchone()
cn.commit()
return _row(out)
@router.delete("/events/{eid}")
def delete_event(eid: int, user=Depends(_require_user)):
with _conn() as cn, cn.cursor() as cur:
cur.execute(
"SELECT created_by FROM pgz_sport.kalendar_events WHERE id=%s",
(eid,),
)
existing = cur.fetchone()
if not existing:
raise HTTPException(404, "Event not found")
if not _is_admin(user) and existing["created_by"] not in (user.get("user_id"), None):
raise HTTPException(403, "Insufficient privileges")
cur.execute("DELETE FROM pgz_sport.kalendar_events WHERE id=%s", (eid,))
cn.commit()
return {"deleted": eid}
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env python3
# ==============================================================================
# notif_router.py — Notification center API for /app#notif
# Author : Damir Radulić — dradulic@outlook.com / damir@rinet.one
# Version: 1.0.0
# Date : 2026-05-05
# Purpose: REST endpoints under /api/v2/notif/* powering the in-app
# notification center. Operates on pgz_sport.notifications table
# (extended by migrations/notifications_20260505.sql).
# Routes : GET /api/v2/notif/list?unread_only=&limit=&user_id=
# GET /api/v2/notif/count?user_id=
# POST /api/v2/notif/{id}/read
# POST /api/v2/notif/mark-all-read
# DELETE /api/v2/notif/{id}
# ==============================================================================
from __future__ import annotations
from typing import Optional, List, Dict, Any
from fastapi import APIRouter, HTTPException, Query, Header
from pydantic import BaseModel
import psycopg2
import psycopg2.extras
# Reuse the v2 DB config (same as pgz_sport_v2_router)
DB = dict(
host="10.10.0.2",
port=6432,
dbname="rinet_v3",
user="rinet",
password="R1net2026!SecureDB#v7",
)
router = APIRouter(prefix="/api/v2/notif", tags=["pgz_sport_notif"])
# ----------------------------- helpers ---------------------------------------
def _conn():
return psycopg2.connect(**DB)
def _row(r) -> Dict[str, Any]:
if r is None:
return {}
if isinstance(r, dict):
d = dict(r)
else:
d = dict(r)
# JSON-friendly conversions
for k, v in list(d.items()):
if hasattr(v, "isoformat"):
d[k] = v.isoformat()
return d
def _resolve_user_id(authorization: Optional[str]) -> Optional[int]:
"""Best-effort: pull user_id from Bearer token. Tries JWT (auth_v2) first
then falls back to legacy session-token-hash lookup."""
if not authorization:
return None
token = authorization.replace("Bearer ", "").strip()
if not token:
return None
# 1) Try JWT (preferred — current auth)
try:
from auth.auth_v2 import decode_token # type: ignore
payload = decode_token(token)
sub = payload.get("sub")
if sub is not None:
try:
return int(sub)
except (TypeError, ValueError):
pass
except Exception:
pass
# 2) Legacy: SHA-256 hash lookup in user_sessions
import hashlib
th = hashlib.sha256(token.encode()).hexdigest()
try:
with _conn() as c:
cur = c.cursor()
cur.execute(
"""SELECT user_id FROM pgz_sport.user_sessions
WHERE token_hash=%s AND revoked=false AND expires_at>now()
LIMIT 1""",
(th,),
)
r = cur.fetchone()
return r[0] if r else None
except Exception:
return None
# ----------------------------- LIST ------------------------------------------
@router.get("/list")
def notif_list(
unread_only: bool = Query(False),
limit: int = Query(50, ge=1, le=500),
user_id: Optional[int] = Query(None, description="Override (admin); else taken from JWT"),
authorization: Optional[str] = Header(None),
):
"""
Returns InApp notifications visible to the user. System-wide (user_id IS NULL)
rows are always included. If no auth + no user_id, returns only system-wide.
"""
uid = user_id if user_id is not None else _resolve_user_id(authorization)
where = ["channel = 'inapp'"]
params: List[Any] = []
if uid is not None:
where.append("(user_id = %s OR user_id IS NULL)")
params.append(uid)
else:
where.append("user_id IS NULL")
if unread_only:
where.append("COALESCE(is_read, false) = false")
sql = f"""
SELECT id, user_id, kind, title, COALESCE(subject, title) AS subject, body,
link, COALESCE(is_read, read_at IS NOT NULL) AS is_read,
status, created_at, read_at
FROM pgz_sport.notifications
WHERE {' AND '.join(where)}
ORDER BY created_at DESC NULLS LAST, id DESC
LIMIT %s
"""
params.append(limit)
with _conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, params)
rows = [_row(r) for r in cur.fetchall()]
return {"count": len(rows), "rows": rows, "user_id": uid}
# ----------------------------- COUNT -----------------------------------------
@router.get("/count")
def notif_count(
user_id: Optional[int] = Query(None),
authorization: Optional[str] = Header(None),
):
uid = user_id if user_id is not None else _resolve_user_id(authorization)
where = ["channel = 'inapp'"]
params: List[Any] = []
if uid is not None:
where.append("(user_id = %s OR user_id IS NULL)")
params.append(uid)
else:
where.append("user_id IS NULL")
sql = f"""
SELECT
COUNT(*) FILTER (WHERE COALESCE(is_read, read_at IS NOT NULL) = false) AS unread,
COUNT(*) AS total
FROM pgz_sport.notifications
WHERE {' AND '.join(where)}
"""
with _conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, params)
r = cur.fetchone() or {"unread": 0, "total": 0}
return {
"unread": int(r["unread"] or 0),
"total": int(r["total"] or 0),
"user_id": uid,
}
# ----------------------------- MARK ONE READ ---------------------------------
@router.post("/{nid}/read")
def notif_mark_read(nid: int):
with _conn() as conn:
cur = conn.cursor()
cur.execute(
"""UPDATE pgz_sport.notifications
SET is_read = true,
read_at = COALESCE(read_at, now()),
status = CASE WHEN status='pending' THEN 'sent' ELSE status END
WHERE id = %s
RETURNING id""",
(nid,),
)
r = cur.fetchone()
if not r:
raise HTTPException(404, "Notifikacija ne postoji")
conn.commit()
return {"ok": True, "id": nid, "is_read": True}
# ----------------------------- MARK ALL READ ---------------------------------
class MarkAllIn(BaseModel):
user_id: Optional[int] = None
include_system: bool = True
@router.post("/mark-all-read")
def notif_mark_all_read(
body: Optional[MarkAllIn] = None,
authorization: Optional[str] = Header(None),
):
body = body or MarkAllIn()
uid = body.user_id if body.user_id is not None else _resolve_user_id(authorization)
where = ["channel = 'inapp'", "COALESCE(is_read, false) = false"]
params: List[Any] = []
if uid is not None:
if body.include_system:
where.append("(user_id = %s OR user_id IS NULL)")
else:
where.append("user_id = %s")
params.append(uid)
else:
where.append("user_id IS NULL")
with _conn() as conn:
cur = conn.cursor()
cur.execute(
f"""UPDATE pgz_sport.notifications
SET is_read = true,
read_at = COALESCE(read_at, now()),
status = CASE WHEN status='pending' THEN 'sent' ELSE status END
WHERE {' AND '.join(where)}
RETURNING id""",
params,
)
ids = [r[0] for r in cur.fetchall()]
conn.commit()
return {"ok": True, "marked_read": len(ids), "ids": ids[:500], "user_id": uid}
# ----------------------------- DELETE ----------------------------------------
@router.delete("/{nid}")
def notif_delete(nid: int):
with _conn() as conn:
cur = conn.cursor()
cur.execute(
"DELETE FROM pgz_sport.notifications WHERE id = %s RETURNING id",
(nid,),
)
r = cur.fetchone()
if not r:
raise HTTPException(404, "Notifikacija ne postoji")
conn.commit()
return {"ok": True, "id": nid, "deleted": True}