1875 lines
87 KiB
Python
1875 lines
87 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
pgz_sport_api.py - FastAPI backend za PGŽ Sportski savez ERP/CRM
|
|
Author: Damir Radulić (damir@rinet.one / dradulic@outlook.com)
|
|
Date: 25.04.2026 (v1.1.0 — 2026-05-05: role-based OIB display + audit log)
|
|
Port: 8095
|
|
Endpoints: savezi, klubovi, članovi, članarine, liječnički, manifestacije, proračun, dashboard, alertovi
|
|
Changes (2026-05-05, sub-agent W5):
|
|
* is_admin() — recognizes super_admin / pgz_admin / pgz_user / pgz_finance /
|
|
pgz_zzjz JWT roles (previous code only matched literal "admin", which broke
|
|
PII visibility for actual PGŽ admins like Damir).
|
|
* apply_privacy() — now scope-aware: savez_admin sees full PII for own savez,
|
|
klub_admin sees full PII for own klub.
|
|
* Added _audit_oib_access() — records full-OIB reveals to Postgres audit_events
|
|
(table pgz_sport.audit_events) under action='oib.read'. Legitimate-interest
|
|
audit trail for GDPR Art.6(1)(f) defensibility.
|
|
"""
|
|
|
|
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/kpi")
|
|
def api_kpi():
|
|
"""CC6 analitika — single-payload KPI for /kpi page.
|
|
|
|
Returns top-level counts (savezi, klubovi, sportasi, members),
|
|
proracun current/trend, top10 sufinanciranje, sport distribution,
|
|
drill-down hooks, and a heartbeat so the page can refresh.
|
|
"""
|
|
counts_row = fetch("""SELECT
|
|
(SELECT COUNT(*) FROM pgz_sport.savezi) AS savezi,
|
|
(SELECT COUNT(*) FROM pgz_sport.klubovi WHERE aktivan) AS klubovi,
|
|
(SELECT COUNT(*) FROM pgz_sport.clanovi WHERE aktivan) AS sportasi,
|
|
(SELECT COUNT(*) FROM pgz_sport.clanovi WHERE aktivan AND reprezentativac) AS reprezentativci,
|
|
(SELECT COUNT(*) FROM pgz_sport.sportski_objekti WHERE aktivan) AS objekti,
|
|
(SELECT COUNT(*) FROM pgz_sport.manifestacije) AS manifestacije
|
|
""")
|
|
counts = counts_row[0] if counts_row else {}
|
|
|
|
proracun_trend = fetch("SELECT godina, ukupno FROM pgz_sport.proracun ORDER BY godina")
|
|
proracun_2026 = next((r['ukupno'] for r in proracun_trend if r.get('godina') == 2026), None)
|
|
|
|
top_sufin = fetch("""SELECT naziv_kluba, godina, iznos
|
|
FROM pgz_sport.potpore_nositelji
|
|
WHERE godina = 2025
|
|
ORDER BY iznos DESC NULLS LAST
|
|
LIMIT 10""")
|
|
|
|
by_sport = fetch("""SELECT sport, COUNT(*)::int 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 COALESCE(region, 'N/A') AS region, COUNT(*)::int AS broj
|
|
FROM pgz_sport.klubovi
|
|
WHERE aktivan
|
|
GROUP BY region
|
|
ORDER BY COUNT(*) DESC""")
|
|
|
|
# Liječnički expiring (next 30d) — ops widget
|
|
lijec_expiring = fetch("""SELECT COUNT(*)::int AS n
|
|
FROM pgz_sport.lijecnicki_pregledi
|
|
WHERE vrijedi_do BETWEEN CURRENT_DATE
|
|
AND CURRENT_DATE + INTERVAL '30 days'""")
|
|
lijec_expiring_n = lijec_expiring[0]['n'] if lijec_expiring else 0
|
|
|
|
return {
|
|
"as_of": datetime.now().isoformat(timespec='seconds'),
|
|
"counts": counts,
|
|
"proracun_2026": proracun_2026,
|
|
"proracun_trend": proracun_trend,
|
|
"top_sufinanciranje_2025": top_sufin,
|
|
"klubovi_by_sport": by_sport,
|
|
"klubovi_by_region": by_region,
|
|
"lijecnicki_expiring_30d": lijec_expiring_n,
|
|
"drill_down": {
|
|
"savezi": "/api/v2/savezi",
|
|
"klubovi": "/api/klubovi",
|
|
"sportasi": "/api/clanovi-full",
|
|
"objekti": "/api/sportski-objekti",
|
|
},
|
|
}
|
|
|
|
|
|
@app.get("/api/dashboard/top-primatelji")
|
|
def dashboard_top_primatelji(godina: int = 2025, limit: int = 50):
|
|
"""Top primatelji javnih potreba — svi klubovi sa primljenim potporama u godini."""
|
|
rows = fetch("""
|
|
SELECT
|
|
pn.naziv_kluba,
|
|
pn.klub_id,
|
|
pn.iznos,
|
|
pn.napomena,
|
|
pn.godina,
|
|
COALESCE(k.sport, 'n/a') AS sport,
|
|
COALESCE(s.naziv, '') AS savez_naziv,
|
|
COALESCE(k.razina, '') AS razina,
|
|
COALESCE(k.grad, '') AS grad,
|
|
CASE
|
|
WHEN pn.napomena ILIKE '%%županijski%%' OR pn.napomena ILIKE '%%PGZ%%' OR pn.napomena ILIKE '%%PGŽ%%' THEN 'Županijski sportski savez PGŽ'
|
|
WHEN pn.napomena ILIKE '%%riječki%%' OR pn.napomena ILIKE '%%RSS%%' THEN 'Riječki sportski savez'
|
|
WHEN pn.napomena ILIKE '%%grad rijeka%%' THEN 'Grad Rijeka'
|
|
ELSE 'Riječki sportski savez'
|
|
END AS davatelj_naziv
|
|
FROM pgz_sport.potpore_nositelji pn
|
|
LEFT JOIN pgz_sport.klubovi k ON k.id = pn.klub_id
|
|
LEFT JOIN pgz_sport.savezi s ON s.id = k.savez_id
|
|
WHERE pn.godina = %s
|
|
ORDER BY pn.iznos DESC NULLS LAST
|
|
LIMIT %s
|
|
""", (godina, limit))
|
|
|
|
return {
|
|
"godina": godina,
|
|
"count": len(rows),
|
|
"rows": rows,
|
|
"ukupno": sum((r.get("iznos") or 0) for r in rows),
|
|
}
|
|
|
|
|
|
@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")
|
|
|
|
# === DEBUG observability router (live dashboard) ===
|
|
try:
|
|
from routers.debug_router import router as debug_router
|
|
app.include_router(debug_router)
|
|
print('[DEBUG] observability router loaded (/api/debug/*)')
|
|
except Exception as e:
|
|
print(f'[DEBUG] router fail: {e}')
|
|
|
|
|
|
@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)
|