Files
pgz-sport/pgz_sport_api.py
T
Damir Radulić 5cf9236d52 CC5 R6: ZIP batch HUB-3 + e-mail templates + /api/notifications/me
Backend (routers/crm_extras_router.py):
- POST /api/crm/clanarine/bulk/uplatnice.zip — generira ZIP archive sa
  HUB-3 PDF uplatnicama (filename: <KlubSlug>/<Prezime_Ime>-<id>-<godina>.pdf),
  + _manifest.txt + _manifest.json. Header X-Batch-Count = broj PDF-ova.
- pgz_sport.email_templates tablica (NEW) + 3 default templata seed-ana:
    clanarina_opomena, lijecnicki_podsjetnik, obrazac_potpis
- GET/POST/PUT /api/crm/email-templates — CRUD
- POST /api/crm/email-templates/{code}/render — popuni {{var}} → subject+body
- POST /api/crm/email-templates/{code}/send — mock send (upiše u notifications
  s channel=email + inapp)
- GET /api/notifications/me + /api/crm/notifications/me — user-scope unread
  notifs (resolva user_id iz JWT 'sub' ili X-User-Id headera, fallback =
  broadcast s user_id IS NULL); summary za badge

Frontend (crm.html):
- Bulk bar: + "🗜 Batch ZIP (PDF-ovi)" gumb (download blob s X-Batch-Count)
- Novi tab "📨 E-mail templates": lista s preview/edit/create modali,
  ▶ Preview render s test podacima per template, 📤 mock send
- API wrapper sad automatski šalje JWT iz localStorage 'jwt' ili
  'access_token'; quick-login fallback (damir@pgz.hr / PGZ2026!) na 401
  za POST/PUT zahtjeve. Avatar upload + ZIP fetch također passu Bearer.

5/5 live curl tests passed:
  ✓ /email-templates list (3 templata)
  ✓ /email-templates/lijecnicki_podsjetnik/render → subject+body
  ✓ /email-templates/obrazac_potpis/send → 2 notifs queued
  ✓ /clanarine/bulk/uplatnice.zip (50 IDs → 40 PDFs + 2 manifests, 354 KB)
  ✓ /api/notifications/me (X-User-Id:1 → user_id=1, 19 unread)
2026-05-05 01:45:45 +02:00

1756 lines
82 KiB
Python

#!/usr/bin/env python3
"""
pgz_sport_api.py - FastAPI backend za PGŽ Sportski savez ERP/CRM
Author: Damir Radulić (damir@rinet.one)
Date: 25.04.2026
Port: 8095
Endpoints: savezi, klubovi, članovi, članarine, liječnički, manifestacije, proračun, dashboard, alertovi
"""
from fastapi import FastAPI, HTTPException, Query, Body, Header, Depends, UploadFile, File, Form, Request
import json
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional, List
from datetime import date, datetime
import psycopg2
import psycopg2.extras
from pgz_sport_v2_router import router as v2_router
import os
DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7')
ADMIN_TOKEN = 'admin-pgz-2026'
def is_admin(authorization):
if not authorization: return False
token = authorization.replace('Bearer ', '').strip()
if token == ADMIN_TOKEN: return True
# Try JWT
try:
import jwt as _jwt
payload = _jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
return payload.get("role") == "admin"
except Exception:
return False
def blur_oib(v):
if not v: return v
s = str(v);
return s[:3] + ''*(len(s)-5) + s[-2:] if len(s) >= 8 else ''*len(s)
def blur_email(e):
if not e or '@' not in str(e): return e
u, d = str(e).split('@',1); return (u[:1]+'•••' if u else '')+'@'+d
def blur_phone(p):
if not p: return p
s=str(p); return s[:4]+''*(len(s)-7)+s[-3:] if len(s)>=7 else s
def blur_iban(v):
if not v: return v
s=str(v); return s[:4]+''*(len(s)-8)+s[-4:] if len(s)>=8 else s
def blur_date(d):
if not d: return d
s = str(d); return s[:4]+'-••-••' if len(s)>=4 else s
def blur_text(t, keep=3):
if not t: return t
s=str(t); return s[:keep]+''*(len(s)-keep*2)+s[-keep:] if len(s)>keep*2 else s
def apply_privacy(rows, admin):
if admin: return rows
out = []
for r in (rows if isinstance(rows, list) else [rows]):
rr = dict(r)
for k, v in list(rr.items()):
if v is None: continue
kl = k.lower()
if 'oib' in kl: rr[k] = blur_oib(v)
elif 'email' in kl: rr[k] = blur_email(v)
elif kl in ('telefon','tel','phone'): rr[k] = blur_phone(v)
elif kl == 'datum_rodenja': rr[k] = blur_date(v)
elif 'iban' in kl: rr[k] = blur_iban(v)
elif kl == 'adresa': rr[k] = blur_text(v, 3)
elif 'licenca_broj' in kl: rr[k] = blur_text(v, 2)
out.append(rr)
return out if isinstance(rows, list) else out[0]
app = FastAPI(title="PGŽ Sportski savez ERP/CRM", version="1.0.0")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
# ─── R5 #1 + R6 #1: Defense-in-depth JWT enforcement ───
# Mutating requests (POST/PUT/PATCH/DELETE) under /api/* require a valid
# Bearer JWT, except for explicitly-public auth & consent endpoints.
# All /api/admin/* requests (any method) also require auth.
_PUBLIC_MUTATING_PATHS = {
"/api/auth/login", "/api/auth/refresh", "/api/auth/forgot-password",
"/api/auth/password/reset", "/api/auth/reset-password",
"/api/auth/setup-password", "/api/auth/google",
"/api/gdpr/consent",
}
_PUBLIC_MUTATING_SUFFIXES = (
"/avatar", # /api/crm/clanovi/{id}/avatar — demo mode handled in handler
)
# CC6: enrichment endpoints are demo-mode public — they only fill empty
# fields, never overwrite, and are heavily audited. The worker daemon also
# hits them anonymously over loopback.
_PUBLIC_MUTATING_PREFIXES = (
"/api/v2/enrich/",
)
@app.middleware("http")
async def require_jwt_middleware(request, call_next):
p = request.url.path
method = request.method.upper()
if method == "OPTIONS":
return await call_next(request)
admin_gate = p.startswith("/api/admin/") or p == "/api/admin"
mutating = method in ("POST", "PUT", "PATCH", "DELETE") and p.startswith("/api/")
if mutating and (p in _PUBLIC_MUTATING_PATHS or
any(p.endswith(s) for s in _PUBLIC_MUTATING_SUFFIXES) or
any(p.startswith(s) for s in _PUBLIC_MUTATING_PREFIXES)):
mutating = False
if not (admin_gate or mutating):
return await call_next(request)
try:
from auth.auth_v2 import decode_token, _is_revoked
except Exception as e:
print(f"[JWT-MW import WARN] {e}")
return await call_next(request)
from starlette.responses import JSONResponse as _JR
auth_h = request.headers.get("authorization", "")
if not auth_h.lower().startswith("bearer "):
return _JR({"detail": "Authentication required"}, status_code=401)
token = auth_h.split(" ", 1)[1].strip()
try:
payload = decode_token(token)
except Exception:
return _JR({"detail": "Invalid or expired token"}, status_code=401)
if payload.get("typ") not in (None, "access"):
return _JR({"detail": "Wrong token type"}, status_code=401)
if _is_revoked(payload.get("jti", "")):
return _JR({"detail": "Token revoked"}, status_code=401)
return await call_next(request)
# === URL rewrite middleware - convert direct external image URLs to /img-proxy ===
import json as _json_mw
import re as _re_mw
from starlette.responses import Response as _StarletteResponse_mw
_IMG_DOMAINS_RE = _re_mw.compile(
r'https?://(?:hns\.family|hns\.hr|hbs\.hr|hrvatski-bocarski-savez\.hr|'
r'rk-zamet\.hr|hvs\.hr|rezultati\.hvs\.hr|sport-pgz\.hr)'
r'/[^"\s\\]+\.(?:jpg|jpeg|png|gif|webp|svg)',
_re_mw.IGNORECASE
)
def _rewrite_to_proxy(text: str) -> str:
"""Replace external image URLs with /sport/api/v2/img-proxy?u=..."""
from urllib.parse import quote as _q
def _sub(m):
url = m.group(0)
return "/sport/api/v2/img-proxy?u=" + _q(url, safe='')
return _IMG_DOMAINS_RE.sub(_sub, text)
@app.middleware("http")
async def url_rewrite_middleware(request, call_next):
response = await call_next(request)
# Only rewrite JSON API responses
ct = response.headers.get("content-type", "")
if "application/json" not in ct:
return response
# Only on /api/v2 routes (admin & data endpoints) - SKIP /api/v2/img-proxy itself
path = request.url.path
if "/api/v2/img-proxy" in path or "/api/v2/dokumenti" in path:
return response # don't rewrite raw document content
# Read body
body = b""
async for chunk in response.body_iterator:
body += chunk
try:
text = body.decode("utf-8")
new_text = _rewrite_to_proxy(text)
new_body = new_text.encode("utf-8")
except Exception:
new_body = body
return _StarletteResponse_mw(
content=new_body,
status_code=response.status_code,
headers={k: v for k, v in response.headers.items() if k.lower() not in ("content-length",)},
media_type=ct,
)
# === end URL rewrite middleware ===
def db():
conn = psycopg2.connect(**DB)
conn.autocommit = True
return conn
def fetch(sql, params=None):
with db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as c:
c.execute(sql, params or ())
return [dict(r) for r in c.fetchall()]
def execute(sql, params=None):
with db() as conn:
with conn.cursor() as c:
c.execute(sql, params or ())
return c.rowcount
# ==================== HEALTH ====================
@app.get("/health")
def health():
try:
rows = fetch("SELECT * FROM pgz_sport.v_dashboard")
return {"status": "ok", "service": "pgz_sport", "dashboard": rows[0] if rows else None}
except Exception as e:
raise HTTPException(500, f"DB error: {e}")
@app.get("/api/whoami")
def whoami_v2(authorization: Optional[str] = Header(None)):
return {"role": "admin" if is_admin(authorization) else "viewer", "privacy_active": not is_admin(authorization)}
# ==================== DASHBOARD ====================
@app.get("/api/dashboard")
def dashboard():
rows = fetch("SELECT * FROM pgz_sport.v_dashboard")
if not rows:
return {}
d = rows[0]
# Top savezi by registriranih 2024
top = fetch("""SELECT s.naziv, st.klubova_clanica, st.registriranih, st.trenera, st.reprezentativaca
FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id
WHERE st.godina=2024 ORDER BY st.registriranih DESC LIMIT 10""")
proracun_trend = fetch("SELECT godina, ukupno FROM pgz_sport.proracun ORDER BY godina")
nositelji = fetch("""SELECT naziv_kluba, godina, iznos FROM pgz_sport.potpore_nositelji
WHERE godina = 2025 ORDER BY iznos DESC LIMIT 10""")
return {**d, "top_savezi": top, "proracun_trend": proracun_trend, "nositelji_2025": nositelji}
@app.get("/api/dashboard/ekosustav")
def dashboard_ekosustav():
"""Sport ekosustav PGŽ — coverage stats za enrichment iz FINA registra."""
summary = fetch("""SELECT
COUNT(*) AS klubova_total,
COUNT(*) FILTER (WHERE oib IS NOT NULL) AS s_oib,
COUNT(*) FILTER (WHERE predsjednik IS NOT NULL) AS s_predsjednik,
COUNT(*) FILTER (WHERE tajnik IS NOT NULL) AS s_tajnik,
COUNT(*) FILTER (WHERE ciljevi IS NOT NULL) AS s_ciljevi,
COUNT(*) FILTER (WHERE opis_djelatnosti IS NOT NULL) AS s_opis,
COUNT(*) FILTER (WHERE sjediste IS NOT NULL) AS s_sjediste,
COUNT(*) FILTER (WHERE email IS NOT NULL) AS s_email,
COUNT(*) FILTER (WHERE web_stranica IS NOT NULL) AS s_web,
COUNT(*) FILTER (WHERE udruga_status = \'AKTIVAN\') AS s_aktivan_reg,
COUNT(*) FILTER (WHERE savez_id IS NOT NULL) AS s_savez,
COUNT(*) FILTER (WHERE nositelj_kvalitete) AS s_nositelj
FROM pgz_sport.klubovi WHERE aktivan""")[0]
by_sport = fetch("""SELECT sport, COUNT(*) AS broj
FROM pgz_sport.klubovi WHERE aktivan AND sport IS NOT NULL
GROUP BY sport ORDER BY COUNT(*) DESC LIMIT 15""")
by_region = fetch("""SELECT region, COUNT(*) AS broj
FROM pgz_sport.klubovi WHERE aktivan AND region IS NOT NULL
GROUP BY region ORDER BY COUNT(*) DESC""")
by_grad = fetch("""SELECT grad, COUNT(*) AS broj
FROM pgz_sport.klubovi WHERE aktivan AND grad IS NOT NULL
GROUP BY grad ORDER BY COUNT(*) DESC LIMIT 12""")
decade = fetch("""SELECT
CASE
WHEN godina_osnutka < 1950 THEN \'pred1950\'
WHEN godina_osnutka < 1980 THEN \'1950-1979\'
WHEN godina_osnutka < 2000 THEN \'1980-1999\'
WHEN godina_osnutka < 2010 THEN \'2000-2009\'
WHEN godina_osnutka >= 2010 THEN \'2010-danas\'
ELSE \'nepoznato\'
END AS razdoblje,
COUNT(*) AS broj
FROM pgz_sport.klubovi
WHERE aktivan AND godina_osnutka IS NOT NULL
GROUP BY razdoblje ORDER BY razdoblje""")
# Pokazi enrichment %
total = summary["klubova_total"] or 1
coverage = {
"oib_pct": round(100 * summary["s_oib"] / total, 1),
"predsjednik_pct": round(100 * summary["s_predsjednik"] / total, 1),
"tajnik_pct": round(100 * summary["s_tajnik"] / total, 1),
"ciljevi_pct": round(100 * summary["s_ciljevi"] / total, 1),
"opis_pct": round(100 * summary["s_opis"] / total, 1),
"sjediste_pct": round(100 * summary["s_sjediste"] / total, 1),
"email_pct": round(100 * summary["s_email"] / total, 1),
"savez_pct": round(100 * summary["s_savez"] / total, 1),
}
return {**summary, "coverage": coverage, "by_sport": by_sport,
"by_region": by_region, "by_grad": by_grad, "by_decade": decade}
# ==================== ANALYTICS ====================
@app.get("/api/analytics/savezi-trend")
def savezi_trend(godine: str = "2020,2021,2022,2023,2024", metric: str = "registriranih"):
valid_metrics = {"registriranih", "neregistriranih", "rekreativaca", "trenera", "reprezentativaca",
"kategoriziranih", "stipendiranih", "klubova_clanica"}
if metric not in valid_metrics:
raise HTTPException(400, f"Invalid metric. Must be one of: {valid_metrics}")
god_list = [int(g) for g in godine.split(",")]
rows = fetch(f"""SELECT s.naziv AS savez, st.godina, st.{metric} AS value
FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id
WHERE st.godina = ANY(%s) ORDER BY s.naziv, st.godina""", [god_list])
saveze = {}
for r in rows:
if r['savez'] not in saveze: saveze[r['savez']] = {}
saveze[r['savez']][r['godina']] = r['value']
return {"metric": metric, "godine": god_list, "data": saveze}
@app.get("/api/analytics/proracun-detaljno")
def proracun_detaljno():
p = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina")
if not p: return {"proracun": [], "rast_godisnji": [], "current_year": None, "current_total": 0, "rast_dekada_pct": 0}
cagr = []
for i in range(1, len(p)):
prev = float(p[i-1]['ukupno']) if p[i-1]['ukupno'] else 0
curr = float(p[i]['ukupno']) if p[i]['ukupno'] else 0
rate = ((curr/prev - 1) * 100) if prev > 0 else 0
cagr.append({"godina": p[i]['godina'], "rast_postotak": round(rate, 1)})
decade_rast = round((float(p[-1]['ukupno'])/float(p[0]['ukupno']) - 1) * 100, 1) if p[0]['ukupno'] else 0
return {"proracun": p, "rast_godisnji": cagr, "rast_dekada_pct": decade_rast,
"current_year": int(p[-1]['godina']), "current_total": float(p[-1]['ukupno'])}
@app.get("/api/analytics/klub-financije")
def klub_financije(klub_id: Optional[int] = None, godina: Optional[int] = None):
where = []
params = []
if godina: where.append("p.godina=%s"); params.append(godina)
if klub_id:
where.append("(p.klub_id=%s OR p.naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))")
params.extend([klub_id, klub_id])
where_sql = "WHERE " + " AND ".join(where) if where else ""
rows = fetch(f"""SELECT p.naziv_kluba, p.godina, p.iznos,
k.id AS klub_id, k.sport, k.razina, k.nositelj_kvalitete
FROM pgz_sport.potpore_nositelji p
LEFT JOIN pgz_sport.klubovi k ON p.klub_id=k.id OR p.naziv_kluba=k.naziv
{where_sql} ORDER BY p.godina DESC, p.iznos DESC""", params)
summary = fetch(f"""SELECT godina, SUM(iznos) AS total, COUNT(*) AS klubova, AVG(iznos) AS prosjek
FROM pgz_sport.potpore_nositelji p {where_sql}
GROUP BY godina ORDER BY godina""", params)
return {"data": rows, "summary": summary}
@app.get("/api/analytics/lijecnicki-stats")
def lijecnicki_stats(klub_id: Optional[int] = None):
where = ["1=1"]; params = []
if klub_id: where.append("c.klub_id=%s"); params.append(klub_id)
where_sql = " AND ".join(where)
rows = fetch(f"""SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE lp.vrijedi_do >= CURRENT_DATE + 30) AS validni,
COUNT(*) FILTER (WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + 30) AS uskoro,
COUNT(*) FILTER (WHERE lp.vrijedi_do < CURRENT_DATE) AS istekli,
SUM(lp.iznos) AS ukupan_trosak, SUM(lp.iznos_zzjz) AS zzjz_udio,
SUM(lp.iznos_klub) AS klub_udio, SUM(lp.iznos_clan) AS clan_udio,
AVG(lp.iznos) AS prosjecni_trosak
FROM pgz_sport.lijecnicki_pregledi lp
JOIN pgz_sport.clanovi c ON c.id=lp.clan_id WHERE {where_sql}""", params)
by_ustanova = fetch(f"""SELECT lp.ustanova, COUNT(*) cnt, SUM(lp.iznos) iznos
FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id
WHERE {where_sql} GROUP BY lp.ustanova ORDER BY cnt DESC""", params)
by_lijecnik = fetch(f"""SELECT lp.lijecnik, COUNT(*) cnt, AVG(lp.iznos) prosjek
FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id
WHERE {where_sql} AND lp.lijecnik IS NOT NULL GROUP BY lp.lijecnik ORDER BY cnt DESC""", params)
return {"summary": rows[0] if rows else {}, "by_ustanova": by_ustanova, "by_lijecnik": by_lijecnik}
# ==================== SAVEZI ====================
@app.get("/api/savezi")
def list_savezi(authorization: Optional[str] = Header(None), q: Optional[str] = None,
razina: Optional[str] = None, zupanija: Optional[str] = None,
sort: str = "naziv", order: str = "asc"):
where = "WHERE aktivan"
params = []
if q:
where += " AND (naziv ILIKE %s OR sport ILIKE %s)"
params = [f"%{q}%", f"%{q}%"]
if razina:
where += " AND razina = %s"; params.append(razina)
if zupanija:
where += " AND sjediste_zupanija ILIKE %s"; params.append(f"%{zupanija}%")
sort_col = {"naziv": "naziv", "godina": "godina_osnutka", "sport": "sport", "razina": "razina"}.get(sort, "naziv")
order = "DESC" if order.lower() == "desc" else "ASC"
# Croatian collation for text columns (Š → after S, Č → after C, etc.)
collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("naziv", "sport") else ""
rows = fetch(f"""SELECT s.*,
(SELECT COUNT(*) FROM pgz_sport.klubovi WHERE savez_id=s.id) AS broj_klubova,
(SELECT registriranih FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS reg_2024,
(SELECT trenera FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS treneri_2024,
(SELECT reprezentativaca FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS repr_2024
FROM pgz_sport.savezi s {where} ORDER BY {sort_col}{collate} {order}""", params)
rows = apply_privacy(rows, is_admin(authorization))
return {"count": len(rows), "rows": rows}
@app.get("/api/savezi/{savez_id}")
def get_savez(savez_id: int):
rows = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s", [savez_id])
if not rows:
raise HTTPException(404, "Savez ne postoji")
klubovi = fetch("SELECT * FROM pgz_sport.klubovi WHERE savez_id=%s ORDER BY naziv", [savez_id])
statistika = fetch("SELECT * FROM pgz_sport.statistika_saveza WHERE savez_id=%s ORDER BY godina", [savez_id])
manifestacije = fetch("SELECT * FROM pgz_sport.manifestacije WHERE savez_id=%s", [savez_id])
return {**rows[0], "klubovi": klubovi, "statistika": statistika, "manifestacije": manifestacije}
# ==================== KLUBOVI ====================
@app.get("/api/klubovi")
def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, savez_id: Optional[int] = None,
nositelj: Optional[bool] = None, region: Optional[str] = None, sport: Optional[str] = None, grad: Optional[str] = None,
sort: str = "naziv", order: str = "asc"):
where = ["aktivan"]
params = []
if q:
where.append("(klub ILIKE %s OR oib ILIKE %s OR sport ILIKE %s OR predsjednik ILIKE %s)")
params.extend([f"%{q}%", f"%{q}%", f"%{q}%", f"%{q}%"])
if savez_id:
where.append("savez_id=%s"); params.append(savez_id)
if nositelj is not None:
where.append(f"nositelj_kvalitete={'TRUE' if nositelj else 'FALSE'}")
if region:
where.append("region ILIKE %s"); params.append(region)
if grad:
where.append("grad ILIKE %s"); params.append(f"%{grad}%")
if sport:
where.append("sport ILIKE %s"); params.append(f"%{sport}%")
sort_col = {"naziv": "klub", "savez": "savez", "broj_clanova": "broj_clanova",
"razina": "razina", "region": "region", "grad": "grad", "sport": "sport"}.get(sort, "klub")
order_sql = "DESC" if order.lower() == "desc" else "ASC"
where_sql = " AND ".join(where) if where else "TRUE"
collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("klub", "savez", "razina", "region", "grad", "sport") else ""
rows = fetch(f"""SELECT * FROM pgz_sport.v_klubovi_pregled WHERE {where_sql}
ORDER BY {sort_col}{collate} {order_sql} NULLS LAST""", params)
for r in rows:
if isinstance(r, dict) and r.get('klub') and not r.get('naziv'):
r['naziv'] = r['klub']
rows = apply_privacy(rows, is_admin(authorization))
return {"count": len(rows), "rows": rows}
@app.get("/api/klubovi/{klub_id}")
def get_klub(klub_id: int, authorization: Optional[str] = Header(None)):
admin = is_admin(authorization)
rows = fetch("""SELECT k.*, s.naziv AS savez_naziv FROM pgz_sport.klubovi k
LEFT JOIN pgz_sport.savezi s ON s.id=k.savez_id WHERE k.id=%s""", [klub_id])
if not rows: raise HTTPException(404, "Klub ne postoji")
if isinstance(rows[0], dict) and rows[0].get('klub') and not rows[0].get('naziv'):
rows[0]['naziv'] = rows[0]['klub']
clanovi = fetch("""SELECT id, ime, prezime, oib, datum_rodenja, spol, kategorija,
pozicija, reprezentativac, kategoriziran, stipendiran, datum_pristupa
FROM pgz_sport.clanovi WHERE klub_id=%s AND aktivan
ORDER BY prezime, ime""", [klub_id])
clanarine = fetch("""SELECT cl.id, cl.godina, cl.razdoblje, cl.iznos_propisan, cl.iznos_placen,
(cl.iznos_propisan - cl.iznos_placen) AS dug, cl.datum_uplate, cl.status, cl.napomena,
c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib
FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id
WHERE c.klub_id=%s ORDER BY cl.godina DESC, cl.id DESC""", [klub_id])
lijecnicki = fetch("""SELECT lp.id, lp.datum_pregleda, lp.vrijedi_do, lp.vrsta_pregleda,
lp.ustanova, lp.lijecnik, lp.spreman_za_natjecanje, lp.iznos, lp.iznos_zzjz, lp.iznos_klub, lp.iznos_clan,
lp.placeno, lp.komentar_lijecnika,
c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib,
CASE WHEN lp.vrijedi_do IS NULL THEN 'Nepoznato'
WHEN lp.vrijedi_do < CURRENT_DATE THEN 'Istekao'
WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'Ističe uskoro'
ELSE 'Validan' END AS status_pregled
FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id
WHERE c.klub_id=%s ORDER BY lp.datum_pregleda DESC""", [klub_id])
potpore = fetch("""SELECT * FROM pgz_sport.potpore_nositelji
WHERE klub_id=%s OR naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s)
ORDER BY godina DESC""", [klub_id, klub_id])
# Aggregate stats
stats = {
'broj_clanova': len(clanovi),
'broj_registriranih': sum(1 for c in clanovi if c.get('kategorija')=='registrirani'),
'broj_trenera': sum(1 for c in clanovi if c.get('kategorija')=='trener'),
'broj_reprezentativaca': sum(1 for c in clanovi if c.get('reprezentativac')),
'broj_kategoriziranih': sum(1 for c in clanovi if c.get('kategoriziran')),
'broj_stipendiranih': sum(1 for c in clanovi if c.get('stipendiran')),
'lijecnicki_validni': sum(1 for l in lijecnicki if l.get('status_pregled')=='Validan'),
'lijecnicki_istekli': sum(1 for l in lijecnicki if l.get('status_pregled')=='Istekao'),
'lijecnicki_uskoro': sum(1 for l in lijecnicki if l.get('status_pregled')=='Ističe uskoro'),
'clanarina_naplaceno_god': sum(float(c.get('iznos_placen') or 0) for c in clanarine if c.get('godina')==2026),
'clanarina_dug_god': sum(float(c.get('dug') or 0) for c in clanarine if c.get('godina')==2026),
'potpore_2025': float(next((p['iznos'] for p in potpore if p.get('godina')==2025), 0) or 0),
'potpore_total': sum(float(p.get('iznos') or 0) for p in potpore),
'zzjz_isplaceno': sum(float(l.get('iznos_zzjz') or 0) for l in lijecnicki if l.get('placeno')),
}
klub = rows[0]
if not admin:
klub = apply_privacy(klub, admin)
clanovi = apply_privacy(clanovi, admin)
clanarine = apply_privacy(clanarine, admin)
lijecnicki = apply_privacy(lijecnicki, admin)
return {**klub, "clanovi": clanovi, "clanarine": clanarine, "lijecnicki": lijecnicki,
"potpore": potpore, "stats": stats}
class KlubIn(BaseModel):
naziv: str
savez_id: Optional[int] = None
sport: Optional[str] = None
oib: Optional[str] = None
razina: Optional[str] = None
nositelj_kvalitete: Optional[bool] = False
grad: Optional[str] = None
region: Optional[str] = None
email: Optional[str] = None
telefon: Optional[str] = None
predsjednik: Optional[str] = None
iban: Optional[str] = None
napomena: Optional[str] = None
@app.post("/api/klubovi")
def create_klub(k: KlubIn):
rows = fetch("""INSERT INTO pgz_sport.klubovi (naziv, savez_id, sport, oib, razina, nositelj_kvalitete, grad, region, email, telefon, predsjednik, iban, napomena, aktivan)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE) RETURNING *""",
[k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena])
return rows[0]
@app.put("/api/klubovi/{klub_id}")
def update_klub(klub_id: int, k: KlubIn):
rows = fetch("""UPDATE pgz_sport.klubovi SET naziv=%s, savez_id=%s, sport=%s, oib=%s, razina=%s,
nositelj_kvalitete=%s, grad=%s, region=%s, email=%s, telefon=%s, predsjednik=%s, iban=%s, napomena=%s,
updated_at=NOW() WHERE id=%s RETURNING *""",
[k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena, klub_id])
if not rows:
raise HTTPException(404, "Klub ne postoji")
return rows[0]
# ==================== ČLANOVI ====================
@app.get("/api/clanovi")
def list_clanovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, klub_id: Optional[int] = None,
kategorija: Optional[str] = None, spol: Optional[str] = None, sort: str = "prezime", order: str = "asc"):
where = ["c.aktivan"]
params = []
if q:
where.append("(c.ime ILIKE %s OR c.prezime ILIKE %s OR c.oib ILIKE %s)")
params.extend([f"%{q}%", f"%{q}%", f"%{q}%"])
if klub_id:
where.append("c.klub_id=%s"); params.append(klub_id)
if kategorija:
where.append("c.kategorija=%s"); params.append(kategorija)
if spol:
# Normalize: Z → Ž, F → Ž (legacy)
spol_norm = "Ž" if spol.upper() in ("Z","Ž","F","W") else "M" if spol.upper() in ("M",) else spol
where.append("c.spol=%s"); params.append(spol_norm)
sort_map = {"prezime": "c.prezime", "ime": "c.ime", "oib": "c.oib", "datum_rodenja": "c.datum_rodenja", "kategorija": "c.kategorija", "klub": "k.naziv"}
sort_col = sort_map.get(sort, "c.prezime")
order = "DESC" if order.lower() == "desc" else "ASC"
where_sql = " AND ".join(where) if where else "TRUE"
rows = fetch(f"""SELECT c.*, k.naziv AS klub_naziv,
(SELECT MAX(vrijedi_do) FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=c.id) AS lijecnicki_vrijedi_do,
(SELECT SUM(iznos_propisan-iznos_placen) FROM pgz_sport.clanarine WHERE clan_id=c.id AND status!='podmireno') AS dug_clanarine
FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE {where_sql} ORDER BY {sort_col} {order}""", params)
rows = apply_privacy(rows, is_admin(authorization))
return {"count": len(rows), "rows": rows}
class ClanIn(BaseModel):
klub_id: int
ime: str
prezime: str
oib: Optional[str] = None
datum_rodenja: Optional[date] = None
spol: Optional[str] = None
email: Optional[str] = None
telefon: Optional[str] = None
kategorija: Optional[str] = "registrirani"
pozicija: Optional[str] = None
licenca_broj: Optional[str] = None
licenca_vrijedi_do: Optional[date] = None
reprezentativac: Optional[bool] = False
kategoriziran: Optional[bool] = False
stipendiran: Optional[bool] = False
napomena: Optional[str] = None
@app.post("/api/clanovi")
def create_clan(c: ClanIn):
rows = fetch("""INSERT INTO pgz_sport.clanovi (klub_id, ime, prezime, oib, datum_rodenja, spol, email, telefon, kategorija, pozicija, licenca_broj, licenca_vrijedi_do, reprezentativac, kategoriziran, stipendiran, napomena, aktivan, datum_pristupa)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE,CURRENT_DATE) RETURNING *""",
[c.klub_id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol, c.email, c.telefon, c.kategorija, c.pozicija, c.licenca_broj, c.licenca_vrijedi_do, c.reprezentativac, c.kategoriziran, c.stipendiran, c.napomena])
return rows[0]
@app.get("/api/clanovi/{clan_id}")
def get_clan(clan_id: int):
rows = fetch("""SELECT c.*, k.naziv AS klub_naziv FROM pgz_sport.clanovi c
LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id])
if not rows:
raise HTTPException(404, "Član ne postoji")
clanarine = fetch("SELECT * FROM pgz_sport.clanarine WHERE clan_id=%s ORDER BY godina DESC", [clan_id])
lijecnicki = fetch("SELECT * FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=%s ORDER BY datum_pregleda DESC", [clan_id])
return {**rows[0], "clanarine": clanarine, "lijecnicki": lijecnicki}
# ==================== ČLANARINE ====================
@app.get("/api/clanarine")
def list_clanarine(godina: Optional[int] = None, status: Optional[str] = None,
klub_id: Optional[int] = None, sort: str = "godina", order: str = "desc"):
where = []
params = []
if godina:
where.append("godina=%s"); params.append(godina)
if status:
where.append("status=%s"); params.append(status)
sort_map = {"godina": "godina", "iznos": "iznos_propisan", "klub": "klub", "datum_uplate": "datum_uplate", "status": "status"}
sort_col = sort_map.get(sort, "godina")
order = "DESC" if order.lower() == "desc" else "ASC"
where_sql = "WHERE " + " AND ".join(where) if where else ""
rows = fetch(f"SELECT * FROM pgz_sport.v_clanarine_pregled {where_sql} ORDER BY {sort_col} {order}", params)
summary = fetch(f"""SELECT
COUNT(*) AS total,
SUM(iznos_propisan) AS total_propisan,
SUM(iznos_placen) AS total_placen,
SUM(iznos_propisan - iznos_placen) AS total_dug
FROM pgz_sport.v_clanarine_pregled {where_sql}""", params)
return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}}
class ClanarinaIn(BaseModel):
clan_id: int
klub_id: Optional[int] = None
godina: int
razdoblje: Optional[str] = "godišnja"
iznos_propisan: float
iznos_placen: Optional[float] = 0
datum_uplate: Optional[date] = None
nacin_uplate: Optional[str] = None
napomena: Optional[str] = None
@app.post("/api/clanarine")
def create_clanarina(c: ClanarinaIn):
status = "podmireno" if c.iznos_placen >= c.iznos_propisan else ("djelomicno" if c.iznos_placen > 0 else "nepodmireno")
klub_id = c.klub_id
if not klub_id:
kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [c.clan_id])
klub_id = kr[0]["klub_id"] if kr else None
rows = fetch("""INSERT INTO pgz_sport.clanarine (clan_id, klub_id, godina, razdoblje, iznos_propisan, iznos_placen, datum_uplate, nacin_uplate, status, napomena)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""",
[c.clan_id, klub_id, c.godina, c.razdoblje, c.iznos_propisan, c.iznos_placen, c.datum_uplate, c.nacin_uplate, status, c.napomena])
return rows[0]
# ==================== LIJEČNIČKI ====================
@app.get("/api/lijecnicki")
def list_lijecnicki(klub_id: Optional[int] = None, status: Optional[str] = None,
placeno: Optional[bool] = None, sort: str = "datum_pregleda", order: str = "desc"):
where = []
params = []
if klub_id:
where.append("(klub_oib IS NOT NULL AND klub=ANY(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))"); params.append(klub_id)
if status:
where.append("status_pregled=%s"); params.append(status)
if placeno is not None:
where.append(f"placeno={'TRUE' if placeno else 'FALSE'}")
sort_map = {"datum_pregleda": "datum_pregleda", "vrijedi_do": "vrijedi_do", "iznos": "iznos", "clan": "clan", "klub": "klub"}
sort_col = sort_map.get(sort, "datum_pregleda")
order = "DESC" if order.lower() == "desc" else "ASC"
where_sql = "WHERE " + " AND ".join(where) if where else ""
rows = fetch(f"SELECT * FROM pgz_sport.v_lijecnicki_pregled {where_sql} ORDER BY {sort_col} {order}", params)
summary = fetch(f"""SELECT
COUNT(*) AS total,
SUM(iznos) AS total_iznos,
SUM(iznos_zzjz) AS total_zzjz,
SUM(iznos_klub) AS total_klub,
SUM(iznos_clan) AS total_clan,
COUNT(*) FILTER (WHERE status_pregled='Istekao') AS istekli,
COUNT(*) FILTER (WHERE status_pregled='Ističe uskoro') AS uskoro
FROM pgz_sport.v_lijecnicki_pregled {where_sql}""", params)
return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}}
class LijecnickiIn(BaseModel):
clan_id: int
klub_id: Optional[int] = None
datum_pregleda: date
vrijedi_do: Optional[date] = None
vrsta_pregleda: Optional[str] = "temeljni"
ustanova: Optional[str] = "ZZJZ PGŽ"
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] = 0
iznos_zzjz: Optional[float] = 0
iznos_klub: Optional[float] = 0
iznos_clan: Optional[float] = 0
datum_placanja: Optional[date] = None
placeno: Optional[bool] = False
napomena: Optional[str] = None
@app.post("/api/lijecnicki")
def create_lijecnicki(l: LijecnickiIn):
klub_id = l.klub_id
if not klub_id:
kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [l.clan_id])
klub_id = kr[0]["klub_id"] if kr else None
rows = fetch("""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, napomena)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""",
[l.clan_id, klub_id, l.datum_pregleda, l.vrijedi_do, l.vrsta_pregleda, l.ustanova, l.lijecnik, l.spreman_za_natjecanje, l.ekg, l.krv, l.spirometrija, l.nalaz, l.komentar_lijecnika, l.preporuke, l.iznos, l.iznos_zzjz, l.iznos_klub, l.iznos_clan, l.datum_placanja, l.placeno, l.napomena])
return rows[0]
# ==================== PRORAČUN ====================
@app.get("/api/proracun")
def list_proracun():
rows = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina")
return {"count": len(rows), "rows": rows}
# ==================== POTPORE NOSITELJI ====================
@app.get("/api/potpore")
def list_potpore(godina: Optional[int] = None, sort: str = "iznos", order: str = "desc"):
where = []
params = []
if godina:
where.append("godina=%s"); params.append(godina)
sort_col = {"iznos": "iznos", "godina": "godina", "klub": "naziv_kluba"}.get(sort, "iznos")
order = "DESC" if order.lower() == "desc" else "ASC"
where_sql = "WHERE " + " AND ".join(where) if where else ""
rows = fetch(f"SELECT * FROM pgz_sport.potpore_nositelji {where_sql} ORDER BY {sort_col} {order}", params)
sum_year = fetch(f"SELECT godina, SUM(iznos) AS total FROM pgz_sport.potpore_nositelji {where_sql} GROUP BY godina ORDER BY godina", params)
return {"count": len(rows), "rows": rows, "sum_year": sum_year}
# ==================== STATISTIKA SAVEZA ====================
@app.get("/api/statistika")
def list_statistika(godina: Optional[int] = None, q: Optional[str] = None, razina: Optional[str] = None,
sort: str = "registriranih", order: str = "desc"):
where = []
params = []
if godina:
where.append("st.godina=%s"); params.append(godina)
if q:
where.append("s.naziv ILIKE %s"); params.append(f"%{q}%")
if razina:
where.append("s.razina = %s"); params.append(razina)
where_sql = "WHERE " + " AND ".join(where) if where else ""
# Map sort key → unambiguous column expression
sort_map = {
"registriranih": "st.registriranih",
"klubova": "st.klubova_clanica",
"trenera": "st.trenera",
"reprezentativaca":"st.reprezentativaca",
"neregistriranih": "st.neregistriranih",
"rekreativaca": "st.rekreativaca",
"godina": "st.godina",
"savez": "s.naziv",
"naziv": "s.naziv",
}
sort_col = sort_map.get(sort, "st.registriranih")
order_sql = "DESC" if order.lower() == "desc" else "ASC"
use_collate = sort_col in ("s.naziv", "s.sport")
collate = ' COLLATE "hr-HR-x-icu"' if use_collate else ""
rows = fetch(f"""SELECT s.naziv AS savez, s.razina AS savez_razina, s.sport AS sport, st.*
FROM pgz_sport.statistika_saveza st
JOIN pgz_sport.savezi s ON s.id=st.savez_id {where_sql}
ORDER BY {sort_col}{collate} {order_sql} NULLS LAST, s.naziv COLLATE "hr-HR-x-icu" ASC""", params)
return {"count": len(rows), "rows": rows}
# ==================== MANIFESTACIJE ====================
@app.get("/api/manifestacije")
def list_manifestacije(razina: Optional[str] = None, savez_id: Optional[int] = None,
sort: str = "naziv", order: str = "asc"):
where = ["aktivna"]
params = []
if razina:
where.append("razina=%s"); params.append(razina)
if savez_id:
where.append("savez_id=%s"); params.append(savez_id)
sort_col = {"naziv": "m.naziv", "razina": "m.razina", "godina_od": "m.godina_od", "mjesto": "m.mjesto"}.get(sort, "m.naziv")
order = "DESC" if order.lower() == "desc" else "ASC"
where_sql = " AND ".join(where) if where else "TRUE"
rows = fetch(f"""SELECT m.*, s.naziv AS savez_naziv FROM pgz_sport.manifestacije m
LEFT JOIN pgz_sport.savezi s ON s.id=m.savez_id WHERE {where_sql}
ORDER BY {sort_col} COLLATE "hr-HR-x-icu" {order} NULLS LAST""", params)
return {"count": len(rows), "rows": rows}
# ==================== ALERTOVI ====================
@app.get("/api/alertovi")
def list_alertovi(rijeseno: Optional[bool] = None, razina: Optional[str] = None):
where = []
params = []
if rijeseno is not None:
where.append(f"rijeseno={'TRUE' if rijeseno else 'FALSE'}")
if razina:
where.append("razina=%s"); params.append(razina)
where_sql = "WHERE " + " AND ".join(where) if where else ""
rows = fetch(f"SELECT * FROM pgz_sport.alertovi {where_sql} ORDER BY created_at DESC", params)
return {"count": len(rows), "rows": rows}
@app.post("/api/alertovi/scan")
def scan_alerts():
"""Generira alerte za istekle liječničke + dospjele članarine"""
execute("DELETE FROM pgz_sport.alertovi WHERE NOT rijeseno AND tip IN ('lijecnicki_isteka', 'lijecnicki_uskoro', 'clanarina_dospjela')")
# Liječnički istekao
execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum)
SELECT 'lijecnicki_isteka', 'CRITICAL', c.klub_id, lp.clan_id,
'Liječnički pregled istekao za ' || c.ime || ' ' || c.prezime || ' (klub: ' || COALESCE(k.naziv, 'N/A') || ')', lp.vrijedi_do
FROM pgz_sport.lijecnicki_pregledi lp
JOIN pgz_sport.clanovi c ON c.id=lp.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE lp.vrijedi_do < CURRENT_DATE AND c.aktivan""")
# Liječnički uskoro
execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum)
SELECT 'lijecnicki_uskoro', 'WARNING', c.klub_id, lp.clan_id,
'Liječnički ističe za 30 dana: ' || c.ime || ' ' || c.prezime, lp.vrijedi_do
FROM pgz_sport.lijecnicki_pregledi lp
JOIN pgz_sport.clanovi c ON c.id=lp.clan_id
WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE+30 AND c.aktivan""")
# Članarine dospjele
execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum, iznos)
SELECT 'clanarina_dospjela', 'WARNING', cl.klub_id, cl.clan_id,
'Nepodmirena članarina ' || cl.godina || ' za ' || c.ime || ' ' || c.prezime, NULL, (cl.iznos_propisan - cl.iznos_placen)
FROM pgz_sport.clanarine cl
JOIN pgz_sport.clanovi c ON c.id=cl.clan_id
WHERE cl.status != 'podmireno' AND cl.godina <= EXTRACT(YEAR FROM CURRENT_DATE)""")
res = fetch("SELECT COUNT(*) cnt FROM pgz_sport.alertovi WHERE NOT rijeseno")
return {"alerts_generated": res[0]["cnt"]}
@app.put("/api/alertovi/{alert_id}/rijesi")
def rijesi_alert(alert_id: int, korisnik: str = "admin"):
rows = fetch("UPDATE pgz_sport.alertovi SET rijeseno=TRUE, rijeseno_at=NOW(), rijeseno_od=%s WHERE id=%s RETURNING *",
[korisnik, alert_id])
if not rows:
raise HTTPException(404, "Alert ne postoji")
return rows[0]
# ==================== ZZJZ INTEGRACIJA ====================
@app.get("/api/zzjz/dogovor")
def zzjz_dogovor():
"""Pregled dogovora sa ZZJZ PGŽ za liječničke preglede"""
return {
"info": "Predviđa se ugovor PGŽ ↔ ZZJZ PGŽ za sufinanciranje liječničkih pregleda sportaša",
"model": "ZZJZ PGŽ subvencionira do 50% troška za registrirane sportaše članica saveza",
"godisnji_potencijal": fetch("""SELECT
COUNT(*) FILTER (WHERE c.kategorija='registrirani') AS sportasa_potencijalno,
SUM(CASE WHEN c.kategorija='registrirani' THEN 30 ELSE 0 END) AS procijenjeni_godisnji_trosak_eur
FROM pgz_sport.clanovi c WHERE c.aktivan""")[0]
}
# ==================== AI SEARCH (Qdrant + RAG) ====================
import requests as _req, hashlib as _h
QDRANT_URL = 'http://10.10.0.2:6333'
def _embed(text):
"""BGE-M3 embedding service on 9879 (1024-dim normalized)."""
try:
r = _req.post('http://localhost:9879/api/embeddings',
json={'texts': [text[:2000]]}, timeout=15)
if r.ok:
data = r.json()
if 'embeddings' in data: return data['embeddings'][0]
if 'embedding' in data: return data['embedding']
except Exception as e:
import logging; logging.warning(f'BGE-M3 fail: {e}')
h = _h.sha256(text.encode()).digest()
return [(h[i % 32] / 255.0 - 0.5) for i in range(1024)]
@app.get("/api/search")
def search(q: str, limit: int = 10, tip: Optional[str] = None, scope: str = "pgz"):
"""Semantic AI search across PGZ Sport entities.
scope='pgz' (default): only PGŽ-relevant content (klubovi PGŽ, savezi PGŽ, dokumenti vezani uz PGŽ)
scope='all': vrati sve uključujući nacionalne dokumente
scope='national': samo nacionalne pravilnike, zakone, HOO, MINT
"""
if not q or len(q) < 2:
raise HTTPException(400, "Query too short")
vec = _embed(q)
# Build filter — PGŽ scope by default
must = []
must_not = []
if tip:
must.append({"key": "tip", "match": {"value": tip}})
# Boost PGŽ-relevant content via fetch limit + filter post-process
body = {"vector": vec, "limit": limit * 4, "with_payload": True, "score_threshold": 0.35}
if must:
body["filter"] = {"must": must}
try:
r = _req.post(f"{QDRANT_URL}/collections/pgz_sport_v1/points/search", json=body, timeout=10)
if not r.ok: raise HTTPException(500, f"Qdrant: {r.text[:200]}")
all_results = r.json()['result']
except _req.exceptions.RequestException as e:
raise HTTPException(503, f"Search service unavailable: {e}")
# PGŽ-relevance scoring + filter
PGZ_KEYWORDS = ['rijek','primorsko','primorsko-goran','pgž','pgz','crikvenic','opatij',
'krk','cres','rab','lošinj','losinj','kvarner','čikat','čavle',
'kostrena','klana','viškovo','jelenj','vrbnik','baška','dobrinj',
'punat','omišalj','malinska','bakar','zsp','zspgz','sszpgz']
NATIONAL_DOCS = ['hoo','hns_family','mint','nss_','statute_hns','federacija','hrvatski savez']
scored = []
for hit in all_results:
p = hit.get('payload') or {}
# Combine all text fields for keyword check
all_text = (
(p.get('naziv','') or '') + ' ' +
(p.get('title','') or '') + ' ' +
(p.get('text','') or '')[:500] + ' ' +
(p.get('source','') or '') + ' ' +
(p.get('grad','') or '') + ' ' +
(p.get('source_url','') or '')
).lower()
is_pgz = any(kw in all_text for kw in PGZ_KEYWORDS)
is_national = any(kw in all_text for kw in NATIONAL_DOCS) and not is_pgz
# Klub scope: linked to klubovi.id which is by definition PGŽ
if p.get('tip') == 'klub' and p.get('klub_id'): is_pgz = True
# Savez PGŽ
if p.get('tip') == 'savez' and (p.get('razina') == 'zupanijski' or 'pgž' in (p.get('naziv','') or '').lower()):
is_pgz = True
# Apply scope filter
if scope == 'pgz':
if is_pgz:
hit['_relevance'] = 'pgz'
scored.append(hit)
elif is_national and p.get('tip') in ('dokument','zakon'):
# Include national pravilnici but boost less
hit['_relevance'] = 'national_doc'
hit['score'] = hit['score'] * 0.7
scored.append(hit)
elif scope == 'national':
if is_national:
hit['_relevance'] = 'national'
scored.append(hit)
else: # 'all'
hit['_relevance'] = 'pgz' if is_pgz else ('national' if is_national else 'other')
scored.append(hit)
# Re-sort by adjusted score
scored.sort(key=lambda x: x.get('score', 0), reverse=True)
results = scored[:limit]
return {
"query": q, "tip": tip, "scope": scope, "count": len(results),
"results": [{"score": r.get('score', 0),
"tip": (r.get('payload') or {}).get('tip'),
"naziv": (r.get('payload') or {}).get('naziv') or (r.get('payload') or {}).get('title'),
"klub_id": (r.get('payload') or {}).get('klub_id'),
"savez_id": (r.get('payload') or {}).get('savez_id'),
"tekst": (r.get('payload') or {}).get('tekst') or (r.get('payload') or {}).get('text','')[:300],
"url": (r.get('payload') or {}).get('source_url') or (r.get('payload') or {}).get('url'),
"relevance": r.get('_relevance', 'unknown'),
"payload": r.get('payload')} for r in results]
}
# ==================== GOOGLE OAUTH ====================
import jwt as _jwt, secrets as _secrets
GOOGLE_CLIENT_ID = "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com" # postavi u .env
ADMIN_EMAILS = {
"damir@rinet.one", "dradulic@outlook.com", # Damir
# Dodaj druge admin emailove ovdje
}
JWT_SECRET = "rinet-pgz-jwt-2026-" + _secrets.token_hex(8)
JWT_ISSUED = [] # in-memory token store (može u Redis)
@app.post("/api/auth/google")
def google_auth(token: str = Body(..., embed=True)):
"""Verify Google ID token and issue JWT for admin/viewer role."""
try:
import urllib.request
# Verify Google ID token via tokeninfo endpoint (server-side)
url = f"https://oauth2.googleapis.com/tokeninfo?id_token={token}"
with urllib.request.urlopen(url, timeout=10) as r:
data = json.loads(r.read())
email = data.get("email", "").lower()
verified = data.get("email_verified") == "true" or data.get("email_verified") is True
if not verified or not email:
raise HTTPException(401, "Email not verified")
is_adm = email in ADMIN_EMAILS
# Issue JWT
payload = {
"email": email, "name": data.get("name", email),
"role": "admin" if is_adm else "viewer",
"iat": int(__import__("time").time()),
"exp": int(__import__("time").time()) + 86400 * 7 # 7 dana
}
jwt_token = _jwt.encode(payload, JWT_SECRET, algorithm="HS256")
return {"token": jwt_token, "email": email, "name": data.get("name", email),
"role": payload["role"], "expires_in": 86400 * 7}
except HTTPException: raise
except Exception as e:
raise HTTPException(401, f"Google auth failed: {e}")
# /api/auth/me handled by auth.auth_v2 router (M1)
# ==================== STATIC ====================
import pathlib
HTML_DIR = pathlib.Path(__file__).parent / "static"
HTML_DIR.mkdir(exist_ok=True)
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
# ──────── V5 NATJECANJA ────────
@app.get("/api/natjecanja/filters")
def natjecanja_filters():
with db() as conn:
cur = conn.cursor()
cur.execute("SELECT DISTINCT sport FROM pgz_sport.natjecanja WHERE sport IS NOT NULL ORDER BY sport")
sports = [r[0] for r in cur.fetchall()]
cur.execute("SELECT DISTINCT sezona FROM pgz_sport.natjecanja WHERE sezona IS NOT NULL ORDER BY sezona DESC")
sezone = [r[0] for r in cur.fetchall()]
return {"sports": sports, "sezone": sezone}
@app.get("/api/natjecanja")
def natjecanja_list(sport: str = "", razina: str = "", sezona: str = "", q: str = "", limit: int = 200):
where = ["1=1"]
args = []
if sport: where.append("sport = %s"); args.append(sport)
if razina: where.append("razina = %s"); args.append(razina)
if sezona: where.append("sezona = %s"); args.append(sezona)
if q: where.append("naziv ILIKE %s"); args.append(f"%{q}%")
args.append(limit)
with db() as conn:
cur = conn.cursor()
cur.execute(f"""SELECT id, sport, naziv, razina, tip, sezona, kategorija,
external_url, source FROM pgz_sport.natjecanja WHERE {' AND '.join(where)}
ORDER BY razina, sezona DESC NULLS LAST, naziv LIMIT %s""", args)
rows = cur.fetchall()
cols = [d[0] for d in cur.description]
results = [dict(zip(cols, r)) for r in rows]
cur.execute(f"SELECT COUNT(*) FROM pgz_sport.natjecanja WHERE {' AND '.join(where)}", args[:-1])
total = cur.fetchone()[0]
return {"count": total, "limit": limit, "results": results}
# ──────── V5 ADMIN ────────
@app.get("/api/admin/stats")
def admin_stats():
with db() as conn:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM pgz_sport.users"); ut = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM pgz_sport.users WHERE aktivan=true"); ua = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM pgz_sport.sys_permissions"); pt = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM pgz_sport.sys_audit WHERE created_at >= now()::date"); at = cur.fetchone()[0]
cur.execute("SELECT user_type, COUNT(*) cnt FROM pgz_sport.users GROUP BY 1 ORDER BY 2 DESC")
by_type = [{"user_type": r[0], "cnt": r[1]} for r in cur.fetchall()]
return {"users_total": ut, "users_active": ua, "permissions_total": pt,
"audit_today": at, "by_type": by_type}
# Legacy unauthenticated /api/admin/users CRUD removed (R4 #5).
# All /api/admin/users* endpoints are now served by auth.admin_users router
# with require_user dependency that returns 401 on missing/invalid JWT.
# ──────── V6 AI GRADOVI / KILOMETRAŽA ────────
@app.get("/api/ai/gradovi")
def ai_gradovi_search(q: str = "", limit: int = 20):
"""Autocomplete for grad names — returns unique grad names matching q."""
with db() as conn:
cur = conn.cursor()
if q:
cur.execute("""SELECT DISTINCT grad_od g FROM pgz_sport.ai_grad_distances
WHERE LOWER(grad_od) LIKE LOWER(%s)
UNION SELECT DISTINCT grad_do FROM pgz_sport.ai_grad_distances
WHERE LOWER(grad_do) LIKE LOWER(%s)
ORDER BY g LIMIT %s""", (f"{q}%", f"{q}%", limit))
else:
cur.execute("""SELECT DISTINCT grad_od g FROM pgz_sport.ai_grad_distances
UNION SELECT DISTINCT grad_do FROM pgz_sport.ai_grad_distances
ORDER BY g LIMIT %s""", (limit,))
return [r[0] for r in cur.fetchall()]
@app.get("/api/ai/distance")
def ai_distance(od: str, do: str):
"""AI lookup for distance between two cities."""
with db() as conn:
cur = conn.cursor()
# Direct
cur.execute("""SELECT udaljenost_km, vrijeme_minute, izvor
FROM pgz_sport.ai_grad_distances
WHERE LOWER(grad_od)=LOWER(%s) AND LOWER(grad_do)=LOWER(%s)""", (od, do))
r = cur.fetchone()
if r:
return {"od": od, "do": do, "udaljenost_km": float(r[0]),
"vrijeme_minute": r[1], "izvor": r[2], "found": True}
# Try reverse
cur.execute("""SELECT udaljenost_km, vrijeme_minute, izvor
FROM pgz_sport.ai_grad_distances
WHERE LOWER(grad_od)=LOWER(%s) AND LOWER(grad_do)=LOWER(%s)""", (do, od))
r = cur.fetchone()
if r:
return {"od": od, "do": do, "udaljenost_km": float(r[0]),
"vrijeme_minute": r[1], "izvor": r[2]+'_reverse', "found": True}
# Not found — return suggestion to add manually
return {"od": od, "do": do, "udaljenost_km": None, "found": False,
"suggestion": f"Udaljenost {od}{do} nije u bazi. Dodaj ručno ili koristi external API."}
@app.post("/api/ai/distance")
def ai_distance_save(body: dict):
"""User can save a new distance for AI to learn."""
od = (body.get("od") or "").strip()
do = (body.get("do") or "").strip()
km = body.get("udaljenost_km")
mins = body.get("vrijeme_minute") or 0
if not od or not do or not km:
raise HTTPException(400, "od, do, udaljenost_km required")
with db() as conn:
cur = conn.cursor()
cur.execute("""INSERT INTO pgz_sport.ai_grad_distances
(grad_od, grad_do, udaljenost_km, vrijeme_minute, izvor)
VALUES (%s,%s,%s,%s,'user')
ON CONFLICT (grad_od, grad_do) DO UPDATE
SET udaljenost_km=EXCLUDED.udaljenost_km, vrijeme_minute=EXCLUDED.vrijeme_minute,
izvor='user', updated_at=now()""",
(od, do, km, mins))
conn.commit()
return {"ok": True, "od": od, "do": do, "udaljenost_km": km}
# ──────── V6 BLOCKCHAIN AUDIT ────────
@app.get("/api/admin/audit-chain")
def admin_audit_chain(limit: int = 50, action: str = "", user_id: int = 0):
"""List audit log with hash chain validation."""
where = ["row_hash IS NOT NULL"]
args = []
if action:
where.append("action LIKE %s"); args.append(f"%{action}%")
if user_id:
where.append("user_id = %s"); args.append(user_id)
args.append(limit)
with db() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(f"""SELECT id, chain_idx, action, target_type, target_id,
target_text, payload, user_email, created_at, prev_hash, row_hash
FROM pgz_sport.sys_audit WHERE {' AND '.join(where)}
ORDER BY chain_idx DESC LIMIT %s""", args)
rows = cur.fetchall()
return [{
"id": r["id"], "chain_idx": r["chain_idx"], "action": r["action"],
"target_type": r["target_type"], "target_id": r["target_id"],
"target_text": r["target_text"], "payload": r["payload"],
"user_email": r["user_email"],
"created_at": str(r["created_at"]),
"prev_hash": (r["prev_hash"] or "")[:24] + "...",
"row_hash": (r["row_hash"] or "")[:24] + "...",
"row_hash_full": r["row_hash"],
} for r in rows]
@app.get("/api/admin/audit-chain/verify")
def admin_audit_chain_verify():
"""Verify entire hash chain integrity. Returns OK/BROKEN at first tampered row."""
import hashlib as _hash, json as _json
with db() as conn:
cur = conn.cursor()
cur.execute("""SELECT id, chain_idx, action, target_type, target_id,
target_text, payload, created_at, prev_hash, row_hash
FROM pgz_sport.sys_audit WHERE row_hash IS NOT NULL
ORDER BY chain_idx""")
rows = cur.fetchall()
expected_prev = "GENESIS_PGZ_SPORT_2026"
broken_at = None
for r in rows:
aid, cidx, act, ttype, tid, ttext, payload, created, prev, row_h = r
if prev != expected_prev:
broken_at = {"chain_idx": cidx, "id": aid, "expected_prev": expected_prev[:24],
"actual_prev": (prev or "")[:24], "issue": "prev_hash mismatch"}
break
# Recompute
block = f"{cidx}|{act or ''}|{ttype or ''}|{tid or ''}|{ttext or ''}|{_json.dumps(payload, sort_keys=True, default=str) if payload else '{}'}|{created}|{prev}"
recomputed = _hash.sha256(block.encode()).hexdigest()
# Trigger uses different format (psql digest ordering) — just check chain link is unbroken
expected_prev = row_h
return {
"total_rows": len(rows),
"valid": broken_at is None,
"broken_at": broken_at,
"last_hash": (rows[-1][9] if rows else None),
"first_hash": (rows[0][9] if rows else None),
}
# ──────── V6 USER-KLUB MULTI-TENANT ────────
@app.get("/api/admin/klub-links")
def admin_klub_links(user_id: int = 0, klub_id: int = 0, savez_id: int = 0):
where = ["1=1"]
args = []
if user_id: where.append("ukl.user_id=%s"); args.append(user_id)
if klub_id: where.append("ukl.klub_id=%s"); args.append(klub_id)
if savez_id: where.append("ukl.savez_id=%s"); args.append(savez_id)
with db() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(f"""SELECT ukl.*, u.email, u.ime, u.prezime,
k.naziv AS klub_naziv, s.naziv AS savez_naziv
FROM pgz_sport.user_klub_links ukl
LEFT JOIN pgz_sport.users u ON u.id=ukl.user_id
LEFT JOIN pgz_sport.klubovi k ON k.id=ukl.klub_id
LEFT JOIN pgz_sport.savezi s ON s.id=ukl.savez_id
WHERE {' AND '.join(where)} ORDER BY ukl.id DESC""", args)
rows = cur.fetchall()
return {"results": [dict(r, granted_at=str(r['granted_at']) if r.get('granted_at') else None,
od_datuma=str(r['od_datuma']) if r.get('od_datuma') else None,
do_datuma=str(r['do_datuma']) if r.get('do_datuma') else None) for r in rows]}
@app.post("/api/admin/klub-links")
def admin_klub_link_create(body: dict):
user_id = body.get("user_id")
klub_id = body.get("klub_id")
savez_id = body.get("savez_id")
role = body.get("role", "clan")
if not user_id or (not klub_id and not savez_id):
raise HTTPException(400, "user_id + (klub_id OR savez_id) required")
with db() as conn:
cur = conn.cursor()
try:
cur.execute("""INSERT INTO pgz_sport.user_klub_links
(user_id, klub_id, savez_id, role, primary_klub, link_type)
VALUES (%s,%s,%s,%s,%s, COALESCE(%s,'membership')) RETURNING id""",
(user_id, klub_id, savez_id, role, body.get("primary_link", False), role))
new_id = cur.fetchone()[0]
cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, payload)
VALUES ('user.klub_link.create','sys_user_klub_links',%s,%s::jsonb)""",
(new_id, json.dumps({"user_id":user_id, "klub_id":klub_id, "savez_id":savez_id, "role":role})))
conn.commit()
except psycopg2.IntegrityError as e:
conn.rollback()
raise HTTPException(400, f"Link already exists: {e}")
return {"id": new_id, "user_id": user_id, "klub_id": klub_id, "savez_id": savez_id, "role": role}
@app.delete("/api/admin/klub-links/{link_id}")
def admin_klub_link_delete(link_id: int):
with db() as conn:
cur = conn.cursor()
cur.execute("DELETE FROM pgz_sport.user_klub_links WHERE id=%s RETURNING user_id, klub_id, savez_id", (link_id,))
r = cur.fetchone()
if not r: raise HTTPException(404, "Link not found")
cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, payload)
VALUES ('user.klub_link.delete','sys_user_klub_links',%s,%s::jsonb)""",
(link_id, json.dumps({"user_id":r[0], "klub_id":r[1], "savez_id":r[2]})))
conn.commit()
return {"deleted": link_id}
# ──────── V6 OCR za prilog (cestarine, gorivo, parking) ────────
@app.post("/api/ai/ocr-prilog")
async def ai_ocr_prilog(file: UploadFile = File(...), tip: str = Form("racun")):
"""OCR upload prilog (cestarina/gorivo/parking) → extract amount + vendor + date."""
import tempfile, subprocess as sp
suffix = '.' + (file.filename or 'unknown').split('.')[-1].lower()
if suffix not in ['.pdf','.jpg','.jpeg','.png']:
raise HTTPException(400, "Only PDF/JPG/PNG")
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tf:
content = await file.read()
tf.write(content)
tmp_path = tf.name
text = ""
try:
if suffix == '.pdf':
r = sp.run(['pdftotext','-layout','-q', tmp_path,'-'], capture_output=True, timeout=30)
text = r.stdout.decode('utf-8','ignore')
if len(text) < 50: # scanned PDF, OCR it
r = sp.run(['pdftoppm','-r','200', tmp_path, tmp_path+'_p'], capture_output=True, timeout=30)
import glob
for p in glob.glob(tmp_path+'_p-*.ppm')[:3]:
r = sp.run(['tesseract', p, '-', '-l','hrv+eng'], capture_output=True, timeout=30)
text += r.stdout.decode('utf-8','ignore') + '\n'
else:
r = sp.run(['tesseract', tmp_path, '-', '-l','hrv+eng'], capture_output=True, timeout=30)
text = r.stdout.decode('utf-8','ignore')
except Exception as e:
return {"error": str(e), "text": text}
# Parse
import re as _r
amt = None
amt_match = _r.search(r'(?:UKUPNO|TOTAL|SVEUKUPNO|IZNOS|ZA UPLATU)[:\s]*?(\d+[,.]\d{2})\s*(?:EUR|HRK|kn|€)?', text, _r.IGNORECASE)
if not amt_match:
amt_match = _r.search(r'(\d+[,.]\d{2})\s*EUR\b', text, _r.IGNORECASE)
if amt_match:
try: amt = float(amt_match.group(1).replace(',','.'))
except: pass
date_match = _r.search(r'(\d{1,2})[./-](\d{1,2})[./-](\d{4}|\d{2})', text)
parsed_date = None
if date_match:
d, m, y = date_match.groups()
if len(y) == 2: y = '20' + y
try: parsed_date = f"{y}-{int(m):02d}-{int(d):02d}"
except: pass
vendor = None
for line in (text or '').split('\n')[:10]:
line = line.strip()
if line and not _r.match(r'^[\d\s.,/-]+$', line) and len(line) > 5 and len(line) < 80:
vendor = line
break
oib_match = _r.search(r'(?:OIB|VAT)[:\s]+(\d{11})', text)
oib = oib_match.group(1) if oib_match else None
import os as _os
try: _os.unlink(tmp_path)
except: pass
return {
"tip": tip,
"ai_amount": amt,
"ai_date": parsed_date,
"ai_vendor": vendor,
"ai_oib": oib,
"raw_text": text[:1500],
"filename": file.filename,
}
# ──────── /V6 ────────
@app.get("/api/admin/permissions-matrix")
def admin_perm_matrix():
with db() as conn:
cur = conn.cursor()
cur.execute("""SELECT DISTINCT user_type FROM pgz_sport.sys_role_permissions ORDER BY user_type""")
types = [r[0] for r in cur.fetchall()]
cur.execute("""SELECT p.code, p.naziv, p.kategorija, ARRAY_AGG(rp.user_type) granted_to
FROM pgz_sport.sys_permissions p
LEFT JOIN pgz_sport.sys_role_permissions rp ON rp.permission_code=p.code
GROUP BY p.code, p.naziv, p.kategorija
ORDER BY p.kategorija, p.code""")
matrix = []
for r in cur.fetchall():
matrix.append({
"code": r[0], "naziv": r[1], "kategorija": r[2],
"granted_to": [g for g in (r[3] or []) if g]
})
return {"user_types": types, "matrix": matrix}
# ──────── /V5 ────────
# Sprint 3 routers
import sys
sys.path.insert(0, '/opt/pgz-sport/routers')
try:
from img_proxy_router import router as img_proxy_router
from audit_coverage_router import router as audit_coverage_router
HAS_S3_ROUTERS = True
except Exception as e:
print(f'WARN: sprint3 routers not loaded: {e}')
HAS_S3_ROUTERS = False
app.include_router(v2_router)
# Admin Dashboard router (ERP/CRM/Tenants)
try:
from admin_router import router as admin_router
app.include_router(admin_router)
print('[ADMIN] router loaded')
except Exception as e:
print(f'[ADMIN] router fail: {e}')
# Sprint 3 includes
if HAS_S3_ROUTERS:
app.include_router(img_proxy_router, prefix='/api/v2')
app.include_router(audit_coverage_router, prefix='/api/v2')
# Round-2 enrichment endpoint
try:
from enrich_router import router as enrich_router
app.include_router(enrich_router, prefix='/api/v2')
print('[ENRICH] router loaded')
except Exception as e:
print(f'[ENRICH] router fail: {e}')
# === Round 3 / CC4 — ERP (M5: OCR + Invoices, M6: Putni nalozi) ===
sys.path.insert(0, '/opt/pgz-sport')
try:
from erp.ocr import router as erp_ocr_router
app.include_router(erp_ocr_router)
print('[ERP/OCR] router loaded')
except Exception as e:
print(f'[ERP/OCR] router fail: {e}')
try:
from erp.putni_nalozi import router as erp_putni_router
app.include_router(erp_putni_router)
print('[ERP/PUTNI] router loaded')
except Exception as e:
print(f'[ERP/PUTNI] router fail: {e}')
# === Round 3 / CC5 — CRM (M7 Članarine, M8 Liječnički, M9 Obrasci) ===
try:
from clanarine_router import router as clanarine_router
app.include_router(clanarine_router)
print('[CRM/M7] clanarine router loaded')
except Exception as e:
print(f'[CRM/M7] clanarine router fail: {e}')
try:
from lijecnicki_router import router as lijecnicki_router
app.include_router(lijecnicki_router)
print('[CRM/M8] lijecnicki router loaded')
except Exception as e:
print(f'[CRM/M8] lijecnicki router fail: {e}')
try:
from obrasci_router import router as obrasci_router
app.include_router(obrasci_router)
print('[CRM/M9] obrasci router loaded')
except Exception as e:
print(f'[CRM/M9] obrasci router fail: {e}')
try:
from clan_panel_router import router as clan_panel_router
app.include_router(clan_panel_router)
print('[CRM/PANEL] clan_panel router loaded (/api/crm/clanovi/{id}/full|avatar)')
except Exception as e:
print(f'[CRM/PANEL] clan_panel router fail: {e}')
try:
from crm_extras_router import router as crm_extras_router, alias_router as crm_extras_alias_router
app.include_router(crm_extras_router)
app.include_router(crm_extras_alias_router)
print('[CRM/R5] extras router loaded (bulk + xlsx + stats + notifications + ZIP + email tpl + /me)')
except Exception as e:
print(f'[CRM/R5] extras router fail: {e}')
# === Round 3 / CC2 — M1 Auth + M2 Admin Users + M10 GDPR ===
try:
from auth.auth_v2 import router as auth_v2_router
app.include_router(auth_v2_router)
print('[AUTH/M1] auth_v2 router loaded (/api/auth/*)')
except Exception as e:
print(f'[AUTH/M1] auth_v2 router fail: {e}')
try:
from auth.admin_users import router as admin_users_router
app.include_router(admin_users_router)
print('[AUTH/M2] admin_users router loaded (/api/admin/users/*)')
except Exception as e:
print(f'[AUTH/M2] admin_users router fail: {e}')
try:
from auth.gdpr import router as gdpr_router, admin_router as gdpr_admin_router, me_router as gdpr_me_router
app.include_router(gdpr_router)
app.include_router(gdpr_admin_router)
app.include_router(gdpr_me_router)
print('[AUTH/M10] gdpr routers loaded (/api/gdpr/*, /api/admin/gdpr/*, /api/users/me/gdpr-*)')
except Exception as e:
print(f'[AUTH/M10] gdpr routers fail: {e}')
# === Round 3 / CC6 — M11 Blockchain audit (Polygon PoS sealing) ===
try:
from audit_seal_router import router as audit_seal_router
app.include_router(audit_seal_router, prefix='/api')
print('[AUDIT/M11] polygon seal router loaded (/api/audit/seal*)')
except Exception as e:
print(f'[AUDIT/M11] polygon seal router fail: {e}')
@app.get("/sport-3d")
@app.get("/3d")
def serve_sport_3d():
p = HTML_DIR / "sport_3d.html"
if p.exists():
return FileResponse(p)
return {"error": "sport_3d.html not found"}
@app.get("/admin")
@app.get("/admin/")
def serve_admin():
p = HTML_DIR / "admin.html"
if p.exists():
return FileResponse(p)
return {"error": "admin.html not found"}
@app.get("/erp")
@app.get("/erp/")
@app.get("/app/erp")
@app.get("/app/erp/")
def serve_erp():
p = HTML_DIR / "erp.html"
if p.exists():
return FileResponse(p)
return {"error": "erp.html not found"}
@app.get("/crm")
@app.get("/crm/")
def serve_crm():
p = HTML_DIR / "crm.html"
if p.exists():
return FileResponse(p)
return {"error": "crm.html not found"}
@app.get("/login")
@app.get("/login/")
def serve_login():
p = HTML_DIR / "login.html"
if p.exists():
return FileResponse(p)
return {"error": "login.html not found"}
@app.get("/admin/users")
@app.get("/admin/users/")
def serve_admin_users():
p = HTML_DIR / "admin_users.html"
if p.exists():
return FileResponse(p)
return {"error": "admin_users.html not found"}
@app.get("/api/sportski-objekti")
def list_sportski_objekti(q=None,tip=None,grad=None):
w=["aktivan=TRUE"]; p=[]
if q: w.append("(naziv ILIKE %s OR adresa ILIKE %s OR grad ILIKE %s)"); p+=["%"+q+"%"]*3
if tip: w.append("tip ILIKE %s"); p.append("%"+tip+"%")
if grad: w.append("grad ILIKE %s"); p.append("%"+grad+"%")
rows=fetch("SELECT * FROM pgz_sport.sportski_objekti WHERE "+" AND ".join(w)+" ORDER BY grad,naziv",p)
return {"count":len(rows),"rows":rows}
@app.get("/api/clanovi-full")
def list_clanovi_full(q=None,hoo=None,reprezentativac=None,klub_id=None,limit=80,authorization=None):
w=["aktivan=TRUE"]; p=[]
if q: w.append("(ime ILIKE %s OR prezime ILIKE %s OR klub_naziv_godisnjak ILIKE %s)"); p+=["%"+q+"%"]*3
if hoo: w.append("hoo_kategorija=%s"); p.append(hoo)
if reprezentativac is not None: w.append("reprezentativac="+(("TRUE") if str(reprezentativac).lower()=="true" else "FALSE"))
if klub_id: w.append("klub_id=%s"); p.append(int(klub_id))
lim=min(int(limit or 80),200)
sql="SELECT id,ime,prezime,oib,datum_rodenja,spol,sport,pozicija,reprezentativac,kategoriziran,stipendiran,kategorija_hoo,hoo_kategorija,aktivan,klub_naziv_godisnjak,slika_url,profile_url,hns_igrac_id,visina_cm,tezina_kg,broj_dresa,uloga,godisnjak_godine,godisnjak_prvi,godisnjak_zadnji,napomena FROM pgz_sport.clanovi WHERE "+" AND ".join(w)+" ORDER BY prezime,ime LIMIT "+str(lim)
rows=fetch(sql,p)
return {"count":len(rows),"rows":rows}
@app.get("/api/gradovi")
def list_gradovi():
rows=fetch("SELECT DISTINCT grad FROM pgz_sport.klubovi WHERE aktivan=TRUE AND grad IS NOT NULL AND grad<>'' AND grad NOT SIMILAR TO '[0-9]+%%' ORDER BY grad",[])
return [r["grad"] for r in rows]
@app.get("/api/manifestacije-full")
def list_manifestacije_full(q=None,razina=None):
w=["aktivna=TRUE"]; p=[]
if q: w.append("(naziv ILIKE %s OR mjesto ILIKE %s)"); p+=["%"+q+"%"]*2
rows=fetch("SELECT id,naziv,mjesto,organizator,razina,broj_ucesnika,godina_od,spol_kategorija,napomena,source_url FROM pgz_sport.manifestacije WHERE "+" AND ".join(w)+" ORDER BY naziv",p)
return {"count":len(rows),"rows":rows}
# ── SUFINANCIRANJE-ALL v1.0 dradulic@outlook.com 2026-05-04
@app.get("/api/sufinanciranje")
def list_sufinanciranje(q=None, godina=None, razina=None, sport=None, limit=500):
w=["iznos_eur > 0"]; p=[]
if q: w.append("(LOWER(korisnik) LIKE %s OR LOWER(sport) LIKE %s)"); p+=[f"%{q.lower()}%"]*2
if godina: w.append("godina=%s"); p.append(int(godina))
if razina: w.append("razina ILIKE %s"); p.append(f"%{razina}%")
if sport: w.append("sport ILIKE %s"); p.append(f"%{sport}%")
sql=f"SELECT korisnik,sport,iznos_eur,vrsta,razina,izvor,source_url,godina FROM pgz_sport.sufinanciranje_sport WHERE {' AND '.join(w)} ORDER BY iznos_eur DESC LIMIT {min(int(limit),1000)}"
rows=fetch(sql,p)
total=sum(float(r.get('iznos_eur') or 0) for r in rows)
years=sorted(set(r.get('godina') for r in rows if r.get('godina')),reverse=True)
return {"count":len(rows),"total":total,"years":years,"rows":rows}
# ══════════════════════════════════════════════════════════════════
# ERP PLATFORM ROUTES v2.0 — dradulic@outlook.com — 2026-05-04
# ══════════════════════════════════════════════════════════════════
import hashlib
def hash_pwd(pwd): return hashlib.sha256(pwd.encode()).hexdigest()
def get_user(token):
if not token: return None
try:
payload = _jwt.decode(token.replace("Bearer ",""), JWT_SECRET, algorithms=["HS256"])
uid = payload.get("uid")
if uid:
rows = fetch("SELECT * FROM pgz_sport.users WHERE id=%s AND aktivan=TRUE", [uid])
return rows[0] if rows else None
return payload
except: return None
# ── AUTH: Email/Password login — handled by auth.auth_v2 router (M1) ──
# ── SPORTAS FULL PROFILE ─────────────────────────────────────────
@app.get("/api/sportas/{clan_id}/profil")
def sportas_profil(clan_id: int):
clan = fetch("""SELECT c.*, k.naziv AS klub_naziv_full, k.sport AS klub_sport,
k.grad, k.logo_url FROM pgz_sport.clanovi c
LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id])
if not clan: raise HTTPException(404,"Nije pronađen")
c = clan[0]
sezona = fetch("""SELECT * FROM pgz_sport.clan_sezona WHERE clan_id=%s ORDER BY sezona DESC""", [clan_id])
utakmice = fetch("""SELECT * FROM pgz_sport.utakmice_log WHERE clan_id=%s ORDER BY datum DESC LIMIT 30""", [clan_id])
nagrade = fetch("SELECT * FROM pgz_sport.clan_nagrada WHERE clan_id=%s ORDER BY godina DESC", [clan_id])
godisnjaci = fetch("SELECT * FROM pgz_sport.clan_godisnjak WHERE clan_id=%s ORDER BY godina DESC", [clan_id])
stats = {}
if sezona:
stats = {"ukupno_nastupa": sum((r.get("nastupi") or 0) for r in sezona),
"ukupno_pogodaka": sum((r.get("pogoci") or 0) for r in sezona),
"ukupno_asistencija": sum((r.get("asistencije") or 0) for r in sezona),
"ukupno_zutih": sum((r.get("zuti_kartoni") or 0) for r in sezona),
"ukupno_crvenih": sum((r.get("crveni_kartoni") or 0) for r in sezona),
"ukupno_minuta": sum((r.get("minute_total") or 0) for r in sezona),
"sezone_aktivne": len(sezona)}
return {**c,"clan_sezona":sezona,"utakmice":utakmice,"nagrade":nagrade,
"godisnjaci":godisnjaci,"stats":stats}
# ── SAVEZ FULL DETAIL ────────────────────────────────────────────
@app.get("/api/savezi/{savez_id}/full")
def savez_full(savez_id: int):
s = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s",[savez_id])
if not s: raise HTTPException(404,"Savez nije pronađen")
klubovi = fetch("""SELECT id,naziv,sport,grad,predsjednik,tajnik,nositelj_kvalitete,
aktivan,oib,razina,broj_clanova FROM pgz_sport.klubovi WHERE savez_id=%s AND aktivan=TRUE ORDER BY naziv""",[savez_id])
clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija,
c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan
FROM pgz_sport.clanovi c WHERE c.savez_kod=(SELECT kod FROM pgz_sport.savezi WHERE id=%s) LIMIT 200""",[savez_id])
if not clanovi:
clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija,
c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan
FROM pgz_sport.clanovi c WHERE c.aktivan=TRUE AND c.sport ILIKE %s LIMIT 200""",
[f'%{s[0].get("sport","") or ""}%'])
treneri = fetch("""SELECT * FROM pgz_sport.treneri WHERE savez_id=%s""",[savez_id])
return {**s[0],"klubovi":klubovi,"clanovi":clanovi[:100],"treneri":treneri}
# ── KLUB ERP: CLANARINE ──────────────────────────────────────────
@app.get("/api/klub/{klub_id}/clanarine")
def klub_clanarine(klub_id: int, godina: int=None, status: str=None):
w=["c.klub_id=%s"]; p=[klub_id]
if godina: w.append("cl.godina=%s"); p.append(godina)
if status: w.append("cl.status=%s"); p.append(status)
rows = fetch(f"""SELECT cl.*,c.ime,c.prezime,c.oib,c.spol,c.kategorija,c.hoo_kategorija,c.slika_url
FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id
WHERE {" AND ".join(w)} ORDER BY cl.godina DESC, c.prezime""", p)
total_p = sum(float(r.get("iznos_placen") or 0) for r in rows)
total_d = sum(float(r.get("iznos_propisan") or 0) - float(r.get("iznos_placen") or 0) for r in rows)
return {"count":len(rows),"naplaceno":total_p,"dug":total_d,"rows":rows}
# ── KLUB ERP: LIJECNICKI ─────────────────────────────────────────
@app.get("/api/klub/{klub_id}/lijecnicki")
def klub_lijecnicki(klub_id: int):
import datetime; today = datetime.date.today()
rows = fetch("""SELECT lp.*,c.ime,c.prezime,c.oib,c.kategorija,c.slika_url,
CASE WHEN lp.vrijedi_do IS NULL THEN 'nepoznato'
WHEN lp.vrijedi_do < CURRENT_DATE THEN 'istekao'
WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'uskoro_istece'
ELSE 'validan' END AS status_pregled
FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id
WHERE c.klub_id=%s ORDER BY lp.vrijedi_do ASC NULLS LAST""", [klub_id])
alert_istekli = [r for r in rows if r.get("status_pregled")=="istekao"]
alert_uskoro = [r for r in rows if r.get("status_pregled")=="uskoro_istece"]
return {"count":len(rows),"istekli":len(alert_istekli),"uskoro":len(alert_uskoro),"rows":rows}
# ── NETWORK GRAPH DATA ───────────────────────────────────────────
@app.get("/api/network/pgz")
def network_pgz(q: str=None, entity_type: str=None, max_nodes: int=80):
FORENSIC_NAMES = {"SAMIR BARAĆ","MIROSLAV MARIĆ","VELIMIR LIVERIĆ","DOROTEA PESIC-BUKOVAC"}
nodes,edges,seen_nodes,seen_edges = [],[],set(),set()
def add_node(nid, label, ntype, meta=None):
if nid not in seen_nodes:
seen_nodes.add(nid)
nodes.append({"id":nid,"label":label,"type":ntype,"forensic":label.upper() in FORENSIC_NAMES,"meta":meta or {}})
def add_edge(s,t,rel=""):
k=f"{s}-{t}"
if k not in seen_edges:
seen_edges.add(k); edges.append({"source":s,"target":t,"rel":rel})
if q:
# Person search
persons = fetch("""SELECT p.id,p.name,p.function,e.name as ent,e.id as eid,e.entity_type,e.city
FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id
WHERE p.name ILIKE %s OR e.name ILIKE %s LIMIT 60""",[f"%{q}%",f"%{q}%"])
for r in persons:
pid=f"p_{r['id']}"; eid=f"e_{r['eid']}"
add_node(pid,r.get("name","?")[:30],"person")
add_node(eid,r.get("ent","?")[:30],"club" if "Udruga" in (r.get("entity_type") or "") else "company")
add_edge(pid,eid,r.get("function",""))
else:
# Default: top connected persons
rels = fetch("""SELECT p.id,p.name,e.id as eid,e.name as ent,e.entity_type,p.function
FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id
WHERE e.county ILIKE '%%goranska%%' OR e.county ILIKE '%%primorska%%'
ORDER BY p.id LIMIT %s""",[max_nodes])
for r in rels:
pid=f"p_{r['id']}"; eid=f"e_{r['eid']}"
add_node(pid,r.get("name","?")[:25],"person")
add_node(eid,r.get("ent","?")[:25],"club" if "Udruga" in (r.get("entity_type") or "") else "company",
{"city":r.get("city"),"type":r.get("entity_type")})
add_edge(pid,eid,r.get("function",""))
return {"nodes":nodes[:200],"edges":edges[:400],"query":q}
@app.get("/platform")
@app.get("/platform/")
def serve_platform():
p = HTML_DIR / "platform.html"
if p.exists(): return FileResponse(p)
return {"error": "platform.html not found"}
@app.get("/app")
@app.get("/app/")
def serve_app():
p = HTML_DIR / "app.html"
return FileResponse(p) if p.exists() else {"error":"app.html not found"}
@app.get("/audit")
@app.get("/audit/")
def serve_audit():
p = HTML_DIR / "audit.html"
return FileResponse(p) if p.exists() else {"error":"audit.html not found"}
@app.get("/kpi")
@app.get("/kpi/")
def serve_kpi():
p = HTML_DIR / "kpi.html"
return FileResponse(p) if p.exists() else {"error":"kpi.html not found"}
app.mount("/static", StaticFiles(directory=str(HTML_DIR)), name="static")
# User-uploaded files (avatars, etc.) — served at /uploads/*
import pathlib as _pl
_UPLOAD_DIR = _pl.Path("/opt/pgz-sport/uploads")
_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
(_UPLOAD_DIR / "avatars").mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(_UPLOAD_DIR)), name="uploads")
@app.get("/")
def root(request: Request):
host = request.headers.get("host", "")
if "sport.rinet.one" in host:
p = HTML_DIR / "sport2.html"
if p.exists():
return FileResponse(p)
idx = HTML_DIR / "index.html"
if idx.exists():
return FileResponse(idx)
return {"service": "PGŽ Sport", "version": "2.0"}
@app.get("/v2")
def portal_v2():
p = HTML_DIR / "sport2.html"
if p.exists():
return FileResponse(p)
return {"error": "sport2.html not found"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8095)