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