2500 lines
114 KiB
Python
2500 lines
114 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
|
|
import time
|
|
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'
|
|
|
|
# Roles that get full PII visibility globally (PGŽ tier).
|
|
# Mirrors auth/auth_v2.py PGZ_USER_TYPES; kept local to avoid import cycle.
|
|
_PGZ_FULL_PII_ROLES = {
|
|
"super_admin", "pgz_admin", "pgz_user", "pgz_finance", "pgz_zzjz",
|
|
"admin", # legacy bearer-token role
|
|
}
|
|
_SAVEZ_PII_ROLES = {"savez_admin", "savez_user"}
|
|
_KLUB_PII_ROLES = {"klub_admin", "klub_user", "klub_trener", "klub_clan"}
|
|
|
|
|
|
def _decode_jwt_safe(authorization):
|
|
"""Decode the bearer JWT using the same secret as auth_v2.
|
|
Returns the payload dict on success, None otherwise. Never raises."""
|
|
if not authorization:
|
|
return None
|
|
token = authorization.replace('Bearer ', '').strip()
|
|
if not token or token == ADMIN_TOKEN:
|
|
return None
|
|
try:
|
|
from auth.auth_v2 import decode_token as _decode
|
|
return _decode(token)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def auth_context(authorization):
|
|
"""Returns (role, klub_id, savez_id, email) — never raises.
|
|
role is one of: super_admin / pgz_admin / savez_admin / klub_admin /
|
|
viewer / 'admin' (legacy token) / None (unauthenticated)."""
|
|
if not authorization:
|
|
return (None, None, None, None)
|
|
token = authorization.replace('Bearer ', '').strip()
|
|
if token == ADMIN_TOKEN:
|
|
return ('admin', None, None, 'legacy-bearer')
|
|
payload = _decode_jwt_safe(authorization) or {}
|
|
role = (payload.get("role") or "viewer").lower()
|
|
scope = payload.get("tenant_scope") or {}
|
|
return (role, scope.get("klub_id"), scope.get("savez_id"), payload.get("email"))
|
|
|
|
|
|
def is_admin(authorization):
|
|
"""Backward-compatible boolean: True iff caller has unscoped full-PII access.
|
|
Now correctly recognizes super_admin / pgz_admin / pgz_user / pgz_finance /
|
|
pgz_zzjz JWT roles, not just literal 'admin'."""
|
|
role, _kid, _sid, _e = auth_context(authorization)
|
|
return role in _PGZ_FULL_PII_ROLES
|
|
|
|
|
|
def can_see_full_pii(authorization, klub_id=None, savez_id=None):
|
|
"""Scope-aware PII gate.
|
|
PGŽ-tier roles: full PII everywhere.
|
|
savez_admin/savez_user: full PII when row.savez_id == own savez_id.
|
|
klub_admin/klub_user/klub_trener/klub_clan: full PII when row.klub_id == own klub_id.
|
|
Otherwise: masked."""
|
|
role, kid, sid, _ = auth_context(authorization)
|
|
if role in _PGZ_FULL_PII_ROLES:
|
|
return True
|
|
if role in _SAVEZ_PII_ROLES and sid is not None and savez_id is not None and int(sid) == int(savez_id):
|
|
return True
|
|
if role in _KLUB_PII_ROLES and kid is not None and klub_id is not None and int(kid) == int(klub_id):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _audit_oib_access(authorization, resource_type, resource_id, count=1, reason="legitimate_interest"):
|
|
"""Log a full-OIB reveal to pgz_sport.audit_events (best-effort, never raises).
|
|
Used for GDPR Art.6(1)(f) defensibility. One row per request, not per OIB."""
|
|
try:
|
|
role, _kid, _sid, email = auth_context(authorization)
|
|
if role is None:
|
|
return # only log authenticated reveals
|
|
from auth.auth_v2 import audit as _audit
|
|
# uid not directly available without re-decoding; pull from payload
|
|
payload = _decode_jwt_safe(authorization) or {}
|
|
uid = payload.get("uid")
|
|
_audit(uid, "oib.read", resource_type=resource_type, resource_id=resource_id,
|
|
meta={"role": role, "email": email, "count": count, "reason": reason})
|
|
except Exception as _e:
|
|
# Audit must never break the request path
|
|
try:
|
|
print(f"[OIB_AUDIT WARN] {_e}")
|
|
except Exception:
|
|
pass
|
|
|
|
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, authorization=None):
|
|
"""Apply per-row privacy masking.
|
|
`admin`: legacy global override — when True, NOTHING is masked.
|
|
`authorization`: when provided, enables per-row scope-aware reveals
|
|
(savez_admin sees own savez rows in clear; klub_admin sees own klub
|
|
rows in clear). Falls back to row-level mask if scope mismatches.
|
|
"""
|
|
if admin: return rows
|
|
is_list = isinstance(rows, list)
|
|
out = []
|
|
for r in (rows if is_list else [rows]):
|
|
rr = dict(r)
|
|
# Per-row scope check (only relevant when authorization is supplied)
|
|
row_full = False
|
|
if authorization is not None:
|
|
row_full = can_see_full_pii(authorization,
|
|
klub_id=rr.get("klub_id") or rr.get("id_klub"),
|
|
savez_id=rr.get("savez_id") or rr.get("id_savez"))
|
|
if row_full:
|
|
out.append(rr)
|
|
continue
|
|
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 is_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/",
|
|
"/api/v2/export/", # ui-sprint: read-only export, mirrors public GET data
|
|
)
|
|
|
|
@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)):
|
|
role, klub_id, savez_id, email = auth_context(authorization)
|
|
full_pii = is_admin(authorization)
|
|
return {
|
|
"role": role or "viewer",
|
|
# Legacy boolean retained for backward compat with old frontend code
|
|
"is_admin": full_pii,
|
|
"privacy_active": not full_pii,
|
|
"scope": {"klub_id": klub_id, "savez_id": savez_id},
|
|
"email": email,
|
|
}
|
|
|
|
# ==================== 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, davatelj: str = None, limit: int = 100):
|
|
"""Top primatelji javnih potreba s davatelj filter + PDF link na godišnjak."""
|
|
where = []
|
|
params = []
|
|
if godina and godina > 0:
|
|
where.append("pn.godina = %s")
|
|
params.append(godina)
|
|
if davatelj and davatelj != 'all':
|
|
where.append("pn.davatelj = %s")
|
|
params.append(davatelj)
|
|
where_sql = "WHERE " + " AND ".join(where) if where else ""
|
|
|
|
rows = fetch(f"""
|
|
SELECT
|
|
pn.id,
|
|
pn.naziv_kluba,
|
|
pn.klub_id,
|
|
pn.iznos,
|
|
COALESCE(LEFT(pn.napomena, 60), '') AS napomena_short,
|
|
pn.napomena,
|
|
pn.godina,
|
|
COALESCE(pn.davatelj, 'RSS (Riječki sportski savez)') AS davatelj,
|
|
COALESCE(pn.vrsta, 'Javne potrebe') AS vrsta,
|
|
COALESCE(k.sport, 'n/a') AS sport,
|
|
COALESCE(s.naziv, '') AS savez_naziv,
|
|
COALESCE(k.razina, '') AS razina,
|
|
COALESCE(k.grad, '') AS grad,
|
|
d.id AS doc_id,
|
|
COALESCE(d.pdf_url, d.izvor_url, '/sport/api/v2/dokumenti/godisnjak/' || pn.godina::text) AS pdf_url,
|
|
COALESCE(d.title, 'Godišnjak ' || pn.godina::text) AS doc_title
|
|
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
|
|
LEFT JOIN pgz_sport.dokumenti d ON d.vrsta='godisnjak' AND d.godina = pn.godina
|
|
{where_sql}
|
|
ORDER BY pn.iznos DESC NULLS LAST, pn.naziv_kluba
|
|
LIMIT %s
|
|
""", tuple(params) + (limit,))
|
|
|
|
# Stats summary
|
|
stats = fetch(f"""
|
|
SELECT
|
|
count(*) AS total_records,
|
|
sum(iznos)::numeric(12,2) AS total_amount,
|
|
count(DISTINCT naziv_kluba) AS unique_klubova,
|
|
count(DISTINCT davatelj) AS unique_davatelji
|
|
FROM pgz_sport.potpore_nositelji pn
|
|
{where_sql}
|
|
""", tuple(params))
|
|
|
|
# Available years (always)
|
|
years = fetch("""
|
|
SELECT godina, count(*) AS broj, sum(iznos)::numeric(12,2) AS suma
|
|
FROM pgz_sport.potpore_nositelji
|
|
GROUP BY godina ORDER BY godina DESC
|
|
""")
|
|
|
|
# Available davatelji (always)
|
|
davatelji = fetch("""
|
|
SELECT DISTINCT COALESCE(davatelj, 'Nepoznato') AS davatelj
|
|
FROM pgz_sport.potpore_nositelji ORDER BY 1
|
|
""")
|
|
|
|
return {
|
|
"godina": godina,
|
|
"davatelj": davatelj or "all",
|
|
"rows": rows,
|
|
"summary": stats[0] if stats else {},
|
|
"available_years": years,
|
|
"available_davatelji": [d["davatelj"] for d in davatelji],
|
|
}
|
|
|
|
|
|
@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,
|
|
sport: Optional[str] = None, kategorija: 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}%")
|
|
if sport:
|
|
where += " AND sport ILIKE %s"; params.append(f"%{sport}%")
|
|
if kategorija:
|
|
kt = kategorija.strip().lower()
|
|
if kt in ("zupanijski","županijski"):
|
|
where += " AND lower(unaccent(razina)) ILIKE %s"; params.append("%zupanij%")
|
|
elif kt == "gradski":
|
|
where += " AND lower(unaccent(razina)) ILIKE %s"; params.append("%gradsk%")
|
|
else:
|
|
where += " AND lower(unaccent(razina)) ILIKE lower(unaccent(%s))"; params.append(f"%{kategorija}%")
|
|
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)
|
|
admin = is_admin(authorization)
|
|
rows = apply_privacy(rows, admin, authorization=authorization)
|
|
if admin:
|
|
_audit_oib_access(authorization, "savez_list", None, count=len(rows))
|
|
return {"count": len(rows), "rows": rows}
|
|
|
|
@app.get("/api/savezi/{savez_id}")
|
|
def get_savez(savez_id: int, authorization: Optional[str] = Header(None)):
|
|
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])
|
|
admin = is_admin(authorization)
|
|
savez = rows[0]
|
|
if not admin:
|
|
savez = apply_privacy(savez, admin, authorization=authorization)
|
|
klubovi = apply_privacy(klubovi, admin, authorization=authorization)
|
|
else:
|
|
_audit_oib_access(authorization, "savez", savez_id, count=1+len(klubovi))
|
|
return {**savez, "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,
|
|
kategorija: Optional[str] = None, godisnjak: Optional[bool] = None, financiran: Optional[bool] = None,
|
|
sort: str = "naziv", order: str = "asc"):
|
|
where = ["v.aktivan"]
|
|
params = []
|
|
if q:
|
|
where.append("(v.klub ILIKE %s OR v.oib ILIKE %s OR v.sport ILIKE %s OR v.predsjednik ILIKE %s)")
|
|
params.extend([f"%{q}%", f"%{q}%", f"%{q}%", f"%{q}%"])
|
|
if savez_id:
|
|
where.append("v.id IN (SELECT id FROM pgz_sport.klubovi WHERE savez_id=%s)"); params.append(savez_id)
|
|
if nositelj is not None:
|
|
where.append(f"v.nositelj_kvalitete={'TRUE' if nositelj else 'FALSE'}")
|
|
if region:
|
|
where.append("v.region ILIKE %s"); params.append(region)
|
|
if grad:
|
|
where.append("v.grad ILIKE %s"); params.append(f"%{grad}%")
|
|
if sport:
|
|
where.append("v.sport ILIKE %s"); params.append(f"%{sport}%")
|
|
if financiran is not None:
|
|
where.append(f"COALESCE(k.pgz_sufinanciran,false) = {'TRUE' if financiran else 'FALSE'}")
|
|
if godisnjak is not None:
|
|
if godisnjak:
|
|
where.append("(k.godisnjak_godine IS NOT NULL AND array_length(k.godisnjak_godine,1) > 0)")
|
|
else:
|
|
where.append("(k.godisnjak_godine IS NULL OR array_length(k.godisnjak_godine,1) IS NULL)")
|
|
if kategorija and kategorija.strip().lower() == "priority":
|
|
where.append("(COALESCE(k.pgz_sufinanciran,false) OR (k.godisnjak_godine IS NOT NULL AND array_length(k.godisnjak_godine,1) > 0))")
|
|
sort_col = {"naziv": "v.klub", "savez": "v.savez", "broj_clanova": "v.broj_clanova",
|
|
"razina": "v.razina", "region": "v.region", "grad": "v.grad", "sport": "v.sport"}.get(sort, "v.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 ("v.klub", "v.savez", "v.razina", "v.region", "v.grad", "v.sport") else ""
|
|
priority_expr = "(COALESCE(k.pgz_sufinanciran,false) OR (k.godisnjak_godine IS NOT NULL AND array_length(k.godisnjak_godine,1) > 0))"
|
|
rows = fetch(f"""SELECT v.*,
|
|
COALESCE(k.pgz_sufinanciran,false) AS financiran,
|
|
(k.godisnjak_godine IS NOT NULL AND array_length(k.godisnjak_godine,1) > 0) AS godisnjak,
|
|
{priority_expr} AS priority,
|
|
k.godisnjak_godine, k.godisnjak_prvi, k.godisnjak_zadnji
|
|
FROM pgz_sport.v_klubovi_pregled v
|
|
LEFT JOIN pgz_sport.klubovi k ON k.id = v.id
|
|
WHERE {where_sql}
|
|
ORDER BY {priority_expr} DESC NULLS LAST,
|
|
{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']
|
|
admin = is_admin(authorization)
|
|
rows = apply_privacy(rows, admin, authorization=authorization)
|
|
if admin:
|
|
_audit_oib_access(authorization, "klub_list", None, count=len(rows))
|
|
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]
|
|
# Scope-aware: klub_admin for THIS klub_id should see full PII even if
|
|
# is_admin() returns False (savez_admin similarly via klub.savez_id).
|
|
scope_full = can_see_full_pii(authorization, klub_id=klub_id, savez_id=klub.get("savez_id"))
|
|
if not admin and not scope_full:
|
|
klub = apply_privacy(klub, admin, authorization=authorization)
|
|
clanovi = apply_privacy(clanovi, admin, authorization=authorization)
|
|
clanarine = apply_privacy(clanarine, admin, authorization=authorization)
|
|
lijecnicki = apply_privacy(lijecnicki, admin, authorization=authorization)
|
|
else:
|
|
# Authenticated full-PII access — audit it.
|
|
_audit_oib_access(authorization, "klub", klub_id,
|
|
count=1 + len(clanovi) + len(clanarine) + len(lijecnicki))
|
|
|
|
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)
|
|
admin = is_admin(authorization)
|
|
rows = apply_privacy(rows, admin, authorization=authorization)
|
|
if admin:
|
|
_audit_oib_access(authorization, "clan_list", None, count=len(rows))
|
|
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}')
|
|
|
|
# === CRM v2 — Salesforce-Lite (Accounts/Contacts/Leads/Opportunities/Activities/Cases) ===
|
|
try:
|
|
from crm_router import router as crm_v2_router
|
|
app.include_router(crm_v2_router)
|
|
print('[CRM/v2] Salesforce-Lite router loaded (/api/v2/crm/*)')
|
|
except Exception as e:
|
|
print(f'[CRM/v2] 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("/erp/full")
|
|
@app.get("/erp/full/")
|
|
def serve_erp_full():
|
|
p = HTML_DIR / "erp_full.html"
|
|
if p.exists():
|
|
return FileResponse(p)
|
|
return {"error": "erp_full.html not found"}
|
|
|
|
# ═══ ERP FULL (SAP-Lite) router — kontni plan, dnevnik, glavna knjiga, partneri,
|
|
# racuni ulazni/izlazni, e-Račun XML, PDV, plaće, izvještaji, export
|
|
try:
|
|
from routers.erp_full_router import router as erp_full_router
|
|
app.include_router(erp_full_router)
|
|
print('[ERP-FULL] router loaded (/api/v2/erp/*)')
|
|
except Exception as e:
|
|
print(f'[ERP-FULL] router fail: {e}')
|
|
|
|
@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("/crm-v2")
|
|
@app.get("/crm-v2/")
|
|
@app.get("/crm_v2")
|
|
@app.get("/crm_v2/")
|
|
@app.get("/crm/v2")
|
|
@app.get("/crm")
|
|
def serve_crm_v2():
|
|
p = HTML_DIR / "crm_v2.html"
|
|
if p.exists():
|
|
return FileResponse(p)
|
|
return {"error": "crm_v2.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,
|
|
sport=None,kategorija=None,godina_rodenja=None,status=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))
|
|
if sport: w.append("sport ILIKE %s"); p.append(f"%{sport}%")
|
|
if kategorija:
|
|
w.append("(kategorija ILIKE %s OR %s = ANY(COALESCE(kategorije,ARRAY[]::text[])))")
|
|
p.extend([f"%{kategorija}%", kategorija])
|
|
if godina_rodenja:
|
|
try:
|
|
w.append("EXTRACT(YEAR FROM datum_rodenja)=%s"); p.append(int(godina_rodenja))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
if status:
|
|
s = str(status).strip().lower()
|
|
if s in ("reprezentativac","reprezentativci"): w.append("reprezentativac=TRUE")
|
|
elif s in ("kategoriziran","kategorizirani"): w.append("kategoriziran=TRUE")
|
|
elif s in ("stipendiran","stipendirani"): w.append("stipendiran=TRUE")
|
|
elif s in ("aktivan","aktivni"): w.append("aktivan=TRUE")
|
|
elif s in ("neaktivan","neaktivni","arhivirani"): w.append("aktivan=FALSE")
|
|
lim=min(int(limit or 80),500)
|
|
sql="SELECT id,ime,prezime,oib,datum_rodenja,spol,sport,pozicija,reprezentativac,kategoriziran,stipendiran,kategorija,kategorije,kategorija_hoo,hoo_kategorija,aktivan,klub_id,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.get("/dokumenti")
|
|
@app.get("/dokumenti/")
|
|
def serve_dokumenti():
|
|
p = HTML_DIR / "dokumenti.html"
|
|
return FileResponse(p) if p.exists() else {"error":"dokumenti.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("/api/v2/clan/{clan_id}/hns-career")
|
|
def clan_hns_career(clan_id: int):
|
|
"""HNS karijera za sportaša: sezone + utakmice."""
|
|
seasons = fetch("""
|
|
SELECT sezona, klub_naziv, natjecanje, nastupi, startna, zamjena, golovi, asistencije, zuti, crveni, minute
|
|
FROM pgz_sport.hns_player_seasons
|
|
WHERE clan_id = %s
|
|
ORDER BY sezona DESC
|
|
""", (clan_id,))
|
|
matches = fetch("""
|
|
SELECT datum, natjecanje, domacin, gost, rezultat, pozicija, startna, golovi, asistencije, zuti, crveni
|
|
FROM pgz_sport.hns_player_matches
|
|
WHERE clan_id = %s
|
|
ORDER BY datum DESC NULLS LAST
|
|
LIMIT 50
|
|
""", (clan_id,))
|
|
# Stats roll-up
|
|
summary = fetch("""
|
|
SELECT
|
|
count(DISTINCT sezona) AS sezona_broj,
|
|
sum(nastupi) AS ukupno_nastupi,
|
|
sum(golovi) AS ukupno_golovi,
|
|
sum(asistencije) AS ukupno_asistencije,
|
|
sum(zuti) AS ukupno_zuti,
|
|
sum(crveni) AS ukupno_crveni
|
|
FROM pgz_sport.hns_player_seasons WHERE clan_id = %s
|
|
""", (clan_id,))
|
|
return {
|
|
"clan_id": clan_id,
|
|
"summary": summary[0] if summary else {},
|
|
"seasons": seasons,
|
|
"matches": matches,
|
|
"total_seasons": len(seasons),
|
|
"total_matches": len(matches),
|
|
}
|
|
|
|
|
|
@app.get("/api/v2/klubovi/pgz-financirani")
|
|
def klubovi_pgz_financirani(sport: str = None, limit: int = 500):
|
|
"""PGŽ financirani klubovi — koji su primili novce iz potpora."""
|
|
where_extra = ""
|
|
params = []
|
|
if sport:
|
|
where_extra = " WHERE sport = %s"
|
|
params.append(sport)
|
|
rows = fetch(f"""
|
|
SELECT k.*,
|
|
(SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id = k.id) AS sportasa_count,
|
|
(SELECT count(*) FROM pgz_sport.hns_klub_roster WHERE klub_id = k.id) AS hns_roster_count,
|
|
(SELECT count(*) FROM pgz_sport.potpore_nositelji WHERE klub_id = k.id OR naziv_kluba ILIKE k.naziv) AS potpora_count,
|
|
(SELECT sum(iznos) FROM pgz_sport.potpore_nositelji WHERE klub_id = k.id OR naziv_kluba ILIKE k.naziv) AS potpora_ukupno
|
|
FROM pgz_sport.v_pgz_financirani_klubovi k
|
|
{where_extra}
|
|
ORDER BY potpora_ukupno DESC NULLS LAST
|
|
LIMIT %s
|
|
""", tuple(params) + (limit,))
|
|
return {"count": len(rows), "rows": rows}
|
|
|
|
|
|
@app.get("/api/v2/dashboard/hns-coverage")
|
|
def dashboard_hns_coverage():
|
|
"""HNS Coverage widget data."""
|
|
stats = fetch("""
|
|
SELECT
|
|
(SELECT count(*) FROM pgz_sport.v_pgz_financirani_klubovi WHERE sport='nogomet' AND source_url LIKE %s) AS klubova_target,
|
|
(SELECT count(DISTINCT klub_id) FROM pgz_sport.hns_klub_roster) AS klubova_scraped,
|
|
(SELECT count(*) FROM pgz_sport.clanovi WHERE hns_igrac_id IS NOT NULL) AS sportasa_s_hns,
|
|
(SELECT count(*) FROM pgz_sport.hns_klub_roster) AS roster_total,
|
|
(SELECT count(*) FROM pgz_sport.hns_player_seasons) AS seasons_total,
|
|
(SELECT max(scraped_at) FROM pgz_sport.hns_klub_roster) AS last_sync
|
|
""", ('%semafor.hns.family/klubovi%',))
|
|
return stats[0] if stats else {}
|
|
|
|
|
|
@app.get("/api/v2/enrich-sources")
|
|
def enrich_sources():
|
|
"""Sport→source mapping za frontend Obogati podatke dugme."""
|
|
rows = fetch("SELECT * FROM pgz_sport.enrichment_sources ORDER BY sport")
|
|
return {"sources": rows}
|
|
|
|
|
|
@app.get("/api/v2/clan/{clan_id}/kategorije")
|
|
def clan_kategorije(clan_id: int):
|
|
"""Kategorije igrača (M2M)."""
|
|
rows = fetch("""
|
|
SELECT kategorija, sezona, klub_id, source, source_url, scraped_at
|
|
FROM pgz_sport.clan_kategorije WHERE clan_id = %s
|
|
ORDER BY sezona DESC, kategorija
|
|
""", (clan_id,))
|
|
return {"clan_id": clan_id, "kategorije": rows}
|
|
|
|
|
|
@app.get("/api/v2/klubovi/priority-sort")
|
|
def klubovi_priority_sort(sport: str = None, limit: int = 500):
|
|
"""Klubovi sortirani: priority (financirani || godišnjak) prvi."""
|
|
where = ""
|
|
params = []
|
|
if sport:
|
|
where = " WHERE sport = %s"
|
|
params.append(sport)
|
|
rows = fetch(f"""
|
|
SELECT k.*, k.priority_label,
|
|
(SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id = k.id) AS sportasa,
|
|
(SELECT count(*) FROM pgz_sport.hns_klub_roster WHERE klub_id = k.id) AS hns_roster,
|
|
(SELECT sum(iznos) FROM pgz_sport.potpore_nositelji WHERE klub_id = k.id OR naziv_kluba ILIKE k.naziv) AS potpora_ukupno
|
|
FROM pgz_sport.v_klubovi_priority_sort k
|
|
{where}
|
|
ORDER BY priority, potpora_ukupno DESC NULLS LAST, naziv
|
|
LIMIT %s
|
|
""", tuple(params) + (limit,))
|
|
return {"count": len(rows), "rows": rows}
|
|
|
|
|
|
@app.get("/api/v2/clan/{clan_id}/full")
|
|
def clan_full(clan_id: int):
|
|
"""Punu sliku igrača: profil + kategorije + sezone + utakmice + potpore."""
|
|
profile = fetch("SELECT * FROM pgz_sport.clanovi WHERE id = %s", (clan_id,))
|
|
if not profile: return {"error": "not_found"}
|
|
p = profile[0]
|
|
|
|
kategorije = fetch("SELECT * FROM pgz_sport.clan_kategorije WHERE clan_id = %s ORDER BY sezona DESC", (clan_id,))
|
|
seasons = fetch("SELECT * FROM pgz_sport.hns_player_seasons WHERE clan_id = %s ORDER BY sezona DESC", (clan_id,))
|
|
matches = fetch("SELECT * FROM pgz_sport.hns_player_matches WHERE clan_id = %s ORDER BY datum DESC NULLS LAST LIMIT 30", (clan_id,))
|
|
multi_stats = fetch("SELECT * FROM pgz_sport.player_stats WHERE clan_id = %s ORDER BY sezona DESC", (clan_id,))
|
|
|
|
return {
|
|
"profile": p,
|
|
"kategorije": kategorije,
|
|
"hns_seasons": seasons,
|
|
"hns_matches": matches,
|
|
"multi_sport_stats": multi_stats,
|
|
"stats": {
|
|
"total_seasons": len(seasons),
|
|
"total_matches": len(matches),
|
|
"total_kategorije": len(kategorije),
|
|
}
|
|
}
|
|
|
|
|
|
@app.post("/api/v2/export/klubovi")
|
|
def export_klubovi(req: dict):
|
|
"""Export klubova kao XLSX."""
|
|
import io
|
|
try:
|
|
from openpyxl import Workbook
|
|
except ImportError:
|
|
return {"error": "openpyxl not installed"}
|
|
|
|
ids = req.get("ids", [])
|
|
if not ids:
|
|
return {"error": "no ids"}
|
|
|
|
rows = fetch("""
|
|
SELECT k.id, k.naziv, k.sport, k.razina, k.oib, k.grad,
|
|
k.financiran, k.u_godisnjaku, k.priority_label,
|
|
(SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id = k.id) AS sportasa,
|
|
(SELECT sum(iznos) FROM pgz_sport.potpore_nositelji WHERE klub_id = k.id OR naziv_kluba ILIKE k.naziv) AS potpora
|
|
FROM pgz_sport.v_klubovi_priority_sort k
|
|
WHERE k.id = ANY(%s)
|
|
ORDER BY k.priority, k.naziv
|
|
""", (ids,))
|
|
|
|
wb = Workbook()
|
|
ws = wb.active
|
|
ws.title = "Klubovi"
|
|
|
|
if rows:
|
|
headers = list(rows[0].keys())
|
|
ws.append([h.replace('_',' ').title() for h in headers])
|
|
for r in rows:
|
|
ws.append([r.get(h) for h in headers])
|
|
|
|
buf = io.BytesIO()
|
|
wb.save(buf)
|
|
buf.seek(0)
|
|
|
|
from fastapi.responses import StreamingResponse
|
|
return StreamingResponse(
|
|
buf,
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={"Content-Disposition": f"attachment; filename=klubovi_export_{int(time.time())}.xlsx"}
|
|
)
|
|
|
|
|
|
@app.get("/api/v2/sportasi/financirani")
|
|
def sportasi_financirani(sport: str = None, kategorija: str = None, godiste: int = None, limit: int = 1000):
|
|
"""Sportaši koji pripadaju klubovima financiranim od PGŽ."""
|
|
where = ["c.klub_id IN (SELECT id FROM pgz_sport.v_pgz_priority_klubovi WHERE financiran)"]
|
|
params = []
|
|
if sport:
|
|
where.append("c.sport = %s"); params.append(sport)
|
|
if kategorija:
|
|
where.append("c.kategorija = %s"); params.append(kategorija)
|
|
if godiste:
|
|
where.append("EXTRACT(YEAR FROM c.datum_rodenja) = %s OR c.godina_rodenja = %s")
|
|
params.extend([godiste, godiste])
|
|
|
|
rows = fetch(f"""
|
|
SELECT c.id, c.ime, c.prezime, c.sport, c.kategorija, c.klub_id,
|
|
k.naziv AS klub_naziv, k.financiran, k.u_godisnjaku, k.priority_label,
|
|
c.datum_rodenja, c.godina_rodenja, c.hns_igrac_id, c.source_url,
|
|
(SELECT count(*) FROM pgz_sport.hns_player_seasons WHERE clan_id = c.id) AS sezone,
|
|
(SELECT count(*) FROM pgz_sport.clan_kategorije WHERE clan_id = c.id) AS kategorije_count
|
|
FROM pgz_sport.clanovi c
|
|
JOIN pgz_sport.v_klubovi_priority_sort k ON k.id = c.klub_id
|
|
WHERE {' AND '.join(where)}
|
|
ORDER BY c.prezime, c.ime
|
|
LIMIT %s
|
|
""", tuple(params) + (limit,))
|
|
return {"count": len(rows), "rows": rows}
|
|
|
|
|
|
@app.get("/api/v2/savezi/financirani")
|
|
def savezi_financirani(sport: str = None):
|
|
"""Savezi (i njihovi klubovi) koji su PGŽ financirani."""
|
|
where = ""
|
|
params = []
|
|
if sport:
|
|
where = " AND s.sport = %s"
|
|
params.append(sport)
|
|
rows = fetch(f"""
|
|
SELECT s.id, s.naziv, s.sport, s.razina, s.oib,
|
|
(SELECT count(*) FROM pgz_sport.klubovi k WHERE k.savez_id = s.id) AS klubovi,
|
|
(SELECT count(*) FROM pgz_sport.v_pgz_priority_klubovi k WHERE k.savez_id = s.id AND k.financiran) AS klubovi_financirani,
|
|
(SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id = c.klub_id WHERE k.savez_id = s.id) AS sportasa,
|
|
(SELECT sum(iznos) FROM pgz_sport.potpore_nositelji p
|
|
JOIN pgz_sport.klubovi k ON k.id = p.klub_id WHERE k.savez_id = s.id) AS potpora_ukupno
|
|
FROM pgz_sport.savezi s
|
|
WHERE 1=1 {where}
|
|
ORDER BY potpora_ukupno DESC NULLS LAST
|
|
""", tuple(params))
|
|
return {"count": len(rows), "rows": rows}
|
|
|
|
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# DOKUMENTI ENDPOINTS — godišnjaci, publikacije, sport-savez izdanja
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
@app.get("/api/v2/dokumenti")
|
|
def dokumenti_list(vrsta: str = None, sport: str = None, godina: int = None, q: str = None, limit: int = 100):
|
|
"""Lista dokumenata: godišnjaci, publikacije, etc."""
|
|
where = ["aktivan = true"]
|
|
params = []
|
|
if vrsta:
|
|
where.append("vrsta = %s"); params.append(vrsta)
|
|
if sport:
|
|
where.append("sport = %s"); params.append(sport)
|
|
if godina:
|
|
where.append("godina = %s"); params.append(godina)
|
|
if q:
|
|
where.append("(title ILIKE %s OR sadrzaj_summary ILIKE %s OR organizacija ILIKE %s)")
|
|
params.extend([f"%%{q}%%"]*3)
|
|
|
|
rows = fetch(f"""
|
|
SELECT id, title, fname, vrsta, sport, godina, organizacija,
|
|
izvor_url, izdano_datum, kratak_opis, kljucne_rijeci, scraped_at,
|
|
LENGTH(sadrzaj) AS sadrzaj_size
|
|
FROM pgz_sport.dokumenti
|
|
WHERE {' AND '.join(where)}
|
|
ORDER BY godina DESC NULLS LAST, izdano_datum DESC NULLS LAST, id DESC
|
|
LIMIT %s
|
|
""", tuple(params) + (limit,))
|
|
return {"count": len(rows), "rows": rows}
|
|
|
|
|
|
@app.get("/api/v2/dokumenti/godisnjaci/list")
|
|
def dokumenti_godisnjaci_list():
|
|
"""Lista svih godišnjaka."""
|
|
rows = fetch("""
|
|
SELECT id, godina, title, fname, izdano_datum, organizacija, kratak_opis,
|
|
LENGTH(sadrzaj) AS sadrzaj_size
|
|
FROM pgz_sport.dokumenti
|
|
WHERE vrsta = 'godisnjak'
|
|
ORDER BY godina DESC
|
|
""")
|
|
return {"count": len(rows), "godisnjaci": rows}
|
|
|
|
|
|
@app.get("/api/v2/dokumenti/godisnjak/{godina}")
|
|
def dokument_godisnjak_pdf(godina: int):
|
|
"""PDF godišnjaka."""
|
|
from fastapi.responses import FileResponse
|
|
import os
|
|
pdf_path = f"/opt/pgz-sport/_data/godisnjaci/godisnjak_{godina}.pdf"
|
|
if os.path.exists(pdf_path):
|
|
return FileResponse(pdf_path, media_type="application/pdf", filename=f"godisnjak_{godina}.pdf")
|
|
return {"error": "not_found", "godina": godina}
|
|
|
|
|
|
@app.get("/api/v2/dokumenti/{doc_id}")
|
|
def dokument_detail(doc_id: int):
|
|
"""Detaljan pregled dokumenta."""
|
|
rows = fetch("SELECT id, title, vrsta, sport, godina, organizacija, izvor_url, kratak_opis, sadrzaj_summary, kljucne_rijeci, scraped_at FROM pgz_sport.dokumenti WHERE id = %s", (doc_id,))
|
|
if not rows: return {"error": "not_found"}
|
|
return rows[0]
|
|
|
|
|
|
@app.get("/favicon.ico")
|
|
def serve_favicon():
|
|
from fastapi.responses import FileResponse
|
|
return FileResponse("/opt/pgz-sport/static/favicon.ico", media_type="image/x-icon")
|
|
|
|
@app.get("/api/v2/klubovi/financirani")
|
|
def klubovi_financirani(sport: str = None, davatelj: str = None, godina: int = None, limit: int = 1000):
|
|
"""Klubovi koji su primili novac od PGŽ/RSS/Grad Rijeka. davatelj: pgz|rss|grad_rijeka|any"""
|
|
where = []
|
|
params = []
|
|
if sport:
|
|
where.append("k.sport = %s")
|
|
params.append(sport)
|
|
if davatelj == 'pgz':
|
|
where.append("k.prima_pgz = true")
|
|
elif davatelj == 'rss':
|
|
where.append("k.prima_rss = true")
|
|
elif davatelj == 'grad_rijeka':
|
|
where.append("k.prima_grad_rijeka = true")
|
|
elif davatelj == 'any':
|
|
where.append("(k.prima_pgz OR k.prima_rss OR k.prima_grad_rijeka)")
|
|
|
|
where_sql = "WHERE " + " AND ".join(where) if where else ""
|
|
rows = fetch(f"""
|
|
SELECT k.id, k.naziv, k.sport, k.razina, k.oib, k.grad, k.adresa,
|
|
k.prima_pgz, k.prima_rss, k.prima_grad_rijeka, k.u_godisnjaku,
|
|
k.broj_potpora, k.ukupno_potpora,
|
|
(SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id = k.id) AS sportasa
|
|
FROM pgz_sport.v_klubovi_financiranje k
|
|
{where_sql}
|
|
ORDER BY k.ukupno_potpora DESC NULLS LAST, k.naziv
|
|
LIMIT %s
|
|
""", tuple(params) + (limit,))
|
|
return {"count": len(rows), "rows": rows, "filter": {"sport": sport, "davatelj": davatelj}}
|
|
|
|
|
|
@app.get("/api/v2/sportasi/filtered")
|
|
def sportasi_filtered(sport: str = None, klub_id: int = None, kategorija: str = None,
|
|
godina_rod_od: int = None, godina_rod_do: int = None,
|
|
q: str = None, limit: int = 500):
|
|
"""Sportaši s range filter za godinu rođenja + kategorije."""
|
|
where = ["c.aktivan = true"]
|
|
params = []
|
|
if sport:
|
|
where.append("(c.sport = %s OR k.sport = %s)")
|
|
params.extend([sport, sport])
|
|
if klub_id:
|
|
where.append("c.klub_id = %s")
|
|
params.append(klub_id)
|
|
if kategorija:
|
|
where.append("(c.kategorija = %s OR EXISTS (SELECT 1 FROM pgz_sport.clan_kategorije ck WHERE ck.clan_id = c.id AND ck.kategorija = %s))")
|
|
params.extend([kategorija, kategorija])
|
|
if godina_rod_od:
|
|
where.append("(EXTRACT(YEAR FROM c.datum_rodenja) >= %s OR c.godina_rodenja >= %s)")
|
|
params.extend([godina_rod_od, godina_rod_od])
|
|
if godina_rod_do:
|
|
where.append("(EXTRACT(YEAR FROM c.datum_rodenja) <= %s OR c.godina_rodenja <= %s)")
|
|
params.extend([godina_rod_do, godina_rod_do])
|
|
if q:
|
|
where.append("(c.ime ILIKE %s OR c.prezime ILIKE %s)")
|
|
params.extend([f"%{q}%", f"%{q}%"])
|
|
|
|
where_sql = "WHERE " + " AND ".join(where)
|
|
rows = fetch(f"""
|
|
SELECT c.id, c.ime, c.prezime, c.spol, c.datum_rodenja, c.godina_rodenja,
|
|
c.kategorija, c.pozicija, c.sport, c.klub_id, k.naziv AS klub_naziv,
|
|
c.hns_igrac_id, c.source, c.source_url
|
|
FROM pgz_sport.clanovi c
|
|
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
|
{where_sql}
|
|
ORDER BY c.prezime, c.ime
|
|
LIMIT %s
|
|
""", tuple(params) + (limit,))
|
|
return {"count": len(rows), "rows": rows}
|
|
|
|
|
|
@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)
|