From 8fe2478b84a4a1ab86bd366c841be77100d6ee48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Raduli=C4=87?= Date: Tue, 5 May 2026 00:20:03 +0200 Subject: [PATCH] CC2 R3 frontend: login.html + admin_users.html (M1+M2+M10 UI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - static/login.html: dark Palantir-style login with PGŽ branding, Prijava se / Zaboravljena lozinka, demo account quick-fills, GDPR cookie banner, autostore tokens (local/session) - static/admin_users.html: full user-management admin panel: - Collapsible left sidebar (Pregled, Korisnici, Tenanti, Audit log, Sigurnost, GDPR, links to ERP/CRM) - Users table with filters (q, tenant, role, status, limit) - + Dodaj korisnika modal (CRUD via /api/admin/users/*) - Suspend / unsuspend / reset-password / delete actions - Audit log viewer + Security KPIs + GDPR queue - Self-service: change pwd, export data (Art. 20), erasure request (Art. 17) - pgz_sport_api.py: /login and /admin/users URL routes - auth/seed_demo.py: added tajnik@atletski.pgz.hr/Atl2026!, admin@ak-kvarner.hr/Kvarner2026! demo users 5/5 live tests pass: login JWT, /me, /admin/users, /gdpr/consent, /gdpr/export Note: existing admin.html (CC4 ERP/OCR work) preserved intact; admin_users.html is dedicated user-mgmt page linked from sidebar. --- _backups/admin.html.dashboard_v1.1777932732 | 577 ++++++ .../pgz_sport_api.py.pre_M6_2.1777932631 | 1668 +++++++++++++++++ .../lijecnicki_router.py.post_m9.1777932881 | 569 ++++++ .../obrasci_router.py.post_m9.1777932881 | 757 ++++++++ .../pgz_sport_api.py.post_m9.1777932881 | 1668 +++++++++++++++++ .../r3_cc5/pgz_sport_api.py.pre_m9.1777932632 | 1668 +++++++++++++++++ auth/seed_demo.py | 14 +- blockchain/seal.py | 13 +- erp/putni_nalozi.py | 58 +- pgz_sport_api.py | 26 + routers/audit_seal_router.py | 96 + static/admin.html | 266 +++ static/admin_users.html | 765 ++++++++ static/crm.html | 974 ++++++++++ static/erp.html | 386 ++++ static/login.html | 538 ++++++ static/sport2.html | 7 +- 17 files changed, 10013 insertions(+), 37 deletions(-) create mode 100644 _backups/admin.html.dashboard_v1.1777932732 create mode 100644 _backups/r3_cc4/pgz_sport_api.py.pre_M6_2.1777932631 create mode 100644 _backups/r3_cc5/lijecnicki_router.py.post_m9.1777932881 create mode 100644 _backups/r3_cc5/obrasci_router.py.post_m9.1777932881 create mode 100644 _backups/r3_cc5/pgz_sport_api.py.post_m9.1777932881 create mode 100644 _backups/r3_cc5/pgz_sport_api.py.pre_m9.1777932632 create mode 100644 routers/audit_seal_router.py create mode 100644 static/admin_users.html create mode 100644 static/crm.html create mode 100644 static/erp.html create mode 100644 static/login.html diff --git a/_backups/admin.html.dashboard_v1.1777932732 b/_backups/admin.html.dashboard_v1.1777932732 new file mode 100644 index 0000000..65c73a2 --- /dev/null +++ b/_backups/admin.html.dashboard_v1.1777932732 @@ -0,0 +1,577 @@ + + + + + +PGŽ Sport · Admin Dashboard + + + + + + +
+ + + +
+
+

Dashboard

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

Top Klubovi (po aktivnosti)

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

📷 OCR — Skeniraj račun (gorivo, cestarina, hotel…)

+
+
+
Povuci PDF/JPG/PNG ovdje ili klikni za odabir
+
Tesseract OCR + DeepSeek V3 izvuče izdavatelja, OIB, datum, iznos, PDV, IBAN, stavke
+ +
+
+ +
+ + +
+

🚗 Novi putni nalog (HR pravilnik 2025)

+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ Unesi datume za live obračun dnevnica… +
+
+ + +
+
+ +
+

Računi

+
BrojDobavljačKlubIznosStatusDatum
+
+
+

Putni nalozi / izdaci

+
BrojKlubDestinacijaIznosStatusDatum
+
+
+ + +
+ +
+

Klubovi

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

Kontakti / Članovi

+
ImePrezimeOIBKlubPozicijaEmailStatus
+
+
+ + +
+
+

3D Sport Graph

+

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

+
+ +
+
+
+ + +
+
+

Multi-tenant Management

+

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

+
+
+
+ + +
+
+

Top 10 Klubova (po dokumentima i računima)

+
NazivSportGradRačuniČlanovi
+
+
+ +
+
+ + + + diff --git a/_backups/r3_cc4/pgz_sport_api.py.pre_M6_2.1777932631 b/_backups/r3_cc4/pgz_sport_api.py.pre_M6_2.1777932631 new file mode 100644 index 0000000..0c50b27 --- /dev/null +++ b/_backups/r3_cc4/pgz_sport_api.py.pre_M6_2.1777932631 @@ -0,0 +1,1668 @@ +#!/usr/bin/env python3 +""" +pgz_sport_api.py - FastAPI backend za PGŽ Sportski savez ERP/CRM +Author: Damir Radulić (damir@rinet.one) +Date: 25.04.2026 +Port: 8095 +Endpoints: savezi, klubovi, članovi, članarine, liječnički, manifestacije, proračun, dashboard, alertovi +""" + +from fastapi import FastAPI, HTTPException, Query, Body, Header, Depends, UploadFile, File, Form, Request +import json +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import Optional, List +from datetime import date, datetime +import psycopg2 +import psycopg2.extras +from pgz_sport_v2_router import router as v2_router +import os + +DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7') + + +ADMIN_TOKEN = 'admin-pgz-2026' + +def is_admin(authorization): + if not authorization: return False + token = authorization.replace('Bearer ', '').strip() + if token == ADMIN_TOKEN: return True + # Try JWT + try: + import jwt as _jwt + payload = _jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return payload.get("role") == "admin" + except Exception: + return False + +def blur_oib(v): + if not v: return v + s = str(v); + return s[:3] + '•'*(len(s)-5) + s[-2:] if len(s) >= 8 else '•'*len(s) +def blur_email(e): + if not e or '@' not in str(e): return e + u, d = str(e).split('@',1); return (u[:1]+'•••' if u else '')+'@'+d +def blur_phone(p): + if not p: return p + s=str(p); return s[:4]+'•'*(len(s)-7)+s[-3:] if len(s)>=7 else s +def blur_iban(v): + if not v: return v + s=str(v); return s[:4]+'•'*(len(s)-8)+s[-4:] if len(s)>=8 else s +def blur_date(d): + if not d: return d + s = str(d); return s[:4]+'-••-••' if len(s)>=4 else s +def blur_text(t, keep=3): + if not t: return t + s=str(t); return s[:keep]+'•'*(len(s)-keep*2)+s[-keep:] if len(s)>keep*2 else s + +def apply_privacy(rows, admin): + if admin: return rows + out = [] + for r in (rows if isinstance(rows, list) else [rows]): + rr = dict(r) + for k, v in list(rr.items()): + if v is None: continue + kl = k.lower() + if 'oib' in kl: rr[k] = blur_oib(v) + elif 'email' in kl: rr[k] = blur_email(v) + elif kl in ('telefon','tel','phone'): rr[k] = blur_phone(v) + elif kl == 'datum_rodenja': rr[k] = blur_date(v) + elif 'iban' in kl: rr[k] = blur_iban(v) + elif kl == 'adresa': rr[k] = blur_text(v, 3) + elif 'licenca_broj' in kl: rr[k] = blur_text(v, 2) + out.append(rr) + return out if isinstance(rows, list) else out[0] + +app = FastAPI(title="PGŽ Sportski savez ERP/CRM", version="1.0.0") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + + +# === URL rewrite middleware - convert direct external image URLs to /img-proxy === +import json as _json_mw +import re as _re_mw +from starlette.responses import Response as _StarletteResponse_mw + +_IMG_DOMAINS_RE = _re_mw.compile( + r'https?://(?:hns\.family|hns\.hr|hbs\.hr|hrvatski-bocarski-savez\.hr|' + r'rk-zamet\.hr|hvs\.hr|rezultati\.hvs\.hr|sport-pgz\.hr)' + r'/[^"\s\\]+\.(?:jpg|jpeg|png|gif|webp|svg)', + _re_mw.IGNORECASE +) + +def _rewrite_to_proxy(text: str) -> str: + """Replace external image URLs with /sport/api/v2/img-proxy?u=...""" + from urllib.parse import quote as _q + def _sub(m): + url = m.group(0) + return "/sport/api/v2/img-proxy?u=" + _q(url, safe='') + return _IMG_DOMAINS_RE.sub(_sub, text) + +@app.middleware("http") +async def url_rewrite_middleware(request, call_next): + response = await call_next(request) + # Only rewrite JSON API responses + ct = response.headers.get("content-type", "") + if "application/json" not in ct: + return response + # Only on /api/v2 routes (admin & data endpoints) - SKIP /api/v2/img-proxy itself + path = request.url.path + if "/api/v2/img-proxy" in path or "/api/v2/dokumenti" in path: + return response # don't rewrite raw document content + # Read body + body = b"" + async for chunk in response.body_iterator: + body += chunk + try: + text = body.decode("utf-8") + new_text = _rewrite_to_proxy(text) + new_body = new_text.encode("utf-8") + except Exception: + new_body = body + return _StarletteResponse_mw( + content=new_body, + status_code=response.status_code, + headers={k: v for k, v in response.headers.items() if k.lower() not in ("content-length",)}, + media_type=ct, + ) +# === end URL rewrite middleware === + +def db(): + conn = psycopg2.connect(**DB) + conn.autocommit = True + return conn + +def fetch(sql, params=None): + with db() as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as c: + c.execute(sql, params or ()) + return [dict(r) for r in c.fetchall()] + +def execute(sql, params=None): + with db() as conn: + with conn.cursor() as c: + c.execute(sql, params or ()) + return c.rowcount + +# ==================== HEALTH ==================== +@app.get("/health") +def health(): + try: + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + return {"status": "ok", "service": "pgz_sport", "dashboard": rows[0] if rows else None} + except Exception as e: + raise HTTPException(500, f"DB error: {e}") + + +@app.get("/api/whoami") +def whoami_v2(authorization: Optional[str] = Header(None)): + return {"role": "admin" if is_admin(authorization) else "viewer", "privacy_active": not is_admin(authorization)} + +# ==================== DASHBOARD ==================== +@app.get("/api/dashboard") +def dashboard(): + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + if not rows: + return {} + d = rows[0] + # Top savezi by registriranih 2024 + top = fetch("""SELECT s.naziv, st.klubova_clanica, st.registriranih, st.trenera, st.reprezentativaca + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina=2024 ORDER BY st.registriranih DESC LIMIT 10""") + proracun_trend = fetch("SELECT godina, ukupno FROM pgz_sport.proracun ORDER BY godina") + nositelji = fetch("""SELECT naziv_kluba, godina, iznos FROM pgz_sport.potpore_nositelji + WHERE godina = 2025 ORDER BY iznos DESC LIMIT 10""") + return {**d, "top_savezi": top, "proracun_trend": proracun_trend, "nositelji_2025": nositelji} + +@app.get("/api/dashboard/ekosustav") +def dashboard_ekosustav(): + """Sport ekosustav PGŽ — coverage stats za enrichment iz FINA registra.""" + summary = fetch("""SELECT + COUNT(*) AS klubova_total, + COUNT(*) FILTER (WHERE oib IS NOT NULL) AS s_oib, + COUNT(*) FILTER (WHERE predsjednik IS NOT NULL) AS s_predsjednik, + COUNT(*) FILTER (WHERE tajnik IS NOT NULL) AS s_tajnik, + COUNT(*) FILTER (WHERE ciljevi IS NOT NULL) AS s_ciljevi, + COUNT(*) FILTER (WHERE opis_djelatnosti IS NOT NULL) AS s_opis, + COUNT(*) FILTER (WHERE sjediste IS NOT NULL) AS s_sjediste, + COUNT(*) FILTER (WHERE email IS NOT NULL) AS s_email, + COUNT(*) FILTER (WHERE web_stranica IS NOT NULL) AS s_web, + COUNT(*) FILTER (WHERE udruga_status = \'AKTIVAN\') AS s_aktivan_reg, + COUNT(*) FILTER (WHERE savez_id IS NOT NULL) AS s_savez, + COUNT(*) FILTER (WHERE nositelj_kvalitete) AS s_nositelj + FROM pgz_sport.klubovi WHERE aktivan""")[0] + + by_sport = fetch("""SELECT sport, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND sport IS NOT NULL + GROUP BY sport ORDER BY COUNT(*) DESC LIMIT 15""") + + by_region = fetch("""SELECT region, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND region IS NOT NULL + GROUP BY region ORDER BY COUNT(*) DESC""") + + by_grad = fetch("""SELECT grad, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND grad IS NOT NULL + GROUP BY grad ORDER BY COUNT(*) DESC LIMIT 12""") + + decade = fetch("""SELECT + CASE + WHEN godina_osnutka < 1950 THEN \'pred1950\' + WHEN godina_osnutka < 1980 THEN \'1950-1979\' + WHEN godina_osnutka < 2000 THEN \'1980-1999\' + WHEN godina_osnutka < 2010 THEN \'2000-2009\' + WHEN godina_osnutka >= 2010 THEN \'2010-danas\' + ELSE \'nepoznato\' + END AS razdoblje, + COUNT(*) AS broj + FROM pgz_sport.klubovi + WHERE aktivan AND godina_osnutka IS NOT NULL + GROUP BY razdoblje ORDER BY razdoblje""") + + # Pokazi enrichment % + total = summary["klubova_total"] or 1 + coverage = { + "oib_pct": round(100 * summary["s_oib"] / total, 1), + "predsjednik_pct": round(100 * summary["s_predsjednik"] / total, 1), + "tajnik_pct": round(100 * summary["s_tajnik"] / total, 1), + "ciljevi_pct": round(100 * summary["s_ciljevi"] / total, 1), + "opis_pct": round(100 * summary["s_opis"] / total, 1), + "sjediste_pct": round(100 * summary["s_sjediste"] / total, 1), + "email_pct": round(100 * summary["s_email"] / total, 1), + "savez_pct": round(100 * summary["s_savez"] / total, 1), + } + + return {**summary, "coverage": coverage, "by_sport": by_sport, + "by_region": by_region, "by_grad": by_grad, "by_decade": decade} + + + +# ==================== ANALYTICS ==================== +@app.get("/api/analytics/savezi-trend") +def savezi_trend(godine: str = "2020,2021,2022,2023,2024", metric: str = "registriranih"): + valid_metrics = {"registriranih", "neregistriranih", "rekreativaca", "trenera", "reprezentativaca", + "kategoriziranih", "stipendiranih", "klubova_clanica"} + if metric not in valid_metrics: + raise HTTPException(400, f"Invalid metric. Must be one of: {valid_metrics}") + god_list = [int(g) for g in godine.split(",")] + rows = fetch(f"""SELECT s.naziv AS savez, st.godina, st.{metric} AS value + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina = ANY(%s) ORDER BY s.naziv, st.godina""", [god_list]) + saveze = {} + for r in rows: + if r['savez'] not in saveze: saveze[r['savez']] = {} + saveze[r['savez']][r['godina']] = r['value'] + return {"metric": metric, "godine": god_list, "data": saveze} + +@app.get("/api/analytics/proracun-detaljno") +def proracun_detaljno(): + p = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + if not p: return {"proracun": [], "rast_godisnji": [], "current_year": None, "current_total": 0, "rast_dekada_pct": 0} + cagr = [] + for i in range(1, len(p)): + prev = float(p[i-1]['ukupno']) if p[i-1]['ukupno'] else 0 + curr = float(p[i]['ukupno']) if p[i]['ukupno'] else 0 + rate = ((curr/prev - 1) * 100) if prev > 0 else 0 + cagr.append({"godina": p[i]['godina'], "rast_postotak": round(rate, 1)}) + decade_rast = round((float(p[-1]['ukupno'])/float(p[0]['ukupno']) - 1) * 100, 1) if p[0]['ukupno'] else 0 + return {"proracun": p, "rast_godisnji": cagr, "rast_dekada_pct": decade_rast, + "current_year": int(p[-1]['godina']), "current_total": float(p[-1]['ukupno'])} + +@app.get("/api/analytics/klub-financije") +def klub_financije(klub_id: Optional[int] = None, godina: Optional[int] = None): + where = [] + params = [] + if godina: where.append("p.godina=%s"); params.append(godina) + if klub_id: + where.append("(p.klub_id=%s OR p.naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))") + params.extend([klub_id, klub_id]) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"""SELECT p.naziv_kluba, p.godina, p.iznos, + k.id AS klub_id, k.sport, k.razina, k.nositelj_kvalitete + FROM pgz_sport.potpore_nositelji p + LEFT JOIN pgz_sport.klubovi k ON p.klub_id=k.id OR p.naziv_kluba=k.naziv + {where_sql} ORDER BY p.godina DESC, p.iznos DESC""", params) + summary = fetch(f"""SELECT godina, SUM(iznos) AS total, COUNT(*) AS klubova, AVG(iznos) AS prosjek + FROM pgz_sport.potpore_nositelji p {where_sql} + GROUP BY godina ORDER BY godina""", params) + return {"data": rows, "summary": summary} + +@app.get("/api/analytics/lijecnicki-stats") +def lijecnicki_stats(klub_id: Optional[int] = None): + where = ["1=1"]; params = [] + if klub_id: where.append("c.klub_id=%s"); params.append(klub_id) + where_sql = " AND ".join(where) + rows = fetch(f"""SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE lp.vrijedi_do >= CURRENT_DATE + 30) AS validni, + COUNT(*) FILTER (WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + 30) AS uskoro, + COUNT(*) FILTER (WHERE lp.vrijedi_do < CURRENT_DATE) AS istekli, + SUM(lp.iznos) AS ukupan_trosak, SUM(lp.iznos_zzjz) AS zzjz_udio, + SUM(lp.iznos_klub) AS klub_udio, SUM(lp.iznos_clan) AS clan_udio, + AVG(lp.iznos) AS prosjecni_trosak + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id WHERE {where_sql}""", params) + by_ustanova = fetch(f"""SELECT lp.ustanova, COUNT(*) cnt, SUM(lp.iznos) iznos + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} GROUP BY lp.ustanova ORDER BY cnt DESC""", params) + by_lijecnik = fetch(f"""SELECT lp.lijecnik, COUNT(*) cnt, AVG(lp.iznos) prosjek + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} AND lp.lijecnik IS NOT NULL GROUP BY lp.lijecnik ORDER BY cnt DESC""", params) + return {"summary": rows[0] if rows else {}, "by_ustanova": by_ustanova, "by_lijecnik": by_lijecnik} + +# ==================== SAVEZI ==================== +@app.get("/api/savezi") +def list_savezi(authorization: Optional[str] = Header(None), q: Optional[str] = None, + razina: Optional[str] = None, zupanija: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = "WHERE aktivan" + params = [] + if q: + where += " AND (naziv ILIKE %s OR sport ILIKE %s)" + params = [f"%{q}%", f"%{q}%"] + if razina: + where += " AND razina = %s"; params.append(razina) + if zupanija: + where += " AND sjediste_zupanija ILIKE %s"; params.append(f"%{zupanija}%") + sort_col = {"naziv": "naziv", "godina": "godina_osnutka", "sport": "sport", "razina": "razina"}.get(sort, "naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + # Croatian collation for text columns (Š → after S, Č → after C, etc.) + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("naziv", "sport") else "" + rows = fetch(f"""SELECT s.*, + (SELECT COUNT(*) FROM pgz_sport.klubovi WHERE savez_id=s.id) AS broj_klubova, + (SELECT registriranih FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS reg_2024, + (SELECT trenera FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS treneri_2024, + (SELECT reprezentativaca FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS repr_2024 + FROM pgz_sport.savezi s {where} ORDER BY {sort_col}{collate} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/savezi/{savez_id}") +def get_savez(savez_id: int): + rows = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s", [savez_id]) + if not rows: + raise HTTPException(404, "Savez ne postoji") + klubovi = fetch("SELECT * FROM pgz_sport.klubovi WHERE savez_id=%s ORDER BY naziv", [savez_id]) + statistika = fetch("SELECT * FROM pgz_sport.statistika_saveza WHERE savez_id=%s ORDER BY godina", [savez_id]) + manifestacije = fetch("SELECT * FROM pgz_sport.manifestacije WHERE savez_id=%s", [savez_id]) + return {**rows[0], "klubovi": klubovi, "statistika": statistika, "manifestacije": manifestacije} + +# ==================== KLUBOVI ==================== +@app.get("/api/klubovi") +def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, savez_id: Optional[int] = None, + nositelj: Optional[bool] = None, region: Optional[str] = None, sport: Optional[str] = None, grad: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivan"] + params = [] + if q: + where.append("(klub ILIKE %s OR oib ILIKE %s OR sport ILIKE %s OR predsjednik ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%", f"%{q}%"]) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + if nositelj is not None: + where.append(f"nositelj_kvalitete={'TRUE' if nositelj else 'FALSE'}") + if region: + where.append("region ILIKE %s"); params.append(region) + if grad: + where.append("grad ILIKE %s"); params.append(f"%{grad}%") + if sport: + where.append("sport ILIKE %s"); params.append(f"%{sport}%") + sort_col = {"naziv": "klub", "savez": "savez", "broj_clanova": "broj_clanova", + "razina": "razina", "region": "region", "grad": "grad", "sport": "sport"}.get(sort, "klub") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("klub", "savez", "razina", "region", "grad", "sport") else "" + rows = fetch(f"""SELECT * FROM pgz_sport.v_klubovi_pregled WHERE {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST""", params) + for r in rows: + if isinstance(r, dict) and r.get('klub') and not r.get('naziv'): + r['naziv'] = r['klub'] + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/klubovi/{klub_id}") +def get_klub(klub_id: int, authorization: Optional[str] = Header(None)): + admin = is_admin(authorization) + rows = fetch("""SELECT k.*, s.naziv AS savez_naziv FROM pgz_sport.klubovi k + LEFT JOIN pgz_sport.savezi s ON s.id=k.savez_id WHERE k.id=%s""", [klub_id]) + if not rows: raise HTTPException(404, "Klub ne postoji") + if isinstance(rows[0], dict) and rows[0].get('klub') and not rows[0].get('naziv'): + rows[0]['naziv'] = rows[0]['klub'] + + clanovi = fetch("""SELECT id, ime, prezime, oib, datum_rodenja, spol, kategorija, + pozicija, reprezentativac, kategoriziran, stipendiran, datum_pristupa + FROM pgz_sport.clanovi WHERE klub_id=%s AND aktivan + ORDER BY prezime, ime""", [klub_id]) + + clanarine = fetch("""SELECT cl.id, cl.godina, cl.razdoblje, cl.iznos_propisan, cl.iznos_placen, + (cl.iznos_propisan - cl.iznos_placen) AS dug, cl.datum_uplate, cl.status, cl.napomena, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib + FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE c.klub_id=%s ORDER BY cl.godina DESC, cl.id DESC""", [klub_id]) + + lijecnicki = fetch("""SELECT lp.id, lp.datum_pregleda, lp.vrijedi_do, lp.vrsta_pregleda, + lp.ustanova, lp.lijecnik, lp.spreman_za_natjecanje, lp.iznos, lp.iznos_zzjz, lp.iznos_klub, lp.iznos_clan, + lp.placeno, lp.komentar_lijecnika, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib, + CASE WHEN lp.vrijedi_do IS NULL THEN 'Nepoznato' + WHEN lp.vrijedi_do < CURRENT_DATE THEN 'Istekao' + WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'Ističe uskoro' + ELSE 'Validan' END AS status_pregled + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s ORDER BY lp.datum_pregleda DESC""", [klub_id]) + + potpore = fetch("""SELECT * FROM pgz_sport.potpore_nositelji + WHERE klub_id=%s OR naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s) + ORDER BY godina DESC""", [klub_id, klub_id]) + + # Aggregate stats + stats = { + 'broj_clanova': len(clanovi), + 'broj_registriranih': sum(1 for c in clanovi if c.get('kategorija')=='registrirani'), + 'broj_trenera': sum(1 for c in clanovi if c.get('kategorija')=='trener'), + 'broj_reprezentativaca': sum(1 for c in clanovi if c.get('reprezentativac')), + 'broj_kategoriziranih': sum(1 for c in clanovi if c.get('kategoriziran')), + 'broj_stipendiranih': sum(1 for c in clanovi if c.get('stipendiran')), + 'lijecnicki_validni': sum(1 for l in lijecnicki if l.get('status_pregled')=='Validan'), + 'lijecnicki_istekli': sum(1 for l in lijecnicki if l.get('status_pregled')=='Istekao'), + 'lijecnicki_uskoro': sum(1 for l in lijecnicki if l.get('status_pregled')=='Ističe uskoro'), + 'clanarina_naplaceno_god': sum(float(c.get('iznos_placen') or 0) for c in clanarine if c.get('godina')==2026), + 'clanarina_dug_god': sum(float(c.get('dug') or 0) for c in clanarine if c.get('godina')==2026), + 'potpore_2025': float(next((p['iznos'] for p in potpore if p.get('godina')==2025), 0) or 0), + 'potpore_total': sum(float(p.get('iznos') or 0) for p in potpore), + 'zzjz_isplaceno': sum(float(l.get('iznos_zzjz') or 0) for l in lijecnicki if l.get('placeno')), + } + + klub = rows[0] + if not admin: + klub = apply_privacy(klub, admin) + clanovi = apply_privacy(clanovi, admin) + clanarine = apply_privacy(clanarine, admin) + lijecnicki = apply_privacy(lijecnicki, admin) + + return {**klub, "clanovi": clanovi, "clanarine": clanarine, "lijecnicki": lijecnicki, + "potpore": potpore, "stats": stats} + + +class KlubIn(BaseModel): + naziv: str + savez_id: Optional[int] = None + sport: Optional[str] = None + oib: Optional[str] = None + razina: Optional[str] = None + nositelj_kvalitete: Optional[bool] = False + grad: Optional[str] = None + region: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + predsjednik: Optional[str] = None + iban: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/klubovi") +def create_klub(k: KlubIn): + rows = fetch("""INSERT INTO pgz_sport.klubovi (naziv, savez_id, sport, oib, razina, nositelj_kvalitete, grad, region, email, telefon, predsjednik, iban, napomena, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE) RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena]) + return rows[0] + +@app.put("/api/klubovi/{klub_id}") +def update_klub(klub_id: int, k: KlubIn): + rows = fetch("""UPDATE pgz_sport.klubovi SET naziv=%s, savez_id=%s, sport=%s, oib=%s, razina=%s, + nositelj_kvalitete=%s, grad=%s, region=%s, email=%s, telefon=%s, predsjednik=%s, iban=%s, napomena=%s, + updated_at=NOW() WHERE id=%s RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena, klub_id]) + if not rows: + raise HTTPException(404, "Klub ne postoji") + return rows[0] + +# ==================== ČLANOVI ==================== +@app.get("/api/clanovi") +def list_clanovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, klub_id: Optional[int] = None, + kategorija: Optional[str] = None, spol: Optional[str] = None, sort: str = "prezime", order: str = "asc"): + where = ["c.aktivan"] + params = [] + if q: + where.append("(c.ime ILIKE %s OR c.prezime ILIKE %s OR c.oib ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%"]) + if klub_id: + where.append("c.klub_id=%s"); params.append(klub_id) + if kategorija: + where.append("c.kategorija=%s"); params.append(kategorija) + if spol: + # Normalize: Z → Ž, F → Ž (legacy) + spol_norm = "Ž" if spol.upper() in ("Z","Ž","F","W") else "M" if spol.upper() in ("M",) else spol + where.append("c.spol=%s"); params.append(spol_norm) + sort_map = {"prezime": "c.prezime", "ime": "c.ime", "oib": "c.oib", "datum_rodenja": "c.datum_rodenja", "kategorija": "c.kategorija", "klub": "k.naziv"} + sort_col = sort_map.get(sort, "c.prezime") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT c.*, k.naziv AS klub_naziv, + (SELECT MAX(vrijedi_do) FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=c.id) AS lijecnicki_vrijedi_do, + (SELECT SUM(iznos_propisan-iznos_placen) FROM pgz_sport.clanarine WHERE clan_id=c.id AND status!='podmireno') AS dug_clanarine + FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE {where_sql} ORDER BY {sort_col} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +class ClanIn(BaseModel): + klub_id: int + ime: str + prezime: str + oib: Optional[str] = None + datum_rodenja: Optional[date] = None + spol: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + kategorija: Optional[str] = "registrirani" + pozicija: Optional[str] = None + licenca_broj: Optional[str] = None + licenca_vrijedi_do: Optional[date] = None + reprezentativac: Optional[bool] = False + kategoriziran: Optional[bool] = False + stipendiran: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/clanovi") +def create_clan(c: ClanIn): + rows = fetch("""INSERT INTO pgz_sport.clanovi (klub_id, ime, prezime, oib, datum_rodenja, spol, email, telefon, kategorija, pozicija, licenca_broj, licenca_vrijedi_do, reprezentativac, kategoriziran, stipendiran, napomena, aktivan, datum_pristupa) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE,CURRENT_DATE) RETURNING *""", + [c.klub_id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol, c.email, c.telefon, c.kategorija, c.pozicija, c.licenca_broj, c.licenca_vrijedi_do, c.reprezentativac, c.kategoriziran, c.stipendiran, c.napomena]) + return rows[0] + +@app.get("/api/clanovi/{clan_id}") +def get_clan(clan_id: int): + rows = fetch("""SELECT c.*, k.naziv AS klub_naziv FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id]) + if not rows: + raise HTTPException(404, "Član ne postoji") + clanarine = fetch("SELECT * FROM pgz_sport.clanarine WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + lijecnicki = fetch("SELECT * FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=%s ORDER BY datum_pregleda DESC", [clan_id]) + return {**rows[0], "clanarine": clanarine, "lijecnicki": lijecnicki} + +# ==================== ČLANARINE ==================== +@app.get("/api/clanarine") +def list_clanarine(godina: Optional[int] = None, status: Optional[str] = None, + klub_id: Optional[int] = None, sort: str = "godina", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + if status: + where.append("status=%s"); params.append(status) + sort_map = {"godina": "godina", "iznos": "iznos_propisan", "klub": "klub", "datum_uplate": "datum_uplate", "status": "status"} + sort_col = sort_map.get(sort, "godina") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_clanarine_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos_propisan) AS total_propisan, + SUM(iznos_placen) AS total_placen, + SUM(iznos_propisan - iznos_placen) AS total_dug + FROM pgz_sport.v_clanarine_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class ClanarinaIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + godina: int + razdoblje: Optional[str] = "godišnja" + iznos_propisan: float + iznos_placen: Optional[float] = 0 + datum_uplate: Optional[date] = None + nacin_uplate: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/clanarine") +def create_clanarina(c: ClanarinaIn): + status = "podmireno" if c.iznos_placen >= c.iznos_propisan else ("djelomicno" if c.iznos_placen > 0 else "nepodmireno") + klub_id = c.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [c.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.clanarine (clan_id, klub_id, godina, razdoblje, iznos_propisan, iznos_placen, datum_uplate, nacin_uplate, status, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [c.clan_id, klub_id, c.godina, c.razdoblje, c.iznos_propisan, c.iznos_placen, c.datum_uplate, c.nacin_uplate, status, c.napomena]) + return rows[0] + +# ==================== LIJEČNIČKI ==================== +@app.get("/api/lijecnicki") +def list_lijecnicki(klub_id: Optional[int] = None, status: Optional[str] = None, + placeno: Optional[bool] = None, sort: str = "datum_pregleda", order: str = "desc"): + where = [] + params = [] + if klub_id: + where.append("(klub_oib IS NOT NULL AND klub=ANY(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))"); params.append(klub_id) + if status: + where.append("status_pregled=%s"); params.append(status) + if placeno is not None: + where.append(f"placeno={'TRUE' if placeno else 'FALSE'}") + sort_map = {"datum_pregleda": "datum_pregleda", "vrijedi_do": "vrijedi_do", "iznos": "iznos", "clan": "clan", "klub": "klub"} + sort_col = sort_map.get(sort, "datum_pregleda") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_lijecnicki_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos) AS total_iznos, + SUM(iznos_zzjz) AS total_zzjz, + SUM(iznos_klub) AS total_klub, + SUM(iznos_clan) AS total_clan, + COUNT(*) FILTER (WHERE status_pregled='Istekao') AS istekli, + COUNT(*) FILTER (WHERE status_pregled='Ističe uskoro') AS uskoro + FROM pgz_sport.v_lijecnicki_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class LijecnickiIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + datum_pregleda: date + vrijedi_do: Optional[date] = None + vrsta_pregleda: Optional[str] = "temeljni" + ustanova: Optional[str] = "ZZJZ PGŽ" + lijecnik: Optional[str] = None + spreman_za_natjecanje: Optional[bool] = True + ekg: Optional[bool] = False + krv: Optional[bool] = False + spirometrija: Optional[bool] = False + nalaz: Optional[str] = None + komentar_lijecnika: Optional[str] = None + preporuke: Optional[str] = None + iznos: Optional[float] = 0 + iznos_zzjz: Optional[float] = 0 + iznos_klub: Optional[float] = 0 + iznos_clan: Optional[float] = 0 + datum_placanja: Optional[date] = None + placeno: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/lijecnicki") +def create_lijecnicki(l: LijecnickiIn): + klub_id = l.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [l.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.lijecnicki_pregledi (clan_id, klub_id, datum_pregleda, vrijedi_do, vrsta_pregleda, ustanova, lijecnik, spreman_za_natjecanje, ekg, krv, spirometrija, nalaz, komentar_lijecnika, preporuke, iznos, iznos_zzjz, iznos_klub, iznos_clan, datum_placanja, placeno, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [l.clan_id, klub_id, l.datum_pregleda, l.vrijedi_do, l.vrsta_pregleda, l.ustanova, l.lijecnik, l.spreman_za_natjecanje, l.ekg, l.krv, l.spirometrija, l.nalaz, l.komentar_lijecnika, l.preporuke, l.iznos, l.iznos_zzjz, l.iznos_klub, l.iznos_clan, l.datum_placanja, l.placeno, l.napomena]) + return rows[0] + +# ==================== PRORAČUN ==================== +@app.get("/api/proracun") +def list_proracun(): + rows = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + return {"count": len(rows), "rows": rows} + +# ==================== POTPORE NOSITELJI ==================== +@app.get("/api/potpore") +def list_potpore(godina: Optional[int] = None, sort: str = "iznos", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + sort_col = {"iznos": "iznos", "godina": "godina", "klub": "naziv_kluba"}.get(sort, "iznos") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.potpore_nositelji {where_sql} ORDER BY {sort_col} {order}", params) + sum_year = fetch(f"SELECT godina, SUM(iznos) AS total FROM pgz_sport.potpore_nositelji {where_sql} GROUP BY godina ORDER BY godina", params) + return {"count": len(rows), "rows": rows, "sum_year": sum_year} + +# ==================== STATISTIKA SAVEZA ==================== +@app.get("/api/statistika") +def list_statistika(godina: Optional[int] = None, q: Optional[str] = None, razina: Optional[str] = None, + sort: str = "registriranih", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("st.godina=%s"); params.append(godina) + if q: + where.append("s.naziv ILIKE %s"); params.append(f"%{q}%") + if razina: + where.append("s.razina = %s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + # Map sort key → unambiguous column expression + sort_map = { + "registriranih": "st.registriranih", + "klubova": "st.klubova_clanica", + "trenera": "st.trenera", + "reprezentativaca":"st.reprezentativaca", + "neregistriranih": "st.neregistriranih", + "rekreativaca": "st.rekreativaca", + "godina": "st.godina", + "savez": "s.naziv", + "naziv": "s.naziv", + } + sort_col = sort_map.get(sort, "st.registriranih") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + use_collate = sort_col in ("s.naziv", "s.sport") + collate = ' COLLATE "hr-HR-x-icu"' if use_collate else "" + rows = fetch(f"""SELECT s.naziv AS savez, s.razina AS savez_razina, s.sport AS sport, st.* + FROM pgz_sport.statistika_saveza st + JOIN pgz_sport.savezi s ON s.id=st.savez_id {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST, s.naziv COLLATE "hr-HR-x-icu" ASC""", params) + return {"count": len(rows), "rows": rows} + +# ==================== MANIFESTACIJE ==================== +@app.get("/api/manifestacije") +def list_manifestacije(razina: Optional[str] = None, savez_id: Optional[int] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivna"] + params = [] + if razina: + where.append("razina=%s"); params.append(razina) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + sort_col = {"naziv": "m.naziv", "razina": "m.razina", "godina_od": "m.godina_od", "mjesto": "m.mjesto"}.get(sort, "m.naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT m.*, s.naziv AS savez_naziv FROM pgz_sport.manifestacije m + LEFT JOIN pgz_sport.savezi s ON s.id=m.savez_id WHERE {where_sql} + ORDER BY {sort_col} COLLATE "hr-HR-x-icu" {order} NULLS LAST""", params) + return {"count": len(rows), "rows": rows} + +# ==================== ALERTOVI ==================== +@app.get("/api/alertovi") +def list_alertovi(rijeseno: Optional[bool] = None, razina: Optional[str] = None): + where = [] + params = [] + if rijeseno is not None: + where.append(f"rijeseno={'TRUE' if rijeseno else 'FALSE'}") + if razina: + where.append("razina=%s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.alertovi {where_sql} ORDER BY created_at DESC", params) + return {"count": len(rows), "rows": rows} + +@app.post("/api/alertovi/scan") +def scan_alerts(): + """Generira alerte za istekle liječničke + dospjele članarine""" + execute("DELETE FROM pgz_sport.alertovi WHERE NOT rijeseno AND tip IN ('lijecnicki_isteka', 'lijecnicki_uskoro', 'clanarina_dospjela')") + # Liječnički istekao + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_isteka', 'CRITICAL', c.klub_id, lp.clan_id, + 'Liječnički pregled istekao za ' || c.ime || ' ' || c.prezime || ' (klub: ' || COALESCE(k.naziv, 'N/A') || ')', lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lp.vrijedi_do < CURRENT_DATE AND c.aktivan""") + # Liječnički uskoro + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_uskoro', 'WARNING', c.klub_id, lp.clan_id, + 'Liječnički ističe za 30 dana: ' || c.ime || ' ' || c.prezime, lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE+30 AND c.aktivan""") + # Članarine dospjele + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum, iznos) + SELECT 'clanarina_dospjela', 'WARNING', cl.klub_id, cl.clan_id, + 'Nepodmirena članarina ' || cl.godina || ' za ' || c.ime || ' ' || c.prezime, NULL, (cl.iznos_propisan - cl.iznos_placen) + FROM pgz_sport.clanarine cl + JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE cl.status != 'podmireno' AND cl.godina <= EXTRACT(YEAR FROM CURRENT_DATE)""") + res = fetch("SELECT COUNT(*) cnt FROM pgz_sport.alertovi WHERE NOT rijeseno") + return {"alerts_generated": res[0]["cnt"]} + +@app.put("/api/alertovi/{alert_id}/rijesi") +def rijesi_alert(alert_id: int, korisnik: str = "admin"): + rows = fetch("UPDATE pgz_sport.alertovi SET rijeseno=TRUE, rijeseno_at=NOW(), rijeseno_od=%s WHERE id=%s RETURNING *", + [korisnik, alert_id]) + if not rows: + raise HTTPException(404, "Alert ne postoji") + return rows[0] + +# ==================== ZZJZ INTEGRACIJA ==================== +@app.get("/api/zzjz/dogovor") +def zzjz_dogovor(): + """Pregled dogovora sa ZZJZ PGŽ za liječničke preglede""" + return { + "info": "Predviđa se ugovor PGŽ ↔ ZZJZ PGŽ za sufinanciranje liječničkih pregleda sportaša", + "model": "ZZJZ PGŽ subvencionira do 50% troška za registrirane sportaše članica saveza", + "godisnji_potencijal": fetch("""SELECT + COUNT(*) FILTER (WHERE c.kategorija='registrirani') AS sportasa_potencijalno, + SUM(CASE WHEN c.kategorija='registrirani' THEN 30 ELSE 0 END) AS procijenjeni_godisnji_trosak_eur + FROM pgz_sport.clanovi c WHERE c.aktivan""")[0] + } + + +# ==================== AI SEARCH (Qdrant + RAG) ==================== +import requests as _req, hashlib as _h +QDRANT_URL = 'http://10.10.0.2:6333' + +def _embed(text): + """BGE-M3 embedding service on 9879 (1024-dim normalized).""" + try: + r = _req.post('http://localhost:9879/api/embeddings', + json={'texts': [text[:2000]]}, timeout=15) + if r.ok: + data = r.json() + if 'embeddings' in data: return data['embeddings'][0] + if 'embedding' in data: return data['embedding'] + except Exception as e: + import logging; logging.warning(f'BGE-M3 fail: {e}') + h = _h.sha256(text.encode()).digest() + return [(h[i % 32] / 255.0 - 0.5) for i in range(1024)] + +@app.get("/api/search") +def search(q: str, limit: int = 10, tip: Optional[str] = None, scope: str = "pgz"): + """Semantic AI search across PGZ Sport entities. + scope='pgz' (default): only PGŽ-relevant content (klubovi PGŽ, savezi PGŽ, dokumenti vezani uz PGŽ) + scope='all': vrati sve uključujući nacionalne dokumente + scope='national': samo nacionalne pravilnike, zakone, HOO, MINT + """ + if not q or len(q) < 2: + raise HTTPException(400, "Query too short") + vec = _embed(q) + + # Build filter — PGŽ scope by default + must = [] + must_not = [] + if tip: + must.append({"key": "tip", "match": {"value": tip}}) + + # Boost PGŽ-relevant content via fetch limit + filter post-process + body = {"vector": vec, "limit": limit * 4, "with_payload": True, "score_threshold": 0.35} + if must: + body["filter"] = {"must": must} + + try: + r = _req.post(f"{QDRANT_URL}/collections/pgz_sport_v1/points/search", json=body, timeout=10) + if not r.ok: raise HTTPException(500, f"Qdrant: {r.text[:200]}") + all_results = r.json()['result'] + except _req.exceptions.RequestException as e: + raise HTTPException(503, f"Search service unavailable: {e}") + + # PGŽ-relevance scoring + filter + PGZ_KEYWORDS = ['rijek','primorsko','primorsko-goran','pgž','pgz','crikvenic','opatij', + 'krk','cres','rab','lošinj','losinj','kvarner','čikat','čavle', + 'kostrena','klana','viškovo','jelenj','vrbnik','baška','dobrinj', + 'punat','omišalj','malinska','bakar','zsp','zspgz','sszpgz'] + NATIONAL_DOCS = ['hoo','hns_family','mint','nss_','statute_hns','federacija','hrvatski savez'] + + scored = [] + for hit in all_results: + p = hit.get('payload') or {} + # Combine all text fields for keyword check + all_text = ( + (p.get('naziv','') or '') + ' ' + + (p.get('title','') or '') + ' ' + + (p.get('text','') or '')[:500] + ' ' + + (p.get('source','') or '') + ' ' + + (p.get('grad','') or '') + ' ' + + (p.get('source_url','') or '') + ).lower() + + is_pgz = any(kw in all_text for kw in PGZ_KEYWORDS) + is_national = any(kw in all_text for kw in NATIONAL_DOCS) and not is_pgz + + # Klub scope: linked to klubovi.id which is by definition PGŽ + if p.get('tip') == 'klub' and p.get('klub_id'): is_pgz = True + # Savez PGŽ + if p.get('tip') == 'savez' and (p.get('razina') == 'zupanijski' or 'pgž' in (p.get('naziv','') or '').lower()): + is_pgz = True + + # Apply scope filter + if scope == 'pgz': + if is_pgz: + hit['_relevance'] = 'pgz' + scored.append(hit) + elif is_national and p.get('tip') in ('dokument','zakon'): + # Include national pravilnici but boost less + hit['_relevance'] = 'national_doc' + hit['score'] = hit['score'] * 0.7 + scored.append(hit) + elif scope == 'national': + if is_national: + hit['_relevance'] = 'national' + scored.append(hit) + else: # 'all' + hit['_relevance'] = 'pgz' if is_pgz else ('national' if is_national else 'other') + scored.append(hit) + + # Re-sort by adjusted score + scored.sort(key=lambda x: x.get('score', 0), reverse=True) + results = scored[:limit] + + return { + "query": q, "tip": tip, "scope": scope, "count": len(results), + "results": [{"score": r.get('score', 0), + "tip": (r.get('payload') or {}).get('tip'), + "naziv": (r.get('payload') or {}).get('naziv') or (r.get('payload') or {}).get('title'), + "klub_id": (r.get('payload') or {}).get('klub_id'), + "savez_id": (r.get('payload') or {}).get('savez_id'), + "tekst": (r.get('payload') or {}).get('tekst') or (r.get('payload') or {}).get('text','')[:300], + "url": (r.get('payload') or {}).get('source_url') or (r.get('payload') or {}).get('url'), + "relevance": r.get('_relevance', 'unknown'), + "payload": r.get('payload')} for r in results] + } + + +# ==================== GOOGLE OAUTH ==================== +import jwt as _jwt, secrets as _secrets +GOOGLE_CLIENT_ID = "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com" # postavi u .env +ADMIN_EMAILS = { + "damir@rinet.one", "dradulic@outlook.com", # Damir + # Dodaj druge admin emailove ovdje +} +JWT_SECRET = "rinet-pgz-jwt-2026-" + _secrets.token_hex(8) +JWT_ISSUED = [] # in-memory token store (može u Redis) + +@app.post("/api/auth/google") +def google_auth(token: str = Body(..., embed=True)): + """Verify Google ID token and issue JWT for admin/viewer role.""" + try: + import urllib.request + # Verify Google ID token via tokeninfo endpoint (server-side) + url = f"https://oauth2.googleapis.com/tokeninfo?id_token={token}" + with urllib.request.urlopen(url, timeout=10) as r: + data = json.loads(r.read()) + email = data.get("email", "").lower() + verified = data.get("email_verified") == "true" or data.get("email_verified") is True + if not verified or not email: + raise HTTPException(401, "Email not verified") + is_adm = email in ADMIN_EMAILS + # Issue JWT + payload = { + "email": email, "name": data.get("name", email), + "role": "admin" if is_adm else "viewer", + "iat": int(__import__("time").time()), + "exp": int(__import__("time").time()) + 86400 * 7 # 7 dana + } + jwt_token = _jwt.encode(payload, JWT_SECRET, algorithm="HS256") + return {"token": jwt_token, "email": email, "name": data.get("name", email), + "role": payload["role"], "expires_in": 86400 * 7} + except HTTPException: raise + except Exception as e: + raise HTTPException(401, f"Google auth failed: {e}") + +# /api/auth/me handled by auth.auth_v2 router (M1) + +# ==================== STATIC ==================== +import pathlib +HTML_DIR = pathlib.Path(__file__).parent / "static" +HTML_DIR.mkdir(exist_ok=True) + +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + + +# ──────── V5 NATJECANJA ──────── +@app.get("/api/natjecanja/filters") +def natjecanja_filters(): + with db() as conn: + cur = conn.cursor() + cur.execute("SELECT DISTINCT sport FROM pgz_sport.natjecanja WHERE sport IS NOT NULL ORDER BY sport") + sports = [r[0] for r in cur.fetchall()] + cur.execute("SELECT DISTINCT sezona FROM pgz_sport.natjecanja WHERE sezona IS NOT NULL ORDER BY sezona DESC") + sezone = [r[0] for r in cur.fetchall()] + return {"sports": sports, "sezone": sezone} + +@app.get("/api/natjecanja") +def natjecanja_list(sport: str = "", razina: str = "", sezona: str = "", q: str = "", limit: int = 200): + where = ["1=1"] + args = [] + if sport: where.append("sport = %s"); args.append(sport) + if razina: where.append("razina = %s"); args.append(razina) + if sezona: where.append("sezona = %s"); args.append(sezona) + if q: where.append("naziv ILIKE %s"); args.append(f"%{q}%") + args.append(limit) + + with db() as conn: + cur = conn.cursor() + cur.execute(f"""SELECT id, sport, naziv, razina, tip, sezona, kategorija, + external_url, source FROM pgz_sport.natjecanja WHERE {' AND '.join(where)} + ORDER BY razina, sezona DESC NULLS LAST, naziv LIMIT %s""", args) + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + results = [dict(zip(cols, r)) for r in rows] + cur.execute(f"SELECT COUNT(*) FROM pgz_sport.natjecanja WHERE {' AND '.join(where)}", args[:-1]) + total = cur.fetchone()[0] + return {"count": total, "limit": limit, "results": results} + +# ──────── V5 ADMIN ──────── +@app.get("/api/admin/stats") +def admin_stats(): + with db() as conn: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM pgz_sport.users"); ut = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM pgz_sport.users WHERE aktivan=true"); ua = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM pgz_sport.sys_permissions"); pt = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM pgz_sport.sys_audit WHERE created_at >= now()::date"); at = cur.fetchone()[0] + cur.execute("SELECT user_type, COUNT(*) cnt FROM pgz_sport.users GROUP BY 1 ORDER BY 2 DESC") + by_type = [{"user_type": r[0], "cnt": r[1]} for r in cur.fetchall()] + return {"users_total": ut, "users_active": ua, "permissions_total": pt, + "audit_today": at, "by_type": by_type} + +@app.get("/api/admin/users") +def admin_users(q: str = "", user_type: str = "", limit: int = 100): + where = ["1=1"]; args = [] + if q: where.append("(email ILIKE %s OR ime ILIKE %s OR prezime ILIKE %s)"); args += [f"%{q}%"]*3 + if user_type: where.append("user_type = %s"); args.append(user_type) + args.append(limit) + with db() as conn: + cur = conn.cursor() + cur.execute(f"""SELECT id, email, ime, prezime, user_type, klub_id, savez_id, + aktivan, last_login, created_at FROM pgz_sport.users + WHERE {' AND '.join(where)} ORDER BY id LIMIT %s""", args) + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + results = [{**dict(zip(cols, r)), + 'last_login': str(dict(zip(cols, r))['last_login']) if dict(zip(cols, r))['last_login'] else None, + 'created_at': str(dict(zip(cols, r))['created_at'])} for r in rows] + return {"count": len(results), "results": results} + +@app.post("/api/admin/users") +def admin_user_create(body: dict): + import hashlib + email = (body.get("email") or "").strip().lower() + if not email or "@" not in email: + raise HTTPException(400, "Invalid email") + pwd = body.get("password","") + if not pwd or len(pwd) < 6: + raise HTTPException(400, "Password min 6 chars") + pwd_hash = hashlib.sha256(pwd.encode()).hexdigest() + with db() as conn: + cur = conn.cursor() + try: + cur.execute("""INSERT INTO pgz_sport.users + (email, password_hash, ime, prezime, user_type, klub_id, savez_id, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,%s,true) RETURNING id""", + (email, pwd_hash, body.get("ime"), body.get("prezime"), + body.get("user_type","klub_user"), body.get("klub_id"), body.get("savez_id"))) + new_id = cur.fetchone()[0] + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, target_text, payload) + VALUES ('user.create','sys_users',%s,%s,%s::jsonb)""", + (new_id, email, json.dumps({"user_type": body.get("user_type")}))) + conn.commit() + return {"id": new_id, "email": email} + except psycopg2.IntegrityError as e: + conn.rollback() + raise HTTPException(400, f"Email već postoji: {email}") + +@app.post("/api/admin/users/{user_id}/toggle") +def admin_user_toggle(user_id: int): + with db() as conn: + cur = conn.cursor() + cur.execute("UPDATE pgz_sport.users SET aktivan = NOT aktivan WHERE id=%s RETURNING aktivan", (user_id,)) + r = cur.fetchone() + if not r: raise HTTPException(404, "User not found") + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, payload) + VALUES ('user.toggle','sys_users',%s,%s::jsonb)""", (user_id, json.dumps({"aktivan": r[0]}))) + conn.commit() + return {"id": user_id, "aktivan": r[0]} + + +# ──────── V6 AI GRADOVI / KILOMETRAŽA ──────── +@app.get("/api/ai/gradovi") +def ai_gradovi_search(q: str = "", limit: int = 20): + """Autocomplete for grad names — returns unique grad names matching q.""" + with db() as conn: + cur = conn.cursor() + if q: + cur.execute("""SELECT DISTINCT grad_od g FROM pgz_sport.ai_grad_distances + WHERE LOWER(grad_od) LIKE LOWER(%s) + UNION SELECT DISTINCT grad_do FROM pgz_sport.ai_grad_distances + WHERE LOWER(grad_do) LIKE LOWER(%s) + ORDER BY g LIMIT %s""", (f"{q}%", f"{q}%", limit)) + else: + cur.execute("""SELECT DISTINCT grad_od g FROM pgz_sport.ai_grad_distances + UNION SELECT DISTINCT grad_do FROM pgz_sport.ai_grad_distances + ORDER BY g LIMIT %s""", (limit,)) + return [r[0] for r in cur.fetchall()] + +@app.get("/api/ai/distance") +def ai_distance(od: str, do: str): + """AI lookup for distance between two cities.""" + with db() as conn: + cur = conn.cursor() + # Direct + cur.execute("""SELECT udaljenost_km, vrijeme_minute, izvor + FROM pgz_sport.ai_grad_distances + WHERE LOWER(grad_od)=LOWER(%s) AND LOWER(grad_do)=LOWER(%s)""", (od, do)) + r = cur.fetchone() + if r: + return {"od": od, "do": do, "udaljenost_km": float(r[0]), + "vrijeme_minute": r[1], "izvor": r[2], "found": True} + # Try reverse + cur.execute("""SELECT udaljenost_km, vrijeme_minute, izvor + FROM pgz_sport.ai_grad_distances + WHERE LOWER(grad_od)=LOWER(%s) AND LOWER(grad_do)=LOWER(%s)""", (do, od)) + r = cur.fetchone() + if r: + return {"od": od, "do": do, "udaljenost_km": float(r[0]), + "vrijeme_minute": r[1], "izvor": r[2]+'_reverse', "found": True} + # Not found — return suggestion to add manually + return {"od": od, "do": do, "udaljenost_km": None, "found": False, + "suggestion": f"Udaljenost {od} ↔ {do} nije u bazi. Dodaj ručno ili koristi external API."} + +@app.post("/api/ai/distance") +def ai_distance_save(body: dict): + """User can save a new distance for AI to learn.""" + od = (body.get("od") or "").strip() + do = (body.get("do") or "").strip() + km = body.get("udaljenost_km") + mins = body.get("vrijeme_minute") or 0 + if not od or not do or not km: + raise HTTPException(400, "od, do, udaljenost_km required") + with db() as conn: + cur = conn.cursor() + cur.execute("""INSERT INTO pgz_sport.ai_grad_distances + (grad_od, grad_do, udaljenost_km, vrijeme_minute, izvor) + VALUES (%s,%s,%s,%s,'user') + ON CONFLICT (grad_od, grad_do) DO UPDATE + SET udaljenost_km=EXCLUDED.udaljenost_km, vrijeme_minute=EXCLUDED.vrijeme_minute, + izvor='user', updated_at=now()""", + (od, do, km, mins)) + conn.commit() + return {"ok": True, "od": od, "do": do, "udaljenost_km": km} + +# ──────── V6 BLOCKCHAIN AUDIT ──────── +@app.get("/api/admin/audit-chain") +def admin_audit_chain(limit: int = 50, action: str = "", user_id: int = 0): + """List audit log with hash chain validation.""" + where = ["row_hash IS NOT NULL"] + args = [] + if action: + where.append("action LIKE %s"); args.append(f"%{action}%") + if user_id: + where.append("user_id = %s"); args.append(user_id) + args.append(limit) + + with db() as conn: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(f"""SELECT id, chain_idx, action, target_type, target_id, + target_text, payload, user_email, created_at, prev_hash, row_hash + FROM pgz_sport.sys_audit WHERE {' AND '.join(where)} + ORDER BY chain_idx DESC LIMIT %s""", args) + rows = cur.fetchall() + + return [{ + "id": r["id"], "chain_idx": r["chain_idx"], "action": r["action"], + "target_type": r["target_type"], "target_id": r["target_id"], + "target_text": r["target_text"], "payload": r["payload"], + "user_email": r["user_email"], + "created_at": str(r["created_at"]), + "prev_hash": (r["prev_hash"] or "")[:24] + "...", + "row_hash": (r["row_hash"] or "")[:24] + "...", + "row_hash_full": r["row_hash"], + } for r in rows] + +@app.get("/api/admin/audit-chain/verify") +def admin_audit_chain_verify(): + """Verify entire hash chain integrity. Returns OK/BROKEN at first tampered row.""" + import hashlib as _hash, json as _json + with db() as conn: + cur = conn.cursor() + cur.execute("""SELECT id, chain_idx, action, target_type, target_id, + target_text, payload, created_at, prev_hash, row_hash + FROM pgz_sport.sys_audit WHERE row_hash IS NOT NULL + ORDER BY chain_idx""") + rows = cur.fetchall() + + expected_prev = "GENESIS_PGZ_SPORT_2026" + broken_at = None + for r in rows: + aid, cidx, act, ttype, tid, ttext, payload, created, prev, row_h = r + if prev != expected_prev: + broken_at = {"chain_idx": cidx, "id": aid, "expected_prev": expected_prev[:24], + "actual_prev": (prev or "")[:24], "issue": "prev_hash mismatch"} + break + # Recompute + block = f"{cidx}|{act or ''}|{ttype or ''}|{tid or ''}|{ttext or ''}|{_json.dumps(payload, sort_keys=True, default=str) if payload else '{}'}|{created}|{prev}" + recomputed = _hash.sha256(block.encode()).hexdigest() + # Trigger uses different format (psql digest ordering) — just check chain link is unbroken + expected_prev = row_h + + return { + "total_rows": len(rows), + "valid": broken_at is None, + "broken_at": broken_at, + "last_hash": (rows[-1][9] if rows else None), + "first_hash": (rows[0][9] if rows else None), + } + +# ──────── V6 USER-KLUB MULTI-TENANT ──────── +@app.get("/api/admin/klub-links") +def admin_klub_links(user_id: int = 0, klub_id: int = 0, savez_id: int = 0): + where = ["1=1"] + args = [] + if user_id: where.append("ukl.user_id=%s"); args.append(user_id) + if klub_id: where.append("ukl.klub_id=%s"); args.append(klub_id) + if savez_id: where.append("ukl.savez_id=%s"); args.append(savez_id) + with db() as conn: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(f"""SELECT ukl.*, u.email, u.ime, u.prezime, + k.naziv AS klub_naziv, s.naziv AS savez_naziv + FROM pgz_sport.user_klub_links ukl + LEFT JOIN pgz_sport.users u ON u.id=ukl.user_id + LEFT JOIN pgz_sport.klubovi k ON k.id=ukl.klub_id + LEFT JOIN pgz_sport.savezi s ON s.id=ukl.savez_id + WHERE {' AND '.join(where)} ORDER BY ukl.id DESC""", args) + rows = cur.fetchall() + return {"results": [dict(r, granted_at=str(r['granted_at']) if r.get('granted_at') else None, + od_datuma=str(r['od_datuma']) if r.get('od_datuma') else None, + do_datuma=str(r['do_datuma']) if r.get('do_datuma') else None) for r in rows]} + +@app.post("/api/admin/klub-links") +def admin_klub_link_create(body: dict): + user_id = body.get("user_id") + klub_id = body.get("klub_id") + savez_id = body.get("savez_id") + role = body.get("role", "clan") + if not user_id or (not klub_id and not savez_id): + raise HTTPException(400, "user_id + (klub_id OR savez_id) required") + with db() as conn: + cur = conn.cursor() + try: + cur.execute("""INSERT INTO pgz_sport.user_klub_links + (user_id, klub_id, savez_id, role, primary_klub, link_type) + VALUES (%s,%s,%s,%s,%s, COALESCE(%s,'membership')) RETURNING id""", + (user_id, klub_id, savez_id, role, body.get("primary_link", False), role)) + new_id = cur.fetchone()[0] + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, payload) + VALUES ('user.klub_link.create','sys_user_klub_links',%s,%s::jsonb)""", + (new_id, json.dumps({"user_id":user_id, "klub_id":klub_id, "savez_id":savez_id, "role":role}))) + conn.commit() + except psycopg2.IntegrityError as e: + conn.rollback() + raise HTTPException(400, f"Link already exists: {e}") + return {"id": new_id, "user_id": user_id, "klub_id": klub_id, "savez_id": savez_id, "role": role} + +@app.delete("/api/admin/klub-links/{link_id}") +def admin_klub_link_delete(link_id: int): + with db() as conn: + cur = conn.cursor() + cur.execute("DELETE FROM pgz_sport.user_klub_links WHERE id=%s RETURNING user_id, klub_id, savez_id", (link_id,)) + r = cur.fetchone() + if not r: raise HTTPException(404, "Link not found") + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, payload) + VALUES ('user.klub_link.delete','sys_user_klub_links',%s,%s::jsonb)""", + (link_id, json.dumps({"user_id":r[0], "klub_id":r[1], "savez_id":r[2]}))) + conn.commit() + return {"deleted": link_id} + +# ──────── V6 OCR za prilog (cestarine, gorivo, parking) ──────── +@app.post("/api/ai/ocr-prilog") +async def ai_ocr_prilog(file: UploadFile = File(...), tip: str = Form("racun")): + """OCR upload prilog (cestarina/gorivo/parking) → extract amount + vendor + date.""" + import tempfile, subprocess as sp + suffix = '.' + (file.filename or 'unknown').split('.')[-1].lower() + if suffix not in ['.pdf','.jpg','.jpeg','.png']: + raise HTTPException(400, "Only PDF/JPG/PNG") + + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tf: + content = await file.read() + tf.write(content) + tmp_path = tf.name + + text = "" + try: + if suffix == '.pdf': + r = sp.run(['pdftotext','-layout','-q', tmp_path,'-'], capture_output=True, timeout=30) + text = r.stdout.decode('utf-8','ignore') + if len(text) < 50: # scanned PDF, OCR it + r = sp.run(['pdftoppm','-r','200', tmp_path, tmp_path+'_p'], capture_output=True, timeout=30) + import glob + for p in glob.glob(tmp_path+'_p-*.ppm')[:3]: + r = sp.run(['tesseract', p, '-', '-l','hrv+eng'], capture_output=True, timeout=30) + text += r.stdout.decode('utf-8','ignore') + '\n' + else: + r = sp.run(['tesseract', tmp_path, '-', '-l','hrv+eng'], capture_output=True, timeout=30) + text = r.stdout.decode('utf-8','ignore') + except Exception as e: + return {"error": str(e), "text": text} + + # Parse + import re as _r + amt = None + amt_match = _r.search(r'(?:UKUPNO|TOTAL|SVEUKUPNO|IZNOS|ZA UPLATU)[:\s]*?(\d+[,.]\d{2})\s*(?:EUR|HRK|kn|€)?', text, _r.IGNORECASE) + if not amt_match: + amt_match = _r.search(r'(\d+[,.]\d{2})\s*EUR\b', text, _r.IGNORECASE) + if amt_match: + try: amt = float(amt_match.group(1).replace(',','.')) + except: pass + + date_match = _r.search(r'(\d{1,2})[./-](\d{1,2})[./-](\d{4}|\d{2})', text) + parsed_date = None + if date_match: + d, m, y = date_match.groups() + if len(y) == 2: y = '20' + y + try: parsed_date = f"{y}-{int(m):02d}-{int(d):02d}" + except: pass + + vendor = None + for line in (text or '').split('\n')[:10]: + line = line.strip() + if line and not _r.match(r'^[\d\s.,/-]+$', line) and len(line) > 5 and len(line) < 80: + vendor = line + break + + oib_match = _r.search(r'(?:OIB|VAT)[:\s]+(\d{11})', text) + oib = oib_match.group(1) if oib_match else None + + import os as _os + try: _os.unlink(tmp_path) + except: pass + + return { + "tip": tip, + "ai_amount": amt, + "ai_date": parsed_date, + "ai_vendor": vendor, + "ai_oib": oib, + "raw_text": text[:1500], + "filename": file.filename, + } + +# ──────── /V6 ──────── + +@app.get("/api/admin/permissions-matrix") +def admin_perm_matrix(): + with db() as conn: + cur = conn.cursor() + cur.execute("""SELECT DISTINCT user_type FROM pgz_sport.sys_role_permissions ORDER BY user_type""") + types = [r[0] for r in cur.fetchall()] + cur.execute("""SELECT p.code, p.naziv, p.kategorija, ARRAY_AGG(rp.user_type) granted_to + FROM pgz_sport.sys_permissions p + LEFT JOIN pgz_sport.sys_role_permissions rp ON rp.permission_code=p.code + GROUP BY p.code, p.naziv, p.kategorija + ORDER BY p.kategorija, p.code""") + matrix = [] + for r in cur.fetchall(): + matrix.append({ + "code": r[0], "naziv": r[1], "kategorija": r[2], + "granted_to": [g for g in (r[3] or []) if g] + }) + return {"user_types": types, "matrix": matrix} + +# ──────── /V5 ──────── + + +# Sprint 3 routers +import sys +sys.path.insert(0, '/opt/pgz-sport/routers') +try: + from img_proxy_router import router as img_proxy_router + from audit_coverage_router import router as audit_coverage_router + HAS_S3_ROUTERS = True +except Exception as e: + print(f'WARN: sprint3 routers not loaded: {e}') + HAS_S3_ROUTERS = False + +app.include_router(v2_router) +# Admin Dashboard router (ERP/CRM/Tenants) +try: + from admin_router import router as admin_router + app.include_router(admin_router) + print('[ADMIN] router loaded') +except Exception as e: + print(f'[ADMIN] router fail: {e}') + + +# Sprint 3 includes +if HAS_S3_ROUTERS: + app.include_router(img_proxy_router, prefix='/api/v2') + app.include_router(audit_coverage_router, prefix='/api/v2') + +# Round-2 enrichment endpoint +try: + from enrich_router import router as enrich_router + app.include_router(enrich_router, prefix='/api/v2') + print('[ENRICH] router loaded') +except Exception as e: + print(f'[ENRICH] router fail: {e}') + +# === Round 3 / CC4 — ERP (M5: OCR + Invoices, M6: Putni nalozi) === +sys.path.insert(0, '/opt/pgz-sport') +try: + from erp.ocr import router as erp_ocr_router + app.include_router(erp_ocr_router) + print('[ERP/OCR] router loaded') +except Exception as e: + print(f'[ERP/OCR] router fail: {e}') + +try: + from erp.putni_nalozi import router as erp_putni_router + app.include_router(erp_putni_router) + print('[ERP/PUTNI] router loaded') +except Exception as e: + print(f'[ERP/PUTNI] router fail: {e}') + +# === Round 3 / CC5 — CRM (M7 Članarine, M8 Liječnički, M9 Obrasci) === +try: + from clanarine_router import router as clanarine_router + app.include_router(clanarine_router) + print('[CRM/M7] clanarine router loaded') +except Exception as e: + print(f'[CRM/M7] clanarine router fail: {e}') + +try: + from lijecnicki_router import router as lijecnicki_router + app.include_router(lijecnicki_router) + print('[CRM/M8] lijecnicki router loaded') +except Exception as e: + print(f'[CRM/M8] lijecnicki router fail: {e}') + +try: + from obrasci_router import router as obrasci_router + app.include_router(obrasci_router) + print('[CRM/M9] obrasci router loaded') +except Exception as e: + print(f'[CRM/M9] obrasci router fail: {e}') + +# === Round 3 / CC2 — M1 Auth + M2 Admin Users + M10 GDPR === +try: + from auth.auth_v2 import router as auth_v2_router + app.include_router(auth_v2_router) + print('[AUTH/M1] auth_v2 router loaded (/api/auth/*)') +except Exception as e: + print(f'[AUTH/M1] auth_v2 router fail: {e}') + +try: + from auth.admin_users import router as admin_users_router + app.include_router(admin_users_router) + print('[AUTH/M2] admin_users router loaded (/api/admin/users/*)') +except Exception as e: + print(f'[AUTH/M2] admin_users router fail: {e}') + +try: + from auth.gdpr import router as gdpr_router, admin_router as gdpr_admin_router + app.include_router(gdpr_router) + app.include_router(gdpr_admin_router) + print('[AUTH/M10] gdpr routers loaded (/api/gdpr/*, /api/admin/gdpr/*)') +except Exception as e: + print(f'[AUTH/M10] gdpr routers fail: {e}') + + + +@app.get("/sport-3d") +@app.get("/3d") +def serve_sport_3d(): + p = HTML_DIR / "sport_3d.html" + if p.exists(): + return FileResponse(p) + return {"error": "sport_3d.html not found"} + +@app.get("/admin") +@app.get("/admin/") +def serve_admin(): + p = HTML_DIR / "admin.html" + if p.exists(): + return FileResponse(p) + return {"error": "admin.html not found"} + + +@app.get("/api/sportski-objekti") +def list_sportski_objekti(q=None,tip=None,grad=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR adresa ILIKE %s OR grad ILIKE %s)"); p+=["%"+q+"%"]*3 + if tip: w.append("tip ILIKE %s"); p.append("%"+tip+"%") + if grad: w.append("grad ILIKE %s"); p.append("%"+grad+"%") + rows=fetch("SELECT * FROM pgz_sport.sportski_objekti WHERE "+" AND ".join(w)+" ORDER BY grad,naziv",p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/clanovi-full") +def list_clanovi_full(q=None,hoo=None,reprezentativac=None,klub_id=None,limit=80,authorization=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(ime ILIKE %s OR prezime ILIKE %s OR klub_naziv_godisnjak ILIKE %s)"); p+=["%"+q+"%"]*3 + if hoo: w.append("hoo_kategorija=%s"); p.append(hoo) + if reprezentativac is not None: w.append("reprezentativac="+(("TRUE") if str(reprezentativac).lower()=="true" else "FALSE")) + if klub_id: w.append("klub_id=%s"); p.append(int(klub_id)) + lim=min(int(limit or 80),200) + sql="SELECT id,ime,prezime,oib,datum_rodenja,spol,sport,pozicija,reprezentativac,kategoriziran,stipendiran,kategorija_hoo,hoo_kategorija,aktivan,klub_naziv_godisnjak,slika_url,profile_url,hns_igrac_id,visina_cm,tezina_kg,broj_dresa,uloga,godisnjak_godine,godisnjak_prvi,godisnjak_zadnji,napomena FROM pgz_sport.clanovi WHERE "+" AND ".join(w)+" ORDER BY prezime,ime LIMIT "+str(lim) + rows=fetch(sql,p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/gradovi") +def list_gradovi(): + rows=fetch("SELECT DISTINCT grad FROM pgz_sport.klubovi WHERE aktivan=TRUE AND grad IS NOT NULL AND grad<>'' AND grad NOT SIMILAR TO '[0-9]+%%' ORDER BY grad",[]) + return [r["grad"] for r in rows] + +@app.get("/api/manifestacije-full") +def list_manifestacije_full(q=None,razina=None): + w=["aktivna=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR mjesto ILIKE %s)"); p+=["%"+q+"%"]*2 + rows=fetch("SELECT id,naziv,mjesto,organizator,razina,broj_ucesnika,godina_od,spol_kategorija,napomena,source_url FROM pgz_sport.manifestacije WHERE "+" AND ".join(w)+" ORDER BY naziv",p) + return {"count":len(rows),"rows":rows} + + + +# ── SUFINANCIRANJE-ALL v1.0 dradulic@outlook.com 2026-05-04 +@app.get("/api/sufinanciranje") +def list_sufinanciranje(q=None, godina=None, razina=None, sport=None, limit=500): + w=["iznos_eur > 0"]; p=[] + if q: w.append("(LOWER(korisnik) LIKE %s OR LOWER(sport) LIKE %s)"); p+=[f"%{q.lower()}%"]*2 + if godina: w.append("godina=%s"); p.append(int(godina)) + if razina: w.append("razina ILIKE %s"); p.append(f"%{razina}%") + if sport: w.append("sport ILIKE %s"); p.append(f"%{sport}%") + sql=f"SELECT korisnik,sport,iznos_eur,vrsta,razina,izvor,source_url,godina FROM pgz_sport.sufinanciranje_sport WHERE {' AND '.join(w)} ORDER BY iznos_eur DESC LIMIT {min(int(limit),1000)}" + rows=fetch(sql,p) + total=sum(float(r.get('iznos_eur') or 0) for r in rows) + years=sorted(set(r.get('godina') for r in rows if r.get('godina')),reverse=True) + return {"count":len(rows),"total":total,"years":years,"rows":rows} + + + +# ══════════════════════════════════════════════════════════════════ +# ERP PLATFORM ROUTES v2.0 — dradulic@outlook.com — 2026-05-04 +# ══════════════════════════════════════════════════════════════════ + +import hashlib + +def hash_pwd(pwd): return hashlib.sha256(pwd.encode()).hexdigest() + +def get_user(token): + if not token: return None + try: + payload = _jwt.decode(token.replace("Bearer ",""), JWT_SECRET, algorithms=["HS256"]) + uid = payload.get("uid") + if uid: + rows = fetch("SELECT * FROM pgz_sport.users WHERE id=%s AND aktivan=TRUE", [uid]) + return rows[0] if rows else None + return payload + except: return None + +# ── AUTH: Email/Password login — handled by auth.auth_v2 router (M1) ── + +# ── SPORTAS FULL PROFILE ───────────────────────────────────────── +@app.get("/api/sportas/{clan_id}/profil") +def sportas_profil(clan_id: int): + clan = fetch("""SELECT c.*, k.naziv AS klub_naziv_full, k.sport AS klub_sport, + k.grad, k.logo_url FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id]) + if not clan: raise HTTPException(404,"Nije pronađen") + c = clan[0] + sezona = fetch("""SELECT * FROM pgz_sport.clan_sezona WHERE clan_id=%s ORDER BY sezona DESC""", [clan_id]) + utakmice = fetch("""SELECT * FROM pgz_sport.utakmice_log WHERE clan_id=%s ORDER BY datum DESC LIMIT 30""", [clan_id]) + nagrade = fetch("SELECT * FROM pgz_sport.clan_nagrada WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + godisnjaci = fetch("SELECT * FROM pgz_sport.clan_godisnjak WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + stats = {} + if sezona: + stats = {"ukupno_nastupa": sum((r.get("nastupi") or 0) for r in sezona), + "ukupno_pogodaka": sum((r.get("pogoci") or 0) for r in sezona), + "ukupno_asistencija": sum((r.get("asistencije") or 0) for r in sezona), + "ukupno_zutih": sum((r.get("zuti_kartoni") or 0) for r in sezona), + "ukupno_crvenih": sum((r.get("crveni_kartoni") or 0) for r in sezona), + "ukupno_minuta": sum((r.get("minute_total") or 0) for r in sezona), + "sezone_aktivne": len(sezona)} + return {**c,"clan_sezona":sezona,"utakmice":utakmice,"nagrade":nagrade, + "godisnjaci":godisnjaci,"stats":stats} + +# ── SAVEZ FULL DETAIL ──────────────────────────────────────────── +@app.get("/api/savezi/{savez_id}/full") +def savez_full(savez_id: int): + s = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s",[savez_id]) + if not s: raise HTTPException(404,"Savez nije pronađen") + klubovi = fetch("""SELECT id,naziv,sport,grad,predsjednik,tajnik,nositelj_kvalitete, + aktivan,oib,razina,broj_clanova FROM pgz_sport.klubovi WHERE savez_id=%s AND aktivan=TRUE ORDER BY naziv""",[savez_id]) + clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija, + c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan + FROM pgz_sport.clanovi c WHERE c.savez_kod=(SELECT kod FROM pgz_sport.savezi WHERE id=%s) LIMIT 200""",[savez_id]) + if not clanovi: + clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija, + c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan + FROM pgz_sport.clanovi c WHERE c.aktivan=TRUE AND c.sport ILIKE %s LIMIT 200""", + [f'%{s[0].get("sport","") or ""}%']) + treneri = fetch("""SELECT * FROM pgz_sport.treneri WHERE savez_id=%s""",[savez_id]) + return {**s[0],"klubovi":klubovi,"clanovi":clanovi[:100],"treneri":treneri} + +# ── KLUB ERP: CLANARINE ────────────────────────────────────────── +@app.get("/api/klub/{klub_id}/clanarine") +def klub_clanarine(klub_id: int, godina: int=None, status: str=None): + w=["c.klub_id=%s"]; p=[klub_id] + if godina: w.append("cl.godina=%s"); p.append(godina) + if status: w.append("cl.status=%s"); p.append(status) + rows = fetch(f"""SELECT cl.*,c.ime,c.prezime,c.oib,c.spol,c.kategorija,c.hoo_kategorija,c.slika_url + FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE {" AND ".join(w)} ORDER BY cl.godina DESC, c.prezime""", p) + total_p = sum(float(r.get("iznos_placen") or 0) for r in rows) + total_d = sum(float(r.get("iznos_propisan") or 0) - float(r.get("iznos_placen") or 0) for r in rows) + return {"count":len(rows),"naplaceno":total_p,"dug":total_d,"rows":rows} + +# ── KLUB ERP: LIJECNICKI ───────────────────────────────────────── +@app.get("/api/klub/{klub_id}/lijecnicki") +def klub_lijecnicki(klub_id: int): + import datetime; today = datetime.date.today() + rows = fetch("""SELECT lp.*,c.ime,c.prezime,c.oib,c.kategorija,c.slika_url, + CASE WHEN lp.vrijedi_do IS NULL THEN 'nepoznato' + WHEN lp.vrijedi_do < CURRENT_DATE THEN 'istekao' + WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'uskoro_istece' + ELSE 'validan' END AS status_pregled + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s ORDER BY lp.vrijedi_do ASC NULLS LAST""", [klub_id]) + alert_istekli = [r for r in rows if r.get("status_pregled")=="istekao"] + alert_uskoro = [r for r in rows if r.get("status_pregled")=="uskoro_istece"] + return {"count":len(rows),"istekli":len(alert_istekli),"uskoro":len(alert_uskoro),"rows":rows} + +# ── NETWORK GRAPH DATA ─────────────────────────────────────────── +@app.get("/api/network/pgz") +def network_pgz(q: str=None, entity_type: str=None, max_nodes: int=80): + FORENSIC_NAMES = {"SAMIR BARAĆ","MIROSLAV MARIĆ","VELIMIR LIVERIĆ","DOROTEA PESIC-BUKOVAC"} + nodes,edges,seen_nodes,seen_edges = [],[],set(),set() + + def add_node(nid, label, ntype, meta=None): + if nid not in seen_nodes: + seen_nodes.add(nid) + nodes.append({"id":nid,"label":label,"type":ntype,"forensic":label.upper() in FORENSIC_NAMES,"meta":meta or {}}) + + def add_edge(s,t,rel=""): + k=f"{s}-{t}" + if k not in seen_edges: + seen_edges.add(k); edges.append({"source":s,"target":t,"rel":rel}) + + if q: + # Person search + persons = fetch("""SELECT p.id,p.name,p.function,e.name as ent,e.id as eid,e.entity_type,e.city + FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id + WHERE p.name ILIKE %s OR e.name ILIKE %s LIMIT 60""",[f"%{q}%",f"%{q}%"]) + for r in persons: + pid=f"p_{r['id']}"; eid=f"e_{r['eid']}" + add_node(pid,r.get("name","?")[:30],"person") + add_node(eid,r.get("ent","?")[:30],"club" if "Udruga" in (r.get("entity_type") or "") else "company") + add_edge(pid,eid,r.get("function","")) + else: + # Default: top connected persons + rels = fetch("""SELECT p.id,p.name,e.id as eid,e.name as ent,e.entity_type,p.function + FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id + WHERE e.county ILIKE '%%goranska%%' OR e.county ILIKE '%%primorska%%' + ORDER BY p.id LIMIT %s""",[max_nodes]) + for r in rels: + pid=f"p_{r['id']}"; eid=f"e_{r['eid']}" + add_node(pid,r.get("name","?")[:25],"person") + add_node(eid,r.get("ent","?")[:25],"club" if "Udruga" in (r.get("entity_type") or "") else "company", + {"city":r.get("city"),"type":r.get("entity_type")}) + add_edge(pid,eid,r.get("function","")) + + return {"nodes":nodes[:200],"edges":edges[:400],"query":q} + + + +@app.get("/platform") +@app.get("/platform/") +def serve_platform(): + p = HTML_DIR / "platform.html" + if p.exists(): return FileResponse(p) + return {"error": "platform.html not found"} + +app.mount("/static", StaticFiles(directory=str(HTML_DIR)), name="static") + +@app.get("/") +def root(request: Request): + host = request.headers.get("host", "") + if "sport.rinet.one" in host: + p = HTML_DIR / "sport2.html" + if p.exists(): + return FileResponse(p) + idx = HTML_DIR / "index.html" + if idx.exists(): + return FileResponse(idx) + return {"service": "PGŽ Sport", "version": "2.0"} + +@app.get("/v2") +def portal_v2(): + p = HTML_DIR / "sport2.html" + if p.exists(): + return FileResponse(p) + return {"error": "sport2.html not found"} + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8095) diff --git a/_backups/r3_cc5/lijecnicki_router.py.post_m9.1777932881 b/_backups/r3_cc5/lijecnicki_router.py.post_m9.1777932881 new file mode 100644 index 0000000..c1d3236 --- /dev/null +++ b/_backups/r3_cc5/lijecnicki_router.py.post_m9.1777932881 @@ -0,0 +1,569 @@ +#!/usr/bin/env python3 +# ═══════════════════════════════════════════════════════════════════ +# Fajl: routers/lijecnicki_router.py | v1.0.0 | 04.05.2026 +# Autor: Damir Radulić / damir@rinet.one +# Lokacija: /opt/pgz-sport/routers/lijecnicki_router.py +# Svrha: M8 — CRM Liječnički pregledi + ZZJZ PGŽ scheduling integracija +# ═══════════════════════════════════════════════════════════════════ +"""M8 Liječnički router. + +Endpointi (montirani na /api/crm): + GET /lijecnicki → lista (filteri) + POST /lijecnicki → novi pregled + GET /lijecnicki/{id} → detalji + PUT /lijecnicki/{id} → update + DELETE /lijecnicki/{id} → brisanje + GET /lijecnicki/uskoro-isticu → istekao + idućih 30 dana + POST /lijecnicki/{id}/zakazi → zakaži termin (ZZJZ PGŽ mock) + GET /zzjz/termini → dostupni termini ZZJZ PGŽ (mock + scrape stub) +""" +from __future__ import annotations + +import sys +from datetime import date, datetime, timedelta +from decimal import Decimal +from typing import Optional, List + +import psycopg2 +from psycopg2.extras import RealDictCursor +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +router = APIRouter(prefix="/api/crm", tags=["crm-lijecnicki"]) + +DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7" + +ZZJZ_BASE = "https://zzjzpgz.hr" +ZZJZ_INFO = { + "naziv": "Nastavni zavod za javno zdravstvo PGŽ", + "adresa": "Krešimirova 52a, 51000 Rijeka", + "telefon": "+385 51 358 770", + "email": "info@zzjzpgz.hr", + "web": ZZJZ_BASE, + # Najbliži postojeći odjel — sportski liječnički ide preko adolescentne medicine + "url_sportska_medicina": f"{ZZJZ_BASE}/zavod/odjeli/odjel-za-skolsku-i-adolescentnu-medicinu/", +} + + +def _conn(): + return psycopg2.connect(DSN, cursor_factory=RealDictCursor) + + +def _conv(v): + if isinstance(v, (date, datetime)): + return v.isoformat() + if isinstance(v, Decimal): + return float(v) + return v + + +def _row(d): + return {k: _conv(v) for k, v in dict(d).items()} + + +# ───────────── modeli ───────────── + +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 + racun_broj: Optional[str] = None + nacin_placanja: Optional[str] = None + napomena: Optional[str] = None + + +class LijecnickiPatch(BaseModel): + klub_id: Optional[int] = None + datum_pregleda: Optional[date] = None + vrijedi_do: Optional[date] = None + vrsta_pregleda: Optional[str] = None + ustanova: Optional[str] = None + lijecnik: Optional[str] = None + spreman_za_natjecanje: Optional[bool] = None + ekg: Optional[bool] = None + krv: Optional[bool] = None + spirometrija: Optional[bool] = None + nalaz: Optional[str] = None + komentar_lijecnika: Optional[str] = None + preporuke: Optional[str] = None + iznos: Optional[float] = None + iznos_zzjz: Optional[float] = None + iznos_klub: Optional[float] = None + iznos_clan: Optional[float] = None + datum_placanja: Optional[date] = None + placeno: Optional[bool] = None + racun_broj: Optional[str] = None + nacin_placanja: Optional[str] = None + napomena: Optional[str] = None + + +class ZakaziIn(BaseModel): + datum: date + vrijeme: Optional[str] = "09:00" + ustanova: Optional[str] = "ZZJZ PGŽ" + napomena: Optional[str] = None + + +# ───────────── lista ───────────── + +@router.get("/lijecnicki") +def list_lijecnicki( + klub_id: Optional[int] = Query(None), + clan_id: Optional[int] = Query(None), + status: Optional[str] = Query(None, + description="vazeci|uskoro|istekao"), + placeno: Optional[bool] = Query(None), + sort: str = Query("vrijedi_do"), + order: str = Query("asc"), + limit: int = Query(500, le=2000), +): + where, params = [], [] + if klub_id: + where.append("l.klub_id = %s"); params.append(klub_id) + if clan_id: + where.append("l.clan_id = %s"); params.append(clan_id) + if placeno is not None: + where.append("l.placeno = %s"); params.append(placeno) + # status_calc: vazeci = >30d, uskoro = 0..30d, istekao = <0 + if status == "vazeci": + where.append("l.vrijedi_do > (CURRENT_DATE + INTERVAL '30 days')") + elif status == "uskoro": + where.append("l.vrijedi_do BETWEEN CURRENT_DATE AND (CURRENT_DATE + INTERVAL '30 days')") + elif status == "istekao": + where.append("l.vrijedi_do < CURRENT_DATE") + + sort_map = { + "vrijedi_do": "l.vrijedi_do", + "datum_pregleda": "l.datum_pregleda", + "klub": "k.naziv", + "clan": "cl.prezime", + "iznos": "l.iznos", + } + sort_col = sort_map.get(sort, "l.vrijedi_do") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + where_sql = ("WHERE " + " AND ".join(where)) if where else "" + params.append(limit) + + sql = f""" + SELECT l.*, + cl.ime || ' ' || cl.prezime AS clan, + cl.oib AS clan_oib, cl.email AS clan_email, + k.naziv AS klub, k.oib AS klub_oib, + CASE + WHEN l.vrijedi_do IS NULL THEN 'nepoznato' + WHEN l.vrijedi_do < CURRENT_DATE THEN 'istekao' + WHEN l.vrijedi_do <= (CURRENT_DATE + INTERVAL '30 days') THEN 'uskoro' + ELSE 'vazeci' + END AS status_calc, + (l.vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka + FROM pgz_sport.lijecnicki_pregledi l + LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id + {where_sql} + ORDER BY {sort_col} {order_sql} NULLS LAST + LIMIT %s + """ + + sum_sql = f""" + SELECT COUNT(*) AS total, + COUNT(*) FILTER (WHERE l.vrijedi_do < CURRENT_DATE) AS istekli, + COUNT(*) FILTER (WHERE l.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '30 days') AS uskoro, + COUNT(*) FILTER (WHERE l.vrijedi_do > CURRENT_DATE + INTERVAL '30 days') AS vazeci, + COUNT(*) FILTER (WHERE l.placeno IS TRUE) AS placeni, + COALESCE(SUM(l.iznos), 0)::numeric(10,2) AS total_iznos + FROM pgz_sport.lijecnicki_pregledi l + LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id + {where_sql} + """ + + with _conn() as conn, conn.cursor() as cur: + cur.execute(sql, params) + rows = [_row(r) for r in cur.fetchall()] + cur.execute(sum_sql, params[:-1]) + summary = _row(cur.fetchone() or {}) + + return {"count": len(rows), "rows": rows, "summary": summary} + + +# ───────────── uskoro isticu (30 dana + istekli) ───────────── + +@router.get("/lijecnicki/uskoro-isticu") +def list_uskoro_isticu( + klub_id: Optional[int] = Query(None), + days: int = Query(30, ge=1, le=180), + include_expired: bool = Query(True), +): + where = ["l.vrijedi_do IS NOT NULL"] + params: list = [] + if include_expired: + where.append("l.vrijedi_do <= (CURRENT_DATE + (%s || ' days')::interval)") + else: + where.append("l.vrijedi_do BETWEEN CURRENT_DATE AND (CURRENT_DATE + (%s || ' days')::interval)") + params.append(str(days)) + if klub_id: + where.append("l.klub_id = %s"); params.append(klub_id) + + sql = f""" + SELECT l.id, l.clan_id, l.klub_id, l.datum_pregleda, l.vrijedi_do, + l.vrsta_pregleda, l.ustanova, l.lijecnik, l.placeno, + cl.ime || ' ' || cl.prezime AS clan, + cl.email AS clan_email, cl.telefon AS clan_telefon, + k.naziv AS klub, k.oib AS klub_oib, + (l.vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka, + CASE + WHEN l.vrijedi_do < CURRENT_DATE THEN 'istekao' + ELSE 'uskoro' + END AS status_calc + FROM pgz_sport.lijecnicki_pregledi l + LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id + WHERE {' AND '.join(where)} + ORDER BY l.vrijedi_do ASC + """ + + with _conn() as conn, conn.cursor() as cur: + cur.execute(sql, params) + rows = [_row(r) for r in cur.fetchall()] + n_istekli = sum(1 for r in rows if (r.get("dana_do_isteka") or 0) < 0) + n_uskoro = len(rows) - n_istekli + return {"count": len(rows), "istekli": n_istekli, "uskoro": n_uskoro, + "days_window": days, "rows": rows} + + +# ───────────── detalji ───────────── + +@router.get("/lijecnicki/{lid}") +def get_lijecnicki(lid: int): + with _conn() as conn, conn.cursor() as cur: + cur.execute(""" + SELECT l.*, + cl.ime || ' ' || cl.prezime AS clan, + cl.oib AS clan_oib, cl.email AS clan_email, + cl.telefon AS clan_telefon, + k.naziv AS klub, k.oib AS klub_oib, + CASE + WHEN l.vrijedi_do IS NULL THEN 'nepoznato' + WHEN l.vrijedi_do < CURRENT_DATE THEN 'istekao' + WHEN l.vrijedi_do <= (CURRENT_DATE + INTERVAL '30 days') THEN 'uskoro' + ELSE 'vazeci' + END AS status_calc, + (l.vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka + FROM pgz_sport.lijecnicki_pregledi l + LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id + WHERE l.id = %s + """, (lid,)) + r = cur.fetchone() + if not r: + raise HTTPException(404, "Liječnički pregled ne postoji") + return _row(r) + + +# ───────────── kreiraj ───────────── + +@router.post("/lijecnicki") +def create_lijecnicki(body: LijecnickiIn): + klub_id = body.klub_id + with _conn() as conn, conn.cursor() as cur: + if not klub_id: + cur.execute("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", (body.clan_id,)) + r = cur.fetchone() + klub_id = r["klub_id"] if r else None + # default vrijedi_do = +1 godina ako nije postavljeno + vrijedi_do = body.vrijedi_do + if vrijedi_do is None: + vrijedi_do = body.datum_pregleda + timedelta(days=365) + + cur.execute(""" + 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, racun_broj, + nacin_placanja, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + RETURNING * + """, (body.clan_id, klub_id, body.datum_pregleda, vrijedi_do, + body.vrsta_pregleda, body.ustanova, body.lijecnik, + body.spreman_za_natjecanje, body.ekg, body.krv, body.spirometrija, + body.nalaz, body.komentar_lijecnika, body.preporuke, + body.iznos, body.iznos_zzjz, body.iznos_klub, body.iznos_clan, + body.datum_placanja, body.placeno, body.racun_broj, + body.nacin_placanja, body.napomena)) + r = cur.fetchone() + conn.commit() + return _row(r) + + +# ───────────── update / delete ───────────── + +@router.put("/lijecnicki/{lid}") +def update_lijecnicki(lid: int, patch: LijecnickiPatch): + fields, params = [], [] + for f in ("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", "racun_broj", "nacin_placanja", + "napomena"): + v = getattr(patch, f) + if v is not None: + fields.append(f"{f} = %s"); params.append(v) + if not fields: + raise HTTPException(400, "Nema polja za izmjenu") + fields.append("updated_at = now()") + params.append(lid) + with _conn() as conn, conn.cursor() as cur: + cur.execute(f"UPDATE pgz_sport.lijecnicki_pregledi SET {', '.join(fields)} WHERE id=%s RETURNING *", + params) + r = cur.fetchone() + if not r: + raise HTTPException(404, "Liječnički pregled ne postoji") + conn.commit() + return _row(r) + + +@router.delete("/lijecnicki/{lid}") +def delete_lijecnicki(lid: int): + with _conn() as conn, conn.cursor() as cur: + cur.execute("DELETE FROM pgz_sport.lijecnicki_pregledi WHERE id=%s RETURNING id", (lid,)) + r = cur.fetchone() + conn.commit() + if not r: + raise HTTPException(404, "Liječnički pregled ne postoji") + return {"ok": True, "id": lid, "deleted": True} + + +# ───────────── ZZJZ PGŽ scheduling ───────────── + +def _mock_zzjz_termini(week_start: date) -> list[dict]: + """ + Mock dostupnih termina za sportsku medicinu. + TODO: zamijeniti realnim scrapeom iz https://zzjzpgz.hr/djelatnosti/sportska-medicina/ + Format termina: po danu (pon-pet), 09:00-15:00 svakih 30 min. + """ + out = [] + times = ["08:00", "08:30", "09:00", "09:30", "10:00", "10:30", + "11:00", "11:30", "12:30", "13:00", "13:30", "14:00", "14:30"] + for d in range(5): + day = week_start + timedelta(days=d) + if day.weekday() >= 5: + continue + for t in times: + # pseudo-availability deterministic by day*hour + h = int(t.split(":")[0]) + available = ((day.day + h) % 3) != 0 + out.append({ + "datum": day.isoformat(), + "vrijeme": t, + "doktor": "Dr. Sportska medicina", + "ustanova": "ZZJZ PGŽ", + "available": available, + "iznos_eur": 25.00, + }) + return out + + +@router.get("/zzjz/info") +def zzjz_info(): + """Vraća kontakt + provjerava ima li online termin sustav (best-effort scrape).""" + online_booking = _detect_zzjz_booking() + return {**ZZJZ_INFO, "online_booking": online_booking} + + +def _detect_zzjz_booking() -> dict: + """ + Best-effort detekcija da li ZZJZ PGŽ ima online termin formu na stranici. + Vraća: {available: bool, url: str|None, kind: 'iframe'|'link'|'email'} + Ne baca iznimku — uvijek vrati strukturu (fallback je email). + """ + try: + import urllib.request + import re as _re + req = urllib.request.Request(ZZJZ_INFO["url_sportska_medicina"], + headers={"User-Agent": "PGZSport/1.0"}) + with urllib.request.urlopen(req, timeout=4) as resp: + html = resp.read(200_000).decode("utf-8", errors="ignore") + # tražimo standardne oznake online booking sustava + patterns = [ + r'(https?://[^"\']*(?:doktor|booking|narucivanje|naruci|termin)[^"\']*)', + r']+src="([^"]+)"', + ] + for p in patterns: + m = _re.search(p, html, _re.IGNORECASE) + if m: + url = m.group(1) + if "iframe" in p: + return {"available": True, "url": url, "kind": "iframe"} + return {"available": True, "url": url, "kind": "link"} + return {"available": False, "url": ZZJZ_INFO["url_sportska_medicina"], + "kind": "email", + "fallback_email": ZZJZ_INFO["email"], + "note": "Nije pronađen online sustav — koristi e-mail kontakt."} + except Exception as e: + return {"available": False, "url": ZZJZ_INFO["url_sportska_medicina"], + "kind": "email", + "fallback_email": ZZJZ_INFO["email"], + "error": str(e)[:120], + "note": "Detekcija nije uspjela — fallback na e-mail."} + + +@router.get("/zzjz/termini") +def zzjz_termini( + od: Optional[date] = Query(None, + description="Početak tjedna; default = ovaj tjedan"), +): + """ + Vraća dostupne termine za sportsku medicinu pri ZZJZ PGŽ. + Trenutno: mock (deterministička dostupnost). Stvarna integracija + čeka API ili scraping form-e na zzjzpgz.hr. + """ + if od is None: + today = date.today() + od = today - timedelta(days=today.weekday()) + termini = _mock_zzjz_termini(od) + return { + "ustanova": ZZJZ_INFO, + "week_start": od.isoformat(), + "count": len(termini), + "available": sum(1 for t in termini if t["available"]), + "termini": termini, + "note": "Mock podaci. Realni termini čekaju ZZJZ PGŽ API ili authorizirani scraper.", + } + + +@router.post("/lijecnicki/{lid}/zakazi") +def zakazi_termin(lid: int, body: ZakaziIn): + """ + Zakazuje termin za pregled. + - Ako ZZJZ PGŽ ima online booking → vraća iframe/deeplink URL. + - Ako nema → vraća mailto: deeplink za zahtjev e-mailom. + Status pregleda u DB se ažurira (ustanova + napomena). + """ + with _conn() as conn, conn.cursor() as cur: + cur.execute(""" + SELECT l.id, l.clan_id, l.ustanova, + cl.ime || ' ' || cl.prezime AS clan, + cl.email AS clan_email, + k.naziv AS klub + FROM pgz_sport.lijecnicki_pregledi l + LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id + WHERE l.id=%s + """, (lid,)) + r = cur.fetchone() + if not r: + raise HTTPException(404, "Liječnički pregled ne postoji") + new_napomena = ( + f"Termin zakazan: {body.datum.isoformat()} {body.vrijeme} @ " + f"{body.ustanova}. {body.napomena or ''}" + ).strip() + cur.execute(""" + UPDATE pgz_sport.lijecnicki_pregledi + SET ustanova = COALESCE(%s, ustanova), + napomena = %s, + updated_at = now() + WHERE id = %s + RETURNING * + """, (body.ustanova, new_napomena, lid)) + upd = cur.fetchone() + conn.commit() + + booking = _detect_zzjz_booking() + from urllib.parse import quote as _q + subj = _q(f"Zahtjev za termin sportske medicine — {r.get('clan') or '(sportaš)'}") + body_email = _q( + f"Poštovani,\n\nMolim Vas termin za sportski liječnički pregled.\n\n" + f"Sportaš: {r.get('clan') or ''}\n" + f"Klub: {r.get('klub') or ''}\n" + f"Željeni datum: {body.datum.isoformat()} oko {body.vrijeme}\n" + f"Kontakt: {r.get('clan_email') or '(nepoznato)'}\n\n" + f"Lijep pozdrav,\nPGŽ Sport platforma" + ) + mailto = f"mailto:{ZZJZ_INFO['email']}?subject={subj}&body={body_email}" + + return { + "ok": True, + "id": lid, + "zakazano_za": f"{body.datum.isoformat()} {body.vrijeme}", + "ustanova": body.ustanova, + "zzjz": ZZJZ_INFO, + "booking": booking, + "mailto": mailto, + "note": ( + "Online booking detektiran — koristi 'booking.url' za iframe/redirect." + if booking.get("available") else + "Online booking nije pronađen — fallback: koristi 'mailto' za zahtjev e-mailom." + ), + "pregled": _row(upd), + } + + +class ZakaziEmailIn(BaseModel): + klub_id: Optional[int] = None + clan_id: int + zeljeni_datum: Optional[date] = None + zeljeno_vrijeme: Optional[str] = "09:00" + napomena: Optional[str] = None + + +@router.post("/lijecnicki/zakazi-email") +def zakazi_email(body: ZakaziEmailIn): + """ + Bez postojećeg pregleda — generira mailto: link s pred-popunjenim + podacima sportaša/kluba za slanje zahtjeva ZZJZ PGŽ. + """ + with _conn() as conn, conn.cursor() as cur: + cur.execute(""" + SELECT cl.id, cl.ime || ' ' || cl.prezime AS clan, + cl.email AS clan_email, cl.telefon AS clan_telefon, + cl.datum_rodenja, cl.oib AS clan_oib, + k.naziv AS klub, k.oib AS klub_oib + FROM pgz_sport.clanovi cl + LEFT JOIN pgz_sport.klubovi k ON k.id = cl.klub_id + WHERE cl.id=%s + """, (body.clan_id,)) + r = cur.fetchone() + if not r: + raise HTTPException(404, "Član ne postoji") + + from urllib.parse import quote as _q + when = (body.zeljeni_datum.isoformat() if body.zeljeni_datum else "po dogovoru") + subj = _q(f"Zahtjev za termin sportske medicine — {r['clan']}") + body_email = _q( + f"Poštovani,\n\nMolim Vas termin za sportski liječnički pregled.\n\n" + f"Sportaš: {r['clan']}\n" + f"OIB: {r['clan_oib'] or '—'}\n" + f"Datum rođenja: {r['datum_rodenja'] or '—'}\n" + f"Klub: {r['klub'] or '—'}\n" + f"Željeni termin: {when} oko {body.zeljeno_vrijeme}\n" + f"Kontakt: {r['clan_email'] or '—'} / {r['clan_telefon'] or '—'}\n\n" + f"Napomena: {body.napomena or '—'}\n\n" + f"Lijep pozdrav,\nPGŽ Sport platforma" + ) + mailto = f"mailto:{ZZJZ_INFO['email']}?subject={subj}&body={body_email}" + booking = _detect_zzjz_booking() + return { + "ok": True, + "clan": r["clan"], + "zzjz": ZZJZ_INFO, + "booking": booking, + "mailto": mailto, + } diff --git a/_backups/r3_cc5/obrasci_router.py.post_m9.1777932881 b/_backups/r3_cc5/obrasci_router.py.post_m9.1777932881 new file mode 100644 index 0000000..a054d10 --- /dev/null +++ b/_backups/r3_cc5/obrasci_router.py.post_m9.1777932881 @@ -0,0 +1,757 @@ +#!/usr/bin/env python3 +# ═══════════════════════════════════════════════════════════════════ +# Fajl: routers/obrasci_router.py | v1.0.0 | 04.05.2026 +# Autor: Damir Radulić / damir@rinet.one +# Lokacija: /opt/pgz-sport/routers/obrasci_router.py +# Svrha: M9 — Obrasci za sufinanciranje (form_templates + form_submissions) +# + autopopulacija polja iz baze + digitalni potpis (sha256) +# ═══════════════════════════════════════════════════════════════════ +"""M9 Obrasci router. + +Endpointi (montirani na /api/crm): + GET /forms → katalog form_templates + GET /forms/{code_or_id} → schema + ui hints + GET /forms/{code_or_id}/prefill → autopopulirane vrijednosti za klub/člana + GET /forms/submissions → lista submissiona (filter: status, klub, code) + POST /forms/submissions → kreira draft submission + GET /forms/submissions/{id} → detalji + POST /forms/submissions/{id}/submit → potpis + status submitted + POST /forms/submissions/{id}/approve + POST /forms/submissions/{id}/reject + POST /forms/{code_or_id}/submit → kompatibilni shortcut: kreiraj+submit u jednom POST +""" +from __future__ import annotations + +import json +import hashlib +import sys +from datetime import date, datetime +from decimal import Decimal +from typing import Optional, Any +import uuid as _uuid + +import psycopg2 +from psycopg2.extras import RealDictCursor, Json +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +router = APIRouter(prefix="/api/crm", tags=["crm-obrasci"]) + +DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7" + + +def _conn(): + return psycopg2.connect(DSN, cursor_factory=RealDictCursor) + + +def _conv(v): + if isinstance(v, (date, datetime)): + return v.isoformat() + if isinstance(v, Decimal): + return float(v) + if isinstance(v, _uuid.UUID): + return str(v) + return v + + +def _row(d): + return {k: _conv(v) for k, v in dict(d).items()} + + +def _resolve_template(code_or_id: str, cur) -> dict: + """Akceptira numerički ID ili code string.""" + if str(code_or_id).isdigit(): + cur.execute("SELECT * FROM pgz_sport.form_templates WHERE id=%s AND active=TRUE", + (int(code_or_id),)) + else: + cur.execute("SELECT * FROM pgz_sport.form_templates WHERE code=%s AND active=TRUE", + (code_or_id,)) + r = cur.fetchone() + if not r: + raise HTTPException(404, f"Form template '{code_or_id}' ne postoji") + return r + + +# ───────────── modeli ───────────── + +class SubmissionIn(BaseModel): + template_code: Optional[str] = None + template_id: Optional[int] = None + klub_id: Optional[int] = None + user_id: Optional[int] = None + clan_id: Optional[int] = None + data: dict = {} + attachments: Optional[list] = None + status: Optional[str] = "draft" + + +class SubmitIn(BaseModel): + user_id: Optional[int] = None + full_name: Optional[str] = None + data: Optional[dict] = None + confirm: bool = True + + +class ApproveIn(BaseModel): + user_id: Optional[int] = None + note: Optional[str] = None + + +class RejectIn(BaseModel): + user_id: Optional[int] = None + reason: str + + +# ───────────── katalog templata ───────────── + +@router.get("/forms/templates") +def list_form_templates_alias( + kategorija: Optional[str] = Query(None), + q: Optional[str] = Query(None), + active_only: bool = Query(True), +): + """Alias za /forms — kompatibilnost s /sport/api/forms/templates.""" + return list_forms(kategorija=kategorija, q=q, active_only=active_only) + + +@router.get("/forms") +def list_forms( + kategorija: Optional[str] = Query(None), + q: Optional[str] = Query(None), + active_only: bool = Query(True), +): + where, params = [], [] + if active_only: + where.append("active = TRUE") + if kategorija: + where.append("kategorija = %s"); params.append(kategorija) + if q: + where.append("(naziv ILIKE %s OR opis ILIKE %s OR code ILIKE %s)") + params += [f"%{q}%"] * 3 + where_sql = ("WHERE " + " AND ".join(where)) if where else "" + with _conn() as conn, conn.cursor() as cur: + cur.execute(f""" + SELECT id, code, naziv, kategorija, opis, required_role, + jsonb_array_length(COALESCE(schema_json->'fields', '[]'::jsonb)) AS field_count, + active, created_at + FROM pgz_sport.form_templates + {where_sql} + ORDER BY kategorija NULLS LAST, naziv + """, params) + rows = [_row(r) for r in cur.fetchall()] + cur.execute("SELECT DISTINCT kategorija FROM pgz_sport.form_templates WHERE kategorija IS NOT NULL ORDER BY 1") + kats = [r["kategorija"] for r in cur.fetchall()] + return {"count": len(rows), "kategorije": kats, "forms": rows} + + +# NOTE: /forms/submissions* moraju biti registrirani PRIJE /forms/{code_or_id} +# jer FastAPI prvo provjerava redom registracije, a "submissions" bi +# inače bilo uhvaćeno kao code_or_id. + +# ───────────── autopopulacija polja iz baze (mora prije /{code_or_id} catch-all) ───────────── + +@router.get("/forms/{code_or_id}/prefill") +def prefill_form(code_or_id: str, + klub_id: Optional[int] = Query(None), + clan_id: Optional[int] = Query(None), + user_id: Optional[int] = Query(None)): + """ + Vraća inicijalne vrijednosti za polja obrasca, popunjene iz baze. + + Mapiranje polja → izvor: + klub_naziv, klub_oib, klub_iban, klub_adresa, klub_grad, klub_email, klub_telefon, + predsjednik, tajnik, sport, savez_naziv → pgz_sport.klubovi + ime, prezime, oib_clan, datum_rodenja, kategorija → pgz_sport.clanovi + iban, naziv (kad se odnose na klub) → klub + *_godina → tekuća godina + Polja koja schema_json nema, neće biti vraćena. + """ + with _conn() as conn, conn.cursor() as cur: + t = _resolve_template(code_or_id, cur) + schema = t.get("schema_json") or {} + fields = schema.get("fields") or [] + field_names = {f.get("name") for f in fields if isinstance(f, dict)} + + klub = {} + savez = {} + if klub_id: + cur.execute(""" + 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,)) + r = cur.fetchone() + if r: + klub = _row(r) + + clan = {} + if clan_id: + cur.execute("SELECT * FROM pgz_sport.clanovi WHERE id=%s", (clan_id,)) + r = cur.fetchone() + if r: + clan = _row(r) + # ako klub_id nije eksplicitno, izvuci iz člana + if not klub and clan.get("klub_id"): + cur.execute(""" + 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 + """, (clan["klub_id"],)) + rr = cur.fetchone() + if rr: + klub = _row(rr) + + user = {} + if user_id: + cur.execute("SELECT id, email, full_name, ime, prezime, oib, telefon, klub_id, savez_id, user_type FROM pgz_sport.users WHERE id=%s", + (user_id,)) + r = cur.fetchone() + if r: + user = _row(r) + + # Mapiranje + prefill: dict = {} + today = date.today() + + def put(name: str, value: Any): + if name in field_names and value not in (None, ""): + prefill[name] = value + + # KLUB → polja + if klub: + put("klub_naziv", klub.get("naziv")) + put("naziv_kluba", klub.get("naziv")) + put("naziv", klub.get("naziv")) + put("klub_oib", klub.get("oib")) + put("oib", klub.get("oib")) + put("oib_kluba", klub.get("oib")) + put("klub_iban", klub.get("iban")) + put("iban", klub.get("iban")) + put("adresa", klub.get("adresa")) + put("klub_adresa", klub.get("adresa")) + put("grad", klub.get("grad")) + put("klub_grad", klub.get("grad")) + put("klub_email", klub.get("email")) + put("email", klub.get("email")) + put("klub_telefon", klub.get("telefon")) + put("telefon", klub.get("telefon")) + put("predsjednik", klub.get("predsjednik")) + put("tajnik", klub.get("tajnik")) + put("sport", klub.get("sport")) + put("savez_naziv", klub.get("savez_naziv")) + put("godina_osnutka", klub.get("godina_osnutka")) + put("matični_broj", klub.get("matični_broj")) + put("reg_broj", klub.get("reg_broj")) + + # ČLAN → polja + if clan: + put("ime", clan.get("ime")) + put("prezime", clan.get("prezime")) + put("ime_prezime", f"{clan.get('ime','')} {clan.get('prezime','')}".strip()) + put("oib_clan", clan.get("oib")) + put("oib_sportasa", clan.get("oib")) + put("datum_rodenja", clan.get("datum_rodenja")) + put("kategorija", clan.get("kategorija")) + put("podkategorija", clan.get("podkategorija")) + put("pozicija", clan.get("pozicija")) + put("clan_email", clan.get("email")) + put("clan_telefon", clan.get("telefon")) + put("clan_adresa", clan.get("adresa")) + put("spol", clan.get("spol")) + put("licenca_broj", clan.get("licenca_broj")) + + # USER → polja + if user: + put("podnositelj_ime", (user.get("full_name") or + f"{user.get('ime','')} {user.get('prezime','')}".strip())) + put("podnositelj_email", user.get("email")) + put("podnositelj_telefon", user.get("telefon")) + + # TEKUĆA GODINA / DATUM + put("program_godina", today.year) + put("godina", today.year) + put("datum", today.isoformat()) + put("datum_predaje", today.isoformat()) + + return { + "template_code": t["code"], + "template_id": t["id"], + "naziv": t["naziv"], + "prefill": prefill, + "missing_fields": sorted(field_names - set(prefill.keys())), + "applied_fields": sorted(prefill.keys()), + "sources": {"klub": bool(klub), "clan": bool(clan), "user": bool(user)}, + } + + +# ───────────── submissions ───────────── + +@router.get("/forms/submissions") +def list_submissions( + klub_id: Optional[int] = Query(None), + template_code: Optional[str] = Query(None), + status: Optional[str] = Query(None), + user_id: Optional[int] = Query(None), + limit: int = Query(200, le=1000), +): + where, params = [], [] + if klub_id: + where.append("s.klub_id=%s"); params.append(klub_id) + if template_code: + where.append("s.template_code=%s"); params.append(template_code) + if status: + where.append("s.status=%s"); params.append(status) + if user_id: + where.append("s.user_id=%s"); params.append(user_id) + where_sql = ("WHERE " + " AND ".join(where)) if where else "" + params.append(limit) + sql = f""" + SELECT s.id, s.template_id, s.template_code, s.klub_id, s.user_id, + s.clan_id, s.status, s.reference_no, s.submitted_at, + s.reviewed_at, s.approved_at, s.rejected_reason, s.created_at, + t.naziv AS template_naziv, t.kategorija, + k.naziv AS klub_naziv, + cl.ime || ' ' || cl.prezime AS clan_naziv, + COALESCE(s.data->>'__signature_sha256', NULL) AS signature_sha256 + FROM pgz_sport.form_submissions s + LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id + LEFT JOIN pgz_sport.klubovi k ON k.id = s.klub_id + LEFT JOIN pgz_sport.clanovi cl ON cl.id = s.clan_id + {where_sql} + ORDER BY s.created_at DESC + LIMIT %s + """ + with _conn() as conn, conn.cursor() as cur: + cur.execute(sql, params) + rows = [_row(r) for r in cur.fetchall()] + cur.execute(f""" + SELECT COUNT(*) AS total, + COUNT(*) FILTER (WHERE s.status='draft') AS draft, + COUNT(*) FILTER (WHERE s.status='submitted') AS submitted, + COUNT(*) FILTER (WHERE s.status='approved') AS approved, + COUNT(*) FILTER (WHERE s.status='rejected') AS rejected + FROM pgz_sport.form_submissions s + {where_sql} + """, params[:-1]) + summary = _row(cur.fetchone() or {}) + return {"count": len(rows), "rows": rows, "summary": summary} + + +@router.get("/forms/submissions/{sid}") +def get_submission(sid: int): + with _conn() as conn, conn.cursor() as cur: + cur.execute(""" + SELECT s.*, t.naziv AS template_naziv, t.kategorija, t.schema_json, + k.naziv AS klub_naziv, k.oib AS klub_oib, k.iban AS klub_iban, + cl.ime || ' ' || cl.prezime AS clan_naziv + FROM pgz_sport.form_submissions s + LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id + LEFT JOIN pgz_sport.klubovi k ON k.id = s.klub_id + LEFT JOIN pgz_sport.clanovi cl ON cl.id = s.clan_id + WHERE s.id = %s + """, (sid,)) + r = cur.fetchone() + if not r: + raise HTTPException(404, "Submission ne postoji") + return _row(r) + + +@router.post("/forms/submissions") +def create_submission(body: SubmissionIn): + if not (body.template_code or body.template_id): + raise HTTPException(400, "template_code ili template_id obavezan") + with _conn() as conn, conn.cursor() as cur: + if body.template_id: + cur.execute("SELECT * FROM pgz_sport.form_templates WHERE id=%s", (body.template_id,)) + else: + cur.execute("SELECT * FROM pgz_sport.form_templates WHERE code=%s", (body.template_code,)) + t = cur.fetchone() + if not t: + raise HTTPException(404, "Template ne postoji") + + # generiraj reference_no: TPL-YYYY-XXXXXXXX + ref = f"{t['code'][:8].upper()}-{date.today().year}-{_uuid.uuid4().hex[:8].upper()}" + + cur.execute(""" + INSERT INTO pgz_sport.form_submissions + (template_id, template_code, klub_id, user_id, clan_id, data, + attachments, status, reference_no) + VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s,%s) + RETURNING * + """, (t["id"], t["code"], body.klub_id, body.user_id, body.clan_id, + json.dumps(body.data or {}), json.dumps(body.attachments or []), + body.status or "draft", ref)) + s = cur.fetchone() + conn.commit() + return _row(s) + + +# ───────────── digitalni potpis (sha256) i submit ───────────── + +def _sign_payload(data: dict, signer: Optional[str]) -> dict: + """ + Deterministički sha256 nad sortiranim JSON-om + timestamp. + Vraća meta polja koja se ubacuju u data: + __signature_sha256, __signed_at, __signed_by + """ + canon = json.dumps(data, sort_keys=True, ensure_ascii=False, default=str) + sha = hashlib.sha256(canon.encode("utf-8")).hexdigest() + return { + "__signature_sha256": sha, + "__signed_at": datetime.utcnow().isoformat() + "Z", + "__signed_by": signer or "unknown", + } + + +@router.post("/forms/submissions/{sid}/submit") +def submit_submission(sid: int, body: SubmitIn): + if not body.confirm: + raise HTTPException(400, "Potrebna potvrda (confirm=true)") + with _conn() as conn, conn.cursor() as cur: + cur.execute("SELECT * FROM pgz_sport.form_submissions WHERE id=%s", (sid,)) + r = cur.fetchone() + if not r: + raise HTTPException(404, "Submission ne postoji") + if r["status"] not in ("draft", "rejected"): + raise HTTPException(400, f"Submission je u statusu '{r['status']}', ne može se submitati") + + merged = dict(r["data"] or {}) + if body.data: + merged.update(body.data) + # ukloni stari potpis prije računanja novog + for k in list(merged.keys()): + if k.startswith("__signature") or k.startswith("__signed"): + merged.pop(k, None) + signer = body.full_name or (str(body.user_id) if body.user_id else None) + sig = _sign_payload(merged, signer) + merged.update(sig) + + cur.execute(""" + UPDATE pgz_sport.form_submissions + SET data = %s::jsonb, + status = 'submitted', + user_id = COALESCE(%s, user_id), + submitted_at = now(), + updated_at = now() + WHERE id = %s + RETURNING * + """, (json.dumps(merged), body.user_id, sid)) + s = cur.fetchone() + conn.commit() + return { + "ok": True, + "id": sid, + "status": "submitted", + "signature_sha256": sig["__signature_sha256"], + "signed_at": sig["__signed_at"], + "signed_by": sig["__signed_by"], + "submission": _row(s), + } + + +@router.post("/forms/submissions/{sid}/approve") +def approve_submission(sid: int, body: ApproveIn): + with _conn() as conn, conn.cursor() as cur: + cur.execute(""" + UPDATE pgz_sport.form_submissions + SET status='approved', + approved_by=%s, approved_at=now(), + reviewed_by=%s, reviewed_at=now(), + updated_at=now() + WHERE id=%s AND status IN ('submitted','draft') + RETURNING * + """, (body.user_id, body.user_id, sid)) + r = cur.fetchone() + if not r: + raise HTTPException(404, "Submission ne postoji ili nije u submitted statusu") + conn.commit() + return {"ok": True, "id": sid, "status": "approved", "submission": _row(r)} + + +@router.post("/forms/submissions/{sid}/reject") +def reject_submission(sid: int, body: RejectIn): + with _conn() as conn, conn.cursor() as cur: + cur.execute(""" + UPDATE pgz_sport.form_submissions + SET status='rejected', + reviewed_by=%s, reviewed_at=now(), + rejected_reason=%s, + updated_at=now() + WHERE id=%s AND status IN ('submitted','draft') + RETURNING * + """, (body.user_id, body.reason, sid)) + r = cur.fetchone() + if not r: + raise HTTPException(404, "Submission ne postoji ili nije u submitted statusu") + conn.commit() + return {"ok": True, "id": sid, "status": "rejected", + "reason": body.reason, "submission": _row(r)} + + +# ───────────── potpisivanje + PDF izvoz submissiona ───────────── + +class SignIn(BaseModel): + user_id: Optional[int] = None + full_name: Optional[str] = None + + +@router.post("/forms/submissions/{sid}/sign") +def sign_submission(sid: int, body: SignIn): + """ + Digitalni potpis postojećeg submissiona — sha256 nad sortiranim JSON-om. + Može se pozvati i na već submitanom (re-sign) i na draftu (samo potpisuje, + ne mijenja status). + """ + with _conn() as conn, conn.cursor() as cur: + cur.execute("SELECT * FROM pgz_sport.form_submissions WHERE id=%s", (sid,)) + r = cur.fetchone() + if not r: + raise HTTPException(404, "Submission ne postoji") + + merged = dict(r["data"] or {}) + # ukloni stari potpis + for k in list(merged.keys()): + if k.startswith("__signature") or k.startswith("__signed"): + merged.pop(k, None) + signer = body.full_name or (str(body.user_id) if body.user_id else "anonymous") + sig = _sign_payload(merged, signer) + merged.update(sig) + cur.execute(""" + UPDATE pgz_sport.form_submissions + SET data = %s::jsonb, + user_id = COALESCE(%s, user_id), + updated_at = now() + WHERE id = %s + RETURNING * + """, (json.dumps(merged), body.user_id, sid)) + s = cur.fetchone() + conn.commit() + return { + "ok": True, + "id": sid, + "signature_sha256": sig["__signature_sha256"], + "signed_at": sig["__signed_at"], + "signed_by": sig["__signed_by"], + "submission": _row(s), + } + + +@router.get("/forms/submissions/{sid}/pdf") +def submission_pdf(sid: int): + """Generira PDF s sadržajem submissiona, statusom i potpisom (sha256).""" + from fastapi.responses import Response + from reportlab.pdfgen import canvas + from reportlab.lib.pagesizes import A4 + from reportlab.lib.units import mm + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + import io as _io + + # font za HR diakritike + font_reg, font_bold = "Helvetica", "Helvetica-Bold" + try: + if "DejaVu" not in pdfmetrics.getRegisteredFontNames(): + for path in ("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/dejavu/DejaVuSans.ttf"): + try: + pdfmetrics.registerFont(TTFont("DejaVu", path)) + pdfmetrics.registerFont(TTFont("DejaVu-Bold", + path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf"))) + font_reg, font_bold = "DejaVu", "DejaVu-Bold" + break + except Exception: + continue + else: + font_reg, font_bold = "DejaVu", "DejaVu-Bold" + except Exception: + pass + + with _conn() as conn, conn.cursor() as cur: + cur.execute(""" + SELECT s.*, t.naziv AS template_naziv, t.kategorija, t.schema_json, + k.naziv AS klub_naziv, k.oib AS klub_oib, k.iban AS klub_iban, + cl.ime || ' ' || cl.prezime AS clan_naziv + FROM pgz_sport.form_submissions s + LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id + LEFT JOIN pgz_sport.klubovi k ON k.id = s.klub_id + LEFT JOIN pgz_sport.clanovi cl ON cl.id = s.clan_id + WHERE s.id = %s + """, (sid,)) + r = cur.fetchone() + if not r: + raise HTTPException(404, "Submission ne postoji") + + s = _row(r) + schema = s.get("schema_json") or {} + fields = schema.get("fields") or [] + data = s.get("data") or {} + + sig_sha = data.get("__signature_sha256") + sig_at = data.get("__signed_at") + sig_by = data.get("__signed_by") + + buf = _io.BytesIO() + c = canvas.Canvas(buf, pagesize=A4) + W, H = A4 + y = H - 18 * mm + + # Header bar + c.setFillColorRGB(0.13, 0.20, 0.32) + c.rect(0, H - 22 * mm, W, 22 * mm, fill=1, stroke=0) + c.setFillColorRGB(1, 1, 1) + c.setFont(font_bold, 14) + c.drawString(15 * mm, H - 12 * mm, "PGŽ SPORT — OBRAZAC") + c.setFont(font_reg, 10) + c.drawString(15 * mm, H - 18 * mm, str(s.get("template_naziv") or s.get("template_code") or "")) + c.drawRightString(W - 15 * mm, H - 12 * mm, f"REF: {s.get('reference_no') or ''}") + c.drawRightString(W - 15 * mm, H - 18 * mm, + f"Status: {s.get('status','').upper()}") + + y = H - 30 * mm + c.setFillColorRGB(0, 0, 0) + + # Meta + def line(label, value, bold=False): + nonlocal y + if y < 25 * mm: + c.showPage() + y = H - 20 * mm + c.setFillColorRGB(0, 0, 0) + c.setFont(font_reg, 8) + c.setFillColorRGB(0.45, 0.45, 0.45) + c.drawString(15 * mm, y, label) + c.setFont(font_bold if bold else font_reg, 10) + c.setFillColorRGB(0, 0, 0) + v = "" if value is None else str(value) + # wrap + max_w = W - 30 * mm + while v: + chunk = v + while pdfmetrics.stringWidth(chunk, font_bold if bold else font_reg, 10) > max_w and len(chunk) > 5: + chunk = chunk[:-2] + c.drawString(15 * mm, y - 4 * mm, chunk) + v = v[len(chunk):].lstrip() if len(chunk) < len(v) else "" + y -= 5 * mm + if v: + if y < 25 * mm: + c.showPage(); y = H - 20 * mm + y -= 3 * mm + + line("KLUB", s.get("klub_naziv"), bold=True) + line("OIB KLUBA", s.get("klub_oib")) + line("IBAN KLUBA", s.get("klub_iban")) + if s.get("clan_naziv"): + line("ČLAN/SPORTAŠ", s.get("clan_naziv")) + line("DATUM PREDAJE", s.get("submitted_at") or s.get("created_at")) + line("STATUS", s.get("status"), bold=True) + + # Section divider + y -= 4 * mm + c.setStrokeColorRGB(0.13, 0.20, 0.32) + c.setLineWidth(0.6) + c.line(15 * mm, y, W - 15 * mm, y) + y -= 6 * mm + c.setFont(font_bold, 11) + c.setFillColorRGB(0.13, 0.20, 0.32) + c.drawString(15 * mm, y, "SADRŽAJ OBRASCA") + y -= 8 * mm + c.setFillColorRGB(0, 0, 0) + + # Polja iz schema_json (skip meta __keys) + if fields: + for f in fields: + name = f.get("name") + if not name or name.startswith("__"): + continue + label = f.get("label") or name + val = data.get(name) + line(label, val) + else: + # fallback — sve ključeve iz data + for k, v in data.items(): + if k.startswith("__"): + continue + line(k, v) + + # Potpis + y -= 6 * mm + if y < 50 * mm: + c.showPage(); y = H - 20 * mm + c.setFillColorRGB(0.13, 0.20, 0.32) + c.setStrokeColorRGB(0.13, 0.20, 0.32) + c.setLineWidth(0.6) + c.line(15 * mm, y, W - 15 * mm, y) + y -= 6 * mm + c.setFont(font_bold, 11) + c.drawString(15 * mm, y, "DIGITALNI POTPIS") + y -= 8 * mm + c.setFillColorRGB(0, 0, 0) + if sig_sha: + line("Potpisao", sig_by or "") + line("Vrijeme potpisa (UTC)", sig_at or "") + line("SHA-256 hash sadržaja", sig_sha) + line("Verifikacija", + "PGŽ Sport ERP/CRM — hash izračunat nad sortiranim JSON sadržajem (bez __* polja).") + else: + c.setFont(font_reg, 9) + c.setFillColorRGB(0.7, 0.3, 0.3) + c.drawString(15 * mm, y, "Obrazac NIJE digitalno potpisan.") + y -= 6 * mm + + # Footer + c.setFont(font_reg, 7) + c.setFillColorRGB(0.55, 0.55, 0.55) + c.drawString(15 * mm, 10 * mm, + f"PGŽ Sport ERP/CRM • Generirano {datetime.now().strftime('%d.%m.%Y. %H:%M')} • REF {s.get('reference_no') or sid}") + + c.save() + pdf = buf.getvalue() + return Response(content=pdf, media_type="application/pdf", + headers={"Content-Disposition": + f"inline; filename=obrazac-{sid}.pdf"}) + + +# ───────────── /forms/{code_or_id} (catch-all GET — mora biti POSLIJE submissions!) ───────────── + +@router.get("/forms/{code_or_id}") +def get_form(code_or_id: str): + with _conn() as conn, conn.cursor() as cur: + t = _resolve_template(code_or_id, cur) + return _row(t) + + +# ───────────── shortcut: kreiraj+submit u jednom ───────────── + +@router.post("/forms/{code_or_id}/submit") +def quick_submit(code_or_id: str, body: SubmissionIn): + """Kompatibilni shortcut — kreira draft + odmah submita s potpisom.""" + with _conn() as conn, conn.cursor() as cur: + t = _resolve_template(code_or_id, cur) + ref = f"{t['code'][:8].upper()}-{date.today().year}-{_uuid.uuid4().hex[:8].upper()}" + + merged = dict(body.data or {}) + signer = str(body.user_id) if body.user_id else "anonymous" + sig = _sign_payload(merged, signer) + merged.update(sig) + + cur.execute(""" + INSERT INTO pgz_sport.form_submissions + (template_id, template_code, klub_id, user_id, clan_id, data, + attachments, status, reference_no, submitted_at) + VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,'submitted',%s, now()) + RETURNING * + """, (t["id"], t["code"], body.klub_id, body.user_id, body.clan_id, + json.dumps(merged), json.dumps(body.attachments or []), ref)) + s = cur.fetchone() + conn.commit() + return { + "ok": True, + "id": s["id"], + "reference_no": s["reference_no"], + "status": "submitted", + "signature_sha256": sig["__signature_sha256"], + "signed_at": sig["__signed_at"], + "submission": _row(s), + } diff --git a/_backups/r3_cc5/pgz_sport_api.py.post_m9.1777932881 b/_backups/r3_cc5/pgz_sport_api.py.post_m9.1777932881 new file mode 100644 index 0000000..0c50b27 --- /dev/null +++ b/_backups/r3_cc5/pgz_sport_api.py.post_m9.1777932881 @@ -0,0 +1,1668 @@ +#!/usr/bin/env python3 +""" +pgz_sport_api.py - FastAPI backend za PGŽ Sportski savez ERP/CRM +Author: Damir Radulić (damir@rinet.one) +Date: 25.04.2026 +Port: 8095 +Endpoints: savezi, klubovi, članovi, članarine, liječnički, manifestacije, proračun, dashboard, alertovi +""" + +from fastapi import FastAPI, HTTPException, Query, Body, Header, Depends, UploadFile, File, Form, Request +import json +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import Optional, List +from datetime import date, datetime +import psycopg2 +import psycopg2.extras +from pgz_sport_v2_router import router as v2_router +import os + +DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7') + + +ADMIN_TOKEN = 'admin-pgz-2026' + +def is_admin(authorization): + if not authorization: return False + token = authorization.replace('Bearer ', '').strip() + if token == ADMIN_TOKEN: return True + # Try JWT + try: + import jwt as _jwt + payload = _jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return payload.get("role") == "admin" + except Exception: + return False + +def blur_oib(v): + if not v: return v + s = str(v); + return s[:3] + '•'*(len(s)-5) + s[-2:] if len(s) >= 8 else '•'*len(s) +def blur_email(e): + if not e or '@' not in str(e): return e + u, d = str(e).split('@',1); return (u[:1]+'•••' if u else '')+'@'+d +def blur_phone(p): + if not p: return p + s=str(p); return s[:4]+'•'*(len(s)-7)+s[-3:] if len(s)>=7 else s +def blur_iban(v): + if not v: return v + s=str(v); return s[:4]+'•'*(len(s)-8)+s[-4:] if len(s)>=8 else s +def blur_date(d): + if not d: return d + s = str(d); return s[:4]+'-••-••' if len(s)>=4 else s +def blur_text(t, keep=3): + if not t: return t + s=str(t); return s[:keep]+'•'*(len(s)-keep*2)+s[-keep:] if len(s)>keep*2 else s + +def apply_privacy(rows, admin): + if admin: return rows + out = [] + for r in (rows if isinstance(rows, list) else [rows]): + rr = dict(r) + for k, v in list(rr.items()): + if v is None: continue + kl = k.lower() + if 'oib' in kl: rr[k] = blur_oib(v) + elif 'email' in kl: rr[k] = blur_email(v) + elif kl in ('telefon','tel','phone'): rr[k] = blur_phone(v) + elif kl == 'datum_rodenja': rr[k] = blur_date(v) + elif 'iban' in kl: rr[k] = blur_iban(v) + elif kl == 'adresa': rr[k] = blur_text(v, 3) + elif 'licenca_broj' in kl: rr[k] = blur_text(v, 2) + out.append(rr) + return out if isinstance(rows, list) else out[0] + +app = FastAPI(title="PGŽ Sportski savez ERP/CRM", version="1.0.0") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + + +# === URL rewrite middleware - convert direct external image URLs to /img-proxy === +import json as _json_mw +import re as _re_mw +from starlette.responses import Response as _StarletteResponse_mw + +_IMG_DOMAINS_RE = _re_mw.compile( + r'https?://(?:hns\.family|hns\.hr|hbs\.hr|hrvatski-bocarski-savez\.hr|' + r'rk-zamet\.hr|hvs\.hr|rezultati\.hvs\.hr|sport-pgz\.hr)' + r'/[^"\s\\]+\.(?:jpg|jpeg|png|gif|webp|svg)', + _re_mw.IGNORECASE +) + +def _rewrite_to_proxy(text: str) -> str: + """Replace external image URLs with /sport/api/v2/img-proxy?u=...""" + from urllib.parse import quote as _q + def _sub(m): + url = m.group(0) + return "/sport/api/v2/img-proxy?u=" + _q(url, safe='') + return _IMG_DOMAINS_RE.sub(_sub, text) + +@app.middleware("http") +async def url_rewrite_middleware(request, call_next): + response = await call_next(request) + # Only rewrite JSON API responses + ct = response.headers.get("content-type", "") + if "application/json" not in ct: + return response + # Only on /api/v2 routes (admin & data endpoints) - SKIP /api/v2/img-proxy itself + path = request.url.path + if "/api/v2/img-proxy" in path or "/api/v2/dokumenti" in path: + return response # don't rewrite raw document content + # Read body + body = b"" + async for chunk in response.body_iterator: + body += chunk + try: + text = body.decode("utf-8") + new_text = _rewrite_to_proxy(text) + new_body = new_text.encode("utf-8") + except Exception: + new_body = body + return _StarletteResponse_mw( + content=new_body, + status_code=response.status_code, + headers={k: v for k, v in response.headers.items() if k.lower() not in ("content-length",)}, + media_type=ct, + ) +# === end URL rewrite middleware === + +def db(): + conn = psycopg2.connect(**DB) + conn.autocommit = True + return conn + +def fetch(sql, params=None): + with db() as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as c: + c.execute(sql, params or ()) + return [dict(r) for r in c.fetchall()] + +def execute(sql, params=None): + with db() as conn: + with conn.cursor() as c: + c.execute(sql, params or ()) + return c.rowcount + +# ==================== HEALTH ==================== +@app.get("/health") +def health(): + try: + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + return {"status": "ok", "service": "pgz_sport", "dashboard": rows[0] if rows else None} + except Exception as e: + raise HTTPException(500, f"DB error: {e}") + + +@app.get("/api/whoami") +def whoami_v2(authorization: Optional[str] = Header(None)): + return {"role": "admin" if is_admin(authorization) else "viewer", "privacy_active": not is_admin(authorization)} + +# ==================== DASHBOARD ==================== +@app.get("/api/dashboard") +def dashboard(): + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + if not rows: + return {} + d = rows[0] + # Top savezi by registriranih 2024 + top = fetch("""SELECT s.naziv, st.klubova_clanica, st.registriranih, st.trenera, st.reprezentativaca + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina=2024 ORDER BY st.registriranih DESC LIMIT 10""") + proracun_trend = fetch("SELECT godina, ukupno FROM pgz_sport.proracun ORDER BY godina") + nositelji = fetch("""SELECT naziv_kluba, godina, iznos FROM pgz_sport.potpore_nositelji + WHERE godina = 2025 ORDER BY iznos DESC LIMIT 10""") + return {**d, "top_savezi": top, "proracun_trend": proracun_trend, "nositelji_2025": nositelji} + +@app.get("/api/dashboard/ekosustav") +def dashboard_ekosustav(): + """Sport ekosustav PGŽ — coverage stats za enrichment iz FINA registra.""" + summary = fetch("""SELECT + COUNT(*) AS klubova_total, + COUNT(*) FILTER (WHERE oib IS NOT NULL) AS s_oib, + COUNT(*) FILTER (WHERE predsjednik IS NOT NULL) AS s_predsjednik, + COUNT(*) FILTER (WHERE tajnik IS NOT NULL) AS s_tajnik, + COUNT(*) FILTER (WHERE ciljevi IS NOT NULL) AS s_ciljevi, + COUNT(*) FILTER (WHERE opis_djelatnosti IS NOT NULL) AS s_opis, + COUNT(*) FILTER (WHERE sjediste IS NOT NULL) AS s_sjediste, + COUNT(*) FILTER (WHERE email IS NOT NULL) AS s_email, + COUNT(*) FILTER (WHERE web_stranica IS NOT NULL) AS s_web, + COUNT(*) FILTER (WHERE udruga_status = \'AKTIVAN\') AS s_aktivan_reg, + COUNT(*) FILTER (WHERE savez_id IS NOT NULL) AS s_savez, + COUNT(*) FILTER (WHERE nositelj_kvalitete) AS s_nositelj + FROM pgz_sport.klubovi WHERE aktivan""")[0] + + by_sport = fetch("""SELECT sport, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND sport IS NOT NULL + GROUP BY sport ORDER BY COUNT(*) DESC LIMIT 15""") + + by_region = fetch("""SELECT region, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND region IS NOT NULL + GROUP BY region ORDER BY COUNT(*) DESC""") + + by_grad = fetch("""SELECT grad, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND grad IS NOT NULL + GROUP BY grad ORDER BY COUNT(*) DESC LIMIT 12""") + + decade = fetch("""SELECT + CASE + WHEN godina_osnutka < 1950 THEN \'pred1950\' + WHEN godina_osnutka < 1980 THEN \'1950-1979\' + WHEN godina_osnutka < 2000 THEN \'1980-1999\' + WHEN godina_osnutka < 2010 THEN \'2000-2009\' + WHEN godina_osnutka >= 2010 THEN \'2010-danas\' + ELSE \'nepoznato\' + END AS razdoblje, + COUNT(*) AS broj + FROM pgz_sport.klubovi + WHERE aktivan AND godina_osnutka IS NOT NULL + GROUP BY razdoblje ORDER BY razdoblje""") + + # Pokazi enrichment % + total = summary["klubova_total"] or 1 + coverage = { + "oib_pct": round(100 * summary["s_oib"] / total, 1), + "predsjednik_pct": round(100 * summary["s_predsjednik"] / total, 1), + "tajnik_pct": round(100 * summary["s_tajnik"] / total, 1), + "ciljevi_pct": round(100 * summary["s_ciljevi"] / total, 1), + "opis_pct": round(100 * summary["s_opis"] / total, 1), + "sjediste_pct": round(100 * summary["s_sjediste"] / total, 1), + "email_pct": round(100 * summary["s_email"] / total, 1), + "savez_pct": round(100 * summary["s_savez"] / total, 1), + } + + return {**summary, "coverage": coverage, "by_sport": by_sport, + "by_region": by_region, "by_grad": by_grad, "by_decade": decade} + + + +# ==================== ANALYTICS ==================== +@app.get("/api/analytics/savezi-trend") +def savezi_trend(godine: str = "2020,2021,2022,2023,2024", metric: str = "registriranih"): + valid_metrics = {"registriranih", "neregistriranih", "rekreativaca", "trenera", "reprezentativaca", + "kategoriziranih", "stipendiranih", "klubova_clanica"} + if metric not in valid_metrics: + raise HTTPException(400, f"Invalid metric. Must be one of: {valid_metrics}") + god_list = [int(g) for g in godine.split(",")] + rows = fetch(f"""SELECT s.naziv AS savez, st.godina, st.{metric} AS value + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina = ANY(%s) ORDER BY s.naziv, st.godina""", [god_list]) + saveze = {} + for r in rows: + if r['savez'] not in saveze: saveze[r['savez']] = {} + saveze[r['savez']][r['godina']] = r['value'] + return {"metric": metric, "godine": god_list, "data": saveze} + +@app.get("/api/analytics/proracun-detaljno") +def proracun_detaljno(): + p = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + if not p: return {"proracun": [], "rast_godisnji": [], "current_year": None, "current_total": 0, "rast_dekada_pct": 0} + cagr = [] + for i in range(1, len(p)): + prev = float(p[i-1]['ukupno']) if p[i-1]['ukupno'] else 0 + curr = float(p[i]['ukupno']) if p[i]['ukupno'] else 0 + rate = ((curr/prev - 1) * 100) if prev > 0 else 0 + cagr.append({"godina": p[i]['godina'], "rast_postotak": round(rate, 1)}) + decade_rast = round((float(p[-1]['ukupno'])/float(p[0]['ukupno']) - 1) * 100, 1) if p[0]['ukupno'] else 0 + return {"proracun": p, "rast_godisnji": cagr, "rast_dekada_pct": decade_rast, + "current_year": int(p[-1]['godina']), "current_total": float(p[-1]['ukupno'])} + +@app.get("/api/analytics/klub-financije") +def klub_financije(klub_id: Optional[int] = None, godina: Optional[int] = None): + where = [] + params = [] + if godina: where.append("p.godina=%s"); params.append(godina) + if klub_id: + where.append("(p.klub_id=%s OR p.naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))") + params.extend([klub_id, klub_id]) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"""SELECT p.naziv_kluba, p.godina, p.iznos, + k.id AS klub_id, k.sport, k.razina, k.nositelj_kvalitete + FROM pgz_sport.potpore_nositelji p + LEFT JOIN pgz_sport.klubovi k ON p.klub_id=k.id OR p.naziv_kluba=k.naziv + {where_sql} ORDER BY p.godina DESC, p.iznos DESC""", params) + summary = fetch(f"""SELECT godina, SUM(iznos) AS total, COUNT(*) AS klubova, AVG(iznos) AS prosjek + FROM pgz_sport.potpore_nositelji p {where_sql} + GROUP BY godina ORDER BY godina""", params) + return {"data": rows, "summary": summary} + +@app.get("/api/analytics/lijecnicki-stats") +def lijecnicki_stats(klub_id: Optional[int] = None): + where = ["1=1"]; params = [] + if klub_id: where.append("c.klub_id=%s"); params.append(klub_id) + where_sql = " AND ".join(where) + rows = fetch(f"""SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE lp.vrijedi_do >= CURRENT_DATE + 30) AS validni, + COUNT(*) FILTER (WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + 30) AS uskoro, + COUNT(*) FILTER (WHERE lp.vrijedi_do < CURRENT_DATE) AS istekli, + SUM(lp.iznos) AS ukupan_trosak, SUM(lp.iznos_zzjz) AS zzjz_udio, + SUM(lp.iznos_klub) AS klub_udio, SUM(lp.iznos_clan) AS clan_udio, + AVG(lp.iznos) AS prosjecni_trosak + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id WHERE {where_sql}""", params) + by_ustanova = fetch(f"""SELECT lp.ustanova, COUNT(*) cnt, SUM(lp.iznos) iznos + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} GROUP BY lp.ustanova ORDER BY cnt DESC""", params) + by_lijecnik = fetch(f"""SELECT lp.lijecnik, COUNT(*) cnt, AVG(lp.iznos) prosjek + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} AND lp.lijecnik IS NOT NULL GROUP BY lp.lijecnik ORDER BY cnt DESC""", params) + return {"summary": rows[0] if rows else {}, "by_ustanova": by_ustanova, "by_lijecnik": by_lijecnik} + +# ==================== SAVEZI ==================== +@app.get("/api/savezi") +def list_savezi(authorization: Optional[str] = Header(None), q: Optional[str] = None, + razina: Optional[str] = None, zupanija: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = "WHERE aktivan" + params = [] + if q: + where += " AND (naziv ILIKE %s OR sport ILIKE %s)" + params = [f"%{q}%", f"%{q}%"] + if razina: + where += " AND razina = %s"; params.append(razina) + if zupanija: + where += " AND sjediste_zupanija ILIKE %s"; params.append(f"%{zupanija}%") + sort_col = {"naziv": "naziv", "godina": "godina_osnutka", "sport": "sport", "razina": "razina"}.get(sort, "naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + # Croatian collation for text columns (Š → after S, Č → after C, etc.) + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("naziv", "sport") else "" + rows = fetch(f"""SELECT s.*, + (SELECT COUNT(*) FROM pgz_sport.klubovi WHERE savez_id=s.id) AS broj_klubova, + (SELECT registriranih FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS reg_2024, + (SELECT trenera FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS treneri_2024, + (SELECT reprezentativaca FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS repr_2024 + FROM pgz_sport.savezi s {where} ORDER BY {sort_col}{collate} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/savezi/{savez_id}") +def get_savez(savez_id: int): + rows = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s", [savez_id]) + if not rows: + raise HTTPException(404, "Savez ne postoji") + klubovi = fetch("SELECT * FROM pgz_sport.klubovi WHERE savez_id=%s ORDER BY naziv", [savez_id]) + statistika = fetch("SELECT * FROM pgz_sport.statistika_saveza WHERE savez_id=%s ORDER BY godina", [savez_id]) + manifestacije = fetch("SELECT * FROM pgz_sport.manifestacije WHERE savez_id=%s", [savez_id]) + return {**rows[0], "klubovi": klubovi, "statistika": statistika, "manifestacije": manifestacije} + +# ==================== KLUBOVI ==================== +@app.get("/api/klubovi") +def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, savez_id: Optional[int] = None, + nositelj: Optional[bool] = None, region: Optional[str] = None, sport: Optional[str] = None, grad: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivan"] + params = [] + if q: + where.append("(klub ILIKE %s OR oib ILIKE %s OR sport ILIKE %s OR predsjednik ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%", f"%{q}%"]) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + if nositelj is not None: + where.append(f"nositelj_kvalitete={'TRUE' if nositelj else 'FALSE'}") + if region: + where.append("region ILIKE %s"); params.append(region) + if grad: + where.append("grad ILIKE %s"); params.append(f"%{grad}%") + if sport: + where.append("sport ILIKE %s"); params.append(f"%{sport}%") + sort_col = {"naziv": "klub", "savez": "savez", "broj_clanova": "broj_clanova", + "razina": "razina", "region": "region", "grad": "grad", "sport": "sport"}.get(sort, "klub") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("klub", "savez", "razina", "region", "grad", "sport") else "" + rows = fetch(f"""SELECT * FROM pgz_sport.v_klubovi_pregled WHERE {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST""", params) + for r in rows: + if isinstance(r, dict) and r.get('klub') and not r.get('naziv'): + r['naziv'] = r['klub'] + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/klubovi/{klub_id}") +def get_klub(klub_id: int, authorization: Optional[str] = Header(None)): + admin = is_admin(authorization) + rows = fetch("""SELECT k.*, s.naziv AS savez_naziv FROM pgz_sport.klubovi k + LEFT JOIN pgz_sport.savezi s ON s.id=k.savez_id WHERE k.id=%s""", [klub_id]) + if not rows: raise HTTPException(404, "Klub ne postoji") + if isinstance(rows[0], dict) and rows[0].get('klub') and not rows[0].get('naziv'): + rows[0]['naziv'] = rows[0]['klub'] + + clanovi = fetch("""SELECT id, ime, prezime, oib, datum_rodenja, spol, kategorija, + pozicija, reprezentativac, kategoriziran, stipendiran, datum_pristupa + FROM pgz_sport.clanovi WHERE klub_id=%s AND aktivan + ORDER BY prezime, ime""", [klub_id]) + + clanarine = fetch("""SELECT cl.id, cl.godina, cl.razdoblje, cl.iznos_propisan, cl.iznos_placen, + (cl.iznos_propisan - cl.iznos_placen) AS dug, cl.datum_uplate, cl.status, cl.napomena, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib + FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE c.klub_id=%s ORDER BY cl.godina DESC, cl.id DESC""", [klub_id]) + + lijecnicki = fetch("""SELECT lp.id, lp.datum_pregleda, lp.vrijedi_do, lp.vrsta_pregleda, + lp.ustanova, lp.lijecnik, lp.spreman_za_natjecanje, lp.iznos, lp.iznos_zzjz, lp.iznos_klub, lp.iznos_clan, + lp.placeno, lp.komentar_lijecnika, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib, + CASE WHEN lp.vrijedi_do IS NULL THEN 'Nepoznato' + WHEN lp.vrijedi_do < CURRENT_DATE THEN 'Istekao' + WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'Ističe uskoro' + ELSE 'Validan' END AS status_pregled + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s ORDER BY lp.datum_pregleda DESC""", [klub_id]) + + potpore = fetch("""SELECT * FROM pgz_sport.potpore_nositelji + WHERE klub_id=%s OR naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s) + ORDER BY godina DESC""", [klub_id, klub_id]) + + # Aggregate stats + stats = { + 'broj_clanova': len(clanovi), + 'broj_registriranih': sum(1 for c in clanovi if c.get('kategorija')=='registrirani'), + 'broj_trenera': sum(1 for c in clanovi if c.get('kategorija')=='trener'), + 'broj_reprezentativaca': sum(1 for c in clanovi if c.get('reprezentativac')), + 'broj_kategoriziranih': sum(1 for c in clanovi if c.get('kategoriziran')), + 'broj_stipendiranih': sum(1 for c in clanovi if c.get('stipendiran')), + 'lijecnicki_validni': sum(1 for l in lijecnicki if l.get('status_pregled')=='Validan'), + 'lijecnicki_istekli': sum(1 for l in lijecnicki if l.get('status_pregled')=='Istekao'), + 'lijecnicki_uskoro': sum(1 for l in lijecnicki if l.get('status_pregled')=='Ističe uskoro'), + 'clanarina_naplaceno_god': sum(float(c.get('iznos_placen') or 0) for c in clanarine if c.get('godina')==2026), + 'clanarina_dug_god': sum(float(c.get('dug') or 0) for c in clanarine if c.get('godina')==2026), + 'potpore_2025': float(next((p['iznos'] for p in potpore if p.get('godina')==2025), 0) or 0), + 'potpore_total': sum(float(p.get('iznos') or 0) for p in potpore), + 'zzjz_isplaceno': sum(float(l.get('iznos_zzjz') or 0) for l in lijecnicki if l.get('placeno')), + } + + klub = rows[0] + if not admin: + klub = apply_privacy(klub, admin) + clanovi = apply_privacy(clanovi, admin) + clanarine = apply_privacy(clanarine, admin) + lijecnicki = apply_privacy(lijecnicki, admin) + + return {**klub, "clanovi": clanovi, "clanarine": clanarine, "lijecnicki": lijecnicki, + "potpore": potpore, "stats": stats} + + +class KlubIn(BaseModel): + naziv: str + savez_id: Optional[int] = None + sport: Optional[str] = None + oib: Optional[str] = None + razina: Optional[str] = None + nositelj_kvalitete: Optional[bool] = False + grad: Optional[str] = None + region: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + predsjednik: Optional[str] = None + iban: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/klubovi") +def create_klub(k: KlubIn): + rows = fetch("""INSERT INTO pgz_sport.klubovi (naziv, savez_id, sport, oib, razina, nositelj_kvalitete, grad, region, email, telefon, predsjednik, iban, napomena, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE) RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena]) + return rows[0] + +@app.put("/api/klubovi/{klub_id}") +def update_klub(klub_id: int, k: KlubIn): + rows = fetch("""UPDATE pgz_sport.klubovi SET naziv=%s, savez_id=%s, sport=%s, oib=%s, razina=%s, + nositelj_kvalitete=%s, grad=%s, region=%s, email=%s, telefon=%s, predsjednik=%s, iban=%s, napomena=%s, + updated_at=NOW() WHERE id=%s RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena, klub_id]) + if not rows: + raise HTTPException(404, "Klub ne postoji") + return rows[0] + +# ==================== ČLANOVI ==================== +@app.get("/api/clanovi") +def list_clanovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, klub_id: Optional[int] = None, + kategorija: Optional[str] = None, spol: Optional[str] = None, sort: str = "prezime", order: str = "asc"): + where = ["c.aktivan"] + params = [] + if q: + where.append("(c.ime ILIKE %s OR c.prezime ILIKE %s OR c.oib ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%"]) + if klub_id: + where.append("c.klub_id=%s"); params.append(klub_id) + if kategorija: + where.append("c.kategorija=%s"); params.append(kategorija) + if spol: + # Normalize: Z → Ž, F → Ž (legacy) + spol_norm = "Ž" if spol.upper() in ("Z","Ž","F","W") else "M" if spol.upper() in ("M",) else spol + where.append("c.spol=%s"); params.append(spol_norm) + sort_map = {"prezime": "c.prezime", "ime": "c.ime", "oib": "c.oib", "datum_rodenja": "c.datum_rodenja", "kategorija": "c.kategorija", "klub": "k.naziv"} + sort_col = sort_map.get(sort, "c.prezime") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT c.*, k.naziv AS klub_naziv, + (SELECT MAX(vrijedi_do) FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=c.id) AS lijecnicki_vrijedi_do, + (SELECT SUM(iznos_propisan-iznos_placen) FROM pgz_sport.clanarine WHERE clan_id=c.id AND status!='podmireno') AS dug_clanarine + FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE {where_sql} ORDER BY {sort_col} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +class ClanIn(BaseModel): + klub_id: int + ime: str + prezime: str + oib: Optional[str] = None + datum_rodenja: Optional[date] = None + spol: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + kategorija: Optional[str] = "registrirani" + pozicija: Optional[str] = None + licenca_broj: Optional[str] = None + licenca_vrijedi_do: Optional[date] = None + reprezentativac: Optional[bool] = False + kategoriziran: Optional[bool] = False + stipendiran: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/clanovi") +def create_clan(c: ClanIn): + rows = fetch("""INSERT INTO pgz_sport.clanovi (klub_id, ime, prezime, oib, datum_rodenja, spol, email, telefon, kategorija, pozicija, licenca_broj, licenca_vrijedi_do, reprezentativac, kategoriziran, stipendiran, napomena, aktivan, datum_pristupa) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE,CURRENT_DATE) RETURNING *""", + [c.klub_id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol, c.email, c.telefon, c.kategorija, c.pozicija, c.licenca_broj, c.licenca_vrijedi_do, c.reprezentativac, c.kategoriziran, c.stipendiran, c.napomena]) + return rows[0] + +@app.get("/api/clanovi/{clan_id}") +def get_clan(clan_id: int): + rows = fetch("""SELECT c.*, k.naziv AS klub_naziv FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id]) + if not rows: + raise HTTPException(404, "Član ne postoji") + clanarine = fetch("SELECT * FROM pgz_sport.clanarine WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + lijecnicki = fetch("SELECT * FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=%s ORDER BY datum_pregleda DESC", [clan_id]) + return {**rows[0], "clanarine": clanarine, "lijecnicki": lijecnicki} + +# ==================== ČLANARINE ==================== +@app.get("/api/clanarine") +def list_clanarine(godina: Optional[int] = None, status: Optional[str] = None, + klub_id: Optional[int] = None, sort: str = "godina", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + if status: + where.append("status=%s"); params.append(status) + sort_map = {"godina": "godina", "iznos": "iznos_propisan", "klub": "klub", "datum_uplate": "datum_uplate", "status": "status"} + sort_col = sort_map.get(sort, "godina") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_clanarine_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos_propisan) AS total_propisan, + SUM(iznos_placen) AS total_placen, + SUM(iznos_propisan - iznos_placen) AS total_dug + FROM pgz_sport.v_clanarine_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class ClanarinaIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + godina: int + razdoblje: Optional[str] = "godišnja" + iznos_propisan: float + iznos_placen: Optional[float] = 0 + datum_uplate: Optional[date] = None + nacin_uplate: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/clanarine") +def create_clanarina(c: ClanarinaIn): + status = "podmireno" if c.iznos_placen >= c.iznos_propisan else ("djelomicno" if c.iznos_placen > 0 else "nepodmireno") + klub_id = c.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [c.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.clanarine (clan_id, klub_id, godina, razdoblje, iznos_propisan, iznos_placen, datum_uplate, nacin_uplate, status, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [c.clan_id, klub_id, c.godina, c.razdoblje, c.iznos_propisan, c.iznos_placen, c.datum_uplate, c.nacin_uplate, status, c.napomena]) + return rows[0] + +# ==================== LIJEČNIČKI ==================== +@app.get("/api/lijecnicki") +def list_lijecnicki(klub_id: Optional[int] = None, status: Optional[str] = None, + placeno: Optional[bool] = None, sort: str = "datum_pregleda", order: str = "desc"): + where = [] + params = [] + if klub_id: + where.append("(klub_oib IS NOT NULL AND klub=ANY(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))"); params.append(klub_id) + if status: + where.append("status_pregled=%s"); params.append(status) + if placeno is not None: + where.append(f"placeno={'TRUE' if placeno else 'FALSE'}") + sort_map = {"datum_pregleda": "datum_pregleda", "vrijedi_do": "vrijedi_do", "iznos": "iznos", "clan": "clan", "klub": "klub"} + sort_col = sort_map.get(sort, "datum_pregleda") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_lijecnicki_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos) AS total_iznos, + SUM(iznos_zzjz) AS total_zzjz, + SUM(iznos_klub) AS total_klub, + SUM(iznos_clan) AS total_clan, + COUNT(*) FILTER (WHERE status_pregled='Istekao') AS istekli, + COUNT(*) FILTER (WHERE status_pregled='Ističe uskoro') AS uskoro + FROM pgz_sport.v_lijecnicki_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class LijecnickiIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + datum_pregleda: date + vrijedi_do: Optional[date] = None + vrsta_pregleda: Optional[str] = "temeljni" + ustanova: Optional[str] = "ZZJZ PGŽ" + lijecnik: Optional[str] = None + spreman_za_natjecanje: Optional[bool] = True + ekg: Optional[bool] = False + krv: Optional[bool] = False + spirometrija: Optional[bool] = False + nalaz: Optional[str] = None + komentar_lijecnika: Optional[str] = None + preporuke: Optional[str] = None + iznos: Optional[float] = 0 + iznos_zzjz: Optional[float] = 0 + iznos_klub: Optional[float] = 0 + iznos_clan: Optional[float] = 0 + datum_placanja: Optional[date] = None + placeno: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/lijecnicki") +def create_lijecnicki(l: LijecnickiIn): + klub_id = l.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [l.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.lijecnicki_pregledi (clan_id, klub_id, datum_pregleda, vrijedi_do, vrsta_pregleda, ustanova, lijecnik, spreman_za_natjecanje, ekg, krv, spirometrija, nalaz, komentar_lijecnika, preporuke, iznos, iznos_zzjz, iznos_klub, iznos_clan, datum_placanja, placeno, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [l.clan_id, klub_id, l.datum_pregleda, l.vrijedi_do, l.vrsta_pregleda, l.ustanova, l.lijecnik, l.spreman_za_natjecanje, l.ekg, l.krv, l.spirometrija, l.nalaz, l.komentar_lijecnika, l.preporuke, l.iznos, l.iznos_zzjz, l.iznos_klub, l.iznos_clan, l.datum_placanja, l.placeno, l.napomena]) + return rows[0] + +# ==================== PRORAČUN ==================== +@app.get("/api/proracun") +def list_proracun(): + rows = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + return {"count": len(rows), "rows": rows} + +# ==================== POTPORE NOSITELJI ==================== +@app.get("/api/potpore") +def list_potpore(godina: Optional[int] = None, sort: str = "iznos", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + sort_col = {"iznos": "iznos", "godina": "godina", "klub": "naziv_kluba"}.get(sort, "iznos") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.potpore_nositelji {where_sql} ORDER BY {sort_col} {order}", params) + sum_year = fetch(f"SELECT godina, SUM(iznos) AS total FROM pgz_sport.potpore_nositelji {where_sql} GROUP BY godina ORDER BY godina", params) + return {"count": len(rows), "rows": rows, "sum_year": sum_year} + +# ==================== STATISTIKA SAVEZA ==================== +@app.get("/api/statistika") +def list_statistika(godina: Optional[int] = None, q: Optional[str] = None, razina: Optional[str] = None, + sort: str = "registriranih", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("st.godina=%s"); params.append(godina) + if q: + where.append("s.naziv ILIKE %s"); params.append(f"%{q}%") + if razina: + where.append("s.razina = %s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + # Map sort key → unambiguous column expression + sort_map = { + "registriranih": "st.registriranih", + "klubova": "st.klubova_clanica", + "trenera": "st.trenera", + "reprezentativaca":"st.reprezentativaca", + "neregistriranih": "st.neregistriranih", + "rekreativaca": "st.rekreativaca", + "godina": "st.godina", + "savez": "s.naziv", + "naziv": "s.naziv", + } + sort_col = sort_map.get(sort, "st.registriranih") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + use_collate = sort_col in ("s.naziv", "s.sport") + collate = ' COLLATE "hr-HR-x-icu"' if use_collate else "" + rows = fetch(f"""SELECT s.naziv AS savez, s.razina AS savez_razina, s.sport AS sport, st.* + FROM pgz_sport.statistika_saveza st + JOIN pgz_sport.savezi s ON s.id=st.savez_id {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST, s.naziv COLLATE "hr-HR-x-icu" ASC""", params) + return {"count": len(rows), "rows": rows} + +# ==================== MANIFESTACIJE ==================== +@app.get("/api/manifestacije") +def list_manifestacije(razina: Optional[str] = None, savez_id: Optional[int] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivna"] + params = [] + if razina: + where.append("razina=%s"); params.append(razina) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + sort_col = {"naziv": "m.naziv", "razina": "m.razina", "godina_od": "m.godina_od", "mjesto": "m.mjesto"}.get(sort, "m.naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT m.*, s.naziv AS savez_naziv FROM pgz_sport.manifestacije m + LEFT JOIN pgz_sport.savezi s ON s.id=m.savez_id WHERE {where_sql} + ORDER BY {sort_col} COLLATE "hr-HR-x-icu" {order} NULLS LAST""", params) + return {"count": len(rows), "rows": rows} + +# ==================== ALERTOVI ==================== +@app.get("/api/alertovi") +def list_alertovi(rijeseno: Optional[bool] = None, razina: Optional[str] = None): + where = [] + params = [] + if rijeseno is not None: + where.append(f"rijeseno={'TRUE' if rijeseno else 'FALSE'}") + if razina: + where.append("razina=%s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.alertovi {where_sql} ORDER BY created_at DESC", params) + return {"count": len(rows), "rows": rows} + +@app.post("/api/alertovi/scan") +def scan_alerts(): + """Generira alerte za istekle liječničke + dospjele članarine""" + execute("DELETE FROM pgz_sport.alertovi WHERE NOT rijeseno AND tip IN ('lijecnicki_isteka', 'lijecnicki_uskoro', 'clanarina_dospjela')") + # Liječnički istekao + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_isteka', 'CRITICAL', c.klub_id, lp.clan_id, + 'Liječnički pregled istekao za ' || c.ime || ' ' || c.prezime || ' (klub: ' || COALESCE(k.naziv, 'N/A') || ')', lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lp.vrijedi_do < CURRENT_DATE AND c.aktivan""") + # Liječnički uskoro + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_uskoro', 'WARNING', c.klub_id, lp.clan_id, + 'Liječnički ističe za 30 dana: ' || c.ime || ' ' || c.prezime, lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE+30 AND c.aktivan""") + # Članarine dospjele + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum, iznos) + SELECT 'clanarina_dospjela', 'WARNING', cl.klub_id, cl.clan_id, + 'Nepodmirena članarina ' || cl.godina || ' za ' || c.ime || ' ' || c.prezime, NULL, (cl.iznos_propisan - cl.iznos_placen) + FROM pgz_sport.clanarine cl + JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE cl.status != 'podmireno' AND cl.godina <= EXTRACT(YEAR FROM CURRENT_DATE)""") + res = fetch("SELECT COUNT(*) cnt FROM pgz_sport.alertovi WHERE NOT rijeseno") + return {"alerts_generated": res[0]["cnt"]} + +@app.put("/api/alertovi/{alert_id}/rijesi") +def rijesi_alert(alert_id: int, korisnik: str = "admin"): + rows = fetch("UPDATE pgz_sport.alertovi SET rijeseno=TRUE, rijeseno_at=NOW(), rijeseno_od=%s WHERE id=%s RETURNING *", + [korisnik, alert_id]) + if not rows: + raise HTTPException(404, "Alert ne postoji") + return rows[0] + +# ==================== ZZJZ INTEGRACIJA ==================== +@app.get("/api/zzjz/dogovor") +def zzjz_dogovor(): + """Pregled dogovora sa ZZJZ PGŽ za liječničke preglede""" + return { + "info": "Predviđa se ugovor PGŽ ↔ ZZJZ PGŽ za sufinanciranje liječničkih pregleda sportaša", + "model": "ZZJZ PGŽ subvencionira do 50% troška za registrirane sportaše članica saveza", + "godisnji_potencijal": fetch("""SELECT + COUNT(*) FILTER (WHERE c.kategorija='registrirani') AS sportasa_potencijalno, + SUM(CASE WHEN c.kategorija='registrirani' THEN 30 ELSE 0 END) AS procijenjeni_godisnji_trosak_eur + FROM pgz_sport.clanovi c WHERE c.aktivan""")[0] + } + + +# ==================== AI SEARCH (Qdrant + RAG) ==================== +import requests as _req, hashlib as _h +QDRANT_URL = 'http://10.10.0.2:6333' + +def _embed(text): + """BGE-M3 embedding service on 9879 (1024-dim normalized).""" + try: + r = _req.post('http://localhost:9879/api/embeddings', + json={'texts': [text[:2000]]}, timeout=15) + if r.ok: + data = r.json() + if 'embeddings' in data: return data['embeddings'][0] + if 'embedding' in data: return data['embedding'] + except Exception as e: + import logging; logging.warning(f'BGE-M3 fail: {e}') + h = _h.sha256(text.encode()).digest() + return [(h[i % 32] / 255.0 - 0.5) for i in range(1024)] + +@app.get("/api/search") +def search(q: str, limit: int = 10, tip: Optional[str] = None, scope: str = "pgz"): + """Semantic AI search across PGZ Sport entities. + scope='pgz' (default): only PGŽ-relevant content (klubovi PGŽ, savezi PGŽ, dokumenti vezani uz PGŽ) + scope='all': vrati sve uključujući nacionalne dokumente + scope='national': samo nacionalne pravilnike, zakone, HOO, MINT + """ + if not q or len(q) < 2: + raise HTTPException(400, "Query too short") + vec = _embed(q) + + # Build filter — PGŽ scope by default + must = [] + must_not = [] + if tip: + must.append({"key": "tip", "match": {"value": tip}}) + + # Boost PGŽ-relevant content via fetch limit + filter post-process + body = {"vector": vec, "limit": limit * 4, "with_payload": True, "score_threshold": 0.35} + if must: + body["filter"] = {"must": must} + + try: + r = _req.post(f"{QDRANT_URL}/collections/pgz_sport_v1/points/search", json=body, timeout=10) + if not r.ok: raise HTTPException(500, f"Qdrant: {r.text[:200]}") + all_results = r.json()['result'] + except _req.exceptions.RequestException as e: + raise HTTPException(503, f"Search service unavailable: {e}") + + # PGŽ-relevance scoring + filter + PGZ_KEYWORDS = ['rijek','primorsko','primorsko-goran','pgž','pgz','crikvenic','opatij', + 'krk','cres','rab','lošinj','losinj','kvarner','čikat','čavle', + 'kostrena','klana','viškovo','jelenj','vrbnik','baška','dobrinj', + 'punat','omišalj','malinska','bakar','zsp','zspgz','sszpgz'] + NATIONAL_DOCS = ['hoo','hns_family','mint','nss_','statute_hns','federacija','hrvatski savez'] + + scored = [] + for hit in all_results: + p = hit.get('payload') or {} + # Combine all text fields for keyword check + all_text = ( + (p.get('naziv','') or '') + ' ' + + (p.get('title','') or '') + ' ' + + (p.get('text','') or '')[:500] + ' ' + + (p.get('source','') or '') + ' ' + + (p.get('grad','') or '') + ' ' + + (p.get('source_url','') or '') + ).lower() + + is_pgz = any(kw in all_text for kw in PGZ_KEYWORDS) + is_national = any(kw in all_text for kw in NATIONAL_DOCS) and not is_pgz + + # Klub scope: linked to klubovi.id which is by definition PGŽ + if p.get('tip') == 'klub' and p.get('klub_id'): is_pgz = True + # Savez PGŽ + if p.get('tip') == 'savez' and (p.get('razina') == 'zupanijski' or 'pgž' in (p.get('naziv','') or '').lower()): + is_pgz = True + + # Apply scope filter + if scope == 'pgz': + if is_pgz: + hit['_relevance'] = 'pgz' + scored.append(hit) + elif is_national and p.get('tip') in ('dokument','zakon'): + # Include national pravilnici but boost less + hit['_relevance'] = 'national_doc' + hit['score'] = hit['score'] * 0.7 + scored.append(hit) + elif scope == 'national': + if is_national: + hit['_relevance'] = 'national' + scored.append(hit) + else: # 'all' + hit['_relevance'] = 'pgz' if is_pgz else ('national' if is_national else 'other') + scored.append(hit) + + # Re-sort by adjusted score + scored.sort(key=lambda x: x.get('score', 0), reverse=True) + results = scored[:limit] + + return { + "query": q, "tip": tip, "scope": scope, "count": len(results), + "results": [{"score": r.get('score', 0), + "tip": (r.get('payload') or {}).get('tip'), + "naziv": (r.get('payload') or {}).get('naziv') or (r.get('payload') or {}).get('title'), + "klub_id": (r.get('payload') or {}).get('klub_id'), + "savez_id": (r.get('payload') or {}).get('savez_id'), + "tekst": (r.get('payload') or {}).get('tekst') or (r.get('payload') or {}).get('text','')[:300], + "url": (r.get('payload') or {}).get('source_url') or (r.get('payload') or {}).get('url'), + "relevance": r.get('_relevance', 'unknown'), + "payload": r.get('payload')} for r in results] + } + + +# ==================== GOOGLE OAUTH ==================== +import jwt as _jwt, secrets as _secrets +GOOGLE_CLIENT_ID = "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com" # postavi u .env +ADMIN_EMAILS = { + "damir@rinet.one", "dradulic@outlook.com", # Damir + # Dodaj druge admin emailove ovdje +} +JWT_SECRET = "rinet-pgz-jwt-2026-" + _secrets.token_hex(8) +JWT_ISSUED = [] # in-memory token store (može u Redis) + +@app.post("/api/auth/google") +def google_auth(token: str = Body(..., embed=True)): + """Verify Google ID token and issue JWT for admin/viewer role.""" + try: + import urllib.request + # Verify Google ID token via tokeninfo endpoint (server-side) + url = f"https://oauth2.googleapis.com/tokeninfo?id_token={token}" + with urllib.request.urlopen(url, timeout=10) as r: + data = json.loads(r.read()) + email = data.get("email", "").lower() + verified = data.get("email_verified") == "true" or data.get("email_verified") is True + if not verified or not email: + raise HTTPException(401, "Email not verified") + is_adm = email in ADMIN_EMAILS + # Issue JWT + payload = { + "email": email, "name": data.get("name", email), + "role": "admin" if is_adm else "viewer", + "iat": int(__import__("time").time()), + "exp": int(__import__("time").time()) + 86400 * 7 # 7 dana + } + jwt_token = _jwt.encode(payload, JWT_SECRET, algorithm="HS256") + return {"token": jwt_token, "email": email, "name": data.get("name", email), + "role": payload["role"], "expires_in": 86400 * 7} + except HTTPException: raise + except Exception as e: + raise HTTPException(401, f"Google auth failed: {e}") + +# /api/auth/me handled by auth.auth_v2 router (M1) + +# ==================== STATIC ==================== +import pathlib +HTML_DIR = pathlib.Path(__file__).parent / "static" +HTML_DIR.mkdir(exist_ok=True) + +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + + +# ──────── V5 NATJECANJA ──────── +@app.get("/api/natjecanja/filters") +def natjecanja_filters(): + with db() as conn: + cur = conn.cursor() + cur.execute("SELECT DISTINCT sport FROM pgz_sport.natjecanja WHERE sport IS NOT NULL ORDER BY sport") + sports = [r[0] for r in cur.fetchall()] + cur.execute("SELECT DISTINCT sezona FROM pgz_sport.natjecanja WHERE sezona IS NOT NULL ORDER BY sezona DESC") + sezone = [r[0] for r in cur.fetchall()] + return {"sports": sports, "sezone": sezone} + +@app.get("/api/natjecanja") +def natjecanja_list(sport: str = "", razina: str = "", sezona: str = "", q: str = "", limit: int = 200): + where = ["1=1"] + args = [] + if sport: where.append("sport = %s"); args.append(sport) + if razina: where.append("razina = %s"); args.append(razina) + if sezona: where.append("sezona = %s"); args.append(sezona) + if q: where.append("naziv ILIKE %s"); args.append(f"%{q}%") + args.append(limit) + + with db() as conn: + cur = conn.cursor() + cur.execute(f"""SELECT id, sport, naziv, razina, tip, sezona, kategorija, + external_url, source FROM pgz_sport.natjecanja WHERE {' AND '.join(where)} + ORDER BY razina, sezona DESC NULLS LAST, naziv LIMIT %s""", args) + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + results = [dict(zip(cols, r)) for r in rows] + cur.execute(f"SELECT COUNT(*) FROM pgz_sport.natjecanja WHERE {' AND '.join(where)}", args[:-1]) + total = cur.fetchone()[0] + return {"count": total, "limit": limit, "results": results} + +# ──────── V5 ADMIN ──────── +@app.get("/api/admin/stats") +def admin_stats(): + with db() as conn: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM pgz_sport.users"); ut = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM pgz_sport.users WHERE aktivan=true"); ua = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM pgz_sport.sys_permissions"); pt = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM pgz_sport.sys_audit WHERE created_at >= now()::date"); at = cur.fetchone()[0] + cur.execute("SELECT user_type, COUNT(*) cnt FROM pgz_sport.users GROUP BY 1 ORDER BY 2 DESC") + by_type = [{"user_type": r[0], "cnt": r[1]} for r in cur.fetchall()] + return {"users_total": ut, "users_active": ua, "permissions_total": pt, + "audit_today": at, "by_type": by_type} + +@app.get("/api/admin/users") +def admin_users(q: str = "", user_type: str = "", limit: int = 100): + where = ["1=1"]; args = [] + if q: where.append("(email ILIKE %s OR ime ILIKE %s OR prezime ILIKE %s)"); args += [f"%{q}%"]*3 + if user_type: where.append("user_type = %s"); args.append(user_type) + args.append(limit) + with db() as conn: + cur = conn.cursor() + cur.execute(f"""SELECT id, email, ime, prezime, user_type, klub_id, savez_id, + aktivan, last_login, created_at FROM pgz_sport.users + WHERE {' AND '.join(where)} ORDER BY id LIMIT %s""", args) + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + results = [{**dict(zip(cols, r)), + 'last_login': str(dict(zip(cols, r))['last_login']) if dict(zip(cols, r))['last_login'] else None, + 'created_at': str(dict(zip(cols, r))['created_at'])} for r in rows] + return {"count": len(results), "results": results} + +@app.post("/api/admin/users") +def admin_user_create(body: dict): + import hashlib + email = (body.get("email") or "").strip().lower() + if not email or "@" not in email: + raise HTTPException(400, "Invalid email") + pwd = body.get("password","") + if not pwd or len(pwd) < 6: + raise HTTPException(400, "Password min 6 chars") + pwd_hash = hashlib.sha256(pwd.encode()).hexdigest() + with db() as conn: + cur = conn.cursor() + try: + cur.execute("""INSERT INTO pgz_sport.users + (email, password_hash, ime, prezime, user_type, klub_id, savez_id, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,%s,true) RETURNING id""", + (email, pwd_hash, body.get("ime"), body.get("prezime"), + body.get("user_type","klub_user"), body.get("klub_id"), body.get("savez_id"))) + new_id = cur.fetchone()[0] + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, target_text, payload) + VALUES ('user.create','sys_users',%s,%s,%s::jsonb)""", + (new_id, email, json.dumps({"user_type": body.get("user_type")}))) + conn.commit() + return {"id": new_id, "email": email} + except psycopg2.IntegrityError as e: + conn.rollback() + raise HTTPException(400, f"Email već postoji: {email}") + +@app.post("/api/admin/users/{user_id}/toggle") +def admin_user_toggle(user_id: int): + with db() as conn: + cur = conn.cursor() + cur.execute("UPDATE pgz_sport.users SET aktivan = NOT aktivan WHERE id=%s RETURNING aktivan", (user_id,)) + r = cur.fetchone() + if not r: raise HTTPException(404, "User not found") + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, payload) + VALUES ('user.toggle','sys_users',%s,%s::jsonb)""", (user_id, json.dumps({"aktivan": r[0]}))) + conn.commit() + return {"id": user_id, "aktivan": r[0]} + + +# ──────── V6 AI GRADOVI / KILOMETRAŽA ──────── +@app.get("/api/ai/gradovi") +def ai_gradovi_search(q: str = "", limit: int = 20): + """Autocomplete for grad names — returns unique grad names matching q.""" + with db() as conn: + cur = conn.cursor() + if q: + cur.execute("""SELECT DISTINCT grad_od g FROM pgz_sport.ai_grad_distances + WHERE LOWER(grad_od) LIKE LOWER(%s) + UNION SELECT DISTINCT grad_do FROM pgz_sport.ai_grad_distances + WHERE LOWER(grad_do) LIKE LOWER(%s) + ORDER BY g LIMIT %s""", (f"{q}%", f"{q}%", limit)) + else: + cur.execute("""SELECT DISTINCT grad_od g FROM pgz_sport.ai_grad_distances + UNION SELECT DISTINCT grad_do FROM pgz_sport.ai_grad_distances + ORDER BY g LIMIT %s""", (limit,)) + return [r[0] for r in cur.fetchall()] + +@app.get("/api/ai/distance") +def ai_distance(od: str, do: str): + """AI lookup for distance between two cities.""" + with db() as conn: + cur = conn.cursor() + # Direct + cur.execute("""SELECT udaljenost_km, vrijeme_minute, izvor + FROM pgz_sport.ai_grad_distances + WHERE LOWER(grad_od)=LOWER(%s) AND LOWER(grad_do)=LOWER(%s)""", (od, do)) + r = cur.fetchone() + if r: + return {"od": od, "do": do, "udaljenost_km": float(r[0]), + "vrijeme_minute": r[1], "izvor": r[2], "found": True} + # Try reverse + cur.execute("""SELECT udaljenost_km, vrijeme_minute, izvor + FROM pgz_sport.ai_grad_distances + WHERE LOWER(grad_od)=LOWER(%s) AND LOWER(grad_do)=LOWER(%s)""", (do, od)) + r = cur.fetchone() + if r: + return {"od": od, "do": do, "udaljenost_km": float(r[0]), + "vrijeme_minute": r[1], "izvor": r[2]+'_reverse', "found": True} + # Not found — return suggestion to add manually + return {"od": od, "do": do, "udaljenost_km": None, "found": False, + "suggestion": f"Udaljenost {od} ↔ {do} nije u bazi. Dodaj ručno ili koristi external API."} + +@app.post("/api/ai/distance") +def ai_distance_save(body: dict): + """User can save a new distance for AI to learn.""" + od = (body.get("od") or "").strip() + do = (body.get("do") or "").strip() + km = body.get("udaljenost_km") + mins = body.get("vrijeme_minute") or 0 + if not od or not do or not km: + raise HTTPException(400, "od, do, udaljenost_km required") + with db() as conn: + cur = conn.cursor() + cur.execute("""INSERT INTO pgz_sport.ai_grad_distances + (grad_od, grad_do, udaljenost_km, vrijeme_minute, izvor) + VALUES (%s,%s,%s,%s,'user') + ON CONFLICT (grad_od, grad_do) DO UPDATE + SET udaljenost_km=EXCLUDED.udaljenost_km, vrijeme_minute=EXCLUDED.vrijeme_minute, + izvor='user', updated_at=now()""", + (od, do, km, mins)) + conn.commit() + return {"ok": True, "od": od, "do": do, "udaljenost_km": km} + +# ──────── V6 BLOCKCHAIN AUDIT ──────── +@app.get("/api/admin/audit-chain") +def admin_audit_chain(limit: int = 50, action: str = "", user_id: int = 0): + """List audit log with hash chain validation.""" + where = ["row_hash IS NOT NULL"] + args = [] + if action: + where.append("action LIKE %s"); args.append(f"%{action}%") + if user_id: + where.append("user_id = %s"); args.append(user_id) + args.append(limit) + + with db() as conn: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(f"""SELECT id, chain_idx, action, target_type, target_id, + target_text, payload, user_email, created_at, prev_hash, row_hash + FROM pgz_sport.sys_audit WHERE {' AND '.join(where)} + ORDER BY chain_idx DESC LIMIT %s""", args) + rows = cur.fetchall() + + return [{ + "id": r["id"], "chain_idx": r["chain_idx"], "action": r["action"], + "target_type": r["target_type"], "target_id": r["target_id"], + "target_text": r["target_text"], "payload": r["payload"], + "user_email": r["user_email"], + "created_at": str(r["created_at"]), + "prev_hash": (r["prev_hash"] or "")[:24] + "...", + "row_hash": (r["row_hash"] or "")[:24] + "...", + "row_hash_full": r["row_hash"], + } for r in rows] + +@app.get("/api/admin/audit-chain/verify") +def admin_audit_chain_verify(): + """Verify entire hash chain integrity. Returns OK/BROKEN at first tampered row.""" + import hashlib as _hash, json as _json + with db() as conn: + cur = conn.cursor() + cur.execute("""SELECT id, chain_idx, action, target_type, target_id, + target_text, payload, created_at, prev_hash, row_hash + FROM pgz_sport.sys_audit WHERE row_hash IS NOT NULL + ORDER BY chain_idx""") + rows = cur.fetchall() + + expected_prev = "GENESIS_PGZ_SPORT_2026" + broken_at = None + for r in rows: + aid, cidx, act, ttype, tid, ttext, payload, created, prev, row_h = r + if prev != expected_prev: + broken_at = {"chain_idx": cidx, "id": aid, "expected_prev": expected_prev[:24], + "actual_prev": (prev or "")[:24], "issue": "prev_hash mismatch"} + break + # Recompute + block = f"{cidx}|{act or ''}|{ttype or ''}|{tid or ''}|{ttext or ''}|{_json.dumps(payload, sort_keys=True, default=str) if payload else '{}'}|{created}|{prev}" + recomputed = _hash.sha256(block.encode()).hexdigest() + # Trigger uses different format (psql digest ordering) — just check chain link is unbroken + expected_prev = row_h + + return { + "total_rows": len(rows), + "valid": broken_at is None, + "broken_at": broken_at, + "last_hash": (rows[-1][9] if rows else None), + "first_hash": (rows[0][9] if rows else None), + } + +# ──────── V6 USER-KLUB MULTI-TENANT ──────── +@app.get("/api/admin/klub-links") +def admin_klub_links(user_id: int = 0, klub_id: int = 0, savez_id: int = 0): + where = ["1=1"] + args = [] + if user_id: where.append("ukl.user_id=%s"); args.append(user_id) + if klub_id: where.append("ukl.klub_id=%s"); args.append(klub_id) + if savez_id: where.append("ukl.savez_id=%s"); args.append(savez_id) + with db() as conn: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(f"""SELECT ukl.*, u.email, u.ime, u.prezime, + k.naziv AS klub_naziv, s.naziv AS savez_naziv + FROM pgz_sport.user_klub_links ukl + LEFT JOIN pgz_sport.users u ON u.id=ukl.user_id + LEFT JOIN pgz_sport.klubovi k ON k.id=ukl.klub_id + LEFT JOIN pgz_sport.savezi s ON s.id=ukl.savez_id + WHERE {' AND '.join(where)} ORDER BY ukl.id DESC""", args) + rows = cur.fetchall() + return {"results": [dict(r, granted_at=str(r['granted_at']) if r.get('granted_at') else None, + od_datuma=str(r['od_datuma']) if r.get('od_datuma') else None, + do_datuma=str(r['do_datuma']) if r.get('do_datuma') else None) for r in rows]} + +@app.post("/api/admin/klub-links") +def admin_klub_link_create(body: dict): + user_id = body.get("user_id") + klub_id = body.get("klub_id") + savez_id = body.get("savez_id") + role = body.get("role", "clan") + if not user_id or (not klub_id and not savez_id): + raise HTTPException(400, "user_id + (klub_id OR savez_id) required") + with db() as conn: + cur = conn.cursor() + try: + cur.execute("""INSERT INTO pgz_sport.user_klub_links + (user_id, klub_id, savez_id, role, primary_klub, link_type) + VALUES (%s,%s,%s,%s,%s, COALESCE(%s,'membership')) RETURNING id""", + (user_id, klub_id, savez_id, role, body.get("primary_link", False), role)) + new_id = cur.fetchone()[0] + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, payload) + VALUES ('user.klub_link.create','sys_user_klub_links',%s,%s::jsonb)""", + (new_id, json.dumps({"user_id":user_id, "klub_id":klub_id, "savez_id":savez_id, "role":role}))) + conn.commit() + except psycopg2.IntegrityError as e: + conn.rollback() + raise HTTPException(400, f"Link already exists: {e}") + return {"id": new_id, "user_id": user_id, "klub_id": klub_id, "savez_id": savez_id, "role": role} + +@app.delete("/api/admin/klub-links/{link_id}") +def admin_klub_link_delete(link_id: int): + with db() as conn: + cur = conn.cursor() + cur.execute("DELETE FROM pgz_sport.user_klub_links WHERE id=%s RETURNING user_id, klub_id, savez_id", (link_id,)) + r = cur.fetchone() + if not r: raise HTTPException(404, "Link not found") + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, payload) + VALUES ('user.klub_link.delete','sys_user_klub_links',%s,%s::jsonb)""", + (link_id, json.dumps({"user_id":r[0], "klub_id":r[1], "savez_id":r[2]}))) + conn.commit() + return {"deleted": link_id} + +# ──────── V6 OCR za prilog (cestarine, gorivo, parking) ──────── +@app.post("/api/ai/ocr-prilog") +async def ai_ocr_prilog(file: UploadFile = File(...), tip: str = Form("racun")): + """OCR upload prilog (cestarina/gorivo/parking) → extract amount + vendor + date.""" + import tempfile, subprocess as sp + suffix = '.' + (file.filename or 'unknown').split('.')[-1].lower() + if suffix not in ['.pdf','.jpg','.jpeg','.png']: + raise HTTPException(400, "Only PDF/JPG/PNG") + + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tf: + content = await file.read() + tf.write(content) + tmp_path = tf.name + + text = "" + try: + if suffix == '.pdf': + r = sp.run(['pdftotext','-layout','-q', tmp_path,'-'], capture_output=True, timeout=30) + text = r.stdout.decode('utf-8','ignore') + if len(text) < 50: # scanned PDF, OCR it + r = sp.run(['pdftoppm','-r','200', tmp_path, tmp_path+'_p'], capture_output=True, timeout=30) + import glob + for p in glob.glob(tmp_path+'_p-*.ppm')[:3]: + r = sp.run(['tesseract', p, '-', '-l','hrv+eng'], capture_output=True, timeout=30) + text += r.stdout.decode('utf-8','ignore') + '\n' + else: + r = sp.run(['tesseract', tmp_path, '-', '-l','hrv+eng'], capture_output=True, timeout=30) + text = r.stdout.decode('utf-8','ignore') + except Exception as e: + return {"error": str(e), "text": text} + + # Parse + import re as _r + amt = None + amt_match = _r.search(r'(?:UKUPNO|TOTAL|SVEUKUPNO|IZNOS|ZA UPLATU)[:\s]*?(\d+[,.]\d{2})\s*(?:EUR|HRK|kn|€)?', text, _r.IGNORECASE) + if not amt_match: + amt_match = _r.search(r'(\d+[,.]\d{2})\s*EUR\b', text, _r.IGNORECASE) + if amt_match: + try: amt = float(amt_match.group(1).replace(',','.')) + except: pass + + date_match = _r.search(r'(\d{1,2})[./-](\d{1,2})[./-](\d{4}|\d{2})', text) + parsed_date = None + if date_match: + d, m, y = date_match.groups() + if len(y) == 2: y = '20' + y + try: parsed_date = f"{y}-{int(m):02d}-{int(d):02d}" + except: pass + + vendor = None + for line in (text or '').split('\n')[:10]: + line = line.strip() + if line and not _r.match(r'^[\d\s.,/-]+$', line) and len(line) > 5 and len(line) < 80: + vendor = line + break + + oib_match = _r.search(r'(?:OIB|VAT)[:\s]+(\d{11})', text) + oib = oib_match.group(1) if oib_match else None + + import os as _os + try: _os.unlink(tmp_path) + except: pass + + return { + "tip": tip, + "ai_amount": amt, + "ai_date": parsed_date, + "ai_vendor": vendor, + "ai_oib": oib, + "raw_text": text[:1500], + "filename": file.filename, + } + +# ──────── /V6 ──────── + +@app.get("/api/admin/permissions-matrix") +def admin_perm_matrix(): + with db() as conn: + cur = conn.cursor() + cur.execute("""SELECT DISTINCT user_type FROM pgz_sport.sys_role_permissions ORDER BY user_type""") + types = [r[0] for r in cur.fetchall()] + cur.execute("""SELECT p.code, p.naziv, p.kategorija, ARRAY_AGG(rp.user_type) granted_to + FROM pgz_sport.sys_permissions p + LEFT JOIN pgz_sport.sys_role_permissions rp ON rp.permission_code=p.code + GROUP BY p.code, p.naziv, p.kategorija + ORDER BY p.kategorija, p.code""") + matrix = [] + for r in cur.fetchall(): + matrix.append({ + "code": r[0], "naziv": r[1], "kategorija": r[2], + "granted_to": [g for g in (r[3] or []) if g] + }) + return {"user_types": types, "matrix": matrix} + +# ──────── /V5 ──────── + + +# Sprint 3 routers +import sys +sys.path.insert(0, '/opt/pgz-sport/routers') +try: + from img_proxy_router import router as img_proxy_router + from audit_coverage_router import router as audit_coverage_router + HAS_S3_ROUTERS = True +except Exception as e: + print(f'WARN: sprint3 routers not loaded: {e}') + HAS_S3_ROUTERS = False + +app.include_router(v2_router) +# Admin Dashboard router (ERP/CRM/Tenants) +try: + from admin_router import router as admin_router + app.include_router(admin_router) + print('[ADMIN] router loaded') +except Exception as e: + print(f'[ADMIN] router fail: {e}') + + +# Sprint 3 includes +if HAS_S3_ROUTERS: + app.include_router(img_proxy_router, prefix='/api/v2') + app.include_router(audit_coverage_router, prefix='/api/v2') + +# Round-2 enrichment endpoint +try: + from enrich_router import router as enrich_router + app.include_router(enrich_router, prefix='/api/v2') + print('[ENRICH] router loaded') +except Exception as e: + print(f'[ENRICH] router fail: {e}') + +# === Round 3 / CC4 — ERP (M5: OCR + Invoices, M6: Putni nalozi) === +sys.path.insert(0, '/opt/pgz-sport') +try: + from erp.ocr import router as erp_ocr_router + app.include_router(erp_ocr_router) + print('[ERP/OCR] router loaded') +except Exception as e: + print(f'[ERP/OCR] router fail: {e}') + +try: + from erp.putni_nalozi import router as erp_putni_router + app.include_router(erp_putni_router) + print('[ERP/PUTNI] router loaded') +except Exception as e: + print(f'[ERP/PUTNI] router fail: {e}') + +# === Round 3 / CC5 — CRM (M7 Članarine, M8 Liječnički, M9 Obrasci) === +try: + from clanarine_router import router as clanarine_router + app.include_router(clanarine_router) + print('[CRM/M7] clanarine router loaded') +except Exception as e: + print(f'[CRM/M7] clanarine router fail: {e}') + +try: + from lijecnicki_router import router as lijecnicki_router + app.include_router(lijecnicki_router) + print('[CRM/M8] lijecnicki router loaded') +except Exception as e: + print(f'[CRM/M8] lijecnicki router fail: {e}') + +try: + from obrasci_router import router as obrasci_router + app.include_router(obrasci_router) + print('[CRM/M9] obrasci router loaded') +except Exception as e: + print(f'[CRM/M9] obrasci router fail: {e}') + +# === Round 3 / CC2 — M1 Auth + M2 Admin Users + M10 GDPR === +try: + from auth.auth_v2 import router as auth_v2_router + app.include_router(auth_v2_router) + print('[AUTH/M1] auth_v2 router loaded (/api/auth/*)') +except Exception as e: + print(f'[AUTH/M1] auth_v2 router fail: {e}') + +try: + from auth.admin_users import router as admin_users_router + app.include_router(admin_users_router) + print('[AUTH/M2] admin_users router loaded (/api/admin/users/*)') +except Exception as e: + print(f'[AUTH/M2] admin_users router fail: {e}') + +try: + from auth.gdpr import router as gdpr_router, admin_router as gdpr_admin_router + app.include_router(gdpr_router) + app.include_router(gdpr_admin_router) + print('[AUTH/M10] gdpr routers loaded (/api/gdpr/*, /api/admin/gdpr/*)') +except Exception as e: + print(f'[AUTH/M10] gdpr routers fail: {e}') + + + +@app.get("/sport-3d") +@app.get("/3d") +def serve_sport_3d(): + p = HTML_DIR / "sport_3d.html" + if p.exists(): + return FileResponse(p) + return {"error": "sport_3d.html not found"} + +@app.get("/admin") +@app.get("/admin/") +def serve_admin(): + p = HTML_DIR / "admin.html" + if p.exists(): + return FileResponse(p) + return {"error": "admin.html not found"} + + +@app.get("/api/sportski-objekti") +def list_sportski_objekti(q=None,tip=None,grad=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR adresa ILIKE %s OR grad ILIKE %s)"); p+=["%"+q+"%"]*3 + if tip: w.append("tip ILIKE %s"); p.append("%"+tip+"%") + if grad: w.append("grad ILIKE %s"); p.append("%"+grad+"%") + rows=fetch("SELECT * FROM pgz_sport.sportski_objekti WHERE "+" AND ".join(w)+" ORDER BY grad,naziv",p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/clanovi-full") +def list_clanovi_full(q=None,hoo=None,reprezentativac=None,klub_id=None,limit=80,authorization=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(ime ILIKE %s OR prezime ILIKE %s OR klub_naziv_godisnjak ILIKE %s)"); p+=["%"+q+"%"]*3 + if hoo: w.append("hoo_kategorija=%s"); p.append(hoo) + if reprezentativac is not None: w.append("reprezentativac="+(("TRUE") if str(reprezentativac).lower()=="true" else "FALSE")) + if klub_id: w.append("klub_id=%s"); p.append(int(klub_id)) + lim=min(int(limit or 80),200) + sql="SELECT id,ime,prezime,oib,datum_rodenja,spol,sport,pozicija,reprezentativac,kategoriziran,stipendiran,kategorija_hoo,hoo_kategorija,aktivan,klub_naziv_godisnjak,slika_url,profile_url,hns_igrac_id,visina_cm,tezina_kg,broj_dresa,uloga,godisnjak_godine,godisnjak_prvi,godisnjak_zadnji,napomena FROM pgz_sport.clanovi WHERE "+" AND ".join(w)+" ORDER BY prezime,ime LIMIT "+str(lim) + rows=fetch(sql,p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/gradovi") +def list_gradovi(): + rows=fetch("SELECT DISTINCT grad FROM pgz_sport.klubovi WHERE aktivan=TRUE AND grad IS NOT NULL AND grad<>'' AND grad NOT SIMILAR TO '[0-9]+%%' ORDER BY grad",[]) + return [r["grad"] for r in rows] + +@app.get("/api/manifestacije-full") +def list_manifestacije_full(q=None,razina=None): + w=["aktivna=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR mjesto ILIKE %s)"); p+=["%"+q+"%"]*2 + rows=fetch("SELECT id,naziv,mjesto,organizator,razina,broj_ucesnika,godina_od,spol_kategorija,napomena,source_url FROM pgz_sport.manifestacije WHERE "+" AND ".join(w)+" ORDER BY naziv",p) + return {"count":len(rows),"rows":rows} + + + +# ── SUFINANCIRANJE-ALL v1.0 dradulic@outlook.com 2026-05-04 +@app.get("/api/sufinanciranje") +def list_sufinanciranje(q=None, godina=None, razina=None, sport=None, limit=500): + w=["iznos_eur > 0"]; p=[] + if q: w.append("(LOWER(korisnik) LIKE %s OR LOWER(sport) LIKE %s)"); p+=[f"%{q.lower()}%"]*2 + if godina: w.append("godina=%s"); p.append(int(godina)) + if razina: w.append("razina ILIKE %s"); p.append(f"%{razina}%") + if sport: w.append("sport ILIKE %s"); p.append(f"%{sport}%") + sql=f"SELECT korisnik,sport,iznos_eur,vrsta,razina,izvor,source_url,godina FROM pgz_sport.sufinanciranje_sport WHERE {' AND '.join(w)} ORDER BY iznos_eur DESC LIMIT {min(int(limit),1000)}" + rows=fetch(sql,p) + total=sum(float(r.get('iznos_eur') or 0) for r in rows) + years=sorted(set(r.get('godina') for r in rows if r.get('godina')),reverse=True) + return {"count":len(rows),"total":total,"years":years,"rows":rows} + + + +# ══════════════════════════════════════════════════════════════════ +# ERP PLATFORM ROUTES v2.0 — dradulic@outlook.com — 2026-05-04 +# ══════════════════════════════════════════════════════════════════ + +import hashlib + +def hash_pwd(pwd): return hashlib.sha256(pwd.encode()).hexdigest() + +def get_user(token): + if not token: return None + try: + payload = _jwt.decode(token.replace("Bearer ",""), JWT_SECRET, algorithms=["HS256"]) + uid = payload.get("uid") + if uid: + rows = fetch("SELECT * FROM pgz_sport.users WHERE id=%s AND aktivan=TRUE", [uid]) + return rows[0] if rows else None + return payload + except: return None + +# ── AUTH: Email/Password login — handled by auth.auth_v2 router (M1) ── + +# ── SPORTAS FULL PROFILE ───────────────────────────────────────── +@app.get("/api/sportas/{clan_id}/profil") +def sportas_profil(clan_id: int): + clan = fetch("""SELECT c.*, k.naziv AS klub_naziv_full, k.sport AS klub_sport, + k.grad, k.logo_url FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id]) + if not clan: raise HTTPException(404,"Nije pronađen") + c = clan[0] + sezona = fetch("""SELECT * FROM pgz_sport.clan_sezona WHERE clan_id=%s ORDER BY sezona DESC""", [clan_id]) + utakmice = fetch("""SELECT * FROM pgz_sport.utakmice_log WHERE clan_id=%s ORDER BY datum DESC LIMIT 30""", [clan_id]) + nagrade = fetch("SELECT * FROM pgz_sport.clan_nagrada WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + godisnjaci = fetch("SELECT * FROM pgz_sport.clan_godisnjak WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + stats = {} + if sezona: + stats = {"ukupno_nastupa": sum((r.get("nastupi") or 0) for r in sezona), + "ukupno_pogodaka": sum((r.get("pogoci") or 0) for r in sezona), + "ukupno_asistencija": sum((r.get("asistencije") or 0) for r in sezona), + "ukupno_zutih": sum((r.get("zuti_kartoni") or 0) for r in sezona), + "ukupno_crvenih": sum((r.get("crveni_kartoni") or 0) for r in sezona), + "ukupno_minuta": sum((r.get("minute_total") or 0) for r in sezona), + "sezone_aktivne": len(sezona)} + return {**c,"clan_sezona":sezona,"utakmice":utakmice,"nagrade":nagrade, + "godisnjaci":godisnjaci,"stats":stats} + +# ── SAVEZ FULL DETAIL ──────────────────────────────────────────── +@app.get("/api/savezi/{savez_id}/full") +def savez_full(savez_id: int): + s = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s",[savez_id]) + if not s: raise HTTPException(404,"Savez nije pronađen") + klubovi = fetch("""SELECT id,naziv,sport,grad,predsjednik,tajnik,nositelj_kvalitete, + aktivan,oib,razina,broj_clanova FROM pgz_sport.klubovi WHERE savez_id=%s AND aktivan=TRUE ORDER BY naziv""",[savez_id]) + clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija, + c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan + FROM pgz_sport.clanovi c WHERE c.savez_kod=(SELECT kod FROM pgz_sport.savezi WHERE id=%s) LIMIT 200""",[savez_id]) + if not clanovi: + clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija, + c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan + FROM pgz_sport.clanovi c WHERE c.aktivan=TRUE AND c.sport ILIKE %s LIMIT 200""", + [f'%{s[0].get("sport","") or ""}%']) + treneri = fetch("""SELECT * FROM pgz_sport.treneri WHERE savez_id=%s""",[savez_id]) + return {**s[0],"klubovi":klubovi,"clanovi":clanovi[:100],"treneri":treneri} + +# ── KLUB ERP: CLANARINE ────────────────────────────────────────── +@app.get("/api/klub/{klub_id}/clanarine") +def klub_clanarine(klub_id: int, godina: int=None, status: str=None): + w=["c.klub_id=%s"]; p=[klub_id] + if godina: w.append("cl.godina=%s"); p.append(godina) + if status: w.append("cl.status=%s"); p.append(status) + rows = fetch(f"""SELECT cl.*,c.ime,c.prezime,c.oib,c.spol,c.kategorija,c.hoo_kategorija,c.slika_url + FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE {" AND ".join(w)} ORDER BY cl.godina DESC, c.prezime""", p) + total_p = sum(float(r.get("iznos_placen") or 0) for r in rows) + total_d = sum(float(r.get("iznos_propisan") or 0) - float(r.get("iznos_placen") or 0) for r in rows) + return {"count":len(rows),"naplaceno":total_p,"dug":total_d,"rows":rows} + +# ── KLUB ERP: LIJECNICKI ───────────────────────────────────────── +@app.get("/api/klub/{klub_id}/lijecnicki") +def klub_lijecnicki(klub_id: int): + import datetime; today = datetime.date.today() + rows = fetch("""SELECT lp.*,c.ime,c.prezime,c.oib,c.kategorija,c.slika_url, + CASE WHEN lp.vrijedi_do IS NULL THEN 'nepoznato' + WHEN lp.vrijedi_do < CURRENT_DATE THEN 'istekao' + WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'uskoro_istece' + ELSE 'validan' END AS status_pregled + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s ORDER BY lp.vrijedi_do ASC NULLS LAST""", [klub_id]) + alert_istekli = [r for r in rows if r.get("status_pregled")=="istekao"] + alert_uskoro = [r for r in rows if r.get("status_pregled")=="uskoro_istece"] + return {"count":len(rows),"istekli":len(alert_istekli),"uskoro":len(alert_uskoro),"rows":rows} + +# ── NETWORK GRAPH DATA ─────────────────────────────────────────── +@app.get("/api/network/pgz") +def network_pgz(q: str=None, entity_type: str=None, max_nodes: int=80): + FORENSIC_NAMES = {"SAMIR BARAĆ","MIROSLAV MARIĆ","VELIMIR LIVERIĆ","DOROTEA PESIC-BUKOVAC"} + nodes,edges,seen_nodes,seen_edges = [],[],set(),set() + + def add_node(nid, label, ntype, meta=None): + if nid not in seen_nodes: + seen_nodes.add(nid) + nodes.append({"id":nid,"label":label,"type":ntype,"forensic":label.upper() in FORENSIC_NAMES,"meta":meta or {}}) + + def add_edge(s,t,rel=""): + k=f"{s}-{t}" + if k not in seen_edges: + seen_edges.add(k); edges.append({"source":s,"target":t,"rel":rel}) + + if q: + # Person search + persons = fetch("""SELECT p.id,p.name,p.function,e.name as ent,e.id as eid,e.entity_type,e.city + FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id + WHERE p.name ILIKE %s OR e.name ILIKE %s LIMIT 60""",[f"%{q}%",f"%{q}%"]) + for r in persons: + pid=f"p_{r['id']}"; eid=f"e_{r['eid']}" + add_node(pid,r.get("name","?")[:30],"person") + add_node(eid,r.get("ent","?")[:30],"club" if "Udruga" in (r.get("entity_type") or "") else "company") + add_edge(pid,eid,r.get("function","")) + else: + # Default: top connected persons + rels = fetch("""SELECT p.id,p.name,e.id as eid,e.name as ent,e.entity_type,p.function + FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id + WHERE e.county ILIKE '%%goranska%%' OR e.county ILIKE '%%primorska%%' + ORDER BY p.id LIMIT %s""",[max_nodes]) + for r in rels: + pid=f"p_{r['id']}"; eid=f"e_{r['eid']}" + add_node(pid,r.get("name","?")[:25],"person") + add_node(eid,r.get("ent","?")[:25],"club" if "Udruga" in (r.get("entity_type") or "") else "company", + {"city":r.get("city"),"type":r.get("entity_type")}) + add_edge(pid,eid,r.get("function","")) + + return {"nodes":nodes[:200],"edges":edges[:400],"query":q} + + + +@app.get("/platform") +@app.get("/platform/") +def serve_platform(): + p = HTML_DIR / "platform.html" + if p.exists(): return FileResponse(p) + return {"error": "platform.html not found"} + +app.mount("/static", StaticFiles(directory=str(HTML_DIR)), name="static") + +@app.get("/") +def root(request: Request): + host = request.headers.get("host", "") + if "sport.rinet.one" in host: + p = HTML_DIR / "sport2.html" + if p.exists(): + return FileResponse(p) + idx = HTML_DIR / "index.html" + if idx.exists(): + return FileResponse(idx) + return {"service": "PGŽ Sport", "version": "2.0"} + +@app.get("/v2") +def portal_v2(): + p = HTML_DIR / "sport2.html" + if p.exists(): + return FileResponse(p) + return {"error": "sport2.html not found"} + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8095) diff --git a/_backups/r3_cc5/pgz_sport_api.py.pre_m9.1777932632 b/_backups/r3_cc5/pgz_sport_api.py.pre_m9.1777932632 new file mode 100644 index 0000000..0c50b27 --- /dev/null +++ b/_backups/r3_cc5/pgz_sport_api.py.pre_m9.1777932632 @@ -0,0 +1,1668 @@ +#!/usr/bin/env python3 +""" +pgz_sport_api.py - FastAPI backend za PGŽ Sportski savez ERP/CRM +Author: Damir Radulić (damir@rinet.one) +Date: 25.04.2026 +Port: 8095 +Endpoints: savezi, klubovi, članovi, članarine, liječnički, manifestacije, proračun, dashboard, alertovi +""" + +from fastapi import FastAPI, HTTPException, Query, Body, Header, Depends, UploadFile, File, Form, Request +import json +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import Optional, List +from datetime import date, datetime +import psycopg2 +import psycopg2.extras +from pgz_sport_v2_router import router as v2_router +import os + +DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7') + + +ADMIN_TOKEN = 'admin-pgz-2026' + +def is_admin(authorization): + if not authorization: return False + token = authorization.replace('Bearer ', '').strip() + if token == ADMIN_TOKEN: return True + # Try JWT + try: + import jwt as _jwt + payload = _jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return payload.get("role") == "admin" + except Exception: + return False + +def blur_oib(v): + if not v: return v + s = str(v); + return s[:3] + '•'*(len(s)-5) + s[-2:] if len(s) >= 8 else '•'*len(s) +def blur_email(e): + if not e or '@' not in str(e): return e + u, d = str(e).split('@',1); return (u[:1]+'•••' if u else '')+'@'+d +def blur_phone(p): + if not p: return p + s=str(p); return s[:4]+'•'*(len(s)-7)+s[-3:] if len(s)>=7 else s +def blur_iban(v): + if not v: return v + s=str(v); return s[:4]+'•'*(len(s)-8)+s[-4:] if len(s)>=8 else s +def blur_date(d): + if not d: return d + s = str(d); return s[:4]+'-••-••' if len(s)>=4 else s +def blur_text(t, keep=3): + if not t: return t + s=str(t); return s[:keep]+'•'*(len(s)-keep*2)+s[-keep:] if len(s)>keep*2 else s + +def apply_privacy(rows, admin): + if admin: return rows + out = [] + for r in (rows if isinstance(rows, list) else [rows]): + rr = dict(r) + for k, v in list(rr.items()): + if v is None: continue + kl = k.lower() + if 'oib' in kl: rr[k] = blur_oib(v) + elif 'email' in kl: rr[k] = blur_email(v) + elif kl in ('telefon','tel','phone'): rr[k] = blur_phone(v) + elif kl == 'datum_rodenja': rr[k] = blur_date(v) + elif 'iban' in kl: rr[k] = blur_iban(v) + elif kl == 'adresa': rr[k] = blur_text(v, 3) + elif 'licenca_broj' in kl: rr[k] = blur_text(v, 2) + out.append(rr) + return out if isinstance(rows, list) else out[0] + +app = FastAPI(title="PGŽ Sportski savez ERP/CRM", version="1.0.0") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + + +# === URL rewrite middleware - convert direct external image URLs to /img-proxy === +import json as _json_mw +import re as _re_mw +from starlette.responses import Response as _StarletteResponse_mw + +_IMG_DOMAINS_RE = _re_mw.compile( + r'https?://(?:hns\.family|hns\.hr|hbs\.hr|hrvatski-bocarski-savez\.hr|' + r'rk-zamet\.hr|hvs\.hr|rezultati\.hvs\.hr|sport-pgz\.hr)' + r'/[^"\s\\]+\.(?:jpg|jpeg|png|gif|webp|svg)', + _re_mw.IGNORECASE +) + +def _rewrite_to_proxy(text: str) -> str: + """Replace external image URLs with /sport/api/v2/img-proxy?u=...""" + from urllib.parse import quote as _q + def _sub(m): + url = m.group(0) + return "/sport/api/v2/img-proxy?u=" + _q(url, safe='') + return _IMG_DOMAINS_RE.sub(_sub, text) + +@app.middleware("http") +async def url_rewrite_middleware(request, call_next): + response = await call_next(request) + # Only rewrite JSON API responses + ct = response.headers.get("content-type", "") + if "application/json" not in ct: + return response + # Only on /api/v2 routes (admin & data endpoints) - SKIP /api/v2/img-proxy itself + path = request.url.path + if "/api/v2/img-proxy" in path or "/api/v2/dokumenti" in path: + return response # don't rewrite raw document content + # Read body + body = b"" + async for chunk in response.body_iterator: + body += chunk + try: + text = body.decode("utf-8") + new_text = _rewrite_to_proxy(text) + new_body = new_text.encode("utf-8") + except Exception: + new_body = body + return _StarletteResponse_mw( + content=new_body, + status_code=response.status_code, + headers={k: v for k, v in response.headers.items() if k.lower() not in ("content-length",)}, + media_type=ct, + ) +# === end URL rewrite middleware === + +def db(): + conn = psycopg2.connect(**DB) + conn.autocommit = True + return conn + +def fetch(sql, params=None): + with db() as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as c: + c.execute(sql, params or ()) + return [dict(r) for r in c.fetchall()] + +def execute(sql, params=None): + with db() as conn: + with conn.cursor() as c: + c.execute(sql, params or ()) + return c.rowcount + +# ==================== HEALTH ==================== +@app.get("/health") +def health(): + try: + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + return {"status": "ok", "service": "pgz_sport", "dashboard": rows[0] if rows else None} + except Exception as e: + raise HTTPException(500, f"DB error: {e}") + + +@app.get("/api/whoami") +def whoami_v2(authorization: Optional[str] = Header(None)): + return {"role": "admin" if is_admin(authorization) else "viewer", "privacy_active": not is_admin(authorization)} + +# ==================== DASHBOARD ==================== +@app.get("/api/dashboard") +def dashboard(): + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + if not rows: + return {} + d = rows[0] + # Top savezi by registriranih 2024 + top = fetch("""SELECT s.naziv, st.klubova_clanica, st.registriranih, st.trenera, st.reprezentativaca + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina=2024 ORDER BY st.registriranih DESC LIMIT 10""") + proracun_trend = fetch("SELECT godina, ukupno FROM pgz_sport.proracun ORDER BY godina") + nositelji = fetch("""SELECT naziv_kluba, godina, iznos FROM pgz_sport.potpore_nositelji + WHERE godina = 2025 ORDER BY iznos DESC LIMIT 10""") + return {**d, "top_savezi": top, "proracun_trend": proracun_trend, "nositelji_2025": nositelji} + +@app.get("/api/dashboard/ekosustav") +def dashboard_ekosustav(): + """Sport ekosustav PGŽ — coverage stats za enrichment iz FINA registra.""" + summary = fetch("""SELECT + COUNT(*) AS klubova_total, + COUNT(*) FILTER (WHERE oib IS NOT NULL) AS s_oib, + COUNT(*) FILTER (WHERE predsjednik IS NOT NULL) AS s_predsjednik, + COUNT(*) FILTER (WHERE tajnik IS NOT NULL) AS s_tajnik, + COUNT(*) FILTER (WHERE ciljevi IS NOT NULL) AS s_ciljevi, + COUNT(*) FILTER (WHERE opis_djelatnosti IS NOT NULL) AS s_opis, + COUNT(*) FILTER (WHERE sjediste IS NOT NULL) AS s_sjediste, + COUNT(*) FILTER (WHERE email IS NOT NULL) AS s_email, + COUNT(*) FILTER (WHERE web_stranica IS NOT NULL) AS s_web, + COUNT(*) FILTER (WHERE udruga_status = \'AKTIVAN\') AS s_aktivan_reg, + COUNT(*) FILTER (WHERE savez_id IS NOT NULL) AS s_savez, + COUNT(*) FILTER (WHERE nositelj_kvalitete) AS s_nositelj + FROM pgz_sport.klubovi WHERE aktivan""")[0] + + by_sport = fetch("""SELECT sport, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND sport IS NOT NULL + GROUP BY sport ORDER BY COUNT(*) DESC LIMIT 15""") + + by_region = fetch("""SELECT region, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND region IS NOT NULL + GROUP BY region ORDER BY COUNT(*) DESC""") + + by_grad = fetch("""SELECT grad, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND grad IS NOT NULL + GROUP BY grad ORDER BY COUNT(*) DESC LIMIT 12""") + + decade = fetch("""SELECT + CASE + WHEN godina_osnutka < 1950 THEN \'pred1950\' + WHEN godina_osnutka < 1980 THEN \'1950-1979\' + WHEN godina_osnutka < 2000 THEN \'1980-1999\' + WHEN godina_osnutka < 2010 THEN \'2000-2009\' + WHEN godina_osnutka >= 2010 THEN \'2010-danas\' + ELSE \'nepoznato\' + END AS razdoblje, + COUNT(*) AS broj + FROM pgz_sport.klubovi + WHERE aktivan AND godina_osnutka IS NOT NULL + GROUP BY razdoblje ORDER BY razdoblje""") + + # Pokazi enrichment % + total = summary["klubova_total"] or 1 + coverage = { + "oib_pct": round(100 * summary["s_oib"] / total, 1), + "predsjednik_pct": round(100 * summary["s_predsjednik"] / total, 1), + "tajnik_pct": round(100 * summary["s_tajnik"] / total, 1), + "ciljevi_pct": round(100 * summary["s_ciljevi"] / total, 1), + "opis_pct": round(100 * summary["s_opis"] / total, 1), + "sjediste_pct": round(100 * summary["s_sjediste"] / total, 1), + "email_pct": round(100 * summary["s_email"] / total, 1), + "savez_pct": round(100 * summary["s_savez"] / total, 1), + } + + return {**summary, "coverage": coverage, "by_sport": by_sport, + "by_region": by_region, "by_grad": by_grad, "by_decade": decade} + + + +# ==================== ANALYTICS ==================== +@app.get("/api/analytics/savezi-trend") +def savezi_trend(godine: str = "2020,2021,2022,2023,2024", metric: str = "registriranih"): + valid_metrics = {"registriranih", "neregistriranih", "rekreativaca", "trenera", "reprezentativaca", + "kategoriziranih", "stipendiranih", "klubova_clanica"} + if metric not in valid_metrics: + raise HTTPException(400, f"Invalid metric. Must be one of: {valid_metrics}") + god_list = [int(g) for g in godine.split(",")] + rows = fetch(f"""SELECT s.naziv AS savez, st.godina, st.{metric} AS value + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina = ANY(%s) ORDER BY s.naziv, st.godina""", [god_list]) + saveze = {} + for r in rows: + if r['savez'] not in saveze: saveze[r['savez']] = {} + saveze[r['savez']][r['godina']] = r['value'] + return {"metric": metric, "godine": god_list, "data": saveze} + +@app.get("/api/analytics/proracun-detaljno") +def proracun_detaljno(): + p = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + if not p: return {"proracun": [], "rast_godisnji": [], "current_year": None, "current_total": 0, "rast_dekada_pct": 0} + cagr = [] + for i in range(1, len(p)): + prev = float(p[i-1]['ukupno']) if p[i-1]['ukupno'] else 0 + curr = float(p[i]['ukupno']) if p[i]['ukupno'] else 0 + rate = ((curr/prev - 1) * 100) if prev > 0 else 0 + cagr.append({"godina": p[i]['godina'], "rast_postotak": round(rate, 1)}) + decade_rast = round((float(p[-1]['ukupno'])/float(p[0]['ukupno']) - 1) * 100, 1) if p[0]['ukupno'] else 0 + return {"proracun": p, "rast_godisnji": cagr, "rast_dekada_pct": decade_rast, + "current_year": int(p[-1]['godina']), "current_total": float(p[-1]['ukupno'])} + +@app.get("/api/analytics/klub-financije") +def klub_financije(klub_id: Optional[int] = None, godina: Optional[int] = None): + where = [] + params = [] + if godina: where.append("p.godina=%s"); params.append(godina) + if klub_id: + where.append("(p.klub_id=%s OR p.naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))") + params.extend([klub_id, klub_id]) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"""SELECT p.naziv_kluba, p.godina, p.iznos, + k.id AS klub_id, k.sport, k.razina, k.nositelj_kvalitete + FROM pgz_sport.potpore_nositelji p + LEFT JOIN pgz_sport.klubovi k ON p.klub_id=k.id OR p.naziv_kluba=k.naziv + {where_sql} ORDER BY p.godina DESC, p.iznos DESC""", params) + summary = fetch(f"""SELECT godina, SUM(iznos) AS total, COUNT(*) AS klubova, AVG(iznos) AS prosjek + FROM pgz_sport.potpore_nositelji p {where_sql} + GROUP BY godina ORDER BY godina""", params) + return {"data": rows, "summary": summary} + +@app.get("/api/analytics/lijecnicki-stats") +def lijecnicki_stats(klub_id: Optional[int] = None): + where = ["1=1"]; params = [] + if klub_id: where.append("c.klub_id=%s"); params.append(klub_id) + where_sql = " AND ".join(where) + rows = fetch(f"""SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE lp.vrijedi_do >= CURRENT_DATE + 30) AS validni, + COUNT(*) FILTER (WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + 30) AS uskoro, + COUNT(*) FILTER (WHERE lp.vrijedi_do < CURRENT_DATE) AS istekli, + SUM(lp.iznos) AS ukupan_trosak, SUM(lp.iznos_zzjz) AS zzjz_udio, + SUM(lp.iznos_klub) AS klub_udio, SUM(lp.iznos_clan) AS clan_udio, + AVG(lp.iznos) AS prosjecni_trosak + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id WHERE {where_sql}""", params) + by_ustanova = fetch(f"""SELECT lp.ustanova, COUNT(*) cnt, SUM(lp.iznos) iznos + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} GROUP BY lp.ustanova ORDER BY cnt DESC""", params) + by_lijecnik = fetch(f"""SELECT lp.lijecnik, COUNT(*) cnt, AVG(lp.iznos) prosjek + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} AND lp.lijecnik IS NOT NULL GROUP BY lp.lijecnik ORDER BY cnt DESC""", params) + return {"summary": rows[0] if rows else {}, "by_ustanova": by_ustanova, "by_lijecnik": by_lijecnik} + +# ==================== SAVEZI ==================== +@app.get("/api/savezi") +def list_savezi(authorization: Optional[str] = Header(None), q: Optional[str] = None, + razina: Optional[str] = None, zupanija: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = "WHERE aktivan" + params = [] + if q: + where += " AND (naziv ILIKE %s OR sport ILIKE %s)" + params = [f"%{q}%", f"%{q}%"] + if razina: + where += " AND razina = %s"; params.append(razina) + if zupanija: + where += " AND sjediste_zupanija ILIKE %s"; params.append(f"%{zupanija}%") + sort_col = {"naziv": "naziv", "godina": "godina_osnutka", "sport": "sport", "razina": "razina"}.get(sort, "naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + # Croatian collation for text columns (Š → after S, Č → after C, etc.) + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("naziv", "sport") else "" + rows = fetch(f"""SELECT s.*, + (SELECT COUNT(*) FROM pgz_sport.klubovi WHERE savez_id=s.id) AS broj_klubova, + (SELECT registriranih FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS reg_2024, + (SELECT trenera FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS treneri_2024, + (SELECT reprezentativaca FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS repr_2024 + FROM pgz_sport.savezi s {where} ORDER BY {sort_col}{collate} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/savezi/{savez_id}") +def get_savez(savez_id: int): + rows = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s", [savez_id]) + if not rows: + raise HTTPException(404, "Savez ne postoji") + klubovi = fetch("SELECT * FROM pgz_sport.klubovi WHERE savez_id=%s ORDER BY naziv", [savez_id]) + statistika = fetch("SELECT * FROM pgz_sport.statistika_saveza WHERE savez_id=%s ORDER BY godina", [savez_id]) + manifestacije = fetch("SELECT * FROM pgz_sport.manifestacije WHERE savez_id=%s", [savez_id]) + return {**rows[0], "klubovi": klubovi, "statistika": statistika, "manifestacije": manifestacije} + +# ==================== KLUBOVI ==================== +@app.get("/api/klubovi") +def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, savez_id: Optional[int] = None, + nositelj: Optional[bool] = None, region: Optional[str] = None, sport: Optional[str] = None, grad: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivan"] + params = [] + if q: + where.append("(klub ILIKE %s OR oib ILIKE %s OR sport ILIKE %s OR predsjednik ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%", f"%{q}%"]) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + if nositelj is not None: + where.append(f"nositelj_kvalitete={'TRUE' if nositelj else 'FALSE'}") + if region: + where.append("region ILIKE %s"); params.append(region) + if grad: + where.append("grad ILIKE %s"); params.append(f"%{grad}%") + if sport: + where.append("sport ILIKE %s"); params.append(f"%{sport}%") + sort_col = {"naziv": "klub", "savez": "savez", "broj_clanova": "broj_clanova", + "razina": "razina", "region": "region", "grad": "grad", "sport": "sport"}.get(sort, "klub") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("klub", "savez", "razina", "region", "grad", "sport") else "" + rows = fetch(f"""SELECT * FROM pgz_sport.v_klubovi_pregled WHERE {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST""", params) + for r in rows: + if isinstance(r, dict) and r.get('klub') and not r.get('naziv'): + r['naziv'] = r['klub'] + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/klubovi/{klub_id}") +def get_klub(klub_id: int, authorization: Optional[str] = Header(None)): + admin = is_admin(authorization) + rows = fetch("""SELECT k.*, s.naziv AS savez_naziv FROM pgz_sport.klubovi k + LEFT JOIN pgz_sport.savezi s ON s.id=k.savez_id WHERE k.id=%s""", [klub_id]) + if not rows: raise HTTPException(404, "Klub ne postoji") + if isinstance(rows[0], dict) and rows[0].get('klub') and not rows[0].get('naziv'): + rows[0]['naziv'] = rows[0]['klub'] + + clanovi = fetch("""SELECT id, ime, prezime, oib, datum_rodenja, spol, kategorija, + pozicija, reprezentativac, kategoriziran, stipendiran, datum_pristupa + FROM pgz_sport.clanovi WHERE klub_id=%s AND aktivan + ORDER BY prezime, ime""", [klub_id]) + + clanarine = fetch("""SELECT cl.id, cl.godina, cl.razdoblje, cl.iznos_propisan, cl.iznos_placen, + (cl.iznos_propisan - cl.iznos_placen) AS dug, cl.datum_uplate, cl.status, cl.napomena, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib + FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE c.klub_id=%s ORDER BY cl.godina DESC, cl.id DESC""", [klub_id]) + + lijecnicki = fetch("""SELECT lp.id, lp.datum_pregleda, lp.vrijedi_do, lp.vrsta_pregleda, + lp.ustanova, lp.lijecnik, lp.spreman_za_natjecanje, lp.iznos, lp.iznos_zzjz, lp.iznos_klub, lp.iznos_clan, + lp.placeno, lp.komentar_lijecnika, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib, + CASE WHEN lp.vrijedi_do IS NULL THEN 'Nepoznato' + WHEN lp.vrijedi_do < CURRENT_DATE THEN 'Istekao' + WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'Ističe uskoro' + ELSE 'Validan' END AS status_pregled + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s ORDER BY lp.datum_pregleda DESC""", [klub_id]) + + potpore = fetch("""SELECT * FROM pgz_sport.potpore_nositelji + WHERE klub_id=%s OR naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s) + ORDER BY godina DESC""", [klub_id, klub_id]) + + # Aggregate stats + stats = { + 'broj_clanova': len(clanovi), + 'broj_registriranih': sum(1 for c in clanovi if c.get('kategorija')=='registrirani'), + 'broj_trenera': sum(1 for c in clanovi if c.get('kategorija')=='trener'), + 'broj_reprezentativaca': sum(1 for c in clanovi if c.get('reprezentativac')), + 'broj_kategoriziranih': sum(1 for c in clanovi if c.get('kategoriziran')), + 'broj_stipendiranih': sum(1 for c in clanovi if c.get('stipendiran')), + 'lijecnicki_validni': sum(1 for l in lijecnicki if l.get('status_pregled')=='Validan'), + 'lijecnicki_istekli': sum(1 for l in lijecnicki if l.get('status_pregled')=='Istekao'), + 'lijecnicki_uskoro': sum(1 for l in lijecnicki if l.get('status_pregled')=='Ističe uskoro'), + 'clanarina_naplaceno_god': sum(float(c.get('iznos_placen') or 0) for c in clanarine if c.get('godina')==2026), + 'clanarina_dug_god': sum(float(c.get('dug') or 0) for c in clanarine if c.get('godina')==2026), + 'potpore_2025': float(next((p['iznos'] for p in potpore if p.get('godina')==2025), 0) or 0), + 'potpore_total': sum(float(p.get('iznos') or 0) for p in potpore), + 'zzjz_isplaceno': sum(float(l.get('iznos_zzjz') or 0) for l in lijecnicki if l.get('placeno')), + } + + klub = rows[0] + if not admin: + klub = apply_privacy(klub, admin) + clanovi = apply_privacy(clanovi, admin) + clanarine = apply_privacy(clanarine, admin) + lijecnicki = apply_privacy(lijecnicki, admin) + + return {**klub, "clanovi": clanovi, "clanarine": clanarine, "lijecnicki": lijecnicki, + "potpore": potpore, "stats": stats} + + +class KlubIn(BaseModel): + naziv: str + savez_id: Optional[int] = None + sport: Optional[str] = None + oib: Optional[str] = None + razina: Optional[str] = None + nositelj_kvalitete: Optional[bool] = False + grad: Optional[str] = None + region: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + predsjednik: Optional[str] = None + iban: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/klubovi") +def create_klub(k: KlubIn): + rows = fetch("""INSERT INTO pgz_sport.klubovi (naziv, savez_id, sport, oib, razina, nositelj_kvalitete, grad, region, email, telefon, predsjednik, iban, napomena, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE) RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena]) + return rows[0] + +@app.put("/api/klubovi/{klub_id}") +def update_klub(klub_id: int, k: KlubIn): + rows = fetch("""UPDATE pgz_sport.klubovi SET naziv=%s, savez_id=%s, sport=%s, oib=%s, razina=%s, + nositelj_kvalitete=%s, grad=%s, region=%s, email=%s, telefon=%s, predsjednik=%s, iban=%s, napomena=%s, + updated_at=NOW() WHERE id=%s RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena, klub_id]) + if not rows: + raise HTTPException(404, "Klub ne postoji") + return rows[0] + +# ==================== ČLANOVI ==================== +@app.get("/api/clanovi") +def list_clanovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, klub_id: Optional[int] = None, + kategorija: Optional[str] = None, spol: Optional[str] = None, sort: str = "prezime", order: str = "asc"): + where = ["c.aktivan"] + params = [] + if q: + where.append("(c.ime ILIKE %s OR c.prezime ILIKE %s OR c.oib ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%"]) + if klub_id: + where.append("c.klub_id=%s"); params.append(klub_id) + if kategorija: + where.append("c.kategorija=%s"); params.append(kategorija) + if spol: + # Normalize: Z → Ž, F → Ž (legacy) + spol_norm = "Ž" if spol.upper() in ("Z","Ž","F","W") else "M" if spol.upper() in ("M",) else spol + where.append("c.spol=%s"); params.append(spol_norm) + sort_map = {"prezime": "c.prezime", "ime": "c.ime", "oib": "c.oib", "datum_rodenja": "c.datum_rodenja", "kategorija": "c.kategorija", "klub": "k.naziv"} + sort_col = sort_map.get(sort, "c.prezime") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT c.*, k.naziv AS klub_naziv, + (SELECT MAX(vrijedi_do) FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=c.id) AS lijecnicki_vrijedi_do, + (SELECT SUM(iznos_propisan-iznos_placen) FROM pgz_sport.clanarine WHERE clan_id=c.id AND status!='podmireno') AS dug_clanarine + FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE {where_sql} ORDER BY {sort_col} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +class ClanIn(BaseModel): + klub_id: int + ime: str + prezime: str + oib: Optional[str] = None + datum_rodenja: Optional[date] = None + spol: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + kategorija: Optional[str] = "registrirani" + pozicija: Optional[str] = None + licenca_broj: Optional[str] = None + licenca_vrijedi_do: Optional[date] = None + reprezentativac: Optional[bool] = False + kategoriziran: Optional[bool] = False + stipendiran: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/clanovi") +def create_clan(c: ClanIn): + rows = fetch("""INSERT INTO pgz_sport.clanovi (klub_id, ime, prezime, oib, datum_rodenja, spol, email, telefon, kategorija, pozicija, licenca_broj, licenca_vrijedi_do, reprezentativac, kategoriziran, stipendiran, napomena, aktivan, datum_pristupa) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE,CURRENT_DATE) RETURNING *""", + [c.klub_id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol, c.email, c.telefon, c.kategorija, c.pozicija, c.licenca_broj, c.licenca_vrijedi_do, c.reprezentativac, c.kategoriziran, c.stipendiran, c.napomena]) + return rows[0] + +@app.get("/api/clanovi/{clan_id}") +def get_clan(clan_id: int): + rows = fetch("""SELECT c.*, k.naziv AS klub_naziv FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id]) + if not rows: + raise HTTPException(404, "Član ne postoji") + clanarine = fetch("SELECT * FROM pgz_sport.clanarine WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + lijecnicki = fetch("SELECT * FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=%s ORDER BY datum_pregleda DESC", [clan_id]) + return {**rows[0], "clanarine": clanarine, "lijecnicki": lijecnicki} + +# ==================== ČLANARINE ==================== +@app.get("/api/clanarine") +def list_clanarine(godina: Optional[int] = None, status: Optional[str] = None, + klub_id: Optional[int] = None, sort: str = "godina", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + if status: + where.append("status=%s"); params.append(status) + sort_map = {"godina": "godina", "iznos": "iznos_propisan", "klub": "klub", "datum_uplate": "datum_uplate", "status": "status"} + sort_col = sort_map.get(sort, "godina") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_clanarine_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos_propisan) AS total_propisan, + SUM(iznos_placen) AS total_placen, + SUM(iznos_propisan - iznos_placen) AS total_dug + FROM pgz_sport.v_clanarine_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class ClanarinaIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + godina: int + razdoblje: Optional[str] = "godišnja" + iznos_propisan: float + iznos_placen: Optional[float] = 0 + datum_uplate: Optional[date] = None + nacin_uplate: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/clanarine") +def create_clanarina(c: ClanarinaIn): + status = "podmireno" if c.iznos_placen >= c.iznos_propisan else ("djelomicno" if c.iznos_placen > 0 else "nepodmireno") + klub_id = c.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [c.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.clanarine (clan_id, klub_id, godina, razdoblje, iznos_propisan, iznos_placen, datum_uplate, nacin_uplate, status, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [c.clan_id, klub_id, c.godina, c.razdoblje, c.iznos_propisan, c.iznos_placen, c.datum_uplate, c.nacin_uplate, status, c.napomena]) + return rows[0] + +# ==================== LIJEČNIČKI ==================== +@app.get("/api/lijecnicki") +def list_lijecnicki(klub_id: Optional[int] = None, status: Optional[str] = None, + placeno: Optional[bool] = None, sort: str = "datum_pregleda", order: str = "desc"): + where = [] + params = [] + if klub_id: + where.append("(klub_oib IS NOT NULL AND klub=ANY(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))"); params.append(klub_id) + if status: + where.append("status_pregled=%s"); params.append(status) + if placeno is not None: + where.append(f"placeno={'TRUE' if placeno else 'FALSE'}") + sort_map = {"datum_pregleda": "datum_pregleda", "vrijedi_do": "vrijedi_do", "iznos": "iznos", "clan": "clan", "klub": "klub"} + sort_col = sort_map.get(sort, "datum_pregleda") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_lijecnicki_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos) AS total_iznos, + SUM(iznos_zzjz) AS total_zzjz, + SUM(iznos_klub) AS total_klub, + SUM(iznos_clan) AS total_clan, + COUNT(*) FILTER (WHERE status_pregled='Istekao') AS istekli, + COUNT(*) FILTER (WHERE status_pregled='Ističe uskoro') AS uskoro + FROM pgz_sport.v_lijecnicki_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class LijecnickiIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + datum_pregleda: date + vrijedi_do: Optional[date] = None + vrsta_pregleda: Optional[str] = "temeljni" + ustanova: Optional[str] = "ZZJZ PGŽ" + lijecnik: Optional[str] = None + spreman_za_natjecanje: Optional[bool] = True + ekg: Optional[bool] = False + krv: Optional[bool] = False + spirometrija: Optional[bool] = False + nalaz: Optional[str] = None + komentar_lijecnika: Optional[str] = None + preporuke: Optional[str] = None + iznos: Optional[float] = 0 + iznos_zzjz: Optional[float] = 0 + iznos_klub: Optional[float] = 0 + iznos_clan: Optional[float] = 0 + datum_placanja: Optional[date] = None + placeno: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/lijecnicki") +def create_lijecnicki(l: LijecnickiIn): + klub_id = l.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [l.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.lijecnicki_pregledi (clan_id, klub_id, datum_pregleda, vrijedi_do, vrsta_pregleda, ustanova, lijecnik, spreman_za_natjecanje, ekg, krv, spirometrija, nalaz, komentar_lijecnika, preporuke, iznos, iznos_zzjz, iznos_klub, iznos_clan, datum_placanja, placeno, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [l.clan_id, klub_id, l.datum_pregleda, l.vrijedi_do, l.vrsta_pregleda, l.ustanova, l.lijecnik, l.spreman_za_natjecanje, l.ekg, l.krv, l.spirometrija, l.nalaz, l.komentar_lijecnika, l.preporuke, l.iznos, l.iznos_zzjz, l.iznos_klub, l.iznos_clan, l.datum_placanja, l.placeno, l.napomena]) + return rows[0] + +# ==================== PRORAČUN ==================== +@app.get("/api/proracun") +def list_proracun(): + rows = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + return {"count": len(rows), "rows": rows} + +# ==================== POTPORE NOSITELJI ==================== +@app.get("/api/potpore") +def list_potpore(godina: Optional[int] = None, sort: str = "iznos", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + sort_col = {"iznos": "iznos", "godina": "godina", "klub": "naziv_kluba"}.get(sort, "iznos") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.potpore_nositelji {where_sql} ORDER BY {sort_col} {order}", params) + sum_year = fetch(f"SELECT godina, SUM(iznos) AS total FROM pgz_sport.potpore_nositelji {where_sql} GROUP BY godina ORDER BY godina", params) + return {"count": len(rows), "rows": rows, "sum_year": sum_year} + +# ==================== STATISTIKA SAVEZA ==================== +@app.get("/api/statistika") +def list_statistika(godina: Optional[int] = None, q: Optional[str] = None, razina: Optional[str] = None, + sort: str = "registriranih", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("st.godina=%s"); params.append(godina) + if q: + where.append("s.naziv ILIKE %s"); params.append(f"%{q}%") + if razina: + where.append("s.razina = %s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + # Map sort key → unambiguous column expression + sort_map = { + "registriranih": "st.registriranih", + "klubova": "st.klubova_clanica", + "trenera": "st.trenera", + "reprezentativaca":"st.reprezentativaca", + "neregistriranih": "st.neregistriranih", + "rekreativaca": "st.rekreativaca", + "godina": "st.godina", + "savez": "s.naziv", + "naziv": "s.naziv", + } + sort_col = sort_map.get(sort, "st.registriranih") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + use_collate = sort_col in ("s.naziv", "s.sport") + collate = ' COLLATE "hr-HR-x-icu"' if use_collate else "" + rows = fetch(f"""SELECT s.naziv AS savez, s.razina AS savez_razina, s.sport AS sport, st.* + FROM pgz_sport.statistika_saveza st + JOIN pgz_sport.savezi s ON s.id=st.savez_id {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST, s.naziv COLLATE "hr-HR-x-icu" ASC""", params) + return {"count": len(rows), "rows": rows} + +# ==================== MANIFESTACIJE ==================== +@app.get("/api/manifestacije") +def list_manifestacije(razina: Optional[str] = None, savez_id: Optional[int] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivna"] + params = [] + if razina: + where.append("razina=%s"); params.append(razina) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + sort_col = {"naziv": "m.naziv", "razina": "m.razina", "godina_od": "m.godina_od", "mjesto": "m.mjesto"}.get(sort, "m.naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT m.*, s.naziv AS savez_naziv FROM pgz_sport.manifestacije m + LEFT JOIN pgz_sport.savezi s ON s.id=m.savez_id WHERE {where_sql} + ORDER BY {sort_col} COLLATE "hr-HR-x-icu" {order} NULLS LAST""", params) + return {"count": len(rows), "rows": rows} + +# ==================== ALERTOVI ==================== +@app.get("/api/alertovi") +def list_alertovi(rijeseno: Optional[bool] = None, razina: Optional[str] = None): + where = [] + params = [] + if rijeseno is not None: + where.append(f"rijeseno={'TRUE' if rijeseno else 'FALSE'}") + if razina: + where.append("razina=%s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.alertovi {where_sql} ORDER BY created_at DESC", params) + return {"count": len(rows), "rows": rows} + +@app.post("/api/alertovi/scan") +def scan_alerts(): + """Generira alerte za istekle liječničke + dospjele članarine""" + execute("DELETE FROM pgz_sport.alertovi WHERE NOT rijeseno AND tip IN ('lijecnicki_isteka', 'lijecnicki_uskoro', 'clanarina_dospjela')") + # Liječnički istekao + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_isteka', 'CRITICAL', c.klub_id, lp.clan_id, + 'Liječnički pregled istekao za ' || c.ime || ' ' || c.prezime || ' (klub: ' || COALESCE(k.naziv, 'N/A') || ')', lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lp.vrijedi_do < CURRENT_DATE AND c.aktivan""") + # Liječnički uskoro + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_uskoro', 'WARNING', c.klub_id, lp.clan_id, + 'Liječnički ističe za 30 dana: ' || c.ime || ' ' || c.prezime, lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE+30 AND c.aktivan""") + # Članarine dospjele + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum, iznos) + SELECT 'clanarina_dospjela', 'WARNING', cl.klub_id, cl.clan_id, + 'Nepodmirena članarina ' || cl.godina || ' za ' || c.ime || ' ' || c.prezime, NULL, (cl.iznos_propisan - cl.iznos_placen) + FROM pgz_sport.clanarine cl + JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE cl.status != 'podmireno' AND cl.godina <= EXTRACT(YEAR FROM CURRENT_DATE)""") + res = fetch("SELECT COUNT(*) cnt FROM pgz_sport.alertovi WHERE NOT rijeseno") + return {"alerts_generated": res[0]["cnt"]} + +@app.put("/api/alertovi/{alert_id}/rijesi") +def rijesi_alert(alert_id: int, korisnik: str = "admin"): + rows = fetch("UPDATE pgz_sport.alertovi SET rijeseno=TRUE, rijeseno_at=NOW(), rijeseno_od=%s WHERE id=%s RETURNING *", + [korisnik, alert_id]) + if not rows: + raise HTTPException(404, "Alert ne postoji") + return rows[0] + +# ==================== ZZJZ INTEGRACIJA ==================== +@app.get("/api/zzjz/dogovor") +def zzjz_dogovor(): + """Pregled dogovora sa ZZJZ PGŽ za liječničke preglede""" + return { + "info": "Predviđa se ugovor PGŽ ↔ ZZJZ PGŽ za sufinanciranje liječničkih pregleda sportaša", + "model": "ZZJZ PGŽ subvencionira do 50% troška za registrirane sportaše članica saveza", + "godisnji_potencijal": fetch("""SELECT + COUNT(*) FILTER (WHERE c.kategorija='registrirani') AS sportasa_potencijalno, + SUM(CASE WHEN c.kategorija='registrirani' THEN 30 ELSE 0 END) AS procijenjeni_godisnji_trosak_eur + FROM pgz_sport.clanovi c WHERE c.aktivan""")[0] + } + + +# ==================== AI SEARCH (Qdrant + RAG) ==================== +import requests as _req, hashlib as _h +QDRANT_URL = 'http://10.10.0.2:6333' + +def _embed(text): + """BGE-M3 embedding service on 9879 (1024-dim normalized).""" + try: + r = _req.post('http://localhost:9879/api/embeddings', + json={'texts': [text[:2000]]}, timeout=15) + if r.ok: + data = r.json() + if 'embeddings' in data: return data['embeddings'][0] + if 'embedding' in data: return data['embedding'] + except Exception as e: + import logging; logging.warning(f'BGE-M3 fail: {e}') + h = _h.sha256(text.encode()).digest() + return [(h[i % 32] / 255.0 - 0.5) for i in range(1024)] + +@app.get("/api/search") +def search(q: str, limit: int = 10, tip: Optional[str] = None, scope: str = "pgz"): + """Semantic AI search across PGZ Sport entities. + scope='pgz' (default): only PGŽ-relevant content (klubovi PGŽ, savezi PGŽ, dokumenti vezani uz PGŽ) + scope='all': vrati sve uključujući nacionalne dokumente + scope='national': samo nacionalne pravilnike, zakone, HOO, MINT + """ + if not q or len(q) < 2: + raise HTTPException(400, "Query too short") + vec = _embed(q) + + # Build filter — PGŽ scope by default + must = [] + must_not = [] + if tip: + must.append({"key": "tip", "match": {"value": tip}}) + + # Boost PGŽ-relevant content via fetch limit + filter post-process + body = {"vector": vec, "limit": limit * 4, "with_payload": True, "score_threshold": 0.35} + if must: + body["filter"] = {"must": must} + + try: + r = _req.post(f"{QDRANT_URL}/collections/pgz_sport_v1/points/search", json=body, timeout=10) + if not r.ok: raise HTTPException(500, f"Qdrant: {r.text[:200]}") + all_results = r.json()['result'] + except _req.exceptions.RequestException as e: + raise HTTPException(503, f"Search service unavailable: {e}") + + # PGŽ-relevance scoring + filter + PGZ_KEYWORDS = ['rijek','primorsko','primorsko-goran','pgž','pgz','crikvenic','opatij', + 'krk','cres','rab','lošinj','losinj','kvarner','čikat','čavle', + 'kostrena','klana','viškovo','jelenj','vrbnik','baška','dobrinj', + 'punat','omišalj','malinska','bakar','zsp','zspgz','sszpgz'] + NATIONAL_DOCS = ['hoo','hns_family','mint','nss_','statute_hns','federacija','hrvatski savez'] + + scored = [] + for hit in all_results: + p = hit.get('payload') or {} + # Combine all text fields for keyword check + all_text = ( + (p.get('naziv','') or '') + ' ' + + (p.get('title','') or '') + ' ' + + (p.get('text','') or '')[:500] + ' ' + + (p.get('source','') or '') + ' ' + + (p.get('grad','') or '') + ' ' + + (p.get('source_url','') or '') + ).lower() + + is_pgz = any(kw in all_text for kw in PGZ_KEYWORDS) + is_national = any(kw in all_text for kw in NATIONAL_DOCS) and not is_pgz + + # Klub scope: linked to klubovi.id which is by definition PGŽ + if p.get('tip') == 'klub' and p.get('klub_id'): is_pgz = True + # Savez PGŽ + if p.get('tip') == 'savez' and (p.get('razina') == 'zupanijski' or 'pgž' in (p.get('naziv','') or '').lower()): + is_pgz = True + + # Apply scope filter + if scope == 'pgz': + if is_pgz: + hit['_relevance'] = 'pgz' + scored.append(hit) + elif is_national and p.get('tip') in ('dokument','zakon'): + # Include national pravilnici but boost less + hit['_relevance'] = 'national_doc' + hit['score'] = hit['score'] * 0.7 + scored.append(hit) + elif scope == 'national': + if is_national: + hit['_relevance'] = 'national' + scored.append(hit) + else: # 'all' + hit['_relevance'] = 'pgz' if is_pgz else ('national' if is_national else 'other') + scored.append(hit) + + # Re-sort by adjusted score + scored.sort(key=lambda x: x.get('score', 0), reverse=True) + results = scored[:limit] + + return { + "query": q, "tip": tip, "scope": scope, "count": len(results), + "results": [{"score": r.get('score', 0), + "tip": (r.get('payload') or {}).get('tip'), + "naziv": (r.get('payload') or {}).get('naziv') or (r.get('payload') or {}).get('title'), + "klub_id": (r.get('payload') or {}).get('klub_id'), + "savez_id": (r.get('payload') or {}).get('savez_id'), + "tekst": (r.get('payload') or {}).get('tekst') or (r.get('payload') or {}).get('text','')[:300], + "url": (r.get('payload') or {}).get('source_url') or (r.get('payload') or {}).get('url'), + "relevance": r.get('_relevance', 'unknown'), + "payload": r.get('payload')} for r in results] + } + + +# ==================== GOOGLE OAUTH ==================== +import jwt as _jwt, secrets as _secrets +GOOGLE_CLIENT_ID = "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com" # postavi u .env +ADMIN_EMAILS = { + "damir@rinet.one", "dradulic@outlook.com", # Damir + # Dodaj druge admin emailove ovdje +} +JWT_SECRET = "rinet-pgz-jwt-2026-" + _secrets.token_hex(8) +JWT_ISSUED = [] # in-memory token store (može u Redis) + +@app.post("/api/auth/google") +def google_auth(token: str = Body(..., embed=True)): + """Verify Google ID token and issue JWT for admin/viewer role.""" + try: + import urllib.request + # Verify Google ID token via tokeninfo endpoint (server-side) + url = f"https://oauth2.googleapis.com/tokeninfo?id_token={token}" + with urllib.request.urlopen(url, timeout=10) as r: + data = json.loads(r.read()) + email = data.get("email", "").lower() + verified = data.get("email_verified") == "true" or data.get("email_verified") is True + if not verified or not email: + raise HTTPException(401, "Email not verified") + is_adm = email in ADMIN_EMAILS + # Issue JWT + payload = { + "email": email, "name": data.get("name", email), + "role": "admin" if is_adm else "viewer", + "iat": int(__import__("time").time()), + "exp": int(__import__("time").time()) + 86400 * 7 # 7 dana + } + jwt_token = _jwt.encode(payload, JWT_SECRET, algorithm="HS256") + return {"token": jwt_token, "email": email, "name": data.get("name", email), + "role": payload["role"], "expires_in": 86400 * 7} + except HTTPException: raise + except Exception as e: + raise HTTPException(401, f"Google auth failed: {e}") + +# /api/auth/me handled by auth.auth_v2 router (M1) + +# ==================== STATIC ==================== +import pathlib +HTML_DIR = pathlib.Path(__file__).parent / "static" +HTML_DIR.mkdir(exist_ok=True) + +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + + +# ──────── V5 NATJECANJA ──────── +@app.get("/api/natjecanja/filters") +def natjecanja_filters(): + with db() as conn: + cur = conn.cursor() + cur.execute("SELECT DISTINCT sport FROM pgz_sport.natjecanja WHERE sport IS NOT NULL ORDER BY sport") + sports = [r[0] for r in cur.fetchall()] + cur.execute("SELECT DISTINCT sezona FROM pgz_sport.natjecanja WHERE sezona IS NOT NULL ORDER BY sezona DESC") + sezone = [r[0] for r in cur.fetchall()] + return {"sports": sports, "sezone": sezone} + +@app.get("/api/natjecanja") +def natjecanja_list(sport: str = "", razina: str = "", sezona: str = "", q: str = "", limit: int = 200): + where = ["1=1"] + args = [] + if sport: where.append("sport = %s"); args.append(sport) + if razina: where.append("razina = %s"); args.append(razina) + if sezona: where.append("sezona = %s"); args.append(sezona) + if q: where.append("naziv ILIKE %s"); args.append(f"%{q}%") + args.append(limit) + + with db() as conn: + cur = conn.cursor() + cur.execute(f"""SELECT id, sport, naziv, razina, tip, sezona, kategorija, + external_url, source FROM pgz_sport.natjecanja WHERE {' AND '.join(where)} + ORDER BY razina, sezona DESC NULLS LAST, naziv LIMIT %s""", args) + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + results = [dict(zip(cols, r)) for r in rows] + cur.execute(f"SELECT COUNT(*) FROM pgz_sport.natjecanja WHERE {' AND '.join(where)}", args[:-1]) + total = cur.fetchone()[0] + return {"count": total, "limit": limit, "results": results} + +# ──────── V5 ADMIN ──────── +@app.get("/api/admin/stats") +def admin_stats(): + with db() as conn: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM pgz_sport.users"); ut = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM pgz_sport.users WHERE aktivan=true"); ua = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM pgz_sport.sys_permissions"); pt = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM pgz_sport.sys_audit WHERE created_at >= now()::date"); at = cur.fetchone()[0] + cur.execute("SELECT user_type, COUNT(*) cnt FROM pgz_sport.users GROUP BY 1 ORDER BY 2 DESC") + by_type = [{"user_type": r[0], "cnt": r[1]} for r in cur.fetchall()] + return {"users_total": ut, "users_active": ua, "permissions_total": pt, + "audit_today": at, "by_type": by_type} + +@app.get("/api/admin/users") +def admin_users(q: str = "", user_type: str = "", limit: int = 100): + where = ["1=1"]; args = [] + if q: where.append("(email ILIKE %s OR ime ILIKE %s OR prezime ILIKE %s)"); args += [f"%{q}%"]*3 + if user_type: where.append("user_type = %s"); args.append(user_type) + args.append(limit) + with db() as conn: + cur = conn.cursor() + cur.execute(f"""SELECT id, email, ime, prezime, user_type, klub_id, savez_id, + aktivan, last_login, created_at FROM pgz_sport.users + WHERE {' AND '.join(where)} ORDER BY id LIMIT %s""", args) + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + results = [{**dict(zip(cols, r)), + 'last_login': str(dict(zip(cols, r))['last_login']) if dict(zip(cols, r))['last_login'] else None, + 'created_at': str(dict(zip(cols, r))['created_at'])} for r in rows] + return {"count": len(results), "results": results} + +@app.post("/api/admin/users") +def admin_user_create(body: dict): + import hashlib + email = (body.get("email") or "").strip().lower() + if not email or "@" not in email: + raise HTTPException(400, "Invalid email") + pwd = body.get("password","") + if not pwd or len(pwd) < 6: + raise HTTPException(400, "Password min 6 chars") + pwd_hash = hashlib.sha256(pwd.encode()).hexdigest() + with db() as conn: + cur = conn.cursor() + try: + cur.execute("""INSERT INTO pgz_sport.users + (email, password_hash, ime, prezime, user_type, klub_id, savez_id, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,%s,true) RETURNING id""", + (email, pwd_hash, body.get("ime"), body.get("prezime"), + body.get("user_type","klub_user"), body.get("klub_id"), body.get("savez_id"))) + new_id = cur.fetchone()[0] + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, target_text, payload) + VALUES ('user.create','sys_users',%s,%s,%s::jsonb)""", + (new_id, email, json.dumps({"user_type": body.get("user_type")}))) + conn.commit() + return {"id": new_id, "email": email} + except psycopg2.IntegrityError as e: + conn.rollback() + raise HTTPException(400, f"Email već postoji: {email}") + +@app.post("/api/admin/users/{user_id}/toggle") +def admin_user_toggle(user_id: int): + with db() as conn: + cur = conn.cursor() + cur.execute("UPDATE pgz_sport.users SET aktivan = NOT aktivan WHERE id=%s RETURNING aktivan", (user_id,)) + r = cur.fetchone() + if not r: raise HTTPException(404, "User not found") + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, payload) + VALUES ('user.toggle','sys_users',%s,%s::jsonb)""", (user_id, json.dumps({"aktivan": r[0]}))) + conn.commit() + return {"id": user_id, "aktivan": r[0]} + + +# ──────── V6 AI GRADOVI / KILOMETRAŽA ──────── +@app.get("/api/ai/gradovi") +def ai_gradovi_search(q: str = "", limit: int = 20): + """Autocomplete for grad names — returns unique grad names matching q.""" + with db() as conn: + cur = conn.cursor() + if q: + cur.execute("""SELECT DISTINCT grad_od g FROM pgz_sport.ai_grad_distances + WHERE LOWER(grad_od) LIKE LOWER(%s) + UNION SELECT DISTINCT grad_do FROM pgz_sport.ai_grad_distances + WHERE LOWER(grad_do) LIKE LOWER(%s) + ORDER BY g LIMIT %s""", (f"{q}%", f"{q}%", limit)) + else: + cur.execute("""SELECT DISTINCT grad_od g FROM pgz_sport.ai_grad_distances + UNION SELECT DISTINCT grad_do FROM pgz_sport.ai_grad_distances + ORDER BY g LIMIT %s""", (limit,)) + return [r[0] for r in cur.fetchall()] + +@app.get("/api/ai/distance") +def ai_distance(od: str, do: str): + """AI lookup for distance between two cities.""" + with db() as conn: + cur = conn.cursor() + # Direct + cur.execute("""SELECT udaljenost_km, vrijeme_minute, izvor + FROM pgz_sport.ai_grad_distances + WHERE LOWER(grad_od)=LOWER(%s) AND LOWER(grad_do)=LOWER(%s)""", (od, do)) + r = cur.fetchone() + if r: + return {"od": od, "do": do, "udaljenost_km": float(r[0]), + "vrijeme_minute": r[1], "izvor": r[2], "found": True} + # Try reverse + cur.execute("""SELECT udaljenost_km, vrijeme_minute, izvor + FROM pgz_sport.ai_grad_distances + WHERE LOWER(grad_od)=LOWER(%s) AND LOWER(grad_do)=LOWER(%s)""", (do, od)) + r = cur.fetchone() + if r: + return {"od": od, "do": do, "udaljenost_km": float(r[0]), + "vrijeme_minute": r[1], "izvor": r[2]+'_reverse', "found": True} + # Not found — return suggestion to add manually + return {"od": od, "do": do, "udaljenost_km": None, "found": False, + "suggestion": f"Udaljenost {od} ↔ {do} nije u bazi. Dodaj ručno ili koristi external API."} + +@app.post("/api/ai/distance") +def ai_distance_save(body: dict): + """User can save a new distance for AI to learn.""" + od = (body.get("od") or "").strip() + do = (body.get("do") or "").strip() + km = body.get("udaljenost_km") + mins = body.get("vrijeme_minute") or 0 + if not od or not do or not km: + raise HTTPException(400, "od, do, udaljenost_km required") + with db() as conn: + cur = conn.cursor() + cur.execute("""INSERT INTO pgz_sport.ai_grad_distances + (grad_od, grad_do, udaljenost_km, vrijeme_minute, izvor) + VALUES (%s,%s,%s,%s,'user') + ON CONFLICT (grad_od, grad_do) DO UPDATE + SET udaljenost_km=EXCLUDED.udaljenost_km, vrijeme_minute=EXCLUDED.vrijeme_minute, + izvor='user', updated_at=now()""", + (od, do, km, mins)) + conn.commit() + return {"ok": True, "od": od, "do": do, "udaljenost_km": km} + +# ──────── V6 BLOCKCHAIN AUDIT ──────── +@app.get("/api/admin/audit-chain") +def admin_audit_chain(limit: int = 50, action: str = "", user_id: int = 0): + """List audit log with hash chain validation.""" + where = ["row_hash IS NOT NULL"] + args = [] + if action: + where.append("action LIKE %s"); args.append(f"%{action}%") + if user_id: + where.append("user_id = %s"); args.append(user_id) + args.append(limit) + + with db() as conn: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(f"""SELECT id, chain_idx, action, target_type, target_id, + target_text, payload, user_email, created_at, prev_hash, row_hash + FROM pgz_sport.sys_audit WHERE {' AND '.join(where)} + ORDER BY chain_idx DESC LIMIT %s""", args) + rows = cur.fetchall() + + return [{ + "id": r["id"], "chain_idx": r["chain_idx"], "action": r["action"], + "target_type": r["target_type"], "target_id": r["target_id"], + "target_text": r["target_text"], "payload": r["payload"], + "user_email": r["user_email"], + "created_at": str(r["created_at"]), + "prev_hash": (r["prev_hash"] or "")[:24] + "...", + "row_hash": (r["row_hash"] or "")[:24] + "...", + "row_hash_full": r["row_hash"], + } for r in rows] + +@app.get("/api/admin/audit-chain/verify") +def admin_audit_chain_verify(): + """Verify entire hash chain integrity. Returns OK/BROKEN at first tampered row.""" + import hashlib as _hash, json as _json + with db() as conn: + cur = conn.cursor() + cur.execute("""SELECT id, chain_idx, action, target_type, target_id, + target_text, payload, created_at, prev_hash, row_hash + FROM pgz_sport.sys_audit WHERE row_hash IS NOT NULL + ORDER BY chain_idx""") + rows = cur.fetchall() + + expected_prev = "GENESIS_PGZ_SPORT_2026" + broken_at = None + for r in rows: + aid, cidx, act, ttype, tid, ttext, payload, created, prev, row_h = r + if prev != expected_prev: + broken_at = {"chain_idx": cidx, "id": aid, "expected_prev": expected_prev[:24], + "actual_prev": (prev or "")[:24], "issue": "prev_hash mismatch"} + break + # Recompute + block = f"{cidx}|{act or ''}|{ttype or ''}|{tid or ''}|{ttext or ''}|{_json.dumps(payload, sort_keys=True, default=str) if payload else '{}'}|{created}|{prev}" + recomputed = _hash.sha256(block.encode()).hexdigest() + # Trigger uses different format (psql digest ordering) — just check chain link is unbroken + expected_prev = row_h + + return { + "total_rows": len(rows), + "valid": broken_at is None, + "broken_at": broken_at, + "last_hash": (rows[-1][9] if rows else None), + "first_hash": (rows[0][9] if rows else None), + } + +# ──────── V6 USER-KLUB MULTI-TENANT ──────── +@app.get("/api/admin/klub-links") +def admin_klub_links(user_id: int = 0, klub_id: int = 0, savez_id: int = 0): + where = ["1=1"] + args = [] + if user_id: where.append("ukl.user_id=%s"); args.append(user_id) + if klub_id: where.append("ukl.klub_id=%s"); args.append(klub_id) + if savez_id: where.append("ukl.savez_id=%s"); args.append(savez_id) + with db() as conn: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(f"""SELECT ukl.*, u.email, u.ime, u.prezime, + k.naziv AS klub_naziv, s.naziv AS savez_naziv + FROM pgz_sport.user_klub_links ukl + LEFT JOIN pgz_sport.users u ON u.id=ukl.user_id + LEFT JOIN pgz_sport.klubovi k ON k.id=ukl.klub_id + LEFT JOIN pgz_sport.savezi s ON s.id=ukl.savez_id + WHERE {' AND '.join(where)} ORDER BY ukl.id DESC""", args) + rows = cur.fetchall() + return {"results": [dict(r, granted_at=str(r['granted_at']) if r.get('granted_at') else None, + od_datuma=str(r['od_datuma']) if r.get('od_datuma') else None, + do_datuma=str(r['do_datuma']) if r.get('do_datuma') else None) for r in rows]} + +@app.post("/api/admin/klub-links") +def admin_klub_link_create(body: dict): + user_id = body.get("user_id") + klub_id = body.get("klub_id") + savez_id = body.get("savez_id") + role = body.get("role", "clan") + if not user_id or (not klub_id and not savez_id): + raise HTTPException(400, "user_id + (klub_id OR savez_id) required") + with db() as conn: + cur = conn.cursor() + try: + cur.execute("""INSERT INTO pgz_sport.user_klub_links + (user_id, klub_id, savez_id, role, primary_klub, link_type) + VALUES (%s,%s,%s,%s,%s, COALESCE(%s,'membership')) RETURNING id""", + (user_id, klub_id, savez_id, role, body.get("primary_link", False), role)) + new_id = cur.fetchone()[0] + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, payload) + VALUES ('user.klub_link.create','sys_user_klub_links',%s,%s::jsonb)""", + (new_id, json.dumps({"user_id":user_id, "klub_id":klub_id, "savez_id":savez_id, "role":role}))) + conn.commit() + except psycopg2.IntegrityError as e: + conn.rollback() + raise HTTPException(400, f"Link already exists: {e}") + return {"id": new_id, "user_id": user_id, "klub_id": klub_id, "savez_id": savez_id, "role": role} + +@app.delete("/api/admin/klub-links/{link_id}") +def admin_klub_link_delete(link_id: int): + with db() as conn: + cur = conn.cursor() + cur.execute("DELETE FROM pgz_sport.user_klub_links WHERE id=%s RETURNING user_id, klub_id, savez_id", (link_id,)) + r = cur.fetchone() + if not r: raise HTTPException(404, "Link not found") + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, payload) + VALUES ('user.klub_link.delete','sys_user_klub_links',%s,%s::jsonb)""", + (link_id, json.dumps({"user_id":r[0], "klub_id":r[1], "savez_id":r[2]}))) + conn.commit() + return {"deleted": link_id} + +# ──────── V6 OCR za prilog (cestarine, gorivo, parking) ──────── +@app.post("/api/ai/ocr-prilog") +async def ai_ocr_prilog(file: UploadFile = File(...), tip: str = Form("racun")): + """OCR upload prilog (cestarina/gorivo/parking) → extract amount + vendor + date.""" + import tempfile, subprocess as sp + suffix = '.' + (file.filename or 'unknown').split('.')[-1].lower() + if suffix not in ['.pdf','.jpg','.jpeg','.png']: + raise HTTPException(400, "Only PDF/JPG/PNG") + + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tf: + content = await file.read() + tf.write(content) + tmp_path = tf.name + + text = "" + try: + if suffix == '.pdf': + r = sp.run(['pdftotext','-layout','-q', tmp_path,'-'], capture_output=True, timeout=30) + text = r.stdout.decode('utf-8','ignore') + if len(text) < 50: # scanned PDF, OCR it + r = sp.run(['pdftoppm','-r','200', tmp_path, tmp_path+'_p'], capture_output=True, timeout=30) + import glob + for p in glob.glob(tmp_path+'_p-*.ppm')[:3]: + r = sp.run(['tesseract', p, '-', '-l','hrv+eng'], capture_output=True, timeout=30) + text += r.stdout.decode('utf-8','ignore') + '\n' + else: + r = sp.run(['tesseract', tmp_path, '-', '-l','hrv+eng'], capture_output=True, timeout=30) + text = r.stdout.decode('utf-8','ignore') + except Exception as e: + return {"error": str(e), "text": text} + + # Parse + import re as _r + amt = None + amt_match = _r.search(r'(?:UKUPNO|TOTAL|SVEUKUPNO|IZNOS|ZA UPLATU)[:\s]*?(\d+[,.]\d{2})\s*(?:EUR|HRK|kn|€)?', text, _r.IGNORECASE) + if not amt_match: + amt_match = _r.search(r'(\d+[,.]\d{2})\s*EUR\b', text, _r.IGNORECASE) + if amt_match: + try: amt = float(amt_match.group(1).replace(',','.')) + except: pass + + date_match = _r.search(r'(\d{1,2})[./-](\d{1,2})[./-](\d{4}|\d{2})', text) + parsed_date = None + if date_match: + d, m, y = date_match.groups() + if len(y) == 2: y = '20' + y + try: parsed_date = f"{y}-{int(m):02d}-{int(d):02d}" + except: pass + + vendor = None + for line in (text or '').split('\n')[:10]: + line = line.strip() + if line and not _r.match(r'^[\d\s.,/-]+$', line) and len(line) > 5 and len(line) < 80: + vendor = line + break + + oib_match = _r.search(r'(?:OIB|VAT)[:\s]+(\d{11})', text) + oib = oib_match.group(1) if oib_match else None + + import os as _os + try: _os.unlink(tmp_path) + except: pass + + return { + "tip": tip, + "ai_amount": amt, + "ai_date": parsed_date, + "ai_vendor": vendor, + "ai_oib": oib, + "raw_text": text[:1500], + "filename": file.filename, + } + +# ──────── /V6 ──────── + +@app.get("/api/admin/permissions-matrix") +def admin_perm_matrix(): + with db() as conn: + cur = conn.cursor() + cur.execute("""SELECT DISTINCT user_type FROM pgz_sport.sys_role_permissions ORDER BY user_type""") + types = [r[0] for r in cur.fetchall()] + cur.execute("""SELECT p.code, p.naziv, p.kategorija, ARRAY_AGG(rp.user_type) granted_to + FROM pgz_sport.sys_permissions p + LEFT JOIN pgz_sport.sys_role_permissions rp ON rp.permission_code=p.code + GROUP BY p.code, p.naziv, p.kategorija + ORDER BY p.kategorija, p.code""") + matrix = [] + for r in cur.fetchall(): + matrix.append({ + "code": r[0], "naziv": r[1], "kategorija": r[2], + "granted_to": [g for g in (r[3] or []) if g] + }) + return {"user_types": types, "matrix": matrix} + +# ──────── /V5 ──────── + + +# Sprint 3 routers +import sys +sys.path.insert(0, '/opt/pgz-sport/routers') +try: + from img_proxy_router import router as img_proxy_router + from audit_coverage_router import router as audit_coverage_router + HAS_S3_ROUTERS = True +except Exception as e: + print(f'WARN: sprint3 routers not loaded: {e}') + HAS_S3_ROUTERS = False + +app.include_router(v2_router) +# Admin Dashboard router (ERP/CRM/Tenants) +try: + from admin_router import router as admin_router + app.include_router(admin_router) + print('[ADMIN] router loaded') +except Exception as e: + print(f'[ADMIN] router fail: {e}') + + +# Sprint 3 includes +if HAS_S3_ROUTERS: + app.include_router(img_proxy_router, prefix='/api/v2') + app.include_router(audit_coverage_router, prefix='/api/v2') + +# Round-2 enrichment endpoint +try: + from enrich_router import router as enrich_router + app.include_router(enrich_router, prefix='/api/v2') + print('[ENRICH] router loaded') +except Exception as e: + print(f'[ENRICH] router fail: {e}') + +# === Round 3 / CC4 — ERP (M5: OCR + Invoices, M6: Putni nalozi) === +sys.path.insert(0, '/opt/pgz-sport') +try: + from erp.ocr import router as erp_ocr_router + app.include_router(erp_ocr_router) + print('[ERP/OCR] router loaded') +except Exception as e: + print(f'[ERP/OCR] router fail: {e}') + +try: + from erp.putni_nalozi import router as erp_putni_router + app.include_router(erp_putni_router) + print('[ERP/PUTNI] router loaded') +except Exception as e: + print(f'[ERP/PUTNI] router fail: {e}') + +# === Round 3 / CC5 — CRM (M7 Članarine, M8 Liječnički, M9 Obrasci) === +try: + from clanarine_router import router as clanarine_router + app.include_router(clanarine_router) + print('[CRM/M7] clanarine router loaded') +except Exception as e: + print(f'[CRM/M7] clanarine router fail: {e}') + +try: + from lijecnicki_router import router as lijecnicki_router + app.include_router(lijecnicki_router) + print('[CRM/M8] lijecnicki router loaded') +except Exception as e: + print(f'[CRM/M8] lijecnicki router fail: {e}') + +try: + from obrasci_router import router as obrasci_router + app.include_router(obrasci_router) + print('[CRM/M9] obrasci router loaded') +except Exception as e: + print(f'[CRM/M9] obrasci router fail: {e}') + +# === Round 3 / CC2 — M1 Auth + M2 Admin Users + M10 GDPR === +try: + from auth.auth_v2 import router as auth_v2_router + app.include_router(auth_v2_router) + print('[AUTH/M1] auth_v2 router loaded (/api/auth/*)') +except Exception as e: + print(f'[AUTH/M1] auth_v2 router fail: {e}') + +try: + from auth.admin_users import router as admin_users_router + app.include_router(admin_users_router) + print('[AUTH/M2] admin_users router loaded (/api/admin/users/*)') +except Exception as e: + print(f'[AUTH/M2] admin_users router fail: {e}') + +try: + from auth.gdpr import router as gdpr_router, admin_router as gdpr_admin_router + app.include_router(gdpr_router) + app.include_router(gdpr_admin_router) + print('[AUTH/M10] gdpr routers loaded (/api/gdpr/*, /api/admin/gdpr/*)') +except Exception as e: + print(f'[AUTH/M10] gdpr routers fail: {e}') + + + +@app.get("/sport-3d") +@app.get("/3d") +def serve_sport_3d(): + p = HTML_DIR / "sport_3d.html" + if p.exists(): + return FileResponse(p) + return {"error": "sport_3d.html not found"} + +@app.get("/admin") +@app.get("/admin/") +def serve_admin(): + p = HTML_DIR / "admin.html" + if p.exists(): + return FileResponse(p) + return {"error": "admin.html not found"} + + +@app.get("/api/sportski-objekti") +def list_sportski_objekti(q=None,tip=None,grad=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR adresa ILIKE %s OR grad ILIKE %s)"); p+=["%"+q+"%"]*3 + if tip: w.append("tip ILIKE %s"); p.append("%"+tip+"%") + if grad: w.append("grad ILIKE %s"); p.append("%"+grad+"%") + rows=fetch("SELECT * FROM pgz_sport.sportski_objekti WHERE "+" AND ".join(w)+" ORDER BY grad,naziv",p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/clanovi-full") +def list_clanovi_full(q=None,hoo=None,reprezentativac=None,klub_id=None,limit=80,authorization=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(ime ILIKE %s OR prezime ILIKE %s OR klub_naziv_godisnjak ILIKE %s)"); p+=["%"+q+"%"]*3 + if hoo: w.append("hoo_kategorija=%s"); p.append(hoo) + if reprezentativac is not None: w.append("reprezentativac="+(("TRUE") if str(reprezentativac).lower()=="true" else "FALSE")) + if klub_id: w.append("klub_id=%s"); p.append(int(klub_id)) + lim=min(int(limit or 80),200) + sql="SELECT id,ime,prezime,oib,datum_rodenja,spol,sport,pozicija,reprezentativac,kategoriziran,stipendiran,kategorija_hoo,hoo_kategorija,aktivan,klub_naziv_godisnjak,slika_url,profile_url,hns_igrac_id,visina_cm,tezina_kg,broj_dresa,uloga,godisnjak_godine,godisnjak_prvi,godisnjak_zadnji,napomena FROM pgz_sport.clanovi WHERE "+" AND ".join(w)+" ORDER BY prezime,ime LIMIT "+str(lim) + rows=fetch(sql,p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/gradovi") +def list_gradovi(): + rows=fetch("SELECT DISTINCT grad FROM pgz_sport.klubovi WHERE aktivan=TRUE AND grad IS NOT NULL AND grad<>'' AND grad NOT SIMILAR TO '[0-9]+%%' ORDER BY grad",[]) + return [r["grad"] for r in rows] + +@app.get("/api/manifestacije-full") +def list_manifestacije_full(q=None,razina=None): + w=["aktivna=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR mjesto ILIKE %s)"); p+=["%"+q+"%"]*2 + rows=fetch("SELECT id,naziv,mjesto,organizator,razina,broj_ucesnika,godina_od,spol_kategorija,napomena,source_url FROM pgz_sport.manifestacije WHERE "+" AND ".join(w)+" ORDER BY naziv",p) + return {"count":len(rows),"rows":rows} + + + +# ── SUFINANCIRANJE-ALL v1.0 dradulic@outlook.com 2026-05-04 +@app.get("/api/sufinanciranje") +def list_sufinanciranje(q=None, godina=None, razina=None, sport=None, limit=500): + w=["iznos_eur > 0"]; p=[] + if q: w.append("(LOWER(korisnik) LIKE %s OR LOWER(sport) LIKE %s)"); p+=[f"%{q.lower()}%"]*2 + if godina: w.append("godina=%s"); p.append(int(godina)) + if razina: w.append("razina ILIKE %s"); p.append(f"%{razina}%") + if sport: w.append("sport ILIKE %s"); p.append(f"%{sport}%") + sql=f"SELECT korisnik,sport,iznos_eur,vrsta,razina,izvor,source_url,godina FROM pgz_sport.sufinanciranje_sport WHERE {' AND '.join(w)} ORDER BY iznos_eur DESC LIMIT {min(int(limit),1000)}" + rows=fetch(sql,p) + total=sum(float(r.get('iznos_eur') or 0) for r in rows) + years=sorted(set(r.get('godina') for r in rows if r.get('godina')),reverse=True) + return {"count":len(rows),"total":total,"years":years,"rows":rows} + + + +# ══════════════════════════════════════════════════════════════════ +# ERP PLATFORM ROUTES v2.0 — dradulic@outlook.com — 2026-05-04 +# ══════════════════════════════════════════════════════════════════ + +import hashlib + +def hash_pwd(pwd): return hashlib.sha256(pwd.encode()).hexdigest() + +def get_user(token): + if not token: return None + try: + payload = _jwt.decode(token.replace("Bearer ",""), JWT_SECRET, algorithms=["HS256"]) + uid = payload.get("uid") + if uid: + rows = fetch("SELECT * FROM pgz_sport.users WHERE id=%s AND aktivan=TRUE", [uid]) + return rows[0] if rows else None + return payload + except: return None + +# ── AUTH: Email/Password login — handled by auth.auth_v2 router (M1) ── + +# ── SPORTAS FULL PROFILE ───────────────────────────────────────── +@app.get("/api/sportas/{clan_id}/profil") +def sportas_profil(clan_id: int): + clan = fetch("""SELECT c.*, k.naziv AS klub_naziv_full, k.sport AS klub_sport, + k.grad, k.logo_url FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id]) + if not clan: raise HTTPException(404,"Nije pronađen") + c = clan[0] + sezona = fetch("""SELECT * FROM pgz_sport.clan_sezona WHERE clan_id=%s ORDER BY sezona DESC""", [clan_id]) + utakmice = fetch("""SELECT * FROM pgz_sport.utakmice_log WHERE clan_id=%s ORDER BY datum DESC LIMIT 30""", [clan_id]) + nagrade = fetch("SELECT * FROM pgz_sport.clan_nagrada WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + godisnjaci = fetch("SELECT * FROM pgz_sport.clan_godisnjak WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + stats = {} + if sezona: + stats = {"ukupno_nastupa": sum((r.get("nastupi") or 0) for r in sezona), + "ukupno_pogodaka": sum((r.get("pogoci") or 0) for r in sezona), + "ukupno_asistencija": sum((r.get("asistencije") or 0) for r in sezona), + "ukupno_zutih": sum((r.get("zuti_kartoni") or 0) for r in sezona), + "ukupno_crvenih": sum((r.get("crveni_kartoni") or 0) for r in sezona), + "ukupno_minuta": sum((r.get("minute_total") or 0) for r in sezona), + "sezone_aktivne": len(sezona)} + return {**c,"clan_sezona":sezona,"utakmice":utakmice,"nagrade":nagrade, + "godisnjaci":godisnjaci,"stats":stats} + +# ── SAVEZ FULL DETAIL ──────────────────────────────────────────── +@app.get("/api/savezi/{savez_id}/full") +def savez_full(savez_id: int): + s = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s",[savez_id]) + if not s: raise HTTPException(404,"Savez nije pronađen") + klubovi = fetch("""SELECT id,naziv,sport,grad,predsjednik,tajnik,nositelj_kvalitete, + aktivan,oib,razina,broj_clanova FROM pgz_sport.klubovi WHERE savez_id=%s AND aktivan=TRUE ORDER BY naziv""",[savez_id]) + clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija, + c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan + FROM pgz_sport.clanovi c WHERE c.savez_kod=(SELECT kod FROM pgz_sport.savezi WHERE id=%s) LIMIT 200""",[savez_id]) + if not clanovi: + clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija, + c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan + FROM pgz_sport.clanovi c WHERE c.aktivan=TRUE AND c.sport ILIKE %s LIMIT 200""", + [f'%{s[0].get("sport","") or ""}%']) + treneri = fetch("""SELECT * FROM pgz_sport.treneri WHERE savez_id=%s""",[savez_id]) + return {**s[0],"klubovi":klubovi,"clanovi":clanovi[:100],"treneri":treneri} + +# ── KLUB ERP: CLANARINE ────────────────────────────────────────── +@app.get("/api/klub/{klub_id}/clanarine") +def klub_clanarine(klub_id: int, godina: int=None, status: str=None): + w=["c.klub_id=%s"]; p=[klub_id] + if godina: w.append("cl.godina=%s"); p.append(godina) + if status: w.append("cl.status=%s"); p.append(status) + rows = fetch(f"""SELECT cl.*,c.ime,c.prezime,c.oib,c.spol,c.kategorija,c.hoo_kategorija,c.slika_url + FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE {" AND ".join(w)} ORDER BY cl.godina DESC, c.prezime""", p) + total_p = sum(float(r.get("iznos_placen") or 0) for r in rows) + total_d = sum(float(r.get("iznos_propisan") or 0) - float(r.get("iznos_placen") or 0) for r in rows) + return {"count":len(rows),"naplaceno":total_p,"dug":total_d,"rows":rows} + +# ── KLUB ERP: LIJECNICKI ───────────────────────────────────────── +@app.get("/api/klub/{klub_id}/lijecnicki") +def klub_lijecnicki(klub_id: int): + import datetime; today = datetime.date.today() + rows = fetch("""SELECT lp.*,c.ime,c.prezime,c.oib,c.kategorija,c.slika_url, + CASE WHEN lp.vrijedi_do IS NULL THEN 'nepoznato' + WHEN lp.vrijedi_do < CURRENT_DATE THEN 'istekao' + WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'uskoro_istece' + ELSE 'validan' END AS status_pregled + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s ORDER BY lp.vrijedi_do ASC NULLS LAST""", [klub_id]) + alert_istekli = [r for r in rows if r.get("status_pregled")=="istekao"] + alert_uskoro = [r for r in rows if r.get("status_pregled")=="uskoro_istece"] + return {"count":len(rows),"istekli":len(alert_istekli),"uskoro":len(alert_uskoro),"rows":rows} + +# ── NETWORK GRAPH DATA ─────────────────────────────────────────── +@app.get("/api/network/pgz") +def network_pgz(q: str=None, entity_type: str=None, max_nodes: int=80): + FORENSIC_NAMES = {"SAMIR BARAĆ","MIROSLAV MARIĆ","VELIMIR LIVERIĆ","DOROTEA PESIC-BUKOVAC"} + nodes,edges,seen_nodes,seen_edges = [],[],set(),set() + + def add_node(nid, label, ntype, meta=None): + if nid not in seen_nodes: + seen_nodes.add(nid) + nodes.append({"id":nid,"label":label,"type":ntype,"forensic":label.upper() in FORENSIC_NAMES,"meta":meta or {}}) + + def add_edge(s,t,rel=""): + k=f"{s}-{t}" + if k not in seen_edges: + seen_edges.add(k); edges.append({"source":s,"target":t,"rel":rel}) + + if q: + # Person search + persons = fetch("""SELECT p.id,p.name,p.function,e.name as ent,e.id as eid,e.entity_type,e.city + FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id + WHERE p.name ILIKE %s OR e.name ILIKE %s LIMIT 60""",[f"%{q}%",f"%{q}%"]) + for r in persons: + pid=f"p_{r['id']}"; eid=f"e_{r['eid']}" + add_node(pid,r.get("name","?")[:30],"person") + add_node(eid,r.get("ent","?")[:30],"club" if "Udruga" in (r.get("entity_type") or "") else "company") + add_edge(pid,eid,r.get("function","")) + else: + # Default: top connected persons + rels = fetch("""SELECT p.id,p.name,e.id as eid,e.name as ent,e.entity_type,p.function + FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id + WHERE e.county ILIKE '%%goranska%%' OR e.county ILIKE '%%primorska%%' + ORDER BY p.id LIMIT %s""",[max_nodes]) + for r in rels: + pid=f"p_{r['id']}"; eid=f"e_{r['eid']}" + add_node(pid,r.get("name","?")[:25],"person") + add_node(eid,r.get("ent","?")[:25],"club" if "Udruga" in (r.get("entity_type") or "") else "company", + {"city":r.get("city"),"type":r.get("entity_type")}) + add_edge(pid,eid,r.get("function","")) + + return {"nodes":nodes[:200],"edges":edges[:400],"query":q} + + + +@app.get("/platform") +@app.get("/platform/") +def serve_platform(): + p = HTML_DIR / "platform.html" + if p.exists(): return FileResponse(p) + return {"error": "platform.html not found"} + +app.mount("/static", StaticFiles(directory=str(HTML_DIR)), name="static") + +@app.get("/") +def root(request: Request): + host = request.headers.get("host", "") + if "sport.rinet.one" in host: + p = HTML_DIR / "sport2.html" + if p.exists(): + return FileResponse(p) + idx = HTML_DIR / "index.html" + if idx.exists(): + return FileResponse(idx) + return {"service": "PGŽ Sport", "version": "2.0"} + +@app.get("/v2") +def portal_v2(): + p = HTML_DIR / "sport2.html" + if p.exists(): + return FileResponse(p) + return {"error": "sport2.html not found"} + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8095) diff --git a/auth/seed_demo.py b/auth/seed_demo.py index e904890..c60ca5c 100644 --- a/auth/seed_demo.py +++ b/auth/seed_demo.py @@ -80,17 +80,21 @@ def main(): print("== Users ==") users = [ - ("damir@pgz.hr", "PGZ2026!", "Damir Radulić", "Damir", "Radulić", "pgz_admin", None, None), - ("pero@atletika.pgz.hr", "PGZ2026!", "Pero Perić", "Pero", "Perić", "savez_admin", None, atletski_savez), - ("ana@akkvarner.hr", "PGZ2026!", "Ana Anić", "Ana", "Anić", "klub_admin", ak_klub, atletski_savez), - ("sportas@akkvarner.hr", "PGZ2026!", "Marko Marković", "Marko", "Marković", "klub_clan", ak_klub, atletski_savez), + ("damir@pgz.hr", "PGZ2026!", "Damir Radulić", "Damir", "Radulić", "pgz_admin", None, None), + ("tajnik@atletski.pgz.hr", "Atl2026!", "Tajnik Atletski","Tajnik","Atletski", "savez_admin", None, atletski_savez), + ("admin@ak-kvarner.hr", "Kvarner2026!", "Admin Kvarner", "Admin", "Kvarner", "klub_admin", ak_klub, atletski_savez), + # Extra demos + ("pero@atletika.pgz.hr", "PGZ2026!", "Pero Perić", "Pero", "Perić", "savez_admin", None, atletski_savez), + ("ana@akkvarner.hr", "PGZ2026!", "Ana Anić", "Ana", "Anić", "klub_admin", ak_klub, atletski_savez), + ("sportas@akkvarner.hr", "PGZ2026!", "Marko Marković", "Marko", "Marković", "klub_clan", ak_klub, atletski_savez), ] for email, pwd, fn, im, pz, ut, kid, sid in users: uid, action = upsert_user(email, pwd, fn, im, pz, ut, kid, sid) print(f" [{action}] {email} (id={uid}, type={ut}, klub_id={kid}, savez_id={sid})") print("\n== Sanity check ==") - for email in ["damir@pgz.hr","pero@atletika.pgz.hr","ana@akkvarner.hr","sportas@akkvarner.hr"]: + for email in ["damir@pgz.hr","tajnik@atletski.pgz.hr","admin@ak-kvarner.hr", + "pero@atletika.pgz.hr","ana@akkvarner.hr","sportas@akkvarner.hr"]: u = db_one("SELECT id, email, user_type, klub_id, savez_id, aktivan FROM pgz_sport.users WHERE LOWER(email)=%s", (email,)) print(f" {email}: {u}") diff --git a/blockchain/seal.py b/blockchain/seal.py index d71f030..0e4f7f5 100644 --- a/blockchain/seal.py +++ b/blockchain/seal.py @@ -63,9 +63,18 @@ POLYGON_WALLET = os.environ.get( POLYGON_PRIVKEY = os.environ.get("POLYGON_PRIVKEY", "").strip() POLYGONSCAN_BASE = os.environ.get("POLYGONSCAN_BASE", "https://polygonscan.com") +_pgh = os.environ.get("PG_HOST", "10.10.0.2") +_pgp = int(os.environ.get("PG_PORT", "6432")) +# pgz-sport.service inherits PG_HOST=localhost:5432 from /opt/.env.rinet which is +# stale (local PG was decommissioned). Honour the DB_HOST/DB_PORT override that +# points at canonical Server B (10.10.0.2:6432). +if _pgh in ("localhost", "127.0.0.1"): + _pgh = os.environ.get("DB_HOST", "10.10.0.2") + _pgp = int(os.environ.get("DB_PORT", "6432")) + DB = dict( - host=os.environ.get("PG_HOST", "10.10.0.2"), - port=int(os.environ.get("PG_PORT", "6432")), + host=_pgh, + port=_pgp, dbname=os.environ.get("PG_DB", "rinet_v3"), user=os.environ.get("PG_USER", "rinet"), password=os.environ.get("PG_PASS", "R1net2026!SecureDB#v7"), diff --git a/erp/putni_nalozi.py b/erp/putni_nalozi.py index d36124c..50629ab 100644 --- a/erp/putni_nalozi.py +++ b/erp/putni_nalozi.py @@ -20,36 +20,38 @@ DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet", password="R1net2026!SecureDB#v7") # === HR pravilnik 2025 — dnevnice === -# Domaće: 30 € (puna) za put preko 8h, 15 € za 5–8h, 0 € za <5h -# Izvor: NN, Pravilnik o porezu na dohodak (neoporezivi iznosi 2025). -# (Service constants — bez ikakve hardkodirane informacije iz prompta; gornje granice neoporezivih dnevnica.) -DNEVNICA_DOM_FULL = 30.00 # EUR -DNEVNICA_DOM_HALF = 15.00 # EUR -KM_RATE_DEFAULT = 0.50 # EUR/km (uvjet: vlastiti automobil; granica neopor. 2025) +# Domaće: 26.54 € (puna) za put >8h, 13.27 € za 5-8h, 0 € za <5h. +# Izvor: NN — Pravilnik o porezu na dohodak, neoporezivi iznosi 2025 (200 kn ≈ 26.54 €). +DNEVNICA_DOM_FULL = 26.54 # EUR +DNEVNICA_DOM_HALF = 13.27 # EUR +KM_RATE_DEFAULT = 0.50 # EUR/km (vlastiti automobil) -# Inozemne dnevnice (gornja granica neoporezivog iznosa po Uredbi o izdacima službenih putovanja) -# Izvor: Uredba o izdacima za službena putovanja u inozemstvo (HR, 2024 ažurirano) +# Inozemne dnevnice (Uredba o izdacima službenih putovanja u inozemstvo). DNEVNICE_INO = { - "Slovenija": 70.00, - "Italija": 70.00, - "Austrija": 70.00, - "Mađarska": 50.00, - "Hungary": 50.00, - "Bosna i Hercegovina": 50.00, - "Srbija": 50.00, - "Crna Gora": 50.00, - "Njemačka": 80.00, - "Germany": 80.00, - "Francuska": 80.00, - "France": 80.00, - "Belgija": 80.00, - "Nizozemska": 80.00, - "Velika Britanija": 90.00, - "UK": 90.00, - "Švicarska": 100.00, - "Switzerland": 100.00, - "SAD": 100.00, - "USA": 100.00, + "Italija": 35.00, + "Italy": 35.00, + "Slovenija": 30.00, + "Slovenia": 30.00, + "Austrija": 35.00, + "Austria": 35.00, + "Mađarska": 30.00, + "Madarska": 30.00, + "Hungary": 30.00, + "Bosna i Hercegovina": 30.00, + "BiH": 30.00, + "Bosnia": 30.00, + "Srbija": 30.00, + "Serbia": 30.00, + "Crna Gora": 30.00, + "Montenegro": 30.00, + "Njemačka": 50.00, + "Germany": 50.00, + "Francuska": 50.00, + "France": 50.00, + "Švicarska": 60.00, + "Switzerland": 60.00, + "SAD": 70.00, + "USA": 70.00, } diff --git a/pgz_sport_api.py b/pgz_sport_api.py index 0c50b27..aa5eea0 100644 --- a/pgz_sport_api.py +++ b/pgz_sport_api.py @@ -1449,6 +1449,32 @@ def serve_admin(): 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("/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): diff --git a/routers/audit_seal_router.py b/routers/audit_seal_router.py new file mode 100644 index 0000000..3a12e44 --- /dev/null +++ b/routers/audit_seal_router.py @@ -0,0 +1,96 @@ +""" +audit_seal_router.py — HTTP surface for the Polygon PoS sealing module +Author: Damir Radulić (damir@rinet.one) / dradulic@outlook.com +Date: 2026-05-04 + +Endpoints (all under /sport/api): + +POST /audit/seal + body: { + action: "sufinanciranje.approved", + ref_type: "sufinanciranje", (optional) + ref_id: "2026-001", (string or number) + payload: { ... }, (sha256 computed server-side) + data_hash: "abc..." (optional — if you already have the hash) + } + returns the seal record (seal_id, tx_hash, polygonscan_url, status, ...). + +GET /audit/seal/list?action=&ref_type=&ref_id=&limit= + Recent seals for the audit-log UI. + +GET /audit/seal/{seal_id} + Single seal with on-chain receipt cross-check (if web3 wired up). + +The legacy hash-chain audit endpoints (/api/admin/audit-chain*) live in +pgz_sport_api.py and remain unchanged. +""" +from __future__ import annotations +import sys, os +from typing import Any, Optional + +from fastapi import APIRouter, Body, HTTPException, Header + +# blockchain.seal lives at /opt/pgz-sport/blockchain/seal.py +sys.path.insert(0, '/opt/pgz-sport') +from blockchain import seal as seal_mod # noqa: E402 + +router = APIRouter() + + +@router.post("/audit/seal") +def audit_seal(body: dict = Body(...), + x_user_email: Optional[str] = Header(default=None), + x_user_id: Optional[int] = Header(default=None)): + """Seal an audit event to Polygon PoS (or queue for later if no key).""" + if not isinstance(body, dict): + raise HTTPException(400, "JSON body required") + action = (body.get('action') or '').strip() + if not action: + raise HTTPException(400, "action is required") + + payload = body.get('payload') + data_hash = (body.get('data_hash') or '').strip().lower().lstrip('0x') + if not data_hash: + if payload is None: + raise HTTPException(400, "either data_hash or payload required") + data_hash = seal_mod.hash_payload(payload) + + ref_id = body.get('ref_id') + if ref_id is None: + raise HTTPException(400, "ref_id is required") + ref_type = body.get('ref_type') + + try: + result = seal_mod.seal_to_polygon( + data_hash=data_hash, + ref_id=str(ref_id), + action=action, + ref_type=ref_type, + payload=payload, + user_id=x_user_id, + user_email=x_user_email, + ) + except ValueError as e: + raise HTTPException(400, str(e)) + return result + + +@router.get("/audit/seal/list") +def audit_seal_list(action: Optional[str] = None, + ref_type: Optional[str] = None, + ref_id: Optional[str] = None, + limit: int = 50): + rows = seal_mod.list_seals(action=action, ref_type=ref_type, + ref_id=ref_id, limit=limit) + return {'count': len(rows), 'rows': rows, + 'wallet': seal_mod.POLYGON_WALLET, + 'chain_id': seal_mod.POLYGON_CHAIN_ID, + 'live': seal_mod.HAS_WEB3 and bool(seal_mod.POLYGON_PRIVKEY)} + + +@router.get("/audit/seal/{seal_id}") +def audit_seal_get(seal_id: str): + row = seal_mod.verify_seal(seal_id) + if not row: + raise HTTPException(404, "seal not found") + return row diff --git a/static/admin.html b/static/admin.html index b35bd76..7f0bcff 100644 --- a/static/admin.html +++ b/static/admin.html @@ -224,6 +224,88 @@ td.num { font-family: 'JetBrains Mono', monospace; text-align: right; }
+ + +
+

📷 OCR — Skeniraj račun (gorivo, cestarina, hotel…)

+
+
+
Povuci PDF/JPG/PNG ovdje ili klikni za odabir
+
Tesseract OCR + DeepSeek V3 izvuče izdavatelja, OIB, datum, iznos, PDV, IBAN, stavke
+ +
+
+ +
+ + +
+

🚗 Novi putni nalog (HR pravilnik 2025)

+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ Unesi datume za live obračun dnevnica… +
+
+ + +
+
+

Računi

BrojDobavljačKlubIznosStatusDatum
@@ -469,6 +551,190 @@ function activateTab(name) { if (name === 'reports') loadReports(); } +// === M5: OCR upload (drag-and-drop) === +const ERP_API = '/api/erp'; + +async function ocrLoadKlubSelectors() { + const sels = [document.getElementById('oc_klub'), document.getElementById('pn_klub')].filter(Boolean); + if (!sels.length) return; + // Use main API for klubovi list (admin-scoped) + const d = await fetch(`/api/klubovi?limit=400`).then(r => r.json()).catch(() => null); + if (!d) return; + const arr = Array.isArray(d) ? d : (d.rows || d.items || []); + const opts = '' + arr.map(k => ``).join(''); + sels.forEach(s => { if (s) s.innerHTML = opts; }); +} + +let ocrParsed = null; +let ocrUploadId = null; + +function ocrSetStatus(msg, color) { + const el = document.getElementById('ocrStatus'); + if (el) { el.textContent = msg || ''; el.style.color = color || 'var(--text-2)'; } +} + +async function ocrHandleFile(file) { + if (!file) return; + ocrSetStatus('⏳ Učitavam datoteku…', 'var(--yellow)'); + const klubVal = document.getElementById('oc_klub')?.value || ''; + const fd = new FormData(); + fd.append('file', file); + if (klubVal) fd.append('klub_id', klubVal); + fd.append('tenant_id', currentTenant || 1); + fd.append('invoice_kind', document.getElementById('oc_kind')?.value || 'ostalo'); + let r = await fetch(`${ERP_API}/ocr/upload`, {method: 'POST', body: fd}); + if (!r.ok) { ocrSetStatus('❌ Upload pao: ' + r.status, 'var(--red)'); return; } + const j = await r.json(); + ocrUploadId = j.upload_id; + ocrSetStatus(`✓ Uploaded (id=${ocrUploadId}, ${j.size} B). Pokrećem OCR + LLM ekstrakciju…`, 'var(--accent)'); + + const fd2 = new FormData(); + fd2.append('upload_id', ocrUploadId); + fd2.append('use_llm', 'true'); + r = await fetch(`${ERP_API}/ocr/parse`, {method: 'POST', body: fd2}); + if (!r.ok) { ocrSetStatus('❌ Parse pao: ' + r.status, 'var(--red)'); return; } + const p = await r.json(); + if (!p.ok) { ocrSetStatus('❌ ' + (p.error || 'Parse fail'), 'var(--red)'); return; } + ocrParsed = p.extracted || {}; + document.getElementById('oc_vendor_name').value = ocrParsed.vendor_name || ''; + document.getElementById('oc_vendor_oib').value = ocrParsed.vendor_oib || ''; + document.getElementById('oc_invoice_no').value = ocrParsed.invoice_no || ''; + document.getElementById('oc_invoice_date').value = ocrParsed.invoice_date || ''; + document.getElementById('oc_amount_net').value = ocrParsed.amount_net ?? ''; + document.getElementById('oc_amount_vat').value = ocrParsed.amount_vat ?? ''; + document.getElementById('oc_amount_gross').value = ocrParsed.amount_gross ?? ''; + document.getElementById('oc_vat_rate').value = ocrParsed.vat_rate ?? ''; + document.getElementById('oc_iban').value = ocrParsed.iban || ''; + document.getElementById('oc_kind').value = ocrParsed.category || 'ostalo'; + document.getElementById('oc_currency').value = ocrParsed.currency || 'EUR'; + document.getElementById('oc_description').value = ocrParsed.description || ''; + document.getElementById('oc_raw').textContent = (p.raw_text_preview || '').slice(0, 4000); + document.getElementById('ocrResult').style.display = 'block'; + ocrSetStatus(`✓ OCR ${p.ocr_method} (${p.raw_chars} znakova). Provjeri polja i klikni "Spremi račun".`, 'var(--green)'); +} + +function ocrInitDrop() { + const drop = document.getElementById('ocrDrop'); + const inp = document.getElementById('ocrFile'); + if (!drop || !inp) return; + drop.addEventListener('click', () => inp.click()); + inp.addEventListener('change', e => { if (e.target.files[0]) ocrHandleFile(e.target.files[0]); }); + ['dragenter','dragover'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.style.borderColor = 'var(--accent)'; })); + ['dragleave','drop'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.style.borderColor = 'var(--border)'; })); + drop.addEventListener('drop', e => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) ocrHandleFile(f); }); + document.getElementById('ocCancel')?.addEventListener('click', () => { + document.getElementById('ocrResult').style.display = 'none'; + ocrParsed = null; ocrUploadId = null; ocrSetStatus(''); + inp.value = ''; + }); + document.getElementById('ocSave')?.addEventListener('click', async () => { + const klub = document.getElementById('oc_klub').value; + if (!klub) { document.getElementById('ocSaveStatus').textContent = 'Odaberi klub'; return; } + const body = { + klub_id: parseInt(klub), + tenant_id: currentTenant || 1, + upload_id: ocrUploadId, + invoice_kind: document.getElementById('oc_kind').value || 'ostalo', + invoice_no: document.getElementById('oc_invoice_no').value, + vendor_name: document.getElementById('oc_vendor_name').value, + vendor_oib: document.getElementById('oc_vendor_oib').value, + invoice_date: document.getElementById('oc_invoice_date').value, + amount_net: parseFloat(document.getElementById('oc_amount_net').value) || null, + amount_vat: parseFloat(document.getElementById('oc_amount_vat').value) || null, + amount_gross: parseFloat(document.getElementById('oc_amount_gross').value), + vat_rate: parseFloat(document.getElementById('oc_vat_rate').value) || null, + iban_to: document.getElementById('oc_iban').value || null, + currency: document.getElementById('oc_currency').value || 'EUR', + category: document.getElementById('oc_kind').value || 'ostalo', + description: document.getElementById('oc_description').value || null, + }; + document.getElementById('ocSaveStatus').textContent = '⏳ Spremam…'; + const r = await fetch(`${ERP_API}/invoices`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}); + const j = await r.json(); + if (j.ok) { + document.getElementById('ocSaveStatus').textContent = `✓ Spremljen kao #${j.invoice.id}`; + document.getElementById('ocSaveStatus').style.color = 'var(--green)'; + setTimeout(() => { document.getElementById('ocrResult').style.display = 'none'; loadERP(); }, 1500); + } else { + document.getElementById('ocSaveStatus').textContent = '❌ ' + (j.detail || 'Greška'); + document.getElementById('ocSaveStatus').style.color = 'var(--red)'; + } + }); +} + +// === M6: Putni nalog form with live dnevnice preview === +let pnPreviewTimer = null; +async function pnRefreshPreview() { + const df = document.getElementById('pn_from')?.value; + const dt = document.getElementById('pn_to')?.value; + const country = document.getElementById('pn_country')?.value || 'Hrvatska'; + const km = parseFloat(document.getElementById('pn_km')?.value || 0); + const km_rate = parseFloat(document.getElementById('pn_kmrate')?.value || 0.5); + const tgt = document.getElementById('pn_preview'); + if (!df || !dt) { if (tgt) tgt.textContent = 'Unesi datume za live obračun dnevnica…'; return; } + const url = `${ERP_API}/putni-nalog/dnevnice/preview?date_from=${encodeURIComponent(df)}&date_to=${encodeURIComponent(dt)}&country=${encodeURIComponent(country)}&km=${km}&km_rate=${km_rate}`; + const r = await fetch(url).then(r => r.json()).catch(() => null); + if (!r || !r.ok) { tgt.textContent = '⚠ Neuspješan obračun'; return; } + const d = r.preview; + tgt.innerHTML = ` +
+
Sati
${d.hours}h
+
Pune dnevnice
${d.days_full} × €${d.rate_full}
+
Pola dnevnica
${d.days_half} × €${d.rate_half}
+
Dnevnice ukupno
€${d.dnevnica_amount_total}
+
Kilometara
${d.km_driven} km
+
Kilometrina
€${d.km_amount}
+
Zemlja
${d.country}
+
PROCJENA UKUPNO
€${d.total_estimated}
+
`; +} + +function pnInit() { + ['pn_from','pn_to','pn_country','pn_km','pn_kmrate'].forEach(id => { + const el = document.getElementById(id); + if (el) el.addEventListener('input', () => { + clearTimeout(pnPreviewTimer); + pnPreviewTimer = setTimeout(pnRefreshPreview, 250); + }); + }); + document.getElementById('pnSave')?.addEventListener('click', async () => { + const klub = document.getElementById('pn_klub').value; + if (!klub) { document.getElementById('pnSaveStatus').textContent = 'Odaberi klub'; return; } + const body = { + klub_id: parseInt(klub), + tenant_id: currentTenant || 1, + voditelj_ime: document.getElementById('pn_voditelj').value, + putnici: (document.getElementById('pn_putnici').value || '').split(',').map(s => s.trim()).filter(Boolean), + svrha: document.getElementById('pn_svrha').value, + od_grada: document.getElementById('pn_od').value, + do_grada: document.getElementById('pn_do').value, + datum_polaska: document.getElementById('pn_from').value, + datum_povratka: document.getElementById('pn_to').value, + country: document.getElementById('pn_country').value, + vehicle_type: document.getElementById('pn_vehicle').value, + registracija_vozila: document.getElementById('pn_plate').value, + kilometara: parseFloat(document.getElementById('pn_km').value) || 0, + km_rate: parseFloat(document.getElementById('pn_kmrate').value) || 0.5, + }; + document.getElementById('pnSaveStatus').textContent = '⏳ Spremam…'; + const r = await fetch(`${ERP_API}/putni-nalog`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}); + const j = await r.json(); + if (j.ok) { + const pn = j.putni_nalog; + document.getElementById('pnSaveStatus').innerHTML = `✓ Putni nalog #${pn.id} kreiran (€${pn.cost_total})`; + document.getElementById('pnSaveStatus').style.color = 'var(--green)'; + loadERP(); + } else { + document.getElementById('pnSaveStatus').textContent = '❌ ' + (j.detail || 'Greška'); + document.getElementById('pnSaveStatus').style.color = 'var(--red)'; + } + }); +} + +ocrInitDrop(); +pnInit(); +ocrLoadKlubSelectors(); + // Init $$('.nav-item').forEach(n => n.addEventListener('click', () => activateTab(n.dataset.tab))); diff --git a/static/admin_users.html b/static/admin_users.html new file mode 100644 index 0000000..2133af1 --- /dev/null +++ b/static/admin_users.html @@ -0,0 +1,765 @@ + + + + + +PGŽ Sport · Admin · Korisnici + + + + + + +
+ + +
+
+ +
+
+

Najnovije akcije zadnjih 10

+
VrijemeKorisnikAkcijaResursIP
+
+
+ +
+ +
+ + + + + +
+
+

Lista korisnika

+ + + +
IDE-mailImeUlogaKlub / SavezStatusZadnja prijavaAkcije
+
+
+ +
+ +

Hijerarhija

+
IDSlugNazivTipOIBStatus
+
+

Savezi

+
IDNazivSportPredsjednikTajnik
+
+

Klubovi

+
IDNazivSportGradOIBSavez ID
+
+
+ +
+ +
+ + + + +
+

Događaji

+
VrijemeUserAkcijaResursIPUAMeta
+
+
+ +
+ +
+

Zaključani / failed-login računi

+
E-mailUlogaPokušajaZaključan doAkcije
+
+

Sesije

+
Sesije se prate per-user kroz audit log (login.ok / logout / auth.refresh)
+
+
+ +
+ +
+

Zahtjevi za brisanje Art. 17

+
IDKorisnikE-mailRazlogStatusZatraženoAkcije
+
+

Pristanak na kolačiće moja povijest

+
VrijemeSessionNužniAnalitičkiMarketingIPVerzija
+
+
+
+
+ + + + + + + +
+ + + + diff --git a/static/crm.html b/static/crm.html new file mode 100644 index 0000000..dcac6b7 --- /dev/null +++ b/static/crm.html @@ -0,0 +1,974 @@ + + + + + +PGŽ Sport — CRM (Članarine • Liječnički • Obrasci) + + + + +
+ +
·
+
CRM — Članarine • Liječnički • Obrasci
+
+ Round 3 / CC5 + ← portal + app → +
+
+ +
+
€ Članarine
+
⚕ Liječnički pregledi
+
📝 Obrasci
+
+ +
+
+ + +
+ + + +
+ + + + + diff --git a/static/erp.html b/static/erp.html new file mode 100644 index 0000000..75febb5 --- /dev/null +++ b/static/erp.html @@ -0,0 +1,386 @@ + + + + + +PGŽ Sport · ERP — OCR + Putni nalozi + + + + + + +
+ +
+
+

Skeniraj račun (OCR)

+ Tesseract + DeepSeek V3 · /api/erp +
+ + +
+
+

📷 Drag-and-drop OCR (PDF / JPG / PNG)

+
+
+
Povuci datoteku ovdje ili klikni za odabir
+
Tesseract OCR (hrv+eng) + DeepSeek V3 LLM ekstrakcija polja
+ +
+
+ + +
+
+ + +
+
+

Računi (svi klubovi)

+
#VrstaBrojDobavljačOIBKlubBruttoStatusDatum
+
+
+ + +
+
+

🚗 Novi putni nalog (HR pravilnik 2025)

+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ Unesi datume za live obračun dnevnica… +
+
+ + +
+

+ HR pravilnik 2025: domaće 26.54 € (>8h), 13.27 € (5–8h), 0 € (<5h). Inozemne dnevnice po zemlji + (Italija/Austrija 35 €, Slovenija/Mađarska/BiH/Srbija 30 €). Kilometrina vlastitim automobilom 0.50 €/km. +

+
+
+ + +
+
+

Lista putnih naloga

+
#KlubDestinacijaPolazakPovratakDnevniceTransportTotalStatus
+
+
+ +
+
+ + + + diff --git a/static/login.html b/static/login.html new file mode 100644 index 0000000..14c8a5b --- /dev/null +++ b/static/login.html @@ -0,0 +1,538 @@ + + + + + +PGŽ Sport · Prijava + + + + + + + +
+
+
P
+
+

PGŽ Sport

+
ERP/CRM Platforma
+
+
+
+

Operativna platforma za sport u Primorsko-goranskoj županiji.

+

Jedinstvena baza klubova, saveza i sportaša. Računovodstvo, članarine, liječnički pregledi, sufinanciranja — sve na jednom mjestu.

+
+
Multi-tenant arhitektura — PGŽ, savezi, klubovi sa svojim view-om
+
OCR za račune, automatska ekstrakcija polja, putni nalozi
+
Članarine s HUB-3 uplatnicama i blockchain audit log
+
GDPR-compliant (Art. 17, 20) · 2FA · audit svih akcija
+
+
+ +
+ +
+
+

Prijava

+
Unesite svoje podatke za pristup platformi.
+ +
+ +
+
+ + +
+
+ + +
+
+ + Zaboravljena lozinka? +
+ +
+ +
Demo računi
+
+
+ PGŽ admin · damir@pgz.hr / PGZ2026! +
+
+ Savez admin · pero@atletika.pgz.hr +
+
+ Klub admin · ana@akkvarner.hr +
+
+ + +
+
+ + + + + + + diff --git a/static/sport2.html b/static/sport2.html index 607e86a..b8851f7 100644 --- a/static/sport2.html +++ b/static/sport2.html @@ -255,6 +255,7 @@ a.tag:hover,.tag[onclick]:hover{transform:translateY(-1px);filter:brightness(1.1
+
@@ -280,7 +281,8 @@ const NAV_ITEMS = [ {id:'objekti', ic:'\u{1F4CD}', label:'Objekti'}, {id:'manifestacije', ic:'\u{1F4C5}', label:'Manifestacije'}, {id:'mreza', ic:'\u{1F578}', label:'Mreža'}, - {id:'forenzika', ic:'⚠', label:'Forenzika'} + {id:'forenzika', ic:'⚠', label:'Forenzika'}, + {id:'audit', ic:'\u{1F512}', label:'Audit log'} ]; const SECTION_TITLES = { dashboard: ['Dashboard', 'Pregled stanja PGŽ Sporta'], @@ -291,7 +293,8 @@ const SECTION_TITLES = { objekti: ['Sportski objekti', 'Geocodirana infrastruktura'], manifestacije: ['Manifestacije', 'Sportski događaji'], mreza: ['Mreža', 'Force-directed graf entiteta i veza'], - forenzika: ['Forenzika', 'Kritični nalazi i alarmi'] + forenzika: ['Forenzika', 'Kritični nalazi i alarmi'], + audit: ['Audit log', 'Polygon PoS pečaćenje ključnih akcija'] }; const _cache = {savezi:null, klubovi:null, clanovi:null, objekti:null, manifestacije:null, sufin:null, dash:null};