5351 lines
247 KiB
Python
5351 lines
247 KiB
Python
#!/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='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7')
|
||
QDRANT = "http://10.10.0.2: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="sport")
|
||
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://10.10.0.2: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='10.10.0.2', port=6432, 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://10.10.0.2: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 = "sport", 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" ", " ", 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://10.10.0.2: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>"
|
||
|