Files
pgz-sport/pgz_sport_v2_router.py.pre_b_switch.1777891945

5351 lines
247 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
pgz_sport_extended_api.py - Multi-tenant + ERP/CRM extension for /api/v1/*
Author: Damir Radulić (damir@rinet.one)
Date: 28.04.2026
Port: 8095 (mounted under /api/v2/)
Endpoints: auth, users, roles, klub-access, invoices, forms, alerts, expense reports, RAG sport agent
"""
from fastapi import APIRouter, HTTPException, Query, Body, Header, Depends, UploadFile, File, Form
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
from datetime import date, datetime, timedelta
import psycopg2, psycopg2.extras
import hashlib, secrets, json, requests, os, re, time
DB = dict(host='localhost', port=5432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7')
QDRANT = "http://localhost:6333"
EMBED = "http://localhost:9879/api/embeddings"
COLL = "pgz_sport_v1"
router = APIRouter(prefix="/api/v2", tags=["pgz_sport_v2"])
# ---------------- DB helpers ----------------
def db_query(sql: str, params=()):
with psycopg2.connect(**DB) as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, params)
if cur.description: return cur.fetchall()
return []
def db_one(sql: str, params=()):
rows = db_query(sql, params)
return rows[0] if rows else None
def db_exec(sql: str, params=()):
with psycopg2.connect(**DB) as c:
cur = c.cursor()
cur.execute(sql, params)
if cur.description:
r = cur.fetchone()
return r[0] if r else None
c.commit()
# ---------------- Auth helpers ----------------
def hash_pw(pw: str) -> str:
return hashlib.sha256(pw.encode()).hexdigest()
def make_token() -> str:
return secrets.token_urlsafe(32)
def get_current_user(authorization: Optional[str] = Header(None)) -> Optional[Dict]:
if not authorization: return None
token = authorization.replace('Bearer ','').strip()
th = hashlib.sha256(token.encode()).hexdigest()
s = db_one("""SELECT s.user_id, u.email, u.full_name, u.status,
u.user_type, u.klub_id, u.savez_id, u.aktivan, u.ime, u.prezime,
u.must_change_pwd
FROM pgz_sport.user_sessions s
JOIN pgz_sport.users u ON u.id=s.user_id
WHERE s.token_hash=%s AND s.revoked=false AND s.expires_at>now()""", (th,))
if not s or not s.get('aktivan', True): return None
s['_token'] = token
return s
# ═══ RBAC HELPERS ═══
def is_super(user) -> bool:
return user and user.get('user_type') == 'super_admin'
def is_pgz_admin(user) -> bool:
return user and user.get('user_type') in ('super_admin', 'pgz_admin')
def can_manage_users(user) -> bool:
return is_pgz_admin(user)
def require_role(user, allowed: list):
if not user or user.get('user_type') not in allowed:
raise HTTPException(403, f"Forbidden — required role: {','.join(allowed)}")
def tenant_filter_users(user) -> tuple:
"""Returns (sql_where, params) tuple to scope users list to the user's tenant."""
ut = user.get('user_type')
if ut == 'super_admin' or ut == 'pgz_admin' or ut == 'pgz_user' or ut == 'pgz_finance' or ut == 'pgz_zzjz':
return ("", [])
if ut == 'savez_admin' or ut == 'savez_user':
sid = user.get('savez_id')
if not sid: return ("AND 1=0", [])
return ("AND (savez_id=%s OR id IN (SELECT user_id FROM pgz_sport.user_klub_links WHERE savez_id=%s))", [sid, sid])
if ut == 'klub_admin' or ut == 'klub_user':
kid = user.get('klub_id')
if not kid: return ("AND 1=0", [])
return ("AND (klub_id=%s OR id IN (SELECT user_id FROM pgz_sport.user_klub_links WHERE klub_id=%s))", [kid, kid])
# klub_clan, guest, viewer → only self
return ("AND id=%s", [user['user_id']])
def require_user(user = Depends(get_current_user)):
if not user: raise HTTPException(401, "Authentication required")
return user
def user_has_role(user_id: int, role_code: str, scope_type: str = None, scope_id: int = None) -> bool:
sql = """SELECT 1 FROM pgz_sport.user_roles ur
JOIN pgz_sport.roles r ON r.id=ur.role_id
WHERE ur.user_id=%s AND r.code=%s AND ur.active=true
AND (ur.expires_at IS NULL OR ur.expires_at>now())"""
args = [user_id, role_code]
if scope_type:
sql += " AND (ur.scope_type=%s OR ur.scope_type='global')"
args.append(scope_type)
if scope_id:
sql += " AND (ur.scope_id=%s OR ur.scope_id IS NULL)"
args.append(scope_id)
return bool(db_one(sql, tuple(args)))
# ============== AUTH ENDPOINTS ==============
class LoginReq(BaseModel):
email: str
password: str
@router.post("/auth/login")
def login(req: LoginReq):
u = db_one("""SELECT id, email, full_name, password_hash, status,
ime, prezime, user_type, klub_id, savez_id, must_change_pwd, aktivan,
locked_until, failed_login_count
FROM pgz_sport.users WHERE email=%s""", (req.email.lower().strip(),))
if not u or u['status'] != 'active' or not u.get('aktivan', True):
raise HTTPException(401, "Invalid credentials")
if u.get('locked_until') and u['locked_until'].tzinfo is not None:
from datetime import timezone
if u['locked_until'] > datetime.now(timezone.utc):
raise HTTPException(423, "Korisnik je privremeno zaključan")
if not u['password_hash'] or u['password_hash'] != hash_pw(req.password):
# bump failed counter, lock after 5
db_exec("""UPDATE pgz_sport.users SET failed_login_count=COALESCE(failed_login_count,0)+1,
locked_until=CASE WHEN COALESCE(failed_login_count,0)+1>=5 THEN now()+interval '15 minutes' ELSE locked_until END
WHERE id=%s""", (u['id'],))
raise HTTPException(401, "Invalid credentials")
db_exec("UPDATE pgz_sport.users SET failed_login_count=0, locked_until=NULL WHERE id=%s", (u['id'],))
token = make_token()
th = hashlib.sha256(token.encode()).hexdigest()
expires = datetime.now() + timedelta(days=30)
db_exec("""INSERT INTO pgz_sport.user_sessions (user_id, token_hash, expires_at)
VALUES (%s,%s,%s)""", (u['id'], th, expires))
db_exec("UPDATE pgz_sport.users SET last_login=now() WHERE id=%s", (u['id'],))
db_exec("""INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,'login')""", (u['id'],))
roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id
FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id
WHERE ur.user_id=%s AND ur.active=true""", (u['id'],))
return {
"token": token,
"expires_at": expires.isoformat(),
"user": {
"id": u['id'], "email": u['email'], "full_name": u['full_name'],
"ime": u.get('ime'), "prezime": u.get('prezime'),
"user_type": u.get('user_type'), "klub_id": u.get('klub_id'), "savez_id": u.get('savez_id'),
"must_change_pwd": bool(u.get('must_change_pwd')),
"roles": roles
}
}
@router.post("/auth/logout")
def logout(user = Depends(require_user)):
th = hashlib.sha256(user.get('_token','').encode()).hexdigest()
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (user['user_id'],))
return {"status":"ok"}
@router.get("/auth/me")
def me(user = Depends(require_user)):
enriched = db_one("""SELECT id, email, full_name, ime, prezime, user_type,
klub_id, savez_id, must_change_pwd, aktivan, status,
last_login, oib, telefon, phone, preferred_language
FROM pgz_sport.users WHERE id=%s""", (user['user_id'],))
if not enriched:
raise HTTPException(404, "User not found")
roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id
FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id
WHERE ur.user_id=%s AND ur.active=true""", (user['user_id'],))
klubovi = db_query("""SELECT k.id, k.naziv, ukl.link_type, ukl.primary_klub, ukl.role
FROM pgz_sport.user_klub_links ukl JOIN pgz_sport.klubovi k ON k.id=ukl.klub_id
WHERE ukl.user_id=%s AND (ukl.do_datuma IS NULL OR ukl.do_datuma>now()::date)""",
(user['user_id'],))
savezi = db_query("""SELECT s.id, s.naziv, ukl.role
FROM pgz_sport.user_klub_links ukl JOIN pgz_sport.savezi s ON s.id=ukl.savez_id
WHERE ukl.user_id=%s AND ukl.savez_id IS NOT NULL""",
(user['user_id'],))
return {**user, **enriched, "must_change_pwd": bool(enriched.get('must_change_pwd')),
"roles": roles, "klubovi": klubovi, "savezi": savezi}
class ChangePwdReq(BaseModel):
new_password: str
old_password: Optional[str] = None
@router.post("/auth/change-password")
def change_password(req: ChangePwdReq, user = Depends(require_user)):
if len(req.new_password) < 8:
raise HTTPException(400, "Password mora imati barem 8 znakova")
u = db_one("SELECT password_hash, must_change_pwd FROM pgz_sport.users WHERE id=%s", (user['user_id'],))
if not u: raise HTTPException(404, "User not found")
# If not in must_change_pwd flow, require old password
if not u.get('must_change_pwd'):
if not req.old_password:
raise HTTPException(400, "old_password required")
if u['password_hash'] != hash_pw(req.old_password):
raise HTTPException(401, "Stara lozinka netočna")
new_hash = hash_pw(req.new_password)
db_exec("""UPDATE pgz_sport.users
SET password_hash=%s, must_change_pwd=false, updated_at=now()
WHERE id=%s""", (new_hash, user['user_id']))
db_exec("""INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,'password.change')""",
(user['user_id'],))
return {"status":"ok"}
# ═══ ADMIN USER MGMT — extended ═══
@router.get("/users/list")
def list_users_v2(
q: Optional[str] = None,
user_type: Optional[str] = None,
klub_id: Optional[int] = None,
savez_id: Optional[int] = None,
aktivan: Optional[bool] = None,
limit: int = 100,
offset: int = 0,
user = Depends(require_user)
):
"""List users with tenant filter applied automatically based on caller's role."""
where = ["1=1"]
args = []
tf, tp = tenant_filter_users(user)
if tf:
where.append(tf.replace('AND ', ''))
args.extend(tp)
if q:
where.append("(LOWER(email) LIKE %s OR LOWER(ime) LIKE %s OR LOWER(prezime) LIKE %s OR LOWER(full_name) LIKE %s)")
args.extend([f"%{q.lower()}%"]*4)
if user_type: where.append("user_type=%s"); args.append(user_type)
if klub_id: where.append("klub_id=%s"); args.append(klub_id)
if savez_id: where.append("savez_id=%s"); args.append(savez_id)
if aktivan is not None: where.append("aktivan=%s"); args.append(aktivan)
sql = f"""SELECT id, email, ime, prezime, full_name, user_type, klub_id, savez_id,
aktivan, must_change_pwd, last_login, locked_until, failed_login_count,
telefon, oib, status, created_at
FROM pgz_sport.users WHERE {" AND ".join(where)}
ORDER BY id LIMIT %s OFFSET %s"""
args.extend([limit, offset])
rows = db_query(sql, tuple(args))
cnt_sql = f"SELECT COUNT(*) AS c FROM pgz_sport.users WHERE {" AND ".join(where)}"
total = db_one(cnt_sql, tuple(args[:-2]))['c']
return {"count": len(rows), "total": total, "results": rows}
class CreateUserV2Req(BaseModel):
email: str
ime: Optional[str] = None
prezime: Optional[str] = None
user_type: str = 'klub_user'
klub_id: Optional[int] = None
savez_id: Optional[int] = None
telefon: Optional[str] = None
oib: Optional[str] = None
password: Optional[str] = None # if not provided, default 'PgzSport2026!' + must_change_pwd
@router.post("/users/create")
def create_user_v2(req: CreateUserV2Req, user = Depends(require_user)):
require_role(user, ['super_admin','pgz_admin'])
pwd = req.password or 'PgzSport2026!'
must_change = not bool(req.password)
full_name = (req.ime or '') + ' ' + (req.prezime or '')
full_name = full_name.strip() or req.email
try:
new_id = db_one("""INSERT INTO pgz_sport.users
(email, password_hash, ime, prezime, full_name, user_type, klub_id, savez_id,
telefon, oib, must_change_pwd, aktivan, status, auth_provider, created_by)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,true,'active','local',%s)
RETURNING id""",
(req.email.lower().strip(), hash_pw(pwd), req.ime, req.prezime, full_name,
req.user_type, req.klub_id, req.savez_id, req.telefon, req.oib, must_change,
user['user_id']))['id']
db_exec("INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,%s)",
(user['user_id'], f'user.create:{new_id}'))
return {"id": new_id, "email": req.email, "must_change_pwd": must_change,
"temporary_password": pwd if must_change else None}
except Exception as e:
if 'duplicate' in str(e).lower() or 'unique' in str(e).lower():
raise HTTPException(409, f"Email već postoji: {req.email}")
raise HTTPException(400, str(e))
class EditUserReq(BaseModel):
ime: Optional[str] = None
prezime: Optional[str] = None
user_type: Optional[str] = None
klub_id: Optional[int] = None
savez_id: Optional[int] = None
telefon: Optional[str] = None
oib: Optional[str] = None
aktivan: Optional[bool] = None
@router.put("/users/{uid}")
def edit_user(uid: int, req: EditUserReq, user = Depends(require_user)):
# Self-edit allowed for limited fields, admin-edit for all
self_edit = uid == user['user_id']
if not self_edit and not is_pgz_admin(user):
raise HTTPException(403, "Forbidden")
fields = []
args = []
allowed_self = {'ime','prezime','telefon'}
for f in ['ime','prezime','user_type','klub_id','savez_id','telefon','oib','aktivan']:
v = getattr(req, f)
if v is not None:
if self_edit and f not in allowed_self and not is_pgz_admin(user):
continue
fields.append(f"{f}=%s")
args.append(v)
if not fields: return {"status":"nothing to update"}
if 'ime' in [f.split('=')[0] for f in fields] or 'prezime' in [f.split('=')[0] for f in fields]:
# rebuild full_name
cur = db_one("SELECT ime, prezime FROM pgz_sport.users WHERE id=%s", (uid,))
new_ime = req.ime if req.ime is not None else (cur['ime'] if cur else '')
new_prez = req.prezime if req.prezime is not None else (cur['prezime'] if cur else '')
fn = ((new_ime or '') + ' ' + (new_prez or '')).strip()
fields.append("full_name=%s"); args.append(fn)
fields.append("updated_at=now()")
args.append(uid)
db_exec(f"UPDATE pgz_sport.users SET {', '.join(fields)} WHERE id=%s", tuple(args))
db_exec("INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,%s)",
(user['user_id'], f'user.edit:{uid}'))
return {"status":"ok", "id": uid}
@router.post("/users/{uid}/reset-password")
def admin_reset_password(uid: int, user = Depends(require_user)):
require_role(user, ['super_admin','pgz_admin'])
new_temp = 'PgzSport' + secrets.token_hex(3) # e.g. PgzSporta3f2c1
db_exec("""UPDATE pgz_sport.users SET password_hash=%s, must_change_pwd=true,
failed_login_count=0, locked_until=NULL, updated_at=now() WHERE id=%s""",
(hash_pw(new_temp), uid))
db_exec("INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,%s)",
(user['user_id'], f'user.reset-pwd:{uid}'))
# Revoke all active sessions
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,))
return {"status":"ok", "temporary_password": new_temp}
@router.post("/users/{uid}/toggle-active")
def admin_toggle_active(uid: int, user = Depends(require_user)):
require_role(user, ['super_admin','pgz_admin'])
r = db_one("UPDATE pgz_sport.users SET aktivan=NOT aktivan WHERE id=%s RETURNING aktivan", (uid,))
if not r: raise HTTPException(404, "User not found")
if not r['aktivan']:
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,))
db_exec("INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,%s)",
(user['user_id'], f'user.toggle:{uid}:{r["aktivan"]}'))
return {"id": uid, "aktivan": r['aktivan']}
@router.post("/users/{uid}/unlock")
def admin_unlock(uid: int, user = Depends(require_user)):
require_role(user, ['super_admin','pgz_admin'])
db_exec("UPDATE pgz_sport.users SET failed_login_count=0, locked_until=NULL WHERE id=%s", (uid,))
return {"status":"ok"}
@router.get("/users/{uid}/audit")
def user_audit(uid: int, limit: int = 50, user = Depends(require_user)):
require_role(user, ['super_admin','pgz_admin'])
rows = db_query("""SELECT id, action, user_id, ts AS created_at, meta AS payload,
resource_type, resource_id, ip_address
FROM pgz_sport.audit_events
WHERE user_id=%s OR meta::text LIKE %s
ORDER BY id DESC LIMIT %s""", (uid, f'%"user_id":{uid}%', limit))
return {"count": len(rows), "results": rows}
@router.get("/admin/audit")
def global_audit(action: Optional[str]=None, user_id: Optional[int]=None,
limit: int=100, offset: int=0, user = Depends(require_user)):
require_role(user, ['super_admin','pgz_admin'])
where = ["1=1"]; args = []
if action: where.append("action LIKE %s"); args.append(f'%{action}%')
if user_id: where.append("user_id=%s"); args.append(user_id)
args.extend([limit, offset])
rows = db_query(f"""SELECT a.id, a.action, a.user_id,
a.ts AS created_at, a.meta AS payload,
a.resource_type, a.resource_id, a.ip_address,
u.email, u.ime, u.prezime
FROM pgz_sport.audit_events a
LEFT JOIN pgz_sport.users u ON u.id=a.user_id
WHERE {" AND ".join(where)}
ORDER BY a.id DESC LIMIT %s OFFSET %s""", tuple(args))
return {"count": len(rows), "results": rows}
@router.get("/admin/permissions-matrix")
def perms_matrix(user = Depends(require_user)):
"""Returns roles with their JSONB permissions, for the editor UI."""
require_role(user, ['super_admin','pgz_admin'])
roles = db_query("SELECT id, code, naziv, opis, permissions FROM pgz_sport.roles ORDER BY id")
user_overrides = db_query("""SELECT user_id, permission_code, granted, granted_at
FROM pgz_sport.user_permissions ORDER BY user_id, permission_code""")
return {"roles": roles, "user_overrides": user_overrides}
class PermissionGrantReq(BaseModel):
user_id: int
permission_code: str
granted: bool = True
note: Optional[str] = None
@router.post("/admin/permissions/grant")
def grant_permission(req: PermissionGrantReq, user = Depends(require_user)):
require_role(user, ['super_admin','pgz_admin'])
db_exec("""INSERT INTO pgz_sport.user_permissions (user_id, permission_code, granted, granted_by, note)
VALUES (%s,%s,%s,%s,%s)
ON CONFLICT (user_id, permission_code) DO UPDATE SET
granted=EXCLUDED.granted, granted_by=EXCLUDED.granted_by,
granted_at=now(), note=EXCLUDED.note""",
(req.user_id, req.permission_code, req.granted, user['user_id'], req.note))
return {"status":"ok"}
# ═══ KLUB-LINK CRUD ═══
class KlubLinkReq(BaseModel):
user_id: int
klub_id: Optional[int] = None
savez_id: Optional[int] = None
role: str = 'membership'
primary_klub: bool = False
@router.post("/users/klub-link")
def klub_link_create(req: KlubLinkReq, user = Depends(require_user)):
require_role(user, ['super_admin','pgz_admin','savez_admin','klub_admin'])
if not req.klub_id and not req.savez_id:
raise HTTPException(400, "klub_id or savez_id required")
new_id = db_one("""INSERT INTO pgz_sport.user_klub_links
(user_id, klub_id, savez_id, role, link_type, primary_klub, granted_by, granted_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,now())
ON CONFLICT (user_id, klub_id, link_type, od_datuma) DO UPDATE SET
role=EXCLUDED.role, primary_klub=EXCLUDED.primary_klub, granted_at=now()
RETURNING id""",
(req.user_id, req.klub_id, req.savez_id, req.role, req.role,
req.primary_klub, user['user_id']))['id']
return {"id": new_id}
@router.delete("/users/klub-link/{lid}")
def klub_link_delete(lid: int, user = Depends(require_user)):
require_role(user, ['super_admin','pgz_admin','savez_admin','klub_admin'])
db_exec("DELETE FROM pgz_sport.user_klub_links WHERE id=%s", (lid,))
return {"status":"ok"}
# ═══ IMPERSONATE (super_admin only) ═══
class ImpersonateReq(BaseModel):
target_user_id: int
@router.post("/admin/impersonate")
def impersonate(req: ImpersonateReq, user = Depends(require_user)):
require_role(user, ['super_admin'])
target = db_one("SELECT id, email, full_name FROM pgz_sport.users WHERE id=%s AND aktivan=true",
(req.target_user_id,))
if not target: raise HTTPException(404, "Target user not found or inactive")
# Issue a session token for target user, with audit tag
token = make_token()
th = hashlib.sha256(token.encode()).hexdigest()
expires = datetime.now() + timedelta(hours=2) # short-lived impersonation
db_exec("""INSERT INTO pgz_sport.user_sessions (user_id, token_hash, expires_at)
VALUES (%s,%s,%s)""", (req.target_user_id, th, expires))
db_exec("""INSERT INTO pgz_sport.audit_events (user_id, action)
VALUES (%s,%s)""",
(user['user_id'], f'admin.impersonate:{req.target_user_id}'))
return {"token": token, "expires_at": expires.isoformat(), "as_user": target,
"impersonated_by": user['email']}
# ============== USER MANAGEMENT (super_admin / pgz_admin) ==============
class CreateUserReq(BaseModel):
email: str
full_name: str
password: Optional[str] = None
oib: Optional[str] = None
phone: Optional[str] = None
role_code: str = 'klub_user'
scope_type: Optional[str] = None
scope_id: Optional[int] = None
@router.post("/users")
def create_user(req: CreateUserReq, user = Depends(require_user)):
if not (user_has_role(user['user_id'],'super_admin') or
user_has_role(user['user_id'],'pgz_admin') or
user_has_role(user['user_id'],'klub_admin', 'klub', req.scope_id)):
raise HTTPException(403, "Forbidden")
pw_hash = hash_pw(req.password) if req.password else None
uid = db_exec("""INSERT INTO pgz_sport.users
(email, full_name, oib, phone, password_hash, status, email_verified)
VALUES (%s,%s,%s,%s,%s,'active',false) RETURNING id""",
(req.email.lower().strip(), req.full_name, req.oib, req.phone, pw_hash))
role_id = db_one("SELECT id FROM pgz_sport.roles WHERE code=%s", (req.role_code,))
if role_id:
db_exec("""INSERT INTO pgz_sport.user_roles (user_id, role_id, scope_type, scope_id, granted_by)
VALUES (%s,%s,%s,%s,%s)""", (uid, role_id['id'], req.scope_type, req.scope_id, user['user_id']))
db_exec("""INSERT INTO pgz_sport.audit_events (user_id, action, resource_type, resource_id)
VALUES (%s,'create_user','user',%s)""", (user['user_id'], uid))
return {"id": uid, "email": req.email}
@router.get("/users")
def list_users(klub_id: Optional[int]=None, role: Optional[str]=None, limit:int=100, user = Depends(require_user)):
where, args = ["1=1"], []
if klub_id:
where.append("EXISTS (SELECT 1 FROM pgz_sport.user_klub_links ukl WHERE ukl.user_id=u.id AND ukl.klub_id=%s)")
args.append(klub_id)
if role:
where.append("""EXISTS (SELECT 1 FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id
WHERE ur.user_id=u.id AND r.code=%s AND ur.active=true)""")
args.append(role)
args.append(limit)
return db_query(f"""SELECT u.id, u.email, u.full_name, u.status, u.last_login,
(SELECT array_agg(r.code) FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id
WHERE ur.user_id=u.id AND ur.active=true) AS roles
FROM pgz_sport.users u WHERE {" AND ".join(where)} ORDER BY u.id LIMIT %s""", args)
class GrantRoleReq(BaseModel):
user_id: int
role_code: str
scope_type: Optional[str] = None
scope_id: Optional[int] = None
expires_at: Optional[datetime] = None
@router.post("/users/grant-role")
def grant_role(req: GrantRoleReq, user = Depends(require_user)):
if not user_has_role(user['user_id'],'super_admin'):
if not user_has_role(user['user_id'],'pgz_admin'):
raise HTTPException(403, "Only super_admin or pgz_admin can grant roles")
role = db_one("SELECT id FROM pgz_sport.roles WHERE code=%s", (req.role_code,))
if not role: raise HTTPException(404, "Unknown role")
db_exec("""INSERT INTO pgz_sport.user_roles
(user_id, role_id, scope_type, scope_id, granted_by, expires_at)
VALUES (%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""",
(req.user_id, role['id'], req.scope_type, req.scope_id, user['user_id'], req.expires_at))
db_exec("""INSERT INTO pgz_sport.audit_events
(user_id, action, resource_type, resource_id, meta)
VALUES (%s,'grant_role','user',%s,%s::jsonb)""",
(user['user_id'], req.user_id, json.dumps({'role':req.role_code,'scope':req.scope_type})))
return {"status":"ok"}
@router.get("/roles")
def list_roles():
return db_query("SELECT id, code, naziv, opis, permissions FROM pgz_sport.roles ORDER BY id")
# ============== KLUB MEMBERSHIP LINKS ==============
class LinkUserKlubReq(BaseModel):
user_id: int
klub_id: int
clan_id: Optional[int] = None
link_type: str # 'sportas','trener','tajnik','predsjednik','clan_uprave','volonter'
od_datuma: Optional[date] = None
primary_klub: bool = True
napomena: Optional[str] = None
@router.post("/klub-links")
def link_user_klub(req: LinkUserKlubReq, user = Depends(require_user)):
if not (user_has_role(user['user_id'],'super_admin') or
user_has_role(user['user_id'],'pgz_admin') or
user_has_role(user['user_id'],'klub_admin','klub', req.klub_id)):
raise HTTPException(403, "Forbidden")
db_exec("""INSERT INTO pgz_sport.user_klub_links
(user_id, klub_id, clan_id, link_type, od_datuma, primary_klub, napomena)
VALUES (%s,%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""",
(req.user_id, req.klub_id, req.clan_id, req.link_type,
req.od_datuma or date.today(), req.primary_klub, req.napomena))
return {"status":"ok"}
# ============== INVOICES (ERP) ==============
class CreateInvoiceReq(BaseModel):
klub_id: int
invoice_kind: str # 'ulazni'|'izlazni'
invoice_no: str
vendor_name: Optional[str] = None
vendor_oib: Optional[str] = None
customer_name: Optional[str] = None
customer_oib: Optional[str] = None
invoice_date: date
due_date: Optional[date] = None
amount_net: Optional[float] = None
amount_vat: Optional[float] = None
amount_gross: float
vat_rate: Optional[float] = 25
description: Optional[str] = None
category: Optional[str] = None
account_code: Optional[str] = None
@router.post("/invoices")
def create_invoice(req: CreateInvoiceReq, user = Depends(require_user)):
if not (user_has_role(user['user_id'],'super_admin') or
user_has_role(user['user_id'],'klub_admin','klub',req.klub_id)):
raise HTTPException(403, "Forbidden")
iid = db_exec("""INSERT INTO pgz_sport.invoices
(klub_id, invoice_kind, invoice_no, vendor_name, vendor_oib, customer_name, customer_oib,
invoice_date, due_date, amount_net, amount_vat, amount_gross, vat_rate,
description, category, account_code, created_by)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id""", (req.klub_id, req.invoice_kind, req.invoice_no,
req.vendor_name, req.vendor_oib, req.customer_name, req.customer_oib,
req.invoice_date, req.due_date, req.amount_net, req.amount_vat, req.amount_gross,
req.vat_rate, req.description, req.category, req.account_code, user['user_id']))
db_exec("""INSERT INTO pgz_sport.audit_events
(user_id, action, resource_type, resource_id)
VALUES (%s,'create_invoice','invoice',%s)""", (user['user_id'], iid))
return {"id": iid}
@router.get("/invoices")
def list_invoices(klub_id: Optional[int]=None, kind: Optional[str]=None,
status: Optional[str]=None, year: Optional[int]=None,
limit:int=200, user=Depends(require_user)):
where, args = ["1=1"], []
if klub_id: where.append("klub_id=%s"); args.append(klub_id)
if kind: where.append("invoice_kind=%s"); args.append(kind)
if status: where.append("payment_status=%s"); args.append(status)
if year: where.append("EXTRACT(year FROM invoice_date)=%s"); args.append(year)
args.append(limit)
return db_query(f"""SELECT id, invoice_no, invoice_kind, vendor_name, customer_name,
invoice_date, due_date, amount_gross, currency, payment_status, category, klub_id
FROM pgz_sport.invoices WHERE {" AND ".join(where)}
ORDER BY invoice_date DESC LIMIT %s""", args)
@router.get("/invoices/{invoice_id}")
def get_invoice(invoice_id: int, user=Depends(require_user)):
inv = db_one("SELECT * FROM pgz_sport.invoices WHERE id=%s", (invoice_id,))
if not inv: raise HTTPException(404)
inv['lines'] = db_query("""SELECT * FROM pgz_sport.invoice_lines
WHERE invoice_id=%s ORDER BY line_no""", (invoice_id,))
inv['payments'] = db_query("""SELECT * FROM pgz_sport.payments
WHERE invoice_id=%s ORDER BY payment_date""", (invoice_id,))
return inv
# ============== INVOICE OCR UPLOAD ==============
class OcrUploadReq(BaseModel):
klub_id: int
file_name: str
file_path: str
file_size: Optional[int] = None
mime: Optional[str] = None
sha256: Optional[str] = None
@router.post("/invoice-uploads")
def create_upload(req: OcrUploadReq, user = Depends(require_user)):
if not (user_has_role(user['user_id'],'super_admin') or
user_has_role(user['user_id'],'klub_admin','klub',req.klub_id) or
user_has_role(user['user_id'],'klub_user','klub',req.klub_id)):
raise HTTPException(403, "Forbidden")
uid = db_exec("""INSERT INTO pgz_sport.invoice_uploads
(klub_id, uploaded_by, file_name, file_path, file_size, mime, sha256)
VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING id""",
(req.klub_id, user['user_id'], req.file_name, req.file_path,
req.file_size, req.mime, req.sha256))
return {"id":uid, "status":"queued_for_ocr"}
@router.get("/invoice-uploads")
def list_uploads(klub_id: Optional[int]=None, status: Optional[str]=None, limit:int=100):
where, args = ["1=1"], []
if klub_id: where.append("klub_id=%s"); args.append(klub_id)
if status: where.append("ocr_status=%s"); args.append(status)
args.append(limit)
return db_query(f"""SELECT id, klub_id, file_name, ocr_status, ai_invoice_no,
ai_amount_gross, ai_vendor_name, ai_invoice_date, uploaded_at, processed_at
FROM pgz_sport.invoice_uploads WHERE {" AND ".join(where)}
ORDER BY uploaded_at DESC LIMIT %s""", args)
# ============== EXPENSE REPORTS ==============
class ExpenseReportReq(BaseModel):
klub_id: int
user_id: Optional[int] = None
clan_id: Optional[int] = None
report_type: str # 'putni_nalog','putni_trosak','dnevnice','vlastiti_auto'
destination: Optional[str] = None
purpose: Optional[str] = None
date_from: date
date_to: date
vehicle_type: Optional[str] = None
km_driven: Optional[float] = None
cost_transport: float = 0
cost_lodging: float = 0
cost_meals: float = 0
cost_other: float = 0
dnevnice_count: int = 0
notes: Optional[str] = None
@router.post("/expense-reports")
def create_expense(req: ExpenseReportReq, user = Depends(require_user)):
cost_total = (req.cost_transport + req.cost_lodging + req.cost_meals + req.cost_other +
(req.km_driven or 0)*0.42 + req.dnevnice_count*30)
eid = db_exec("""INSERT INTO pgz_sport.expense_reports
(klub_id, user_id, clan_id, report_type, destination, purpose, date_from, date_to,
vehicle_type, km_driven, cost_transport, cost_lodging, cost_meals, cost_other,
cost_total, dnevnice_count, notes)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id""", (req.klub_id, req.user_id or user['user_id'], req.clan_id,
req.report_type, req.destination, req.purpose, req.date_from, req.date_to,
req.vehicle_type, req.km_driven, req.cost_transport, req.cost_lodging,
req.cost_meals, req.cost_other, cost_total, req.dnevnice_count, req.notes))
return {"id": eid, "cost_total": float(cost_total)}
@router.get("/expense-reports")
def list_expenses(klub_id: Optional[int]=None, status: Optional[str]=None, limit:int=100):
where, args = ["1=1"], []
if klub_id: where.append("klub_id=%s"); args.append(klub_id)
if status: where.append("status=%s"); args.append(status)
args.append(limit)
return db_query(f"""SELECT * FROM pgz_sport.expense_reports
WHERE {" AND ".join(where)} ORDER BY created_at DESC LIMIT %s""", args)
# ============== FORMS ==============
@router.get("/forms/templates")
def list_form_templates(kategorija: Optional[str]=None):
where, args = ["active=true"], []
if kategorija: where.append("kategorija=%s"); args.append(kategorija)
return db_query(f"""SELECT id, code, naziv, kategorija, opis, schema_json, required_role
FROM pgz_sport.form_templates WHERE {" AND ".join(where)} ORDER BY kategorija, naziv""", args)
@router.get("/forms/templates/{code}")
def get_form_template(code: str):
t = db_one("SELECT * FROM pgz_sport.form_templates WHERE code=%s AND active=true", (code,))
if not t: raise HTTPException(404)
return t
class SubmitFormReq(BaseModel):
template_code: str
klub_id: int
clan_id: Optional[int] = None
data: Dict[str, Any]
submit: bool = False # True = submit, False = save draft
@router.post("/forms/submit")
def submit_form(req: SubmitFormReq, user = Depends(require_user)):
t = db_one("SELECT id FROM pgz_sport.form_templates WHERE code=%s", (req.template_code,))
if not t: raise HTTPException(404, "Unknown template")
sid = db_exec("""INSERT INTO pgz_sport.form_submissions
(template_id, template_code, klub_id, user_id, clan_id, data, status, submitted_at)
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s,%s) RETURNING id""",
(t['id'], req.template_code, req.klub_id, user['user_id'], req.clan_id,
json.dumps(req.data), 'submitted' if req.submit else 'draft',
datetime.now() if req.submit else None))
db_exec("""INSERT INTO pgz_sport.audit_events
(user_id, action, resource_type, resource_id, meta)
VALUES (%s,'submit_form','form',%s,%s::jsonb)""",
(user['user_id'], sid, json.dumps({'template':req.template_code})))
return {"id": sid}
@router.get("/forms/submissions")
def list_submissions(klub_id: Optional[int]=None, template_code: Optional[str]=None,
status: Optional[str]=None, limit:int=100):
where, args = ["1=1"], []
if klub_id: where.append("klub_id=%s"); args.append(klub_id)
if template_code: where.append("template_code=%s"); args.append(template_code)
if status: where.append("status=%s"); args.append(status)
args.append(limit)
return db_query(f"""SELECT id, template_code, klub_id, user_id, clan_id, status,
submitted_at, approved_at, reference_no
FROM pgz_sport.form_submissions WHERE {" AND ".join(where)}
ORDER BY created_at DESC LIMIT %s""", args)
# ============== ALERTS ==============
@router.get("/alerts")
def list_alerts(klub_id: Optional[int]=None, severity: Optional[str]=None,
resolved: bool=False, limit:int=100):
where, args = ["rijeseno=%s"], [resolved]
if klub_id: where.append("klub_id=%s"); args.append(klub_id)
if severity: where.append("razina=%s"); args.append(severity.upper())
args.append(limit)
return db_query(f"""SELECT id, tip, razina, poruka, klub_id, clan_id, due_date,
iznos, datum, rijeseno, created_at
FROM pgz_sport.alertovi WHERE {" AND ".join(where)}
ORDER BY
CASE razina WHEN 'CRITICAL' THEN 1 WHEN 'WARNING' THEN 2 ELSE 3 END,
due_date NULLS LAST LIMIT %s""", args)
@router.post("/alerts/{alert_id}/resolve")
def resolve_alert(alert_id: int, user = Depends(require_user)):
db_exec("""UPDATE pgz_sport.alertovi SET rijeseno=true, rijeseno_at=now(), rijeseno_od=%s
WHERE id=%s""", (user['user_id'], alert_id))
return {"status":"ok"}
@router.post("/alerts/scan")
def trigger_scan(user = Depends(require_user)):
"""Run alert rules now."""
if not (user_has_role(user['user_id'],'super_admin') or user_has_role(user['user_id'],'pgz_admin')):
raise HTTPException(403)
return {"status":"scan_queued","note":"Scan triggers will be honoured on next cron tick"}
# ============== CLUB DASHBOARD ==============
@router.get("/klub/{klub_id}/dashboard")
def klub_dashboard(klub_id: int):
klub = db_one("SELECT * FROM pgz_sport.v_klub_full WHERE id=%s", (klub_id,))
if not klub: raise HTTPException(404)
return {
"klub": klub,
"clanovi_count": db_one("SELECT COUNT(*) AS n FROM pgz_sport.clanovi WHERE klub_id=%s AND aktivan=true", (klub_id,)),
"lijecnicki_isteka_30d": db_one("""SELECT COUNT(*) AS n FROM pgz_sport.lijecnicki_pregledi lp
JOIN pgz_sport.clanovi c ON c.id=lp.clan_id
WHERE c.klub_id=%s AND lp.vrijedi_do BETWEEN now()::date AND now()::date+interval '30 days'""", (klub_id,)),
"alerts_open": db_query("""SELECT id, tip, razina, poruka, due_date FROM pgz_sport.alertovi
WHERE klub_id=%s AND rijeseno=false ORDER BY
CASE razina WHEN 'CRITICAL' THEN 1 WHEN 'WARNING' THEN 2 ELSE 3 END LIMIT 10""", (klub_id,)),
"invoices_unpaid": db_one("""SELECT COUNT(*) AS n, COALESCE(SUM(amount_gross),0) AS sum_eur
FROM pgz_sport.invoices WHERE klub_id=%s AND payment_status='unpaid'""", (klub_id,)),
"clanarine_status": db_one("""SELECT
SUM(CASE WHEN status='podmireno' THEN 1 ELSE 0 END) AS placena,
SUM(CASE WHEN status='nepodmireno' THEN 1 ELSE 0 END) AS neplacena,
SUM(CASE WHEN status='djelomicno' THEN 1 ELSE 0 END) AS djelomicno,
COALESCE(SUM(iznos_propisan-COALESCE(iznos_placen,0)),0) AS dug_total
FROM pgz_sport.clanarine WHERE klub_id=%s AND godina=EXTRACT(year FROM now())::int""", (klub_id,)),
"form_drafts": db_query("""SELECT id, template_code, status, updated_at
FROM pgz_sport.form_submissions WHERE klub_id=%s AND status='draft'
ORDER BY updated_at DESC LIMIT 5""", (klub_id,)),
}
# ============== AI RAG SPORT AGENT ==============
class AskReq(BaseModel):
query: str
limit: int = 5
@router.post("/sport/ask")
def sport_ask(req: AskReq):
"""RAG over pgz_sport_v1 — return relevant context for an LLM.
POST-PROCESS: resolve deleted clan_id/klub_id to canonical via DB lookup."""
try:
r = requests.post(EMBED, json={"input":[req.query, req.query]}, timeout=30)
j = r.json()
emb = j.get('embeddings', [j.get('embedding')])[0]
except Exception as e:
raise HTTPException(503, f"Embedder unavailable: {e}")
# Fetch MORE results (limit*3) so we can dedupe and resolve
r = requests.post(f"{QDRANT}/collections/{COLL}/points/search",
json={"vector": emb, "limit": req.limit * 3, "with_payload": True}, timeout=30)
if r.status_code >= 400: raise HTTPException(503, f"Qdrant: {r.text[:200]}")
hits = r.json()['result']
# Resolve deleted IDs to canonical via DB
valid_clan_ids = set(r['id'] for r in db_query("SELECT id FROM pgz_sport.clanovi"))
valid_klub_ids = set(r['id'] for r in db_query("SELECT id FROM pgz_sport.klubovi"))
seen_canon = set()
out_results = []
for h in hits:
pl = h['payload']
tip = pl.get('tip', pl.get('type'))
cid = pl.get('clan_id')
kid = pl.get('klub_id')
naziv = pl.get('naziv') or pl.get('title') or '?'
# If clan_id deleted, try to resolve by name
if tip == 'clan' and cid and cid not in valid_clan_ids:
parts = naziv.split()
if len(parts) >= 2:
ime = parts[0]
prezime = ' '.join(parts[1:])
resolved = db_one("""SELECT c.id, c.klub_id, k.naziv AS klub
FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE LOWER(c.ime)=LOWER(%s) AND LOWER(c.prezime)=LOWER(%s)
ORDER BY (c.slika_url IS NOT NULL) DESC, c.id ASC LIMIT 1""", (ime, prezime))
if resolved:
cid = resolved['id']
kid = resolved['klub_id']
pl = {**pl, 'clan_id': cid, 'klub_id': kid, 'klub': resolved['klub']}
else:
continue # skip - not findable
# Skip klub points if klub deleted
if tip == 'klub' and kid and kid not in valid_klub_ids:
continue
# Dedup by canonical clan_id (or klub_id)
canon_key = ('clan', cid) if tip == 'clan' else ('klub', kid) if tip == 'klub' else (tip, naziv)
if canon_key in seen_canon:
continue
seen_canon.add(canon_key)
out_results.append({
"score": h['score'],
"type": tip,
"title": naziv,
"snippet": (pl.get('tekst') or '')[:500],
"payload": {k:v for k,v in pl.items() if k != 'tekst'}
})
if len(out_results) >= req.limit:
break
return {"query": req.query, "results": out_results}
# ============== CALENDAR / SCHEDULE ==============
@router.post("/sport/lawyer")
def sport_lawyer(payload: dict):
"""
AI Pravnik — odgovara na sve pravne, regulatorne i proceduralne nedoumice
iz pravilnika HOO, MINT, županije i klubova. Koristi RAG + DeepSeek/Groq LLM.
"""
q = (payload.get("query") or payload.get("question") or "").strip()
if not q:
raise HTTPException(400, "query je prazan")
# OS-FIRST: delegate to orchestrator if env flag set
if USE_ORCHESTRATOR:
result = delegate_to_orchestrator(q, persona="app")
if result.get("delegated"):
return {
"query": q,
"answer": result["answer"],
"sources": result.get("sources", []),
"llm": result["llm"],
"hits_count": len(result.get("sources", [])),
"via": "orchestrator",
}
# Fallback to local waterfall if orchestrator failed
# 1) RAG v2 — Fetch 25 candidates → dedup → top 6 unique docs
import requests as _requests
try:
emb_r = _requests.post(
"http://localhost:9879/api/embeddings",
json={"input": [q]}, timeout=20
)
if not emb_r.ok:
raise HTTPException(500, f"embed failed: {emb_r.status_code}")
_r = emb_r.json(); emb = _r.get("embedding") or _r.get("embeddings",[None])[0]
except Exception as e:
raise HTTPException(500, f"embedding error: {e}")
# Fetch 25 candidates (will dedup)
try:
qr = _requests.post(
"http://localhost:6333/collections/pgz_sport_v1/points/search",
json={"vector": emb, "limit": 25, "with_payload": True, "score_threshold": 0.35},
timeout=20
)
if not qr.ok:
raise HTTPException(500, f"qdrant http {qr.status_code}: {qr.text[:200]}")
all_hits = qr.json().get("result", [])
except HTTPException: raise
except Exception as e:
raise HTTPException(500, f"qdrant error: {e}")
if not all_hits:
return {"query": q, "answer": "Nema relevantnih pravilnika u bazi za ovaj upit. Probaj preformulirati pitanje konkretnije, npr. uključi specifičan sport, godinu, ili tip dokumenta (pravilnik, zakon, kriterij).", "sources": []}
# 2) Dedup by document — keep top chunk per unique doc_id/title (not all chunks of same doc)
seen_docs = {} # key = doc_id or normalized title
for h in all_hits:
p = h.get("payload") or {}
# Normalize doc identity
doc_key = p.get("doc_id") or p.get("source_url") or p.get("title", "?")
if doc_key not in seen_docs or h.get("score", 0) > seen_docs[doc_key].get("score", 0):
seen_docs[doc_key] = h
# Sort by score, take top 6 unique docs
unique_hits = sorted(seen_docs.values(), key=lambda x: x.get("score", 0), reverse=True)[:6]
hits = unique_hits
# 3) PGŽ-relevance boost: prefer PGŽ-specific docs over general national
PGZ_KW = ['pgž','pgz','primorsk','rijeka','kvarner','crikvenic','opatij','krk','cres','lošinj','rab']
def pgz_boost(h):
p = h.get("payload") or {}
all_t = ((p.get("title","") or "") + " " + (p.get("text","")[:300] or "") + " " + (p.get("source_url","") or "")).lower()
if any(k in all_t for k in PGZ_KW):
return h.get("score", 0) * 1.15 # 15% boost
return h.get("score", 0)
hits = sorted(hits, key=pgz_boost, reverse=True)
# 4) Build context with metadata
ctx_chunks = []
sources = []
for i, h in enumerate(hits):
p = h.get("payload") or {}
text = (p.get("text") or "")[:1200] # more context per chunk
title = p.get("title", "(bez naslova)")
url = p.get("source_url") or p.get("url", "")
doc_type = p.get("doc_type", "")
publish_date = p.get("publish_date", "")
source = p.get("source", "")
date_str = f", {publish_date[:10]}" if publish_date else ""
if text and len(text) > 50:
ctx_chunks.append(f"[{i+1}] {title}{date_str} ({doc_type or source}):\n{text}")
sources.append({
"id": i+1, "title": title, "url": url,
"doc_type": doc_type, "publish_date": publish_date,
"source": source, "score": round(float(h.get("score", 0)), 3)
})
context = "\n\n".join(ctx_chunks)
# 3) LLM call — DeepSeek primary
SYSTEM = """Ti si AI PRAVNIK Zajednice sportova Primorsko-goranske županije (ZSPGŽ).
Specijaliziran si za hrvatsko sportsko pravo i propise koje primjenjuje ZSPGŽ.
ZNANJE TI DOLAZI ISKLJUČIVO IZ PRILOŽENIH IZVORA. Nikada ne izmišljaj ni datume ni iznose ni članke.
PRAVILA ODGOVORA:
1. KRATAK DIREKTAN ODGOVOR PRVI (1-3 rečenice). Što tražitelj treba znati odmah.
2. DETALJI: konkretni iznosi, rokovi, članci pravilnika, postupci. Citiraj BROJEVE [1], [2]... za svaki podatak.
3. AKO INFORMACIJA NIJE U IZVORIMA: jasno kaži "Ovo pitanje nije pokriveno priloženim pravilnicima — preporučujem provjeru izravno na sport-pgz.hr ili kod nadležne osobe."
4. AKO POSTOJE SLIČNI ALI NE IDENTIČNI PRAVILNICI: navedi razliku jasno.
5. PRIORITET: ZSPGŽ pravilnici > PGŽ županijski > nacionalni HOO/MINT > zakonski tekst.
6. STIL: stručan, ali razumljiv. Hrvatski jezik. Bez fraza poput "kako je navedeno" — direktno citiraj.
7. NE PONAVLJAJ pitanje. Ne počinji s "Prema priloženim pravilnicima..." — direktno na odgovor.
FORMAT:
**Odgovor:** [1-3 rečenice s ključnim podacima i [1] referencama]
**Detalji:**
- [bullet] [konkretan podatak] [referenca]
- [bullet] [postupak] [referenca]
**Reference:** [auto generirano ispod, ne pisati u odgovoru]
Ako tražitelj pita o konkretnom iznosu/rokovima a nemaš to u izvorima, **nemoj nagađati** — kaži da nisi siguran i preporuči direktan kontakt."""
user_msg = f"PITANJE: {q}\n\nPRILOŽENI PRAVILNICI/IZVORI:\n{context}\n\nODGOVOR (sa referencama [1], [2]...):"
answer = ""
llm_used = "none"
# Try DeepSeek
try:
ds_key = os.environ.get("DEEPSEEK_API_KEY")
if ds_key:
r = _requests.post(
"https://api.deepseek.com/v1/chat/completions",
headers={"Authorization": f"Bearer {ds_key}"},
json={
"model": "deepseek-chat",
"messages": [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": user_msg},
],
"temperature": 0.15,
"max_tokens": 2000,
},
timeout=60,
)
if r.ok:
answer = r.json()["choices"][0]["message"]["content"]
llm_used = "deepseek"
except Exception as e:
pass
# Fallback Groq
if not answer:
try:
gk = os.environ.get("GROQ_API_KEY")
if gk:
r = _requests.post(
"https://api.groq.com/openai/v1/chat/completions",
headers={"Authorization": f"Bearer {gk}"},
json={
"model": "llama-3.3-70b-versatile",
"messages": [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": user_msg},
],
"temperature": 0.15,
"max_tokens": 2000,
},
timeout=60,
)
if r.ok:
answer = r.json()["choices"][0]["message"]["content"]
llm_used = "groq"
except Exception as e:
pass
# Local Ollama fallback
if not answer:
try:
r = _requests.post(
"http://localhost:11434/api/chat",
json={
"model": "qwen2.5:7b",
"messages": [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": user_msg},
],
"stream": False,
"options": {"temperature": 0.15, "num_predict": 1500},
},
timeout=120,
)
if r.ok:
answer = r.json().get("message", {}).get("content", "")
llm_used = "ollama_qwen2.5"
except Exception:
pass
if not answer:
# No LLM available — return pure RAG with notice
answer = "**[LLM nije dostupan, evo top relevantnih izvora:]**\n\n"
for i, src_item in enumerate(sources[:3], 1):
answer += f"**[{i}] {src_item['title']}**\n"
answer += f"_{src_item.get('doc_type','')}, score={src_item['score']:.2f}_\n\n"
llm_used = "rag_only"
# Audit
try:
from psycopg2 import connect
conn = connect(host='localhost', dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7')
cu = conn.cursor()
cu.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_text, payload)
VALUES (%s,%s,%s,%s::jsonb)""",
('lawyer.query', 'sport_lawyer', q[:500],
json.dumps({"llm": llm_used, "hits": len(hits), "sources": len(sources)})))
conn.commit(); conn.close()
except Exception:
pass
return {
"query": q,
"answer": answer,
"sources": sources,
"llm": llm_used,
"hits_count": len(hits),
}
@router.get("/calendar/upcoming")
def calendar_upcoming(klub_id: Optional[int]=None, days_ahead: int=30):
end = datetime.now() + timedelta(days=days_ahead)
out = []
# Liječnički pregledi koji ističu
where = ""; args = []
if klub_id:
where = "AND c.klub_id=%s"; args = [klub_id]
args = [end] + args
rows = db_query(f"""SELECT 'lijecnicki_istek' AS type,
'Liječnički istječe — '||c.ime||' '||c.prezime AS title,
lp.vrijedi_do AS date, c.klub_id, lp.clan_id, k.naziv AS klub_naziv
FROM pgz_sport.lijecnicki_pregledi lp
JOIN pgz_sport.clanovi c ON c.id=lp.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE lp.vrijedi_do BETWEEN now()::date AND %s::date {where}
ORDER BY lp.vrijedi_do""", args)
out.extend(rows)
# Računi koji dospijevaju
args2 = [end]
if klub_id: args2.append(klub_id)
where2 = " AND klub_id=%s" if klub_id else ""
rows = db_query(f"""SELECT 'invoice_due' AS type,
'Račun '||invoice_no||''||COALESCE(vendor_name,'?')||''||amount_gross::text||' EUR' AS title,
due_date AS date, klub_id, NULL::int AS clan_id, NULL::text AS klub_naziv
FROM pgz_sport.invoices WHERE due_date BETWEEN now()::date AND %s::date
AND payment_status='unpaid'{where2}
ORDER BY due_date""", args2)
out.extend(rows)
# Alerts otvorene
args3 = [end]
if klub_id: args3.append(klub_id)
where3 = " AND klub_id=%s" if klub_id else ""
rows = db_query(f"""SELECT 'alert' AS type, poruka AS title, due_date AS date,
klub_id, clan_id, NULL::text FROM pgz_sport.alertovi
WHERE rijeseno=false AND due_date IS NOT NULL AND due_date<=%s::date{where3}
ORDER BY due_date""", args3)
out.extend(rows)
return sorted(out, key=lambda x: x.get('date') or date.max)
# ============== EKOSUSTAV STATS (extended) ==============
@router.get("/ekosustav/v2")
def ekosustav_v2():
return {
"savezi": db_one("""SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE razina='nacional') AS nacional,
COUNT(*) FILTER (WHERE razina='zupanijski') AS zupanijski,
COUNT(*) FILTER (WHERE razina='gradski') AS gradski
FROM pgz_sport.savezi"""),
"klubovi": db_one("""SELECT
COUNT(*) AS total,
COUNT(oib) AS s_oib,
COUNT(*) FILTER (WHERE entity_id IS NOT NULL) AS linked
FROM pgz_sport.klubovi"""),
"documents": db_one("""SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE LENGTH(COALESCE(text_extracted,''))>=200) AS extracted
FROM sport.documents"""),
"qdrant_points": (lambda: requests.get(f"{QDRANT}/collections/{COLL}").json()['result']['points_count'])(),
"users": db_one("""SELECT COUNT(*) FROM pgz_sport.users WHERE status='active'"""),
"alerts_open": db_one("""SELECT
COUNT(*) FILTER (WHERE razina='CRITICAL') AS critical,
COUNT(*) FILTER (WHERE razina='WARNING') AS warning,
COUNT(*) FILTER (WHERE razina='INFO') AS info
FROM pgz_sport.alertovi WHERE rijeseno=false"""),
"forms_templates": db_one("SELECT COUNT(*) FROM pgz_sport.form_templates WHERE active=true"),
"invoices": db_one("""SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE payment_status='unpaid') AS unpaid,
COALESCE(SUM(amount_gross),0)::numeric AS total_eur
FROM pgz_sport.invoices"""),
}
# =========== MULTIPART UPLOAD + USER CREATE (added 28apr) ===========
UPLOAD_DIR = "/var/lib/pgz-sport/invoices"
os.makedirs(UPLOAD_DIR, exist_ok=True)
@router.post("/invoice-uploads/file")
async def upload_invoice_file(
file: UploadFile = File(...),
klub_id: int = Form(...),
invoice_kind: str = Form("ulazni"),
user = Depends(require_user)
):
"""Multipart upload — saves to disk + queues for OCR."""
# Permissions: super_admin (global) OR klub_admin/klub_user/pgz_admin/pgz_user
is_authorized = (
user_has_role(user['user_id'],'super_admin') or
user_has_role(user['user_id'],'pgz_admin') or
user_has_role(user['user_id'],'pgz_user') or
user_has_role(user['user_id'],'klub_admin','klub',klub_id) or
user_has_role(user['user_id'],'klub_user','klub',klub_id) or
# Fallback - if user_type field on users table indicates super_admin
(db_one("SELECT user_type FROM pgz_sport.users WHERE id=%s", (user['user_id'],)) or {}).get('user_type') in ('super_admin','pgz_admin')
)
if not is_authorized:
raise HTTPException(403, f"Forbidden - need klub_admin role for klub_id={klub_id}")
raw = await file.read()
sha = hashlib.sha256(raw).hexdigest()
safe = re.sub(r'[^a-zA-Z0-9._-]','_', file.filename or 'invoice')
path = f"{UPLOAD_DIR}/{klub_id}_{int(time.time())}_{sha[:8]}_{safe}"
with open(path, 'wb') as f:
f.write(raw)
uid = db_exec("""INSERT INTO pgz_sport.invoice_uploads
(klub_id, uploaded_by, file_name, file_path, file_size, mime, sha256, ocr_status)
VALUES (%s,%s,%s,%s,%s,%s,%s,'pending') RETURNING id""",
(klub_id, user['user_id'], file.filename, path, len(raw), file.content_type, sha))
db_exec("INSERT INTO pgz_sport.audit_events(user_id,action,resource_type,resource_id,meta) VALUES (%s,'upload_invoice','invoice_upload',%s,%s)",
(user['user_id'], uid, json.dumps({'klub_id':klub_id,'kind':invoice_kind,'sha':sha[:16]})))
return {"upload_id": uid, "ocr_status": "pending", "klub_id": klub_id, "size": len(raw), "sha": sha[:16]}
# =========== USER CREATE (simple, no token required from super_admin via API) ===========
class CreateUserReq(BaseModel):
email: str
full_name: Optional[str] = None
password: str
role: str = "viewer"
klub_id: Optional[int] = None
oib: Optional[str] = None
phone: Optional[str] = None
@router.post("/users")
def api_create_user(req: CreateUserReq, user = Depends(require_user)):
"""Create new user. Requires super_admin or pgz_admin or klub_admin."""
if not (user_has_role(user['user_id'],'super_admin') or
user_has_role(user['user_id'],'pgz_admin') or
(req.klub_id and user_has_role(user['user_id'],'klub_admin','klub',req.klub_id))):
raise HTTPException(403, "Need super_admin/pgz_admin/klub_admin")
# Existing user?
existing = db_one("SELECT id FROM pgz_sport.users WHERE email=%s", (req.email,))
if existing:
raise HTTPException(409, f"User {req.email} already exists (id={existing['id']})")
# Create
uid = db_exec("""INSERT INTO pgz_sport.users (email, full_name, oib, phone, password_hash, status)
VALUES (%s,%s,%s,%s,%s,'active') RETURNING id""",
(req.email, req.full_name, req.oib, req.phone, hash_pw(req.password)))
# Assign role
role_row = db_one("SELECT id FROM pgz_sport.roles WHERE code=%s", (req.role,))
if role_row:
scope = ('klub', req.klub_id) if req.klub_id else ('global', None)
db_exec("""INSERT INTO pgz_sport.user_roles (user_id, role_id, scope_type, scope_id, granted_by, active)
VALUES (%s,%s,%s,%s,%s,true)""",
(uid, role_row['id'], scope[0], scope[1], user['user_id']))
db_exec("INSERT INTO pgz_sport.audit_events(user_id,action,resource_type,resource_id,meta) VALUES (%s,'create_user','user',%s,%s)",
(user['user_id'], uid, json.dumps({'email':req.email,'role':req.role,'klub_id':req.klub_id})))
return {"user_id": uid, "email": req.email, "role": req.role, "klub_id": req.klub_id}
# ═══════════════════════════════════════════════════════
# SPORTAŠ (Player) ENDPOINTS — semafor.hns.family style
# ═══════════════════════════════════════════════════════
@router.get("/sportas/{cid}/profile")
def sportas_profile(cid: int):
"""Full player profile + per-season stats + match log. Public read."""
sportas = db_one("""SELECT c.id, c.kategorije, c.promocija_kategorije, c.sport, c.ime, c.prezime, c.datum_rodenja, c.mjesto_rodenja,
c.slika_url, c.source, c.source_id, c.source_url, c.source_synced_at,
c.pozicija, c.dominantna_noga, c.visina_cm, c.tezina_kg, c.broj_dresa,
c.reprezentativac, c.reprezentacija_kategorija, c.biografija,
c.klub_id, k.naziv AS klub_naziv, k.sport, k.razina, k.region, k.logo_url,
k.hns_klub_id, k.hns_slug
FROM pgz_sport.clanovi c
LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE c.id=%s""", (cid,))
if not sportas:
raise HTTPException(404, "Sportaš nije pronađen")
# Per-season aggregate from utakmice_log
seasons = db_query("""
SELECT
CASE WHEN EXTRACT(MONTH FROM datum)>=7
THEN EXTRACT(YEAR FROM datum)::TEXT||'/'||(EXTRACT(YEAR FROM datum)+1)::TEXT
ELSE (EXTRACT(YEAR FROM datum)-1)::TEXT||'/'||EXTRACT(YEAR FROM datum)::TEXT
END AS sezona,
natjecanje, count(*) AS nastupi,
COALESCE(SUM(pogodaka),0) AS pogoci,
COALESCE(SUM(zuti_kartoni),0) AS zuti,
COALESCE(SUM(crveni_kartoni),0) AS crveni,
COALESCE(SUM(minute),0) AS minute_total
FROM pgz_sport.utakmice_log
WHERE clan_id=%s
GROUP BY 1, 2
ORDER BY 1 DESC, 2""", (cid,))
# Match log (latest 50)
matches = db_query("""SELECT id, datum, vrijeme, natjecanje,
klub_dom, klub_dom_logo, klub_gost, klub_gost_logo,
rezultat, pogodaka, zuti_kartoni, crveni_kartoni, minute,
zapocet_kao_starter, source_url
FROM pgz_sport.utakmice_log
WHERE clan_id=%s ORDER BY datum DESC NULLS LAST LIMIT 50""", (cid,))
# Career — clubs over time (from utakmice_log distinct za_klub_id)
career = db_query("""SELECT k.id, k.naziv, k.logo_url,
min(ul.datum) AS od_dat, max(ul.datum) AS do_dat,
count(*) AS nastupa
FROM pgz_sport.utakmice_log ul
JOIN pgz_sport.klubovi k ON k.id=ul.za_klub_id
WHERE ul.clan_id=%s GROUP BY k.id, k.naziv, k.logo_url
ORDER BY min(ul.datum)""", (cid,))
return {
"sportas": sportas,
"seasons": seasons,
"career": career,
"matches": matches,
"totals": {
"nastupa": sum(s['nastupi'] for s in seasons),
"pogodaka": sum(s['pogoci'] for s in seasons),
"zutih": sum(s['zuti'] for s in seasons),
"crvenih": sum(s['crveni'] for s in seasons),
}
}
@router.get("/klub/{kid}/sportasi")
def klub_sportasi(kid: int, limit: int = 100, offset: int = 0):
"""Roster: players in a club."""
klub = db_one("SELECT id, naziv, sport, razina, hns_klub_id, logo_url FROM pgz_sport.klubovi WHERE id=%s", (kid,))
if not klub:
raise HTTPException(404, "Klub nije pronađen")
sportasi = db_query("""SELECT c.id, c.ime, c.prezime, c.datum_rodenja, c.mjesto_rodenja,
c.slika_url, c.pozicija, c.broj_dresa, c.reprezentativac, c.source, c.source_url,
(SELECT count(*) FROM pgz_sport.utakmice_log WHERE clan_id=c.id) AS nastupa,
(SELECT COALESCE(sum(pogodaka),0) FROM pgz_sport.utakmice_log WHERE clan_id=c.id) AS pogoci
FROM pgz_sport.clanovi c
WHERE c.klub_id=%s
ORDER BY c.broj_dresa NULLS LAST, c.prezime, c.ime
LIMIT %s OFFSET %s""", (kid, limit, offset))
total = db_one("SELECT count(*) AS c FROM pgz_sport.clanovi WHERE klub_id=%s", (kid,))['c']
# A4_KLUB_TROFEJI_PATCH: trofeji, povijesne nagrade, top medalisti
trofeji = db_query("""
SELECT sezona, natjecanje, plasiranje, trofej, bodovi, napomena
FROM pgz_sport.klub_sezona
WHERE klub_id=%s
ORDER BY
CASE WHEN sezona ~ '^[0-9]{4}' THEN substring(sezona FROM '^[0-9]{4}')::INT ELSE 0 END DESC,
plasiranje ASC NULLS LAST
LIMIT 50""", (kid,))
priznanja = db_query("""
SELECT godina, kategorija, ime_prezime, sport, napomena, clan_id
FROM pgz_sport.najbolji_sportasi
WHERE klub_id=%s
ORDER BY godina DESC LIMIT 50""", (kid,))
top_medalisti = db_query("""
SELECT ime_prezime, clan_id, count(*) AS nagrade,
count(*) FILTER (WHERE medalja='ZLATO') AS z,
count(*) FILTER (WHERE medalja='SREBRO') AS s,
count(*) FILTER (WHERE medalja='BRONCA') AS b,
count(*) FILTER (WHERE razina_natjecanja IN ('SP','EP','OI')) AS svj
FROM pgz_sport.clan_nagrada
WHERE klub_id=%s AND medalja IS NOT NULL
GROUP BY ime_prezime, clan_id
ORDER BY count(*) FILTER (WHERE medalja='ZLATO') DESC, count(*) DESC
LIMIT 15""", (kid,))
# HOO kategorizirani u klubu
hoo_sportasi = db_query("""
SELECT id, ime, prezime, kategorija_hoo, sport
FROM pgz_sport.clanovi
WHERE klub_id=%s AND kategorija_hoo IS NOT NULL
ORDER BY kategorija_hoo, prezime, ime""", (kid,))
return {
"klub": klub, "count": len(sportasi), "total": total, "sportasi": sportasi,
"trofeji": trofeji,
"priznanja": priznanja,
"top_medalisti": top_medalisti,
"hoo_sportasi": hoo_sportasi
}
# === NATJECANJA_TABLICA_PATCH ===
@router.get("/natjecanja")
def natjecanja_list(sport: Optional[str] = None, sezona: Optional[str] = None, pgz_only: bool = False, limit: int = 100):
"""List natjecanja (lige) with filtering."""
where = []; args = []
if sport: where.append("LOWER(sport) = LOWER(%s)"); args.append(sport)
if sezona: where.append("sezona = %s"); args.append(sezona)
if pgz_only: where.append("pgz_relevant = true")
where_sql = " AND ".join(where) if where else "1=1"
args.append(limit)
rows = db_query(f"""
SELECT n.id, n.sport, n.naziv, n.razina, n.tip, n.sezona, n.source, n.source_url,
n.pgz_relevant,
(SELECT count(*) FROM pgz_sport.natjecanja_tablice WHERE natjecanje_id=n.id) AS broj_klubova,
s.naziv AS savez_naziv
FROM pgz_sport.natjecanja n
LEFT JOIN pgz_sport.savezi s ON s.id = n.savez_id
WHERE {where_sql}
ORDER BY n.pgz_relevant DESC, n.sport, n.razina, n.naziv
LIMIT %s""", tuple(args))
return {"count": len(rows), "natjecanja": rows}
@router.get("/natjecanja/{nid}/tablica")
def natjecanja_tablica(nid: int):
"""Get current standings for a natjecanje - from DB or external URL."""
natj = db_one("""SELECT n.*, s.naziv AS savez_naziv FROM pgz_sport.natjecanja n
LEFT JOIN pgz_sport.savezi s ON s.id=n.savez_id
WHERE n.id=%s""", (nid,))
if not natj:
raise HTTPException(404, f"Natjecanje {nid} nije pronađeno")
# Get from scraped table
klubovi = db_query("""
SELECT nt.rang, nt.klub_naziv, nt.utakmica, nt.pobjede, nt.nerijeseno, nt.porazi,
nt.golovi_za, nt.golovi_protiv, nt.bodovi, nt.source, nt.scraped_at,
k.id as klub_id
FROM pgz_sport.natjecanje_tablica nt
LEFT JOIN pgz_sport.klubovi k ON LOWER(k.naziv) LIKE LOWER('%%'||LEFT(nt.klub_naziv,15)||'%%') AND k.aktivan=true
WHERE nt.natjecanje_id=%s
ORDER BY nt.rang
""", (nid,))
return {"natjecanje": natj, "klubovi": klubovi, "count": len(klubovi)}
@router.get("/audit/freshness")
def audit_freshness():
"""Show data freshness across all scrapers."""
rows = db_query("""
SELECT 'utakmice_log (HNS)' AS tabela, count(*) AS broj, max(scraped_at) AS zadnji_update,
min(scraped_at) AS prvi_update,
(now() - max(scraped_at))::text AS od_zadnjeg
FROM pgz_sport.utakmice_log
UNION ALL
SELECT 'clan_sezona', count(*), max(last_scraped_at), min(last_scraped_at),
(now() - max(last_scraped_at))::text
FROM pgz_sport.clan_sezona
UNION ALL
SELECT 'klubovi (HNS)', count(*), max(source_synced_at), min(source_synced_at),
(now() - max(source_synced_at))::text
FROM pgz_sport.klubovi WHERE source = 'hns_semafor'
UNION ALL
SELECT 'klubovi (HBS)', count(*), max(source_synced_at), min(source_synced_at),
(now() - max(source_synced_at))::text
FROM pgz_sport.klubovi WHERE source = 'hbs_savez'
UNION ALL
SELECT 'natjecanja_tablice', count(*), max(updated_at), min(updated_at),
(now() - max(updated_at))::text
FROM pgz_sport.natjecanja_tablice
UNION ALL
SELECT 'natjecanja', count(*), max(updated_at), min(updated_at),
(now() - max(updated_at))::text
FROM pgz_sport.natjecanja
UNION ALL
SELECT 'dokumenti', count(*), max(scraped_at), min(scraped_at),
(now() - max(scraped_at))::text
FROM pgz_sport.dokumenti
UNION ALL
SELECT 'clan_nagrada', count(*), max(last_updated), min(last_updated),
(now() - max(last_updated))::text
FROM pgz_sport.clan_nagrada
ORDER BY tabela
""")
return {"freshness": rows}
@router.get("/audit/sources")
def audit_sources():
"""Distribucija izvora po tablicama."""
klubovi = db_query("SELECT COALESCE(source,'unknown') AS source, count(*) AS broj FROM pgz_sport.klubovi GROUP BY source ORDER BY count(*) DESC")
clanovi = db_query("SELECT COALESCE(source,'unknown') AS source, count(*) AS broj FROM pgz_sport.clanovi GROUP BY source ORDER BY count(*) DESC")
dokumenti = db_query("SELECT COALESCE(vrsta,'unknown') AS source, count(*) AS broj FROM pgz_sport.dokumenti GROUP BY vrsta ORDER BY count(*) DESC")
natjecanja = db_query("SELECT COALESCE(source,'unknown') AS source, count(*) AS broj FROM pgz_sport.natjecanja GROUP BY source ORDER BY count(*) DESC")
return {
"klubovi_by_source": klubovi,
"clanovi_by_source": clanovi,
"dokumenti_by_vrsta": dokumenti,
"natjecanja_by_source": natjecanja
}
@router.get("/sportas/{clan_id}/godisnjak_history")
def godisnjak_history(clan_id: int):
"""Vraća sve spomene sportaša u godišnjacima sa snippet kontekstima."""
with psycopg2.connect(**DB) as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cu:
cu.execute("SELECT ime, prezime, sport FROM pgz_sport.clanovi WHERE id=%s", (clan_id,))
sp = cu.fetchone()
if not sp:
raise HTTPException(404, "Sportaš nije pronađen")
cu.execute("""SELECT cg.godina, cg.snippet, cg.klub_naziv, cg.keywords,
cg.has_medal, cg.has_kategorija, d.izvor_url, d.title
FROM pgz_sport.clan_godisnjak cg
JOIN pgz_sport.dokumenti d ON d.id = cg.dokument_id
WHERE cg.clan_id = %s ORDER BY cg.godina""", (clan_id,))
history = cu.fetchall()
return {"sportas": dict(sp), "count": len(history), "history": [dict(h) for h in history]}
@router.get("/godisnjak/{godina}/sportasi")
def godisnjak_sportasi(godina: int, has_medal: bool = False, limit: int = 100):
"""Sportaši spomenuti u određenom godišnjaku."""
with psycopg2.connect(**DB) as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cu:
sql = """SELECT cg.clan_id, c.ime, c.prezime, c.sport, k.naziv AS klub,
cg.has_medal, cg.has_kategorija, cg.keywords, cg.snippet
FROM pgz_sport.clan_godisnjak cg
JOIN pgz_sport.clanovi c ON c.id = cg.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE cg.godina = %s"""
params = [godina]
if has_medal:
sql += " AND cg.has_medal = true"
sql += " ORDER BY cg.has_medal DESC, c.prezime LIMIT %s"
params.append(limit)
cu.execute(sql, params)
rows = cu.fetchall()
return {"godina": godina, "count": len(rows), "sportasi": [dict(r) for r in rows]}
@router.get("/audit/coverage_matrix")
def audit_coverage_matrix(limit: int = 80):
"""Pokrivenost po klubu: heat-map data za GUI."""
with psycopg2.connect(**DB) as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cu:
cu.execute("""
SELECT k.id, k.naziv, k.sport, k.hns_klub_id,
(SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id=k.id) AS sportasa,
(SELECT count(*) FROM pgz_sport.utakmice_log WHERE za_klub_id=k.id) AS utakmica,
(SELECT count(*) FROM pgz_sport.clan_sezona WHERE klub_naziv ILIKE '%%' || k.naziv || '%%' OR klub_naziv ILIKE k.naziv) AS sezona,
(SELECT count(*) FROM pgz_sport.klub_sezona WHERE klub_id=k.id) AS trofeja,
(SELECT count(*) FROM pgz_sport.clan_nagrada WHERE klub_id=k.id) AS nagrada,
(SELECT count(*) FROM pgz_sport.natjecanja_tablice WHERE klub_id=k.id) AS u_ligama,
CASE WHEN k.logo_url IS NOT NULL THEN 1 ELSE 0 END AS ima_logo,
array_length(k.godisnjak_godine, 1) AS godina_god,
k.godisnjak_prvi, k.godisnjak_zadnji
FROM pgz_sport.klubovi k
WHERE k.aktivan=true
ORDER BY
((SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id=k.id) +
(SELECT count(*) FROM pgz_sport.utakmice_log WHERE za_klub_id=k.id)/10 +
COALESCE(array_length(k.godisnjak_godine, 1), 0)*5) DESC
LIMIT %s
""", (limit,))
rows = cu.fetchall()
return {"count": len(rows), "klubovi": [dict(r) for r in rows]}
@router.get("/audit/coverage")
def audit_coverage():
"""Pokrivenost po klubu — koliko podataka imamo."""
rows = db_query("""
SELECT k.id, k.naziv, k.sport, k.razina, k.hns_klub_id,
k.source, k.source_synced_at,
(SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id=k.id) AS sportasa,
(SELECT count(*) FROM pgz_sport.utakmice_log WHERE za_klub_id=k.id) AS utakmica,
(SELECT count(*) FROM pgz_sport.clan_sezona WHERE clan_id IN (SELECT id FROM pgz_sport.clanovi WHERE klub_id=k.id)) AS sezona,
(SELECT count(*) FROM pgz_sport.clan_nagrada WHERE klub_id=k.id) AS nagrada,
(SELECT count(*) FROM pgz_sport.klub_sezona WHERE klub_id=k.id) AS trofeja,
(SELECT count(*) FROM pgz_sport.natjecanja_tablice WHERE klub_id=k.id) AS u_ligama
FROM pgz_sport.klubovi k
WHERE k.aktivan = true
AND ((SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id=k.id) > 0
OR (SELECT count(*) FROM pgz_sport.klub_sezona WHERE klub_id=k.id) > 0)
ORDER BY
(SELECT count(*) FROM pgz_sport.utakmice_log WHERE za_klub_id=k.id) DESC,
(SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id=k.id) DESC
LIMIT 200
""")
return {"count": len(rows), "klubovi": rows}
@router.get("/audit/feed")
def audit_feed_recent(limit: int = 50):
"""Recent audit events."""
rows = db_query("""SELECT table_name, action, source, source_url, scraped_at,
changed_fields, details
FROM pgz_sport.audit_feed
ORDER BY scraped_at DESC LIMIT %s""", (limit,))
return {"count": len(rows), "events": rows}
# === END AUDIT_PATCH ===
# === GODISNJAK_SEARCH_PATCH ===
@router.get("/dokumenti")
def dokumenti_list(vrsta: Optional[str] = None, godina: Optional[int] = None, limit: int = 100):
"""List dokumenti."""
where = []; args = []
if vrsta: where.append("vrsta = %s"); args.append(vrsta)
if godina: where.append("godina = %s"); args.append(godina)
where_sql = " AND ".join(where) if where else "1=1"
args.append(limit)
rows = db_query(f"""SELECT id, title, vrsta, godina, izdano_datum, organizacija,
length(sadrzaj) AS chars, izvor_url, scraped_at
FROM pgz_sport.dokumenti
WHERE aktivan = true AND {where_sql}
ORDER BY godina DESC NULLS LAST, izdano_datum DESC NULLS LAST
LIMIT %s""", tuple(args))
return {"count": len(rows), "dokumenti": rows}
@router.get("/dokumenti/{did:int}")
def dokument_detail(did: int):
"""Get dokument detail with full text or excerpt."""
d = db_one("""SELECT id, title, vrsta, godina, izdano_datum, organizacija, kratak_opis,
izvor_url, sadrzaj, scraped_at
FROM pgz_sport.dokumenti WHERE id = %s""", (did,))
if not d: raise HTTPException(404, "Dokument nije pronađen")
return d
@router.get("/dokumenti/search/q")
def dokumenti_search(q: str, vrsta: Optional[str] = None, limit: int = 30):
"""Full-text search kroz godišnjake i sve dokumente."""
if not q or len(q) < 2: raise HTTPException(400, "Query premalen")
where_extra = "AND vrsta = %s" if vrsta else ""
args = [f"%{q}%", f"%{q}%"]
if vrsta: args.append(vrsta)
args.append(limit)
rows = db_query(f"""
SELECT id, title, vrsta, godina, izdano_datum, izvor_url,
(CASE
WHEN POSITION(LOWER(%s) IN LOWER(sadrzaj)) > 100
THEN '' || SUBSTR(sadrzaj, GREATEST(1, POSITION(LOWER(%s) IN LOWER(sadrzaj)) - 100), 400) || ''
ELSE SUBSTR(sadrzaj, 1, 400) || ''
END) AS excerpt
FROM pgz_sport.dokumenti
WHERE LOWER(sadrzaj) LIKE LOWER(%s) {where_extra}
ORDER BY godina DESC NULLS LAST
LIMIT %s""",
tuple([q, q, f"%{q}%"] + ([vrsta] if vrsta else []) + [limit]))
return {"query": q, "count": len(rows), "rezultati": rows}
# === END GODISNJAK_SEARCH_PATCH ===
@router.get("/dokumenti/{did:int}/pdf")
def dokumenti_pdf(did: int):
"""Stream the original PDF file if available locally."""
from fastapi.responses import FileResponse, JSONResponse
import os
rows = db_query("SELECT fname, vrsta, godina, title FROM pgz_sport.dokumenti WHERE id=%s", (did,))
if not rows:
return JSONResponse({"error": "not found"}, status_code=404)
rec = rows[0]
# Try local paths
candidates = []
if rec.get("fname"):
candidates.extend([
f"/opt/pgz-sport/_data/godisnjaci/{rec['fname']}",
f"/opt/pgz-sport/_data/dokumenti/{rec['fname']}",
f"/opt/pgz-sport/_data/{rec['fname']}",
])
# Construct from godina/vrsta
if rec.get("godina") and rec.get("vrsta") == "godisnjak":
candidates.append(f"/opt/pgz-sport/_data/godisnjaci/godisnjak_{rec['godina']}.pdf")
for path in candidates:
if os.path.isfile(path):
return FileResponse(path, media_type="application/pdf",
filename=os.path.basename(path),
headers={"Cache-Control": "max-age=3600"})
return JSONResponse({"error": "PDF file not found locally", "candidates": candidates}, status_code=404)
@router.get("/dokumenti/{did:int}/text")
def dokumenti_text(did: int):
"""Return the full parsed text content of a document."""
from fastapi.responses import PlainTextResponse, JSONResponse
rows = db_query("SELECT title, sadrzaj, vrsta, godina FROM pgz_sport.dokumenti WHERE id=%s", (did,))
if not rows:
return JSONResponse({"error": "not found"}, status_code=404)
rec = rows[0]
if not rec.get("sadrzaj"):
return JSONResponse({"error": "no parsed text"}, status_code=404)
import re as _re; title = _re.sub(r"[^A-Za-z0-9_.-]", "_", rec.get("title", f"dokument_{did}"))
return PlainTextResponse(rec["sadrzaj"], headers={
"Content-Disposition": f'inline; filename="{title}.txt"',
"Cache-Control": "max-age=3600"
})
@router.get("/klub/{kid}/natjecanja")
def klub_natjecanja(kid: int):
"""Get all natjecanja a klub participates in (current sezona)."""
rows = db_query("""
SELECT n.id, n.sport, n.naziv, n.razina, n.sezona, n.pgz_relevant,
t.pozicija, t.odigrano, t.pobjede, t.nerijeseno, t.porazi, t.bodovi
FROM pgz_sport.natjecanja_tablice t
JOIN pgz_sport.natjecanja n ON n.id = t.natjecanje_id
WHERE t.klub_id = %s
ORDER BY n.sport, n.razina""", (kid,))
return {"count": len(rows), "natjecanja": rows}
@router.get("/sportas/search")
def sportas_search(q: str = "", klub_id: Optional[int] = None, limit: int = 30):
"""Search players by name."""
where = ["1=1"]; args = []
if q:
where.append("(LOWER(ime||' '||prezime) LIKE %s OR LOWER(prezime||' '||ime) LIKE %s)")
args.extend([f"%{q.lower()}%"]*2)
if klub_id: where.append("klub_id=%s"); args.append(klub_id)
args.append(limit)
rows = db_query(f"""SELECT c.id, c.ime, c.prezime, c.datum_rodenja, c.slika_url, c.source,
k.naziv AS klub
FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE {" AND ".join(where)}
ORDER BY c.prezime, c.ime LIMIT %s""", tuple(args))
return {"count": len(rows), "results": rows}
# ═══════════════════════════════════════════════════════
# DASHBOARD STATS
# ═══════════════════════════════════════════════════════
@router.get("/dashboard/sport-stats")
def dashboard_sport_stats():
"""High-level KPIs + top players for landing dashboard."""
summary = db_one("""
SELECT
(SELECT count(*) FROM pgz_sport.savezi WHERE aktivan=true) AS savezi,
(SELECT count(*) FROM pgz_sport.klubovi WHERE aktivan=true) AS klubovi,
(SELECT count(*) FROM pgz_sport.klubovi WHERE hns_klub_id IS NOT NULL) AS klubova_hns,
(SELECT count(*) FROM pgz_sport.clanovi) AS clanova,
(SELECT count(*) FROM pgz_sport.clanovi WHERE source='hns_semafor') AS sportasa_hns,
(SELECT count(distinct clan_id) FROM pgz_sport.utakmice_log) AS aktivnih_igraca,
(SELECT count(distinct source_match_id) FROM pgz_sport.utakmice_log) AS scraped_utakmica,
(SELECT COALESCE(sum(pogodaka),0) FROM pgz_sport.utakmice_log) AS ukupno_golova,
(SELECT COALESCE(sum(zuti_kartoni),0) FROM pgz_sport.utakmice_log) AS ukupno_zutih,
(SELECT COALESCE(sum(crveni_kartoni),0) FROM pgz_sport.utakmice_log) AS ukupno_crvenih
""")
top_scorers = db_query("""
SELECT c.id, c.ime||' '||COALESCE(c.prezime,'') AS ime,
c.broj_dresa, c.pozicija, c.slika_url, k.naziv AS klub,
SUM(ul.pogodaka) AS pogodaka,
COUNT(*) AS nastupa
FROM pgz_sport.utakmice_log ul
JOIN pgz_sport.clanovi c ON c.id=ul.clan_id
JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE ul.pogodaka > 0
GROUP BY c.id, c.ime, c.prezime, c.broj_dresa, c.pozicija, c.slika_url, k.naziv
ORDER BY pogodaka DESC, nastupa ASC
LIMIT 10""")
top_appearances = db_query("""
SELECT c.id, c.ime||' '||COALESCE(c.prezime,'') AS ime,
c.broj_dresa, c.pozicija, c.slika_url, k.naziv AS klub,
COUNT(*) AS nastupa,
SUM(ul.minute) AS ukupno_minuta,
SUM(ul.pogodaka) AS pogoci
FROM pgz_sport.utakmice_log ul
JOIN pgz_sport.clanovi c ON c.id=ul.clan_id
JOIN pgz_sport.klubovi k ON k.id=c.klub_id
GROUP BY c.id, c.ime, c.prezime, c.broj_dresa, c.pozicija, c.slika_url, k.naziv
ORDER BY nastupa DESC, ukupno_minuta DESC NULLS LAST
LIMIT 10""")
most_carded = db_query("""
SELECT c.id, c.ime||' '||COALESCE(c.prezime,'') AS ime,
k.naziv AS klub, c.slika_url,
SUM(ul.zuti_kartoni) AS zutih,
SUM(ul.crveni_kartoni) AS crvenih,
COUNT(*) AS nastupa
FROM pgz_sport.utakmice_log ul
JOIN pgz_sport.clanovi c ON c.id=ul.clan_id
JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE ul.zuti_kartoni > 0 OR ul.crveni_kartoni > 0
GROUP BY c.id, c.ime, c.prezime, c.slika_url, k.naziv
ORDER BY (SUM(ul.zuti_kartoni) + SUM(ul.crveni_kartoni)*2) DESC
LIMIT 10""")
klub_breakdown = db_query("""
SELECT k.id, k.naziv, k.sport,
COUNT(DISTINCT c.id) AS sportasa,
COUNT(DISTINCT ul.source_match_id) AS utakmica,
SUM(ul.pogodaka) AS pogoci
FROM pgz_sport.klubovi k
LEFT JOIN pgz_sport.clanovi c ON c.klub_id=k.id AND c.source='hns_semafor'
LEFT JOIN pgz_sport.utakmice_log ul ON ul.clan_id=c.id
WHERE k.hns_klub_id IS NOT NULL
GROUP BY k.id, k.naziv, k.sport
ORDER BY sportasa DESC NULLS LAST
LIMIT 20""")
recent_matches = db_query("""
SELECT DISTINCT ON (ul.source_match_id)
ul.source_match_id, ul.datum, ul.vrijeme, ul.natjecanje,
ul.klub_dom, ul.klub_dom_logo, ul.klub_gost, ul.klub_gost_logo,
ul.rezultat, ul.source_url
FROM pgz_sport.utakmice_log ul
ORDER BY ul.source_match_id, ul.datum DESC
LIMIT 20""")
return {
"summary": summary,
"top_scorers": top_scorers,
"top_appearances": top_appearances,
"most_carded": most_carded,
"klub_breakdown": klub_breakdown,
"recent_matches": recent_matches,
"proracun_tek_god": float(db_exec("SELECT COALESCE(sum(iznos_eur),0) FROM pgz_sport.sufinanciranje_sport WHERE godina=EXTRACT(YEAR FROM NOW())::int") or 0),
"proracun_godina": int(db_exec("SELECT EXTRACT(YEAR FROM NOW())::int") or 2026),
"top_hoo": db_query("SELECT ime, prezime, hoo_kategorija, sport FROM pgz_sport.clanovi WHERE hoo_kategorija IN ('I','II','III') ORDER BY hoo_kategorija, sport LIMIT 20"),
}
# ═══════════════════════════════════════════════════════
# RUČNI UNOS SPORTAŠA (klub admin / pgz_admin)
# ═══════════════════════════════════════════════════════
class CreateSportasReq(BaseModel):
ime: str
prezime: str
klub_id: int
datum_rodenja: Optional[str] = None
mjesto_rodenja: Optional[str] = None
broj_dresa: Optional[int] = None
pozicija: Optional[str] = None
dominantna_noga: Optional[str] = None
visina_cm: Optional[int] = None
tezina_kg: Optional[int] = None
slika_url: Optional[str] = None
oib: Optional[str] = None
biografija: Optional[str] = None
reprezentativac: Optional[bool] = False
reprezentacija_kategorija: Optional[str] = None
@router.post("/sportas/create")
def create_sportas(req: CreateSportasReq, user = Depends(require_user)):
"""Klub admin or pgz_admin can manually add a player to their club."""
ut = user.get('user_type')
if ut not in ('super_admin','pgz_admin','pgz_user','savez_admin','savez_user','klub_admin','klub_user'):
raise HTTPException(403, "Forbidden — only admins can add sportaše")
# Klub admin — must add to their own klub
if ut in ('klub_admin','klub_user'):
if user.get('klub_id') != req.klub_id:
# check via user_klub_links
link = db_one("SELECT 1 FROM pgz_sport.user_klub_links WHERE user_id=%s AND klub_id=%s",
(user['user_id'], req.klub_id))
if not link:
raise HTTPException(403, "Možeš dodavati sportaše samo u svoj klub")
if ut in ('savez_admin','savez_user'):
if user.get('savez_id'):
klub = db_one("SELECT savez_id FROM pgz_sport.klubovi WHERE id=%s", (req.klub_id,))
if klub and klub.get('savez_id') != user['savez_id']:
raise HTTPException(403, "Klub nije u tvom savezu")
# Slug
name = (req.ime + ' ' + req.prezime).strip()
slug = re.sub(r'[^\w]+','-', name.lower()).strip('-')
new_id = db_one("""INSERT INTO pgz_sport.clanovi
(ime, prezime, klub_id, datum_rodenja, mjesto_rodenja, broj_dresa,
pozicija, dominantna_noga, visina_cm, tezina_kg, slika_url, oib,
biografija, reprezentativac, reprezentacija_kategorija,
source, source_synced_at, slug)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'manual',now(),%s)
RETURNING id""",
(req.ime, req.prezime, req.klub_id, req.datum_rodenja, req.mjesto_rodenja,
req.broj_dresa, req.pozicija, req.dominantna_noga, req.visina_cm, req.tezina_kg,
req.slika_url, req.oib, req.biografija, req.reprezentativac,
req.reprezentacija_kategorija, slug))['id']
db_exec("INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,%s)",
(user['user_id'], f'sportas.create:{new_id}'))
return {"id": new_id, "ime": req.ime, "prezime": req.prezime, "klub_id": req.klub_id}
# ═══════════════════════════════════════════════════════
# RUČNI UNOS UTAKMICA (klub admin → po igraču)
# ═══════════════════════════════════════════════════════
class CreateUtakmicaLogReq(BaseModel):
clan_id: int
za_klub_id: int
datum: str
natjecanje: Optional[str] = None
klub_dom: Optional[str] = None
klub_gost: Optional[str] = None
rezultat: Optional[str] = None
pogodaka: Optional[int] = 0
zuti_kartoni: Optional[int] = 0
crveni_kartoni: Optional[int] = 0
minute: Optional[int] = None
zapocet_kao_starter: Optional[bool] = True
@router.post("/utakmice/log")
def create_utakmica_log(req: CreateUtakmicaLogReq, user = Depends(require_user)):
"""Klub admin can log a match for a sportaš in their klub."""
ut = user.get('user_type')
if ut not in ('super_admin','pgz_admin','klub_admin','klub_user','savez_admin'):
raise HTTPException(403, "Forbidden")
# Verify sportaš belongs to klub
c = db_one("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", (req.clan_id,))
if not c: raise HTTPException(404, "Sportaš ne postoji")
if ut in ('klub_admin','klub_user'):
if user.get('klub_id') and user['klub_id'] != c['klub_id']:
raise HTTPException(403, "Sportaš nije u tvom klubu")
new_id = db_one("""INSERT INTO pgz_sport.utakmice_log
(clan_id, za_klub_id, datum, natjecanje, klub_dom, klub_gost, rezultat,
pogodaka, zuti_kartoni, crveni_kartoni, minute, zapocet_kao_starter, source)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'manual')
RETURNING id""",
(req.clan_id, req.za_klub_id, req.datum, req.natjecanje,
req.klub_dom, req.klub_gost, req.rezultat, req.pogodaka,
req.zuti_kartoni, req.crveni_kartoni, req.minute, req.zapocet_kao_starter))['id']
return {"id": new_id, "clan_id": req.clan_id}
# ═══════════════════════════════════════════════════════
# OSOBE / FUNKCIONARI — sportski rukovoditelji PGŽ
# ═══════════════════════════════════════════════════════
@router.get("/osobe-funkcije/list")
def list_osobe_funkcije(sport: Optional[str] = None, savez_id: Optional[int] = None,
q: Optional[str] = None, limit: int = 100):
"""List funkcionare (rukovoditelji saveza/klubova)."""
where = []
params = []
if sport:
where.append("sport ILIKE %s"); params.append(f"%{sport}%")
if savez_id:
where.append("savez_id = %s"); params.append(savez_id)
if q:
where.append("(ime ILIKE %s OR prezime ILIKE %s OR funkcija ILIKE %s OR organizacija ILIKE %s)")
params.extend([f"%{q}%", f"%{q}%", f"%{q}%", f"%{q}%"])
where.append("o.aktivan = true")
sql = f"""
SELECT o.id, o.ime, o.prezime, o.funkcija, o.sport, o.organizacija,
o.izvor, o.izvor_url, o.mandate_od, o.mandate_do,
o.kontakt_email, o.kontakt_tel, o.savez_id, o.klub_id,
s.naziv AS savez_naziv, k.naziv AS klub_naziv
FROM pgz_sport.osobe_funkcije o
LEFT JOIN pgz_sport.savezi s ON s.id=o.savez_id
LEFT JOIN pgz_sport.klubovi k ON k.id=o.klub_id
WHERE {" AND ".join(where)}
ORDER BY o.organizacija NULLS LAST, o.sport NULLS LAST, o.prezime, o.ime
LIMIT %s"""
params.append(limit)
rows = db_query(sql, params)
return {"count": len(rows), "results": rows}
@router.get("/osobe-funkcije/by-sport")
def osobe_by_sport():
"""Group funkcionare by sport."""
rows = db_query("""
SELECT sport, count(*) AS osoba_count,
json_agg(json_build_object(
'id', id, 'ime', ime, 'prezime', prezime,
'funkcija', funkcija, 'organizacija', organizacija
) ORDER BY ime) AS osobe
FROM pgz_sport.osobe_funkcije
WHERE sport IS NOT NULL AND aktivan=true
GROUP BY sport
ORDER BY sport""")
return {"count": len(rows), "results": rows}
# ═══════════════════════════════════════════════════════
# DOBNE KATEGORIJE (auto-assign po sportu i godini rođenja)
# ═══════════════════════════════════════════════════════
class AutoAssignReq(BaseModel):
datum_rodenja: str # YYYY-MM-DD
sport: str
referentna_godina: Optional[int] = None # default: tekuća
spol: Optional[str] = 'MIX'
@router.get("/dobne-kategorije/list")
def list_dobne_kategorije(sport: Optional[str] = None):
"""List sve dobne kategorije, optionally filtrirano po sportu."""
where = ["aktivan = true"]
params = []
if sport:
where.append("LOWER(sport) = LOWER(%s)")
params.append(sport)
sql = f"""SELECT id, sport, naziv, oznaka, min_godina, max_godina,
spol, organizacija, redoslijed, napomena, promocija_dozvoljena
FROM pgz_sport.dobne_kategorije
WHERE {' AND '.join(where)}
ORDER BY sport, redoslijed"""
rows = db_query(sql, params)
return {"count": len(rows), "results": rows}
@router.post("/dobne-kategorije/auto-assign")
def auto_assign_categories(req: AutoAssignReq):
"""Iz datuma rođenja + sporta vraća primjenjive kategorije.
Sportaš može biti u više kategorija (npr. mladi koji su pozvani u stariju selekciju).
Returns:
primary: kategorija najbolje odgovara dobi
additional: ostale primjenjive kategorije (mlađe koje uključuju)
promocije: kategorije u koje se može promovirati (sljedeća stariju)
neeligible: kategorije za koje je presta(la)o pravo
"""
from datetime import date as _date, datetime as _dt
try:
dob = _dt.strptime(req.datum_rodenja, '%Y-%m-%d').date()
except:
raise HTTPException(400, "datum_rodenja mora biti YYYY-MM-DD format")
ref_god = req.referentna_godina or _date.today().year
starost = ref_god - dob.year
rows = db_query("""SELECT id, sport, naziv, oznaka, min_godina, max_godina,
organizacija, redoslijed, napomena, promocija_dozvoljena
FROM pgz_sport.dobne_kategorije
WHERE LOWER(sport) = LOWER(%s) AND aktivan = true
ORDER BY redoslijed""", (req.sport,))
if not rows:
return {
"starost": starost,
"datum_rodenja": req.datum_rodenja,
"sport": req.sport,
"primary": None,
"additional": [],
"promocije": [],
"neeligible": [],
"warning": f"Nema definiranih dobnih kategorija za sport '{req.sport}'"
}
primary = None
additional = []
promocije = []
neeligible = []
# Pravilo: Sportaš pripada svim kategorijama gdje je njegova dob unutar [min, max].
# Primarna = najmlađa (najniži redoslijed) gdje pripada.
# Promocije = sljedeća jedna ili dvije starije (mladi se često promoviraju u stariju selekciju).
# Neeligible = kategorije gdje je dob preuska/iznad max.
eligible_idx = []
for i, k in enumerate(rows):
mn = k['min_godina'] if k['min_godina'] is not None else 0
mx = k['max_godina'] if k['max_godina'] is not None else 200
in_range = mn <= starost <= mx
if in_range:
eligible_idx.append(i)
if eligible_idx:
primary = rows[eligible_idx[0]]
for j in eligible_idx[1:]:
additional.append(rows[j])
# Promocije = sljedeće 1-2 starije kategorije od primary (po redoslijed)
primary_redoslijed = primary['redoslijed']
promocije_kandidati = [k for k in rows if k['redoslijed'] > primary_redoslijed and k.get('promocija_dozvoljena', True)]
# Filtriraj samo one čija je donja granica blizu starosti (max +3 godine razlike)
promocije = []
for k in sorted(promocije_kandidati, key=lambda x: x['redoslijed'])[:3]:
mn = k['min_godina'] or 0
if mn - starost <= 3: # može se promovirati ako je razlika ≤ 3 godine
promocije.append(k)
# Neeligible = kategorije gdje je presta(la)o pravo
neeligible = [k for k in rows if k['redoslijed'] < primary_redoslijed and
(k.get('max_godina') is not None) and starost > k['max_godina']]
else:
# Nije eligible u nijednu kategoriju (presta vrlo mlad ili previše star)
# Pokušaj naći najbližu nižu po starosti
for k in rows:
if k.get('max_godina') and starost > k['max_godina']:
neeligible.append(k)
else:
if not primary:
primary = k
else:
additional.append(k)
return {
"starost": starost,
"datum_rodenja": req.datum_rodenja,
"referentna_godina": ref_god,
"sport": req.sport,
"primary": primary,
"additional": additional,
"promocije": promocije,
"neeligible": neeligible,
"ukupno_dostupno": len(rows),
}
@router.post("/sportas/{cid}/recalc-categories")
def recalc_sportas_categories(cid: int, sport: Optional[str] = None, user = Depends(require_user)):
"""Re-izračunaj kategorije za sportaša na osnovi datuma rođenja + sport."""
s = db_one("""SELECT c.id, c.ime, c.prezime, c.datum_rodenja, c.sport,
k.sport AS klub_sport, c.kategorije
FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE c.id=%s""", (cid,))
if not s: raise HTTPException(404, "Sportaš ne postoji")
if not s.get('datum_rodenja'):
return {"id": cid, "primary": None, "kategorije": [], "promocije": [],
"warning": "Nema datum_rodenja"}
sport_use = sport or s.get('sport') or s.get('klub_sport')
if not sport_use:
return {"id": cid, "primary": None, "kategorije": [], "promocije": [],
"warning": "Nema sporta (klub_sport ni sport stupac)"}
# Use auto_assign internally
from datetime import date as _date
dob_str = str(s['datum_rodenja'])[:10]
req = AutoAssignReq(datum_rodenja=dob_str, sport=sport_use)
result = auto_assign_categories(req)
primary_oznaka = result['primary']['oznaka'] if result.get('primary') else None
primary_naziv = result['primary']['naziv'] if result.get('primary') else None
additional_ozn = [r['oznaka'] for r in result.get('additional', []) if r.get('oznaka')]
promocije_ozn = [r['oznaka'] for r in result.get('promocije', []) if r.get('oznaka')]
# Save to clanovi.kategorije and promocija_kategorije
kategorije = []
if primary_oznaka: kategorije.append(primary_oznaka)
kategorije.extend(additional_ozn)
db_exec("""UPDATE pgz_sport.clanovi
SET kategorije=%s,
promocija_kategorije=%s,
sport=COALESCE(sport, %s),
auto_kategorija_calc_at=now()
WHERE id=%s""",
(kategorije, promocije_ozn, sport_use, cid))
return {
"id": cid,
"ime": s['ime'], "prezime": s.get('prezime'),
"starost": result['starost'],
"sport": sport_use,
"primary": primary_naziv,
"primary_oznaka": primary_oznaka,
"kategorije": kategorije,
"promocije": promocije_ozn,
"details": result,
}
@router.post("/sportas/recalc-all-categories")
def recalc_all_categories(user = Depends(require_user)):
"""Bulk re-izračun kategorija za sve sportaše sa datum_rođenja + sport."""
if user.get('user_type') not in ('super_admin', 'pgz_admin'):
raise HTTPException(403, "Forbidden — samo admin")
rows = db_query("""SELECT c.id, c.datum_rodenja, COALESCE(c.sport, k.sport) AS sport
FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE c.datum_rodenja IS NOT NULL""")
n_updated = 0; n_skipped = 0; n_errors = 0
from datetime import date as _date
for r in rows:
if not r.get('sport'):
n_skipped += 1; continue
try:
dob_str = str(r['datum_rodenja'])[:10]
req = AutoAssignReq(datum_rodenja=dob_str, sport=r['sport'])
result = auto_assign_categories(req)
primary_ozn = result['primary']['oznaka'] if result.get('primary') else None
additional_ozn = [k['oznaka'] for k in result.get('additional', []) if k.get('oznaka')]
promocije_ozn = [k['oznaka'] for k in result.get('promocije', []) if k.get('oznaka')]
kategorije = []
if primary_ozn: kategorije.append(primary_ozn)
kategorije.extend(additional_ozn)
db_exec("""UPDATE pgz_sport.clanovi
SET kategorije=%s, promocija_kategorije=%s, sport=%s,
auto_kategorija_calc_at=now() WHERE id=%s""",
(kategorije, promocije_ozn, r['sport'], r['id']))
n_updated += 1
except Exception as e:
n_errors += 1
return {"updated": n_updated, "skipped_no_sport": n_skipped, "errors": n_errors}
@router.get("/dobne-kategorije/by-sport")
def kategorije_grouped():
"""Group kategorije po sportu — for GUI display."""
rows = db_query("""SELECT sport,
json_agg(json_build_object(
'id', id, 'naziv', naziv, 'oznaka', oznaka,
'min_godina', min_godina, 'max_godina', max_godina,
'organizacija', organizacija, 'redoslijed', redoslijed,
'napomena', napomena, 'promocija_dozvoljena', promocija_dozvoljena
) ORDER BY redoslijed) AS kategorije,
count(*) AS broj
FROM pgz_sport.dobne_kategorije WHERE aktivan=true
GROUP BY sport ORDER BY sport""")
return {"count": len(rows), "results": rows}
# ═══════════════════════════════════════════════════════
# DOKUMENTI / ZAKONI / PRAVILNICI — RAG search + AI agent
# ═══════════════════════════════════════════════════════
import requests as _rq
QDRANT_URL = "http://localhost:6333"
DOK_COLL = "pgz_sport_dokumenti_v1"
EMBED_URL = "http://localhost:9879/api/embeddings"
def _embed_query(text: str):
r = _rq.post(EMBED_URL, json={"model":"bge-m3","prompt":text}, timeout=20)
j = r.json()
return j.get('embedding') or (j.get('data') or [{}])[0].get('embedding')
@router.get("/dokumenti/list")
def list_dokumenti(razina: Optional[str] = None, vrsta: Optional[str] = None,
organizacija: Optional[str] = None, sport: Optional[str] = None,
q: Optional[str] = None, limit: int = 200):
"""Filterable list svih dokumenata."""
where = ["COALESCE(aktivan,true)=true"]
params = []
if razina: where.append("razina = %s"); params.append(razina)
if vrsta: where.append("vrsta = %s"); params.append(vrsta)
if organizacija: where.append("organizacija = %s"); params.append(organizacija)
if sport: where.append("LOWER(sport) = LOWER(%s)"); params.append(sport)
if q:
where.append("(title ILIKE %s OR kratak_opis ILIKE %s OR organizacija ILIKE %s)")
params.extend([f'%{q}%', f'%{q}%', f'%{q}%'])
sql = f"""SELECT id, title AS naziv, kratak_opis, vrsta, razina, organizacija,
sport, sluzbeni_glasnik, izvor_url, kljucne_rijeci, izdano_datum,
CASE WHEN sadrzaj IS NOT NULL THEN length(sadrzaj) ELSE 0 END AS bytes
FROM pgz_sport.dokumenti WHERE {' AND '.join(where)}
ORDER BY razina, vrsta, title LIMIT %s"""
params.append(limit)
rows = db_query(sql, params)
return {"count": len(rows), "results": rows}
@router.get("/dokumenti/by-razina")
def dokumenti_grouped():
"""Group po razini i vrsti — for dashboard."""
rows = db_query("""SELECT razina, vrsta, count(*) AS broj
FROM pgz_sport.dokumenti
WHERE COALESCE(aktivan,true)=true
GROUP BY razina, vrsta ORDER BY razina, vrsta""")
return {"count": len(rows), "results": rows}
@router.get("/dokumenti/{did:int}")
def get_dokument(did: int):
"""Full dokument view with content."""
d = db_one("""SELECT id, title AS naziv, kratak_opis, sadrzaj, vrsta, razina,
organizacija, sport, sluzbeni_glasnik, izvor_url, pdf_url,
kljucne_rijeci, izdano_datum, godina
FROM pgz_sport.dokumenti WHERE id=%s""", (did,))
if not d: raise HTTPException(404, "Dokument ne postoji")
chunks = db_query("""SELECT id, chunk_index, chunk_text, chunk_tokens
FROM pgz_sport.dokument_chunks WHERE dokument_id=%s
ORDER BY chunk_index""", (did,))
return {"dokument": d, "chunks": chunks, "chunks_count": len(chunks)}
class DocSearchReq(BaseModel):
q: str
limit: Optional[int] = 10
razina: Optional[str] = None
sport: Optional[str] = None
@router.post("/dokumenti/search")
def search_dokumenti(req: DocSearchReq):
"""RAG search — vector similarity search across chunks."""
try:
vec = _embed_query(req.q)
if not vec:
raise HTTPException(500, "Embedding failed")
except Exception as e:
raise HTTPException(500, f"Embed error: {e}")
qdrant_filter = None
must = []
if req.razina:
must.append({"key":"razina","match":{"value": req.razina}})
if req.sport:
must.append({"key":"sport","match":{"value": req.sport}})
if must:
qdrant_filter = {"must": must}
body = {
"vector": vec,
"limit": req.limit or 10,
"with_payload": True,
}
if qdrant_filter: body["filter"] = qdrant_filter
r = _rq.post(f"{QDRANT_URL}/collections/{DOK_COLL}/points/search",
json=body, timeout=15)
if r.status_code != 200:
raise HTTPException(500, f"Qdrant error: {r.text[:200]}")
hits = r.json().get("result", [])
# Enrich with full chunk text
results = []
seen_dok = set()
for h in hits:
p = h.get("payload", {})
dok_id = p.get("dokument_id")
chunk = db_one("""SELECT chunk_text FROM pgz_sport.dokument_chunks
WHERE dokument_id=%s AND chunk_index=%s""",
(dok_id, p.get("chunk_index", 0)))
results.append({
"dokument_id": dok_id,
"naziv": p.get("title"),
"vrsta": p.get("vrsta"),
"razina": p.get("razina"),
"organizacija": p.get("organizacija"),
"sport": p.get("sport"),
"izvor_url": p.get("izvor_url"),
"score": round(h.get("score", 0), 4),
"snippet": (chunk["chunk_text"][:400] if chunk else p.get("preview","")) + "...",
})
return {"query": req.q, "count": len(results), "results": results}
class DocAskReq(BaseModel):
q: str
limit_context: Optional[int] = 5
@router.post("/dokumenti/ask")
def ask_legal_expert(req: DocAskReq):
"""AI legal expert — RAG + DeepSeek V3 odgovor s citiranjem."""
# 1. RAG retrieval
try:
vec = _embed_query(req.q)
if not vec:
return {"answer":"Greška u embeddingu pitanja.","sources":[]}
except Exception as e:
return {"answer":f"Embedding greška: {e}","sources":[]}
r = _rq.post(f"{QDRANT_URL}/collections/{DOK_COLL}/points/search",
json={"vector":vec, "limit":req.limit_context or 5, "with_payload":True},
timeout=15)
hits = r.json().get("result", [])
# 2. Build context with sources
context_parts = []
sources = []
for i, h in enumerate(hits):
p = h.get("payload", {})
dok_id = p.get("dokument_id")
chunk = db_one("""SELECT chunk_text FROM pgz_sport.dokument_chunks
WHERE dokument_id=%s AND chunk_index=%s""",
(dok_id, p.get("chunk_index", 0)))
text = chunk["chunk_text"] if chunk else p.get("preview","")
context_parts.append(f"[{i+1}] {p.get('title','?')} ({p.get('razina','?')} · {p.get('organizacija','?')}):\n{text}\n")
sources.append({
"n": i+1,
"naziv": p.get("title"),
"razina": p.get("razina"),
"organizacija": p.get("organizacija"),
"izvor_url": p.get("izvor_url"),
"score": round(h.get("score",0),4),
})
context = "\n\n".join(context_parts)
# 3. LLM via DeepSeek V3
import os
# ═══════════════════════════════════════════════════════════════════════════
# ORCHESTRATOR DELEGATION (OS-first)
# Set USE_ORCHESTRATOR=1 in env to delegate sport queries to dabi-orchestrator
# instead of running pgz-sport's own LLM waterfall.
# Same brain, sport-domain prompt comes from orchestrator persona.
# ═══════════════════════════════════════════════════════════════════════════
USE_ORCHESTRATOR = os.environ.get("USE_ORCHESTRATOR", "0") == "1"
ORCHESTRATOR_URL = os.environ.get("ORCHESTRATOR_URL", "http://localhost:8080/api/v3/ask")
def delegate_to_orchestrator(question: str, persona: str = "app", timeout: int = 60):
"""Call dabi-orchestrator-v3 instead of running our own LLM waterfall.
Returns dict with answer + sources, compatible with sport_lawyer response."""
import json, urllib.request
try:
body = json.dumps({"question": question, "persona": persona}).encode()
req = urllib.request.Request(ORCHESTRATOR_URL, data=body,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r:
d = json.loads(r.read())
return {
"answer": d.get("answer", ""),
"sources": d.get("sources", []) or [],
"intent": d.get("intent"),
"confidence": d.get("confidence"),
"llm": "orchestrator_v3",
"delegated": True,
}
except Exception as e:
return {"answer": "", "delegated": False, "error": str(e)}
api_key = os.environ.get("DEEPSEEK_API_KEY")
if not api_key:
try:
with open("/opt/.env.rinet") as f:
for line in f:
if line.startswith("DEEPSEEK_API_KEY="):
api_key = line.strip().split("=",1)[1].strip("'\"")
break
except: pass
if not api_key:
return {
"answer":"AI agent nije konfiguriran (nedostaje DEEPSEEK_API_KEY). Vraćam sirove rezultate pretrage.",
"sources":sources, "context": context_parts
}
system = """Ti si vrhunski hrvatski stručnjak za sport, zakone i pravilnike — radiš za Zajednicu sportova Primorsko-goranske županije.
Odgovaraš na hrvatskom, kratko, oštro, profesionalno (kao iskusan pravnik za sport).
PRAVILA:
1. Koristi ISKLJUČIVO informacije iz priloženih dokumenata. NIKAD ne izmišljaj.
2. Citiraj izvore brojevima [1], [2] itd. nakon svake tvrdnje koja zahtijeva izvor.
3. Ako kontekst djelomično odgovara — daj odgovor na temelju onoga što imaš + jasno označi što fali.
4. Ako kontekst NE sadrži odgovor — kaži "U dostupnim dokumentima nema odgovora na ovo pitanje" + predloži koji bi dokument bio relevantan.
5. Strukturiraj odgovor: GLAVNI ODGOVOR → DETALJI po točkama → CITIRANI IZVORI [1], [2]…
6. Za pitanja o postupcima (registracija, transfer, kategorizacija) — daj numeriranu listu koraka.
7. Za pitanja o financiranju — istakni iznose, rokove, kriterije.
8. Za PGŽ-specifična pitanja — fokusiraj se na PGZ i Grad Rijeka razinu, ali napomeni i RH/HOO razinu kad je relevantno.
9. Spomeni i nadležnu instituciju (HNS, HOO, HASMS, MTS, ZS PGŽ, itd.).
10. Ako se citirani članci, brojevi NN-a ili slični specifikumi pojavljuju u kontekstu — uvijek ih navedi."""
user_msg = f"PITANJE: {req.q}\n\nKONTEKST DOKUMENATA:\n{context}\n\nOdgovor:"
try:
resp = _rq.post("https://api.deepseek.com/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={
"model":"deepseek-chat",
"messages":[
{"role":"system","content": system},
{"role":"user","content": user_msg}
],
"temperature": 0.2,
"max_tokens": 800
}, timeout=30)
if resp.status_code != 200:
return {"answer":f"LLM error {resp.status_code}: {resp.text[:200]}","sources":sources}
data = resp.json()
answer = data.get("choices",[{}])[0].get("message",{}).get("content","")
return {"answer": answer, "sources": sources, "model":"deepseek-v3"}
except Exception as e:
return {"answer":f"LLM error: {e}","sources":sources, "context":context_parts}
# ═══════════════════════════════════════════════════════
# SPORTSKI OBJEKTI
# ═══════════════════════════════════════════════════════
@router.get("/objekti/list")
def list_objekti(grad: Optional[str] = None, tip: Optional[str] = None, sport: Optional[str] = None):
where = ["aktivan = true"]
params = []
if grad: where.append("grad = %s"); params.append(grad)
if tip: where.append("tip = %s"); params.append(tip)
if sport: where.append("%s = ANY(sportovi)"); params.append(sport)
sql = f"""SELECT id, naziv, tip, grad, adresa, upravitelj, kapacitet, sportovi,
izgradeno, natkrita, web, napomena, lat, lng
FROM pgz_sport.sportski_objekti WHERE {' AND '.join(where)}
ORDER BY grad, naziv"""
rows = db_query(sql, params)
return {"count": len(rows), "results": rows}
@router.get("/objekti/by-grad")
def objekti_by_grad():
rows = db_query("""SELECT grad, count(*) AS broj,
array_agg(DISTINCT tip) AS tipovi
FROM pgz_sport.sportski_objekti WHERE aktivan = true
GROUP BY grad ORDER BY broj DESC""")
return {"count": len(rows), "results": rows}
# ═══════════════════════════════════════════════════════
# NATJECANJA (366)
# ═══════════════════════════════════════════════════════
@router.get("/natjecanja/list")
def list_natjecanja(sport: Optional[str] = None, sezona: Optional[str] = None,
razina: Optional[str] = None, limit: int = 100):
where = []
params = []
if sport: where.append("LOWER(sport) = LOWER(%s)"); params.append(sport)
if sezona: where.append("sezona = %s"); params.append(sezona)
if razina: where.append("razina = %s"); params.append(razina)
where_clause = " WHERE " + " AND ".join(where) if where else ""
sql = f"""SELECT id, naziv, sport, razina, tip, sezona, kategorija, spol,
datum_pocetka, datum_zavrsetka, status, source, external_url
FROM pgz_sport.natjecanja {where_clause}
ORDER BY datum_pocetka DESC NULLS LAST, naziv LIMIT %s"""
params.append(limit)
rows = db_query(sql, params)
return {"count": len(rows), "results": rows}
# ═══════════════════════════════════════════════════════
# MANIFESTACIJE (113)
# ═══════════════════════════════════════════════════════
@router.get("/manifestacije/list")
def list_manifestacije(savez_id: Optional[int] = None, mjesto: Optional[str] = None, limit: int = 200):
where = ["aktivna = true"]
params = []
if savez_id: where.append("savez_id = %s"); params.append(savez_id)
if mjesto: where.append("mjesto ILIKE %s"); params.append(f"%{mjesto}%")
sql = f"""SELECT m.id, m.naziv, m.mjesto, m.organizator, m.razina, m.broj_ucesnika,
m.godina_od, m.spol_kategorija, m.napomena, s.naziv AS savez_naziv, m.savez_id
FROM pgz_sport.manifestacije m
LEFT JOIN pgz_sport.savezi s ON s.id = m.savez_id
WHERE {' AND '.join(where)}
ORDER BY m.naziv LIMIT %s"""
params.append(limit)
rows = db_query(sql, params)
return {"count": len(rows), "results": rows}
# ═══════════════════════════════════════════════════════
# NAJBOLJI SPORTAŠI (22)
# ═══════════════════════════════════════════════════════
@router.get("/najbolji/list")
def list_najbolji(godina: Optional[int] = None):
where = []
params = []
if godina: where.append("godina = %s"); params.append(godina)
sql = f"""SELECT id, godina, kategorija, ime_prezime, klub, sport, napomena
FROM pgz_sport.najbolji_sportasi
{('WHERE ' + ' AND '.join(where)) if where else ''}
ORDER BY godina DESC, kategorija"""
rows = db_query(sql, params)
return {"count": len(rows), "results": rows}
# ═══════════════════════════════════════════════════════
# POTPORE NOSITELJIMA KVALITETE (182)
# ═══════════════════════════════════════════════════════
@router.get("/potpore/list")
def list_potpore(godina: Optional[int] = None):
where = []
params = []
if godina: where.append("godina = %s"); params.append(godina)
sql = f"""SELECT p.id, p.naziv_kluba, p.godina, p.iznos, p.napomena, p.klub_id, k.sport
FROM pgz_sport.potpore_nositelji p
LEFT JOIN pgz_sport.klubovi k ON k.id = p.klub_id
{('WHERE ' + ' AND '.join(where)) if where else ''}
ORDER BY p.godina DESC, p.iznos DESC"""
rows = db_query(sql, params)
return {"count": len(rows), "results": rows,
"total_iznos": sum(float(r.get('iznos') or 0) for r in rows)}
@router.get("/potpore/by-godina")
def potpore_by_godina():
rows = db_query("""SELECT godina, count(*) AS broj, sum(iznos) AS ukupno
FROM pgz_sport.potpore_nositelji
GROUP BY godina ORDER BY godina DESC""")
return {"count": len(rows), "results": rows}
# ═══════════════════════════════════════════════════════
# STATISTIKA SAVEZA (166)
# ═══════════════════════════════════════════════════════
@router.get("/statistika/list")
def list_statistika(godina: Optional[int] = None, savez_id: Optional[int] = None):
where = []
params = []
if godina: where.append("ss.godina = %s"); params.append(godina)
if savez_id: where.append("ss.savez_id = %s"); params.append(savez_id)
sql = f"""SELECT ss.id, ss.savez_id, s.naziv AS savez_naziv, ss.godina,
ss.klubova_clanica, ss.kategoriziranih, ss.registriranih, ss.rekreativaca,
ss.trenera, ss.reprezentativaca, ss.stipendiranih, ss.zaposlenika
FROM pgz_sport.statistika_saveza ss
LEFT JOIN pgz_sport.savezi s ON s.id = ss.savez_id
{('WHERE ' + ' AND '.join(where)) if where else ''}
ORDER BY ss.godina DESC, s.naziv"""
rows = db_query(sql, params)
return {"count": len(rows), "results": rows}
# ═══════════════════════════════════════════════════════
# VIJESTI (286)
# ═══════════════════════════════════════════════════════
@router.get("/vijesti/list")
def list_vijesti(limit: int = 30):
rows = db_query("""SELECT id, title AS naslov, scraped_at AS datum, kind AS kategorija,
substring(body, 1, 200) AS sazetak, url
FROM pgz_sport.vijesti
WHERE title IS NOT NULL
ORDER BY scraped_at DESC NULLS LAST LIMIT %s""", (limit,))
return {"count": len(rows), "results": rows}
# ═══════════════════════════════════════════════════════
# SUCI / TRENERI / SPONZORI / MEDIJI / AKADEMSKI SPORT
# ═══════════════════════════════════════════════════════
@router.get("/suci/list")
def list_suci(sport: Optional[str] = None, grad: Optional[str] = None):
where = ["aktivan=true"]; params = []
if sport: where.append("LOWER(sport)=LOWER(%s)"); params.append(sport)
if grad: where.append("LOWER(grad)=LOWER(%s)"); params.append(grad)
sql = f"""SELECT id, ime, prezime, sport, licenca, kategorija, organizacija, grad
FROM pgz_sport.suci WHERE {' AND '.join(where)}
ORDER BY sport, prezime, ime"""
rows = db_query(sql, params)
return {"count": len(rows), "results": rows}
@router.get("/treneri/list")
def list_treneri(sport: Optional[str] = None, klub_naziv: Optional[str] = None):
where = ["aktivan=true"]; params = []
if sport: where.append("LOWER(sport)=LOWER(%s)"); params.append(sport)
if klub_naziv: where.append("klub_naziv ILIKE %s"); params.append(f"%{klub_naziv}%")
sql = f"""SELECT id, ime, prezime, sport, licenca, organizacija, klub_naziv, pozicija, grad
FROM pgz_sport.treneri WHERE {' AND '.join(where)}
ORDER BY sport, klub_naziv, pozicija, prezime"""
rows = db_query(sql, params)
return {"count": len(rows), "results": rows}
@router.get("/sponzori/list")
def list_sponzori(klub: Optional[str] = None):
where = ["aktivan=true"]; params = []
if klub: where.append("naziv_kluba ILIKE %s"); params.append(f"%{klub}%")
sql = f"""SELECT id, naziv_kluba, sponzor, tip, razdoblje_od, razdoblje_do, iznos_eur, napomena
FROM pgz_sport.sponzori WHERE {' AND '.join(where)}
ORDER BY naziv_kluba, tip, sponzor"""
rows = db_query(sql, params)
return {"count": len(rows), "results": rows}
@router.get("/mediji/list")
def list_mediji(tip: Optional[str] = None, grad: Optional[str] = None):
where = ["aktivan=true"]; params = []
if tip: where.append("tip=%s"); params.append(tip)
if grad: where.append("LOWER(grad)=LOWER(%s)"); params.append(grad)
sql = f"""SELECT id, naziv, tip, grad, vlasnik, web, sport_fokus, pokrivenost
FROM pgz_sport.mediji WHERE {' AND '.join(where)}
ORDER BY tip, naziv"""
rows = db_query(sql, params)
return {"count": len(rows), "results": rows}
@router.get("/akademski/list")
def list_akademski():
rows = db_query("""SELECT id, naziv, fakultet, sveuciliste, sport, sportovi, voditelj,
web, razina, broj_clanova
FROM pgz_sport.akademski_sport WHERE aktivan=true
ORDER BY fakultet, naziv""")
return {"count": len(rows), "results": rows}
# ═══════════════════════════════════════════════════════
# HYBRID AI AGENT — SQL + RAG router
# ═══════════════════════════════════════════════════════
# Schema descriptor — što AI zna o bazi (kratko, fokusirano)
SCHEMA_HINT = """
TABLES PGŽ SPORT (čitanje samo, koristi pgz_sport.X notaciju):
-- KLUBOVI: 1086 PGŽ klubova
pgz_sport.klubovi (id, naziv, sport, oib, savez_id, grad, adresa, telefon, email, web,
godina_osnutka, broj_clanova, predsjednik, tajnik, region, sjediste)
-- SPORTAŠI: 1129
pgz_sport.clanovi (id, ime, prezime, klub_id, datum_rodenja, sport, spol, pozicija,
broj_dresa, dominantna_noga, kategorije TEXT[], promocija_kategorije TEXT[], oib, biografija, slika_url)
-- SAVEZI: 220
pgz_sport.savezi (id, naziv, razina, oib, sport, sjediste, predsjednik, web)
-- SPORTSKI OBJEKTI: 60
pgz_sport.sportski_objekti (id, naziv, tip, grad, adresa, upravitelj, kapacitet,
sportovi TEXT[], "izgrađeno", natkrita, web)
-- tip: 'stadion','dvorana','bazen','klizalište','marina','strelište','boćalište','kompleks','tenis kompleks',...
-- SUCI PGŽ: 27
pgz_sport.suci (id, ime, prezime, sport, licenca, kategorija, organizacija, grad, aktivan)
-- TRENERI PGŽ: 30
pgz_sport.treneri (id, ime, prezime, sport, licenca, organizacija, klub_naziv, pozicija, grad)
-- pozicija: 'glavni','pomoćni','kondicijski','konzultant'
-- SPONZORI: 22 ugovora
pgz_sport.sponzori (id, naziv_kluba, sponzor, tip, razdoblje_od, iznos_eur, napomena)
-- MEDIJI: 15
pgz_sport.mediji (id, naziv, tip, grad, vlasnik, web, sport_fokus TEXT[], pokrivenost)
-- AKADEMSKI SPORT UNIRI: 11
pgz_sport.akademski_sport (id, naziv, fakultet, sveuciliste, sport, sportovi TEXT[], voditelj, web, razina, broj_clanova)
-- NATJECANJA KALENDAR: 366
pgz_sport.natjecanja (id, sport, naziv, razina, tip, sezona, kategorija, spol,
datum_pocetka, datum_zavrsetka, status, savez_id)
-- MANIFESTACIJE: 113
pgz_sport.manifestacije (id, naziv, mjesto, organizator, razina, broj_ucesnika, godina_od, savez_id)
-- NAJBOLJI SPORTAŠI PGŽ: 22 godišnjih nagrada
pgz_sport.najbolji_sportasi (id, godina, kategorija, ime_prezime, klub, sport)
-- POTPORE: 182 isplate
pgz_sport.potpore_nositelji (id, klub_id, naziv_kluba, godina, iznos)
-- iznosi su EUR
-- STATISTIKA SAVEZA godišnja: 166
pgz_sport.statistika_saveza (id, savez_id, godina, klubova_clanica, kategoriziranih,
registriranih, rekreativaca, trenera, reprezentativaca, stipendiranih, zaposlenika)
-- DOBNE KATEGORIJE: 127
pgz_sport.dobne_kategorije (id, sport, naziv, oznaka, min_godina, max_godina, organizacija)
-- FUNKCIONARI saveza/klubova: 155
pgz_sport.osobe_funkcije (id, ime, prezime, funkcija, sport, savez_id, klub_id, organizacija)
-- VIJESTI: 286
pgz_sport.vijesti (id, naslov, datum, kategorija, sazetak, url)
-- DOKUMENTI / ZAKONI / PRAVILNICI: 176 (kolona naziv = title)
pgz_sport.dokumenti (id, title, kratak_opis, vrsta, razina, organizacija, sport, sluzbeni_glasnik, izvor_url, kljucne_rijeci, sadrzaj)
-- UTAKMICE log: 5017
pgz_sport.utakmice_log (id, datum, sport, klub_dom, klub_gost, rezultat, klub_id, sezona)
"""
class HybridAskReq(BaseModel):
q: str
@router.post("/ai/ask")
def hybrid_ai_ask(req: HybridAskReq):
"""Hybrid agent — odluči SQL vs RAG vs oba.
Workflow:
1. LLM klasificira pitanje (SQL / RAG / oba)
2. Ako SQL: generira SELECT, izvršava, formira odgovor
3. Ako RAG: vector search dokumentima
4. Ako oba: kombinira
"""
import os
api_key = os.environ.get("DEEPSEEK_API_KEY")
if not api_key:
try:
with open("/opt/.env.rinet") as f:
for line in f:
if line.startswith("DEEPSEEK_API_KEY="):
api_key = line.strip().split("=",1)[1].strip("'\"")
break
except: pass
if not api_key:
return {"answer":"AI agent nije konfiguriran","mode":"error"}
# STEP 1: Classify + generate SQL if applicable
classify_prompt = f"""Ti si SQL ekspert za PGŽ sport bazu. Korisnik je pitao:
PITANJE: {req.q}
DOSTUPNE TABLICE:
{SCHEMA_HINT}
ODLUČI:
1) SQL — ako pitanje traži operativne podatke iz tablica (imena trenera, popis objekata, statistike, brojevi, lokacije, najbolji)
2) RAG — ako pitanje traži pravne/regulativne info (zakoni, pravilnici, postupci, propisi, definicije)
3) BOTH — ako traži oba
VRATI VALIDAN JSON OBJEKT (bez markdown):
{{"mode":"SQL"|"RAG"|"BOTH", "sql": "SELECT ...", "rag_query": "..."}}
PRAVILA SQL:
- Koristi LIMIT 30
- ILIKE za fuzzy match
- Koristi LOWER() za case-insensitive
- NIKAD UPDATE/DELETE/INSERT/DROP
- Samo SELECT iz pgz_sport.* tablica
- Koristi join-ove gdje treba
PRIMJERI:
"Tko je trener HNK Rijeke?" → SQL: SELECT ime, prezime, pozicija, licenca FROM pgz_sport.treneri WHERE klub_naziv ILIKE '%HNK Rijeka%'
"Sportski objekti Rijeka" → SQL: SELECT naziv, tip, adresa, kapacitet, sportovi FROM pgz_sport.sportski_objekti WHERE grad='Rijeka' ORDER BY tip, naziv LIMIT 30
"Najbolji sportaši 2025" → SQL: SELECT kategorija, ime_prezime, klub FROM pgz_sport.najbolji_sportasi WHERE godina=2025
"Koje obveze ima sportski klub po Zakonu o sportu" → RAG: question
"Suci nogometa u PGŽ" → SQL: SELECT ime, prezime, licenca, kategorija FROM pgz_sport.suci WHERE sport='nogomet'
"Sponzori HNK Rijeka" → SQL: SELECT sponzor, tip, razdoblje_od FROM pgz_sport.sponzori WHERE naziv_kluba ILIKE '%HNK Rijeka%'
"Koliko klubova ima Boćarski savez PGŽ" → SQL: SELECT klubova_clanica FROM pgz_sport.statistika_saveza ss JOIN pgz_sport.savezi s ON s.id=ss.savez_id WHERE s.naziv ILIKE '%Boćarski savez%' ORDER BY godina DESC LIMIT 1
"""
try:
resp = _rq.post("https://api.deepseek.com/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type":"application/json"},
json={
"model":"deepseek-chat",
"messages":[{"role":"user","content": classify_prompt}],
"temperature": 0.0, "max_tokens": 500,
"response_format": {"type":"json_object"}
}, timeout=20)
plan = resp.json()["choices"][0]["message"]["content"]
import json as _json
plan_obj = _json.loads(plan)
except Exception as e:
return {"answer":f"Klasifikacija greška: {e}","mode":"error"}
mode = plan_obj.get("mode","SQL").upper()
sql = plan_obj.get("sql","")
rag_q = plan_obj.get("rag_query", req.q)
sql_results = []
rag_sources = []
# STEP 2a: Execute SQL if needed
if mode in ("SQL","BOTH") and sql:
# Safety: only SELECT, only pgz_sport.*
sql_lower = sql.lower().strip()
if not sql_lower.startswith("select") or any(x in sql_lower for x in ["update ","delete ","insert ","drop ","alter ","create ","truncate ","grant ","revoke ","exec "]):
return {"answer":"SQL nije siguran","mode":"error","sql": sql}
try:
# Direct execution bez parameter binding to avoid % placeholder issues
import psycopg2 as _ps2, psycopg2.extras as _pse2
with _ps2.connect(**DB) as _c:
_cur = _c.cursor(cursor_factory=_pse2.RealDictCursor)
_cur.execute(sql) # no params, raw SQL
sql_results = _cur.fetchall() if _cur.description else []
except Exception as e:
return {"answer":f"SQL greška: {e}","mode":"sql_error","sql":sql}
# STEP 2b: Execute RAG if needed
rag_context = ""
if mode in ("RAG","BOTH"):
try:
vec = _embed_query(rag_q)
r = _rq.post(f"{QDRANT_URL}/collections/{DOK_COLL}/points/search",
json={"vector":vec, "limit":4, "with_payload":True}, timeout=15)
hits = r.json().get("result",[])
for i, h in enumerate(hits):
p_data = h.get("payload",{})
ch = db_one("""SELECT chunk_text FROM pgz_sport.dokument_chunks
WHERE dokument_id=%s AND chunk_index=%s""",
(p_data.get("dokument_id"), p_data.get("chunk_index",0)))
txt = ch["chunk_text"] if ch else p_data.get("preview","")
rag_context += f"[{i+1}] {p_data.get('title','?')}: {txt[:600]}\n\n"
rag_sources.append({"n":i+1, "naziv":p_data.get("title"),
"razina":p_data.get("razina"), "organizacija":p_data.get("organizacija"),
"izvor_url":p_data.get("izvor_url"), "score": round(h.get("score",0),3)})
except Exception as e:
rag_context = ""
# STEP 3: Final answer
final_prompt = f"""Ti si stručnjak za PGŽ sport. Korisnik pitao:
PITANJE: {req.q}
"""
if sql_results:
import json as _json
final_prompt += f"REZULTATI SQL UPITA ({len(sql_results)} redaka):\n{_json.dumps(sql_results, ensure_ascii=False, default=str)[:3000]}\n\n"
if rag_context:
final_prompt += f"PRAVNI/REGULATIVNI KONTEKST:\n{rag_context}\n\n"
final_prompt += """Daj kratak, precizan, profesionalan odgovor na hrvatskom.
Koristi konkretne podatke iz SQL rezultata.
Citiraj pravne izvore brojevima [1][2] ako koristiš RAG.
Ako rezultati nisu dovoljni, kaži to iskreno.
Ne ponavljaj cijele tablice — sažmi i istakni najvažnije."""
try:
resp = _rq.post("https://api.deepseek.com/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type":"application/json"},
json={
"model":"deepseek-chat",
"messages":[{"role":"user","content": final_prompt}],
"temperature": 0.2, "max_tokens": 800
}, timeout=30)
answer = resp.json()["choices"][0]["message"]["content"]
except Exception as e:
return {"answer":f"Final greška: {e}","mode":mode,"sql":sql,"sql_results":sql_results[:3]}
return {
"answer": answer,
"mode": mode,
"sql": sql if sql else None,
"sql_count": len(sql_results),
"sql_sample": sql_results[:5] if sql_results else None,
"sources": rag_sources if rag_sources else None,
}
# ═══════════════════════════════════════════════════════
# TEXT-TO-SQL AGENT — operativna pitanja preko SQL
# ═══════════════════════════════════════════════════════
# Whitelist tablica koje SQL agent smije čitati
SQL_AGENT_TABLES = {
'klubovi': 'Sportski klubovi PGŽ. Stupci: id, naziv, sport, oib, iban, web, email, telefon, adresa, grad, region, godina_osnutka, predsjednik, tajnik, broj_clanova, savez_id',
'savezi': 'Sportski savezi (županijski/gradski/nacionalni). Stupci: id, naziv, razina, oib, sjediste, web, email',
'clanovi': 'Sportaši. Stupci: id, ime, prezime, klub_id (može biti NULL!), sport, datum_rodenja, spol, kategorije TEXT[] (dobne kat npr. {U17} ili {OPEN}), pozicija, broj_dresa, oib, hoo_kategorija TEXT (rimski I/II/III/IV/V/VI), hoo_vrijedi_od/do DATE, klub_naziv_godisnjak TEXT (KORISTI OVO za pretragu klub-roster, npr. HNK Rijeka roster preko klub_naziv_godisnjak ILIKE \'%HNK Rijeka%\'). NAPOMENE: za broj sportaša u klubu, koristi klub_naziv_godisnjak ILIKE \'%X%\' (mnogi sportaši nemaju klub_id, ali imaju ime kluba u godišnjaku). NE JOIN-aj s dobne_kategorije.',
'sportski_objekti': 'Sportske građevine PGŽ. Stupci: id, naziv, tip, grad, adresa, upravitelj, kapacitet, sportovi (array), izgradeno (BEZ Č - godina izgradnje), natkrita (bool), web. NAPOMENA: tip može biti dvorana/stadion/bazen/marina/klizalište/skijaški/strelište/boćalište.',
'suci': 'Suci po sportovima. Stupci: id, ime, prezime, sport, licenca, kategorija, organizacija, grad',
'treneri': 'Treneri klubova PGŽ. Stupci: id, ime, prezime, sport, licenca, organizacija, klub_naziv, pozicija, grad',
'sponzori': 'Sponzorstva klubova. Stupci: id, naziv_kluba, sponzor, tip, razdoblje_od, iznos_eur, napomena',
'mediji': 'Sportski mediji PGŽ. Stupci: id, naziv, tip, grad, vlasnik, web, sport_fokus (array), pokrivenost',
'akademski_sport': 'UNIRI sportski klubovi. Stupci: id, naziv, fakultet, sport, sportovi (array), voditelj, web, razina, broj_clanova',
'natjecanja': 'Sportska natjecanja. Stupci: id, naziv, sport, savez_id, razina, tip, sezona, kategorija, datum_pocetka, datum_zavrsetka, status',
'manifestacije': 'Sportske manifestacije. Stupci: id, naziv, mjesto, organizator, razina, broj_ucesnika, godina_od, savez_id',
'najbolji_sportasi': 'Godišnji najbolji/najuspješniji sportaši PGŽ (godišnja izborna lista). Stupci: id, godina, kategorija (npr. "Najuspješniji sportaš senior", "Najuspješnija sportašica seniorka", "Najuspješniji trener", "Sportski djelatnik godine", "Najuspješnija muška seniorska ekipa", "Najuspješniji parasportaš (motor/slijepi)", "Najuspješniji sportaš junior/kadet"), ime_prezime, klub, sport, napomena. NAPOMENE: za pretragu po kategoriji koristi ILIKE "%trener%" ili "%djelatnik%" jer kategorije imaju duga imena.',
'potpore_nositelji': 'Financijske potpore klubovima. Stupci: id, naziv_kluba, klub_id, godina, iznos, napomena',
'statistika_saveza': 'Godišnja statistika saveza. Stupci: id, savez_id, godina, klubova_clanica, kategoriziranih, registriranih, rekreativaca, trenera, reprezentativaca, stipendiranih',
'vijesti': 'Sportske vijesti. Stupci: id, naslov, datum, kategorija, sazetak, url',
'osobe_funkcije': 'Funkcionari klubova/saveza. Stupci: id, ime, prezime, funkcija, sport, savez_id, klub_id, organizacija',
'dobne_kategorije': 'Dobne kategorije po sportu. Stupci: id, sport, naziv, oznaka, min_godina, max_godina, organizacija',
'sportas_specifika': 'Sport-specifični podaci sportaša (1:N s clanovima). Stupci: id, clan_id, sport, pojas_titula (npr. "Velemajstor (GM)", "Olimpijka", "CMAS instructor"), rating, rating_sustav (FIDE/ATP/WA), najbolji_rezultat, najbolja_godina, hns_id, visina_cm, tezina_kg, nogometska_pozicija, napomena.',
'klub_sezona': 'Klubovi po sezoni i trofejima. Stupci: id, klub_id, klub_naziv, sezona (npr. "2024/2025"), natjecanje (npr. "1. HNL", "PH rukomet", "SP Szekesfehérvár"), plasiranje, bodovi, trofej (npr. "PRVAK HRVATSKE", "OSVAJAČI KUPA", "SVJETSKO ZLATO"), napomena.',
'utakmice_log': 'Utakmice log. Stupci: id, klub_id, sportas_id, datum, protivnik, rezultat, golovi, asistencije, zuti, crveni, sezona, sport, broj_natjecanja',
}
def _db_schema_brief():
parts = []
for tbl, desc in SQL_AGENT_TABLES.items():
parts.append(f"pgz_sport.{tbl}{desc}")
return "\n".join(parts)
def _sql_safe(sql: str) -> bool:
"""Verify SQL is read-only and only touches whitelist tables."""
sql_lower = sql.lower().strip()
# Block destructive
forbidden = ['insert', 'update', 'delete', 'drop', 'truncate', 'alter', 'create', 'grant', 'revoke', '--', ';--', 'pg_', 'into ', 'copy ', 'replace ']
for f in forbidden:
if f in sql_lower: return False
# Must be SELECT
if not sql_lower.startswith('select'): return False
# Multiple statements not allowed
if sql_lower.rstrip(';').count(';') > 0: return False
return True
class AskSmartReq(BaseModel):
q: str
limit_context: Optional[int] = 5
@router.post("/dokumenti/ask-smart")
def ask_smart(req: AskSmartReq):
"""Smart ask: prvo procijeni pitanje (operativno vs regulativno),
onda izvrši SQL (operativno) ili RAG (regulativno).
"""
import os
api_key = os.environ.get("DEEPSEEK_API_KEY")
if not api_key:
try:
with open("/opt/.env.rinet") as f:
for line in f:
if line.startswith("DEEPSEEK_API_KEY="):
api_key = line.strip().split("=",1)[1].strip("\'\"")
break
except: pass
if not api_key:
return {"answer":"DEEPSEEK_API_KEY missing","sources":[],"mode":"error"}
# Step 1: classify
classify_msg = f"""Pitanje korisnika: "{req.q}"
Kategorija pitanja:
A) OPERATIVNO — pita konkretne entitete iz baze:
- Imena pojedinačnih trenera/sudaca/sportaša ("tko je X", "tko trenira Y")
- Liste s konkretnim redovima (objekti u Rijeci, sponzori HNK, suci nogomet)
- Brojevi entiteta (koliko klubova, sportaša, sudaca - count po kriteriju)
- Top N po metriki (top 5 klubova, najmlađi, najstariji)
- Trofeji klub_sezona (HNK Rijeka, KK Mlaka)
- HOO kategorije (I-VI), najbolji godine
B) REGULATIVNO/OPISNO — RAG iz dokumenata:
- Zakoni, pravilnici, statuti, etika, fair play
- Postupci (kako se registrira, licencira, kategorizira)
- Definicije pojmova
- PRORAČUNI ZS PGŽ (proračun, financiranje, potpore)
- PROGRAMI ZS PGŽ (programi, programske aktivnosti, sufinanciranje)
- SPORTSKI PREGLEDI (zdravstveni pregledi, ambulanta)
- Razvoj sporta, statistički pregledi cijele PGŽ scene
- Najuspješniji sportovi međunarodno
- Općenita pitanja "tko/što su X" ako nije named entity
Odgovor SAMO jednim slovom: A ili B"""
try:
cl_resp = _rq.post("https://api.deepseek.com/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={"model":"deepseek-chat",
"messages":[{"role":"user","content": classify_msg}],
"temperature":0.0, "max_tokens":5}, timeout=20)
clss = cl_resp.json().get("choices",[{}])[0].get("message",{}).get("content","B").strip().upper()
mode = "SQL" if clss.startswith("A") else "RAG"
except:
mode = "RAG"
# Step 2: SQL or RAG
if mode == "SQL":
# Generate SQL
schema = _db_schema_brief()
sql_prompt = f"""Ti si PostgreSQL ekspert. Schema:
{schema}
Pravila:
- KORISTI SAMO SELECT, nikad INSERT/UPDATE/DELETE/DROP/ALTER
- Schema je `pgz_sport.<tablica>`
- Vrati SAMO čisti SQL, bez markdown ```, bez objašnjenja
- LIMIT 50 ako vraćaš listu
- Za pretragu po imenu koristi ILIKE '%text%'
- Datum_rodenja je DATE format
- Za pretragu sportaša po kategoriji: WHERE 'U17' = ANY(c.kategorije) — NIKAD JOIN s dobne_kategorije
- Stupci s kvačicama: koristi izgradeno (NE izgrađeno), velicina (NE veličina) — uvijek ASCII
- Koristi LOWER() za case-insensitive equals: WHERE LOWER(grad) = LOWER('rijeka')
Primjeri:
P: "Tko su sportaši I. kategorije?" → SELECT ime, prezime, sport, klub_naziv_godisnjak FROM pgz_sport.clanovi WHERE hoo_kategorija='I' ORDER BY sport, prezime LIMIT 50
P: "Najbolji trener 2025?" → SELECT ime_prezime, klub, sport FROM pgz_sport.najbolji_sportasi WHERE godina=2025 AND kategorija ILIKE '%trener%'
P: "Sportski djelatnik 2025?" → SELECT ime_prezime, klub FROM pgz_sport.najbolji_sportasi WHERE godina=2025 AND kategorija ILIKE '%djelatnik%'
P: "Koliko sportaša HNK Rijeka?" → SELECT count(*) FROM pgz_sport.clanovi WHERE klub_naziv_godisnjak ILIKE '%nogometni klub%Rijeka%' OR klub_naziv_godisnjak ILIKE '%HNK Rijeka%' (NAPOMENA: u DB je "Hrvatski nogometni klub \"Rijeka\"")
P: "VK Primorje juniori roster?" → SELECT ime, prezime, sport FROM pgz_sport.clanovi WHERE klub_naziv_godisnjak ILIKE '%Primorje EB%' OR klub_naziv_godisnjak ILIKE '%Primorje%vaterpolo%'
P: "VK Primorje juniori?" → SELECT ime, prezime FROM pgz_sport.clanovi WHERE klub_naziv_godisnjak ILIKE '%Primorje%' AND napomena ILIKE '%junior%'
P: "Koliko vrhunskih u karateu?" → SELECT count(*) FROM pgz_sport.clanovi WHERE LOWER(sport)='karate' AND hoo_kategorija IN ('I','II')
P: "Tko trenira X?" → SELECT ime, prezime, pozicija FROM pgz_sport.treneri WHERE klub_naziv ILIKE '%X%'
P: "Koliko sportaša u U17?" → SELECT count(*) FROM pgz_sport.clanovi WHERE 'U17' = ANY(kategorije)
P: "Stadioni s preko 5000 mjesta" → SELECT naziv, kapacitet FROM pgz_sport.sportski_objekti WHERE tip='stadion' AND kapacitet>5000
P: "Sportaši po kategoriji nogomet" → SELECT kat, count(*) FROM pgz_sport.clanovi c, unnest(c.kategorije) AS kat WHERE c.sport='nogomet' GROUP BY kat ORDER BY count(*) DESC
P: "Sponzori najveći iznos" → SELECT sponzor, naziv_kluba, iznos_eur FROM pgz_sport.sponzori WHERE iznos_eur IS NOT NULL ORDER BY iznos_eur DESC LIMIT 10
P: "Ukupno potpora 2026" → SELECT sum(iznos) FROM pgz_sport.potpore_nositelji WHERE godina=2026
P: "Statistika saveza po klubovima" → SELECT s.naziv, ss.klubova_clanica FROM pgz_sport.statistika_saveza ss JOIN pgz_sport.savezi s ON s.id=ss.savez_id WHERE ss.godina=2024 ORDER BY ss.klubova_clanica DESC LIMIT 10
P: "Tko su svjetski prvaci PGŽ 2025?" → SELECT ime_prezime, klub, sport, napomena FROM pgz_sport.najbolji_sportasi WHERE godina=2025 AND kategorija ILIKE '%SVJETSKI%'
P: "Trofeji HNK Rijeka 2024/25?" → SELECT natjecanje, plasiranje, trofej FROM pgz_sport.klub_sezona WHERE klub_naziv ILIKE '%HNK Rijeka%' AND sezona='2024/2025'
P: "Nagrade za životno djelo 2025?" → SELECT ime_prezime, klub, sport, napomena FROM pgz_sport.najbolji_sportasi WHERE godina=2025 AND kategorija ILIKE '%životno djelo%'
P: "Sport-spec za Sara Kolak?" → SELECT s.* FROM pgz_sport.sportas_specifika s JOIN pgz_sport.clanovi c ON c.id=s.clan_id WHERE c.ime='Sara' AND c.prezime='Kolak'
P: "Top saveza po klubovima 2025" → SELECT s.naziv, ss.klubova_clanica FROM pgz_sport.statistika_saveza ss JOIN pgz_sport.savezi s ON s.id=ss.savez_id WHERE ss.godina=2025 AND ss.klubova_clanica IS NOT NULL ORDER BY ss.klubova_clanica DESC NULLS LAST LIMIT 10
P: "Predsjednik X saveza" → SELECT o.ime, o.prezime, o.funkcija FROM pgz_sport.osobe_funkcije o JOIN pgz_sport.savezi s ON s.id=o.savez_id WHERE s.naziv ILIKE '%X%' AND o.funkcija ILIKE '%predsjednik%'
P: "Tko je predsjednik Parasportskog saveza" → SELECT o.ime, o.prezime FROM pgz_sport.osobe_funkcije o JOIN pgz_sport.savezi s ON s.id=o.savez_id WHERE s.naziv ILIKE 'Parasportski%' AND o.funkcija ILIKE '%predsjednik%'
P: "Klubovi u parasportskom savezu" → SELECT k.naziv, k.sport, k.grad FROM pgz_sport.klubovi k JOIN pgz_sport.savezi s ON s.id=k.savez_id WHERE s.naziv ILIKE 'Parasportski%' ORDER BY k.naziv
P: "Koje sportove pokriva X savez" → SELECT DISTINCT k.sport FROM pgz_sport.klubovi k JOIN pgz_sport.savezi s ON s.id=k.savez_id WHERE s.naziv ILIKE '%X%'
P: "Sport sportaša X" → SELECT DISTINCT sport FROM pgz_sport.najbolji_sportasi WHERE ime_prezime ILIKE '%X%' UNION SELECT DISTINCT sport FROM pgz_sport.clanovi WHERE (ime || ' ' || prezime) ILIKE '%X%'
Pitanje: {req.q}
SQL:"""
try:
sg_resp = _rq.post("https://api.deepseek.com/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={"model":"deepseek-chat",
"messages":[{"role":"user","content": sql_prompt}],
"temperature":0.0, "max_tokens":300}, timeout=20)
raw = sg_resp.json().get("choices",[{}])[0].get("message",{}).get("content","").strip()
# Strip markdown if present
sql = raw.replace("```sql","").replace("```","").strip()
if not _sql_safe(sql):
return {"mode":"SQL_BLOCKED","answer":"Generirani SQL nije siguran. Pokušaj drugo pitanje.","sql_attempt":sql,"sources":[]}
# Execute
try:
# Use psycopg2 directly to avoid % placeholder collision with ILIKE
_conn = psycopg2.connect(**DB)
_cur = _conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
_cur.execute(sql)
rows = _cur.fetchall() if _cur.description else []
_conn.close()
except Exception as e:
return {"mode":"SQL_ERROR","answer":f"SQL greška: {e}","sql":sql,"sources":[]}
# Generate natural answer
data_str = json.dumps(rows[:30], default=str, ensure_ascii=False)
ans_prompt = f"""Pitanje: {req.q}
Rezultati iz baze (JSON):
{data_str}
Sastavi kratak, konkretan odgovor na hrvatskom jeziku. Ako rezultata nema, kaži to."""
try:
ans_resp = _rq.post("https://api.deepseek.com/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={"model":"deepseek-chat",
"messages":[{"role":"user","content": ans_prompt}],
"temperature":0.2, "max_tokens":600}, timeout=20)
answer = ans_resp.json().get("choices",[{}])[0].get("message",{}).get("content","")
except Exception as e:
answer = f"Pronađeno {len(rows)} zapisa. (LLM error: {e})"
return {"mode":"SQL","answer":answer,"sql":sql,"row_count":len(rows),
"rows":rows[:30],"sources":[]}
except Exception as e:
return {"mode":"SQL_FAIL","answer":f"Greška u SQL agentu: {e}","sources":[]}
# RAG fallback
return ask_legal_expert(DocAskReq(q=req.q, limit_context=req.limit_context))
import json
# ═══ HOO KATEGORIZIRANI SPORTAŠI ═══
@router.get("/kategorizirani/list")
def list_kategorizirani(kategorija: Optional[str] = None, sport: Optional[str] = None):
where = ["c.kategorija_hoo AS hoo_kategorija IS NOT NULL"]; params = []
if kategorija: where.append("c.hoo_kategorija=%s"); params.append(kategorija)
if sport: where.append("LOWER(c.sport)=LOWER(%s)"); params.append(sport)
sql = f"""SELECT c.id, c.ime, c.prezime, c.hoo_kategorija, c.sport,
c.hoo_kategorija_od, c.hoo_kategorija_do, c.mjesto_rodenja,
k.naziv AS klub_naziv
FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE {' AND '.join(where)}
ORDER BY c.hoo_kategorija, c.sport, c.prezime, c.ime"""
rows = db_query(sql, params)
return {"count": len(rows), "results": rows}
@router.get("/kategorizirani/by-sport")
def kategorizirani_by_sport():
rows = db_query("""SELECT sport, hoo_kategorija, count(*) AS broj
FROM pgz_sport.clanovi WHERE hoo_kategorija IS NOT NULL
GROUP BY sport, hoo_kategorija ORDER BY sport, hoo_kategorija""")
return {"count": len(rows), "results": rows}
@router.get("/statistika-2025")
def stats_2025():
"""Brojevi sportaša po savezu/sportu prema Sportskom godišnjaku ZS PGŽ 2025."""
rows = db_query("""SELECT s.naziv AS savez, ss.godina, ss.registriranih
FROM pgz_sport.statistika_saveza ss
JOIN pgz_sport.savezi s ON s.id=ss.savez_id
WHERE ss.godina=2025 AND ss.registriranih > 0
ORDER BY ss.registriranih DESC""")
return {"count": len(rows), "results": rows,
"ukupno": sum(r['registriranih'] for r in rows),
"izvor": "Sportski godišnjak ZS PGŽ 2025"}
# === HNS Semafor stil endpointi (29.04.2026 sprint) ===
@router.get("/klubovi/{kid}/clanovi")
def klub_clanovi_pregled(kid: int):
"""HNS Semafor stil pregled članstva - svi sportaši kluba sa kratkom statistikom."""
klub = db_one("""SELECT id, naziv, sport, razina, region, grad, godina_osnutka,
adresa, telefon, web, hns_klub_id, hns_slug, logo_url, source_synced_at
FROM pgz_sport.klubovi WHERE id=%s""", (kid,))
if not klub:
raise HTTPException(404, "Klub nije pronađen")
# Sportaši + agg per igrača
sportasi = db_query("""SELECT c.id, c.ime, c.prezime, c.slika_url, c.broj_dresa, c.pozicija,
c.datum_rodenja, c.mjesto_rodenja, c.uloga, c.reprezentativac, c.aktivan,
c.source, c.source_id, c.source_url,
(SELECT count(*) FROM pgz_sport.utakmice_log u WHERE u.clan_id=c.id) AS nastupa_total,
(SELECT COALESCE(sum(pogodaka),0) FROM pgz_sport.utakmice_log u WHERE u.clan_id=c.id) AS pogoci_total,
(SELECT COALESCE(sum(minute),0) FROM pgz_sport.utakmice_log u WHERE u.clan_id=c.id) AS minute_total,
(SELECT max(datum) FROM pgz_sport.utakmice_log u WHERE u.clan_id=c.id) AS zadnja_utakmica
FROM pgz_sport.clanovi c
WHERE c.klub_id=%s
ORDER BY
CASE c.uloga
WHEN 'predsjednik' THEN 1
WHEN 'dopredsjednik' THEN 2
WHEN 'tajnik' THEN 3
WHEN 'direktor' THEN 4
WHEN 'član uprave' THEN 5
WHEN 'član nadzornog odbora' THEN 6
WHEN 'team_manager' THEN 7
WHEN 'trener' THEN 10
WHEN 'pomocni_trener' THEN 11
WHEN 'trener_vratara' THEN 12
WHEN 'kondicioni_trener' THEN 13
WHEN 'fizioterapeut' THEN 14
WHEN 'lijecnik' THEN 15
WHEN 'analiticar' THEN 16
WHEN 'video_analiticar' THEN 17
WHEN 'igrac' THEN 50
WHEN 'sportaš' THEN 51
WHEN 'sportas' THEN 51
WHEN 'sudac' THEN 60
WHEN 'ostalo' THEN 90
ELSE 99
END,
c.broj_dresa NULLS LAST, c.prezime, c.ime""", (kid,))
return {
"klub": klub,
"count": len(sportasi),
"sportasi": sportasi,
"izvor": klub.get("source_synced_at") and "HNS Semafor (auto-sync)" or "Ručno"
}
@router.get("/klubovi/sa-clanstvom")
def klubovi_sa_clanstvom(sport: str = None, region: str = "PGŽ", limit: int = 100, offset: int = 0):
"""Lista klubova sa brojem članova i izvorom podataka."""
where = ["k.aktivan=true"]; args = []
if sport: where.append("k.sport=%s"); args.append(sport)
if region: where.append("k.region=%s"); args.append(region)
where_sql = " AND ".join(where)
args.extend([limit, offset])
rows = db_query(f"""SELECT k.id, k.naziv, k.sport, k.razina, k.grad, k.godina_osnutka,
k.logo_url, k.hns_klub_id, k.hns_slug, k.source_synced_at,
(SELECT count(*) FROM pgz_sport.clanovi c WHERE c.klub_id=k.id) AS broj_clanova,
(SELECT count(*) FROM pgz_sport.clanovi c WHERE c.klub_id=k.id AND c.source='hns_semafor') AS hns_clanova
FROM pgz_sport.klubovi k
WHERE {where_sql}
ORDER BY broj_clanova DESC, k.naziv
LIMIT %s OFFSET %s""", tuple(args))
return {"count": len(rows), "klubovi": rows}
@router.get("/clanovi")
def list_clanovi(sport: Optional[str] = None, klub_id: Optional[int] = None,
kategorija_min: Optional[int] = None, reprezentativac: Optional[bool] = None,
spol: Optional[str] = None, uloga: Optional[str] = None,
q: Optional[str] = None, limit: int = 200, offset: int = 0):
"""Filterable list of clanovi for showSportasiModal."""
where = ["c.aktivan IS NOT FALSE"]
params = []
if sport:
where.append("(LOWER(c.sport) = LOWER(%s) OR LOWER(k.sport) = LOWER(%s))")
params.extend([sport, sport])
if klub_id: where.append("c.klub_id = %s"); params.append(klub_id)
if kategorija_min: where.append("c.kategorija_hoo IS NOT NULL AND c.kategorija_hoo <= %s"); params.append(kategorija_min)
if reprezentativac is not None: where.append("c.reprezentativac = %s"); params.append(reprezentativac)
if spol: where.append("c.spol = %s"); params.append(spol)
if uloga: where.append("c.uloga = %s"); params.append(uloga)
if q:
where.append("(c.ime ILIKE %s OR c.prezime ILIKE %s OR k.naziv ILIKE %s)")
params.extend([f"%{q}%", f"%{q}%", f"%{q}%"])
sql = f"""SELECT c.id, c.ime, c.prezime, c.sport, c.uloga, c.spol,
c.kategorija_hoo, c.reprezentativac, c.slika_url, c.broj_dres AS broj_dresa,
c.pozicija, c.klub_id, k.naziv AS klub_naziv,
c.datum_rodjenja, c.godina_rodenja, c.mjesto_rodjenja
FROM pgz_sport.clanovi c
LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE {' AND '.join(where)}
ORDER BY c.kategorija_hoo NULLS LAST, c.prezime, c.ime
LIMIT %s OFFSET %s"""
params.extend([limit, offset])
rows = db_query(sql, params)
return {"count": len(rows), "data": rows}
@router.get("/clanovi/{cid}/full-profile")
def clan_full_profile(cid: int):
"""HNS Semafor stil kompletni profil sportaša - dovoljno za GUI render."""
sp = db_one("""SELECT c.id, c.ime, c.prezime, c.slika_url, c.broj_dresa, c.pozicija,
c.datum_rodenja, c.godina_rodenja, c.mjesto_rodenja, c.adresa, c.grad, c.uloga,
c.dominantna_noga, c.visina_cm, c.tezina_kg, c.biografija,
c.reprezentativac, c.reprezentacija_kategorija, c.kategorija_hoo,
c.licenca_broj, c.licenca_vrijedi_do, c.aktivan,
c.source, c.source_id, c.source_url, c.source_synced_at, c.slug,
c.klub_id, k.naziv AS klub_naziv, k.sport, k.razina, k.region,
k.grad AS klub_grad, k.logo_url AS klub_logo,
k.hns_klub_id, k.hns_slug
FROM pgz_sport.clanovi c
LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE c.id=%s""", (cid,))
if not sp:
raise HTTPException(404, "Sportaš nije pronađen")
# Sezone agregirane
sezone = db_query("""
SELECT
CASE WHEN EXTRACT(MONTH FROM datum)>=7
THEN EXTRACT(YEAR FROM datum)::TEXT||'/'||LPAD(((EXTRACT(YEAR FROM datum)+1)::INT %% 100)::TEXT, 2, '0')
ELSE (EXTRACT(YEAR FROM datum)-1)::TEXT||'/'||LPAD((EXTRACT(YEAR FROM datum)::INT %% 100)::TEXT, 2, '0')
END AS sezona,
natjecanje, count(*) AS nastupi,
COALESCE(SUM(pogodaka),0) AS pogoci,
COALESCE(SUM(zuti_kartoni),0) AS zuti,
COALESCE(SUM(crveni_kartoni),0) AS crveni,
COALESCE(SUM(minute),0) AS minute_total
FROM pgz_sport.utakmice_log
WHERE clan_id=%s AND datum IS NOT NULL
GROUP BY 1, 2
ORDER BY 1 DESC, 2""", (cid,))
# Utakmice (zadnjih 50)
utakmice = db_query("""SELECT id, datum, vrijeme, natjecanje,
klub_dom, klub_dom_logo, klub_gost, klub_gost_logo,
rezultat, pogodaka, zuti_kartoni, crveni_kartoni, minute,
zapocet_kao_starter, source_url
FROM pgz_sport.utakmice_log
WHERE clan_id=%s ORDER BY datum DESC NULLS LAST LIMIT 50""", (cid,))
# Karijera - klubovi kroz vrijeme
karijera = db_query("""SELECT k.id, k.naziv, k.logo_url,
min(ul.datum) AS od_dat, max(ul.datum) AS do_dat,
count(*) AS nastupa,
COALESCE(sum(ul.pogodaka),0) AS pogoci
FROM pgz_sport.utakmice_log ul
JOIN pgz_sport.klubovi k ON k.id=ul.za_klub_id
WHERE ul.clan_id=%s
GROUP BY k.id, k.naziv, k.logo_url
ORDER BY min(ul.datum)""", (cid,))
# Trenutna sezona stats (top blok)
cur_season = db_one("""
SELECT count(*) AS nastupi, COALESCE(sum(pogodaka),0) AS pogoci,
COALESCE(sum(zuti_kartoni),0) AS zuti, COALESCE(sum(crveni_kartoni),0) AS crveni,
COALESCE(sum(minute),0) AS minute_total
FROM pgz_sport.utakmice_log
WHERE clan_id=%s
AND datum >= (CASE WHEN EXTRACT(MONTH FROM CURRENT_DATE)>=7
THEN make_date(EXTRACT(YEAR FROM CURRENT_DATE)::INT, 7, 1)
ELSE make_date(EXTRACT(YEAR FROM CURRENT_DATE)::INT - 1, 7, 1)
END)""", (cid,))
# A4_NAGRADE_PATCH: pojedinačne nagrade/medalje sportaša
nagrade = db_query("""
SELECT godina, sezona, natjecanje, razina_natjecanja, dobna_kategorija,
disciplina, plasman, medalja, napomena, source, source_url
FROM pgz_sport.clan_nagrada
WHERE clan_id=%s
ORDER BY
CASE razina_natjecanja
WHEN 'OI' THEN 1 WHEN 'SP' THEN 2 WHEN 'EP' THEN 3
WHEN 'SK' THEN 4 WHEN 'EK' THEN 5 WHEN 'DP' THEN 6 ELSE 7 END,
plasman ASC NULLS LAST, godina DESC""", (cid,))
# A4_TROFEJI_KLUB: ako sportaš ima klub_id, dohvati klubove trofeje (sezonske)
klub_trofeji = []
if sp.get('klub_id'):
klub_trofeji = db_query("""
SELECT sezona, natjecanje, plasiranje, trofej, bodovi
FROM pgz_sport.klub_sezona
WHERE klub_id=%s
ORDER BY sezona DESC LIMIT 30""", (sp['klub_id'],))
# A4_PRIZNANJA: najbolji_sportasi nagrade (godišnje povijesne)
priznanja = db_query("""
SELECT godina, kategorija, klub, sport, napomena
FROM pgz_sport.najbolji_sportasi
WHERE clan_id=%s OR (clan_id IS NULL AND LOWER(ime_prezime) = LOWER(%s))
ORDER BY godina DESC""", (cid, f"{sp.get('ime','')} {sp.get('prezime','')}"))
return {
"sportas": sp,
"trenutna_sezona": cur_season or {"nastupi":0,"pogoci":0,"zuti":0,"crveni":0,"minute_total":0},
"sezone": sezone,
"karijera": karijera,
"utakmice": utakmice,
"nagrade": nagrade,
"klub_trofeji": klub_trofeji,
"priznanja": priznanja,
"totals": {
"nastupa": sum((s.get('nastupi') or 0) for s in sezone),
"pogodaka": sum((s.get('pogoci') or 0) for s in sezone),
"zutih": sum((s.get('zuti') or 0) for s in sezone),
"crvenih": sum((s.get('crveni') or 0) for s in sezone),
"minuta": sum((s.get('minute_total') or 0) for s in sezone),
}
}
# === SPORT PREGLED ENDPOINTI (29.04.2026 sprint - svi sportovi) ===
@router.get("/sport/svi/stats")
def svi_sportovi_stats():
"""Sumarno za sve sportove - broj klubova, sportaša, saveza, manifestacija."""
rows = db_query("""
WITH agg AS (
SELECT k.sport,
count(distinct k.id) AS klubova,
count(distinct k.grad) AS gradova,
count(distinct c.id) AS sportasa,
count(distinct c.id) FILTER (WHERE (c.kategoriziran=true OR c.kategorija_hoo IS NOT NULL)) AS kategoriziranih
FROM pgz_sport.klubovi k
LEFT JOIN pgz_sport.clanovi c ON c.klub_id=k.id
WHERE k.sport IS NOT NULL AND k.sport != ''
GROUP BY k.sport
),
savezi_agg AS (
SELECT lower(sport) AS sport_l, count(*) AS savez_count
FROM pgz_sport.savezi WHERE sport IS NOT NULL
GROUP BY lower(sport)
),
manif_agg AS (
SELECT lower(s.sport) AS sport_l, count(*) AS manif_count
FROM pgz_sport.manifestacije m
JOIN pgz_sport.savezi s ON s.id=m.savez_id
WHERE s.sport IS NOT NULL
GROUP BY lower(s.sport)
),
nagrade_agg AS (
SELECT lower(sport) AS sport_l, count(*) AS nagrade_count
FROM pgz_sport.najbolji_sportasi WHERE sport IS NOT NULL
GROUP BY lower(sport)
)
SELECT a.sport, a.klubova, a.gradova, a.sportasa, a.kategoriziranih,
COALESCE(sa.savez_count, 0) AS saveza,
COALESCE(ma.manif_count, 0) AS manifestacija,
COALESCE(na.nagrade_count, 0) AS nagrada
FROM agg a
LEFT JOIN savezi_agg sa ON sa.sport_l = lower(a.sport)
LEFT JOIN manif_agg ma ON ma.sport_l = lower(a.sport)
LEFT JOIN nagrade_agg na ON na.sport_l = lower(a.sport)
ORDER BY a.klubova DESC
""")
return {"count": len(rows), "sportovi": rows,
"totals": {
"klubova": sum(r['klubova'] for r in rows),
"sportasa": sum(r['sportasa'] for r in rows),
"saveza": sum(r['saveza'] for r in rows),
"manifestacija": sum(r['manifestacija'] for r in rows),
}}
@router.get("/sport/{sport_naziv}/pregled")
def sport_pregled(sport_naziv: str):
"""Detaljan pregled za jedan sport - klubovi, sportaši, savez, trofeji, najbolji, manifestacije."""
sport_l = sport_naziv.lower().strip()
# Sinonimi - savez nazivi često imaju različitu morfologiju (nogomet ↔ Nogometni, rukomet ↔ Rukometni)
SPORT_SYNONYMS = {
'nogomet': ['nogomet', 'nogometni'],
'rukomet': ['rukomet', 'rukometni'],
'košarka': ['košarka', 'košarkaški', 'kosarka', 'kosarkaski'],
'kosarka': ['košarka', 'košarkaški', 'kosarka', 'kosarkaski'],
'vaterpolo': ['vaterpolo', 'vaterpolski'],
'odbojka': ['odbojka', 'odbojkaški', 'odbojkaski'],
'tenis': ['tenis', 'teniski'],
'stolni tenis': ['stolni tenis', 'stolnoteniski'],
'plivanje': ['plivanje', 'plivački', 'plivacki'],
'biciklizam': ['biciklizam', 'biciklistički', 'biciklisticki'],
'boks': ['boks', 'boksački', 'boksacki'],
'boćanje': ['boćanje', 'bocanje', 'boćarski', 'bocarski'],
'kuglanje': ['kuglanje', 'kuglački', 'kuglacki'],
'streljaštvo': ['streljaštvo', 'streljaski', 'streljački'],
'streličarstvo': ['streličarstvo', 'strelicarstvo'],
'judo': ['judo'],
'karate': ['karate'],
'taekwondo': ['taekwondo'],
'kickboxing': ['kickboxing'],
'jedriličarstvo': ['jedriličarstvo', 'jedrilicarstvo', 'jedriličarski', 'jedrilicarski'],
'šah': ['šah', 'sah', 'šahovski', 'sahovski'],
'sah': ['šah', 'sah', 'šahovski', 'sahovski'],
'pikado': ['pikado'],
'ribolov': ['ribolov', 'ribolovni', 'športsko ribolovni'],
'skijanje': ['skijanje', 'skijaški', 'skijaski'],
'atletika': ['atletika', 'atletski'],
'gimnastika': ['gimnastika'],
'planinarstvo': ['planinarstvo', 'planinarski'],
'motosport': ['motosport', 'motociklizam', 'auto-moto'],
'rekreacija': ['rekreacija', 'rekreacijski', 'sportska rekreacija'],
'parasport': ['parasport', 'parasportski', 'paraolimpijski', 'osoba s invaliditetom'],
'multisport': ['multisport', 'sportski savez'],
'lov': ['lov', 'lovački', 'lovacki'],
'ples': ['ples', 'plesni'],
'borilački sport': ['borilački', 'borilacki'],
}
sport_synonyms = SPORT_SYNONYMS.get(sport_l, [sport_l])
sport_pattern = '|'.join(sport_synonyms) # za regex
sport_ilike_pattern = '|'.join(f'%{s}%' for s in sport_synonyms)
# Saveze - matching ILIKE ANY
savezi = db_query("""SELECT id, naziv, skraceni_naziv, godina_osnutka, predsjednik, tajnik,
adresa, grad, telefon, email, web, razina
FROM pgz_sport.savezi
WHERE (lower(sport) = ANY(%s) OR lower(naziv) ~ %s) AND aktivan=true
ORDER BY naziv""", (sport_synonyms, sport_pattern))
# Klubovi - top 50 po broju članova
klubovi = db_query("""SELECT k.id, k.naziv, k.razina, k.region, k.grad, k.godina_osnutka,
k.logo_url, k.hns_klub_id,
(SELECT count(*) FROM pgz_sport.clanovi c WHERE c.klub_id=k.id) AS broj_clanova,
(SELECT count(*) FROM pgz_sport.clanovi c
WHERE c.klub_id=k.id AND (c.kategoriziran=true OR c.kategorija_hoo IS NOT NULL)) AS broj_kategoriziranih
FROM pgz_sport.klubovi k
WHERE lower(k.sport) = ANY(%s) AND k.aktivan=true
ORDER BY broj_clanova DESC, k.naziv
LIMIT 100""", (sport_synonyms,))
# Top sportaši - kategorizirani (HOO I, II, III)
top_sportasi = db_query("""SELECT c.id, c.ime, c.prezime, c.slika_url, c.kategorija_hoo,
c.reprezentativac, c.broj_dresa, c.pozicija, c.aktivan,
k.naziv AS klub_naziv, k.id AS klub_id
FROM pgz_sport.clanovi c
LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE (lower(c.sport) = ANY(%s) OR lower(k.sport) = ANY(%s))
AND ((c.kategoriziran=true OR c.kategorija_hoo IS NOT NULL) OR c.reprezentativac=true OR c.kategorija_hoo IS NOT NULL)
ORDER BY c.kategorija_hoo NULLS LAST, c.prezime
LIMIT 50""", (sport_synonyms, sport_synonyms))
# Trofeji povijesni iz klub_sezona
trofeji = db_query("""SELECT ks.klub_naziv, ks.sezona, ks.natjecanje, ks.plasiranje,
ks.trofej, ks.napomena, k.id AS klub_id
FROM pgz_sport.klub_sezona ks
LEFT JOIN pgz_sport.klubovi k ON k.id=ks.klub_id
WHERE k.id IS NOT NULL AND lower(k.sport) = ANY(%s)
ORDER BY ks.sezona DESC NULLS LAST, ks.plasiranje
LIMIT 100""", (sport_synonyms,))
# Najbolji sportaši kroz godine
najbolji = db_query("""SELECT godina, kategorija, ime_prezime, klub, napomena
FROM pgz_sport.najbolji_sportasi
WHERE lower(sport) = ANY(%s) OR lower(sport) ~ %s
ORDER BY godina DESC, kategorija
LIMIT 100""", (sport_synonyms, sport_pattern))
# Manifestacije
manifestacije = db_query("""SELECT m.id, m.naziv, m.mjesto, m.organizator, m.razina,
m.broj_ucesnika, m.godina_od, m.spol_kategorija,
s.naziv AS savez_naziv
FROM pgz_sport.manifestacije m
LEFT JOIN pgz_sport.savezi s ON s.id=m.savez_id
WHERE (lower(s.sport) = ANY(%s) OR lower(s.naziv) ~ %s) AND m.aktivna=true
ORDER BY m.naziv
LIMIT 100""", (sport_synonyms, sport_pattern))
# Stats sumarno za ovaj sport (sa M/Ž razdvajanjem)
stats = db_one("""SELECT
(SELECT count(*) FROM pgz_sport.klubovi WHERE lower(sport)=ANY(%s) AND aktivan=true) AS broj_klubova,
(SELECT count(distinct grad) FROM pgz_sport.klubovi WHERE lower(sport)=ANY(%s)) AS broj_gradova,
(SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id AND k.aktivan=true
WHERE lower(k.sport)=ANY(%s)) AS broj_sportasa,
(SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id AND k.aktivan=true
WHERE lower(k.sport)=ANY(%s) AND c.spol='M') AS broj_sportasa_m,
(SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id AND k.aktivan=true
WHERE lower(k.sport)=ANY(%s) AND c.spol='Ž') AS broj_sportasa_z,
(SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE lower(k.sport)=ANY(%s) AND (c.kategoriziran=true OR c.kategorija_hoo IS NOT NULL)) AS broj_kategoriziranih,
(SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE lower(k.sport)=ANY(%s) AND (c.kategoriziran=true OR c.kategorija_hoo IS NOT NULL) AND c.spol='M') AS broj_kategoriziranih_m,
(SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE lower(k.sport)=ANY(%s) AND (c.kategoriziran=true OR c.kategorija_hoo IS NOT NULL) AND c.spol='Ž') AS broj_kategoriziranih_z,
(SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE lower(k.sport)=ANY(%s) AND c.reprezentativac=true) AS broj_reprezentativaca,
(SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE lower(k.sport)=ANY(%s) AND c.reprezentativac=true AND c.spol='M') AS broj_reprezentativaca_m,
(SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE lower(k.sport)=ANY(%s) AND c.reprezentativac=true AND c.spol='Ž') AS broj_reprezentativaca_z""",
(sport_synonyms, sport_synonyms, sport_synonyms, sport_synonyms, sport_synonyms,
sport_synonyms, sport_synonyms, sport_synonyms, sport_synonyms, sport_synonyms, sport_synonyms))
# Latest scrape - ako je nogomet, daj zadnju utakmicu
zadnja_utakmica = None
if sport_l == 'nogomet':
zadnja_utakmica = db_one("""SELECT max(datum) AS datum FROM pgz_sport.utakmice_log""")
return {
"sport": sport_naziv,
"stats": stats,
"savezi": savezi,
"klubovi": klubovi,
"top_sportasi": top_sportasi,
"trofeji": trofeji,
"najbolji": najbolji,
"manifestacije": manifestacije,
"zadnja_aktivnost": zadnja_utakmica
}
# ============ GOOGLE AI ENRICHMENT ============
class EnrichRequest(BaseModel):
entity_type: str
entity_id: Optional[int] = None
query: str
@router.post("/enrich/google-ai")
def enrich_google_ai(req: EnrichRequest):
"""Search internet + LLM synthesis + save to docs.
Uses DuckDuckGo HTML for search (no API key) + Groq Llama 3.3 70b."""
import urllib.request, urllib.parse, json as jj, gzip, re as re2
query = req.query.strip()
if not query:
return {"summary": "Nema upita.", "sources": []}
# Step 1: Search DuckDuckGo HTML (no API key)
sources = []
try:
ddg_url = f"https://duckduckgo.com/html/?q={urllib.parse.quote(query)}"
req_h = urllib.request.Request(ddg_url, headers={
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
'Accept-Encoding': 'gzip'
})
with urllib.request.urlopen(req_h, timeout=10) as r:
data = r.read()
if r.headers.get('Content-Encoding') == 'gzip':
data = gzip.decompress(data)
html = data.decode('utf-8', errors='replace')
# Parse top results
import sys; print(f"DDG html len={len(html)}", file=sys.stderr); results = re2.findall(r'<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([^<]+)</a>', html)
for url, title in results[:5]:
# Decode DDG redirect
m = re2.search(r'uddg=([^&]+)', url)
if m:
url = urllib.parse.unquote(m.group(1))
sources.append({"url": url, "title": title.strip()[:120]})
except Exception as e:
sources = [{"url": f"https://www.google.com/search?q={urllib.parse.quote(query)}", "title": "Google search"}]
# Step 2: Fetch top 2-3 sources
fetched = []
for s in sources[:3]:
try:
req_h = urllib.request.Request(s["url"], headers={
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
'Accept-Encoding': 'gzip'
})
with urllib.request.urlopen(req_h, timeout=8) as r:
data = r.read()
if r.headers.get('Content-Encoding') == 'gzip':
data = gzip.decompress(data)
src_html = data.decode('utf-8', errors='replace')
text = re2.sub(r'<script[^>]*>.*?</script>', ' ', src_html, flags=re2.DOTALL)
text = re2.sub(r'<style[^>]*>.*?</style>', ' ', text, flags=re2.DOTALL)
text = re2.sub(r'<[^>]+>', ' ', text)
text = re2.sub(r'\s+', ' ', text).strip()[:3000]
fetched.append({"url": s["url"], "title": s["title"], "text": text})
except Exception:
pass
# Step 3: LLM synthesis via Groq
summary = ""
facts = []
if fetched:
sources_block = "\n\n".join(f"IZVOR: {f['title']}\nURL: {f['url']}\nTEKST: {f['text']}" for f in fetched)
prompt = f"""Sintetiziraj informacije o "{query}" iz sljedećih izvora. Odgovori na hrvatskom, kratko i jasno (max 200 riječi).
{sources_block}
Format odgovora (JSON):
{{"summary": "kratki opis u 2-3 rečenice", "facts": ["činjenica 1", "činjenica 2", "činjenica 3"]}}
Odgovori SAMO sa JSON objektom, bez markdown wrapper-a."""
try:
import os
gk = os.environ.get("GROQ_API_KEY")
if not gk:
with open("/opt/rinet-gpu/.env.master") as f:
for line in f:
if line.startswith("GROQ_API_KEY="):
gk = line.split("=",1)[1].strip()
break
if gk:
groq_req = urllib.request.Request(
"https://api.groq.com/openai/v1/chat/completions",
data=jj.dumps({
"model": "llama-3.3-70b-versatile",
"messages": [{"role":"user","content":prompt}],
"max_tokens": 800,
"temperature": 0.2
}).encode(),
headers={"Authorization": f"Bearer {gk}", "Content-Type":"application/json", "User-Agent":"Mozilla/5.0"}
)
with urllib.request.urlopen(groq_req, timeout=20) as r:
resp = jj.loads(r.read())
content = resp["choices"][0]["message"]["content"].strip()
content = re2.sub(r'^```(?:json)?\s*', '', content)
content = re2.sub(r'\s*```$', '', content).strip()
try:
obj = jj.loads(content)
summary = obj.get("summary", "")
facts = obj.get("facts", [])
except:
summary = content[:500]
except Exception as e:
summary = f"AI sinteza nije uspjela: {e}"
# Step 4: Save to dokumenti for RAG
saved = False
if summary and len(summary) > 50:
try:
doc_id = db_one("""INSERT INTO pgz_sport.dokumenti
(title, sadrzaj, vrsta, izvor_url, organizacija, kratak_opis, izdano_datum, aktivan)
VALUES (%s, %s, 'enrichment', %s, 'AI Enrichment', %s, CURRENT_DATE, true)
RETURNING id""",
(f"AI Enrichment: {req.query[:120]}",
f"{summary}\n\nKljučne činjenice:\n" + "\n".join(f"- {f}" for f in facts) +
f"\n\nIzvori:\n" + "\n".join(f"{s['title']}: {s['url']}" for s in sources),
sources[0]["url"] if sources else None,
summary[:300]))
saved = bool(doc_id)
except Exception:
pass
return {
"summary": summary,
"facts": facts,
"sources": sources[:5],
"saved_to_db": saved,
"google_search_url": f"https://www.google.com/search?q={urllib.parse.quote(query)}"
}
# ============ KLUB WEB ENRICHMENT ============
class KlubEnrichRequest(BaseModel):
klub_id: int
urls: Optional[List[str]] = None # if None, try klub.web
@router.post("/enrich/klub-web")
def enrich_klub_web(req: KlubEnrichRequest):
"""Scrape klub web for roster + uprava + stručni stožer.
Uses Groq Llama to extract structured roster from HTML.
Returns list of upserted clanovi + counts by uloga."""
import urllib.request, urllib.parse, json as jj, gzip, re as re2, os
klub = db_one("""SELECT id, naziv, web, sport, region FROM pgz_sport.klubovi WHERE id=%s""", (req.klub_id,))
if not klub:
return {"error": "klub not found", "klub_id": req.klub_id}
urls = req.urls or []
if not urls and klub.get("web"):
web = klub["web"].strip().rstrip("/")
# Try common subpaths for HR football clubs
urls = [web + p for p in ["/uprava/", "/momcad/", "/strucni-stozer/", "/igraci/", "/tim/", "/klub/"]]
urls.append(web) # also home
if not urls:
return {"error": "no web URL for klub", "klub_id": req.klub_id, "klub_naziv": klub.get("naziv")}
UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
all_text = []
fetched_urls = []
for u in urls[:6]:
try:
r_h = urllib.request.Request(u, headers={"User-Agent": UA, "Accept-Encoding": "gzip"})
with urllib.request.urlopen(r_h, timeout=10) as r:
data = r.read()
if r.headers.get("Content-Encoding") == "gzip":
data = gzip.decompress(data)
src_html = data.decode("utf-8", errors="replace")
# Strip scripts/styles
text = re2.sub(r"<script[^>]*>.*?</script>", " ", src_html, flags=re2.DOTALL)
text = re2.sub(r"<style[^>]*>.*?</style>", " ", text, flags=re2.DOTALL)
# Keep <h*>, <strong>, <b> tags as markers
text = re2.sub(r"<(h[1-6])[^>]*>", "\n## ", text)
text = re2.sub(r"</h[1-6]>", "\n", text)
text = re2.sub(r"<(strong|b)[^>]*>", "**", text)
text = re2.sub(r"</(strong|b)>", "**", text)
text = re2.sub(r"<br[^>]*>", "\n", text)
text = re2.sub(r"<[^>]+>", " ", text)
text = re2.sub(r"&nbsp;", " ", text)
text = re2.sub(r"\s+", " ", text).strip()[:8000]
if text and len(text) > 100:
all_text.append(f"=== URL: {u} ===\n{text}")
fetched_urls.append(u)
except Exception:
pass
if not all_text:
return {"error": "could not fetch any URL", "tried": urls}
combined = "\n\n".join(all_text)[:18000]
# LLM extraction via Groq
prompt = f"""Iz priloženih HTML stranica nogometnog/sportskog kluba "{klub.get('naziv')}" izvuci strukturirani JSON popis osoba.
Stranice mogu sadržavati:
- UPRAVU (predsjednik, dopredsjednik, tajnik, direktor, član uprave)
- STRUČNI STOŽER (glavni trener, pomoćni trener, trener vratara, kondicijski trener, fizioterapeut, liječnik, video-analitičar)
- IGRAČE prve momčadi (ime, prezime, broj dresa)
Vrati SAMO JSON array. Format svake osobe:
{{"ime":"X", "prezime":"Y", "uloga":"predsjednik|dopredsjednik|tajnik|direktor|trener|pomocni_trener|trener_vratara|kondicioni_trener|fizioterapeut|lijecnik|igrac|ostalo", "broj_dresa": null|N, "pozicija": null|"GK"|"DF"|"MF"|"FW", "napomena": null|"opis"}}
PRAVILA:
- Ne izmišljaj. Ako neka osoba nije jasno spomenuta, preskoči je.
- Igrače stavi u uloga="igrac" SAMO ako su jasno na popisu igrača prve momčadi.
- Predsjednik/uprava NIKAD nemaju uloga="igrac".
- Brojeve dresa parsiraj iz formata "Ime Prezime (BROJ)" → broj_dresa=BROJ.
Stranice:
{combined}
Vrati SAMO JSON array, bez markdown, bez objašnjenja."""
try:
gk = os.environ.get("GROQ_API_KEY")
if not gk:
return {"error": "no GROQ_API_KEY"}
groq_req = urllib.request.Request(
"https://api.groq.com/openai/v1/chat/completions",
data=jj.dumps({
"model": "llama-3.3-70b-versatile",
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 4000,
"temperature": 0.1
}).encode(),
headers={"Authorization": f"Bearer {gk}", "Content-Type": "application/json", "User-Agent": "Mozilla/5.0"}
)
with urllib.request.urlopen(groq_req, timeout=40) as r:
resp = jj.loads(r.read())
content = resp["choices"][0]["message"]["content"].strip()
content = re2.sub(r"^```(?:json)?\s*", "", content)
content = re2.sub(r"\s*```$", "", content).strip()
# Find JSON array
m_arr = re2.search(r"\[[\s\S]*\]", content)
if not m_arr:
return {"error": "LLM did not return JSON array", "raw": content[:500]}
try:
people = jj.loads(m_arr.group(0))
except Exception as e:
return {"error": f"JSON parse: {e}", "raw": content[:500]}
except Exception as e:
return {"error": f"LLM call: {e}"}
if not isinstance(people, list):
return {"error": "not a list", "people": people}
# Upsert each person
inserted = 0
updated = 0
skipped = 0
by_uloga = {}
for p in people:
if not isinstance(p, dict): continue
ime = (p.get("ime") or "").strip()
prezime = (p.get("prezime") or "").strip()
if not ime or not prezime: skipped += 1; continue
uloga = (p.get("uloga") or "ostalo").strip()
broj = p.get("broj_dresa")
try: broj = int(broj) if broj else None
except: broj = None
poz = p.get("pozicija")
nap = p.get("napomena")
# Check if exists by name + klub
existing = db_one("""SELECT id, uloga FROM pgz_sport.clanovi
WHERE LOWER(ime) = LOWER(%s) AND LOWER(prezime) = LOWER(%s)
AND klub_id = %s LIMIT 1""", (ime, prezime, req.klub_id))
source_url = fetched_urls[0] if fetched_urls else None
try:
if existing:
db_exec("""UPDATE pgz_sport.clanovi SET
uloga=%s, broj_dres=COALESCE(%s, broj_dres),
pozicija=COALESCE(%s, pozicija),
source='klub_web', source_url=COALESCE(source_url, %s),
last_updated=now() WHERE id=%s""",
(uloga, broj, poz, source_url, existing["id"]))
updated += 1
else:
db_exec("""INSERT INTO pgz_sport.clanovi
(ime, prezime, uloga, broj_dres, pozicija, klub_id, sport,
source, source_url, napomena, last_updated)
VALUES (%s, %s, %s, %s, %s, %s, %s, 'klub_web', %s, %s, now())""",
(ime, prezime, uloga, broj, poz, req.klub_id, klub.get("sport"), source_url, nap))
inserted += 1
by_uloga[uloga] = by_uloga.get(uloga, 0) + 1
except Exception as e:
skipped += 1
# Audit
db_exec("""INSERT INTO pgz_sport.audit_feed (table_name, action, source, source_url, details)
VALUES ('clanovi', 'klub_web_enrich', 'klub_web', %s, %s::jsonb)""",
(fetched_urls[0] if fetched_urls else None,
jj.dumps({"klub_id": req.klub_id, "klub": klub.get("naziv"),
"inserted": inserted, "updated": updated, "by_uloga": by_uloga})))
return {
"klub_id": req.klub_id,
"klub_naziv": klub.get("naziv"),
"fetched_urls": fetched_urls,
"people_count": len(people),
"inserted": inserted,
"updated": updated,
"skipped": skipped,
"by_uloga": by_uloga
}
@router.get("/audit/data-quality")
def audit_data_quality(user = Depends(require_user)):
"""Pokaze koliko podataka je provjereno (sa source_url) vs neprovjereno."""
require_role(user, ['super_admin','pgz_admin','pgz_user'])
sportasi = db_query("""
SELECT
source,
count(*) AS total,
count(source_url) AS sa_izvorom,
count(datum_rodenja) AS sa_dat_rod,
count(godina_rodenja) AS sa_god_rod,
count(mjesto_rodenja) AS sa_mjesto,
count(slika_url) AS sa_slikom,
count(klub_id) AS sa_klubom
FROM pgz_sport.clanovi
GROUP BY source
ORDER BY total DESC
""")
klubovi = db_query("""
SELECT
COALESCE(scrape_source, 'manual') AS source,
count(*) AS total,
count(scrape_url) AS sa_izvorom,
count(godina_osnutka) AS sa_godinom,
count(adresa) AS sa_adresom,
count(telefon) AS sa_telefonom,
count(hns_klub_id) AS sa_hns
FROM pgz_sport.klubovi
GROUP BY scrape_source
ORDER BY total DESC
""")
purge_history = db_query("""
SELECT created_at, action, target_text, payload
FROM pgz_sport.sys_audit
WHERE action LIKE '%purge%' OR action LIKE '%clean%'
ORDER BY created_at DESC LIMIT 10
""")
# Top sumnjivi - manual sa malo info
sumnjivi = db_query("""
SELECT c.id, c.ime, c.prezime, c.sport, c.uloga, c.source,
k.naziv AS klub_naziv,
(CASE WHEN c.source_url IS NOT NULL THEN 1 ELSE 0 END +
CASE WHEN c.datum_rodenja IS NOT NULL THEN 1 ELSE 0 END +
CASE WHEN c.slika_url IS NOT NULL THEN 1 ELSE 0 END +
CASE WHEN c.klub_id IS NOT NULL THEN 1 ELSE 0 END) AS quality
FROM pgz_sport.clanovi c
LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE c.source = 'manual'
ORDER BY quality ASC, c.id LIMIT 30
""")
# Top trusted
trusted = db_query("""
SELECT c.id, c.ime, c.prezime, c.sport, c.uloga, c.source, c.source_url,
k.naziv AS klub_naziv, c.datum_rodenja, c.godina_rodenja
FROM pgz_sport.clanovi c
LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE c.source IN ('hns_semafor', 'hbs_savez', 'rk_zamet_web')
AND c.datum_rodenja IS NOT NULL
ORDER BY c.source_synced_at DESC NULLS LAST LIMIT 20
""")
return {
"sportasi_po_izvoru": sportasi,
"klubovi_po_izvoru": klubovi,
"purge_history": purge_history,
"sumnjivi_zapisi": sumnjivi,
"trusted_zapisi": trusted,
"policy": {
"datum_rodenja": "MORA imati source_url - inače trigger postavlja NULL",
"slika_url": "MORA imati source_url - inače trigger postavlja NULL",
"validation_trigger": "clanovi_validate_source"
},
"trusted_sources": ["hns_semafor", "hbs_savez", "rk_zamet_web"],
"needs_verification": ["manual"]
}
@router.get("/statistika/clanstvo-po-sportu")
def statistika_clanstvo_po_sportu(sport: str = None):
"""Agregirane statistike clanstva po savezima iz PGŽ dostave (Boris Milanovic xlsx 2026)"""
rows = db_query("""
SELECT sport, kategorija, godiste, zene, muski, veterani, ukupno
FROM pgz_sport.savez_statistika_clanstvo
WHERE (%s IS NULL OR sport = %s) ORDER BY sport, kategorija
""", (sport, sport)) if sport else db_query("""
SELECT sport,
SUM(COALESCE(zene,0)) as zene,
SUM(COALESCE(muski,0)) as muski,
SUM(COALESCE(veterani,0)) as veterani,
SUM(CASE WHEN kategorija ILIKE '%%seniori%%' THEN COALESCE(ukupno,0) ELSE 0 END) as seniori_ukupno,
SUM(COALESCE(ukupno,0)) as ukupno_sve_kategorije
FROM pgz_sport.savez_statistika_clanstvo
GROUP BY sport ORDER BY SUM(COALESCE(ukupno,0)) DESC
""")
return {"sport": sport, "count": len(rows), "data": rows}
@router.get("/statistika/clanstvo-ukupno")
def statistika_clanstvo_ukupno():
"""Totali po spolu i dobnoj skupini kroz sve saveze"""
totals = db_one("""
SELECT
SUM(COALESCE(zene,0)) as ukupno_zene,
SUM(COALESCE(muski,0)) as ukupno_muski,
SUM(COALESCE(veterani,0)) as ukupno_veterani,
COUNT(DISTINCT sport) as broj_saveza,
SUM(CASE WHEN kategorija ILIKE '%%seniori%%' THEN COALESCE(zene,0) ELSE 0 END) as seniori_zene,
SUM(CASE WHEN kategorija ILIKE '%%seniori%%' THEN COALESCE(muski,0) ELSE 0 END) as seniori_muski
FROM pgz_sport.savez_statistika_clanstvo
""")
po_kat = db_query("""
SELECT kategorija, godiste,
SUM(COALESCE(zene,0)) as zene,
SUM(COALESCE(muski,0)) as muski,
SUM(COALESCE(ukupno,0)) as ukupno
FROM pgz_sport.savez_statistika_clanstvo
GROUP BY kategorija, godiste ORDER BY kategorija
""")
return {**totals, "po_kategoriji": po_kat}
@router.get("/javne-potrebe")
def javne_potrebe_pgz(godina: int = None):
"""Javne potrebe u sportu PGŽ po godinama (ZSP PGŽ podaci)"""
if godina:
rows = db_query("""
SELECT id, naslov, kategorija, sadrzaj, url, scraped_at
FROM pgz_sport.zsp_dokumenti
WHERE kategorija='javne_potrebe' AND naslov ILIKE %s
ORDER BY scraped_at DESC
""", (f'%{godina}%',))
else:
rows = db_query("""
SELECT id, naslov, kategorija, sadrzaj, url, scraped_at
FROM pgz_sport.zsp_dokumenti
WHERE kategorija IN ('javne_potrebe','natjecaj_sufinanciranje')
ORDER BY naslov DESC
""")
return {"count": len(rows), "data": rows}
@router.get("/hoo-pravilnici")
def hoo_pravilnici(kategorija: str = None):
"""HOO pravilnici i kriteriji kategorizacije"""
if kategorija:
rows = db_query("SELECT * FROM pgz_sport.hoo_pravilnici WHERE kategorija=%s ORDER BY scraped_at DESC", (kategorija,))
else:
rows = db_query("SELECT id, naslov, kategorija, url, scraped_at FROM pgz_sport.hoo_pravilnici ORDER BY kategorija, naslov")
return {"count": len(rows), "data": rows}
@router.get("/rno-udruge")
def rno_udruge(sport: str = None, grad: str = None):
"""Registar sportskih udruga PGŽ (RNO podaci)"""
sql = "SELECT * FROM pgz_sport.rno_sportske_udruge WHERE 1=1"
params = []
if sport: sql += " AND djelatnost ILIKE %s"; params.append(f'%{sport}%')
if grad: sql += " AND grad ILIKE %s"; params.append(f'%{grad}%')
sql += " ORDER BY naziv"
rows = db_query(sql, params)
return {"count": len(rows), "data": rows}
@router.get("/zsp-dokumenti")
def zsp_dokumenti(kategorija: str = None):
"""ZSP PGŽ i RSS dokumenti (savezi, programi, nagrade)"""
if kategorija:
rows = db_query("""
SELECT id, naslov, kategorija, url, izvor, scraped_at
FROM pgz_sport.zsp_dokumenti WHERE kategorija=%s ORDER BY naslov
""", (kategorija,))
else:
rows = db_query("""
SELECT kategorija, COUNT(*) as broj, MAX(scraped_at) as zadnji_scrape
FROM pgz_sport.zsp_dokumenti GROUP BY kategorija ORDER BY kategorija
""")
return {"count": len(rows), "data": rows}
# ============================================================
# PGŽ Boris Milanović stats — official Excel data 30.04.2026
# ============================================================
@router.get("/pgz/savez-stats")
def pgz_savez_stats():
"""Službena statistika PGŽ saveza (Boris Milanović Excel 30.04.2026)."""
rows = db_query("""
SELECT savez,
sum(natjecateljki) AS žene,
sum(natjecatelja) AS muški,
sum(veterani) AS veterani,
sum(ukupno) AS ukupno
FROM pgz_sport.savez_stats_oficijalno
GROUP BY savez
ORDER BY ukupno DESC NULLS LAST
""")
summary = db_one("""
SELECT count(DISTINCT savez) AS broj_saveza,
sum(ukupno) AS ukupno_sportasa,
sum(natjecateljki) AS žene,
sum(natjecatelja) AS muški,
sum(veterani) AS veterani
FROM pgz_sport.savez_stats_oficijalno
""")
return {"savezi": rows, "summary": summary, "izvor": "Boris Milanović PGŽ - 30.04.2026"}
@router.get("/pgz/savez-stats/{savez_naziv}")
def pgz_savez_detail(savez_naziv: str):
"""Detaljna kategorijska razdioba za jedan savez."""
rows = db_query("""
SELECT kategorija, natjecateljki, natjecatelja, veterani, ukupno
FROM pgz_sport.savez_stats_oficijalno
WHERE savez = %s
ORDER BY id
""", (savez_naziv,))
if not rows:
raise HTTPException(404, f"Savez '{savez_naziv}' nije pronađen")
return {"savez": savez_naziv, "kategorije": rows, "izvor": "Boris Milanović PGŽ - 30.04.2026"}
@router.get("/pgz/sport-organizacije")
def pgz_sport_organizacije(kategorija: str = None, sufinanciran: bool = None, limit: int = 200):
"""Klubovi po kategoriji organizacije (sport_klub, sport_savez, sportski_ribolov...)
Default = samo sportske organizacije."""
where = ["aktivan=true"]; args = []
if kategorija:
where.append("kategorija_organizacije=%s"); args.append(kategorija)
else:
where.append("kategorija_organizacije IN ('sport_klub','sport_savez','sportski_ribolov','planinarstvo')")
if sufinanciran is not None:
where.append("pgz_sufinanciran=%s"); args.append(sufinanciran)
args.append(limit)
rows = db_query(f"""
SELECT id, naziv, sport, razina, grad, godina_osnutka,
kategorija_organizacije, pgz_sufinanciran, scrape_source
FROM pgz_sport.klubovi
WHERE {' AND '.join(where)}
ORDER BY naziv
LIMIT %s
""", tuple(args))
return {"count": len(rows), "klubovi": rows}
@router.get("/pgz/cleanup-summary")
def pgz_cleanup_summary():
"""Summary cleanup operacije 30.04.2026 - što je sport, što je obrisano."""
rows = db_query("""
SELECT
COALESCE(kategorija_organizacije, '(unclassified)') AS kategorija,
count(*) FILTER (WHERE aktivan=true) AS aktivni,
count(*) FILTER (WHERE aktivan=false) AS deaktivirani,
count(*) AS ukupno,
count(*) FILTER (WHERE pgz_sufinanciran=true) AS pgz_sufinanciran
FROM pgz_sport.klubovi
GROUP BY kategorija_organizacije
ORDER BY ukupno DESC
""")
summary = db_one("""
SELECT
count(*) AS total,
count(*) FILTER (WHERE aktivan=true) AS aktivni,
count(*) FILTER (WHERE aktivan=false) AS deaktivirani,
count(*) FILTER (WHERE pgz_sufinanciran=true) AS sufinancirani
FROM pgz_sport.klubovi
""")
return {"by_category": rows, "summary": summary,
"info": "Cleanup 30.04.2026 - Boris Milanović PGŽ zahtjev",
"backup_table": "pgz_sport.klubovi_pre_cleanup_20260430"}
@router.get("/pgz/enrichment-gap")
def pgz_enrichment_gap():
"""Dashboard za Borisa - gap između Boris baseline (savez_stats) i naših podataka."""
rows = db_query("""
SELECT savez_naziv, sport, imamo_klubova, imamo_clanova,
boris_baseline, gap, pct_complete, status
FROM pgz_sport.v_enrichment_gap
ORDER BY gap DESC NULLS LAST
""")
summary = db_one("""
SELECT
sum(imamo_klubova) AS klubova,
sum(imamo_clanova) AS clanova,
sum(boris_baseline) AS boris_total,
sum(gap) AS gap_total,
ROUND(100.0 * sum(imamo_clanova) / NULLIF(sum(boris_baseline), 0), 1) AS overall_pct,
count(*) FILTER (WHERE status = 'OK') AS ok_savezi,
count(*) FILTER (WHERE status = 'NULA') AS nula_savezi
FROM pgz_sport.v_enrichment_gap
""")
return {"saveza": rows, "summary": summary,
"boris_baseline_source": "Boris Milanović PGŽ Excel 30.04.2026"}
@router.get("/pgz/dedup-summary")
def pgz_dedup_summary():
"""Pregled cleanup operacija 30.04.2026."""
audit = db_query("""
SELECT created_at, action, target_text, payload
FROM pgz_sport.sys_audit
WHERE created_at >= '2026-04-30'
AND action IN ('cleanup_klubovi', 'planinarstvo_verified', 'dedup_klubovi', 'import_boris_stats')
ORDER BY id DESC
""")
return {"audit_log": audit}
# ===== RNO — Registar neprofitnih organizacija =====
@router.get("/rno")
def get_rno(q: str = "", status: str = "", sort: str = "naziv", limit: int = 100):
"""PGZ sport organizacije iz RNO s financijskim podacima"""
filters = ["1=1"]
params = []
if q:
filters.append("(o.naziv ILIKE %s OR o.oib ILIKE %s OR o.mjesto ILIKE %s)")
params += [f"%{q}%", f"%{q}%", f"%{q}%"]
if status == "active":
filters.append("aktivna = true")
elif status == "inactive":
filters.append("aktivna = false")
order = {"naziv": "naziv", "prihodi": "pr.prihodi_ukupno DESC NULLS LAST", "rashodi": "pr.rashodi_ukupno DESC NULLS LAST"}.get(sort, "naziv")
sql = f"""
SELECT o.rno_broj, o.naziv, o.oib, o.mjesto, o.pravni_oblik,
o.adresa, o.email, o.web, o.aktivna, o.sifra_djelatnosti,
CASE WHEN COALESCE(pr.godina,0) < 2023 THEN pr.prihodi_ukupno/7.53450 ELSE pr.prihodi_ukupno END as prihodi,
CASE WHEN COALESCE(pr.godina,0) < 2023 THEN pr.prihodi_javni/7.53450 ELSE pr.prihodi_javni END as prihodi_javni,
CASE WHEN COALESCE(pr.godina,0) < 2023 THEN pr.rashodi_ukupno/7.53450 ELSE pr.rashodi_ukupno END as rashodi,
CASE WHEN COALESCE(pr.godina,0) < 2023 THEN pr.rezultat/7.53450 ELSE pr.rezultat END as rezultat,
pr.godina as fin_godina,
CASE WHEN COALESCE(b.godina,0) < 2023 THEN b.imovina_ukupno/7.53450 ELSE b.imovina_ukupno END as imovina_ukupno
FROM pgz_sport.rno_organizacije o
LEFT JOIN pgz_sport.rno_prras pr ON pr.oib = o.oib AND pr.godina = (
SELECT MAX(p2.godina) FROM pgz_sport.rno_prras p2 WHERE p2.oib = o.oib
)
LEFT JOIN pgz_sport.rno_bilanca b ON b.oib = o.oib AND b.godina = pr.godina
WHERE {' AND '.join(filters)}
ORDER BY {order}
LIMIT %s
"""
params.append(limit)
return [dict(r) for r in db_query(sql, params)]
# ===== HNS Natjecanja =====
@router.get("/hns-natjecanja")
def get_hns_natjecanja(season: str = "", org: str = ""):
filters = ["1=1"]
params = []
if season:
filters.append("sezona = %s")
params.append(season)
if org:
filters.append("org_id = %s")
params.append(int(org))
sql = f"""
SELECT id, naziv, sezona, org_id, url, sort
FROM pgz_sport.hns_natjecanja
WHERE {' AND '.join(filters)}
ORDER BY sezona DESC, sort, naziv
"""
return [dict(r) for r in db_query(sql, params)]
# ===== Godišnjaci AI pretraga =====
@router.post("/godisnjaci/search")
def search_godisnjaci(body: dict = None):
"""AI semantic search through ZSP PGZ godisnjaci 2006-2024"""
import json, requests as req
if not body:
return {"answer": "Nema pitanja.", "sources": []}
question = body.get("question", "")
if not question:
return {"answer": "Nema pitanja.", "sources": []}
try:
# Embed the question using Ollama nomic-embed
emb_r = req.post("http://localhost:11434/api/embed",
json={"model": "nomic-embed-text", "input": [question]},
timeout=15)
if emb_r.status_code != 200:
return {"answer": "Greška pri embeddingu.", "sources": []}
query_vec = emb_r.json()["embeddings"][0]
# Search Qdrant pgz_godisnjaci
qdrant_r = req.post("http://localhost:6333/collections/pgz_godisnjaci/points/search",
json={"vector": query_vec, "limit": 6, "with_payload": True},
timeout=15)
hits = qdrant_r.json().get("result", [])
sources = []
context_parts = []
for h in hits:
payload = h.get("payload", {})
text = payload.get("text", "")
godina = payload.get("godina", "?")
score = h.get("score", 0)
if score > 0.3 and text:
sources.append({"godina": godina, "text": text, "score": round(score, 3)})
context_parts.append(f"[Godišnjak {godina}]: {text[:400]}")
if not context_parts:
return {"answer": "Nisam pronašao relevantne podatke u godišnjacima za ovo pitanje.", "sources": []}
context = "\n\n".join(context_parts[:4])
# Use vLLM for answer generation
llm_r = req.post("http://localhost:8001/v1/chat/completions",
json={
"model": "Qwen/Qwen2.5-7B-Instruct-AWQ",
"messages": [
{"role": "system", "content": "Ti si AI asistent za PGŽ sport. Odgovori na pitanje koristeći isključivo podatke iz godišnjaka Zajednice sportova PGŽ. Odgovaraj kratko i jasno na hrvatskom jeziku."},
{"role": "user", "content": f"Kontekst iz godišnjaka:\n{context}\n\nPitanje: {question}"}
],
"max_tokens": 300,
"temperature": 0.1
},
timeout=30)
if llm_r.status_code == 200:
answer = llm_r.json()["choices"][0]["message"]["content"]
else:
# Fallback: return best matching text
answer = sources[0]["text"][:500] if sources else "Nema odgovora."
return {"answer": answer, "sources": sources}
except Exception as e:
return {"answer": f"Greška: {str(e)[:100]}", "sources": []}
# ===== BUDGET ANALYTICS — Scoring model za PGŽ proračun =====
@router.get("/analytics/budget-score")
def get_budget_score(godina: int = 2025, min_clanova: int = 0, sport: str = ""):
"""Bodovanje saveza za PGZ proracunsku odluku (HOO kriteriji, max 100 bodova)"""
sql = """
WITH stat AS (
SELECT savez_id,
MAX(registriranih) as registriranih,
MAX(trenera) as trenera,
MAX(reprezentativaca) as repr,
MAX(klubova_clanica) as klubova_stat
FROM pgz_sport.statistika_saveza
WHERE godina <= %(god)s
GROUP BY savez_id
),
clan_stat AS (
SELECT k.savez_id,
COUNT(c.id) as n_clanova,
COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END) as c_s_dob,
COUNT(DISTINCT k.id) as n_klubova_clanovi
FROM pgz_sport.clanovi c
JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE c.aktivan = true
GROUP BY k.savez_id
)
SELECT
s.id,
s.naziv,
s.sport,
s.grad,
COALESCE(st.registriranih, 0) as registriranih,
COALESCE(cs.n_clanova, 0) as clanova_u_sustavu,
COALESCE(cs.c_s_dob, 0) as clanova_s_dob,
COALESCE(CASE WHEN cs.n_clanova > 0 THEN ROUND(cs.c_s_dob::numeric/cs.n_clanova*100,1) ELSE 0 END, 0) as pct_s_dob,
COALESCE(cs.n_klubova_clanovi, 0) as klubova,
COALESCE(st.trenera, 0) as trenera,
COALESCE(st.repr, 0) as reprezentativaca,
LEAST(25, COALESCE(st.registriranih, 0) / 50) as bod_clanovi,
LEAST(15, COALESCE(cs.n_klubova_clanovi, 0) * 2) as bod_klubovi,
LEAST(15, COALESCE(st.trenera, 0) * 2) as bod_treneri,
LEAST(20, CASE WHEN cs.n_clanova > 0
THEN ROUND(cs.c_s_dob::numeric/cs.n_clanova*20,0)
ELSE 0 END) as bod_evidencija,
LEAST(15, COALESCE(st.repr, 0)) as bod_reprezentativci,
(LEAST(25, COALESCE(st.registriranih, 0) / 50) +
LEAST(15, COALESCE(cs.n_klubova_clanovi, 0) * 2) +
LEAST(15, COALESCE(st.trenera, 0) * 2) +
LEAST(20, CASE WHEN cs.n_clanova > 0
THEN ROUND(cs.c_s_dob::numeric/cs.n_clanova*20,0)
ELSE 0 END) +
LEAST(15, COALESCE(st.repr, 0))
) as score_ukupno
FROM pgz_sport.savezi s
LEFT JOIN stat st ON st.savez_id = s.id
LEFT JOIN clan_stat cs ON cs.savez_id = s.id
WHERE s.aktivan = true
AND COALESCE(cs.n_clanova, st.registriranih, 0) >= %(min_cl)s
"""
if sport:
sql += " AND s.sport ILIKE %(sport)s"
sql += " ORDER BY score_ukupno DESC, registriranih DESC LIMIT 50"
params = {"god": godina, "min_cl": min_clanova, "sport": f"%{sport}%"}
return [dict(r) for r in db_query(sql, params)]
@router.get("/analytics/proracun-trend")
def get_proracun_trend():
"""Višegodišnji trend proračuna PGŽ za sport s indeksima rasta"""
sql = """
SELECT godina,
ROUND(proracun_pgz::numeric, 2) as pgz_eur,
ROUND(ukupno_pgz::numeric, 2) as pgz_ukupno_eur,
ROUND(ministarstvo::numeric, 2) as ministarstvo_eur,
ROUND(ukupno::numeric, 2) as ukupno_eur,
ROUND(ukupno::numeric / NULLIF(
LAG(ukupno) OVER (ORDER BY godina), 0
) * 100 - 100, 1) as rast_pct
FROM pgz_sport.proracun
ORDER BY godina
"""
return [dict(r) for r in db_query(sql)]
@router.get("/analytics/savez-drill")
def get_savez_drill(savez_id: int, godina: int = 2025):
"""Deep drill-down za jedan savez"""
savez = db_one("SELECT * FROM pgz_sport.savezi WHERE id = %s", (savez_id,))
if not savez:
return {"error": "Savez not found"}
klubovi = db_query("""
SELECT k.id, k.naziv, k.grad, k.oib,
COUNT(c.id) as n_clanova,
COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END) as s_dob
FROM pgz_sport.klubovi k
LEFT JOIN pgz_sport.clanovi c ON c.klub_id = k.id AND c.aktivan = true
WHERE k.savez_id = %s AND k.aktivan = true
GROUP BY k.id, k.naziv, k.grad, k.oib
ORDER BY n_clanova DESC LIMIT 20
""", (savez_id,))
statistike = db_query("""
SELECT godina, registriranih, trenera, reprezentativaca,
klubova_clanica, stipendiranih, zaposlenika
FROM pgz_sport.statistika_saveza
WHERE savez_id = %s ORDER BY godina DESC LIMIT 8
""", (savez_id,))
javne = db_query("""
SELECT godina, iznos_eur, naslov, vrsta
FROM pgz_sport.javne_potrebe
WHERE LOWER(korisnik) LIKE LOWER(%s) OR LOWER(korisnik) LIKE LOWER(%s)
ORDER BY godina DESC LIMIT 10
""", (f"%{dict(savez).get('naziv','')[0:20]}%", f"%{dict(savez).get('naziv','').split()[-1] if dict(savez).get('naziv','') else ''}%"))
lij = db_query("""
SELECT lp.datum_pregleda, lp.vrijedi_do, lp.spreman_za_natjecanje
FROM pgz_sport.lijecnicki_pregledi lp
JOIN pgz_sport.klubovi k ON k.id = lp.klub_id
WHERE k.savez_id = %s
ORDER BY lp.datum_pregleda DESC LIMIT 10
""", (savez_id,))
clanovi_spol = db_one("""
SELECT
COUNT(c.id) as ukupno,
COUNT(CASE WHEN c.spol='M' THEN 1 END) as muski,
COUNT(CASE WHEN c.spol='Z' THEN 1 END) as zenski,
COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END) as s_dob,
MIN(EXTRACT(YEAR FROM age(c.datum_rodjenja)))::int as min_dob,
MAX(EXTRACT(YEAR FROM age(c.datum_rodjenja)))::int as max_dob
FROM pgz_sport.clanovi c
JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE k.savez_id = %s AND c.aktivan = true
""", (savez_id,))
return {
"savez": dict(savez),
"klubovi": [dict(k) for k in klubovi],
"statistike": [dict(s) for s in statistike],
"javne_potrebe": [dict(j) for j in javne],
"lijecnicki": [dict(l) for l in lij],
"clanovi_spol": dict(clanovi_spol) if clanovi_spol else {}
}
@router.get("/analytics/klub-score")
def get_klub_score(
min_clanova: int = 0,
sport: str = "",
savez_id: int = 0,
sort: str = "score"
):
"""Bodovanje klubova po sličnim kriterijima"""
filters = ["k.aktivan = true"]
params: list = []
if min_clanova:
filters.append("COUNT(c.id) >= %s")
params.append(min_clanova)
if sport:
filters.append("k.sport ILIKE %s")
params.append(f"%{sport}%")
if savez_id:
filters.append("k.savez_id = %s")
params.append(savez_id)
sql = f"""
SELECT k.id, k.naziv, k.grad, k.sport,
s.naziv as savez,
COUNT(c.id) as n_clanova,
COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END) as c_s_dob,
CASE WHEN COUNT(c.id) > 0 THEN ROUND(COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END)::numeric/COUNT(c.id)*100,1) ELSE 0 END as pct_dob,
COUNT(DISTINCT CASE WHEN c.spol='M' THEN c.id END) as muski,
COUNT(DISTINCT CASE WHEN c.spol='Z' THEN c.id END) as zenski,
ROUND(COUNT(c.id) * 0.5 +
CASE WHEN COUNT(c.id) > 0 THEN COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END)::numeric/COUNT(c.id)*30 ELSE 0 END
, 1) as score
FROM pgz_sport.klubovi k
LEFT JOIN pgz_sport.clanovi c ON c.klub_id = k.id AND c.aktivan = true
LEFT JOIN pgz_sport.savezi s ON s.id = k.savez_id
WHERE {' AND '.join(filters[:2])}
GROUP BY k.id, k.naziv, k.grad, k.sport, s.naziv
HAVING COUNT(c.id) >= {min_clanova}
{"AND k.sport ILIKE '"+sport+"'" if sport else ""}
ORDER BY score DESC
LIMIT 50
"""
# Simplify query
where_parts = ["k.aktivan = true"]
p2 = []
if sport:
where_parts.append("k.sport ILIKE %s")
p2.append(f"%{sport}%")
if savez_id:
where_parts.append("k.savez_id = %s")
p2.append(savez_id)
sql2 = f"""
SELECT k.id, k.naziv, k.grad, k.sport,
s.naziv as savez,
COUNT(c.id) as n_clanova,
COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END) as c_s_dob,
CASE WHEN COUNT(c.id) > 0 THEN ROUND(COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END)::numeric/COUNT(c.id)*100,1) ELSE 0 END as pct_dob,
COUNT(DISTINCT CASE WHEN c.spol='M' THEN c.id END) as muski,
COUNT(DISTINCT CASE WHEN c.spol='Z' THEN c.id END) as zenski,
ROUND(COUNT(c.id) * 0.5 +
CASE WHEN COUNT(c.id) > 0
THEN COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END)::numeric/COUNT(c.id)*30
ELSE 0 END, 1) as score
FROM pgz_sport.klubovi k
LEFT JOIN pgz_sport.clanovi c ON c.klub_id = k.id AND c.aktivan = true
LEFT JOIN pgz_sport.savezi s ON s.id = k.savez_id
WHERE {' AND '.join(where_parts)}
GROUP BY k.id, k.naziv, k.grad, k.sport, s.naziv
HAVING COUNT(c.id) >= {min_clanova}
ORDER BY score DESC
LIMIT 50
"""
return [dict(r) for r in db_query(sql2, p2)]
# ===== FILTER OPTIONS (for dropdowns) =====
@router.get("/analytics/filter-options")
def get_filter_options():
"""Vraca dostupne vrijednosti za dropdown filtere"""
sportovi = db_query("""
SELECT DISTINCT INITCAP(LOWER(TRIM(sport))) as sport
FROM pgz_sport.savezi
WHERE aktivan=true AND sport IS NOT NULL AND TRIM(sport) != ''
ORDER BY 1
""")
gradovi = db_query("""
SELECT DISTINCT grad FROM pgz_sport.savezi
WHERE aktivan=true AND grad IS NOT NULL
ORDER BY grad
""")
sport_klub = db_query("""
SELECT DISTINCT INITCAP(LOWER(TRIM(sport))) as sport
FROM pgz_sport.klubovi
WHERE aktivan=true AND sport IS NOT NULL AND TRIM(sport) != ''
ORDER BY 1 LIMIT 40
""")
savezi_list = db_query("""
SELECT id, naziv FROM pgz_sport.savezi
WHERE aktivan=true ORDER BY naziv LIMIT 60
""")
return {
"sportovi_savezi": [r["sport"] for r in sportovi],
"sportovi_klubovi": [r["sport"] for r in sport_klub],
"gradovi": [r["grad"] for r in gradovi],
"savezi": [{"id": r["id"], "naziv": r["naziv"]} for r in savezi_list]
}
@router.get("/sport/objekti")
def get_sport_objekti(tip: str = "", grad: str = "", q: str = ""):
"""106 sportskih objekata PGZ s filterima"""
filters = ["aktivan = true"]
params = []
if tip:
filters.append("LOWER(tip) = LOWER(%s)")
params.append(tip)
if grad:
filters.append("LOWER(grad) = LOWER(%s)")
params.append(grad)
if q:
filters.append("(LOWER(naziv) LIKE LOWER(%s) OR LOWER(adresa) LIKE LOWER(%s))")
params.extend([f"%{q}%", f"%{q}%"])
sql = f"""
SELECT id, naziv, tip, grad, adresa, lat, lng,
upravitelj, kapacitet, sportovi,
izgradeno, obnovljeno_god, natkrita,
napomena, web, aktivan
FROM pgz_sport.sportski_objekti
WHERE {' AND '.join(filters)}
ORDER BY grad, naziv
LIMIT 200
"""
return [dict(r) for r in db_query(sql, params)]
# ═══════════════════════════════════════════════════════
# GRAPH — person/club/savez connections from osobe_funkcije
# ═══════════════════════════════════════════════════════
@router.get("/graph/connections")
def graph_connections(q: str = "", savez_id: int = None, limit: int = 300):
"""D3 force graph nodes+edges from pgz_sport.osobe_funkcije."""
filters = ["1=1"]
params = []
if q:
filters.append("(LOWER(of.ime||' '||of.prezime) LIKE LOWER(%s) OR LOWER(COALESCE(s.naziv,''||k.naziv,'')) LIKE LOWER(%s))")
params += [f"%{q}%", f"%{q}%"]
if savez_id:
filters.append("of.savez_id = %s")
params.append(savez_id)
rows = db_query(f"""
SELECT of.id, of.ime, of.prezime, of.funkcija, of.sport,
of.savez_id, s.naziv as savez_naziv,
of.klub_id, k.naziv as klub_naziv,
of.mandate_od, of.mandate_do
FROM pgz_sport.osobe_funkcije of
LEFT JOIN pgz_sport.savezi s ON of.savez_id = s.id
LEFT JOIN pgz_sport.klubovi k ON of.klub_id = k.id
WHERE {" AND ".join(filters)}
ORDER BY of.prezime, of.ime
LIMIT %s
""", params + [limit])
nodes = {}
edges = []
def add_node(nid, label, ntype, meta=None):
if nid not in nodes:
nodes[nid] = {"id": nid, "label": label, "type": ntype, **(meta or {})}
for r in rows:
pid = f"p_{r['id']}"
add_node(pid, f"{r['ime']} {r['prezime']}", "person",
{"funkcija": r.get("funkcija"), "sport": r.get("sport")})
if r.get("savez_id"):
sid = f"s_{r['savez_id']}"
sn = r.get("savez_naziv") or f"Savez {r['savez_id']}"
add_node(sid, sn[:40]+('' if len(sn)>40 else ''), "savez")
edges.append({"source": pid, "target": sid,
"label": r.get("funkcija",""), "sport": r.get("sport","")})
if r.get("klub_id"):
kid = f"k_{r['klub_id']}"
add_node(kid, r.get("klub_naziv") or f"Klub {r['klub_id']}", "klub")
edges.append({"source": pid, "target": kid,
"label": r.get("funkcija",""), "sport": r.get("sport","")})
return {"nodes": list(nodes.values()), "edges": edges,
"count": len(nodes), "edge_count": len(edges)}
# ═══════════════════════════════════════════════════════
# POTPORE AGGREGATE — sve izvore
# ═══════════════════════════════════════════════════════
@router.get("/potpore/aggregate")
def potpore_aggregate(q: str = "", limit: int = 300):
"""Aggregate sufinanciranje + javne_potrebe per korisnik."""
like = f"%{q}%" if q else "%"
rows = db_query("""
SELECT korisnik, sport, SUM(iznos_eur) as ukupno_eur,
COUNT(*) as n_potpore, MIN(godina) as od_god, MAX(godina) as do_god,
'sufinanciranje' as tip,
MAX(source_url) as source_url,
STRING_AGG(DISTINCT COALESCE(izvor,''), ', ') as izvori
FROM pgz_sport.sufinanciranje_sport
WHERE LOWER(COALESCE(korisnik,'')) LIKE LOWER(%s)
GROUP BY korisnik, sport
UNION ALL
SELECT korisnik, NULL as sport, SUM(iznos_eur) as ukupno_eur,
COUNT(*) as n_potpore, MIN(godina) as od_god, MAX(godina) as do_god,
'javne_potrebe' as tip,
MAX(url) as source_url,
STRING_AGG(DISTINCT COALESCE(izvor,''), ', ') as izvori
FROM pgz_sport.javne_potrebe
WHERE LOWER(COALESCE(korisnik,'')) LIKE LOWER(%s) AND korisnik IS NOT NULL
GROUP BY korisnik
ORDER BY ukupno_eur DESC NULLS LAST
LIMIT %s
""", [like, like, limit])
return {"count": len(rows), "results": rows}
# ═══════════════════════════════════════════════════════
# USER DASHBOARD
# ═══════════════════════════════════════════════════════
@router.get("/user/dashboard")
def user_dashboard(user=Depends(require_user)):
"""Personalized dashboard for logged-in user."""
ut = user.get("user_type", "")
klub_id = user.get("klub_id")
result = {"user_type": ut, "user": {"email": user.get("email"), "ime": user.get("full_name"), "user_type": ut}}
if ut in ("klub_admin", "klub_user") and klub_id:
klub = db_one("SELECT * FROM pgz_sport.klubovi WHERE id=%s", (klub_id,))
stats_row = db_one("""
SELECT
(SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id=%s AND aktivan=true) as clanovi,
(SELECT count(*) FROM pgz_sport.lijecnicki WHERE klub_id=%s AND vrijedi_do < CURRENT_DATE) as med_exp,
(SELECT count(*) FROM pgz_sport.lijecnicki WHERE klub_id=%s AND vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE+30) as med_warn
""", (klub_id, klub_id, klub_id))
result["klub"] = klub
result["stats"] = stats_row or {}
elif ut in ("pgz_admin", "super_admin", "pgz_user"):
stats = db_one("""
SELECT
(SELECT count(*) FROM pgz_sport.klubovi WHERE aktivan=true) as klubovi,
(SELECT count(*) FROM pgz_sport.savezi WHERE aktivan=true) as savezi,
(SELECT count(*) FROM pgz_sport.clanovi WHERE aktivan=true) as clanovi,
(SELECT count(*) FROM pgz_sport.lijecnicki WHERE vrijedi_do < CURRENT_DATE) as med_exp,
(SELECT count(*) FROM pgz_sport.pgz_sport_users WHERE aktivan=true) as korisnici
""", ())
result["stats"] = stats or {}
return result
# ═══════════════════════════════════════════════════════
# GODIŠNJACI — serve PDF and TXT files
# ═══════════════════════════════════════════════════════
import os
from fastapi.responses import FileResponse, HTMLResponse
GODISNJACI_DIR = "/opt/pgz-sport/_data/godisnjaci"
@router.get("/godisnjaci/pdf/{god}")
def godisnjak_pdf(god: int):
path = os.path.join(GODISNJACI_DIR, f"godisnjak_{god}.pdf")
if not os.path.exists(path):
raise HTTPException(404, f"Godišnjak {god} nije pronađen")
return FileResponse(path, media_type="application/pdf",
filename=f"godisnjak_ZSP_PGZ_{god}.pdf")
@router.get("/godisnjaci/txt/{god}")
def godisnjak_txt(god: int):
path = os.path.join(GODISNJACI_DIR, f"godisnjak_{god}.txt")
if not os.path.exists(path):
raise HTTPException(404, f"Godišnjak {god}.txt nije pronađen")
return FileResponse(path, media_type="text/plain; charset=utf-8",
filename=f"godisnjak_ZSP_PGZ_{god}.txt")
# ═══════════════════════════════════════════════
# GODIŠNJACI — serve original PGZ PDFs
# ═══════════════════════════════════════════════
import re as _re, os as _os
GODISNJACI_PGZ_DIR = "/opt/pgz-sport/_downloads/godisnjaci_szpgz"
GODISNJACI_DATA_DIR = "/opt/pgz-sport/_data/godisnjaci"
def _god_map():
m = {}
if not _os.path.exists(GODISNJACI_PGZ_DIR):
return m
for f in sorted(_os.listdir(GODISNJACI_PGZ_DIR)):
mt = _re.search(r'(?:godisnjak[_-]?)(\d{4})', f, _re.IGNORECASE)
if mt:
y = int(mt.group(1))
if 2006 <= y <= 2025:
size = _os.path.getsize(_os.path.join(GODISNJACI_PGZ_DIR, f))
display = _re.sub(r'^[a-f0-9]{16}_', '', f)
display = _re.sub(r'_\d{4}-\d{2}-\d{2}-\d+_\w+', '', display).replace('.pdf','')
m[y] = {"file": f, "display": display, "size_kb": size//1024, "god": y}
return m
@router.get("/godisnjaci/popis")
def godisnjaci_popis():
"""Lista svih godišnjaka ZSP PGŽ s meta podacima."""
m = _god_map()
rows = sorted(m.values(), key=lambda x: x["god"], reverse=True)
return {"count": len(rows), "results": rows}
@router.get("/godisnjaci/pgz-pdf/{god}")
def godisnjak_pgz_pdf(god: int):
"""Serviraj originalni PGZ PDF godišnjaka."""
m = _god_map()
if god not in m:
raise HTTPException(404, f"Godišnjak {god} nije dostupan")
path = _os.path.join(GODISNJACI_PGZ_DIR, m[god]["file"])
return FileResponse(path, media_type="application/pdf",
filename=f"ZSP-PGZ-Sportski-godisnjak-{god}.pdf")
# ═══════════════════════════════════════════════════════
# PRORAČUN — breakdown by sport + recipient
# ═══════════════════════════════════════════════════════
@router.get("/analytics/proracun-sport")
def proracun_sport(godina: int = None):
"""Raspodjela sufinanciranja po sportu za godinu."""
import datetime
yr = godina or datetime.date.today().year
rows = db_query("""
SELECT sport,
sum(iznos_eur) as ukupno,
count(*) as n_stavki,
STRING_AGG(DISTINCT izvor, ', ') as izvori
FROM pgz_sport.sufinanciranje_sport
WHERE godina = %s AND iznos_eur > 0
GROUP BY sport
ORDER BY ukupno DESC
""", (yr,))
# Get detail recipients
detail = db_query("""
SELECT korisnik, sport, iznos_eur, vrsta, izvor, source_url
FROM pgz_sport.sufinanciranje_sport
WHERE godina = %s AND iznos_eur > 0
ORDER BY iznos_eur DESC
LIMIT 200
""", (yr,))
total = float(db_exec("SELECT COALESCE(sum(iznos_eur),0) FROM pgz_sport.sufinanciranje_sport WHERE godina=%s", (yr,)) or 0)
return {"godina": yr, "total": total, "po_sportu": rows, "detalji": detail}
# ═══════════════════════════════════════════════════════
# POTPORE — by year filter
# ═══════════════════════════════════════════════════════
@router.get("/potpore/by-year")
def potpore_by_year(godina: int = None, q: str = ""):
"""Sufinanciranje za specifičnu godinu."""
import datetime
yr = godina or datetime.date.today().year
like = f"%{q}%" if q else "%"
rows = db_query("""
SELECT korisnik, sport, iznos_eur, vrsta, napomena, izvor, source_url, godina,
(SELECT k.id FROM pgz_sport.klubovi k WHERE LOWER(k.naziv) LIKE LOWER('%%'||LEFT(korisnik,20)||'%%') AND k.aktivan=true LIMIT 1) as klub_id
FROM pgz_sport.sufinanciranje_sport
WHERE godina = %s AND LOWER(COALESCE(korisnik,'')) LIKE LOWER(%s)
ORDER BY iznos_eur DESC NULLS LAST
LIMIT 500
""", (yr, like))
total = sum(float(r.get('iznos_eur') or 0) for r in rows)
return {"godina": yr, "count": len(rows), "total": total, "results": rows}
# ═══════════════════════════════════════════════════════
# MULTI-CHAIR conflict of interest
# ═══════════════════════════════════════════════════════
@router.get("/graph/multi-chair")
def multi_chair():
"""Persons sitting in multiple organizations."""
rows = db_query("""
SELECT ime, prezime, MAX(of.oib) as oib,
count(DISTINCT COALESCE(of.savez_id::text, of.klub_id::text, of.organizacija)) as n_orgs,
STRING_AGG(DISTINCT COALESCE(s.naziv, k.naziv, of.organizacija), ' | '
ORDER BY COALESCE(s.naziv, k.naziv, of.organizacija)) as orgs,
STRING_AGG(DISTINCT of.funkcija, ', ') as funkcije
FROM pgz_sport.osobe_funkcije of
LEFT JOIN pgz_sport.savezi s ON of.savez_id = s.id
LEFT JOIN pgz_sport.klubovi k ON of.klub_id = k.id
GROUP BY LOWER(ime), LOWER(prezime), ime, prezime
HAVING count(DISTINCT COALESCE(of.savez_id::text, of.klub_id::text, of.organizacija)) >= 2
ORDER BY n_orgs DESC, prezime
""")
return {"count": len(rows), "results": rows}
@router.get("/graph/iframe", response_class=HTMLResponse)
def graph_iframe(savez_id: int = None, q: str = "", limit: int = 300):
"""Serve complete D3 force graph as HTML — iframe approach."""
# Get data
filters = ["1=1"]
params = []
if q:
filters.append("(LOWER(of.ime||' '||of.prezime) LIKE LOWER(%s) OR LOWER(COALESCE(s.naziv,'')) LIKE LOWER(%s))")
params += [f"%{q}%", f"%{q}%"]
if savez_id:
filters.append("of.savez_id = %s")
params.append(savez_id)
rows = db_query(f"""
SELECT of.id, of.ime, of.prezime, of.funkcija, of.sport, of.oib,
of.savez_id, s.naziv as savez_naziv,
of.klub_id, k.naziv as klub_naziv
FROM pgz_sport.osobe_funkcije of
LEFT JOIN pgz_sport.savezi s ON of.savez_id = s.id
LEFT JOIN pgz_sport.klubovi k ON of.klub_id = k.id
WHERE {" AND ".join(filters)}
ORDER BY of.prezime, of.ime LIMIT %s
""", params + [limit])
nodes = {}
edges = []
def add_node(nid, label, ntype, meta=None):
if nid not in nodes:
nodes[nid] = {"id": nid, "label": label, "type": ntype, **(meta or {})}
# Count multi-chair
person_org_count = {}
for r in rows:
pid = f"p_{r['id']}"
person_org_count[pid] = person_org_count.get(pid, 0) + 1
for r in rows:
pid = f"p_{r['id']}"
add_node(pid, f"{r['ime']} {r['prezime']}", "person",
{"funkcija": r.get("funkcija"), "sport": r.get("sport"),
"oib": r.get("oib"), "multiChair": person_org_count.get(pid,0) > 1})
if r.get("savez_id"):
sid = f"s_{r['savez_id']}"
sn = r.get("savez_naziv") or f"Savez {r['savez_id']}"
add_node(sid, sn[:40]+("" if len(sn)>40 else ""), "savez")
edges.append({"source": pid, "target": sid,
"label": (r.get("funkcija") or "")[:30], "sport": r.get("sport","")})
if r.get("klub_id"):
kid = f"k_{r['klub_id']}"
kn = r.get("klub_naziv") or f"Klub {r['klub_id']}"
add_node(kid, kn[:35]+("" if len(kn)>35 else ""), "klub")
edges.append({"source": pid, "target": kid,
"label": (r.get("funkcija") or "")[:30], "sport": r.get("sport","")})
import json as _json
nodes_json = _json.dumps(list(nodes.values()))
edges_json = _json.dumps(edges)
html = """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
<style>
body{margin:0;background:#06080d;overflow:hidden;font-family:system-ui,sans-serif}
text{fill:#94a3b8;font-size:9px;pointer-events:none}
line{stroke-opacity:0.35;stroke-width:1.5}
svg{cursor:grab}
svg:active{cursor:grabbing}
#tt{position:fixed;background:rgba(10,14,24,.95);border:1px solid #1a2035;border-radius:6px;
padding:8px 12px;font-size:11px;color:#e8eaf0;pointer-events:none;display:none;z-index:99;max-width:220px;line-height:1.6}
#ctrl{position:fixed;top:8px;right:8px;display:flex;flex-direction:column;gap:4px;z-index:50}
.cb{background:rgba(10,14,24,.85);border:1px solid #1a2035;border-radius:4px;color:#8892a4;
width:28px;height:28px;cursor:pointer;font-size:15px;display:flex;align-items:center;justify-content:center;user-select:none}
.cb:hover{border-color:#00f0ff;color:#00f0ff}
#inf{position:fixed;bottom:6px;left:8px;font-size:9px;color:#2a3550;font-family:monospace}
#leg{position:fixed;bottom:6px;right:8px;display:flex;gap:10px;font-size:9px;color:#4b5563}
.ld{display:flex;align-items:center;gap:3px}
.dot{width:8px;height:8px;border-radius:50%;display:inline-block}
</style>
</head>
<body>
<div id="tt"></div>
<div id="ctrl">
<div class="cb" id="zi">+</div>
<div class="cb" id="zo"></div>
<div class="cb" id="zr">⌂</div>
</div>
<div id="leg">
<div class="ld"><div class="dot" style="background:#f59e0b"></div>Osoba</div>
<div class="ld"><div class="dot" style="background:#00f0ff"></div>Savez</div>
<div class="ld"><div class="dot" style="background:#00e5a0"></div>Klub</div>
</div>
<div id="inf"></div>
<svg id="g" width="100%" height="100%"></svg>
<script>
const W = (window.innerWidth > 50 ? window.innerWidth : document.documentElement.clientWidth) || 900;
const H = (window.innerHeight > 50 ? window.innerHeight : document.documentElement.clientHeight) || 580;
const nodes = """ + nodes_json + """;
const links = """ + edges_json + """;
const COL = {person:"#f59e0b", savez:"#00f0ff", klub:"#00e5a0"};
const RAD = {person:9, savez:18, klub:12};
const svg = d3.select("#g").attr("viewBox", [0, 0, W, H]);
const container = svg.append("g");
// Zoom + Pan
const zoom = d3.zoom().scaleExtent([0.05, 8]).on("zoom", e => container.attr("transform", e.transform));
svg.call(zoom);
document.getElementById("zi").addEventListener("click", () => svg.transition().duration(250).call(zoom.scaleBy, 1.5));
document.getElementById("zo").addEventListener("click", () => svg.transition().duration(250).call(zoom.scaleBy, 0.67));
document.getElementById("zr").addEventListener("click", () => svg.transition().duration(400).call(zoom.transform, d3.zoomIdentity));
// Simulation
const sim = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id).distance(d => {
var s = d.source, t = d.target;
return (s.type==="savez"||t.type==="savez") ? 130 : 85;
}))
.force("charge", d3.forceManyBody().strength(-280))
.force("center", d3.forceCenter(W/2, H/2))
.force("x", d3.forceX(W/2).strength(0.08))
.force("y", d3.forceY(H/2).strength(0.08))
.velocityDecay(0.45)
.force("collision", d3.forceCollide().radius(d => (RAD[d.type]||8) + 15));
// Links
const link = container.append("g")
.selectAll("line").data(links).join("line")
.attr("stroke", d => COL[d.source.type] || "#334155");
// Nodes
const node = container.append("g")
.selectAll("g").data(nodes).join("g")
.call(d3.drag()
.on("start", (e,d) => { if(!e.active) sim.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; })
.on("drag", (e,d) => { d.fx=e.x; d.fy=e.y; })
.on("end", (e,d) => { if(!e.active) sim.alphaTarget(0); d.fx=null; d.fy=null; }));
// Savez ring
node.filter(d => d.type==="savez").append("circle")
.attr("r", d => RAD[d.type]+8).attr("fill","none")
.attr("stroke","#00f0ff").attr("stroke-opacity",0.2)
.attr("stroke-width",1.5).attr("stroke-dasharray","3,3");
// Main circle
node.append("circle")
.attr("r", d => RAD[d.type]||8)
.attr("fill", d => COL[d.type]||"#8892a4")
.attr("fill-opacity", d => d.type==="savez" ? 0.2 : 0.85)
.attr("stroke", d => COL[d.type]||"#8892a4")
.attr("stroke-opacity", 0.5).attr("stroke-width", 1.5)
.style("cursor","pointer")
.style("filter", d => {
const g = d.type==="savez"?"rgba(0,240,255,0.5)":d.type==="klub"?"rgba(0,229,160,0.4)":"rgba(245,158,11,0.4)";
return "drop-shadow(0 0 "+(d.type==="savez"?"8":"4")+"px "+g+")";
});
// Multi-chair marker
node.filter(d => d.multiChair).append("circle")
.attr("r",4).attr("cx",d=>(RAD[d.type]||8)-2).attr("cy",d=>-(RAD[d.type]||8)+2)
.attr("fill","#ff3366").attr("stroke","#06080d").attr("stroke-width",1.5);
// Labels
node.append("text")
.text(d => d.label.length>22 ? d.label.substring(0,21)+"" : d.label)
.attr("dx", d => (RAD[d.type]||8)+5).attr("dy", 4)
.style("fill", d => COL[d.type]||"#8892a4")
.style("font-size", d => d.type==="savez"?"11px":"10px")
.style("font-weight", d => d.type==="savez"?"600":"400");
node.filter(d=>d.sport).append("text")
.text(d => d.sport||"")
.attr("dx", d=>(RAD[d.type]||8)+5).attr("dy",16)
.style("fill","#4b5563").style("font-size","8px");
// Tooltip
const tt = document.getElementById("tt");
node.on("mouseenter", (e,d) => {
const conn = links.filter(l => {
const s = typeof l.source==="object" ? l.source.id : l.source;
const t = typeof l.target==="object" ? l.target.id : l.target;
return s===d.id || t===d.id;
});
tt.innerHTML = "<b style='color:"+(COL[d.type]||"#fff")+"'>"+d.label+"</b>"
+"<div style='color:#8892a4;font-size:10px'>"+d.type.toUpperCase()+" · "+conn.length+" veza</div>"
+(d.funkcija ? "<div>"+d.funkcija+"</div>" : "")
+(d.sport ? "<div style='color:#f59e0b'>"+d.sport+"</div>" : "")
+(d.multiChair ? "<div style='color:#ff3366;font-weight:700'>⚠ Višestruke funkcije</div>" : "");
tt.style.cssText = "display:block;left:"+(e.clientX+15)+"px;top:"+(e.clientY-10)+"px";
}).on("mousemove", e => {
tt.style.left=(e.clientX+15)+"px"; tt.style.top=(e.clientY-10)+"px";
}).on("mouseleave", () => { tt.style.display="none"; })
.on("click", (e,d) => window.parent.postMessage({type:"node_click",id:d.id,label:d.label,ntype:d.type},"*"));
// Tick — resolveEnd pattern from NetworkGraph.tsx
function resolveEnd(end) {
if (end && typeof end==="object") return end;
if (typeof end==="string") { for (var i=0;i<nodes.length;i++) { if(nodes[i].id===end) return nodes[i]; } }
return {x:0, y:0};
}
const inf = document.getElementById("inf");
// Resize handler for deferred iframe layout
if(typeof ResizeObserver !== "undefined"){
new ResizeObserver(entries => {
for(const e of entries){
const {width,height} = e.contentRect;
if(width>50 && height>50){
svg.attr("viewBox",[0,0,width,height]);
sim.force("center",d3.forceCenter(width/2,height/2)).alpha(0.3).restart();
}
}
}).observe(document.body);
}
sim.on("tick", () => {
link.each(function(d) {
var s=resolveEnd(d.source), t=resolveEnd(d.target);
d3.select(this).attr("x1",typeof s.x==="number"?s.x:0).attr("y1",typeof s.y==="number"?s.y:0)
.attr("x2",typeof t.x==="number"?t.x:0).attr("y2",typeof t.y==="number"?t.y:0);
});
node.attr("transform", d => "translate("+(typeof d.x==="number"?d.x:0)+","+(typeof d.y==="number"?d.y:0)+")");
if(inf) inf.textContent = nodes.length+" čvorova · "+links.length+" veza";
});
</script>
</body></html>"""
return html
@router.get("/med/pregled-status")
def med_pregled_status(q: str = "", klub_id: int = None):
"""Status medicinskih pregleda clanova."""
import datetime
today = datetime.date.today()
warn_date = today + datetime.timedelta(days=30)
filters = ["1=1"]
params = []
if q:
filters.append("(LOWER(c.ime||' '||c.prezime) LIKE LOWER(%s) OR LOWER(COALESCE(k.naziv,'')) LIKE LOWER(%s))")
params += [f"%{q}%", f"%{q}%"]
if klub_id:
filters.append("c.klub_id = %s")
params.append(klub_id)
rows = db_query(f"""
SELECT c.id, c.ime, c.prezime, c.datum_rodenja, c.licenca_vrijedi_do AS med_expiry,
c.kategorija, c.hoo_kategorija, c.sport, c.pozicija,
k.naziv as klub_naziv, k.id as klub_id
FROM pgz_sport.clanovi c
LEFT JOIN pgz_sport.klubovi k ON c.klub_id = k.id
WHERE {" AND ".join(filters)}
ORDER BY c.licenca_vrijedi_do ASC NULLS LAST, c.prezime
LIMIT 500
""", params)
for r in rows:
exp = r.get("med_expiry")
if not exp:
r["status"] = "unknown"
elif exp < today:
r["status"] = "expired"
elif exp <= warn_date:
r["status"] = "warning"
else:
r["status"] = "ok"
expired = sum(1 for r in rows if r["status"] == "expired")
warning = sum(1 for r in rows if r["status"] == "warning")
ok = sum(1 for r in rows if r["status"] == "ok")
unknown = sum(1 for r in rows if r["status"] == "unknown")
return {
"count": len(rows), "expired": expired, "warning": warning,
"ok": ok, "unknown": unknown, "rows": rows
}
@router.get("/erp/putni-nalozi")
def erp_putni_nalozi(klub_id: int = None, status: str = None):
"""Lista putnih naloga."""
filters = ["1=1"]
params = []
if klub_id:
filters.append("pn.klub_id = %s"); params.append(klub_id)
if status:
filters.append("pn.status = %s"); params.append(status)
# Table might not exist yet
try:
rows = db_query(f"""
SELECT pn.*, k.naziv as klub_naziv,
n.naziv as natjecanje_naziv
FROM pgz_sport.putni_nalozi pn
LEFT JOIN pgz_sport.klubovi k ON pn.klub_id = k.id
LEFT JOIN pgz_sport.natjecanja n ON pn.natjecanje_id = n.id
WHERE {" AND ".join(filters)}
ORDER BY pn.datum DESC LIMIT 100
""", params)
return {"count": len(rows), "rows": rows}
except Exception:
return {"count": 0, "rows": [], "note": "Tablica putni_nalozi u razvoju"}
@router.post("/erp/putni-nalozi")
def erp_putni_nalog_create(body: dict = Body({})):
"""Kreiraj novi putni nalog."""
try:
db_exec("""
CREATE TABLE IF NOT EXISTS pgz_sport.putni_nalozi (
id SERIAL PRIMARY KEY,
klub_id INTEGER,
natjecanje_id INTEGER,
destinacija TEXT,
datum DATE,
broj_sudionika INTEGER DEFAULT 1,
iznos_eur NUMERIC(10,2),
napomena TEXT,
status TEXT DEFAULT 'na_cekanju',
created_at TIMESTAMPTZ DEFAULT NOW()
)
""")
db_exec("""
INSERT INTO pgz_sport.putni_nalozi
(klub_id, natjecanje_id, destinacija, datum, broj_sudionika, iznos_eur, napomena)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (body.get("klub_id"), body.get("natjecanje_id"), body.get("destinacija"),
body.get("datum"), body.get("broj_sudionika", 1),
body.get("iznos_eur"), body.get("napomena")))
return {"ok": True}
except Exception as e:
raise HTTPException(500, str(e))
@router.get("/dms/documents")
def dms_documents(klub_id: int = None, limit: int = 20):
"""Lista uploadanih dokumenata."""
try:
rows = db_query("""
SELECT id, naziv, filename, mime_type, size_kb, url, klub_id, created_at
FROM pgz_sport.dms_documents
ORDER BY created_at DESC LIMIT %s
""", (limit,))
return {"count": len(rows), "rows": rows}
except Exception:
return {"count": 0, "rows": [], "note": "DMS tablica u razvoju"}
@router.post("/dms/upload")
async def dms_upload(file: UploadFile = File(...)):
"""Upload dokumenta u DMS."""
import os, uuid, aiofiles
upload_dir = "/opt/pgz-sport/uploads/dms"
os.makedirs(upload_dir, exist_ok=True)
ext = os.path.splitext(file.filename)[1]
fname = f"{uuid.uuid4().hex}{ext}"
fpath = os.path.join(upload_dir, fname)
content = await file.read()
with open(fpath, "wb") as f:
f.write(content)
size_kb = len(content) // 1024
url = f"/api/v2/dms/file/{fname}"
try:
db_exec("""
CREATE TABLE IF NOT EXISTS pgz_sport.dms_documents (
id SERIAL PRIMARY KEY, naziv TEXT, filename TEXT,
mime_type TEXT, size_kb INTEGER, url TEXT,
klub_id INTEGER, created_at TIMESTAMPTZ DEFAULT NOW()
)
""")
db_exec("""
INSERT INTO pgz_sport.dms_documents (naziv, filename, mime_type, size_kb, url)
VALUES (%s, %s, %s, %s, %s)
""", (file.filename, fname, file.content_type, size_kb, url))
except Exception:
pass
return {"ok": True, "url": url, "size_kb": size_kb}
# ═══════════════════════════════════════════════════════════════════
# Fajl: graph_3d_endpoint.py (patch chunk for pgz_sport_v2_router.py)
# Verzija: 1.0.0
# Datum: 04.05.2026
# Autor: Damir Radulić <dradulic@outlook.com>
# Lokacija: /opt/pgz-sport/pgz_sport_v2_router.py (append before last line)
# Svrha: 3D person-network endpoint — multi-chair detection
# Zavisi od: db_query helper iz pgz_sport_v2_router
# Utječe na: novi /api/v2/graph/3d-network endpoint
# ═══════════════════════════════════════════════════════════════════
@router.get("/graph/3d-network")
def graph_3d_network(min_orgs: int = 2, top_n: int = 100, sport: str = ""):
"""3D person-network graf. Multi-chair osobe + njihove organizacije.
Args:
min_orgs: minimalan broj organizacija po osobi (default 2 = multi-chair)
top_n: max broj osoba (sortirano po n_orgs desc)
sport: filter po sportu saveza (opcionalno)
Returns:
{
"nodes": [{"id": "p:dragan-naglic", "name": "Dragan Naglić",
"type": "person", "n_orgs": 6, "val": 6}, ...],
"links": [{"source": "p:dragan-naglic", "target": "klub:123",
"role": "predsjednik"}, ...],
"stats": {"persons": N, "orgs": M, "links": L, "multichair": K}
}
"""
# All person-org relationships
sql = """
WITH all_links AS (
SELECT lower(trim(predsjednik)) AS person_key,
initcap(trim(predsjednik)) AS person_name,
'klub:'||k.id AS org_id,
k.naziv AS org_name, 'klub' AS org_type,
'predsjednik' AS role,
k.sport
FROM pgz_sport.klubovi k
WHERE predsjednik IS NOT NULL AND length(trim(predsjednik)) > 5
AND (%(sport)s = '' OR k.sport = %(sport)s)
UNION ALL
SELECT lower(trim(tajnik)), initcap(trim(tajnik)),
'klub:'||k.id, k.naziv, 'klub', 'tajnik', k.sport
FROM pgz_sport.klubovi k
WHERE tajnik IS NOT NULL AND length(trim(tajnik)) > 5
AND (%(sport)s = '' OR k.sport = %(sport)s)
UNION ALL
SELECT lower(trim(predsjednik)), initcap(trim(predsjednik)),
'savez:'||s.id, s.naziv, 'savez', 'predsjednik', NULL
FROM pgz_sport.savezi s
WHERE predsjednik IS NOT NULL AND length(trim(predsjednik)) > 5
UNION ALL
SELECT lower(trim(tajnik)), initcap(trim(tajnik)),
'savez:'||s.id, s.naziv, 'savez', 'tajnik', NULL
FROM pgz_sport.savezi s
WHERE tajnik IS NOT NULL AND length(trim(tajnik)) > 5
),
person_stats AS (
SELECT person_key, max(person_name) AS person_name,
count(DISTINCT org_id) AS n_orgs,
string_agg(DISTINCT org_type, ',') AS org_types
FROM all_links
GROUP BY person_key
HAVING count(DISTINCT org_id) >= %(min_orgs)s
ORDER BY count(DISTINCT org_id) DESC
LIMIT %(top_n)s
)
SELECT al.person_key, ps.person_name, al.org_id, al.org_name,
al.org_type, al.role, al.sport, ps.n_orgs
FROM all_links al
JOIN person_stats ps ON al.person_key = ps.person_key
"""
rows = db_query(sql, {"min_orgs": min_orgs, "top_n": top_n, "sport": sport})
nodes = {}
links = []
multichair = 0
for r in rows:
pid = "p:" + r["person_key"].replace(" ", "-").replace(",", "")[:60]
if pid not in nodes:
n_orgs = r["n_orgs"]
nodes[pid] = {
"id": pid,
"name": r["person_name"],
"type": "multichair" if n_orgs >= 2 else "person",
"n_orgs": n_orgs,
"val": min(n_orgs * 3, 30) # 3D graf node size
}
if n_orgs >= 2:
multichair += 1
oid = r["org_id"]
if oid not in nodes:
nodes[oid] = {
"id": oid,
"name": r["org_name"][:40],
"type": r["org_type"],
"sport": r["sport"],
"val": 5
}
links.append({
"source": pid,
"target": oid,
"role": r["role"]
})
return {
"nodes": list(nodes.values()),
"links": links,
"stats": {
"persons": sum(1 for n in nodes.values() if n["type"] in ("person","multichair")),
"multichair": multichair,
"orgs": sum(1 for n in nodes.values() if n["type"] in ("klub","savez")),
"links": len(links),
"min_orgs": min_orgs,
"top_n": top_n,
"sport": sport or "svi"
}
}
@router.get("/graph/3d-iframe", response_class=HTMLResponse)
def graph_3d_iframe(min_orgs: int = 2, top_n: int = 100, sport: str = ""):
"""v2.0 — 3D ForceGraph s drill-down detail panel + year/sport filtri + highlight search."""
import os
p = os.path.join(os.path.dirname(__file__), "static", "sport_3d_v2.html")
if os.path.exists(p):
with open(p, encoding="utf-8") as f:
return f.read()
return "<h1>3D iframe not found</h1>"