diff --git a/_backups/r3_cc4/pgz_sport_api.py.pre_M5.1777931436 b/_backups/r3_cc4/pgz_sport_api.py.pre_M5.1777931436 new file mode 100644 index 0000000..a0221ec --- /dev/null +++ b/_backups/r3_cc4/pgz_sport_api.py.pre_M5.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_cc4/sport2.html.pre_M5.1777931436 b/_backups/r3_cc4/sport2.html.pre_M5.1777931436 new file mode 100644 index 0000000..824e137 --- /dev/null +++ b/_backups/r3_cc4/sport2.html.pre_M5.1777931436 @@ -0,0 +1,2257 @@ + + +
+ + +