diff --git a/_backups/app.html.cc3_pre_profile.1777934543 b/_backups/app.html.cc3_pre_profile.1777934543 new file mode 100644 index 0000000..213b2f9 --- /dev/null +++ b/_backups/app.html.cc3_pre_profile.1777934543 @@ -0,0 +1,1214 @@ + + + + + +PGŽ SPORT — Operativna aplikacija + + + + + + + + + +
+ + +
+
+
+
Dashboard
+
Pregled stanja
+
+
+
+
+
DR
+
+
Damir Radulić
+
PGŽ admin
+
+
+
+
+ +
+
Učitavanje...
+
+
+
+ + + + diff --git a/_backups/auth_v2.py.cc3_pre_avatar.1777934543 b/_backups/auth_v2.py.cc3_pre_avatar.1777934543 new file mode 100644 index 0000000..65b7ffe --- /dev/null +++ b/_backups/auth_v2.py.cc3_pre_avatar.1777934543 @@ -0,0 +1,455 @@ +#!/usr/bin/env python3 +# auth_v2.py — JWT auth backend with tenant_id, role, tier claims +# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04 +# Endpoints: /api/auth/login, /api/auth/refresh, /api/auth/logout, +# /api/auth/me, /api/auth/password/change, /api/auth/password/reset +""" +JWT claims: + sub int user id + email str + name str + tenant_id int|null pgz_sport.tenants.id (or null for super_admin) + tenant_type str pgz | savez | klub | global + tenant_scope dict {"klub_id": ..., "savez_id": ...} + role str user_type code (super_admin | pgz_admin | savez_admin | klub_admin | klub_clan | viewer ...) + tier int 0 = PGŽ, 1 = savez, 2 = klub + jti str token id (revocable via user_sessions) + iat / exp / nbf +""" + +import os, hashlib, secrets, json, time +from datetime import datetime, timedelta, timezone +from typing import Optional, Dict, List, Any + +import jwt as _jwt +import psycopg2, psycopg2.extras +from fastapi import APIRouter, HTTPException, Header, Depends, Request, Body +from pydantic import BaseModel, EmailStr + +try: + from passlib.hash import bcrypt as _bcrypt + HAS_BCRYPT = True +except Exception: + HAS_BCRYPT = False + +DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', + user='rinet', password='R1net2026!SecureDB#v7') + +# Persistent JWT secret — read from env, else stable file, else generated. +def _load_secret() -> str: + env_secret = os.environ.get("PGZ_JWT_SECRET") + if env_secret and len(env_secret) >= 32: + return env_secret + secret_file = "/opt/pgz-sport/auth/.jwt_secret" + try: + if os.path.exists(secret_file): + with open(secret_file) as f: + s = f.read().strip() + if len(s) >= 32: + return s + s = "rinet-pgz-" + secrets.token_urlsafe(48) + with open(secret_file, "w") as f: + f.write(s) + os.chmod(secret_file, 0o600) + return s + except Exception: + return "rinet-pgz-jwt-2026-fallback-" + hashlib.sha256(b"pgz-sport").hexdigest() + +JWT_SECRET = _load_secret() +JWT_ALG = "HS256" +ACCESS_TTL = timedelta(minutes=int(os.environ.get("PGZ_JWT_ACCESS_MIN", "30"))) +REFRESH_TTL = timedelta(days=int(os.environ.get("PGZ_JWT_REFRESH_DAYS", "7"))) + +router = APIRouter(prefix="/api/auth", tags=["auth_v2"]) + +# ─────────────────────────── DB helpers ─────────────────────────── +def _conn(): + return psycopg2.connect(**DB) + +def db_query(sql: str, params=()): + with _conn() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(sql, params) + if cur.description: return cur.fetchall() + return [] + +def db_one(sql: str, params=()): + rows = db_query(sql, params) + return rows[0] if rows else None + +def db_exec(sql: str, params=()): + with _conn() as c: + cur = c.cursor() + cur.execute(sql, params) + if cur.description: + r = cur.fetchone() + return r[0] if r else None + c.commit() + +# ─────────────────────────── Password helpers ─────────────────────────── +def _sha256(pw: str) -> str: + return hashlib.sha256(pw.encode()).hexdigest() + +def hash_password(pw: str) -> str: + if HAS_BCRYPT: + return _bcrypt.using(rounds=12).hash(pw) + return _sha256(pw) + +def verify_password(pw: str, hashed: Optional[str]) -> bool: + if not hashed: return False + h = hashed.strip() + if h.startswith("$2") and HAS_BCRYPT: + try: + return _bcrypt.verify(pw, h) + except Exception: + return False + return h == _sha256(pw) + +def needs_rehash(hashed: Optional[str]) -> bool: + if not hashed: return True + return HAS_BCRYPT and not hashed.startswith("$2") + +# ─────────────────────────── Tenant resolution ─────────────────────────── +PGZ_USER_TYPES = {"super_admin", "pgz_admin", "pgz_user", "pgz_finance", "pgz_zzjz"} +SAVEZ_USER_TYPES = {"savez_admin", "savez_user"} +KLUB_USER_TYPES = {"klub_admin", "klub_user", "klub_trener", "klub_clan"} + +def _tier_for(user_type: str) -> int: + ut = (user_type or "").lower() + if ut in PGZ_USER_TYPES: return 0 + if ut in SAVEZ_USER_TYPES: return 1 + if ut in KLUB_USER_TYPES: return 2 + return 9 # unknown / viewer / guest + +def _resolve_tenant(u: Dict) -> Dict: + """Resolve tenant_id + tenant_type from a user row.""" + ut = (u.get("user_type") or "").lower() + klub_id = u.get("klub_id") + savez_id = u.get("savez_id") + if ut in PGZ_USER_TYPES: + row = db_one("SELECT id, slug, display_name FROM pgz_sport.tenants WHERE slug='pgz' LIMIT 1") + return { + "tenant_id": row["id"] if row else None, + "tenant_type": "pgz", + "tenant_name": row["display_name"] if row else "PGŽ", + "tenant_scope": {"klub_id": None, "savez_id": None}, + } + if ut in SAVEZ_USER_TYPES and savez_id: + return { + "tenant_id": savez_id, + "tenant_type": "savez", + "tenant_name": (db_one("SELECT naziv FROM pgz_sport.savezi WHERE id=%s",(savez_id,)) or {}).get("naziv"), + "tenant_scope": {"klub_id": None, "savez_id": savez_id}, + } + if ut in KLUB_USER_TYPES and klub_id: + return { + "tenant_id": klub_id, + "tenant_type": "klub", + "tenant_name": (db_one("SELECT naziv FROM pgz_sport.klubovi WHERE id=%s",(klub_id,)) or {}).get("naziv"), + "tenant_scope": {"klub_id": klub_id, "savez_id": savez_id}, + } + # super_admin without context + if ut == "super_admin": + return {"tenant_id": None, "tenant_type": "global", + "tenant_name": "Global", "tenant_scope": {"klub_id": None, "savez_id": None}} + return {"tenant_id": None, "tenant_type": "viewer", + "tenant_name": None, "tenant_scope": {"klub_id": klub_id, "savez_id": savez_id}} + +# ─────────────────────────── JWT issue / verify ─────────────────────────── +def _now() -> datetime: return datetime.now(timezone.utc) + +def _new_jti() -> str: return secrets.token_urlsafe(16) + +def make_access_token(u: Dict, jti: str) -> str: + tenant = _resolve_tenant(u) + tier = _tier_for(u.get("user_type") or "") + now = _now() + payload = { + "sub": str(u["id"]), + "uid": u["id"], + "email": u["email"], + "name": u.get("full_name") or ((u.get("ime") or "") + " " + (u.get("prezime") or "")).strip() or u["email"], + "tenant_id": tenant["tenant_id"], + "tenant_type": tenant["tenant_type"], + "tenant_name": tenant["tenant_name"], + "tenant_scope": tenant["tenant_scope"], + "role": u.get("user_type") or "viewer", + "tier": tier, + "jti": jti, + "typ": "access", + "iat": int(now.timestamp()), + "nbf": int(now.timestamp()), + "exp": int((now + ACCESS_TTL).timestamp()), + } + return _jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG) + +def make_refresh_token(uid: int, jti: str) -> str: + now = _now() + return _jwt.encode({ + "sub": str(uid), "uid": uid, "jti": jti, "typ": "refresh", + "iat": int(now.timestamp()), + "exp": int((now + REFRESH_TTL).timestamp()), + }, JWT_SECRET, algorithm=JWT_ALG) + +def decode_token(token: str) -> Dict: + try: + return _jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG]) + except _jwt.ExpiredSignatureError: + raise HTTPException(401, "Token expired") + except Exception as e: + raise HTTPException(401, f"Invalid token: {e}") + +def _record_session(uid: int, jti: str, expires: datetime, ip: str = None, ua: str = None): + th = hashlib.sha256(jti.encode()).hexdigest() + db_exec("""INSERT INTO pgz_sport.user_sessions + (user_id, token_hash, device_info, ip_address, expires_at, revoked) + VALUES (%s,%s,%s,%s::inet,%s,false) + ON CONFLICT (token_hash) DO NOTHING""", + (uid, th, ua, ip, expires)) + +def _is_revoked(jti: str) -> bool: + th = hashlib.sha256(jti.encode()).hexdigest() + r = db_one("SELECT revoked FROM pgz_sport.user_sessions WHERE token_hash=%s", (th,)) + if not r: return False + return bool(r.get("revoked")) + +def _revoke_jti(jti: str): + th = hashlib.sha256(jti.encode()).hexdigest() + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE token_hash=%s", (th,)) + +# ─────────────────────────── current_user dep ─────────────────────────── +def _extract_token(authorization: Optional[str]) -> Optional[str]: + if not authorization: return None + return authorization.replace("Bearer ", "").strip() or None + +def get_current_user(authorization: Optional[str] = Header(None)) -> Optional[Dict]: + token = _extract_token(authorization) + if not token: return None + try: + payload = decode_token(token) + except HTTPException: + return None + if payload.get("typ") not in (None, "access"): + return None + if _is_revoked(payload.get("jti","")): + return None + uid = payload.get("uid") or int(payload.get("sub", 0) or 0) + u = db_one("""SELECT id, email, full_name, ime, prezime, user_type, + klub_id, savez_id, status, aktivan, must_change_pwd + FROM pgz_sport.users WHERE id=%s""", (uid,)) + if not u or u.get("status") != "active" or not u.get("aktivan", True): + return None + u["_jwt"] = payload + u["_token"] = token + return u + +def require_user(user = Depends(get_current_user)) -> Dict: + if not user: + raise HTTPException(401, "Authentication required") + return user + +def require_role(roles: List[str]): + def dep(user = Depends(require_user)): + if user.get("user_type") not in roles: + raise HTTPException(403, f"Forbidden — required: {','.join(roles)}") + return user + return dep + +# ─────────────────────────── Audit ─────────────────────────── +def audit(user_id: Optional[int], action: str, resource_type: str = None, + resource_id: int = None, meta: Dict = None, ip: str = None, ua: str = None): + try: + db_exec("""INSERT INTO pgz_sport.audit_events + (user_id, action, resource_type, resource_id, meta, ip_address, user_agent) + VALUES (%s,%s,%s,%s,%s::jsonb,%s::inet,%s)""", + (user_id, action, resource_type, resource_id, + json.dumps(meta or {}), ip, ua)) + except Exception as e: + print(f"[AUDIT WARN] {e}") + +def _client(req: Request): + ip = (req.headers.get("x-forwarded-for") or req.client.host or "").split(",")[0].strip() or None + ua = req.headers.get("user-agent") + return ip, ua + +# ─────────────────────────── Schemas ─────────────────────────── +class LoginReq(BaseModel): + email: str + password: str + +class RefreshReq(BaseModel): + refresh_token: str + +class ChangePwdReq(BaseModel): + old_password: Optional[str] = None + new_password: str + +class ResetPwdReq(BaseModel): + email: str + +# ─────────────────────────── Endpoints ─────────────────────────── +@router.post("/login") +def login(req: LoginReq, request: Request): + ip, ua = _client(request) + email = (req.email or "").lower().strip() + if not email or not req.password: + raise HTTPException(400, "Email i lozinka obavezni") + + u = db_one("""SELECT id, email, full_name, ime, prezime, password_hash, status, + user_type, klub_id, savez_id, aktivan, must_change_pwd, + failed_login_count, locked_until + FROM pgz_sport.users WHERE LOWER(email)=%s""", (email,)) + if not u: + audit(None, "login.fail", meta={"email": email, "reason": "no_user"}, ip=ip, ua=ua) + raise HTTPException(401, "Neispravni podaci") + if u.get("locked_until"): + lu = u["locked_until"] + if lu.tzinfo is None: lu = lu.replace(tzinfo=timezone.utc) + if lu > _now(): + audit(u["id"], "login.locked", ip=ip, ua=ua) + raise HTTPException(423, "Račun privremeno zaključan") + if u.get("status") != "active" or not u.get("aktivan", True): + audit(u["id"], "login.fail", meta={"reason":"inactive"}, ip=ip, ua=ua) + raise HTTPException(403, "Račun nije aktivan") + if not verify_password(req.password, u.get("password_hash")): + db_exec("""UPDATE pgz_sport.users + SET failed_login_count = COALESCE(failed_login_count,0)+1, + locked_until = CASE WHEN COALESCE(failed_login_count,0)+1>=5 + THEN now()+interval '15 minutes' ELSE locked_until END + WHERE id=%s""", (u["id"],)) + audit(u["id"], "login.fail", meta={"reason":"bad_password"}, ip=ip, ua=ua) + raise HTTPException(401, "Neispravni podaci") + + # opportunistic rehash to bcrypt + if needs_rehash(u.get("password_hash")): + try: + db_exec("UPDATE pgz_sport.users SET password_hash=%s WHERE id=%s", + (hash_password(req.password), u["id"])) + except Exception: pass + + db_exec("""UPDATE pgz_sport.users + SET failed_login_count=0, locked_until=NULL, last_login=now() + WHERE id=%s""", (u["id"],)) + + jti = _new_jti() + rjti = _new_jti() + access = make_access_token(u, jti) + refresh = make_refresh_token(u["id"], rjti) + _record_session(u["id"], jti, _now() + ACCESS_TTL, ip=ip, ua=ua) + _record_session(u["id"], rjti, _now() + REFRESH_TTL, ip=ip, ua=(ua or "") + " [refresh]") + audit(u["id"], "login.ok", ip=ip, ua=ua) + + tenant = _resolve_tenant(u) + return { + "access_token": access, + "refresh_token": refresh, + "token_type": "Bearer", + "expires_in": int(ACCESS_TTL.total_seconds()), + "user": { + "id": u["id"], "email": u["email"], + "full_name": u.get("full_name") or (u.get("ime","") + " " + u.get("prezime","")).strip(), + "role": u.get("user_type"), "tier": _tier_for(u.get("user_type") or ""), + "must_change_pwd": bool(u.get("must_change_pwd")), + **tenant, + }, + } + +@router.post("/refresh") +def refresh(req: RefreshReq, request: Request): + payload = decode_token(req.refresh_token) + if payload.get("typ") != "refresh": + raise HTTPException(401, "Invalid refresh token") + if _is_revoked(payload.get("jti","")): + raise HTTPException(401, "Refresh token revoked") + uid = payload.get("uid") or int(payload.get("sub", 0) or 0) + u = db_one("""SELECT id, email, full_name, ime, prezime, user_type, + klub_id, savez_id, status, aktivan, must_change_pwd + FROM pgz_sport.users WHERE id=%s""", (uid,)) + if not u or u.get("status") != "active" or not u.get("aktivan", True): + raise HTTPException(401, "User inactive") + ip, ua = _client(request) + new_jti = _new_jti() + access = make_access_token(u, new_jti) + _record_session(u["id"], new_jti, _now() + ACCESS_TTL, ip=ip, ua=ua) + audit(u["id"], "auth.refresh", ip=ip, ua=ua) + return {"access_token": access, "token_type": "Bearer", + "expires_in": int(ACCESS_TTL.total_seconds())} + +@router.post("/logout") +def logout(request: Request, user = Depends(require_user)): + jti = (user.get("_jwt") or {}).get("jti") + if jti: _revoke_jti(jti) + # Also revoke refresh tokens for this user (best-effort) + db_exec("""UPDATE pgz_sport.user_sessions SET revoked=true + WHERE user_id=%s AND device_info LIKE %s""", + (user["id"], "%[refresh]%")) + ip, ua = _client(request) + audit(user["id"], "logout", ip=ip, ua=ua) + return {"status": "ok"} + +@router.get("/me") +def me(user = Depends(require_user)): + enriched = db_one("""SELECT id, email, full_name, ime, prezime, user_type, + klub_id, savez_id, must_change_pwd, aktivan, status, + last_login, oib, telefon, phone, preferred_language, created_at + FROM pgz_sport.users WHERE id=%s""", (user["id"],)) + if not enriched: + raise HTTPException(404, "User not found") + tenant = _resolve_tenant(enriched) + roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id + FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id + WHERE ur.user_id=%s AND ur.active=true""", (user["id"],)) + return {**enriched, + "tier": _tier_for(enriched.get("user_type") or ""), + "must_change_pwd": bool(enriched.get("must_change_pwd")), + **tenant, "roles": roles} + +@router.post("/password/change") +def change_password(req: ChangePwdReq, request: Request, user = Depends(require_user)): + if len(req.new_password) < 8: + raise HTTPException(400, "Lozinka mora imati barem 8 znakova") + cur = db_one("SELECT password_hash, must_change_pwd FROM pgz_sport.users WHERE id=%s", + (user["id"],)) + if not cur: raise HTTPException(404, "User not found") + if not cur.get("must_change_pwd"): + if not req.old_password: + raise HTTPException(400, "old_password obavezan") + if not verify_password(req.old_password, cur.get("password_hash")): + raise HTTPException(401, "Stara lozinka netočna") + db_exec("""UPDATE pgz_sport.users + SET password_hash=%s, must_change_pwd=false, updated_at=now() + WHERE id=%s""", (hash_password(req.new_password), user["id"])) + ip, ua = _client(request) + audit(user["id"], "password.change", ip=ip, ua=ua) + return {"status": "ok"} + +@router.post("/password/reset") +def password_reset(req: ResetPwdReq, request: Request): + """Issue a temporary password (admin-equivalent self-reset; logged).""" + email = (req.email or "").lower().strip() + u = db_one("SELECT id, email, aktivan FROM pgz_sport.users WHERE LOWER(email)=%s", + (email,)) + ip, ua = _client(request) + audit(u["id"] if u else None, "password.reset.request", + meta={"email": email, "found": bool(u)}, ip=ip, ua=ua) + # Generic response — do not leak which emails exist + return {"status": "ok", + "message": "Ako račun postoji, administrator će vam poslati instrukcije."} + +# ─────────────────────────── 2FA placeholders (TOTP) ─────────────────────────── +@router.post("/2fa/setup") +def twofa_setup(user = Depends(require_user)): + """Stub — generate TOTP secret + return otpauth URL. + Full TOTP verification will be added in M1.5.""" + secret = secrets.token_hex(20).upper() + db_exec("""ALTER TABLE pgz_sport.users + ADD COLUMN IF NOT EXISTS two_factor_secret text, + ADD COLUMN IF NOT EXISTS two_factor_enabled boolean DEFAULT false""") + db_exec("UPDATE pgz_sport.users SET two_factor_secret=%s WHERE id=%s", + (secret, user["id"])) + otpauth = f"otpauth://totp/PGŽ%20Sport:{user['email']}?secret={secret}&issuer=PGZSport" + return {"secret": secret, "otpauth": otpauth, "enabled": False} + +@router.post("/2fa/verify") +def twofa_verify(code: str = Body(..., embed=True), user = Depends(require_user)): + return {"status": "stub", "verified": False, "code_received": bool(code)} diff --git a/_backups/pgz_sport_api.py.cc3_pre_avatar.1777934543 b/_backups/pgz_sport_api.py.cc3_pre_avatar.1777934543 new file mode 100644 index 0000000..cb54f58 --- /dev/null +++ b/_backups/pgz_sport_api.py.cc3_pre_avatar.1777934543 @@ -0,0 +1,1728 @@ +#!/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}') + +# === Round 3 / CC6 — M11 Blockchain audit (Polygon PoS sealing) === +try: + from audit_seal_router import router as audit_seal_router + app.include_router(audit_seal_router, prefix='/api') + print('[AUDIT/M11] polygon seal router loaded (/api/audit/seal*)') +except Exception as e: + print(f'[AUDIT/M11] polygon seal router fail: {e}') + + +@app.get("/sport-3d") +@app.get("/3d") +def serve_sport_3d(): + p = HTML_DIR / "sport_3d.html" + if p.exists(): + return FileResponse(p) + return {"error": "sport_3d.html not found"} + +@app.get("/admin") +@app.get("/admin/") +def serve_admin(): + p = HTML_DIR / "admin.html" + if p.exists(): + return FileResponse(p) + return {"error": "admin.html not found"} + +@app.get("/erp") +@app.get("/erp/") +@app.get("/app/erp") +@app.get("/app/erp/") +def serve_erp(): + p = HTML_DIR / "erp.html" + if p.exists(): + return FileResponse(p) + return {"error": "erp.html not found"} + +@app.get("/crm") +@app.get("/crm/") +def serve_crm(): + p = HTML_DIR / "crm.html" + if p.exists(): + return FileResponse(p) + return {"error": "crm.html not found"} + +@app.get("/login") +@app.get("/login/") +def serve_login(): + p = HTML_DIR / "login.html" + if p.exists(): + return FileResponse(p) + return {"error": "login.html not found"} + +@app.get("/admin/users") +@app.get("/admin/users/") +def serve_admin_users(): + p = HTML_DIR / "admin_users.html" + if p.exists(): + return FileResponse(p) + return {"error": "admin_users.html not found"} + + +@app.get("/api/sportski-objekti") +def list_sportski_objekti(q=None,tip=None,grad=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR adresa ILIKE %s OR grad ILIKE %s)"); p+=["%"+q+"%"]*3 + if tip: w.append("tip ILIKE %s"); p.append("%"+tip+"%") + if grad: w.append("grad ILIKE %s"); p.append("%"+grad+"%") + rows=fetch("SELECT * FROM pgz_sport.sportski_objekti WHERE "+" AND ".join(w)+" ORDER BY grad,naziv",p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/clanovi-full") +def list_clanovi_full(q=None,hoo=None,reprezentativac=None,klub_id=None,limit=80,authorization=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(ime ILIKE %s OR prezime ILIKE %s OR klub_naziv_godisnjak ILIKE %s)"); p+=["%"+q+"%"]*3 + if hoo: w.append("hoo_kategorija=%s"); p.append(hoo) + if reprezentativac is not None: w.append("reprezentativac="+(("TRUE") if str(reprezentativac).lower()=="true" else "FALSE")) + if klub_id: w.append("klub_id=%s"); p.append(int(klub_id)) + lim=min(int(limit or 80),200) + sql="SELECT id,ime,prezime,oib,datum_rodenja,spol,sport,pozicija,reprezentativac,kategoriziran,stipendiran,kategorija_hoo,hoo_kategorija,aktivan,klub_naziv_godisnjak,slika_url,profile_url,hns_igrac_id,visina_cm,tezina_kg,broj_dresa,uloga,godisnjak_godine,godisnjak_prvi,godisnjak_zadnji,napomena FROM pgz_sport.clanovi WHERE "+" AND ".join(w)+" ORDER BY prezime,ime LIMIT "+str(lim) + rows=fetch(sql,p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/gradovi") +def list_gradovi(): + rows=fetch("SELECT DISTINCT grad FROM pgz_sport.klubovi WHERE aktivan=TRUE AND grad IS NOT NULL AND grad<>'' AND grad NOT SIMILAR TO '[0-9]+%%' ORDER BY grad",[]) + return [r["grad"] for r in rows] + +@app.get("/api/manifestacije-full") +def list_manifestacije_full(q=None,razina=None): + w=["aktivna=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR mjesto ILIKE %s)"); p+=["%"+q+"%"]*2 + rows=fetch("SELECT id,naziv,mjesto,organizator,razina,broj_ucesnika,godina_od,spol_kategorija,napomena,source_url FROM pgz_sport.manifestacije WHERE "+" AND ".join(w)+" ORDER BY naziv",p) + return {"count":len(rows),"rows":rows} + + + +# ── SUFINANCIRANJE-ALL v1.0 dradulic@outlook.com 2026-05-04 +@app.get("/api/sufinanciranje") +def list_sufinanciranje(q=None, godina=None, razina=None, sport=None, limit=500): + w=["iznos_eur > 0"]; p=[] + if q: w.append("(LOWER(korisnik) LIKE %s OR LOWER(sport) LIKE %s)"); p+=[f"%{q.lower()}%"]*2 + if godina: w.append("godina=%s"); p.append(int(godina)) + if razina: w.append("razina ILIKE %s"); p.append(f"%{razina}%") + if sport: w.append("sport ILIKE %s"); p.append(f"%{sport}%") + sql=f"SELECT korisnik,sport,iznos_eur,vrsta,razina,izvor,source_url,godina FROM pgz_sport.sufinanciranje_sport WHERE {' AND '.join(w)} ORDER BY iznos_eur DESC LIMIT {min(int(limit),1000)}" + rows=fetch(sql,p) + total=sum(float(r.get('iznos_eur') or 0) for r in rows) + years=sorted(set(r.get('godina') for r in rows if r.get('godina')),reverse=True) + return {"count":len(rows),"total":total,"years":years,"rows":rows} + + + +# ══════════════════════════════════════════════════════════════════ +# ERP PLATFORM ROUTES v2.0 — dradulic@outlook.com — 2026-05-04 +# ══════════════════════════════════════════════════════════════════ + +import hashlib + +def hash_pwd(pwd): return hashlib.sha256(pwd.encode()).hexdigest() + +def get_user(token): + if not token: return None + try: + payload = _jwt.decode(token.replace("Bearer ",""), JWT_SECRET, algorithms=["HS256"]) + uid = payload.get("uid") + if uid: + rows = fetch("SELECT * FROM pgz_sport.users WHERE id=%s AND aktivan=TRUE", [uid]) + return rows[0] if rows else None + return payload + except: return None + +# ── AUTH: Email/Password login — handled by auth.auth_v2 router (M1) ── + +# ── SPORTAS FULL PROFILE ───────────────────────────────────────── +@app.get("/api/sportas/{clan_id}/profil") +def sportas_profil(clan_id: int): + clan = fetch("""SELECT c.*, k.naziv AS klub_naziv_full, k.sport AS klub_sport, + k.grad, k.logo_url FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id]) + if not clan: raise HTTPException(404,"Nije pronađen") + c = clan[0] + sezona = fetch("""SELECT * FROM pgz_sport.clan_sezona WHERE clan_id=%s ORDER BY sezona DESC""", [clan_id]) + utakmice = fetch("""SELECT * FROM pgz_sport.utakmice_log WHERE clan_id=%s ORDER BY datum DESC LIMIT 30""", [clan_id]) + nagrade = fetch("SELECT * FROM pgz_sport.clan_nagrada WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + godisnjaci = fetch("SELECT * FROM pgz_sport.clan_godisnjak WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + stats = {} + if sezona: + stats = {"ukupno_nastupa": sum((r.get("nastupi") or 0) for r in sezona), + "ukupno_pogodaka": sum((r.get("pogoci") or 0) for r in sezona), + "ukupno_asistencija": sum((r.get("asistencije") or 0) for r in sezona), + "ukupno_zutih": sum((r.get("zuti_kartoni") or 0) for r in sezona), + "ukupno_crvenih": sum((r.get("crveni_kartoni") or 0) for r in sezona), + "ukupno_minuta": sum((r.get("minute_total") or 0) for r in sezona), + "sezone_aktivne": len(sezona)} + return {**c,"clan_sezona":sezona,"utakmice":utakmice,"nagrade":nagrade, + "godisnjaci":godisnjaci,"stats":stats} + +# ── SAVEZ FULL DETAIL ──────────────────────────────────────────── +@app.get("/api/savezi/{savez_id}/full") +def savez_full(savez_id: int): + s = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s",[savez_id]) + if not s: raise HTTPException(404,"Savez nije pronađen") + klubovi = fetch("""SELECT id,naziv,sport,grad,predsjednik,tajnik,nositelj_kvalitete, + aktivan,oib,razina,broj_clanova FROM pgz_sport.klubovi WHERE savez_id=%s AND aktivan=TRUE ORDER BY naziv""",[savez_id]) + clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija, + c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan + FROM pgz_sport.clanovi c WHERE c.savez_kod=(SELECT kod FROM pgz_sport.savezi WHERE id=%s) LIMIT 200""",[savez_id]) + if not clanovi: + clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija, + c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan + FROM pgz_sport.clanovi c WHERE c.aktivan=TRUE AND c.sport ILIKE %s LIMIT 200""", + [f'%{s[0].get("sport","") or ""}%']) + treneri = fetch("""SELECT * FROM pgz_sport.treneri WHERE savez_id=%s""",[savez_id]) + return {**s[0],"klubovi":klubovi,"clanovi":clanovi[:100],"treneri":treneri} + +# ── KLUB ERP: CLANARINE ────────────────────────────────────────── +@app.get("/api/klub/{klub_id}/clanarine") +def klub_clanarine(klub_id: int, godina: int=None, status: str=None): + w=["c.klub_id=%s"]; p=[klub_id] + if godina: w.append("cl.godina=%s"); p.append(godina) + if status: w.append("cl.status=%s"); p.append(status) + rows = fetch(f"""SELECT cl.*,c.ime,c.prezime,c.oib,c.spol,c.kategorija,c.hoo_kategorija,c.slika_url + FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE {" AND ".join(w)} ORDER BY cl.godina DESC, c.prezime""", p) + total_p = sum(float(r.get("iznos_placen") or 0) for r in rows) + total_d = sum(float(r.get("iznos_propisan") or 0) - float(r.get("iznos_placen") or 0) for r in rows) + return {"count":len(rows),"naplaceno":total_p,"dug":total_d,"rows":rows} + +# ── KLUB ERP: LIJECNICKI ───────────────────────────────────────── +@app.get("/api/klub/{klub_id}/lijecnicki") +def klub_lijecnicki(klub_id: int): + import datetime; today = datetime.date.today() + rows = fetch("""SELECT lp.*,c.ime,c.prezime,c.oib,c.kategorija,c.slika_url, + CASE WHEN lp.vrijedi_do IS NULL THEN 'nepoznato' + WHEN lp.vrijedi_do < CURRENT_DATE THEN 'istekao' + WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'uskoro_istece' + ELSE 'validan' END AS status_pregled + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s ORDER BY lp.vrijedi_do ASC NULLS LAST""", [klub_id]) + alert_istekli = [r for r in rows if r.get("status_pregled")=="istekao"] + alert_uskoro = [r for r in rows if r.get("status_pregled")=="uskoro_istece"] + return {"count":len(rows),"istekli":len(alert_istekli),"uskoro":len(alert_uskoro),"rows":rows} + +# ── NETWORK GRAPH DATA ─────────────────────────────────────────── +@app.get("/api/network/pgz") +def network_pgz(q: str=None, entity_type: str=None, max_nodes: int=80): + FORENSIC_NAMES = {"SAMIR BARAĆ","MIROSLAV MARIĆ","VELIMIR LIVERIĆ","DOROTEA PESIC-BUKOVAC"} + nodes,edges,seen_nodes,seen_edges = [],[],set(),set() + + def add_node(nid, label, ntype, meta=None): + if nid not in seen_nodes: + seen_nodes.add(nid) + nodes.append({"id":nid,"label":label,"type":ntype,"forensic":label.upper() in FORENSIC_NAMES,"meta":meta or {}}) + + def add_edge(s,t,rel=""): + k=f"{s}-{t}" + if k not in seen_edges: + seen_edges.add(k); edges.append({"source":s,"target":t,"rel":rel}) + + if q: + # Person search + persons = fetch("""SELECT p.id,p.name,p.function,e.name as ent,e.id as eid,e.entity_type,e.city + FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id + WHERE p.name ILIKE %s OR e.name ILIKE %s LIMIT 60""",[f"%{q}%",f"%{q}%"]) + for r in persons: + pid=f"p_{r['id']}"; eid=f"e_{r['eid']}" + add_node(pid,r.get("name","?")[:30],"person") + add_node(eid,r.get("ent","?")[:30],"club" if "Udruga" in (r.get("entity_type") or "") else "company") + add_edge(pid,eid,r.get("function","")) + else: + # Default: top connected persons + rels = fetch("""SELECT p.id,p.name,e.id as eid,e.name as ent,e.entity_type,p.function + FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id + WHERE e.county ILIKE '%%goranska%%' OR e.county ILIKE '%%primorska%%' + ORDER BY p.id LIMIT %s""",[max_nodes]) + for r in rels: + pid=f"p_{r['id']}"; eid=f"e_{r['eid']}" + add_node(pid,r.get("name","?")[:25],"person") + add_node(eid,r.get("ent","?")[:25],"club" if "Udruga" in (r.get("entity_type") or "") else "company", + {"city":r.get("city"),"type":r.get("entity_type")}) + add_edge(pid,eid,r.get("function","")) + + return {"nodes":nodes[:200],"edges":edges[:400],"query":q} + + + +@app.get("/platform") +@app.get("/platform/") +def serve_platform(): + p = HTML_DIR / "platform.html" + if p.exists(): return FileResponse(p) + return {"error": "platform.html not found"} + + +@app.get("/app") +@app.get("/app/") +def serve_app(): + p = HTML_DIR / "app.html" + return FileResponse(p) if p.exists() else {"error":"app.html not found"} + +@app.get("/audit") +@app.get("/audit/") +def serve_audit(): + p = HTML_DIR / "audit.html" + return FileResponse(p) if p.exists() else {"error":"audit.html not found"} + +@app.get("/kpi") +@app.get("/kpi/") +def serve_kpi(): + p = HTML_DIR / "kpi.html" + return FileResponse(p) if p.exists() else {"error":"kpi.html not found"} + +app.mount("/static", StaticFiles(directory=str(HTML_DIR)), name="static") + +@app.get("/") +def root(request: Request): + host = request.headers.get("host", "") + if "sport.rinet.one" in host: + p = HTML_DIR / "sport2.html" + if p.exists(): + return FileResponse(p) + idx = HTML_DIR / "index.html" + if idx.exists(): + return FileResponse(idx) + return {"service": "PGŽ Sport", "version": "2.0"} + +@app.get("/v2") +def portal_v2(): + p = HTML_DIR / "sport2.html" + if p.exists(): + return FileResponse(p) + return {"error": "sport2.html not found"} + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8095) diff --git a/_backups/pgz_sport_api.py.r4_pre.1777934657 b/_backups/pgz_sport_api.py.r4_pre.1777934657 new file mode 100644 index 0000000..ffe80f1 --- /dev/null +++ b/_backups/pgz_sport_api.py.r4_pre.1777934657 @@ -0,0 +1,1735 @@ +#!/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}') + +# === Round 3 / CC6 — M11 Blockchain audit (Polygon PoS sealing) === +try: + from audit_seal_router import router as audit_seal_router + app.include_router(audit_seal_router, prefix='/api') + print('[AUDIT/M11] polygon seal router loaded (/api/audit/seal*)') +except Exception as e: + print(f'[AUDIT/M11] polygon seal router fail: {e}') + + +@app.get("/sport-3d") +@app.get("/3d") +def serve_sport_3d(): + p = HTML_DIR / "sport_3d.html" + if p.exists(): + return FileResponse(p) + return {"error": "sport_3d.html not found"} + +@app.get("/admin") +@app.get("/admin/") +def serve_admin(): + p = HTML_DIR / "admin.html" + if p.exists(): + return FileResponse(p) + return {"error": "admin.html not found"} + +@app.get("/erp") +@app.get("/erp/") +@app.get("/app/erp") +@app.get("/app/erp/") +def serve_erp(): + p = HTML_DIR / "erp.html" + if p.exists(): + return FileResponse(p) + return {"error": "erp.html not found"} + +@app.get("/crm") +@app.get("/crm/") +def serve_crm(): + p = HTML_DIR / "crm.html" + if p.exists(): + return FileResponse(p) + return {"error": "crm.html not found"} + +@app.get("/login") +@app.get("/login/") +def serve_login(): + p = HTML_DIR / "login.html" + if p.exists(): + return FileResponse(p) + return {"error": "login.html not found"} + +@app.get("/admin/users") +@app.get("/admin/users/") +def serve_admin_users(): + p = HTML_DIR / "admin_users.html" + if p.exists(): + return FileResponse(p) + return {"error": "admin_users.html not found"} + + +@app.get("/api/sportski-objekti") +def list_sportski_objekti(q=None,tip=None,grad=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR adresa ILIKE %s OR grad ILIKE %s)"); p+=["%"+q+"%"]*3 + if tip: w.append("tip ILIKE %s"); p.append("%"+tip+"%") + if grad: w.append("grad ILIKE %s"); p.append("%"+grad+"%") + rows=fetch("SELECT * FROM pgz_sport.sportski_objekti WHERE "+" AND ".join(w)+" ORDER BY grad,naziv",p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/clanovi-full") +def list_clanovi_full(q=None,hoo=None,reprezentativac=None,klub_id=None,limit=80,authorization=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(ime ILIKE %s OR prezime ILIKE %s OR klub_naziv_godisnjak ILIKE %s)"); p+=["%"+q+"%"]*3 + if hoo: w.append("hoo_kategorija=%s"); p.append(hoo) + if reprezentativac is not None: w.append("reprezentativac="+(("TRUE") if str(reprezentativac).lower()=="true" else "FALSE")) + if klub_id: w.append("klub_id=%s"); p.append(int(klub_id)) + lim=min(int(limit or 80),200) + sql="SELECT id,ime,prezime,oib,datum_rodenja,spol,sport,pozicija,reprezentativac,kategoriziran,stipendiran,kategorija_hoo,hoo_kategorija,aktivan,klub_naziv_godisnjak,slika_url,profile_url,hns_igrac_id,visina_cm,tezina_kg,broj_dresa,uloga,godisnjak_godine,godisnjak_prvi,godisnjak_zadnji,napomena FROM pgz_sport.clanovi WHERE "+" AND ".join(w)+" ORDER BY prezime,ime LIMIT "+str(lim) + rows=fetch(sql,p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/gradovi") +def list_gradovi(): + rows=fetch("SELECT DISTINCT grad FROM pgz_sport.klubovi WHERE aktivan=TRUE AND grad IS NOT NULL AND grad<>'' AND grad NOT SIMILAR TO '[0-9]+%%' ORDER BY grad",[]) + return [r["grad"] for r in rows] + +@app.get("/api/manifestacije-full") +def list_manifestacije_full(q=None,razina=None): + w=["aktivna=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR mjesto ILIKE %s)"); p+=["%"+q+"%"]*2 + rows=fetch("SELECT id,naziv,mjesto,organizator,razina,broj_ucesnika,godina_od,spol_kategorija,napomena,source_url FROM pgz_sport.manifestacije WHERE "+" AND ".join(w)+" ORDER BY naziv",p) + return {"count":len(rows),"rows":rows} + + + +# ── SUFINANCIRANJE-ALL v1.0 dradulic@outlook.com 2026-05-04 +@app.get("/api/sufinanciranje") +def list_sufinanciranje(q=None, godina=None, razina=None, sport=None, limit=500): + w=["iznos_eur > 0"]; p=[] + if q: w.append("(LOWER(korisnik) LIKE %s OR LOWER(sport) LIKE %s)"); p+=[f"%{q.lower()}%"]*2 + if godina: w.append("godina=%s"); p.append(int(godina)) + if razina: w.append("razina ILIKE %s"); p.append(f"%{razina}%") + if sport: w.append("sport ILIKE %s"); p.append(f"%{sport}%") + sql=f"SELECT korisnik,sport,iznos_eur,vrsta,razina,izvor,source_url,godina FROM pgz_sport.sufinanciranje_sport WHERE {' AND '.join(w)} ORDER BY iznos_eur DESC LIMIT {min(int(limit),1000)}" + rows=fetch(sql,p) + total=sum(float(r.get('iznos_eur') or 0) for r in rows) + years=sorted(set(r.get('godina') for r in rows if r.get('godina')),reverse=True) + return {"count":len(rows),"total":total,"years":years,"rows":rows} + + + +# ══════════════════════════════════════════════════════════════════ +# ERP PLATFORM ROUTES v2.0 — dradulic@outlook.com — 2026-05-04 +# ══════════════════════════════════════════════════════════════════ + +import hashlib + +def hash_pwd(pwd): return hashlib.sha256(pwd.encode()).hexdigest() + +def get_user(token): + if not token: return None + try: + payload = _jwt.decode(token.replace("Bearer ",""), JWT_SECRET, algorithms=["HS256"]) + uid = payload.get("uid") + if uid: + rows = fetch("SELECT * FROM pgz_sport.users WHERE id=%s AND aktivan=TRUE", [uid]) + return rows[0] if rows else None + return payload + except: return None + +# ── AUTH: Email/Password login — handled by auth.auth_v2 router (M1) ── + +# ── SPORTAS FULL PROFILE ───────────────────────────────────────── +@app.get("/api/sportas/{clan_id}/profil") +def sportas_profil(clan_id: int): + clan = fetch("""SELECT c.*, k.naziv AS klub_naziv_full, k.sport AS klub_sport, + k.grad, k.logo_url FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id]) + if not clan: raise HTTPException(404,"Nije pronađen") + c = clan[0] + sezona = fetch("""SELECT * FROM pgz_sport.clan_sezona WHERE clan_id=%s ORDER BY sezona DESC""", [clan_id]) + utakmice = fetch("""SELECT * FROM pgz_sport.utakmice_log WHERE clan_id=%s ORDER BY datum DESC LIMIT 30""", [clan_id]) + nagrade = fetch("SELECT * FROM pgz_sport.clan_nagrada WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + godisnjaci = fetch("SELECT * FROM pgz_sport.clan_godisnjak WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + stats = {} + if sezona: + stats = {"ukupno_nastupa": sum((r.get("nastupi") or 0) for r in sezona), + "ukupno_pogodaka": sum((r.get("pogoci") or 0) for r in sezona), + "ukupno_asistencija": sum((r.get("asistencije") or 0) for r in sezona), + "ukupno_zutih": sum((r.get("zuti_kartoni") or 0) for r in sezona), + "ukupno_crvenih": sum((r.get("crveni_kartoni") or 0) for r in sezona), + "ukupno_minuta": sum((r.get("minute_total") or 0) for r in sezona), + "sezone_aktivne": len(sezona)} + return {**c,"clan_sezona":sezona,"utakmice":utakmice,"nagrade":nagrade, + "godisnjaci":godisnjaci,"stats":stats} + +# ── SAVEZ FULL DETAIL ──────────────────────────────────────────── +@app.get("/api/savezi/{savez_id}/full") +def savez_full(savez_id: int): + s = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s",[savez_id]) + if not s: raise HTTPException(404,"Savez nije pronađen") + klubovi = fetch("""SELECT id,naziv,sport,grad,predsjednik,tajnik,nositelj_kvalitete, + aktivan,oib,razina,broj_clanova FROM pgz_sport.klubovi WHERE savez_id=%s AND aktivan=TRUE ORDER BY naziv""",[savez_id]) + clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija, + c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan + FROM pgz_sport.clanovi c WHERE c.savez_kod=(SELECT kod FROM pgz_sport.savezi WHERE id=%s) LIMIT 200""",[savez_id]) + if not clanovi: + clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija, + c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan + FROM pgz_sport.clanovi c WHERE c.aktivan=TRUE AND c.sport ILIKE %s LIMIT 200""", + [f'%{s[0].get("sport","") or ""}%']) + treneri = fetch("""SELECT * FROM pgz_sport.treneri WHERE savez_id=%s""",[savez_id]) + return {**s[0],"klubovi":klubovi,"clanovi":clanovi[:100],"treneri":treneri} + +# ── KLUB ERP: CLANARINE ────────────────────────────────────────── +@app.get("/api/klub/{klub_id}/clanarine") +def klub_clanarine(klub_id: int, godina: int=None, status: str=None): + w=["c.klub_id=%s"]; p=[klub_id] + if godina: w.append("cl.godina=%s"); p.append(godina) + if status: w.append("cl.status=%s"); p.append(status) + rows = fetch(f"""SELECT cl.*,c.ime,c.prezime,c.oib,c.spol,c.kategorija,c.hoo_kategorija,c.slika_url + FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE {" AND ".join(w)} ORDER BY cl.godina DESC, c.prezime""", p) + total_p = sum(float(r.get("iznos_placen") or 0) for r in rows) + total_d = sum(float(r.get("iznos_propisan") or 0) - float(r.get("iznos_placen") or 0) for r in rows) + return {"count":len(rows),"naplaceno":total_p,"dug":total_d,"rows":rows} + +# ── KLUB ERP: LIJECNICKI ───────────────────────────────────────── +@app.get("/api/klub/{klub_id}/lijecnicki") +def klub_lijecnicki(klub_id: int): + import datetime; today = datetime.date.today() + rows = fetch("""SELECT lp.*,c.ime,c.prezime,c.oib,c.kategorija,c.slika_url, + CASE WHEN lp.vrijedi_do IS NULL THEN 'nepoznato' + WHEN lp.vrijedi_do < CURRENT_DATE THEN 'istekao' + WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'uskoro_istece' + ELSE 'validan' END AS status_pregled + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s ORDER BY lp.vrijedi_do ASC NULLS LAST""", [klub_id]) + alert_istekli = [r for r in rows if r.get("status_pregled")=="istekao"] + alert_uskoro = [r for r in rows if r.get("status_pregled")=="uskoro_istece"] + return {"count":len(rows),"istekli":len(alert_istekli),"uskoro":len(alert_uskoro),"rows":rows} + +# ── NETWORK GRAPH DATA ─────────────────────────────────────────── +@app.get("/api/network/pgz") +def network_pgz(q: str=None, entity_type: str=None, max_nodes: int=80): + FORENSIC_NAMES = {"SAMIR BARAĆ","MIROSLAV MARIĆ","VELIMIR LIVERIĆ","DOROTEA PESIC-BUKOVAC"} + nodes,edges,seen_nodes,seen_edges = [],[],set(),set() + + def add_node(nid, label, ntype, meta=None): + if nid not in seen_nodes: + seen_nodes.add(nid) + nodes.append({"id":nid,"label":label,"type":ntype,"forensic":label.upper() in FORENSIC_NAMES,"meta":meta or {}}) + + def add_edge(s,t,rel=""): + k=f"{s}-{t}" + if k not in seen_edges: + seen_edges.add(k); edges.append({"source":s,"target":t,"rel":rel}) + + if q: + # Person search + persons = fetch("""SELECT p.id,p.name,p.function,e.name as ent,e.id as eid,e.entity_type,e.city + FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id + WHERE p.name ILIKE %s OR e.name ILIKE %s LIMIT 60""",[f"%{q}%",f"%{q}%"]) + for r in persons: + pid=f"p_{r['id']}"; eid=f"e_{r['eid']}" + add_node(pid,r.get("name","?")[:30],"person") + add_node(eid,r.get("ent","?")[:30],"club" if "Udruga" in (r.get("entity_type") or "") else "company") + add_edge(pid,eid,r.get("function","")) + else: + # Default: top connected persons + rels = fetch("""SELECT p.id,p.name,e.id as eid,e.name as ent,e.entity_type,p.function + FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id + WHERE e.county ILIKE '%%goranska%%' OR e.county ILIKE '%%primorska%%' + ORDER BY p.id LIMIT %s""",[max_nodes]) + for r in rels: + pid=f"p_{r['id']}"; eid=f"e_{r['eid']}" + add_node(pid,r.get("name","?")[:25],"person") + add_node(eid,r.get("ent","?")[:25],"club" if "Udruga" in (r.get("entity_type") or "") else "company", + {"city":r.get("city"),"type":r.get("entity_type")}) + add_edge(pid,eid,r.get("function","")) + + return {"nodes":nodes[:200],"edges":edges[:400],"query":q} + + + +@app.get("/platform") +@app.get("/platform/") +def serve_platform(): + p = HTML_DIR / "platform.html" + if p.exists(): return FileResponse(p) + return {"error": "platform.html not found"} + + +@app.get("/app") +@app.get("/app/") +def serve_app(): + p = HTML_DIR / "app.html" + return FileResponse(p) if p.exists() else {"error":"app.html not found"} + +@app.get("/audit") +@app.get("/audit/") +def serve_audit(): + p = HTML_DIR / "audit.html" + return FileResponse(p) if p.exists() else {"error":"audit.html not found"} + +@app.get("/kpi") +@app.get("/kpi/") +def serve_kpi(): + p = HTML_DIR / "kpi.html" + return FileResponse(p) if p.exists() else {"error":"kpi.html not found"} + +app.mount("/static", StaticFiles(directory=str(HTML_DIR)), name="static") + +# User-uploaded files (avatars, etc.) — served at /uploads/* +import pathlib as _pl +_UPLOAD_DIR = _pl.Path("/opt/pgz-sport/uploads") +_UPLOAD_DIR.mkdir(parents=True, exist_ok=True) +(_UPLOAD_DIR / "avatars").mkdir(parents=True, exist_ok=True) +app.mount("/uploads", StaticFiles(directory=str(_UPLOAD_DIR)), name="uploads") + +@app.get("/") +def root(request: Request): + host = request.headers.get("host", "") + if "sport.rinet.one" in host: + p = HTML_DIR / "sport2.html" + if p.exists(): + return FileResponse(p) + idx = HTML_DIR / "index.html" + if idx.exists(): + return FileResponse(idx) + return {"service": "PGŽ Sport", "version": "2.0"} + +@app.get("/v2") +def portal_v2(): + p = HTML_DIR / "sport2.html" + if p.exists(): + return FileResponse(p) + return {"error": "sport2.html not found"} + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8095) diff --git a/_backups/r3_cc4/erp.html.pre_M5_5.1777934523 b/_backups/r3_cc4/erp.html.pre_M5_5.1777934523 new file mode 100644 index 0000000..75febb5 --- /dev/null +++ b/_backups/r3_cc4/erp.html.pre_M5_5.1777934523 @@ -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/_backups/r3_cc4/ocr.py.pre_M5_5.1777934523 b/_backups/r3_cc4/ocr.py.pre_M5_5.1777934523 new file mode 100644 index 0000000..7695d39 --- /dev/null +++ b/_backups/r3_cc4/ocr.py.pre_M5_5.1777934523 @@ -0,0 +1,659 @@ +#!/usr/bin/env python3 +# erp/ocr.py — PGŽ Sport ERP OCR router (M5) +# Author: Damir Radulić / dradulic@outlook.com +# Date: 2026-05-04 +# Description: /api/erp/ocr/upload + /parse — Tesseract OCR + DeepSeek V3 LLM extraction +# Persists into pgz_sport.invoice_uploads, then offers structured invoice parse. + +from __future__ import annotations + +import os +import re +import json +import hashlib +import subprocess +import tempfile +import traceback +from datetime import datetime, date +from pathlib import Path +from typing import Optional, List, Any + +import psycopg2 +import psycopg2.extras +import requests +from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Header, Query, Body +from fastapi.responses import JSONResponse + +router = APIRouter(prefix="/api/erp", tags=["erp-ocr"]) + +# === Config === +DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet", + password="R1net2026!SecureDB#v7") +UPLOAD_DIR = Path("/opt/pgz-sport/_data/uploads/invoices") +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + +DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY", "sk-33d29054d1ab4377b7d1a84bc0a423c7") +DEEPSEEK_URL = "https://api.deepseek.com/v1/chat/completions" +DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL", "deepseek-chat") + +ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"} +MAX_BYTES = 12 * 1024 * 1024 # 12 MB + +ADMIN_TOKEN = "admin-pgz-2026" + + +def _db(): + c = psycopg2.connect(**DB) + c.autocommit = True + return c + + +def _is_admin(authorization: Optional[str]) -> bool: + if not authorization: + return False + t = authorization.replace("Bearer ", "").strip() + return t == ADMIN_TOKEN + + +def _safe_filename(orig: str) -> str: + base = re.sub(r"[^A-Za-z0-9._-]+", "_", (orig or "upload").strip())[:120] + if not base: + base = "upload" + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + return f"{ts}_{base}" + + +def _extract_text(path: Path) -> tuple[str, str]: + """Return (text, method). Tries pdftotext first, falls back to tesseract.""" + suf = path.suffix.lower() + if suf == ".pdf": + try: + r = subprocess.run( + ["pdftotext", "-layout", "-q", str(path), "-"], + capture_output=True, timeout=45, + ) + txt = r.stdout.decode("utf-8", "ignore") + if len(txt.strip()) > 80: + return txt, "pdftotext" + except Exception: + pass + # Rasterize + tesseract + try: + with tempfile.TemporaryDirectory(prefix="ocr_") as td: + subprocess.run( + ["pdftoppm", "-r", "200", str(path), f"{td}/page"], + timeout=120, check=True, + ) + chunks = [] + for img in sorted(Path(td).glob("page-*.ppm"))[:5]: + r = subprocess.run( + ["tesseract", str(img), "-", "-l", "hrv+eng", "--psm", "6"], + capture_output=True, timeout=90, + ) + chunks.append(r.stdout.decode("utf-8", "ignore")) + return "\n".join(chunks), "tesseract" + except Exception as e: + return "", f"pdf_err:{e}" + if suf in {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"}: + try: + r = subprocess.run( + ["tesseract", str(path), "-", "-l", "hrv+eng", "--psm", "6"], + capture_output=True, timeout=120, + ) + return r.stdout.decode("utf-8", "ignore"), "tesseract" + except Exception as e: + return "", f"img_err:{e}" + return "", f"unsupported:{suf}" + + +# === HR invoice regex helpers === +_OIB = re.compile(r"\b(\d{11})\b") +_IBAN = re.compile(r"\b(HR\d{19})\b") +_DATE_DOT = re.compile(r"\b(\d{1,2})[.\s\-/]+(\d{1,2})[.\s\-/]+(20\d{2})\b") +_DATE_ISO = re.compile(r"\b(20\d{2})[\-/](\d{1,2})[\-/](\d{1,2})\b") +_AMOUNT_TOTAL = re.compile( + r"(?i)(?:UKUPNO|TOTAL|SVEUKUPNO|ZA NAPLATU|ZA PLATITI|ZA UPLATU|IZNOS\s+UKUPNO)[\s:€]*([\d.\s]{1,12}[,.]\d{2})" +) +_AMOUNT_VAT = re.compile(r"(?i)(?:PDV|VAT)[\s:%]*?([\d.\s]{1,8}[,.]\d{2})") +_INVOICE_NO = re.compile(r"(?i)(?:ra[čc]un|invoice|broj|fakture|br\.)\s*[:#]?\s*([A-Z0-9\-/.]{3,30})") + + +def _parse_amount(s: str) -> Optional[float]: + if not s: + return None + s = s.replace(" ", "").replace("\xa0", "") + # Croatian style "1.234,56" → 1234.56 + if "," in s and "." in s: + s = s.replace(".", "").replace(",", ".") + elif "," in s: + s = s.replace(",", ".") + try: + return float(s) + except Exception: + return None + + +def regex_extract(text: str) -> dict: + out: dict[str, Any] = {"raw_chars": len(text or "")} + if not text: + return out + oibs = list(dict.fromkeys(_OIB.findall(text))) + if oibs: + out["oibs_found"] = oibs + out["vendor_oib"] = oibs[0] + if len(oibs) > 1: + out["customer_oib"] = oibs[1] + + m = _IBAN.search(text.replace(" ", "")) + if m: + out["iban"] = m.group(1) + + m = _INVOICE_NO.search(text) + if m: + out["invoice_no"] = m.group(1).strip().rstrip(".,;") + + for rx, order in [(_DATE_DOT, "dmy"), (_DATE_ISO, "ymd")]: + m = rx.search(text) + if m: + g = m.groups() + try: + if order == "dmy": + out["invoice_date"] = f"{g[2]}-{int(g[1]):02d}-{int(g[0]):02d}" + else: + out["invoice_date"] = f"{g[0]}-{int(g[1]):02d}-{int(g[2]):02d}" + # validate + date.fromisoformat(out["invoice_date"]) + break + except Exception: + out.pop("invoice_date", None) + + totals = [_parse_amount(x) for x in _AMOUNT_TOTAL.findall(text)] + totals = [t for t in totals if t and t > 0.01] + if totals: + out["amount_gross"] = max(totals) + out["amounts_found"] = totals[:6] + + vats = [_parse_amount(x) for x in _AMOUNT_VAT.findall(text)] + vats = [v for v in vats if v and v > 0.01] + if vats: + # smallest plausible PDV (less than gross) + if "amount_gross" in out: + cand = [v for v in vats if v < out["amount_gross"]] + if cand: + out["amount_vat"] = max(cand) + else: + out["amount_vat"] = max(vats) + + if "amount_gross" in out and "amount_vat" in out: + out["amount_net"] = round(out["amount_gross"] - out["amount_vat"], 2) + + # Vendor name guess: first non-numeric, non-OIB line in header + for line in text.split("\n")[:12]: + ln = line.strip() + if 4 < len(ln) < 80 and not _OIB.search(ln) and not re.match(r"^[\d\s.,\-/€:]+$", ln): + out["vendor_name"] = ln + break + + # Crude vendor guess for known HR sellers + upper = text.upper() + for keyword, label in [ + ("INA d.d.", "INA"), ("INA-MAZIVA", "INA"), ("TIFON", "TIFON"), + ("PETROL", "PETROL"), ("HAC", "HAC"), ("BINA-ISTRA", "BINA-ISTRA"), + ("HRVATSKE AUTOCESTE", "HAC"), + ]: + if keyword in upper: + out.setdefault("vendor_brand", label) + break + + return out + + +# === DeepSeek V3 LLM extraction === +SYSTEM_PROMPT = ( + "Ti si stručnjak za hrvatske račune (R-1, fiskalne, HUB-3). " + "Korisnik daje tekst računa izvučen OCR-om. Vrati ISKLJUČIVO valjani JSON, bez markdowna i komentara. " + "Ako neko polje nije sigurno - vrati null. Iznosi su brojevi (decimal s točkom). Datum je 'YYYY-MM-DD'." +) + +LLM_SCHEMA_HINT = """{ + "izdavatelj_naziv": str|null, + "izdavatelj_oib": str|null, + "izdavatelj_adresa": str|null, + "kupac_naziv": str|null, + "kupac_oib": str|null, + "datum": "YYYY-MM-DD"|null, + "broj_racuna": str|null, + "iznos_neto": float|null, + "iznos_pdv": float|null, + "iznos_brutto": float|null, + "stopa_pdv": float|null, + "valuta": "EUR"|"HRK"|null, + "nacin_placanja": str|null, + "IBAN": str|null, + "opis_svrhe": str|null, + "vrsta_troska": "gorivo"|"cestarina"|"hotel"|"restoran"|"oprema"|"ostalo"|null, + "stavke": [ + {"opis": str, "kolicina": float, "jedinica": str, "cijena": float, "ukupno": float} + ] +}""" + + +def deepseek_extract(text: str, hint: dict | None = None) -> dict: + """Call DeepSeek chat completions for structured JSON extraction.""" + if not DEEPSEEK_API_KEY: + return {"error": "no_api_key"} + if not text or len(text.strip()) < 20: + return {"error": "empty_text"} + + user_msg = ( + f"Iz teksta računa ispod izvuci polja po shemi:\n{LLM_SCHEMA_HINT}\n\n" + f"REGEX hint (može biti nepotpun ili netočan): {json.dumps(hint or {}, ensure_ascii=False)}\n\n" + f"--- TEKST RAČUNA ---\n{text[:8000]}\n--- KRAJ ---" + ) + payload = { + "model": DEEPSEEK_MODEL, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_msg}, + ], + "response_format": {"type": "json_object"}, + "temperature": 0.0, + "max_tokens": 1200, + } + headers = { + "Authorization": f"Bearer {DEEPSEEK_API_KEY}", + "Content-Type": "application/json", + } + try: + r = requests.post(DEEPSEEK_URL, headers=headers, json=payload, timeout=60) + except Exception as e: + return {"error": f"net:{e}"} + if r.status_code != 200: + return {"error": f"http_{r.status_code}", "detail": r.text[:300]} + try: + body = r.json() + content = body["choices"][0]["message"]["content"] + return json.loads(content) + except Exception as e: + return {"error": f"parse:{e}", "raw": (r.text[:500] if r else "")} + + +# === Endpoints === + +@router.post("/ocr/upload") +async def ocr_upload( + file: UploadFile = File(...), + klub_id: Optional[int] = Form(None), + tenant_id: int = Form(1), + invoice_kind: str = Form("ostalo"), + authorization: Optional[str] = Header(None), +): + """Upload an invoice file (PDF/image) → store on disk + insert pgz_sport.invoice_uploads.""" + suffix = "." + (file.filename or "").rsplit(".", 1)[-1].lower() + if suffix not in ALLOWED_EXT: + raise HTTPException(400, f"Tip datoteke nije podržan: {suffix}. Dozvoljeno: {sorted(ALLOWED_EXT)}") + + raw = await file.read() + if not raw: + raise HTTPException(400, "Prazna datoteka") + if len(raw) > MAX_BYTES: + raise HTTPException(400, f"Datoteka prevelika ({len(raw)} > {MAX_BYTES} bajtova)") + + sha256 = hashlib.sha256(raw).hexdigest() + fname = _safe_filename(file.filename or "upload") + if not fname.endswith(suffix): + fname += suffix + path = UPLOAD_DIR / fname + path.write_bytes(raw) + + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """ + INSERT INTO pgz_sport.invoice_uploads + (klub_id, file_name, file_path, file_size, mime, sha256, ocr_status, meta) + VALUES (%s, %s, %s, %s, %s, %s, 'pending', %s) + RETURNING id, klub_id, file_name, ocr_status, uploaded_at + """, + (klub_id, file.filename, str(path), len(raw), file.content_type or "", + sha256, json.dumps({"tenant_id": tenant_id, "invoice_kind": invoice_kind})), + ) + row = cur.fetchone() + return {"ok": True, "upload_id": row["id"], "file_name": row["file_name"], + "size": len(raw), "sha256": sha256, "status": row["ocr_status"]} + + +@router.post("/ocr/parse") +async def ocr_parse( + upload_id: Optional[int] = Form(None), + file: Optional[UploadFile] = File(None), + use_llm: bool = Form(True), + authorization: Optional[str] = Header(None), +): + """Run OCR + (optional) DeepSeek LLM extraction. + Either pass upload_id (parse a previously uploaded file) or send file directly (one-shot).""" + tmp_to_clean: Optional[Path] = None + upload_row = None + try: + if upload_id: + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT * FROM pgz_sport.invoice_uploads WHERE id=%s", (upload_id,)) + upload_row = cur.fetchone() + if not upload_row: + raise HTTPException(404, f"Upload id={upload_id} ne postoji") + target = Path(upload_row["file_path"]) + if not target.exists(): + raise HTTPException(404, f"Datoteka ne postoji na disku: {target}") + elif file: + suffix = "." + (file.filename or "").rsplit(".", 1)[-1].lower() + if suffix not in ALLOWED_EXT: + raise HTTPException(400, f"Tip datoteke nije podržan: {suffix}") + raw = await file.read() + if not raw: + raise HTTPException(400, "Prazna datoteka") + tmp = tempfile.NamedTemporaryFile(prefix="parse_", suffix=suffix, delete=False) + tmp.write(raw); tmp.close() + target = Path(tmp.name) + tmp_to_clean = target + else: + raise HTTPException(400, "Treba poslati upload_id ILI file") + + text, method = _extract_text(target) + if len(text.strip()) < 20: + return {"ok": False, "ocr_method": method, "raw_chars": len(text), + "error": "OCR nije uspio izvući dovoljno teksta"} + + regex_fields = regex_extract(text) + regex_fields["ocr_method"] = method + + llm_fields: dict = {} + if use_llm: + llm_fields = deepseek_extract(text, hint=regex_fields) + + # Merge: LLM overrides regex when valid + merged = dict(regex_fields) + for k in ("izdavatelj_naziv", "izdavatelj_oib", "kupac_oib", "datum", + "broj_racuna", "iznos_neto", "iznos_pdv", "iznos_brutto", + "stopa_pdv", "valuta", "IBAN", "opis_svrhe", "vrsta_troska", + "izdavatelj_adresa", "nacin_placanja"): + v = llm_fields.get(k) if isinstance(llm_fields, dict) else None + if v not in (None, "", "null"): + merged[k] = v + + # Normalize aliases for UI / DB + if "izdavatelj_naziv" in merged: merged.setdefault("vendor_name", merged["izdavatelj_naziv"]) + if "izdavatelj_oib" in merged: merged.setdefault("vendor_oib", merged["izdavatelj_oib"]) + if "izdavatelj_adresa" in merged: merged.setdefault("vendor_address", merged["izdavatelj_adresa"]) + if "kupac_oib" in merged: merged.setdefault("customer_oib", merged["kupac_oib"]) + if "datum" in merged: merged.setdefault("invoice_date", merged["datum"]) + if "broj_racuna" in merged: merged.setdefault("invoice_no", merged["broj_racuna"]) + if "iznos_brutto" in merged: merged.setdefault("amount_gross", merged["iznos_brutto"]) + if "iznos_neto" in merged: merged.setdefault("amount_net", merged["iznos_neto"]) + if "iznos_pdv" in merged: merged.setdefault("amount_vat", merged["iznos_pdv"]) + if "stopa_pdv" in merged: merged.setdefault("vat_rate", merged["stopa_pdv"]) + if "valuta" in merged: merged.setdefault("currency", merged["valuta"]) + if "IBAN" in merged: merged.setdefault("iban", merged["IBAN"]) + if "opis_svrhe" in merged: merged.setdefault("description", merged["opis_svrhe"]) + if "vrsta_troska" in merged: merged.setdefault("category", merged["vrsta_troska"]) + + # Persist back to invoice_uploads when we have upload_row + if upload_row: + try: + with _db() as c: + c.cursor().execute( + """UPDATE pgz_sport.invoice_uploads + SET ocr_status='done', processed_at=NOW(), + ocr_engine=%s, ocr_text=%s, + ai_invoice_no=%s, ai_invoice_date=%s, + ai_vendor_name=%s, ai_vendor_oib=%s, + ai_amount_gross=%s, ai_currency=%s, ai_iban=%s, + ai_extracted=%s, ai_engine=%s + WHERE id=%s""", + ( + method, text[:50000], + merged.get("invoice_no"), + merged.get("invoice_date") if isinstance(merged.get("invoice_date"), str) else None, + merged.get("vendor_name"), + merged.get("vendor_oib"), + merged.get("amount_gross"), + merged.get("currency", "EUR"), + merged.get("iban"), + json.dumps({"regex": regex_fields, "llm": llm_fields, "merged": merged}, + ensure_ascii=False, default=str), + ("deepseek-v3" if use_llm and "error" not in (llm_fields or {}) else "regex"), + upload_row["id"], + ), + ) + except Exception as e: + merged["_persist_warn"] = str(e)[:200] + + return { + "ok": True, + "upload_id": (upload_row["id"] if upload_row else None), + "ocr_method": method, + "raw_chars": len(text), + "regex": regex_fields, + "llm": llm_fields, + "extracted": merged, + "raw_text_preview": text[:1500], + } + finally: + if tmp_to_clean and tmp_to_clean.exists(): + try: + tmp_to_clean.unlink() + except Exception: + pass + + +# === Invoices CRUD (M5) === + +@router.get("/invoices") +def invoices_list( + tenant_id: Optional[int] = Query(None), + klub_id: Optional[int] = Query(None), + status: Optional[str] = Query(None), + kind: Optional[str] = Query(None), + limit: int = Query(100, le=500), + offset: int = Query(0), +): + sql = """SELECT i.id, i.klub_id, k.naziv AS klub_naziv, + i.invoice_kind, i.invoice_no, i.internal_no, + i.vendor_name, i.vendor_oib, i.customer_name, i.customer_oib, + i.invoice_date, i.due_date, i.paid_date, i.currency, + i.amount_net, i.amount_vat, i.amount_gross, i.vat_rate, + i.payment_status, i.payment_method, i.iban_to, + i.description, i.category, i.tenant_id, + i.created_at, i.approved_at + FROM pgz_sport.invoices i + LEFT JOIN pgz_sport.klubovi k ON k.id = i.klub_id + WHERE 1=1""" + args: list = [] + if tenant_id is not None: + sql += " AND i.tenant_id=%s"; args.append(tenant_id) + if klub_id is not None: + sql += " AND i.klub_id=%s"; args.append(klub_id) + if status: + sql += " AND i.payment_status=%s"; args.append(status) + if kind: + sql += " AND i.invoice_kind=%s"; args.append(kind) + sql += " ORDER BY i.invoice_date DESC NULLS LAST, i.id DESC LIMIT %s OFFSET %s" + args += [limit, offset] + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(sql, args) + rows = cur.fetchall() + return {"ok": True, "rows": rows, "count": len(rows)} + + +@router.get("/invoices/{invoice_id}") +def invoices_get(invoice_id: int): + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT * FROM pgz_sport.invoices WHERE id=%s", (invoice_id,)) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Račun ne postoji") + cur.execute("SELECT * FROM pgz_sport.invoice_lines WHERE invoice_id=%s ORDER BY line_no, id", + (invoice_id,)) + lines = cur.fetchall() + cur.execute("SELECT id, file_name, sha256, ocr_status, uploaded_at FROM pgz_sport.invoice_uploads WHERE invoice_id=%s", + (invoice_id,)) + uploads = cur.fetchall() + return {"ok": True, "invoice": row, "lines": lines, "uploads": uploads} + + +@router.post("/invoices") +def invoices_create(body: dict = Body(...), authorization: Optional[str] = Header(None)): + """Create an invoice from parsed OCR result. + Body: {klub_id, tenant_id, invoice_kind, invoice_no, vendor_name, vendor_oib, + invoice_date, amount_gross, amount_net, amount_vat, vat_rate, currency, + iban_to, description, category, lines:[{...}], upload_id?}""" + required = ["invoice_kind", "invoice_no", "invoice_date", "amount_gross"] + for k in required: + if body.get(k) in (None, ""): + raise HTTPException(400, f"Nedostaje polje: {k}") + + klub_id = body.get("klub_id") + tenant_id = body.get("tenant_id", 1) + upload_id = body.get("upload_id") + lines = body.get("lines") or [] + + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """INSERT INTO pgz_sport.invoices + (klub_id, invoice_kind, invoice_no, internal_no, + vendor_oib, vendor_name, vendor_address, + customer_oib, customer_name, + invoice_date, due_date, currency, + amount_net, amount_vat, amount_gross, vat_rate, + payment_status, payment_method, iban_to, + description, category, account_code, tenant_id, meta) + VALUES (%s,%s,%s,%s, %s,%s,%s, %s,%s, + %s,%s,COALESCE(%s,'EUR'), + %s,%s,%s,%s, + COALESCE(%s,'unpaid'),%s,%s, + %s,%s,%s,%s,%s) + ON CONFLICT (klub_id, invoice_kind, invoice_no, vendor_oib) + DO UPDATE SET amount_gross=EXCLUDED.amount_gross, + amount_net=EXCLUDED.amount_net, + amount_vat=EXCLUDED.amount_vat, + updated_at=NOW() + RETURNING id, invoice_no, amount_gross, payment_status""", + ( + klub_id, body["invoice_kind"], body["invoice_no"], body.get("internal_no"), + body.get("vendor_oib"), body.get("vendor_name"), body.get("vendor_address"), + body.get("customer_oib"), body.get("customer_name"), + body["invoice_date"], body.get("due_date"), body.get("currency"), + body.get("amount_net"), body.get("amount_vat"), body["amount_gross"], body.get("vat_rate"), + body.get("payment_status"), body.get("payment_method"), body.get("iban_to"), + body.get("description"), body.get("category"), body.get("account_code"), + tenant_id, json.dumps(body.get("meta", {})), + ), + ) + inv = cur.fetchone() + inv_id = inv["id"] + + # Replace lines + cur.execute("DELETE FROM pgz_sport.invoice_lines WHERE invoice_id=%s", (inv_id,)) + for i, ln in enumerate(lines, start=1): + cur.execute( + """INSERT INTO pgz_sport.invoice_lines + (invoice_id, line_no, description, quantity, unit, unit_price, + vat_rate, line_net, line_vat, line_gross, account_code, cost_center, meta) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", + ( + inv_id, ln.get("line_no", i), ln.get("description") or ln.get("opis") or "", + ln.get("quantity") or ln.get("kolicina") or 1, + ln.get("unit") or ln.get("jedinica") or "kom", + ln.get("unit_price") or ln.get("cijena"), + ln.get("vat_rate", 25), + ln.get("line_net"), ln.get("line_vat"), + ln.get("line_gross") or ln.get("ukupno"), + ln.get("account_code"), ln.get("cost_center"), + json.dumps(ln.get("meta", {})), + ), + ) + + # Link upload to invoice + if upload_id: + cur.execute( + "UPDATE pgz_sport.invoice_uploads SET invoice_id=%s WHERE id=%s", + (inv_id, upload_id), + ) + + return {"ok": True, "invoice": inv} + + +@router.put("/invoices/{invoice_id}") +def invoices_update(invoice_id: int, body: dict = Body(...), authorization: Optional[str] = Header(None)): + """Update / approve invoice. Body may include any of: payment_status, paid_date, + approved (bool), notes, category, account_code, due_date.""" + fields = [] + args: list = [] + for col in ("payment_status", "paid_date", "due_date", "category", + "account_code", "notes", "vat_rate", "amount_net", "amount_vat", + "amount_gross", "payment_method", "iban_to"): + if col in body: + fields.append(f"{col}=%s") + args.append(body[col]) + if body.get("approved"): + fields.append("approved_at=NOW()") + if not fields: + raise HTTPException(400, "Nema polja za izmjenu") + fields.append("updated_at=NOW()") + args.append(invoice_id) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(f"UPDATE pgz_sport.invoices SET {','.join(fields)} WHERE id=%s RETURNING *", args) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Račun ne postoji") + return {"ok": True, "invoice": row} + + +@router.post("/invoices/{invoice_id}/pay") +def invoices_pay(invoice_id: int, body: dict = Body(default={})): + paid_date = body.get("paid_date") or date.today().isoformat() + payment_method = body.get("payment_method", "transfer") + iban_from = body.get("iban_from") + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """UPDATE pgz_sport.invoices + SET payment_status='paid', paid_date=%s, + payment_method=COALESCE(%s,payment_method), + iban_from=COALESCE(%s,iban_from), updated_at=NOW() + WHERE id=%s RETURNING id, invoice_no, paid_date, amount_gross""", + (paid_date, payment_method, iban_from, invoice_id), + ) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Račun ne postoji") + # log payment + cur.execute( + """INSERT INTO pgz_sport.payments (invoice_id, amount, payment_date, method, iban_from) + VALUES (%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""", + (invoice_id, row["amount_gross"], paid_date, payment_method, iban_from), + ) if False else None # payments table column-set may differ; skip silently + return {"ok": True, "invoice": row} + + +@router.get("/invoices/uploads/list") +def uploads_list(klub_id: Optional[int] = None, status: Optional[str] = None, limit: int = 50): + sql = """SELECT id, klub_id, file_name, file_size, mime, ocr_status, ocr_engine, + ai_invoice_no, ai_invoice_date, ai_vendor_name, ai_vendor_oib, + ai_amount_gross, ai_currency, invoice_id, uploaded_at, processed_at + FROM pgz_sport.invoice_uploads WHERE 1=1""" + args: list = [] + if klub_id is not None: + sql += " AND klub_id=%s"; args.append(klub_id) + if status: + sql += " AND ocr_status=%s"; args.append(status) + sql += " ORDER BY uploaded_at DESC LIMIT %s"; args.append(limit) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(sql, args) + rows = cur.fetchall() + return {"ok": True, "rows": rows} diff --git a/_backups/r3_cc4/putni_nalozi.py.pre_M5_5.1777934523 b/_backups/r3_cc4/putni_nalozi.py.pre_M5_5.1777934523 new file mode 100644 index 0000000..50629ab --- /dev/null +++ b/_backups/r3_cc4/putni_nalozi.py.pre_M5_5.1777934523 @@ -0,0 +1,413 @@ +#!/usr/bin/env python3 +# erp/putni_nalozi.py — PGŽ Sport ERP putni nalozi (M6) +# Author: Damir Radulić / dradulic@outlook.com +# Date: 2026-05-04 +# Description: CRUD putnih naloga + obračun dnevnica (HR pravilnik 2025). + +from __future__ import annotations + +import json +from datetime import datetime, date, timedelta +from typing import Optional, Any + +import psycopg2 +import psycopg2.extras +from fastapi import APIRouter, Body, HTTPException, Query, Header + +router = APIRouter(prefix="/api/erp", tags=["erp-putni-nalozi"]) + +DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet", + password="R1net2026!SecureDB#v7") + +# === HR pravilnik 2025 — dnevnice === +# 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 (Uredba o izdacima službenih putovanja u inozemstvo). +DNEVNICE_INO = { + "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, +} + + +def _db(): + c = psycopg2.connect(**DB) + c.autocommit = True + return c + + +def _parse_dt(v) -> Optional[datetime]: + if v is None or v == "": + return None + if isinstance(v, datetime): + return v + s = str(v).strip().replace("Z", "+00:00") + for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M", "%Y-%m-%d"): + try: + return datetime.strptime(s[:len(fmt) + 5].rstrip("ZZ"), fmt) + except Exception: + continue + try: + return datetime.fromisoformat(s) + except Exception: + return None + + +def compute_dnevnice(date_from, date_to, country: str = "Hrvatska") -> dict: + """ + Vraća: {hours, days_full, days_half, dnevnica_amount_total, breakdown[]} + Pravila (HR pravilnik 2025, neoporeziv iznos): + - Domaće: <5h = 0; 5-8h = pola; >8h = puna; svaka dodatna pokrivena 24h sekcija = puna. + - Inozemne: pune dnevnice po zemlji (DNEVNICE_INO), inače fallback 50 €. + - Više dana: zaokružujemo po 24h segmentima; završetak <8h = 0, 8-12 = puna (po pravilu zaokruživanja na cijele dane), no koristimo konzervativni izračun po segmentima. + Implementacija (jednostavna, transparentna): + 1) ukupne sate računaj kao razliku. + 2) full_segments = sati // 24 + 3) ostatak_sati = sati - full_segments*24 + 4) ako ostatak >= 8 → +1 puna; ako 5 <= ostatak < 8 → +0.5; ako <5 → +0. + 5) puna dnevnica = pun_iznos po zemlji; pola = polovica. + """ + df = _parse_dt(date_from) + dt = _parse_dt(date_to) + if not df or not dt or dt < df: + return {"error": "neispravni datumi", "hours": 0, + "days_full": 0, "days_half": 0, + "dnevnica_amount_total": 0.0, "breakdown": []} + + delta = dt - df + hours = round(delta.total_seconds() / 3600, 2) + + full_segments = int(delta.total_seconds() // (24 * 3600)) + remainder_h = (delta.total_seconds() - full_segments * 24 * 3600) / 3600.0 + + days_full = full_segments + days_half = 0.0 + if remainder_h >= 8: + days_full += 1 + elif remainder_h >= 5: + days_half += 1 + # else: 0 + + is_domestic = (country or "").strip().lower() in ("hrvatska", "croatia", "hr") + if is_domestic: + full_amt = DNEVNICA_DOM_FULL + half_amt = DNEVNICA_DOM_HALF + else: + full_amt = DNEVNICE_INO.get(country.strip(), 50.00) + half_amt = full_amt / 2.0 + + total = round(days_full * full_amt + days_half * half_amt, 2) + + return { + "hours": hours, + "days_full": days_full, + "days_half": days_half, + "country": country, + "rate_full": full_amt, + "rate_half": half_amt, + "dnevnica_amount_total": total, + "breakdown": [ + f"{days_full} pun{'' if days_full == 1 else 'e'} dnevnice × {full_amt:.2f} €", + f"{days_half} pola dnevnice × {full_amt:.2f} €" if days_half else "", + ], + } + + +def compute_kilometrina(km: float, km_rate: float = KM_RATE_DEFAULT) -> float: + try: + return round(float(km or 0) * float(km_rate or 0), 2) + except Exception: + return 0.0 + + +# === Endpoints === + +@router.get("/putni-nalog/dnevnice/preview") +def preview_dnevnice(date_from: str, date_to: str, country: str = "Hrvatska", + km: float = 0.0, km_rate: float = KM_RATE_DEFAULT): + """Preview dnevnica + kilometrine bez upisa u DB. Koristi UI za live preview.""" + d = compute_dnevnice(date_from, date_to, country) + km_amt = compute_kilometrina(km, km_rate) + d["km_amount"] = km_amt + d["km_driven"] = km + d["km_rate"] = km_rate + d["total_estimated"] = round((d.get("dnevnica_amount_total") or 0) + km_amt, 2) + return {"ok": True, "preview": d} + + +@router.get("/putni-nalog") +def list_putni_nalozi(klub_id: Optional[int] = None, + status: Optional[str] = None, + limit: int = Query(100, le=500), + offset: int = 0): + sql = """SELECT er.id, er.klub_id, k.naziv AS klub_naziv, + er.user_id, er.clan_id, er.report_type, er.report_no, + er.destination, er.purpose, + er.date_from, er.date_to, + er.vehicle_type, er.vehicle_plate, + er.km_driven, er.km_rate, + er.cost_transport, er.cost_lodging, er.cost_meals, + er.cost_other, er.cost_total, + er.dnevnice_count, er.dnevnice_amount, + er.status, er.approved_at, er.paid_at, + er.created_at, er.tenant_id, er.notes + FROM pgz_sport.expense_reports er + LEFT JOIN pgz_sport.klubovi k ON k.id = er.klub_id + WHERE er.report_type='putni_nalog'""" + args: list = [] + if klub_id is not None: + sql += " AND er.klub_id=%s"; args.append(klub_id) + if status: + sql += " AND er.status=%s"; args.append(status) + sql += " ORDER BY er.date_from DESC NULLS LAST, er.id DESC LIMIT %s OFFSET %s" + args += [limit, offset] + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(sql, args) + rows = cur.fetchall() + return {"ok": True, "rows": rows, "count": len(rows)} + + +@router.get("/putni-nalog/{nalog_id}") +def get_putni_nalog(nalog_id: int): + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("""SELECT er.*, k.naziv AS klub_naziv + FROM pgz_sport.expense_reports er + LEFT JOIN pgz_sport.klubovi k ON k.id = er.klub_id + WHERE er.id=%s AND er.report_type='putni_nalog'""", (nalog_id,)) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Putni nalog ne postoji") + return {"ok": True, "putni_nalog": row} + + +@router.post("/putni-nalog") +def create_putni_nalog(body: dict = Body(...), authorization: Optional[str] = Header(None)): + """Kreiraj putni nalog. + Polja: klub_id, user_id, clan_id, voditelj_ime, putnici[], + svrha (purpose), od_grada, do_grada (destination), + datum_polaska (date_from), datum_povratka (date_to), + registracija_vozila (vehicle_plate), vehicle_type, + kilometara (km_driven), km_rate, + predviđeni_troškovi (cost_estimate), country, notes.""" + df = body.get("date_from") or body.get("datum_polaska") + dt = body.get("date_to") or body.get("datum_povratka") + if not df or not dt: + raise HTTPException(400, "Datum polaska i povratka su obavezni") + klub_id = body.get("klub_id") + if not klub_id: + raise HTTPException(400, "klub_id je obavezan") + + country = body.get("country", "Hrvatska") + km = body.get("km_driven", body.get("kilometara", 0)) or 0 + km_rate = body.get("km_rate") or KM_RATE_DEFAULT + dnv = compute_dnevnice(df, dt, country) + dnevnice_count = (dnv.get("days_full") or 0) + 0.5 * (dnv.get("days_half") or 0) + dnevnice_amount = dnv.get("dnevnica_amount_total") or 0 + cost_transport = compute_kilometrina(km, km_rate) + (body.get("cost_transport") or 0) + + od = body.get("od_grada") or body.get("from_city") + do = body.get("do_grada") or body.get("to_city") or body.get("destination") + destination = " → ".join([x for x in [od, do] if x]) or do + + putnici = body.get("putnici") or [] + voditelj = body.get("voditelj_ime") or body.get("voditelj") + purpose = body.get("svrha") or body.get("purpose") or "" + + meta = { + "voditelj": voditelj, + "putnici": putnici, + "from_city": od, "to_city": do, + "country": country, + "dnevnice_calc": dnv, + "predvideni_troskovi": body.get("predvideni_troskovi") or body.get("cost_estimate") or [], + } + + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """INSERT INTO pgz_sport.expense_reports + (klub_id, user_id, clan_id, report_type, report_no, destination, purpose, + date_from, date_to, vehicle_type, vehicle_plate, km_driven, km_rate, + cost_transport, cost_lodging, cost_meals, cost_other, + dnevnice_count, dnevnice_amount, status, attachments, notes, tenant_id) + VALUES (%s, %s, %s, 'putni_nalog', %s, %s, %s, + %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, + %s, %s, COALESCE(%s,'draft'), %s, %s, %s) + RETURNING id, klub_id, status, dnevnice_count, dnevnice_amount, + cost_transport, date_from, date_to, destination""", + ( + klub_id, body.get("user_id"), body.get("clan_id"), + body.get("report_no"), destination, purpose, + df, dt, body.get("vehicle_type"), body.get("vehicle_plate") or body.get("registracija_vozila"), + float(km or 0), float(km_rate or 0), + cost_transport, + body.get("cost_lodging") or 0, body.get("cost_meals") or 0, + body.get("cost_other") or 0, + dnevnice_count, dnevnice_amount, + body.get("status"), + json.dumps(meta, ensure_ascii=False, default=str), + body.get("notes"), + body.get("tenant_id", 1), + ), + ) + row = cur.fetchone() + # cost_total via trigger maybe; recompute here + cur.execute( + """UPDATE pgz_sport.expense_reports + SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0) + +COALESCE(cost_meals,0)+COALESCE(cost_other,0) + +COALESCE(dnevnice_amount,0) + WHERE id=%s + RETURNING cost_total""", (row["id"],), + ) + ct = cur.fetchone() + if ct: + row["cost_total"] = ct["cost_total"] + return {"ok": True, "putni_nalog": row, "dnevnice_calc": dnv} + + +@router.put("/putni-nalog/{nalog_id}") +def update_putni_nalog(nalog_id: int, body: dict = Body(...)): + """Update polja putnog naloga (osim odobrenja/zatvaranja - oni imaju vlastite endpointe).""" + cols = [] + args: list = [] + for col in ("destination", "purpose", "date_from", "date_to", "vehicle_type", + "vehicle_plate", "km_driven", "km_rate", "cost_transport", + "cost_lodging", "cost_meals", "cost_other", "notes", + "dnevnice_count", "dnevnice_amount"): + if col in body: + cols.append(f"{col}=%s"); args.append(body[col]) + # Recompute dnevnice if dates provided + if "date_from" in body or "date_to" in body or "country" in body: + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT date_from, date_to, attachments FROM pgz_sport.expense_reports WHERE id=%s", (nalog_id,)) + cur_row = cur.fetchone() + if cur_row: + df = body.get("date_from") or cur_row["date_from"] + dt = body.get("date_to") or cur_row["date_to"] + country = body.get("country") or (cur_row["attachments"] or {}).get("country", "Hrvatska") + d = compute_dnevnice(df, dt, country) + cols += ["dnevnice_count=%s", "dnevnice_amount=%s"] + args += [(d.get("days_full") or 0) + 0.5 * (d.get("days_half") or 0), + d.get("dnevnica_amount_total") or 0] + if not cols: + raise HTTPException(400, "Nema polja za izmjenu") + cols.append("updated_at=NOW()") + args.append(nalog_id) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(f"UPDATE pgz_sport.expense_reports SET {','.join(cols)} WHERE id=%s AND report_type='putni_nalog' RETURNING *", args) + row = cur.fetchone() + if row: + cur.execute( + """UPDATE pgz_sport.expense_reports + SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0) + +COALESCE(cost_meals,0)+COALESCE(cost_other,0) + +COALESCE(dnevnice_amount,0) + WHERE id=%s""", (nalog_id,), + ) + if not row: + raise HTTPException(404, "Putni nalog ne postoji") + return {"ok": True, "putni_nalog": row} + + +@router.post("/putni-nalog/{nalog_id}/odobriti") +def odobriti_putni_nalog(nalog_id: int, body: dict = Body(default={})): + approved_by = body.get("approved_by") + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """UPDATE pgz_sport.expense_reports + SET status='odobren', approved_by=%s, approved_at=NOW(), updated_at=NOW() + WHERE id=%s AND report_type='putni_nalog' + RETURNING id, status, approved_at""", (approved_by, nalog_id), + ) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Putni nalog ne postoji") + return {"ok": True, "putni_nalog": row} + + +@router.post("/putni-nalog/{nalog_id}/zatvori") +def zatvori_putni_nalog(nalog_id: int, body: dict = Body(default={})): + """Zatvori putni nalog: priloži račune i konačan obračun.""" + invoice_ids = body.get("invoice_ids") or [] + cost_lodging = body.get("cost_lodging") + cost_meals = body.get("cost_meals") + cost_other = body.get("cost_other") + notes = body.get("notes") + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT * FROM pgz_sport.expense_reports WHERE id=%s AND report_type='putni_nalog'", (nalog_id,)) + cur_row = cur.fetchone() + if not cur_row: + raise HTTPException(404, "Putni nalog ne postoji") + + # Aggregiraj iznose iz računa (ako su poslani) + if invoice_ids: + cur.execute( + "SELECT COALESCE(SUM(amount_gross),0) AS total FROM pgz_sport.invoices WHERE id = ANY(%s)", + (invoice_ids,), + ) + invs_total = float(cur.fetchone()["total"] or 0) + else: + invs_total = None + + sets = ["status='zatvoren'", "updated_at=NOW()"] + args: list = [] + if cost_lodging is not None: sets.append("cost_lodging=%s"); args.append(cost_lodging) + if cost_meals is not None: sets.append("cost_meals=%s"); args.append(cost_meals) + if cost_other is not None: sets.append("cost_other=%s"); args.append(cost_other) + if notes: sets.append("notes=%s"); args.append(notes) + # Pohrani povezane račune u attachments + atts = cur_row["attachments"] or {} + if isinstance(atts, str): + try: atts = json.loads(atts) + except Exception: atts = {} + atts["invoice_ids"] = invoice_ids + if invs_total is not None: + atts["invoices_total"] = invs_total + sets.append("attachments=%s"); args.append(json.dumps(atts, ensure_ascii=False, default=str)) + args.append(nalog_id) + cur.execute(f"UPDATE pgz_sport.expense_reports SET {','.join(sets)} WHERE id=%s RETURNING *", args) + row = cur.fetchone() + cur.execute( + """UPDATE pgz_sport.expense_reports + SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0) + +COALESCE(cost_meals,0)+COALESCE(cost_other,0) + +COALESCE(dnevnice_amount,0) + WHERE id=%s RETURNING cost_total""", (nalog_id,), + ) + ct = cur.fetchone() + if ct: row["cost_total"] = ct["cost_total"] + return {"ok": True, "putni_nalog": row} diff --git a/_backups/r3_cc5/app.html.cc5_links.1777933397 b/_backups/r3_cc5/app.html.cc5_links.1777933397 new file mode 100644 index 0000000..213b2f9 --- /dev/null +++ b/_backups/r3_cc5/app.html.cc5_links.1777933397 @@ -0,0 +1,1214 @@ + + + + + +PGŽ SPORT — Operativna aplikacija + + + + + + + + + +
+ + +
+
+
+
Dashboard
+
Pregled stanja
+
+
+
+
+
DR
+
+
Damir Radulić
+
PGŽ admin
+
+
+
+
+ +
+
Učitavanje...
+
+
+
+ + + + diff --git a/_backups/r3_cc5/crm.html.v1.1777933221 b/_backups/r3_cc5/crm.html.v1.1777933221 new file mode 100644 index 0000000..dcac6b7 --- /dev/null +++ b/_backups/r3_cc5/crm.html.v1.1777933221 @@ -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/_backups/r3_cc5/pgz_sport_api.py.post_ui.1777933221 b/_backups/r3_cc5/pgz_sport_api.py.post_ui.1777933221 new file mode 100644 index 0000000..d890f55 --- /dev/null +++ b/_backups/r3_cc5/pgz_sport_api.py.post_ui.1777933221 @@ -0,0 +1,1702 @@ +#!/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("/erp") +@app.get("/erp/") +@app.get("/app/erp") +@app.get("/app/erp/") +def serve_erp(): + p = HTML_DIR / "erp.html" + if p.exists(): + return FileResponse(p) + return {"error": "erp.html not found"} + +@app.get("/crm") +@app.get("/crm/") +def serve_crm(): + p = HTML_DIR / "crm.html" + if p.exists(): + return FileResponse(p) + return {"error": "crm.html not found"} + +@app.get("/login") +@app.get("/login/") +def serve_login(): + p = HTML_DIR / "login.html" + if p.exists(): + return FileResponse(p) + return {"error": "login.html not found"} + +@app.get("/admin/users") +@app.get("/admin/users/") +def serve_admin_users(): + p = HTML_DIR / "admin_users.html" + if p.exists(): + return FileResponse(p) + return {"error": "admin_users.html not found"} + + +@app.get("/api/sportski-objekti") +def list_sportski_objekti(q=None,tip=None,grad=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR adresa ILIKE %s OR grad ILIKE %s)"); p+=["%"+q+"%"]*3 + if tip: w.append("tip ILIKE %s"); p.append("%"+tip+"%") + if grad: w.append("grad ILIKE %s"); p.append("%"+grad+"%") + rows=fetch("SELECT * FROM pgz_sport.sportski_objekti WHERE "+" AND ".join(w)+" ORDER BY grad,naziv",p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/clanovi-full") +def list_clanovi_full(q=None,hoo=None,reprezentativac=None,klub_id=None,limit=80,authorization=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(ime ILIKE %s OR prezime ILIKE %s OR klub_naziv_godisnjak ILIKE %s)"); p+=["%"+q+"%"]*3 + if hoo: w.append("hoo_kategorija=%s"); p.append(hoo) + if reprezentativac is not None: w.append("reprezentativac="+(("TRUE") if str(reprezentativac).lower()=="true" else "FALSE")) + if klub_id: w.append("klub_id=%s"); p.append(int(klub_id)) + lim=min(int(limit or 80),200) + sql="SELECT id,ime,prezime,oib,datum_rodenja,spol,sport,pozicija,reprezentativac,kategoriziran,stipendiran,kategorija_hoo,hoo_kategorija,aktivan,klub_naziv_godisnjak,slika_url,profile_url,hns_igrac_id,visina_cm,tezina_kg,broj_dresa,uloga,godisnjak_godine,godisnjak_prvi,godisnjak_zadnji,napomena FROM pgz_sport.clanovi WHERE "+" AND ".join(w)+" ORDER BY prezime,ime LIMIT "+str(lim) + rows=fetch(sql,p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/gradovi") +def list_gradovi(): + rows=fetch("SELECT DISTINCT grad FROM pgz_sport.klubovi WHERE aktivan=TRUE AND grad IS NOT NULL AND grad<>'' AND grad NOT SIMILAR TO '[0-9]+%%' ORDER BY grad",[]) + return [r["grad"] for r in rows] + +@app.get("/api/manifestacije-full") +def list_manifestacije_full(q=None,razina=None): + w=["aktivna=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR mjesto ILIKE %s)"); p+=["%"+q+"%"]*2 + rows=fetch("SELECT id,naziv,mjesto,organizator,razina,broj_ucesnika,godina_od,spol_kategorija,napomena,source_url FROM pgz_sport.manifestacije WHERE "+" AND ".join(w)+" ORDER BY naziv",p) + return {"count":len(rows),"rows":rows} + + + +# ── SUFINANCIRANJE-ALL v1.0 dradulic@outlook.com 2026-05-04 +@app.get("/api/sufinanciranje") +def list_sufinanciranje(q=None, godina=None, razina=None, sport=None, limit=500): + w=["iznos_eur > 0"]; p=[] + if q: w.append("(LOWER(korisnik) LIKE %s OR LOWER(sport) LIKE %s)"); p+=[f"%{q.lower()}%"]*2 + if godina: w.append("godina=%s"); p.append(int(godina)) + if razina: w.append("razina ILIKE %s"); p.append(f"%{razina}%") + if sport: w.append("sport ILIKE %s"); p.append(f"%{sport}%") + sql=f"SELECT korisnik,sport,iznos_eur,vrsta,razina,izvor,source_url,godina FROM pgz_sport.sufinanciranje_sport WHERE {' AND '.join(w)} ORDER BY iznos_eur DESC LIMIT {min(int(limit),1000)}" + rows=fetch(sql,p) + total=sum(float(r.get('iznos_eur') or 0) for r in rows) + years=sorted(set(r.get('godina') for r in rows if r.get('godina')),reverse=True) + return {"count":len(rows),"total":total,"years":years,"rows":rows} + + + +# ══════════════════════════════════════════════════════════════════ +# ERP PLATFORM ROUTES v2.0 — dradulic@outlook.com — 2026-05-04 +# ══════════════════════════════════════════════════════════════════ + +import hashlib + +def hash_pwd(pwd): return hashlib.sha256(pwd.encode()).hexdigest() + +def get_user(token): + if not token: return None + try: + payload = _jwt.decode(token.replace("Bearer ",""), JWT_SECRET, algorithms=["HS256"]) + uid = payload.get("uid") + if uid: + rows = fetch("SELECT * FROM pgz_sport.users WHERE id=%s AND aktivan=TRUE", [uid]) + return rows[0] if rows else None + return payload + except: return None + +# ── AUTH: Email/Password login — handled by auth.auth_v2 router (M1) ── + +# ── SPORTAS FULL PROFILE ───────────────────────────────────────── +@app.get("/api/sportas/{clan_id}/profil") +def sportas_profil(clan_id: int): + clan = fetch("""SELECT c.*, k.naziv AS klub_naziv_full, k.sport AS klub_sport, + k.grad, k.logo_url FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id]) + if not clan: raise HTTPException(404,"Nije pronađen") + c = clan[0] + sezona = fetch("""SELECT * FROM pgz_sport.clan_sezona WHERE clan_id=%s ORDER BY sezona DESC""", [clan_id]) + utakmice = fetch("""SELECT * FROM pgz_sport.utakmice_log WHERE clan_id=%s ORDER BY datum DESC LIMIT 30""", [clan_id]) + nagrade = fetch("SELECT * FROM pgz_sport.clan_nagrada WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + godisnjaci = fetch("SELECT * FROM pgz_sport.clan_godisnjak WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + stats = {} + if sezona: + stats = {"ukupno_nastupa": sum((r.get("nastupi") or 0) for r in sezona), + "ukupno_pogodaka": sum((r.get("pogoci") or 0) for r in sezona), + "ukupno_asistencija": sum((r.get("asistencije") or 0) for r in sezona), + "ukupno_zutih": sum((r.get("zuti_kartoni") or 0) for r in sezona), + "ukupno_crvenih": sum((r.get("crveni_kartoni") or 0) for r in sezona), + "ukupno_minuta": sum((r.get("minute_total") or 0) for r in sezona), + "sezone_aktivne": len(sezona)} + return {**c,"clan_sezona":sezona,"utakmice":utakmice,"nagrade":nagrade, + "godisnjaci":godisnjaci,"stats":stats} + +# ── SAVEZ FULL DETAIL ──────────────────────────────────────────── +@app.get("/api/savezi/{savez_id}/full") +def savez_full(savez_id: int): + s = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s",[savez_id]) + if not s: raise HTTPException(404,"Savez nije pronađen") + klubovi = fetch("""SELECT id,naziv,sport,grad,predsjednik,tajnik,nositelj_kvalitete, + aktivan,oib,razina,broj_clanova FROM pgz_sport.klubovi WHERE savez_id=%s AND aktivan=TRUE ORDER BY naziv""",[savez_id]) + clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija, + c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan + FROM pgz_sport.clanovi c WHERE c.savez_kod=(SELECT kod FROM pgz_sport.savezi WHERE id=%s) LIMIT 200""",[savez_id]) + if not clanovi: + clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija, + c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan + FROM pgz_sport.clanovi c WHERE c.aktivan=TRUE AND c.sport ILIKE %s LIMIT 200""", + [f'%{s[0].get("sport","") or ""}%']) + treneri = fetch("""SELECT * FROM pgz_sport.treneri WHERE savez_id=%s""",[savez_id]) + return {**s[0],"klubovi":klubovi,"clanovi":clanovi[:100],"treneri":treneri} + +# ── KLUB ERP: CLANARINE ────────────────────────────────────────── +@app.get("/api/klub/{klub_id}/clanarine") +def klub_clanarine(klub_id: int, godina: int=None, status: str=None): + w=["c.klub_id=%s"]; p=[klub_id] + if godina: w.append("cl.godina=%s"); p.append(godina) + if status: w.append("cl.status=%s"); p.append(status) + rows = fetch(f"""SELECT cl.*,c.ime,c.prezime,c.oib,c.spol,c.kategorija,c.hoo_kategorija,c.slika_url + FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE {" AND ".join(w)} ORDER BY cl.godina DESC, c.prezime""", p) + total_p = sum(float(r.get("iznos_placen") or 0) for r in rows) + total_d = sum(float(r.get("iznos_propisan") or 0) - float(r.get("iznos_placen") or 0) for r in rows) + return {"count":len(rows),"naplaceno":total_p,"dug":total_d,"rows":rows} + +# ── KLUB ERP: LIJECNICKI ───────────────────────────────────────── +@app.get("/api/klub/{klub_id}/lijecnicki") +def klub_lijecnicki(klub_id: int): + import datetime; today = datetime.date.today() + rows = fetch("""SELECT lp.*,c.ime,c.prezime,c.oib,c.kategorija,c.slika_url, + CASE WHEN lp.vrijedi_do IS NULL THEN 'nepoznato' + WHEN lp.vrijedi_do < CURRENT_DATE THEN 'istekao' + WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'uskoro_istece' + ELSE 'validan' END AS status_pregled + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s ORDER BY lp.vrijedi_do ASC NULLS LAST""", [klub_id]) + alert_istekli = [r for r in rows if r.get("status_pregled")=="istekao"] + alert_uskoro = [r for r in rows if r.get("status_pregled")=="uskoro_istece"] + return {"count":len(rows),"istekli":len(alert_istekli),"uskoro":len(alert_uskoro),"rows":rows} + +# ── NETWORK GRAPH DATA ─────────────────────────────────────────── +@app.get("/api/network/pgz") +def network_pgz(q: str=None, entity_type: str=None, max_nodes: int=80): + FORENSIC_NAMES = {"SAMIR BARAĆ","MIROSLAV MARIĆ","VELIMIR LIVERIĆ","DOROTEA PESIC-BUKOVAC"} + nodes,edges,seen_nodes,seen_edges = [],[],set(),set() + + def add_node(nid, label, ntype, meta=None): + if nid not in seen_nodes: + seen_nodes.add(nid) + nodes.append({"id":nid,"label":label,"type":ntype,"forensic":label.upper() in FORENSIC_NAMES,"meta":meta or {}}) + + def add_edge(s,t,rel=""): + k=f"{s}-{t}" + if k not in seen_edges: + seen_edges.add(k); edges.append({"source":s,"target":t,"rel":rel}) + + if q: + # Person search + persons = fetch("""SELECT p.id,p.name,p.function,e.name as ent,e.id as eid,e.entity_type,e.city + FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id + WHERE p.name ILIKE %s OR e.name ILIKE %s LIMIT 60""",[f"%{q}%",f"%{q}%"]) + for r in persons: + pid=f"p_{r['id']}"; eid=f"e_{r['eid']}" + add_node(pid,r.get("name","?")[:30],"person") + add_node(eid,r.get("ent","?")[:30],"club" if "Udruga" in (r.get("entity_type") or "") else "company") + add_edge(pid,eid,r.get("function","")) + else: + # Default: top connected persons + rels = fetch("""SELECT p.id,p.name,e.id as eid,e.name as ent,e.entity_type,p.function + FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id + WHERE e.county ILIKE '%%goranska%%' OR e.county ILIKE '%%primorska%%' + ORDER BY p.id LIMIT %s""",[max_nodes]) + for r in rels: + pid=f"p_{r['id']}"; eid=f"e_{r['eid']}" + add_node(pid,r.get("name","?")[:25],"person") + add_node(eid,r.get("ent","?")[:25],"club" if "Udruga" in (r.get("entity_type") or "") else "company", + {"city":r.get("city"),"type":r.get("entity_type")}) + add_edge(pid,eid,r.get("function","")) + + return {"nodes":nodes[:200],"edges":edges[:400],"query":q} + + + +@app.get("/platform") +@app.get("/platform/") +def serve_platform(): + p = HTML_DIR / "platform.html" + if p.exists(): return FileResponse(p) + return {"error": "platform.html not found"} + +app.mount("/static", StaticFiles(directory=str(HTML_DIR)), name="static") + +@app.get("/") +def root(request: Request): + host = request.headers.get("host", "") + if "sport.rinet.one" in host: + p = HTML_DIR / "sport2.html" + if p.exists(): + return FileResponse(p) + idx = HTML_DIR / "index.html" + if idx.exists(): + return FileResponse(idx) + return {"service": "PGŽ Sport", "version": "2.0"} + +@app.get("/v2") +def portal_v2(): + p = HTML_DIR / "sport2.html" + if p.exists(): + return FileResponse(p) + return {"error": "sport2.html not found"} + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8095) diff --git a/_data/uploads/invoices/20260505_003249_343303817_21783837_mama_A1.pdf b/_data/uploads/invoices/20260505_003249_343303817_21783837_mama_A1.pdf new file mode 100644 index 0000000..bf66dd2 Binary files /dev/null and b/_data/uploads/invoices/20260505_003249_343303817_21783837_mama_A1.pdf differ diff --git a/erp/ocr.py b/erp/ocr.py index 7695d39..c9aae4c 100644 --- a/erp/ocr.py +++ b/erp/ocr.py @@ -22,7 +22,28 @@ import psycopg2 import psycopg2.extras import requests from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Header, Query, Body -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, FileResponse + +try: + from erp.permissions import ( + can_view_invoice, can_edit_invoice, can_pay_invoice, can_comment_invoice, + invoice_actions, audit_invoice, fetch_audit, is_pgz_admin, + ) +except Exception: + # Fallback (always-allow) for unauth dev + def can_view_invoice(u, i): return True + def can_edit_invoice(u, i): return True + def can_pay_invoice(u, i): return True + def can_comment_invoice(u, i): return True + def invoice_actions(u, i): return {"view": True, "edit": True, "pay": True, "comment": True, "delete": False} + def audit_invoice(u, iid, op, field=None, old=None, new=None): pass + def fetch_audit(t, r, limit=50): return [] + def is_pgz_admin(u): return False + +try: + from auth.auth_v2 import get_current_user as _auth_user +except Exception: + _auth_user = None router = APIRouter(prefix="/api/erp", tags=["erp-ocr"]) @@ -55,6 +76,20 @@ def _is_admin(authorization: Optional[str]) -> bool: return t == ADMIN_TOKEN +def _resolve_user(authorization: Optional[str]) -> Optional[dict]: + """Resolve current user via auth_v2 JWT, fallback to admin token (returns synthetic pgz_admin).""" + if _auth_user: + try: + u = _auth_user(authorization) + if u: return u + except Exception: + pass + if _is_admin(authorization): + return {"id": 0, "email": "admin@token", "user_type": "pgz_admin", + "klub_id": None, "savez_id": None, "_synthetic": True} + return None + + def _safe_filename(orig: str) -> str: base = re.sub(r"[^A-Za-z0-9._-]+", "_", (orig or "upload").strip())[:120] if not base: @@ -487,20 +522,117 @@ def invoices_list( @router.get("/invoices/{invoice_id}") -def invoices_get(invoice_id: int): +def invoices_get(invoice_id: int, authorization: Optional[str] = Header(None)): + user = _resolve_user(authorization) with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) - cur.execute("SELECT * FROM pgz_sport.invoices WHERE id=%s", (invoice_id,)) + cur.execute( + """SELECT i.*, k.naziv AS klub_naziv, k.savez_id + FROM pgz_sport.invoices i + LEFT JOIN pgz_sport.klubovi k ON k.id = i.klub_id + WHERE i.id=%s""", (invoice_id,)) row = cur.fetchone() if not row: raise HTTPException(404, "Račun ne postoji") + if user and not can_view_invoice(user, row): + raise HTTPException(403, "Nemate ovlasti vidjeti ovaj račun") cur.execute("SELECT * FROM pgz_sport.invoice_lines WHERE invoice_id=%s ORDER BY line_no, id", (invoice_id,)) lines = cur.fetchall() - cur.execute("SELECT id, file_name, sha256, ocr_status, uploaded_at FROM pgz_sport.invoice_uploads WHERE invoice_id=%s", - (invoice_id,)) + cur.execute( + """SELECT id, file_name, file_size, mime, sha256, ocr_status, ocr_engine, + ai_extracted, uploaded_at, processed_at + FROM pgz_sport.invoice_uploads WHERE invoice_id=%s + ORDER BY uploaded_at DESC""", (invoice_id,)) uploads = cur.fetchall() - return {"ok": True, "invoice": row, "lines": lines, "uploads": uploads} + cur.execute( + """SELECT id, payment_date, amount, currency, payment_method, iban_from, + iban_to, reference, bank_transaction_id, matched_status, created_at + FROM pgz_sport.payments WHERE invoice_id=%s ORDER BY payment_date DESC""", + (invoice_id,)) + payments = cur.fetchall() + audit = fetch_audit("pgz_sport.invoices", invoice_id, 50) + actions = invoice_actions(user, row) if user else {"view": True, "edit": False, "pay": False, "comment": False, "delete": False} + return {"ok": True, "invoice": row, "lines": lines, "uploads": uploads, + "payments": payments, "audit": audit, "actions": actions} + + +@router.get("/invoices/{invoice_id}/file") +def invoices_file(invoice_id: int, authorization: Optional[str] = Header(None)): + """Streamira originalnu datoteku skena/računa (slika ili PDF).""" + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT i.id, i.klub_id FROM pgz_sport.invoices i WHERE i.id=%s", (invoice_id,)) + inv = cur.fetchone() + if not inv: + raise HTTPException(404, "Račun ne postoji") + if user and not can_view_invoice(user, inv): + raise HTTPException(403, "Nemate ovlasti") + cur.execute( + """SELECT file_path, file_name, mime FROM pgz_sport.invoice_uploads + WHERE invoice_id=%s ORDER BY uploaded_at DESC LIMIT 1""", (invoice_id,)) + up = cur.fetchone() + if not up: + raise HTTPException(404, "Datoteka skena ne postoji za ovaj račun") + p = Path(up["file_path"]) + if not p.exists(): + raise HTTPException(404, f"Datoteka ne postoji na disku") + return FileResponse(str(p), media_type=up.get("mime") or "application/octet-stream", + filename=up.get("file_name") or p.name) + + +@router.get("/invoices/uploads/{upload_id}/file") +def upload_file(upload_id: int, authorization: Optional[str] = Header(None)): + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT * FROM pgz_sport.invoice_uploads WHERE id=%s", (upload_id,)) + up = cur.fetchone() + if not up: + raise HTTPException(404, "Upload ne postoji") + if user and not is_pgz_admin(user) and user.get("klub_id") != up.get("klub_id"): + raise HTTPException(403, "Nemate ovlasti") + p = Path(up["file_path"]) + if not p.exists(): + raise HTTPException(404, "Datoteka ne postoji") + return FileResponse(str(p), media_type=up.get("mime") or "application/octet-stream", + filename=up.get("file_name") or p.name) + + +@router.post("/invoices/{invoice_id}/comment") +def invoices_comment(invoice_id: int, body: dict = Body(...), + authorization: Optional[str] = Header(None)): + """Savez admin / klub admin / pgz admin može dodati komentar (audit log entry).""" + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT i.*, k.savez_id FROM pgz_sport.invoices i LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id WHERE i.id=%s", (invoice_id,)) + inv = cur.fetchone() + if not inv: + raise HTTPException(404, "Račun ne postoji") + if user and not can_comment_invoice(user, inv): + raise HTTPException(403, "Nemate ovlasti komentirati") + txt = (body.get("comment") or "").strip() + if not txt: + raise HTTPException(400, "Komentar je prazan") + audit_invoice(user, invoice_id, "comment", field="komentar", old=None, new=txt[:500]) + return {"ok": True, "invoice_id": invoice_id, "comment": txt} + + +@router.get("/invoices/{invoice_id}/audit") +def invoices_audit(invoice_id: int, limit: int = 100, + authorization: Optional[str] = Header(None)): + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT i.id, i.klub_id FROM pgz_sport.invoices i WHERE i.id=%s", (invoice_id,)) + inv = cur.fetchone() + if not inv: + raise HTTPException(404, "Račun ne postoji") + if user and not can_view_invoice(user, inv): + raise HTTPException(403, "Nemate ovlasti") + return {"ok": True, "audit": fetch_audit("pgz_sport.invoices", invoice_id, limit)} @router.post("/invoices") @@ -590,16 +722,29 @@ def invoices_create(body: dict = Body(...), authorization: Optional[str] = Heade def invoices_update(invoice_id: int, body: dict = Body(...), authorization: Optional[str] = Header(None)): """Update / approve invoice. Body may include any of: payment_status, paid_date, approved (bool), notes, category, account_code, due_date.""" + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT i.*, k.savez_id FROM pgz_sport.invoices i LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id WHERE i.id=%s", (invoice_id,)) + inv = cur.fetchone() + if not inv: + raise HTTPException(404, "Račun ne postoji") + if user and not can_edit_invoice(user, inv): + raise HTTPException(403, "Nemate ovlasti uređivati ovaj račun") + fields = [] args: list = [] + changes = [] for col in ("payment_status", "paid_date", "due_date", "category", "account_code", "notes", "vat_rate", "amount_net", "amount_vat", "amount_gross", "payment_method", "iban_to"): if col in body: fields.append(f"{col}=%s") args.append(body[col]) + changes.append((col, inv.get(col), body[col])) if body.get("approved"): fields.append("approved_at=NOW()") + changes.append(("approved_at", inv.get("approved_at"), "now")) if not fields: raise HTTPException(400, "Nema polja za izmjenu") fields.append("updated_at=NOW()") @@ -608,36 +753,67 @@ def invoices_update(invoice_id: int, body: dict = Body(...), authorization: Opti cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute(f"UPDATE pgz_sport.invoices SET {','.join(fields)} WHERE id=%s RETURNING *", args) row = cur.fetchone() - if not row: - raise HTTPException(404, "Račun ne postoji") + for f, o, n in changes: + audit_invoice(user, invoice_id, "update", field=f, old=o, new=n) return {"ok": True, "invoice": row} @router.post("/invoices/{invoice_id}/pay") -def invoices_pay(invoice_id: int, body: dict = Body(default={})): +def invoices_pay(invoice_id: int, body: dict = Body(default={}), + authorization: Optional[str] = Header(None)): + """Označi račun kao plaćen + insert payment record. + Body: {iban_to, iban_from, paid_date, reference, bank_transaction_id, payment_method, amount} + """ + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT i.*, k.savez_id FROM pgz_sport.invoices i LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id WHERE i.id=%s", (invoice_id,)) + inv = cur.fetchone() + if not inv: + raise HTTPException(404, "Račun ne postoji") + if user and not can_pay_invoice(user, inv): + raise HTTPException(403, "Nemate ovlasti označiti račun kao plaćen") + if (inv.get("payment_status") or "").lower() == "paid": + raise HTTPException(409, "Račun je već označen kao plaćen") + paid_date = body.get("paid_date") or date.today().isoformat() - payment_method = body.get("payment_method", "transfer") + payment_method = body.get("payment_method") or "transfer" iban_from = body.get("iban_from") + iban_to = body.get("iban_to") or inv.get("iban_to") + reference = body.get("reference") + tx_id = body.get("bank_transaction_id") or body.get("tx_id") + amount = body.get("amount") or inv.get("amount_gross") + with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute( """UPDATE pgz_sport.invoices SET payment_status='paid', paid_date=%s, payment_method=COALESCE(%s,payment_method), - iban_from=COALESCE(%s,iban_from), updated_at=NOW() - WHERE id=%s RETURNING id, invoice_no, paid_date, amount_gross""", - (paid_date, payment_method, iban_from, invoice_id), + iban_from=COALESCE(%s,iban_from), + iban_to=COALESCE(%s,iban_to), + updated_at=NOW() + WHERE id=%s + RETURNING id, invoice_no, paid_date, amount_gross, payment_status, + iban_from, iban_to, payment_method""", + (paid_date, payment_method, iban_from, iban_to, invoice_id), ) row = cur.fetchone() - if not row: - raise HTTPException(404, "Račun ne postoji") - # log payment + # Insert payment record cur.execute( - """INSERT INTO pgz_sport.payments (invoice_id, amount, payment_date, method, iban_from) - VALUES (%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""", - (invoice_id, row["amount_gross"], paid_date, payment_method, iban_from), - ) if False else None # payments table column-set may differ; skip silently - return {"ok": True, "invoice": row} + """INSERT INTO pgz_sport.payments + (klub_id, invoice_id, payment_date, amount, currency, payment_method, + iban_from, iban_to, reference, bank_transaction_id, matched_status) + VALUES (%s,%s,%s,%s,COALESCE(%s,'EUR'),%s,%s,%s,%s,%s,'matched') + RETURNING id""", + (inv.get("klub_id"), invoice_id, paid_date, amount, + inv.get("currency"), payment_method, iban_from, iban_to, + reference, tx_id), + ) + pay = cur.fetchone() + audit_invoice(user, invoice_id, "pay", field="payment_status", + old=inv.get("payment_status"), new="paid") + return {"ok": True, "invoice": row, "payment_id": pay["id"] if pay else None} @router.get("/invoices/uploads/list") diff --git a/erp/permissions.py b/erp/permissions.py new file mode 100644 index 0000000..62b610f --- /dev/null +++ b/erp/permissions.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +# erp/permissions.py — PGŽ Sport ERP RBAC helpers (M5/M6) +# Author: Damir Radulić / dradulic@outlook.com +# Date: 2026-05-04 +# Description: Centralizirane provjere ovlasti za račune i putne naloge. +# +# Uloge (pgz_sport.roles): +# super_admin, pgz_admin, savez_admin, klub_admin, klub_user, clan, viewer +# +# Korisnik (dict iz auth_v2.get_current_user) ima: id, user_type, klub_id, savez_id. + +from __future__ import annotations +from typing import Optional, Dict, Any +import psycopg2, psycopg2.extras + +DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet", + password="R1net2026!SecureDB#v7") + + +def _db(): + c = psycopg2.connect(**DB); c.autocommit = True; return c + + +# ── role helpers ────────────────────────────────────────────────────── +def is_super(user) -> bool: + return bool(user) and user.get("user_type") == "super_admin" + +def is_pgz_admin(user) -> bool: + return bool(user) and user.get("user_type") in ("super_admin", "pgz_admin") + +def is_savez_admin(user) -> bool: + return bool(user) and user.get("user_type") == "savez_admin" + +def is_klub_admin(user) -> bool: + return bool(user) and user.get("user_type") == "klub_admin" + +def is_klub_user(user) -> bool: + return bool(user) and user.get("user_type") in ("klub_admin", "klub_user") + + +def klub_savez(klub_id: int) -> Optional[int]: + """Vraća savez_id kojem klub pripada (preko klubovi.savez_id ili user_klub_links).""" + if not klub_id: return None + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT savez_id FROM pgz_sport.klubovi WHERE id=%s", (klub_id,)) + r = cur.fetchone() + return r["savez_id"] if r else None + + +def user_can_see_klub(user, klub_id: Optional[int]) -> bool: + """Tko može VIDJETI klub: super, pgz, savez (ako klub u savezu), klub_admin/user (ako vlastiti klub).""" + if not user or not klub_id: + return is_pgz_admin(user) + if is_pgz_admin(user): + return True + if is_klub_user(user): + return user.get("klub_id") == klub_id + if is_savez_admin(user): + return klub_savez(klub_id) == user.get("savez_id") + return False + + +# ── INVOICES ────────────────────────────────────────────────────────── +def can_view_invoice(user, invoice: Dict[str, Any]) -> bool: + """Pgž admin vidi sve. Savez admin svoje saveze. Klub admin/user vlastiti klub.""" + if not invoice: return False + if is_pgz_admin(user): return True + return user_can_see_klub(user, invoice.get("klub_id")) + + +def can_edit_invoice(user, invoice: Dict[str, Any]) -> bool: + """ + Edit (izmjena polja, korekcija OCR-a) — samo klub_admin vlastitog kluba ILI pgz_admin. + Savez admin može komentirati, ali NE editirati. + Plaćeni računi su read-only osim za pgz_admin. + """ + if not invoice: return False + if is_pgz_admin(user): return True + if invoice.get("payment_status") in ("paid",): + return False + if is_klub_admin(user): + return user.get("klub_id") == invoice.get("klub_id") + return False + + +def can_pay_invoice(user, invoice: Dict[str, Any]) -> bool: + """Označi kao plaćen — klub_admin vlastitog kluba ili pgz_admin.""" + if not invoice: return False + if is_pgz_admin(user): return True + if is_klub_admin(user): + return user.get("klub_id") == invoice.get("klub_id") + return False + + +def can_comment_invoice(user, invoice: Dict[str, Any]) -> bool: + """Komentirati može pgz_admin, savez_admin (svog saveza) i klub_admin (svog kluba).""" + if not invoice: return False + if is_pgz_admin(user): return True + if is_savez_admin(user): + return klub_savez(invoice.get("klub_id")) == user.get("savez_id") + if is_klub_admin(user): + return user.get("klub_id") == invoice.get("klub_id") + return False + + +def invoice_actions(user, invoice: Dict[str, Any]) -> Dict[str, bool]: + """UI hint — koji gumbi su dostupni.""" + return { + "view": can_view_invoice(user, invoice), + "edit": can_edit_invoice(user, invoice), + "pay": can_pay_invoice(user, invoice) and invoice.get("payment_status") != "paid", + "comment": can_comment_invoice(user, invoice), + "delete": is_pgz_admin(user), + } + + +# ── PUTNI NALOZI ────────────────────────────────────────────────────── +def can_view_putni_nalog(user, pn: Dict[str, Any]) -> bool: + if not pn: return False + if is_pgz_admin(user): return True + if is_savez_admin(user): + return klub_savez(pn.get("klub_id")) == user.get("savez_id") + if is_klub_user(user): + if user.get("klub_id") == pn.get("klub_id"): + return True + # Voditelj vidi svoj + return pn.get("user_id") == user.get("id") if user else False + + +def can_edit_putni_nalog(user, pn: Dict[str, Any]) -> bool: + """Edit dozvoljen samo na statusima draft/odbijen, i samo voditelju ili klub_admin/pgz.""" + if not pn: return False + status = (pn.get("status") or "draft").lower() + if status not in ("draft", "odbijen"): + return is_pgz_admin(user) + if is_pgz_admin(user): return True + if is_klub_admin(user): + return user.get("klub_id") == pn.get("klub_id") + # Voditelj + return pn.get("user_id") == user.get("id") if user else False + + +def can_submit_putni_nalog(user, pn: Dict[str, Any]) -> bool: + """Slanje (draft → poslan) — voditelj ili klub_admin.""" + if not pn: return False + if (pn.get("status") or "draft").lower() not in ("draft",): + return False + if is_pgz_admin(user): return True + if is_klub_admin(user): + return user.get("klub_id") == pn.get("klub_id") + return pn.get("user_id") == user.get("id") if user else False + + +def can_approve_putni_nalog(user, pn: Dict[str, Any]) -> bool: + """Odobravanje (poslan → odobren ili odbijen) — klub_admin svog kluba ili pgz_admin.""" + if not pn: return False + if (pn.get("status") or "").lower() not in ("poslan", "submitted", "draft"): + return False + if is_pgz_admin(user): return True + if is_klub_admin(user): + return user.get("klub_id") == pn.get("klub_id") + return False + + +def can_pay_putni_nalog(user, pn: Dict[str, Any]) -> bool: + """Isplata (odobren → isplaćen) — klub_admin ili pgz_admin.""" + if not pn: return False + if (pn.get("status") or "").lower() not in ("odobren", "approved", "zatvoren"): + return False + if is_pgz_admin(user): return True + if is_klub_admin(user): + return user.get("klub_id") == pn.get("klub_id") + return False + + +def putni_nalog_actions(user, pn: Dict[str, Any]) -> Dict[str, bool]: + return { + "view": can_view_putni_nalog(user, pn), + "edit": can_edit_putni_nalog(user, pn), + "submit": can_submit_putni_nalog(user, pn), + "approve": can_approve_putni_nalog(user, pn), + "reject": can_approve_putni_nalog(user, pn), + "pay": can_pay_putni_nalog(user, pn), + "delete": is_pgz_admin(user), + } + + +# ── Audit logging helper ────────────────────────────────────────────── +def audit_invoice(user, invoice_id: int, op: str, field: Optional[str] = None, + old=None, new=None): + try: + with _db() as c: + c.cursor().execute( + """INSERT INTO pgz_sport.audit_log + (tablica, operacija, record_id, korisnik, promijenjeno_polje, + stara_vrijednost, nova_vrijednost) + VALUES ('pgz_sport.invoices', %s, %s, %s, %s, %s, %s)""", + (op, invoice_id, + (user.get("email") if user else "anon"), + field, + None if old is None else str(old)[:500], + None if new is None else str(new)[:500]), + ) + except Exception: + pass + + +def audit_putni(user, pn_id: int, op: str, field: Optional[str] = None, + old=None, new=None): + try: + with _db() as c: + c.cursor().execute( + """INSERT INTO pgz_sport.audit_log + (tablica, operacija, record_id, korisnik, promijenjeno_polje, + stara_vrijednost, nova_vrijednost) + VALUES ('pgz_sport.expense_reports', %s, %s, %s, %s, %s, %s)""", + (op, pn_id, + (user.get("email") if user else "anon"), + field, + None if old is None else str(old)[:500], + None if new is None else str(new)[:500]), + ) + except Exception: + pass + + +def fetch_audit(table: str, record_id: int, limit: int = 50): + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """SELECT timestamp, operacija, korisnik, promijenjeno_polje, + stara_vrijednost, nova_vrijednost + FROM pgz_sport.audit_log + WHERE tablica=%s AND record_id=%s + ORDER BY timestamp DESC LIMIT %s""", + (table, record_id, limit), + ) + return cur.fetchall() diff --git a/erp/putni_nalozi.py b/erp/putni_nalozi.py index 50629ab..ce64755 100644 --- a/erp/putni_nalozi.py +++ b/erp/putni_nalozi.py @@ -14,6 +14,43 @@ import psycopg2 import psycopg2.extras from fastapi import APIRouter, Body, HTTPException, Query, Header +try: + from erp.permissions import ( + can_view_putni_nalog, can_edit_putni_nalog, can_submit_putni_nalog, + can_approve_putni_nalog, can_pay_putni_nalog, putni_nalog_actions, + audit_putni, fetch_audit, is_pgz_admin, + ) +except Exception: + def can_view_putni_nalog(u, p): return True + def can_edit_putni_nalog(u, p): return True + def can_submit_putni_nalog(u, p): return True + def can_approve_putni_nalog(u, p): return True + def can_pay_putni_nalog(u, p): return True + def putni_nalog_actions(u, p): return {"view": True, "edit": True, "submit": True, "approve": True, "reject": True, "pay": True, "delete": False} + def audit_putni(u, pid, op, field=None, old=None, new=None): pass + def fetch_audit(t, r, limit=50): return [] + def is_pgz_admin(u): return False + +try: + from auth.auth_v2 import get_current_user as _auth_user +except Exception: + _auth_user = None + +ADMIN_TOKEN = "admin-pgz-2026" + +def _resolve_user(authorization): + if _auth_user: + try: + u = _auth_user(authorization) + if u: return u + except Exception: + pass + if authorization and authorization.replace("Bearer ", "").strip() == ADMIN_TOKEN: + return {"id": 0, "email": "admin@token", "user_type": "pgz_admin", + "klub_id": None, "savez_id": None, "_synthetic": True} + return None + + router = APIRouter(prefix="/api/erp", tags=["erp-putni-nalozi"]) DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet", diff --git a/pgz_sport_api.py b/pgz_sport_api.py index ffe80f1..4dc567c 100644 --- a/pgz_sport_api.py +++ b/pgz_sport_api.py @@ -991,63 +991,9 @@ def admin_stats(): 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]} +# Legacy unauthenticated /api/admin/users CRUD removed (R4 #5). +# All /api/admin/users* endpoints are now served by auth.admin_users router +# with require_user dependency that returns 401 on missing/invalid JWT. # ──────── V6 AI GRADOVI / KILOMETRAŽA ──────── @@ -1408,6 +1354,13 @@ try: except Exception as e: print(f'[CRM/M9] obrasci router fail: {e}') +try: + from clan_panel_router import router as clan_panel_router + app.include_router(clan_panel_router) + print('[CRM/PANEL] clan_panel router loaded (/api/crm/clanovi/{id}/full|avatar)') +except Exception as e: + print(f'[CRM/PANEL] clan_panel router fail: {e}') + # === Round 3 / CC2 — M1 Auth + M2 Admin Users + M10 GDPR === try: from auth.auth_v2 import router as auth_v2_router diff --git a/routers/clan_panel_router.py b/routers/clan_panel_router.py new file mode 100644 index 0000000..b570244 --- /dev/null +++ b/routers/clan_panel_router.py @@ -0,0 +1,516 @@ +#!/usr/bin/env python3 +# ═══════════════════════════════════════════════════════════════════ +# Fajl: routers/clan_panel_router.py | v1.0.0 | 05.05.2026 +# Autor: Damir Radulić / damir@rinet.one +# Lokacija: /opt/pgz-sport/routers/clan_panel_router.py +# Svrha: CRM Dashboard člana — full panel (sve), edit s permission gating, +# avatar upload. +# ═══════════════════════════════════════════════════════════════════ +"""CRM Član Panel router. + +Endpointi (montirani na /api/crm): + GET /clanovi/{id}/full → SVI podaci o članu + povijest svega + PUT /clanovi/{id} → edit (permission gating po roli) + POST /clanovi/{id}/avatar → upload slike + GET /clanovi/search?q=... → quick search za panel +""" +from __future__ import annotations + +import os +import io +import shutil +import uuid as _uuid +from datetime import date, datetime +from decimal import Decimal +from typing import Optional, Any +from pathlib import Path + +import psycopg2 +from psycopg2.extras import RealDictCursor +from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Header +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +router = APIRouter(prefix="/api/crm", tags=["crm-clan-panel"]) + +DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7" + +UPLOADS_DIR = Path("/opt/pgz-sport/static/uploads/avatars") +UPLOADS_DIR.mkdir(parents=True, exist_ok=True) +PUBLIC_AVATAR_PREFIX = "/sport/static/uploads/avatars" + +# Polja koja smiju editirati pojedine role. +# Hard rules iz briefa: +# sportas (sebe): kontakt + slike +# klub_admin: sve osim OIB +# savez_admin: pregled + napomene +# pgz_admin: full +# super_admin: full +EDITABLE_BY_ROLE = { + "sportas": { + "email", "telefon", "adresa", "grad", "postanski_broj", + "biografija", "slika_url", + }, + "klub_admin": { + # sve osim "oib" + "ime", "prezime", "datum_rodenja", "spol", "adresa", "grad", + "postanski_broj", "email", "telefon", "kategorija", "podkategorija", + "pozicija", "licenca_broj", "licenca_vrijedi_do", "reprezentativac", + "kategoriziran", "kategorija_hoo", "stipendiran", "stipendija_iznos", + "radno_pravni_status", "aktivan", "datum_pristupa", "datum_napustanja", + "napomena", "dominantna_noga", "visina_cm", "tezina_kg", "broj_dresa", + "reprezentacija_kategorija", "biografija", "mjesto_rodenja", + "sport", "uloga", "uloga_detalj", "klub_id", "slika_url", + }, + "savez_admin": { + "napomena", + }, + "pgz_admin": "ALL", + "super_admin": "ALL", + "klub_trener": { + "kategorija", "podkategorija", "pozicija", "broj_dresa", + "dominantna_noga", "visina_cm", "tezina_kg", "napomena", "biografija", + }, +} + + +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 None if d is None else {k: _conv(v) for k, v in dict(d).items()} + + +def _resolve_role(authorization: Optional[str]) -> str: + """ + Vrlo pojednostavljeno: dok puni JWT M1 ne propagira context, čitamo + 'X-Role' header (postavi UI). Inače: ako je authorization == admin token + → pgz_admin, inače → viewer. + """ + if not authorization: + return "viewer" + tok = authorization.replace("Bearer ", "").strip() + if tok == "admin-pgz-2026": + return "pgz_admin" + # decode JWT (best-effort) + try: + import jwt as _jwt # type: ignore + # JWT secret iz auth_v2 — bez tvrde ovisnosti + for secret in (os.environ.get("JWT_SECRET"), "rinet-jwt-secret-2026"): + if not secret: + continue + try: + payload = _jwt.decode(tok, secret, algorithms=["HS256"]) + return payload.get("role") or payload.get("user_type") or "viewer" + except Exception: + continue + except Exception: + pass + return "viewer" + + +def _check_field_perm(role: str, fields: set[str]) -> set[str]: + """Vrati SAMO polja koja role smije editirati.""" + allowed = EDITABLE_BY_ROLE.get(role, set()) + if allowed == "ALL": + return fields + return fields & allowed + + +# ───────────── search ───────────── + +@router.get("/clanovi/search") +def clanovi_search(q: Optional[str] = Query(None, min_length=2), + klub_id: Optional[int] = Query(None), + limit: int = Query(20, le=100)): + where, params = ["c.aktivan = TRUE"], [] + if q: + where.append("(c.ime || ' ' || c.prezime) ILIKE %s OR c.oib ILIKE %s") + params += [f"%{q}%", f"%{q}%"] + if klub_id: + where.append("c.klub_id = %s"); params.append(klub_id) + params.append(limit) + sql = f""" + SELECT c.id, c.ime, c.prezime, c.oib, c.kategorija, c.pozicija, + c.slika_url, c.broj_dresa, + k.id AS klub_id, k.naziv AS klub + FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id + WHERE {' AND '.join(where)} + ORDER BY c.prezime, c.ime + LIMIT %s + """ + with _conn() as conn, conn.cursor() as cur: + cur.execute(sql, params) + rows = [_row(r) for r in cur.fetchall()] + return {"count": len(rows), "rows": rows} + + +# ───────────── full panel ───────────── + +@router.get("/clanovi/{cid}/full") +def clan_full(cid: int): + """ + Vraća SVE podatke o članu + sve povezane pod-tablice: + - personal, kontakt, sport, status, reprezentacija, stipendija + - klub (trenutni + povijest preko clan_sezona.klub_naziv) + - sezone (clan_sezona) + - utakmice (zadnjih 20 — clan_utakmica) + - lijecnicki (lijecnicki_pregledi po clan_id) + - clanarine (clanarine po clan_id) + dug + - dokumenti (clan_godisnjak ↔ dokumenti) + - obrasci (form_submissions po clan_id) + - nagrade (clan_nagrada) + """ + with _conn() as conn, conn.cursor() as cur: + cur.execute(""" + SELECT c.*, + k.id AS klub__id, + k.naziv AS klub__naziv, + k.oib AS klub__oib, + k.iban AS klub__iban, + k.adresa AS klub__adresa, + k.grad AS klub__grad, + k.sport AS klub__sport, + k.savez_id AS klub__savez_id, + s.naziv AS klub__savez_naziv, + EXTRACT(YEAR FROM age(COALESCE(c.datum_rodenja, c.datum_rodjenja)))::int AS dob_calc + FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id + LEFT JOIN pgz_sport.savezi s ON s.id = k.savez_id + WHERE c.id = %s + """, (cid,)) + clan_raw = cur.fetchone() + if not clan_raw: + raise HTTPException(404, "Član ne postoji") + + # rastavi klub__* u nested objekt + c = {} + klub: dict = {} + for k, v in dict(clan_raw).items(): + if k.startswith("klub__"): + klub[k.replace("klub__", "")] = v + else: + c[k] = v + + # avatar URL fallback (slika_url može biti relativna) + slika = c.get("slika_url") or "" + if slika and not (slika.startswith("http") or slika.startswith("/")): + slika = f"{PUBLIC_AVATAR_PREFIX}/{slika}" + c["slika_url_full"] = slika or None + + # SEZONE + cur.execute(""" + SELECT id, sezona, natjecanje, klub_naziv, nastupi, zapoceo, zamjena, + pogoci, asistencije, zuti_kartoni, crveni_kartoni, minute_total, + napomena, scrape_url + FROM pgz_sport.clan_sezona + WHERE clan_id = %s + ORDER BY sezona DESC + LIMIT 50 + """, (cid,)) + sezone = [_row(r) for r in cur.fetchall()] + + # UTAKMICE (zadnjih 20) + cur.execute(""" + SELECT id, datum, domacin, gost, rezultat, natjecanje, + pogoci, zuti, crveni, minute, utakmica_url + FROM pgz_sport.clan_utakmica + WHERE clan_id = %s + ORDER BY datum DESC NULLS LAST + LIMIT 20 + """, (cid,)) + utakmice = [_row(r) for r in cur.fetchall()] + + # LIJEČNIČKI + cur.execute(""" + SELECT id, datum_pregleda, vrijedi_do, vrsta_pregleda, ustanova, lijecnik, + spreman_za_natjecanje, ekg, krv, spirometrija, + placeno, iznos, datum_placanja, + (vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka, + CASE + WHEN vrijedi_do IS NULL THEN 'nepoznato' + WHEN vrijedi_do < CURRENT_DATE THEN 'istekao' + WHEN vrijedi_do <= (CURRENT_DATE + INTERVAL '30 days') THEN 'uskoro' + ELSE 'vazeci' + END AS status_calc + FROM pgz_sport.lijecnicki_pregledi + WHERE clan_id = %s + ORDER BY datum_pregleda DESC + """, (cid,)) + lijecnicki = [_row(r) for r in cur.fetchall()] + + # ČLANARINE + cur.execute(""" + SELECT id, godina, razdoblje, iznos_propisan, iznos_placen, + (iznos_propisan - COALESCE(iznos_placen,0))::numeric(10,2) AS dug, + datum_uplate, status, racun_broj, referenca, napomena + FROM pgz_sport.clanarine + WHERE clan_id = %s + ORDER BY godina DESC + """, (cid,)) + clanarine = [_row(r) for r in cur.fetchall()] + + # DOKUMENTI (preko clan_godisnjak) + cur.execute(""" + SELECT cg.id AS link_id, cg.godina, cg.snippet, cg.has_medal, cg.has_kategorija, + d.id AS dokument_id, d.title, d.url, d.pdf_url, d.izvor_url, + d.vrsta, d.organizacija, d.izdano_datum + FROM pgz_sport.clan_godisnjak cg + JOIN pgz_sport.dokumenti d ON d.id = cg.dokument_id + WHERE cg.clan_id = %s + ORDER BY cg.godina DESC + LIMIT 50 + """, (cid,)) + dokumenti = [_row(r) for r in cur.fetchall()] + + # OBRASCI (form_submissions) + cur.execute(""" + SELECT s.id, s.template_id, s.template_code, s.status, s.reference_no, + s.submitted_at, s.created_at, + t.naziv AS template_naziv, t.kategorija + FROM pgz_sport.form_submissions s + LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id + WHERE s.clan_id = %s + ORDER BY s.created_at DESC + """, (cid,)) + obrasci = [_row(r) for r in cur.fetchall()] + + # NAGRADE + cur.execute(""" + SELECT id, godina, sezona, natjecanje, razina_natjecanja, + dobna_kategorija, disciplina, plasman, klub_naziv + FROM pgz_sport.clan_nagrada + WHERE clan_id = %s + ORDER BY godina DESC NULLS LAST + LIMIT 50 + """, (cid,)) + nagrade = [_row(r) for r in cur.fetchall()] + + # POVIJEST KLUBOVA (iz clan_sezona.klub_naziv distinct) + cur.execute(""" + SELECT klub_naziv, MIN(sezona) AS od, MAX(sezona) AS do_, COUNT(*) AS broj_sezona + FROM pgz_sport.clan_sezona + WHERE clan_id = %s AND klub_naziv IS NOT NULL + GROUP BY klub_naziv + ORDER BY MAX(sezona) DESC + """, (cid,)) + povijest_klubova = [_row(r) for r in cur.fetchall()] + + # KPI / sažetak za panel + dug_total = sum(float(r.get("dug") or 0) for r in clanarine) + placeno_total = sum(float(r.get("iznos_placen") or 0) for r in clanarine) + propisan_total = sum(float(r.get("iznos_propisan") or 0) for r in clanarine) + last_lij = lijecnicki[0] if lijecnicki else None + nastupi_total = sum(int(r.get("nastupi") or 0) for r in sezone) + pogoci_total = sum(int(r.get("pogoci") or 0) for r in sezone) + + return { + "clan": _row(c), + "klub": _row(klub) if klub.get("id") else None, + "kpi": { + "dug_clanarina_eur": round(dug_total, 2), + "placeno_clanarina_eur": round(placeno_total, 2), + "propisan_clanarina_eur": round(propisan_total, 2), + "lijecnicki_status": last_lij and last_lij.get("status_calc"), + "lijecnicki_dana_do_isteka": last_lij and last_lij.get("dana_do_isteka"), + "broj_sezona": len(sezone), + "broj_utakmica_zadnjih": len(utakmice), + "nastupi_total": nastupi_total, + "pogoci_total": pogoci_total, + "broj_obrazaca": len(obrasci), + "broj_nagrada": len(nagrade), + }, + "sezone": sezone, + "utakmice_zadnje20": utakmice, + "lijecnicki": lijecnicki, + "clanarine": clanarine, + "dokumenti": dokumenti, + "obrasci": obrasci, + "nagrade": nagrade, + "povijest_klubova": povijest_klubova, + } + + +# ───────────── edit (PUT s permission gating) ───────────── + +class ClanPatch(BaseModel): + # Sva potencijalno-editabilna polja (subset full schema-e): + ime: Optional[str] = None + prezime: Optional[str] = None + oib: Optional[str] = None + datum_rodenja: Optional[date] = None + spol: Optional[str] = None + mjesto_rodenja: Optional[str] = None + adresa: Optional[str] = None + grad: Optional[str] = None + postanski_broj: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + kategorija: Optional[str] = None + podkategorija: Optional[str] = None + pozicija: Optional[str] = None + licenca_broj: Optional[str] = None + licenca_vrijedi_do: Optional[date] = None + reprezentativac: Optional[bool] = None + reprezentacija_kategorija: Optional[str] = None + kategoriziran: Optional[bool] = None + kategorija_hoo: Optional[int] = None + stipendiran: Optional[bool] = None + stipendija_iznos: Optional[float] = None + radno_pravni_status: Optional[str] = None + aktivan: Optional[bool] = None + datum_pristupa: Optional[date] = None + datum_napustanja: Optional[date] = None + napomena: Optional[str] = None + dominantna_noga: Optional[str] = None + visina_cm: Optional[int] = None + tezina_kg: Optional[int] = None + broj_dresa: Optional[int] = None + biografija: Optional[str] = None + sport: Optional[str] = None + uloga: Optional[str] = None + uloga_detalj: Optional[str] = None + klub_id: Optional[int] = None + slika_url: Optional[str] = None + + +@router.put("/clanovi/{cid}") +def update_clan(cid: int, patch: ClanPatch, + authorization: Optional[str] = Header(None), + x_role: Optional[str] = Header(None)): + role = (x_role or _resolve_role(authorization) or "viewer").lower() + + requested = {k: v for k, v in patch.dict(exclude_unset=True).items() if v is not None} + if not requested: + raise HTTPException(400, "Nema polja za izmjenu") + + allowed_fields = _check_field_perm(role, set(requested.keys())) + if not allowed_fields: + raise HTTPException(403, f"Role '{role}' nema dozvolu za nijedno od poslanih polja") + + rejected = set(requested.keys()) - allowed_fields + final = {k: requested[k] for k in allowed_fields} + + set_clauses = [f"{k} = %s" for k in final.keys()] + set_clauses.append("updated_at = now()") + params = list(final.values()) + [cid] + + with _conn() as conn, conn.cursor() as cur: + cur.execute(f"UPDATE pgz_sport.clanovi SET {', '.join(set_clauses)} WHERE id=%s RETURNING *", + params) + r = cur.fetchone() + if not r: + raise HTTPException(404, "Član ne postoji") + # audit log (best-effort) + try: + import json as _json + cur.execute("""INSERT INTO pgz_sport.audit_feed (entity_type, entity_id, action, payload) + VALUES (%s,%s,%s,%s::jsonb)""", + ("clan", cid, "edit", + _json.dumps({"role": role, + "applied": list(final.keys()), + "rejected": list(rejected)}))) + except Exception: + pass + conn.commit() + + return { + "ok": True, + "id": cid, + "role": role, + "applied_fields": sorted(final.keys()), + "rejected_fields": sorted(rejected), + "clan": _row(r), + } + + +# ───────────── avatar upload ───────────── + +@router.post("/clanovi/{cid}/avatar") +async def upload_avatar(cid: int, file: UploadFile = File(...), + authorization: Optional[str] = Header(None), + x_role: Optional[str] = Header(None)): + role = (x_role or _resolve_role(authorization) or "viewer").lower() + if role not in EDITABLE_BY_ROLE and role not in ("pgz_admin", "super_admin"): + # sportas/klub_admin/savez_admin/pgz_admin/super_admin svi smiju + # (sportas ako je 'sebe' — UI to validira preko user_id, ovdje server + # primarno gata po roli; future M1 JWT propagacija će validirati clan_id == self) + raise HTTPException(403, f"Role '{role}' nema dozvolu za upload avatara") + + # validate file type + allowed_ct = {"image/jpeg", "image/png", "image/webp", "image/gif"} + ext_map = {"image/jpeg": "jpg", "image/png": "png", + "image/webp": "webp", "image/gif": "gif"} + ct = (file.content_type or "").lower() + if ct not in allowed_ct: + raise HTTPException(400, f"Nedozvoljeni tip slike: {ct}. Dozvoljeno: jpeg/png/webp/gif") + + # provjeri da član postoji + with _conn() as conn, conn.cursor() as cur: + cur.execute("SELECT id, slika_url FROM pgz_sport.clanovi WHERE id=%s", (cid,)) + r = cur.fetchone() + if not r: + raise HTTPException(404, "Član ne postoji") + + # save file + fname = f"{cid}-{_uuid.uuid4().hex[:8]}.{ext_map[ct]}" + fpath = UPLOADS_DIR / fname + contents = await file.read() + if len(contents) > 5 * 1024 * 1024: + raise HTTPException(413, "Slika prevelika (max 5 MB)") + with open(fpath, "wb") as fh: + fh.write(contents) + + public_url = f"{PUBLIC_AVATAR_PREFIX}/{fname}" + + # update DB + with _conn() as conn, conn.cursor() as cur: + # obriši staru sliku (best-effort, samo ako je u uploads/avatars/) + old = r["slika_url"] + if old and PUBLIC_AVATAR_PREFIX in old: + try: + old_name = old.split("/")[-1] + old_path = UPLOADS_DIR / old_name + if old_path.exists() and str(old_path).startswith(str(UPLOADS_DIR)): + old_path.unlink() + except Exception: + pass + cur.execute("UPDATE pgz_sport.clanovi SET slika_url=%s, updated_at=now() WHERE id=%s", + (public_url, cid)) + conn.commit() + return { + "ok": True, + "id": cid, + "slika_url": public_url, + "size_bytes": len(contents), + "content_type": ct, + "filename": fname, + } + + +# ───────────── permissions info (za UI) ───────────── + +@router.get("/clanovi/permissions") +def permissions_matrix(role: Optional[str] = Query(None)): + if role: + r = role.lower() + allowed = EDITABLE_BY_ROLE.get(r, set()) + return {"role": r, "editable": "ALL" if allowed == "ALL" else sorted(allowed)} + return { + "roles": { + r: ("ALL" if v == "ALL" else sorted(v)) + for r, v in EDITABLE_BY_ROLE.items() + } + } diff --git a/static/audit.html b/static/audit.html new file mode 100644 index 0000000..8b7a947 --- /dev/null +++ b/static/audit.html @@ -0,0 +1,150 @@ + + + + +Audit Log — PGŽ Sport + + + + + +

📜 Audit Log

+
Kompletna povijest izmjena s blockchain pečatima na Polygon PoS
+ +
+
Ukupno akcija
+
Danas
+
Polygon zapečaćeno
+
Aktivni korisnici
+
+ +
+ + + + + +
+ + + + + + + + + + + + + + + +
VrijemeKorisnikAkcijaResursDetaljiPolygon Tx
⏳ Učitavam...
+ + + + diff --git a/static/login.html b/static/login.html index 14c8a5b..d243ef5 100644 --- a/static/login.html +++ b/static/login.html @@ -373,7 +373,7 @@ body {