From 492c8fdd8706d4bdd999242b8e296378e9f5e15d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Raduli=C4=87?= Date: Tue, 5 May 2026 00:09:09 +0200 Subject: [PATCH] M1+M2+M10 (CC2 R3): JWT auth + admin users + GDPR backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth/auth_v2.py: JWT login/refresh/logout/me + bcrypt + tenant_id/role/tier claims - auth/admin_users.py: /api/admin/users CRUD + invite/role/suspend + bulk CSV - auth/gdpr.py: cookie consent + Art.20 export + Art.17 erasure + admin queue - auth/seed_demo.py: 3 demo tenants + 4 users (damir@pgz.hr / PGZ2026!) - Removed legacy /api/auth/login + /api/auth/me from pgz_sport_api.py - Wired auth/admin/gdpr routers into FastAPI 5/5 live curl tests pass: damir@pgz.hr login → JWT with tenant_id=1, role=pgz_admin, tier=0 --- _backups/admin.html.preauth_v2.1777931444 | 495 ++ .../pgz_sport_api.py.cc5_pre_m7.1777931433 | 1646 +++++ .../pgz_sport_api.py.preauth_v2.1777931444 | 1646 +++++ ...z_sport_v2_router.py.preauth_v2.1777931444 | 5350 +++++++++++++++++ .../r3_cc4/pgz_sport_api.py.pre_M6.1777932513 | 1668 +++++ _backups/r3_cc4/sport2.html.pre_M6.1777932513 | 2341 ++++++++ .../pgz_sport_api.py.post_m7.1777931653 | 1682 ++++++ .../pgz_sport_api.py.post_m8.1777932522 | 1668 +++++ .../r3_cc5/pgz_sport_api.py.pre_m7.1777931436 | 1646 +++++ .../r3_cc5/pgz_sport_api.py.pre_m8.1777932387 | 1668 +++++ .../invoices/20260505_000453_ina_racun.png | Bin 0 -> 47432 bytes .../invoices/20260505_000630_ina_racun.png | Bin 0 -> 47432 bytes ...FF_20260504_2350_FULL_MIGRATION_CLEANUP.md | 164 + auth/.jwt_secret | 1 + auth/__init__.py | 2 + auth/admin_users.py | 446 ++ auth/auth_v2.py | 455 ++ auth/gdpr.py | 230 + auth/seed_demo.py | 98 + pgz_sport_api.py | 64 +- routers/enrich_router.py | 190 +- static/sport2.html | 6 +- swarm.sh | 101 + 23 files changed, 21518 insertions(+), 49 deletions(-) create mode 100644 _backups/admin.html.preauth_v2.1777931444 create mode 100644 _backups/pgz_sport_api.py.cc5_pre_m7.1777931433 create mode 100644 _backups/pgz_sport_api.py.preauth_v2.1777931444 create mode 100644 _backups/pgz_sport_v2_router.py.preauth_v2.1777931444 create mode 100644 _backups/r3_cc4/pgz_sport_api.py.pre_M6.1777932513 create mode 100644 _backups/r3_cc4/sport2.html.pre_M6.1777932513 create mode 100644 _backups/r3_cc5/pgz_sport_api.py.post_m7.1777931653 create mode 100644 _backups/r3_cc5/pgz_sport_api.py.post_m8.1777932522 create mode 100644 _backups/r3_cc5/pgz_sport_api.py.pre_m7.1777931436 create mode 100644 _backups/r3_cc5/pgz_sport_api.py.pre_m8.1777932387 create mode 100644 _data/uploads/invoices/20260505_000453_ina_racun.png create mode 100644 _data/uploads/invoices/20260505_000630_ina_racun.png create mode 100644 _handoff/HANDOFF_20260504_2350_FULL_MIGRATION_CLEANUP.md create mode 100644 auth/.jwt_secret create mode 100644 auth/__init__.py create mode 100644 auth/admin_users.py create mode 100644 auth/auth_v2.py create mode 100644 auth/gdpr.py create mode 100644 auth/seed_demo.py create mode 100755 swarm.sh diff --git a/_backups/admin.html.preauth_v2.1777931444 b/_backups/admin.html.preauth_v2.1777931444 new file mode 100644 index 0000000..b35bd76 --- /dev/null +++ b/_backups/admin.html.preauth_v2.1777931444 @@ -0,0 +1,495 @@ + + + + + +PGŽ Sport · Admin Dashboard + + + + + + +
+ + + +
+
+

Dashboard

+ učitavam… +
+ + +
+
+
+

Top Klubovi (po aktivnosti)

+
NazivSportGradČlanoviRačuni
+
+
+ + +
+
+
+

Računi

+
BrojDobavljačKlubIznosStatusDatum
+
+
+

Putni nalozi / izdaci

+
BrojKlubDestinacijaIznosStatusDatum
+
+
+ + +
+ +
+

Klubovi

+
NazivOIBSportGradEmailČlanoviRačuni
+
+
+ + +
+ +
+

Kontakti / Članovi

+
ImePrezimeOIBKlubPozicijaEmailStatus
+
+
+ + +
+
+

3D Sport Graph

+

Interaktivni 3D prikaz svih klubova, saveza i osoba s drill-down na detalje.

+
+ +
+
+
+ + +
+
+

Multi-tenant Management

+

Tenants u sustavu. Svaki tenant ima vlastiti scope klubova, financija i konfiguracije.

+
+
+
+ + +
+
+

Top 10 Klubova (po dokumentima i računima)

+
NazivSportGradRačuniČlanovi
+
+
+ +
+
+ + + + diff --git a/_backups/pgz_sport_api.py.cc5_pre_m7.1777931433 b/_backups/pgz_sport_api.py.cc5_pre_m7.1777931433 new file mode 100644 index 0000000..a0221ec --- /dev/null +++ b/_backups/pgz_sport_api.py.cc5_pre_m7.1777931433 @@ -0,0 +1,1646 @@ +#!/usr/bin/env python3 +""" +pgz_sport_api.py - FastAPI backend za PGŽ Sportski savez ERP/CRM +Author: Damir Radulić (damir@rinet.one) +Date: 25.04.2026 +Port: 8095 +Endpoints: savezi, klubovi, članovi, članarine, liječnički, manifestacije, proračun, dashboard, alertovi +""" + +from fastapi import FastAPI, HTTPException, Query, Body, Header, Depends, UploadFile, File, Form, Request +import json +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import Optional, List +from datetime import date, datetime +import psycopg2 +import psycopg2.extras +from pgz_sport_v2_router import router as v2_router +import os + +DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7') + + +ADMIN_TOKEN = 'admin-pgz-2026' + +def is_admin(authorization): + if not authorization: return False + token = authorization.replace('Bearer ', '').strip() + if token == ADMIN_TOKEN: return True + # Try JWT + try: + import jwt as _jwt + payload = _jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return payload.get("role") == "admin" + except Exception: + return False + +def blur_oib(v): + if not v: return v + s = str(v); + return s[:3] + '•'*(len(s)-5) + s[-2:] if len(s) >= 8 else '•'*len(s) +def blur_email(e): + if not e or '@' not in str(e): return e + u, d = str(e).split('@',1); return (u[:1]+'•••' if u else '')+'@'+d +def blur_phone(p): + if not p: return p + s=str(p); return s[:4]+'•'*(len(s)-7)+s[-3:] if len(s)>=7 else s +def blur_iban(v): + if not v: return v + s=str(v); return s[:4]+'•'*(len(s)-8)+s[-4:] if len(s)>=8 else s +def blur_date(d): + if not d: return d + s = str(d); return s[:4]+'-••-••' if len(s)>=4 else s +def blur_text(t, keep=3): + if not t: return t + s=str(t); return s[:keep]+'•'*(len(s)-keep*2)+s[-keep:] if len(s)>keep*2 else s + +def apply_privacy(rows, admin): + if admin: return rows + out = [] + for r in (rows if isinstance(rows, list) else [rows]): + rr = dict(r) + for k, v in list(rr.items()): + if v is None: continue + kl = k.lower() + if 'oib' in kl: rr[k] = blur_oib(v) + elif 'email' in kl: rr[k] = blur_email(v) + elif kl in ('telefon','tel','phone'): rr[k] = blur_phone(v) + elif kl == 'datum_rodenja': rr[k] = blur_date(v) + elif 'iban' in kl: rr[k] = blur_iban(v) + elif kl == 'adresa': rr[k] = blur_text(v, 3) + elif 'licenca_broj' in kl: rr[k] = blur_text(v, 2) + out.append(rr) + return out if isinstance(rows, list) else out[0] + +app = FastAPI(title="PGŽ Sportski savez ERP/CRM", version="1.0.0") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + + +# === URL rewrite middleware - convert direct external image URLs to /img-proxy === +import json as _json_mw +import re as _re_mw +from starlette.responses import Response as _StarletteResponse_mw + +_IMG_DOMAINS_RE = _re_mw.compile( + r'https?://(?:hns\.family|hns\.hr|hbs\.hr|hrvatski-bocarski-savez\.hr|' + r'rk-zamet\.hr|hvs\.hr|rezultati\.hvs\.hr|sport-pgz\.hr)' + r'/[^"\s\\]+\.(?:jpg|jpeg|png|gif|webp|svg)', + _re_mw.IGNORECASE +) + +def _rewrite_to_proxy(text: str) -> str: + """Replace external image URLs with /sport/api/v2/img-proxy?u=...""" + from urllib.parse import quote as _q + def _sub(m): + url = m.group(0) + return "/sport/api/v2/img-proxy?u=" + _q(url, safe='') + return _IMG_DOMAINS_RE.sub(_sub, text) + +@app.middleware("http") +async def url_rewrite_middleware(request, call_next): + response = await call_next(request) + # Only rewrite JSON API responses + ct = response.headers.get("content-type", "") + if "application/json" not in ct: + return response + # Only on /api/v2 routes (admin & data endpoints) - SKIP /api/v2/img-proxy itself + path = request.url.path + if "/api/v2/img-proxy" in path or "/api/v2/dokumenti" in path: + return response # don't rewrite raw document content + # Read body + body = b"" + async for chunk in response.body_iterator: + body += chunk + try: + text = body.decode("utf-8") + new_text = _rewrite_to_proxy(text) + new_body = new_text.encode("utf-8") + except Exception: + new_body = body + return _StarletteResponse_mw( + content=new_body, + status_code=response.status_code, + headers={k: v for k, v in response.headers.items() if k.lower() not in ("content-length",)}, + media_type=ct, + ) +# === end URL rewrite middleware === + +def db(): + conn = psycopg2.connect(**DB) + conn.autocommit = True + return conn + +def fetch(sql, params=None): + with db() as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as c: + c.execute(sql, params or ()) + return [dict(r) for r in c.fetchall()] + +def execute(sql, params=None): + with db() as conn: + with conn.cursor() as c: + c.execute(sql, params or ()) + return c.rowcount + +# ==================== HEALTH ==================== +@app.get("/health") +def health(): + try: + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + return {"status": "ok", "service": "pgz_sport", "dashboard": rows[0] if rows else None} + except Exception as e: + raise HTTPException(500, f"DB error: {e}") + + +@app.get("/api/whoami") +def whoami_v2(authorization: Optional[str] = Header(None)): + return {"role": "admin" if is_admin(authorization) else "viewer", "privacy_active": not is_admin(authorization)} + +# ==================== DASHBOARD ==================== +@app.get("/api/dashboard") +def dashboard(): + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + if not rows: + return {} + d = rows[0] + # Top savezi by registriranih 2024 + top = fetch("""SELECT s.naziv, st.klubova_clanica, st.registriranih, st.trenera, st.reprezentativaca + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina=2024 ORDER BY st.registriranih DESC LIMIT 10""") + proracun_trend = fetch("SELECT godina, ukupno FROM pgz_sport.proracun ORDER BY godina") + nositelji = fetch("""SELECT naziv_kluba, godina, iznos FROM pgz_sport.potpore_nositelji + WHERE godina = 2025 ORDER BY iznos DESC LIMIT 10""") + return {**d, "top_savezi": top, "proracun_trend": proracun_trend, "nositelji_2025": nositelji} + +@app.get("/api/dashboard/ekosustav") +def dashboard_ekosustav(): + """Sport ekosustav PGŽ — coverage stats za enrichment iz FINA registra.""" + summary = fetch("""SELECT + COUNT(*) AS klubova_total, + COUNT(*) FILTER (WHERE oib IS NOT NULL) AS s_oib, + COUNT(*) FILTER (WHERE predsjednik IS NOT NULL) AS s_predsjednik, + COUNT(*) FILTER (WHERE tajnik IS NOT NULL) AS s_tajnik, + COUNT(*) FILTER (WHERE ciljevi IS NOT NULL) AS s_ciljevi, + COUNT(*) FILTER (WHERE opis_djelatnosti IS NOT NULL) AS s_opis, + COUNT(*) FILTER (WHERE sjediste IS NOT NULL) AS s_sjediste, + COUNT(*) FILTER (WHERE email IS NOT NULL) AS s_email, + COUNT(*) FILTER (WHERE web_stranica IS NOT NULL) AS s_web, + COUNT(*) FILTER (WHERE udruga_status = \'AKTIVAN\') AS s_aktivan_reg, + COUNT(*) FILTER (WHERE savez_id IS NOT NULL) AS s_savez, + COUNT(*) FILTER (WHERE nositelj_kvalitete) AS s_nositelj + FROM pgz_sport.klubovi WHERE aktivan""")[0] + + by_sport = fetch("""SELECT sport, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND sport IS NOT NULL + GROUP BY sport ORDER BY COUNT(*) DESC LIMIT 15""") + + by_region = fetch("""SELECT region, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND region IS NOT NULL + GROUP BY region ORDER BY COUNT(*) DESC""") + + by_grad = fetch("""SELECT grad, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND grad IS NOT NULL + GROUP BY grad ORDER BY COUNT(*) DESC LIMIT 12""") + + decade = fetch("""SELECT + CASE + WHEN godina_osnutka < 1950 THEN \'pred1950\' + WHEN godina_osnutka < 1980 THEN \'1950-1979\' + WHEN godina_osnutka < 2000 THEN \'1980-1999\' + WHEN godina_osnutka < 2010 THEN \'2000-2009\' + WHEN godina_osnutka >= 2010 THEN \'2010-danas\' + ELSE \'nepoznato\' + END AS razdoblje, + COUNT(*) AS broj + FROM pgz_sport.klubovi + WHERE aktivan AND godina_osnutka IS NOT NULL + GROUP BY razdoblje ORDER BY razdoblje""") + + # Pokazi enrichment % + total = summary["klubova_total"] or 1 + coverage = { + "oib_pct": round(100 * summary["s_oib"] / total, 1), + "predsjednik_pct": round(100 * summary["s_predsjednik"] / total, 1), + "tajnik_pct": round(100 * summary["s_tajnik"] / total, 1), + "ciljevi_pct": round(100 * summary["s_ciljevi"] / total, 1), + "opis_pct": round(100 * summary["s_opis"] / total, 1), + "sjediste_pct": round(100 * summary["s_sjediste"] / total, 1), + "email_pct": round(100 * summary["s_email"] / total, 1), + "savez_pct": round(100 * summary["s_savez"] / total, 1), + } + + return {**summary, "coverage": coverage, "by_sport": by_sport, + "by_region": by_region, "by_grad": by_grad, "by_decade": decade} + + + +# ==================== ANALYTICS ==================== +@app.get("/api/analytics/savezi-trend") +def savezi_trend(godine: str = "2020,2021,2022,2023,2024", metric: str = "registriranih"): + valid_metrics = {"registriranih", "neregistriranih", "rekreativaca", "trenera", "reprezentativaca", + "kategoriziranih", "stipendiranih", "klubova_clanica"} + if metric not in valid_metrics: + raise HTTPException(400, f"Invalid metric. Must be one of: {valid_metrics}") + god_list = [int(g) for g in godine.split(",")] + rows = fetch(f"""SELECT s.naziv AS savez, st.godina, st.{metric} AS value + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina = ANY(%s) ORDER BY s.naziv, st.godina""", [god_list]) + saveze = {} + for r in rows: + if r['savez'] not in saveze: saveze[r['savez']] = {} + saveze[r['savez']][r['godina']] = r['value'] + return {"metric": metric, "godine": god_list, "data": saveze} + +@app.get("/api/analytics/proracun-detaljno") +def proracun_detaljno(): + p = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + if not p: return {"proracun": [], "rast_godisnji": [], "current_year": None, "current_total": 0, "rast_dekada_pct": 0} + cagr = [] + for i in range(1, len(p)): + prev = float(p[i-1]['ukupno']) if p[i-1]['ukupno'] else 0 + curr = float(p[i]['ukupno']) if p[i]['ukupno'] else 0 + rate = ((curr/prev - 1) * 100) if prev > 0 else 0 + cagr.append({"godina": p[i]['godina'], "rast_postotak": round(rate, 1)}) + decade_rast = round((float(p[-1]['ukupno'])/float(p[0]['ukupno']) - 1) * 100, 1) if p[0]['ukupno'] else 0 + return {"proracun": p, "rast_godisnji": cagr, "rast_dekada_pct": decade_rast, + "current_year": int(p[-1]['godina']), "current_total": float(p[-1]['ukupno'])} + +@app.get("/api/analytics/klub-financije") +def klub_financije(klub_id: Optional[int] = None, godina: Optional[int] = None): + where = [] + params = [] + if godina: where.append("p.godina=%s"); params.append(godina) + if klub_id: + where.append("(p.klub_id=%s OR p.naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))") + params.extend([klub_id, klub_id]) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"""SELECT p.naziv_kluba, p.godina, p.iznos, + k.id AS klub_id, k.sport, k.razina, k.nositelj_kvalitete + FROM pgz_sport.potpore_nositelji p + LEFT JOIN pgz_sport.klubovi k ON p.klub_id=k.id OR p.naziv_kluba=k.naziv + {where_sql} ORDER BY p.godina DESC, p.iznos DESC""", params) + summary = fetch(f"""SELECT godina, SUM(iznos) AS total, COUNT(*) AS klubova, AVG(iznos) AS prosjek + FROM pgz_sport.potpore_nositelji p {where_sql} + GROUP BY godina ORDER BY godina""", params) + return {"data": rows, "summary": summary} + +@app.get("/api/analytics/lijecnicki-stats") +def lijecnicki_stats(klub_id: Optional[int] = None): + where = ["1=1"]; params = [] + if klub_id: where.append("c.klub_id=%s"); params.append(klub_id) + where_sql = " AND ".join(where) + rows = fetch(f"""SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE lp.vrijedi_do >= CURRENT_DATE + 30) AS validni, + COUNT(*) FILTER (WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + 30) AS uskoro, + COUNT(*) FILTER (WHERE lp.vrijedi_do < CURRENT_DATE) AS istekli, + SUM(lp.iznos) AS ukupan_trosak, SUM(lp.iznos_zzjz) AS zzjz_udio, + SUM(lp.iznos_klub) AS klub_udio, SUM(lp.iznos_clan) AS clan_udio, + AVG(lp.iznos) AS prosjecni_trosak + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id WHERE {where_sql}""", params) + by_ustanova = fetch(f"""SELECT lp.ustanova, COUNT(*) cnt, SUM(lp.iznos) iznos + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} GROUP BY lp.ustanova ORDER BY cnt DESC""", params) + by_lijecnik = fetch(f"""SELECT lp.lijecnik, COUNT(*) cnt, AVG(lp.iznos) prosjek + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} AND lp.lijecnik IS NOT NULL GROUP BY lp.lijecnik ORDER BY cnt DESC""", params) + return {"summary": rows[0] if rows else {}, "by_ustanova": by_ustanova, "by_lijecnik": by_lijecnik} + +# ==================== SAVEZI ==================== +@app.get("/api/savezi") +def list_savezi(authorization: Optional[str] = Header(None), q: Optional[str] = None, + razina: Optional[str] = None, zupanija: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = "WHERE aktivan" + params = [] + if q: + where += " AND (naziv ILIKE %s OR sport ILIKE %s)" + params = [f"%{q}%", f"%{q}%"] + if razina: + where += " AND razina = %s"; params.append(razina) + if zupanija: + where += " AND sjediste_zupanija ILIKE %s"; params.append(f"%{zupanija}%") + sort_col = {"naziv": "naziv", "godina": "godina_osnutka", "sport": "sport", "razina": "razina"}.get(sort, "naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + # Croatian collation for text columns (Š → after S, Č → after C, etc.) + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("naziv", "sport") else "" + rows = fetch(f"""SELECT s.*, + (SELECT COUNT(*) FROM pgz_sport.klubovi WHERE savez_id=s.id) AS broj_klubova, + (SELECT registriranih FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS reg_2024, + (SELECT trenera FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS treneri_2024, + (SELECT reprezentativaca FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS repr_2024 + FROM pgz_sport.savezi s {where} ORDER BY {sort_col}{collate} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/savezi/{savez_id}") +def get_savez(savez_id: int): + rows = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s", [savez_id]) + if not rows: + raise HTTPException(404, "Savez ne postoji") + klubovi = fetch("SELECT * FROM pgz_sport.klubovi WHERE savez_id=%s ORDER BY naziv", [savez_id]) + statistika = fetch("SELECT * FROM pgz_sport.statistika_saveza WHERE savez_id=%s ORDER BY godina", [savez_id]) + manifestacije = fetch("SELECT * FROM pgz_sport.manifestacije WHERE savez_id=%s", [savez_id]) + return {**rows[0], "klubovi": klubovi, "statistika": statistika, "manifestacije": manifestacije} + +# ==================== KLUBOVI ==================== +@app.get("/api/klubovi") +def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, savez_id: Optional[int] = None, + nositelj: Optional[bool] = None, region: Optional[str] = None, sport: Optional[str] = None, grad: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivan"] + params = [] + if q: + where.append("(klub ILIKE %s OR oib ILIKE %s OR sport ILIKE %s OR predsjednik ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%", f"%{q}%"]) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + if nositelj is not None: + where.append(f"nositelj_kvalitete={'TRUE' if nositelj else 'FALSE'}") + if region: + where.append("region ILIKE %s"); params.append(region) + if grad: + where.append("grad ILIKE %s"); params.append(f"%{grad}%") + if sport: + where.append("sport ILIKE %s"); params.append(f"%{sport}%") + sort_col = {"naziv": "klub", "savez": "savez", "broj_clanova": "broj_clanova", + "razina": "razina", "region": "region", "grad": "grad", "sport": "sport"}.get(sort, "klub") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("klub", "savez", "razina", "region", "grad", "sport") else "" + rows = fetch(f"""SELECT * FROM pgz_sport.v_klubovi_pregled WHERE {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST""", params) + for r in rows: + if isinstance(r, dict) and r.get('klub') and not r.get('naziv'): + r['naziv'] = r['klub'] + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/klubovi/{klub_id}") +def get_klub(klub_id: int, authorization: Optional[str] = Header(None)): + admin = is_admin(authorization) + rows = fetch("""SELECT k.*, s.naziv AS savez_naziv FROM pgz_sport.klubovi k + LEFT JOIN pgz_sport.savezi s ON s.id=k.savez_id WHERE k.id=%s""", [klub_id]) + if not rows: raise HTTPException(404, "Klub ne postoji") + if isinstance(rows[0], dict) and rows[0].get('klub') and not rows[0].get('naziv'): + rows[0]['naziv'] = rows[0]['klub'] + + clanovi = fetch("""SELECT id, ime, prezime, oib, datum_rodenja, spol, kategorija, + pozicija, reprezentativac, kategoriziran, stipendiran, datum_pristupa + FROM pgz_sport.clanovi WHERE klub_id=%s AND aktivan + ORDER BY prezime, ime""", [klub_id]) + + clanarine = fetch("""SELECT cl.id, cl.godina, cl.razdoblje, cl.iznos_propisan, cl.iznos_placen, + (cl.iznos_propisan - cl.iznos_placen) AS dug, cl.datum_uplate, cl.status, cl.napomena, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib + FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE c.klub_id=%s ORDER BY cl.godina DESC, cl.id DESC""", [klub_id]) + + lijecnicki = fetch("""SELECT lp.id, lp.datum_pregleda, lp.vrijedi_do, lp.vrsta_pregleda, + lp.ustanova, lp.lijecnik, lp.spreman_za_natjecanje, lp.iznos, lp.iznos_zzjz, lp.iznos_klub, lp.iznos_clan, + lp.placeno, lp.komentar_lijecnika, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib, + CASE WHEN lp.vrijedi_do IS NULL THEN 'Nepoznato' + WHEN lp.vrijedi_do < CURRENT_DATE THEN 'Istekao' + WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'Ističe uskoro' + ELSE 'Validan' END AS status_pregled + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s ORDER BY lp.datum_pregleda DESC""", [klub_id]) + + potpore = fetch("""SELECT * FROM pgz_sport.potpore_nositelji + WHERE klub_id=%s OR naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s) + ORDER BY godina DESC""", [klub_id, klub_id]) + + # Aggregate stats + stats = { + 'broj_clanova': len(clanovi), + 'broj_registriranih': sum(1 for c in clanovi if c.get('kategorija')=='registrirani'), + 'broj_trenera': sum(1 for c in clanovi if c.get('kategorija')=='trener'), + 'broj_reprezentativaca': sum(1 for c in clanovi if c.get('reprezentativac')), + 'broj_kategoriziranih': sum(1 for c in clanovi if c.get('kategoriziran')), + 'broj_stipendiranih': sum(1 for c in clanovi if c.get('stipendiran')), + 'lijecnicki_validni': sum(1 for l in lijecnicki if l.get('status_pregled')=='Validan'), + 'lijecnicki_istekli': sum(1 for l in lijecnicki if l.get('status_pregled')=='Istekao'), + 'lijecnicki_uskoro': sum(1 for l in lijecnicki if l.get('status_pregled')=='Ističe uskoro'), + 'clanarina_naplaceno_god': sum(float(c.get('iznos_placen') or 0) for c in clanarine if c.get('godina')==2026), + 'clanarina_dug_god': sum(float(c.get('dug') or 0) for c in clanarine if c.get('godina')==2026), + 'potpore_2025': float(next((p['iznos'] for p in potpore if p.get('godina')==2025), 0) or 0), + 'potpore_total': sum(float(p.get('iznos') or 0) for p in potpore), + 'zzjz_isplaceno': sum(float(l.get('iznos_zzjz') or 0) for l in lijecnicki if l.get('placeno')), + } + + klub = rows[0] + if not admin: + klub = apply_privacy(klub, admin) + clanovi = apply_privacy(clanovi, admin) + clanarine = apply_privacy(clanarine, admin) + lijecnicki = apply_privacy(lijecnicki, admin) + + return {**klub, "clanovi": clanovi, "clanarine": clanarine, "lijecnicki": lijecnicki, + "potpore": potpore, "stats": stats} + + +class KlubIn(BaseModel): + naziv: str + savez_id: Optional[int] = None + sport: Optional[str] = None + oib: Optional[str] = None + razina: Optional[str] = None + nositelj_kvalitete: Optional[bool] = False + grad: Optional[str] = None + region: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + predsjednik: Optional[str] = None + iban: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/klubovi") +def create_klub(k: KlubIn): + rows = fetch("""INSERT INTO pgz_sport.klubovi (naziv, savez_id, sport, oib, razina, nositelj_kvalitete, grad, region, email, telefon, predsjednik, iban, napomena, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE) RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena]) + return rows[0] + +@app.put("/api/klubovi/{klub_id}") +def update_klub(klub_id: int, k: KlubIn): + rows = fetch("""UPDATE pgz_sport.klubovi SET naziv=%s, savez_id=%s, sport=%s, oib=%s, razina=%s, + nositelj_kvalitete=%s, grad=%s, region=%s, email=%s, telefon=%s, predsjednik=%s, iban=%s, napomena=%s, + updated_at=NOW() WHERE id=%s RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena, klub_id]) + if not rows: + raise HTTPException(404, "Klub ne postoji") + return rows[0] + +# ==================== ČLANOVI ==================== +@app.get("/api/clanovi") +def list_clanovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, klub_id: Optional[int] = None, + kategorija: Optional[str] = None, spol: Optional[str] = None, sort: str = "prezime", order: str = "asc"): + where = ["c.aktivan"] + params = [] + if q: + where.append("(c.ime ILIKE %s OR c.prezime ILIKE %s OR c.oib ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%"]) + if klub_id: + where.append("c.klub_id=%s"); params.append(klub_id) + if kategorija: + where.append("c.kategorija=%s"); params.append(kategorija) + if spol: + # Normalize: Z → Ž, F → Ž (legacy) + spol_norm = "Ž" if spol.upper() in ("Z","Ž","F","W") else "M" if spol.upper() in ("M",) else spol + where.append("c.spol=%s"); params.append(spol_norm) + sort_map = {"prezime": "c.prezime", "ime": "c.ime", "oib": "c.oib", "datum_rodenja": "c.datum_rodenja", "kategorija": "c.kategorija", "klub": "k.naziv"} + sort_col = sort_map.get(sort, "c.prezime") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT c.*, k.naziv AS klub_naziv, + (SELECT MAX(vrijedi_do) FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=c.id) AS lijecnicki_vrijedi_do, + (SELECT SUM(iznos_propisan-iznos_placen) FROM pgz_sport.clanarine WHERE clan_id=c.id AND status!='podmireno') AS dug_clanarine + FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE {where_sql} ORDER BY {sort_col} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +class ClanIn(BaseModel): + klub_id: int + ime: str + prezime: str + oib: Optional[str] = None + datum_rodenja: Optional[date] = None + spol: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + kategorija: Optional[str] = "registrirani" + pozicija: Optional[str] = None + licenca_broj: Optional[str] = None + licenca_vrijedi_do: Optional[date] = None + reprezentativac: Optional[bool] = False + kategoriziran: Optional[bool] = False + stipendiran: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/clanovi") +def create_clan(c: ClanIn): + rows = fetch("""INSERT INTO pgz_sport.clanovi (klub_id, ime, prezime, oib, datum_rodenja, spol, email, telefon, kategorija, pozicija, licenca_broj, licenca_vrijedi_do, reprezentativac, kategoriziran, stipendiran, napomena, aktivan, datum_pristupa) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE,CURRENT_DATE) RETURNING *""", + [c.klub_id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol, c.email, c.telefon, c.kategorija, c.pozicija, c.licenca_broj, c.licenca_vrijedi_do, c.reprezentativac, c.kategoriziran, c.stipendiran, c.napomena]) + return rows[0] + +@app.get("/api/clanovi/{clan_id}") +def get_clan(clan_id: int): + rows = fetch("""SELECT c.*, k.naziv AS klub_naziv FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id]) + if not rows: + raise HTTPException(404, "Član ne postoji") + clanarine = fetch("SELECT * FROM pgz_sport.clanarine WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + lijecnicki = fetch("SELECT * FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=%s ORDER BY datum_pregleda DESC", [clan_id]) + return {**rows[0], "clanarine": clanarine, "lijecnicki": lijecnicki} + +# ==================== ČLANARINE ==================== +@app.get("/api/clanarine") +def list_clanarine(godina: Optional[int] = None, status: Optional[str] = None, + klub_id: Optional[int] = None, sort: str = "godina", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + if status: + where.append("status=%s"); params.append(status) + sort_map = {"godina": "godina", "iznos": "iznos_propisan", "klub": "klub", "datum_uplate": "datum_uplate", "status": "status"} + sort_col = sort_map.get(sort, "godina") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_clanarine_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos_propisan) AS total_propisan, + SUM(iznos_placen) AS total_placen, + SUM(iznos_propisan - iznos_placen) AS total_dug + FROM pgz_sport.v_clanarine_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class ClanarinaIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + godina: int + razdoblje: Optional[str] = "godišnja" + iznos_propisan: float + iznos_placen: Optional[float] = 0 + datum_uplate: Optional[date] = None + nacin_uplate: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/clanarine") +def create_clanarina(c: ClanarinaIn): + status = "podmireno" if c.iznos_placen >= c.iznos_propisan else ("djelomicno" if c.iznos_placen > 0 else "nepodmireno") + klub_id = c.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [c.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.clanarine (clan_id, klub_id, godina, razdoblje, iznos_propisan, iznos_placen, datum_uplate, nacin_uplate, status, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [c.clan_id, klub_id, c.godina, c.razdoblje, c.iznos_propisan, c.iznos_placen, c.datum_uplate, c.nacin_uplate, status, c.napomena]) + return rows[0] + +# ==================== LIJEČNIČKI ==================== +@app.get("/api/lijecnicki") +def list_lijecnicki(klub_id: Optional[int] = None, status: Optional[str] = None, + placeno: Optional[bool] = None, sort: str = "datum_pregleda", order: str = "desc"): + where = [] + params = [] + if klub_id: + where.append("(klub_oib IS NOT NULL AND klub=ANY(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))"); params.append(klub_id) + if status: + where.append("status_pregled=%s"); params.append(status) + if placeno is not None: + where.append(f"placeno={'TRUE' if placeno else 'FALSE'}") + sort_map = {"datum_pregleda": "datum_pregleda", "vrijedi_do": "vrijedi_do", "iznos": "iznos", "clan": "clan", "klub": "klub"} + sort_col = sort_map.get(sort, "datum_pregleda") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_lijecnicki_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos) AS total_iznos, + SUM(iznos_zzjz) AS total_zzjz, + SUM(iznos_klub) AS total_klub, + SUM(iznos_clan) AS total_clan, + COUNT(*) FILTER (WHERE status_pregled='Istekao') AS istekli, + COUNT(*) FILTER (WHERE status_pregled='Ističe uskoro') AS uskoro + FROM pgz_sport.v_lijecnicki_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class LijecnickiIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + datum_pregleda: date + vrijedi_do: Optional[date] = None + vrsta_pregleda: Optional[str] = "temeljni" + ustanova: Optional[str] = "ZZJZ PGŽ" + lijecnik: Optional[str] = None + spreman_za_natjecanje: Optional[bool] = True + ekg: Optional[bool] = False + krv: Optional[bool] = False + spirometrija: Optional[bool] = False + nalaz: Optional[str] = None + komentar_lijecnika: Optional[str] = None + preporuke: Optional[str] = None + iznos: Optional[float] = 0 + iznos_zzjz: Optional[float] = 0 + iznos_klub: Optional[float] = 0 + iznos_clan: Optional[float] = 0 + datum_placanja: Optional[date] = None + placeno: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/lijecnicki") +def create_lijecnicki(l: LijecnickiIn): + klub_id = l.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [l.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.lijecnicki_pregledi (clan_id, klub_id, datum_pregleda, vrijedi_do, vrsta_pregleda, ustanova, lijecnik, spreman_za_natjecanje, ekg, krv, spirometrija, nalaz, komentar_lijecnika, preporuke, iznos, iznos_zzjz, iznos_klub, iznos_clan, datum_placanja, placeno, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [l.clan_id, klub_id, l.datum_pregleda, l.vrijedi_do, l.vrsta_pregleda, l.ustanova, l.lijecnik, l.spreman_za_natjecanje, l.ekg, l.krv, l.spirometrija, l.nalaz, l.komentar_lijecnika, l.preporuke, l.iznos, l.iznos_zzjz, l.iznos_klub, l.iznos_clan, l.datum_placanja, l.placeno, l.napomena]) + return rows[0] + +# ==================== PRORAČUN ==================== +@app.get("/api/proracun") +def list_proracun(): + rows = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + return {"count": len(rows), "rows": rows} + +# ==================== POTPORE NOSITELJI ==================== +@app.get("/api/potpore") +def list_potpore(godina: Optional[int] = None, sort: str = "iznos", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + sort_col = {"iznos": "iznos", "godina": "godina", "klub": "naziv_kluba"}.get(sort, "iznos") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.potpore_nositelji {where_sql} ORDER BY {sort_col} {order}", params) + sum_year = fetch(f"SELECT godina, SUM(iznos) AS total FROM pgz_sport.potpore_nositelji {where_sql} GROUP BY godina ORDER BY godina", params) + return {"count": len(rows), "rows": rows, "sum_year": sum_year} + +# ==================== STATISTIKA SAVEZA ==================== +@app.get("/api/statistika") +def list_statistika(godina: Optional[int] = None, q: Optional[str] = None, razina: Optional[str] = None, + sort: str = "registriranih", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("st.godina=%s"); params.append(godina) + if q: + where.append("s.naziv ILIKE %s"); params.append(f"%{q}%") + if razina: + where.append("s.razina = %s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + # Map sort key → unambiguous column expression + sort_map = { + "registriranih": "st.registriranih", + "klubova": "st.klubova_clanica", + "trenera": "st.trenera", + "reprezentativaca":"st.reprezentativaca", + "neregistriranih": "st.neregistriranih", + "rekreativaca": "st.rekreativaca", + "godina": "st.godina", + "savez": "s.naziv", + "naziv": "s.naziv", + } + sort_col = sort_map.get(sort, "st.registriranih") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + use_collate = sort_col in ("s.naziv", "s.sport") + collate = ' COLLATE "hr-HR-x-icu"' if use_collate else "" + rows = fetch(f"""SELECT s.naziv AS savez, s.razina AS savez_razina, s.sport AS sport, st.* + FROM pgz_sport.statistika_saveza st + JOIN pgz_sport.savezi s ON s.id=st.savez_id {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST, s.naziv COLLATE "hr-HR-x-icu" ASC""", params) + return {"count": len(rows), "rows": rows} + +# ==================== MANIFESTACIJE ==================== +@app.get("/api/manifestacije") +def list_manifestacije(razina: Optional[str] = None, savez_id: Optional[int] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivna"] + params = [] + if razina: + where.append("razina=%s"); params.append(razina) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + sort_col = {"naziv": "m.naziv", "razina": "m.razina", "godina_od": "m.godina_od", "mjesto": "m.mjesto"}.get(sort, "m.naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT m.*, s.naziv AS savez_naziv FROM pgz_sport.manifestacije m + LEFT JOIN pgz_sport.savezi s ON s.id=m.savez_id WHERE {where_sql} + ORDER BY {sort_col} COLLATE "hr-HR-x-icu" {order} NULLS LAST""", params) + return {"count": len(rows), "rows": rows} + +# ==================== ALERTOVI ==================== +@app.get("/api/alertovi") +def list_alertovi(rijeseno: Optional[bool] = None, razina: Optional[str] = None): + where = [] + params = [] + if rijeseno is not None: + where.append(f"rijeseno={'TRUE' if rijeseno else 'FALSE'}") + if razina: + where.append("razina=%s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.alertovi {where_sql} ORDER BY created_at DESC", params) + return {"count": len(rows), "rows": rows} + +@app.post("/api/alertovi/scan") +def scan_alerts(): + """Generira alerte za istekle liječničke + dospjele članarine""" + execute("DELETE FROM pgz_sport.alertovi WHERE NOT rijeseno AND tip IN ('lijecnicki_isteka', 'lijecnicki_uskoro', 'clanarina_dospjela')") + # Liječnički istekao + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_isteka', 'CRITICAL', c.klub_id, lp.clan_id, + 'Liječnički pregled istekao za ' || c.ime || ' ' || c.prezime || ' (klub: ' || COALESCE(k.naziv, 'N/A') || ')', lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lp.vrijedi_do < CURRENT_DATE AND c.aktivan""") + # Liječnički uskoro + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_uskoro', 'WARNING', c.klub_id, lp.clan_id, + 'Liječnički ističe za 30 dana: ' || c.ime || ' ' || c.prezime, lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE+30 AND c.aktivan""") + # Članarine dospjele + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum, iznos) + SELECT 'clanarina_dospjela', 'WARNING', cl.klub_id, cl.clan_id, + 'Nepodmirena članarina ' || cl.godina || ' za ' || c.ime || ' ' || c.prezime, NULL, (cl.iznos_propisan - cl.iznos_placen) + FROM pgz_sport.clanarine cl + JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE cl.status != 'podmireno' AND cl.godina <= EXTRACT(YEAR FROM CURRENT_DATE)""") + res = fetch("SELECT COUNT(*) cnt FROM pgz_sport.alertovi WHERE NOT rijeseno") + return {"alerts_generated": res[0]["cnt"]} + +@app.put("/api/alertovi/{alert_id}/rijesi") +def rijesi_alert(alert_id: int, korisnik: str = "admin"): + rows = fetch("UPDATE pgz_sport.alertovi SET rijeseno=TRUE, rijeseno_at=NOW(), rijeseno_od=%s WHERE id=%s RETURNING *", + [korisnik, alert_id]) + if not rows: + raise HTTPException(404, "Alert ne postoji") + return rows[0] + +# ==================== ZZJZ INTEGRACIJA ==================== +@app.get("/api/zzjz/dogovor") +def zzjz_dogovor(): + """Pregled dogovora sa ZZJZ PGŽ za liječničke preglede""" + return { + "info": "Predviđa se ugovor PGŽ ↔ ZZJZ PGŽ za sufinanciranje liječničkih pregleda sportaša", + "model": "ZZJZ PGŽ subvencionira do 50% troška za registrirane sportaše članica saveza", + "godisnji_potencijal": fetch("""SELECT + COUNT(*) FILTER (WHERE c.kategorija='registrirani') AS sportasa_potencijalno, + SUM(CASE WHEN c.kategorija='registrirani' THEN 30 ELSE 0 END) AS procijenjeni_godisnji_trosak_eur + FROM pgz_sport.clanovi c WHERE c.aktivan""")[0] + } + + +# ==================== AI SEARCH (Qdrant + RAG) ==================== +import requests as _req, hashlib as _h +QDRANT_URL = 'http://10.10.0.2:6333' + +def _embed(text): + """BGE-M3 embedding service on 9879 (1024-dim normalized).""" + try: + r = _req.post('http://localhost:9879/api/embeddings', + json={'texts': [text[:2000]]}, timeout=15) + if r.ok: + data = r.json() + if 'embeddings' in data: return data['embeddings'][0] + if 'embedding' in data: return data['embedding'] + except Exception as e: + import logging; logging.warning(f'BGE-M3 fail: {e}') + h = _h.sha256(text.encode()).digest() + return [(h[i % 32] / 255.0 - 0.5) for i in range(1024)] + +@app.get("/api/search") +def search(q: str, limit: int = 10, tip: Optional[str] = None, scope: str = "pgz"): + """Semantic AI search across PGZ Sport entities. + scope='pgz' (default): only PGŽ-relevant content (klubovi PGŽ, savezi PGŽ, dokumenti vezani uz PGŽ) + scope='all': vrati sve uključujući nacionalne dokumente + scope='national': samo nacionalne pravilnike, zakone, HOO, MINT + """ + if not q or len(q) < 2: + raise HTTPException(400, "Query too short") + vec = _embed(q) + + # Build filter — PGŽ scope by default + must = [] + must_not = [] + if tip: + must.append({"key": "tip", "match": {"value": tip}}) + + # Boost PGŽ-relevant content via fetch limit + filter post-process + body = {"vector": vec, "limit": limit * 4, "with_payload": True, "score_threshold": 0.35} + if must: + body["filter"] = {"must": must} + + try: + r = _req.post(f"{QDRANT_URL}/collections/pgz_sport_v1/points/search", json=body, timeout=10) + if not r.ok: raise HTTPException(500, f"Qdrant: {r.text[:200]}") + all_results = r.json()['result'] + except _req.exceptions.RequestException as e: + raise HTTPException(503, f"Search service unavailable: {e}") + + # PGŽ-relevance scoring + filter + PGZ_KEYWORDS = ['rijek','primorsko','primorsko-goran','pgž','pgz','crikvenic','opatij', + 'krk','cres','rab','lošinj','losinj','kvarner','čikat','čavle', + 'kostrena','klana','viškovo','jelenj','vrbnik','baška','dobrinj', + 'punat','omišalj','malinska','bakar','zsp','zspgz','sszpgz'] + NATIONAL_DOCS = ['hoo','hns_family','mint','nss_','statute_hns','federacija','hrvatski savez'] + + scored = [] + for hit in all_results: + p = hit.get('payload') or {} + # Combine all text fields for keyword check + all_text = ( + (p.get('naziv','') or '') + ' ' + + (p.get('title','') or '') + ' ' + + (p.get('text','') or '')[:500] + ' ' + + (p.get('source','') or '') + ' ' + + (p.get('grad','') or '') + ' ' + + (p.get('source_url','') or '') + ).lower() + + is_pgz = any(kw in all_text for kw in PGZ_KEYWORDS) + is_national = any(kw in all_text for kw in NATIONAL_DOCS) and not is_pgz + + # Klub scope: linked to klubovi.id which is by definition PGŽ + if p.get('tip') == 'klub' and p.get('klub_id'): is_pgz = True + # Savez PGŽ + if p.get('tip') == 'savez' and (p.get('razina') == 'zupanijski' or 'pgž' in (p.get('naziv','') or '').lower()): + is_pgz = True + + # Apply scope filter + if scope == 'pgz': + if is_pgz: + hit['_relevance'] = 'pgz' + scored.append(hit) + elif is_national and p.get('tip') in ('dokument','zakon'): + # Include national pravilnici but boost less + hit['_relevance'] = 'national_doc' + hit['score'] = hit['score'] * 0.7 + scored.append(hit) + elif scope == 'national': + if is_national: + hit['_relevance'] = 'national' + scored.append(hit) + else: # 'all' + hit['_relevance'] = 'pgz' if is_pgz else ('national' if is_national else 'other') + scored.append(hit) + + # Re-sort by adjusted score + scored.sort(key=lambda x: x.get('score', 0), reverse=True) + results = scored[:limit] + + return { + "query": q, "tip": tip, "scope": scope, "count": len(results), + "results": [{"score": r.get('score', 0), + "tip": (r.get('payload') or {}).get('tip'), + "naziv": (r.get('payload') or {}).get('naziv') or (r.get('payload') or {}).get('title'), + "klub_id": (r.get('payload') or {}).get('klub_id'), + "savez_id": (r.get('payload') or {}).get('savez_id'), + "tekst": (r.get('payload') or {}).get('tekst') or (r.get('payload') or {}).get('text','')[:300], + "url": (r.get('payload') or {}).get('source_url') or (r.get('payload') or {}).get('url'), + "relevance": r.get('_relevance', 'unknown'), + "payload": r.get('payload')} for r in results] + } + + +# ==================== GOOGLE OAUTH ==================== +import jwt as _jwt, secrets as _secrets +GOOGLE_CLIENT_ID = "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com" # postavi u .env +ADMIN_EMAILS = { + "damir@rinet.one", "dradulic@outlook.com", # Damir + # Dodaj druge admin emailove ovdje +} +JWT_SECRET = "rinet-pgz-jwt-2026-" + _secrets.token_hex(8) +JWT_ISSUED = [] # in-memory token store (može u Redis) + +@app.post("/api/auth/google") +def google_auth(token: str = Body(..., embed=True)): + """Verify Google ID token and issue JWT for admin/viewer role.""" + try: + import urllib.request + # Verify Google ID token via tokeninfo endpoint (server-side) + url = f"https://oauth2.googleapis.com/tokeninfo?id_token={token}" + with urllib.request.urlopen(url, timeout=10) as r: + data = json.loads(r.read()) + email = data.get("email", "").lower() + verified = data.get("email_verified") == "true" or data.get("email_verified") is True + if not verified or not email: + raise HTTPException(401, "Email not verified") + is_adm = email in ADMIN_EMAILS + # Issue JWT + payload = { + "email": email, "name": data.get("name", email), + "role": "admin" if is_adm else "viewer", + "iat": int(__import__("time").time()), + "exp": int(__import__("time").time()) + 86400 * 7 # 7 dana + } + jwt_token = _jwt.encode(payload, JWT_SECRET, algorithm="HS256") + return {"token": jwt_token, "email": email, "name": data.get("name", email), + "role": payload["role"], "expires_in": 86400 * 7} + except HTTPException: raise + except Exception as e: + raise HTTPException(401, f"Google auth failed: {e}") + +@app.get("/api/auth/me") +def auth_me(authorization: Optional[str] = Header(None)): + """Get current user info from JWT.""" + if not authorization: return {"role": "viewer", "email": None, "name": None} + token = authorization.replace("Bearer ", "").strip() + # Try JWT first + try: + payload = _jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return {"role": payload.get("role"), "email": payload.get("email"), "name": payload.get("name")} + except Exception: + pass + # Legacy demo token + if token == ADMIN_TOKEN: + return {"role": "admin", "email": "demo@admin", "name": "Demo Admin"} + return {"role": "viewer", "email": None, "name": None} + +# ==================== 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} + +@app.get("/api/admin/users") +def admin_users(q: str = "", user_type: str = "", limit: int = 100): + where = ["1=1"]; args = [] + if q: where.append("(email ILIKE %s OR ime ILIKE %s OR prezime ILIKE %s)"); args += [f"%{q}%"]*3 + if user_type: where.append("user_type = %s"); args.append(user_type) + args.append(limit) + with db() as conn: + cur = conn.cursor() + cur.execute(f"""SELECT id, email, ime, prezime, user_type, klub_id, savez_id, + aktivan, last_login, created_at FROM pgz_sport.users + WHERE {' AND '.join(where)} ORDER BY id LIMIT %s""", args) + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + results = [{**dict(zip(cols, r)), + 'last_login': str(dict(zip(cols, r))['last_login']) if dict(zip(cols, r))['last_login'] else None, + 'created_at': str(dict(zip(cols, r))['created_at'])} for r in rows] + return {"count": len(results), "results": results} + +@app.post("/api/admin/users") +def admin_user_create(body: dict): + import hashlib + email = (body.get("email") or "").strip().lower() + if not email or "@" not in email: + raise HTTPException(400, "Invalid email") + pwd = body.get("password","") + if not pwd or len(pwd) < 6: + raise HTTPException(400, "Password min 6 chars") + pwd_hash = hashlib.sha256(pwd.encode()).hexdigest() + with db() as conn: + cur = conn.cursor() + try: + cur.execute("""INSERT INTO pgz_sport.users + (email, password_hash, ime, prezime, user_type, klub_id, savez_id, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,%s,true) RETURNING id""", + (email, pwd_hash, body.get("ime"), body.get("prezime"), + body.get("user_type","klub_user"), body.get("klub_id"), body.get("savez_id"))) + new_id = cur.fetchone()[0] + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, target_text, payload) + VALUES ('user.create','sys_users',%s,%s,%s::jsonb)""", + (new_id, email, json.dumps({"user_type": body.get("user_type")}))) + conn.commit() + return {"id": new_id, "email": email} + except psycopg2.IntegrityError as e: + conn.rollback() + raise HTTPException(400, f"Email već postoji: {email}") + +@app.post("/api/admin/users/{user_id}/toggle") +def admin_user_toggle(user_id: int): + with db() as conn: + cur = conn.cursor() + cur.execute("UPDATE pgz_sport.users SET aktivan = NOT aktivan WHERE id=%s RETURNING aktivan", (user_id,)) + r = cur.fetchone() + if not r: raise HTTPException(404, "User not found") + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, payload) + VALUES ('user.toggle','sys_users',%s,%s::jsonb)""", (user_id, json.dumps({"aktivan": r[0]}))) + conn.commit() + return {"id": user_id, "aktivan": r[0]} + + +# ──────── 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}') + + + + + +@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("/api/sportski-objekti") +def list_sportski_objekti(q=None,tip=None,grad=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR adresa ILIKE %s OR grad ILIKE %s)"); p+=["%"+q+"%"]*3 + if tip: w.append("tip ILIKE %s"); p.append("%"+tip+"%") + if grad: w.append("grad ILIKE %s"); p.append("%"+grad+"%") + rows=fetch("SELECT * FROM pgz_sport.sportski_objekti WHERE "+" AND ".join(w)+" ORDER BY grad,naziv",p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/clanovi-full") +def list_clanovi_full(q=None,hoo=None,reprezentativac=None,klub_id=None,limit=80,authorization=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(ime ILIKE %s OR prezime ILIKE %s OR klub_naziv_godisnjak ILIKE %s)"); p+=["%"+q+"%"]*3 + if hoo: w.append("hoo_kategorija=%s"); p.append(hoo) + if reprezentativac is not None: w.append("reprezentativac="+(("TRUE") if str(reprezentativac).lower()=="true" else "FALSE")) + if klub_id: w.append("klub_id=%s"); p.append(int(klub_id)) + lim=min(int(limit or 80),200) + sql="SELECT id,ime,prezime,oib,datum_rodenja,spol,sport,pozicija,reprezentativac,kategoriziran,stipendiran,kategorija_hoo,hoo_kategorija,aktivan,klub_naziv_godisnjak,slika_url,profile_url,hns_igrac_id,visina_cm,tezina_kg,broj_dresa,uloga,godisnjak_godine,godisnjak_prvi,godisnjak_zadnji,napomena FROM pgz_sport.clanovi WHERE "+" AND ".join(w)+" ORDER BY prezime,ime LIMIT "+str(lim) + rows=fetch(sql,p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/gradovi") +def list_gradovi(): + rows=fetch("SELECT DISTINCT grad FROM pgz_sport.klubovi WHERE aktivan=TRUE AND grad IS NOT NULL AND grad<>'' AND grad NOT SIMILAR TO '[0-9]+%%' ORDER BY grad",[]) + return [r["grad"] for r in rows] + +@app.get("/api/manifestacije-full") +def list_manifestacije_full(q=None,razina=None): + w=["aktivna=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR mjesto ILIKE %s)"); p+=["%"+q+"%"]*2 + rows=fetch("SELECT id,naziv,mjesto,organizator,razina,broj_ucesnika,godina_od,spol_kategorija,napomena,source_url FROM pgz_sport.manifestacije WHERE "+" AND ".join(w)+" ORDER BY naziv",p) + return {"count":len(rows),"rows":rows} + + + +# ── SUFINANCIRANJE-ALL v1.0 dradulic@outlook.com 2026-05-04 +@app.get("/api/sufinanciranje") +def list_sufinanciranje(q=None, godina=None, razina=None, sport=None, limit=500): + w=["iznos_eur > 0"]; p=[] + if q: w.append("(LOWER(korisnik) LIKE %s OR LOWER(sport) LIKE %s)"); p+=[f"%{q.lower()}%"]*2 + if godina: w.append("godina=%s"); p.append(int(godina)) + if razina: w.append("razina ILIKE %s"); p.append(f"%{razina}%") + if sport: w.append("sport ILIKE %s"); p.append(f"%{sport}%") + sql=f"SELECT korisnik,sport,iznos_eur,vrsta,razina,izvor,source_url,godina FROM pgz_sport.sufinanciranje_sport WHERE {' AND '.join(w)} ORDER BY iznos_eur DESC LIMIT {min(int(limit),1000)}" + rows=fetch(sql,p) + total=sum(float(r.get('iznos_eur') or 0) for r in rows) + years=sorted(set(r.get('godina') for r in rows if r.get('godina')),reverse=True) + return {"count":len(rows),"total":total,"years":years,"rows":rows} + + + +# ══════════════════════════════════════════════════════════════════ +# ERP PLATFORM ROUTES v2.0 — dradulic@outlook.com — 2026-05-04 +# ══════════════════════════════════════════════════════════════════ + +import hashlib + +def hash_pwd(pwd): return hashlib.sha256(pwd.encode()).hexdigest() + +def get_user(token): + if not token: return None + try: + payload = _jwt.decode(token.replace("Bearer ",""), JWT_SECRET, algorithms=["HS256"]) + uid = payload.get("uid") + if uid: + rows = fetch("SELECT * FROM pgz_sport.users WHERE id=%s AND aktivan=TRUE", [uid]) + return rows[0] if rows else None + return payload + except: return None + +# ── AUTH: Email/Password login ────────────────────────────────── +@app.post("/api/auth/login") +def login(body: dict = Body(...)): + email = (body.get("email","")).lower().strip() + pwd = body.get("password","") + if not email or not pwd: raise HTTPException(400,"Email i lozinka obavezni") + rows = fetch("SELECT * FROM pgz_sport.users WHERE LOWER(email)=%s AND aktivan=TRUE",[email]) + if not rows: raise HTTPException(401,"Neispravni podaci") + u = rows[0] + ph = hashlib.sha256(pwd.encode()).hexdigest() + if u.get("password_hash") != ph: raise HTTPException(401,"Neispravni podaci") + payload = {"uid":u["id"],"email":email,"name":u.get("full_name",email), + "role":u.get("user_type","viewer"),"klub_id":u.get("klub_id"), + "savez_id":u.get("savez_id"),"iat":int(__import__("time").time()), + "exp":int(__import__("time").time())+86400*7} + tok = _jwt.encode(payload, JWT_SECRET, algorithm="HS256") + try: + with db() as conn: + cur=conn.cursor() + cur.execute("UPDATE pgz_sport.users SET last_login=NOW() WHERE id=%s",[u["id"]]) + conn.commit() + except: pass + return {"token":tok,"role":payload["role"],"name":payload["name"], + "email":email,"klub_id":payload["klub_id"],"savez_id":payload["savez_id"]} + +# ── 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.mount("/static", StaticFiles(directory=str(HTML_DIR)), name="static") + +@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) diff --git a/_backups/pgz_sport_api.py.preauth_v2.1777931444 b/_backups/pgz_sport_api.py.preauth_v2.1777931444 new file mode 100644 index 0000000..a0221ec --- /dev/null +++ b/_backups/pgz_sport_api.py.preauth_v2.1777931444 @@ -0,0 +1,1646 @@ +#!/usr/bin/env python3 +""" +pgz_sport_api.py - FastAPI backend za PGŽ Sportski savez ERP/CRM +Author: Damir Radulić (damir@rinet.one) +Date: 25.04.2026 +Port: 8095 +Endpoints: savezi, klubovi, članovi, članarine, liječnički, manifestacije, proračun, dashboard, alertovi +""" + +from fastapi import FastAPI, HTTPException, Query, Body, Header, Depends, UploadFile, File, Form, Request +import json +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import Optional, List +from datetime import date, datetime +import psycopg2 +import psycopg2.extras +from pgz_sport_v2_router import router as v2_router +import os + +DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7') + + +ADMIN_TOKEN = 'admin-pgz-2026' + +def is_admin(authorization): + if not authorization: return False + token = authorization.replace('Bearer ', '').strip() + if token == ADMIN_TOKEN: return True + # Try JWT + try: + import jwt as _jwt + payload = _jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return payload.get("role") == "admin" + except Exception: + return False + +def blur_oib(v): + if not v: return v + s = str(v); + return s[:3] + '•'*(len(s)-5) + s[-2:] if len(s) >= 8 else '•'*len(s) +def blur_email(e): + if not e or '@' not in str(e): return e + u, d = str(e).split('@',1); return (u[:1]+'•••' if u else '')+'@'+d +def blur_phone(p): + if not p: return p + s=str(p); return s[:4]+'•'*(len(s)-7)+s[-3:] if len(s)>=7 else s +def blur_iban(v): + if not v: return v + s=str(v); return s[:4]+'•'*(len(s)-8)+s[-4:] if len(s)>=8 else s +def blur_date(d): + if not d: return d + s = str(d); return s[:4]+'-••-••' if len(s)>=4 else s +def blur_text(t, keep=3): + if not t: return t + s=str(t); return s[:keep]+'•'*(len(s)-keep*2)+s[-keep:] if len(s)>keep*2 else s + +def apply_privacy(rows, admin): + if admin: return rows + out = [] + for r in (rows if isinstance(rows, list) else [rows]): + rr = dict(r) + for k, v in list(rr.items()): + if v is None: continue + kl = k.lower() + if 'oib' in kl: rr[k] = blur_oib(v) + elif 'email' in kl: rr[k] = blur_email(v) + elif kl in ('telefon','tel','phone'): rr[k] = blur_phone(v) + elif kl == 'datum_rodenja': rr[k] = blur_date(v) + elif 'iban' in kl: rr[k] = blur_iban(v) + elif kl == 'adresa': rr[k] = blur_text(v, 3) + elif 'licenca_broj' in kl: rr[k] = blur_text(v, 2) + out.append(rr) + return out if isinstance(rows, list) else out[0] + +app = FastAPI(title="PGŽ Sportski savez ERP/CRM", version="1.0.0") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + + +# === URL rewrite middleware - convert direct external image URLs to /img-proxy === +import json as _json_mw +import re as _re_mw +from starlette.responses import Response as _StarletteResponse_mw + +_IMG_DOMAINS_RE = _re_mw.compile( + r'https?://(?:hns\.family|hns\.hr|hbs\.hr|hrvatski-bocarski-savez\.hr|' + r'rk-zamet\.hr|hvs\.hr|rezultati\.hvs\.hr|sport-pgz\.hr)' + r'/[^"\s\\]+\.(?:jpg|jpeg|png|gif|webp|svg)', + _re_mw.IGNORECASE +) + +def _rewrite_to_proxy(text: str) -> str: + """Replace external image URLs with /sport/api/v2/img-proxy?u=...""" + from urllib.parse import quote as _q + def _sub(m): + url = m.group(0) + return "/sport/api/v2/img-proxy?u=" + _q(url, safe='') + return _IMG_DOMAINS_RE.sub(_sub, text) + +@app.middleware("http") +async def url_rewrite_middleware(request, call_next): + response = await call_next(request) + # Only rewrite JSON API responses + ct = response.headers.get("content-type", "") + if "application/json" not in ct: + return response + # Only on /api/v2 routes (admin & data endpoints) - SKIP /api/v2/img-proxy itself + path = request.url.path + if "/api/v2/img-proxy" in path or "/api/v2/dokumenti" in path: + return response # don't rewrite raw document content + # Read body + body = b"" + async for chunk in response.body_iterator: + body += chunk + try: + text = body.decode("utf-8") + new_text = _rewrite_to_proxy(text) + new_body = new_text.encode("utf-8") + except Exception: + new_body = body + return _StarletteResponse_mw( + content=new_body, + status_code=response.status_code, + headers={k: v for k, v in response.headers.items() if k.lower() not in ("content-length",)}, + media_type=ct, + ) +# === end URL rewrite middleware === + +def db(): + conn = psycopg2.connect(**DB) + conn.autocommit = True + return conn + +def fetch(sql, params=None): + with db() as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as c: + c.execute(sql, params or ()) + return [dict(r) for r in c.fetchall()] + +def execute(sql, params=None): + with db() as conn: + with conn.cursor() as c: + c.execute(sql, params or ()) + return c.rowcount + +# ==================== HEALTH ==================== +@app.get("/health") +def health(): + try: + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + return {"status": "ok", "service": "pgz_sport", "dashboard": rows[0] if rows else None} + except Exception as e: + raise HTTPException(500, f"DB error: {e}") + + +@app.get("/api/whoami") +def whoami_v2(authorization: Optional[str] = Header(None)): + return {"role": "admin" if is_admin(authorization) else "viewer", "privacy_active": not is_admin(authorization)} + +# ==================== DASHBOARD ==================== +@app.get("/api/dashboard") +def dashboard(): + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + if not rows: + return {} + d = rows[0] + # Top savezi by registriranih 2024 + top = fetch("""SELECT s.naziv, st.klubova_clanica, st.registriranih, st.trenera, st.reprezentativaca + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina=2024 ORDER BY st.registriranih DESC LIMIT 10""") + proracun_trend = fetch("SELECT godina, ukupno FROM pgz_sport.proracun ORDER BY godina") + nositelji = fetch("""SELECT naziv_kluba, godina, iznos FROM pgz_sport.potpore_nositelji + WHERE godina = 2025 ORDER BY iznos DESC LIMIT 10""") + return {**d, "top_savezi": top, "proracun_trend": proracun_trend, "nositelji_2025": nositelji} + +@app.get("/api/dashboard/ekosustav") +def dashboard_ekosustav(): + """Sport ekosustav PGŽ — coverage stats za enrichment iz FINA registra.""" + summary = fetch("""SELECT + COUNT(*) AS klubova_total, + COUNT(*) FILTER (WHERE oib IS NOT NULL) AS s_oib, + COUNT(*) FILTER (WHERE predsjednik IS NOT NULL) AS s_predsjednik, + COUNT(*) FILTER (WHERE tajnik IS NOT NULL) AS s_tajnik, + COUNT(*) FILTER (WHERE ciljevi IS NOT NULL) AS s_ciljevi, + COUNT(*) FILTER (WHERE opis_djelatnosti IS NOT NULL) AS s_opis, + COUNT(*) FILTER (WHERE sjediste IS NOT NULL) AS s_sjediste, + COUNT(*) FILTER (WHERE email IS NOT NULL) AS s_email, + COUNT(*) FILTER (WHERE web_stranica IS NOT NULL) AS s_web, + COUNT(*) FILTER (WHERE udruga_status = \'AKTIVAN\') AS s_aktivan_reg, + COUNT(*) FILTER (WHERE savez_id IS NOT NULL) AS s_savez, + COUNT(*) FILTER (WHERE nositelj_kvalitete) AS s_nositelj + FROM pgz_sport.klubovi WHERE aktivan""")[0] + + by_sport = fetch("""SELECT sport, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND sport IS NOT NULL + GROUP BY sport ORDER BY COUNT(*) DESC LIMIT 15""") + + by_region = fetch("""SELECT region, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND region IS NOT NULL + GROUP BY region ORDER BY COUNT(*) DESC""") + + by_grad = fetch("""SELECT grad, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND grad IS NOT NULL + GROUP BY grad ORDER BY COUNT(*) DESC LIMIT 12""") + + decade = fetch("""SELECT + CASE + WHEN godina_osnutka < 1950 THEN \'pred1950\' + WHEN godina_osnutka < 1980 THEN \'1950-1979\' + WHEN godina_osnutka < 2000 THEN \'1980-1999\' + WHEN godina_osnutka < 2010 THEN \'2000-2009\' + WHEN godina_osnutka >= 2010 THEN \'2010-danas\' + ELSE \'nepoznato\' + END AS razdoblje, + COUNT(*) AS broj + FROM pgz_sport.klubovi + WHERE aktivan AND godina_osnutka IS NOT NULL + GROUP BY razdoblje ORDER BY razdoblje""") + + # Pokazi enrichment % + total = summary["klubova_total"] or 1 + coverage = { + "oib_pct": round(100 * summary["s_oib"] / total, 1), + "predsjednik_pct": round(100 * summary["s_predsjednik"] / total, 1), + "tajnik_pct": round(100 * summary["s_tajnik"] / total, 1), + "ciljevi_pct": round(100 * summary["s_ciljevi"] / total, 1), + "opis_pct": round(100 * summary["s_opis"] / total, 1), + "sjediste_pct": round(100 * summary["s_sjediste"] / total, 1), + "email_pct": round(100 * summary["s_email"] / total, 1), + "savez_pct": round(100 * summary["s_savez"] / total, 1), + } + + return {**summary, "coverage": coverage, "by_sport": by_sport, + "by_region": by_region, "by_grad": by_grad, "by_decade": decade} + + + +# ==================== ANALYTICS ==================== +@app.get("/api/analytics/savezi-trend") +def savezi_trend(godine: str = "2020,2021,2022,2023,2024", metric: str = "registriranih"): + valid_metrics = {"registriranih", "neregistriranih", "rekreativaca", "trenera", "reprezentativaca", + "kategoriziranih", "stipendiranih", "klubova_clanica"} + if metric not in valid_metrics: + raise HTTPException(400, f"Invalid metric. Must be one of: {valid_metrics}") + god_list = [int(g) for g in godine.split(",")] + rows = fetch(f"""SELECT s.naziv AS savez, st.godina, st.{metric} AS value + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina = ANY(%s) ORDER BY s.naziv, st.godina""", [god_list]) + saveze = {} + for r in rows: + if r['savez'] not in saveze: saveze[r['savez']] = {} + saveze[r['savez']][r['godina']] = r['value'] + return {"metric": metric, "godine": god_list, "data": saveze} + +@app.get("/api/analytics/proracun-detaljno") +def proracun_detaljno(): + p = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + if not p: return {"proracun": [], "rast_godisnji": [], "current_year": None, "current_total": 0, "rast_dekada_pct": 0} + cagr = [] + for i in range(1, len(p)): + prev = float(p[i-1]['ukupno']) if p[i-1]['ukupno'] else 0 + curr = float(p[i]['ukupno']) if p[i]['ukupno'] else 0 + rate = ((curr/prev - 1) * 100) if prev > 0 else 0 + cagr.append({"godina": p[i]['godina'], "rast_postotak": round(rate, 1)}) + decade_rast = round((float(p[-1]['ukupno'])/float(p[0]['ukupno']) - 1) * 100, 1) if p[0]['ukupno'] else 0 + return {"proracun": p, "rast_godisnji": cagr, "rast_dekada_pct": decade_rast, + "current_year": int(p[-1]['godina']), "current_total": float(p[-1]['ukupno'])} + +@app.get("/api/analytics/klub-financije") +def klub_financije(klub_id: Optional[int] = None, godina: Optional[int] = None): + where = [] + params = [] + if godina: where.append("p.godina=%s"); params.append(godina) + if klub_id: + where.append("(p.klub_id=%s OR p.naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))") + params.extend([klub_id, klub_id]) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"""SELECT p.naziv_kluba, p.godina, p.iznos, + k.id AS klub_id, k.sport, k.razina, k.nositelj_kvalitete + FROM pgz_sport.potpore_nositelji p + LEFT JOIN pgz_sport.klubovi k ON p.klub_id=k.id OR p.naziv_kluba=k.naziv + {where_sql} ORDER BY p.godina DESC, p.iznos DESC""", params) + summary = fetch(f"""SELECT godina, SUM(iznos) AS total, COUNT(*) AS klubova, AVG(iznos) AS prosjek + FROM pgz_sport.potpore_nositelji p {where_sql} + GROUP BY godina ORDER BY godina""", params) + return {"data": rows, "summary": summary} + +@app.get("/api/analytics/lijecnicki-stats") +def lijecnicki_stats(klub_id: Optional[int] = None): + where = ["1=1"]; params = [] + if klub_id: where.append("c.klub_id=%s"); params.append(klub_id) + where_sql = " AND ".join(where) + rows = fetch(f"""SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE lp.vrijedi_do >= CURRENT_DATE + 30) AS validni, + COUNT(*) FILTER (WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + 30) AS uskoro, + COUNT(*) FILTER (WHERE lp.vrijedi_do < CURRENT_DATE) AS istekli, + SUM(lp.iznos) AS ukupan_trosak, SUM(lp.iznos_zzjz) AS zzjz_udio, + SUM(lp.iznos_klub) AS klub_udio, SUM(lp.iznos_clan) AS clan_udio, + AVG(lp.iznos) AS prosjecni_trosak + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id WHERE {where_sql}""", params) + by_ustanova = fetch(f"""SELECT lp.ustanova, COUNT(*) cnt, SUM(lp.iznos) iznos + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} GROUP BY lp.ustanova ORDER BY cnt DESC""", params) + by_lijecnik = fetch(f"""SELECT lp.lijecnik, COUNT(*) cnt, AVG(lp.iznos) prosjek + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} AND lp.lijecnik IS NOT NULL GROUP BY lp.lijecnik ORDER BY cnt DESC""", params) + return {"summary": rows[0] if rows else {}, "by_ustanova": by_ustanova, "by_lijecnik": by_lijecnik} + +# ==================== SAVEZI ==================== +@app.get("/api/savezi") +def list_savezi(authorization: Optional[str] = Header(None), q: Optional[str] = None, + razina: Optional[str] = None, zupanija: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = "WHERE aktivan" + params = [] + if q: + where += " AND (naziv ILIKE %s OR sport ILIKE %s)" + params = [f"%{q}%", f"%{q}%"] + if razina: + where += " AND razina = %s"; params.append(razina) + if zupanija: + where += " AND sjediste_zupanija ILIKE %s"; params.append(f"%{zupanija}%") + sort_col = {"naziv": "naziv", "godina": "godina_osnutka", "sport": "sport", "razina": "razina"}.get(sort, "naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + # Croatian collation for text columns (Š → after S, Č → after C, etc.) + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("naziv", "sport") else "" + rows = fetch(f"""SELECT s.*, + (SELECT COUNT(*) FROM pgz_sport.klubovi WHERE savez_id=s.id) AS broj_klubova, + (SELECT registriranih FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS reg_2024, + (SELECT trenera FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS treneri_2024, + (SELECT reprezentativaca FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS repr_2024 + FROM pgz_sport.savezi s {where} ORDER BY {sort_col}{collate} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/savezi/{savez_id}") +def get_savez(savez_id: int): + rows = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s", [savez_id]) + if not rows: + raise HTTPException(404, "Savez ne postoji") + klubovi = fetch("SELECT * FROM pgz_sport.klubovi WHERE savez_id=%s ORDER BY naziv", [savez_id]) + statistika = fetch("SELECT * FROM pgz_sport.statistika_saveza WHERE savez_id=%s ORDER BY godina", [savez_id]) + manifestacije = fetch("SELECT * FROM pgz_sport.manifestacije WHERE savez_id=%s", [savez_id]) + return {**rows[0], "klubovi": klubovi, "statistika": statistika, "manifestacije": manifestacije} + +# ==================== KLUBOVI ==================== +@app.get("/api/klubovi") +def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, savez_id: Optional[int] = None, + nositelj: Optional[bool] = None, region: Optional[str] = None, sport: Optional[str] = None, grad: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivan"] + params = [] + if q: + where.append("(klub ILIKE %s OR oib ILIKE %s OR sport ILIKE %s OR predsjednik ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%", f"%{q}%"]) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + if nositelj is not None: + where.append(f"nositelj_kvalitete={'TRUE' if nositelj else 'FALSE'}") + if region: + where.append("region ILIKE %s"); params.append(region) + if grad: + where.append("grad ILIKE %s"); params.append(f"%{grad}%") + if sport: + where.append("sport ILIKE %s"); params.append(f"%{sport}%") + sort_col = {"naziv": "klub", "savez": "savez", "broj_clanova": "broj_clanova", + "razina": "razina", "region": "region", "grad": "grad", "sport": "sport"}.get(sort, "klub") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("klub", "savez", "razina", "region", "grad", "sport") else "" + rows = fetch(f"""SELECT * FROM pgz_sport.v_klubovi_pregled WHERE {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST""", params) + for r in rows: + if isinstance(r, dict) and r.get('klub') and not r.get('naziv'): + r['naziv'] = r['klub'] + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/klubovi/{klub_id}") +def get_klub(klub_id: int, authorization: Optional[str] = Header(None)): + admin = is_admin(authorization) + rows = fetch("""SELECT k.*, s.naziv AS savez_naziv FROM pgz_sport.klubovi k + LEFT JOIN pgz_sport.savezi s ON s.id=k.savez_id WHERE k.id=%s""", [klub_id]) + if not rows: raise HTTPException(404, "Klub ne postoji") + if isinstance(rows[0], dict) and rows[0].get('klub') and not rows[0].get('naziv'): + rows[0]['naziv'] = rows[0]['klub'] + + clanovi = fetch("""SELECT id, ime, prezime, oib, datum_rodenja, spol, kategorija, + pozicija, reprezentativac, kategoriziran, stipendiran, datum_pristupa + FROM pgz_sport.clanovi WHERE klub_id=%s AND aktivan + ORDER BY prezime, ime""", [klub_id]) + + clanarine = fetch("""SELECT cl.id, cl.godina, cl.razdoblje, cl.iznos_propisan, cl.iznos_placen, + (cl.iznos_propisan - cl.iznos_placen) AS dug, cl.datum_uplate, cl.status, cl.napomena, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib + FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE c.klub_id=%s ORDER BY cl.godina DESC, cl.id DESC""", [klub_id]) + + lijecnicki = fetch("""SELECT lp.id, lp.datum_pregleda, lp.vrijedi_do, lp.vrsta_pregleda, + lp.ustanova, lp.lijecnik, lp.spreman_za_natjecanje, lp.iznos, lp.iznos_zzjz, lp.iznos_klub, lp.iznos_clan, + lp.placeno, lp.komentar_lijecnika, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib, + CASE WHEN lp.vrijedi_do IS NULL THEN 'Nepoznato' + WHEN lp.vrijedi_do < CURRENT_DATE THEN 'Istekao' + WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'Ističe uskoro' + ELSE 'Validan' END AS status_pregled + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s ORDER BY lp.datum_pregleda DESC""", [klub_id]) + + potpore = fetch("""SELECT * FROM pgz_sport.potpore_nositelji + WHERE klub_id=%s OR naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s) + ORDER BY godina DESC""", [klub_id, klub_id]) + + # Aggregate stats + stats = { + 'broj_clanova': len(clanovi), + 'broj_registriranih': sum(1 for c in clanovi if c.get('kategorija')=='registrirani'), + 'broj_trenera': sum(1 for c in clanovi if c.get('kategorija')=='trener'), + 'broj_reprezentativaca': sum(1 for c in clanovi if c.get('reprezentativac')), + 'broj_kategoriziranih': sum(1 for c in clanovi if c.get('kategoriziran')), + 'broj_stipendiranih': sum(1 for c in clanovi if c.get('stipendiran')), + 'lijecnicki_validni': sum(1 for l in lijecnicki if l.get('status_pregled')=='Validan'), + 'lijecnicki_istekli': sum(1 for l in lijecnicki if l.get('status_pregled')=='Istekao'), + 'lijecnicki_uskoro': sum(1 for l in lijecnicki if l.get('status_pregled')=='Ističe uskoro'), + 'clanarina_naplaceno_god': sum(float(c.get('iznos_placen') or 0) for c in clanarine if c.get('godina')==2026), + 'clanarina_dug_god': sum(float(c.get('dug') or 0) for c in clanarine if c.get('godina')==2026), + 'potpore_2025': float(next((p['iznos'] for p in potpore if p.get('godina')==2025), 0) or 0), + 'potpore_total': sum(float(p.get('iznos') or 0) for p in potpore), + 'zzjz_isplaceno': sum(float(l.get('iznos_zzjz') or 0) for l in lijecnicki if l.get('placeno')), + } + + klub = rows[0] + if not admin: + klub = apply_privacy(klub, admin) + clanovi = apply_privacy(clanovi, admin) + clanarine = apply_privacy(clanarine, admin) + lijecnicki = apply_privacy(lijecnicki, admin) + + return {**klub, "clanovi": clanovi, "clanarine": clanarine, "lijecnicki": lijecnicki, + "potpore": potpore, "stats": stats} + + +class KlubIn(BaseModel): + naziv: str + savez_id: Optional[int] = None + sport: Optional[str] = None + oib: Optional[str] = None + razina: Optional[str] = None + nositelj_kvalitete: Optional[bool] = False + grad: Optional[str] = None + region: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + predsjednik: Optional[str] = None + iban: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/klubovi") +def create_klub(k: KlubIn): + rows = fetch("""INSERT INTO pgz_sport.klubovi (naziv, savez_id, sport, oib, razina, nositelj_kvalitete, grad, region, email, telefon, predsjednik, iban, napomena, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE) RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena]) + return rows[0] + +@app.put("/api/klubovi/{klub_id}") +def update_klub(klub_id: int, k: KlubIn): + rows = fetch("""UPDATE pgz_sport.klubovi SET naziv=%s, savez_id=%s, sport=%s, oib=%s, razina=%s, + nositelj_kvalitete=%s, grad=%s, region=%s, email=%s, telefon=%s, predsjednik=%s, iban=%s, napomena=%s, + updated_at=NOW() WHERE id=%s RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena, klub_id]) + if not rows: + raise HTTPException(404, "Klub ne postoji") + return rows[0] + +# ==================== ČLANOVI ==================== +@app.get("/api/clanovi") +def list_clanovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, klub_id: Optional[int] = None, + kategorija: Optional[str] = None, spol: Optional[str] = None, sort: str = "prezime", order: str = "asc"): + where = ["c.aktivan"] + params = [] + if q: + where.append("(c.ime ILIKE %s OR c.prezime ILIKE %s OR c.oib ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%"]) + if klub_id: + where.append("c.klub_id=%s"); params.append(klub_id) + if kategorija: + where.append("c.kategorija=%s"); params.append(kategorija) + if spol: + # Normalize: Z → Ž, F → Ž (legacy) + spol_norm = "Ž" if spol.upper() in ("Z","Ž","F","W") else "M" if spol.upper() in ("M",) else spol + where.append("c.spol=%s"); params.append(spol_norm) + sort_map = {"prezime": "c.prezime", "ime": "c.ime", "oib": "c.oib", "datum_rodenja": "c.datum_rodenja", "kategorija": "c.kategorija", "klub": "k.naziv"} + sort_col = sort_map.get(sort, "c.prezime") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT c.*, k.naziv AS klub_naziv, + (SELECT MAX(vrijedi_do) FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=c.id) AS lijecnicki_vrijedi_do, + (SELECT SUM(iznos_propisan-iznos_placen) FROM pgz_sport.clanarine WHERE clan_id=c.id AND status!='podmireno') AS dug_clanarine + FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE {where_sql} ORDER BY {sort_col} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +class ClanIn(BaseModel): + klub_id: int + ime: str + prezime: str + oib: Optional[str] = None + datum_rodenja: Optional[date] = None + spol: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + kategorija: Optional[str] = "registrirani" + pozicija: Optional[str] = None + licenca_broj: Optional[str] = None + licenca_vrijedi_do: Optional[date] = None + reprezentativac: Optional[bool] = False + kategoriziran: Optional[bool] = False + stipendiran: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/clanovi") +def create_clan(c: ClanIn): + rows = fetch("""INSERT INTO pgz_sport.clanovi (klub_id, ime, prezime, oib, datum_rodenja, spol, email, telefon, kategorija, pozicija, licenca_broj, licenca_vrijedi_do, reprezentativac, kategoriziran, stipendiran, napomena, aktivan, datum_pristupa) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE,CURRENT_DATE) RETURNING *""", + [c.klub_id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol, c.email, c.telefon, c.kategorija, c.pozicija, c.licenca_broj, c.licenca_vrijedi_do, c.reprezentativac, c.kategoriziran, c.stipendiran, c.napomena]) + return rows[0] + +@app.get("/api/clanovi/{clan_id}") +def get_clan(clan_id: int): + rows = fetch("""SELECT c.*, k.naziv AS klub_naziv FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id]) + if not rows: + raise HTTPException(404, "Član ne postoji") + clanarine = fetch("SELECT * FROM pgz_sport.clanarine WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + lijecnicki = fetch("SELECT * FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=%s ORDER BY datum_pregleda DESC", [clan_id]) + return {**rows[0], "clanarine": clanarine, "lijecnicki": lijecnicki} + +# ==================== ČLANARINE ==================== +@app.get("/api/clanarine") +def list_clanarine(godina: Optional[int] = None, status: Optional[str] = None, + klub_id: Optional[int] = None, sort: str = "godina", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + if status: + where.append("status=%s"); params.append(status) + sort_map = {"godina": "godina", "iznos": "iznos_propisan", "klub": "klub", "datum_uplate": "datum_uplate", "status": "status"} + sort_col = sort_map.get(sort, "godina") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_clanarine_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos_propisan) AS total_propisan, + SUM(iznos_placen) AS total_placen, + SUM(iznos_propisan - iznos_placen) AS total_dug + FROM pgz_sport.v_clanarine_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class ClanarinaIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + godina: int + razdoblje: Optional[str] = "godišnja" + iznos_propisan: float + iznos_placen: Optional[float] = 0 + datum_uplate: Optional[date] = None + nacin_uplate: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/clanarine") +def create_clanarina(c: ClanarinaIn): + status = "podmireno" if c.iznos_placen >= c.iznos_propisan else ("djelomicno" if c.iznos_placen > 0 else "nepodmireno") + klub_id = c.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [c.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.clanarine (clan_id, klub_id, godina, razdoblje, iznos_propisan, iznos_placen, datum_uplate, nacin_uplate, status, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [c.clan_id, klub_id, c.godina, c.razdoblje, c.iznos_propisan, c.iznos_placen, c.datum_uplate, c.nacin_uplate, status, c.napomena]) + return rows[0] + +# ==================== LIJEČNIČKI ==================== +@app.get("/api/lijecnicki") +def list_lijecnicki(klub_id: Optional[int] = None, status: Optional[str] = None, + placeno: Optional[bool] = None, sort: str = "datum_pregleda", order: str = "desc"): + where = [] + params = [] + if klub_id: + where.append("(klub_oib IS NOT NULL AND klub=ANY(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))"); params.append(klub_id) + if status: + where.append("status_pregled=%s"); params.append(status) + if placeno is not None: + where.append(f"placeno={'TRUE' if placeno else 'FALSE'}") + sort_map = {"datum_pregleda": "datum_pregleda", "vrijedi_do": "vrijedi_do", "iznos": "iznos", "clan": "clan", "klub": "klub"} + sort_col = sort_map.get(sort, "datum_pregleda") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_lijecnicki_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos) AS total_iznos, + SUM(iznos_zzjz) AS total_zzjz, + SUM(iznos_klub) AS total_klub, + SUM(iznos_clan) AS total_clan, + COUNT(*) FILTER (WHERE status_pregled='Istekao') AS istekli, + COUNT(*) FILTER (WHERE status_pregled='Ističe uskoro') AS uskoro + FROM pgz_sport.v_lijecnicki_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class LijecnickiIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + datum_pregleda: date + vrijedi_do: Optional[date] = None + vrsta_pregleda: Optional[str] = "temeljni" + ustanova: Optional[str] = "ZZJZ PGŽ" + lijecnik: Optional[str] = None + spreman_za_natjecanje: Optional[bool] = True + ekg: Optional[bool] = False + krv: Optional[bool] = False + spirometrija: Optional[bool] = False + nalaz: Optional[str] = None + komentar_lijecnika: Optional[str] = None + preporuke: Optional[str] = None + iznos: Optional[float] = 0 + iznos_zzjz: Optional[float] = 0 + iznos_klub: Optional[float] = 0 + iznos_clan: Optional[float] = 0 + datum_placanja: Optional[date] = None + placeno: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/lijecnicki") +def create_lijecnicki(l: LijecnickiIn): + klub_id = l.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [l.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.lijecnicki_pregledi (clan_id, klub_id, datum_pregleda, vrijedi_do, vrsta_pregleda, ustanova, lijecnik, spreman_za_natjecanje, ekg, krv, spirometrija, nalaz, komentar_lijecnika, preporuke, iznos, iznos_zzjz, iznos_klub, iznos_clan, datum_placanja, placeno, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [l.clan_id, klub_id, l.datum_pregleda, l.vrijedi_do, l.vrsta_pregleda, l.ustanova, l.lijecnik, l.spreman_za_natjecanje, l.ekg, l.krv, l.spirometrija, l.nalaz, l.komentar_lijecnika, l.preporuke, l.iznos, l.iznos_zzjz, l.iznos_klub, l.iznos_clan, l.datum_placanja, l.placeno, l.napomena]) + return rows[0] + +# ==================== PRORAČUN ==================== +@app.get("/api/proracun") +def list_proracun(): + rows = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + return {"count": len(rows), "rows": rows} + +# ==================== POTPORE NOSITELJI ==================== +@app.get("/api/potpore") +def list_potpore(godina: Optional[int] = None, sort: str = "iznos", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + sort_col = {"iznos": "iznos", "godina": "godina", "klub": "naziv_kluba"}.get(sort, "iznos") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.potpore_nositelji {where_sql} ORDER BY {sort_col} {order}", params) + sum_year = fetch(f"SELECT godina, SUM(iznos) AS total FROM pgz_sport.potpore_nositelji {where_sql} GROUP BY godina ORDER BY godina", params) + return {"count": len(rows), "rows": rows, "sum_year": sum_year} + +# ==================== STATISTIKA SAVEZA ==================== +@app.get("/api/statistika") +def list_statistika(godina: Optional[int] = None, q: Optional[str] = None, razina: Optional[str] = None, + sort: str = "registriranih", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("st.godina=%s"); params.append(godina) + if q: + where.append("s.naziv ILIKE %s"); params.append(f"%{q}%") + if razina: + where.append("s.razina = %s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + # Map sort key → unambiguous column expression + sort_map = { + "registriranih": "st.registriranih", + "klubova": "st.klubova_clanica", + "trenera": "st.trenera", + "reprezentativaca":"st.reprezentativaca", + "neregistriranih": "st.neregistriranih", + "rekreativaca": "st.rekreativaca", + "godina": "st.godina", + "savez": "s.naziv", + "naziv": "s.naziv", + } + sort_col = sort_map.get(sort, "st.registriranih") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + use_collate = sort_col in ("s.naziv", "s.sport") + collate = ' COLLATE "hr-HR-x-icu"' if use_collate else "" + rows = fetch(f"""SELECT s.naziv AS savez, s.razina AS savez_razina, s.sport AS sport, st.* + FROM pgz_sport.statistika_saveza st + JOIN pgz_sport.savezi s ON s.id=st.savez_id {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST, s.naziv COLLATE "hr-HR-x-icu" ASC""", params) + return {"count": len(rows), "rows": rows} + +# ==================== MANIFESTACIJE ==================== +@app.get("/api/manifestacije") +def list_manifestacije(razina: Optional[str] = None, savez_id: Optional[int] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivna"] + params = [] + if razina: + where.append("razina=%s"); params.append(razina) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + sort_col = {"naziv": "m.naziv", "razina": "m.razina", "godina_od": "m.godina_od", "mjesto": "m.mjesto"}.get(sort, "m.naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT m.*, s.naziv AS savez_naziv FROM pgz_sport.manifestacije m + LEFT JOIN pgz_sport.savezi s ON s.id=m.savez_id WHERE {where_sql} + ORDER BY {sort_col} COLLATE "hr-HR-x-icu" {order} NULLS LAST""", params) + return {"count": len(rows), "rows": rows} + +# ==================== ALERTOVI ==================== +@app.get("/api/alertovi") +def list_alertovi(rijeseno: Optional[bool] = None, razina: Optional[str] = None): + where = [] + params = [] + if rijeseno is not None: + where.append(f"rijeseno={'TRUE' if rijeseno else 'FALSE'}") + if razina: + where.append("razina=%s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.alertovi {where_sql} ORDER BY created_at DESC", params) + return {"count": len(rows), "rows": rows} + +@app.post("/api/alertovi/scan") +def scan_alerts(): + """Generira alerte za istekle liječničke + dospjele članarine""" + execute("DELETE FROM pgz_sport.alertovi WHERE NOT rijeseno AND tip IN ('lijecnicki_isteka', 'lijecnicki_uskoro', 'clanarina_dospjela')") + # Liječnički istekao + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_isteka', 'CRITICAL', c.klub_id, lp.clan_id, + 'Liječnički pregled istekao za ' || c.ime || ' ' || c.prezime || ' (klub: ' || COALESCE(k.naziv, 'N/A') || ')', lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lp.vrijedi_do < CURRENT_DATE AND c.aktivan""") + # Liječnički uskoro + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_uskoro', 'WARNING', c.klub_id, lp.clan_id, + 'Liječnički ističe za 30 dana: ' || c.ime || ' ' || c.prezime, lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE+30 AND c.aktivan""") + # Članarine dospjele + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum, iznos) + SELECT 'clanarina_dospjela', 'WARNING', cl.klub_id, cl.clan_id, + 'Nepodmirena članarina ' || cl.godina || ' za ' || c.ime || ' ' || c.prezime, NULL, (cl.iznos_propisan - cl.iznos_placen) + FROM pgz_sport.clanarine cl + JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE cl.status != 'podmireno' AND cl.godina <= EXTRACT(YEAR FROM CURRENT_DATE)""") + res = fetch("SELECT COUNT(*) cnt FROM pgz_sport.alertovi WHERE NOT rijeseno") + return {"alerts_generated": res[0]["cnt"]} + +@app.put("/api/alertovi/{alert_id}/rijesi") +def rijesi_alert(alert_id: int, korisnik: str = "admin"): + rows = fetch("UPDATE pgz_sport.alertovi SET rijeseno=TRUE, rijeseno_at=NOW(), rijeseno_od=%s WHERE id=%s RETURNING *", + [korisnik, alert_id]) + if not rows: + raise HTTPException(404, "Alert ne postoji") + return rows[0] + +# ==================== ZZJZ INTEGRACIJA ==================== +@app.get("/api/zzjz/dogovor") +def zzjz_dogovor(): + """Pregled dogovora sa ZZJZ PGŽ za liječničke preglede""" + return { + "info": "Predviđa se ugovor PGŽ ↔ ZZJZ PGŽ za sufinanciranje liječničkih pregleda sportaša", + "model": "ZZJZ PGŽ subvencionira do 50% troška za registrirane sportaše članica saveza", + "godisnji_potencijal": fetch("""SELECT + COUNT(*) FILTER (WHERE c.kategorija='registrirani') AS sportasa_potencijalno, + SUM(CASE WHEN c.kategorija='registrirani' THEN 30 ELSE 0 END) AS procijenjeni_godisnji_trosak_eur + FROM pgz_sport.clanovi c WHERE c.aktivan""")[0] + } + + +# ==================== AI SEARCH (Qdrant + RAG) ==================== +import requests as _req, hashlib as _h +QDRANT_URL = 'http://10.10.0.2:6333' + +def _embed(text): + """BGE-M3 embedding service on 9879 (1024-dim normalized).""" + try: + r = _req.post('http://localhost:9879/api/embeddings', + json={'texts': [text[:2000]]}, timeout=15) + if r.ok: + data = r.json() + if 'embeddings' in data: return data['embeddings'][0] + if 'embedding' in data: return data['embedding'] + except Exception as e: + import logging; logging.warning(f'BGE-M3 fail: {e}') + h = _h.sha256(text.encode()).digest() + return [(h[i % 32] / 255.0 - 0.5) for i in range(1024)] + +@app.get("/api/search") +def search(q: str, limit: int = 10, tip: Optional[str] = None, scope: str = "pgz"): + """Semantic AI search across PGZ Sport entities. + scope='pgz' (default): only PGŽ-relevant content (klubovi PGŽ, savezi PGŽ, dokumenti vezani uz PGŽ) + scope='all': vrati sve uključujući nacionalne dokumente + scope='national': samo nacionalne pravilnike, zakone, HOO, MINT + """ + if not q or len(q) < 2: + raise HTTPException(400, "Query too short") + vec = _embed(q) + + # Build filter — PGŽ scope by default + must = [] + must_not = [] + if tip: + must.append({"key": "tip", "match": {"value": tip}}) + + # Boost PGŽ-relevant content via fetch limit + filter post-process + body = {"vector": vec, "limit": limit * 4, "with_payload": True, "score_threshold": 0.35} + if must: + body["filter"] = {"must": must} + + try: + r = _req.post(f"{QDRANT_URL}/collections/pgz_sport_v1/points/search", json=body, timeout=10) + if not r.ok: raise HTTPException(500, f"Qdrant: {r.text[:200]}") + all_results = r.json()['result'] + except _req.exceptions.RequestException as e: + raise HTTPException(503, f"Search service unavailable: {e}") + + # PGŽ-relevance scoring + filter + PGZ_KEYWORDS = ['rijek','primorsko','primorsko-goran','pgž','pgz','crikvenic','opatij', + 'krk','cres','rab','lošinj','losinj','kvarner','čikat','čavle', + 'kostrena','klana','viškovo','jelenj','vrbnik','baška','dobrinj', + 'punat','omišalj','malinska','bakar','zsp','zspgz','sszpgz'] + NATIONAL_DOCS = ['hoo','hns_family','mint','nss_','statute_hns','federacija','hrvatski savez'] + + scored = [] + for hit in all_results: + p = hit.get('payload') or {} + # Combine all text fields for keyword check + all_text = ( + (p.get('naziv','') or '') + ' ' + + (p.get('title','') or '') + ' ' + + (p.get('text','') or '')[:500] + ' ' + + (p.get('source','') or '') + ' ' + + (p.get('grad','') or '') + ' ' + + (p.get('source_url','') or '') + ).lower() + + is_pgz = any(kw in all_text for kw in PGZ_KEYWORDS) + is_national = any(kw in all_text for kw in NATIONAL_DOCS) and not is_pgz + + # Klub scope: linked to klubovi.id which is by definition PGŽ + if p.get('tip') == 'klub' and p.get('klub_id'): is_pgz = True + # Savez PGŽ + if p.get('tip') == 'savez' and (p.get('razina') == 'zupanijski' or 'pgž' in (p.get('naziv','') or '').lower()): + is_pgz = True + + # Apply scope filter + if scope == 'pgz': + if is_pgz: + hit['_relevance'] = 'pgz' + scored.append(hit) + elif is_national and p.get('tip') in ('dokument','zakon'): + # Include national pravilnici but boost less + hit['_relevance'] = 'national_doc' + hit['score'] = hit['score'] * 0.7 + scored.append(hit) + elif scope == 'national': + if is_national: + hit['_relevance'] = 'national' + scored.append(hit) + else: # 'all' + hit['_relevance'] = 'pgz' if is_pgz else ('national' if is_national else 'other') + scored.append(hit) + + # Re-sort by adjusted score + scored.sort(key=lambda x: x.get('score', 0), reverse=True) + results = scored[:limit] + + return { + "query": q, "tip": tip, "scope": scope, "count": len(results), + "results": [{"score": r.get('score', 0), + "tip": (r.get('payload') or {}).get('tip'), + "naziv": (r.get('payload') or {}).get('naziv') or (r.get('payload') or {}).get('title'), + "klub_id": (r.get('payload') or {}).get('klub_id'), + "savez_id": (r.get('payload') or {}).get('savez_id'), + "tekst": (r.get('payload') or {}).get('tekst') or (r.get('payload') or {}).get('text','')[:300], + "url": (r.get('payload') or {}).get('source_url') or (r.get('payload') or {}).get('url'), + "relevance": r.get('_relevance', 'unknown'), + "payload": r.get('payload')} for r in results] + } + + +# ==================== GOOGLE OAUTH ==================== +import jwt as _jwt, secrets as _secrets +GOOGLE_CLIENT_ID = "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com" # postavi u .env +ADMIN_EMAILS = { + "damir@rinet.one", "dradulic@outlook.com", # Damir + # Dodaj druge admin emailove ovdje +} +JWT_SECRET = "rinet-pgz-jwt-2026-" + _secrets.token_hex(8) +JWT_ISSUED = [] # in-memory token store (može u Redis) + +@app.post("/api/auth/google") +def google_auth(token: str = Body(..., embed=True)): + """Verify Google ID token and issue JWT for admin/viewer role.""" + try: + import urllib.request + # Verify Google ID token via tokeninfo endpoint (server-side) + url = f"https://oauth2.googleapis.com/tokeninfo?id_token={token}" + with urllib.request.urlopen(url, timeout=10) as r: + data = json.loads(r.read()) + email = data.get("email", "").lower() + verified = data.get("email_verified") == "true" or data.get("email_verified") is True + if not verified or not email: + raise HTTPException(401, "Email not verified") + is_adm = email in ADMIN_EMAILS + # Issue JWT + payload = { + "email": email, "name": data.get("name", email), + "role": "admin" if is_adm else "viewer", + "iat": int(__import__("time").time()), + "exp": int(__import__("time").time()) + 86400 * 7 # 7 dana + } + jwt_token = _jwt.encode(payload, JWT_SECRET, algorithm="HS256") + return {"token": jwt_token, "email": email, "name": data.get("name", email), + "role": payload["role"], "expires_in": 86400 * 7} + except HTTPException: raise + except Exception as e: + raise HTTPException(401, f"Google auth failed: {e}") + +@app.get("/api/auth/me") +def auth_me(authorization: Optional[str] = Header(None)): + """Get current user info from JWT.""" + if not authorization: return {"role": "viewer", "email": None, "name": None} + token = authorization.replace("Bearer ", "").strip() + # Try JWT first + try: + payload = _jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return {"role": payload.get("role"), "email": payload.get("email"), "name": payload.get("name")} + except Exception: + pass + # Legacy demo token + if token == ADMIN_TOKEN: + return {"role": "admin", "email": "demo@admin", "name": "Demo Admin"} + return {"role": "viewer", "email": None, "name": None} + +# ==================== 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} + +@app.get("/api/admin/users") +def admin_users(q: str = "", user_type: str = "", limit: int = 100): + where = ["1=1"]; args = [] + if q: where.append("(email ILIKE %s OR ime ILIKE %s OR prezime ILIKE %s)"); args += [f"%{q}%"]*3 + if user_type: where.append("user_type = %s"); args.append(user_type) + args.append(limit) + with db() as conn: + cur = conn.cursor() + cur.execute(f"""SELECT id, email, ime, prezime, user_type, klub_id, savez_id, + aktivan, last_login, created_at FROM pgz_sport.users + WHERE {' AND '.join(where)} ORDER BY id LIMIT %s""", args) + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + results = [{**dict(zip(cols, r)), + 'last_login': str(dict(zip(cols, r))['last_login']) if dict(zip(cols, r))['last_login'] else None, + 'created_at': str(dict(zip(cols, r))['created_at'])} for r in rows] + return {"count": len(results), "results": results} + +@app.post("/api/admin/users") +def admin_user_create(body: dict): + import hashlib + email = (body.get("email") or "").strip().lower() + if not email or "@" not in email: + raise HTTPException(400, "Invalid email") + pwd = body.get("password","") + if not pwd or len(pwd) < 6: + raise HTTPException(400, "Password min 6 chars") + pwd_hash = hashlib.sha256(pwd.encode()).hexdigest() + with db() as conn: + cur = conn.cursor() + try: + cur.execute("""INSERT INTO pgz_sport.users + (email, password_hash, ime, prezime, user_type, klub_id, savez_id, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,%s,true) RETURNING id""", + (email, pwd_hash, body.get("ime"), body.get("prezime"), + body.get("user_type","klub_user"), body.get("klub_id"), body.get("savez_id"))) + new_id = cur.fetchone()[0] + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, target_text, payload) + VALUES ('user.create','sys_users',%s,%s,%s::jsonb)""", + (new_id, email, json.dumps({"user_type": body.get("user_type")}))) + conn.commit() + return {"id": new_id, "email": email} + except psycopg2.IntegrityError as e: + conn.rollback() + raise HTTPException(400, f"Email već postoji: {email}") + +@app.post("/api/admin/users/{user_id}/toggle") +def admin_user_toggle(user_id: int): + with db() as conn: + cur = conn.cursor() + cur.execute("UPDATE pgz_sport.users SET aktivan = NOT aktivan WHERE id=%s RETURNING aktivan", (user_id,)) + r = cur.fetchone() + if not r: raise HTTPException(404, "User not found") + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, payload) + VALUES ('user.toggle','sys_users',%s,%s::jsonb)""", (user_id, json.dumps({"aktivan": r[0]}))) + conn.commit() + return {"id": user_id, "aktivan": r[0]} + + +# ──────── 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}') + + + + + +@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("/api/sportski-objekti") +def list_sportski_objekti(q=None,tip=None,grad=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR adresa ILIKE %s OR grad ILIKE %s)"); p+=["%"+q+"%"]*3 + if tip: w.append("tip ILIKE %s"); p.append("%"+tip+"%") + if grad: w.append("grad ILIKE %s"); p.append("%"+grad+"%") + rows=fetch("SELECT * FROM pgz_sport.sportski_objekti WHERE "+" AND ".join(w)+" ORDER BY grad,naziv",p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/clanovi-full") +def list_clanovi_full(q=None,hoo=None,reprezentativac=None,klub_id=None,limit=80,authorization=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(ime ILIKE %s OR prezime ILIKE %s OR klub_naziv_godisnjak ILIKE %s)"); p+=["%"+q+"%"]*3 + if hoo: w.append("hoo_kategorija=%s"); p.append(hoo) + if reprezentativac is not None: w.append("reprezentativac="+(("TRUE") if str(reprezentativac).lower()=="true" else "FALSE")) + if klub_id: w.append("klub_id=%s"); p.append(int(klub_id)) + lim=min(int(limit or 80),200) + sql="SELECT id,ime,prezime,oib,datum_rodenja,spol,sport,pozicija,reprezentativac,kategoriziran,stipendiran,kategorija_hoo,hoo_kategorija,aktivan,klub_naziv_godisnjak,slika_url,profile_url,hns_igrac_id,visina_cm,tezina_kg,broj_dresa,uloga,godisnjak_godine,godisnjak_prvi,godisnjak_zadnji,napomena FROM pgz_sport.clanovi WHERE "+" AND ".join(w)+" ORDER BY prezime,ime LIMIT "+str(lim) + rows=fetch(sql,p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/gradovi") +def list_gradovi(): + rows=fetch("SELECT DISTINCT grad FROM pgz_sport.klubovi WHERE aktivan=TRUE AND grad IS NOT NULL AND grad<>'' AND grad NOT SIMILAR TO '[0-9]+%%' ORDER BY grad",[]) + return [r["grad"] for r in rows] + +@app.get("/api/manifestacije-full") +def list_manifestacije_full(q=None,razina=None): + w=["aktivna=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR mjesto ILIKE %s)"); p+=["%"+q+"%"]*2 + rows=fetch("SELECT id,naziv,mjesto,organizator,razina,broj_ucesnika,godina_od,spol_kategorija,napomena,source_url FROM pgz_sport.manifestacije WHERE "+" AND ".join(w)+" ORDER BY naziv",p) + return {"count":len(rows),"rows":rows} + + + +# ── SUFINANCIRANJE-ALL v1.0 dradulic@outlook.com 2026-05-04 +@app.get("/api/sufinanciranje") +def list_sufinanciranje(q=None, godina=None, razina=None, sport=None, limit=500): + w=["iznos_eur > 0"]; p=[] + if q: w.append("(LOWER(korisnik) LIKE %s OR LOWER(sport) LIKE %s)"); p+=[f"%{q.lower()}%"]*2 + if godina: w.append("godina=%s"); p.append(int(godina)) + if razina: w.append("razina ILIKE %s"); p.append(f"%{razina}%") + if sport: w.append("sport ILIKE %s"); p.append(f"%{sport}%") + sql=f"SELECT korisnik,sport,iznos_eur,vrsta,razina,izvor,source_url,godina FROM pgz_sport.sufinanciranje_sport WHERE {' AND '.join(w)} ORDER BY iznos_eur DESC LIMIT {min(int(limit),1000)}" + rows=fetch(sql,p) + total=sum(float(r.get('iznos_eur') or 0) for r in rows) + years=sorted(set(r.get('godina') for r in rows if r.get('godina')),reverse=True) + return {"count":len(rows),"total":total,"years":years,"rows":rows} + + + +# ══════════════════════════════════════════════════════════════════ +# ERP PLATFORM ROUTES v2.0 — dradulic@outlook.com — 2026-05-04 +# ══════════════════════════════════════════════════════════════════ + +import hashlib + +def hash_pwd(pwd): return hashlib.sha256(pwd.encode()).hexdigest() + +def get_user(token): + if not token: return None + try: + payload = _jwt.decode(token.replace("Bearer ",""), JWT_SECRET, algorithms=["HS256"]) + uid = payload.get("uid") + if uid: + rows = fetch("SELECT * FROM pgz_sport.users WHERE id=%s AND aktivan=TRUE", [uid]) + return rows[0] if rows else None + return payload + except: return None + +# ── AUTH: Email/Password login ────────────────────────────────── +@app.post("/api/auth/login") +def login(body: dict = Body(...)): + email = (body.get("email","")).lower().strip() + pwd = body.get("password","") + if not email or not pwd: raise HTTPException(400,"Email i lozinka obavezni") + rows = fetch("SELECT * FROM pgz_sport.users WHERE LOWER(email)=%s AND aktivan=TRUE",[email]) + if not rows: raise HTTPException(401,"Neispravni podaci") + u = rows[0] + ph = hashlib.sha256(pwd.encode()).hexdigest() + if u.get("password_hash") != ph: raise HTTPException(401,"Neispravni podaci") + payload = {"uid":u["id"],"email":email,"name":u.get("full_name",email), + "role":u.get("user_type","viewer"),"klub_id":u.get("klub_id"), + "savez_id":u.get("savez_id"),"iat":int(__import__("time").time()), + "exp":int(__import__("time").time())+86400*7} + tok = _jwt.encode(payload, JWT_SECRET, algorithm="HS256") + try: + with db() as conn: + cur=conn.cursor() + cur.execute("UPDATE pgz_sport.users SET last_login=NOW() WHERE id=%s",[u["id"]]) + conn.commit() + except: pass + return {"token":tok,"role":payload["role"],"name":payload["name"], + "email":email,"klub_id":payload["klub_id"],"savez_id":payload["savez_id"]} + +# ── 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.mount("/static", StaticFiles(directory=str(HTML_DIR)), name="static") + +@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) diff --git a/_backups/pgz_sport_v2_router.py.preauth_v2.1777931444 b/_backups/pgz_sport_v2_router.py.preauth_v2.1777931444 new file mode 100644 index 0000000..9498629 --- /dev/null +++ b/_backups/pgz_sport_v2_router.py.preauth_v2.1777931444 @@ -0,0 +1,5350 @@ +#!/usr/bin/env python3 +""" +pgz_sport_extended_api.py - Multi-tenant + ERP/CRM extension for /api/v1/* +Author: Damir Radulić (damir@rinet.one) +Date: 28.04.2026 +Port: 8095 (mounted under /api/v2/) +Endpoints: auth, users, roles, klub-access, invoices, forms, alerts, expense reports, RAG sport agent +""" +from fastapi import APIRouter, HTTPException, Query, Body, Header, Depends, UploadFile, File, Form +from pydantic import BaseModel +from typing import Optional, List, Dict, Any +from datetime import date, datetime, timedelta +import psycopg2, psycopg2.extras +import hashlib, secrets, json, requests, os, re, time + +DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7') +QDRANT = "http://10.10.0.2:6333" +EMBED = "http://localhost:9879/api/embeddings" +COLL = "pgz_sport_v1" + +router = APIRouter(prefix="/api/v2", tags=["pgz_sport_v2"]) + +# ---------------- DB helpers ---------------- +def db_query(sql: str, params=()): + with psycopg2.connect(**DB) as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(sql, params) + if cur.description: return cur.fetchall() + return [] + +def db_one(sql: str, params=()): + rows = db_query(sql, params) + return rows[0] if rows else None + +def db_exec(sql: str, params=()): + with psycopg2.connect(**DB) as c: + cur = c.cursor() + cur.execute(sql, params) + if cur.description: + r = cur.fetchone() + return r[0] if r else None + c.commit() + +# ---------------- Auth helpers ---------------- +def hash_pw(pw: str) -> str: + return hashlib.sha256(pw.encode()).hexdigest() + +def make_token() -> str: + return secrets.token_urlsafe(32) + +def get_current_user(authorization: Optional[str] = Header(None)) -> Optional[Dict]: + if not authorization: return None + token = authorization.replace('Bearer ','').strip() + th = hashlib.sha256(token.encode()).hexdigest() + s = db_one("""SELECT s.user_id, u.email, u.full_name, u.status, + u.user_type, u.klub_id, u.savez_id, u.aktivan, u.ime, u.prezime, + u.must_change_pwd + FROM pgz_sport.user_sessions s + JOIN pgz_sport.users u ON u.id=s.user_id + WHERE s.token_hash=%s AND s.revoked=false AND s.expires_at>now()""", (th,)) + if not s or not s.get('aktivan', True): return None + s['_token'] = token + return s + +# ═══ RBAC HELPERS ═══ +def is_super(user) -> bool: + return user and user.get('user_type') == 'super_admin' + +def is_pgz_admin(user) -> bool: + return user and user.get('user_type') in ('super_admin', 'pgz_admin') + +def can_manage_users(user) -> bool: + return is_pgz_admin(user) + +def require_role(user, allowed: list): + if not user or user.get('user_type') not in allowed: + raise HTTPException(403, f"Forbidden — required role: {','.join(allowed)}") + +def tenant_filter_users(user) -> tuple: + """Returns (sql_where, params) tuple to scope users list to the user's tenant.""" + ut = user.get('user_type') + if ut == 'super_admin' or ut == 'pgz_admin' or ut == 'pgz_user' or ut == 'pgz_finance' or ut == 'pgz_zzjz': + return ("", []) + if ut == 'savez_admin' or ut == 'savez_user': + sid = user.get('savez_id') + if not sid: return ("AND 1=0", []) + return ("AND (savez_id=%s OR id IN (SELECT user_id FROM pgz_sport.user_klub_links WHERE savez_id=%s))", [sid, sid]) + if ut == 'klub_admin' or ut == 'klub_user': + kid = user.get('klub_id') + if not kid: return ("AND 1=0", []) + return ("AND (klub_id=%s OR id IN (SELECT user_id FROM pgz_sport.user_klub_links WHERE klub_id=%s))", [kid, kid]) + # klub_clan, guest, viewer → only self + return ("AND id=%s", [user['user_id']]) + +def require_user(user = Depends(get_current_user)): + if not user: raise HTTPException(401, "Authentication required") + return user + +def user_has_role(user_id: int, role_code: str, scope_type: str = None, scope_id: int = None) -> bool: + sql = """SELECT 1 FROM pgz_sport.user_roles ur + JOIN pgz_sport.roles r ON r.id=ur.role_id + WHERE ur.user_id=%s AND r.code=%s AND ur.active=true + AND (ur.expires_at IS NULL OR ur.expires_at>now())""" + args = [user_id, role_code] + if scope_type: + sql += " AND (ur.scope_type=%s OR ur.scope_type='global')" + args.append(scope_type) + if scope_id: + sql += " AND (ur.scope_id=%s OR ur.scope_id IS NULL)" + args.append(scope_id) + return bool(db_one(sql, tuple(args))) + +# ============== AUTH ENDPOINTS ============== +class LoginReq(BaseModel): + email: str + password: str + +@router.post("/auth/login") +def login(req: LoginReq): + u = db_one("""SELECT id, email, full_name, password_hash, status, + ime, prezime, user_type, klub_id, savez_id, must_change_pwd, aktivan, + locked_until, failed_login_count + FROM pgz_sport.users WHERE email=%s""", (req.email.lower().strip(),)) + if not u or u['status'] != 'active' or not u.get('aktivan', True): + raise HTTPException(401, "Invalid credentials") + if u.get('locked_until') and u['locked_until'].tzinfo is not None: + from datetime import timezone + if u['locked_until'] > datetime.now(timezone.utc): + raise HTTPException(423, "Korisnik je privremeno zaključan") + if not u['password_hash'] or u['password_hash'] != hash_pw(req.password): + # bump failed counter, lock after 5 + db_exec("""UPDATE pgz_sport.users SET failed_login_count=COALESCE(failed_login_count,0)+1, + locked_until=CASE WHEN COALESCE(failed_login_count,0)+1>=5 THEN now()+interval '15 minutes' ELSE locked_until END + WHERE id=%s""", (u['id'],)) + raise HTTPException(401, "Invalid credentials") + db_exec("UPDATE pgz_sport.users SET failed_login_count=0, locked_until=NULL WHERE id=%s", (u['id'],)) + + token = make_token() + th = hashlib.sha256(token.encode()).hexdigest() + expires = datetime.now() + timedelta(days=30) + db_exec("""INSERT INTO pgz_sport.user_sessions (user_id, token_hash, expires_at) + VALUES (%s,%s,%s)""", (u['id'], th, expires)) + db_exec("UPDATE pgz_sport.users SET last_login=now() WHERE id=%s", (u['id'],)) + db_exec("""INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,'login')""", (u['id'],)) + + roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id + FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id + WHERE ur.user_id=%s AND ur.active=true""", (u['id'],)) + + return { + "token": token, + "expires_at": expires.isoformat(), + "user": { + "id": u['id'], "email": u['email'], "full_name": u['full_name'], + "ime": u.get('ime'), "prezime": u.get('prezime'), + "user_type": u.get('user_type'), "klub_id": u.get('klub_id'), "savez_id": u.get('savez_id'), + "must_change_pwd": bool(u.get('must_change_pwd')), + "roles": roles + } + } + +@router.post("/auth/logout") +def logout(user = Depends(require_user)): + th = hashlib.sha256(user.get('_token','').encode()).hexdigest() + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (user['user_id'],)) + return {"status":"ok"} + +@router.get("/auth/me") +def me(user = Depends(require_user)): + enriched = db_one("""SELECT id, email, full_name, ime, prezime, user_type, + klub_id, savez_id, must_change_pwd, aktivan, status, + last_login, oib, telefon, phone, preferred_language + FROM pgz_sport.users WHERE id=%s""", (user['user_id'],)) + if not enriched: + raise HTTPException(404, "User not found") + roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id + FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id + WHERE ur.user_id=%s AND ur.active=true""", (user['user_id'],)) + klubovi = db_query("""SELECT k.id, k.naziv, ukl.link_type, ukl.primary_klub, ukl.role + FROM pgz_sport.user_klub_links ukl JOIN pgz_sport.klubovi k ON k.id=ukl.klub_id + WHERE ukl.user_id=%s AND (ukl.do_datuma IS NULL OR ukl.do_datuma>now()::date)""", + (user['user_id'],)) + savezi = db_query("""SELECT s.id, s.naziv, ukl.role + FROM pgz_sport.user_klub_links ukl JOIN pgz_sport.savezi s ON s.id=ukl.savez_id + WHERE ukl.user_id=%s AND ukl.savez_id IS NOT NULL""", + (user['user_id'],)) + return {**user, **enriched, "must_change_pwd": bool(enriched.get('must_change_pwd')), + "roles": roles, "klubovi": klubovi, "savezi": savezi} + +class ChangePwdReq(BaseModel): + new_password: str + old_password: Optional[str] = None + +@router.post("/auth/change-password") +def change_password(req: ChangePwdReq, user = Depends(require_user)): + if len(req.new_password) < 8: + raise HTTPException(400, "Password mora imati barem 8 znakova") + u = db_one("SELECT password_hash, must_change_pwd FROM pgz_sport.users WHERE id=%s", (user['user_id'],)) + if not u: raise HTTPException(404, "User not found") + # If not in must_change_pwd flow, require old password + if not u.get('must_change_pwd'): + if not req.old_password: + raise HTTPException(400, "old_password required") + if u['password_hash'] != hash_pw(req.old_password): + raise HTTPException(401, "Stara lozinka netočna") + new_hash = hash_pw(req.new_password) + db_exec("""UPDATE pgz_sport.users + SET password_hash=%s, must_change_pwd=false, updated_at=now() + WHERE id=%s""", (new_hash, user['user_id'])) + db_exec("""INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,'password.change')""", + (user['user_id'],)) + return {"status":"ok"} + + + +# ═══ ADMIN USER MGMT — extended ═══ + +@router.get("/users/list") +def list_users_v2( + q: Optional[str] = None, + user_type: Optional[str] = None, + klub_id: Optional[int] = None, + savez_id: Optional[int] = None, + aktivan: Optional[bool] = None, + limit: int = 100, + offset: int = 0, + user = Depends(require_user) +): + """List users with tenant filter applied automatically based on caller's role.""" + where = ["1=1"] + args = [] + tf, tp = tenant_filter_users(user) + if tf: + where.append(tf.replace('AND ', '')) + args.extend(tp) + if q: + where.append("(LOWER(email) LIKE %s OR LOWER(ime) LIKE %s OR LOWER(prezime) LIKE %s OR LOWER(full_name) LIKE %s)") + args.extend([f"%{q.lower()}%"]*4) + if user_type: where.append("user_type=%s"); args.append(user_type) + if klub_id: where.append("klub_id=%s"); args.append(klub_id) + if savez_id: where.append("savez_id=%s"); args.append(savez_id) + if aktivan is not None: where.append("aktivan=%s"); args.append(aktivan) + + sql = f"""SELECT id, email, ime, prezime, full_name, user_type, klub_id, savez_id, + aktivan, must_change_pwd, last_login, locked_until, failed_login_count, + telefon, oib, status, created_at + FROM pgz_sport.users WHERE {" AND ".join(where)} + ORDER BY id LIMIT %s OFFSET %s""" + args.extend([limit, offset]) + rows = db_query(sql, tuple(args)) + + cnt_sql = f"SELECT COUNT(*) AS c FROM pgz_sport.users WHERE {" AND ".join(where)}" + total = db_one(cnt_sql, tuple(args[:-2]))['c'] + + return {"count": len(rows), "total": total, "results": rows} + +class CreateUserV2Req(BaseModel): + email: str + ime: Optional[str] = None + prezime: Optional[str] = None + user_type: str = 'klub_user' + klub_id: Optional[int] = None + savez_id: Optional[int] = None + telefon: Optional[str] = None + oib: Optional[str] = None + password: Optional[str] = None # if not provided, default 'PgzSport2026!' + must_change_pwd + +@router.post("/users/create") +def create_user_v2(req: CreateUserV2Req, user = Depends(require_user)): + require_role(user, ['super_admin','pgz_admin']) + pwd = req.password or 'PgzSport2026!' + must_change = not bool(req.password) + full_name = (req.ime or '') + ' ' + (req.prezime or '') + full_name = full_name.strip() or req.email + try: + new_id = db_one("""INSERT INTO pgz_sport.users + (email, password_hash, ime, prezime, full_name, user_type, klub_id, savez_id, + telefon, oib, must_change_pwd, aktivan, status, auth_provider, created_by) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,true,'active','local',%s) + RETURNING id""", + (req.email.lower().strip(), hash_pw(pwd), req.ime, req.prezime, full_name, + req.user_type, req.klub_id, req.savez_id, req.telefon, req.oib, must_change, + user['user_id']))['id'] + db_exec("INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,%s)", + (user['user_id'], f'user.create:{new_id}')) + return {"id": new_id, "email": req.email, "must_change_pwd": must_change, + "temporary_password": pwd if must_change else None} + except Exception as e: + if 'duplicate' in str(e).lower() or 'unique' in str(e).lower(): + raise HTTPException(409, f"Email već postoji: {req.email}") + raise HTTPException(400, str(e)) + +class EditUserReq(BaseModel): + ime: Optional[str] = None + prezime: Optional[str] = None + user_type: Optional[str] = None + klub_id: Optional[int] = None + savez_id: Optional[int] = None + telefon: Optional[str] = None + oib: Optional[str] = None + aktivan: Optional[bool] = None + +@router.put("/users/{uid}") +def edit_user(uid: int, req: EditUserReq, user = Depends(require_user)): + # Self-edit allowed for limited fields, admin-edit for all + self_edit = uid == user['user_id'] + if not self_edit and not is_pgz_admin(user): + raise HTTPException(403, "Forbidden") + fields = [] + args = [] + allowed_self = {'ime','prezime','telefon'} + for f in ['ime','prezime','user_type','klub_id','savez_id','telefon','oib','aktivan']: + v = getattr(req, f) + if v is not None: + if self_edit and f not in allowed_self and not is_pgz_admin(user): + continue + fields.append(f"{f}=%s") + args.append(v) + if not fields: return {"status":"nothing to update"} + if 'ime' in [f.split('=')[0] for f in fields] or 'prezime' in [f.split('=')[0] for f in fields]: + # rebuild full_name + cur = db_one("SELECT ime, prezime FROM pgz_sport.users WHERE id=%s", (uid,)) + new_ime = req.ime if req.ime is not None else (cur['ime'] if cur else '') + new_prez = req.prezime if req.prezime is not None else (cur['prezime'] if cur else '') + fn = ((new_ime or '') + ' ' + (new_prez or '')).strip() + fields.append("full_name=%s"); args.append(fn) + fields.append("updated_at=now()") + args.append(uid) + db_exec(f"UPDATE pgz_sport.users SET {', '.join(fields)} WHERE id=%s", tuple(args)) + db_exec("INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,%s)", + (user['user_id'], f'user.edit:{uid}')) + return {"status":"ok", "id": uid} + +@router.post("/users/{uid}/reset-password") +def admin_reset_password(uid: int, user = Depends(require_user)): + require_role(user, ['super_admin','pgz_admin']) + new_temp = 'PgzSport' + secrets.token_hex(3) # e.g. PgzSporta3f2c1 + db_exec("""UPDATE pgz_sport.users SET password_hash=%s, must_change_pwd=true, + failed_login_count=0, locked_until=NULL, updated_at=now() WHERE id=%s""", + (hash_pw(new_temp), uid)) + db_exec("INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,%s)", + (user['user_id'], f'user.reset-pwd:{uid}')) + # Revoke all active sessions + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,)) + return {"status":"ok", "temporary_password": new_temp} + +@router.post("/users/{uid}/toggle-active") +def admin_toggle_active(uid: int, user = Depends(require_user)): + require_role(user, ['super_admin','pgz_admin']) + r = db_one("UPDATE pgz_sport.users SET aktivan=NOT aktivan WHERE id=%s RETURNING aktivan", (uid,)) + if not r: raise HTTPException(404, "User not found") + if not r['aktivan']: + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,)) + db_exec("INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,%s)", + (user['user_id'], f'user.toggle:{uid}:{r["aktivan"]}')) + return {"id": uid, "aktivan": r['aktivan']} + +@router.post("/users/{uid}/unlock") +def admin_unlock(uid: int, user = Depends(require_user)): + require_role(user, ['super_admin','pgz_admin']) + db_exec("UPDATE pgz_sport.users SET failed_login_count=0, locked_until=NULL WHERE id=%s", (uid,)) + return {"status":"ok"} + +@router.get("/users/{uid}/audit") +def user_audit(uid: int, limit: int = 50, user = Depends(require_user)): + require_role(user, ['super_admin','pgz_admin']) + rows = db_query("""SELECT id, action, user_id, ts AS created_at, meta AS payload, + resource_type, resource_id, ip_address + FROM pgz_sport.audit_events + WHERE user_id=%s OR meta::text LIKE %s + ORDER BY id DESC LIMIT %s""", (uid, f'%"user_id":{uid}%', limit)) + return {"count": len(rows), "results": rows} + +@router.get("/admin/audit") +def global_audit(action: Optional[str]=None, user_id: Optional[int]=None, + limit: int=100, offset: int=0, user = Depends(require_user)): + require_role(user, ['super_admin','pgz_admin']) + where = ["1=1"]; 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.extend([limit, offset]) + rows = db_query(f"""SELECT a.id, a.action, a.user_id, + a.ts AS created_at, a.meta AS payload, + a.resource_type, a.resource_id, a.ip_address, + u.email, u.ime, u.prezime + FROM pgz_sport.audit_events a + LEFT JOIN pgz_sport.users u ON u.id=a.user_id + WHERE {" AND ".join(where)} + ORDER BY a.id DESC LIMIT %s OFFSET %s""", tuple(args)) + return {"count": len(rows), "results": rows} + +@router.get("/admin/permissions-matrix") +def perms_matrix(user = Depends(require_user)): + """Returns roles with their JSONB permissions, for the editor UI.""" + require_role(user, ['super_admin','pgz_admin']) + roles = db_query("SELECT id, code, naziv, opis, permissions FROM pgz_sport.roles ORDER BY id") + user_overrides = db_query("""SELECT user_id, permission_code, granted, granted_at + FROM pgz_sport.user_permissions ORDER BY user_id, permission_code""") + return {"roles": roles, "user_overrides": user_overrides} + +class PermissionGrantReq(BaseModel): + user_id: int + permission_code: str + granted: bool = True + note: Optional[str] = None + +@router.post("/admin/permissions/grant") +def grant_permission(req: PermissionGrantReq, user = Depends(require_user)): + require_role(user, ['super_admin','pgz_admin']) + db_exec("""INSERT INTO pgz_sport.user_permissions (user_id, permission_code, granted, granted_by, note) + VALUES (%s,%s,%s,%s,%s) + ON CONFLICT (user_id, permission_code) DO UPDATE SET + granted=EXCLUDED.granted, granted_by=EXCLUDED.granted_by, + granted_at=now(), note=EXCLUDED.note""", + (req.user_id, req.permission_code, req.granted, user['user_id'], req.note)) + return {"status":"ok"} + +# ═══ KLUB-LINK CRUD ═══ +class KlubLinkReq(BaseModel): + user_id: int + klub_id: Optional[int] = None + savez_id: Optional[int] = None + role: str = 'membership' + primary_klub: bool = False + +@router.post("/users/klub-link") +def klub_link_create(req: KlubLinkReq, user = Depends(require_user)): + require_role(user, ['super_admin','pgz_admin','savez_admin','klub_admin']) + if not req.klub_id and not req.savez_id: + raise HTTPException(400, "klub_id or savez_id required") + new_id = db_one("""INSERT INTO pgz_sport.user_klub_links + (user_id, klub_id, savez_id, role, link_type, primary_klub, granted_by, granted_at) + VALUES (%s,%s,%s,%s,%s,%s,%s,now()) + ON CONFLICT (user_id, klub_id, link_type, od_datuma) DO UPDATE SET + role=EXCLUDED.role, primary_klub=EXCLUDED.primary_klub, granted_at=now() + RETURNING id""", + (req.user_id, req.klub_id, req.savez_id, req.role, req.role, + req.primary_klub, user['user_id']))['id'] + return {"id": new_id} + +@router.delete("/users/klub-link/{lid}") +def klub_link_delete(lid: int, user = Depends(require_user)): + require_role(user, ['super_admin','pgz_admin','savez_admin','klub_admin']) + db_exec("DELETE FROM pgz_sport.user_klub_links WHERE id=%s", (lid,)) + return {"status":"ok"} + +# ═══ IMPERSONATE (super_admin only) ═══ +class ImpersonateReq(BaseModel): + target_user_id: int + +@router.post("/admin/impersonate") +def impersonate(req: ImpersonateReq, user = Depends(require_user)): + require_role(user, ['super_admin']) + target = db_one("SELECT id, email, full_name FROM pgz_sport.users WHERE id=%s AND aktivan=true", + (req.target_user_id,)) + if not target: raise HTTPException(404, "Target user not found or inactive") + # Issue a session token for target user, with audit tag + token = make_token() + th = hashlib.sha256(token.encode()).hexdigest() + expires = datetime.now() + timedelta(hours=2) # short-lived impersonation + db_exec("""INSERT INTO pgz_sport.user_sessions (user_id, token_hash, expires_at) + VALUES (%s,%s,%s)""", (req.target_user_id, th, expires)) + db_exec("""INSERT INTO pgz_sport.audit_events (user_id, action) + VALUES (%s,%s)""", + (user['user_id'], f'admin.impersonate:{req.target_user_id}')) + return {"token": token, "expires_at": expires.isoformat(), "as_user": target, + "impersonated_by": user['email']} + +# ============== USER MANAGEMENT (super_admin / pgz_admin) ============== +class CreateUserReq(BaseModel): + email: str + full_name: str + password: Optional[str] = None + oib: Optional[str] = None + phone: Optional[str] = None + role_code: str = 'klub_user' + scope_type: Optional[str] = None + scope_id: Optional[int] = None + +@router.post("/users") +def create_user(req: CreateUserReq, user = Depends(require_user)): + if not (user_has_role(user['user_id'],'super_admin') or + user_has_role(user['user_id'],'pgz_admin') or + user_has_role(user['user_id'],'klub_admin', 'klub', req.scope_id)): + raise HTTPException(403, "Forbidden") + + pw_hash = hash_pw(req.password) if req.password else None + uid = db_exec("""INSERT INTO pgz_sport.users + (email, full_name, oib, phone, password_hash, status, email_verified) + VALUES (%s,%s,%s,%s,%s,'active',false) RETURNING id""", + (req.email.lower().strip(), req.full_name, req.oib, req.phone, pw_hash)) + + role_id = db_one("SELECT id FROM pgz_sport.roles WHERE code=%s", (req.role_code,)) + if role_id: + db_exec("""INSERT INTO pgz_sport.user_roles (user_id, role_id, scope_type, scope_id, granted_by) + VALUES (%s,%s,%s,%s,%s)""", (uid, role_id['id'], req.scope_type, req.scope_id, user['user_id'])) + + db_exec("""INSERT INTO pgz_sport.audit_events (user_id, action, resource_type, resource_id) + VALUES (%s,'create_user','user',%s)""", (user['user_id'], uid)) + return {"id": uid, "email": req.email} + +@router.get("/users") +def list_users(klub_id: Optional[int]=None, role: Optional[str]=None, limit:int=100, user = Depends(require_user)): + where, args = ["1=1"], [] + if klub_id: + where.append("EXISTS (SELECT 1 FROM pgz_sport.user_klub_links ukl WHERE ukl.user_id=u.id AND ukl.klub_id=%s)") + args.append(klub_id) + if role: + where.append("""EXISTS (SELECT 1 FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id + WHERE ur.user_id=u.id AND r.code=%s AND ur.active=true)""") + args.append(role) + args.append(limit) + return db_query(f"""SELECT u.id, u.email, u.full_name, u.status, u.last_login, + (SELECT array_agg(r.code) FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id + WHERE ur.user_id=u.id AND ur.active=true) AS roles + FROM pgz_sport.users u WHERE {" AND ".join(where)} ORDER BY u.id LIMIT %s""", args) + +class GrantRoleReq(BaseModel): + user_id: int + role_code: str + scope_type: Optional[str] = None + scope_id: Optional[int] = None + expires_at: Optional[datetime] = None + +@router.post("/users/grant-role") +def grant_role(req: GrantRoleReq, user = Depends(require_user)): + if not user_has_role(user['user_id'],'super_admin'): + if not user_has_role(user['user_id'],'pgz_admin'): + raise HTTPException(403, "Only super_admin or pgz_admin can grant roles") + role = db_one("SELECT id FROM pgz_sport.roles WHERE code=%s", (req.role_code,)) + if not role: raise HTTPException(404, "Unknown role") + db_exec("""INSERT INTO pgz_sport.user_roles + (user_id, role_id, scope_type, scope_id, granted_by, expires_at) + VALUES (%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""", + (req.user_id, role['id'], req.scope_type, req.scope_id, user['user_id'], req.expires_at)) + db_exec("""INSERT INTO pgz_sport.audit_events + (user_id, action, resource_type, resource_id, meta) + VALUES (%s,'grant_role','user',%s,%s::jsonb)""", + (user['user_id'], req.user_id, json.dumps({'role':req.role_code,'scope':req.scope_type}))) + return {"status":"ok"} + +@router.get("/roles") +def list_roles(): + return db_query("SELECT id, code, naziv, opis, permissions FROM pgz_sport.roles ORDER BY id") + +# ============== KLUB MEMBERSHIP LINKS ============== +class LinkUserKlubReq(BaseModel): + user_id: int + klub_id: int + clan_id: Optional[int] = None + link_type: str # 'sportas','trener','tajnik','predsjednik','clan_uprave','volonter' + od_datuma: Optional[date] = None + primary_klub: bool = True + napomena: Optional[str] = None + +@router.post("/klub-links") +def link_user_klub(req: LinkUserKlubReq, user = Depends(require_user)): + if not (user_has_role(user['user_id'],'super_admin') or + user_has_role(user['user_id'],'pgz_admin') or + user_has_role(user['user_id'],'klub_admin','klub', req.klub_id)): + raise HTTPException(403, "Forbidden") + db_exec("""INSERT INTO pgz_sport.user_klub_links + (user_id, klub_id, clan_id, link_type, od_datuma, primary_klub, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""", + (req.user_id, req.klub_id, req.clan_id, req.link_type, + req.od_datuma or date.today(), req.primary_klub, req.napomena)) + return {"status":"ok"} + +# ============== INVOICES (ERP) ============== +class CreateInvoiceReq(BaseModel): + klub_id: int + invoice_kind: str # 'ulazni'|'izlazni' + invoice_no: str + vendor_name: Optional[str] = None + vendor_oib: Optional[str] = None + customer_name: Optional[str] = None + customer_oib: Optional[str] = None + invoice_date: date + due_date: Optional[date] = None + amount_net: Optional[float] = None + amount_vat: Optional[float] = None + amount_gross: float + vat_rate: Optional[float] = 25 + description: Optional[str] = None + category: Optional[str] = None + account_code: Optional[str] = None + +@router.post("/invoices") +def create_invoice(req: CreateInvoiceReq, user = Depends(require_user)): + if not (user_has_role(user['user_id'],'super_admin') or + user_has_role(user['user_id'],'klub_admin','klub',req.klub_id)): + raise HTTPException(403, "Forbidden") + iid = db_exec("""INSERT INTO pgz_sport.invoices + (klub_id, invoice_kind, invoice_no, vendor_name, vendor_oib, customer_name, customer_oib, + invoice_date, due_date, amount_net, amount_vat, amount_gross, vat_rate, + description, category, account_code, created_by) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + RETURNING id""", (req.klub_id, req.invoice_kind, req.invoice_no, + req.vendor_name, req.vendor_oib, req.customer_name, req.customer_oib, + req.invoice_date, req.due_date, req.amount_net, req.amount_vat, req.amount_gross, + req.vat_rate, req.description, req.category, req.account_code, user['user_id'])) + db_exec("""INSERT INTO pgz_sport.audit_events + (user_id, action, resource_type, resource_id) + VALUES (%s,'create_invoice','invoice',%s)""", (user['user_id'], iid)) + return {"id": iid} + +@router.get("/invoices") +def list_invoices(klub_id: Optional[int]=None, kind: Optional[str]=None, + status: Optional[str]=None, year: Optional[int]=None, + limit:int=200, user=Depends(require_user)): + where, args = ["1=1"], [] + if klub_id: where.append("klub_id=%s"); args.append(klub_id) + if kind: where.append("invoice_kind=%s"); args.append(kind) + if status: where.append("payment_status=%s"); args.append(status) + if year: where.append("EXTRACT(year FROM invoice_date)=%s"); args.append(year) + args.append(limit) + return db_query(f"""SELECT id, invoice_no, invoice_kind, vendor_name, customer_name, + invoice_date, due_date, amount_gross, currency, payment_status, category, klub_id + FROM pgz_sport.invoices WHERE {" AND ".join(where)} + ORDER BY invoice_date DESC LIMIT %s""", args) + +@router.get("/invoices/{invoice_id}") +def get_invoice(invoice_id: int, user=Depends(require_user)): + inv = db_one("SELECT * FROM pgz_sport.invoices WHERE id=%s", (invoice_id,)) + if not inv: raise HTTPException(404) + inv['lines'] = db_query("""SELECT * FROM pgz_sport.invoice_lines + WHERE invoice_id=%s ORDER BY line_no""", (invoice_id,)) + inv['payments'] = db_query("""SELECT * FROM pgz_sport.payments + WHERE invoice_id=%s ORDER BY payment_date""", (invoice_id,)) + return inv + +# ============== INVOICE OCR UPLOAD ============== +class OcrUploadReq(BaseModel): + klub_id: int + file_name: str + file_path: str + file_size: Optional[int] = None + mime: Optional[str] = None + sha256: Optional[str] = None + +@router.post("/invoice-uploads") +def create_upload(req: OcrUploadReq, user = Depends(require_user)): + if not (user_has_role(user['user_id'],'super_admin') or + user_has_role(user['user_id'],'klub_admin','klub',req.klub_id) or + user_has_role(user['user_id'],'klub_user','klub',req.klub_id)): + raise HTTPException(403, "Forbidden") + uid = db_exec("""INSERT INTO pgz_sport.invoice_uploads + (klub_id, uploaded_by, file_name, file_path, file_size, mime, sha256) + VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING id""", + (req.klub_id, user['user_id'], req.file_name, req.file_path, + req.file_size, req.mime, req.sha256)) + return {"id":uid, "status":"queued_for_ocr"} + +@router.get("/invoice-uploads") +def list_uploads(klub_id: Optional[int]=None, status: Optional[str]=None, limit:int=100): + where, args = ["1=1"], [] + if klub_id: where.append("klub_id=%s"); args.append(klub_id) + if status: where.append("ocr_status=%s"); args.append(status) + args.append(limit) + return db_query(f"""SELECT id, klub_id, file_name, ocr_status, ai_invoice_no, + ai_amount_gross, ai_vendor_name, ai_invoice_date, uploaded_at, processed_at + FROM pgz_sport.invoice_uploads WHERE {" AND ".join(where)} + ORDER BY uploaded_at DESC LIMIT %s""", args) + +# ============== EXPENSE REPORTS ============== +class ExpenseReportReq(BaseModel): + klub_id: int + user_id: Optional[int] = None + clan_id: Optional[int] = None + report_type: str # 'putni_nalog','putni_trosak','dnevnice','vlastiti_auto' + destination: Optional[str] = None + purpose: Optional[str] = None + date_from: date + date_to: date + vehicle_type: Optional[str] = None + km_driven: Optional[float] = None + cost_transport: float = 0 + cost_lodging: float = 0 + cost_meals: float = 0 + cost_other: float = 0 + dnevnice_count: int = 0 + notes: Optional[str] = None + +@router.post("/expense-reports") +def create_expense(req: ExpenseReportReq, user = Depends(require_user)): + cost_total = (req.cost_transport + req.cost_lodging + req.cost_meals + req.cost_other + + (req.km_driven or 0)*0.42 + req.dnevnice_count*30) + eid = db_exec("""INSERT INTO pgz_sport.expense_reports + (klub_id, user_id, clan_id, report_type, destination, purpose, date_from, date_to, + vehicle_type, km_driven, cost_transport, cost_lodging, cost_meals, cost_other, + cost_total, dnevnice_count, notes) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + RETURNING id""", (req.klub_id, req.user_id or user['user_id'], req.clan_id, + req.report_type, req.destination, req.purpose, req.date_from, req.date_to, + req.vehicle_type, req.km_driven, req.cost_transport, req.cost_lodging, + req.cost_meals, req.cost_other, cost_total, req.dnevnice_count, req.notes)) + return {"id": eid, "cost_total": float(cost_total)} + +@router.get("/expense-reports") +def list_expenses(klub_id: Optional[int]=None, status: Optional[str]=None, limit:int=100): + where, args = ["1=1"], [] + if klub_id: where.append("klub_id=%s"); args.append(klub_id) + if status: where.append("status=%s"); args.append(status) + args.append(limit) + return db_query(f"""SELECT * FROM pgz_sport.expense_reports + WHERE {" AND ".join(where)} ORDER BY created_at DESC LIMIT %s""", args) + +# ============== FORMS ============== +@router.get("/forms/templates") +def list_form_templates(kategorija: Optional[str]=None): + where, args = ["active=true"], [] + if kategorija: where.append("kategorija=%s"); args.append(kategorija) + return db_query(f"""SELECT id, code, naziv, kategorija, opis, schema_json, required_role + FROM pgz_sport.form_templates WHERE {" AND ".join(where)} ORDER BY kategorija, naziv""", args) + +@router.get("/forms/templates/{code}") +def get_form_template(code: str): + t = db_one("SELECT * FROM pgz_sport.form_templates WHERE code=%s AND active=true", (code,)) + if not t: raise HTTPException(404) + return t + +class SubmitFormReq(BaseModel): + template_code: str + klub_id: int + clan_id: Optional[int] = None + data: Dict[str, Any] + submit: bool = False # True = submit, False = save draft + +@router.post("/forms/submit") +def submit_form(req: SubmitFormReq, user = Depends(require_user)): + t = db_one("SELECT id FROM pgz_sport.form_templates WHERE code=%s", (req.template_code,)) + if not t: raise HTTPException(404, "Unknown template") + sid = db_exec("""INSERT INTO pgz_sport.form_submissions + (template_id, template_code, klub_id, user_id, clan_id, data, status, submitted_at) + VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s,%s) RETURNING id""", + (t['id'], req.template_code, req.klub_id, user['user_id'], req.clan_id, + json.dumps(req.data), 'submitted' if req.submit else 'draft', + datetime.now() if req.submit else None)) + db_exec("""INSERT INTO pgz_sport.audit_events + (user_id, action, resource_type, resource_id, meta) + VALUES (%s,'submit_form','form',%s,%s::jsonb)""", + (user['user_id'], sid, json.dumps({'template':req.template_code}))) + return {"id": sid} + +@router.get("/forms/submissions") +def list_submissions(klub_id: Optional[int]=None, template_code: Optional[str]=None, + status: Optional[str]=None, limit:int=100): + where, args = ["1=1"], [] + if klub_id: where.append("klub_id=%s"); args.append(klub_id) + if template_code: where.append("template_code=%s"); args.append(template_code) + if status: where.append("status=%s"); args.append(status) + args.append(limit) + return db_query(f"""SELECT id, template_code, klub_id, user_id, clan_id, status, + submitted_at, approved_at, reference_no + FROM pgz_sport.form_submissions WHERE {" AND ".join(where)} + ORDER BY created_at DESC LIMIT %s""", args) + +# ============== ALERTS ============== +@router.get("/alerts") +def list_alerts(klub_id: Optional[int]=None, severity: Optional[str]=None, + resolved: bool=False, limit:int=100): + where, args = ["rijeseno=%s"], [resolved] + if klub_id: where.append("klub_id=%s"); args.append(klub_id) + if severity: where.append("razina=%s"); args.append(severity.upper()) + args.append(limit) + return db_query(f"""SELECT id, tip, razina, poruka, klub_id, clan_id, due_date, + iznos, datum, rijeseno, created_at + FROM pgz_sport.alertovi WHERE {" AND ".join(where)} + ORDER BY + CASE razina WHEN 'CRITICAL' THEN 1 WHEN 'WARNING' THEN 2 ELSE 3 END, + due_date NULLS LAST LIMIT %s""", args) + +@router.post("/alerts/{alert_id}/resolve") +def resolve_alert(alert_id: int, user = Depends(require_user)): + db_exec("""UPDATE pgz_sport.alertovi SET rijeseno=true, rijeseno_at=now(), rijeseno_od=%s + WHERE id=%s""", (user['user_id'], alert_id)) + return {"status":"ok"} + +@router.post("/alerts/scan") +def trigger_scan(user = Depends(require_user)): + """Run alert rules now.""" + if not (user_has_role(user['user_id'],'super_admin') or user_has_role(user['user_id'],'pgz_admin')): + raise HTTPException(403) + return {"status":"scan_queued","note":"Scan triggers will be honoured on next cron tick"} + +# ============== CLUB DASHBOARD ============== +@router.get("/klub/{klub_id}/dashboard") +def klub_dashboard(klub_id: int): + klub = db_one("SELECT * FROM pgz_sport.v_klub_full WHERE id=%s", (klub_id,)) + if not klub: raise HTTPException(404) + return { + "klub": klub, + "clanovi_count": db_one("SELECT COUNT(*) AS n FROM pgz_sport.clanovi WHERE klub_id=%s AND aktivan=true", (klub_id,)), + "lijecnicki_isteka_30d": db_one("""SELECT COUNT(*) AS n FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s AND lp.vrijedi_do BETWEEN now()::date AND now()::date+interval '30 days'""", (klub_id,)), + "alerts_open": db_query("""SELECT id, tip, razina, poruka, due_date FROM pgz_sport.alertovi + WHERE klub_id=%s AND rijeseno=false ORDER BY + CASE razina WHEN 'CRITICAL' THEN 1 WHEN 'WARNING' THEN 2 ELSE 3 END LIMIT 10""", (klub_id,)), + "invoices_unpaid": db_one("""SELECT COUNT(*) AS n, COALESCE(SUM(amount_gross),0) AS sum_eur + FROM pgz_sport.invoices WHERE klub_id=%s AND payment_status='unpaid'""", (klub_id,)), + "clanarine_status": db_one("""SELECT + SUM(CASE WHEN status='podmireno' THEN 1 ELSE 0 END) AS placena, + SUM(CASE WHEN status='nepodmireno' THEN 1 ELSE 0 END) AS neplacena, + SUM(CASE WHEN status='djelomicno' THEN 1 ELSE 0 END) AS djelomicno, + COALESCE(SUM(iznos_propisan-COALESCE(iznos_placen,0)),0) AS dug_total + FROM pgz_sport.clanarine WHERE klub_id=%s AND godina=EXTRACT(year FROM now())::int""", (klub_id,)), + "form_drafts": db_query("""SELECT id, template_code, status, updated_at + FROM pgz_sport.form_submissions WHERE klub_id=%s AND status='draft' + ORDER BY updated_at DESC LIMIT 5""", (klub_id,)), + } + +# ============== AI RAG SPORT AGENT ============== +class AskReq(BaseModel): + query: str + limit: int = 5 + +@router.post("/sport/ask") +def sport_ask(req: AskReq): + """RAG over pgz_sport_v1 — return relevant context for an LLM. + POST-PROCESS: resolve deleted clan_id/klub_id to canonical via DB lookup.""" + try: + r = requests.post(EMBED, json={"input":[req.query, req.query]}, timeout=30) + j = r.json() + emb = j.get('embeddings', [j.get('embedding')])[0] + except Exception as e: + raise HTTPException(503, f"Embedder unavailable: {e}") + + # Fetch MORE results (limit*3) so we can dedupe and resolve + r = requests.post(f"{QDRANT}/collections/{COLL}/points/search", + json={"vector": emb, "limit": req.limit * 3, "with_payload": True}, timeout=30) + if r.status_code >= 400: raise HTTPException(503, f"Qdrant: {r.text[:200]}") + hits = r.json()['result'] + + # Resolve deleted IDs to canonical via DB + valid_clan_ids = set(r['id'] for r in db_query("SELECT id FROM pgz_sport.clanovi")) + valid_klub_ids = set(r['id'] for r in db_query("SELECT id FROM pgz_sport.klubovi")) + + seen_canon = set() + out_results = [] + for h in hits: + pl = h['payload'] + tip = pl.get('tip', pl.get('type')) + cid = pl.get('clan_id') + kid = pl.get('klub_id') + naziv = pl.get('naziv') or pl.get('title') or '?' + + # If clan_id deleted, try to resolve by name + if tip == 'clan' and cid and cid not in valid_clan_ids: + parts = naziv.split() + if len(parts) >= 2: + ime = parts[0] + prezime = ' '.join(parts[1:]) + resolved = db_one("""SELECT c.id, c.klub_id, k.naziv AS klub + FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE LOWER(c.ime)=LOWER(%s) AND LOWER(c.prezime)=LOWER(%s) + ORDER BY (c.slika_url IS NOT NULL) DESC, c.id ASC LIMIT 1""", (ime, prezime)) + if resolved: + cid = resolved['id'] + kid = resolved['klub_id'] + pl = {**pl, 'clan_id': cid, 'klub_id': kid, 'klub': resolved['klub']} + else: + continue # skip - not findable + + # Skip klub points if klub deleted + if tip == 'klub' and kid and kid not in valid_klub_ids: + continue + + # Dedup by canonical clan_id (or klub_id) + canon_key = ('clan', cid) if tip == 'clan' else ('klub', kid) if tip == 'klub' else (tip, naziv) + if canon_key in seen_canon: + continue + seen_canon.add(canon_key) + + out_results.append({ + "score": h['score'], + "type": tip, + "title": naziv, + "snippet": (pl.get('tekst') or '')[:500], + "payload": {k:v for k,v in pl.items() if k != 'tekst'} + }) + if len(out_results) >= req.limit: + break + + return {"query": req.query, "results": out_results} + +# ============== CALENDAR / SCHEDULE ============== + +@router.post("/sport/lawyer") +def sport_lawyer(payload: dict): + """ + AI Pravnik — odgovara na sve pravne, regulatorne i proceduralne nedoumice + iz pravilnika HOO, MINT, županije i klubova. Koristi RAG + DeepSeek/Groq LLM. + """ + q = (payload.get("query") or payload.get("question") or "").strip() + if not q: + raise HTTPException(400, "query je prazan") + + # OS-FIRST: delegate to orchestrator if env flag set + if USE_ORCHESTRATOR: + result = delegate_to_orchestrator(q, persona="sport") + if result.get("delegated"): + return { + "query": q, + "answer": result["answer"], + "sources": result.get("sources", []), + "llm": result["llm"], + "hits_count": len(result.get("sources", [])), + "via": "orchestrator", + } + # Fallback to local waterfall if orchestrator failed + + # 1) RAG v2 — Fetch 25 candidates → dedup → top 6 unique docs + import requests as _requests + try: + emb_r = _requests.post( + "http://localhost:9879/api/embeddings", + json={"input": [q]}, timeout=20 + ) + if not emb_r.ok: + raise HTTPException(500, f"embed failed: {emb_r.status_code}") + _r = emb_r.json(); emb = _r.get("embedding") or _r.get("embeddings",[None])[0] + except Exception as e: + raise HTTPException(500, f"embedding error: {e}") + + # Fetch 25 candidates (will dedup) + try: + qr = _requests.post( + "http://10.10.0.2:6333/collections/pgz_sport_v1/points/search", + json={"vector": emb, "limit": 25, "with_payload": True, "score_threshold": 0.35}, + timeout=20 + ) + if not qr.ok: + raise HTTPException(500, f"qdrant http {qr.status_code}: {qr.text[:200]}") + all_hits = qr.json().get("result", []) + except HTTPException: raise + except Exception as e: + raise HTTPException(500, f"qdrant error: {e}") + + if not all_hits: + return {"query": q, "answer": "Nema relevantnih pravilnika u bazi za ovaj upit. Probaj preformulirati pitanje konkretnije, npr. uključi specifičan sport, godinu, ili tip dokumenta (pravilnik, zakon, kriterij).", "sources": []} + + # 2) Dedup by document — keep top chunk per unique doc_id/title (not all chunks of same doc) + seen_docs = {} # key = doc_id or normalized title + for h in all_hits: + p = h.get("payload") or {} + # Normalize doc identity + doc_key = p.get("doc_id") or p.get("source_url") or p.get("title", "?") + if doc_key not in seen_docs or h.get("score", 0) > seen_docs[doc_key].get("score", 0): + seen_docs[doc_key] = h + + # Sort by score, take top 6 unique docs + unique_hits = sorted(seen_docs.values(), key=lambda x: x.get("score", 0), reverse=True)[:6] + hits = unique_hits + + # 3) PGŽ-relevance boost: prefer PGŽ-specific docs over general national + PGZ_KW = ['pgž','pgz','primorsk','rijeka','kvarner','crikvenic','opatij','krk','cres','lošinj','rab'] + def pgz_boost(h): + p = h.get("payload") or {} + all_t = ((p.get("title","") or "") + " " + (p.get("text","")[:300] or "") + " " + (p.get("source_url","") or "")).lower() + if any(k in all_t for k in PGZ_KW): + return h.get("score", 0) * 1.15 # 15% boost + return h.get("score", 0) + hits = sorted(hits, key=pgz_boost, reverse=True) + + # 4) Build context with metadata + ctx_chunks = [] + sources = [] + for i, h in enumerate(hits): + p = h.get("payload") or {} + text = (p.get("text") or "")[:1200] # more context per chunk + title = p.get("title", "(bez naslova)") + url = p.get("source_url") or p.get("url", "") + doc_type = p.get("doc_type", "") + publish_date = p.get("publish_date", "") + source = p.get("source", "") + date_str = f", {publish_date[:10]}" if publish_date else "" + if text and len(text) > 50: + ctx_chunks.append(f"[{i+1}] {title}{date_str} ({doc_type or source}):\n{text}") + sources.append({ + "id": i+1, "title": title, "url": url, + "doc_type": doc_type, "publish_date": publish_date, + "source": source, "score": round(float(h.get("score", 0)), 3) + }) + + context = "\n\n".join(ctx_chunks) + + # 3) LLM call — DeepSeek primary + SYSTEM = """Ti si AI PRAVNIK Zajednice sportova Primorsko-goranske županije (ZSPGŽ). +Specijaliziran si za hrvatsko sportsko pravo i propise koje primjenjuje ZSPGŽ. + +ZNANJE TI DOLAZI ISKLJUČIVO IZ PRILOŽENIH IZVORA. Nikada ne izmišljaj ni datume ni iznose ni članke. + +PRAVILA ODGOVORA: +1. KRATAK DIREKTAN ODGOVOR PRVI (1-3 rečenice). Što tražitelj treba znati odmah. +2. DETALJI: konkretni iznosi, rokovi, članci pravilnika, postupci. Citiraj BROJEVE [1], [2]... za svaki podatak. +3. AKO INFORMACIJA NIJE U IZVORIMA: jasno kaži "Ovo pitanje nije pokriveno priloženim pravilnicima — preporučujem provjeru izravno na sport-pgz.hr ili kod nadležne osobe." +4. AKO POSTOJE SLIČNI ALI NE IDENTIČNI PRAVILNICI: navedi razliku jasno. +5. PRIORITET: ZSPGŽ pravilnici > PGŽ županijski > nacionalni HOO/MINT > zakonski tekst. +6. STIL: stručan, ali razumljiv. Hrvatski jezik. Bez fraza poput "kako je navedeno" — direktno citiraj. +7. NE PONAVLJAJ pitanje. Ne počinji s "Prema priloženim pravilnicima..." — direktno na odgovor. + +FORMAT: +**Odgovor:** [1-3 rečenice s ključnim podacima i [1] referencama] + +**Detalji:** +- [bullet] [konkretan podatak] [referenca] +- [bullet] [postupak] [referenca] + +**Reference:** [auto generirano ispod, ne pisati u odgovoru] + +Ako tražitelj pita o konkretnom iznosu/rokovima a nemaš to u izvorima, **nemoj nagađati** — kaži da nisi siguran i preporuči direktan kontakt.""" + + user_msg = f"PITANJE: {q}\n\nPRILOŽENI PRAVILNICI/IZVORI:\n{context}\n\nODGOVOR (sa referencama [1], [2]...):" + + answer = "" + llm_used = "none" + + # Try DeepSeek + try: + ds_key = os.environ.get("DEEPSEEK_API_KEY") + if ds_key: + r = _requests.post( + "https://api.deepseek.com/v1/chat/completions", + headers={"Authorization": f"Bearer {ds_key}"}, + json={ + "model": "deepseek-chat", + "messages": [ + {"role": "system", "content": SYSTEM}, + {"role": "user", "content": user_msg}, + ], + "temperature": 0.15, + "max_tokens": 2000, + }, + timeout=60, + ) + if r.ok: + answer = r.json()["choices"][0]["message"]["content"] + llm_used = "deepseek" + except Exception as e: + pass + + # Fallback Groq + if not answer: + try: + gk = os.environ.get("GROQ_API_KEY") + if gk: + r = _requests.post( + "https://api.groq.com/openai/v1/chat/completions", + headers={"Authorization": f"Bearer {gk}"}, + json={ + "model": "llama-3.3-70b-versatile", + "messages": [ + {"role": "system", "content": SYSTEM}, + {"role": "user", "content": user_msg}, + ], + "temperature": 0.15, + "max_tokens": 2000, + }, + timeout=60, + ) + if r.ok: + answer = r.json()["choices"][0]["message"]["content"] + llm_used = "groq" + except Exception as e: + pass + + # Local Ollama fallback + if not answer: + try: + r = _requests.post( + "http://localhost:11434/api/chat", + json={ + "model": "qwen2.5:7b", + "messages": [ + {"role": "system", "content": SYSTEM}, + {"role": "user", "content": user_msg}, + ], + "stream": False, + "options": {"temperature": 0.15, "num_predict": 1500}, + }, + timeout=120, + ) + if r.ok: + answer = r.json().get("message", {}).get("content", "") + llm_used = "ollama_qwen2.5" + except Exception: + pass + + if not answer: + # No LLM available — return pure RAG with notice + answer = "**[LLM nije dostupan, evo top relevantnih izvora:]**\n\n" + for i, src_item in enumerate(sources[:3], 1): + answer += f"**[{i}] {src_item['title']}**\n" + answer += f"_{src_item.get('doc_type','')}, score={src_item['score']:.2f}_\n\n" + llm_used = "rag_only" + + # Audit + try: + from psycopg2 import connect + conn = connect(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7') + cu = conn.cursor() + cu.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_text, payload) + VALUES (%s,%s,%s,%s::jsonb)""", + ('lawyer.query', 'sport_lawyer', q[:500], + json.dumps({"llm": llm_used, "hits": len(hits), "sources": len(sources)}))) + conn.commit(); conn.close() + except Exception: + pass + + return { + "query": q, + "answer": answer, + "sources": sources, + "llm": llm_used, + "hits_count": len(hits), + } + + +@router.get("/calendar/upcoming") +def calendar_upcoming(klub_id: Optional[int]=None, days_ahead: int=30): + end = datetime.now() + timedelta(days=days_ahead) + out = [] + + # Liječnički pregledi koji ističu + where = ""; args = [] + if klub_id: + where = "AND c.klub_id=%s"; args = [klub_id] + args = [end] + args + rows = db_query(f"""SELECT 'lijecnicki_istek' AS type, + 'Liječnički istječe — '||c.ime||' '||c.prezime AS title, + lp.vrijedi_do AS date, c.klub_id, lp.clan_id, k.naziv AS klub_naziv + 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 BETWEEN now()::date AND %s::date {where} + ORDER BY lp.vrijedi_do""", args) + out.extend(rows) + + # Računi koji dospijevaju + args2 = [end] + if klub_id: args2.append(klub_id) + where2 = " AND klub_id=%s" if klub_id else "" + rows = db_query(f"""SELECT 'invoice_due' AS type, + 'Račun '||invoice_no||' — '||COALESCE(vendor_name,'?')||' — '||amount_gross::text||' EUR' AS title, + due_date AS date, klub_id, NULL::int AS clan_id, NULL::text AS klub_naziv + FROM pgz_sport.invoices WHERE due_date BETWEEN now()::date AND %s::date + AND payment_status='unpaid'{where2} + ORDER BY due_date""", args2) + out.extend(rows) + + # Alerts otvorene + args3 = [end] + if klub_id: args3.append(klub_id) + where3 = " AND klub_id=%s" if klub_id else "" + rows = db_query(f"""SELECT 'alert' AS type, poruka AS title, due_date AS date, + klub_id, clan_id, NULL::text FROM pgz_sport.alertovi + WHERE rijeseno=false AND due_date IS NOT NULL AND due_date<=%s::date{where3} + ORDER BY due_date""", args3) + out.extend(rows) + + return sorted(out, key=lambda x: x.get('date') or date.max) + +# ============== EKOSUSTAV STATS (extended) ============== +@router.get("/ekosustav/v2") +def ekosustav_v2(): + return { + "savezi": db_one("""SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE razina='nacional') AS nacional, + COUNT(*) FILTER (WHERE razina='zupanijski') AS zupanijski, + COUNT(*) FILTER (WHERE razina='gradski') AS gradski + FROM pgz_sport.savezi"""), + "klubovi": db_one("""SELECT + COUNT(*) AS total, + COUNT(oib) AS s_oib, + COUNT(*) FILTER (WHERE entity_id IS NOT NULL) AS linked + FROM pgz_sport.klubovi"""), + "documents": db_one("""SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE LENGTH(COALESCE(text_extracted,''))>=200) AS extracted + FROM sport.documents"""), + "qdrant_points": (lambda: requests.get(f"{QDRANT}/collections/{COLL}").json()['result']['points_count'])(), + "users": db_one("""SELECT COUNT(*) FROM pgz_sport.users WHERE status='active'"""), + "alerts_open": db_one("""SELECT + COUNT(*) FILTER (WHERE razina='CRITICAL') AS critical, + COUNT(*) FILTER (WHERE razina='WARNING') AS warning, + COUNT(*) FILTER (WHERE razina='INFO') AS info + FROM pgz_sport.alertovi WHERE rijeseno=false"""), + "forms_templates": db_one("SELECT COUNT(*) FROM pgz_sport.form_templates WHERE active=true"), + "invoices": db_one("""SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE payment_status='unpaid') AS unpaid, + COALESCE(SUM(amount_gross),0)::numeric AS total_eur + FROM pgz_sport.invoices"""), + } + +# =========== MULTIPART UPLOAD + USER CREATE (added 28apr) =========== +UPLOAD_DIR = "/var/lib/pgz-sport/invoices" +os.makedirs(UPLOAD_DIR, exist_ok=True) + +@router.post("/invoice-uploads/file") +async def upload_invoice_file( + file: UploadFile = File(...), + klub_id: int = Form(...), + invoice_kind: str = Form("ulazni"), + user = Depends(require_user) +): + """Multipart upload — saves to disk + queues for OCR.""" + # Permissions: super_admin (global) OR klub_admin/klub_user/pgz_admin/pgz_user + is_authorized = ( + user_has_role(user['user_id'],'super_admin') or + user_has_role(user['user_id'],'pgz_admin') or + user_has_role(user['user_id'],'pgz_user') or + user_has_role(user['user_id'],'klub_admin','klub',klub_id) or + user_has_role(user['user_id'],'klub_user','klub',klub_id) or + # Fallback - if user_type field on users table indicates super_admin + (db_one("SELECT user_type FROM pgz_sport.users WHERE id=%s", (user['user_id'],)) or {}).get('user_type') in ('super_admin','pgz_admin') + ) + if not is_authorized: + raise HTTPException(403, f"Forbidden - need klub_admin role for klub_id={klub_id}") + raw = await file.read() + sha = hashlib.sha256(raw).hexdigest() + safe = re.sub(r'[^a-zA-Z0-9._-]','_', file.filename or 'invoice') + path = f"{UPLOAD_DIR}/{klub_id}_{int(time.time())}_{sha[:8]}_{safe}" + with open(path, 'wb') as f: + f.write(raw) + uid = db_exec("""INSERT INTO pgz_sport.invoice_uploads + (klub_id, uploaded_by, file_name, file_path, file_size, mime, sha256, ocr_status) + VALUES (%s,%s,%s,%s,%s,%s,%s,'pending') RETURNING id""", + (klub_id, user['user_id'], file.filename, path, len(raw), file.content_type, sha)) + db_exec("INSERT INTO pgz_sport.audit_events(user_id,action,resource_type,resource_id,meta) VALUES (%s,'upload_invoice','invoice_upload',%s,%s)", + (user['user_id'], uid, json.dumps({'klub_id':klub_id,'kind':invoice_kind,'sha':sha[:16]}))) + return {"upload_id": uid, "ocr_status": "pending", "klub_id": klub_id, "size": len(raw), "sha": sha[:16]} + +# =========== USER CREATE (simple, no token required from super_admin via API) =========== +class CreateUserReq(BaseModel): + email: str + full_name: Optional[str] = None + password: str + role: str = "viewer" + klub_id: Optional[int] = None + oib: Optional[str] = None + phone: Optional[str] = None + +@router.post("/users") +def api_create_user(req: CreateUserReq, user = Depends(require_user)): + """Create new user. Requires super_admin or pgz_admin or klub_admin.""" + if not (user_has_role(user['user_id'],'super_admin') or + user_has_role(user['user_id'],'pgz_admin') or + (req.klub_id and user_has_role(user['user_id'],'klub_admin','klub',req.klub_id))): + raise HTTPException(403, "Need super_admin/pgz_admin/klub_admin") + # Existing user? + existing = db_one("SELECT id FROM pgz_sport.users WHERE email=%s", (req.email,)) + if existing: + raise HTTPException(409, f"User {req.email} already exists (id={existing['id']})") + # Create + uid = db_exec("""INSERT INTO pgz_sport.users (email, full_name, oib, phone, password_hash, status) + VALUES (%s,%s,%s,%s,%s,'active') RETURNING id""", + (req.email, req.full_name, req.oib, req.phone, hash_pw(req.password))) + # Assign role + role_row = db_one("SELECT id FROM pgz_sport.roles WHERE code=%s", (req.role,)) + if role_row: + scope = ('klub', req.klub_id) if req.klub_id else ('global', None) + db_exec("""INSERT INTO pgz_sport.user_roles (user_id, role_id, scope_type, scope_id, granted_by, active) + VALUES (%s,%s,%s,%s,%s,true)""", + (uid, role_row['id'], scope[0], scope[1], user['user_id'])) + db_exec("INSERT INTO pgz_sport.audit_events(user_id,action,resource_type,resource_id,meta) VALUES (%s,'create_user','user',%s,%s)", + (user['user_id'], uid, json.dumps({'email':req.email,'role':req.role,'klub_id':req.klub_id}))) + return {"user_id": uid, "email": req.email, "role": req.role, "klub_id": req.klub_id} + + +# ═══════════════════════════════════════════════════════ +# SPORTAŠ (Player) ENDPOINTS — semafor.hns.family style +# ═══════════════════════════════════════════════════════ + +@router.get("/sportas/{cid}/profile") +def sportas_profile(cid: int): + """Full player profile + per-season stats + match log. Public read.""" + sportas = db_one("""SELECT c.id, c.kategorije, c.promocija_kategorije, c.sport, c.ime, c.prezime, c.datum_rodenja, c.mjesto_rodenja, + c.slika_url, c.source, c.source_id, c.source_url, c.source_synced_at, + c.pozicija, c.dominantna_noga, c.visina_cm, c.tezina_kg, c.broj_dresa, + c.reprezentativac, c.reprezentacija_kategorija, c.biografija, + c.klub_id, k.naziv AS klub_naziv, k.sport, k.razina, k.region, k.logo_url, + k.hns_klub_id, k.hns_slug + FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE c.id=%s""", (cid,)) + if not sportas: + raise HTTPException(404, "Sportaš nije pronađen") + + # Per-season aggregate from utakmice_log + seasons = db_query(""" + SELECT + CASE WHEN EXTRACT(MONTH FROM datum)>=7 + THEN EXTRACT(YEAR FROM datum)::TEXT||'/'||(EXTRACT(YEAR FROM datum)+1)::TEXT + ELSE (EXTRACT(YEAR FROM datum)-1)::TEXT||'/'||EXTRACT(YEAR FROM datum)::TEXT + END AS sezona, + natjecanje, count(*) AS nastupi, + COALESCE(SUM(pogodaka),0) AS pogoci, + COALESCE(SUM(zuti_kartoni),0) AS zuti, + COALESCE(SUM(crveni_kartoni),0) AS crveni, + COALESCE(SUM(minute),0) AS minute_total + FROM pgz_sport.utakmice_log + WHERE clan_id=%s + GROUP BY 1, 2 + ORDER BY 1 DESC, 2""", (cid,)) + + # Match log (latest 50) + matches = db_query("""SELECT id, datum, vrijeme, natjecanje, + klub_dom, klub_dom_logo, klub_gost, klub_gost_logo, + rezultat, pogodaka, zuti_kartoni, crveni_kartoni, minute, + zapocet_kao_starter, source_url + FROM pgz_sport.utakmice_log + WHERE clan_id=%s ORDER BY datum DESC NULLS LAST LIMIT 50""", (cid,)) + + # Career — clubs over time (from utakmice_log distinct za_klub_id) + career = db_query("""SELECT k.id, k.naziv, k.logo_url, + min(ul.datum) AS od_dat, max(ul.datum) AS do_dat, + count(*) AS nastupa + FROM pgz_sport.utakmice_log ul + JOIN pgz_sport.klubovi k ON k.id=ul.za_klub_id + WHERE ul.clan_id=%s GROUP BY k.id, k.naziv, k.logo_url + ORDER BY min(ul.datum)""", (cid,)) + + return { + "sportas": sportas, + "seasons": seasons, + "career": career, + "matches": matches, + "totals": { + "nastupa": sum(s['nastupi'] for s in seasons), + "pogodaka": sum(s['pogoci'] for s in seasons), + "zutih": sum(s['zuti'] for s in seasons), + "crvenih": sum(s['crveni'] for s in seasons), + } + } + +@router.get("/klub/{kid}/sportasi") +def klub_sportasi(kid: int, limit: int = 100, offset: int = 0): + """Roster: players in a club.""" + klub = db_one("SELECT id, naziv, sport, razina, hns_klub_id, logo_url FROM pgz_sport.klubovi WHERE id=%s", (kid,)) + if not klub: + raise HTTPException(404, "Klub nije pronađen") + sportasi = db_query("""SELECT c.id, c.ime, c.prezime, c.datum_rodenja, c.mjesto_rodenja, + c.slika_url, c.pozicija, c.broj_dresa, c.reprezentativac, c.source, c.source_url, + (SELECT count(*) FROM pgz_sport.utakmice_log WHERE clan_id=c.id) AS nastupa, + (SELECT COALESCE(sum(pogodaka),0) FROM pgz_sport.utakmice_log WHERE clan_id=c.id) AS pogoci + FROM pgz_sport.clanovi c + WHERE c.klub_id=%s + ORDER BY c.broj_dresa NULLS LAST, c.prezime, c.ime + LIMIT %s OFFSET %s""", (kid, limit, offset)) + total = db_one("SELECT count(*) AS c FROM pgz_sport.clanovi WHERE klub_id=%s", (kid,))['c'] + + # A4_KLUB_TROFEJI_PATCH: trofeji, povijesne nagrade, top medalisti + trofeji = db_query(""" + SELECT sezona, natjecanje, plasiranje, trofej, bodovi, napomena + FROM pgz_sport.klub_sezona + WHERE klub_id=%s + ORDER BY + CASE WHEN sezona ~ '^[0-9]{4}' THEN substring(sezona FROM '^[0-9]{4}')::INT ELSE 0 END DESC, + plasiranje ASC NULLS LAST + LIMIT 50""", (kid,)) + + priznanja = db_query(""" + SELECT godina, kategorija, ime_prezime, sport, napomena, clan_id + FROM pgz_sport.najbolji_sportasi + WHERE klub_id=%s + ORDER BY godina DESC LIMIT 50""", (kid,)) + + top_medalisti = db_query(""" + SELECT ime_prezime, clan_id, count(*) AS nagrade, + count(*) FILTER (WHERE medalja='ZLATO') AS z, + count(*) FILTER (WHERE medalja='SREBRO') AS s, + count(*) FILTER (WHERE medalja='BRONCA') AS b, + count(*) FILTER (WHERE razina_natjecanja IN ('SP','EP','OI')) AS svj + FROM pgz_sport.clan_nagrada + WHERE klub_id=%s AND medalja IS NOT NULL + GROUP BY ime_prezime, clan_id + ORDER BY count(*) FILTER (WHERE medalja='ZLATO') DESC, count(*) DESC + LIMIT 15""", (kid,)) + + # HOO kategorizirani u klubu + hoo_sportasi = db_query(""" + SELECT id, ime, prezime, kategorija_hoo, sport + FROM pgz_sport.clanovi + WHERE klub_id=%s AND kategorija_hoo IS NOT NULL + ORDER BY kategorija_hoo, prezime, ime""", (kid,)) + + return { + "klub": klub, "count": len(sportasi), "total": total, "sportasi": sportasi, + "trofeji": trofeji, + "priznanja": priznanja, + "top_medalisti": top_medalisti, + "hoo_sportasi": hoo_sportasi + } + + +# === NATJECANJA_TABLICA_PATCH === +@router.get("/natjecanja") +def natjecanja_list(sport: Optional[str] = None, sezona: Optional[str] = None, pgz_only: bool = False, limit: int = 100): + """List natjecanja (lige) with filtering.""" + where = []; args = [] + if sport: where.append("LOWER(sport) = LOWER(%s)"); args.append(sport) + if sezona: where.append("sezona = %s"); args.append(sezona) + if pgz_only: where.append("pgz_relevant = true") + where_sql = " AND ".join(where) if where else "1=1" + args.append(limit) + rows = db_query(f""" + SELECT n.id, n.sport, n.naziv, n.razina, n.tip, n.sezona, n.source, n.source_url, + n.pgz_relevant, + (SELECT count(*) FROM pgz_sport.natjecanja_tablice WHERE natjecanje_id=n.id) AS broj_klubova, + s.naziv AS savez_naziv + FROM pgz_sport.natjecanja n + LEFT JOIN pgz_sport.savezi s ON s.id = n.savez_id + WHERE {where_sql} + ORDER BY n.pgz_relevant DESC, n.sport, n.razina, n.naziv + LIMIT %s""", tuple(args)) + return {"count": len(rows), "natjecanja": rows} + + +@router.get("/natjecanja/{nid}/tablica") +def natjecanja_tablica(nid: int): + """Get current standings for a natjecanje - from DB or external URL.""" + natj = db_one("""SELECT n.*, s.naziv AS savez_naziv FROM pgz_sport.natjecanja n + LEFT JOIN pgz_sport.savezi s ON s.id=n.savez_id + WHERE n.id=%s""", (nid,)) + if not natj: + raise HTTPException(404, f"Natjecanje {nid} nije pronađeno") + + # Get from scraped table + klubovi = db_query(""" + SELECT nt.rang, nt.klub_naziv, nt.utakmica, nt.pobjede, nt.nerijeseno, nt.porazi, + nt.golovi_za, nt.golovi_protiv, nt.bodovi, nt.source, nt.scraped_at, + k.id as klub_id + FROM pgz_sport.natjecanje_tablica nt + LEFT JOIN pgz_sport.klubovi k ON LOWER(k.naziv) LIKE LOWER('%%'||LEFT(nt.klub_naziv,15)||'%%') AND k.aktivan=true + WHERE nt.natjecanje_id=%s + ORDER BY nt.rang + """, (nid,)) + + return {"natjecanje": natj, "klubovi": klubovi, "count": len(klubovi)} + + +@router.get("/audit/freshness") +def audit_freshness(): + """Show data freshness across all scrapers.""" + rows = db_query(""" + SELECT 'utakmice_log (HNS)' AS tabela, count(*) AS broj, max(scraped_at) AS zadnji_update, + min(scraped_at) AS prvi_update, + (now() - max(scraped_at))::text AS od_zadnjeg + FROM pgz_sport.utakmice_log + UNION ALL + SELECT 'clan_sezona', count(*), max(last_scraped_at), min(last_scraped_at), + (now() - max(last_scraped_at))::text + FROM pgz_sport.clan_sezona + UNION ALL + SELECT 'klubovi (HNS)', count(*), max(source_synced_at), min(source_synced_at), + (now() - max(source_synced_at))::text + FROM pgz_sport.klubovi WHERE source = 'hns_semafor' + UNION ALL + SELECT 'klubovi (HBS)', count(*), max(source_synced_at), min(source_synced_at), + (now() - max(source_synced_at))::text + FROM pgz_sport.klubovi WHERE source = 'hbs_savez' + UNION ALL + SELECT 'natjecanja_tablice', count(*), max(updated_at), min(updated_at), + (now() - max(updated_at))::text + FROM pgz_sport.natjecanja_tablice + UNION ALL + SELECT 'natjecanja', count(*), max(updated_at), min(updated_at), + (now() - max(updated_at))::text + FROM pgz_sport.natjecanja + UNION ALL + SELECT 'dokumenti', count(*), max(scraped_at), min(scraped_at), + (now() - max(scraped_at))::text + FROM pgz_sport.dokumenti + UNION ALL + SELECT 'clan_nagrada', count(*), max(last_updated), min(last_updated), + (now() - max(last_updated))::text + FROM pgz_sport.clan_nagrada + ORDER BY tabela + """) + return {"freshness": rows} + +@router.get("/audit/sources") +def audit_sources(): + """Distribucija izvora po tablicama.""" + klubovi = db_query("SELECT COALESCE(source,'unknown') AS source, count(*) AS broj FROM pgz_sport.klubovi GROUP BY source ORDER BY count(*) DESC") + clanovi = db_query("SELECT COALESCE(source,'unknown') AS source, count(*) AS broj FROM pgz_sport.clanovi GROUP BY source ORDER BY count(*) DESC") + dokumenti = db_query("SELECT COALESCE(vrsta,'unknown') AS source, count(*) AS broj FROM pgz_sport.dokumenti GROUP BY vrsta ORDER BY count(*) DESC") + natjecanja = db_query("SELECT COALESCE(source,'unknown') AS source, count(*) AS broj FROM pgz_sport.natjecanja GROUP BY source ORDER BY count(*) DESC") + return { + "klubovi_by_source": klubovi, + "clanovi_by_source": clanovi, + "dokumenti_by_vrsta": dokumenti, + "natjecanja_by_source": natjecanja + } + +@router.get("/sportas/{clan_id}/godisnjak_history") +def godisnjak_history(clan_id: int): + """Vraća sve spomene sportaša u godišnjacima sa snippet kontekstima.""" + with psycopg2.connect(**DB) as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cu: + cu.execute("SELECT ime, prezime, sport FROM pgz_sport.clanovi WHERE id=%s", (clan_id,)) + sp = cu.fetchone() + if not sp: + raise HTTPException(404, "Sportaš nije pronađen") + cu.execute("""SELECT cg.godina, cg.snippet, cg.klub_naziv, cg.keywords, + cg.has_medal, cg.has_kategorija, d.izvor_url, d.title + FROM pgz_sport.clan_godisnjak cg + JOIN pgz_sport.dokumenti d ON d.id = cg.dokument_id + WHERE cg.clan_id = %s ORDER BY cg.godina""", (clan_id,)) + history = cu.fetchall() + return {"sportas": dict(sp), "count": len(history), "history": [dict(h) for h in history]} + + +@router.get("/godisnjak/{godina}/sportasi") +def godisnjak_sportasi(godina: int, has_medal: bool = False, limit: int = 100): + """Sportaši spomenuti u određenom godišnjaku.""" + with psycopg2.connect(**DB) as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cu: + sql = """SELECT cg.clan_id, c.ime, c.prezime, c.sport, k.naziv AS klub, + cg.has_medal, cg.has_kategorija, cg.keywords, cg.snippet + FROM pgz_sport.clan_godisnjak cg + JOIN pgz_sport.clanovi c ON c.id = cg.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id + WHERE cg.godina = %s""" + params = [godina] + if has_medal: + sql += " AND cg.has_medal = true" + sql += " ORDER BY cg.has_medal DESC, c.prezime LIMIT %s" + params.append(limit) + cu.execute(sql, params) + rows = cu.fetchall() + return {"godina": godina, "count": len(rows), "sportasi": [dict(r) for r in rows]} + + +@router.get("/audit/coverage_matrix") +def audit_coverage_matrix(limit: int = 80): + """Pokrivenost po klubu: heat-map data za GUI.""" + with psycopg2.connect(**DB) as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cu: + cu.execute(""" + SELECT k.id, k.naziv, k.sport, k.hns_klub_id, + (SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id=k.id) AS sportasa, + (SELECT count(*) FROM pgz_sport.utakmice_log WHERE za_klub_id=k.id) AS utakmica, + (SELECT count(*) FROM pgz_sport.clan_sezona WHERE klub_naziv ILIKE '%%' || k.naziv || '%%' OR klub_naziv ILIKE k.naziv) AS sezona, + (SELECT count(*) FROM pgz_sport.klub_sezona WHERE klub_id=k.id) AS trofeja, + (SELECT count(*) FROM pgz_sport.clan_nagrada WHERE klub_id=k.id) AS nagrada, + (SELECT count(*) FROM pgz_sport.natjecanja_tablice WHERE klub_id=k.id) AS u_ligama, + CASE WHEN k.logo_url IS NOT NULL THEN 1 ELSE 0 END AS ima_logo, + array_length(k.godisnjak_godine, 1) AS godina_god, + k.godisnjak_prvi, k.godisnjak_zadnji + FROM pgz_sport.klubovi k + WHERE k.aktivan=true + ORDER BY + ((SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id=k.id) + + (SELECT count(*) FROM pgz_sport.utakmice_log WHERE za_klub_id=k.id)/10 + + COALESCE(array_length(k.godisnjak_godine, 1), 0)*5) DESC + LIMIT %s + """, (limit,)) + rows = cu.fetchall() + return {"count": len(rows), "klubovi": [dict(r) for r in rows]} + + +@router.get("/audit/coverage") +def audit_coverage(): + """Pokrivenost po klubu — koliko podataka imamo.""" + rows = db_query(""" + SELECT k.id, k.naziv, k.sport, k.razina, k.hns_klub_id, + k.source, k.source_synced_at, + (SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id=k.id) AS sportasa, + (SELECT count(*) FROM pgz_sport.utakmice_log WHERE za_klub_id=k.id) AS utakmica, + (SELECT count(*) FROM pgz_sport.clan_sezona WHERE clan_id IN (SELECT id FROM pgz_sport.clanovi WHERE klub_id=k.id)) AS sezona, + (SELECT count(*) FROM pgz_sport.clan_nagrada WHERE klub_id=k.id) AS nagrada, + (SELECT count(*) FROM pgz_sport.klub_sezona WHERE klub_id=k.id) AS trofeja, + (SELECT count(*) FROM pgz_sport.natjecanja_tablice WHERE klub_id=k.id) AS u_ligama + FROM pgz_sport.klubovi k + WHERE k.aktivan = true + AND ((SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id=k.id) > 0 + OR (SELECT count(*) FROM pgz_sport.klub_sezona WHERE klub_id=k.id) > 0) + ORDER BY + (SELECT count(*) FROM pgz_sport.utakmice_log WHERE za_klub_id=k.id) DESC, + (SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id=k.id) DESC + LIMIT 200 + """) + return {"count": len(rows), "klubovi": rows} + +@router.get("/audit/feed") +def audit_feed_recent(limit: int = 50): + """Recent audit events.""" + rows = db_query("""SELECT table_name, action, source, source_url, scraped_at, + changed_fields, details + FROM pgz_sport.audit_feed + ORDER BY scraped_at DESC LIMIT %s""", (limit,)) + return {"count": len(rows), "events": rows} + +# === END AUDIT_PATCH === + +# === GODISNJAK_SEARCH_PATCH === +@router.get("/dokumenti") +def dokumenti_list(vrsta: Optional[str] = None, godina: Optional[int] = None, limit: int = 100): + """List dokumenti.""" + where = []; args = [] + if vrsta: where.append("vrsta = %s"); args.append(vrsta) + if godina: where.append("godina = %s"); args.append(godina) + where_sql = " AND ".join(where) if where else "1=1" + args.append(limit) + rows = db_query(f"""SELECT id, title, vrsta, godina, izdano_datum, organizacija, + length(sadrzaj) AS chars, izvor_url, scraped_at + FROM pgz_sport.dokumenti + WHERE aktivan = true AND {where_sql} + ORDER BY godina DESC NULLS LAST, izdano_datum DESC NULLS LAST + LIMIT %s""", tuple(args)) + return {"count": len(rows), "dokumenti": rows} + +@router.get("/dokumenti/{did:int}") +def dokument_detail(did: int): + """Get dokument detail with full text or excerpt.""" + d = db_one("""SELECT id, title, vrsta, godina, izdano_datum, organizacija, kratak_opis, + izvor_url, sadrzaj, scraped_at + FROM pgz_sport.dokumenti WHERE id = %s""", (did,)) + if not d: raise HTTPException(404, "Dokument nije pronađen") + return d + +@router.get("/dokumenti/search/q") +def dokumenti_search(q: str, vrsta: Optional[str] = None, limit: int = 30): + """Full-text search kroz godišnjake i sve dokumente.""" + if not q or len(q) < 2: raise HTTPException(400, "Query premalen") + where_extra = "AND vrsta = %s" if vrsta else "" + args = [f"%{q}%", f"%{q}%"] + if vrsta: args.append(vrsta) + args.append(limit) + + rows = db_query(f""" + SELECT id, title, vrsta, godina, izdano_datum, izvor_url, + (CASE + WHEN POSITION(LOWER(%s) IN LOWER(sadrzaj)) > 100 + THEN '…' || SUBSTR(sadrzaj, GREATEST(1, POSITION(LOWER(%s) IN LOWER(sadrzaj)) - 100), 400) || '…' + ELSE SUBSTR(sadrzaj, 1, 400) || '…' + END) AS excerpt + FROM pgz_sport.dokumenti + WHERE LOWER(sadrzaj) LIKE LOWER(%s) {where_extra} + ORDER BY godina DESC NULLS LAST + LIMIT %s""", + tuple([q, q, f"%{q}%"] + ([vrsta] if vrsta else []) + [limit])) + return {"query": q, "count": len(rows), "rezultati": rows} +# === END GODISNJAK_SEARCH_PATCH === + + + + +@router.get("/dokumenti/{did:int}/pdf") +def dokumenti_pdf(did: int): + """Stream the original PDF file if available locally.""" + from fastapi.responses import FileResponse, JSONResponse + import os + rows = db_query("SELECT fname, vrsta, godina, title FROM pgz_sport.dokumenti WHERE id=%s", (did,)) + if not rows: + return JSONResponse({"error": "not found"}, status_code=404) + rec = rows[0] + # Try local paths + candidates = [] + if rec.get("fname"): + candidates.extend([ + f"/opt/pgz-sport/_data/godisnjaci/{rec['fname']}", + f"/opt/pgz-sport/_data/dokumenti/{rec['fname']}", + f"/opt/pgz-sport/_data/{rec['fname']}", + ]) + # Construct from godina/vrsta + if rec.get("godina") and rec.get("vrsta") == "godisnjak": + candidates.append(f"/opt/pgz-sport/_data/godisnjaci/godisnjak_{rec['godina']}.pdf") + for path in candidates: + if os.path.isfile(path): + return FileResponse(path, media_type="application/pdf", + filename=os.path.basename(path), + headers={"Cache-Control": "max-age=3600"}) + return JSONResponse({"error": "PDF file not found locally", "candidates": candidates}, status_code=404) + + +@router.get("/dokumenti/{did:int}/text") +def dokumenti_text(did: int): + """Return the full parsed text content of a document.""" + from fastapi.responses import PlainTextResponse, JSONResponse + rows = db_query("SELECT title, sadrzaj, vrsta, godina FROM pgz_sport.dokumenti WHERE id=%s", (did,)) + if not rows: + return JSONResponse({"error": "not found"}, status_code=404) + rec = rows[0] + if not rec.get("sadrzaj"): + return JSONResponse({"error": "no parsed text"}, status_code=404) + import re as _re; title = _re.sub(r"[^A-Za-z0-9_.-]", "_", rec.get("title", f"dokument_{did}")) + return PlainTextResponse(rec["sadrzaj"], headers={ + "Content-Disposition": f'inline; filename="{title}.txt"', + "Cache-Control": "max-age=3600" + }) + +@router.get("/klub/{kid}/natjecanja") +def klub_natjecanja(kid: int): + """Get all natjecanja a klub participates in (current sezona).""" + rows = db_query(""" + SELECT n.id, n.sport, n.naziv, n.razina, n.sezona, n.pgz_relevant, + t.pozicija, t.odigrano, t.pobjede, t.nerijeseno, t.porazi, t.bodovi + FROM pgz_sport.natjecanja_tablice t + JOIN pgz_sport.natjecanja n ON n.id = t.natjecanje_id + WHERE t.klub_id = %s + ORDER BY n.sport, n.razina""", (kid,)) + return {"count": len(rows), "natjecanja": rows} + +@router.get("/sportas/search") +def sportas_search(q: str = "", klub_id: Optional[int] = None, limit: int = 30): + """Search players by name.""" + where = ["1=1"]; args = [] + if q: + where.append("(LOWER(ime||' '||prezime) LIKE %s OR LOWER(prezime||' '||ime) LIKE %s)") + args.extend([f"%{q.lower()}%"]*2) + if klub_id: where.append("klub_id=%s"); args.append(klub_id) + args.append(limit) + rows = db_query(f"""SELECT c.id, c.ime, c.prezime, c.datum_rodenja, c.slika_url, c.source, + k.naziv AS klub + FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE {" AND ".join(where)} + ORDER BY c.prezime, c.ime LIMIT %s""", tuple(args)) + return {"count": len(rows), "results": rows} + + +# ═══════════════════════════════════════════════════════ +# DASHBOARD STATS +# ═══════════════════════════════════════════════════════ + +@router.get("/dashboard/sport-stats") +def dashboard_sport_stats(): + """High-level KPIs + top players for landing dashboard.""" + summary = db_one(""" + SELECT + (SELECT count(*) FROM pgz_sport.savezi WHERE aktivan=true) AS savezi, + (SELECT count(*) FROM pgz_sport.klubovi WHERE aktivan=true) AS klubovi, + (SELECT count(*) FROM pgz_sport.klubovi WHERE hns_klub_id IS NOT NULL) AS klubova_hns, + (SELECT count(*) FROM pgz_sport.clanovi) AS clanova, + (SELECT count(*) FROM pgz_sport.clanovi WHERE source='hns_semafor') AS sportasa_hns, + (SELECT count(distinct clan_id) FROM pgz_sport.utakmice_log) AS aktivnih_igraca, + (SELECT count(distinct source_match_id) FROM pgz_sport.utakmice_log) AS scraped_utakmica, + (SELECT COALESCE(sum(pogodaka),0) FROM pgz_sport.utakmice_log) AS ukupno_golova, + (SELECT COALESCE(sum(zuti_kartoni),0) FROM pgz_sport.utakmice_log) AS ukupno_zutih, + (SELECT COALESCE(sum(crveni_kartoni),0) FROM pgz_sport.utakmice_log) AS ukupno_crvenih + """) + + top_scorers = db_query(""" + SELECT c.id, c.ime||' '||COALESCE(c.prezime,'') AS ime, + c.broj_dresa, c.pozicija, c.slika_url, k.naziv AS klub, + SUM(ul.pogodaka) AS pogodaka, + COUNT(*) AS nastupa + FROM pgz_sport.utakmice_log ul + JOIN pgz_sport.clanovi c ON c.id=ul.clan_id + JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE ul.pogodaka > 0 + GROUP BY c.id, c.ime, c.prezime, c.broj_dresa, c.pozicija, c.slika_url, k.naziv + ORDER BY pogodaka DESC, nastupa ASC + LIMIT 10""") + + top_appearances = db_query(""" + SELECT c.id, c.ime||' '||COALESCE(c.prezime,'') AS ime, + c.broj_dresa, c.pozicija, c.slika_url, k.naziv AS klub, + COUNT(*) AS nastupa, + SUM(ul.minute) AS ukupno_minuta, + SUM(ul.pogodaka) AS pogoci + FROM pgz_sport.utakmice_log ul + JOIN pgz_sport.clanovi c ON c.id=ul.clan_id + JOIN pgz_sport.klubovi k ON k.id=c.klub_id + GROUP BY c.id, c.ime, c.prezime, c.broj_dresa, c.pozicija, c.slika_url, k.naziv + ORDER BY nastupa DESC, ukupno_minuta DESC NULLS LAST + LIMIT 10""") + + most_carded = db_query(""" + SELECT c.id, c.ime||' '||COALESCE(c.prezime,'') AS ime, + k.naziv AS klub, c.slika_url, + SUM(ul.zuti_kartoni) AS zutih, + SUM(ul.crveni_kartoni) AS crvenih, + COUNT(*) AS nastupa + FROM pgz_sport.utakmice_log ul + JOIN pgz_sport.clanovi c ON c.id=ul.clan_id + JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE ul.zuti_kartoni > 0 OR ul.crveni_kartoni > 0 + GROUP BY c.id, c.ime, c.prezime, c.slika_url, k.naziv + ORDER BY (SUM(ul.zuti_kartoni) + SUM(ul.crveni_kartoni)*2) DESC + LIMIT 10""") + + klub_breakdown = db_query(""" + SELECT k.id, k.naziv, k.sport, + COUNT(DISTINCT c.id) AS sportasa, + COUNT(DISTINCT ul.source_match_id) AS utakmica, + SUM(ul.pogodaka) AS pogoci + FROM pgz_sport.klubovi k + LEFT JOIN pgz_sport.clanovi c ON c.klub_id=k.id AND c.source='hns_semafor' + LEFT JOIN pgz_sport.utakmice_log ul ON ul.clan_id=c.id + WHERE k.hns_klub_id IS NOT NULL + GROUP BY k.id, k.naziv, k.sport + ORDER BY sportasa DESC NULLS LAST + LIMIT 20""") + + recent_matches = db_query(""" + SELECT DISTINCT ON (ul.source_match_id) + ul.source_match_id, ul.datum, ul.vrijeme, ul.natjecanje, + ul.klub_dom, ul.klub_dom_logo, ul.klub_gost, ul.klub_gost_logo, + ul.rezultat, ul.source_url + FROM pgz_sport.utakmice_log ul + ORDER BY ul.source_match_id, ul.datum DESC + LIMIT 20""") + + return { + "summary": summary, + "top_scorers": top_scorers, + "top_appearances": top_appearances, + "most_carded": most_carded, + "klub_breakdown": klub_breakdown, + "recent_matches": recent_matches, + "proracun_tek_god": float(db_exec("SELECT COALESCE(sum(iznos_eur),0) FROM pgz_sport.sufinanciranje_sport WHERE godina=EXTRACT(YEAR FROM NOW())::int") or 0), + "proracun_godina": int(db_exec("SELECT EXTRACT(YEAR FROM NOW())::int") or 2026), + "top_hoo": db_query("SELECT ime, prezime, hoo_kategorija, sport FROM pgz_sport.clanovi WHERE hoo_kategorija IN ('I','II','III') ORDER BY hoo_kategorija, sport LIMIT 20"), + } + + +# ═══════════════════════════════════════════════════════ +# RUČNI UNOS SPORTAŠA (klub admin / pgz_admin) +# ═══════════════════════════════════════════════════════ + +class CreateSportasReq(BaseModel): + ime: str + prezime: str + klub_id: int + datum_rodenja: Optional[str] = None + mjesto_rodenja: Optional[str] = None + broj_dresa: Optional[int] = None + pozicija: Optional[str] = None + dominantna_noga: Optional[str] = None + visina_cm: Optional[int] = None + tezina_kg: Optional[int] = None + slika_url: Optional[str] = None + oib: Optional[str] = None + biografija: Optional[str] = None + reprezentativac: Optional[bool] = False + reprezentacija_kategorija: Optional[str] = None + +@router.post("/sportas/create") +def create_sportas(req: CreateSportasReq, user = Depends(require_user)): + """Klub admin or pgz_admin can manually add a player to their club.""" + ut = user.get('user_type') + if ut not in ('super_admin','pgz_admin','pgz_user','savez_admin','savez_user','klub_admin','klub_user'): + raise HTTPException(403, "Forbidden — only admins can add sportaše") + + # Klub admin — must add to their own klub + if ut in ('klub_admin','klub_user'): + if user.get('klub_id') != req.klub_id: + # check via user_klub_links + link = db_one("SELECT 1 FROM pgz_sport.user_klub_links WHERE user_id=%s AND klub_id=%s", + (user['user_id'], req.klub_id)) + if not link: + raise HTTPException(403, "Možeš dodavati sportaše samo u svoj klub") + if ut in ('savez_admin','savez_user'): + if user.get('savez_id'): + klub = db_one("SELECT savez_id FROM pgz_sport.klubovi WHERE id=%s", (req.klub_id,)) + if klub and klub.get('savez_id') != user['savez_id']: + raise HTTPException(403, "Klub nije u tvom savezu") + + # Slug + name = (req.ime + ' ' + req.prezime).strip() + slug = re.sub(r'[^\w]+','-', name.lower()).strip('-') + + new_id = db_one("""INSERT INTO pgz_sport.clanovi + (ime, prezime, klub_id, datum_rodenja, mjesto_rodenja, broj_dresa, + pozicija, dominantna_noga, visina_cm, tezina_kg, slika_url, oib, + biografija, reprezentativac, reprezentacija_kategorija, + source, source_synced_at, slug) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'manual',now(),%s) + RETURNING id""", + (req.ime, req.prezime, req.klub_id, req.datum_rodenja, req.mjesto_rodenja, + req.broj_dresa, req.pozicija, req.dominantna_noga, req.visina_cm, req.tezina_kg, + req.slika_url, req.oib, req.biografija, req.reprezentativac, + req.reprezentacija_kategorija, slug))['id'] + + db_exec("INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,%s)", + (user['user_id'], f'sportas.create:{new_id}')) + return {"id": new_id, "ime": req.ime, "prezime": req.prezime, "klub_id": req.klub_id} + + +# ═══════════════════════════════════════════════════════ +# RUČNI UNOS UTAKMICA (klub admin → po igraču) +# ═══════════════════════════════════════════════════════ + +class CreateUtakmicaLogReq(BaseModel): + clan_id: int + za_klub_id: int + datum: str + natjecanje: Optional[str] = None + klub_dom: Optional[str] = None + klub_gost: Optional[str] = None + rezultat: Optional[str] = None + pogodaka: Optional[int] = 0 + zuti_kartoni: Optional[int] = 0 + crveni_kartoni: Optional[int] = 0 + minute: Optional[int] = None + zapocet_kao_starter: Optional[bool] = True + +@router.post("/utakmice/log") +def create_utakmica_log(req: CreateUtakmicaLogReq, user = Depends(require_user)): + """Klub admin can log a match for a sportaš in their klub.""" + ut = user.get('user_type') + if ut not in ('super_admin','pgz_admin','klub_admin','klub_user','savez_admin'): + raise HTTPException(403, "Forbidden") + + # Verify sportaš belongs to klub + c = db_one("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", (req.clan_id,)) + if not c: raise HTTPException(404, "Sportaš ne postoji") + if ut in ('klub_admin','klub_user'): + if user.get('klub_id') and user['klub_id'] != c['klub_id']: + raise HTTPException(403, "Sportaš nije u tvom klubu") + + new_id = db_one("""INSERT INTO pgz_sport.utakmice_log + (clan_id, za_klub_id, datum, natjecanje, klub_dom, klub_gost, rezultat, + pogodaka, zuti_kartoni, crveni_kartoni, minute, zapocet_kao_starter, source) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'manual') + RETURNING id""", + (req.clan_id, req.za_klub_id, req.datum, req.natjecanje, + req.klub_dom, req.klub_gost, req.rezultat, req.pogodaka, + req.zuti_kartoni, req.crveni_kartoni, req.minute, req.zapocet_kao_starter))['id'] + + return {"id": new_id, "clan_id": req.clan_id} + + +# ═══════════════════════════════════════════════════════ +# OSOBE / FUNKCIONARI — sportski rukovoditelji PGŽ +# ═══════════════════════════════════════════════════════ + +@router.get("/osobe-funkcije/list") +def list_osobe_funkcije(sport: Optional[str] = None, savez_id: Optional[int] = None, + q: Optional[str] = None, limit: int = 100): + """List funkcionare (rukovoditelji saveza/klubova).""" + where = [] + params = [] + if sport: + where.append("sport ILIKE %s"); params.append(f"%{sport}%") + if savez_id: + where.append("savez_id = %s"); params.append(savez_id) + if q: + where.append("(ime ILIKE %s OR prezime ILIKE %s OR funkcija ILIKE %s OR organizacija ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%", f"%{q}%"]) + where.append("o.aktivan = true") + sql = f""" + SELECT o.id, o.ime, o.prezime, o.funkcija, o.sport, o.organizacija, + o.izvor, o.izvor_url, o.mandate_od, o.mandate_do, + o.kontakt_email, o.kontakt_tel, o.savez_id, o.klub_id, + s.naziv AS savez_naziv, k.naziv AS klub_naziv + FROM pgz_sport.osobe_funkcije o + LEFT JOIN pgz_sport.savezi s ON s.id=o.savez_id + LEFT JOIN pgz_sport.klubovi k ON k.id=o.klub_id + WHERE {" AND ".join(where)} + ORDER BY o.organizacija NULLS LAST, o.sport NULLS LAST, o.prezime, o.ime + LIMIT %s""" + params.append(limit) + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +@router.get("/osobe-funkcije/by-sport") +def osobe_by_sport(): + """Group funkcionare by sport.""" + rows = db_query(""" + SELECT sport, count(*) AS osoba_count, + json_agg(json_build_object( + 'id', id, 'ime', ime, 'prezime', prezime, + 'funkcija', funkcija, 'organizacija', organizacija + ) ORDER BY ime) AS osobe + FROM pgz_sport.osobe_funkcije + WHERE sport IS NOT NULL AND aktivan=true + GROUP BY sport + ORDER BY sport""") + return {"count": len(rows), "results": rows} + + +# ═══════════════════════════════════════════════════════ +# DOBNE KATEGORIJE (auto-assign po sportu i godini rođenja) +# ═══════════════════════════════════════════════════════ + +class AutoAssignReq(BaseModel): + datum_rodenja: str # YYYY-MM-DD + sport: str + referentna_godina: Optional[int] = None # default: tekuća + spol: Optional[str] = 'MIX' + +@router.get("/dobne-kategorije/list") +def list_dobne_kategorije(sport: Optional[str] = None): + """List sve dobne kategorije, optionally filtrirano po sportu.""" + where = ["aktivan = true"] + params = [] + if sport: + where.append("LOWER(sport) = LOWER(%s)") + params.append(sport) + sql = f"""SELECT id, sport, naziv, oznaka, min_godina, max_godina, + spol, organizacija, redoslijed, napomena, promocija_dozvoljena + FROM pgz_sport.dobne_kategorije + WHERE {' AND '.join(where)} + ORDER BY sport, redoslijed""" + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +@router.post("/dobne-kategorije/auto-assign") +def auto_assign_categories(req: AutoAssignReq): + """Iz datuma rođenja + sporta vraća primjenjive kategorije. + Sportaš može biti u više kategorija (npr. mladi koji su pozvani u stariju selekciju). + + Returns: + primary: kategorija najbolje odgovara dobi + additional: ostale primjenjive kategorije (mlađe koje uključuju) + promocije: kategorije u koje se može promovirati (sljedeća stariju) + neeligible: kategorije za koje je presta(la)o pravo + """ + from datetime import date as _date, datetime as _dt + try: + dob = _dt.strptime(req.datum_rodenja, '%Y-%m-%d').date() + except: + raise HTTPException(400, "datum_rodenja mora biti YYYY-MM-DD format") + + ref_god = req.referentna_godina or _date.today().year + starost = ref_god - dob.year + + rows = db_query("""SELECT id, sport, naziv, oznaka, min_godina, max_godina, + organizacija, redoslijed, napomena, promocija_dozvoljena + FROM pgz_sport.dobne_kategorije + WHERE LOWER(sport) = LOWER(%s) AND aktivan = true + ORDER BY redoslijed""", (req.sport,)) + + if not rows: + return { + "starost": starost, + "datum_rodenja": req.datum_rodenja, + "sport": req.sport, + "primary": None, + "additional": [], + "promocije": [], + "neeligible": [], + "warning": f"Nema definiranih dobnih kategorija za sport '{req.sport}'" + } + + primary = None + additional = [] + promocije = [] + neeligible = [] + + # Pravilo: Sportaš pripada svim kategorijama gdje je njegova dob unutar [min, max]. + # Primarna = najmlađa (najniži redoslijed) gdje pripada. + # Promocije = sljedeća jedna ili dvije starije (mladi se često promoviraju u stariju selekciju). + # Neeligible = kategorije gdje je dob preuska/iznad max. + + eligible_idx = [] + for i, k in enumerate(rows): + mn = k['min_godina'] if k['min_godina'] is not None else 0 + mx = k['max_godina'] if k['max_godina'] is not None else 200 + in_range = mn <= starost <= mx + if in_range: + eligible_idx.append(i) + + if eligible_idx: + primary = rows[eligible_idx[0]] + for j in eligible_idx[1:]: + additional.append(rows[j]) + # Promocije = sljedeće 1-2 starije kategorije od primary (po redoslijed) + primary_redoslijed = primary['redoslijed'] + promocije_kandidati = [k for k in rows if k['redoslijed'] > primary_redoslijed and k.get('promocija_dozvoljena', True)] + # Filtriraj samo one čija je donja granica blizu starosti (max +3 godine razlike) + promocije = [] + for k in sorted(promocije_kandidati, key=lambda x: x['redoslijed'])[:3]: + mn = k['min_godina'] or 0 + if mn - starost <= 3: # može se promovirati ako je razlika ≤ 3 godine + promocije.append(k) + # Neeligible = kategorije gdje je presta(la)o pravo + neeligible = [k for k in rows if k['redoslijed'] < primary_redoslijed and + (k.get('max_godina') is not None) and starost > k['max_godina']] + else: + # Nije eligible u nijednu kategoriju (presta vrlo mlad ili previše star) + # Pokušaj naći najbližu nižu po starosti + for k in rows: + if k.get('max_godina') and starost > k['max_godina']: + neeligible.append(k) + else: + if not primary: + primary = k + else: + additional.append(k) + + return { + "starost": starost, + "datum_rodenja": req.datum_rodenja, + "referentna_godina": ref_god, + "sport": req.sport, + "primary": primary, + "additional": additional, + "promocije": promocije, + "neeligible": neeligible, + "ukupno_dostupno": len(rows), + } + + +@router.post("/sportas/{cid}/recalc-categories") +def recalc_sportas_categories(cid: int, sport: Optional[str] = None, user = Depends(require_user)): + """Re-izračunaj kategorije za sportaša na osnovi datuma rođenja + sport.""" + s = db_one("""SELECT c.id, c.ime, c.prezime, c.datum_rodenja, c.sport, + k.sport AS klub_sport, c.kategorije + FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE c.id=%s""", (cid,)) + if not s: raise HTTPException(404, "Sportaš ne postoji") + if not s.get('datum_rodenja'): + return {"id": cid, "primary": None, "kategorije": [], "promocije": [], + "warning": "Nema datum_rodenja"} + + sport_use = sport or s.get('sport') or s.get('klub_sport') + if not sport_use: + return {"id": cid, "primary": None, "kategorije": [], "promocije": [], + "warning": "Nema sporta (klub_sport ni sport stupac)"} + + # Use auto_assign internally + from datetime import date as _date + dob_str = str(s['datum_rodenja'])[:10] + req = AutoAssignReq(datum_rodenja=dob_str, sport=sport_use) + result = auto_assign_categories(req) + + primary_oznaka = result['primary']['oznaka'] if result.get('primary') else None + primary_naziv = result['primary']['naziv'] if result.get('primary') else None + additional_ozn = [r['oznaka'] for r in result.get('additional', []) if r.get('oznaka')] + promocije_ozn = [r['oznaka'] for r in result.get('promocije', []) if r.get('oznaka')] + + # Save to clanovi.kategorije and promocija_kategorije + kategorije = [] + if primary_oznaka: kategorije.append(primary_oznaka) + kategorije.extend(additional_ozn) + + db_exec("""UPDATE pgz_sport.clanovi + SET kategorije=%s, + promocija_kategorije=%s, + sport=COALESCE(sport, %s), + auto_kategorija_calc_at=now() + WHERE id=%s""", + (kategorije, promocije_ozn, sport_use, cid)) + + return { + "id": cid, + "ime": s['ime'], "prezime": s.get('prezime'), + "starost": result['starost'], + "sport": sport_use, + "primary": primary_naziv, + "primary_oznaka": primary_oznaka, + "kategorije": kategorije, + "promocije": promocije_ozn, + "details": result, + } + + +@router.post("/sportas/recalc-all-categories") +def recalc_all_categories(user = Depends(require_user)): + """Bulk re-izračun kategorija za sve sportaše sa datum_rođenja + sport.""" + if user.get('user_type') not in ('super_admin', 'pgz_admin'): + raise HTTPException(403, "Forbidden — samo admin") + + rows = db_query("""SELECT c.id, c.datum_rodenja, COALESCE(c.sport, k.sport) AS sport + FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE c.datum_rodenja IS NOT NULL""") + + n_updated = 0; n_skipped = 0; n_errors = 0 + from datetime import date as _date + for r in rows: + if not r.get('sport'): + n_skipped += 1; continue + try: + dob_str = str(r['datum_rodenja'])[:10] + req = AutoAssignReq(datum_rodenja=dob_str, sport=r['sport']) + result = auto_assign_categories(req) + primary_ozn = result['primary']['oznaka'] if result.get('primary') else None + additional_ozn = [k['oznaka'] for k in result.get('additional', []) if k.get('oznaka')] + promocije_ozn = [k['oznaka'] for k in result.get('promocije', []) if k.get('oznaka')] + kategorije = [] + if primary_ozn: kategorije.append(primary_ozn) + kategorije.extend(additional_ozn) + db_exec("""UPDATE pgz_sport.clanovi + SET kategorije=%s, promocija_kategorije=%s, sport=%s, + auto_kategorija_calc_at=now() WHERE id=%s""", + (kategorije, promocije_ozn, r['sport'], r['id'])) + n_updated += 1 + except Exception as e: + n_errors += 1 + + return {"updated": n_updated, "skipped_no_sport": n_skipped, "errors": n_errors} + + +@router.get("/dobne-kategorije/by-sport") +def kategorije_grouped(): + """Group kategorije po sportu — for GUI display.""" + rows = db_query("""SELECT sport, + json_agg(json_build_object( + 'id', id, 'naziv', naziv, 'oznaka', oznaka, + 'min_godina', min_godina, 'max_godina', max_godina, + 'organizacija', organizacija, 'redoslijed', redoslijed, + 'napomena', napomena, 'promocija_dozvoljena', promocija_dozvoljena + ) ORDER BY redoslijed) AS kategorije, + count(*) AS broj + FROM pgz_sport.dobne_kategorije WHERE aktivan=true + GROUP BY sport ORDER BY sport""") + return {"count": len(rows), "results": rows} + + +# ═══════════════════════════════════════════════════════ +# DOKUMENTI / ZAKONI / PRAVILNICI — RAG search + AI agent +# ═══════════════════════════════════════════════════════ + +import requests as _rq + +QDRANT_URL = "http://10.10.0.2:6333" +DOK_COLL = "pgz_sport_dokumenti_v1" +EMBED_URL = "http://localhost:9879/api/embeddings" + +def _embed_query(text: str): + r = _rq.post(EMBED_URL, json={"model":"bge-m3","prompt":text}, timeout=20) + j = r.json() + return j.get('embedding') or (j.get('data') or [{}])[0].get('embedding') + +@router.get("/dokumenti/list") +def list_dokumenti(razina: Optional[str] = None, vrsta: Optional[str] = None, + organizacija: Optional[str] = None, sport: Optional[str] = None, + q: Optional[str] = None, limit: int = 200): + """Filterable list svih dokumenata.""" + where = ["COALESCE(aktivan,true)=true"] + params = [] + if razina: where.append("razina = %s"); params.append(razina) + if vrsta: where.append("vrsta = %s"); params.append(vrsta) + if organizacija: where.append("organizacija = %s"); params.append(organizacija) + if sport: where.append("LOWER(sport) = LOWER(%s)"); params.append(sport) + if q: + where.append("(title ILIKE %s OR kratak_opis ILIKE %s OR organizacija ILIKE %s)") + params.extend([f'%{q}%', f'%{q}%', f'%{q}%']) + sql = f"""SELECT id, title AS naziv, kratak_opis, vrsta, razina, organizacija, + sport, sluzbeni_glasnik, izvor_url, kljucne_rijeci, izdano_datum, + CASE WHEN sadrzaj IS NOT NULL THEN length(sadrzaj) ELSE 0 END AS bytes + FROM pgz_sport.dokumenti WHERE {' AND '.join(where)} + ORDER BY razina, vrsta, title LIMIT %s""" + params.append(limit) + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +@router.get("/dokumenti/by-razina") +def dokumenti_grouped(): + """Group po razini i vrsti — for dashboard.""" + rows = db_query("""SELECT razina, vrsta, count(*) AS broj + FROM pgz_sport.dokumenti + WHERE COALESCE(aktivan,true)=true + GROUP BY razina, vrsta ORDER BY razina, vrsta""") + return {"count": len(rows), "results": rows} + +@router.get("/dokumenti/{did:int}") +def get_dokument(did: int): + """Full dokument view with content.""" + d = db_one("""SELECT id, title AS naziv, kratak_opis, sadrzaj, vrsta, razina, + organizacija, sport, sluzbeni_glasnik, izvor_url, pdf_url, + kljucne_rijeci, izdano_datum, godina + FROM pgz_sport.dokumenti WHERE id=%s""", (did,)) + if not d: raise HTTPException(404, "Dokument ne postoji") + chunks = db_query("""SELECT id, chunk_index, chunk_text, chunk_tokens + FROM pgz_sport.dokument_chunks WHERE dokument_id=%s + ORDER BY chunk_index""", (did,)) + return {"dokument": d, "chunks": chunks, "chunks_count": len(chunks)} + +class DocSearchReq(BaseModel): + q: str + limit: Optional[int] = 10 + razina: Optional[str] = None + sport: Optional[str] = None + +@router.post("/dokumenti/search") +def search_dokumenti(req: DocSearchReq): + """RAG search — vector similarity search across chunks.""" + try: + vec = _embed_query(req.q) + if not vec: + raise HTTPException(500, "Embedding failed") + except Exception as e: + raise HTTPException(500, f"Embed error: {e}") + + qdrant_filter = None + must = [] + if req.razina: + must.append({"key":"razina","match":{"value": req.razina}}) + if req.sport: + must.append({"key":"sport","match":{"value": req.sport}}) + if must: + qdrant_filter = {"must": must} + + body = { + "vector": vec, + "limit": req.limit or 10, + "with_payload": True, + } + if qdrant_filter: body["filter"] = qdrant_filter + + r = _rq.post(f"{QDRANT_URL}/collections/{DOK_COLL}/points/search", + json=body, timeout=15) + if r.status_code != 200: + raise HTTPException(500, f"Qdrant error: {r.text[:200]}") + + hits = r.json().get("result", []) + + # Enrich with full chunk text + results = [] + seen_dok = set() + for h in hits: + p = h.get("payload", {}) + dok_id = p.get("dokument_id") + chunk = db_one("""SELECT chunk_text FROM pgz_sport.dokument_chunks + WHERE dokument_id=%s AND chunk_index=%s""", + (dok_id, p.get("chunk_index", 0))) + results.append({ + "dokument_id": dok_id, + "naziv": p.get("title"), + "vrsta": p.get("vrsta"), + "razina": p.get("razina"), + "organizacija": p.get("organizacija"), + "sport": p.get("sport"), + "izvor_url": p.get("izvor_url"), + "score": round(h.get("score", 0), 4), + "snippet": (chunk["chunk_text"][:400] if chunk else p.get("preview","")) + "...", + }) + + return {"query": req.q, "count": len(results), "results": results} + +class DocAskReq(BaseModel): + q: str + limit_context: Optional[int] = 5 + +@router.post("/dokumenti/ask") +def ask_legal_expert(req: DocAskReq): + """AI legal expert — RAG + DeepSeek V3 odgovor s citiranjem.""" + # 1. RAG retrieval + try: + vec = _embed_query(req.q) + if not vec: + return {"answer":"Greška u embeddingu pitanja.","sources":[]} + except Exception as e: + return {"answer":f"Embedding greška: {e}","sources":[]} + + r = _rq.post(f"{QDRANT_URL}/collections/{DOK_COLL}/points/search", + json={"vector":vec, "limit":req.limit_context or 5, "with_payload":True}, + timeout=15) + hits = r.json().get("result", []) + + # 2. Build context with sources + context_parts = [] + sources = [] + for i, h in enumerate(hits): + p = h.get("payload", {}) + dok_id = p.get("dokument_id") + chunk = db_one("""SELECT chunk_text FROM pgz_sport.dokument_chunks + WHERE dokument_id=%s AND chunk_index=%s""", + (dok_id, p.get("chunk_index", 0))) + text = chunk["chunk_text"] if chunk else p.get("preview","") + context_parts.append(f"[{i+1}] {p.get('title','?')} ({p.get('razina','?')} · {p.get('organizacija','?')}):\n{text}\n") + sources.append({ + "n": i+1, + "naziv": p.get("title"), + "razina": p.get("razina"), + "organizacija": p.get("organizacija"), + "izvor_url": p.get("izvor_url"), + "score": round(h.get("score",0),4), + }) + + context = "\n\n".join(context_parts) + + # 3. LLM via DeepSeek V3 + import os + +# ═══════════════════════════════════════════════════════════════════════════ +# ORCHESTRATOR DELEGATION (OS-first) +# Set USE_ORCHESTRATOR=1 in env to delegate sport queries to dabi-orchestrator +# instead of running pgz-sport's own LLM waterfall. +# Same brain, sport-domain prompt comes from orchestrator persona. +# ═══════════════════════════════════════════════════════════════════════════ +USE_ORCHESTRATOR = os.environ.get("USE_ORCHESTRATOR", "0") == "1" +ORCHESTRATOR_URL = os.environ.get("ORCHESTRATOR_URL", "http://localhost:8080/api/v3/ask") + +def delegate_to_orchestrator(question: str, persona: str = "sport", timeout: int = 60): + """Call dabi-orchestrator-v3 instead of running our own LLM waterfall. + Returns dict with answer + sources, compatible with sport_lawyer response.""" + import json, urllib.request + try: + body = json.dumps({"question": question, "persona": persona}).encode() + req = urllib.request.Request(ORCHESTRATOR_URL, data=body, + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=timeout) as r: + d = json.loads(r.read()) + return { + "answer": d.get("answer", ""), + "sources": d.get("sources", []) or [], + "intent": d.get("intent"), + "confidence": d.get("confidence"), + "llm": "orchestrator_v3", + "delegated": True, + } + except Exception as e: + return {"answer": "", "delegated": False, "error": str(e)} + + api_key = os.environ.get("DEEPSEEK_API_KEY") + if not api_key: + try: + with open("/opt/.env.rinet") as f: + for line in f: + if line.startswith("DEEPSEEK_API_KEY="): + api_key = line.strip().split("=",1)[1].strip("'\"") + break + except: pass + + if not api_key: + return { + "answer":"AI agent nije konfiguriran (nedostaje DEEPSEEK_API_KEY). Vraćam sirove rezultate pretrage.", + "sources":sources, "context": context_parts + } + + system = """Ti si vrhunski hrvatski stručnjak za sport, zakone i pravilnike — radiš za Zajednicu sportova Primorsko-goranske županije. +Odgovaraš na hrvatskom, kratko, oštro, profesionalno (kao iskusan pravnik za sport). + +PRAVILA: +1. Koristi ISKLJUČIVO informacije iz priloženih dokumenata. NIKAD ne izmišljaj. +2. Citiraj izvore brojevima [1], [2] itd. nakon svake tvrdnje koja zahtijeva izvor. +3. Ako kontekst djelomično odgovara — daj odgovor na temelju onoga što imaš + jasno označi što fali. +4. Ako kontekst NE sadrži odgovor — kaži "U dostupnim dokumentima nema odgovora na ovo pitanje" + predloži koji bi dokument bio relevantan. +5. Strukturiraj odgovor: GLAVNI ODGOVOR → DETALJI po točkama → CITIRANI IZVORI [1], [2]… +6. Za pitanja o postupcima (registracija, transfer, kategorizacija) — daj numeriranu listu koraka. +7. Za pitanja o financiranju — istakni iznose, rokove, kriterije. +8. Za PGŽ-specifična pitanja — fokusiraj se na PGZ i Grad Rijeka razinu, ali napomeni i RH/HOO razinu kad je relevantno. +9. Spomeni i nadležnu instituciju (HNS, HOO, HASMS, MTS, ZS PGŽ, itd.). +10. Ako se citirani članci, brojevi NN-a ili slični specifikumi pojavljuju u kontekstu — uvijek ih navedi.""" + + user_msg = f"PITANJE: {req.q}\n\nKONTEKST DOKUMENATA:\n{context}\n\nOdgovor:" + + try: + resp = _rq.post("https://api.deepseek.com/v1/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={ + "model":"deepseek-chat", + "messages":[ + {"role":"system","content": system}, + {"role":"user","content": user_msg} + ], + "temperature": 0.2, + "max_tokens": 800 + }, timeout=30) + if resp.status_code != 200: + return {"answer":f"LLM error {resp.status_code}: {resp.text[:200]}","sources":sources} + data = resp.json() + answer = data.get("choices",[{}])[0].get("message",{}).get("content","") + return {"answer": answer, "sources": sources, "model":"deepseek-v3"} + except Exception as e: + return {"answer":f"LLM error: {e}","sources":sources, "context":context_parts} + + +# ═══════════════════════════════════════════════════════ +# SPORTSKI OBJEKTI +# ═══════════════════════════════════════════════════════ +@router.get("/objekti/list") +def list_objekti(grad: Optional[str] = None, tip: Optional[str] = None, sport: Optional[str] = None): + where = ["aktivan = true"] + params = [] + if grad: where.append("grad = %s"); params.append(grad) + if tip: where.append("tip = %s"); params.append(tip) + if sport: where.append("%s = ANY(sportovi)"); params.append(sport) + sql = f"""SELECT id, naziv, tip, grad, adresa, upravitelj, kapacitet, sportovi, + izgradeno, natkrita, web, napomena, lat, lng + FROM pgz_sport.sportski_objekti WHERE {' AND '.join(where)} + ORDER BY grad, naziv""" + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +@router.get("/objekti/by-grad") +def objekti_by_grad(): + rows = db_query("""SELECT grad, count(*) AS broj, + array_agg(DISTINCT tip) AS tipovi + FROM pgz_sport.sportski_objekti WHERE aktivan = true + GROUP BY grad ORDER BY broj DESC""") + return {"count": len(rows), "results": rows} + +# ═══════════════════════════════════════════════════════ +# NATJECANJA (366) +# ═══════════════════════════════════════════════════════ +@router.get("/natjecanja/list") +def list_natjecanja(sport: Optional[str] = None, sezona: Optional[str] = None, + razina: Optional[str] = None, limit: int = 100): + where = [] + params = [] + if sport: where.append("LOWER(sport) = LOWER(%s)"); params.append(sport) + if sezona: where.append("sezona = %s"); params.append(sezona) + if razina: where.append("razina = %s"); params.append(razina) + where_clause = " WHERE " + " AND ".join(where) if where else "" + sql = f"""SELECT id, naziv, sport, razina, tip, sezona, kategorija, spol, + datum_pocetka, datum_zavrsetka, status, source, external_url + FROM pgz_sport.natjecanja {where_clause} + ORDER BY datum_pocetka DESC NULLS LAST, naziv LIMIT %s""" + params.append(limit) + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +# ═══════════════════════════════════════════════════════ +# MANIFESTACIJE (113) +# ═══════════════════════════════════════════════════════ +@router.get("/manifestacije/list") +def list_manifestacije(savez_id: Optional[int] = None, mjesto: Optional[str] = None, limit: int = 200): + where = ["aktivna = true"] + params = [] + if savez_id: where.append("savez_id = %s"); params.append(savez_id) + if mjesto: where.append("mjesto ILIKE %s"); params.append(f"%{mjesto}%") + sql = f"""SELECT m.id, m.naziv, m.mjesto, m.organizator, m.razina, m.broj_ucesnika, + m.godina_od, m.spol_kategorija, m.napomena, s.naziv AS savez_naziv, m.savez_id + FROM pgz_sport.manifestacije m + LEFT JOIN pgz_sport.savezi s ON s.id = m.savez_id + WHERE {' AND '.join(where)} + ORDER BY m.naziv LIMIT %s""" + params.append(limit) + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +# ═══════════════════════════════════════════════════════ +# NAJBOLJI SPORTAŠI (22) +# ═══════════════════════════════════════════════════════ +@router.get("/najbolji/list") +def list_najbolji(godina: Optional[int] = None): + where = [] + params = [] + if godina: where.append("godina = %s"); params.append(godina) + sql = f"""SELECT id, godina, kategorija, ime_prezime, klub, sport, napomena + FROM pgz_sport.najbolji_sportasi + {('WHERE ' + ' AND '.join(where)) if where else ''} + ORDER BY godina DESC, kategorija""" + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +# ═══════════════════════════════════════════════════════ +# POTPORE NOSITELJIMA KVALITETE (182) +# ═══════════════════════════════════════════════════════ +@router.get("/potpore/list") +def list_potpore(godina: Optional[int] = None): + where = [] + params = [] + if godina: where.append("godina = %s"); params.append(godina) + sql = f"""SELECT p.id, p.naziv_kluba, p.godina, p.iznos, p.napomena, p.klub_id, k.sport + FROM pgz_sport.potpore_nositelji p + LEFT JOIN pgz_sport.klubovi k ON k.id = p.klub_id + {('WHERE ' + ' AND '.join(where)) if where else ''} + ORDER BY p.godina DESC, p.iznos DESC""" + rows = db_query(sql, params) + return {"count": len(rows), "results": rows, + "total_iznos": sum(float(r.get('iznos') or 0) for r in rows)} + +@router.get("/potpore/by-godina") +def potpore_by_godina(): + rows = db_query("""SELECT godina, count(*) AS broj, sum(iznos) AS ukupno + FROM pgz_sport.potpore_nositelji + GROUP BY godina ORDER BY godina DESC""") + return {"count": len(rows), "results": rows} + +# ═══════════════════════════════════════════════════════ +# STATISTIKA SAVEZA (166) +# ═══════════════════════════════════════════════════════ +@router.get("/statistika/list") +def list_statistika(godina: Optional[int] = None, savez_id: Optional[int] = None): + where = [] + params = [] + if godina: where.append("ss.godina = %s"); params.append(godina) + if savez_id: where.append("ss.savez_id = %s"); params.append(savez_id) + sql = f"""SELECT ss.id, ss.savez_id, s.naziv AS savez_naziv, ss.godina, + ss.klubova_clanica, ss.kategoriziranih, ss.registriranih, ss.rekreativaca, + ss.trenera, ss.reprezentativaca, ss.stipendiranih, ss.zaposlenika + FROM pgz_sport.statistika_saveza ss + LEFT JOIN pgz_sport.savezi s ON s.id = ss.savez_id + {('WHERE ' + ' AND '.join(where)) if where else ''} + ORDER BY ss.godina DESC, s.naziv""" + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +# ═══════════════════════════════════════════════════════ +# VIJESTI (286) +# ═══════════════════════════════════════════════════════ +@router.get("/vijesti/list") +def list_vijesti(limit: int = 30): + rows = db_query("""SELECT id, title AS naslov, scraped_at AS datum, kind AS kategorija, + substring(body, 1, 200) AS sazetak, url + FROM pgz_sport.vijesti + WHERE title IS NOT NULL + ORDER BY scraped_at DESC NULLS LAST LIMIT %s""", (limit,)) + return {"count": len(rows), "results": rows} + + +# ═══════════════════════════════════════════════════════ +# SUCI / TRENERI / SPONZORI / MEDIJI / AKADEMSKI SPORT +# ═══════════════════════════════════════════════════════ + +@router.get("/suci/list") +def list_suci(sport: Optional[str] = None, grad: Optional[str] = None): + where = ["aktivan=true"]; params = [] + if sport: where.append("LOWER(sport)=LOWER(%s)"); params.append(sport) + if grad: where.append("LOWER(grad)=LOWER(%s)"); params.append(grad) + sql = f"""SELECT id, ime, prezime, sport, licenca, kategorija, organizacija, grad + FROM pgz_sport.suci WHERE {' AND '.join(where)} + ORDER BY sport, prezime, ime""" + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +@router.get("/treneri/list") +def list_treneri(sport: Optional[str] = None, klub_naziv: Optional[str] = None): + where = ["aktivan=true"]; params = [] + if sport: where.append("LOWER(sport)=LOWER(%s)"); params.append(sport) + if klub_naziv: where.append("klub_naziv ILIKE %s"); params.append(f"%{klub_naziv}%") + sql = f"""SELECT id, ime, prezime, sport, licenca, organizacija, klub_naziv, pozicija, grad + FROM pgz_sport.treneri WHERE {' AND '.join(where)} + ORDER BY sport, klub_naziv, pozicija, prezime""" + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +@router.get("/sponzori/list") +def list_sponzori(klub: Optional[str] = None): + where = ["aktivan=true"]; params = [] + if klub: where.append("naziv_kluba ILIKE %s"); params.append(f"%{klub}%") + sql = f"""SELECT id, naziv_kluba, sponzor, tip, razdoblje_od, razdoblje_do, iznos_eur, napomena + FROM pgz_sport.sponzori WHERE {' AND '.join(where)} + ORDER BY naziv_kluba, tip, sponzor""" + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +@router.get("/mediji/list") +def list_mediji(tip: Optional[str] = None, grad: Optional[str] = None): + where = ["aktivan=true"]; params = [] + if tip: where.append("tip=%s"); params.append(tip) + if grad: where.append("LOWER(grad)=LOWER(%s)"); params.append(grad) + sql = f"""SELECT id, naziv, tip, grad, vlasnik, web, sport_fokus, pokrivenost + FROM pgz_sport.mediji WHERE {' AND '.join(where)} + ORDER BY tip, naziv""" + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +@router.get("/akademski/list") +def list_akademski(): + rows = db_query("""SELECT id, naziv, fakultet, sveuciliste, sport, sportovi, voditelj, + web, razina, broj_clanova + FROM pgz_sport.akademski_sport WHERE aktivan=true + ORDER BY fakultet, naziv""") + return {"count": len(rows), "results": rows} + + +# ═══════════════════════════════════════════════════════ +# HYBRID AI AGENT — SQL + RAG router +# ═══════════════════════════════════════════════════════ + +# Schema descriptor — što AI zna o bazi (kratko, fokusirano) +SCHEMA_HINT = """ +TABLES PGŽ SPORT (čitanje samo, koristi pgz_sport.X notaciju): + +-- KLUBOVI: 1086 PGŽ klubova +pgz_sport.klubovi (id, naziv, sport, oib, savez_id, grad, adresa, telefon, email, web, + godina_osnutka, broj_clanova, predsjednik, tajnik, region, sjediste) + +-- SPORTAŠI: 1129 +pgz_sport.clanovi (id, ime, prezime, klub_id, datum_rodenja, sport, spol, pozicija, + broj_dresa, dominantna_noga, kategorije TEXT[], promocija_kategorije TEXT[], oib, biografija, slika_url) + +-- SAVEZI: 220 +pgz_sport.savezi (id, naziv, razina, oib, sport, sjediste, predsjednik, web) + +-- SPORTSKI OBJEKTI: 60 +pgz_sport.sportski_objekti (id, naziv, tip, grad, adresa, upravitelj, kapacitet, + sportovi TEXT[], "izgrađeno", natkrita, web) + -- tip: 'stadion','dvorana','bazen','klizalište','marina','strelište','boćalište','kompleks','tenis kompleks',... + +-- SUCI PGŽ: 27 +pgz_sport.suci (id, ime, prezime, sport, licenca, kategorija, organizacija, grad, aktivan) + +-- TRENERI PGŽ: 30 +pgz_sport.treneri (id, ime, prezime, sport, licenca, organizacija, klub_naziv, pozicija, grad) + -- pozicija: 'glavni','pomoćni','kondicijski','konzultant' + +-- SPONZORI: 22 ugovora +pgz_sport.sponzori (id, naziv_kluba, sponzor, tip, razdoblje_od, iznos_eur, napomena) + +-- MEDIJI: 15 +pgz_sport.mediji (id, naziv, tip, grad, vlasnik, web, sport_fokus TEXT[], pokrivenost) + +-- AKADEMSKI SPORT UNIRI: 11 +pgz_sport.akademski_sport (id, naziv, fakultet, sveuciliste, sport, sportovi TEXT[], voditelj, web, razina, broj_clanova) + +-- NATJECANJA KALENDAR: 366 +pgz_sport.natjecanja (id, sport, naziv, razina, tip, sezona, kategorija, spol, + datum_pocetka, datum_zavrsetka, status, savez_id) + +-- MANIFESTACIJE: 113 +pgz_sport.manifestacije (id, naziv, mjesto, organizator, razina, broj_ucesnika, godina_od, savez_id) + +-- NAJBOLJI SPORTAŠI PGŽ: 22 godišnjih nagrada +pgz_sport.najbolji_sportasi (id, godina, kategorija, ime_prezime, klub, sport) + +-- POTPORE: 182 isplate +pgz_sport.potpore_nositelji (id, klub_id, naziv_kluba, godina, iznos) + -- iznosi su EUR + +-- STATISTIKA SAVEZA godišnja: 166 +pgz_sport.statistika_saveza (id, savez_id, godina, klubova_clanica, kategoriziranih, + registriranih, rekreativaca, trenera, reprezentativaca, stipendiranih, zaposlenika) + +-- DOBNE KATEGORIJE: 127 +pgz_sport.dobne_kategorije (id, sport, naziv, oznaka, min_godina, max_godina, organizacija) + +-- FUNKCIONARI saveza/klubova: 155 +pgz_sport.osobe_funkcije (id, ime, prezime, funkcija, sport, savez_id, klub_id, organizacija) + +-- VIJESTI: 286 +pgz_sport.vijesti (id, naslov, datum, kategorija, sazetak, url) + +-- DOKUMENTI / ZAKONI / PRAVILNICI: 176 (kolona naziv = title) +pgz_sport.dokumenti (id, title, kratak_opis, vrsta, razina, organizacija, sport, sluzbeni_glasnik, izvor_url, kljucne_rijeci, sadrzaj) + +-- UTAKMICE log: 5017 +pgz_sport.utakmice_log (id, datum, sport, klub_dom, klub_gost, rezultat, klub_id, sezona) +""" + +class HybridAskReq(BaseModel): + q: str + +@router.post("/ai/ask") +def hybrid_ai_ask(req: HybridAskReq): + """Hybrid agent — odluči SQL vs RAG vs oba. + Workflow: + 1. LLM klasificira pitanje (SQL / RAG / oba) + 2. Ako SQL: generira SELECT, izvršava, formira odgovor + 3. Ako RAG: vector search dokumentima + 4. Ako oba: kombinira + """ + import os + api_key = os.environ.get("DEEPSEEK_API_KEY") + if not api_key: + try: + with open("/opt/.env.rinet") as f: + for line in f: + if line.startswith("DEEPSEEK_API_KEY="): + api_key = line.strip().split("=",1)[1].strip("'\"") + break + except: pass + + if not api_key: + return {"answer":"AI agent nije konfiguriran","mode":"error"} + + # STEP 1: Classify + generate SQL if applicable + classify_prompt = f"""Ti si SQL ekspert za PGŽ sport bazu. Korisnik je pitao: + +PITANJE: {req.q} + +DOSTUPNE TABLICE: +{SCHEMA_HINT} + +ODLUČI: +1) SQL — ako pitanje traži operativne podatke iz tablica (imena trenera, popis objekata, statistike, brojevi, lokacije, najbolji) +2) RAG — ako pitanje traži pravne/regulativne info (zakoni, pravilnici, postupci, propisi, definicije) +3) BOTH — ako traži oba + +VRATI VALIDAN JSON OBJEKT (bez markdown): +{{"mode":"SQL"|"RAG"|"BOTH", "sql": "SELECT ...", "rag_query": "..."}} + +PRAVILA SQL: +- Koristi LIMIT 30 +- ILIKE za fuzzy match +- Koristi LOWER() za case-insensitive +- NIKAD UPDATE/DELETE/INSERT/DROP +- Samo SELECT iz pgz_sport.* tablica +- Koristi join-ove gdje treba + +PRIMJERI: +"Tko je trener HNK Rijeke?" → SQL: SELECT ime, prezime, pozicija, licenca FROM pgz_sport.treneri WHERE klub_naziv ILIKE '%HNK Rijeka%' +"Sportski objekti Rijeka" → SQL: SELECT naziv, tip, adresa, kapacitet, sportovi FROM pgz_sport.sportski_objekti WHERE grad='Rijeka' ORDER BY tip, naziv LIMIT 30 +"Najbolji sportaši 2025" → SQL: SELECT kategorija, ime_prezime, klub FROM pgz_sport.najbolji_sportasi WHERE godina=2025 +"Koje obveze ima sportski klub po Zakonu o sportu" → RAG: question +"Suci nogometa u PGŽ" → SQL: SELECT ime, prezime, licenca, kategorija FROM pgz_sport.suci WHERE sport='nogomet' +"Sponzori HNK Rijeka" → SQL: SELECT sponzor, tip, razdoblje_od FROM pgz_sport.sponzori WHERE naziv_kluba ILIKE '%HNK Rijeka%' +"Koliko klubova ima Boćarski savez PGŽ" → SQL: SELECT klubova_clanica FROM pgz_sport.statistika_saveza ss JOIN pgz_sport.savezi s ON s.id=ss.savez_id WHERE s.naziv ILIKE '%Boćarski savez%' ORDER BY godina DESC LIMIT 1 +""" + + try: + resp = _rq.post("https://api.deepseek.com/v1/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type":"application/json"}, + json={ + "model":"deepseek-chat", + "messages":[{"role":"user","content": classify_prompt}], + "temperature": 0.0, "max_tokens": 500, + "response_format": {"type":"json_object"} + }, timeout=20) + plan = resp.json()["choices"][0]["message"]["content"] + import json as _json + plan_obj = _json.loads(plan) + except Exception as e: + return {"answer":f"Klasifikacija greška: {e}","mode":"error"} + + mode = plan_obj.get("mode","SQL").upper() + sql = plan_obj.get("sql","") + rag_q = plan_obj.get("rag_query", req.q) + + sql_results = [] + rag_sources = [] + + # STEP 2a: Execute SQL if needed + if mode in ("SQL","BOTH") and sql: + # Safety: only SELECT, only pgz_sport.* + sql_lower = sql.lower().strip() + if not sql_lower.startswith("select") or any(x in sql_lower for x in ["update ","delete ","insert ","drop ","alter ","create ","truncate ","grant ","revoke ","exec "]): + return {"answer":"SQL nije siguran","mode":"error","sql": sql} + try: + # Direct execution bez parameter binding to avoid % placeholder issues + import psycopg2 as _ps2, psycopg2.extras as _pse2 + with _ps2.connect(**DB) as _c: + _cur = _c.cursor(cursor_factory=_pse2.RealDictCursor) + _cur.execute(sql) # no params, raw SQL + sql_results = _cur.fetchall() if _cur.description else [] + except Exception as e: + return {"answer":f"SQL greška: {e}","mode":"sql_error","sql":sql} + + # STEP 2b: Execute RAG if needed + rag_context = "" + if mode in ("RAG","BOTH"): + try: + vec = _embed_query(rag_q) + r = _rq.post(f"{QDRANT_URL}/collections/{DOK_COLL}/points/search", + json={"vector":vec, "limit":4, "with_payload":True}, timeout=15) + hits = r.json().get("result",[]) + for i, h in enumerate(hits): + p_data = h.get("payload",{}) + ch = db_one("""SELECT chunk_text FROM pgz_sport.dokument_chunks + WHERE dokument_id=%s AND chunk_index=%s""", + (p_data.get("dokument_id"), p_data.get("chunk_index",0))) + txt = ch["chunk_text"] if ch else p_data.get("preview","") + rag_context += f"[{i+1}] {p_data.get('title','?')}: {txt[:600]}\n\n" + rag_sources.append({"n":i+1, "naziv":p_data.get("title"), + "razina":p_data.get("razina"), "organizacija":p_data.get("organizacija"), + "izvor_url":p_data.get("izvor_url"), "score": round(h.get("score",0),3)}) + except Exception as e: + rag_context = "" + + # STEP 3: Final answer + final_prompt = f"""Ti si stručnjak za PGŽ sport. Korisnik pitao: + +PITANJE: {req.q} + +""" + if sql_results: + import json as _json + final_prompt += f"REZULTATI SQL UPITA ({len(sql_results)} redaka):\n{_json.dumps(sql_results, ensure_ascii=False, default=str)[:3000]}\n\n" + if rag_context: + final_prompt += f"PRAVNI/REGULATIVNI KONTEKST:\n{rag_context}\n\n" + + final_prompt += """Daj kratak, precizan, profesionalan odgovor na hrvatskom. +Koristi konkretne podatke iz SQL rezultata. +Citiraj pravne izvore brojevima [1][2] ako koristiš RAG. +Ako rezultati nisu dovoljni, kaži to iskreno. +Ne ponavljaj cijele tablice — sažmi i istakni najvažnije.""" + + try: + resp = _rq.post("https://api.deepseek.com/v1/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type":"application/json"}, + json={ + "model":"deepseek-chat", + "messages":[{"role":"user","content": final_prompt}], + "temperature": 0.2, "max_tokens": 800 + }, timeout=30) + answer = resp.json()["choices"][0]["message"]["content"] + except Exception as e: + return {"answer":f"Final greška: {e}","mode":mode,"sql":sql,"sql_results":sql_results[:3]} + + return { + "answer": answer, + "mode": mode, + "sql": sql if sql else None, + "sql_count": len(sql_results), + "sql_sample": sql_results[:5] if sql_results else None, + "sources": rag_sources if rag_sources else None, + } + + +# ═══════════════════════════════════════════════════════ +# TEXT-TO-SQL AGENT — operativna pitanja preko SQL +# ═══════════════════════════════════════════════════════ + +# Whitelist tablica koje SQL agent smije čitati +SQL_AGENT_TABLES = { + 'klubovi': 'Sportski klubovi PGŽ. Stupci: id, naziv, sport, oib, iban, web, email, telefon, adresa, grad, region, godina_osnutka, predsjednik, tajnik, broj_clanova, savez_id', + 'savezi': 'Sportski savezi (županijski/gradski/nacionalni). Stupci: id, naziv, razina, oib, sjediste, web, email', + 'clanovi': 'Sportaši. Stupci: id, ime, prezime, klub_id (može biti NULL!), sport, datum_rodenja, spol, kategorije TEXT[] (dobne kat npr. {U17} ili {OPEN}), pozicija, broj_dresa, oib, hoo_kategorija TEXT (rimski I/II/III/IV/V/VI), hoo_vrijedi_od/do DATE, klub_naziv_godisnjak TEXT (KORISTI OVO za pretragu klub-roster, npr. HNK Rijeka roster preko klub_naziv_godisnjak ILIKE \'%HNK Rijeka%\'). NAPOMENE: za broj sportaša u klubu, koristi klub_naziv_godisnjak ILIKE \'%X%\' (mnogi sportaši nemaju klub_id, ali imaju ime kluba u godišnjaku). NE JOIN-aj s dobne_kategorije.', + 'sportski_objekti': 'Sportske građevine PGŽ. Stupci: id, naziv, tip, grad, adresa, upravitelj, kapacitet, sportovi (array), izgradeno (BEZ Č - godina izgradnje), natkrita (bool), web. NAPOMENA: tip može biti dvorana/stadion/bazen/marina/klizalište/skijaški/strelište/boćalište.', + 'suci': 'Suci po sportovima. Stupci: id, ime, prezime, sport, licenca, kategorija, organizacija, grad', + 'treneri': 'Treneri klubova PGŽ. Stupci: id, ime, prezime, sport, licenca, organizacija, klub_naziv, pozicija, grad', + 'sponzori': 'Sponzorstva klubova. Stupci: id, naziv_kluba, sponzor, tip, razdoblje_od, iznos_eur, napomena', + 'mediji': 'Sportski mediji PGŽ. Stupci: id, naziv, tip, grad, vlasnik, web, sport_fokus (array), pokrivenost', + 'akademski_sport': 'UNIRI sportski klubovi. Stupci: id, naziv, fakultet, sport, sportovi (array), voditelj, web, razina, broj_clanova', + 'natjecanja': 'Sportska natjecanja. Stupci: id, naziv, sport, savez_id, razina, tip, sezona, kategorija, datum_pocetka, datum_zavrsetka, status', + 'manifestacije': 'Sportske manifestacije. Stupci: id, naziv, mjesto, organizator, razina, broj_ucesnika, godina_od, savez_id', + 'najbolji_sportasi': 'Godišnji najbolji/najuspješniji sportaši PGŽ (godišnja izborna lista). Stupci: id, godina, kategorija (npr. "Najuspješniji sportaš senior", "Najuspješnija sportašica seniorka", "Najuspješniji trener", "Sportski djelatnik godine", "Najuspješnija muška seniorska ekipa", "Najuspješniji parasportaš (motor/slijepi)", "Najuspješniji sportaš junior/kadet"), ime_prezime, klub, sport, napomena. NAPOMENE: za pretragu po kategoriji koristi ILIKE "%trener%" ili "%djelatnik%" jer kategorije imaju duga imena.', + 'potpore_nositelji': 'Financijske potpore klubovima. Stupci: id, naziv_kluba, klub_id, godina, iznos, napomena', + 'statistika_saveza': 'Godišnja statistika saveza. Stupci: id, savez_id, godina, klubova_clanica, kategoriziranih, registriranih, rekreativaca, trenera, reprezentativaca, stipendiranih', + 'vijesti': 'Sportske vijesti. Stupci: id, naslov, datum, kategorija, sazetak, url', + 'osobe_funkcije': 'Funkcionari klubova/saveza. Stupci: id, ime, prezime, funkcija, sport, savez_id, klub_id, organizacija', + 'dobne_kategorije': 'Dobne kategorije po sportu. Stupci: id, sport, naziv, oznaka, min_godina, max_godina, organizacija', + 'sportas_specifika': 'Sport-specifični podaci sportaša (1:N s clanovima). Stupci: id, clan_id, sport, pojas_titula (npr. "Velemajstor (GM)", "Olimpijka", "CMAS instructor"), rating, rating_sustav (FIDE/ATP/WA), najbolji_rezultat, najbolja_godina, hns_id, visina_cm, tezina_kg, nogometska_pozicija, napomena.', + 'klub_sezona': 'Klubovi po sezoni i trofejima. Stupci: id, klub_id, klub_naziv, sezona (npr. "2024/2025"), natjecanje (npr. "1. HNL", "PH rukomet", "SP Szekesfehérvár"), plasiranje, bodovi, trofej (npr. "PRVAK HRVATSKE", "OSVAJAČI KUPA", "SVJETSKO ZLATO"), napomena.', + 'utakmice_log': 'Utakmice log. Stupci: id, klub_id, sportas_id, datum, protivnik, rezultat, golovi, asistencije, zuti, crveni, sezona, sport, broj_natjecanja', +} + +def _db_schema_brief(): + parts = [] + for tbl, desc in SQL_AGENT_TABLES.items(): + parts.append(f"pgz_sport.{tbl} — {desc}") + return "\n".join(parts) + +def _sql_safe(sql: str) -> bool: + """Verify SQL is read-only and only touches whitelist tables.""" + sql_lower = sql.lower().strip() + # Block destructive + forbidden = ['insert', 'update', 'delete', 'drop', 'truncate', 'alter', 'create', 'grant', 'revoke', '--', ';--', 'pg_', 'into ', 'copy ', 'replace '] + for f in forbidden: + if f in sql_lower: return False + # Must be SELECT + if not sql_lower.startswith('select'): return False + # Multiple statements not allowed + if sql_lower.rstrip(';').count(';') > 0: return False + return True + +class AskSmartReq(BaseModel): + q: str + limit_context: Optional[int] = 5 + +@router.post("/dokumenti/ask-smart") +def ask_smart(req: AskSmartReq): + """Smart ask: prvo procijeni pitanje (operativno vs regulativno), + onda izvrši SQL (operativno) ili RAG (regulativno). + """ + import os + api_key = os.environ.get("DEEPSEEK_API_KEY") + if not api_key: + try: + with open("/opt/.env.rinet") as f: + for line in f: + if line.startswith("DEEPSEEK_API_KEY="): + api_key = line.strip().split("=",1)[1].strip("\'\"") + break + except: pass + + if not api_key: + return {"answer":"DEEPSEEK_API_KEY missing","sources":[],"mode":"error"} + + # Step 1: classify + classify_msg = f"""Pitanje korisnika: "{req.q}" + +Kategorija pitanja: +A) OPERATIVNO — pita konkretne entitete iz baze: + - Imena pojedinačnih trenera/sudaca/sportaša ("tko je X", "tko trenira Y") + - Liste s konkretnim redovima (objekti u Rijeci, sponzori HNK, suci nogomet) + - Brojevi entiteta (koliko klubova, sportaša, sudaca - count po kriteriju) + - Top N po metriki (top 5 klubova, najmlađi, najstariji) + - Trofeji klub_sezona (HNK Rijeka, KK Mlaka) + - HOO kategorije (I-VI), najbolji godine +B) REGULATIVNO/OPISNO — RAG iz dokumenata: + - Zakoni, pravilnici, statuti, etika, fair play + - Postupci (kako se registrira, licencira, kategorizira) + - Definicije pojmova + - PRORAČUNI ZS PGŽ (proračun, financiranje, potpore) + - PROGRAMI ZS PGŽ (programi, programske aktivnosti, sufinanciranje) + - SPORTSKI PREGLEDI (zdravstveni pregledi, ambulanta) + - Razvoj sporta, statistički pregledi cijele PGŽ scene + - Najuspješniji sportovi međunarodno + - Općenita pitanja "tko/što su X" ako nije named entity + +Odgovor SAMO jednim slovom: A ili B""" + + try: + cl_resp = _rq.post("https://api.deepseek.com/v1/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={"model":"deepseek-chat", + "messages":[{"role":"user","content": classify_msg}], + "temperature":0.0, "max_tokens":5}, timeout=20) + clss = cl_resp.json().get("choices",[{}])[0].get("message",{}).get("content","B").strip().upper() + mode = "SQL" if clss.startswith("A") else "RAG" + except: + mode = "RAG" + + # Step 2: SQL or RAG + if mode == "SQL": + # Generate SQL + schema = _db_schema_brief() + sql_prompt = f"""Ti si PostgreSQL ekspert. Schema: + +{schema} + +Pravila: +- KORISTI SAMO SELECT, nikad INSERT/UPDATE/DELETE/DROP/ALTER +- Schema je `pgz_sport.` +- Vrati SAMO čisti SQL, bez markdown ```, bez objašnjenja +- LIMIT 50 ako vraćaš listu +- Za pretragu po imenu koristi ILIKE '%text%' +- Datum_rodenja je DATE format +- Za pretragu sportaša po kategoriji: WHERE 'U17' = ANY(c.kategorije) — NIKAD JOIN s dobne_kategorije +- Stupci s kvačicama: koristi izgradeno (NE izgrađeno), velicina (NE veličina) — uvijek ASCII +- Koristi LOWER() za case-insensitive equals: WHERE LOWER(grad) = LOWER('rijeka') + +Primjeri: +P: "Tko su sportaši I. kategorije?" → SELECT ime, prezime, sport, klub_naziv_godisnjak FROM pgz_sport.clanovi WHERE hoo_kategorija='I' ORDER BY sport, prezime LIMIT 50 +P: "Najbolji trener 2025?" → SELECT ime_prezime, klub, sport FROM pgz_sport.najbolji_sportasi WHERE godina=2025 AND kategorija ILIKE '%trener%' +P: "Sportski djelatnik 2025?" → SELECT ime_prezime, klub FROM pgz_sport.najbolji_sportasi WHERE godina=2025 AND kategorija ILIKE '%djelatnik%' +P: "Koliko sportaša HNK Rijeka?" → SELECT count(*) FROM pgz_sport.clanovi WHERE klub_naziv_godisnjak ILIKE '%nogometni klub%Rijeka%' OR klub_naziv_godisnjak ILIKE '%HNK Rijeka%' (NAPOMENA: u DB je "Hrvatski nogometni klub \"Rijeka\"") +P: "VK Primorje juniori roster?" → SELECT ime, prezime, sport FROM pgz_sport.clanovi WHERE klub_naziv_godisnjak ILIKE '%Primorje EB%' OR klub_naziv_godisnjak ILIKE '%Primorje%vaterpolo%' +P: "VK Primorje juniori?" → SELECT ime, prezime FROM pgz_sport.clanovi WHERE klub_naziv_godisnjak ILIKE '%Primorje%' AND napomena ILIKE '%junior%' +P: "Koliko vrhunskih u karateu?" → SELECT count(*) FROM pgz_sport.clanovi WHERE LOWER(sport)='karate' AND hoo_kategorija IN ('I','II') +P: "Tko trenira X?" → SELECT ime, prezime, pozicija FROM pgz_sport.treneri WHERE klub_naziv ILIKE '%X%' +P: "Koliko sportaša u U17?" → SELECT count(*) FROM pgz_sport.clanovi WHERE 'U17' = ANY(kategorije) +P: "Stadioni s preko 5000 mjesta" → SELECT naziv, kapacitet FROM pgz_sport.sportski_objekti WHERE tip='stadion' AND kapacitet>5000 +P: "Sportaši po kategoriji nogomet" → SELECT kat, count(*) FROM pgz_sport.clanovi c, unnest(c.kategorije) AS kat WHERE c.sport='nogomet' GROUP BY kat ORDER BY count(*) DESC +P: "Sponzori najveći iznos" → SELECT sponzor, naziv_kluba, iznos_eur FROM pgz_sport.sponzori WHERE iznos_eur IS NOT NULL ORDER BY iznos_eur DESC LIMIT 10 +P: "Ukupno potpora 2026" → SELECT sum(iznos) FROM pgz_sport.potpore_nositelji WHERE godina=2026 +P: "Statistika saveza po klubovima" → SELECT s.naziv, ss.klubova_clanica FROM pgz_sport.statistika_saveza ss JOIN pgz_sport.savezi s ON s.id=ss.savez_id WHERE ss.godina=2024 ORDER BY ss.klubova_clanica DESC LIMIT 10 +P: "Tko su svjetski prvaci PGŽ 2025?" → SELECT ime_prezime, klub, sport, napomena FROM pgz_sport.najbolji_sportasi WHERE godina=2025 AND kategorija ILIKE '%SVJETSKI%' +P: "Trofeji HNK Rijeka 2024/25?" → SELECT natjecanje, plasiranje, trofej FROM pgz_sport.klub_sezona WHERE klub_naziv ILIKE '%HNK Rijeka%' AND sezona='2024/2025' +P: "Nagrade za životno djelo 2025?" → SELECT ime_prezime, klub, sport, napomena FROM pgz_sport.najbolji_sportasi WHERE godina=2025 AND kategorija ILIKE '%životno djelo%' +P: "Sport-spec za Sara Kolak?" → SELECT s.* FROM pgz_sport.sportas_specifika s JOIN pgz_sport.clanovi c ON c.id=s.clan_id WHERE c.ime='Sara' AND c.prezime='Kolak' +P: "Top saveza po klubovima 2025" → SELECT s.naziv, ss.klubova_clanica FROM pgz_sport.statistika_saveza ss JOIN pgz_sport.savezi s ON s.id=ss.savez_id WHERE ss.godina=2025 AND ss.klubova_clanica IS NOT NULL ORDER BY ss.klubova_clanica DESC NULLS LAST LIMIT 10 +P: "Predsjednik X saveza" → SELECT o.ime, o.prezime, o.funkcija FROM pgz_sport.osobe_funkcije o JOIN pgz_sport.savezi s ON s.id=o.savez_id WHERE s.naziv ILIKE '%X%' AND o.funkcija ILIKE '%predsjednik%' +P: "Tko je predsjednik Parasportskog saveza" → SELECT o.ime, o.prezime FROM pgz_sport.osobe_funkcije o JOIN pgz_sport.savezi s ON s.id=o.savez_id WHERE s.naziv ILIKE 'Parasportski%' AND o.funkcija ILIKE '%predsjednik%' +P: "Klubovi u parasportskom savezu" → SELECT k.naziv, k.sport, k.grad FROM pgz_sport.klubovi k JOIN pgz_sport.savezi s ON s.id=k.savez_id WHERE s.naziv ILIKE 'Parasportski%' ORDER BY k.naziv +P: "Koje sportove pokriva X savez" → SELECT DISTINCT k.sport FROM pgz_sport.klubovi k JOIN pgz_sport.savezi s ON s.id=k.savez_id WHERE s.naziv ILIKE '%X%' +P: "Sport sportaša X" → SELECT DISTINCT sport FROM pgz_sport.najbolji_sportasi WHERE ime_prezime ILIKE '%X%' UNION SELECT DISTINCT sport FROM pgz_sport.clanovi WHERE (ime || ' ' || prezime) ILIKE '%X%' + + +Pitanje: {req.q} + +SQL:""" + try: + sg_resp = _rq.post("https://api.deepseek.com/v1/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={"model":"deepseek-chat", + "messages":[{"role":"user","content": sql_prompt}], + "temperature":0.0, "max_tokens":300}, timeout=20) + raw = sg_resp.json().get("choices",[{}])[0].get("message",{}).get("content","").strip() + # Strip markdown if present + sql = raw.replace("```sql","").replace("```","").strip() + if not _sql_safe(sql): + return {"mode":"SQL_BLOCKED","answer":"Generirani SQL nije siguran. Pokušaj drugo pitanje.","sql_attempt":sql,"sources":[]} + + # Execute + try: + # Use psycopg2 directly to avoid % placeholder collision with ILIKE + _conn = psycopg2.connect(**DB) + _cur = _conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + _cur.execute(sql) + rows = _cur.fetchall() if _cur.description else [] + _conn.close() + except Exception as e: + return {"mode":"SQL_ERROR","answer":f"SQL greška: {e}","sql":sql,"sources":[]} + + # Generate natural answer + data_str = json.dumps(rows[:30], default=str, ensure_ascii=False) + ans_prompt = f"""Pitanje: {req.q} +Rezultati iz baze (JSON): +{data_str} + +Sastavi kratak, konkretan odgovor na hrvatskom jeziku. Ako rezultata nema, kaži to.""" + try: + ans_resp = _rq.post("https://api.deepseek.com/v1/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={"model":"deepseek-chat", + "messages":[{"role":"user","content": ans_prompt}], + "temperature":0.2, "max_tokens":600}, timeout=20) + answer = ans_resp.json().get("choices",[{}])[0].get("message",{}).get("content","") + except Exception as e: + answer = f"Pronađeno {len(rows)} zapisa. (LLM error: {e})" + + return {"mode":"SQL","answer":answer,"sql":sql,"row_count":len(rows), + "rows":rows[:30],"sources":[]} + except Exception as e: + return {"mode":"SQL_FAIL","answer":f"Greška u SQL agentu: {e}","sources":[]} + + # RAG fallback + return ask_legal_expert(DocAskReq(q=req.q, limit_context=req.limit_context)) + +import json + + +# ═══ HOO KATEGORIZIRANI SPORTAŠI ═══ +@router.get("/kategorizirani/list") +def list_kategorizirani(kategorija: Optional[str] = None, sport: Optional[str] = None): + where = ["c.kategorija_hoo AS hoo_kategorija IS NOT NULL"]; params = [] + if kategorija: where.append("c.hoo_kategorija=%s"); params.append(kategorija) + if sport: where.append("LOWER(c.sport)=LOWER(%s)"); params.append(sport) + sql = f"""SELECT c.id, c.ime, c.prezime, c.hoo_kategorija, c.sport, + c.hoo_kategorija_od, c.hoo_kategorija_do, c.mjesto_rodenja, + k.naziv AS klub_naziv + FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE {' AND '.join(where)} + ORDER BY c.hoo_kategorija, c.sport, c.prezime, c.ime""" + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +@router.get("/kategorizirani/by-sport") +def kategorizirani_by_sport(): + rows = db_query("""SELECT sport, hoo_kategorija, count(*) AS broj + FROM pgz_sport.clanovi WHERE hoo_kategorija IS NOT NULL + GROUP BY sport, hoo_kategorija ORDER BY sport, hoo_kategorija""") + return {"count": len(rows), "results": rows} + +@router.get("/statistika-2025") +def stats_2025(): + """Brojevi sportaša po savezu/sportu prema Sportskom godišnjaku ZS PGŽ 2025.""" + rows = db_query("""SELECT s.naziv AS savez, ss.godina, ss.registriranih + FROM pgz_sport.statistika_saveza ss + JOIN pgz_sport.savezi s ON s.id=ss.savez_id + WHERE ss.godina=2025 AND ss.registriranih > 0 + ORDER BY ss.registriranih DESC""") + return {"count": len(rows), "results": rows, + "ukupno": sum(r['registriranih'] for r in rows), + "izvor": "Sportski godišnjak ZS PGŽ 2025"} + +# === HNS Semafor stil endpointi (29.04.2026 sprint) === + +@router.get("/klubovi/{kid}/clanovi") +def klub_clanovi_pregled(kid: int): + """HNS Semafor stil pregled članstva - svi sportaši kluba sa kratkom statistikom.""" + klub = db_one("""SELECT id, naziv, sport, razina, region, grad, godina_osnutka, + adresa, telefon, web, hns_klub_id, hns_slug, logo_url, source_synced_at + FROM pgz_sport.klubovi WHERE id=%s""", (kid,)) + if not klub: + raise HTTPException(404, "Klub nije pronađen") + + # Sportaši + agg per igrača + sportasi = db_query("""SELECT c.id, c.ime, c.prezime, c.slika_url, c.broj_dresa, c.pozicija, + c.datum_rodenja, c.mjesto_rodenja, c.uloga, c.reprezentativac, c.aktivan, + c.source, c.source_id, c.source_url, + (SELECT count(*) FROM pgz_sport.utakmice_log u WHERE u.clan_id=c.id) AS nastupa_total, + (SELECT COALESCE(sum(pogodaka),0) FROM pgz_sport.utakmice_log u WHERE u.clan_id=c.id) AS pogoci_total, + (SELECT COALESCE(sum(minute),0) FROM pgz_sport.utakmice_log u WHERE u.clan_id=c.id) AS minute_total, + (SELECT max(datum) FROM pgz_sport.utakmice_log u WHERE u.clan_id=c.id) AS zadnja_utakmica + FROM pgz_sport.clanovi c + WHERE c.klub_id=%s + ORDER BY + CASE c.uloga + WHEN 'predsjednik' THEN 1 + WHEN 'dopredsjednik' THEN 2 + WHEN 'tajnik' THEN 3 + WHEN 'direktor' THEN 4 + WHEN 'član uprave' THEN 5 + WHEN 'član nadzornog odbora' THEN 6 + WHEN 'team_manager' THEN 7 + WHEN 'trener' THEN 10 + WHEN 'pomocni_trener' THEN 11 + WHEN 'trener_vratara' THEN 12 + WHEN 'kondicioni_trener' THEN 13 + WHEN 'fizioterapeut' THEN 14 + WHEN 'lijecnik' THEN 15 + WHEN 'analiticar' THEN 16 + WHEN 'video_analiticar' THEN 17 + WHEN 'igrac' THEN 50 + WHEN 'sportaš' THEN 51 + WHEN 'sportas' THEN 51 + WHEN 'sudac' THEN 60 + WHEN 'ostalo' THEN 90 + ELSE 99 + END, + c.broj_dresa NULLS LAST, c.prezime, c.ime""", (kid,)) + + return { + "klub": klub, + "count": len(sportasi), + "sportasi": sportasi, + "izvor": klub.get("source_synced_at") and "HNS Semafor (auto-sync)" or "Ručno" + } + + +@router.get("/klubovi/sa-clanstvom") +def klubovi_sa_clanstvom(sport: str = None, region: str = "PGŽ", limit: int = 100, offset: int = 0): + """Lista klubova sa brojem članova i izvorom podataka.""" + where = ["k.aktivan=true"]; args = [] + if sport: where.append("k.sport=%s"); args.append(sport) + if region: where.append("k.region=%s"); args.append(region) + where_sql = " AND ".join(where) + args.extend([limit, offset]) + rows = db_query(f"""SELECT k.id, k.naziv, k.sport, k.razina, k.grad, k.godina_osnutka, + k.logo_url, k.hns_klub_id, k.hns_slug, k.source_synced_at, + (SELECT count(*) FROM pgz_sport.clanovi c WHERE c.klub_id=k.id) AS broj_clanova, + (SELECT count(*) FROM pgz_sport.clanovi c WHERE c.klub_id=k.id AND c.source='hns_semafor') AS hns_clanova + FROM pgz_sport.klubovi k + WHERE {where_sql} + ORDER BY broj_clanova DESC, k.naziv + LIMIT %s OFFSET %s""", tuple(args)) + return {"count": len(rows), "klubovi": rows} + + + +@router.get("/clanovi") +def list_clanovi(sport: Optional[str] = None, klub_id: Optional[int] = None, + kategorija_min: Optional[int] = None, reprezentativac: Optional[bool] = None, + spol: Optional[str] = None, uloga: Optional[str] = None, + q: Optional[str] = None, limit: int = 200, offset: int = 0): + """Filterable list of clanovi for showSportasiModal.""" + where = ["c.aktivan IS NOT FALSE"] + params = [] + if sport: + where.append("(LOWER(c.sport) = LOWER(%s) OR LOWER(k.sport) = LOWER(%s))") + params.extend([sport, sport]) + if klub_id: where.append("c.klub_id = %s"); params.append(klub_id) + if kategorija_min: where.append("c.kategorija_hoo IS NOT NULL AND c.kategorija_hoo <= %s"); params.append(kategorija_min) + if reprezentativac is not None: where.append("c.reprezentativac = %s"); params.append(reprezentativac) + if spol: where.append("c.spol = %s"); params.append(spol) + if uloga: where.append("c.uloga = %s"); params.append(uloga) + if q: + where.append("(c.ime ILIKE %s OR c.prezime ILIKE %s OR k.naziv ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%"]) + + sql = f"""SELECT c.id, c.ime, c.prezime, c.sport, c.uloga, c.spol, + c.kategorija_hoo, c.reprezentativac, c.slika_url, c.broj_dres AS broj_dresa, + c.pozicija, c.klub_id, k.naziv AS klub_naziv, + c.datum_rodjenja, c.godina_rodenja, c.mjesto_rodjenja + FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE {' AND '.join(where)} + ORDER BY c.kategorija_hoo NULLS LAST, c.prezime, c.ime + LIMIT %s OFFSET %s""" + params.extend([limit, offset]) + rows = db_query(sql, params) + return {"count": len(rows), "data": rows} + +@router.get("/clanovi/{cid}/full-profile") +def clan_full_profile(cid: int): + """HNS Semafor stil kompletni profil sportaša - dovoljno za GUI render.""" + sp = db_one("""SELECT c.id, c.ime, c.prezime, c.slika_url, c.broj_dresa, c.pozicija, + c.datum_rodenja, c.godina_rodenja, c.mjesto_rodenja, c.adresa, c.grad, c.uloga, + c.dominantna_noga, c.visina_cm, c.tezina_kg, c.biografija, + c.reprezentativac, c.reprezentacija_kategorija, c.kategorija_hoo, + c.licenca_broj, c.licenca_vrijedi_do, c.aktivan, + c.source, c.source_id, c.source_url, c.source_synced_at, c.slug, + c.klub_id, k.naziv AS klub_naziv, k.sport, k.razina, k.region, + k.grad AS klub_grad, k.logo_url AS klub_logo, + k.hns_klub_id, k.hns_slug + FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE c.id=%s""", (cid,)) + if not sp: + raise HTTPException(404, "Sportaš nije pronađen") + + # Sezone agregirane + sezone = db_query(""" + SELECT + CASE WHEN EXTRACT(MONTH FROM datum)>=7 + THEN EXTRACT(YEAR FROM datum)::TEXT||'/'||LPAD(((EXTRACT(YEAR FROM datum)+1)::INT %% 100)::TEXT, 2, '0') + ELSE (EXTRACT(YEAR FROM datum)-1)::TEXT||'/'||LPAD((EXTRACT(YEAR FROM datum)::INT %% 100)::TEXT, 2, '0') + END AS sezona, + natjecanje, count(*) AS nastupi, + COALESCE(SUM(pogodaka),0) AS pogoci, + COALESCE(SUM(zuti_kartoni),0) AS zuti, + COALESCE(SUM(crveni_kartoni),0) AS crveni, + COALESCE(SUM(minute),0) AS minute_total + FROM pgz_sport.utakmice_log + WHERE clan_id=%s AND datum IS NOT NULL + GROUP BY 1, 2 + ORDER BY 1 DESC, 2""", (cid,)) + + # Utakmice (zadnjih 50) + utakmice = db_query("""SELECT id, datum, vrijeme, natjecanje, + klub_dom, klub_dom_logo, klub_gost, klub_gost_logo, + rezultat, pogodaka, zuti_kartoni, crveni_kartoni, minute, + zapocet_kao_starter, source_url + FROM pgz_sport.utakmice_log + WHERE clan_id=%s ORDER BY datum DESC NULLS LAST LIMIT 50""", (cid,)) + + # Karijera - klubovi kroz vrijeme + karijera = db_query("""SELECT k.id, k.naziv, k.logo_url, + min(ul.datum) AS od_dat, max(ul.datum) AS do_dat, + count(*) AS nastupa, + COALESCE(sum(ul.pogodaka),0) AS pogoci + FROM pgz_sport.utakmice_log ul + JOIN pgz_sport.klubovi k ON k.id=ul.za_klub_id + WHERE ul.clan_id=%s + GROUP BY k.id, k.naziv, k.logo_url + ORDER BY min(ul.datum)""", (cid,)) + + # Trenutna sezona stats (top blok) + cur_season = db_one(""" + SELECT count(*) AS nastupi, COALESCE(sum(pogodaka),0) AS pogoci, + COALESCE(sum(zuti_kartoni),0) AS zuti, COALESCE(sum(crveni_kartoni),0) AS crveni, + COALESCE(sum(minute),0) AS minute_total + FROM pgz_sport.utakmice_log + WHERE clan_id=%s + AND datum >= (CASE WHEN EXTRACT(MONTH FROM CURRENT_DATE)>=7 + THEN make_date(EXTRACT(YEAR FROM CURRENT_DATE)::INT, 7, 1) + ELSE make_date(EXTRACT(YEAR FROM CURRENT_DATE)::INT - 1, 7, 1) + END)""", (cid,)) + + # A4_NAGRADE_PATCH: pojedinačne nagrade/medalje sportaša + nagrade = db_query(""" + SELECT godina, sezona, natjecanje, razina_natjecanja, dobna_kategorija, + disciplina, plasman, medalja, napomena, source, source_url + FROM pgz_sport.clan_nagrada + WHERE clan_id=%s + ORDER BY + CASE razina_natjecanja + WHEN 'OI' THEN 1 WHEN 'SP' THEN 2 WHEN 'EP' THEN 3 + WHEN 'SK' THEN 4 WHEN 'EK' THEN 5 WHEN 'DP' THEN 6 ELSE 7 END, + plasman ASC NULLS LAST, godina DESC""", (cid,)) + + # A4_TROFEJI_KLUB: ako sportaš ima klub_id, dohvati klubove trofeje (sezonske) + klub_trofeji = [] + if sp.get('klub_id'): + klub_trofeji = db_query(""" + SELECT sezona, natjecanje, plasiranje, trofej, bodovi + FROM pgz_sport.klub_sezona + WHERE klub_id=%s + ORDER BY sezona DESC LIMIT 30""", (sp['klub_id'],)) + + # A4_PRIZNANJA: najbolji_sportasi nagrade (godišnje povijesne) + priznanja = db_query(""" + SELECT godina, kategorija, klub, sport, napomena + FROM pgz_sport.najbolji_sportasi + WHERE clan_id=%s OR (clan_id IS NULL AND LOWER(ime_prezime) = LOWER(%s)) + ORDER BY godina DESC""", (cid, f"{sp.get('ime','')} {sp.get('prezime','')}")) + + return { + "sportas": sp, + "trenutna_sezona": cur_season or {"nastupi":0,"pogoci":0,"zuti":0,"crveni":0,"minute_total":0}, + "sezone": sezone, + "karijera": karijera, + "utakmice": utakmice, + "nagrade": nagrade, + "klub_trofeji": klub_trofeji, + "priznanja": priznanja, + "totals": { + "nastupa": sum((s.get('nastupi') or 0) for s in sezone), + "pogodaka": sum((s.get('pogoci') or 0) for s in sezone), + "zutih": sum((s.get('zuti') or 0) for s in sezone), + "crvenih": sum((s.get('crveni') or 0) for s in sezone), + "minuta": sum((s.get('minute_total') or 0) for s in sezone), + } + } + +# === SPORT PREGLED ENDPOINTI (29.04.2026 sprint - svi sportovi) === + +@router.get("/sport/svi/stats") +def svi_sportovi_stats(): + """Sumarno za sve sportove - broj klubova, sportaša, saveza, manifestacija.""" + rows = db_query(""" + WITH agg AS ( + SELECT k.sport, + count(distinct k.id) AS klubova, + count(distinct k.grad) AS gradova, + count(distinct c.id) AS sportasa, + count(distinct c.id) FILTER (WHERE (c.kategoriziran=true OR c.kategorija_hoo IS NOT NULL)) AS kategoriziranih + FROM pgz_sport.klubovi k + LEFT JOIN pgz_sport.clanovi c ON c.klub_id=k.id + WHERE k.sport IS NOT NULL AND k.sport != '' + GROUP BY k.sport + ), + savezi_agg AS ( + SELECT lower(sport) AS sport_l, count(*) AS savez_count + FROM pgz_sport.savezi WHERE sport IS NOT NULL + GROUP BY lower(sport) + ), + manif_agg AS ( + SELECT lower(s.sport) AS sport_l, count(*) AS manif_count + FROM pgz_sport.manifestacije m + JOIN pgz_sport.savezi s ON s.id=m.savez_id + WHERE s.sport IS NOT NULL + GROUP BY lower(s.sport) + ), + nagrade_agg AS ( + SELECT lower(sport) AS sport_l, count(*) AS nagrade_count + FROM pgz_sport.najbolji_sportasi WHERE sport IS NOT NULL + GROUP BY lower(sport) + ) + SELECT a.sport, a.klubova, a.gradova, a.sportasa, a.kategoriziranih, + COALESCE(sa.savez_count, 0) AS saveza, + COALESCE(ma.manif_count, 0) AS manifestacija, + COALESCE(na.nagrade_count, 0) AS nagrada + FROM agg a + LEFT JOIN savezi_agg sa ON sa.sport_l = lower(a.sport) + LEFT JOIN manif_agg ma ON ma.sport_l = lower(a.sport) + LEFT JOIN nagrade_agg na ON na.sport_l = lower(a.sport) + ORDER BY a.klubova DESC + """) + return {"count": len(rows), "sportovi": rows, + "totals": { + "klubova": sum(r['klubova'] for r in rows), + "sportasa": sum(r['sportasa'] for r in rows), + "saveza": sum(r['saveza'] for r in rows), + "manifestacija": sum(r['manifestacija'] for r in rows), + }} + + +@router.get("/sport/{sport_naziv}/pregled") +def sport_pregled(sport_naziv: str): + """Detaljan pregled za jedan sport - klubovi, sportaši, savez, trofeji, najbolji, manifestacije.""" + sport_l = sport_naziv.lower().strip() + + # Sinonimi - savez nazivi često imaju različitu morfologiju (nogomet ↔ Nogometni, rukomet ↔ Rukometni) + SPORT_SYNONYMS = { + 'nogomet': ['nogomet', 'nogometni'], + 'rukomet': ['rukomet', 'rukometni'], + 'košarka': ['košarka', 'košarkaški', 'kosarka', 'kosarkaski'], + 'kosarka': ['košarka', 'košarkaški', 'kosarka', 'kosarkaski'], + 'vaterpolo': ['vaterpolo', 'vaterpolski'], + 'odbojka': ['odbojka', 'odbojkaški', 'odbojkaski'], + 'tenis': ['tenis', 'teniski'], + 'stolni tenis': ['stolni tenis', 'stolnoteniski'], + 'plivanje': ['plivanje', 'plivački', 'plivacki'], + 'biciklizam': ['biciklizam', 'biciklistički', 'biciklisticki'], + 'boks': ['boks', 'boksački', 'boksacki'], + 'boćanje': ['boćanje', 'bocanje', 'boćarski', 'bocarski'], + 'kuglanje': ['kuglanje', 'kuglački', 'kuglacki'], + 'streljaštvo': ['streljaštvo', 'streljaski', 'streljački'], + 'streličarstvo': ['streličarstvo', 'strelicarstvo'], + 'judo': ['judo'], + 'karate': ['karate'], + 'taekwondo': ['taekwondo'], + 'kickboxing': ['kickboxing'], + 'jedriličarstvo': ['jedriličarstvo', 'jedrilicarstvo', 'jedriličarski', 'jedrilicarski'], + 'šah': ['šah', 'sah', 'šahovski', 'sahovski'], + 'sah': ['šah', 'sah', 'šahovski', 'sahovski'], + 'pikado': ['pikado'], + 'ribolov': ['ribolov', 'ribolovni', 'športsko ribolovni'], + 'skijanje': ['skijanje', 'skijaški', 'skijaski'], + 'atletika': ['atletika', 'atletski'], + 'gimnastika': ['gimnastika'], + 'planinarstvo': ['planinarstvo', 'planinarski'], + 'motosport': ['motosport', 'motociklizam', 'auto-moto'], + 'rekreacija': ['rekreacija', 'rekreacijski', 'sportska rekreacija'], + 'parasport': ['parasport', 'parasportski', 'paraolimpijski', 'osoba s invaliditetom'], + 'multisport': ['multisport', 'sportski savez'], + 'lov': ['lov', 'lovački', 'lovacki'], + 'ples': ['ples', 'plesni'], + 'borilački sport': ['borilački', 'borilacki'], + } + sport_synonyms = SPORT_SYNONYMS.get(sport_l, [sport_l]) + sport_pattern = '|'.join(sport_synonyms) # za regex + sport_ilike_pattern = '|'.join(f'%{s}%' for s in sport_synonyms) + + # Saveze - matching ILIKE ANY + savezi = db_query("""SELECT id, naziv, skraceni_naziv, godina_osnutka, predsjednik, tajnik, + adresa, grad, telefon, email, web, razina + FROM pgz_sport.savezi + WHERE (lower(sport) = ANY(%s) OR lower(naziv) ~ %s) AND aktivan=true + ORDER BY naziv""", (sport_synonyms, sport_pattern)) + + # Klubovi - top 50 po broju članova + klubovi = db_query("""SELECT k.id, k.naziv, k.razina, k.region, k.grad, k.godina_osnutka, + k.logo_url, k.hns_klub_id, + (SELECT count(*) FROM pgz_sport.clanovi c WHERE c.klub_id=k.id) AS broj_clanova, + (SELECT count(*) FROM pgz_sport.clanovi c + WHERE c.klub_id=k.id AND (c.kategoriziran=true OR c.kategorija_hoo IS NOT NULL)) AS broj_kategoriziranih + FROM pgz_sport.klubovi k + WHERE lower(k.sport) = ANY(%s) AND k.aktivan=true + ORDER BY broj_clanova DESC, k.naziv + LIMIT 100""", (sport_synonyms,)) + + # Top sportaši - kategorizirani (HOO I, II, III) + top_sportasi = db_query("""SELECT c.id, c.ime, c.prezime, c.slika_url, c.kategorija_hoo, + c.reprezentativac, c.broj_dresa, c.pozicija, c.aktivan, + k.naziv AS klub_naziv, k.id AS klub_id + FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE (lower(c.sport) = ANY(%s) OR lower(k.sport) = ANY(%s)) + AND ((c.kategoriziran=true OR c.kategorija_hoo IS NOT NULL) OR c.reprezentativac=true OR c.kategorija_hoo IS NOT NULL) + ORDER BY c.kategorija_hoo NULLS LAST, c.prezime + LIMIT 50""", (sport_synonyms, sport_synonyms)) + + # Trofeji povijesni iz klub_sezona + trofeji = db_query("""SELECT ks.klub_naziv, ks.sezona, ks.natjecanje, ks.plasiranje, + ks.trofej, ks.napomena, k.id AS klub_id + FROM pgz_sport.klub_sezona ks + LEFT JOIN pgz_sport.klubovi k ON k.id=ks.klub_id + WHERE k.id IS NOT NULL AND lower(k.sport) = ANY(%s) + ORDER BY ks.sezona DESC NULLS LAST, ks.plasiranje + LIMIT 100""", (sport_synonyms,)) + + # Najbolji sportaši kroz godine + najbolji = db_query("""SELECT godina, kategorija, ime_prezime, klub, napomena + FROM pgz_sport.najbolji_sportasi + WHERE lower(sport) = ANY(%s) OR lower(sport) ~ %s + ORDER BY godina DESC, kategorija + LIMIT 100""", (sport_synonyms, sport_pattern)) + + # Manifestacije + manifestacije = db_query("""SELECT m.id, m.naziv, m.mjesto, m.organizator, m.razina, + m.broj_ucesnika, m.godina_od, m.spol_kategorija, + s.naziv AS savez_naziv + FROM pgz_sport.manifestacije m + LEFT JOIN pgz_sport.savezi s ON s.id=m.savez_id + WHERE (lower(s.sport) = ANY(%s) OR lower(s.naziv) ~ %s) AND m.aktivna=true + ORDER BY m.naziv + LIMIT 100""", (sport_synonyms, sport_pattern)) + + # Stats sumarno za ovaj sport (sa M/Ž razdvajanjem) + stats = db_one("""SELECT + (SELECT count(*) FROM pgz_sport.klubovi WHERE lower(sport)=ANY(%s) AND aktivan=true) AS broj_klubova, + (SELECT count(distinct grad) FROM pgz_sport.klubovi WHERE lower(sport)=ANY(%s)) AS broj_gradova, + (SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id AND k.aktivan=true + WHERE lower(k.sport)=ANY(%s)) AS broj_sportasa, + (SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id AND k.aktivan=true + WHERE lower(k.sport)=ANY(%s) AND c.spol='M') AS broj_sportasa_m, + (SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id AND k.aktivan=true + WHERE lower(k.sport)=ANY(%s) AND c.spol='Ž') AS broj_sportasa_z, + (SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lower(k.sport)=ANY(%s) AND (c.kategoriziran=true OR c.kategorija_hoo IS NOT NULL)) AS broj_kategoriziranih, + (SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lower(k.sport)=ANY(%s) AND (c.kategoriziran=true OR c.kategorija_hoo IS NOT NULL) AND c.spol='M') AS broj_kategoriziranih_m, + (SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lower(k.sport)=ANY(%s) AND (c.kategoriziran=true OR c.kategorija_hoo IS NOT NULL) AND c.spol='Ž') AS broj_kategoriziranih_z, + (SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lower(k.sport)=ANY(%s) AND c.reprezentativac=true) AS broj_reprezentativaca, + (SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lower(k.sport)=ANY(%s) AND c.reprezentativac=true AND c.spol='M') AS broj_reprezentativaca_m, + (SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lower(k.sport)=ANY(%s) AND c.reprezentativac=true AND c.spol='Ž') AS broj_reprezentativaca_z""", + (sport_synonyms, sport_synonyms, sport_synonyms, sport_synonyms, sport_synonyms, + sport_synonyms, sport_synonyms, sport_synonyms, sport_synonyms, sport_synonyms, sport_synonyms)) + + # Latest scrape - ako je nogomet, daj zadnju utakmicu + zadnja_utakmica = None + if sport_l == 'nogomet': + zadnja_utakmica = db_one("""SELECT max(datum) AS datum FROM pgz_sport.utakmice_log""") + + return { + "sport": sport_naziv, + "stats": stats, + "savezi": savezi, + "klubovi": klubovi, + "top_sportasi": top_sportasi, + "trofeji": trofeji, + "najbolji": najbolji, + "manifestacije": manifestacije, + "zadnja_aktivnost": zadnja_utakmica + } + + +# ============ GOOGLE AI ENRICHMENT ============ +class EnrichRequest(BaseModel): + entity_type: str + entity_id: Optional[int] = None + query: str + +@router.post("/enrich/google-ai") +def enrich_google_ai(req: EnrichRequest): + """Search internet + LLM synthesis + save to docs. + Uses DuckDuckGo HTML for search (no API key) + Groq Llama 3.3 70b.""" + import urllib.request, urllib.parse, json as jj, gzip, re as re2 + + query = req.query.strip() + if not query: + return {"summary": "Nema upita.", "sources": []} + + # Step 1: Search DuckDuckGo HTML (no API key) + sources = [] + try: + ddg_url = f"https://duckduckgo.com/html/?q={urllib.parse.quote(query)}" + req_h = urllib.request.Request(ddg_url, headers={ + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36', + 'Accept-Encoding': 'gzip' + }) + with urllib.request.urlopen(req_h, timeout=10) as r: + data = r.read() + if r.headers.get('Content-Encoding') == 'gzip': + data = gzip.decompress(data) + html = data.decode('utf-8', errors='replace') + # Parse top results + import sys; print(f"DDG html len={len(html)}", file=sys.stderr); results = re2.findall(r']*class="result__a"[^>]*href="([^"]+)"[^>]*>([^<]+)', html) + for url, title in results[:5]: + # Decode DDG redirect + m = re2.search(r'uddg=([^&]+)', url) + if m: + url = urllib.parse.unquote(m.group(1)) + sources.append({"url": url, "title": title.strip()[:120]}) + except Exception as e: + sources = [{"url": f"https://www.google.com/search?q={urllib.parse.quote(query)}", "title": "Google search"}] + + # Step 2: Fetch top 2-3 sources + fetched = [] + for s in sources[:3]: + try: + req_h = urllib.request.Request(s["url"], headers={ + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36', + 'Accept-Encoding': 'gzip' + }) + with urllib.request.urlopen(req_h, timeout=8) as r: + data = r.read() + if r.headers.get('Content-Encoding') == 'gzip': + data = gzip.decompress(data) + src_html = data.decode('utf-8', errors='replace') + text = re2.sub(r']*>.*?', ' ', src_html, flags=re2.DOTALL) + text = re2.sub(r']*>.*?', ' ', text, flags=re2.DOTALL) + text = re2.sub(r'<[^>]+>', ' ', text) + text = re2.sub(r'\s+', ' ', text).strip()[:3000] + fetched.append({"url": s["url"], "title": s["title"], "text": text}) + except Exception: + pass + + # Step 3: LLM synthesis via Groq + summary = "" + facts = [] + + if fetched: + sources_block = "\n\n".join(f"IZVOR: {f['title']}\nURL: {f['url']}\nTEKST: {f['text']}" for f in fetched) + prompt = f"""Sintetiziraj informacije o "{query}" iz sljedećih izvora. Odgovori na hrvatskom, kratko i jasno (max 200 riječi). + +{sources_block} + +Format odgovora (JSON): +{{"summary": "kratki opis u 2-3 rečenice", "facts": ["činjenica 1", "činjenica 2", "činjenica 3"]}} + +Odgovori SAMO sa JSON objektom, bez markdown wrapper-a.""" + + try: + import os + gk = os.environ.get("GROQ_API_KEY") + if not gk: + with open("/opt/rinet-gpu/.env.master") as f: + for line in f: + if line.startswith("GROQ_API_KEY="): + gk = line.split("=",1)[1].strip() + break + if gk: + groq_req = urllib.request.Request( + "https://api.groq.com/openai/v1/chat/completions", + data=jj.dumps({ + "model": "llama-3.3-70b-versatile", + "messages": [{"role":"user","content":prompt}], + "max_tokens": 800, + "temperature": 0.2 + }).encode(), + headers={"Authorization": f"Bearer {gk}", "Content-Type":"application/json", "User-Agent":"Mozilla/5.0"} + ) + with urllib.request.urlopen(groq_req, timeout=20) as r: + resp = jj.loads(r.read()) + content = resp["choices"][0]["message"]["content"].strip() + content = re2.sub(r'^```(?:json)?\s*', '', content) + content = re2.sub(r'\s*```$', '', content).strip() + try: + obj = jj.loads(content) + summary = obj.get("summary", "") + facts = obj.get("facts", []) + except: + summary = content[:500] + except Exception as e: + summary = f"AI sinteza nije uspjela: {e}" + + # Step 4: Save to dokumenti for RAG + saved = False + if summary and len(summary) > 50: + try: + doc_id = db_one("""INSERT INTO pgz_sport.dokumenti + (title, sadrzaj, vrsta, izvor_url, organizacija, kratak_opis, izdano_datum, aktivan) + VALUES (%s, %s, 'enrichment', %s, 'AI Enrichment', %s, CURRENT_DATE, true) + RETURNING id""", + (f"AI Enrichment: {req.query[:120]}", + f"{summary}\n\nKljučne činjenice:\n" + "\n".join(f"- {f}" for f in facts) + + f"\n\nIzvori:\n" + "\n".join(f"{s['title']}: {s['url']}" for s in sources), + sources[0]["url"] if sources else None, + summary[:300])) + saved = bool(doc_id) + except Exception: + pass + + return { + "summary": summary, + "facts": facts, + "sources": sources[:5], + "saved_to_db": saved, + "google_search_url": f"https://www.google.com/search?q={urllib.parse.quote(query)}" + } + + + +# ============ KLUB WEB ENRICHMENT ============ +class KlubEnrichRequest(BaseModel): + klub_id: int + urls: Optional[List[str]] = None # if None, try klub.web + +@router.post("/enrich/klub-web") +def enrich_klub_web(req: KlubEnrichRequest): + """Scrape klub web for roster + uprava + stručni stožer. + Uses Groq Llama to extract structured roster from HTML. + Returns list of upserted clanovi + counts by uloga.""" + import urllib.request, urllib.parse, json as jj, gzip, re as re2, os + + klub = db_one("""SELECT id, naziv, web, sport, region FROM pgz_sport.klubovi WHERE id=%s""", (req.klub_id,)) + if not klub: + return {"error": "klub not found", "klub_id": req.klub_id} + + urls = req.urls or [] + if not urls and klub.get("web"): + web = klub["web"].strip().rstrip("/") + # Try common subpaths for HR football clubs + urls = [web + p for p in ["/uprava/", "/momcad/", "/strucni-stozer/", "/igraci/", "/tim/", "/klub/"]] + urls.append(web) # also home + + if not urls: + return {"error": "no web URL for klub", "klub_id": req.klub_id, "klub_naziv": klub.get("naziv")} + + UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" + + all_text = [] + fetched_urls = [] + for u in urls[:6]: + try: + r_h = urllib.request.Request(u, headers={"User-Agent": UA, "Accept-Encoding": "gzip"}) + with urllib.request.urlopen(r_h, timeout=10) as r: + data = r.read() + if r.headers.get("Content-Encoding") == "gzip": + data = gzip.decompress(data) + src_html = data.decode("utf-8", errors="replace") + # Strip scripts/styles + text = re2.sub(r"]*>.*?", " ", src_html, flags=re2.DOTALL) + text = re2.sub(r"]*>.*?", " ", text, flags=re2.DOTALL) + # Keep , , tags as markers + text = re2.sub(r"<(h[1-6])[^>]*>", "\n## ", text) + text = re2.sub(r"", "\n", text) + text = re2.sub(r"<(strong|b)[^>]*>", "**", text) + text = re2.sub(r"", "**", text) + text = re2.sub(r"]*>", "\n", text) + text = re2.sub(r"<[^>]+>", " ", text) + text = re2.sub(r" ", " ", text) + text = re2.sub(r"\s+", " ", text).strip()[:8000] + if text and len(text) > 100: + all_text.append(f"=== URL: {u} ===\n{text}") + fetched_urls.append(u) + except Exception: + pass + + if not all_text: + return {"error": "could not fetch any URL", "tried": urls} + + combined = "\n\n".join(all_text)[:18000] + + # LLM extraction via Groq + prompt = f"""Iz priloženih HTML stranica nogometnog/sportskog kluba "{klub.get('naziv')}" izvuci strukturirani JSON popis osoba. + +Stranice mogu sadržavati: +- UPRAVU (predsjednik, dopredsjednik, tajnik, direktor, član uprave) +- STRUČNI STOŽER (glavni trener, pomoćni trener, trener vratara, kondicijski trener, fizioterapeut, liječnik, video-analitičar) +- IGRAČE prve momčadi (ime, prezime, broj dresa) + +Vrati SAMO JSON array. Format svake osobe: +{{"ime":"X", "prezime":"Y", "uloga":"predsjednik|dopredsjednik|tajnik|direktor|trener|pomocni_trener|trener_vratara|kondicioni_trener|fizioterapeut|lijecnik|igrac|ostalo", "broj_dresa": null|N, "pozicija": null|"GK"|"DF"|"MF"|"FW", "napomena": null|"opis"}} + +PRAVILA: +- Ne izmišljaj. Ako neka osoba nije jasno spomenuta, preskoči je. +- Igrače stavi u uloga="igrac" SAMO ako su jasno na popisu igrača prve momčadi. +- Predsjednik/uprava NIKAD nemaju uloga="igrac". +- Brojeve dresa parsiraj iz formata "Ime Prezime (BROJ)" → broj_dresa=BROJ. + +Stranice: +{combined} + +Vrati SAMO JSON array, bez markdown, bez objašnjenja.""" + + try: + gk = os.environ.get("GROQ_API_KEY") + if not gk: + return {"error": "no GROQ_API_KEY"} + groq_req = urllib.request.Request( + "https://api.groq.com/openai/v1/chat/completions", + data=jj.dumps({ + "model": "llama-3.3-70b-versatile", + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 4000, + "temperature": 0.1 + }).encode(), + headers={"Authorization": f"Bearer {gk}", "Content-Type": "application/json", "User-Agent": "Mozilla/5.0"} + ) + with urllib.request.urlopen(groq_req, timeout=40) as r: + resp = jj.loads(r.read()) + content = resp["choices"][0]["message"]["content"].strip() + content = re2.sub(r"^```(?:json)?\s*", "", content) + content = re2.sub(r"\s*```$", "", content).strip() + # Find JSON array + m_arr = re2.search(r"\[[\s\S]*\]", content) + if not m_arr: + return {"error": "LLM did not return JSON array", "raw": content[:500]} + try: + people = jj.loads(m_arr.group(0)) + except Exception as e: + return {"error": f"JSON parse: {e}", "raw": content[:500]} + except Exception as e: + return {"error": f"LLM call: {e}"} + + if not isinstance(people, list): + return {"error": "not a list", "people": people} + + # Upsert each person + inserted = 0 + updated = 0 + skipped = 0 + by_uloga = {} + for p in people: + if not isinstance(p, dict): continue + ime = (p.get("ime") or "").strip() + prezime = (p.get("prezime") or "").strip() + if not ime or not prezime: skipped += 1; continue + uloga = (p.get("uloga") or "ostalo").strip() + broj = p.get("broj_dresa") + try: broj = int(broj) if broj else None + except: broj = None + poz = p.get("pozicija") + nap = p.get("napomena") + + # Check if exists by name + klub + existing = db_one("""SELECT id, uloga FROM pgz_sport.clanovi + WHERE LOWER(ime) = LOWER(%s) AND LOWER(prezime) = LOWER(%s) + AND klub_id = %s LIMIT 1""", (ime, prezime, req.klub_id)) + + source_url = fetched_urls[0] if fetched_urls else None + + try: + if existing: + db_exec("""UPDATE pgz_sport.clanovi SET + uloga=%s, broj_dres=COALESCE(%s, broj_dres), + pozicija=COALESCE(%s, pozicija), + source='klub_web', source_url=COALESCE(source_url, %s), + last_updated=now() WHERE id=%s""", + (uloga, broj, poz, source_url, existing["id"])) + updated += 1 + else: + db_exec("""INSERT INTO pgz_sport.clanovi + (ime, prezime, uloga, broj_dres, pozicija, klub_id, sport, + source, source_url, napomena, last_updated) + VALUES (%s, %s, %s, %s, %s, %s, %s, 'klub_web', %s, %s, now())""", + (ime, prezime, uloga, broj, poz, req.klub_id, klub.get("sport"), source_url, nap)) + inserted += 1 + by_uloga[uloga] = by_uloga.get(uloga, 0) + 1 + except Exception as e: + skipped += 1 + + # Audit + db_exec("""INSERT INTO pgz_sport.audit_feed (table_name, action, source, source_url, details) + VALUES ('clanovi', 'klub_web_enrich', 'klub_web', %s, %s::jsonb)""", + (fetched_urls[0] if fetched_urls else None, + jj.dumps({"klub_id": req.klub_id, "klub": klub.get("naziv"), + "inserted": inserted, "updated": updated, "by_uloga": by_uloga}))) + + return { + "klub_id": req.klub_id, + "klub_naziv": klub.get("naziv"), + "fetched_urls": fetched_urls, + "people_count": len(people), + "inserted": inserted, + "updated": updated, + "skipped": skipped, + "by_uloga": by_uloga + } + + +@router.get("/audit/data-quality") +def audit_data_quality(user = Depends(require_user)): + """Pokaze koliko podataka je provjereno (sa source_url) vs neprovjereno.""" + require_role(user, ['super_admin','pgz_admin','pgz_user']) + + sportasi = db_query(""" + SELECT + source, + count(*) AS total, + count(source_url) AS sa_izvorom, + count(datum_rodenja) AS sa_dat_rod, + count(godina_rodenja) AS sa_god_rod, + count(mjesto_rodenja) AS sa_mjesto, + count(slika_url) AS sa_slikom, + count(klub_id) AS sa_klubom + FROM pgz_sport.clanovi + GROUP BY source + ORDER BY total DESC + """) + + klubovi = db_query(""" + SELECT + COALESCE(scrape_source, 'manual') AS source, + count(*) AS total, + count(scrape_url) AS sa_izvorom, + count(godina_osnutka) AS sa_godinom, + count(adresa) AS sa_adresom, + count(telefon) AS sa_telefonom, + count(hns_klub_id) AS sa_hns + FROM pgz_sport.klubovi + GROUP BY scrape_source + ORDER BY total DESC + """) + + purge_history = db_query(""" + SELECT created_at, action, target_text, payload + FROM pgz_sport.sys_audit + WHERE action LIKE '%purge%' OR action LIKE '%clean%' + ORDER BY created_at DESC LIMIT 10 + """) + + # Top sumnjivi - manual sa malo info + sumnjivi = db_query(""" + SELECT c.id, c.ime, c.prezime, c.sport, c.uloga, c.source, + k.naziv AS klub_naziv, + (CASE WHEN c.source_url IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN c.datum_rodenja IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN c.slika_url IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN c.klub_id IS NOT NULL THEN 1 ELSE 0 END) AS quality + FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE c.source = 'manual' + ORDER BY quality ASC, c.id LIMIT 30 + """) + + # Top trusted + trusted = db_query(""" + SELECT c.id, c.ime, c.prezime, c.sport, c.uloga, c.source, c.source_url, + k.naziv AS klub_naziv, c.datum_rodenja, c.godina_rodenja + FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE c.source IN ('hns_semafor', 'hbs_savez', 'rk_zamet_web') + AND c.datum_rodenja IS NOT NULL + ORDER BY c.source_synced_at DESC NULLS LAST LIMIT 20 + """) + + return { + "sportasi_po_izvoru": sportasi, + "klubovi_po_izvoru": klubovi, + "purge_history": purge_history, + "sumnjivi_zapisi": sumnjivi, + "trusted_zapisi": trusted, + "policy": { + "datum_rodenja": "MORA imati source_url - inače trigger postavlja NULL", + "slika_url": "MORA imati source_url - inače trigger postavlja NULL", + "validation_trigger": "clanovi_validate_source" + }, + "trusted_sources": ["hns_semafor", "hbs_savez", "rk_zamet_web"], + "needs_verification": ["manual"] + } + + + +@router.get("/statistika/clanstvo-po-sportu") +def statistika_clanstvo_po_sportu(sport: str = None): + """Agregirane statistike clanstva po savezima iz PGŽ dostave (Boris Milanovic xlsx 2026)""" + rows = db_query(""" + SELECT sport, kategorija, godiste, zene, muski, veterani, ukupno + FROM pgz_sport.savez_statistika_clanstvo + WHERE (%s IS NULL OR sport = %s) ORDER BY sport, kategorija + """, (sport, sport)) if sport else db_query(""" + SELECT sport, + SUM(COALESCE(zene,0)) as zene, + SUM(COALESCE(muski,0)) as muski, + SUM(COALESCE(veterani,0)) as veterani, + SUM(CASE WHEN kategorija ILIKE '%%seniori%%' THEN COALESCE(ukupno,0) ELSE 0 END) as seniori_ukupno, + SUM(COALESCE(ukupno,0)) as ukupno_sve_kategorije + FROM pgz_sport.savez_statistika_clanstvo + GROUP BY sport ORDER BY SUM(COALESCE(ukupno,0)) DESC + """) + return {"sport": sport, "count": len(rows), "data": rows} + +@router.get("/statistika/clanstvo-ukupno") +def statistika_clanstvo_ukupno(): + """Totali po spolu i dobnoj skupini kroz sve saveze""" + totals = db_one(""" + SELECT + SUM(COALESCE(zene,0)) as ukupno_zene, + SUM(COALESCE(muski,0)) as ukupno_muski, + SUM(COALESCE(veterani,0)) as ukupno_veterani, + COUNT(DISTINCT sport) as broj_saveza, + SUM(CASE WHEN kategorija ILIKE '%%seniori%%' THEN COALESCE(zene,0) ELSE 0 END) as seniori_zene, + SUM(CASE WHEN kategorija ILIKE '%%seniori%%' THEN COALESCE(muski,0) ELSE 0 END) as seniori_muski + FROM pgz_sport.savez_statistika_clanstvo + """) + po_kat = db_query(""" + SELECT kategorija, godiste, + SUM(COALESCE(zene,0)) as zene, + SUM(COALESCE(muski,0)) as muski, + SUM(COALESCE(ukupno,0)) as ukupno + FROM pgz_sport.savez_statistika_clanstvo + GROUP BY kategorija, godiste ORDER BY kategorija + """) + return {**totals, "po_kategoriji": po_kat} + + +@router.get("/javne-potrebe") +def javne_potrebe_pgz(godina: int = None): + """Javne potrebe u sportu PGŽ po godinama (ZSP PGŽ podaci)""" + if godina: + rows = db_query(""" + SELECT id, naslov, kategorija, sadrzaj, url, scraped_at + FROM pgz_sport.zsp_dokumenti + WHERE kategorija='javne_potrebe' AND naslov ILIKE %s + ORDER BY scraped_at DESC + """, (f'%{godina}%',)) + else: + rows = db_query(""" + SELECT id, naslov, kategorija, sadrzaj, url, scraped_at + FROM pgz_sport.zsp_dokumenti + WHERE kategorija IN ('javne_potrebe','natjecaj_sufinanciranje') + ORDER BY naslov DESC + """) + return {"count": len(rows), "data": rows} + +@router.get("/hoo-pravilnici") +def hoo_pravilnici(kategorija: str = None): + """HOO pravilnici i kriteriji kategorizacije""" + if kategorija: + rows = db_query("SELECT * FROM pgz_sport.hoo_pravilnici WHERE kategorija=%s ORDER BY scraped_at DESC", (kategorija,)) + else: + rows = db_query("SELECT id, naslov, kategorija, url, scraped_at FROM pgz_sport.hoo_pravilnici ORDER BY kategorija, naslov") + return {"count": len(rows), "data": rows} + +@router.get("/rno-udruge") +def rno_udruge(sport: str = None, grad: str = None): + """Registar sportskih udruga PGŽ (RNO podaci)""" + sql = "SELECT * FROM pgz_sport.rno_sportske_udruge WHERE 1=1" + params = [] + if sport: sql += " AND djelatnost ILIKE %s"; params.append(f'%{sport}%') + if grad: sql += " AND grad ILIKE %s"; params.append(f'%{grad}%') + sql += " ORDER BY naziv" + rows = db_query(sql, params) + return {"count": len(rows), "data": rows} + +@router.get("/zsp-dokumenti") +def zsp_dokumenti(kategorija: str = None): + """ZSP PGŽ i RSS dokumenti (savezi, programi, nagrade)""" + if kategorija: + rows = db_query(""" + SELECT id, naslov, kategorija, url, izvor, scraped_at + FROM pgz_sport.zsp_dokumenti WHERE kategorija=%s ORDER BY naslov + """, (kategorija,)) + else: + rows = db_query(""" + SELECT kategorija, COUNT(*) as broj, MAX(scraped_at) as zadnji_scrape + FROM pgz_sport.zsp_dokumenti GROUP BY kategorija ORDER BY kategorija + """) + return {"count": len(rows), "data": rows} + + +# ============================================================ +# PGŽ Boris Milanović stats — official Excel data 30.04.2026 +# ============================================================ + +@router.get("/pgz/savez-stats") +def pgz_savez_stats(): + """Službena statistika PGŽ saveza (Boris Milanović Excel 30.04.2026).""" + rows = db_query(""" + SELECT savez, + sum(natjecateljki) AS žene, + sum(natjecatelja) AS muški, + sum(veterani) AS veterani, + sum(ukupno) AS ukupno + FROM pgz_sport.savez_stats_oficijalno + GROUP BY savez + ORDER BY ukupno DESC NULLS LAST + """) + summary = db_one(""" + SELECT count(DISTINCT savez) AS broj_saveza, + sum(ukupno) AS ukupno_sportasa, + sum(natjecateljki) AS žene, + sum(natjecatelja) AS muški, + sum(veterani) AS veterani + FROM pgz_sport.savez_stats_oficijalno + """) + return {"savezi": rows, "summary": summary, "izvor": "Boris Milanović PGŽ - 30.04.2026"} + + +@router.get("/pgz/savez-stats/{savez_naziv}") +def pgz_savez_detail(savez_naziv: str): + """Detaljna kategorijska razdioba za jedan savez.""" + rows = db_query(""" + SELECT kategorija, natjecateljki, natjecatelja, veterani, ukupno + FROM pgz_sport.savez_stats_oficijalno + WHERE savez = %s + ORDER BY id + """, (savez_naziv,)) + if not rows: + raise HTTPException(404, f"Savez '{savez_naziv}' nije pronađen") + return {"savez": savez_naziv, "kategorije": rows, "izvor": "Boris Milanović PGŽ - 30.04.2026"} + + +@router.get("/pgz/sport-organizacije") +def pgz_sport_organizacije(kategorija: str = None, sufinanciran: bool = None, limit: int = 200): + """Klubovi po kategoriji organizacije (sport_klub, sport_savez, sportski_ribolov...) + Default = samo sportske organizacije.""" + where = ["aktivan=true"]; args = [] + if kategorija: + where.append("kategorija_organizacije=%s"); args.append(kategorija) + else: + where.append("kategorija_organizacije IN ('sport_klub','sport_savez','sportski_ribolov','planinarstvo')") + if sufinanciran is not None: + where.append("pgz_sufinanciran=%s"); args.append(sufinanciran) + args.append(limit) + rows = db_query(f""" + SELECT id, naziv, sport, razina, grad, godina_osnutka, + kategorija_organizacije, pgz_sufinanciran, scrape_source + FROM pgz_sport.klubovi + WHERE {' AND '.join(where)} + ORDER BY naziv + LIMIT %s + """, tuple(args)) + return {"count": len(rows), "klubovi": rows} + + +@router.get("/pgz/cleanup-summary") +def pgz_cleanup_summary(): + """Summary cleanup operacije 30.04.2026 - što je sport, što je obrisano.""" + rows = db_query(""" + SELECT + COALESCE(kategorija_organizacije, '(unclassified)') AS kategorija, + count(*) FILTER (WHERE aktivan=true) AS aktivni, + count(*) FILTER (WHERE aktivan=false) AS deaktivirani, + count(*) AS ukupno, + count(*) FILTER (WHERE pgz_sufinanciran=true) AS pgz_sufinanciran + FROM pgz_sport.klubovi + GROUP BY kategorija_organizacije + ORDER BY ukupno DESC + """) + summary = db_one(""" + SELECT + count(*) AS total, + count(*) FILTER (WHERE aktivan=true) AS aktivni, + count(*) FILTER (WHERE aktivan=false) AS deaktivirani, + count(*) FILTER (WHERE pgz_sufinanciran=true) AS sufinancirani + FROM pgz_sport.klubovi + """) + return {"by_category": rows, "summary": summary, + "info": "Cleanup 30.04.2026 - Boris Milanović PGŽ zahtjev", + "backup_table": "pgz_sport.klubovi_pre_cleanup_20260430"} + + +@router.get("/pgz/enrichment-gap") +def pgz_enrichment_gap(): + """Dashboard za Borisa - gap između Boris baseline (savez_stats) i naših podataka.""" + rows = db_query(""" + SELECT savez_naziv, sport, imamo_klubova, imamo_clanova, + boris_baseline, gap, pct_complete, status + FROM pgz_sport.v_enrichment_gap + ORDER BY gap DESC NULLS LAST + """) + summary = db_one(""" + SELECT + sum(imamo_klubova) AS klubova, + sum(imamo_clanova) AS clanova, + sum(boris_baseline) AS boris_total, + sum(gap) AS gap_total, + ROUND(100.0 * sum(imamo_clanova) / NULLIF(sum(boris_baseline), 0), 1) AS overall_pct, + count(*) FILTER (WHERE status = 'OK') AS ok_savezi, + count(*) FILTER (WHERE status = 'NULA') AS nula_savezi + FROM pgz_sport.v_enrichment_gap + """) + return {"saveza": rows, "summary": summary, + "boris_baseline_source": "Boris Milanović PGŽ Excel 30.04.2026"} + + +@router.get("/pgz/dedup-summary") +def pgz_dedup_summary(): + """Pregled cleanup operacija 30.04.2026.""" + audit = db_query(""" + SELECT created_at, action, target_text, payload + FROM pgz_sport.sys_audit + WHERE created_at >= '2026-04-30' + AND action IN ('cleanup_klubovi', 'planinarstvo_verified', 'dedup_klubovi', 'import_boris_stats') + ORDER BY id DESC + """) + return {"audit_log": audit} + + +# ===== RNO — Registar neprofitnih organizacija ===== +@router.get("/rno") +def get_rno(q: str = "", status: str = "", sort: str = "naziv", limit: int = 100): + """PGZ sport organizacije iz RNO s financijskim podacima""" + filters = ["1=1"] + params = [] + if q: + filters.append("(o.naziv ILIKE %s OR o.oib ILIKE %s OR o.mjesto ILIKE %s)") + params += [f"%{q}%", f"%{q}%", f"%{q}%"] + if status == "active": + filters.append("aktivna = true") + elif status == "inactive": + filters.append("aktivna = false") + + order = {"naziv": "naziv", "prihodi": "pr.prihodi_ukupno DESC NULLS LAST", "rashodi": "pr.rashodi_ukupno DESC NULLS LAST"}.get(sort, "naziv") + + sql = f""" + SELECT o.rno_broj, o.naziv, o.oib, o.mjesto, o.pravni_oblik, + o.adresa, o.email, o.web, o.aktivna, o.sifra_djelatnosti, + CASE WHEN COALESCE(pr.godina,0) < 2023 THEN pr.prihodi_ukupno/7.53450 ELSE pr.prihodi_ukupno END as prihodi, + CASE WHEN COALESCE(pr.godina,0) < 2023 THEN pr.prihodi_javni/7.53450 ELSE pr.prihodi_javni END as prihodi_javni, + CASE WHEN COALESCE(pr.godina,0) < 2023 THEN pr.rashodi_ukupno/7.53450 ELSE pr.rashodi_ukupno END as rashodi, + CASE WHEN COALESCE(pr.godina,0) < 2023 THEN pr.rezultat/7.53450 ELSE pr.rezultat END as rezultat, + pr.godina as fin_godina, + CASE WHEN COALESCE(b.godina,0) < 2023 THEN b.imovina_ukupno/7.53450 ELSE b.imovina_ukupno END as imovina_ukupno + FROM pgz_sport.rno_organizacije o + LEFT JOIN pgz_sport.rno_prras pr ON pr.oib = o.oib AND pr.godina = ( + SELECT MAX(p2.godina) FROM pgz_sport.rno_prras p2 WHERE p2.oib = o.oib + ) + LEFT JOIN pgz_sport.rno_bilanca b ON b.oib = o.oib AND b.godina = pr.godina + WHERE {' AND '.join(filters)} + ORDER BY {order} + LIMIT %s + """ + params.append(limit) + return [dict(r) for r in db_query(sql, params)] + + +# ===== HNS Natjecanja ===== +@router.get("/hns-natjecanja") +def get_hns_natjecanja(season: str = "", org: str = ""): + filters = ["1=1"] + params = [] + if season: + filters.append("sezona = %s") + params.append(season) + if org: + filters.append("org_id = %s") + params.append(int(org)) + sql = f""" + SELECT id, naziv, sezona, org_id, url, sort + FROM pgz_sport.hns_natjecanja + WHERE {' AND '.join(filters)} + ORDER BY sezona DESC, sort, naziv + """ + return [dict(r) for r in db_query(sql, params)] + + +# ===== Godišnjaci AI pretraga ===== +@router.post("/godisnjaci/search") +def search_godisnjaci(body: dict = None): + """AI semantic search through ZSP PGZ godisnjaci 2006-2024""" + import json, requests as req + if not body: + return {"answer": "Nema pitanja.", "sources": []} + + question = body.get("question", "") + if not question: + return {"answer": "Nema pitanja.", "sources": []} + + try: + # Embed the question using Ollama nomic-embed + emb_r = req.post("http://localhost:11434/api/embed", + json={"model": "nomic-embed-text", "input": [question]}, + timeout=15) + if emb_r.status_code != 200: + return {"answer": "Greška pri embeddingu.", "sources": []} + + query_vec = emb_r.json()["embeddings"][0] + + # Search Qdrant pgz_godisnjaci + qdrant_r = req.post("http://10.10.0.2:6333/collections/pgz_godisnjaci/points/search", + json={"vector": query_vec, "limit": 6, "with_payload": True}, + timeout=15) + + hits = qdrant_r.json().get("result", []) + sources = [] + context_parts = [] + + for h in hits: + payload = h.get("payload", {}) + text = payload.get("text", "") + godina = payload.get("godina", "?") + score = h.get("score", 0) + if score > 0.3 and text: + sources.append({"godina": godina, "text": text, "score": round(score, 3)}) + context_parts.append(f"[Godišnjak {godina}]: {text[:400]}") + + if not context_parts: + return {"answer": "Nisam pronašao relevantne podatke u godišnjacima za ovo pitanje.", "sources": []} + + context = "\n\n".join(context_parts[:4]) + + # Use vLLM for answer generation + llm_r = req.post("http://localhost:8001/v1/chat/completions", + json={ + "model": "Qwen/Qwen2.5-7B-Instruct-AWQ", + "messages": [ + {"role": "system", "content": "Ti si AI asistent za PGŽ sport. Odgovori na pitanje koristeći isključivo podatke iz godišnjaka Zajednice sportova PGŽ. Odgovaraj kratko i jasno na hrvatskom jeziku."}, + {"role": "user", "content": f"Kontekst iz godišnjaka:\n{context}\n\nPitanje: {question}"} + ], + "max_tokens": 300, + "temperature": 0.1 + }, + timeout=30) + + if llm_r.status_code == 200: + answer = llm_r.json()["choices"][0]["message"]["content"] + else: + # Fallback: return best matching text + answer = sources[0]["text"][:500] if sources else "Nema odgovora." + + return {"answer": answer, "sources": sources} + + except Exception as e: + return {"answer": f"Greška: {str(e)[:100]}", "sources": []} + + +# ===== BUDGET ANALYTICS — Scoring model za PGŽ proračun ===== + +@router.get("/analytics/budget-score") +def get_budget_score(godina: int = 2025, min_clanova: int = 0, sport: str = ""): + """Bodovanje saveza za PGZ proracunsku odluku (HOO kriteriji, max 100 bodova)""" + sql = """ + WITH stat AS ( + SELECT savez_id, + MAX(registriranih) as registriranih, + MAX(trenera) as trenera, + MAX(reprezentativaca) as repr, + MAX(klubova_clanica) as klubova_stat + FROM pgz_sport.statistika_saveza + WHERE godina <= %(god)s + GROUP BY savez_id + ), + clan_stat AS ( + SELECT k.savez_id, + COUNT(c.id) as n_clanova, + COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END) as c_s_dob, + COUNT(DISTINCT k.id) as n_klubova_clanovi + FROM pgz_sport.clanovi c + JOIN pgz_sport.klubovi k ON k.id = c.klub_id + WHERE c.aktivan = true + GROUP BY k.savez_id + ) + SELECT + s.id, + s.naziv, + s.sport, + s.grad, + COALESCE(st.registriranih, 0) as registriranih, + COALESCE(cs.n_clanova, 0) as clanova_u_sustavu, + COALESCE(cs.c_s_dob, 0) as clanova_s_dob, + COALESCE(CASE WHEN cs.n_clanova > 0 THEN ROUND(cs.c_s_dob::numeric/cs.n_clanova*100,1) ELSE 0 END, 0) as pct_s_dob, + COALESCE(cs.n_klubova_clanovi, 0) as klubova, + COALESCE(st.trenera, 0) as trenera, + COALESCE(st.repr, 0) as reprezentativaca, + LEAST(25, COALESCE(st.registriranih, 0) / 50) as bod_clanovi, + LEAST(15, COALESCE(cs.n_klubova_clanovi, 0) * 2) as bod_klubovi, + LEAST(15, COALESCE(st.trenera, 0) * 2) as bod_treneri, + LEAST(20, CASE WHEN cs.n_clanova > 0 + THEN ROUND(cs.c_s_dob::numeric/cs.n_clanova*20,0) + ELSE 0 END) as bod_evidencija, + LEAST(15, COALESCE(st.repr, 0)) as bod_reprezentativci, + (LEAST(25, COALESCE(st.registriranih, 0) / 50) + + LEAST(15, COALESCE(cs.n_klubova_clanovi, 0) * 2) + + LEAST(15, COALESCE(st.trenera, 0) * 2) + + LEAST(20, CASE WHEN cs.n_clanova > 0 + THEN ROUND(cs.c_s_dob::numeric/cs.n_clanova*20,0) + ELSE 0 END) + + LEAST(15, COALESCE(st.repr, 0)) + ) as score_ukupno + FROM pgz_sport.savezi s + LEFT JOIN stat st ON st.savez_id = s.id + LEFT JOIN clan_stat cs ON cs.savez_id = s.id + WHERE s.aktivan = true + AND COALESCE(cs.n_clanova, st.registriranih, 0) >= %(min_cl)s + """ + if sport: + sql += " AND s.sport ILIKE %(sport)s" + sql += " ORDER BY score_ukupno DESC, registriranih DESC LIMIT 50" + params = {"god": godina, "min_cl": min_clanova, "sport": f"%{sport}%"} + return [dict(r) for r in db_query(sql, params)] + +@router.get("/analytics/proracun-trend") +def get_proracun_trend(): + """Višegodišnji trend proračuna PGŽ za sport s indeksima rasta""" + sql = """ + SELECT godina, + ROUND(proracun_pgz::numeric, 2) as pgz_eur, + ROUND(ukupno_pgz::numeric, 2) as pgz_ukupno_eur, + ROUND(ministarstvo::numeric, 2) as ministarstvo_eur, + ROUND(ukupno::numeric, 2) as ukupno_eur, + ROUND(ukupno::numeric / NULLIF( + LAG(ukupno) OVER (ORDER BY godina), 0 + ) * 100 - 100, 1) as rast_pct + FROM pgz_sport.proracun + ORDER BY godina + """ + return [dict(r) for r in db_query(sql)] + + +@router.get("/analytics/savez-drill") +def get_savez_drill(savez_id: int, godina: int = 2025): + """Deep drill-down za jedan savez""" + savez = db_one("SELECT * FROM pgz_sport.savezi WHERE id = %s", (savez_id,)) + if not savez: + return {"error": "Savez not found"} + + klubovi = db_query(""" + SELECT k.id, k.naziv, k.grad, k.oib, + COUNT(c.id) as n_clanova, + COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END) as s_dob + FROM pgz_sport.klubovi k + LEFT JOIN pgz_sport.clanovi c ON c.klub_id = k.id AND c.aktivan = true + WHERE k.savez_id = %s AND k.aktivan = true + GROUP BY k.id, k.naziv, k.grad, k.oib + ORDER BY n_clanova DESC LIMIT 20 + """, (savez_id,)) + + statistike = db_query(""" + SELECT godina, registriranih, trenera, reprezentativaca, + klubova_clanica, stipendiranih, zaposlenika + FROM pgz_sport.statistika_saveza + WHERE savez_id = %s ORDER BY godina DESC LIMIT 8 + """, (savez_id,)) + + javne = db_query(""" + SELECT godina, iznos_eur, naslov, vrsta + FROM pgz_sport.javne_potrebe + WHERE LOWER(korisnik) LIKE LOWER(%s) OR LOWER(korisnik) LIKE LOWER(%s) + ORDER BY godina DESC LIMIT 10 + """, (f"%{dict(savez).get('naziv','')[0:20]}%", f"%{dict(savez).get('naziv','').split()[-1] if dict(savez).get('naziv','') else ''}%")) + + lij = db_query(""" + SELECT lp.datum_pregleda, lp.vrijedi_do, lp.spreman_za_natjecanje + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.klubovi k ON k.id = lp.klub_id + WHERE k.savez_id = %s + ORDER BY lp.datum_pregleda DESC LIMIT 10 + """, (savez_id,)) + + clanovi_spol = db_one(""" + SELECT + COUNT(c.id) as ukupno, + COUNT(CASE WHEN c.spol='M' THEN 1 END) as muski, + COUNT(CASE WHEN c.spol='Z' THEN 1 END) as zenski, + COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END) as s_dob, + MIN(EXTRACT(YEAR FROM age(c.datum_rodjenja)))::int as min_dob, + MAX(EXTRACT(YEAR FROM age(c.datum_rodjenja)))::int as max_dob + FROM pgz_sport.clanovi c + JOIN pgz_sport.klubovi k ON k.id = c.klub_id + WHERE k.savez_id = %s AND c.aktivan = true + """, (savez_id,)) + + return { + "savez": dict(savez), + "klubovi": [dict(k) for k in klubovi], + "statistike": [dict(s) for s in statistike], + "javne_potrebe": [dict(j) for j in javne], + "lijecnicki": [dict(l) for l in lij], + "clanovi_spol": dict(clanovi_spol) if clanovi_spol else {} + } + +@router.get("/analytics/klub-score") +def get_klub_score( + min_clanova: int = 0, + sport: str = "", + savez_id: int = 0, + sort: str = "score" +): + """Bodovanje klubova po sličnim kriterijima""" + filters = ["k.aktivan = true"] + params: list = [] + if min_clanova: + filters.append("COUNT(c.id) >= %s") + params.append(min_clanova) + if sport: + filters.append("k.sport ILIKE %s") + params.append(f"%{sport}%") + if savez_id: + filters.append("k.savez_id = %s") + params.append(savez_id) + + sql = f""" + SELECT k.id, k.naziv, k.grad, k.sport, + s.naziv as savez, + COUNT(c.id) as n_clanova, + COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END) as c_s_dob, + CASE WHEN COUNT(c.id) > 0 THEN ROUND(COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END)::numeric/COUNT(c.id)*100,1) ELSE 0 END as pct_dob, + COUNT(DISTINCT CASE WHEN c.spol='M' THEN c.id END) as muski, + COUNT(DISTINCT CASE WHEN c.spol='Z' THEN c.id END) as zenski, + ROUND(COUNT(c.id) * 0.5 + + CASE WHEN COUNT(c.id) > 0 THEN COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END)::numeric/COUNT(c.id)*30 ELSE 0 END + , 1) as score + FROM pgz_sport.klubovi k + LEFT JOIN pgz_sport.clanovi c ON c.klub_id = k.id AND c.aktivan = true + LEFT JOIN pgz_sport.savezi s ON s.id = k.savez_id + WHERE {' AND '.join(filters[:2])} + GROUP BY k.id, k.naziv, k.grad, k.sport, s.naziv + HAVING COUNT(c.id) >= {min_clanova} + {"AND k.sport ILIKE '"+sport+"'" if sport else ""} + ORDER BY score DESC + LIMIT 50 + """ + # Simplify query + where_parts = ["k.aktivan = true"] + p2 = [] + if sport: + where_parts.append("k.sport ILIKE %s") + p2.append(f"%{sport}%") + if savez_id: + where_parts.append("k.savez_id = %s") + p2.append(savez_id) + + sql2 = f""" + SELECT k.id, k.naziv, k.grad, k.sport, + s.naziv as savez, + COUNT(c.id) as n_clanova, + COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END) as c_s_dob, + CASE WHEN COUNT(c.id) > 0 THEN ROUND(COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END)::numeric/COUNT(c.id)*100,1) ELSE 0 END as pct_dob, + COUNT(DISTINCT CASE WHEN c.spol='M' THEN c.id END) as muski, + COUNT(DISTINCT CASE WHEN c.spol='Z' THEN c.id END) as zenski, + ROUND(COUNT(c.id) * 0.5 + + CASE WHEN COUNT(c.id) > 0 + THEN COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END)::numeric/COUNT(c.id)*30 + ELSE 0 END, 1) as score + FROM pgz_sport.klubovi k + LEFT JOIN pgz_sport.clanovi c ON c.klub_id = k.id AND c.aktivan = true + LEFT JOIN pgz_sport.savezi s ON s.id = k.savez_id + WHERE {' AND '.join(where_parts)} + GROUP BY k.id, k.naziv, k.grad, k.sport, s.naziv + HAVING COUNT(c.id) >= {min_clanova} + ORDER BY score DESC + LIMIT 50 + """ + return [dict(r) for r in db_query(sql2, p2)] + + +# ===== FILTER OPTIONS (for dropdowns) ===== +@router.get("/analytics/filter-options") +def get_filter_options(): + """Vraca dostupne vrijednosti za dropdown filtere""" + sportovi = db_query(""" + SELECT DISTINCT INITCAP(LOWER(TRIM(sport))) as sport + FROM pgz_sport.savezi + WHERE aktivan=true AND sport IS NOT NULL AND TRIM(sport) != '' + ORDER BY 1 + """) + gradovi = db_query(""" + SELECT DISTINCT grad FROM pgz_sport.savezi + WHERE aktivan=true AND grad IS NOT NULL + ORDER BY grad + """) + sport_klub = db_query(""" + SELECT DISTINCT INITCAP(LOWER(TRIM(sport))) as sport + FROM pgz_sport.klubovi + WHERE aktivan=true AND sport IS NOT NULL AND TRIM(sport) != '' + ORDER BY 1 LIMIT 40 + """) + savezi_list = db_query(""" + SELECT id, naziv FROM pgz_sport.savezi + WHERE aktivan=true ORDER BY naziv LIMIT 60 + """) + return { + "sportovi_savezi": [r["sport"] for r in sportovi], + "sportovi_klubovi": [r["sport"] for r in sport_klub], + "gradovi": [r["grad"] for r in gradovi], + "savezi": [{"id": r["id"], "naziv": r["naziv"]} for r in savezi_list] + } + + +@router.get("/sport/objekti") +def get_sport_objekti(tip: str = "", grad: str = "", q: str = ""): + """106 sportskih objekata PGZ s filterima""" + filters = ["aktivan = true"] + params = [] + if tip: + filters.append("LOWER(tip) = LOWER(%s)") + params.append(tip) + if grad: + filters.append("LOWER(grad) = LOWER(%s)") + params.append(grad) + if q: + filters.append("(LOWER(naziv) LIKE LOWER(%s) OR LOWER(adresa) LIKE LOWER(%s))") + params.extend([f"%{q}%", f"%{q}%"]) + + sql = f""" + SELECT id, naziv, tip, grad, adresa, lat, lng, + upravitelj, kapacitet, sportovi, + izgradeno, obnovljeno_god, natkrita, + napomena, web, aktivan + FROM pgz_sport.sportski_objekti + WHERE {' AND '.join(filters)} + ORDER BY grad, naziv + LIMIT 200 + """ + return [dict(r) for r in db_query(sql, params)] + + +# ═══════════════════════════════════════════════════════ +# GRAPH — person/club/savez connections from osobe_funkcije +# ═══════════════════════════════════════════════════════ +@router.get("/graph/connections") +def graph_connections(q: str = "", savez_id: int = None, limit: int = 300): + """D3 force graph nodes+edges from pgz_sport.osobe_funkcije.""" + filters = ["1=1"] + params = [] + if q: + filters.append("(LOWER(of.ime||' '||of.prezime) LIKE LOWER(%s) OR LOWER(COALESCE(s.naziv,''||k.naziv,'')) LIKE LOWER(%s))") + params += [f"%{q}%", f"%{q}%"] + if savez_id: + filters.append("of.savez_id = %s") + params.append(savez_id) + + rows = db_query(f""" + SELECT of.id, of.ime, of.prezime, of.funkcija, of.sport, + of.savez_id, s.naziv as savez_naziv, + of.klub_id, k.naziv as klub_naziv, + of.mandate_od, of.mandate_do + FROM pgz_sport.osobe_funkcije of + LEFT JOIN pgz_sport.savezi s ON of.savez_id = s.id + LEFT JOIN pgz_sport.klubovi k ON of.klub_id = k.id + WHERE {" AND ".join(filters)} + ORDER BY of.prezime, of.ime + LIMIT %s + """, params + [limit]) + + nodes = {} + edges = [] + + def add_node(nid, label, ntype, meta=None): + if nid not in nodes: + nodes[nid] = {"id": nid, "label": label, "type": ntype, **(meta or {})} + + for r in rows: + pid = f"p_{r['id']}" + add_node(pid, f"{r['ime']} {r['prezime']}", "person", + {"funkcija": r.get("funkcija"), "sport": r.get("sport")}) + if r.get("savez_id"): + sid = f"s_{r['savez_id']}" + sn = r.get("savez_naziv") or f"Savez {r['savez_id']}" + add_node(sid, sn[:40]+('…' if len(sn)>40 else ''), "savez") + edges.append({"source": pid, "target": sid, + "label": r.get("funkcija",""), "sport": r.get("sport","")}) + if r.get("klub_id"): + kid = f"k_{r['klub_id']}" + add_node(kid, r.get("klub_naziv") or f"Klub {r['klub_id']}", "klub") + edges.append({"source": pid, "target": kid, + "label": r.get("funkcija",""), "sport": r.get("sport","")}) + + return {"nodes": list(nodes.values()), "edges": edges, + "count": len(nodes), "edge_count": len(edges)} + + +# ═══════════════════════════════════════════════════════ +# POTPORE AGGREGATE — sve izvore +# ═══════════════════════════════════════════════════════ +@router.get("/potpore/aggregate") +def potpore_aggregate(q: str = "", limit: int = 300): + """Aggregate sufinanciranje + javne_potrebe per korisnik.""" + like = f"%{q}%" if q else "%" + rows = db_query(""" + SELECT korisnik, sport, SUM(iznos_eur) as ukupno_eur, + COUNT(*) as n_potpore, MIN(godina) as od_god, MAX(godina) as do_god, + 'sufinanciranje' as tip, + MAX(source_url) as source_url, + STRING_AGG(DISTINCT COALESCE(izvor,''), ', ') as izvori + FROM pgz_sport.sufinanciranje_sport + WHERE LOWER(COALESCE(korisnik,'')) LIKE LOWER(%s) + GROUP BY korisnik, sport + UNION ALL + SELECT korisnik, NULL as sport, SUM(iznos_eur) as ukupno_eur, + COUNT(*) as n_potpore, MIN(godina) as od_god, MAX(godina) as do_god, + 'javne_potrebe' as tip, + MAX(url) as source_url, + STRING_AGG(DISTINCT COALESCE(izvor,''), ', ') as izvori + FROM pgz_sport.javne_potrebe + WHERE LOWER(COALESCE(korisnik,'')) LIKE LOWER(%s) AND korisnik IS NOT NULL + GROUP BY korisnik + ORDER BY ukupno_eur DESC NULLS LAST + LIMIT %s + """, [like, like, limit]) + return {"count": len(rows), "results": rows} + + +# ═══════════════════════════════════════════════════════ +# USER DASHBOARD +# ═══════════════════════════════════════════════════════ +@router.get("/user/dashboard") +def user_dashboard(user=Depends(require_user)): + """Personalized dashboard for logged-in user.""" + ut = user.get("user_type", "") + klub_id = user.get("klub_id") + result = {"user_type": ut, "user": {"email": user.get("email"), "ime": user.get("full_name"), "user_type": ut}} + + if ut in ("klub_admin", "klub_user") and klub_id: + klub = db_one("SELECT * FROM pgz_sport.klubovi WHERE id=%s", (klub_id,)) + stats_row = db_one(""" + SELECT + (SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id=%s AND aktivan=true) as clanovi, + (SELECT count(*) FROM pgz_sport.lijecnicki WHERE klub_id=%s AND vrijedi_do < CURRENT_DATE) as med_exp, + (SELECT count(*) FROM pgz_sport.lijecnicki WHERE klub_id=%s AND vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE+30) as med_warn + """, (klub_id, klub_id, klub_id)) + result["klub"] = klub + result["stats"] = stats_row or {} + elif ut in ("pgz_admin", "super_admin", "pgz_user"): + stats = db_one(""" + SELECT + (SELECT count(*) FROM pgz_sport.klubovi WHERE aktivan=true) as klubovi, + (SELECT count(*) FROM pgz_sport.savezi WHERE aktivan=true) as savezi, + (SELECT count(*) FROM pgz_sport.clanovi WHERE aktivan=true) as clanovi, + (SELECT count(*) FROM pgz_sport.lijecnicki WHERE vrijedi_do < CURRENT_DATE) as med_exp, + (SELECT count(*) FROM pgz_sport.pgz_sport_users WHERE aktivan=true) as korisnici + """, ()) + result["stats"] = stats or {} + + return result + +# ═══════════════════════════════════════════════════════ +# GODIŠNJACI — serve PDF and TXT files +# ═══════════════════════════════════════════════════════ +import os +from fastapi.responses import FileResponse, HTMLResponse + +GODISNJACI_DIR = "/opt/pgz-sport/_data/godisnjaci" + +@router.get("/godisnjaci/pdf/{god}") +def godisnjak_pdf(god: int): + path = os.path.join(GODISNJACI_DIR, f"godisnjak_{god}.pdf") + if not os.path.exists(path): + raise HTTPException(404, f"Godišnjak {god} nije pronađen") + return FileResponse(path, media_type="application/pdf", + filename=f"godisnjak_ZSP_PGZ_{god}.pdf") + +@router.get("/godisnjaci/txt/{god}") +def godisnjak_txt(god: int): + path = os.path.join(GODISNJACI_DIR, f"godisnjak_{god}.txt") + if not os.path.exists(path): + raise HTTPException(404, f"Godišnjak {god}.txt nije pronađen") + return FileResponse(path, media_type="text/plain; charset=utf-8", + filename=f"godisnjak_ZSP_PGZ_{god}.txt") + +# ═══════════════════════════════════════════════ +# GODIŠNJACI — serve original PGZ PDFs +# ═══════════════════════════════════════════════ +import re as _re, os as _os + +GODISNJACI_PGZ_DIR = "/opt/pgz-sport/_downloads/godisnjaci_szpgz" +GODISNJACI_DATA_DIR = "/opt/pgz-sport/_data/godisnjaci" + +def _god_map(): + m = {} + if not _os.path.exists(GODISNJACI_PGZ_DIR): + return m + for f in sorted(_os.listdir(GODISNJACI_PGZ_DIR)): + mt = _re.search(r'(?:godisnjak[_-]?)(\d{4})', f, _re.IGNORECASE) + if mt: + y = int(mt.group(1)) + if 2006 <= y <= 2025: + size = _os.path.getsize(_os.path.join(GODISNJACI_PGZ_DIR, f)) + display = _re.sub(r'^[a-f0-9]{16}_', '', f) + display = _re.sub(r'_\d{4}-\d{2}-\d{2}-\d+_\w+', '', display).replace('.pdf','') + m[y] = {"file": f, "display": display, "size_kb": size//1024, "god": y} + return m + +@router.get("/godisnjaci/popis") +def godisnjaci_popis(): + """Lista svih godišnjaka ZSP PGŽ s meta podacima.""" + m = _god_map() + rows = sorted(m.values(), key=lambda x: x["god"], reverse=True) + return {"count": len(rows), "results": rows} + +@router.get("/godisnjaci/pgz-pdf/{god}") +def godisnjak_pgz_pdf(god: int): + """Serviraj originalni PGZ PDF godišnjaka.""" + m = _god_map() + if god not in m: + raise HTTPException(404, f"Godišnjak {god} nije dostupan") + path = _os.path.join(GODISNJACI_PGZ_DIR, m[god]["file"]) + return FileResponse(path, media_type="application/pdf", + filename=f"ZSP-PGZ-Sportski-godisnjak-{god}.pdf") + + +# ═══════════════════════════════════════════════════════ +# PRORAČUN — breakdown by sport + recipient +# ═══════════════════════════════════════════════════════ +@router.get("/analytics/proracun-sport") +def proracun_sport(godina: int = None): + """Raspodjela sufinanciranja po sportu za godinu.""" + import datetime + yr = godina or datetime.date.today().year + rows = db_query(""" + SELECT sport, + sum(iznos_eur) as ukupno, + count(*) as n_stavki, + STRING_AGG(DISTINCT izvor, ', ') as izvori + FROM pgz_sport.sufinanciranje_sport + WHERE godina = %s AND iznos_eur > 0 + GROUP BY sport + ORDER BY ukupno DESC + """, (yr,)) + # Get detail recipients + detail = db_query(""" + SELECT korisnik, sport, iznos_eur, vrsta, izvor, source_url + FROM pgz_sport.sufinanciranje_sport + WHERE godina = %s AND iznos_eur > 0 + ORDER BY iznos_eur DESC + LIMIT 200 + """, (yr,)) + total = float(db_exec("SELECT COALESCE(sum(iznos_eur),0) FROM pgz_sport.sufinanciranje_sport WHERE godina=%s", (yr,)) or 0) + return {"godina": yr, "total": total, "po_sportu": rows, "detalji": detail} + + +# ═══════════════════════════════════════════════════════ +# POTPORE — by year filter +# ═══════════════════════════════════════════════════════ +@router.get("/potpore/by-year") +def potpore_by_year(godina: int = None, q: str = ""): + """Sufinanciranje za specifičnu godinu.""" + import datetime + yr = godina or datetime.date.today().year + like = f"%{q}%" if q else "%" + rows = db_query(""" + SELECT korisnik, sport, iznos_eur, vrsta, napomena, izvor, source_url, godina, + (SELECT k.id FROM pgz_sport.klubovi k WHERE LOWER(k.naziv) LIKE LOWER('%%'||LEFT(korisnik,20)||'%%') AND k.aktivan=true LIMIT 1) as klub_id + FROM pgz_sport.sufinanciranje_sport + WHERE godina = %s AND LOWER(COALESCE(korisnik,'')) LIKE LOWER(%s) + ORDER BY iznos_eur DESC NULLS LAST + LIMIT 500 + """, (yr, like)) + total = sum(float(r.get('iznos_eur') or 0) for r in rows) + return {"godina": yr, "count": len(rows), "total": total, "results": rows} + + +# ═══════════════════════════════════════════════════════ +# MULTI-CHAIR conflict of interest +# ═══════════════════════════════════════════════════════ +@router.get("/graph/multi-chair") +def multi_chair(): + """Persons sitting in multiple organizations.""" + rows = db_query(""" + SELECT ime, prezime, MAX(of.oib) as oib, + count(DISTINCT COALESCE(of.savez_id::text, of.klub_id::text, of.organizacija)) as n_orgs, + STRING_AGG(DISTINCT COALESCE(s.naziv, k.naziv, of.organizacija), ' | ' + ORDER BY COALESCE(s.naziv, k.naziv, of.organizacija)) as orgs, + STRING_AGG(DISTINCT of.funkcija, ', ') as funkcije + FROM pgz_sport.osobe_funkcije of + LEFT JOIN pgz_sport.savezi s ON of.savez_id = s.id + LEFT JOIN pgz_sport.klubovi k ON of.klub_id = k.id + GROUP BY LOWER(ime), LOWER(prezime), ime, prezime + HAVING count(DISTINCT COALESCE(of.savez_id::text, of.klub_id::text, of.organizacija)) >= 2 + ORDER BY n_orgs DESC, prezime + """) + return {"count": len(rows), "results": rows} + + +@router.get("/graph/iframe", response_class=HTMLResponse) +def graph_iframe(savez_id: int = None, q: str = "", limit: int = 300): + """Serve complete D3 force graph as HTML — iframe approach.""" + # Get data + filters = ["1=1"] + params = [] + if q: + filters.append("(LOWER(of.ime||' '||of.prezime) LIKE LOWER(%s) OR LOWER(COALESCE(s.naziv,'')) LIKE LOWER(%s))") + params += [f"%{q}%", f"%{q}%"] + if savez_id: + filters.append("of.savez_id = %s") + params.append(savez_id) + + rows = db_query(f""" + SELECT of.id, of.ime, of.prezime, of.funkcija, of.sport, of.oib, + of.savez_id, s.naziv as savez_naziv, + of.klub_id, k.naziv as klub_naziv + FROM pgz_sport.osobe_funkcije of + LEFT JOIN pgz_sport.savezi s ON of.savez_id = s.id + LEFT JOIN pgz_sport.klubovi k ON of.klub_id = k.id + WHERE {" AND ".join(filters)} + ORDER BY of.prezime, of.ime LIMIT %s + """, params + [limit]) + + nodes = {} + edges = [] + + def add_node(nid, label, ntype, meta=None): + if nid not in nodes: + nodes[nid] = {"id": nid, "label": label, "type": ntype, **(meta or {})} + + # Count multi-chair + person_org_count = {} + for r in rows: + pid = f"p_{r['id']}" + person_org_count[pid] = person_org_count.get(pid, 0) + 1 + + for r in rows: + pid = f"p_{r['id']}" + add_node(pid, f"{r['ime']} {r['prezime']}", "person", + {"funkcija": r.get("funkcija"), "sport": r.get("sport"), + "oib": r.get("oib"), "multiChair": person_org_count.get(pid,0) > 1}) + if r.get("savez_id"): + sid = f"s_{r['savez_id']}" + sn = r.get("savez_naziv") or f"Savez {r['savez_id']}" + add_node(sid, sn[:40]+("…" if len(sn)>40 else ""), "savez") + edges.append({"source": pid, "target": sid, + "label": (r.get("funkcija") or "")[:30], "sport": r.get("sport","")}) + if r.get("klub_id"): + kid = f"k_{r['klub_id']}" + kn = r.get("klub_naziv") or f"Klub {r['klub_id']}" + add_node(kid, kn[:35]+("…" if len(kn)>35 else ""), "klub") + edges.append({"source": pid, "target": kid, + "label": (r.get("funkcija") or "")[:30], "sport": r.get("sport","")}) + + import json as _json + nodes_json = _json.dumps(list(nodes.values())) + edges_json = _json.dumps(edges) + + html = """ + + + + + + + +
+
+
+
+
+
+
+
+
Osoba
+
Savez
+
Klub
+
+
+ + +""" + return html + + + + +@router.get("/med/pregled-status") +def med_pregled_status(q: str = "", klub_id: int = None): + """Status medicinskih pregleda clanova.""" + import datetime + today = datetime.date.today() + warn_date = today + datetime.timedelta(days=30) + + filters = ["1=1"] + params = [] + if q: + filters.append("(LOWER(c.ime||' '||c.prezime) LIKE LOWER(%s) OR LOWER(COALESCE(k.naziv,'')) LIKE LOWER(%s))") + params += [f"%{q}%", f"%{q}%"] + if klub_id: + filters.append("c.klub_id = %s") + params.append(klub_id) + + rows = db_query(f""" + SELECT c.id, c.ime, c.prezime, c.datum_rodenja, c.licenca_vrijedi_do AS med_expiry, + c.kategorija, c.hoo_kategorija, c.sport, c.pozicija, + k.naziv as klub_naziv, k.id as klub_id + FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON c.klub_id = k.id + WHERE {" AND ".join(filters)} + ORDER BY c.licenca_vrijedi_do ASC NULLS LAST, c.prezime + LIMIT 500 + """, params) + + for r in rows: + exp = r.get("med_expiry") + if not exp: + r["status"] = "unknown" + elif exp < today: + r["status"] = "expired" + elif exp <= warn_date: + r["status"] = "warning" + else: + r["status"] = "ok" + + expired = sum(1 for r in rows if r["status"] == "expired") + warning = sum(1 for r in rows if r["status"] == "warning") + ok = sum(1 for r in rows if r["status"] == "ok") + unknown = sum(1 for r in rows if r["status"] == "unknown") + + return { + "count": len(rows), "expired": expired, "warning": warning, + "ok": ok, "unknown": unknown, "rows": rows + } + + +@router.get("/erp/putni-nalozi") +def erp_putni_nalozi(klub_id: int = None, status: str = None): + """Lista putnih naloga.""" + filters = ["1=1"] + params = [] + if klub_id: + filters.append("pn.klub_id = %s"); params.append(klub_id) + if status: + filters.append("pn.status = %s"); params.append(status) + + # Table might not exist yet + try: + rows = db_query(f""" + SELECT pn.*, k.naziv as klub_naziv, + n.naziv as natjecanje_naziv + FROM pgz_sport.putni_nalozi pn + LEFT JOIN pgz_sport.klubovi k ON pn.klub_id = k.id + LEFT JOIN pgz_sport.natjecanja n ON pn.natjecanje_id = n.id + WHERE {" AND ".join(filters)} + ORDER BY pn.datum DESC LIMIT 100 + """, params) + return {"count": len(rows), "rows": rows} + except Exception: + return {"count": 0, "rows": [], "note": "Tablica putni_nalozi u razvoju"} + + +@router.post("/erp/putni-nalozi") +def erp_putni_nalog_create(body: dict = Body({})): + """Kreiraj novi putni nalog.""" + try: + db_exec(""" + CREATE TABLE IF NOT EXISTS pgz_sport.putni_nalozi ( + id SERIAL PRIMARY KEY, + klub_id INTEGER, + natjecanje_id INTEGER, + destinacija TEXT, + datum DATE, + broj_sudionika INTEGER DEFAULT 1, + iznos_eur NUMERIC(10,2), + napomena TEXT, + status TEXT DEFAULT 'na_cekanju', + created_at TIMESTAMPTZ DEFAULT NOW() + ) + """) + db_exec(""" + INSERT INTO pgz_sport.putni_nalozi + (klub_id, natjecanje_id, destinacija, datum, broj_sudionika, iznos_eur, napomena) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, (body.get("klub_id"), body.get("natjecanje_id"), body.get("destinacija"), + body.get("datum"), body.get("broj_sudionika", 1), + body.get("iznos_eur"), body.get("napomena"))) + return {"ok": True} + except Exception as e: + raise HTTPException(500, str(e)) + + +@router.get("/dms/documents") +def dms_documents(klub_id: int = None, limit: int = 20): + """Lista uploadanih dokumenata.""" + try: + rows = db_query(""" + SELECT id, naziv, filename, mime_type, size_kb, url, klub_id, created_at + FROM pgz_sport.dms_documents + ORDER BY created_at DESC LIMIT %s + """, (limit,)) + return {"count": len(rows), "rows": rows} + except Exception: + return {"count": 0, "rows": [], "note": "DMS tablica u razvoju"} + + +@router.post("/dms/upload") +async def dms_upload(file: UploadFile = File(...)): + """Upload dokumenta u DMS.""" + import os, uuid, aiofiles + upload_dir = "/opt/pgz-sport/uploads/dms" + os.makedirs(upload_dir, exist_ok=True) + + ext = os.path.splitext(file.filename)[1] + fname = f"{uuid.uuid4().hex}{ext}" + fpath = os.path.join(upload_dir, fname) + + content = await file.read() + with open(fpath, "wb") as f: + f.write(content) + + size_kb = len(content) // 1024 + url = f"/api/v2/dms/file/{fname}" + + try: + db_exec(""" + CREATE TABLE IF NOT EXISTS pgz_sport.dms_documents ( + id SERIAL PRIMARY KEY, naziv TEXT, filename TEXT, + mime_type TEXT, size_kb INTEGER, url TEXT, + klub_id INTEGER, created_at TIMESTAMPTZ DEFAULT NOW() + ) + """) + db_exec(""" + INSERT INTO pgz_sport.dms_documents (naziv, filename, mime_type, size_kb, url) + VALUES (%s, %s, %s, %s, %s) + """, (file.filename, fname, file.content_type, size_kb, url)) + except Exception: + pass + + return {"ok": True, "url": url, "size_kb": size_kb} + +# ═══════════════════════════════════════════════════════════════════ +# Fajl: graph_3d_endpoint.py (patch chunk for pgz_sport_v2_router.py) +# Verzija: 1.0.0 +# Datum: 04.05.2026 +# Autor: Damir Radulić +# Lokacija: /opt/pgz-sport/pgz_sport_v2_router.py (append before last line) +# Svrha: 3D person-network endpoint — multi-chair detection +# Zavisi od: db_query helper iz pgz_sport_v2_router +# Utječe na: novi /api/v2/graph/3d-network endpoint +# ═══════════════════════════════════════════════════════════════════ + +@router.get("/graph/3d-network") +def graph_3d_network(min_orgs: int = 2, top_n: int = 100, sport: str = ""): + """3D person-network graf. Multi-chair osobe + njihove organizacije. + + Args: + min_orgs: minimalan broj organizacija po osobi (default 2 = multi-chair) + top_n: max broj osoba (sortirano po n_orgs desc) + sport: filter po sportu saveza (opcionalno) + + Returns: + { + "nodes": [{"id": "p:dragan-naglic", "name": "Dragan Naglić", + "type": "person", "n_orgs": 6, "val": 6}, ...], + "links": [{"source": "p:dragan-naglic", "target": "klub:123", + "role": "predsjednik"}, ...], + "stats": {"persons": N, "orgs": M, "links": L, "multichair": K} + } + """ + # All person-org relationships + sql = """ + WITH all_links AS ( + SELECT lower(trim(predsjednik)) AS person_key, + initcap(trim(predsjednik)) AS person_name, + 'klub:'||k.id AS org_id, + k.naziv AS org_name, 'klub' AS org_type, + 'predsjednik' AS role, + k.sport + FROM pgz_sport.klubovi k + WHERE predsjednik IS NOT NULL AND length(trim(predsjednik)) > 5 + AND (%(sport)s = '' OR k.sport = %(sport)s) + UNION ALL + SELECT lower(trim(tajnik)), initcap(trim(tajnik)), + 'klub:'||k.id, k.naziv, 'klub', 'tajnik', k.sport + FROM pgz_sport.klubovi k + WHERE tajnik IS NOT NULL AND length(trim(tajnik)) > 5 + AND (%(sport)s = '' OR k.sport = %(sport)s) + UNION ALL + SELECT lower(trim(predsjednik)), initcap(trim(predsjednik)), + 'savez:'||s.id, s.naziv, 'savez', 'predsjednik', NULL + FROM pgz_sport.savezi s + WHERE predsjednik IS NOT NULL AND length(trim(predsjednik)) > 5 + UNION ALL + SELECT lower(trim(tajnik)), initcap(trim(tajnik)), + 'savez:'||s.id, s.naziv, 'savez', 'tajnik', NULL + FROM pgz_sport.savezi s + WHERE tajnik IS NOT NULL AND length(trim(tajnik)) > 5 + ), + person_stats AS ( + SELECT person_key, max(person_name) AS person_name, + count(DISTINCT org_id) AS n_orgs, + string_agg(DISTINCT org_type, ',') AS org_types + FROM all_links + GROUP BY person_key + HAVING count(DISTINCT org_id) >= %(min_orgs)s + ORDER BY count(DISTINCT org_id) DESC + LIMIT %(top_n)s + ) + SELECT al.person_key, ps.person_name, al.org_id, al.org_name, + al.org_type, al.role, al.sport, ps.n_orgs + FROM all_links al + JOIN person_stats ps ON al.person_key = ps.person_key + """ + rows = db_query(sql, {"min_orgs": min_orgs, "top_n": top_n, "sport": sport}) + + nodes = {} + links = [] + multichair = 0 + + for r in rows: + pid = "p:" + r["person_key"].replace(" ", "-").replace(",", "")[:60] + if pid not in nodes: + n_orgs = r["n_orgs"] + nodes[pid] = { + "id": pid, + "name": r["person_name"], + "type": "multichair" if n_orgs >= 2 else "person", + "n_orgs": n_orgs, + "val": min(n_orgs * 3, 30) # 3D graf node size + } + if n_orgs >= 2: + multichair += 1 + + oid = r["org_id"] + if oid not in nodes: + nodes[oid] = { + "id": oid, + "name": r["org_name"][:40], + "type": r["org_type"], + "sport": r["sport"], + "val": 5 + } + + links.append({ + "source": pid, + "target": oid, + "role": r["role"] + }) + + return { + "nodes": list(nodes.values()), + "links": links, + "stats": { + "persons": sum(1 for n in nodes.values() if n["type"] in ("person","multichair")), + "multichair": multichair, + "orgs": sum(1 for n in nodes.values() if n["type"] in ("klub","savez")), + "links": len(links), + "min_orgs": min_orgs, + "top_n": top_n, + "sport": sport or "svi" + } + } + + +@router.get("/graph/3d-iframe", response_class=HTMLResponse) +def graph_3d_iframe(min_orgs: int = 2, top_n: int = 100, sport: str = ""): + """v2.0 — 3D ForceGraph s drill-down detail panel + year/sport filtri + highlight search.""" + import os + p = os.path.join(os.path.dirname(__file__), "static", "sport_3d_v2.html") + if os.path.exists(p): + with open(p, encoding="utf-8") as f: + return f.read() + return "

3D iframe not found

" + diff --git a/_backups/r3_cc4/pgz_sport_api.py.pre_M6.1777932513 b/_backups/r3_cc4/pgz_sport_api.py.pre_M6.1777932513 new file mode 100644 index 0000000..0c50b27 --- /dev/null +++ b/_backups/r3_cc4/pgz_sport_api.py.pre_M6.1777932513 @@ -0,0 +1,1668 @@ +#!/usr/bin/env python3 +""" +pgz_sport_api.py - FastAPI backend za PGŽ Sportski savez ERP/CRM +Author: Damir Radulić (damir@rinet.one) +Date: 25.04.2026 +Port: 8095 +Endpoints: savezi, klubovi, članovi, članarine, liječnički, manifestacije, proračun, dashboard, alertovi +""" + +from fastapi import FastAPI, HTTPException, Query, Body, Header, Depends, UploadFile, File, Form, Request +import json +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import Optional, List +from datetime import date, datetime +import psycopg2 +import psycopg2.extras +from pgz_sport_v2_router import router as v2_router +import os + +DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7') + + +ADMIN_TOKEN = 'admin-pgz-2026' + +def is_admin(authorization): + if not authorization: return False + token = authorization.replace('Bearer ', '').strip() + if token == ADMIN_TOKEN: return True + # Try JWT + try: + import jwt as _jwt + payload = _jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return payload.get("role") == "admin" + except Exception: + return False + +def blur_oib(v): + if not v: return v + s = str(v); + return s[:3] + '•'*(len(s)-5) + s[-2:] if len(s) >= 8 else '•'*len(s) +def blur_email(e): + if not e or '@' not in str(e): return e + u, d = str(e).split('@',1); return (u[:1]+'•••' if u else '')+'@'+d +def blur_phone(p): + if not p: return p + s=str(p); return s[:4]+'•'*(len(s)-7)+s[-3:] if len(s)>=7 else s +def blur_iban(v): + if not v: return v + s=str(v); return s[:4]+'•'*(len(s)-8)+s[-4:] if len(s)>=8 else s +def blur_date(d): + if not d: return d + s = str(d); return s[:4]+'-••-••' if len(s)>=4 else s +def blur_text(t, keep=3): + if not t: return t + s=str(t); return s[:keep]+'•'*(len(s)-keep*2)+s[-keep:] if len(s)>keep*2 else s + +def apply_privacy(rows, admin): + if admin: return rows + out = [] + for r in (rows if isinstance(rows, list) else [rows]): + rr = dict(r) + for k, v in list(rr.items()): + if v is None: continue + kl = k.lower() + if 'oib' in kl: rr[k] = blur_oib(v) + elif 'email' in kl: rr[k] = blur_email(v) + elif kl in ('telefon','tel','phone'): rr[k] = blur_phone(v) + elif kl == 'datum_rodenja': rr[k] = blur_date(v) + elif 'iban' in kl: rr[k] = blur_iban(v) + elif kl == 'adresa': rr[k] = blur_text(v, 3) + elif 'licenca_broj' in kl: rr[k] = blur_text(v, 2) + out.append(rr) + return out if isinstance(rows, list) else out[0] + +app = FastAPI(title="PGŽ Sportski savez ERP/CRM", version="1.0.0") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + + +# === URL rewrite middleware - convert direct external image URLs to /img-proxy === +import json as _json_mw +import re as _re_mw +from starlette.responses import Response as _StarletteResponse_mw + +_IMG_DOMAINS_RE = _re_mw.compile( + r'https?://(?:hns\.family|hns\.hr|hbs\.hr|hrvatski-bocarski-savez\.hr|' + r'rk-zamet\.hr|hvs\.hr|rezultati\.hvs\.hr|sport-pgz\.hr)' + r'/[^"\s\\]+\.(?:jpg|jpeg|png|gif|webp|svg)', + _re_mw.IGNORECASE +) + +def _rewrite_to_proxy(text: str) -> str: + """Replace external image URLs with /sport/api/v2/img-proxy?u=...""" + from urllib.parse import quote as _q + def _sub(m): + url = m.group(0) + return "/sport/api/v2/img-proxy?u=" + _q(url, safe='') + return _IMG_DOMAINS_RE.sub(_sub, text) + +@app.middleware("http") +async def url_rewrite_middleware(request, call_next): + response = await call_next(request) + # Only rewrite JSON API responses + ct = response.headers.get("content-type", "") + if "application/json" not in ct: + return response + # Only on /api/v2 routes (admin & data endpoints) - SKIP /api/v2/img-proxy itself + path = request.url.path + if "/api/v2/img-proxy" in path or "/api/v2/dokumenti" in path: + return response # don't rewrite raw document content + # Read body + body = b"" + async for chunk in response.body_iterator: + body += chunk + try: + text = body.decode("utf-8") + new_text = _rewrite_to_proxy(text) + new_body = new_text.encode("utf-8") + except Exception: + new_body = body + return _StarletteResponse_mw( + content=new_body, + status_code=response.status_code, + headers={k: v for k, v in response.headers.items() if k.lower() not in ("content-length",)}, + media_type=ct, + ) +# === end URL rewrite middleware === + +def db(): + conn = psycopg2.connect(**DB) + conn.autocommit = True + return conn + +def fetch(sql, params=None): + with db() as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as c: + c.execute(sql, params or ()) + return [dict(r) for r in c.fetchall()] + +def execute(sql, params=None): + with db() as conn: + with conn.cursor() as c: + c.execute(sql, params or ()) + return c.rowcount + +# ==================== HEALTH ==================== +@app.get("/health") +def health(): + try: + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + return {"status": "ok", "service": "pgz_sport", "dashboard": rows[0] if rows else None} + except Exception as e: + raise HTTPException(500, f"DB error: {e}") + + +@app.get("/api/whoami") +def whoami_v2(authorization: Optional[str] = Header(None)): + return {"role": "admin" if is_admin(authorization) else "viewer", "privacy_active": not is_admin(authorization)} + +# ==================== DASHBOARD ==================== +@app.get("/api/dashboard") +def dashboard(): + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + if not rows: + return {} + d = rows[0] + # Top savezi by registriranih 2024 + top = fetch("""SELECT s.naziv, st.klubova_clanica, st.registriranih, st.trenera, st.reprezentativaca + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina=2024 ORDER BY st.registriranih DESC LIMIT 10""") + proracun_trend = fetch("SELECT godina, ukupno FROM pgz_sport.proracun ORDER BY godina") + nositelji = fetch("""SELECT naziv_kluba, godina, iznos FROM pgz_sport.potpore_nositelji + WHERE godina = 2025 ORDER BY iznos DESC LIMIT 10""") + return {**d, "top_savezi": top, "proracun_trend": proracun_trend, "nositelji_2025": nositelji} + +@app.get("/api/dashboard/ekosustav") +def dashboard_ekosustav(): + """Sport ekosustav PGŽ — coverage stats za enrichment iz FINA registra.""" + summary = fetch("""SELECT + COUNT(*) AS klubova_total, + COUNT(*) FILTER (WHERE oib IS NOT NULL) AS s_oib, + COUNT(*) FILTER (WHERE predsjednik IS NOT NULL) AS s_predsjednik, + COUNT(*) FILTER (WHERE tajnik IS NOT NULL) AS s_tajnik, + COUNT(*) FILTER (WHERE ciljevi IS NOT NULL) AS s_ciljevi, + COUNT(*) FILTER (WHERE opis_djelatnosti IS NOT NULL) AS s_opis, + COUNT(*) FILTER (WHERE sjediste IS NOT NULL) AS s_sjediste, + COUNT(*) FILTER (WHERE email IS NOT NULL) AS s_email, + COUNT(*) FILTER (WHERE web_stranica IS NOT NULL) AS s_web, + COUNT(*) FILTER (WHERE udruga_status = \'AKTIVAN\') AS s_aktivan_reg, + COUNT(*) FILTER (WHERE savez_id IS NOT NULL) AS s_savez, + COUNT(*) FILTER (WHERE nositelj_kvalitete) AS s_nositelj + FROM pgz_sport.klubovi WHERE aktivan""")[0] + + by_sport = fetch("""SELECT sport, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND sport IS NOT NULL + GROUP BY sport ORDER BY COUNT(*) DESC LIMIT 15""") + + by_region = fetch("""SELECT region, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND region IS NOT NULL + GROUP BY region ORDER BY COUNT(*) DESC""") + + by_grad = fetch("""SELECT grad, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND grad IS NOT NULL + GROUP BY grad ORDER BY COUNT(*) DESC LIMIT 12""") + + decade = fetch("""SELECT + CASE + WHEN godina_osnutka < 1950 THEN \'pred1950\' + WHEN godina_osnutka < 1980 THEN \'1950-1979\' + WHEN godina_osnutka < 2000 THEN \'1980-1999\' + WHEN godina_osnutka < 2010 THEN \'2000-2009\' + WHEN godina_osnutka >= 2010 THEN \'2010-danas\' + ELSE \'nepoznato\' + END AS razdoblje, + COUNT(*) AS broj + FROM pgz_sport.klubovi + WHERE aktivan AND godina_osnutka IS NOT NULL + GROUP BY razdoblje ORDER BY razdoblje""") + + # Pokazi enrichment % + total = summary["klubova_total"] or 1 + coverage = { + "oib_pct": round(100 * summary["s_oib"] / total, 1), + "predsjednik_pct": round(100 * summary["s_predsjednik"] / total, 1), + "tajnik_pct": round(100 * summary["s_tajnik"] / total, 1), + "ciljevi_pct": round(100 * summary["s_ciljevi"] / total, 1), + "opis_pct": round(100 * summary["s_opis"] / total, 1), + "sjediste_pct": round(100 * summary["s_sjediste"] / total, 1), + "email_pct": round(100 * summary["s_email"] / total, 1), + "savez_pct": round(100 * summary["s_savez"] / total, 1), + } + + return {**summary, "coverage": coverage, "by_sport": by_sport, + "by_region": by_region, "by_grad": by_grad, "by_decade": decade} + + + +# ==================== ANALYTICS ==================== +@app.get("/api/analytics/savezi-trend") +def savezi_trend(godine: str = "2020,2021,2022,2023,2024", metric: str = "registriranih"): + valid_metrics = {"registriranih", "neregistriranih", "rekreativaca", "trenera", "reprezentativaca", + "kategoriziranih", "stipendiranih", "klubova_clanica"} + if metric not in valid_metrics: + raise HTTPException(400, f"Invalid metric. Must be one of: {valid_metrics}") + god_list = [int(g) for g in godine.split(",")] + rows = fetch(f"""SELECT s.naziv AS savez, st.godina, st.{metric} AS value + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina = ANY(%s) ORDER BY s.naziv, st.godina""", [god_list]) + saveze = {} + for r in rows: + if r['savez'] not in saveze: saveze[r['savez']] = {} + saveze[r['savez']][r['godina']] = r['value'] + return {"metric": metric, "godine": god_list, "data": saveze} + +@app.get("/api/analytics/proracun-detaljno") +def proracun_detaljno(): + p = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + if not p: return {"proracun": [], "rast_godisnji": [], "current_year": None, "current_total": 0, "rast_dekada_pct": 0} + cagr = [] + for i in range(1, len(p)): + prev = float(p[i-1]['ukupno']) if p[i-1]['ukupno'] else 0 + curr = float(p[i]['ukupno']) if p[i]['ukupno'] else 0 + rate = ((curr/prev - 1) * 100) if prev > 0 else 0 + cagr.append({"godina": p[i]['godina'], "rast_postotak": round(rate, 1)}) + decade_rast = round((float(p[-1]['ukupno'])/float(p[0]['ukupno']) - 1) * 100, 1) if p[0]['ukupno'] else 0 + return {"proracun": p, "rast_godisnji": cagr, "rast_dekada_pct": decade_rast, + "current_year": int(p[-1]['godina']), "current_total": float(p[-1]['ukupno'])} + +@app.get("/api/analytics/klub-financije") +def klub_financije(klub_id: Optional[int] = None, godina: Optional[int] = None): + where = [] + params = [] + if godina: where.append("p.godina=%s"); params.append(godina) + if klub_id: + where.append("(p.klub_id=%s OR p.naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))") + params.extend([klub_id, klub_id]) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"""SELECT p.naziv_kluba, p.godina, p.iznos, + k.id AS klub_id, k.sport, k.razina, k.nositelj_kvalitete + FROM pgz_sport.potpore_nositelji p + LEFT JOIN pgz_sport.klubovi k ON p.klub_id=k.id OR p.naziv_kluba=k.naziv + {where_sql} ORDER BY p.godina DESC, p.iznos DESC""", params) + summary = fetch(f"""SELECT godina, SUM(iznos) AS total, COUNT(*) AS klubova, AVG(iznos) AS prosjek + FROM pgz_sport.potpore_nositelji p {where_sql} + GROUP BY godina ORDER BY godina""", params) + return {"data": rows, "summary": summary} + +@app.get("/api/analytics/lijecnicki-stats") +def lijecnicki_stats(klub_id: Optional[int] = None): + where = ["1=1"]; params = [] + if klub_id: where.append("c.klub_id=%s"); params.append(klub_id) + where_sql = " AND ".join(where) + rows = fetch(f"""SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE lp.vrijedi_do >= CURRENT_DATE + 30) AS validni, + COUNT(*) FILTER (WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + 30) AS uskoro, + COUNT(*) FILTER (WHERE lp.vrijedi_do < CURRENT_DATE) AS istekli, + SUM(lp.iznos) AS ukupan_trosak, SUM(lp.iznos_zzjz) AS zzjz_udio, + SUM(lp.iznos_klub) AS klub_udio, SUM(lp.iznos_clan) AS clan_udio, + AVG(lp.iznos) AS prosjecni_trosak + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id WHERE {where_sql}""", params) + by_ustanova = fetch(f"""SELECT lp.ustanova, COUNT(*) cnt, SUM(lp.iznos) iznos + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} GROUP BY lp.ustanova ORDER BY cnt DESC""", params) + by_lijecnik = fetch(f"""SELECT lp.lijecnik, COUNT(*) cnt, AVG(lp.iznos) prosjek + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} AND lp.lijecnik IS NOT NULL GROUP BY lp.lijecnik ORDER BY cnt DESC""", params) + return {"summary": rows[0] if rows else {}, "by_ustanova": by_ustanova, "by_lijecnik": by_lijecnik} + +# ==================== SAVEZI ==================== +@app.get("/api/savezi") +def list_savezi(authorization: Optional[str] = Header(None), q: Optional[str] = None, + razina: Optional[str] = None, zupanija: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = "WHERE aktivan" + params = [] + if q: + where += " AND (naziv ILIKE %s OR sport ILIKE %s)" + params = [f"%{q}%", f"%{q}%"] + if razina: + where += " AND razina = %s"; params.append(razina) + if zupanija: + where += " AND sjediste_zupanija ILIKE %s"; params.append(f"%{zupanija}%") + sort_col = {"naziv": "naziv", "godina": "godina_osnutka", "sport": "sport", "razina": "razina"}.get(sort, "naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + # Croatian collation for text columns (Š → after S, Č → after C, etc.) + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("naziv", "sport") else "" + rows = fetch(f"""SELECT s.*, + (SELECT COUNT(*) FROM pgz_sport.klubovi WHERE savez_id=s.id) AS broj_klubova, + (SELECT registriranih FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS reg_2024, + (SELECT trenera FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS treneri_2024, + (SELECT reprezentativaca FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS repr_2024 + FROM pgz_sport.savezi s {where} ORDER BY {sort_col}{collate} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/savezi/{savez_id}") +def get_savez(savez_id: int): + rows = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s", [savez_id]) + if not rows: + raise HTTPException(404, "Savez ne postoji") + klubovi = fetch("SELECT * FROM pgz_sport.klubovi WHERE savez_id=%s ORDER BY naziv", [savez_id]) + statistika = fetch("SELECT * FROM pgz_sport.statistika_saveza WHERE savez_id=%s ORDER BY godina", [savez_id]) + manifestacije = fetch("SELECT * FROM pgz_sport.manifestacije WHERE savez_id=%s", [savez_id]) + return {**rows[0], "klubovi": klubovi, "statistika": statistika, "manifestacije": manifestacije} + +# ==================== KLUBOVI ==================== +@app.get("/api/klubovi") +def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, savez_id: Optional[int] = None, + nositelj: Optional[bool] = None, region: Optional[str] = None, sport: Optional[str] = None, grad: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivan"] + params = [] + if q: + where.append("(klub ILIKE %s OR oib ILIKE %s OR sport ILIKE %s OR predsjednik ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%", f"%{q}%"]) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + if nositelj is not None: + where.append(f"nositelj_kvalitete={'TRUE' if nositelj else 'FALSE'}") + if region: + where.append("region ILIKE %s"); params.append(region) + if grad: + where.append("grad ILIKE %s"); params.append(f"%{grad}%") + if sport: + where.append("sport ILIKE %s"); params.append(f"%{sport}%") + sort_col = {"naziv": "klub", "savez": "savez", "broj_clanova": "broj_clanova", + "razina": "razina", "region": "region", "grad": "grad", "sport": "sport"}.get(sort, "klub") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("klub", "savez", "razina", "region", "grad", "sport") else "" + rows = fetch(f"""SELECT * FROM pgz_sport.v_klubovi_pregled WHERE {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST""", params) + for r in rows: + if isinstance(r, dict) and r.get('klub') and not r.get('naziv'): + r['naziv'] = r['klub'] + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/klubovi/{klub_id}") +def get_klub(klub_id: int, authorization: Optional[str] = Header(None)): + admin = is_admin(authorization) + rows = fetch("""SELECT k.*, s.naziv AS savez_naziv FROM pgz_sport.klubovi k + LEFT JOIN pgz_sport.savezi s ON s.id=k.savez_id WHERE k.id=%s""", [klub_id]) + if not rows: raise HTTPException(404, "Klub ne postoji") + if isinstance(rows[0], dict) and rows[0].get('klub') and not rows[0].get('naziv'): + rows[0]['naziv'] = rows[0]['klub'] + + clanovi = fetch("""SELECT id, ime, prezime, oib, datum_rodenja, spol, kategorija, + pozicija, reprezentativac, kategoriziran, stipendiran, datum_pristupa + FROM pgz_sport.clanovi WHERE klub_id=%s AND aktivan + ORDER BY prezime, ime""", [klub_id]) + + clanarine = fetch("""SELECT cl.id, cl.godina, cl.razdoblje, cl.iznos_propisan, cl.iznos_placen, + (cl.iznos_propisan - cl.iznos_placen) AS dug, cl.datum_uplate, cl.status, cl.napomena, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib + FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE c.klub_id=%s ORDER BY cl.godina DESC, cl.id DESC""", [klub_id]) + + lijecnicki = fetch("""SELECT lp.id, lp.datum_pregleda, lp.vrijedi_do, lp.vrsta_pregleda, + lp.ustanova, lp.lijecnik, lp.spreman_za_natjecanje, lp.iznos, lp.iznos_zzjz, lp.iznos_klub, lp.iznos_clan, + lp.placeno, lp.komentar_lijecnika, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib, + CASE WHEN lp.vrijedi_do IS NULL THEN 'Nepoznato' + WHEN lp.vrijedi_do < CURRENT_DATE THEN 'Istekao' + WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'Ističe uskoro' + ELSE 'Validan' END AS status_pregled + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s ORDER BY lp.datum_pregleda DESC""", [klub_id]) + + potpore = fetch("""SELECT * FROM pgz_sport.potpore_nositelji + WHERE klub_id=%s OR naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s) + ORDER BY godina DESC""", [klub_id, klub_id]) + + # Aggregate stats + stats = { + 'broj_clanova': len(clanovi), + 'broj_registriranih': sum(1 for c in clanovi if c.get('kategorija')=='registrirani'), + 'broj_trenera': sum(1 for c in clanovi if c.get('kategorija')=='trener'), + 'broj_reprezentativaca': sum(1 for c in clanovi if c.get('reprezentativac')), + 'broj_kategoriziranih': sum(1 for c in clanovi if c.get('kategoriziran')), + 'broj_stipendiranih': sum(1 for c in clanovi if c.get('stipendiran')), + 'lijecnicki_validni': sum(1 for l in lijecnicki if l.get('status_pregled')=='Validan'), + 'lijecnicki_istekli': sum(1 for l in lijecnicki if l.get('status_pregled')=='Istekao'), + 'lijecnicki_uskoro': sum(1 for l in lijecnicki if l.get('status_pregled')=='Ističe uskoro'), + 'clanarina_naplaceno_god': sum(float(c.get('iznos_placen') or 0) for c in clanarine if c.get('godina')==2026), + 'clanarina_dug_god': sum(float(c.get('dug') or 0) for c in clanarine if c.get('godina')==2026), + 'potpore_2025': float(next((p['iznos'] for p in potpore if p.get('godina')==2025), 0) or 0), + 'potpore_total': sum(float(p.get('iznos') or 0) for p in potpore), + 'zzjz_isplaceno': sum(float(l.get('iznos_zzjz') or 0) for l in lijecnicki if l.get('placeno')), + } + + klub = rows[0] + if not admin: + klub = apply_privacy(klub, admin) + clanovi = apply_privacy(clanovi, admin) + clanarine = apply_privacy(clanarine, admin) + lijecnicki = apply_privacy(lijecnicki, admin) + + return {**klub, "clanovi": clanovi, "clanarine": clanarine, "lijecnicki": lijecnicki, + "potpore": potpore, "stats": stats} + + +class KlubIn(BaseModel): + naziv: str + savez_id: Optional[int] = None + sport: Optional[str] = None + oib: Optional[str] = None + razina: Optional[str] = None + nositelj_kvalitete: Optional[bool] = False + grad: Optional[str] = None + region: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + predsjednik: Optional[str] = None + iban: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/klubovi") +def create_klub(k: KlubIn): + rows = fetch("""INSERT INTO pgz_sport.klubovi (naziv, savez_id, sport, oib, razina, nositelj_kvalitete, grad, region, email, telefon, predsjednik, iban, napomena, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE) RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena]) + return rows[0] + +@app.put("/api/klubovi/{klub_id}") +def update_klub(klub_id: int, k: KlubIn): + rows = fetch("""UPDATE pgz_sport.klubovi SET naziv=%s, savez_id=%s, sport=%s, oib=%s, razina=%s, + nositelj_kvalitete=%s, grad=%s, region=%s, email=%s, telefon=%s, predsjednik=%s, iban=%s, napomena=%s, + updated_at=NOW() WHERE id=%s RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena, klub_id]) + if not rows: + raise HTTPException(404, "Klub ne postoji") + return rows[0] + +# ==================== ČLANOVI ==================== +@app.get("/api/clanovi") +def list_clanovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, klub_id: Optional[int] = None, + kategorija: Optional[str] = None, spol: Optional[str] = None, sort: str = "prezime", order: str = "asc"): + where = ["c.aktivan"] + params = [] + if q: + where.append("(c.ime ILIKE %s OR c.prezime ILIKE %s OR c.oib ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%"]) + if klub_id: + where.append("c.klub_id=%s"); params.append(klub_id) + if kategorija: + where.append("c.kategorija=%s"); params.append(kategorija) + if spol: + # Normalize: Z → Ž, F → Ž (legacy) + spol_norm = "Ž" if spol.upper() in ("Z","Ž","F","W") else "M" if spol.upper() in ("M",) else spol + where.append("c.spol=%s"); params.append(spol_norm) + sort_map = {"prezime": "c.prezime", "ime": "c.ime", "oib": "c.oib", "datum_rodenja": "c.datum_rodenja", "kategorija": "c.kategorija", "klub": "k.naziv"} + sort_col = sort_map.get(sort, "c.prezime") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT c.*, k.naziv AS klub_naziv, + (SELECT MAX(vrijedi_do) FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=c.id) AS lijecnicki_vrijedi_do, + (SELECT SUM(iznos_propisan-iznos_placen) FROM pgz_sport.clanarine WHERE clan_id=c.id AND status!='podmireno') AS dug_clanarine + FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE {where_sql} ORDER BY {sort_col} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +class ClanIn(BaseModel): + klub_id: int + ime: str + prezime: str + oib: Optional[str] = None + datum_rodenja: Optional[date] = None + spol: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + kategorija: Optional[str] = "registrirani" + pozicija: Optional[str] = None + licenca_broj: Optional[str] = None + licenca_vrijedi_do: Optional[date] = None + reprezentativac: Optional[bool] = False + kategoriziran: Optional[bool] = False + stipendiran: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/clanovi") +def create_clan(c: ClanIn): + rows = fetch("""INSERT INTO pgz_sport.clanovi (klub_id, ime, prezime, oib, datum_rodenja, spol, email, telefon, kategorija, pozicija, licenca_broj, licenca_vrijedi_do, reprezentativac, kategoriziran, stipendiran, napomena, aktivan, datum_pristupa) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE,CURRENT_DATE) RETURNING *""", + [c.klub_id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol, c.email, c.telefon, c.kategorija, c.pozicija, c.licenca_broj, c.licenca_vrijedi_do, c.reprezentativac, c.kategoriziran, c.stipendiran, c.napomena]) + return rows[0] + +@app.get("/api/clanovi/{clan_id}") +def get_clan(clan_id: int): + rows = fetch("""SELECT c.*, k.naziv AS klub_naziv FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id]) + if not rows: + raise HTTPException(404, "Član ne postoji") + clanarine = fetch("SELECT * FROM pgz_sport.clanarine WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + lijecnicki = fetch("SELECT * FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=%s ORDER BY datum_pregleda DESC", [clan_id]) + return {**rows[0], "clanarine": clanarine, "lijecnicki": lijecnicki} + +# ==================== ČLANARINE ==================== +@app.get("/api/clanarine") +def list_clanarine(godina: Optional[int] = None, status: Optional[str] = None, + klub_id: Optional[int] = None, sort: str = "godina", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + if status: + where.append("status=%s"); params.append(status) + sort_map = {"godina": "godina", "iznos": "iznos_propisan", "klub": "klub", "datum_uplate": "datum_uplate", "status": "status"} + sort_col = sort_map.get(sort, "godina") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_clanarine_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos_propisan) AS total_propisan, + SUM(iznos_placen) AS total_placen, + SUM(iznos_propisan - iznos_placen) AS total_dug + FROM pgz_sport.v_clanarine_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class ClanarinaIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + godina: int + razdoblje: Optional[str] = "godišnja" + iznos_propisan: float + iznos_placen: Optional[float] = 0 + datum_uplate: Optional[date] = None + nacin_uplate: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/clanarine") +def create_clanarina(c: ClanarinaIn): + status = "podmireno" if c.iznos_placen >= c.iznos_propisan else ("djelomicno" if c.iznos_placen > 0 else "nepodmireno") + klub_id = c.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [c.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.clanarine (clan_id, klub_id, godina, razdoblje, iznos_propisan, iznos_placen, datum_uplate, nacin_uplate, status, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [c.clan_id, klub_id, c.godina, c.razdoblje, c.iznos_propisan, c.iznos_placen, c.datum_uplate, c.nacin_uplate, status, c.napomena]) + return rows[0] + +# ==================== LIJEČNIČKI ==================== +@app.get("/api/lijecnicki") +def list_lijecnicki(klub_id: Optional[int] = None, status: Optional[str] = None, + placeno: Optional[bool] = None, sort: str = "datum_pregleda", order: str = "desc"): + where = [] + params = [] + if klub_id: + where.append("(klub_oib IS NOT NULL AND klub=ANY(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))"); params.append(klub_id) + if status: + where.append("status_pregled=%s"); params.append(status) + if placeno is not None: + where.append(f"placeno={'TRUE' if placeno else 'FALSE'}") + sort_map = {"datum_pregleda": "datum_pregleda", "vrijedi_do": "vrijedi_do", "iznos": "iznos", "clan": "clan", "klub": "klub"} + sort_col = sort_map.get(sort, "datum_pregleda") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_lijecnicki_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos) AS total_iznos, + SUM(iznos_zzjz) AS total_zzjz, + SUM(iznos_klub) AS total_klub, + SUM(iznos_clan) AS total_clan, + COUNT(*) FILTER (WHERE status_pregled='Istekao') AS istekli, + COUNT(*) FILTER (WHERE status_pregled='Ističe uskoro') AS uskoro + FROM pgz_sport.v_lijecnicki_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class LijecnickiIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + datum_pregleda: date + vrijedi_do: Optional[date] = None + vrsta_pregleda: Optional[str] = "temeljni" + ustanova: Optional[str] = "ZZJZ PGŽ" + lijecnik: Optional[str] = None + spreman_za_natjecanje: Optional[bool] = True + ekg: Optional[bool] = False + krv: Optional[bool] = False + spirometrija: Optional[bool] = False + nalaz: Optional[str] = None + komentar_lijecnika: Optional[str] = None + preporuke: Optional[str] = None + iznos: Optional[float] = 0 + iznos_zzjz: Optional[float] = 0 + iznos_klub: Optional[float] = 0 + iznos_clan: Optional[float] = 0 + datum_placanja: Optional[date] = None + placeno: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/lijecnicki") +def create_lijecnicki(l: LijecnickiIn): + klub_id = l.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [l.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.lijecnicki_pregledi (clan_id, klub_id, datum_pregleda, vrijedi_do, vrsta_pregleda, ustanova, lijecnik, spreman_za_natjecanje, ekg, krv, spirometrija, nalaz, komentar_lijecnika, preporuke, iznos, iznos_zzjz, iznos_klub, iznos_clan, datum_placanja, placeno, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [l.clan_id, klub_id, l.datum_pregleda, l.vrijedi_do, l.vrsta_pregleda, l.ustanova, l.lijecnik, l.spreman_za_natjecanje, l.ekg, l.krv, l.spirometrija, l.nalaz, l.komentar_lijecnika, l.preporuke, l.iznos, l.iznos_zzjz, l.iznos_klub, l.iznos_clan, l.datum_placanja, l.placeno, l.napomena]) + return rows[0] + +# ==================== PRORAČUN ==================== +@app.get("/api/proracun") +def list_proracun(): + rows = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + return {"count": len(rows), "rows": rows} + +# ==================== POTPORE NOSITELJI ==================== +@app.get("/api/potpore") +def list_potpore(godina: Optional[int] = None, sort: str = "iznos", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + sort_col = {"iznos": "iznos", "godina": "godina", "klub": "naziv_kluba"}.get(sort, "iznos") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.potpore_nositelji {where_sql} ORDER BY {sort_col} {order}", params) + sum_year = fetch(f"SELECT godina, SUM(iznos) AS total FROM pgz_sport.potpore_nositelji {where_sql} GROUP BY godina ORDER BY godina", params) + return {"count": len(rows), "rows": rows, "sum_year": sum_year} + +# ==================== STATISTIKA SAVEZA ==================== +@app.get("/api/statistika") +def list_statistika(godina: Optional[int] = None, q: Optional[str] = None, razina: Optional[str] = None, + sort: str = "registriranih", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("st.godina=%s"); params.append(godina) + if q: + where.append("s.naziv ILIKE %s"); params.append(f"%{q}%") + if razina: + where.append("s.razina = %s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + # Map sort key → unambiguous column expression + sort_map = { + "registriranih": "st.registriranih", + "klubova": "st.klubova_clanica", + "trenera": "st.trenera", + "reprezentativaca":"st.reprezentativaca", + "neregistriranih": "st.neregistriranih", + "rekreativaca": "st.rekreativaca", + "godina": "st.godina", + "savez": "s.naziv", + "naziv": "s.naziv", + } + sort_col = sort_map.get(sort, "st.registriranih") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + use_collate = sort_col in ("s.naziv", "s.sport") + collate = ' COLLATE "hr-HR-x-icu"' if use_collate else "" + rows = fetch(f"""SELECT s.naziv AS savez, s.razina AS savez_razina, s.sport AS sport, st.* + FROM pgz_sport.statistika_saveza st + JOIN pgz_sport.savezi s ON s.id=st.savez_id {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST, s.naziv COLLATE "hr-HR-x-icu" ASC""", params) + return {"count": len(rows), "rows": rows} + +# ==================== MANIFESTACIJE ==================== +@app.get("/api/manifestacije") +def list_manifestacije(razina: Optional[str] = None, savez_id: Optional[int] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivna"] + params = [] + if razina: + where.append("razina=%s"); params.append(razina) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + sort_col = {"naziv": "m.naziv", "razina": "m.razina", "godina_od": "m.godina_od", "mjesto": "m.mjesto"}.get(sort, "m.naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT m.*, s.naziv AS savez_naziv FROM pgz_sport.manifestacije m + LEFT JOIN pgz_sport.savezi s ON s.id=m.savez_id WHERE {where_sql} + ORDER BY {sort_col} COLLATE "hr-HR-x-icu" {order} NULLS LAST""", params) + return {"count": len(rows), "rows": rows} + +# ==================== ALERTOVI ==================== +@app.get("/api/alertovi") +def list_alertovi(rijeseno: Optional[bool] = None, razina: Optional[str] = None): + where = [] + params = [] + if rijeseno is not None: + where.append(f"rijeseno={'TRUE' if rijeseno else 'FALSE'}") + if razina: + where.append("razina=%s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.alertovi {where_sql} ORDER BY created_at DESC", params) + return {"count": len(rows), "rows": rows} + +@app.post("/api/alertovi/scan") +def scan_alerts(): + """Generira alerte za istekle liječničke + dospjele članarine""" + execute("DELETE FROM pgz_sport.alertovi WHERE NOT rijeseno AND tip IN ('lijecnicki_isteka', 'lijecnicki_uskoro', 'clanarina_dospjela')") + # Liječnički istekao + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_isteka', 'CRITICAL', c.klub_id, lp.clan_id, + 'Liječnički pregled istekao za ' || c.ime || ' ' || c.prezime || ' (klub: ' || COALESCE(k.naziv, 'N/A') || ')', lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lp.vrijedi_do < CURRENT_DATE AND c.aktivan""") + # Liječnički uskoro + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_uskoro', 'WARNING', c.klub_id, lp.clan_id, + 'Liječnički ističe za 30 dana: ' || c.ime || ' ' || c.prezime, lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE+30 AND c.aktivan""") + # Članarine dospjele + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum, iznos) + SELECT 'clanarina_dospjela', 'WARNING', cl.klub_id, cl.clan_id, + 'Nepodmirena članarina ' || cl.godina || ' za ' || c.ime || ' ' || c.prezime, NULL, (cl.iznos_propisan - cl.iznos_placen) + FROM pgz_sport.clanarine cl + JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE cl.status != 'podmireno' AND cl.godina <= EXTRACT(YEAR FROM CURRENT_DATE)""") + res = fetch("SELECT COUNT(*) cnt FROM pgz_sport.alertovi WHERE NOT rijeseno") + return {"alerts_generated": res[0]["cnt"]} + +@app.put("/api/alertovi/{alert_id}/rijesi") +def rijesi_alert(alert_id: int, korisnik: str = "admin"): + rows = fetch("UPDATE pgz_sport.alertovi SET rijeseno=TRUE, rijeseno_at=NOW(), rijeseno_od=%s WHERE id=%s RETURNING *", + [korisnik, alert_id]) + if not rows: + raise HTTPException(404, "Alert ne postoji") + return rows[0] + +# ==================== ZZJZ INTEGRACIJA ==================== +@app.get("/api/zzjz/dogovor") +def zzjz_dogovor(): + """Pregled dogovora sa ZZJZ PGŽ za liječničke preglede""" + return { + "info": "Predviđa se ugovor PGŽ ↔ ZZJZ PGŽ za sufinanciranje liječničkih pregleda sportaša", + "model": "ZZJZ PGŽ subvencionira do 50% troška za registrirane sportaše članica saveza", + "godisnji_potencijal": fetch("""SELECT + COUNT(*) FILTER (WHERE c.kategorija='registrirani') AS sportasa_potencijalno, + SUM(CASE WHEN c.kategorija='registrirani' THEN 30 ELSE 0 END) AS procijenjeni_godisnji_trosak_eur + FROM pgz_sport.clanovi c WHERE c.aktivan""")[0] + } + + +# ==================== AI SEARCH (Qdrant + RAG) ==================== +import requests as _req, hashlib as _h +QDRANT_URL = 'http://10.10.0.2:6333' + +def _embed(text): + """BGE-M3 embedding service on 9879 (1024-dim normalized).""" + try: + r = _req.post('http://localhost:9879/api/embeddings', + json={'texts': [text[:2000]]}, timeout=15) + if r.ok: + data = r.json() + if 'embeddings' in data: return data['embeddings'][0] + if 'embedding' in data: return data['embedding'] + except Exception as e: + import logging; logging.warning(f'BGE-M3 fail: {e}') + h = _h.sha256(text.encode()).digest() + return [(h[i % 32] / 255.0 - 0.5) for i in range(1024)] + +@app.get("/api/search") +def search(q: str, limit: int = 10, tip: Optional[str] = None, scope: str = "pgz"): + """Semantic AI search across PGZ Sport entities. + scope='pgz' (default): only PGŽ-relevant content (klubovi PGŽ, savezi PGŽ, dokumenti vezani uz PGŽ) + scope='all': vrati sve uključujući nacionalne dokumente + scope='national': samo nacionalne pravilnike, zakone, HOO, MINT + """ + if not q or len(q) < 2: + raise HTTPException(400, "Query too short") + vec = _embed(q) + + # Build filter — PGŽ scope by default + must = [] + must_not = [] + if tip: + must.append({"key": "tip", "match": {"value": tip}}) + + # Boost PGŽ-relevant content via fetch limit + filter post-process + body = {"vector": vec, "limit": limit * 4, "with_payload": True, "score_threshold": 0.35} + if must: + body["filter"] = {"must": must} + + try: + r = _req.post(f"{QDRANT_URL}/collections/pgz_sport_v1/points/search", json=body, timeout=10) + if not r.ok: raise HTTPException(500, f"Qdrant: {r.text[:200]}") + all_results = r.json()['result'] + except _req.exceptions.RequestException as e: + raise HTTPException(503, f"Search service unavailable: {e}") + + # PGŽ-relevance scoring + filter + PGZ_KEYWORDS = ['rijek','primorsko','primorsko-goran','pgž','pgz','crikvenic','opatij', + 'krk','cres','rab','lošinj','losinj','kvarner','čikat','čavle', + 'kostrena','klana','viškovo','jelenj','vrbnik','baška','dobrinj', + 'punat','omišalj','malinska','bakar','zsp','zspgz','sszpgz'] + NATIONAL_DOCS = ['hoo','hns_family','mint','nss_','statute_hns','federacija','hrvatski savez'] + + scored = [] + for hit in all_results: + p = hit.get('payload') or {} + # Combine all text fields for keyword check + all_text = ( + (p.get('naziv','') or '') + ' ' + + (p.get('title','') or '') + ' ' + + (p.get('text','') or '')[:500] + ' ' + + (p.get('source','') or '') + ' ' + + (p.get('grad','') or '') + ' ' + + (p.get('source_url','') or '') + ).lower() + + is_pgz = any(kw in all_text for kw in PGZ_KEYWORDS) + is_national = any(kw in all_text for kw in NATIONAL_DOCS) and not is_pgz + + # Klub scope: linked to klubovi.id which is by definition PGŽ + if p.get('tip') == 'klub' and p.get('klub_id'): is_pgz = True + # Savez PGŽ + if p.get('tip') == 'savez' and (p.get('razina') == 'zupanijski' or 'pgž' in (p.get('naziv','') or '').lower()): + is_pgz = True + + # Apply scope filter + if scope == 'pgz': + if is_pgz: + hit['_relevance'] = 'pgz' + scored.append(hit) + elif is_national and p.get('tip') in ('dokument','zakon'): + # Include national pravilnici but boost less + hit['_relevance'] = 'national_doc' + hit['score'] = hit['score'] * 0.7 + scored.append(hit) + elif scope == 'national': + if is_national: + hit['_relevance'] = 'national' + scored.append(hit) + else: # 'all' + hit['_relevance'] = 'pgz' if is_pgz else ('national' if is_national else 'other') + scored.append(hit) + + # Re-sort by adjusted score + scored.sort(key=lambda x: x.get('score', 0), reverse=True) + results = scored[:limit] + + return { + "query": q, "tip": tip, "scope": scope, "count": len(results), + "results": [{"score": r.get('score', 0), + "tip": (r.get('payload') or {}).get('tip'), + "naziv": (r.get('payload') or {}).get('naziv') or (r.get('payload') or {}).get('title'), + "klub_id": (r.get('payload') or {}).get('klub_id'), + "savez_id": (r.get('payload') or {}).get('savez_id'), + "tekst": (r.get('payload') or {}).get('tekst') or (r.get('payload') or {}).get('text','')[:300], + "url": (r.get('payload') or {}).get('source_url') or (r.get('payload') or {}).get('url'), + "relevance": r.get('_relevance', 'unknown'), + "payload": r.get('payload')} for r in results] + } + + +# ==================== GOOGLE OAUTH ==================== +import jwt as _jwt, secrets as _secrets +GOOGLE_CLIENT_ID = "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com" # postavi u .env +ADMIN_EMAILS = { + "damir@rinet.one", "dradulic@outlook.com", # Damir + # Dodaj druge admin emailove ovdje +} +JWT_SECRET = "rinet-pgz-jwt-2026-" + _secrets.token_hex(8) +JWT_ISSUED = [] # in-memory token store (može u Redis) + +@app.post("/api/auth/google") +def google_auth(token: str = Body(..., embed=True)): + """Verify Google ID token and issue JWT for admin/viewer role.""" + try: + import urllib.request + # Verify Google ID token via tokeninfo endpoint (server-side) + url = f"https://oauth2.googleapis.com/tokeninfo?id_token={token}" + with urllib.request.urlopen(url, timeout=10) as r: + data = json.loads(r.read()) + email = data.get("email", "").lower() + verified = data.get("email_verified") == "true" or data.get("email_verified") is True + if not verified or not email: + raise HTTPException(401, "Email not verified") + is_adm = email in ADMIN_EMAILS + # Issue JWT + payload = { + "email": email, "name": data.get("name", email), + "role": "admin" if is_adm else "viewer", + "iat": int(__import__("time").time()), + "exp": int(__import__("time").time()) + 86400 * 7 # 7 dana + } + jwt_token = _jwt.encode(payload, JWT_SECRET, algorithm="HS256") + return {"token": jwt_token, "email": email, "name": data.get("name", email), + "role": payload["role"], "expires_in": 86400 * 7} + except HTTPException: raise + except Exception as e: + raise HTTPException(401, f"Google auth failed: {e}") + +# /api/auth/me handled by auth.auth_v2 router (M1) + +# ==================== STATIC ==================== +import pathlib +HTML_DIR = pathlib.Path(__file__).parent / "static" +HTML_DIR.mkdir(exist_ok=True) + +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + + +# ──────── V5 NATJECANJA ──────── +@app.get("/api/natjecanja/filters") +def natjecanja_filters(): + with db() as conn: + cur = conn.cursor() + cur.execute("SELECT DISTINCT sport FROM pgz_sport.natjecanja WHERE sport IS NOT NULL ORDER BY sport") + sports = [r[0] for r in cur.fetchall()] + cur.execute("SELECT DISTINCT sezona FROM pgz_sport.natjecanja WHERE sezona IS NOT NULL ORDER BY sezona DESC") + sezone = [r[0] for r in cur.fetchall()] + return {"sports": sports, "sezone": sezone} + +@app.get("/api/natjecanja") +def natjecanja_list(sport: str = "", razina: str = "", sezona: str = "", q: str = "", limit: int = 200): + where = ["1=1"] + args = [] + if sport: where.append("sport = %s"); args.append(sport) + if razina: where.append("razina = %s"); args.append(razina) + if sezona: where.append("sezona = %s"); args.append(sezona) + if q: where.append("naziv ILIKE %s"); args.append(f"%{q}%") + args.append(limit) + + with db() as conn: + cur = conn.cursor() + cur.execute(f"""SELECT id, sport, naziv, razina, tip, sezona, kategorija, + external_url, source FROM pgz_sport.natjecanja WHERE {' AND '.join(where)} + ORDER BY razina, sezona DESC NULLS LAST, naziv LIMIT %s""", args) + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + results = [dict(zip(cols, r)) for r in rows] + cur.execute(f"SELECT COUNT(*) FROM pgz_sport.natjecanja WHERE {' AND '.join(where)}", args[:-1]) + total = cur.fetchone()[0] + return {"count": total, "limit": limit, "results": results} + +# ──────── V5 ADMIN ──────── +@app.get("/api/admin/stats") +def admin_stats(): + with db() as conn: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM pgz_sport.users"); ut = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM pgz_sport.users WHERE aktivan=true"); ua = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM pgz_sport.sys_permissions"); pt = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM pgz_sport.sys_audit WHERE created_at >= now()::date"); at = cur.fetchone()[0] + cur.execute("SELECT user_type, COUNT(*) cnt FROM pgz_sport.users GROUP BY 1 ORDER BY 2 DESC") + by_type = [{"user_type": r[0], "cnt": r[1]} for r in cur.fetchall()] + return {"users_total": ut, "users_active": ua, "permissions_total": pt, + "audit_today": at, "by_type": by_type} + +@app.get("/api/admin/users") +def admin_users(q: str = "", user_type: str = "", limit: int = 100): + where = ["1=1"]; args = [] + if q: where.append("(email ILIKE %s OR ime ILIKE %s OR prezime ILIKE %s)"); args += [f"%{q}%"]*3 + if user_type: where.append("user_type = %s"); args.append(user_type) + args.append(limit) + with db() as conn: + cur = conn.cursor() + cur.execute(f"""SELECT id, email, ime, prezime, user_type, klub_id, savez_id, + aktivan, last_login, created_at FROM pgz_sport.users + WHERE {' AND '.join(where)} ORDER BY id LIMIT %s""", args) + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + results = [{**dict(zip(cols, r)), + 'last_login': str(dict(zip(cols, r))['last_login']) if dict(zip(cols, r))['last_login'] else None, + 'created_at': str(dict(zip(cols, r))['created_at'])} for r in rows] + return {"count": len(results), "results": results} + +@app.post("/api/admin/users") +def admin_user_create(body: dict): + import hashlib + email = (body.get("email") or "").strip().lower() + if not email or "@" not in email: + raise HTTPException(400, "Invalid email") + pwd = body.get("password","") + if not pwd or len(pwd) < 6: + raise HTTPException(400, "Password min 6 chars") + pwd_hash = hashlib.sha256(pwd.encode()).hexdigest() + with db() as conn: + cur = conn.cursor() + try: + cur.execute("""INSERT INTO pgz_sport.users + (email, password_hash, ime, prezime, user_type, klub_id, savez_id, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,%s,true) RETURNING id""", + (email, pwd_hash, body.get("ime"), body.get("prezime"), + body.get("user_type","klub_user"), body.get("klub_id"), body.get("savez_id"))) + new_id = cur.fetchone()[0] + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, target_text, payload) + VALUES ('user.create','sys_users',%s,%s,%s::jsonb)""", + (new_id, email, json.dumps({"user_type": body.get("user_type")}))) + conn.commit() + return {"id": new_id, "email": email} + except psycopg2.IntegrityError as e: + conn.rollback() + raise HTTPException(400, f"Email već postoji: {email}") + +@app.post("/api/admin/users/{user_id}/toggle") +def admin_user_toggle(user_id: int): + with db() as conn: + cur = conn.cursor() + cur.execute("UPDATE pgz_sport.users SET aktivan = NOT aktivan WHERE id=%s RETURNING aktivan", (user_id,)) + r = cur.fetchone() + if not r: raise HTTPException(404, "User not found") + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, payload) + VALUES ('user.toggle','sys_users',%s,%s::jsonb)""", (user_id, json.dumps({"aktivan": r[0]}))) + conn.commit() + return {"id": user_id, "aktivan": r[0]} + + +# ──────── 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}') + +# === 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 + app.include_router(gdpr_router) + app.include_router(gdpr_admin_router) + print('[AUTH/M10] gdpr routers loaded (/api/gdpr/*, /api/admin/gdpr/*)') +except Exception as e: + print(f'[AUTH/M10] gdpr routers 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("/api/sportski-objekti") +def list_sportski_objekti(q=None,tip=None,grad=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR adresa ILIKE %s OR grad ILIKE %s)"); p+=["%"+q+"%"]*3 + if tip: w.append("tip ILIKE %s"); p.append("%"+tip+"%") + if grad: w.append("grad ILIKE %s"); p.append("%"+grad+"%") + rows=fetch("SELECT * FROM pgz_sport.sportski_objekti WHERE "+" AND ".join(w)+" ORDER BY grad,naziv",p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/clanovi-full") +def list_clanovi_full(q=None,hoo=None,reprezentativac=None,klub_id=None,limit=80,authorization=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(ime ILIKE %s OR prezime ILIKE %s OR klub_naziv_godisnjak ILIKE %s)"); p+=["%"+q+"%"]*3 + if hoo: w.append("hoo_kategorija=%s"); p.append(hoo) + if reprezentativac is not None: w.append("reprezentativac="+(("TRUE") if str(reprezentativac).lower()=="true" else "FALSE")) + if klub_id: w.append("klub_id=%s"); p.append(int(klub_id)) + lim=min(int(limit or 80),200) + sql="SELECT id,ime,prezime,oib,datum_rodenja,spol,sport,pozicija,reprezentativac,kategoriziran,stipendiran,kategorija_hoo,hoo_kategorija,aktivan,klub_naziv_godisnjak,slika_url,profile_url,hns_igrac_id,visina_cm,tezina_kg,broj_dresa,uloga,godisnjak_godine,godisnjak_prvi,godisnjak_zadnji,napomena FROM pgz_sport.clanovi WHERE "+" AND ".join(w)+" ORDER BY prezime,ime LIMIT "+str(lim) + rows=fetch(sql,p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/gradovi") +def list_gradovi(): + rows=fetch("SELECT DISTINCT grad FROM pgz_sport.klubovi WHERE aktivan=TRUE AND grad IS NOT NULL AND grad<>'' AND grad NOT SIMILAR TO '[0-9]+%%' ORDER BY grad",[]) + return [r["grad"] for r in rows] + +@app.get("/api/manifestacije-full") +def list_manifestacije_full(q=None,razina=None): + w=["aktivna=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR mjesto ILIKE %s)"); p+=["%"+q+"%"]*2 + rows=fetch("SELECT id,naziv,mjesto,organizator,razina,broj_ucesnika,godina_od,spol_kategorija,napomena,source_url FROM pgz_sport.manifestacije WHERE "+" AND ".join(w)+" ORDER BY naziv",p) + return {"count":len(rows),"rows":rows} + + + +# ── SUFINANCIRANJE-ALL v1.0 dradulic@outlook.com 2026-05-04 +@app.get("/api/sufinanciranje") +def list_sufinanciranje(q=None, godina=None, razina=None, sport=None, limit=500): + w=["iznos_eur > 0"]; p=[] + if q: w.append("(LOWER(korisnik) LIKE %s OR LOWER(sport) LIKE %s)"); p+=[f"%{q.lower()}%"]*2 + if godina: w.append("godina=%s"); p.append(int(godina)) + if razina: w.append("razina ILIKE %s"); p.append(f"%{razina}%") + if sport: w.append("sport ILIKE %s"); p.append(f"%{sport}%") + sql=f"SELECT korisnik,sport,iznos_eur,vrsta,razina,izvor,source_url,godina FROM pgz_sport.sufinanciranje_sport WHERE {' AND '.join(w)} ORDER BY iznos_eur DESC LIMIT {min(int(limit),1000)}" + rows=fetch(sql,p) + total=sum(float(r.get('iznos_eur') or 0) for r in rows) + years=sorted(set(r.get('godina') for r in rows if r.get('godina')),reverse=True) + return {"count":len(rows),"total":total,"years":years,"rows":rows} + + + +# ══════════════════════════════════════════════════════════════════ +# ERP PLATFORM ROUTES v2.0 — dradulic@outlook.com — 2026-05-04 +# ══════════════════════════════════════════════════════════════════ + +import hashlib + +def hash_pwd(pwd): return hashlib.sha256(pwd.encode()).hexdigest() + +def get_user(token): + if not token: return None + try: + payload = _jwt.decode(token.replace("Bearer ",""), JWT_SECRET, algorithms=["HS256"]) + uid = payload.get("uid") + if uid: + rows = fetch("SELECT * FROM pgz_sport.users WHERE id=%s AND aktivan=TRUE", [uid]) + return rows[0] if rows else None + return payload + except: return None + +# ── AUTH: Email/Password login — handled by auth.auth_v2 router (M1) ── + +# ── SPORTAS FULL PROFILE ───────────────────────────────────────── +@app.get("/api/sportas/{clan_id}/profil") +def sportas_profil(clan_id: int): + clan = fetch("""SELECT c.*, k.naziv AS klub_naziv_full, k.sport AS klub_sport, + k.grad, k.logo_url FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id]) + if not clan: raise HTTPException(404,"Nije pronađen") + c = clan[0] + sezona = fetch("""SELECT * FROM pgz_sport.clan_sezona WHERE clan_id=%s ORDER BY sezona DESC""", [clan_id]) + utakmice = fetch("""SELECT * FROM pgz_sport.utakmice_log WHERE clan_id=%s ORDER BY datum DESC LIMIT 30""", [clan_id]) + nagrade = fetch("SELECT * FROM pgz_sport.clan_nagrada WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + godisnjaci = fetch("SELECT * FROM pgz_sport.clan_godisnjak WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + stats = {} + if sezona: + stats = {"ukupno_nastupa": sum((r.get("nastupi") or 0) for r in sezona), + "ukupno_pogodaka": sum((r.get("pogoci") or 0) for r in sezona), + "ukupno_asistencija": sum((r.get("asistencije") or 0) for r in sezona), + "ukupno_zutih": sum((r.get("zuti_kartoni") or 0) for r in sezona), + "ukupno_crvenih": sum((r.get("crveni_kartoni") or 0) for r in sezona), + "ukupno_minuta": sum((r.get("minute_total") or 0) for r in sezona), + "sezone_aktivne": len(sezona)} + return {**c,"clan_sezona":sezona,"utakmice":utakmice,"nagrade":nagrade, + "godisnjaci":godisnjaci,"stats":stats} + +# ── SAVEZ FULL DETAIL ──────────────────────────────────────────── +@app.get("/api/savezi/{savez_id}/full") +def savez_full(savez_id: int): + s = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s",[savez_id]) + if not s: raise HTTPException(404,"Savez nije pronađen") + klubovi = fetch("""SELECT id,naziv,sport,grad,predsjednik,tajnik,nositelj_kvalitete, + aktivan,oib,razina,broj_clanova FROM pgz_sport.klubovi WHERE savez_id=%s AND aktivan=TRUE ORDER BY naziv""",[savez_id]) + clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija, + c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan + FROM pgz_sport.clanovi c WHERE c.savez_kod=(SELECT kod FROM pgz_sport.savezi WHERE id=%s) LIMIT 200""",[savez_id]) + if not clanovi: + clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija, + c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan + FROM pgz_sport.clanovi c WHERE c.aktivan=TRUE AND c.sport ILIKE %s LIMIT 200""", + [f'%{s[0].get("sport","") or ""}%']) + treneri = fetch("""SELECT * FROM pgz_sport.treneri WHERE savez_id=%s""",[savez_id]) + return {**s[0],"klubovi":klubovi,"clanovi":clanovi[:100],"treneri":treneri} + +# ── KLUB ERP: CLANARINE ────────────────────────────────────────── +@app.get("/api/klub/{klub_id}/clanarine") +def klub_clanarine(klub_id: int, godina: int=None, status: str=None): + w=["c.klub_id=%s"]; p=[klub_id] + if godina: w.append("cl.godina=%s"); p.append(godina) + if status: w.append("cl.status=%s"); p.append(status) + rows = fetch(f"""SELECT cl.*,c.ime,c.prezime,c.oib,c.spol,c.kategorija,c.hoo_kategorija,c.slika_url + FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE {" AND ".join(w)} ORDER BY cl.godina DESC, c.prezime""", p) + total_p = sum(float(r.get("iznos_placen") or 0) for r in rows) + total_d = sum(float(r.get("iznos_propisan") or 0) - float(r.get("iznos_placen") or 0) for r in rows) + return {"count":len(rows),"naplaceno":total_p,"dug":total_d,"rows":rows} + +# ── KLUB ERP: LIJECNICKI ───────────────────────────────────────── +@app.get("/api/klub/{klub_id}/lijecnicki") +def klub_lijecnicki(klub_id: int): + import datetime; today = datetime.date.today() + rows = fetch("""SELECT lp.*,c.ime,c.prezime,c.oib,c.kategorija,c.slika_url, + CASE WHEN lp.vrijedi_do IS NULL THEN 'nepoznato' + WHEN lp.vrijedi_do < CURRENT_DATE THEN 'istekao' + WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'uskoro_istece' + ELSE 'validan' END AS status_pregled + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s ORDER BY lp.vrijedi_do ASC NULLS LAST""", [klub_id]) + alert_istekli = [r for r in rows if r.get("status_pregled")=="istekao"] + alert_uskoro = [r for r in rows if r.get("status_pregled")=="uskoro_istece"] + return {"count":len(rows),"istekli":len(alert_istekli),"uskoro":len(alert_uskoro),"rows":rows} + +# ── NETWORK GRAPH DATA ─────────────────────────────────────────── +@app.get("/api/network/pgz") +def network_pgz(q: str=None, entity_type: str=None, max_nodes: int=80): + FORENSIC_NAMES = {"SAMIR BARAĆ","MIROSLAV MARIĆ","VELIMIR LIVERIĆ","DOROTEA PESIC-BUKOVAC"} + nodes,edges,seen_nodes,seen_edges = [],[],set(),set() + + def add_node(nid, label, ntype, meta=None): + if nid not in seen_nodes: + seen_nodes.add(nid) + nodes.append({"id":nid,"label":label,"type":ntype,"forensic":label.upper() in FORENSIC_NAMES,"meta":meta or {}}) + + def add_edge(s,t,rel=""): + k=f"{s}-{t}" + if k not in seen_edges: + seen_edges.add(k); edges.append({"source":s,"target":t,"rel":rel}) + + if q: + # Person search + persons = fetch("""SELECT p.id,p.name,p.function,e.name as ent,e.id as eid,e.entity_type,e.city + FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id + WHERE p.name ILIKE %s OR e.name ILIKE %s LIMIT 60""",[f"%{q}%",f"%{q}%"]) + for r in persons: + pid=f"p_{r['id']}"; eid=f"e_{r['eid']}" + add_node(pid,r.get("name","?")[:30],"person") + add_node(eid,r.get("ent","?")[:30],"club" if "Udruga" in (r.get("entity_type") or "") else "company") + add_edge(pid,eid,r.get("function","")) + else: + # Default: top connected persons + rels = fetch("""SELECT p.id,p.name,e.id as eid,e.name as ent,e.entity_type,p.function + FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id + WHERE e.county ILIKE '%%goranska%%' OR e.county ILIKE '%%primorska%%' + ORDER BY p.id LIMIT %s""",[max_nodes]) + for r in rels: + pid=f"p_{r['id']}"; eid=f"e_{r['eid']}" + add_node(pid,r.get("name","?")[:25],"person") + add_node(eid,r.get("ent","?")[:25],"club" if "Udruga" in (r.get("entity_type") or "") else "company", + {"city":r.get("city"),"type":r.get("entity_type")}) + add_edge(pid,eid,r.get("function","")) + + return {"nodes":nodes[:200],"edges":edges[:400],"query":q} + + + +@app.get("/platform") +@app.get("/platform/") +def serve_platform(): + p = HTML_DIR / "platform.html" + if p.exists(): return FileResponse(p) + return {"error": "platform.html not found"} + +app.mount("/static", StaticFiles(directory=str(HTML_DIR)), name="static") + +@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) diff --git a/_backups/r3_cc4/sport2.html.pre_M6.1777932513 b/_backups/r3_cc4/sport2.html.pre_M6.1777932513 new file mode 100644 index 0000000..93dc41e --- /dev/null +++ b/_backups/r3_cc4/sport2.html.pre_M6.1777932513 @@ -0,0 +1,2341 @@ + + + + + +PGŽ SPORT — Platforma + + + + + + + + + + + +
+ + +
+
+
+
Dashboard
+
Pregled stanja
+
+
+ API live · sport.rinet.one +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
Detalji
+
×
+
+
+
+ + + + diff --git a/_backups/r3_cc5/pgz_sport_api.py.post_m7.1777931653 b/_backups/r3_cc5/pgz_sport_api.py.post_m7.1777931653 new file mode 100644 index 0000000..597b05b --- /dev/null +++ b/_backups/r3_cc5/pgz_sport_api.py.post_m7.1777931653 @@ -0,0 +1,1682 @@ +#!/usr/bin/env python3 +""" +pgz_sport_api.py - FastAPI backend za PGŽ Sportski savez ERP/CRM +Author: Damir Radulić (damir@rinet.one) +Date: 25.04.2026 +Port: 8095 +Endpoints: savezi, klubovi, članovi, članarine, liječnički, manifestacije, proračun, dashboard, alertovi +""" + +from fastapi import FastAPI, HTTPException, Query, Body, Header, Depends, UploadFile, File, Form, Request +import json +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import Optional, List +from datetime import date, datetime +import psycopg2 +import psycopg2.extras +from pgz_sport_v2_router import router as v2_router +import os + +DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7') + + +ADMIN_TOKEN = 'admin-pgz-2026' + +def is_admin(authorization): + if not authorization: return False + token = authorization.replace('Bearer ', '').strip() + if token == ADMIN_TOKEN: return True + # Try JWT + try: + import jwt as _jwt + payload = _jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return payload.get("role") == "admin" + except Exception: + return False + +def blur_oib(v): + if not v: return v + s = str(v); + return s[:3] + '•'*(len(s)-5) + s[-2:] if len(s) >= 8 else '•'*len(s) +def blur_email(e): + if not e or '@' not in str(e): return e + u, d = str(e).split('@',1); return (u[:1]+'•••' if u else '')+'@'+d +def blur_phone(p): + if not p: return p + s=str(p); return s[:4]+'•'*(len(s)-7)+s[-3:] if len(s)>=7 else s +def blur_iban(v): + if not v: return v + s=str(v); return s[:4]+'•'*(len(s)-8)+s[-4:] if len(s)>=8 else s +def blur_date(d): + if not d: return d + s = str(d); return s[:4]+'-••-••' if len(s)>=4 else s +def blur_text(t, keep=3): + if not t: return t + s=str(t); return s[:keep]+'•'*(len(s)-keep*2)+s[-keep:] if len(s)>keep*2 else s + +def apply_privacy(rows, admin): + if admin: return rows + out = [] + for r in (rows if isinstance(rows, list) else [rows]): + rr = dict(r) + for k, v in list(rr.items()): + if v is None: continue + kl = k.lower() + if 'oib' in kl: rr[k] = blur_oib(v) + elif 'email' in kl: rr[k] = blur_email(v) + elif kl in ('telefon','tel','phone'): rr[k] = blur_phone(v) + elif kl == 'datum_rodenja': rr[k] = blur_date(v) + elif 'iban' in kl: rr[k] = blur_iban(v) + elif kl == 'adresa': rr[k] = blur_text(v, 3) + elif 'licenca_broj' in kl: rr[k] = blur_text(v, 2) + out.append(rr) + return out if isinstance(rows, list) else out[0] + +app = FastAPI(title="PGŽ Sportski savez ERP/CRM", version="1.0.0") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + + +# === URL rewrite middleware - convert direct external image URLs to /img-proxy === +import json as _json_mw +import re as _re_mw +from starlette.responses import Response as _StarletteResponse_mw + +_IMG_DOMAINS_RE = _re_mw.compile( + r'https?://(?:hns\.family|hns\.hr|hbs\.hr|hrvatski-bocarski-savez\.hr|' + r'rk-zamet\.hr|hvs\.hr|rezultati\.hvs\.hr|sport-pgz\.hr)' + r'/[^"\s\\]+\.(?:jpg|jpeg|png|gif|webp|svg)', + _re_mw.IGNORECASE +) + +def _rewrite_to_proxy(text: str) -> str: + """Replace external image URLs with /sport/api/v2/img-proxy?u=...""" + from urllib.parse import quote as _q + def _sub(m): + url = m.group(0) + return "/sport/api/v2/img-proxy?u=" + _q(url, safe='') + return _IMG_DOMAINS_RE.sub(_sub, text) + +@app.middleware("http") +async def url_rewrite_middleware(request, call_next): + response = await call_next(request) + # Only rewrite JSON API responses + ct = response.headers.get("content-type", "") + if "application/json" not in ct: + return response + # Only on /api/v2 routes (admin & data endpoints) - SKIP /api/v2/img-proxy itself + path = request.url.path + if "/api/v2/img-proxy" in path or "/api/v2/dokumenti" in path: + return response # don't rewrite raw document content + # Read body + body = b"" + async for chunk in response.body_iterator: + body += chunk + try: + text = body.decode("utf-8") + new_text = _rewrite_to_proxy(text) + new_body = new_text.encode("utf-8") + except Exception: + new_body = body + return _StarletteResponse_mw( + content=new_body, + status_code=response.status_code, + headers={k: v for k, v in response.headers.items() if k.lower() not in ("content-length",)}, + media_type=ct, + ) +# === end URL rewrite middleware === + +def db(): + conn = psycopg2.connect(**DB) + conn.autocommit = True + return conn + +def fetch(sql, params=None): + with db() as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as c: + c.execute(sql, params or ()) + return [dict(r) for r in c.fetchall()] + +def execute(sql, params=None): + with db() as conn: + with conn.cursor() as c: + c.execute(sql, params or ()) + return c.rowcount + +# ==================== HEALTH ==================== +@app.get("/health") +def health(): + try: + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + return {"status": "ok", "service": "pgz_sport", "dashboard": rows[0] if rows else None} + except Exception as e: + raise HTTPException(500, f"DB error: {e}") + + +@app.get("/api/whoami") +def whoami_v2(authorization: Optional[str] = Header(None)): + return {"role": "admin" if is_admin(authorization) else "viewer", "privacy_active": not is_admin(authorization)} + +# ==================== DASHBOARD ==================== +@app.get("/api/dashboard") +def dashboard(): + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + if not rows: + return {} + d = rows[0] + # Top savezi by registriranih 2024 + top = fetch("""SELECT s.naziv, st.klubova_clanica, st.registriranih, st.trenera, st.reprezentativaca + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina=2024 ORDER BY st.registriranih DESC LIMIT 10""") + proracun_trend = fetch("SELECT godina, ukupno FROM pgz_sport.proracun ORDER BY godina") + nositelji = fetch("""SELECT naziv_kluba, godina, iznos FROM pgz_sport.potpore_nositelji + WHERE godina = 2025 ORDER BY iznos DESC LIMIT 10""") + return {**d, "top_savezi": top, "proracun_trend": proracun_trend, "nositelji_2025": nositelji} + +@app.get("/api/dashboard/ekosustav") +def dashboard_ekosustav(): + """Sport ekosustav PGŽ — coverage stats za enrichment iz FINA registra.""" + summary = fetch("""SELECT + COUNT(*) AS klubova_total, + COUNT(*) FILTER (WHERE oib IS NOT NULL) AS s_oib, + COUNT(*) FILTER (WHERE predsjednik IS NOT NULL) AS s_predsjednik, + COUNT(*) FILTER (WHERE tajnik IS NOT NULL) AS s_tajnik, + COUNT(*) FILTER (WHERE ciljevi IS NOT NULL) AS s_ciljevi, + COUNT(*) FILTER (WHERE opis_djelatnosti IS NOT NULL) AS s_opis, + COUNT(*) FILTER (WHERE sjediste IS NOT NULL) AS s_sjediste, + COUNT(*) FILTER (WHERE email IS NOT NULL) AS s_email, + COUNT(*) FILTER (WHERE web_stranica IS NOT NULL) AS s_web, + COUNT(*) FILTER (WHERE udruga_status = \'AKTIVAN\') AS s_aktivan_reg, + COUNT(*) FILTER (WHERE savez_id IS NOT NULL) AS s_savez, + COUNT(*) FILTER (WHERE nositelj_kvalitete) AS s_nositelj + FROM pgz_sport.klubovi WHERE aktivan""")[0] + + by_sport = fetch("""SELECT sport, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND sport IS NOT NULL + GROUP BY sport ORDER BY COUNT(*) DESC LIMIT 15""") + + by_region = fetch("""SELECT region, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND region IS NOT NULL + GROUP BY region ORDER BY COUNT(*) DESC""") + + by_grad = fetch("""SELECT grad, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND grad IS NOT NULL + GROUP BY grad ORDER BY COUNT(*) DESC LIMIT 12""") + + decade = fetch("""SELECT + CASE + WHEN godina_osnutka < 1950 THEN \'pred1950\' + WHEN godina_osnutka < 1980 THEN \'1950-1979\' + WHEN godina_osnutka < 2000 THEN \'1980-1999\' + WHEN godina_osnutka < 2010 THEN \'2000-2009\' + WHEN godina_osnutka >= 2010 THEN \'2010-danas\' + ELSE \'nepoznato\' + END AS razdoblje, + COUNT(*) AS broj + FROM pgz_sport.klubovi + WHERE aktivan AND godina_osnutka IS NOT NULL + GROUP BY razdoblje ORDER BY razdoblje""") + + # Pokazi enrichment % + total = summary["klubova_total"] or 1 + coverage = { + "oib_pct": round(100 * summary["s_oib"] / total, 1), + "predsjednik_pct": round(100 * summary["s_predsjednik"] / total, 1), + "tajnik_pct": round(100 * summary["s_tajnik"] / total, 1), + "ciljevi_pct": round(100 * summary["s_ciljevi"] / total, 1), + "opis_pct": round(100 * summary["s_opis"] / total, 1), + "sjediste_pct": round(100 * summary["s_sjediste"] / total, 1), + "email_pct": round(100 * summary["s_email"] / total, 1), + "savez_pct": round(100 * summary["s_savez"] / total, 1), + } + + return {**summary, "coverage": coverage, "by_sport": by_sport, + "by_region": by_region, "by_grad": by_grad, "by_decade": decade} + + + +# ==================== ANALYTICS ==================== +@app.get("/api/analytics/savezi-trend") +def savezi_trend(godine: str = "2020,2021,2022,2023,2024", metric: str = "registriranih"): + valid_metrics = {"registriranih", "neregistriranih", "rekreativaca", "trenera", "reprezentativaca", + "kategoriziranih", "stipendiranih", "klubova_clanica"} + if metric not in valid_metrics: + raise HTTPException(400, f"Invalid metric. Must be one of: {valid_metrics}") + god_list = [int(g) for g in godine.split(",")] + rows = fetch(f"""SELECT s.naziv AS savez, st.godina, st.{metric} AS value + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina = ANY(%s) ORDER BY s.naziv, st.godina""", [god_list]) + saveze = {} + for r in rows: + if r['savez'] not in saveze: saveze[r['savez']] = {} + saveze[r['savez']][r['godina']] = r['value'] + return {"metric": metric, "godine": god_list, "data": saveze} + +@app.get("/api/analytics/proracun-detaljno") +def proracun_detaljno(): + p = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + if not p: return {"proracun": [], "rast_godisnji": [], "current_year": None, "current_total": 0, "rast_dekada_pct": 0} + cagr = [] + for i in range(1, len(p)): + prev = float(p[i-1]['ukupno']) if p[i-1]['ukupno'] else 0 + curr = float(p[i]['ukupno']) if p[i]['ukupno'] else 0 + rate = ((curr/prev - 1) * 100) if prev > 0 else 0 + cagr.append({"godina": p[i]['godina'], "rast_postotak": round(rate, 1)}) + decade_rast = round((float(p[-1]['ukupno'])/float(p[0]['ukupno']) - 1) * 100, 1) if p[0]['ukupno'] else 0 + return {"proracun": p, "rast_godisnji": cagr, "rast_dekada_pct": decade_rast, + "current_year": int(p[-1]['godina']), "current_total": float(p[-1]['ukupno'])} + +@app.get("/api/analytics/klub-financije") +def klub_financije(klub_id: Optional[int] = None, godina: Optional[int] = None): + where = [] + params = [] + if godina: where.append("p.godina=%s"); params.append(godina) + if klub_id: + where.append("(p.klub_id=%s OR p.naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))") + params.extend([klub_id, klub_id]) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"""SELECT p.naziv_kluba, p.godina, p.iznos, + k.id AS klub_id, k.sport, k.razina, k.nositelj_kvalitete + FROM pgz_sport.potpore_nositelji p + LEFT JOIN pgz_sport.klubovi k ON p.klub_id=k.id OR p.naziv_kluba=k.naziv + {where_sql} ORDER BY p.godina DESC, p.iznos DESC""", params) + summary = fetch(f"""SELECT godina, SUM(iznos) AS total, COUNT(*) AS klubova, AVG(iznos) AS prosjek + FROM pgz_sport.potpore_nositelji p {where_sql} + GROUP BY godina ORDER BY godina""", params) + return {"data": rows, "summary": summary} + +@app.get("/api/analytics/lijecnicki-stats") +def lijecnicki_stats(klub_id: Optional[int] = None): + where = ["1=1"]; params = [] + if klub_id: where.append("c.klub_id=%s"); params.append(klub_id) + where_sql = " AND ".join(where) + rows = fetch(f"""SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE lp.vrijedi_do >= CURRENT_DATE + 30) AS validni, + COUNT(*) FILTER (WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + 30) AS uskoro, + COUNT(*) FILTER (WHERE lp.vrijedi_do < CURRENT_DATE) AS istekli, + SUM(lp.iznos) AS ukupan_trosak, SUM(lp.iznos_zzjz) AS zzjz_udio, + SUM(lp.iznos_klub) AS klub_udio, SUM(lp.iznos_clan) AS clan_udio, + AVG(lp.iznos) AS prosjecni_trosak + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id WHERE {where_sql}""", params) + by_ustanova = fetch(f"""SELECT lp.ustanova, COUNT(*) cnt, SUM(lp.iznos) iznos + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} GROUP BY lp.ustanova ORDER BY cnt DESC""", params) + by_lijecnik = fetch(f"""SELECT lp.lijecnik, COUNT(*) cnt, AVG(lp.iznos) prosjek + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} AND lp.lijecnik IS NOT NULL GROUP BY lp.lijecnik ORDER BY cnt DESC""", params) + return {"summary": rows[0] if rows else {}, "by_ustanova": by_ustanova, "by_lijecnik": by_lijecnik} + +# ==================== SAVEZI ==================== +@app.get("/api/savezi") +def list_savezi(authorization: Optional[str] = Header(None), q: Optional[str] = None, + razina: Optional[str] = None, zupanija: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = "WHERE aktivan" + params = [] + if q: + where += " AND (naziv ILIKE %s OR sport ILIKE %s)" + params = [f"%{q}%", f"%{q}%"] + if razina: + where += " AND razina = %s"; params.append(razina) + if zupanija: + where += " AND sjediste_zupanija ILIKE %s"; params.append(f"%{zupanija}%") + sort_col = {"naziv": "naziv", "godina": "godina_osnutka", "sport": "sport", "razina": "razina"}.get(sort, "naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + # Croatian collation for text columns (Š → after S, Č → after C, etc.) + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("naziv", "sport") else "" + rows = fetch(f"""SELECT s.*, + (SELECT COUNT(*) FROM pgz_sport.klubovi WHERE savez_id=s.id) AS broj_klubova, + (SELECT registriranih FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS reg_2024, + (SELECT trenera FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS treneri_2024, + (SELECT reprezentativaca FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS repr_2024 + FROM pgz_sport.savezi s {where} ORDER BY {sort_col}{collate} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/savezi/{savez_id}") +def get_savez(savez_id: int): + rows = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s", [savez_id]) + if not rows: + raise HTTPException(404, "Savez ne postoji") + klubovi = fetch("SELECT * FROM pgz_sport.klubovi WHERE savez_id=%s ORDER BY naziv", [savez_id]) + statistika = fetch("SELECT * FROM pgz_sport.statistika_saveza WHERE savez_id=%s ORDER BY godina", [savez_id]) + manifestacije = fetch("SELECT * FROM pgz_sport.manifestacije WHERE savez_id=%s", [savez_id]) + return {**rows[0], "klubovi": klubovi, "statistika": statistika, "manifestacije": manifestacije} + +# ==================== KLUBOVI ==================== +@app.get("/api/klubovi") +def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, savez_id: Optional[int] = None, + nositelj: Optional[bool] = None, region: Optional[str] = None, sport: Optional[str] = None, grad: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivan"] + params = [] + if q: + where.append("(klub ILIKE %s OR oib ILIKE %s OR sport ILIKE %s OR predsjednik ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%", f"%{q}%"]) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + if nositelj is not None: + where.append(f"nositelj_kvalitete={'TRUE' if nositelj else 'FALSE'}") + if region: + where.append("region ILIKE %s"); params.append(region) + if grad: + where.append("grad ILIKE %s"); params.append(f"%{grad}%") + if sport: + where.append("sport ILIKE %s"); params.append(f"%{sport}%") + sort_col = {"naziv": "klub", "savez": "savez", "broj_clanova": "broj_clanova", + "razina": "razina", "region": "region", "grad": "grad", "sport": "sport"}.get(sort, "klub") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("klub", "savez", "razina", "region", "grad", "sport") else "" + rows = fetch(f"""SELECT * FROM pgz_sport.v_klubovi_pregled WHERE {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST""", params) + for r in rows: + if isinstance(r, dict) and r.get('klub') and not r.get('naziv'): + r['naziv'] = r['klub'] + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/klubovi/{klub_id}") +def get_klub(klub_id: int, authorization: Optional[str] = Header(None)): + admin = is_admin(authorization) + rows = fetch("""SELECT k.*, s.naziv AS savez_naziv FROM pgz_sport.klubovi k + LEFT JOIN pgz_sport.savezi s ON s.id=k.savez_id WHERE k.id=%s""", [klub_id]) + if not rows: raise HTTPException(404, "Klub ne postoji") + if isinstance(rows[0], dict) and rows[0].get('klub') and not rows[0].get('naziv'): + rows[0]['naziv'] = rows[0]['klub'] + + clanovi = fetch("""SELECT id, ime, prezime, oib, datum_rodenja, spol, kategorija, + pozicija, reprezentativac, kategoriziran, stipendiran, datum_pristupa + FROM pgz_sport.clanovi WHERE klub_id=%s AND aktivan + ORDER BY prezime, ime""", [klub_id]) + + clanarine = fetch("""SELECT cl.id, cl.godina, cl.razdoblje, cl.iznos_propisan, cl.iznos_placen, + (cl.iznos_propisan - cl.iznos_placen) AS dug, cl.datum_uplate, cl.status, cl.napomena, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib + FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE c.klub_id=%s ORDER BY cl.godina DESC, cl.id DESC""", [klub_id]) + + lijecnicki = fetch("""SELECT lp.id, lp.datum_pregleda, lp.vrijedi_do, lp.vrsta_pregleda, + lp.ustanova, lp.lijecnik, lp.spreman_za_natjecanje, lp.iznos, lp.iznos_zzjz, lp.iznos_klub, lp.iznos_clan, + lp.placeno, lp.komentar_lijecnika, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib, + CASE WHEN lp.vrijedi_do IS NULL THEN 'Nepoznato' + WHEN lp.vrijedi_do < CURRENT_DATE THEN 'Istekao' + WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'Ističe uskoro' + ELSE 'Validan' END AS status_pregled + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s ORDER BY lp.datum_pregleda DESC""", [klub_id]) + + potpore = fetch("""SELECT * FROM pgz_sport.potpore_nositelji + WHERE klub_id=%s OR naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s) + ORDER BY godina DESC""", [klub_id, klub_id]) + + # Aggregate stats + stats = { + 'broj_clanova': len(clanovi), + 'broj_registriranih': sum(1 for c in clanovi if c.get('kategorija')=='registrirani'), + 'broj_trenera': sum(1 for c in clanovi if c.get('kategorija')=='trener'), + 'broj_reprezentativaca': sum(1 for c in clanovi if c.get('reprezentativac')), + 'broj_kategoriziranih': sum(1 for c in clanovi if c.get('kategoriziran')), + 'broj_stipendiranih': sum(1 for c in clanovi if c.get('stipendiran')), + 'lijecnicki_validni': sum(1 for l in lijecnicki if l.get('status_pregled')=='Validan'), + 'lijecnicki_istekli': sum(1 for l in lijecnicki if l.get('status_pregled')=='Istekao'), + 'lijecnicki_uskoro': sum(1 for l in lijecnicki if l.get('status_pregled')=='Ističe uskoro'), + 'clanarina_naplaceno_god': sum(float(c.get('iznos_placen') or 0) for c in clanarine if c.get('godina')==2026), + 'clanarina_dug_god': sum(float(c.get('dug') or 0) for c in clanarine if c.get('godina')==2026), + 'potpore_2025': float(next((p['iznos'] for p in potpore if p.get('godina')==2025), 0) or 0), + 'potpore_total': sum(float(p.get('iznos') or 0) for p in potpore), + 'zzjz_isplaceno': sum(float(l.get('iznos_zzjz') or 0) for l in lijecnicki if l.get('placeno')), + } + + klub = rows[0] + if not admin: + klub = apply_privacy(klub, admin) + clanovi = apply_privacy(clanovi, admin) + clanarine = apply_privacy(clanarine, admin) + lijecnicki = apply_privacy(lijecnicki, admin) + + return {**klub, "clanovi": clanovi, "clanarine": clanarine, "lijecnicki": lijecnicki, + "potpore": potpore, "stats": stats} + + +class KlubIn(BaseModel): + naziv: str + savez_id: Optional[int] = None + sport: Optional[str] = None + oib: Optional[str] = None + razina: Optional[str] = None + nositelj_kvalitete: Optional[bool] = False + grad: Optional[str] = None + region: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + predsjednik: Optional[str] = None + iban: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/klubovi") +def create_klub(k: KlubIn): + rows = fetch("""INSERT INTO pgz_sport.klubovi (naziv, savez_id, sport, oib, razina, nositelj_kvalitete, grad, region, email, telefon, predsjednik, iban, napomena, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE) RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena]) + return rows[0] + +@app.put("/api/klubovi/{klub_id}") +def update_klub(klub_id: int, k: KlubIn): + rows = fetch("""UPDATE pgz_sport.klubovi SET naziv=%s, savez_id=%s, sport=%s, oib=%s, razina=%s, + nositelj_kvalitete=%s, grad=%s, region=%s, email=%s, telefon=%s, predsjednik=%s, iban=%s, napomena=%s, + updated_at=NOW() WHERE id=%s RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena, klub_id]) + if not rows: + raise HTTPException(404, "Klub ne postoji") + return rows[0] + +# ==================== ČLANOVI ==================== +@app.get("/api/clanovi") +def list_clanovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, klub_id: Optional[int] = None, + kategorija: Optional[str] = None, spol: Optional[str] = None, sort: str = "prezime", order: str = "asc"): + where = ["c.aktivan"] + params = [] + if q: + where.append("(c.ime ILIKE %s OR c.prezime ILIKE %s OR c.oib ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%"]) + if klub_id: + where.append("c.klub_id=%s"); params.append(klub_id) + if kategorija: + where.append("c.kategorija=%s"); params.append(kategorija) + if spol: + # Normalize: Z → Ž, F → Ž (legacy) + spol_norm = "Ž" if spol.upper() in ("Z","Ž","F","W") else "M" if spol.upper() in ("M",) else spol + where.append("c.spol=%s"); params.append(spol_norm) + sort_map = {"prezime": "c.prezime", "ime": "c.ime", "oib": "c.oib", "datum_rodenja": "c.datum_rodenja", "kategorija": "c.kategorija", "klub": "k.naziv"} + sort_col = sort_map.get(sort, "c.prezime") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT c.*, k.naziv AS klub_naziv, + (SELECT MAX(vrijedi_do) FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=c.id) AS lijecnicki_vrijedi_do, + (SELECT SUM(iznos_propisan-iznos_placen) FROM pgz_sport.clanarine WHERE clan_id=c.id AND status!='podmireno') AS dug_clanarine + FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE {where_sql} ORDER BY {sort_col} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +class ClanIn(BaseModel): + klub_id: int + ime: str + prezime: str + oib: Optional[str] = None + datum_rodenja: Optional[date] = None + spol: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + kategorija: Optional[str] = "registrirani" + pozicija: Optional[str] = None + licenca_broj: Optional[str] = None + licenca_vrijedi_do: Optional[date] = None + reprezentativac: Optional[bool] = False + kategoriziran: Optional[bool] = False + stipendiran: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/clanovi") +def create_clan(c: ClanIn): + rows = fetch("""INSERT INTO pgz_sport.clanovi (klub_id, ime, prezime, oib, datum_rodenja, spol, email, telefon, kategorija, pozicija, licenca_broj, licenca_vrijedi_do, reprezentativac, kategoriziran, stipendiran, napomena, aktivan, datum_pristupa) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE,CURRENT_DATE) RETURNING *""", + [c.klub_id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol, c.email, c.telefon, c.kategorija, c.pozicija, c.licenca_broj, c.licenca_vrijedi_do, c.reprezentativac, c.kategoriziran, c.stipendiran, c.napomena]) + return rows[0] + +@app.get("/api/clanovi/{clan_id}") +def get_clan(clan_id: int): + rows = fetch("""SELECT c.*, k.naziv AS klub_naziv FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id]) + if not rows: + raise HTTPException(404, "Član ne postoji") + clanarine = fetch("SELECT * FROM pgz_sport.clanarine WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + lijecnicki = fetch("SELECT * FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=%s ORDER BY datum_pregleda DESC", [clan_id]) + return {**rows[0], "clanarine": clanarine, "lijecnicki": lijecnicki} + +# ==================== ČLANARINE ==================== +@app.get("/api/clanarine") +def list_clanarine(godina: Optional[int] = None, status: Optional[str] = None, + klub_id: Optional[int] = None, sort: str = "godina", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + if status: + where.append("status=%s"); params.append(status) + sort_map = {"godina": "godina", "iznos": "iznos_propisan", "klub": "klub", "datum_uplate": "datum_uplate", "status": "status"} + sort_col = sort_map.get(sort, "godina") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_clanarine_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos_propisan) AS total_propisan, + SUM(iznos_placen) AS total_placen, + SUM(iznos_propisan - iznos_placen) AS total_dug + FROM pgz_sport.v_clanarine_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class ClanarinaIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + godina: int + razdoblje: Optional[str] = "godišnja" + iznos_propisan: float + iznos_placen: Optional[float] = 0 + datum_uplate: Optional[date] = None + nacin_uplate: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/clanarine") +def create_clanarina(c: ClanarinaIn): + status = "podmireno" if c.iznos_placen >= c.iznos_propisan else ("djelomicno" if c.iznos_placen > 0 else "nepodmireno") + klub_id = c.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [c.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.clanarine (clan_id, klub_id, godina, razdoblje, iznos_propisan, iznos_placen, datum_uplate, nacin_uplate, status, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [c.clan_id, klub_id, c.godina, c.razdoblje, c.iznos_propisan, c.iznos_placen, c.datum_uplate, c.nacin_uplate, status, c.napomena]) + return rows[0] + +# ==================== LIJEČNIČKI ==================== +@app.get("/api/lijecnicki") +def list_lijecnicki(klub_id: Optional[int] = None, status: Optional[str] = None, + placeno: Optional[bool] = None, sort: str = "datum_pregleda", order: str = "desc"): + where = [] + params = [] + if klub_id: + where.append("(klub_oib IS NOT NULL AND klub=ANY(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))"); params.append(klub_id) + if status: + where.append("status_pregled=%s"); params.append(status) + if placeno is not None: + where.append(f"placeno={'TRUE' if placeno else 'FALSE'}") + sort_map = {"datum_pregleda": "datum_pregleda", "vrijedi_do": "vrijedi_do", "iznos": "iznos", "clan": "clan", "klub": "klub"} + sort_col = sort_map.get(sort, "datum_pregleda") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_lijecnicki_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos) AS total_iznos, + SUM(iznos_zzjz) AS total_zzjz, + SUM(iznos_klub) AS total_klub, + SUM(iznos_clan) AS total_clan, + COUNT(*) FILTER (WHERE status_pregled='Istekao') AS istekli, + COUNT(*) FILTER (WHERE status_pregled='Ističe uskoro') AS uskoro + FROM pgz_sport.v_lijecnicki_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class LijecnickiIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + datum_pregleda: date + vrijedi_do: Optional[date] = None + vrsta_pregleda: Optional[str] = "temeljni" + ustanova: Optional[str] = "ZZJZ PGŽ" + lijecnik: Optional[str] = None + spreman_za_natjecanje: Optional[bool] = True + ekg: Optional[bool] = False + krv: Optional[bool] = False + spirometrija: Optional[bool] = False + nalaz: Optional[str] = None + komentar_lijecnika: Optional[str] = None + preporuke: Optional[str] = None + iznos: Optional[float] = 0 + iznos_zzjz: Optional[float] = 0 + iznos_klub: Optional[float] = 0 + iznos_clan: Optional[float] = 0 + datum_placanja: Optional[date] = None + placeno: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/lijecnicki") +def create_lijecnicki(l: LijecnickiIn): + klub_id = l.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [l.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.lijecnicki_pregledi (clan_id, klub_id, datum_pregleda, vrijedi_do, vrsta_pregleda, ustanova, lijecnik, spreman_za_natjecanje, ekg, krv, spirometrija, nalaz, komentar_lijecnika, preporuke, iznos, iznos_zzjz, iznos_klub, iznos_clan, datum_placanja, placeno, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [l.clan_id, klub_id, l.datum_pregleda, l.vrijedi_do, l.vrsta_pregleda, l.ustanova, l.lijecnik, l.spreman_za_natjecanje, l.ekg, l.krv, l.spirometrija, l.nalaz, l.komentar_lijecnika, l.preporuke, l.iznos, l.iznos_zzjz, l.iznos_klub, l.iznos_clan, l.datum_placanja, l.placeno, l.napomena]) + return rows[0] + +# ==================== PRORAČUN ==================== +@app.get("/api/proracun") +def list_proracun(): + rows = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + return {"count": len(rows), "rows": rows} + +# ==================== POTPORE NOSITELJI ==================== +@app.get("/api/potpore") +def list_potpore(godina: Optional[int] = None, sort: str = "iznos", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + sort_col = {"iznos": "iznos", "godina": "godina", "klub": "naziv_kluba"}.get(sort, "iznos") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.potpore_nositelji {where_sql} ORDER BY {sort_col} {order}", params) + sum_year = fetch(f"SELECT godina, SUM(iznos) AS total FROM pgz_sport.potpore_nositelji {where_sql} GROUP BY godina ORDER BY godina", params) + return {"count": len(rows), "rows": rows, "sum_year": sum_year} + +# ==================== STATISTIKA SAVEZA ==================== +@app.get("/api/statistika") +def list_statistika(godina: Optional[int] = None, q: Optional[str] = None, razina: Optional[str] = None, + sort: str = "registriranih", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("st.godina=%s"); params.append(godina) + if q: + where.append("s.naziv ILIKE %s"); params.append(f"%{q}%") + if razina: + where.append("s.razina = %s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + # Map sort key → unambiguous column expression + sort_map = { + "registriranih": "st.registriranih", + "klubova": "st.klubova_clanica", + "trenera": "st.trenera", + "reprezentativaca":"st.reprezentativaca", + "neregistriranih": "st.neregistriranih", + "rekreativaca": "st.rekreativaca", + "godina": "st.godina", + "savez": "s.naziv", + "naziv": "s.naziv", + } + sort_col = sort_map.get(sort, "st.registriranih") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + use_collate = sort_col in ("s.naziv", "s.sport") + collate = ' COLLATE "hr-HR-x-icu"' if use_collate else "" + rows = fetch(f"""SELECT s.naziv AS savez, s.razina AS savez_razina, s.sport AS sport, st.* + FROM pgz_sport.statistika_saveza st + JOIN pgz_sport.savezi s ON s.id=st.savez_id {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST, s.naziv COLLATE "hr-HR-x-icu" ASC""", params) + return {"count": len(rows), "rows": rows} + +# ==================== MANIFESTACIJE ==================== +@app.get("/api/manifestacije") +def list_manifestacije(razina: Optional[str] = None, savez_id: Optional[int] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivna"] + params = [] + if razina: + where.append("razina=%s"); params.append(razina) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + sort_col = {"naziv": "m.naziv", "razina": "m.razina", "godina_od": "m.godina_od", "mjesto": "m.mjesto"}.get(sort, "m.naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT m.*, s.naziv AS savez_naziv FROM pgz_sport.manifestacije m + LEFT JOIN pgz_sport.savezi s ON s.id=m.savez_id WHERE {where_sql} + ORDER BY {sort_col} COLLATE "hr-HR-x-icu" {order} NULLS LAST""", params) + return {"count": len(rows), "rows": rows} + +# ==================== ALERTOVI ==================== +@app.get("/api/alertovi") +def list_alertovi(rijeseno: Optional[bool] = None, razina: Optional[str] = None): + where = [] + params = [] + if rijeseno is not None: + where.append(f"rijeseno={'TRUE' if rijeseno else 'FALSE'}") + if razina: + where.append("razina=%s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.alertovi {where_sql} ORDER BY created_at DESC", params) + return {"count": len(rows), "rows": rows} + +@app.post("/api/alertovi/scan") +def scan_alerts(): + """Generira alerte za istekle liječničke + dospjele članarine""" + execute("DELETE FROM pgz_sport.alertovi WHERE NOT rijeseno AND tip IN ('lijecnicki_isteka', 'lijecnicki_uskoro', 'clanarina_dospjela')") + # Liječnički istekao + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_isteka', 'CRITICAL', c.klub_id, lp.clan_id, + 'Liječnički pregled istekao za ' || c.ime || ' ' || c.prezime || ' (klub: ' || COALESCE(k.naziv, 'N/A') || ')', lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lp.vrijedi_do < CURRENT_DATE AND c.aktivan""") + # Liječnički uskoro + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_uskoro', 'WARNING', c.klub_id, lp.clan_id, + 'Liječnički ističe za 30 dana: ' || c.ime || ' ' || c.prezime, lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE+30 AND c.aktivan""") + # Članarine dospjele + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum, iznos) + SELECT 'clanarina_dospjela', 'WARNING', cl.klub_id, cl.clan_id, + 'Nepodmirena članarina ' || cl.godina || ' za ' || c.ime || ' ' || c.prezime, NULL, (cl.iznos_propisan - cl.iznos_placen) + FROM pgz_sport.clanarine cl + JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE cl.status != 'podmireno' AND cl.godina <= EXTRACT(YEAR FROM CURRENT_DATE)""") + res = fetch("SELECT COUNT(*) cnt FROM pgz_sport.alertovi WHERE NOT rijeseno") + return {"alerts_generated": res[0]["cnt"]} + +@app.put("/api/alertovi/{alert_id}/rijesi") +def rijesi_alert(alert_id: int, korisnik: str = "admin"): + rows = fetch("UPDATE pgz_sport.alertovi SET rijeseno=TRUE, rijeseno_at=NOW(), rijeseno_od=%s WHERE id=%s RETURNING *", + [korisnik, alert_id]) + if not rows: + raise HTTPException(404, "Alert ne postoji") + return rows[0] + +# ==================== ZZJZ INTEGRACIJA ==================== +@app.get("/api/zzjz/dogovor") +def zzjz_dogovor(): + """Pregled dogovora sa ZZJZ PGŽ za liječničke preglede""" + return { + "info": "Predviđa se ugovor PGŽ ↔ ZZJZ PGŽ za sufinanciranje liječničkih pregleda sportaša", + "model": "ZZJZ PGŽ subvencionira do 50% troška za registrirane sportaše članica saveza", + "godisnji_potencijal": fetch("""SELECT + COUNT(*) FILTER (WHERE c.kategorija='registrirani') AS sportasa_potencijalno, + SUM(CASE WHEN c.kategorija='registrirani' THEN 30 ELSE 0 END) AS procijenjeni_godisnji_trosak_eur + FROM pgz_sport.clanovi c WHERE c.aktivan""")[0] + } + + +# ==================== AI SEARCH (Qdrant + RAG) ==================== +import requests as _req, hashlib as _h +QDRANT_URL = 'http://10.10.0.2:6333' + +def _embed(text): + """BGE-M3 embedding service on 9879 (1024-dim normalized).""" + try: + r = _req.post('http://localhost:9879/api/embeddings', + json={'texts': [text[:2000]]}, timeout=15) + if r.ok: + data = r.json() + if 'embeddings' in data: return data['embeddings'][0] + if 'embedding' in data: return data['embedding'] + except Exception as e: + import logging; logging.warning(f'BGE-M3 fail: {e}') + h = _h.sha256(text.encode()).digest() + return [(h[i % 32] / 255.0 - 0.5) for i in range(1024)] + +@app.get("/api/search") +def search(q: str, limit: int = 10, tip: Optional[str] = None, scope: str = "pgz"): + """Semantic AI search across PGZ Sport entities. + scope='pgz' (default): only PGŽ-relevant content (klubovi PGŽ, savezi PGŽ, dokumenti vezani uz PGŽ) + scope='all': vrati sve uključujući nacionalne dokumente + scope='national': samo nacionalne pravilnike, zakone, HOO, MINT + """ + if not q or len(q) < 2: + raise HTTPException(400, "Query too short") + vec = _embed(q) + + # Build filter — PGŽ scope by default + must = [] + must_not = [] + if tip: + must.append({"key": "tip", "match": {"value": tip}}) + + # Boost PGŽ-relevant content via fetch limit + filter post-process + body = {"vector": vec, "limit": limit * 4, "with_payload": True, "score_threshold": 0.35} + if must: + body["filter"] = {"must": must} + + try: + r = _req.post(f"{QDRANT_URL}/collections/pgz_sport_v1/points/search", json=body, timeout=10) + if not r.ok: raise HTTPException(500, f"Qdrant: {r.text[:200]}") + all_results = r.json()['result'] + except _req.exceptions.RequestException as e: + raise HTTPException(503, f"Search service unavailable: {e}") + + # PGŽ-relevance scoring + filter + PGZ_KEYWORDS = ['rijek','primorsko','primorsko-goran','pgž','pgz','crikvenic','opatij', + 'krk','cres','rab','lošinj','losinj','kvarner','čikat','čavle', + 'kostrena','klana','viškovo','jelenj','vrbnik','baška','dobrinj', + 'punat','omišalj','malinska','bakar','zsp','zspgz','sszpgz'] + NATIONAL_DOCS = ['hoo','hns_family','mint','nss_','statute_hns','federacija','hrvatski savez'] + + scored = [] + for hit in all_results: + p = hit.get('payload') or {} + # Combine all text fields for keyword check + all_text = ( + (p.get('naziv','') or '') + ' ' + + (p.get('title','') or '') + ' ' + + (p.get('text','') or '')[:500] + ' ' + + (p.get('source','') or '') + ' ' + + (p.get('grad','') or '') + ' ' + + (p.get('source_url','') or '') + ).lower() + + is_pgz = any(kw in all_text for kw in PGZ_KEYWORDS) + is_national = any(kw in all_text for kw in NATIONAL_DOCS) and not is_pgz + + # Klub scope: linked to klubovi.id which is by definition PGŽ + if p.get('tip') == 'klub' and p.get('klub_id'): is_pgz = True + # Savez PGŽ + if p.get('tip') == 'savez' and (p.get('razina') == 'zupanijski' or 'pgž' in (p.get('naziv','') or '').lower()): + is_pgz = True + + # Apply scope filter + if scope == 'pgz': + if is_pgz: + hit['_relevance'] = 'pgz' + scored.append(hit) + elif is_national and p.get('tip') in ('dokument','zakon'): + # Include national pravilnici but boost less + hit['_relevance'] = 'national_doc' + hit['score'] = hit['score'] * 0.7 + scored.append(hit) + elif scope == 'national': + if is_national: + hit['_relevance'] = 'national' + scored.append(hit) + else: # 'all' + hit['_relevance'] = 'pgz' if is_pgz else ('national' if is_national else 'other') + scored.append(hit) + + # Re-sort by adjusted score + scored.sort(key=lambda x: x.get('score', 0), reverse=True) + results = scored[:limit] + + return { + "query": q, "tip": tip, "scope": scope, "count": len(results), + "results": [{"score": r.get('score', 0), + "tip": (r.get('payload') or {}).get('tip'), + "naziv": (r.get('payload') or {}).get('naziv') or (r.get('payload') or {}).get('title'), + "klub_id": (r.get('payload') or {}).get('klub_id'), + "savez_id": (r.get('payload') or {}).get('savez_id'), + "tekst": (r.get('payload') or {}).get('tekst') or (r.get('payload') or {}).get('text','')[:300], + "url": (r.get('payload') or {}).get('source_url') or (r.get('payload') or {}).get('url'), + "relevance": r.get('_relevance', 'unknown'), + "payload": r.get('payload')} for r in results] + } + + +# ==================== GOOGLE OAUTH ==================== +import jwt as _jwt, secrets as _secrets +GOOGLE_CLIENT_ID = "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com" # postavi u .env +ADMIN_EMAILS = { + "damir@rinet.one", "dradulic@outlook.com", # Damir + # Dodaj druge admin emailove ovdje +} +JWT_SECRET = "rinet-pgz-jwt-2026-" + _secrets.token_hex(8) +JWT_ISSUED = [] # in-memory token store (može u Redis) + +@app.post("/api/auth/google") +def google_auth(token: str = Body(..., embed=True)): + """Verify Google ID token and issue JWT for admin/viewer role.""" + try: + import urllib.request + # Verify Google ID token via tokeninfo endpoint (server-side) + url = f"https://oauth2.googleapis.com/tokeninfo?id_token={token}" + with urllib.request.urlopen(url, timeout=10) as r: + data = json.loads(r.read()) + email = data.get("email", "").lower() + verified = data.get("email_verified") == "true" or data.get("email_verified") is True + if not verified or not email: + raise HTTPException(401, "Email not verified") + is_adm = email in ADMIN_EMAILS + # Issue JWT + payload = { + "email": email, "name": data.get("name", email), + "role": "admin" if is_adm else "viewer", + "iat": int(__import__("time").time()), + "exp": int(__import__("time").time()) + 86400 * 7 # 7 dana + } + jwt_token = _jwt.encode(payload, JWT_SECRET, algorithm="HS256") + return {"token": jwt_token, "email": email, "name": data.get("name", email), + "role": payload["role"], "expires_in": 86400 * 7} + except HTTPException: raise + except Exception as e: + raise HTTPException(401, f"Google auth failed: {e}") + +@app.get("/api/auth/me") +def auth_me(authorization: Optional[str] = Header(None)): + """Get current user info from JWT.""" + if not authorization: return {"role": "viewer", "email": None, "name": None} + token = authorization.replace("Bearer ", "").strip() + # Try JWT first + try: + payload = _jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return {"role": payload.get("role"), "email": payload.get("email"), "name": payload.get("name")} + except Exception: + pass + # Legacy demo token + if token == ADMIN_TOKEN: + return {"role": "admin", "email": "demo@admin", "name": "Demo Admin"} + return {"role": "viewer", "email": None, "name": None} + +# ==================== 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} + +@app.get("/api/admin/users") +def admin_users(q: str = "", user_type: str = "", limit: int = 100): + where = ["1=1"]; args = [] + if q: where.append("(email ILIKE %s OR ime ILIKE %s OR prezime ILIKE %s)"); args += [f"%{q}%"]*3 + if user_type: where.append("user_type = %s"); args.append(user_type) + args.append(limit) + with db() as conn: + cur = conn.cursor() + cur.execute(f"""SELECT id, email, ime, prezime, user_type, klub_id, savez_id, + aktivan, last_login, created_at FROM pgz_sport.users + WHERE {' AND '.join(where)} ORDER BY id LIMIT %s""", args) + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + results = [{**dict(zip(cols, r)), + 'last_login': str(dict(zip(cols, r))['last_login']) if dict(zip(cols, r))['last_login'] else None, + 'created_at': str(dict(zip(cols, r))['created_at'])} for r in rows] + return {"count": len(results), "results": results} + +@app.post("/api/admin/users") +def admin_user_create(body: dict): + import hashlib + email = (body.get("email") or "").strip().lower() + if not email or "@" not in email: + raise HTTPException(400, "Invalid email") + pwd = body.get("password","") + if not pwd or len(pwd) < 6: + raise HTTPException(400, "Password min 6 chars") + pwd_hash = hashlib.sha256(pwd.encode()).hexdigest() + with db() as conn: + cur = conn.cursor() + try: + cur.execute("""INSERT INTO pgz_sport.users + (email, password_hash, ime, prezime, user_type, klub_id, savez_id, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,%s,true) RETURNING id""", + (email, pwd_hash, body.get("ime"), body.get("prezime"), + body.get("user_type","klub_user"), body.get("klub_id"), body.get("savez_id"))) + new_id = cur.fetchone()[0] + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, target_text, payload) + VALUES ('user.create','sys_users',%s,%s,%s::jsonb)""", + (new_id, email, json.dumps({"user_type": body.get("user_type")}))) + conn.commit() + return {"id": new_id, "email": email} + except psycopg2.IntegrityError as e: + conn.rollback() + raise HTTPException(400, f"Email već postoji: {email}") + +@app.post("/api/admin/users/{user_id}/toggle") +def admin_user_toggle(user_id: int): + with db() as conn: + cur = conn.cursor() + cur.execute("UPDATE pgz_sport.users SET aktivan = NOT aktivan WHERE id=%s RETURNING aktivan", (user_id,)) + r = cur.fetchone() + if not r: raise HTTPException(404, "User not found") + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, payload) + VALUES ('user.toggle','sys_users',%s,%s::jsonb)""", (user_id, json.dumps({"aktivan": r[0]}))) + conn.commit() + return {"id": user_id, "aktivan": r[0]} + + +# ──────── 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}') + + + +@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("/api/sportski-objekti") +def list_sportski_objekti(q=None,tip=None,grad=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR adresa ILIKE %s OR grad ILIKE %s)"); p+=["%"+q+"%"]*3 + if tip: w.append("tip ILIKE %s"); p.append("%"+tip+"%") + if grad: w.append("grad ILIKE %s"); p.append("%"+grad+"%") + rows=fetch("SELECT * FROM pgz_sport.sportski_objekti WHERE "+" AND ".join(w)+" ORDER BY grad,naziv",p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/clanovi-full") +def list_clanovi_full(q=None,hoo=None,reprezentativac=None,klub_id=None,limit=80,authorization=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(ime ILIKE %s OR prezime ILIKE %s OR klub_naziv_godisnjak ILIKE %s)"); p+=["%"+q+"%"]*3 + if hoo: w.append("hoo_kategorija=%s"); p.append(hoo) + if reprezentativac is not None: w.append("reprezentativac="+(("TRUE") if str(reprezentativac).lower()=="true" else "FALSE")) + if klub_id: w.append("klub_id=%s"); p.append(int(klub_id)) + lim=min(int(limit or 80),200) + sql="SELECT id,ime,prezime,oib,datum_rodenja,spol,sport,pozicija,reprezentativac,kategoriziran,stipendiran,kategorija_hoo,hoo_kategorija,aktivan,klub_naziv_godisnjak,slika_url,profile_url,hns_igrac_id,visina_cm,tezina_kg,broj_dresa,uloga,godisnjak_godine,godisnjak_prvi,godisnjak_zadnji,napomena FROM pgz_sport.clanovi WHERE "+" AND ".join(w)+" ORDER BY prezime,ime LIMIT "+str(lim) + rows=fetch(sql,p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/gradovi") +def list_gradovi(): + rows=fetch("SELECT DISTINCT grad FROM pgz_sport.klubovi WHERE aktivan=TRUE AND grad IS NOT NULL AND grad<>'' AND grad NOT SIMILAR TO '[0-9]+%%' ORDER BY grad",[]) + return [r["grad"] for r in rows] + +@app.get("/api/manifestacije-full") +def list_manifestacije_full(q=None,razina=None): + w=["aktivna=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR mjesto ILIKE %s)"); p+=["%"+q+"%"]*2 + rows=fetch("SELECT id,naziv,mjesto,organizator,razina,broj_ucesnika,godina_od,spol_kategorija,napomena,source_url FROM pgz_sport.manifestacije WHERE "+" AND ".join(w)+" ORDER BY naziv",p) + return {"count":len(rows),"rows":rows} + + + +# ── SUFINANCIRANJE-ALL v1.0 dradulic@outlook.com 2026-05-04 +@app.get("/api/sufinanciranje") +def list_sufinanciranje(q=None, godina=None, razina=None, sport=None, limit=500): + w=["iznos_eur > 0"]; p=[] + if q: w.append("(LOWER(korisnik) LIKE %s OR LOWER(sport) LIKE %s)"); p+=[f"%{q.lower()}%"]*2 + if godina: w.append("godina=%s"); p.append(int(godina)) + if razina: w.append("razina ILIKE %s"); p.append(f"%{razina}%") + if sport: w.append("sport ILIKE %s"); p.append(f"%{sport}%") + sql=f"SELECT korisnik,sport,iznos_eur,vrsta,razina,izvor,source_url,godina FROM pgz_sport.sufinanciranje_sport WHERE {' AND '.join(w)} ORDER BY iznos_eur DESC LIMIT {min(int(limit),1000)}" + rows=fetch(sql,p) + total=sum(float(r.get('iznos_eur') or 0) for r in rows) + years=sorted(set(r.get('godina') for r in rows if r.get('godina')),reverse=True) + return {"count":len(rows),"total":total,"years":years,"rows":rows} + + + +# ══════════════════════════════════════════════════════════════════ +# ERP PLATFORM ROUTES v2.0 — dradulic@outlook.com — 2026-05-04 +# ══════════════════════════════════════════════════════════════════ + +import hashlib + +def hash_pwd(pwd): return hashlib.sha256(pwd.encode()).hexdigest() + +def get_user(token): + if not token: return None + try: + payload = _jwt.decode(token.replace("Bearer ",""), JWT_SECRET, algorithms=["HS256"]) + uid = payload.get("uid") + if uid: + rows = fetch("SELECT * FROM pgz_sport.users WHERE id=%s AND aktivan=TRUE", [uid]) + return rows[0] if rows else None + return payload + except: return None + +# ── AUTH: Email/Password login ────────────────────────────────── +@app.post("/api/auth/login") +def login(body: dict = Body(...)): + email = (body.get("email","")).lower().strip() + pwd = body.get("password","") + if not email or not pwd: raise HTTPException(400,"Email i lozinka obavezni") + rows = fetch("SELECT * FROM pgz_sport.users WHERE LOWER(email)=%s AND aktivan=TRUE",[email]) + if not rows: raise HTTPException(401,"Neispravni podaci") + u = rows[0] + ph = hashlib.sha256(pwd.encode()).hexdigest() + if u.get("password_hash") != ph: raise HTTPException(401,"Neispravni podaci") + payload = {"uid":u["id"],"email":email,"name":u.get("full_name",email), + "role":u.get("user_type","viewer"),"klub_id":u.get("klub_id"), + "savez_id":u.get("savez_id"),"iat":int(__import__("time").time()), + "exp":int(__import__("time").time())+86400*7} + tok = _jwt.encode(payload, JWT_SECRET, algorithm="HS256") + try: + with db() as conn: + cur=conn.cursor() + cur.execute("UPDATE pgz_sport.users SET last_login=NOW() WHERE id=%s",[u["id"]]) + conn.commit() + except: pass + return {"token":tok,"role":payload["role"],"name":payload["name"], + "email":email,"klub_id":payload["klub_id"],"savez_id":payload["savez_id"]} + +# ── 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.mount("/static", StaticFiles(directory=str(HTML_DIR)), name="static") + +@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) diff --git a/_backups/r3_cc5/pgz_sport_api.py.post_m8.1777932522 b/_backups/r3_cc5/pgz_sport_api.py.post_m8.1777932522 new file mode 100644 index 0000000..0c50b27 --- /dev/null +++ b/_backups/r3_cc5/pgz_sport_api.py.post_m8.1777932522 @@ -0,0 +1,1668 @@ +#!/usr/bin/env python3 +""" +pgz_sport_api.py - FastAPI backend za PGŽ Sportski savez ERP/CRM +Author: Damir Radulić (damir@rinet.one) +Date: 25.04.2026 +Port: 8095 +Endpoints: savezi, klubovi, članovi, članarine, liječnički, manifestacije, proračun, dashboard, alertovi +""" + +from fastapi import FastAPI, HTTPException, Query, Body, Header, Depends, UploadFile, File, Form, Request +import json +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import Optional, List +from datetime import date, datetime +import psycopg2 +import psycopg2.extras +from pgz_sport_v2_router import router as v2_router +import os + +DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7') + + +ADMIN_TOKEN = 'admin-pgz-2026' + +def is_admin(authorization): + if not authorization: return False + token = authorization.replace('Bearer ', '').strip() + if token == ADMIN_TOKEN: return True + # Try JWT + try: + import jwt as _jwt + payload = _jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return payload.get("role") == "admin" + except Exception: + return False + +def blur_oib(v): + if not v: return v + s = str(v); + return s[:3] + '•'*(len(s)-5) + s[-2:] if len(s) >= 8 else '•'*len(s) +def blur_email(e): + if not e or '@' not in str(e): return e + u, d = str(e).split('@',1); return (u[:1]+'•••' if u else '')+'@'+d +def blur_phone(p): + if not p: return p + s=str(p); return s[:4]+'•'*(len(s)-7)+s[-3:] if len(s)>=7 else s +def blur_iban(v): + if not v: return v + s=str(v); return s[:4]+'•'*(len(s)-8)+s[-4:] if len(s)>=8 else s +def blur_date(d): + if not d: return d + s = str(d); return s[:4]+'-••-••' if len(s)>=4 else s +def blur_text(t, keep=3): + if not t: return t + s=str(t); return s[:keep]+'•'*(len(s)-keep*2)+s[-keep:] if len(s)>keep*2 else s + +def apply_privacy(rows, admin): + if admin: return rows + out = [] + for r in (rows if isinstance(rows, list) else [rows]): + rr = dict(r) + for k, v in list(rr.items()): + if v is None: continue + kl = k.lower() + if 'oib' in kl: rr[k] = blur_oib(v) + elif 'email' in kl: rr[k] = blur_email(v) + elif kl in ('telefon','tel','phone'): rr[k] = blur_phone(v) + elif kl == 'datum_rodenja': rr[k] = blur_date(v) + elif 'iban' in kl: rr[k] = blur_iban(v) + elif kl == 'adresa': rr[k] = blur_text(v, 3) + elif 'licenca_broj' in kl: rr[k] = blur_text(v, 2) + out.append(rr) + return out if isinstance(rows, list) else out[0] + +app = FastAPI(title="PGŽ Sportski savez ERP/CRM", version="1.0.0") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + + +# === URL rewrite middleware - convert direct external image URLs to /img-proxy === +import json as _json_mw +import re as _re_mw +from starlette.responses import Response as _StarletteResponse_mw + +_IMG_DOMAINS_RE = _re_mw.compile( + r'https?://(?:hns\.family|hns\.hr|hbs\.hr|hrvatski-bocarski-savez\.hr|' + r'rk-zamet\.hr|hvs\.hr|rezultati\.hvs\.hr|sport-pgz\.hr)' + r'/[^"\s\\]+\.(?:jpg|jpeg|png|gif|webp|svg)', + _re_mw.IGNORECASE +) + +def _rewrite_to_proxy(text: str) -> str: + """Replace external image URLs with /sport/api/v2/img-proxy?u=...""" + from urllib.parse import quote as _q + def _sub(m): + url = m.group(0) + return "/sport/api/v2/img-proxy?u=" + _q(url, safe='') + return _IMG_DOMAINS_RE.sub(_sub, text) + +@app.middleware("http") +async def url_rewrite_middleware(request, call_next): + response = await call_next(request) + # Only rewrite JSON API responses + ct = response.headers.get("content-type", "") + if "application/json" not in ct: + return response + # Only on /api/v2 routes (admin & data endpoints) - SKIP /api/v2/img-proxy itself + path = request.url.path + if "/api/v2/img-proxy" in path or "/api/v2/dokumenti" in path: + return response # don't rewrite raw document content + # Read body + body = b"" + async for chunk in response.body_iterator: + body += chunk + try: + text = body.decode("utf-8") + new_text = _rewrite_to_proxy(text) + new_body = new_text.encode("utf-8") + except Exception: + new_body = body + return _StarletteResponse_mw( + content=new_body, + status_code=response.status_code, + headers={k: v for k, v in response.headers.items() if k.lower() not in ("content-length",)}, + media_type=ct, + ) +# === end URL rewrite middleware === + +def db(): + conn = psycopg2.connect(**DB) + conn.autocommit = True + return conn + +def fetch(sql, params=None): + with db() as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as c: + c.execute(sql, params or ()) + return [dict(r) for r in c.fetchall()] + +def execute(sql, params=None): + with db() as conn: + with conn.cursor() as c: + c.execute(sql, params or ()) + return c.rowcount + +# ==================== HEALTH ==================== +@app.get("/health") +def health(): + try: + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + return {"status": "ok", "service": "pgz_sport", "dashboard": rows[0] if rows else None} + except Exception as e: + raise HTTPException(500, f"DB error: {e}") + + +@app.get("/api/whoami") +def whoami_v2(authorization: Optional[str] = Header(None)): + return {"role": "admin" if is_admin(authorization) else "viewer", "privacy_active": not is_admin(authorization)} + +# ==================== DASHBOARD ==================== +@app.get("/api/dashboard") +def dashboard(): + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + if not rows: + return {} + d = rows[0] + # Top savezi by registriranih 2024 + top = fetch("""SELECT s.naziv, st.klubova_clanica, st.registriranih, st.trenera, st.reprezentativaca + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina=2024 ORDER BY st.registriranih DESC LIMIT 10""") + proracun_trend = fetch("SELECT godina, ukupno FROM pgz_sport.proracun ORDER BY godina") + nositelji = fetch("""SELECT naziv_kluba, godina, iznos FROM pgz_sport.potpore_nositelji + WHERE godina = 2025 ORDER BY iznos DESC LIMIT 10""") + return {**d, "top_savezi": top, "proracun_trend": proracun_trend, "nositelji_2025": nositelji} + +@app.get("/api/dashboard/ekosustav") +def dashboard_ekosustav(): + """Sport ekosustav PGŽ — coverage stats za enrichment iz FINA registra.""" + summary = fetch("""SELECT + COUNT(*) AS klubova_total, + COUNT(*) FILTER (WHERE oib IS NOT NULL) AS s_oib, + COUNT(*) FILTER (WHERE predsjednik IS NOT NULL) AS s_predsjednik, + COUNT(*) FILTER (WHERE tajnik IS NOT NULL) AS s_tajnik, + COUNT(*) FILTER (WHERE ciljevi IS NOT NULL) AS s_ciljevi, + COUNT(*) FILTER (WHERE opis_djelatnosti IS NOT NULL) AS s_opis, + COUNT(*) FILTER (WHERE sjediste IS NOT NULL) AS s_sjediste, + COUNT(*) FILTER (WHERE email IS NOT NULL) AS s_email, + COUNT(*) FILTER (WHERE web_stranica IS NOT NULL) AS s_web, + COUNT(*) FILTER (WHERE udruga_status = \'AKTIVAN\') AS s_aktivan_reg, + COUNT(*) FILTER (WHERE savez_id IS NOT NULL) AS s_savez, + COUNT(*) FILTER (WHERE nositelj_kvalitete) AS s_nositelj + FROM pgz_sport.klubovi WHERE aktivan""")[0] + + by_sport = fetch("""SELECT sport, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND sport IS NOT NULL + GROUP BY sport ORDER BY COUNT(*) DESC LIMIT 15""") + + by_region = fetch("""SELECT region, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND region IS NOT NULL + GROUP BY region ORDER BY COUNT(*) DESC""") + + by_grad = fetch("""SELECT grad, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND grad IS NOT NULL + GROUP BY grad ORDER BY COUNT(*) DESC LIMIT 12""") + + decade = fetch("""SELECT + CASE + WHEN godina_osnutka < 1950 THEN \'pred1950\' + WHEN godina_osnutka < 1980 THEN \'1950-1979\' + WHEN godina_osnutka < 2000 THEN \'1980-1999\' + WHEN godina_osnutka < 2010 THEN \'2000-2009\' + WHEN godina_osnutka >= 2010 THEN \'2010-danas\' + ELSE \'nepoznato\' + END AS razdoblje, + COUNT(*) AS broj + FROM pgz_sport.klubovi + WHERE aktivan AND godina_osnutka IS NOT NULL + GROUP BY razdoblje ORDER BY razdoblje""") + + # Pokazi enrichment % + total = summary["klubova_total"] or 1 + coverage = { + "oib_pct": round(100 * summary["s_oib"] / total, 1), + "predsjednik_pct": round(100 * summary["s_predsjednik"] / total, 1), + "tajnik_pct": round(100 * summary["s_tajnik"] / total, 1), + "ciljevi_pct": round(100 * summary["s_ciljevi"] / total, 1), + "opis_pct": round(100 * summary["s_opis"] / total, 1), + "sjediste_pct": round(100 * summary["s_sjediste"] / total, 1), + "email_pct": round(100 * summary["s_email"] / total, 1), + "savez_pct": round(100 * summary["s_savez"] / total, 1), + } + + return {**summary, "coverage": coverage, "by_sport": by_sport, + "by_region": by_region, "by_grad": by_grad, "by_decade": decade} + + + +# ==================== ANALYTICS ==================== +@app.get("/api/analytics/savezi-trend") +def savezi_trend(godine: str = "2020,2021,2022,2023,2024", metric: str = "registriranih"): + valid_metrics = {"registriranih", "neregistriranih", "rekreativaca", "trenera", "reprezentativaca", + "kategoriziranih", "stipendiranih", "klubova_clanica"} + if metric not in valid_metrics: + raise HTTPException(400, f"Invalid metric. Must be one of: {valid_metrics}") + god_list = [int(g) for g in godine.split(",")] + rows = fetch(f"""SELECT s.naziv AS savez, st.godina, st.{metric} AS value + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina = ANY(%s) ORDER BY s.naziv, st.godina""", [god_list]) + saveze = {} + for r in rows: + if r['savez'] not in saveze: saveze[r['savez']] = {} + saveze[r['savez']][r['godina']] = r['value'] + return {"metric": metric, "godine": god_list, "data": saveze} + +@app.get("/api/analytics/proracun-detaljno") +def proracun_detaljno(): + p = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + if not p: return {"proracun": [], "rast_godisnji": [], "current_year": None, "current_total": 0, "rast_dekada_pct": 0} + cagr = [] + for i in range(1, len(p)): + prev = float(p[i-1]['ukupno']) if p[i-1]['ukupno'] else 0 + curr = float(p[i]['ukupno']) if p[i]['ukupno'] else 0 + rate = ((curr/prev - 1) * 100) if prev > 0 else 0 + cagr.append({"godina": p[i]['godina'], "rast_postotak": round(rate, 1)}) + decade_rast = round((float(p[-1]['ukupno'])/float(p[0]['ukupno']) - 1) * 100, 1) if p[0]['ukupno'] else 0 + return {"proracun": p, "rast_godisnji": cagr, "rast_dekada_pct": decade_rast, + "current_year": int(p[-1]['godina']), "current_total": float(p[-1]['ukupno'])} + +@app.get("/api/analytics/klub-financije") +def klub_financije(klub_id: Optional[int] = None, godina: Optional[int] = None): + where = [] + params = [] + if godina: where.append("p.godina=%s"); params.append(godina) + if klub_id: + where.append("(p.klub_id=%s OR p.naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))") + params.extend([klub_id, klub_id]) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"""SELECT p.naziv_kluba, p.godina, p.iznos, + k.id AS klub_id, k.sport, k.razina, k.nositelj_kvalitete + FROM pgz_sport.potpore_nositelji p + LEFT JOIN pgz_sport.klubovi k ON p.klub_id=k.id OR p.naziv_kluba=k.naziv + {where_sql} ORDER BY p.godina DESC, p.iznos DESC""", params) + summary = fetch(f"""SELECT godina, SUM(iznos) AS total, COUNT(*) AS klubova, AVG(iznos) AS prosjek + FROM pgz_sport.potpore_nositelji p {where_sql} + GROUP BY godina ORDER BY godina""", params) + return {"data": rows, "summary": summary} + +@app.get("/api/analytics/lijecnicki-stats") +def lijecnicki_stats(klub_id: Optional[int] = None): + where = ["1=1"]; params = [] + if klub_id: where.append("c.klub_id=%s"); params.append(klub_id) + where_sql = " AND ".join(where) + rows = fetch(f"""SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE lp.vrijedi_do >= CURRENT_DATE + 30) AS validni, + COUNT(*) FILTER (WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + 30) AS uskoro, + COUNT(*) FILTER (WHERE lp.vrijedi_do < CURRENT_DATE) AS istekli, + SUM(lp.iznos) AS ukupan_trosak, SUM(lp.iznos_zzjz) AS zzjz_udio, + SUM(lp.iznos_klub) AS klub_udio, SUM(lp.iznos_clan) AS clan_udio, + AVG(lp.iznos) AS prosjecni_trosak + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id WHERE {where_sql}""", params) + by_ustanova = fetch(f"""SELECT lp.ustanova, COUNT(*) cnt, SUM(lp.iznos) iznos + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} GROUP BY lp.ustanova ORDER BY cnt DESC""", params) + by_lijecnik = fetch(f"""SELECT lp.lijecnik, COUNT(*) cnt, AVG(lp.iznos) prosjek + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} AND lp.lijecnik IS NOT NULL GROUP BY lp.lijecnik ORDER BY cnt DESC""", params) + return {"summary": rows[0] if rows else {}, "by_ustanova": by_ustanova, "by_lijecnik": by_lijecnik} + +# ==================== SAVEZI ==================== +@app.get("/api/savezi") +def list_savezi(authorization: Optional[str] = Header(None), q: Optional[str] = None, + razina: Optional[str] = None, zupanija: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = "WHERE aktivan" + params = [] + if q: + where += " AND (naziv ILIKE %s OR sport ILIKE %s)" + params = [f"%{q}%", f"%{q}%"] + if razina: + where += " AND razina = %s"; params.append(razina) + if zupanija: + where += " AND sjediste_zupanija ILIKE %s"; params.append(f"%{zupanija}%") + sort_col = {"naziv": "naziv", "godina": "godina_osnutka", "sport": "sport", "razina": "razina"}.get(sort, "naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + # Croatian collation for text columns (Š → after S, Č → after C, etc.) + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("naziv", "sport") else "" + rows = fetch(f"""SELECT s.*, + (SELECT COUNT(*) FROM pgz_sport.klubovi WHERE savez_id=s.id) AS broj_klubova, + (SELECT registriranih FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS reg_2024, + (SELECT trenera FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS treneri_2024, + (SELECT reprezentativaca FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS repr_2024 + FROM pgz_sport.savezi s {where} ORDER BY {sort_col}{collate} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/savezi/{savez_id}") +def get_savez(savez_id: int): + rows = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s", [savez_id]) + if not rows: + raise HTTPException(404, "Savez ne postoji") + klubovi = fetch("SELECT * FROM pgz_sport.klubovi WHERE savez_id=%s ORDER BY naziv", [savez_id]) + statistika = fetch("SELECT * FROM pgz_sport.statistika_saveza WHERE savez_id=%s ORDER BY godina", [savez_id]) + manifestacije = fetch("SELECT * FROM pgz_sport.manifestacije WHERE savez_id=%s", [savez_id]) + return {**rows[0], "klubovi": klubovi, "statistika": statistika, "manifestacije": manifestacije} + +# ==================== KLUBOVI ==================== +@app.get("/api/klubovi") +def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, savez_id: Optional[int] = None, + nositelj: Optional[bool] = None, region: Optional[str] = None, sport: Optional[str] = None, grad: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivan"] + params = [] + if q: + where.append("(klub ILIKE %s OR oib ILIKE %s OR sport ILIKE %s OR predsjednik ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%", f"%{q}%"]) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + if nositelj is not None: + where.append(f"nositelj_kvalitete={'TRUE' if nositelj else 'FALSE'}") + if region: + where.append("region ILIKE %s"); params.append(region) + if grad: + where.append("grad ILIKE %s"); params.append(f"%{grad}%") + if sport: + where.append("sport ILIKE %s"); params.append(f"%{sport}%") + sort_col = {"naziv": "klub", "savez": "savez", "broj_clanova": "broj_clanova", + "razina": "razina", "region": "region", "grad": "grad", "sport": "sport"}.get(sort, "klub") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("klub", "savez", "razina", "region", "grad", "sport") else "" + rows = fetch(f"""SELECT * FROM pgz_sport.v_klubovi_pregled WHERE {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST""", params) + for r in rows: + if isinstance(r, dict) and r.get('klub') and not r.get('naziv'): + r['naziv'] = r['klub'] + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/klubovi/{klub_id}") +def get_klub(klub_id: int, authorization: Optional[str] = Header(None)): + admin = is_admin(authorization) + rows = fetch("""SELECT k.*, s.naziv AS savez_naziv FROM pgz_sport.klubovi k + LEFT JOIN pgz_sport.savezi s ON s.id=k.savez_id WHERE k.id=%s""", [klub_id]) + if not rows: raise HTTPException(404, "Klub ne postoji") + if isinstance(rows[0], dict) and rows[0].get('klub') and not rows[0].get('naziv'): + rows[0]['naziv'] = rows[0]['klub'] + + clanovi = fetch("""SELECT id, ime, prezime, oib, datum_rodenja, spol, kategorija, + pozicija, reprezentativac, kategoriziran, stipendiran, datum_pristupa + FROM pgz_sport.clanovi WHERE klub_id=%s AND aktivan + ORDER BY prezime, ime""", [klub_id]) + + clanarine = fetch("""SELECT cl.id, cl.godina, cl.razdoblje, cl.iznos_propisan, cl.iznos_placen, + (cl.iznos_propisan - cl.iznos_placen) AS dug, cl.datum_uplate, cl.status, cl.napomena, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib + FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE c.klub_id=%s ORDER BY cl.godina DESC, cl.id DESC""", [klub_id]) + + lijecnicki = fetch("""SELECT lp.id, lp.datum_pregleda, lp.vrijedi_do, lp.vrsta_pregleda, + lp.ustanova, lp.lijecnik, lp.spreman_za_natjecanje, lp.iznos, lp.iznos_zzjz, lp.iznos_klub, lp.iznos_clan, + lp.placeno, lp.komentar_lijecnika, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib, + CASE WHEN lp.vrijedi_do IS NULL THEN 'Nepoznato' + WHEN lp.vrijedi_do < CURRENT_DATE THEN 'Istekao' + WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'Ističe uskoro' + ELSE 'Validan' END AS status_pregled + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s ORDER BY lp.datum_pregleda DESC""", [klub_id]) + + potpore = fetch("""SELECT * FROM pgz_sport.potpore_nositelji + WHERE klub_id=%s OR naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s) + ORDER BY godina DESC""", [klub_id, klub_id]) + + # Aggregate stats + stats = { + 'broj_clanova': len(clanovi), + 'broj_registriranih': sum(1 for c in clanovi if c.get('kategorija')=='registrirani'), + 'broj_trenera': sum(1 for c in clanovi if c.get('kategorija')=='trener'), + 'broj_reprezentativaca': sum(1 for c in clanovi if c.get('reprezentativac')), + 'broj_kategoriziranih': sum(1 for c in clanovi if c.get('kategoriziran')), + 'broj_stipendiranih': sum(1 for c in clanovi if c.get('stipendiran')), + 'lijecnicki_validni': sum(1 for l in lijecnicki if l.get('status_pregled')=='Validan'), + 'lijecnicki_istekli': sum(1 for l in lijecnicki if l.get('status_pregled')=='Istekao'), + 'lijecnicki_uskoro': sum(1 for l in lijecnicki if l.get('status_pregled')=='Ističe uskoro'), + 'clanarina_naplaceno_god': sum(float(c.get('iznos_placen') or 0) for c in clanarine if c.get('godina')==2026), + 'clanarina_dug_god': sum(float(c.get('dug') or 0) for c in clanarine if c.get('godina')==2026), + 'potpore_2025': float(next((p['iznos'] for p in potpore if p.get('godina')==2025), 0) or 0), + 'potpore_total': sum(float(p.get('iznos') or 0) for p in potpore), + 'zzjz_isplaceno': sum(float(l.get('iznos_zzjz') or 0) for l in lijecnicki if l.get('placeno')), + } + + klub = rows[0] + if not admin: + klub = apply_privacy(klub, admin) + clanovi = apply_privacy(clanovi, admin) + clanarine = apply_privacy(clanarine, admin) + lijecnicki = apply_privacy(lijecnicki, admin) + + return {**klub, "clanovi": clanovi, "clanarine": clanarine, "lijecnicki": lijecnicki, + "potpore": potpore, "stats": stats} + + +class KlubIn(BaseModel): + naziv: str + savez_id: Optional[int] = None + sport: Optional[str] = None + oib: Optional[str] = None + razina: Optional[str] = None + nositelj_kvalitete: Optional[bool] = False + grad: Optional[str] = None + region: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + predsjednik: Optional[str] = None + iban: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/klubovi") +def create_klub(k: KlubIn): + rows = fetch("""INSERT INTO pgz_sport.klubovi (naziv, savez_id, sport, oib, razina, nositelj_kvalitete, grad, region, email, telefon, predsjednik, iban, napomena, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE) RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena]) + return rows[0] + +@app.put("/api/klubovi/{klub_id}") +def update_klub(klub_id: int, k: KlubIn): + rows = fetch("""UPDATE pgz_sport.klubovi SET naziv=%s, savez_id=%s, sport=%s, oib=%s, razina=%s, + nositelj_kvalitete=%s, grad=%s, region=%s, email=%s, telefon=%s, predsjednik=%s, iban=%s, napomena=%s, + updated_at=NOW() WHERE id=%s RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena, klub_id]) + if not rows: + raise HTTPException(404, "Klub ne postoji") + return rows[0] + +# ==================== ČLANOVI ==================== +@app.get("/api/clanovi") +def list_clanovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, klub_id: Optional[int] = None, + kategorija: Optional[str] = None, spol: Optional[str] = None, sort: str = "prezime", order: str = "asc"): + where = ["c.aktivan"] + params = [] + if q: + where.append("(c.ime ILIKE %s OR c.prezime ILIKE %s OR c.oib ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%"]) + if klub_id: + where.append("c.klub_id=%s"); params.append(klub_id) + if kategorija: + where.append("c.kategorija=%s"); params.append(kategorija) + if spol: + # Normalize: Z → Ž, F → Ž (legacy) + spol_norm = "Ž" if spol.upper() in ("Z","Ž","F","W") else "M" if spol.upper() in ("M",) else spol + where.append("c.spol=%s"); params.append(spol_norm) + sort_map = {"prezime": "c.prezime", "ime": "c.ime", "oib": "c.oib", "datum_rodenja": "c.datum_rodenja", "kategorija": "c.kategorija", "klub": "k.naziv"} + sort_col = sort_map.get(sort, "c.prezime") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT c.*, k.naziv AS klub_naziv, + (SELECT MAX(vrijedi_do) FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=c.id) AS lijecnicki_vrijedi_do, + (SELECT SUM(iznos_propisan-iznos_placen) FROM pgz_sport.clanarine WHERE clan_id=c.id AND status!='podmireno') AS dug_clanarine + FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE {where_sql} ORDER BY {sort_col} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +class ClanIn(BaseModel): + klub_id: int + ime: str + prezime: str + oib: Optional[str] = None + datum_rodenja: Optional[date] = None + spol: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + kategorija: Optional[str] = "registrirani" + pozicija: Optional[str] = None + licenca_broj: Optional[str] = None + licenca_vrijedi_do: Optional[date] = None + reprezentativac: Optional[bool] = False + kategoriziran: Optional[bool] = False + stipendiran: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/clanovi") +def create_clan(c: ClanIn): + rows = fetch("""INSERT INTO pgz_sport.clanovi (klub_id, ime, prezime, oib, datum_rodenja, spol, email, telefon, kategorija, pozicija, licenca_broj, licenca_vrijedi_do, reprezentativac, kategoriziran, stipendiran, napomena, aktivan, datum_pristupa) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE,CURRENT_DATE) RETURNING *""", + [c.klub_id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol, c.email, c.telefon, c.kategorija, c.pozicija, c.licenca_broj, c.licenca_vrijedi_do, c.reprezentativac, c.kategoriziran, c.stipendiran, c.napomena]) + return rows[0] + +@app.get("/api/clanovi/{clan_id}") +def get_clan(clan_id: int): + rows = fetch("""SELECT c.*, k.naziv AS klub_naziv FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id]) + if not rows: + raise HTTPException(404, "Član ne postoji") + clanarine = fetch("SELECT * FROM pgz_sport.clanarine WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + lijecnicki = fetch("SELECT * FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=%s ORDER BY datum_pregleda DESC", [clan_id]) + return {**rows[0], "clanarine": clanarine, "lijecnicki": lijecnicki} + +# ==================== ČLANARINE ==================== +@app.get("/api/clanarine") +def list_clanarine(godina: Optional[int] = None, status: Optional[str] = None, + klub_id: Optional[int] = None, sort: str = "godina", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + if status: + where.append("status=%s"); params.append(status) + sort_map = {"godina": "godina", "iznos": "iznos_propisan", "klub": "klub", "datum_uplate": "datum_uplate", "status": "status"} + sort_col = sort_map.get(sort, "godina") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_clanarine_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos_propisan) AS total_propisan, + SUM(iznos_placen) AS total_placen, + SUM(iznos_propisan - iznos_placen) AS total_dug + FROM pgz_sport.v_clanarine_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class ClanarinaIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + godina: int + razdoblje: Optional[str] = "godišnja" + iznos_propisan: float + iznos_placen: Optional[float] = 0 + datum_uplate: Optional[date] = None + nacin_uplate: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/clanarine") +def create_clanarina(c: ClanarinaIn): + status = "podmireno" if c.iznos_placen >= c.iznos_propisan else ("djelomicno" if c.iznos_placen > 0 else "nepodmireno") + klub_id = c.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [c.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.clanarine (clan_id, klub_id, godina, razdoblje, iznos_propisan, iznos_placen, datum_uplate, nacin_uplate, status, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [c.clan_id, klub_id, c.godina, c.razdoblje, c.iznos_propisan, c.iznos_placen, c.datum_uplate, c.nacin_uplate, status, c.napomena]) + return rows[0] + +# ==================== LIJEČNIČKI ==================== +@app.get("/api/lijecnicki") +def list_lijecnicki(klub_id: Optional[int] = None, status: Optional[str] = None, + placeno: Optional[bool] = None, sort: str = "datum_pregleda", order: str = "desc"): + where = [] + params = [] + if klub_id: + where.append("(klub_oib IS NOT NULL AND klub=ANY(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))"); params.append(klub_id) + if status: + where.append("status_pregled=%s"); params.append(status) + if placeno is not None: + where.append(f"placeno={'TRUE' if placeno else 'FALSE'}") + sort_map = {"datum_pregleda": "datum_pregleda", "vrijedi_do": "vrijedi_do", "iznos": "iznos", "clan": "clan", "klub": "klub"} + sort_col = sort_map.get(sort, "datum_pregleda") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_lijecnicki_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos) AS total_iznos, + SUM(iznos_zzjz) AS total_zzjz, + SUM(iznos_klub) AS total_klub, + SUM(iznos_clan) AS total_clan, + COUNT(*) FILTER (WHERE status_pregled='Istekao') AS istekli, + COUNT(*) FILTER (WHERE status_pregled='Ističe uskoro') AS uskoro + FROM pgz_sport.v_lijecnicki_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class LijecnickiIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + datum_pregleda: date + vrijedi_do: Optional[date] = None + vrsta_pregleda: Optional[str] = "temeljni" + ustanova: Optional[str] = "ZZJZ PGŽ" + lijecnik: Optional[str] = None + spreman_za_natjecanje: Optional[bool] = True + ekg: Optional[bool] = False + krv: Optional[bool] = False + spirometrija: Optional[bool] = False + nalaz: Optional[str] = None + komentar_lijecnika: Optional[str] = None + preporuke: Optional[str] = None + iznos: Optional[float] = 0 + iznos_zzjz: Optional[float] = 0 + iznos_klub: Optional[float] = 0 + iznos_clan: Optional[float] = 0 + datum_placanja: Optional[date] = None + placeno: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/lijecnicki") +def create_lijecnicki(l: LijecnickiIn): + klub_id = l.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [l.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.lijecnicki_pregledi (clan_id, klub_id, datum_pregleda, vrijedi_do, vrsta_pregleda, ustanova, lijecnik, spreman_za_natjecanje, ekg, krv, spirometrija, nalaz, komentar_lijecnika, preporuke, iznos, iznos_zzjz, iznos_klub, iznos_clan, datum_placanja, placeno, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [l.clan_id, klub_id, l.datum_pregleda, l.vrijedi_do, l.vrsta_pregleda, l.ustanova, l.lijecnik, l.spreman_za_natjecanje, l.ekg, l.krv, l.spirometrija, l.nalaz, l.komentar_lijecnika, l.preporuke, l.iznos, l.iznos_zzjz, l.iznos_klub, l.iznos_clan, l.datum_placanja, l.placeno, l.napomena]) + return rows[0] + +# ==================== PRORAČUN ==================== +@app.get("/api/proracun") +def list_proracun(): + rows = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + return {"count": len(rows), "rows": rows} + +# ==================== POTPORE NOSITELJI ==================== +@app.get("/api/potpore") +def list_potpore(godina: Optional[int] = None, sort: str = "iznos", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + sort_col = {"iznos": "iznos", "godina": "godina", "klub": "naziv_kluba"}.get(sort, "iznos") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.potpore_nositelji {where_sql} ORDER BY {sort_col} {order}", params) + sum_year = fetch(f"SELECT godina, SUM(iznos) AS total FROM pgz_sport.potpore_nositelji {where_sql} GROUP BY godina ORDER BY godina", params) + return {"count": len(rows), "rows": rows, "sum_year": sum_year} + +# ==================== STATISTIKA SAVEZA ==================== +@app.get("/api/statistika") +def list_statistika(godina: Optional[int] = None, q: Optional[str] = None, razina: Optional[str] = None, + sort: str = "registriranih", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("st.godina=%s"); params.append(godina) + if q: + where.append("s.naziv ILIKE %s"); params.append(f"%{q}%") + if razina: + where.append("s.razina = %s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + # Map sort key → unambiguous column expression + sort_map = { + "registriranih": "st.registriranih", + "klubova": "st.klubova_clanica", + "trenera": "st.trenera", + "reprezentativaca":"st.reprezentativaca", + "neregistriranih": "st.neregistriranih", + "rekreativaca": "st.rekreativaca", + "godina": "st.godina", + "savez": "s.naziv", + "naziv": "s.naziv", + } + sort_col = sort_map.get(sort, "st.registriranih") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + use_collate = sort_col in ("s.naziv", "s.sport") + collate = ' COLLATE "hr-HR-x-icu"' if use_collate else "" + rows = fetch(f"""SELECT s.naziv AS savez, s.razina AS savez_razina, s.sport AS sport, st.* + FROM pgz_sport.statistika_saveza st + JOIN pgz_sport.savezi s ON s.id=st.savez_id {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST, s.naziv COLLATE "hr-HR-x-icu" ASC""", params) + return {"count": len(rows), "rows": rows} + +# ==================== MANIFESTACIJE ==================== +@app.get("/api/manifestacije") +def list_manifestacije(razina: Optional[str] = None, savez_id: Optional[int] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivna"] + params = [] + if razina: + where.append("razina=%s"); params.append(razina) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + sort_col = {"naziv": "m.naziv", "razina": "m.razina", "godina_od": "m.godina_od", "mjesto": "m.mjesto"}.get(sort, "m.naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT m.*, s.naziv AS savez_naziv FROM pgz_sport.manifestacije m + LEFT JOIN pgz_sport.savezi s ON s.id=m.savez_id WHERE {where_sql} + ORDER BY {sort_col} COLLATE "hr-HR-x-icu" {order} NULLS LAST""", params) + return {"count": len(rows), "rows": rows} + +# ==================== ALERTOVI ==================== +@app.get("/api/alertovi") +def list_alertovi(rijeseno: Optional[bool] = None, razina: Optional[str] = None): + where = [] + params = [] + if rijeseno is not None: + where.append(f"rijeseno={'TRUE' if rijeseno else 'FALSE'}") + if razina: + where.append("razina=%s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.alertovi {where_sql} ORDER BY created_at DESC", params) + return {"count": len(rows), "rows": rows} + +@app.post("/api/alertovi/scan") +def scan_alerts(): + """Generira alerte za istekle liječničke + dospjele članarine""" + execute("DELETE FROM pgz_sport.alertovi WHERE NOT rijeseno AND tip IN ('lijecnicki_isteka', 'lijecnicki_uskoro', 'clanarina_dospjela')") + # Liječnički istekao + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_isteka', 'CRITICAL', c.klub_id, lp.clan_id, + 'Liječnički pregled istekao za ' || c.ime || ' ' || c.prezime || ' (klub: ' || COALESCE(k.naziv, 'N/A') || ')', lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lp.vrijedi_do < CURRENT_DATE AND c.aktivan""") + # Liječnički uskoro + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_uskoro', 'WARNING', c.klub_id, lp.clan_id, + 'Liječnički ističe za 30 dana: ' || c.ime || ' ' || c.prezime, lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE+30 AND c.aktivan""") + # Članarine dospjele + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum, iznos) + SELECT 'clanarina_dospjela', 'WARNING', cl.klub_id, cl.clan_id, + 'Nepodmirena članarina ' || cl.godina || ' za ' || c.ime || ' ' || c.prezime, NULL, (cl.iznos_propisan - cl.iznos_placen) + FROM pgz_sport.clanarine cl + JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE cl.status != 'podmireno' AND cl.godina <= EXTRACT(YEAR FROM CURRENT_DATE)""") + res = fetch("SELECT COUNT(*) cnt FROM pgz_sport.alertovi WHERE NOT rijeseno") + return {"alerts_generated": res[0]["cnt"]} + +@app.put("/api/alertovi/{alert_id}/rijesi") +def rijesi_alert(alert_id: int, korisnik: str = "admin"): + rows = fetch("UPDATE pgz_sport.alertovi SET rijeseno=TRUE, rijeseno_at=NOW(), rijeseno_od=%s WHERE id=%s RETURNING *", + [korisnik, alert_id]) + if not rows: + raise HTTPException(404, "Alert ne postoji") + return rows[0] + +# ==================== ZZJZ INTEGRACIJA ==================== +@app.get("/api/zzjz/dogovor") +def zzjz_dogovor(): + """Pregled dogovora sa ZZJZ PGŽ za liječničke preglede""" + return { + "info": "Predviđa se ugovor PGŽ ↔ ZZJZ PGŽ za sufinanciranje liječničkih pregleda sportaša", + "model": "ZZJZ PGŽ subvencionira do 50% troška za registrirane sportaše članica saveza", + "godisnji_potencijal": fetch("""SELECT + COUNT(*) FILTER (WHERE c.kategorija='registrirani') AS sportasa_potencijalno, + SUM(CASE WHEN c.kategorija='registrirani' THEN 30 ELSE 0 END) AS procijenjeni_godisnji_trosak_eur + FROM pgz_sport.clanovi c WHERE c.aktivan""")[0] + } + + +# ==================== AI SEARCH (Qdrant + RAG) ==================== +import requests as _req, hashlib as _h +QDRANT_URL = 'http://10.10.0.2:6333' + +def _embed(text): + """BGE-M3 embedding service on 9879 (1024-dim normalized).""" + try: + r = _req.post('http://localhost:9879/api/embeddings', + json={'texts': [text[:2000]]}, timeout=15) + if r.ok: + data = r.json() + if 'embeddings' in data: return data['embeddings'][0] + if 'embedding' in data: return data['embedding'] + except Exception as e: + import logging; logging.warning(f'BGE-M3 fail: {e}') + h = _h.sha256(text.encode()).digest() + return [(h[i % 32] / 255.0 - 0.5) for i in range(1024)] + +@app.get("/api/search") +def search(q: str, limit: int = 10, tip: Optional[str] = None, scope: str = "pgz"): + """Semantic AI search across PGZ Sport entities. + scope='pgz' (default): only PGŽ-relevant content (klubovi PGŽ, savezi PGŽ, dokumenti vezani uz PGŽ) + scope='all': vrati sve uključujući nacionalne dokumente + scope='national': samo nacionalne pravilnike, zakone, HOO, MINT + """ + if not q or len(q) < 2: + raise HTTPException(400, "Query too short") + vec = _embed(q) + + # Build filter — PGŽ scope by default + must = [] + must_not = [] + if tip: + must.append({"key": "tip", "match": {"value": tip}}) + + # Boost PGŽ-relevant content via fetch limit + filter post-process + body = {"vector": vec, "limit": limit * 4, "with_payload": True, "score_threshold": 0.35} + if must: + body["filter"] = {"must": must} + + try: + r = _req.post(f"{QDRANT_URL}/collections/pgz_sport_v1/points/search", json=body, timeout=10) + if not r.ok: raise HTTPException(500, f"Qdrant: {r.text[:200]}") + all_results = r.json()['result'] + except _req.exceptions.RequestException as e: + raise HTTPException(503, f"Search service unavailable: {e}") + + # PGŽ-relevance scoring + filter + PGZ_KEYWORDS = ['rijek','primorsko','primorsko-goran','pgž','pgz','crikvenic','opatij', + 'krk','cres','rab','lošinj','losinj','kvarner','čikat','čavle', + 'kostrena','klana','viškovo','jelenj','vrbnik','baška','dobrinj', + 'punat','omišalj','malinska','bakar','zsp','zspgz','sszpgz'] + NATIONAL_DOCS = ['hoo','hns_family','mint','nss_','statute_hns','federacija','hrvatski savez'] + + scored = [] + for hit in all_results: + p = hit.get('payload') or {} + # Combine all text fields for keyword check + all_text = ( + (p.get('naziv','') or '') + ' ' + + (p.get('title','') or '') + ' ' + + (p.get('text','') or '')[:500] + ' ' + + (p.get('source','') or '') + ' ' + + (p.get('grad','') or '') + ' ' + + (p.get('source_url','') or '') + ).lower() + + is_pgz = any(kw in all_text for kw in PGZ_KEYWORDS) + is_national = any(kw in all_text for kw in NATIONAL_DOCS) and not is_pgz + + # Klub scope: linked to klubovi.id which is by definition PGŽ + if p.get('tip') == 'klub' and p.get('klub_id'): is_pgz = True + # Savez PGŽ + if p.get('tip') == 'savez' and (p.get('razina') == 'zupanijski' or 'pgž' in (p.get('naziv','') or '').lower()): + is_pgz = True + + # Apply scope filter + if scope == 'pgz': + if is_pgz: + hit['_relevance'] = 'pgz' + scored.append(hit) + elif is_national and p.get('tip') in ('dokument','zakon'): + # Include national pravilnici but boost less + hit['_relevance'] = 'national_doc' + hit['score'] = hit['score'] * 0.7 + scored.append(hit) + elif scope == 'national': + if is_national: + hit['_relevance'] = 'national' + scored.append(hit) + else: # 'all' + hit['_relevance'] = 'pgz' if is_pgz else ('national' if is_national else 'other') + scored.append(hit) + + # Re-sort by adjusted score + scored.sort(key=lambda x: x.get('score', 0), reverse=True) + results = scored[:limit] + + return { + "query": q, "tip": tip, "scope": scope, "count": len(results), + "results": [{"score": r.get('score', 0), + "tip": (r.get('payload') or {}).get('tip'), + "naziv": (r.get('payload') or {}).get('naziv') or (r.get('payload') or {}).get('title'), + "klub_id": (r.get('payload') or {}).get('klub_id'), + "savez_id": (r.get('payload') or {}).get('savez_id'), + "tekst": (r.get('payload') or {}).get('tekst') or (r.get('payload') or {}).get('text','')[:300], + "url": (r.get('payload') or {}).get('source_url') or (r.get('payload') or {}).get('url'), + "relevance": r.get('_relevance', 'unknown'), + "payload": r.get('payload')} for r in results] + } + + +# ==================== GOOGLE OAUTH ==================== +import jwt as _jwt, secrets as _secrets +GOOGLE_CLIENT_ID = "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com" # postavi u .env +ADMIN_EMAILS = { + "damir@rinet.one", "dradulic@outlook.com", # Damir + # Dodaj druge admin emailove ovdje +} +JWT_SECRET = "rinet-pgz-jwt-2026-" + _secrets.token_hex(8) +JWT_ISSUED = [] # in-memory token store (može u Redis) + +@app.post("/api/auth/google") +def google_auth(token: str = Body(..., embed=True)): + """Verify Google ID token and issue JWT for admin/viewer role.""" + try: + import urllib.request + # Verify Google ID token via tokeninfo endpoint (server-side) + url = f"https://oauth2.googleapis.com/tokeninfo?id_token={token}" + with urllib.request.urlopen(url, timeout=10) as r: + data = json.loads(r.read()) + email = data.get("email", "").lower() + verified = data.get("email_verified") == "true" or data.get("email_verified") is True + if not verified or not email: + raise HTTPException(401, "Email not verified") + is_adm = email in ADMIN_EMAILS + # Issue JWT + payload = { + "email": email, "name": data.get("name", email), + "role": "admin" if is_adm else "viewer", + "iat": int(__import__("time").time()), + "exp": int(__import__("time").time()) + 86400 * 7 # 7 dana + } + jwt_token = _jwt.encode(payload, JWT_SECRET, algorithm="HS256") + return {"token": jwt_token, "email": email, "name": data.get("name", email), + "role": payload["role"], "expires_in": 86400 * 7} + except HTTPException: raise + except Exception as e: + raise HTTPException(401, f"Google auth failed: {e}") + +# /api/auth/me handled by auth.auth_v2 router (M1) + +# ==================== STATIC ==================== +import pathlib +HTML_DIR = pathlib.Path(__file__).parent / "static" +HTML_DIR.mkdir(exist_ok=True) + +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + + +# ──────── V5 NATJECANJA ──────── +@app.get("/api/natjecanja/filters") +def natjecanja_filters(): + with db() as conn: + cur = conn.cursor() + cur.execute("SELECT DISTINCT sport FROM pgz_sport.natjecanja WHERE sport IS NOT NULL ORDER BY sport") + sports = [r[0] for r in cur.fetchall()] + cur.execute("SELECT DISTINCT sezona FROM pgz_sport.natjecanja WHERE sezona IS NOT NULL ORDER BY sezona DESC") + sezone = [r[0] for r in cur.fetchall()] + return {"sports": sports, "sezone": sezone} + +@app.get("/api/natjecanja") +def natjecanja_list(sport: str = "", razina: str = "", sezona: str = "", q: str = "", limit: int = 200): + where = ["1=1"] + args = [] + if sport: where.append("sport = %s"); args.append(sport) + if razina: where.append("razina = %s"); args.append(razina) + if sezona: where.append("sezona = %s"); args.append(sezona) + if q: where.append("naziv ILIKE %s"); args.append(f"%{q}%") + args.append(limit) + + with db() as conn: + cur = conn.cursor() + cur.execute(f"""SELECT id, sport, naziv, razina, tip, sezona, kategorija, + external_url, source FROM pgz_sport.natjecanja WHERE {' AND '.join(where)} + ORDER BY razina, sezona DESC NULLS LAST, naziv LIMIT %s""", args) + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + results = [dict(zip(cols, r)) for r in rows] + cur.execute(f"SELECT COUNT(*) FROM pgz_sport.natjecanja WHERE {' AND '.join(where)}", args[:-1]) + total = cur.fetchone()[0] + return {"count": total, "limit": limit, "results": results} + +# ──────── V5 ADMIN ──────── +@app.get("/api/admin/stats") +def admin_stats(): + with db() as conn: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM pgz_sport.users"); ut = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM pgz_sport.users WHERE aktivan=true"); ua = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM pgz_sport.sys_permissions"); pt = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM pgz_sport.sys_audit WHERE created_at >= now()::date"); at = cur.fetchone()[0] + cur.execute("SELECT user_type, COUNT(*) cnt FROM pgz_sport.users GROUP BY 1 ORDER BY 2 DESC") + by_type = [{"user_type": r[0], "cnt": r[1]} for r in cur.fetchall()] + return {"users_total": ut, "users_active": ua, "permissions_total": pt, + "audit_today": at, "by_type": by_type} + +@app.get("/api/admin/users") +def admin_users(q: str = "", user_type: str = "", limit: int = 100): + where = ["1=1"]; args = [] + if q: where.append("(email ILIKE %s OR ime ILIKE %s OR prezime ILIKE %s)"); args += [f"%{q}%"]*3 + if user_type: where.append("user_type = %s"); args.append(user_type) + args.append(limit) + with db() as conn: + cur = conn.cursor() + cur.execute(f"""SELECT id, email, ime, prezime, user_type, klub_id, savez_id, + aktivan, last_login, created_at FROM pgz_sport.users + WHERE {' AND '.join(where)} ORDER BY id LIMIT %s""", args) + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + results = [{**dict(zip(cols, r)), + 'last_login': str(dict(zip(cols, r))['last_login']) if dict(zip(cols, r))['last_login'] else None, + 'created_at': str(dict(zip(cols, r))['created_at'])} for r in rows] + return {"count": len(results), "results": results} + +@app.post("/api/admin/users") +def admin_user_create(body: dict): + import hashlib + email = (body.get("email") or "").strip().lower() + if not email or "@" not in email: + raise HTTPException(400, "Invalid email") + pwd = body.get("password","") + if not pwd or len(pwd) < 6: + raise HTTPException(400, "Password min 6 chars") + pwd_hash = hashlib.sha256(pwd.encode()).hexdigest() + with db() as conn: + cur = conn.cursor() + try: + cur.execute("""INSERT INTO pgz_sport.users + (email, password_hash, ime, prezime, user_type, klub_id, savez_id, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,%s,true) RETURNING id""", + (email, pwd_hash, body.get("ime"), body.get("prezime"), + body.get("user_type","klub_user"), body.get("klub_id"), body.get("savez_id"))) + new_id = cur.fetchone()[0] + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, target_text, payload) + VALUES ('user.create','sys_users',%s,%s,%s::jsonb)""", + (new_id, email, json.dumps({"user_type": body.get("user_type")}))) + conn.commit() + return {"id": new_id, "email": email} + except psycopg2.IntegrityError as e: + conn.rollback() + raise HTTPException(400, f"Email već postoji: {email}") + +@app.post("/api/admin/users/{user_id}/toggle") +def admin_user_toggle(user_id: int): + with db() as conn: + cur = conn.cursor() + cur.execute("UPDATE pgz_sport.users SET aktivan = NOT aktivan WHERE id=%s RETURNING aktivan", (user_id,)) + r = cur.fetchone() + if not r: raise HTTPException(404, "User not found") + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, payload) + VALUES ('user.toggle','sys_users',%s,%s::jsonb)""", (user_id, json.dumps({"aktivan": r[0]}))) + conn.commit() + return {"id": user_id, "aktivan": r[0]} + + +# ──────── 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}') + +# === 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 + app.include_router(gdpr_router) + app.include_router(gdpr_admin_router) + print('[AUTH/M10] gdpr routers loaded (/api/gdpr/*, /api/admin/gdpr/*)') +except Exception as e: + print(f'[AUTH/M10] gdpr routers 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("/api/sportski-objekti") +def list_sportski_objekti(q=None,tip=None,grad=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR adresa ILIKE %s OR grad ILIKE %s)"); p+=["%"+q+"%"]*3 + if tip: w.append("tip ILIKE %s"); p.append("%"+tip+"%") + if grad: w.append("grad ILIKE %s"); p.append("%"+grad+"%") + rows=fetch("SELECT * FROM pgz_sport.sportski_objekti WHERE "+" AND ".join(w)+" ORDER BY grad,naziv",p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/clanovi-full") +def list_clanovi_full(q=None,hoo=None,reprezentativac=None,klub_id=None,limit=80,authorization=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(ime ILIKE %s OR prezime ILIKE %s OR klub_naziv_godisnjak ILIKE %s)"); p+=["%"+q+"%"]*3 + if hoo: w.append("hoo_kategorija=%s"); p.append(hoo) + if reprezentativac is not None: w.append("reprezentativac="+(("TRUE") if str(reprezentativac).lower()=="true" else "FALSE")) + if klub_id: w.append("klub_id=%s"); p.append(int(klub_id)) + lim=min(int(limit or 80),200) + sql="SELECT id,ime,prezime,oib,datum_rodenja,spol,sport,pozicija,reprezentativac,kategoriziran,stipendiran,kategorija_hoo,hoo_kategorija,aktivan,klub_naziv_godisnjak,slika_url,profile_url,hns_igrac_id,visina_cm,tezina_kg,broj_dresa,uloga,godisnjak_godine,godisnjak_prvi,godisnjak_zadnji,napomena FROM pgz_sport.clanovi WHERE "+" AND ".join(w)+" ORDER BY prezime,ime LIMIT "+str(lim) + rows=fetch(sql,p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/gradovi") +def list_gradovi(): + rows=fetch("SELECT DISTINCT grad FROM pgz_sport.klubovi WHERE aktivan=TRUE AND grad IS NOT NULL AND grad<>'' AND grad NOT SIMILAR TO '[0-9]+%%' ORDER BY grad",[]) + return [r["grad"] for r in rows] + +@app.get("/api/manifestacije-full") +def list_manifestacije_full(q=None,razina=None): + w=["aktivna=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR mjesto ILIKE %s)"); p+=["%"+q+"%"]*2 + rows=fetch("SELECT id,naziv,mjesto,organizator,razina,broj_ucesnika,godina_od,spol_kategorija,napomena,source_url FROM pgz_sport.manifestacije WHERE "+" AND ".join(w)+" ORDER BY naziv",p) + return {"count":len(rows),"rows":rows} + + + +# ── SUFINANCIRANJE-ALL v1.0 dradulic@outlook.com 2026-05-04 +@app.get("/api/sufinanciranje") +def list_sufinanciranje(q=None, godina=None, razina=None, sport=None, limit=500): + w=["iznos_eur > 0"]; p=[] + if q: w.append("(LOWER(korisnik) LIKE %s OR LOWER(sport) LIKE %s)"); p+=[f"%{q.lower()}%"]*2 + if godina: w.append("godina=%s"); p.append(int(godina)) + if razina: w.append("razina ILIKE %s"); p.append(f"%{razina}%") + if sport: w.append("sport ILIKE %s"); p.append(f"%{sport}%") + sql=f"SELECT korisnik,sport,iznos_eur,vrsta,razina,izvor,source_url,godina FROM pgz_sport.sufinanciranje_sport WHERE {' AND '.join(w)} ORDER BY iznos_eur DESC LIMIT {min(int(limit),1000)}" + rows=fetch(sql,p) + total=sum(float(r.get('iznos_eur') or 0) for r in rows) + years=sorted(set(r.get('godina') for r in rows if r.get('godina')),reverse=True) + return {"count":len(rows),"total":total,"years":years,"rows":rows} + + + +# ══════════════════════════════════════════════════════════════════ +# ERP PLATFORM ROUTES v2.0 — dradulic@outlook.com — 2026-05-04 +# ══════════════════════════════════════════════════════════════════ + +import hashlib + +def hash_pwd(pwd): return hashlib.sha256(pwd.encode()).hexdigest() + +def get_user(token): + if not token: return None + try: + payload = _jwt.decode(token.replace("Bearer ",""), JWT_SECRET, algorithms=["HS256"]) + uid = payload.get("uid") + if uid: + rows = fetch("SELECT * FROM pgz_sport.users WHERE id=%s AND aktivan=TRUE", [uid]) + return rows[0] if rows else None + return payload + except: return None + +# ── AUTH: Email/Password login — handled by auth.auth_v2 router (M1) ── + +# ── SPORTAS FULL PROFILE ───────────────────────────────────────── +@app.get("/api/sportas/{clan_id}/profil") +def sportas_profil(clan_id: int): + clan = fetch("""SELECT c.*, k.naziv AS klub_naziv_full, k.sport AS klub_sport, + k.grad, k.logo_url FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id]) + if not clan: raise HTTPException(404,"Nije pronađen") + c = clan[0] + sezona = fetch("""SELECT * FROM pgz_sport.clan_sezona WHERE clan_id=%s ORDER BY sezona DESC""", [clan_id]) + utakmice = fetch("""SELECT * FROM pgz_sport.utakmice_log WHERE clan_id=%s ORDER BY datum DESC LIMIT 30""", [clan_id]) + nagrade = fetch("SELECT * FROM pgz_sport.clan_nagrada WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + godisnjaci = fetch("SELECT * FROM pgz_sport.clan_godisnjak WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + stats = {} + if sezona: + stats = {"ukupno_nastupa": sum((r.get("nastupi") or 0) for r in sezona), + "ukupno_pogodaka": sum((r.get("pogoci") or 0) for r in sezona), + "ukupno_asistencija": sum((r.get("asistencije") or 0) for r in sezona), + "ukupno_zutih": sum((r.get("zuti_kartoni") or 0) for r in sezona), + "ukupno_crvenih": sum((r.get("crveni_kartoni") or 0) for r in sezona), + "ukupno_minuta": sum((r.get("minute_total") or 0) for r in sezona), + "sezone_aktivne": len(sezona)} + return {**c,"clan_sezona":sezona,"utakmice":utakmice,"nagrade":nagrade, + "godisnjaci":godisnjaci,"stats":stats} + +# ── SAVEZ FULL DETAIL ──────────────────────────────────────────── +@app.get("/api/savezi/{savez_id}/full") +def savez_full(savez_id: int): + s = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s",[savez_id]) + if not s: raise HTTPException(404,"Savez nije pronađen") + klubovi = fetch("""SELECT id,naziv,sport,grad,predsjednik,tajnik,nositelj_kvalitete, + aktivan,oib,razina,broj_clanova FROM pgz_sport.klubovi WHERE savez_id=%s AND aktivan=TRUE ORDER BY naziv""",[savez_id]) + clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija, + c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan + FROM pgz_sport.clanovi c WHERE c.savez_kod=(SELECT kod FROM pgz_sport.savezi WHERE id=%s) LIMIT 200""",[savez_id]) + if not clanovi: + clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija, + c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan + FROM pgz_sport.clanovi c WHERE c.aktivan=TRUE AND c.sport ILIKE %s LIMIT 200""", + [f'%{s[0].get("sport","") or ""}%']) + treneri = fetch("""SELECT * FROM pgz_sport.treneri WHERE savez_id=%s""",[savez_id]) + return {**s[0],"klubovi":klubovi,"clanovi":clanovi[:100],"treneri":treneri} + +# ── KLUB ERP: CLANARINE ────────────────────────────────────────── +@app.get("/api/klub/{klub_id}/clanarine") +def klub_clanarine(klub_id: int, godina: int=None, status: str=None): + w=["c.klub_id=%s"]; p=[klub_id] + if godina: w.append("cl.godina=%s"); p.append(godina) + if status: w.append("cl.status=%s"); p.append(status) + rows = fetch(f"""SELECT cl.*,c.ime,c.prezime,c.oib,c.spol,c.kategorija,c.hoo_kategorija,c.slika_url + FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE {" AND ".join(w)} ORDER BY cl.godina DESC, c.prezime""", p) + total_p = sum(float(r.get("iznos_placen") or 0) for r in rows) + total_d = sum(float(r.get("iznos_propisan") or 0) - float(r.get("iznos_placen") or 0) for r in rows) + return {"count":len(rows),"naplaceno":total_p,"dug":total_d,"rows":rows} + +# ── KLUB ERP: LIJECNICKI ───────────────────────────────────────── +@app.get("/api/klub/{klub_id}/lijecnicki") +def klub_lijecnicki(klub_id: int): + import datetime; today = datetime.date.today() + rows = fetch("""SELECT lp.*,c.ime,c.prezime,c.oib,c.kategorija,c.slika_url, + CASE WHEN lp.vrijedi_do IS NULL THEN 'nepoznato' + WHEN lp.vrijedi_do < CURRENT_DATE THEN 'istekao' + WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'uskoro_istece' + ELSE 'validan' END AS status_pregled + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s ORDER BY lp.vrijedi_do ASC NULLS LAST""", [klub_id]) + alert_istekli = [r for r in rows if r.get("status_pregled")=="istekao"] + alert_uskoro = [r for r in rows if r.get("status_pregled")=="uskoro_istece"] + return {"count":len(rows),"istekli":len(alert_istekli),"uskoro":len(alert_uskoro),"rows":rows} + +# ── NETWORK GRAPH DATA ─────────────────────────────────────────── +@app.get("/api/network/pgz") +def network_pgz(q: str=None, entity_type: str=None, max_nodes: int=80): + FORENSIC_NAMES = {"SAMIR BARAĆ","MIROSLAV MARIĆ","VELIMIR LIVERIĆ","DOROTEA PESIC-BUKOVAC"} + nodes,edges,seen_nodes,seen_edges = [],[],set(),set() + + def add_node(nid, label, ntype, meta=None): + if nid not in seen_nodes: + seen_nodes.add(nid) + nodes.append({"id":nid,"label":label,"type":ntype,"forensic":label.upper() in FORENSIC_NAMES,"meta":meta or {}}) + + def add_edge(s,t,rel=""): + k=f"{s}-{t}" + if k not in seen_edges: + seen_edges.add(k); edges.append({"source":s,"target":t,"rel":rel}) + + if q: + # Person search + persons = fetch("""SELECT p.id,p.name,p.function,e.name as ent,e.id as eid,e.entity_type,e.city + FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id + WHERE p.name ILIKE %s OR e.name ILIKE %s LIMIT 60""",[f"%{q}%",f"%{q}%"]) + for r in persons: + pid=f"p_{r['id']}"; eid=f"e_{r['eid']}" + add_node(pid,r.get("name","?")[:30],"person") + add_node(eid,r.get("ent","?")[:30],"club" if "Udruga" in (r.get("entity_type") or "") else "company") + add_edge(pid,eid,r.get("function","")) + else: + # Default: top connected persons + rels = fetch("""SELECT p.id,p.name,e.id as eid,e.name as ent,e.entity_type,p.function + FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id + WHERE e.county ILIKE '%%goranska%%' OR e.county ILIKE '%%primorska%%' + ORDER BY p.id LIMIT %s""",[max_nodes]) + for r in rels: + pid=f"p_{r['id']}"; eid=f"e_{r['eid']}" + add_node(pid,r.get("name","?")[:25],"person") + add_node(eid,r.get("ent","?")[:25],"club" if "Udruga" in (r.get("entity_type") or "") else "company", + {"city":r.get("city"),"type":r.get("entity_type")}) + add_edge(pid,eid,r.get("function","")) + + return {"nodes":nodes[:200],"edges":edges[:400],"query":q} + + + +@app.get("/platform") +@app.get("/platform/") +def serve_platform(): + p = HTML_DIR / "platform.html" + if p.exists(): return FileResponse(p) + return {"error": "platform.html not found"} + +app.mount("/static", StaticFiles(directory=str(HTML_DIR)), name="static") + +@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) diff --git a/_backups/r3_cc5/pgz_sport_api.py.pre_m7.1777931436 b/_backups/r3_cc5/pgz_sport_api.py.pre_m7.1777931436 new file mode 100644 index 0000000..a0221ec --- /dev/null +++ b/_backups/r3_cc5/pgz_sport_api.py.pre_m7.1777931436 @@ -0,0 +1,1646 @@ +#!/usr/bin/env python3 +""" +pgz_sport_api.py - FastAPI backend za PGŽ Sportski savez ERP/CRM +Author: Damir Radulić (damir@rinet.one) +Date: 25.04.2026 +Port: 8095 +Endpoints: savezi, klubovi, članovi, članarine, liječnički, manifestacije, proračun, dashboard, alertovi +""" + +from fastapi import FastAPI, HTTPException, Query, Body, Header, Depends, UploadFile, File, Form, Request +import json +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import Optional, List +from datetime import date, datetime +import psycopg2 +import psycopg2.extras +from pgz_sport_v2_router import router as v2_router +import os + +DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7') + + +ADMIN_TOKEN = 'admin-pgz-2026' + +def is_admin(authorization): + if not authorization: return False + token = authorization.replace('Bearer ', '').strip() + if token == ADMIN_TOKEN: return True + # Try JWT + try: + import jwt as _jwt + payload = _jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return payload.get("role") == "admin" + except Exception: + return False + +def blur_oib(v): + if not v: return v + s = str(v); + return s[:3] + '•'*(len(s)-5) + s[-2:] if len(s) >= 8 else '•'*len(s) +def blur_email(e): + if not e or '@' not in str(e): return e + u, d = str(e).split('@',1); return (u[:1]+'•••' if u else '')+'@'+d +def blur_phone(p): + if not p: return p + s=str(p); return s[:4]+'•'*(len(s)-7)+s[-3:] if len(s)>=7 else s +def blur_iban(v): + if not v: return v + s=str(v); return s[:4]+'•'*(len(s)-8)+s[-4:] if len(s)>=8 else s +def blur_date(d): + if not d: return d + s = str(d); return s[:4]+'-••-••' if len(s)>=4 else s +def blur_text(t, keep=3): + if not t: return t + s=str(t); return s[:keep]+'•'*(len(s)-keep*2)+s[-keep:] if len(s)>keep*2 else s + +def apply_privacy(rows, admin): + if admin: return rows + out = [] + for r in (rows if isinstance(rows, list) else [rows]): + rr = dict(r) + for k, v in list(rr.items()): + if v is None: continue + kl = k.lower() + if 'oib' in kl: rr[k] = blur_oib(v) + elif 'email' in kl: rr[k] = blur_email(v) + elif kl in ('telefon','tel','phone'): rr[k] = blur_phone(v) + elif kl == 'datum_rodenja': rr[k] = blur_date(v) + elif 'iban' in kl: rr[k] = blur_iban(v) + elif kl == 'adresa': rr[k] = blur_text(v, 3) + elif 'licenca_broj' in kl: rr[k] = blur_text(v, 2) + out.append(rr) + return out if isinstance(rows, list) else out[0] + +app = FastAPI(title="PGŽ Sportski savez ERP/CRM", version="1.0.0") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + + +# === URL rewrite middleware - convert direct external image URLs to /img-proxy === +import json as _json_mw +import re as _re_mw +from starlette.responses import Response as _StarletteResponse_mw + +_IMG_DOMAINS_RE = _re_mw.compile( + r'https?://(?:hns\.family|hns\.hr|hbs\.hr|hrvatski-bocarski-savez\.hr|' + r'rk-zamet\.hr|hvs\.hr|rezultati\.hvs\.hr|sport-pgz\.hr)' + r'/[^"\s\\]+\.(?:jpg|jpeg|png|gif|webp|svg)', + _re_mw.IGNORECASE +) + +def _rewrite_to_proxy(text: str) -> str: + """Replace external image URLs with /sport/api/v2/img-proxy?u=...""" + from urllib.parse import quote as _q + def _sub(m): + url = m.group(0) + return "/sport/api/v2/img-proxy?u=" + _q(url, safe='') + return _IMG_DOMAINS_RE.sub(_sub, text) + +@app.middleware("http") +async def url_rewrite_middleware(request, call_next): + response = await call_next(request) + # Only rewrite JSON API responses + ct = response.headers.get("content-type", "") + if "application/json" not in ct: + return response + # Only on /api/v2 routes (admin & data endpoints) - SKIP /api/v2/img-proxy itself + path = request.url.path + if "/api/v2/img-proxy" in path or "/api/v2/dokumenti" in path: + return response # don't rewrite raw document content + # Read body + body = b"" + async for chunk in response.body_iterator: + body += chunk + try: + text = body.decode("utf-8") + new_text = _rewrite_to_proxy(text) + new_body = new_text.encode("utf-8") + except Exception: + new_body = body + return _StarletteResponse_mw( + content=new_body, + status_code=response.status_code, + headers={k: v for k, v in response.headers.items() if k.lower() not in ("content-length",)}, + media_type=ct, + ) +# === end URL rewrite middleware === + +def db(): + conn = psycopg2.connect(**DB) + conn.autocommit = True + return conn + +def fetch(sql, params=None): + with db() as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as c: + c.execute(sql, params or ()) + return [dict(r) for r in c.fetchall()] + +def execute(sql, params=None): + with db() as conn: + with conn.cursor() as c: + c.execute(sql, params or ()) + return c.rowcount + +# ==================== HEALTH ==================== +@app.get("/health") +def health(): + try: + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + return {"status": "ok", "service": "pgz_sport", "dashboard": rows[0] if rows else None} + except Exception as e: + raise HTTPException(500, f"DB error: {e}") + + +@app.get("/api/whoami") +def whoami_v2(authorization: Optional[str] = Header(None)): + return {"role": "admin" if is_admin(authorization) else "viewer", "privacy_active": not is_admin(authorization)} + +# ==================== DASHBOARD ==================== +@app.get("/api/dashboard") +def dashboard(): + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + if not rows: + return {} + d = rows[0] + # Top savezi by registriranih 2024 + top = fetch("""SELECT s.naziv, st.klubova_clanica, st.registriranih, st.trenera, st.reprezentativaca + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina=2024 ORDER BY st.registriranih DESC LIMIT 10""") + proracun_trend = fetch("SELECT godina, ukupno FROM pgz_sport.proracun ORDER BY godina") + nositelji = fetch("""SELECT naziv_kluba, godina, iznos FROM pgz_sport.potpore_nositelji + WHERE godina = 2025 ORDER BY iznos DESC LIMIT 10""") + return {**d, "top_savezi": top, "proracun_trend": proracun_trend, "nositelji_2025": nositelji} + +@app.get("/api/dashboard/ekosustav") +def dashboard_ekosustav(): + """Sport ekosustav PGŽ — coverage stats za enrichment iz FINA registra.""" + summary = fetch("""SELECT + COUNT(*) AS klubova_total, + COUNT(*) FILTER (WHERE oib IS NOT NULL) AS s_oib, + COUNT(*) FILTER (WHERE predsjednik IS NOT NULL) AS s_predsjednik, + COUNT(*) FILTER (WHERE tajnik IS NOT NULL) AS s_tajnik, + COUNT(*) FILTER (WHERE ciljevi IS NOT NULL) AS s_ciljevi, + COUNT(*) FILTER (WHERE opis_djelatnosti IS NOT NULL) AS s_opis, + COUNT(*) FILTER (WHERE sjediste IS NOT NULL) AS s_sjediste, + COUNT(*) FILTER (WHERE email IS NOT NULL) AS s_email, + COUNT(*) FILTER (WHERE web_stranica IS NOT NULL) AS s_web, + COUNT(*) FILTER (WHERE udruga_status = \'AKTIVAN\') AS s_aktivan_reg, + COUNT(*) FILTER (WHERE savez_id IS NOT NULL) AS s_savez, + COUNT(*) FILTER (WHERE nositelj_kvalitete) AS s_nositelj + FROM pgz_sport.klubovi WHERE aktivan""")[0] + + by_sport = fetch("""SELECT sport, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND sport IS NOT NULL + GROUP BY sport ORDER BY COUNT(*) DESC LIMIT 15""") + + by_region = fetch("""SELECT region, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND region IS NOT NULL + GROUP BY region ORDER BY COUNT(*) DESC""") + + by_grad = fetch("""SELECT grad, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND grad IS NOT NULL + GROUP BY grad ORDER BY COUNT(*) DESC LIMIT 12""") + + decade = fetch("""SELECT + CASE + WHEN godina_osnutka < 1950 THEN \'pred1950\' + WHEN godina_osnutka < 1980 THEN \'1950-1979\' + WHEN godina_osnutka < 2000 THEN \'1980-1999\' + WHEN godina_osnutka < 2010 THEN \'2000-2009\' + WHEN godina_osnutka >= 2010 THEN \'2010-danas\' + ELSE \'nepoznato\' + END AS razdoblje, + COUNT(*) AS broj + FROM pgz_sport.klubovi + WHERE aktivan AND godina_osnutka IS NOT NULL + GROUP BY razdoblje ORDER BY razdoblje""") + + # Pokazi enrichment % + total = summary["klubova_total"] or 1 + coverage = { + "oib_pct": round(100 * summary["s_oib"] / total, 1), + "predsjednik_pct": round(100 * summary["s_predsjednik"] / total, 1), + "tajnik_pct": round(100 * summary["s_tajnik"] / total, 1), + "ciljevi_pct": round(100 * summary["s_ciljevi"] / total, 1), + "opis_pct": round(100 * summary["s_opis"] / total, 1), + "sjediste_pct": round(100 * summary["s_sjediste"] / total, 1), + "email_pct": round(100 * summary["s_email"] / total, 1), + "savez_pct": round(100 * summary["s_savez"] / total, 1), + } + + return {**summary, "coverage": coverage, "by_sport": by_sport, + "by_region": by_region, "by_grad": by_grad, "by_decade": decade} + + + +# ==================== ANALYTICS ==================== +@app.get("/api/analytics/savezi-trend") +def savezi_trend(godine: str = "2020,2021,2022,2023,2024", metric: str = "registriranih"): + valid_metrics = {"registriranih", "neregistriranih", "rekreativaca", "trenera", "reprezentativaca", + "kategoriziranih", "stipendiranih", "klubova_clanica"} + if metric not in valid_metrics: + raise HTTPException(400, f"Invalid metric. Must be one of: {valid_metrics}") + god_list = [int(g) for g in godine.split(",")] + rows = fetch(f"""SELECT s.naziv AS savez, st.godina, st.{metric} AS value + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina = ANY(%s) ORDER BY s.naziv, st.godina""", [god_list]) + saveze = {} + for r in rows: + if r['savez'] not in saveze: saveze[r['savez']] = {} + saveze[r['savez']][r['godina']] = r['value'] + return {"metric": metric, "godine": god_list, "data": saveze} + +@app.get("/api/analytics/proracun-detaljno") +def proracun_detaljno(): + p = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + if not p: return {"proracun": [], "rast_godisnji": [], "current_year": None, "current_total": 0, "rast_dekada_pct": 0} + cagr = [] + for i in range(1, len(p)): + prev = float(p[i-1]['ukupno']) if p[i-1]['ukupno'] else 0 + curr = float(p[i]['ukupno']) if p[i]['ukupno'] else 0 + rate = ((curr/prev - 1) * 100) if prev > 0 else 0 + cagr.append({"godina": p[i]['godina'], "rast_postotak": round(rate, 1)}) + decade_rast = round((float(p[-1]['ukupno'])/float(p[0]['ukupno']) - 1) * 100, 1) if p[0]['ukupno'] else 0 + return {"proracun": p, "rast_godisnji": cagr, "rast_dekada_pct": decade_rast, + "current_year": int(p[-1]['godina']), "current_total": float(p[-1]['ukupno'])} + +@app.get("/api/analytics/klub-financije") +def klub_financije(klub_id: Optional[int] = None, godina: Optional[int] = None): + where = [] + params = [] + if godina: where.append("p.godina=%s"); params.append(godina) + if klub_id: + where.append("(p.klub_id=%s OR p.naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))") + params.extend([klub_id, klub_id]) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"""SELECT p.naziv_kluba, p.godina, p.iznos, + k.id AS klub_id, k.sport, k.razina, k.nositelj_kvalitete + FROM pgz_sport.potpore_nositelji p + LEFT JOIN pgz_sport.klubovi k ON p.klub_id=k.id OR p.naziv_kluba=k.naziv + {where_sql} ORDER BY p.godina DESC, p.iznos DESC""", params) + summary = fetch(f"""SELECT godina, SUM(iznos) AS total, COUNT(*) AS klubova, AVG(iznos) AS prosjek + FROM pgz_sport.potpore_nositelji p {where_sql} + GROUP BY godina ORDER BY godina""", params) + return {"data": rows, "summary": summary} + +@app.get("/api/analytics/lijecnicki-stats") +def lijecnicki_stats(klub_id: Optional[int] = None): + where = ["1=1"]; params = [] + if klub_id: where.append("c.klub_id=%s"); params.append(klub_id) + where_sql = " AND ".join(where) + rows = fetch(f"""SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE lp.vrijedi_do >= CURRENT_DATE + 30) AS validni, + COUNT(*) FILTER (WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + 30) AS uskoro, + COUNT(*) FILTER (WHERE lp.vrijedi_do < CURRENT_DATE) AS istekli, + SUM(lp.iznos) AS ukupan_trosak, SUM(lp.iznos_zzjz) AS zzjz_udio, + SUM(lp.iznos_klub) AS klub_udio, SUM(lp.iznos_clan) AS clan_udio, + AVG(lp.iznos) AS prosjecni_trosak + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id WHERE {where_sql}""", params) + by_ustanova = fetch(f"""SELECT lp.ustanova, COUNT(*) cnt, SUM(lp.iznos) iznos + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} GROUP BY lp.ustanova ORDER BY cnt DESC""", params) + by_lijecnik = fetch(f"""SELECT lp.lijecnik, COUNT(*) cnt, AVG(lp.iznos) prosjek + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} AND lp.lijecnik IS NOT NULL GROUP BY lp.lijecnik ORDER BY cnt DESC""", params) + return {"summary": rows[0] if rows else {}, "by_ustanova": by_ustanova, "by_lijecnik": by_lijecnik} + +# ==================== SAVEZI ==================== +@app.get("/api/savezi") +def list_savezi(authorization: Optional[str] = Header(None), q: Optional[str] = None, + razina: Optional[str] = None, zupanija: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = "WHERE aktivan" + params = [] + if q: + where += " AND (naziv ILIKE %s OR sport ILIKE %s)" + params = [f"%{q}%", f"%{q}%"] + if razina: + where += " AND razina = %s"; params.append(razina) + if zupanija: + where += " AND sjediste_zupanija ILIKE %s"; params.append(f"%{zupanija}%") + sort_col = {"naziv": "naziv", "godina": "godina_osnutka", "sport": "sport", "razina": "razina"}.get(sort, "naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + # Croatian collation for text columns (Š → after S, Č → after C, etc.) + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("naziv", "sport") else "" + rows = fetch(f"""SELECT s.*, + (SELECT COUNT(*) FROM pgz_sport.klubovi WHERE savez_id=s.id) AS broj_klubova, + (SELECT registriranih FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS reg_2024, + (SELECT trenera FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS treneri_2024, + (SELECT reprezentativaca FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS repr_2024 + FROM pgz_sport.savezi s {where} ORDER BY {sort_col}{collate} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/savezi/{savez_id}") +def get_savez(savez_id: int): + rows = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s", [savez_id]) + if not rows: + raise HTTPException(404, "Savez ne postoji") + klubovi = fetch("SELECT * FROM pgz_sport.klubovi WHERE savez_id=%s ORDER BY naziv", [savez_id]) + statistika = fetch("SELECT * FROM pgz_sport.statistika_saveza WHERE savez_id=%s ORDER BY godina", [savez_id]) + manifestacije = fetch("SELECT * FROM pgz_sport.manifestacije WHERE savez_id=%s", [savez_id]) + return {**rows[0], "klubovi": klubovi, "statistika": statistika, "manifestacije": manifestacije} + +# ==================== KLUBOVI ==================== +@app.get("/api/klubovi") +def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, savez_id: Optional[int] = None, + nositelj: Optional[bool] = None, region: Optional[str] = None, sport: Optional[str] = None, grad: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivan"] + params = [] + if q: + where.append("(klub ILIKE %s OR oib ILIKE %s OR sport ILIKE %s OR predsjednik ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%", f"%{q}%"]) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + if nositelj is not None: + where.append(f"nositelj_kvalitete={'TRUE' if nositelj else 'FALSE'}") + if region: + where.append("region ILIKE %s"); params.append(region) + if grad: + where.append("grad ILIKE %s"); params.append(f"%{grad}%") + if sport: + where.append("sport ILIKE %s"); params.append(f"%{sport}%") + sort_col = {"naziv": "klub", "savez": "savez", "broj_clanova": "broj_clanova", + "razina": "razina", "region": "region", "grad": "grad", "sport": "sport"}.get(sort, "klub") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("klub", "savez", "razina", "region", "grad", "sport") else "" + rows = fetch(f"""SELECT * FROM pgz_sport.v_klubovi_pregled WHERE {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST""", params) + for r in rows: + if isinstance(r, dict) and r.get('klub') and not r.get('naziv'): + r['naziv'] = r['klub'] + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/klubovi/{klub_id}") +def get_klub(klub_id: int, authorization: Optional[str] = Header(None)): + admin = is_admin(authorization) + rows = fetch("""SELECT k.*, s.naziv AS savez_naziv FROM pgz_sport.klubovi k + LEFT JOIN pgz_sport.savezi s ON s.id=k.savez_id WHERE k.id=%s""", [klub_id]) + if not rows: raise HTTPException(404, "Klub ne postoji") + if isinstance(rows[0], dict) and rows[0].get('klub') and not rows[0].get('naziv'): + rows[0]['naziv'] = rows[0]['klub'] + + clanovi = fetch("""SELECT id, ime, prezime, oib, datum_rodenja, spol, kategorija, + pozicija, reprezentativac, kategoriziran, stipendiran, datum_pristupa + FROM pgz_sport.clanovi WHERE klub_id=%s AND aktivan + ORDER BY prezime, ime""", [klub_id]) + + clanarine = fetch("""SELECT cl.id, cl.godina, cl.razdoblje, cl.iznos_propisan, cl.iznos_placen, + (cl.iznos_propisan - cl.iznos_placen) AS dug, cl.datum_uplate, cl.status, cl.napomena, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib + FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE c.klub_id=%s ORDER BY cl.godina DESC, cl.id DESC""", [klub_id]) + + lijecnicki = fetch("""SELECT lp.id, lp.datum_pregleda, lp.vrijedi_do, lp.vrsta_pregleda, + lp.ustanova, lp.lijecnik, lp.spreman_za_natjecanje, lp.iznos, lp.iznos_zzjz, lp.iznos_klub, lp.iznos_clan, + lp.placeno, lp.komentar_lijecnika, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib, + CASE WHEN lp.vrijedi_do IS NULL THEN 'Nepoznato' + WHEN lp.vrijedi_do < CURRENT_DATE THEN 'Istekao' + WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'Ističe uskoro' + ELSE 'Validan' END AS status_pregled + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s ORDER BY lp.datum_pregleda DESC""", [klub_id]) + + potpore = fetch("""SELECT * FROM pgz_sport.potpore_nositelji + WHERE klub_id=%s OR naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s) + ORDER BY godina DESC""", [klub_id, klub_id]) + + # Aggregate stats + stats = { + 'broj_clanova': len(clanovi), + 'broj_registriranih': sum(1 for c in clanovi if c.get('kategorija')=='registrirani'), + 'broj_trenera': sum(1 for c in clanovi if c.get('kategorija')=='trener'), + 'broj_reprezentativaca': sum(1 for c in clanovi if c.get('reprezentativac')), + 'broj_kategoriziranih': sum(1 for c in clanovi if c.get('kategoriziran')), + 'broj_stipendiranih': sum(1 for c in clanovi if c.get('stipendiran')), + 'lijecnicki_validni': sum(1 for l in lijecnicki if l.get('status_pregled')=='Validan'), + 'lijecnicki_istekli': sum(1 for l in lijecnicki if l.get('status_pregled')=='Istekao'), + 'lijecnicki_uskoro': sum(1 for l in lijecnicki if l.get('status_pregled')=='Ističe uskoro'), + 'clanarina_naplaceno_god': sum(float(c.get('iznos_placen') or 0) for c in clanarine if c.get('godina')==2026), + 'clanarina_dug_god': sum(float(c.get('dug') or 0) for c in clanarine if c.get('godina')==2026), + 'potpore_2025': float(next((p['iznos'] for p in potpore if p.get('godina')==2025), 0) or 0), + 'potpore_total': sum(float(p.get('iznos') or 0) for p in potpore), + 'zzjz_isplaceno': sum(float(l.get('iznos_zzjz') or 0) for l in lijecnicki if l.get('placeno')), + } + + klub = rows[0] + if not admin: + klub = apply_privacy(klub, admin) + clanovi = apply_privacy(clanovi, admin) + clanarine = apply_privacy(clanarine, admin) + lijecnicki = apply_privacy(lijecnicki, admin) + + return {**klub, "clanovi": clanovi, "clanarine": clanarine, "lijecnicki": lijecnicki, + "potpore": potpore, "stats": stats} + + +class KlubIn(BaseModel): + naziv: str + savez_id: Optional[int] = None + sport: Optional[str] = None + oib: Optional[str] = None + razina: Optional[str] = None + nositelj_kvalitete: Optional[bool] = False + grad: Optional[str] = None + region: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + predsjednik: Optional[str] = None + iban: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/klubovi") +def create_klub(k: KlubIn): + rows = fetch("""INSERT INTO pgz_sport.klubovi (naziv, savez_id, sport, oib, razina, nositelj_kvalitete, grad, region, email, telefon, predsjednik, iban, napomena, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE) RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena]) + return rows[0] + +@app.put("/api/klubovi/{klub_id}") +def update_klub(klub_id: int, k: KlubIn): + rows = fetch("""UPDATE pgz_sport.klubovi SET naziv=%s, savez_id=%s, sport=%s, oib=%s, razina=%s, + nositelj_kvalitete=%s, grad=%s, region=%s, email=%s, telefon=%s, predsjednik=%s, iban=%s, napomena=%s, + updated_at=NOW() WHERE id=%s RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena, klub_id]) + if not rows: + raise HTTPException(404, "Klub ne postoji") + return rows[0] + +# ==================== ČLANOVI ==================== +@app.get("/api/clanovi") +def list_clanovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, klub_id: Optional[int] = None, + kategorija: Optional[str] = None, spol: Optional[str] = None, sort: str = "prezime", order: str = "asc"): + where = ["c.aktivan"] + params = [] + if q: + where.append("(c.ime ILIKE %s OR c.prezime ILIKE %s OR c.oib ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%"]) + if klub_id: + where.append("c.klub_id=%s"); params.append(klub_id) + if kategorija: + where.append("c.kategorija=%s"); params.append(kategorija) + if spol: + # Normalize: Z → Ž, F → Ž (legacy) + spol_norm = "Ž" if spol.upper() in ("Z","Ž","F","W") else "M" if spol.upper() in ("M",) else spol + where.append("c.spol=%s"); params.append(spol_norm) + sort_map = {"prezime": "c.prezime", "ime": "c.ime", "oib": "c.oib", "datum_rodenja": "c.datum_rodenja", "kategorija": "c.kategorija", "klub": "k.naziv"} + sort_col = sort_map.get(sort, "c.prezime") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT c.*, k.naziv AS klub_naziv, + (SELECT MAX(vrijedi_do) FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=c.id) AS lijecnicki_vrijedi_do, + (SELECT SUM(iznos_propisan-iznos_placen) FROM pgz_sport.clanarine WHERE clan_id=c.id AND status!='podmireno') AS dug_clanarine + FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE {where_sql} ORDER BY {sort_col} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +class ClanIn(BaseModel): + klub_id: int + ime: str + prezime: str + oib: Optional[str] = None + datum_rodenja: Optional[date] = None + spol: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + kategorija: Optional[str] = "registrirani" + pozicija: Optional[str] = None + licenca_broj: Optional[str] = None + licenca_vrijedi_do: Optional[date] = None + reprezentativac: Optional[bool] = False + kategoriziran: Optional[bool] = False + stipendiran: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/clanovi") +def create_clan(c: ClanIn): + rows = fetch("""INSERT INTO pgz_sport.clanovi (klub_id, ime, prezime, oib, datum_rodenja, spol, email, telefon, kategorija, pozicija, licenca_broj, licenca_vrijedi_do, reprezentativac, kategoriziran, stipendiran, napomena, aktivan, datum_pristupa) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE,CURRENT_DATE) RETURNING *""", + [c.klub_id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol, c.email, c.telefon, c.kategorija, c.pozicija, c.licenca_broj, c.licenca_vrijedi_do, c.reprezentativac, c.kategoriziran, c.stipendiran, c.napomena]) + return rows[0] + +@app.get("/api/clanovi/{clan_id}") +def get_clan(clan_id: int): + rows = fetch("""SELECT c.*, k.naziv AS klub_naziv FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id]) + if not rows: + raise HTTPException(404, "Član ne postoji") + clanarine = fetch("SELECT * FROM pgz_sport.clanarine WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + lijecnicki = fetch("SELECT * FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=%s ORDER BY datum_pregleda DESC", [clan_id]) + return {**rows[0], "clanarine": clanarine, "lijecnicki": lijecnicki} + +# ==================== ČLANARINE ==================== +@app.get("/api/clanarine") +def list_clanarine(godina: Optional[int] = None, status: Optional[str] = None, + klub_id: Optional[int] = None, sort: str = "godina", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + if status: + where.append("status=%s"); params.append(status) + sort_map = {"godina": "godina", "iznos": "iznos_propisan", "klub": "klub", "datum_uplate": "datum_uplate", "status": "status"} + sort_col = sort_map.get(sort, "godina") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_clanarine_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos_propisan) AS total_propisan, + SUM(iznos_placen) AS total_placen, + SUM(iznos_propisan - iznos_placen) AS total_dug + FROM pgz_sport.v_clanarine_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class ClanarinaIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + godina: int + razdoblje: Optional[str] = "godišnja" + iznos_propisan: float + iznos_placen: Optional[float] = 0 + datum_uplate: Optional[date] = None + nacin_uplate: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/clanarine") +def create_clanarina(c: ClanarinaIn): + status = "podmireno" if c.iznos_placen >= c.iznos_propisan else ("djelomicno" if c.iznos_placen > 0 else "nepodmireno") + klub_id = c.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [c.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.clanarine (clan_id, klub_id, godina, razdoblje, iznos_propisan, iznos_placen, datum_uplate, nacin_uplate, status, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [c.clan_id, klub_id, c.godina, c.razdoblje, c.iznos_propisan, c.iznos_placen, c.datum_uplate, c.nacin_uplate, status, c.napomena]) + return rows[0] + +# ==================== LIJEČNIČKI ==================== +@app.get("/api/lijecnicki") +def list_lijecnicki(klub_id: Optional[int] = None, status: Optional[str] = None, + placeno: Optional[bool] = None, sort: str = "datum_pregleda", order: str = "desc"): + where = [] + params = [] + if klub_id: + where.append("(klub_oib IS NOT NULL AND klub=ANY(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))"); params.append(klub_id) + if status: + where.append("status_pregled=%s"); params.append(status) + if placeno is not None: + where.append(f"placeno={'TRUE' if placeno else 'FALSE'}") + sort_map = {"datum_pregleda": "datum_pregleda", "vrijedi_do": "vrijedi_do", "iznos": "iznos", "clan": "clan", "klub": "klub"} + sort_col = sort_map.get(sort, "datum_pregleda") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_lijecnicki_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos) AS total_iznos, + SUM(iznos_zzjz) AS total_zzjz, + SUM(iznos_klub) AS total_klub, + SUM(iznos_clan) AS total_clan, + COUNT(*) FILTER (WHERE status_pregled='Istekao') AS istekli, + COUNT(*) FILTER (WHERE status_pregled='Ističe uskoro') AS uskoro + FROM pgz_sport.v_lijecnicki_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class LijecnickiIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + datum_pregleda: date + vrijedi_do: Optional[date] = None + vrsta_pregleda: Optional[str] = "temeljni" + ustanova: Optional[str] = "ZZJZ PGŽ" + lijecnik: Optional[str] = None + spreman_za_natjecanje: Optional[bool] = True + ekg: Optional[bool] = False + krv: Optional[bool] = False + spirometrija: Optional[bool] = False + nalaz: Optional[str] = None + komentar_lijecnika: Optional[str] = None + preporuke: Optional[str] = None + iznos: Optional[float] = 0 + iznos_zzjz: Optional[float] = 0 + iznos_klub: Optional[float] = 0 + iznos_clan: Optional[float] = 0 + datum_placanja: Optional[date] = None + placeno: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/lijecnicki") +def create_lijecnicki(l: LijecnickiIn): + klub_id = l.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [l.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.lijecnicki_pregledi (clan_id, klub_id, datum_pregleda, vrijedi_do, vrsta_pregleda, ustanova, lijecnik, spreman_za_natjecanje, ekg, krv, spirometrija, nalaz, komentar_lijecnika, preporuke, iznos, iznos_zzjz, iznos_klub, iznos_clan, datum_placanja, placeno, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [l.clan_id, klub_id, l.datum_pregleda, l.vrijedi_do, l.vrsta_pregleda, l.ustanova, l.lijecnik, l.spreman_za_natjecanje, l.ekg, l.krv, l.spirometrija, l.nalaz, l.komentar_lijecnika, l.preporuke, l.iznos, l.iznos_zzjz, l.iznos_klub, l.iznos_clan, l.datum_placanja, l.placeno, l.napomena]) + return rows[0] + +# ==================== PRORAČUN ==================== +@app.get("/api/proracun") +def list_proracun(): + rows = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + return {"count": len(rows), "rows": rows} + +# ==================== POTPORE NOSITELJI ==================== +@app.get("/api/potpore") +def list_potpore(godina: Optional[int] = None, sort: str = "iznos", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + sort_col = {"iznos": "iznos", "godina": "godina", "klub": "naziv_kluba"}.get(sort, "iznos") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.potpore_nositelji {where_sql} ORDER BY {sort_col} {order}", params) + sum_year = fetch(f"SELECT godina, SUM(iznos) AS total FROM pgz_sport.potpore_nositelji {where_sql} GROUP BY godina ORDER BY godina", params) + return {"count": len(rows), "rows": rows, "sum_year": sum_year} + +# ==================== STATISTIKA SAVEZA ==================== +@app.get("/api/statistika") +def list_statistika(godina: Optional[int] = None, q: Optional[str] = None, razina: Optional[str] = None, + sort: str = "registriranih", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("st.godina=%s"); params.append(godina) + if q: + where.append("s.naziv ILIKE %s"); params.append(f"%{q}%") + if razina: + where.append("s.razina = %s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + # Map sort key → unambiguous column expression + sort_map = { + "registriranih": "st.registriranih", + "klubova": "st.klubova_clanica", + "trenera": "st.trenera", + "reprezentativaca":"st.reprezentativaca", + "neregistriranih": "st.neregistriranih", + "rekreativaca": "st.rekreativaca", + "godina": "st.godina", + "savez": "s.naziv", + "naziv": "s.naziv", + } + sort_col = sort_map.get(sort, "st.registriranih") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + use_collate = sort_col in ("s.naziv", "s.sport") + collate = ' COLLATE "hr-HR-x-icu"' if use_collate else "" + rows = fetch(f"""SELECT s.naziv AS savez, s.razina AS savez_razina, s.sport AS sport, st.* + FROM pgz_sport.statistika_saveza st + JOIN pgz_sport.savezi s ON s.id=st.savez_id {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST, s.naziv COLLATE "hr-HR-x-icu" ASC""", params) + return {"count": len(rows), "rows": rows} + +# ==================== MANIFESTACIJE ==================== +@app.get("/api/manifestacije") +def list_manifestacije(razina: Optional[str] = None, savez_id: Optional[int] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivna"] + params = [] + if razina: + where.append("razina=%s"); params.append(razina) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + sort_col = {"naziv": "m.naziv", "razina": "m.razina", "godina_od": "m.godina_od", "mjesto": "m.mjesto"}.get(sort, "m.naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT m.*, s.naziv AS savez_naziv FROM pgz_sport.manifestacije m + LEFT JOIN pgz_sport.savezi s ON s.id=m.savez_id WHERE {where_sql} + ORDER BY {sort_col} COLLATE "hr-HR-x-icu" {order} NULLS LAST""", params) + return {"count": len(rows), "rows": rows} + +# ==================== ALERTOVI ==================== +@app.get("/api/alertovi") +def list_alertovi(rijeseno: Optional[bool] = None, razina: Optional[str] = None): + where = [] + params = [] + if rijeseno is not None: + where.append(f"rijeseno={'TRUE' if rijeseno else 'FALSE'}") + if razina: + where.append("razina=%s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.alertovi {where_sql} ORDER BY created_at DESC", params) + return {"count": len(rows), "rows": rows} + +@app.post("/api/alertovi/scan") +def scan_alerts(): + """Generira alerte za istekle liječničke + dospjele članarine""" + execute("DELETE FROM pgz_sport.alertovi WHERE NOT rijeseno AND tip IN ('lijecnicki_isteka', 'lijecnicki_uskoro', 'clanarina_dospjela')") + # Liječnički istekao + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_isteka', 'CRITICAL', c.klub_id, lp.clan_id, + 'Liječnički pregled istekao za ' || c.ime || ' ' || c.prezime || ' (klub: ' || COALESCE(k.naziv, 'N/A') || ')', lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lp.vrijedi_do < CURRENT_DATE AND c.aktivan""") + # Liječnički uskoro + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_uskoro', 'WARNING', c.klub_id, lp.clan_id, + 'Liječnički ističe za 30 dana: ' || c.ime || ' ' || c.prezime, lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE+30 AND c.aktivan""") + # Članarine dospjele + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum, iznos) + SELECT 'clanarina_dospjela', 'WARNING', cl.klub_id, cl.clan_id, + 'Nepodmirena članarina ' || cl.godina || ' za ' || c.ime || ' ' || c.prezime, NULL, (cl.iznos_propisan - cl.iznos_placen) + FROM pgz_sport.clanarine cl + JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE cl.status != 'podmireno' AND cl.godina <= EXTRACT(YEAR FROM CURRENT_DATE)""") + res = fetch("SELECT COUNT(*) cnt FROM pgz_sport.alertovi WHERE NOT rijeseno") + return {"alerts_generated": res[0]["cnt"]} + +@app.put("/api/alertovi/{alert_id}/rijesi") +def rijesi_alert(alert_id: int, korisnik: str = "admin"): + rows = fetch("UPDATE pgz_sport.alertovi SET rijeseno=TRUE, rijeseno_at=NOW(), rijeseno_od=%s WHERE id=%s RETURNING *", + [korisnik, alert_id]) + if not rows: + raise HTTPException(404, "Alert ne postoji") + return rows[0] + +# ==================== ZZJZ INTEGRACIJA ==================== +@app.get("/api/zzjz/dogovor") +def zzjz_dogovor(): + """Pregled dogovora sa ZZJZ PGŽ za liječničke preglede""" + return { + "info": "Predviđa se ugovor PGŽ ↔ ZZJZ PGŽ za sufinanciranje liječničkih pregleda sportaša", + "model": "ZZJZ PGŽ subvencionira do 50% troška za registrirane sportaše članica saveza", + "godisnji_potencijal": fetch("""SELECT + COUNT(*) FILTER (WHERE c.kategorija='registrirani') AS sportasa_potencijalno, + SUM(CASE WHEN c.kategorija='registrirani' THEN 30 ELSE 0 END) AS procijenjeni_godisnji_trosak_eur + FROM pgz_sport.clanovi c WHERE c.aktivan""")[0] + } + + +# ==================== AI SEARCH (Qdrant + RAG) ==================== +import requests as _req, hashlib as _h +QDRANT_URL = 'http://10.10.0.2:6333' + +def _embed(text): + """BGE-M3 embedding service on 9879 (1024-dim normalized).""" + try: + r = _req.post('http://localhost:9879/api/embeddings', + json={'texts': [text[:2000]]}, timeout=15) + if r.ok: + data = r.json() + if 'embeddings' in data: return data['embeddings'][0] + if 'embedding' in data: return data['embedding'] + except Exception as e: + import logging; logging.warning(f'BGE-M3 fail: {e}') + h = _h.sha256(text.encode()).digest() + return [(h[i % 32] / 255.0 - 0.5) for i in range(1024)] + +@app.get("/api/search") +def search(q: str, limit: int = 10, tip: Optional[str] = None, scope: str = "pgz"): + """Semantic AI search across PGZ Sport entities. + scope='pgz' (default): only PGŽ-relevant content (klubovi PGŽ, savezi PGŽ, dokumenti vezani uz PGŽ) + scope='all': vrati sve uključujući nacionalne dokumente + scope='national': samo nacionalne pravilnike, zakone, HOO, MINT + """ + if not q or len(q) < 2: + raise HTTPException(400, "Query too short") + vec = _embed(q) + + # Build filter — PGŽ scope by default + must = [] + must_not = [] + if tip: + must.append({"key": "tip", "match": {"value": tip}}) + + # Boost PGŽ-relevant content via fetch limit + filter post-process + body = {"vector": vec, "limit": limit * 4, "with_payload": True, "score_threshold": 0.35} + if must: + body["filter"] = {"must": must} + + try: + r = _req.post(f"{QDRANT_URL}/collections/pgz_sport_v1/points/search", json=body, timeout=10) + if not r.ok: raise HTTPException(500, f"Qdrant: {r.text[:200]}") + all_results = r.json()['result'] + except _req.exceptions.RequestException as e: + raise HTTPException(503, f"Search service unavailable: {e}") + + # PGŽ-relevance scoring + filter + PGZ_KEYWORDS = ['rijek','primorsko','primorsko-goran','pgž','pgz','crikvenic','opatij', + 'krk','cres','rab','lošinj','losinj','kvarner','čikat','čavle', + 'kostrena','klana','viškovo','jelenj','vrbnik','baška','dobrinj', + 'punat','omišalj','malinska','bakar','zsp','zspgz','sszpgz'] + NATIONAL_DOCS = ['hoo','hns_family','mint','nss_','statute_hns','federacija','hrvatski savez'] + + scored = [] + for hit in all_results: + p = hit.get('payload') or {} + # Combine all text fields for keyword check + all_text = ( + (p.get('naziv','') or '') + ' ' + + (p.get('title','') or '') + ' ' + + (p.get('text','') or '')[:500] + ' ' + + (p.get('source','') or '') + ' ' + + (p.get('grad','') or '') + ' ' + + (p.get('source_url','') or '') + ).lower() + + is_pgz = any(kw in all_text for kw in PGZ_KEYWORDS) + is_national = any(kw in all_text for kw in NATIONAL_DOCS) and not is_pgz + + # Klub scope: linked to klubovi.id which is by definition PGŽ + if p.get('tip') == 'klub' and p.get('klub_id'): is_pgz = True + # Savez PGŽ + if p.get('tip') == 'savez' and (p.get('razina') == 'zupanijski' or 'pgž' in (p.get('naziv','') or '').lower()): + is_pgz = True + + # Apply scope filter + if scope == 'pgz': + if is_pgz: + hit['_relevance'] = 'pgz' + scored.append(hit) + elif is_national and p.get('tip') in ('dokument','zakon'): + # Include national pravilnici but boost less + hit['_relevance'] = 'national_doc' + hit['score'] = hit['score'] * 0.7 + scored.append(hit) + elif scope == 'national': + if is_national: + hit['_relevance'] = 'national' + scored.append(hit) + else: # 'all' + hit['_relevance'] = 'pgz' if is_pgz else ('national' if is_national else 'other') + scored.append(hit) + + # Re-sort by adjusted score + scored.sort(key=lambda x: x.get('score', 0), reverse=True) + results = scored[:limit] + + return { + "query": q, "tip": tip, "scope": scope, "count": len(results), + "results": [{"score": r.get('score', 0), + "tip": (r.get('payload') or {}).get('tip'), + "naziv": (r.get('payload') or {}).get('naziv') or (r.get('payload') or {}).get('title'), + "klub_id": (r.get('payload') or {}).get('klub_id'), + "savez_id": (r.get('payload') or {}).get('savez_id'), + "tekst": (r.get('payload') or {}).get('tekst') or (r.get('payload') or {}).get('text','')[:300], + "url": (r.get('payload') or {}).get('source_url') or (r.get('payload') or {}).get('url'), + "relevance": r.get('_relevance', 'unknown'), + "payload": r.get('payload')} for r in results] + } + + +# ==================== GOOGLE OAUTH ==================== +import jwt as _jwt, secrets as _secrets +GOOGLE_CLIENT_ID = "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com" # postavi u .env +ADMIN_EMAILS = { + "damir@rinet.one", "dradulic@outlook.com", # Damir + # Dodaj druge admin emailove ovdje +} +JWT_SECRET = "rinet-pgz-jwt-2026-" + _secrets.token_hex(8) +JWT_ISSUED = [] # in-memory token store (može u Redis) + +@app.post("/api/auth/google") +def google_auth(token: str = Body(..., embed=True)): + """Verify Google ID token and issue JWT for admin/viewer role.""" + try: + import urllib.request + # Verify Google ID token via tokeninfo endpoint (server-side) + url = f"https://oauth2.googleapis.com/tokeninfo?id_token={token}" + with urllib.request.urlopen(url, timeout=10) as r: + data = json.loads(r.read()) + email = data.get("email", "").lower() + verified = data.get("email_verified") == "true" or data.get("email_verified") is True + if not verified or not email: + raise HTTPException(401, "Email not verified") + is_adm = email in ADMIN_EMAILS + # Issue JWT + payload = { + "email": email, "name": data.get("name", email), + "role": "admin" if is_adm else "viewer", + "iat": int(__import__("time").time()), + "exp": int(__import__("time").time()) + 86400 * 7 # 7 dana + } + jwt_token = _jwt.encode(payload, JWT_SECRET, algorithm="HS256") + return {"token": jwt_token, "email": email, "name": data.get("name", email), + "role": payload["role"], "expires_in": 86400 * 7} + except HTTPException: raise + except Exception as e: + raise HTTPException(401, f"Google auth failed: {e}") + +@app.get("/api/auth/me") +def auth_me(authorization: Optional[str] = Header(None)): + """Get current user info from JWT.""" + if not authorization: return {"role": "viewer", "email": None, "name": None} + token = authorization.replace("Bearer ", "").strip() + # Try JWT first + try: + payload = _jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return {"role": payload.get("role"), "email": payload.get("email"), "name": payload.get("name")} + except Exception: + pass + # Legacy demo token + if token == ADMIN_TOKEN: + return {"role": "admin", "email": "demo@admin", "name": "Demo Admin"} + return {"role": "viewer", "email": None, "name": None} + +# ==================== 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} + +@app.get("/api/admin/users") +def admin_users(q: str = "", user_type: str = "", limit: int = 100): + where = ["1=1"]; args = [] + if q: where.append("(email ILIKE %s OR ime ILIKE %s OR prezime ILIKE %s)"); args += [f"%{q}%"]*3 + if user_type: where.append("user_type = %s"); args.append(user_type) + args.append(limit) + with db() as conn: + cur = conn.cursor() + cur.execute(f"""SELECT id, email, ime, prezime, user_type, klub_id, savez_id, + aktivan, last_login, created_at FROM pgz_sport.users + WHERE {' AND '.join(where)} ORDER BY id LIMIT %s""", args) + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + results = [{**dict(zip(cols, r)), + 'last_login': str(dict(zip(cols, r))['last_login']) if dict(zip(cols, r))['last_login'] else None, + 'created_at': str(dict(zip(cols, r))['created_at'])} for r in rows] + return {"count": len(results), "results": results} + +@app.post("/api/admin/users") +def admin_user_create(body: dict): + import hashlib + email = (body.get("email") or "").strip().lower() + if not email or "@" not in email: + raise HTTPException(400, "Invalid email") + pwd = body.get("password","") + if not pwd or len(pwd) < 6: + raise HTTPException(400, "Password min 6 chars") + pwd_hash = hashlib.sha256(pwd.encode()).hexdigest() + with db() as conn: + cur = conn.cursor() + try: + cur.execute("""INSERT INTO pgz_sport.users + (email, password_hash, ime, prezime, user_type, klub_id, savez_id, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,%s,true) RETURNING id""", + (email, pwd_hash, body.get("ime"), body.get("prezime"), + body.get("user_type","klub_user"), body.get("klub_id"), body.get("savez_id"))) + new_id = cur.fetchone()[0] + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, target_text, payload) + VALUES ('user.create','sys_users',%s,%s,%s::jsonb)""", + (new_id, email, json.dumps({"user_type": body.get("user_type")}))) + conn.commit() + return {"id": new_id, "email": email} + except psycopg2.IntegrityError as e: + conn.rollback() + raise HTTPException(400, f"Email već postoji: {email}") + +@app.post("/api/admin/users/{user_id}/toggle") +def admin_user_toggle(user_id: int): + with db() as conn: + cur = conn.cursor() + cur.execute("UPDATE pgz_sport.users SET aktivan = NOT aktivan WHERE id=%s RETURNING aktivan", (user_id,)) + r = cur.fetchone() + if not r: raise HTTPException(404, "User not found") + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, payload) + VALUES ('user.toggle','sys_users',%s,%s::jsonb)""", (user_id, json.dumps({"aktivan": r[0]}))) + conn.commit() + return {"id": user_id, "aktivan": r[0]} + + +# ──────── 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}') + + + + + +@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("/api/sportski-objekti") +def list_sportski_objekti(q=None,tip=None,grad=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR adresa ILIKE %s OR grad ILIKE %s)"); p+=["%"+q+"%"]*3 + if tip: w.append("tip ILIKE %s"); p.append("%"+tip+"%") + if grad: w.append("grad ILIKE %s"); p.append("%"+grad+"%") + rows=fetch("SELECT * FROM pgz_sport.sportski_objekti WHERE "+" AND ".join(w)+" ORDER BY grad,naziv",p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/clanovi-full") +def list_clanovi_full(q=None,hoo=None,reprezentativac=None,klub_id=None,limit=80,authorization=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(ime ILIKE %s OR prezime ILIKE %s OR klub_naziv_godisnjak ILIKE %s)"); p+=["%"+q+"%"]*3 + if hoo: w.append("hoo_kategorija=%s"); p.append(hoo) + if reprezentativac is not None: w.append("reprezentativac="+(("TRUE") if str(reprezentativac).lower()=="true" else "FALSE")) + if klub_id: w.append("klub_id=%s"); p.append(int(klub_id)) + lim=min(int(limit or 80),200) + sql="SELECT id,ime,prezime,oib,datum_rodenja,spol,sport,pozicija,reprezentativac,kategoriziran,stipendiran,kategorija_hoo,hoo_kategorija,aktivan,klub_naziv_godisnjak,slika_url,profile_url,hns_igrac_id,visina_cm,tezina_kg,broj_dresa,uloga,godisnjak_godine,godisnjak_prvi,godisnjak_zadnji,napomena FROM pgz_sport.clanovi WHERE "+" AND ".join(w)+" ORDER BY prezime,ime LIMIT "+str(lim) + rows=fetch(sql,p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/gradovi") +def list_gradovi(): + rows=fetch("SELECT DISTINCT grad FROM pgz_sport.klubovi WHERE aktivan=TRUE AND grad IS NOT NULL AND grad<>'' AND grad NOT SIMILAR TO '[0-9]+%%' ORDER BY grad",[]) + return [r["grad"] for r in rows] + +@app.get("/api/manifestacije-full") +def list_manifestacije_full(q=None,razina=None): + w=["aktivna=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR mjesto ILIKE %s)"); p+=["%"+q+"%"]*2 + rows=fetch("SELECT id,naziv,mjesto,organizator,razina,broj_ucesnika,godina_od,spol_kategorija,napomena,source_url FROM pgz_sport.manifestacije WHERE "+" AND ".join(w)+" ORDER BY naziv",p) + return {"count":len(rows),"rows":rows} + + + +# ── SUFINANCIRANJE-ALL v1.0 dradulic@outlook.com 2026-05-04 +@app.get("/api/sufinanciranje") +def list_sufinanciranje(q=None, godina=None, razina=None, sport=None, limit=500): + w=["iznos_eur > 0"]; p=[] + if q: w.append("(LOWER(korisnik) LIKE %s OR LOWER(sport) LIKE %s)"); p+=[f"%{q.lower()}%"]*2 + if godina: w.append("godina=%s"); p.append(int(godina)) + if razina: w.append("razina ILIKE %s"); p.append(f"%{razina}%") + if sport: w.append("sport ILIKE %s"); p.append(f"%{sport}%") + sql=f"SELECT korisnik,sport,iznos_eur,vrsta,razina,izvor,source_url,godina FROM pgz_sport.sufinanciranje_sport WHERE {' AND '.join(w)} ORDER BY iznos_eur DESC LIMIT {min(int(limit),1000)}" + rows=fetch(sql,p) + total=sum(float(r.get('iznos_eur') or 0) for r in rows) + years=sorted(set(r.get('godina') for r in rows if r.get('godina')),reverse=True) + return {"count":len(rows),"total":total,"years":years,"rows":rows} + + + +# ══════════════════════════════════════════════════════════════════ +# ERP PLATFORM ROUTES v2.0 — dradulic@outlook.com — 2026-05-04 +# ══════════════════════════════════════════════════════════════════ + +import hashlib + +def hash_pwd(pwd): return hashlib.sha256(pwd.encode()).hexdigest() + +def get_user(token): + if not token: return None + try: + payload = _jwt.decode(token.replace("Bearer ",""), JWT_SECRET, algorithms=["HS256"]) + uid = payload.get("uid") + if uid: + rows = fetch("SELECT * FROM pgz_sport.users WHERE id=%s AND aktivan=TRUE", [uid]) + return rows[0] if rows else None + return payload + except: return None + +# ── AUTH: Email/Password login ────────────────────────────────── +@app.post("/api/auth/login") +def login(body: dict = Body(...)): + email = (body.get("email","")).lower().strip() + pwd = body.get("password","") + if not email or not pwd: raise HTTPException(400,"Email i lozinka obavezni") + rows = fetch("SELECT * FROM pgz_sport.users WHERE LOWER(email)=%s AND aktivan=TRUE",[email]) + if not rows: raise HTTPException(401,"Neispravni podaci") + u = rows[0] + ph = hashlib.sha256(pwd.encode()).hexdigest() + if u.get("password_hash") != ph: raise HTTPException(401,"Neispravni podaci") + payload = {"uid":u["id"],"email":email,"name":u.get("full_name",email), + "role":u.get("user_type","viewer"),"klub_id":u.get("klub_id"), + "savez_id":u.get("savez_id"),"iat":int(__import__("time").time()), + "exp":int(__import__("time").time())+86400*7} + tok = _jwt.encode(payload, JWT_SECRET, algorithm="HS256") + try: + with db() as conn: + cur=conn.cursor() + cur.execute("UPDATE pgz_sport.users SET last_login=NOW() WHERE id=%s",[u["id"]]) + conn.commit() + except: pass + return {"token":tok,"role":payload["role"],"name":payload["name"], + "email":email,"klub_id":payload["klub_id"],"savez_id":payload["savez_id"]} + +# ── 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.mount("/static", StaticFiles(directory=str(HTML_DIR)), name="static") + +@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) diff --git a/_backups/r3_cc5/pgz_sport_api.py.pre_m8.1777932387 b/_backups/r3_cc5/pgz_sport_api.py.pre_m8.1777932387 new file mode 100644 index 0000000..0c50b27 --- /dev/null +++ b/_backups/r3_cc5/pgz_sport_api.py.pre_m8.1777932387 @@ -0,0 +1,1668 @@ +#!/usr/bin/env python3 +""" +pgz_sport_api.py - FastAPI backend za PGŽ Sportski savez ERP/CRM +Author: Damir Radulić (damir@rinet.one) +Date: 25.04.2026 +Port: 8095 +Endpoints: savezi, klubovi, članovi, članarine, liječnički, manifestacije, proračun, dashboard, alertovi +""" + +from fastapi import FastAPI, HTTPException, Query, Body, Header, Depends, UploadFile, File, Form, Request +import json +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import Optional, List +from datetime import date, datetime +import psycopg2 +import psycopg2.extras +from pgz_sport_v2_router import router as v2_router +import os + +DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7') + + +ADMIN_TOKEN = 'admin-pgz-2026' + +def is_admin(authorization): + if not authorization: return False + token = authorization.replace('Bearer ', '').strip() + if token == ADMIN_TOKEN: return True + # Try JWT + try: + import jwt as _jwt + payload = _jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return payload.get("role") == "admin" + except Exception: + return False + +def blur_oib(v): + if not v: return v + s = str(v); + return s[:3] + '•'*(len(s)-5) + s[-2:] if len(s) >= 8 else '•'*len(s) +def blur_email(e): + if not e or '@' not in str(e): return e + u, d = str(e).split('@',1); return (u[:1]+'•••' if u else '')+'@'+d +def blur_phone(p): + if not p: return p + s=str(p); return s[:4]+'•'*(len(s)-7)+s[-3:] if len(s)>=7 else s +def blur_iban(v): + if not v: return v + s=str(v); return s[:4]+'•'*(len(s)-8)+s[-4:] if len(s)>=8 else s +def blur_date(d): + if not d: return d + s = str(d); return s[:4]+'-••-••' if len(s)>=4 else s +def blur_text(t, keep=3): + if not t: return t + s=str(t); return s[:keep]+'•'*(len(s)-keep*2)+s[-keep:] if len(s)>keep*2 else s + +def apply_privacy(rows, admin): + if admin: return rows + out = [] + for r in (rows if isinstance(rows, list) else [rows]): + rr = dict(r) + for k, v in list(rr.items()): + if v is None: continue + kl = k.lower() + if 'oib' in kl: rr[k] = blur_oib(v) + elif 'email' in kl: rr[k] = blur_email(v) + elif kl in ('telefon','tel','phone'): rr[k] = blur_phone(v) + elif kl == 'datum_rodenja': rr[k] = blur_date(v) + elif 'iban' in kl: rr[k] = blur_iban(v) + elif kl == 'adresa': rr[k] = blur_text(v, 3) + elif 'licenca_broj' in kl: rr[k] = blur_text(v, 2) + out.append(rr) + return out if isinstance(rows, list) else out[0] + +app = FastAPI(title="PGŽ Sportski savez ERP/CRM", version="1.0.0") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + + +# === URL rewrite middleware - convert direct external image URLs to /img-proxy === +import json as _json_mw +import re as _re_mw +from starlette.responses import Response as _StarletteResponse_mw + +_IMG_DOMAINS_RE = _re_mw.compile( + r'https?://(?:hns\.family|hns\.hr|hbs\.hr|hrvatski-bocarski-savez\.hr|' + r'rk-zamet\.hr|hvs\.hr|rezultati\.hvs\.hr|sport-pgz\.hr)' + r'/[^"\s\\]+\.(?:jpg|jpeg|png|gif|webp|svg)', + _re_mw.IGNORECASE +) + +def _rewrite_to_proxy(text: str) -> str: + """Replace external image URLs with /sport/api/v2/img-proxy?u=...""" + from urllib.parse import quote as _q + def _sub(m): + url = m.group(0) + return "/sport/api/v2/img-proxy?u=" + _q(url, safe='') + return _IMG_DOMAINS_RE.sub(_sub, text) + +@app.middleware("http") +async def url_rewrite_middleware(request, call_next): + response = await call_next(request) + # Only rewrite JSON API responses + ct = response.headers.get("content-type", "") + if "application/json" not in ct: + return response + # Only on /api/v2 routes (admin & data endpoints) - SKIP /api/v2/img-proxy itself + path = request.url.path + if "/api/v2/img-proxy" in path or "/api/v2/dokumenti" in path: + return response # don't rewrite raw document content + # Read body + body = b"" + async for chunk in response.body_iterator: + body += chunk + try: + text = body.decode("utf-8") + new_text = _rewrite_to_proxy(text) + new_body = new_text.encode("utf-8") + except Exception: + new_body = body + return _StarletteResponse_mw( + content=new_body, + status_code=response.status_code, + headers={k: v for k, v in response.headers.items() if k.lower() not in ("content-length",)}, + media_type=ct, + ) +# === end URL rewrite middleware === + +def db(): + conn = psycopg2.connect(**DB) + conn.autocommit = True + return conn + +def fetch(sql, params=None): + with db() as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as c: + c.execute(sql, params or ()) + return [dict(r) for r in c.fetchall()] + +def execute(sql, params=None): + with db() as conn: + with conn.cursor() as c: + c.execute(sql, params or ()) + return c.rowcount + +# ==================== HEALTH ==================== +@app.get("/health") +def health(): + try: + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + return {"status": "ok", "service": "pgz_sport", "dashboard": rows[0] if rows else None} + except Exception as e: + raise HTTPException(500, f"DB error: {e}") + + +@app.get("/api/whoami") +def whoami_v2(authorization: Optional[str] = Header(None)): + return {"role": "admin" if is_admin(authorization) else "viewer", "privacy_active": not is_admin(authorization)} + +# ==================== DASHBOARD ==================== +@app.get("/api/dashboard") +def dashboard(): + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + if not rows: + return {} + d = rows[0] + # Top savezi by registriranih 2024 + top = fetch("""SELECT s.naziv, st.klubova_clanica, st.registriranih, st.trenera, st.reprezentativaca + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina=2024 ORDER BY st.registriranih DESC LIMIT 10""") + proracun_trend = fetch("SELECT godina, ukupno FROM pgz_sport.proracun ORDER BY godina") + nositelji = fetch("""SELECT naziv_kluba, godina, iznos FROM pgz_sport.potpore_nositelji + WHERE godina = 2025 ORDER BY iznos DESC LIMIT 10""") + return {**d, "top_savezi": top, "proracun_trend": proracun_trend, "nositelji_2025": nositelji} + +@app.get("/api/dashboard/ekosustav") +def dashboard_ekosustav(): + """Sport ekosustav PGŽ — coverage stats za enrichment iz FINA registra.""" + summary = fetch("""SELECT + COUNT(*) AS klubova_total, + COUNT(*) FILTER (WHERE oib IS NOT NULL) AS s_oib, + COUNT(*) FILTER (WHERE predsjednik IS NOT NULL) AS s_predsjednik, + COUNT(*) FILTER (WHERE tajnik IS NOT NULL) AS s_tajnik, + COUNT(*) FILTER (WHERE ciljevi IS NOT NULL) AS s_ciljevi, + COUNT(*) FILTER (WHERE opis_djelatnosti IS NOT NULL) AS s_opis, + COUNT(*) FILTER (WHERE sjediste IS NOT NULL) AS s_sjediste, + COUNT(*) FILTER (WHERE email IS NOT NULL) AS s_email, + COUNT(*) FILTER (WHERE web_stranica IS NOT NULL) AS s_web, + COUNT(*) FILTER (WHERE udruga_status = \'AKTIVAN\') AS s_aktivan_reg, + COUNT(*) FILTER (WHERE savez_id IS NOT NULL) AS s_savez, + COUNT(*) FILTER (WHERE nositelj_kvalitete) AS s_nositelj + FROM pgz_sport.klubovi WHERE aktivan""")[0] + + by_sport = fetch("""SELECT sport, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND sport IS NOT NULL + GROUP BY sport ORDER BY COUNT(*) DESC LIMIT 15""") + + by_region = fetch("""SELECT region, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND region IS NOT NULL + GROUP BY region ORDER BY COUNT(*) DESC""") + + by_grad = fetch("""SELECT grad, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND grad IS NOT NULL + GROUP BY grad ORDER BY COUNT(*) DESC LIMIT 12""") + + decade = fetch("""SELECT + CASE + WHEN godina_osnutka < 1950 THEN \'pred1950\' + WHEN godina_osnutka < 1980 THEN \'1950-1979\' + WHEN godina_osnutka < 2000 THEN \'1980-1999\' + WHEN godina_osnutka < 2010 THEN \'2000-2009\' + WHEN godina_osnutka >= 2010 THEN \'2010-danas\' + ELSE \'nepoznato\' + END AS razdoblje, + COUNT(*) AS broj + FROM pgz_sport.klubovi + WHERE aktivan AND godina_osnutka IS NOT NULL + GROUP BY razdoblje ORDER BY razdoblje""") + + # Pokazi enrichment % + total = summary["klubova_total"] or 1 + coverage = { + "oib_pct": round(100 * summary["s_oib"] / total, 1), + "predsjednik_pct": round(100 * summary["s_predsjednik"] / total, 1), + "tajnik_pct": round(100 * summary["s_tajnik"] / total, 1), + "ciljevi_pct": round(100 * summary["s_ciljevi"] / total, 1), + "opis_pct": round(100 * summary["s_opis"] / total, 1), + "sjediste_pct": round(100 * summary["s_sjediste"] / total, 1), + "email_pct": round(100 * summary["s_email"] / total, 1), + "savez_pct": round(100 * summary["s_savez"] / total, 1), + } + + return {**summary, "coverage": coverage, "by_sport": by_sport, + "by_region": by_region, "by_grad": by_grad, "by_decade": decade} + + + +# ==================== ANALYTICS ==================== +@app.get("/api/analytics/savezi-trend") +def savezi_trend(godine: str = "2020,2021,2022,2023,2024", metric: str = "registriranih"): + valid_metrics = {"registriranih", "neregistriranih", "rekreativaca", "trenera", "reprezentativaca", + "kategoriziranih", "stipendiranih", "klubova_clanica"} + if metric not in valid_metrics: + raise HTTPException(400, f"Invalid metric. Must be one of: {valid_metrics}") + god_list = [int(g) for g in godine.split(",")] + rows = fetch(f"""SELECT s.naziv AS savez, st.godina, st.{metric} AS value + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina = ANY(%s) ORDER BY s.naziv, st.godina""", [god_list]) + saveze = {} + for r in rows: + if r['savez'] not in saveze: saveze[r['savez']] = {} + saveze[r['savez']][r['godina']] = r['value'] + return {"metric": metric, "godine": god_list, "data": saveze} + +@app.get("/api/analytics/proracun-detaljno") +def proracun_detaljno(): + p = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + if not p: return {"proracun": [], "rast_godisnji": [], "current_year": None, "current_total": 0, "rast_dekada_pct": 0} + cagr = [] + for i in range(1, len(p)): + prev = float(p[i-1]['ukupno']) if p[i-1]['ukupno'] else 0 + curr = float(p[i]['ukupno']) if p[i]['ukupno'] else 0 + rate = ((curr/prev - 1) * 100) if prev > 0 else 0 + cagr.append({"godina": p[i]['godina'], "rast_postotak": round(rate, 1)}) + decade_rast = round((float(p[-1]['ukupno'])/float(p[0]['ukupno']) - 1) * 100, 1) if p[0]['ukupno'] else 0 + return {"proracun": p, "rast_godisnji": cagr, "rast_dekada_pct": decade_rast, + "current_year": int(p[-1]['godina']), "current_total": float(p[-1]['ukupno'])} + +@app.get("/api/analytics/klub-financije") +def klub_financije(klub_id: Optional[int] = None, godina: Optional[int] = None): + where = [] + params = [] + if godina: where.append("p.godina=%s"); params.append(godina) + if klub_id: + where.append("(p.klub_id=%s OR p.naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))") + params.extend([klub_id, klub_id]) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"""SELECT p.naziv_kluba, p.godina, p.iznos, + k.id AS klub_id, k.sport, k.razina, k.nositelj_kvalitete + FROM pgz_sport.potpore_nositelji p + LEFT JOIN pgz_sport.klubovi k ON p.klub_id=k.id OR p.naziv_kluba=k.naziv + {where_sql} ORDER BY p.godina DESC, p.iznos DESC""", params) + summary = fetch(f"""SELECT godina, SUM(iznos) AS total, COUNT(*) AS klubova, AVG(iznos) AS prosjek + FROM pgz_sport.potpore_nositelji p {where_sql} + GROUP BY godina ORDER BY godina""", params) + return {"data": rows, "summary": summary} + +@app.get("/api/analytics/lijecnicki-stats") +def lijecnicki_stats(klub_id: Optional[int] = None): + where = ["1=1"]; params = [] + if klub_id: where.append("c.klub_id=%s"); params.append(klub_id) + where_sql = " AND ".join(where) + rows = fetch(f"""SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE lp.vrijedi_do >= CURRENT_DATE + 30) AS validni, + COUNT(*) FILTER (WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + 30) AS uskoro, + COUNT(*) FILTER (WHERE lp.vrijedi_do < CURRENT_DATE) AS istekli, + SUM(lp.iznos) AS ukupan_trosak, SUM(lp.iznos_zzjz) AS zzjz_udio, + SUM(lp.iznos_klub) AS klub_udio, SUM(lp.iznos_clan) AS clan_udio, + AVG(lp.iznos) AS prosjecni_trosak + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id WHERE {where_sql}""", params) + by_ustanova = fetch(f"""SELECT lp.ustanova, COUNT(*) cnt, SUM(lp.iznos) iznos + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} GROUP BY lp.ustanova ORDER BY cnt DESC""", params) + by_lijecnik = fetch(f"""SELECT lp.lijecnik, COUNT(*) cnt, AVG(lp.iznos) prosjek + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} AND lp.lijecnik IS NOT NULL GROUP BY lp.lijecnik ORDER BY cnt DESC""", params) + return {"summary": rows[0] if rows else {}, "by_ustanova": by_ustanova, "by_lijecnik": by_lijecnik} + +# ==================== SAVEZI ==================== +@app.get("/api/savezi") +def list_savezi(authorization: Optional[str] = Header(None), q: Optional[str] = None, + razina: Optional[str] = None, zupanija: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = "WHERE aktivan" + params = [] + if q: + where += " AND (naziv ILIKE %s OR sport ILIKE %s)" + params = [f"%{q}%", f"%{q}%"] + if razina: + where += " AND razina = %s"; params.append(razina) + if zupanija: + where += " AND sjediste_zupanija ILIKE %s"; params.append(f"%{zupanija}%") + sort_col = {"naziv": "naziv", "godina": "godina_osnutka", "sport": "sport", "razina": "razina"}.get(sort, "naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + # Croatian collation for text columns (Š → after S, Č → after C, etc.) + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("naziv", "sport") else "" + rows = fetch(f"""SELECT s.*, + (SELECT COUNT(*) FROM pgz_sport.klubovi WHERE savez_id=s.id) AS broj_klubova, + (SELECT registriranih FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS reg_2024, + (SELECT trenera FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS treneri_2024, + (SELECT reprezentativaca FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS repr_2024 + FROM pgz_sport.savezi s {where} ORDER BY {sort_col}{collate} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/savezi/{savez_id}") +def get_savez(savez_id: int): + rows = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s", [savez_id]) + if not rows: + raise HTTPException(404, "Savez ne postoji") + klubovi = fetch("SELECT * FROM pgz_sport.klubovi WHERE savez_id=%s ORDER BY naziv", [savez_id]) + statistika = fetch("SELECT * FROM pgz_sport.statistika_saveza WHERE savez_id=%s ORDER BY godina", [savez_id]) + manifestacije = fetch("SELECT * FROM pgz_sport.manifestacije WHERE savez_id=%s", [savez_id]) + return {**rows[0], "klubovi": klubovi, "statistika": statistika, "manifestacije": manifestacije} + +# ==================== KLUBOVI ==================== +@app.get("/api/klubovi") +def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, savez_id: Optional[int] = None, + nositelj: Optional[bool] = None, region: Optional[str] = None, sport: Optional[str] = None, grad: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivan"] + params = [] + if q: + where.append("(klub ILIKE %s OR oib ILIKE %s OR sport ILIKE %s OR predsjednik ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%", f"%{q}%"]) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + if nositelj is not None: + where.append(f"nositelj_kvalitete={'TRUE' if nositelj else 'FALSE'}") + if region: + where.append("region ILIKE %s"); params.append(region) + if grad: + where.append("grad ILIKE %s"); params.append(f"%{grad}%") + if sport: + where.append("sport ILIKE %s"); params.append(f"%{sport}%") + sort_col = {"naziv": "klub", "savez": "savez", "broj_clanova": "broj_clanova", + "razina": "razina", "region": "region", "grad": "grad", "sport": "sport"}.get(sort, "klub") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("klub", "savez", "razina", "region", "grad", "sport") else "" + rows = fetch(f"""SELECT * FROM pgz_sport.v_klubovi_pregled WHERE {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST""", params) + for r in rows: + if isinstance(r, dict) and r.get('klub') and not r.get('naziv'): + r['naziv'] = r['klub'] + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/klubovi/{klub_id}") +def get_klub(klub_id: int, authorization: Optional[str] = Header(None)): + admin = is_admin(authorization) + rows = fetch("""SELECT k.*, s.naziv AS savez_naziv FROM pgz_sport.klubovi k + LEFT JOIN pgz_sport.savezi s ON s.id=k.savez_id WHERE k.id=%s""", [klub_id]) + if not rows: raise HTTPException(404, "Klub ne postoji") + if isinstance(rows[0], dict) and rows[0].get('klub') and not rows[0].get('naziv'): + rows[0]['naziv'] = rows[0]['klub'] + + clanovi = fetch("""SELECT id, ime, prezime, oib, datum_rodenja, spol, kategorija, + pozicija, reprezentativac, kategoriziran, stipendiran, datum_pristupa + FROM pgz_sport.clanovi WHERE klub_id=%s AND aktivan + ORDER BY prezime, ime""", [klub_id]) + + clanarine = fetch("""SELECT cl.id, cl.godina, cl.razdoblje, cl.iznos_propisan, cl.iznos_placen, + (cl.iznos_propisan - cl.iznos_placen) AS dug, cl.datum_uplate, cl.status, cl.napomena, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib + FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE c.klub_id=%s ORDER BY cl.godina DESC, cl.id DESC""", [klub_id]) + + lijecnicki = fetch("""SELECT lp.id, lp.datum_pregleda, lp.vrijedi_do, lp.vrsta_pregleda, + lp.ustanova, lp.lijecnik, lp.spreman_za_natjecanje, lp.iznos, lp.iznos_zzjz, lp.iznos_klub, lp.iznos_clan, + lp.placeno, lp.komentar_lijecnika, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib, + CASE WHEN lp.vrijedi_do IS NULL THEN 'Nepoznato' + WHEN lp.vrijedi_do < CURRENT_DATE THEN 'Istekao' + WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'Ističe uskoro' + ELSE 'Validan' END AS status_pregled + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s ORDER BY lp.datum_pregleda DESC""", [klub_id]) + + potpore = fetch("""SELECT * FROM pgz_sport.potpore_nositelji + WHERE klub_id=%s OR naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s) + ORDER BY godina DESC""", [klub_id, klub_id]) + + # Aggregate stats + stats = { + 'broj_clanova': len(clanovi), + 'broj_registriranih': sum(1 for c in clanovi if c.get('kategorija')=='registrirani'), + 'broj_trenera': sum(1 for c in clanovi if c.get('kategorija')=='trener'), + 'broj_reprezentativaca': sum(1 for c in clanovi if c.get('reprezentativac')), + 'broj_kategoriziranih': sum(1 for c in clanovi if c.get('kategoriziran')), + 'broj_stipendiranih': sum(1 for c in clanovi if c.get('stipendiran')), + 'lijecnicki_validni': sum(1 for l in lijecnicki if l.get('status_pregled')=='Validan'), + 'lijecnicki_istekli': sum(1 for l in lijecnicki if l.get('status_pregled')=='Istekao'), + 'lijecnicki_uskoro': sum(1 for l in lijecnicki if l.get('status_pregled')=='Ističe uskoro'), + 'clanarina_naplaceno_god': sum(float(c.get('iznos_placen') or 0) for c in clanarine if c.get('godina')==2026), + 'clanarina_dug_god': sum(float(c.get('dug') or 0) for c in clanarine if c.get('godina')==2026), + 'potpore_2025': float(next((p['iznos'] for p in potpore if p.get('godina')==2025), 0) or 0), + 'potpore_total': sum(float(p.get('iznos') or 0) for p in potpore), + 'zzjz_isplaceno': sum(float(l.get('iznos_zzjz') or 0) for l in lijecnicki if l.get('placeno')), + } + + klub = rows[0] + if not admin: + klub = apply_privacy(klub, admin) + clanovi = apply_privacy(clanovi, admin) + clanarine = apply_privacy(clanarine, admin) + lijecnicki = apply_privacy(lijecnicki, admin) + + return {**klub, "clanovi": clanovi, "clanarine": clanarine, "lijecnicki": lijecnicki, + "potpore": potpore, "stats": stats} + + +class KlubIn(BaseModel): + naziv: str + savez_id: Optional[int] = None + sport: Optional[str] = None + oib: Optional[str] = None + razina: Optional[str] = None + nositelj_kvalitete: Optional[bool] = False + grad: Optional[str] = None + region: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + predsjednik: Optional[str] = None + iban: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/klubovi") +def create_klub(k: KlubIn): + rows = fetch("""INSERT INTO pgz_sport.klubovi (naziv, savez_id, sport, oib, razina, nositelj_kvalitete, grad, region, email, telefon, predsjednik, iban, napomena, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE) RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena]) + return rows[0] + +@app.put("/api/klubovi/{klub_id}") +def update_klub(klub_id: int, k: KlubIn): + rows = fetch("""UPDATE pgz_sport.klubovi SET naziv=%s, savez_id=%s, sport=%s, oib=%s, razina=%s, + nositelj_kvalitete=%s, grad=%s, region=%s, email=%s, telefon=%s, predsjednik=%s, iban=%s, napomena=%s, + updated_at=NOW() WHERE id=%s RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena, klub_id]) + if not rows: + raise HTTPException(404, "Klub ne postoji") + return rows[0] + +# ==================== ČLANOVI ==================== +@app.get("/api/clanovi") +def list_clanovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, klub_id: Optional[int] = None, + kategorija: Optional[str] = None, spol: Optional[str] = None, sort: str = "prezime", order: str = "asc"): + where = ["c.aktivan"] + params = [] + if q: + where.append("(c.ime ILIKE %s OR c.prezime ILIKE %s OR c.oib ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%"]) + if klub_id: + where.append("c.klub_id=%s"); params.append(klub_id) + if kategorija: + where.append("c.kategorija=%s"); params.append(kategorija) + if spol: + # Normalize: Z → Ž, F → Ž (legacy) + spol_norm = "Ž" if spol.upper() in ("Z","Ž","F","W") else "M" if spol.upper() in ("M",) else spol + where.append("c.spol=%s"); params.append(spol_norm) + sort_map = {"prezime": "c.prezime", "ime": "c.ime", "oib": "c.oib", "datum_rodenja": "c.datum_rodenja", "kategorija": "c.kategorija", "klub": "k.naziv"} + sort_col = sort_map.get(sort, "c.prezime") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT c.*, k.naziv AS klub_naziv, + (SELECT MAX(vrijedi_do) FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=c.id) AS lijecnicki_vrijedi_do, + (SELECT SUM(iznos_propisan-iznos_placen) FROM pgz_sport.clanarine WHERE clan_id=c.id AND status!='podmireno') AS dug_clanarine + FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE {where_sql} ORDER BY {sort_col} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +class ClanIn(BaseModel): + klub_id: int + ime: str + prezime: str + oib: Optional[str] = None + datum_rodenja: Optional[date] = None + spol: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + kategorija: Optional[str] = "registrirani" + pozicija: Optional[str] = None + licenca_broj: Optional[str] = None + licenca_vrijedi_do: Optional[date] = None + reprezentativac: Optional[bool] = False + kategoriziran: Optional[bool] = False + stipendiran: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/clanovi") +def create_clan(c: ClanIn): + rows = fetch("""INSERT INTO pgz_sport.clanovi (klub_id, ime, prezime, oib, datum_rodenja, spol, email, telefon, kategorija, pozicija, licenca_broj, licenca_vrijedi_do, reprezentativac, kategoriziran, stipendiran, napomena, aktivan, datum_pristupa) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE,CURRENT_DATE) RETURNING *""", + [c.klub_id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol, c.email, c.telefon, c.kategorija, c.pozicija, c.licenca_broj, c.licenca_vrijedi_do, c.reprezentativac, c.kategoriziran, c.stipendiran, c.napomena]) + return rows[0] + +@app.get("/api/clanovi/{clan_id}") +def get_clan(clan_id: int): + rows = fetch("""SELECT c.*, k.naziv AS klub_naziv FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id]) + if not rows: + raise HTTPException(404, "Član ne postoji") + clanarine = fetch("SELECT * FROM pgz_sport.clanarine WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + lijecnicki = fetch("SELECT * FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=%s ORDER BY datum_pregleda DESC", [clan_id]) + return {**rows[0], "clanarine": clanarine, "lijecnicki": lijecnicki} + +# ==================== ČLANARINE ==================== +@app.get("/api/clanarine") +def list_clanarine(godina: Optional[int] = None, status: Optional[str] = None, + klub_id: Optional[int] = None, sort: str = "godina", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + if status: + where.append("status=%s"); params.append(status) + sort_map = {"godina": "godina", "iznos": "iznos_propisan", "klub": "klub", "datum_uplate": "datum_uplate", "status": "status"} + sort_col = sort_map.get(sort, "godina") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_clanarine_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos_propisan) AS total_propisan, + SUM(iznos_placen) AS total_placen, + SUM(iznos_propisan - iznos_placen) AS total_dug + FROM pgz_sport.v_clanarine_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class ClanarinaIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + godina: int + razdoblje: Optional[str] = "godišnja" + iznos_propisan: float + iznos_placen: Optional[float] = 0 + datum_uplate: Optional[date] = None + nacin_uplate: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/clanarine") +def create_clanarina(c: ClanarinaIn): + status = "podmireno" if c.iznos_placen >= c.iznos_propisan else ("djelomicno" if c.iznos_placen > 0 else "nepodmireno") + klub_id = c.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [c.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.clanarine (clan_id, klub_id, godina, razdoblje, iznos_propisan, iznos_placen, datum_uplate, nacin_uplate, status, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [c.clan_id, klub_id, c.godina, c.razdoblje, c.iznos_propisan, c.iznos_placen, c.datum_uplate, c.nacin_uplate, status, c.napomena]) + return rows[0] + +# ==================== LIJEČNIČKI ==================== +@app.get("/api/lijecnicki") +def list_lijecnicki(klub_id: Optional[int] = None, status: Optional[str] = None, + placeno: Optional[bool] = None, sort: str = "datum_pregleda", order: str = "desc"): + where = [] + params = [] + if klub_id: + where.append("(klub_oib IS NOT NULL AND klub=ANY(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))"); params.append(klub_id) + if status: + where.append("status_pregled=%s"); params.append(status) + if placeno is not None: + where.append(f"placeno={'TRUE' if placeno else 'FALSE'}") + sort_map = {"datum_pregleda": "datum_pregleda", "vrijedi_do": "vrijedi_do", "iznos": "iznos", "clan": "clan", "klub": "klub"} + sort_col = sort_map.get(sort, "datum_pregleda") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_lijecnicki_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos) AS total_iznos, + SUM(iznos_zzjz) AS total_zzjz, + SUM(iznos_klub) AS total_klub, + SUM(iznos_clan) AS total_clan, + COUNT(*) FILTER (WHERE status_pregled='Istekao') AS istekli, + COUNT(*) FILTER (WHERE status_pregled='Ističe uskoro') AS uskoro + FROM pgz_sport.v_lijecnicki_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class LijecnickiIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + datum_pregleda: date + vrijedi_do: Optional[date] = None + vrsta_pregleda: Optional[str] = "temeljni" + ustanova: Optional[str] = "ZZJZ PGŽ" + lijecnik: Optional[str] = None + spreman_za_natjecanje: Optional[bool] = True + ekg: Optional[bool] = False + krv: Optional[bool] = False + spirometrija: Optional[bool] = False + nalaz: Optional[str] = None + komentar_lijecnika: Optional[str] = None + preporuke: Optional[str] = None + iznos: Optional[float] = 0 + iznos_zzjz: Optional[float] = 0 + iznos_klub: Optional[float] = 0 + iznos_clan: Optional[float] = 0 + datum_placanja: Optional[date] = None + placeno: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/lijecnicki") +def create_lijecnicki(l: LijecnickiIn): + klub_id = l.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [l.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.lijecnicki_pregledi (clan_id, klub_id, datum_pregleda, vrijedi_do, vrsta_pregleda, ustanova, lijecnik, spreman_za_natjecanje, ekg, krv, spirometrija, nalaz, komentar_lijecnika, preporuke, iznos, iznos_zzjz, iznos_klub, iznos_clan, datum_placanja, placeno, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [l.clan_id, klub_id, l.datum_pregleda, l.vrijedi_do, l.vrsta_pregleda, l.ustanova, l.lijecnik, l.spreman_za_natjecanje, l.ekg, l.krv, l.spirometrija, l.nalaz, l.komentar_lijecnika, l.preporuke, l.iznos, l.iznos_zzjz, l.iznos_klub, l.iznos_clan, l.datum_placanja, l.placeno, l.napomena]) + return rows[0] + +# ==================== PRORAČUN ==================== +@app.get("/api/proracun") +def list_proracun(): + rows = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + return {"count": len(rows), "rows": rows} + +# ==================== POTPORE NOSITELJI ==================== +@app.get("/api/potpore") +def list_potpore(godina: Optional[int] = None, sort: str = "iznos", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + sort_col = {"iznos": "iznos", "godina": "godina", "klub": "naziv_kluba"}.get(sort, "iznos") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.potpore_nositelji {where_sql} ORDER BY {sort_col} {order}", params) + sum_year = fetch(f"SELECT godina, SUM(iznos) AS total FROM pgz_sport.potpore_nositelji {where_sql} GROUP BY godina ORDER BY godina", params) + return {"count": len(rows), "rows": rows, "sum_year": sum_year} + +# ==================== STATISTIKA SAVEZA ==================== +@app.get("/api/statistika") +def list_statistika(godina: Optional[int] = None, q: Optional[str] = None, razina: Optional[str] = None, + sort: str = "registriranih", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("st.godina=%s"); params.append(godina) + if q: + where.append("s.naziv ILIKE %s"); params.append(f"%{q}%") + if razina: + where.append("s.razina = %s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + # Map sort key → unambiguous column expression + sort_map = { + "registriranih": "st.registriranih", + "klubova": "st.klubova_clanica", + "trenera": "st.trenera", + "reprezentativaca":"st.reprezentativaca", + "neregistriranih": "st.neregistriranih", + "rekreativaca": "st.rekreativaca", + "godina": "st.godina", + "savez": "s.naziv", + "naziv": "s.naziv", + } + sort_col = sort_map.get(sort, "st.registriranih") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + use_collate = sort_col in ("s.naziv", "s.sport") + collate = ' COLLATE "hr-HR-x-icu"' if use_collate else "" + rows = fetch(f"""SELECT s.naziv AS savez, s.razina AS savez_razina, s.sport AS sport, st.* + FROM pgz_sport.statistika_saveza st + JOIN pgz_sport.savezi s ON s.id=st.savez_id {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST, s.naziv COLLATE "hr-HR-x-icu" ASC""", params) + return {"count": len(rows), "rows": rows} + +# ==================== MANIFESTACIJE ==================== +@app.get("/api/manifestacije") +def list_manifestacije(razina: Optional[str] = None, savez_id: Optional[int] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivna"] + params = [] + if razina: + where.append("razina=%s"); params.append(razina) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + sort_col = {"naziv": "m.naziv", "razina": "m.razina", "godina_od": "m.godina_od", "mjesto": "m.mjesto"}.get(sort, "m.naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT m.*, s.naziv AS savez_naziv FROM pgz_sport.manifestacije m + LEFT JOIN pgz_sport.savezi s ON s.id=m.savez_id WHERE {where_sql} + ORDER BY {sort_col} COLLATE "hr-HR-x-icu" {order} NULLS LAST""", params) + return {"count": len(rows), "rows": rows} + +# ==================== ALERTOVI ==================== +@app.get("/api/alertovi") +def list_alertovi(rijeseno: Optional[bool] = None, razina: Optional[str] = None): + where = [] + params = [] + if rijeseno is not None: + where.append(f"rijeseno={'TRUE' if rijeseno else 'FALSE'}") + if razina: + where.append("razina=%s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.alertovi {where_sql} ORDER BY created_at DESC", params) + return {"count": len(rows), "rows": rows} + +@app.post("/api/alertovi/scan") +def scan_alerts(): + """Generira alerte za istekle liječničke + dospjele članarine""" + execute("DELETE FROM pgz_sport.alertovi WHERE NOT rijeseno AND tip IN ('lijecnicki_isteka', 'lijecnicki_uskoro', 'clanarina_dospjela')") + # Liječnički istekao + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_isteka', 'CRITICAL', c.klub_id, lp.clan_id, + 'Liječnički pregled istekao za ' || c.ime || ' ' || c.prezime || ' (klub: ' || COALESCE(k.naziv, 'N/A') || ')', lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lp.vrijedi_do < CURRENT_DATE AND c.aktivan""") + # Liječnički uskoro + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_uskoro', 'WARNING', c.klub_id, lp.clan_id, + 'Liječnički ističe za 30 dana: ' || c.ime || ' ' || c.prezime, lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE+30 AND c.aktivan""") + # Članarine dospjele + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum, iznos) + SELECT 'clanarina_dospjela', 'WARNING', cl.klub_id, cl.clan_id, + 'Nepodmirena članarina ' || cl.godina || ' za ' || c.ime || ' ' || c.prezime, NULL, (cl.iznos_propisan - cl.iznos_placen) + FROM pgz_sport.clanarine cl + JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE cl.status != 'podmireno' AND cl.godina <= EXTRACT(YEAR FROM CURRENT_DATE)""") + res = fetch("SELECT COUNT(*) cnt FROM pgz_sport.alertovi WHERE NOT rijeseno") + return {"alerts_generated": res[0]["cnt"]} + +@app.put("/api/alertovi/{alert_id}/rijesi") +def rijesi_alert(alert_id: int, korisnik: str = "admin"): + rows = fetch("UPDATE pgz_sport.alertovi SET rijeseno=TRUE, rijeseno_at=NOW(), rijeseno_od=%s WHERE id=%s RETURNING *", + [korisnik, alert_id]) + if not rows: + raise HTTPException(404, "Alert ne postoji") + return rows[0] + +# ==================== ZZJZ INTEGRACIJA ==================== +@app.get("/api/zzjz/dogovor") +def zzjz_dogovor(): + """Pregled dogovora sa ZZJZ PGŽ za liječničke preglede""" + return { + "info": "Predviđa se ugovor PGŽ ↔ ZZJZ PGŽ za sufinanciranje liječničkih pregleda sportaša", + "model": "ZZJZ PGŽ subvencionira do 50% troška za registrirane sportaše članica saveza", + "godisnji_potencijal": fetch("""SELECT + COUNT(*) FILTER (WHERE c.kategorija='registrirani') AS sportasa_potencijalno, + SUM(CASE WHEN c.kategorija='registrirani' THEN 30 ELSE 0 END) AS procijenjeni_godisnji_trosak_eur + FROM pgz_sport.clanovi c WHERE c.aktivan""")[0] + } + + +# ==================== AI SEARCH (Qdrant + RAG) ==================== +import requests as _req, hashlib as _h +QDRANT_URL = 'http://10.10.0.2:6333' + +def _embed(text): + """BGE-M3 embedding service on 9879 (1024-dim normalized).""" + try: + r = _req.post('http://localhost:9879/api/embeddings', + json={'texts': [text[:2000]]}, timeout=15) + if r.ok: + data = r.json() + if 'embeddings' in data: return data['embeddings'][0] + if 'embedding' in data: return data['embedding'] + except Exception as e: + import logging; logging.warning(f'BGE-M3 fail: {e}') + h = _h.sha256(text.encode()).digest() + return [(h[i % 32] / 255.0 - 0.5) for i in range(1024)] + +@app.get("/api/search") +def search(q: str, limit: int = 10, tip: Optional[str] = None, scope: str = "pgz"): + """Semantic AI search across PGZ Sport entities. + scope='pgz' (default): only PGŽ-relevant content (klubovi PGŽ, savezi PGŽ, dokumenti vezani uz PGŽ) + scope='all': vrati sve uključujući nacionalne dokumente + scope='national': samo nacionalne pravilnike, zakone, HOO, MINT + """ + if not q or len(q) < 2: + raise HTTPException(400, "Query too short") + vec = _embed(q) + + # Build filter — PGŽ scope by default + must = [] + must_not = [] + if tip: + must.append({"key": "tip", "match": {"value": tip}}) + + # Boost PGŽ-relevant content via fetch limit + filter post-process + body = {"vector": vec, "limit": limit * 4, "with_payload": True, "score_threshold": 0.35} + if must: + body["filter"] = {"must": must} + + try: + r = _req.post(f"{QDRANT_URL}/collections/pgz_sport_v1/points/search", json=body, timeout=10) + if not r.ok: raise HTTPException(500, f"Qdrant: {r.text[:200]}") + all_results = r.json()['result'] + except _req.exceptions.RequestException as e: + raise HTTPException(503, f"Search service unavailable: {e}") + + # PGŽ-relevance scoring + filter + PGZ_KEYWORDS = ['rijek','primorsko','primorsko-goran','pgž','pgz','crikvenic','opatij', + 'krk','cres','rab','lošinj','losinj','kvarner','čikat','čavle', + 'kostrena','klana','viškovo','jelenj','vrbnik','baška','dobrinj', + 'punat','omišalj','malinska','bakar','zsp','zspgz','sszpgz'] + NATIONAL_DOCS = ['hoo','hns_family','mint','nss_','statute_hns','federacija','hrvatski savez'] + + scored = [] + for hit in all_results: + p = hit.get('payload') or {} + # Combine all text fields for keyword check + all_text = ( + (p.get('naziv','') or '') + ' ' + + (p.get('title','') or '') + ' ' + + (p.get('text','') or '')[:500] + ' ' + + (p.get('source','') or '') + ' ' + + (p.get('grad','') or '') + ' ' + + (p.get('source_url','') or '') + ).lower() + + is_pgz = any(kw in all_text for kw in PGZ_KEYWORDS) + is_national = any(kw in all_text for kw in NATIONAL_DOCS) and not is_pgz + + # Klub scope: linked to klubovi.id which is by definition PGŽ + if p.get('tip') == 'klub' and p.get('klub_id'): is_pgz = True + # Savez PGŽ + if p.get('tip') == 'savez' and (p.get('razina') == 'zupanijski' or 'pgž' in (p.get('naziv','') or '').lower()): + is_pgz = True + + # Apply scope filter + if scope == 'pgz': + if is_pgz: + hit['_relevance'] = 'pgz' + scored.append(hit) + elif is_national and p.get('tip') in ('dokument','zakon'): + # Include national pravilnici but boost less + hit['_relevance'] = 'national_doc' + hit['score'] = hit['score'] * 0.7 + scored.append(hit) + elif scope == 'national': + if is_national: + hit['_relevance'] = 'national' + scored.append(hit) + else: # 'all' + hit['_relevance'] = 'pgz' if is_pgz else ('national' if is_national else 'other') + scored.append(hit) + + # Re-sort by adjusted score + scored.sort(key=lambda x: x.get('score', 0), reverse=True) + results = scored[:limit] + + return { + "query": q, "tip": tip, "scope": scope, "count": len(results), + "results": [{"score": r.get('score', 0), + "tip": (r.get('payload') or {}).get('tip'), + "naziv": (r.get('payload') or {}).get('naziv') or (r.get('payload') or {}).get('title'), + "klub_id": (r.get('payload') or {}).get('klub_id'), + "savez_id": (r.get('payload') or {}).get('savez_id'), + "tekst": (r.get('payload') or {}).get('tekst') or (r.get('payload') or {}).get('text','')[:300], + "url": (r.get('payload') or {}).get('source_url') or (r.get('payload') or {}).get('url'), + "relevance": r.get('_relevance', 'unknown'), + "payload": r.get('payload')} for r in results] + } + + +# ==================== GOOGLE OAUTH ==================== +import jwt as _jwt, secrets as _secrets +GOOGLE_CLIENT_ID = "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com" # postavi u .env +ADMIN_EMAILS = { + "damir@rinet.one", "dradulic@outlook.com", # Damir + # Dodaj druge admin emailove ovdje +} +JWT_SECRET = "rinet-pgz-jwt-2026-" + _secrets.token_hex(8) +JWT_ISSUED = [] # in-memory token store (može u Redis) + +@app.post("/api/auth/google") +def google_auth(token: str = Body(..., embed=True)): + """Verify Google ID token and issue JWT for admin/viewer role.""" + try: + import urllib.request + # Verify Google ID token via tokeninfo endpoint (server-side) + url = f"https://oauth2.googleapis.com/tokeninfo?id_token={token}" + with urllib.request.urlopen(url, timeout=10) as r: + data = json.loads(r.read()) + email = data.get("email", "").lower() + verified = data.get("email_verified") == "true" or data.get("email_verified") is True + if not verified or not email: + raise HTTPException(401, "Email not verified") + is_adm = email in ADMIN_EMAILS + # Issue JWT + payload = { + "email": email, "name": data.get("name", email), + "role": "admin" if is_adm else "viewer", + "iat": int(__import__("time").time()), + "exp": int(__import__("time").time()) + 86400 * 7 # 7 dana + } + jwt_token = _jwt.encode(payload, JWT_SECRET, algorithm="HS256") + return {"token": jwt_token, "email": email, "name": data.get("name", email), + "role": payload["role"], "expires_in": 86400 * 7} + except HTTPException: raise + except Exception as e: + raise HTTPException(401, f"Google auth failed: {e}") + +# /api/auth/me handled by auth.auth_v2 router (M1) + +# ==================== STATIC ==================== +import pathlib +HTML_DIR = pathlib.Path(__file__).parent / "static" +HTML_DIR.mkdir(exist_ok=True) + +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + + +# ──────── V5 NATJECANJA ──────── +@app.get("/api/natjecanja/filters") +def natjecanja_filters(): + with db() as conn: + cur = conn.cursor() + cur.execute("SELECT DISTINCT sport FROM pgz_sport.natjecanja WHERE sport IS NOT NULL ORDER BY sport") + sports = [r[0] for r in cur.fetchall()] + cur.execute("SELECT DISTINCT sezona FROM pgz_sport.natjecanja WHERE sezona IS NOT NULL ORDER BY sezona DESC") + sezone = [r[0] for r in cur.fetchall()] + return {"sports": sports, "sezone": sezone} + +@app.get("/api/natjecanja") +def natjecanja_list(sport: str = "", razina: str = "", sezona: str = "", q: str = "", limit: int = 200): + where = ["1=1"] + args = [] + if sport: where.append("sport = %s"); args.append(sport) + if razina: where.append("razina = %s"); args.append(razina) + if sezona: where.append("sezona = %s"); args.append(sezona) + if q: where.append("naziv ILIKE %s"); args.append(f"%{q}%") + args.append(limit) + + with db() as conn: + cur = conn.cursor() + cur.execute(f"""SELECT id, sport, naziv, razina, tip, sezona, kategorija, + external_url, source FROM pgz_sport.natjecanja WHERE {' AND '.join(where)} + ORDER BY razina, sezona DESC NULLS LAST, naziv LIMIT %s""", args) + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + results = [dict(zip(cols, r)) for r in rows] + cur.execute(f"SELECT COUNT(*) FROM pgz_sport.natjecanja WHERE {' AND '.join(where)}", args[:-1]) + total = cur.fetchone()[0] + return {"count": total, "limit": limit, "results": results} + +# ──────── V5 ADMIN ──────── +@app.get("/api/admin/stats") +def admin_stats(): + with db() as conn: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM pgz_sport.users"); ut = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM pgz_sport.users WHERE aktivan=true"); ua = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM pgz_sport.sys_permissions"); pt = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM pgz_sport.sys_audit WHERE created_at >= now()::date"); at = cur.fetchone()[0] + cur.execute("SELECT user_type, COUNT(*) cnt FROM pgz_sport.users GROUP BY 1 ORDER BY 2 DESC") + by_type = [{"user_type": r[0], "cnt": r[1]} for r in cur.fetchall()] + return {"users_total": ut, "users_active": ua, "permissions_total": pt, + "audit_today": at, "by_type": by_type} + +@app.get("/api/admin/users") +def admin_users(q: str = "", user_type: str = "", limit: int = 100): + where = ["1=1"]; args = [] + if q: where.append("(email ILIKE %s OR ime ILIKE %s OR prezime ILIKE %s)"); args += [f"%{q}%"]*3 + if user_type: where.append("user_type = %s"); args.append(user_type) + args.append(limit) + with db() as conn: + cur = conn.cursor() + cur.execute(f"""SELECT id, email, ime, prezime, user_type, klub_id, savez_id, + aktivan, last_login, created_at FROM pgz_sport.users + WHERE {' AND '.join(where)} ORDER BY id LIMIT %s""", args) + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + results = [{**dict(zip(cols, r)), + 'last_login': str(dict(zip(cols, r))['last_login']) if dict(zip(cols, r))['last_login'] else None, + 'created_at': str(dict(zip(cols, r))['created_at'])} for r in rows] + return {"count": len(results), "results": results} + +@app.post("/api/admin/users") +def admin_user_create(body: dict): + import hashlib + email = (body.get("email") or "").strip().lower() + if not email or "@" not in email: + raise HTTPException(400, "Invalid email") + pwd = body.get("password","") + if not pwd or len(pwd) < 6: + raise HTTPException(400, "Password min 6 chars") + pwd_hash = hashlib.sha256(pwd.encode()).hexdigest() + with db() as conn: + cur = conn.cursor() + try: + cur.execute("""INSERT INTO pgz_sport.users + (email, password_hash, ime, prezime, user_type, klub_id, savez_id, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,%s,true) RETURNING id""", + (email, pwd_hash, body.get("ime"), body.get("prezime"), + body.get("user_type","klub_user"), body.get("klub_id"), body.get("savez_id"))) + new_id = cur.fetchone()[0] + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, target_text, payload) + VALUES ('user.create','sys_users',%s,%s,%s::jsonb)""", + (new_id, email, json.dumps({"user_type": body.get("user_type")}))) + conn.commit() + return {"id": new_id, "email": email} + except psycopg2.IntegrityError as e: + conn.rollback() + raise HTTPException(400, f"Email već postoji: {email}") + +@app.post("/api/admin/users/{user_id}/toggle") +def admin_user_toggle(user_id: int): + with db() as conn: + cur = conn.cursor() + cur.execute("UPDATE pgz_sport.users SET aktivan = NOT aktivan WHERE id=%s RETURNING aktivan", (user_id,)) + r = cur.fetchone() + if not r: raise HTTPException(404, "User not found") + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, payload) + VALUES ('user.toggle','sys_users',%s,%s::jsonb)""", (user_id, json.dumps({"aktivan": r[0]}))) + conn.commit() + return {"id": user_id, "aktivan": r[0]} + + +# ──────── 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}') + +# === 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 + app.include_router(gdpr_router) + app.include_router(gdpr_admin_router) + print('[AUTH/M10] gdpr routers loaded (/api/gdpr/*, /api/admin/gdpr/*)') +except Exception as e: + print(f'[AUTH/M10] gdpr routers 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("/api/sportski-objekti") +def list_sportski_objekti(q=None,tip=None,grad=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR adresa ILIKE %s OR grad ILIKE %s)"); p+=["%"+q+"%"]*3 + if tip: w.append("tip ILIKE %s"); p.append("%"+tip+"%") + if grad: w.append("grad ILIKE %s"); p.append("%"+grad+"%") + rows=fetch("SELECT * FROM pgz_sport.sportski_objekti WHERE "+" AND ".join(w)+" ORDER BY grad,naziv",p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/clanovi-full") +def list_clanovi_full(q=None,hoo=None,reprezentativac=None,klub_id=None,limit=80,authorization=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(ime ILIKE %s OR prezime ILIKE %s OR klub_naziv_godisnjak ILIKE %s)"); p+=["%"+q+"%"]*3 + if hoo: w.append("hoo_kategorija=%s"); p.append(hoo) + if reprezentativac is not None: w.append("reprezentativac="+(("TRUE") if str(reprezentativac).lower()=="true" else "FALSE")) + if klub_id: w.append("klub_id=%s"); p.append(int(klub_id)) + lim=min(int(limit or 80),200) + sql="SELECT id,ime,prezime,oib,datum_rodenja,spol,sport,pozicija,reprezentativac,kategoriziran,stipendiran,kategorija_hoo,hoo_kategorija,aktivan,klub_naziv_godisnjak,slika_url,profile_url,hns_igrac_id,visina_cm,tezina_kg,broj_dresa,uloga,godisnjak_godine,godisnjak_prvi,godisnjak_zadnji,napomena FROM pgz_sport.clanovi WHERE "+" AND ".join(w)+" ORDER BY prezime,ime LIMIT "+str(lim) + rows=fetch(sql,p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/gradovi") +def list_gradovi(): + rows=fetch("SELECT DISTINCT grad FROM pgz_sport.klubovi WHERE aktivan=TRUE AND grad IS NOT NULL AND grad<>'' AND grad NOT SIMILAR TO '[0-9]+%%' ORDER BY grad",[]) + return [r["grad"] for r in rows] + +@app.get("/api/manifestacije-full") +def list_manifestacije_full(q=None,razina=None): + w=["aktivna=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR mjesto ILIKE %s)"); p+=["%"+q+"%"]*2 + rows=fetch("SELECT id,naziv,mjesto,organizator,razina,broj_ucesnika,godina_od,spol_kategorija,napomena,source_url FROM pgz_sport.manifestacije WHERE "+" AND ".join(w)+" ORDER BY naziv",p) + return {"count":len(rows),"rows":rows} + + + +# ── SUFINANCIRANJE-ALL v1.0 dradulic@outlook.com 2026-05-04 +@app.get("/api/sufinanciranje") +def list_sufinanciranje(q=None, godina=None, razina=None, sport=None, limit=500): + w=["iznos_eur > 0"]; p=[] + if q: w.append("(LOWER(korisnik) LIKE %s OR LOWER(sport) LIKE %s)"); p+=[f"%{q.lower()}%"]*2 + if godina: w.append("godina=%s"); p.append(int(godina)) + if razina: w.append("razina ILIKE %s"); p.append(f"%{razina}%") + if sport: w.append("sport ILIKE %s"); p.append(f"%{sport}%") + sql=f"SELECT korisnik,sport,iznos_eur,vrsta,razina,izvor,source_url,godina FROM pgz_sport.sufinanciranje_sport WHERE {' AND '.join(w)} ORDER BY iznos_eur DESC LIMIT {min(int(limit),1000)}" + rows=fetch(sql,p) + total=sum(float(r.get('iznos_eur') or 0) for r in rows) + years=sorted(set(r.get('godina') for r in rows if r.get('godina')),reverse=True) + return {"count":len(rows),"total":total,"years":years,"rows":rows} + + + +# ══════════════════════════════════════════════════════════════════ +# ERP PLATFORM ROUTES v2.0 — dradulic@outlook.com — 2026-05-04 +# ══════════════════════════════════════════════════════════════════ + +import hashlib + +def hash_pwd(pwd): return hashlib.sha256(pwd.encode()).hexdigest() + +def get_user(token): + if not token: return None + try: + payload = _jwt.decode(token.replace("Bearer ",""), JWT_SECRET, algorithms=["HS256"]) + uid = payload.get("uid") + if uid: + rows = fetch("SELECT * FROM pgz_sport.users WHERE id=%s AND aktivan=TRUE", [uid]) + return rows[0] if rows else None + return payload + except: return None + +# ── AUTH: Email/Password login — handled by auth.auth_v2 router (M1) ── + +# ── SPORTAS FULL PROFILE ───────────────────────────────────────── +@app.get("/api/sportas/{clan_id}/profil") +def sportas_profil(clan_id: int): + clan = fetch("""SELECT c.*, k.naziv AS klub_naziv_full, k.sport AS klub_sport, + k.grad, k.logo_url FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id]) + if not clan: raise HTTPException(404,"Nije pronađen") + c = clan[0] + sezona = fetch("""SELECT * FROM pgz_sport.clan_sezona WHERE clan_id=%s ORDER BY sezona DESC""", [clan_id]) + utakmice = fetch("""SELECT * FROM pgz_sport.utakmice_log WHERE clan_id=%s ORDER BY datum DESC LIMIT 30""", [clan_id]) + nagrade = fetch("SELECT * FROM pgz_sport.clan_nagrada WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + godisnjaci = fetch("SELECT * FROM pgz_sport.clan_godisnjak WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + stats = {} + if sezona: + stats = {"ukupno_nastupa": sum((r.get("nastupi") or 0) for r in sezona), + "ukupno_pogodaka": sum((r.get("pogoci") or 0) for r in sezona), + "ukupno_asistencija": sum((r.get("asistencije") or 0) for r in sezona), + "ukupno_zutih": sum((r.get("zuti_kartoni") or 0) for r in sezona), + "ukupno_crvenih": sum((r.get("crveni_kartoni") or 0) for r in sezona), + "ukupno_minuta": sum((r.get("minute_total") or 0) for r in sezona), + "sezone_aktivne": len(sezona)} + return {**c,"clan_sezona":sezona,"utakmice":utakmice,"nagrade":nagrade, + "godisnjaci":godisnjaci,"stats":stats} + +# ── SAVEZ FULL DETAIL ──────────────────────────────────────────── +@app.get("/api/savezi/{savez_id}/full") +def savez_full(savez_id: int): + s = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s",[savez_id]) + if not s: raise HTTPException(404,"Savez nije pronađen") + klubovi = fetch("""SELECT id,naziv,sport,grad,predsjednik,tajnik,nositelj_kvalitete, + aktivan,oib,razina,broj_clanova FROM pgz_sport.klubovi WHERE savez_id=%s AND aktivan=TRUE ORDER BY naziv""",[savez_id]) + clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija, + c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan + FROM pgz_sport.clanovi c WHERE c.savez_kod=(SELECT kod FROM pgz_sport.savezi WHERE id=%s) LIMIT 200""",[savez_id]) + if not clanovi: + clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija, + c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan + FROM pgz_sport.clanovi c WHERE c.aktivan=TRUE AND c.sport ILIKE %s LIMIT 200""", + [f'%{s[0].get("sport","") or ""}%']) + treneri = fetch("""SELECT * FROM pgz_sport.treneri WHERE savez_id=%s""",[savez_id]) + return {**s[0],"klubovi":klubovi,"clanovi":clanovi[:100],"treneri":treneri} + +# ── KLUB ERP: CLANARINE ────────────────────────────────────────── +@app.get("/api/klub/{klub_id}/clanarine") +def klub_clanarine(klub_id: int, godina: int=None, status: str=None): + w=["c.klub_id=%s"]; p=[klub_id] + if godina: w.append("cl.godina=%s"); p.append(godina) + if status: w.append("cl.status=%s"); p.append(status) + rows = fetch(f"""SELECT cl.*,c.ime,c.prezime,c.oib,c.spol,c.kategorija,c.hoo_kategorija,c.slika_url + FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE {" AND ".join(w)} ORDER BY cl.godina DESC, c.prezime""", p) + total_p = sum(float(r.get("iznos_placen") or 0) for r in rows) + total_d = sum(float(r.get("iznos_propisan") or 0) - float(r.get("iznos_placen") or 0) for r in rows) + return {"count":len(rows),"naplaceno":total_p,"dug":total_d,"rows":rows} + +# ── KLUB ERP: LIJECNICKI ───────────────────────────────────────── +@app.get("/api/klub/{klub_id}/lijecnicki") +def klub_lijecnicki(klub_id: int): + import datetime; today = datetime.date.today() + rows = fetch("""SELECT lp.*,c.ime,c.prezime,c.oib,c.kategorija,c.slika_url, + CASE WHEN lp.vrijedi_do IS NULL THEN 'nepoznato' + WHEN lp.vrijedi_do < CURRENT_DATE THEN 'istekao' + WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'uskoro_istece' + ELSE 'validan' END AS status_pregled + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s ORDER BY lp.vrijedi_do ASC NULLS LAST""", [klub_id]) + alert_istekli = [r for r in rows if r.get("status_pregled")=="istekao"] + alert_uskoro = [r for r in rows if r.get("status_pregled")=="uskoro_istece"] + return {"count":len(rows),"istekli":len(alert_istekli),"uskoro":len(alert_uskoro),"rows":rows} + +# ── NETWORK GRAPH DATA ─────────────────────────────────────────── +@app.get("/api/network/pgz") +def network_pgz(q: str=None, entity_type: str=None, max_nodes: int=80): + FORENSIC_NAMES = {"SAMIR BARAĆ","MIROSLAV MARIĆ","VELIMIR LIVERIĆ","DOROTEA PESIC-BUKOVAC"} + nodes,edges,seen_nodes,seen_edges = [],[],set(),set() + + def add_node(nid, label, ntype, meta=None): + if nid not in seen_nodes: + seen_nodes.add(nid) + nodes.append({"id":nid,"label":label,"type":ntype,"forensic":label.upper() in FORENSIC_NAMES,"meta":meta or {}}) + + def add_edge(s,t,rel=""): + k=f"{s}-{t}" + if k not in seen_edges: + seen_edges.add(k); edges.append({"source":s,"target":t,"rel":rel}) + + if q: + # Person search + persons = fetch("""SELECT p.id,p.name,p.function,e.name as ent,e.id as eid,e.entity_type,e.city + FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id + WHERE p.name ILIKE %s OR e.name ILIKE %s LIMIT 60""",[f"%{q}%",f"%{q}%"]) + for r in persons: + pid=f"p_{r['id']}"; eid=f"e_{r['eid']}" + add_node(pid,r.get("name","?")[:30],"person") + add_node(eid,r.get("ent","?")[:30],"club" if "Udruga" in (r.get("entity_type") or "") else "company") + add_edge(pid,eid,r.get("function","")) + else: + # Default: top connected persons + rels = fetch("""SELECT p.id,p.name,e.id as eid,e.name as ent,e.entity_type,p.function + FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id + WHERE e.county ILIKE '%%goranska%%' OR e.county ILIKE '%%primorska%%' + ORDER BY p.id LIMIT %s""",[max_nodes]) + for r in rels: + pid=f"p_{r['id']}"; eid=f"e_{r['eid']}" + add_node(pid,r.get("name","?")[:25],"person") + add_node(eid,r.get("ent","?")[:25],"club" if "Udruga" in (r.get("entity_type") or "") else "company", + {"city":r.get("city"),"type":r.get("entity_type")}) + add_edge(pid,eid,r.get("function","")) + + return {"nodes":nodes[:200],"edges":edges[:400],"query":q} + + + +@app.get("/platform") +@app.get("/platform/") +def serve_platform(): + p = HTML_DIR / "platform.html" + if p.exists(): return FileResponse(p) + return {"error": "platform.html not found"} + +app.mount("/static", StaticFiles(directory=str(HTML_DIR)), name="static") + +@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) diff --git a/_data/uploads/invoices/20260505_000453_ina_racun.png b/_data/uploads/invoices/20260505_000453_ina_racun.png new file mode 100644 index 0000000000000000000000000000000000000000..c2d76bab11065120cea15b73d22be71e0069b787 GIT binary patch literal 47432 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 bool: + return (u.get("user_type") or "").lower() in ("super_admin", "pgz_admin") + +def _is_savez_admin(u: Dict) -> bool: + return (u.get("user_type") or "").lower() == "savez_admin" + +def _is_klub_admin(u: Dict) -> bool: + return (u.get("user_type") or "").lower() == "klub_admin" + +def _can_manage(actor: Dict, target_user_type: str, + target_klub_id: Optional[int], target_savez_id: Optional[int]) -> bool: + """Hierarchical management: + - super_admin / pgz_admin → manage everyone + - savez_admin → manage savez_*, klub_admin in their savez + - klub_admin → manage klub_user/klub_trener/klub_clan in their klub + """ + if _is_pgz_admin(actor): return True + tut = (target_user_type or "").lower() + if _is_savez_admin(actor): + if tut in PGZ_USER_TYPES: return False + if tut in SAVEZ_USER_TYPES and (target_savez_id == actor.get("savez_id")): return True + if tut == "klub_admin" and target_savez_id and target_savez_id == actor.get("savez_id"): + return True + # any klub user that belongs to this savez + if tut in KLUB_USER_TYPES and target_savez_id == actor.get("savez_id"): + return True + return False + if _is_klub_admin(actor): + if tut not in {"klub_user", "klub_trener", "klub_clan", "viewer"}: + return False + return target_klub_id and target_klub_id == actor.get("klub_id") + return False + +def _scoped_where(actor: Dict) -> tuple: + """Filter user list by actor's scope.""" + if _is_pgz_admin(actor): return ("", []) + if _is_savez_admin(actor): + sid = actor.get("savez_id") + if not sid: return ("AND 1=0", []) + return ("AND (u.savez_id=%s OR u.klub_id IN (SELECT id FROM pgz_sport.klubovi WHERE savez_id=%s))", + [sid, sid]) + if _is_klub_admin(actor): + kid = actor.get("klub_id") + if not kid: return ("AND 1=0", []) + return ("AND u.klub_id=%s", [kid]) + return ("AND u.id=%s", [actor["id"]]) + +# ─────────────────────────── List / read ─────────────────────────── +@router.get("/users") +def list_users( + q: Optional[str] = None, + user_type: Optional[str] = None, + tenant_type: Optional[str] = None, + tenant_id: Optional[int] = None, + klub_id: Optional[int] = None, + savez_id: Optional[int] = None, + aktivan: Optional[bool] = None, + limit: int = 100, + offset: int = 0, + actor = Depends(require_user), +): + if not (_is_pgz_admin(actor) or _is_savez_admin(actor) or _is_klub_admin(actor)): + raise HTTPException(403, "Forbidden — admin required") + where = ["1=1"]; args: List[Any] = [] + sw, sp = _scoped_where(actor) + if sw: where.append(sw.replace("AND ", "")); args.extend(sp) + if q: + where.append("(LOWER(u.email) LIKE %s OR LOWER(u.full_name) LIKE %s OR LOWER(COALESCE(u.ime,'')) LIKE %s OR LOWER(COALESCE(u.prezime,'')) LIKE %s)") + like = f"%{q.lower()}%"; args.extend([like]*4) + if user_type: where.append("u.user_type=%s"); args.append(user_type) + if klub_id: where.append("u.klub_id=%s"); args.append(klub_id) + if savez_id: where.append("u.savez_id=%s"); args.append(savez_id) + if aktivan is not None: where.append("u.aktivan=%s"); args.append(aktivan) + if tenant_type and tenant_id is not None: + if tenant_type == "klub": where.append("u.klub_id=%s"); args.append(tenant_id) + elif tenant_type == "savez": where.append("u.savez_id=%s"); args.append(tenant_id) + base_args = list(args) + args.extend([limit, offset]) + rows = db_query(f"""SELECT u.id, u.email, u.full_name, u.ime, u.prezime, u.user_type, + u.klub_id, u.savez_id, u.aktivan, u.status, u.must_change_pwd, + u.last_login, u.locked_until, u.failed_login_count, u.telefon, + u.created_at, u.gdpr_consent_at, + k.naziv AS klub_naziv, s.naziv AS savez_naziv + FROM pgz_sport.users u + LEFT JOIN pgz_sport.klubovi k ON k.id=u.klub_id + LEFT JOIN pgz_sport.savezi s ON s.id=u.savez_id + WHERE {' AND '.join(where)} + ORDER BY u.id DESC LIMIT %s OFFSET %s""", tuple(args)) + total = db_one(f"SELECT COUNT(*) AS c FROM pgz_sport.users u WHERE {' AND '.join(where)}", + tuple(base_args))["c"] + return {"count": len(rows), "total": total, "results": rows} + +@router.get("/users/{uid}") +def get_user(uid: int, actor = Depends(require_user)): + u = db_one("""SELECT u.*, k.naziv AS klub_naziv, s.naziv AS savez_naziv + FROM pgz_sport.users u + LEFT JOIN pgz_sport.klubovi k ON k.id=u.klub_id + LEFT JOIN pgz_sport.savezi s ON s.id=u.savez_id + WHERE u.id=%s""", (uid,)) + if not u: raise HTTPException(404, "User not found") + if not (_is_pgz_admin(actor) or + _can_manage(actor, u.get("user_type"), u.get("klub_id"), u.get("savez_id")) or + actor["id"] == uid): + raise HTTPException(403, "Forbidden") + # Strip sensitive + u.pop("password_hash", None) + u.pop("two_factor_secret", None) + return u + +# ─────────────────────────── Create ─────────────────────────── +class CreateUserReq(BaseModel): + email: str + full_name: Optional[str] = None + ime: Optional[str] = None + prezime: Optional[str] = None + user_type: str = "klub_user" + klub_id: Optional[int] = None + savez_id: Optional[int] = None + telefon: Optional[str] = None + oib: Optional[str] = None + password: Optional[str] = None # if absent → temp pwd + must_change + +@router.post("/users") +def create_user(req: CreateUserReq, request: Request, actor = Depends(require_user)): + if req.user_type not in VALID_USER_TYPES: + raise HTTPException(400, f"Invalid user_type: {req.user_type}") + if not _can_manage(actor, req.user_type, req.klub_id, req.savez_id): + raise HTTPException(403, "Forbidden — out of management scope") + full_name = req.full_name or ((req.ime or "") + " " + (req.prezime or "")).strip() or req.email + pwd = req.password or ("PGZ-" + secrets.token_hex(4)) + must_change = not bool(req.password) + try: + new_id = db_one("""INSERT INTO pgz_sport.users + (email, password_hash, full_name, ime, prezime, user_type, klub_id, savez_id, + telefon, oib, must_change_pwd, aktivan, status, auth_provider) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,true,'active','local') + RETURNING id""", + (req.email.lower().strip(), hash_password(pwd), full_name, + req.ime, req.prezime, req.user_type, req.klub_id, req.savez_id, + req.telefon, req.oib, must_change))["id"] + except Exception as e: + if "duplicate" in str(e).lower() or "unique" in str(e).lower(): + raise HTTPException(409, f"Email već postoji: {req.email}") + raise HTTPException(400, str(e)) + ip, ua = _client(request) + audit(actor["id"], "user.create", "user", new_id, + {"email": req.email, "user_type": req.user_type, + "klub_id": req.klub_id, "savez_id": req.savez_id}, ip, ua) + return {"id": new_id, "email": req.email, "user_type": req.user_type, + "must_change_pwd": must_change, + "temporary_password": pwd if must_change else None} + +# ─────────────────────────── Update ─────────────────────────── +class UpdateUserReq(BaseModel): + full_name: Optional[str] = None + ime: Optional[str] = None + prezime: Optional[str] = None + user_type: Optional[str] = None + klub_id: Optional[int] = None + savez_id: Optional[int] = None + telefon: Optional[str] = None + oib: Optional[str] = None + aktivan: Optional[bool] = None + +@router.put("/users/{uid}") +def update_user(uid: int, req: UpdateUserReq, request: Request, + actor = Depends(require_user)): + target = db_one("SELECT user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s", + (uid,)) + if not target: raise HTTPException(404, "User not found") + if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]): + raise HTTPException(403, "Forbidden") + fields, args = [], [] + for f in ["full_name","ime","prezime","user_type","klub_id","savez_id","telefon","oib","aktivan"]: + v = getattr(req, f) + if v is not None: + if f == "user_type" and v not in VALID_USER_TYPES: + raise HTTPException(400, f"Invalid user_type: {v}") + fields.append(f"{f}=%s"); args.append(v) + if not fields: + return {"status": "nothing_to_update"} + fields.append("updated_at=now()") + args.append(uid) + db_exec(f"UPDATE pgz_sport.users SET {', '.join(fields)} WHERE id=%s", tuple(args)) + ip, ua = _client(request) + audit(actor["id"], "user.update", "user", uid, + req.dict(exclude_none=True), ip, ua) + return {"status": "ok", "id": uid} + +# ─────────────────────────── Delete (soft) ─────────────────────────── +@router.delete("/users/{uid}") +def delete_user(uid: int, request: Request, actor = Depends(require_user)): + if uid == actor["id"]: + raise HTTPException(400, "Ne možete obrisati svoj račun") + target = db_one("SELECT user_type, klub_id, savez_id, email FROM pgz_sport.users WHERE id=%s", + (uid,)) + if not target: raise HTTPException(404, "User not found") + if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]): + raise HTTPException(403, "Forbidden") + db_exec("""UPDATE pgz_sport.users SET aktivan=false, status='deleted', + updated_at=now() WHERE id=%s""", (uid,)) + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,)) + ip, ua = _client(request) + audit(actor["id"], "user.delete", "user", uid, {"email": target["email"]}, ip, ua) + return {"status": "ok", "id": uid} + +# ─────────────────────────── Invite ─────────────────────────── +class InviteReq(BaseModel): + send_email: bool = False # placeholder — wired to mailer in M11 + note: Optional[str] = None + +@router.post("/users/{uid}/invite") +def invite_user(uid: int, req: InviteReq, request: Request, + actor = Depends(require_user)): + target = db_one("SELECT email, user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s", + (uid,)) + if not target: raise HTTPException(404, "User not found") + if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]): + raise HTTPException(403, "Forbidden") + new_temp = "PGZ-" + secrets.token_hex(4) + db_exec("""UPDATE pgz_sport.users + SET password_hash=%s, must_change_pwd=true, + failed_login_count=0, locked_until=NULL, updated_at=now() + WHERE id=%s""", (hash_password(new_temp), uid)) + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,)) + ip, ua = _client(request) + audit(actor["id"], "user.invite", "user", uid, + {"email": target["email"], "send_email": req.send_email}, ip, ua) + invite_link = f"https://api.rinet.one/sport/login?email={target['email']}" + return {"status": "ok", "id": uid, + "temporary_password": new_temp, + "invite_link": invite_link, + "email_sent": False} # mailer wired later + +# ─────────────────────────── Role change ─────────────────────────── +class RoleReq(BaseModel): + user_type: str + +@router.post("/users/{uid}/role") +def change_role(uid: int, req: RoleReq, request: Request, + actor = Depends(require_user)): + if not _is_pgz_admin(actor): + raise HTTPException(403, "Samo PGŽ admin može mijenjati role") + if req.user_type not in VALID_USER_TYPES: + raise HTTPException(400, f"Invalid user_type: {req.user_type}") + target = db_one("SELECT user_type FROM pgz_sport.users WHERE id=%s", (uid,)) + if not target: raise HTTPException(404, "User not found") + db_exec("UPDATE pgz_sport.users SET user_type=%s, updated_at=now() WHERE id=%s", + (req.user_type, uid)) + ip, ua = _client(request) + audit(actor["id"], "user.role.change", "user", uid, + {"from": target["user_type"], "to": req.user_type}, ip, ua) + return {"status": "ok", "id": uid, "user_type": req.user_type} + +# ─────────────────────────── Suspend / unsuspend ─────────────────────────── +class SuspendReq(BaseModel): + reason: Optional[str] = None + minutes: Optional[int] = None # null → indefinite + +@router.post("/users/{uid}/suspend") +def suspend_user(uid: int, req: SuspendReq, request: Request, + actor = Depends(require_user)): + target = db_one("SELECT user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s", + (uid,)) + if not target: raise HTTPException(404, "User not found") + if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]): + raise HTTPException(403, "Forbidden") + if req.minutes: + db_exec("""UPDATE pgz_sport.users + SET locked_until = now() + (interval '1 minute' * %s), + updated_at = now() WHERE id=%s""", (req.minutes, uid)) + else: + db_exec("UPDATE pgz_sport.users SET aktivan=false, updated_at=now() WHERE id=%s", + (uid,)) + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,)) + ip, ua = _client(request) + audit(actor["id"], "user.suspend", "user", uid, + {"reason": req.reason, "minutes": req.minutes}, ip, ua) + return {"status": "ok", "id": uid} + +@router.post("/users/{uid}/unsuspend") +def unsuspend_user(uid: int, request: Request, actor = Depends(require_user)): + target = db_one("SELECT user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s", + (uid,)) + if not target: raise HTTPException(404, "User not found") + if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]): + raise HTTPException(403, "Forbidden") + db_exec("""UPDATE pgz_sport.users + SET aktivan=true, locked_until=NULL, failed_login_count=0, + updated_at=now() WHERE id=%s""", (uid,)) + ip, ua = _client(request) + audit(actor["id"], "user.unsuspend", "user", uid, None, ip, ua) + return {"status": "ok", "id": uid} + +# ─────────────────────────── Reset password (admin) ─────────────────────────── +@router.post("/users/{uid}/reset-password") +def admin_reset_password(uid: int, request: Request, actor = Depends(require_user)): + target = db_one("SELECT user_type, klub_id, savez_id, email FROM pgz_sport.users WHERE id=%s", + (uid,)) + if not target: raise HTTPException(404, "User not found") + if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]): + raise HTTPException(403, "Forbidden") + new_temp = "PGZ-" + secrets.token_hex(4) + db_exec("""UPDATE pgz_sport.users + SET password_hash=%s, must_change_pwd=true, + failed_login_count=0, locked_until=NULL, updated_at=now() + WHERE id=%s""", (hash_password(new_temp), uid)) + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,)) + ip, ua = _client(request) + audit(actor["id"], "user.password.reset", "user", uid, + {"email": target["email"]}, ip, ua) + return {"status": "ok", "temporary_password": new_temp} + +# ─────────────────────────── Audit log ─────────────────────────── +@router.get("/audit") +def audit_log(user_id: Optional[int] = None, + action: Optional[str] = None, + resource_type: Optional[str] = None, + limit: int = 100, + offset: int = 0, + actor = Depends(require_user)): + if not _is_pgz_admin(actor): + # savez/klub admins see only their scope + if not (_is_savez_admin(actor) or _is_klub_admin(actor)): + raise HTTPException(403, "Forbidden") + where = ["1=1"]; args: List[Any] = [] + if user_id: where.append("a.user_id=%s"); args.append(user_id) + if action: where.append("a.action LIKE %s"); args.append(f"%{action}%") + if resource_type: where.append("a.resource_type=%s"); args.append(resource_type) + if not _is_pgz_admin(actor): + # restrict to own user's actions or resources within scope + if _is_savez_admin(actor): + where.append("(a.user_id IN (SELECT id FROM pgz_sport.users WHERE savez_id=%s OR klub_id IN (SELECT id FROM pgz_sport.klubovi WHERE savez_id=%s)))") + args.extend([actor.get("savez_id"), actor.get("savez_id")]) + elif _is_klub_admin(actor): + where.append("(a.user_id IN (SELECT id FROM pgz_sport.users WHERE klub_id=%s))") + args.append(actor.get("klub_id")) + args.extend([limit, offset]) + rows = db_query(f"""SELECT a.id, a.action, a.resource_type, a.resource_id, + a.user_id, a.ts AS created_at, a.meta, a.ip_address, a.user_agent, + u.email AS actor_email, u.full_name AS actor_name + FROM pgz_sport.audit_events a + LEFT JOIN pgz_sport.users u ON u.id=a.user_id + WHERE {' AND '.join(where)} + ORDER BY a.id DESC LIMIT %s OFFSET %s""", tuple(args)) + return {"count": len(rows), "results": rows} + +# ─────────────────────────── Tenants list ─────────────────────────── +@router.get("/tenants") +def list_tenants(actor = Depends(require_user)): + """Combined view: tenants table + savezi + klubovi.""" + tenants = db_query("""SELECT id, slug, display_name, type, status, oib, created_at + FROM pgz_sport.tenants ORDER BY id""") + if _is_pgz_admin(actor): + savezi = db_query("""SELECT id, naziv, sport, oib, predsjednik, tajnik + FROM pgz_sport.savezi WHERE aktivan=true ORDER BY naziv LIMIT 200""") + klubovi = db_query("""SELECT id, naziv, sport, grad, oib, savez_id + FROM pgz_sport.klubovi WHERE aktivan=true ORDER BY naziv LIMIT 500""") + elif _is_savez_admin(actor): + sid = actor.get("savez_id") + savezi = db_query("""SELECT id, naziv, sport, oib, predsjednik, tajnik + FROM pgz_sport.savezi WHERE id=%s""", (sid,)) + klubovi = db_query("""SELECT id, naziv, sport, grad, oib, savez_id + FROM pgz_sport.klubovi WHERE savez_id=%s AND aktivan=true ORDER BY naziv""", (sid,)) + else: + kid = actor.get("klub_id") + savezi = [] + klubovi = db_query("""SELECT id, naziv, sport, grad, oib, savez_id + FROM pgz_sport.klubovi WHERE id=%s""", (kid,)) + return {"tenants": tenants, "savezi": savezi, "klubovi": klubovi} + +# ─────────────────────────── Bulk CSV import ─────────────────────────── +@router.post("/users/bulk-csv") +async def bulk_csv(file: UploadFile = File(...), + default_user_type: str = "klub_clan", + default_klub_id: Optional[int] = None, + default_savez_id: Optional[int] = None, + request: Request = None, + actor = Depends(require_user)): + """CSV columns (header required): email,ime,prezime,user_type,klub_id,savez_id,telefon,oib""" + if not _is_pgz_admin(actor): + raise HTTPException(403, "Samo PGŽ admin može masovno uvoziti") + raw = (await file.read()).decode("utf-8", errors="replace") + rdr = csv.DictReader(io.StringIO(raw)) + created, skipped, errors = 0, 0, [] + for i, row in enumerate(rdr, 1): + email = (row.get("email") or "").lower().strip() + if not email: + skipped += 1; continue + try: + ut = row.get("user_type") or default_user_type + if ut not in VALID_USER_TYPES: + errors.append(f"row {i}: invalid user_type {ut}"); skipped += 1; continue + kid = int(row["klub_id"]) if row.get("klub_id") else default_klub_id + sid = int(row["savez_id"]) if row.get("savez_id") else default_savez_id + full_name = (row.get("ime","") + " " + row.get("prezime","")).strip() or email + temp_pwd = "PGZ-" + secrets.token_hex(4) + new_id = db_one("""INSERT INTO pgz_sport.users + (email, password_hash, ime, prezime, full_name, user_type, klub_id, savez_id, + telefon, oib, must_change_pwd, aktivan, status, auth_provider) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,true,true,'active','local') + ON CONFLICT (email) DO NOTHING RETURNING id""", + (email, hash_password(temp_pwd), row.get("ime"), row.get("prezime"), + full_name, ut, kid, sid, row.get("telefon"), row.get("oib"))) + if new_id and new_id.get("id"): + created += 1 + else: + skipped += 1 + except Exception as e: + errors.append(f"row {i}: {e}"); skipped += 1 + audit(actor["id"], "user.bulk_csv", meta={"created": created, "skipped": skipped}) + return {"created": created, "skipped": skipped, "errors": errors[:20]} diff --git a/auth/auth_v2.py b/auth/auth_v2.py new file mode 100644 index 0000000..65b7ffe --- /dev/null +++ b/auth/auth_v2.py @@ -0,0 +1,455 @@ +#!/usr/bin/env python3 +# auth_v2.py — JWT auth backend with tenant_id, role, tier claims +# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04 +# Endpoints: /api/auth/login, /api/auth/refresh, /api/auth/logout, +# /api/auth/me, /api/auth/password/change, /api/auth/password/reset +""" +JWT claims: + sub int user id + email str + name str + tenant_id int|null pgz_sport.tenants.id (or null for super_admin) + tenant_type str pgz | savez | klub | global + tenant_scope dict {"klub_id": ..., "savez_id": ...} + role str user_type code (super_admin | pgz_admin | savez_admin | klub_admin | klub_clan | viewer ...) + tier int 0 = PGŽ, 1 = savez, 2 = klub + jti str token id (revocable via user_sessions) + iat / exp / nbf +""" + +import os, hashlib, secrets, json, time +from datetime import datetime, timedelta, timezone +from typing import Optional, Dict, List, Any + +import jwt as _jwt +import psycopg2, psycopg2.extras +from fastapi import APIRouter, HTTPException, Header, Depends, Request, Body +from pydantic import BaseModel, EmailStr + +try: + from passlib.hash import bcrypt as _bcrypt + HAS_BCRYPT = True +except Exception: + HAS_BCRYPT = False + +DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', + user='rinet', password='R1net2026!SecureDB#v7') + +# Persistent JWT secret — read from env, else stable file, else generated. +def _load_secret() -> str: + env_secret = os.environ.get("PGZ_JWT_SECRET") + if env_secret and len(env_secret) >= 32: + return env_secret + secret_file = "/opt/pgz-sport/auth/.jwt_secret" + try: + if os.path.exists(secret_file): + with open(secret_file) as f: + s = f.read().strip() + if len(s) >= 32: + return s + s = "rinet-pgz-" + secrets.token_urlsafe(48) + with open(secret_file, "w") as f: + f.write(s) + os.chmod(secret_file, 0o600) + return s + except Exception: + return "rinet-pgz-jwt-2026-fallback-" + hashlib.sha256(b"pgz-sport").hexdigest() + +JWT_SECRET = _load_secret() +JWT_ALG = "HS256" +ACCESS_TTL = timedelta(minutes=int(os.environ.get("PGZ_JWT_ACCESS_MIN", "30"))) +REFRESH_TTL = timedelta(days=int(os.environ.get("PGZ_JWT_REFRESH_DAYS", "7"))) + +router = APIRouter(prefix="/api/auth", tags=["auth_v2"]) + +# ─────────────────────────── DB helpers ─────────────────────────── +def _conn(): + return psycopg2.connect(**DB) + +def db_query(sql: str, params=()): + with _conn() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(sql, params) + if cur.description: return cur.fetchall() + return [] + +def db_one(sql: str, params=()): + rows = db_query(sql, params) + return rows[0] if rows else None + +def db_exec(sql: str, params=()): + with _conn() as c: + cur = c.cursor() + cur.execute(sql, params) + if cur.description: + r = cur.fetchone() + return r[0] if r else None + c.commit() + +# ─────────────────────────── Password helpers ─────────────────────────── +def _sha256(pw: str) -> str: + return hashlib.sha256(pw.encode()).hexdigest() + +def hash_password(pw: str) -> str: + if HAS_BCRYPT: + return _bcrypt.using(rounds=12).hash(pw) + return _sha256(pw) + +def verify_password(pw: str, hashed: Optional[str]) -> bool: + if not hashed: return False + h = hashed.strip() + if h.startswith("$2") and HAS_BCRYPT: + try: + return _bcrypt.verify(pw, h) + except Exception: + return False + return h == _sha256(pw) + +def needs_rehash(hashed: Optional[str]) -> bool: + if not hashed: return True + return HAS_BCRYPT and not hashed.startswith("$2") + +# ─────────────────────────── Tenant resolution ─────────────────────────── +PGZ_USER_TYPES = {"super_admin", "pgz_admin", "pgz_user", "pgz_finance", "pgz_zzjz"} +SAVEZ_USER_TYPES = {"savez_admin", "savez_user"} +KLUB_USER_TYPES = {"klub_admin", "klub_user", "klub_trener", "klub_clan"} + +def _tier_for(user_type: str) -> int: + ut = (user_type or "").lower() + if ut in PGZ_USER_TYPES: return 0 + if ut in SAVEZ_USER_TYPES: return 1 + if ut in KLUB_USER_TYPES: return 2 + return 9 # unknown / viewer / guest + +def _resolve_tenant(u: Dict) -> Dict: + """Resolve tenant_id + tenant_type from a user row.""" + ut = (u.get("user_type") or "").lower() + klub_id = u.get("klub_id") + savez_id = u.get("savez_id") + if ut in PGZ_USER_TYPES: + row = db_one("SELECT id, slug, display_name FROM pgz_sport.tenants WHERE slug='pgz' LIMIT 1") + return { + "tenant_id": row["id"] if row else None, + "tenant_type": "pgz", + "tenant_name": row["display_name"] if row else "PGŽ", + "tenant_scope": {"klub_id": None, "savez_id": None}, + } + if ut in SAVEZ_USER_TYPES and savez_id: + return { + "tenant_id": savez_id, + "tenant_type": "savez", + "tenant_name": (db_one("SELECT naziv FROM pgz_sport.savezi WHERE id=%s",(savez_id,)) or {}).get("naziv"), + "tenant_scope": {"klub_id": None, "savez_id": savez_id}, + } + if ut in KLUB_USER_TYPES and klub_id: + return { + "tenant_id": klub_id, + "tenant_type": "klub", + "tenant_name": (db_one("SELECT naziv FROM pgz_sport.klubovi WHERE id=%s",(klub_id,)) or {}).get("naziv"), + "tenant_scope": {"klub_id": klub_id, "savez_id": savez_id}, + } + # super_admin without context + if ut == "super_admin": + return {"tenant_id": None, "tenant_type": "global", + "tenant_name": "Global", "tenant_scope": {"klub_id": None, "savez_id": None}} + return {"tenant_id": None, "tenant_type": "viewer", + "tenant_name": None, "tenant_scope": {"klub_id": klub_id, "savez_id": savez_id}} + +# ─────────────────────────── JWT issue / verify ─────────────────────────── +def _now() -> datetime: return datetime.now(timezone.utc) + +def _new_jti() -> str: return secrets.token_urlsafe(16) + +def make_access_token(u: Dict, jti: str) -> str: + tenant = _resolve_tenant(u) + tier = _tier_for(u.get("user_type") or "") + now = _now() + payload = { + "sub": str(u["id"]), + "uid": u["id"], + "email": u["email"], + "name": u.get("full_name") or ((u.get("ime") or "") + " " + (u.get("prezime") or "")).strip() or u["email"], + "tenant_id": tenant["tenant_id"], + "tenant_type": tenant["tenant_type"], + "tenant_name": tenant["tenant_name"], + "tenant_scope": tenant["tenant_scope"], + "role": u.get("user_type") or "viewer", + "tier": tier, + "jti": jti, + "typ": "access", + "iat": int(now.timestamp()), + "nbf": int(now.timestamp()), + "exp": int((now + ACCESS_TTL).timestamp()), + } + return _jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG) + +def make_refresh_token(uid: int, jti: str) -> str: + now = _now() + return _jwt.encode({ + "sub": str(uid), "uid": uid, "jti": jti, "typ": "refresh", + "iat": int(now.timestamp()), + "exp": int((now + REFRESH_TTL).timestamp()), + }, JWT_SECRET, algorithm=JWT_ALG) + +def decode_token(token: str) -> Dict: + try: + return _jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG]) + except _jwt.ExpiredSignatureError: + raise HTTPException(401, "Token expired") + except Exception as e: + raise HTTPException(401, f"Invalid token: {e}") + +def _record_session(uid: int, jti: str, expires: datetime, ip: str = None, ua: str = None): + th = hashlib.sha256(jti.encode()).hexdigest() + db_exec("""INSERT INTO pgz_sport.user_sessions + (user_id, token_hash, device_info, ip_address, expires_at, revoked) + VALUES (%s,%s,%s,%s::inet,%s,false) + ON CONFLICT (token_hash) DO NOTHING""", + (uid, th, ua, ip, expires)) + +def _is_revoked(jti: str) -> bool: + th = hashlib.sha256(jti.encode()).hexdigest() + r = db_one("SELECT revoked FROM pgz_sport.user_sessions WHERE token_hash=%s", (th,)) + if not r: return False + return bool(r.get("revoked")) + +def _revoke_jti(jti: str): + th = hashlib.sha256(jti.encode()).hexdigest() + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE token_hash=%s", (th,)) + +# ─────────────────────────── current_user dep ─────────────────────────── +def _extract_token(authorization: Optional[str]) -> Optional[str]: + if not authorization: return None + return authorization.replace("Bearer ", "").strip() or None + +def get_current_user(authorization: Optional[str] = Header(None)) -> Optional[Dict]: + token = _extract_token(authorization) + if not token: return None + try: + payload = decode_token(token) + except HTTPException: + return None + if payload.get("typ") not in (None, "access"): + return None + if _is_revoked(payload.get("jti","")): + return None + uid = payload.get("uid") or int(payload.get("sub", 0) or 0) + u = db_one("""SELECT id, email, full_name, ime, prezime, user_type, + klub_id, savez_id, status, aktivan, must_change_pwd + FROM pgz_sport.users WHERE id=%s""", (uid,)) + if not u or u.get("status") != "active" or not u.get("aktivan", True): + return None + u["_jwt"] = payload + u["_token"] = token + return u + +def require_user(user = Depends(get_current_user)) -> Dict: + if not user: + raise HTTPException(401, "Authentication required") + return user + +def require_role(roles: List[str]): + def dep(user = Depends(require_user)): + if user.get("user_type") not in roles: + raise HTTPException(403, f"Forbidden — required: {','.join(roles)}") + return user + return dep + +# ─────────────────────────── Audit ─────────────────────────── +def audit(user_id: Optional[int], action: str, resource_type: str = None, + resource_id: int = None, meta: Dict = None, ip: str = None, ua: str = None): + try: + db_exec("""INSERT INTO pgz_sport.audit_events + (user_id, action, resource_type, resource_id, meta, ip_address, user_agent) + VALUES (%s,%s,%s,%s,%s::jsonb,%s::inet,%s)""", + (user_id, action, resource_type, resource_id, + json.dumps(meta or {}), ip, ua)) + except Exception as e: + print(f"[AUDIT WARN] {e}") + +def _client(req: Request): + ip = (req.headers.get("x-forwarded-for") or req.client.host or "").split(",")[0].strip() or None + ua = req.headers.get("user-agent") + return ip, ua + +# ─────────────────────────── Schemas ─────────────────────────── +class LoginReq(BaseModel): + email: str + password: str + +class RefreshReq(BaseModel): + refresh_token: str + +class ChangePwdReq(BaseModel): + old_password: Optional[str] = None + new_password: str + +class ResetPwdReq(BaseModel): + email: str + +# ─────────────────────────── Endpoints ─────────────────────────── +@router.post("/login") +def login(req: LoginReq, request: Request): + ip, ua = _client(request) + email = (req.email or "").lower().strip() + if not email or not req.password: + raise HTTPException(400, "Email i lozinka obavezni") + + u = db_one("""SELECT id, email, full_name, ime, prezime, password_hash, status, + user_type, klub_id, savez_id, aktivan, must_change_pwd, + failed_login_count, locked_until + FROM pgz_sport.users WHERE LOWER(email)=%s""", (email,)) + if not u: + audit(None, "login.fail", meta={"email": email, "reason": "no_user"}, ip=ip, ua=ua) + raise HTTPException(401, "Neispravni podaci") + if u.get("locked_until"): + lu = u["locked_until"] + if lu.tzinfo is None: lu = lu.replace(tzinfo=timezone.utc) + if lu > _now(): + audit(u["id"], "login.locked", ip=ip, ua=ua) + raise HTTPException(423, "Račun privremeno zaključan") + if u.get("status") != "active" or not u.get("aktivan", True): + audit(u["id"], "login.fail", meta={"reason":"inactive"}, ip=ip, ua=ua) + raise HTTPException(403, "Račun nije aktivan") + if not verify_password(req.password, u.get("password_hash")): + db_exec("""UPDATE pgz_sport.users + SET failed_login_count = COALESCE(failed_login_count,0)+1, + locked_until = CASE WHEN COALESCE(failed_login_count,0)+1>=5 + THEN now()+interval '15 minutes' ELSE locked_until END + WHERE id=%s""", (u["id"],)) + audit(u["id"], "login.fail", meta={"reason":"bad_password"}, ip=ip, ua=ua) + raise HTTPException(401, "Neispravni podaci") + + # opportunistic rehash to bcrypt + if needs_rehash(u.get("password_hash")): + try: + db_exec("UPDATE pgz_sport.users SET password_hash=%s WHERE id=%s", + (hash_password(req.password), u["id"])) + except Exception: pass + + db_exec("""UPDATE pgz_sport.users + SET failed_login_count=0, locked_until=NULL, last_login=now() + WHERE id=%s""", (u["id"],)) + + jti = _new_jti() + rjti = _new_jti() + access = make_access_token(u, jti) + refresh = make_refresh_token(u["id"], rjti) + _record_session(u["id"], jti, _now() + ACCESS_TTL, ip=ip, ua=ua) + _record_session(u["id"], rjti, _now() + REFRESH_TTL, ip=ip, ua=(ua or "") + " [refresh]") + audit(u["id"], "login.ok", ip=ip, ua=ua) + + tenant = _resolve_tenant(u) + return { + "access_token": access, + "refresh_token": refresh, + "token_type": "Bearer", + "expires_in": int(ACCESS_TTL.total_seconds()), + "user": { + "id": u["id"], "email": u["email"], + "full_name": u.get("full_name") or (u.get("ime","") + " " + u.get("prezime","")).strip(), + "role": u.get("user_type"), "tier": _tier_for(u.get("user_type") or ""), + "must_change_pwd": bool(u.get("must_change_pwd")), + **tenant, + }, + } + +@router.post("/refresh") +def refresh(req: RefreshReq, request: Request): + payload = decode_token(req.refresh_token) + if payload.get("typ") != "refresh": + raise HTTPException(401, "Invalid refresh token") + if _is_revoked(payload.get("jti","")): + raise HTTPException(401, "Refresh token revoked") + uid = payload.get("uid") or int(payload.get("sub", 0) or 0) + u = db_one("""SELECT id, email, full_name, ime, prezime, user_type, + klub_id, savez_id, status, aktivan, must_change_pwd + FROM pgz_sport.users WHERE id=%s""", (uid,)) + if not u or u.get("status") != "active" or not u.get("aktivan", True): + raise HTTPException(401, "User inactive") + ip, ua = _client(request) + new_jti = _new_jti() + access = make_access_token(u, new_jti) + _record_session(u["id"], new_jti, _now() + ACCESS_TTL, ip=ip, ua=ua) + audit(u["id"], "auth.refresh", ip=ip, ua=ua) + return {"access_token": access, "token_type": "Bearer", + "expires_in": int(ACCESS_TTL.total_seconds())} + +@router.post("/logout") +def logout(request: Request, user = Depends(require_user)): + jti = (user.get("_jwt") or {}).get("jti") + if jti: _revoke_jti(jti) + # Also revoke refresh tokens for this user (best-effort) + db_exec("""UPDATE pgz_sport.user_sessions SET revoked=true + WHERE user_id=%s AND device_info LIKE %s""", + (user["id"], "%[refresh]%")) + ip, ua = _client(request) + audit(user["id"], "logout", ip=ip, ua=ua) + return {"status": "ok"} + +@router.get("/me") +def me(user = Depends(require_user)): + enriched = db_one("""SELECT id, email, full_name, ime, prezime, user_type, + klub_id, savez_id, must_change_pwd, aktivan, status, + last_login, oib, telefon, phone, preferred_language, created_at + FROM pgz_sport.users WHERE id=%s""", (user["id"],)) + if not enriched: + raise HTTPException(404, "User not found") + tenant = _resolve_tenant(enriched) + roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id + FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id + WHERE ur.user_id=%s AND ur.active=true""", (user["id"],)) + return {**enriched, + "tier": _tier_for(enriched.get("user_type") or ""), + "must_change_pwd": bool(enriched.get("must_change_pwd")), + **tenant, "roles": roles} + +@router.post("/password/change") +def change_password(req: ChangePwdReq, request: Request, user = Depends(require_user)): + if len(req.new_password) < 8: + raise HTTPException(400, "Lozinka mora imati barem 8 znakova") + cur = db_one("SELECT password_hash, must_change_pwd FROM pgz_sport.users WHERE id=%s", + (user["id"],)) + if not cur: raise HTTPException(404, "User not found") + if not cur.get("must_change_pwd"): + if not req.old_password: + raise HTTPException(400, "old_password obavezan") + if not verify_password(req.old_password, cur.get("password_hash")): + raise HTTPException(401, "Stara lozinka netočna") + db_exec("""UPDATE pgz_sport.users + SET password_hash=%s, must_change_pwd=false, updated_at=now() + WHERE id=%s""", (hash_password(req.new_password), user["id"])) + ip, ua = _client(request) + audit(user["id"], "password.change", ip=ip, ua=ua) + return {"status": "ok"} + +@router.post("/password/reset") +def password_reset(req: ResetPwdReq, request: Request): + """Issue a temporary password (admin-equivalent self-reset; logged).""" + email = (req.email or "").lower().strip() + u = db_one("SELECT id, email, aktivan FROM pgz_sport.users WHERE LOWER(email)=%s", + (email,)) + ip, ua = _client(request) + audit(u["id"] if u else None, "password.reset.request", + meta={"email": email, "found": bool(u)}, ip=ip, ua=ua) + # Generic response — do not leak which emails exist + return {"status": "ok", + "message": "Ako račun postoji, administrator će vam poslati instrukcije."} + +# ─────────────────────────── 2FA placeholders (TOTP) ─────────────────────────── +@router.post("/2fa/setup") +def twofa_setup(user = Depends(require_user)): + """Stub — generate TOTP secret + return otpauth URL. + Full TOTP verification will be added in M1.5.""" + secret = secrets.token_hex(20).upper() + db_exec("""ALTER TABLE pgz_sport.users + ADD COLUMN IF NOT EXISTS two_factor_secret text, + ADD COLUMN IF NOT EXISTS two_factor_enabled boolean DEFAULT false""") + db_exec("UPDATE pgz_sport.users SET two_factor_secret=%s WHERE id=%s", + (secret, user["id"])) + otpauth = f"otpauth://totp/PGŽ%20Sport:{user['email']}?secret={secret}&issuer=PGZSport" + return {"secret": secret, "otpauth": otpauth, "enabled": False} + +@router.post("/2fa/verify") +def twofa_verify(code: str = Body(..., embed=True), user = Depends(require_user)): + return {"status": "stub", "verified": False, "code_received": bool(code)} diff --git a/auth/gdpr.py b/auth/gdpr.py new file mode 100644 index 0000000..a0d46e0 --- /dev/null +++ b/auth/gdpr.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +# gdpr.py — GDPR endpoints: export, erasure, consent, audit (M10) +# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04 +""" +GET /api/gdpr/export (current user — Art. 20 portability) +POST /api/gdpr/erase (current user — Art. 17 erasure request) +POST /api/gdpr/consent (cookie / processing consent log) +GET /api/gdpr/consent +GET /api/gdpr/policy (returns text URL/markdown) +GET /api/admin/gdpr/erasure-requests (PGŽ admin) +POST /api/admin/gdpr/erasure-requests/{id}/process +""" +import json +from datetime import datetime +from typing import Optional, Dict, List +from fastapi import APIRouter, HTTPException, Depends, Request, Body +from pydantic import BaseModel +from fastapi.responses import JSONResponse + +from .auth_v2 import ( + db_query, db_one, db_exec, + require_user, audit, _client, +) +from .admin_users import _is_pgz_admin + +router = APIRouter(prefix="/api/gdpr", tags=["gdpr"]) +admin_router = APIRouter(prefix="/api/admin/gdpr", tags=["gdpr_admin"]) + +# Ensure GDPR tables exist (idempotent) +def _ensure_tables(): + try: + db_exec("""CREATE TABLE IF NOT EXISTS pgz_sport.gdpr_consent ( + id BIGSERIAL PRIMARY KEY, + user_id INTEGER REFERENCES pgz_sport.users(id) ON DELETE CASCADE, + session_id TEXT, + ip TEXT, + necessary BOOLEAN DEFAULT true, + analytics BOOLEAN DEFAULT false, + marketing BOOLEAN DEFAULT false, + consent_at TIMESTAMPTZ DEFAULT now(), + policy_version TEXT DEFAULT 'v1', + user_agent TEXT + )""") + db_exec("""CREATE INDEX IF NOT EXISTS idx_gdpr_consent_user ON pgz_sport.gdpr_consent(user_id)""") + db_exec("""CREATE TABLE IF NOT EXISTS pgz_sport.gdpr_erasure_requests ( + id BIGSERIAL PRIMARY KEY, + user_id INTEGER REFERENCES pgz_sport.users(id) ON DELETE CASCADE, + email TEXT, + requested_at TIMESTAMPTZ DEFAULT now(), + reason TEXT, + status TEXT DEFAULT 'pending', -- pending|approved|denied|completed + processed_by INTEGER REFERENCES pgz_sport.users(id), + processed_at TIMESTAMPTZ, + note TEXT + )""") + db_exec("""ALTER TABLE pgz_sport.users + ADD COLUMN IF NOT EXISTS gdpr_consent_at TIMESTAMPTZ""") + except Exception as e: + print(f"[GDPR migration WARN] {e}") + +_ensure_tables() + +POLICY_VERSION = "v1" + +# ─────────────────────────── Cookie / consent ─────────────────────────── +class ConsentReq(BaseModel): + necessary: bool = True + analytics: bool = False + marketing: bool = False + session_id: Optional[str] = None + policy_version: Optional[str] = None + +@router.post("/consent") +def post_consent(req: ConsentReq, request: Request): + """Record a consent event. Works for anonymous (session_id only) or logged-in users.""" + user = None + auth = request.headers.get("authorization") + if auth: + from .auth_v2 import get_current_user + user = get_current_user(authorization=auth) + ip, ua = _client(request) + uid = user["id"] if user else None + db_exec("""INSERT INTO pgz_sport.gdpr_consent + (user_id, session_id, ip, necessary, analytics, marketing, policy_version, user_agent) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""", + (uid, req.session_id, ip, req.necessary, req.analytics, req.marketing, + req.policy_version or POLICY_VERSION, ua)) + if uid: + db_exec("UPDATE pgz_sport.users SET gdpr_consent_at=now() WHERE id=%s", (uid,)) + audit(uid, "gdpr.consent", meta={ + "necessary": req.necessary, "analytics": req.analytics, + "marketing": req.marketing, "session_id": req.session_id}, ip=ip, ua=ua) + return {"status": "ok", "policy_version": POLICY_VERSION} + +@router.get("/consent") +def get_consent(user = Depends(require_user)): + rows = db_query("""SELECT necessary, analytics, marketing, consent_at, + policy_version, ip, session_id + FROM pgz_sport.gdpr_consent WHERE user_id=%s + ORDER BY consent_at DESC LIMIT 50""", (user["id"],)) + return {"current": rows[0] if rows else None, "history": rows} + +@router.get("/policy") +def get_policy(): + return { + "version": POLICY_VERSION, + "url": "https://api.rinet.one/sport/static/privacy.html", + "rights": [ + "Art. 15 — Pravo na pristup", + "Art. 16 — Pravo na ispravak", + "Art. 17 — Pravo na brisanje", + "Art. 18 — Pravo na ograničenje obrade", + "Art. 20 — Pravo na prenosivost podataka", + "Art. 21 — Pravo na prigovor", + ], + "controller": "Primorsko-goranska županija — Odjel za sport", + "contact": "gdpr@pgz.hr", + "dpo": "Damir Radulić (damir@rinet.one)", + } + +# ─────────────────────────── Article 20 — data export ─────────────────────────── +@router.get("/export") +def export_my_data(user = Depends(require_user)): + """Return all data we hold about the calling user — JSON dump.""" + uid = user["id"] + profile = db_one("""SELECT id, email, full_name, ime, prezime, oib, telefon, phone, + user_type, klub_id, savez_id, status, aktivan, last_login, created_at, + preferred_language, gdpr_consent_at + FROM pgz_sport.users WHERE id=%s""", (uid,)) + sessions = db_query("""SELECT id, device_info, ip_address::text AS ip, + created_at, expires_at, revoked + FROM pgz_sport.user_sessions WHERE user_id=%s ORDER BY created_at DESC""", (uid,)) + audit_rows = db_query("""SELECT id, action, resource_type, resource_id, + ts AS created_at, ip_address::text AS ip, user_agent, meta + FROM pgz_sport.audit_events WHERE user_id=%s ORDER BY ts DESC LIMIT 1000""", (uid,)) + consent = db_query("""SELECT necessary, analytics, marketing, consent_at, + policy_version FROM pgz_sport.gdpr_consent WHERE user_id=%s + ORDER BY consent_at DESC""", (uid,)) + klub_links = db_query("""SELECT klub_id, savez_id, link_type, role, + primary_klub, granted_at, od_datuma, do_datuma + FROM pgz_sport.user_klub_links WHERE user_id=%s""", (uid,)) + roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id, + ur.granted_at, ur.expires_at, ur.active + FROM pgz_sport.user_roles ur + JOIN pgz_sport.roles r ON r.id=ur.role_id + WHERE ur.user_id=%s""", (uid,)) + audit(uid, "gdpr.export") + return { + "exported_at": datetime.utcnow().isoformat() + "Z", + "policy_version": POLICY_VERSION, + "subject": profile, + "sessions": sessions, + "audit_events": audit_rows, + "consent_history": consent, + "klub_links": klub_links, + "roles": roles, + } + +# ─────────────────────────── Article 17 — erasure request ─────────────────────────── +class EraseReq(BaseModel): + reason: Optional[str] = None + confirm_email: Optional[str] = None + +@router.post("/erase") +def request_erasure(req: EraseReq, request: Request, user = Depends(require_user)): + if req.confirm_email and req.confirm_email.lower().strip() != user["email"].lower(): + raise HTTPException(400, "confirm_email se ne poklapa") + ip, ua = _client(request) + new_id = db_one("""INSERT INTO pgz_sport.gdpr_erasure_requests + (user_id, email, reason, status) VALUES (%s,%s,%s,'pending') RETURNING id""", + (user["id"], user["email"], req.reason))["id"] + audit(user["id"], "gdpr.erasure.request", "user", user["id"], + {"reason": req.reason}, ip, ua) + return {"status": "ok", "request_id": new_id, + "message": "Vaš zahtjev je zaprimljen i bit će obrađen unutar 30 dana."} + +# ─────────────────────────── Admin: erasure queue ─────────────────────────── +@admin_router.get("/erasure-requests") +def list_erasure_requests(status: Optional[str] = None, + actor = Depends(require_user)): + if not _is_pgz_admin(actor): + raise HTTPException(403, "PGŽ admin only") + where, args = ["1=1"], [] + if status: where.append("er.status=%s"); args.append(status) + rows = db_query(f"""SELECT er.id, er.user_id, er.email, er.requested_at, + er.reason, er.status, er.processed_by, er.processed_at, er.note, + u.full_name + FROM pgz_sport.gdpr_erasure_requests er + LEFT JOIN pgz_sport.users u ON u.id=er.user_id + WHERE {' AND '.join(where)} + ORDER BY er.requested_at DESC""", tuple(args)) + return {"count": len(rows), "results": rows} + +class ProcessEraseReq(BaseModel): + decision: str # 'approve' | 'deny' + note: Optional[str] = None + anonymize: bool = True + +@admin_router.post("/erasure-requests/{rid}/process") +def process_erasure(rid: int, req: ProcessEraseReq, request: Request, + actor = Depends(require_user)): + if not _is_pgz_admin(actor): + raise HTTPException(403, "PGŽ admin only") + er = db_one("SELECT * FROM pgz_sport.gdpr_erasure_requests WHERE id=%s", (rid,)) + if not er: raise HTTPException(404, "Request not found") + if er["status"] != "pending": + raise HTTPException(400, f"Already {er['status']}") + if req.decision == "approve": + if req.anonymize and er["user_id"]: + db_exec("""UPDATE pgz_sport.users SET + email = CONCAT('erased-', id, '@anonymous.gdpr'), + full_name = 'Erased', + ime = NULL, prezime = NULL, oib = NULL, + telefon = NULL, phone = NULL, password_hash = NULL, + aktivan = false, status = 'erased', + google_sub = NULL, google_picture = NULL, + updated_at = now() + WHERE id=%s""", (er["user_id"],)) + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", + (er["user_id"],)) + new_status = "completed" + else: + new_status = "denied" + db_exec("""UPDATE pgz_sport.gdpr_erasure_requests + SET status=%s, processed_by=%s, processed_at=now(), note=%s + WHERE id=%s""", (new_status, actor["id"], req.note, rid)) + ip, ua = _client(request) + audit(actor["id"], "gdpr.erasure.process", "user", er["user_id"] or 0, + {"request_id": rid, "decision": req.decision, "note": req.note}, ip, ua) + return {"status": new_status, "id": rid} diff --git a/auth/seed_demo.py b/auth/seed_demo.py new file mode 100644 index 0000000..e904890 --- /dev/null +++ b/auth/seed_demo.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# seed_demo.py — Demo tenants & users for Round 3 (M1+M2+M10) +# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04 +""" +Seeds: +- 3 tenants: PGŽ (existing), Atletski savez PGŽ, AK Kvarner Rijeka +- Demo users: + damir@pgz.hr / PGZ2026! (pgz_admin) ← KEY DEMO + pero@atletika.pgz.hr/ PGZ2026! (savez_admin) + ana@akkvarner.hr / PGZ2026! (klub_admin) + sportas@akkvarner.hr/ PGZ2026! (klub_clan) +""" +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from auth.auth_v2 import db_query, db_one, db_exec, hash_password + +def get_or_create_tenant(slug, display_name, ttype, oib=None): + row = db_one("SELECT id FROM pgz_sport.tenants WHERE slug=%s", (slug,)) + if row: return row["id"] + return db_one("""INSERT INTO pgz_sport.tenants (slug, display_name, type, oib, status) + VALUES (%s,%s,%s,%s,'active') RETURNING id""", + (slug, display_name, ttype, oib))["id"] + +def get_or_create_savez(naziv, sport, oib=None): + row = db_one("SELECT id FROM pgz_sport.savezi WHERE naziv=%s LIMIT 1", (naziv,)) + if row: return row["id"] + return db_one("""INSERT INTO pgz_sport.savezi (naziv, sport, oib, aktivan) + VALUES (%s,%s,%s,true) RETURNING id""", (naziv, sport, oib))["id"] + +def get_or_create_klub(naziv, sport, grad, savez_id, oib=None, tenant_id=None): + row = db_one("SELECT id FROM pgz_sport.klubovi WHERE naziv=%s LIMIT 1", (naziv,)) + if row: return row["id"] + return db_one("""INSERT INTO pgz_sport.klubovi + (naziv, sport, grad, savez_id, oib, tenant_id, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,true) RETURNING id""", + (naziv, sport, grad, savez_id, oib, tenant_id))["id"] + +def upsert_user(email, password, full_name, ime, prezime, user_type, + klub_id=None, savez_id=None): + pw_hash = hash_password(password) + row = db_one("SELECT id FROM pgz_sport.users WHERE LOWER(email)=%s", + (email.lower(),)) + if row: + db_exec("""UPDATE pgz_sport.users SET + password_hash=%s, full_name=%s, ime=%s, prezime=%s, + user_type=%s, klub_id=%s, savez_id=%s, + aktivan=true, status='active', must_change_pwd=false, + failed_login_count=0, locked_until=NULL, + updated_at=now() WHERE id=%s""", + (pw_hash, full_name, ime, prezime, user_type, + klub_id, savez_id, row["id"])) + return row["id"], "updated" + new_id = db_one("""INSERT INTO pgz_sport.users + (email, password_hash, full_name, ime, prezime, user_type, klub_id, savez_id, + aktivan, status, must_change_pwd, auth_provider, email_verified) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,true,'active',false,'local',true) + RETURNING id""", + (email.lower(), pw_hash, full_name, ime, prezime, + user_type, klub_id, savez_id))["id"] + return new_id, "created" + +def main(): + print("== Tenants ==") + pgz_id = get_or_create_tenant("pgz", "Primorsko-goranska županija", "county") + atletski_id = get_or_create_tenant("atletski_savez_pgz", "Atletski savez PGŽ", "federation") + ak_kvarner_t = get_or_create_tenant("ak_kvarner_rijeka", "AK Kvarner Rijeka", "club") + print(f" pgz tenant: {pgz_id}") + print(f" atletski_savez_pgz tenant: {atletski_id}") + print(f" ak_kvarner_rijeka tenant: {ak_kvarner_t}") + + print("== Savezi ==") + atletski_savez = get_or_create_savez("Atletski savez Primorsko-goranske županije", "Atletika") + print(f" atletski_savez id: {atletski_savez}") + + print("== Klub ==") + ak_klub = get_or_create_klub("Atletski klub Kvarner Rijeka", "Atletika", + "Rijeka", atletski_savez, tenant_id=ak_kvarner_t) + print(f" AK Kvarner: {ak_klub}") + + print("== Users ==") + users = [ + ("damir@pgz.hr", "PGZ2026!", "Damir Radulić", "Damir", "Radulić", "pgz_admin", None, None), + ("pero@atletika.pgz.hr", "PGZ2026!", "Pero Perić", "Pero", "Perić", "savez_admin", None, atletski_savez), + ("ana@akkvarner.hr", "PGZ2026!", "Ana Anić", "Ana", "Anić", "klub_admin", ak_klub, atletski_savez), + ("sportas@akkvarner.hr", "PGZ2026!", "Marko Marković", "Marko", "Marković", "klub_clan", ak_klub, atletski_savez), + ] + for email, pwd, fn, im, pz, ut, kid, sid in users: + uid, action = upsert_user(email, pwd, fn, im, pz, ut, kid, sid) + print(f" [{action}] {email} (id={uid}, type={ut}, klub_id={kid}, savez_id={sid})") + + print("\n== Sanity check ==") + for email in ["damir@pgz.hr","pero@atletika.pgz.hr","ana@akkvarner.hr","sportas@akkvarner.hr"]: + u = db_one("SELECT id, email, user_type, klub_id, savez_id, aktivan FROM pgz_sport.users WHERE LOWER(email)=%s", (email,)) + print(f" {email}: {u}") + +if __name__ == "__main__": + main() diff --git a/pgz_sport_api.py b/pgz_sport_api.py index 597b05b..0c50b27 100644 --- a/pgz_sport_api.py +++ b/pgz_sport_api.py @@ -933,21 +933,7 @@ def google_auth(token: str = Body(..., embed=True)): except Exception as e: raise HTTPException(401, f"Google auth failed: {e}") -@app.get("/api/auth/me") -def auth_me(authorization: Optional[str] = Header(None)): - """Get current user info from JWT.""" - if not authorization: return {"role": "viewer", "email": None, "name": None} - token = authorization.replace("Bearer ", "").strip() - # Try JWT first - try: - payload = _jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) - return {"role": payload.get("role"), "email": payload.get("email"), "name": payload.get("name")} - except Exception: - pass - # Legacy demo token - if token == ADMIN_TOKEN: - return {"role": "admin", "email": "demo@admin", "name": "Demo Admin"} - return {"role": "viewer", "email": None, "name": None} +# /api/auth/me handled by auth.auth_v2 router (M1) # ==================== STATIC ==================== import pathlib @@ -1422,6 +1408,29 @@ try: except Exception as e: print(f'[CRM/M9] obrasci 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 + app.include_router(gdpr_router) + app.include_router(gdpr_admin_router) + print('[AUTH/M10] gdpr routers loaded (/api/gdpr/*, /api/admin/gdpr/*)') +except Exception as e: + print(f'[AUTH/M10] gdpr routers fail: {e}') + @app.get("/sport-3d") @@ -1511,30 +1520,7 @@ def get_user(token): return payload except: return None -# ── AUTH: Email/Password login ────────────────────────────────── -@app.post("/api/auth/login") -def login(body: dict = Body(...)): - email = (body.get("email","")).lower().strip() - pwd = body.get("password","") - if not email or not pwd: raise HTTPException(400,"Email i lozinka obavezni") - rows = fetch("SELECT * FROM pgz_sport.users WHERE LOWER(email)=%s AND aktivan=TRUE",[email]) - if not rows: raise HTTPException(401,"Neispravni podaci") - u = rows[0] - ph = hashlib.sha256(pwd.encode()).hexdigest() - if u.get("password_hash") != ph: raise HTTPException(401,"Neispravni podaci") - payload = {"uid":u["id"],"email":email,"name":u.get("full_name",email), - "role":u.get("user_type","viewer"),"klub_id":u.get("klub_id"), - "savez_id":u.get("savez_id"),"iat":int(__import__("time").time()), - "exp":int(__import__("time").time())+86400*7} - tok = _jwt.encode(payload, JWT_SECRET, algorithm="HS256") - try: - with db() as conn: - cur=conn.cursor() - cur.execute("UPDATE pgz_sport.users SET last_login=NOW() WHERE id=%s",[u["id"]]) - conn.commit() - except: pass - return {"token":tok,"role":payload["role"],"name":payload["name"], - "email":email,"klub_id":payload["klub_id"],"savez_id":payload["savez_id"]} +# ── AUTH: Email/Password login — handled by auth.auth_v2 router (M1) ── # ── SPORTAS FULL PROFILE ───────────────────────────────────────── @app.get("/api/sportas/{clan_id}/profil") diff --git a/routers/enrich_router.py b/routers/enrich_router.py index 8da932b..3cefb82 100644 --- a/routers/enrich_router.py +++ b/routers/enrich_router.py @@ -1,8 +1,9 @@ """ -enrich_router.py — Round-2 enrichment endpoint -Author: dradulic@outlook.com Date: 2026-05-04 +enrich_router.py — Round-2/3B enrichment + forensic-scan endpoints +Author: dradulic@outlook.com Date: 2026-05-04 (R2), 2026-05-05 (R3B) -Surfaces "Obogati podatke" buttons for klubovi, savezi, sportasi. +Surfaces "Obogati podatke" buttons for klubovi, savezi, sportasi, plus +the Forenzika "Pokreni novu analizu" scan endpoint that searches civic.*. Strategy: 1) Read what's already in DB and surface fields the frontend may not have shown. @@ -10,18 +11,28 @@ Strategy: HNS Semafor) so the operator can verify or expand by hand. 3) If the entity has a `web` URL set, quickly fetch the page and extract + <meta description> to return as a "live snippet". 5s timeout, fail-soft. + 4) /forensic/scan — match name across civic.persons, return entity links, + forensic_findings hits, and a synthesised risk score. + 5) /enrich/{kind}/{id}/apply — fetch best web source for entity and UPDATE the + row's web/email/telefon fields when missing. """ import os, re, json, time, urllib.parse, urllib.request, html import psycopg2, psycopg2.extras -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Body router = APIRouter() -DB = dict(host=os.environ.get('PG_HOST','10.10.0.2'), - port=int(os.environ.get('PG_PORT','6432')), +_pgh = os.environ.get('PG_HOST','10.10.0.2') +_pgp = int(os.environ.get('PG_PORT','6432')) +# pgz-sport.service inherits PG_HOST=localhost:5432 from /opt/.env.rinet which is wrong +# (local PG is disabled). Force the Server B DSN if env says localhost. +if _pgh in ('localhost', '127.0.0.1'): + _pgh = os.environ.get('DB_HOST','10.10.0.2') + _pgp = int(os.environ.get('DB_PORT','6432')) +DB = dict(host=_pgh, port=_pgp, dbname=os.environ.get('PG_DB','rinet_v3'), user=os.environ.get('PG_USER','rinet'), - password=os.environ.get('PG_PASS','')) + password=os.environ.get('PG_PASS','R1net2026!SecureDB#v7')) UA = 'pgz-sport-enrich/2.0' @@ -132,3 +143,168 @@ def enrich(kind: str, eid: int): 'research_links': _research_links(naziv, kind, grad), 'enriched_at': int(time.time()), } + + +# ── R3B P4 — FORENSIC SCAN ────────────────────────────────────────── +@router.post("/forensic/scan") +def forensic_scan(req: dict = Body(...)): + """ + Search civic.persons by name. For each match, gather entities, person + role, forensic_findings count, and synthesise a risk score. + Body: {"name": "Velimir Liverić"} + """ + name = (req.get('name') or '').strip() + if len(name) < 3: + raise HTTPException(400, "name must be at least 3 chars") + + with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute(""" + SELECT id, name, function, party, county, city, oib, trust_tier + FROM civic.persons + WHERE upper(name) ILIKE upper(%s) + ORDER BY oib NULLS LAST, id + LIMIT 25 + """, ('%'+name+'%',)) + persons = [dict(r) for r in cur.fetchall()] + + # For each person collect entity links via OIB + for p in persons: + p['links'] = [] + p['findings'] = [] + if p.get('oib'): + cur.execute(""" + SELECT pel.entity_id, pel.roles, e.name AS entity_name, e.oib AS entity_oib, + e.entity_type, e.city, e.risk_score + FROM civic.person_entity_links pel + LEFT JOIN civic.entities e ON e.id = pel.entity_id + WHERE pel.person_oib = %s + LIMIT 50 + """, (p['oib'],)) + p['links'] = [dict(r) for r in cur.fetchall()] + # Forensic findings JSONB containing this OIB + cur.execute(""" + SELECT id, finding_type, severity, title, severity_score, created_at + FROM civic.forensic_findings + WHERE entities_involved::text ILIKE %s + ORDER BY severity_score DESC, created_at DESC + LIMIT 30 + """, ('%'+p['oib']+'%',)) + p['findings'] = [dict(r) for r in cur.fetchall()] + # Also search forensic_findings by name + if not p['findings']: + cur.execute(""" + SELECT id, finding_type, severity, title, severity_score, created_at + FROM civic.forensic_findings + WHERE title ILIKE %s OR description ILIKE %s + ORDER BY severity_score DESC, created_at DESC + LIMIT 30 + """, ('%'+p['name']+'%', '%'+p['name']+'%')) + p['findings'] = [dict(r) for r in cur.fetchall()] + + # Synthesise risk score per person and overall + total_links = 0 + total_findings = 0 + crit_findings = 0 + for p in persons: + total_links += len(p.get('links') or []) + for f in p.get('findings') or []: + total_findings += 1 + if f.get('severity') in ('CRITICAL','HIGH'): + crit_findings += 1 + # per-person risk: 30 base if PEP-like (function set), +5 per link, +10 per finding, +20 per crit + score = 0 + if (p.get('function') or '').strip(): + score += 30 + if (p.get('party') or '').strip(): + score += 15 + score += min(40, len(p.get('links') or [])*5) + score += min(40, len(p.get('findings') or [])*10) + score += sum(20 for f in (p.get('findings') or []) if f.get('severity') in ('CRITICAL','HIGH')) + p['risk_score'] = min(100, score) + + overall = 0 + if persons: + overall = max(p.get('risk_score',0) for p in persons) + + return { + 'query': name, + 'matched_persons': len(persons), + 'overall_risk_score': overall, + 'total_links': total_links, + 'total_findings': total_findings, + 'critical_findings': crit_findings, + 'persons': persons, + 'scanned_at': int(time.time()), + } + + +# ── R3B P6 — ENRICH /apply (write enriched fields back to DB) ─────── +@router.post("/enrich/{kind}/{eid}/apply") +def enrich_apply(kind: str, eid: int, req: dict = Body(default={})): + """ + Apply enrichment to DB. Body may contain {fields: {web, email, telefon}} + to override the auto-derived suggestions; otherwise we apply derived ones. + Only updates fields that are currently NULL or empty in DB (additive only). + """ + if kind not in ('klub','savez','sportas'): + raise HTTPException(400, "kind must be klub|savez|sportas") + body_fields = (req.get('fields') if isinstance(req, dict) else {}) or {} + + if kind == 'klub': + table = 'pgz_sport.klubovi' + cols = ['web','email','telefon'] + elif kind == 'savez': + table = 'pgz_sport.savezi' + cols = ['web','email','telefon'] + else: + table = 'pgz_sport.clanovi' + cols = ['biografija','profile_url'] + + with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute(f"SELECT * FROM {table} WHERE id=%s", (eid,)) + row = cur.fetchone() + if not row: raise HTTPException(404, kind+" not found") + row = dict(row) + + # Try a live fetch from primary URL to glean email/phone + primary = row.get('web') or row.get('web_stranica') or row.get('source_url') or row.get('scrape_url') or row.get('profile_url') + derived = {} + if primary: + snippet = _fetch_title(primary, timeout=6) + try: + if snippet and snippet.get('url'): + req2 = urllib.request.Request(primary, headers={'User-Agent': UA}) + with urllib.request.urlopen(req2, timeout=6) as r: + page = r.read(80000).decode('utf-8','ignore') + em = re.search(r'[\w\.-]+@[\w\.-]+\.[a-z]{2,8}', page, re.I) + if em: derived['email'] = em.group(0) + tel = re.search(r'\+?385[\s\-]?\d[\d\s\-/]{6,}', page) + if tel: derived['telefon'] = re.sub(r'\s+', ' ', tel.group(0).strip()) + except Exception: + pass + + # Merge: body fields override derived + proposed = dict(derived) + for k, v in (body_fields or {}).items(): + if k in cols and v: + proposed[k] = v + + # Only apply where DB currently empty + applied = {} + for k, v in proposed.items(): + if k in cols and (row.get(k) is None or row.get(k)==''): + applied[k] = v + + if applied: + sets = ', '.join([f"{k}=%s" for k in applied]) + params = list(applied.values()) + [eid] + cur.execute(f"UPDATE {table} SET {sets} WHERE id=%s", params) + c.commit() + + return { + 'kind': kind, 'id': eid, + 'proposed': proposed, + 'applied': applied, + 'skipped_existing': [k for k in proposed if k not in applied], + 'applied_at': int(time.time()), + } diff --git a/static/sport2.html b/static/sport2.html index 1e8dbbc..93dc41e 100644 --- a/static/sport2.html +++ b/static/sport2.html @@ -219,7 +219,7 @@ a.tag:hover,.tag[onclick]:hover{transform:translateY(-1px);filter:brightness(1.1 <div class="sb-h"> <div class="logo">PGŽ <span class="g">SPORT</span></div> <div class="sub">Primorsko-goranska županija</div> - <div class="sb-toggle" id="sb-toggle" onclick="toggleSidebar()" title="Skupi/raširi">⮜</div> + <div class="sb-toggle" id="sb-toggle" onclick="toggleSidebar()" title="Skupi/raširi sidebar">≡</div> </div> <nav class="sb-nav" id="nav"></nav> <div class="sb-foot">v2.0 · 2026</div> @@ -475,7 +475,7 @@ function toggleSidebar(){ const tg = document.getElementById('sb-toggle'); if(!sb) return; const isCollapsed = sb.classList.toggle('collapsed'); - if(tg) tg.textContent = isCollapsed ? '⮞' : '⮜'; + if(tg) tg.textContent = '≡'; try { localStorage.setItem('sidebar-state', isCollapsed ? 'collapsed' : 'expanded'); } catch(e){} } function restoreSidebar(){ @@ -485,7 +485,7 @@ function restoreSidebar(){ const sb = document.getElementById('sb'); const tg = document.getElementById('sb-toggle'); if(sb) sb.classList.add('collapsed'); - if(tg) tg.textContent = '⮞'; + if(tg) tg.textContent = '≡'; } } catch(e){} } diff --git a/swarm.sh b/swarm.sh new file mode 100755 index 0000000..20cb4fc --- /dev/null +++ b/swarm.sh @@ -0,0 +1,101 @@ +#!/bin/bash +# CC SWARM MONITOR v2 — radni tiled prikaz + +case "${1:-help}" in + view|loop) + while true; do + clear + echo "╔════════════════════════════════════════════════════════════════════╗" + echo "║ PGŽ SPORT — CC SWARM ($(date '+%Y-%m-%d %H:%M:%S')) ║" + echo "╚════════════════════════════════════════════════════════════════════╝" + for s in cc1 cc2 cc3 cc4 cc5 cc6; do + echo + echo "─── [$s] ───────────────────────────────────────────────────────" + tmux capture-pane -t ${s}:0 -p 2>/dev/null | grep -v "^─*$" | grep -v "^$" | tail -5 | sed 's/^/ /' + done + echo + echo "════════════════════════════════════════════════════════════════════" + echo "Git: $(cd /opt/pgz-sport && git log --oneline -1 2>/dev/null)" + echo "Refresh 30s | Ctrl+C izlaz" + echo "════════════════════════════════════════════════════════════════════" + sleep 30 + done + ;; + + tiled|tile) + # Stvori SAMO display sesiju koja gleda svih 6 odjednom + tmux kill-session -t swarm-view 2>/dev/null + + # Nova sesija + tmux new-session -d -s swarm-view -x 240 -y 60 + + # Koristi watch da gleda capture-pane svakih 2s — to RADI bez nestanja + tmux send-keys -t swarm-view:0 'watch -n 2 -t "echo === CC1 ===; tmux capture-pane -t cc1:0 -p | tail -8"' Enter + + # Split na 6 panela + tmux split-window -h -t swarm-view:0 + tmux send-keys -t swarm-view:0.1 'watch -n 2 -t "echo === CC2 ===; tmux capture-pane -t cc2:0 -p | tail -8"' Enter + + tmux split-window -v -t swarm-view:0.0 + tmux send-keys -t swarm-view:0.2 'watch -n 2 -t "echo === CC3 ===; tmux capture-pane -t cc3:0 -p | tail -8"' Enter + + tmux split-window -v -t swarm-view:0.1 + tmux send-keys -t swarm-view:0.3 'watch -n 2 -t "echo === CC4 ===; tmux capture-pane -t cc4:0 -p | tail -8"' Enter + + tmux split-window -v -t swarm-view:0.0 + tmux send-keys -t swarm-view:0.4 'watch -n 2 -t "echo === CC5 ===; tmux capture-pane -t cc5:0 -p | tail -8"' Enter + + tmux split-window -v -t swarm-view:0.1 + tmux send-keys -t swarm-view:0.5 'watch -n 2 -t "echo === CC6 ===; tmux capture-pane -t cc6:0 -p | tail -8"' Enter + + tmux select-layout -t swarm-view tiled + + echo "═════════════════════════════════════════════════" + echo " Sesija swarm-view kreirana s 6 panela" + echo "═════════════════════════════════════════════════" + echo " Pogledaj: tmux attach -t swarm-view" + echo " Detach: Ctrl+B pa D" + echo "═════════════════════════════════════════════════" + + # Auto-attach + tmux attach -t swarm-view + ;; + + status|s) + for s in cc1 cc2 cc3 cc4 cc5 cc6; do + echo "=== $s ===" + tmux capture-pane -t ${s}:0 -p 2>/dev/null | tail -8 + echo + done + ;; + + git|log) + cd /opt/pgz-sport + echo "═══ COMMITS ═══" + git log --oneline -20 + ;; + + cc1|1) tmux attach -t cc1 ;; + cc2|2) tmux attach -t cc2 ;; + cc3|3) tmux attach -t cc3 ;; + cc4|4) tmux attach -t cc4 ;; + cc5|5) tmux attach -t cc5 ;; + cc6|6) tmux attach -t cc6 ;; + + *) + cat << 'HELP' +═══════════════════════════════════════════════════════════════════════ + CC SWARM — PGŽ SPORT +═══════════════════════════════════════════════════════════════════════ + bash swarm.sh tiled 6 panela live prikaz (ATTACHA ODMAH) + bash swarm.sh view Auto-refresh 30s (cijela slika u 1 ekranu) + bash swarm.sh status Brzi snapshot + bash swarm.sh git Git log + bash swarm.sh cc1..6 Attach na specifični agent + + Detach iz attached: Ctrl+B pa D + Switch tmux panel: Ctrl+B pa strijelica +═══════════════════════════════════════════════════════════════════════ +HELP + ;; +esac