#!/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} # ───────────────────────────────────────────────────────────────────── # Unified dokumenti library — Agent G (2026-05-05) # dradulic@outlook.com / damir@rinet.one # Maps free-form vrsta + organizacija into a normalized (tip, izdavatelj) # pair so the frontend can offer a single "Svi dokumenti" tab with two # canonical dropdowns. No DB changes — pure SQL CASE expressions. # ───────────────────────────────────────────────────────────────────── _TIP_CASE_SQL = """ CASE WHEN vrsta = 'godisnjak' OR vrsta = 'sportski-godisnjak' THEN 'godisnjak' WHEN vrsta = 'manifestacija' OR vrsta ILIKE '%%natjec%%' THEN 'natjecaj' WHEN vrsta IN ('pravilnik','pravilnik_savez','statut','odluka','zakon','zakon_dopuna') THEN 'pravilnik' WHEN vrsta IN ('publikacija','periodika','povijest','program','plan','strategija','raspodjela','erasmus') THEN 'publikacija' WHEN COALESCE(title,'') ILIKE '%%javni poziv%%' OR COALESCE(title,'') ILIKE '%%natje%%aj%%' THEN 'javni-poziv' ELSE 'ostalo' END """ _IZDAVATELJ_CASE_SQL = """ CASE WHEN organizacija ILIKE '%%RSS%%' OR organizacija ILIKE '%%Riječki sportski savez%%' OR url ILIKE '%%rss.hr%%' THEN 'RSS' WHEN organizacija ILIKE '%%Hrvatski olimpijski%%' OR organizacija = 'HOO' OR organizacija ILIKE '%%MTIS/HOO%%' OR url ILIKE '%%hoo.hr%%' OR url ILIKE '%%media-hoo%%' OR vrsta = 'hoo' THEN 'HOO' WHEN organizacija ILIKE '%%Zajednica%%PGŽ%%' OR organizacija ILIKE '%%ZSP PGŽ%%' OR organizacija ILIKE '%%Zajednica sportova PGŽ%%' OR organizacija ILIKE '%%Zajednica športskih saveza PGŽ%%' THEN 'ZSPGZ' WHEN organizacija ILIKE 'Primorsko-goranska%%' OR organizacija = 'PGŽ' OR url ILIKE '%%pgz.hr%%' OR url ILIKE '%%sport-pgz%%' THEN 'PGŽ' WHEN organizacija ILIKE 'Grad Rijeka%%' OR organizacija ILIKE 'Grad/Općina%%' OR url ILIKE '%%rijeka.hr%%' OR vrsta ILIKE 'jls_%%' OR vrsta = 'grad_rijeka_sport' THEN 'JLS' WHEN organizacija ILIKE 'Hrvatski %%savez%%' OR organizacija IN ('HKS','HOK','HNS','HRS','HVS','HBS') OR vrsta ILIKE 'savez_%%' THEN 'savez' WHEN organizacija ILIKE '%%klub%%' THEN 'klub' ELSE 'ostalo' END """ @router.get("/dokumenti/unified") def dokumenti_unified( tip: Optional[str] = None, izdavatelj: Optional[str] = None, vrsta: Optional[str] = None, q: Optional[str] = None, godina_min: Optional[int] = None, godina_max: Optional[int] = None, limit: int = 500, ): """Unified document library — returns ALL documents with normalized `tip` (godisnjak/manifestacija/natjecaj/pravilnik/publikacija/javni-poziv/ostalo) and `izdavatelj` (RSS/HOO/PGŽ/ZSPGZ/JLS/savez/klub/ostalo) derived from raw vrsta+organizacija+url columns. Filters: tip, izdavatelj, vrsta (raw), q (FTS over title/opis/organizacija), godina_min/godina_max, limit. """ where = ["COALESCE(aktivan,true)=true", "vrsta NOT IN ('corpus','corpus_v2','corpus_v3','novost_savez','audit','godisnjak_facts','enrichment','autogen')"] params = [] if tip: where.append(f"({_TIP_CASE_SQL}) = %s"); params.append(tip) if izdavatelj: where.append(f"({_IZDAVATELJ_CASE_SQL}) = %s"); params.append(izdavatelj) if vrsta: where.append("vrsta = %s"); params.append(vrsta) if godina_min: where.append("godina >= %s"); params.append(godina_min) if godina_max: where.append("godina <= %s"); params.append(godina_max) 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, kratak_opis, vrsta, {_TIP_CASE_SQL} AS tip, {_IZDAVATELJ_CASE_SQL} AS izdavatelj, razina, organizacija, sport, sluzbeni_glasnik, izvor_url, pdf_url, url, fname, godina, izdano_datum, CASE WHEN sadrzaj IS NOT NULL THEN length(sadrzaj) ELSE 0 END AS chars, scraped_at FROM pgz_sport.dokumenti WHERE {' AND '.join(where)} ORDER BY izdano_datum DESC NULLS LAST, godina DESC NULLS LAST, id DESC LIMIT %s""" params.append(limit) rows = db_query(sql, params) return {"count": len(rows), "dokumenti": rows} @router.get("/dokumenti/facets") def dokumenti_facets(): """Counts po normaliziranom tipu i izdavatelju — za UI dropdown badges.""" by_tip = db_query(f"""SELECT {_TIP_CASE_SQL} AS tip, count(*) AS broj FROM pgz_sport.dokumenti WHERE COALESCE(aktivan,true)=true AND vrsta NOT IN ('corpus','corpus_v2','corpus_v3','novost_savez','audit','godisnjak_facts','enrichment','autogen') GROUP BY 1 ORDER BY 2 DESC""") by_izd = db_query(f"""SELECT {_IZDAVATELJ_CASE_SQL} AS izdavatelj, count(*) AS broj FROM pgz_sport.dokumenti WHERE COALESCE(aktivan,true)=true AND vrsta NOT IN ('corpus','corpus_v2','corpus_v3','novost_savez','audit','godisnjak_facts','enrichment','autogen') GROUP BY 1 ORDER BY 2 DESC""") return {"by_tip": by_tip, "by_izdavatelj": by_izd} @router.post("/dokumenti/upload") async def upload_dokument( file: UploadFile = File(...), title: str = Form(...), vrsta: str = Form("ostalo"), razina: Optional[str] = Form(None), organizacija: Optional[str] = Form(None), sport: Optional[str] = Form(None), izvor_url: Optional[str] = Form(None), godina: Optional[int] = Form(None), kratak_opis: Optional[str] = Form(None), authorization: Optional[str] = Header(None), ): """Upload novog dokumenta (PDF/DOCX/TXT) → spremi datoteku + DB row. Vraća: {ok, dokument_id, fname, size, content_type}. Tekstualni sadržaj se ekstrahira (pdftotext za PDF, raw za TXT).""" import pathlib, subprocess as _sp, tempfile as _tf, hashlib as _hl raw = await file.read() if not raw: raise HTTPException(400, "Prazna datoteka") if len(raw) > 32 * 1024 * 1024: raise HTTPException(400, "Datoteka prevelika (max 32 MB)") suf = ("." + (file.filename or "").rsplit(".", 1)[-1].lower()) if "." in (file.filename or "") else "" if suf not in (".pdf", ".doc", ".docx", ".txt", ".rtf"): raise HTTPException(400, f"Tip nije podržan: {suf}. Dozvoljeno: PDF/DOC/DOCX/TXT/RTF") out_dir = pathlib.Path("/opt/pgz-sport/_data/dokumenti_uploads") out_dir.mkdir(parents=True, exist_ok=True) sha = _hl.sha256(raw).hexdigest()[:12] safe = re.sub(r"[^A-Za-z0-9._-]+", "_", file.filename or "upload")[:120] fname = f"{int(time.time())}_{sha}_{safe}" if not fname.endswith(suf): fname += suf fpath = out_dir / fname fpath.write_bytes(raw) # Tekst ekstrakcija (best-effort) sadrzaj = "" try: if suf == ".pdf": r = _sp.run(["pdftotext", "-layout", "-q", str(fpath), "-"], capture_output=True, timeout=60) sadrzaj = r.stdout.decode("utf-8", "ignore") elif suf == ".txt": sadrzaj = raw.decode("utf-8", "ignore") # docx/rtf: best-effort, skip except Exception: pass row = db_one(""" INSERT INTO pgz_sport.dokumenti (title, kratak_opis, vrsta, razina, organizacija, sport, izvor_url, godina, fname, sadrzaj, scraped_at, aktivan) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,now(),true) RETURNING id, title, vrsta, razina, fname, length(sadrzaj) AS chars """, (title, kratak_opis, vrsta, razina, organizacija, sport, izvor_url, godina, fname, sadrzaj or None)) # Audit try: from erp.audit_helper import audit as _audit _audit("pgz_sport.dokumenti", "upload", row["id"], korisnik="api", field="title", new=f"{title} ({len(raw)} B, sha={sha})") except Exception: pass return {"ok": True, "dokument_id": row["id"], "fname": fname, "title": row["title"], "vrsta": row["vrsta"], "chars": row["chars"], "size": len(raw), "content_type": file.content_type, "sha12": sha} @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}/full") def get_dokument_full(did: int): """Full dokument view + RAG chunks (renamed from duplicate /dokumenti/{did:int}). Old route bila duplikat — sad je eksplicitno /full za bogatiji prikaz.""" 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.` - 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.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']*class="result__a"[^>]*href="([^"]+)"[^>]*>([^<]+)', 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']*>.*?', ' ', src_html, flags=re2.DOTALL) text = re2.sub(r']*>.*?', ' ', 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"]*>.*?", " ", src_html, flags=re2.DOTALL) text = re2.sub(r"]*>.*?", " ", text, flags=re2.DOTALL) # Keep , , tags as markers text = re2.sub(r"<(h[1-6])[^>]*>", "\n## ", text) text = re2.sub(r"", "\n", text) text = re2.sub(r"<(strong|b)[^>]*>", "**", text) text = re2.sub(r"", "**", text) text = re2.sub(r"]*>", "\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/meta") def potpore_meta(): """Dropdown options za Financije sekciju.""" sportovi = db_query("SELECT DISTINCT sport FROM pgz_sport.sufinanciranje_sport WHERE sport IS NOT NULL ORDER BY sport") vrste = db_query("SELECT DISTINCT vrsta FROM pgz_sport.sufinanciranje_sport WHERE vrsta IS NOT NULL ORDER BY vrsta") davatelji = db_query("SELECT DISTINCT izvor, count(*) AS broj FROM pgz_sport.sufinanciranje_sport WHERE izvor IS NOT NULL GROUP BY izvor ORDER BY broj DESC") godine = db_query("SELECT DISTINCT godina, count(*) AS broj, sum(iznos_eur)::numeric(12,2) AS suma FROM pgz_sport.sufinanciranje_sport GROUP BY godina ORDER BY godina DESC") return { "sportovi": [r["sport"] for r in sportovi], "vrste": [r["vrsta"] for r in vrste], "davatelji": [r["izvor"] for r in davatelji], "godine": godine, } @router.get("/potpore/by-year") def potpore_by_year(godina: int = None, q: str = "", samo_klubovi: bool = True, davatelj: str = None, sport: str = None, vrsta: str = None): """Sufinanciranje za specifičnu godinu — samo_klubovi=True izbacuje programe/totals/services.""" import datetime yr = godina or datetime.date.today().year like = f"%{q}%" if q else "%" where = ["godina = %s", "LOWER(COALESCE(korisnik,'')) LIKE LOWER(%s)"] params = [yr, like] if samo_klubovi: where.append("(je_klub IS NULL OR je_klub = true)") if sport: where.append("LOWER(sport) = LOWER(%s)") params.append(sport) if vrsta: where.append("LOWER(vrsta) = LOWER(%s)") params.append(vrsta) if davatelj == 'rijeka': where.append("izvor ILIKE '%%rijeka.hr%%'") elif davatelj == 'pgz': where.append("izvor ILIKE '%%sport-pgz%%'") sql = f""" SELECT id, korisnik, sport, iznos_eur, vrsta, napomena, izvor, source_url, godina, klub_id, je_klub FROM pgz_sport.sufinanciranje_sport WHERE {' AND '.join(where)} ORDER BY iznos_eur DESC NULLS LAST LIMIT 500 """ rows = db_query(sql, params) 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 = """
+
Osoba
Savez
Klub
""" 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 } # Legacy stub /erp/putni-nalozi removed — superseded by routers/erp_full_router.py # (full CRUD on pgz_sport.expense_reports + status workflow). @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ć # 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 "

3D iframe not found

" # ═══════════════════════════════════════════════════════════════════════════ # CC4-Sub1 (05.05.2026) — fill audit gaps: /api/v2/{klubovi,savezi,sport/} # Author: cc4-sub1@rinet.one # ═══════════════════════════════════════════════════════════════════════════ @router.get("/klubovi") def v2_klubovi_list(q: Optional[str] = None, savez_id: Optional[int] = None, sport: Optional[str] = None, grad: Optional[str] = None, limit: int = 500): """v2 alias for /api/klubovi — minimal listing for portal/CRM panels.""" where = ["aktivan"] params: List[Any] = [] if q: where.append("(naziv ILIKE %s OR oib ILIKE %s OR sport ILIKE %s)") params.extend([f"%{q}%", f"%{q}%", f"%{q}%"]) if savez_id: where.append("savez_id=%s"); params.append(savez_id) if sport: where.append("sport ILIKE %s"); params.append(f"%{sport}%") if grad: where.append("grad ILIKE %s"); params.append(f"%{grad}%") params.append(max(1, min(limit, 2000))) sql = f"""SELECT id, naziv, oib, sport, grad, savez_id, region, broj_clanova, predsjednik, email, telefon, web FROM pgz_sport.klubovi WHERE {' AND '.join(where)} ORDER BY naziv COLLATE "hr-HR-x-icu" LIMIT %s""" rows = db_query(sql, params) return {"ok": True, "count": len(rows), "rows": rows} @router.get("/savezi") def v2_savezi_list(q: Optional[str] = None, razina: Optional[str] = None, sport: Optional[str] = None, limit: int = 500): """v2 alias for /api/savezi — minimal listing.""" where = ["aktivan"] params: List[Any] = [] if q: where.append("(naziv ILIKE %s OR sport ILIKE %s)") params.extend([f"%{q}%", f"%{q}%"]) if razina: where.append("razina = %s"); params.append(razina) if sport: where.append("sport ILIKE %s"); params.append(f"%{sport}%") params.append(max(1, min(limit, 2000))) sql = f"""SELECT id, naziv, sport, razina, sjediste_zupanija, godina_osnutka, predsjednik, email, web, (SELECT COUNT(*) FROM pgz_sport.klubovi WHERE savez_id=s.id) AS broj_klubova FROM pgz_sport.savezi s WHERE {' AND '.join(where)} ORDER BY naziv COLLATE "hr-HR-x-icu" LIMIT %s""" rows = db_query(sql, params) return {"ok": True, "count": len(rows), "rows": rows} @router.get("/sport") @router.get("/sport/") def v2_sport_index(): """v2 sport index — discovery endpoint listing sport-related sub-routes.""" return { "ok": True, "service": "pgz_sport v2 sport namespace", "endpoints": { "GET /api/v2/sport/svi/stats": "all-sports aggregate stats", "GET /api/v2/sport/{sport_naziv}/pregled": "drill-down per-sport view", "POST /api/v2/sport/ask": "RAG sport agent (Q&A)", "POST /api/v2/sport/lawyer": "RAG legal/regulation agent", "GET /api/v2/sport/objekti": "sport facilities listing", }, } # ============================================================================= # UI SPRINT 2026-05-05 — sport-aware enrichment + export + per-kategorija # ============================================================================= import csv as _csv import io as _io import unicodedata as _ud def _norm_sport(s: str) -> str: """Lowercase + strip diacritics for tolerant sport-name matching.""" if not s: return "" s = s.strip().lower() return "".join(c for c in _ud.normalize("NFD", s) if not _ud.combining(c)) @router.get("/enrich-sources") def v2_enrich_sources(sport: Optional[str] = None, q: Optional[str] = None): """Per-sport federation enrichment URLs (HNS / HKS-CBF / HRS / HOS-CVF / HVS). Returns rows from `pgz_sport.enrichment_sources` (schema: sport PK, primary_source_name, primary_source_url, player_url_pattern, klub_url_pattern, description). When `sport` is provided, returns a diacritic-tolerant single match. When `q` is provided, also yields a `search_url` derived from `primary_source_url` (URL-encoded query). """ base_sql = """SELECT sport, primary_source_name, primary_source_url, player_url_pattern, klub_url_pattern, description FROM pgz_sport.enrichment_sources""" if sport: rows = db_query( base_sql + " WHERE lower(unaccent(sport)) = lower(unaccent(%s)) LIMIT 1", [sport], ) if not rows: rows = db_query( base_sql + " WHERE sport ILIKE %s OR %s ILIKE '%%' || sport || '%%' LIMIT 1", [f"%{sport}%", sport], ) else: rows = db_query(base_sql + " ORDER BY sport COLLATE \"hr-HR-x-icu\"") out = [] enc_q = requests.utils.quote(q) if q else "" for r in rows: base = (r.get("primary_source_url") or "").rstrip("/") # build a generic search URL — federation sites all have a search page # behind ?s= or /search/?q=; default to base+?s= which works for HKS/HRS/HOS/HVS # and for HNS we route to /klubovi?q= sport_key = (r.get("sport") or "").lower() if sport_key == "nogomet": search_url = f"{base}/klubovi?q={enc_q}" if base else "" else: search_url = f"{base}/?s={enc_q}" if base else "" out.append({ "sport": r.get("sport"), "naziv": r.get("primary_source_name"), "base_url": r.get("primary_source_url"), "klub_url_pattern": r.get("klub_url_pattern"), "player_url_pattern": r.get("player_url_pattern"), "description": r.get("description"), "search_url": search_url if q else None, }) if sport: return {"ok": True, "match": out[0] if out else None, "rows": out} return {"ok": True, "count": len(out), "rows": out} class _ExportKluboviReq(BaseModel): ids: List[int] format: str = "xlsx" # 'xlsx' | 'csv' columns: Optional[List[str]] = None _EXPORT_DEFAULT_COLS = [ "id", "klub", "sport", "razina", "grad", "region", "predsjednik", "oib", "broj_clanova", "registriranih", "trenera", "nositelj_kvalitete", ] _EXPORT_ALLOWED = set(_EXPORT_DEFAULT_COLS) | { "savez", "email", "telefon", "web_stranica", "godina_osnutka", "reprezentativaca", "validni_lijecnicki", "isteki_lijecnicki", } @router.post("/export/klubovi") def v2_export_klubovi(req: _ExportKluboviReq): """Export selected clubs as CSV or XLSX. Body: {ids: [int,...], format: 'csv'|'xlsx', columns?: [str,...]}. """ from fastapi.responses import StreamingResponse if not req.ids: raise HTTPException(400, "ids list je prazan") fmt = (req.format or "xlsx").lower() if fmt not in ("xlsx", "csv"): raise HTTPException(400, "format mora biti 'xlsx' ili 'csv'") cols = [c for c in (req.columns or _EXPORT_DEFAULT_COLS) if c in _EXPORT_ALLOWED] if not cols: cols = list(_EXPORT_DEFAULT_COLS) sel = ", ".join(cols) rows = db_query( f"SELECT {sel} FROM pgz_sport.v_klubovi_pregled WHERE id = ANY(%s) ORDER BY klub", [req.ids], ) ts = datetime.now().strftime("%Y%m%d_%H%M") fname = f"pgz_klubovi_{ts}.{fmt}" if fmt == "csv": buf = _io.StringIO() w = _csv.writer(buf, delimiter=";", quoting=_csv.QUOTE_MINIMAL) w.writerow(cols) for r in rows: w.writerow([r.get(c, "") if r.get(c) is not None else "" for c in cols]) data = buf.getvalue().encode("utf-8-sig") # BOM za Excel return StreamingResponse( _io.BytesIO(data), media_type="text/csv; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{fname}"'}, ) # xlsx from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment wb = Workbook() ws = wb.active ws.title = "Klubovi" header_font = Font(bold=True, color="FFFFFF") header_fill = PatternFill("solid", fgColor="1F2937") ws.append(cols) for cell in ws[1]: cell.font = header_font cell.fill = header_fill cell.alignment = Alignment(horizontal="left", vertical="center") for r in rows: ws.append([ (r.get(c) if not isinstance(r.get(c), (list, dict)) else json.dumps(r.get(c), ensure_ascii=False)) for c in cols ]) # column widths for i, c in enumerate(cols, start=1): ws.column_dimensions[ws.cell(row=1, column=i).column_letter].width = max(12, min(40, len(c) + 6)) ws.freeze_panes = "A2" buf = _io.BytesIO() wb.save(buf) buf.seek(0) return StreamingResponse( buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f'attachment; filename="{fname}"'}, ) @router.get("/sportasi-by-kategorija") def v2_sportasi_by_kategorija( sport: Optional[str] = None, klub_id: Optional[int] = None, limit_per_kat: int = 500, ): """Group clanovi by kategorija. Players in multiple categories appear in each. Uses `kategorije TEXT[]` if non-empty, falls back to scalar `kategorija`. """ where = ["c.aktivan"] params: List[Any] = [] if sport: where.append("c.sport ILIKE %s") params.append(f"%{sport}%") if klub_id: where.append("c.klub_id = %s") params.append(klub_id) where_sql = " AND ".join(where) sql = f""" SELECT * FROM ( SELECT COALESCE(NULLIF(u.unnest_kat, ''), c.kategorija, '(nepoznata)') AS kat, c.id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol, c.sport, c.pozicija, c.kategorija, c.kategorije, c.reprezentativac, c.kategoriziran, c.stipendiran, c.klub_id, k.naziv AS klub_naziv, c.slika_url, c.broj_dresa FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id LEFT JOIN LATERAL ( SELECT unnest( CASE WHEN c.kategorije IS NOT NULL AND array_length(c.kategorije,1) > 0 THEN c.kategorije ELSE ARRAY[COALESCE(c.kategorija,'(nepoznata)')] END ) AS unnest_kat ) u ON TRUE WHERE {where_sql} ) sub ORDER BY kat COLLATE "hr-HR-x-icu", prezime, ime """ rows = db_query(sql, params) groups: Dict[str, Dict[str, Any]] = {} for r in rows: kat = r.pop("kat") or "(nepoznata)" g = groups.setdefault(kat, {"kategorija": kat, "count": 0, "rows": []}) if g["count"] < limit_per_kat: g["rows"].append(r) g["count"] += 1 out = sorted(groups.values(), key=lambda x: x["kategorija"]) return {"ok": True, "groups": out, "total_kategorija": len(out)} # ────────────────────────────────────────────────────────────────── # PGŽ-financed (priority) thin wrappers for savezi & clanovi (SUB6) # ────────────────────────────────────────────────────────────────── @router.get("/savezi/priority-sort") def v2_savezi_priority_sort(only: bool = False, limit: int = 500): """Savezi sa pgz_relevant=true prvi (ili samo oni ako only=true).""" where = "WHERE COALESCE(s.aktivan,true)" if only: where += " AND COALESCE(s.pgz_relevant,false) = TRUE" rows = db_query(f""" SELECT s.*, COALESCE(s.pgz_relevant,false) AS priority, (SELECT COUNT(*) FROM pgz_sport.klubovi WHERE savez_id=s.id) AS broj_klubova FROM pgz_sport.savezi s {where} ORDER BY COALESCE(s.pgz_relevant,false) DESC, s.naziv COLLATE "hr-HR-x-icu" LIMIT %s """, (limit,)) return {"count": len(rows), "rows": rows} @router.get("/clanovi/priority-sort") def v2_clanovi_priority_sort(only: bool = False, limit: int = 500): """Sportaši čiji klub je PGŽ-financiran ili u godišnjaku — prioritetni prvi.""" priority_expr = ("(COALESCE(k.pgz_sufinanciran,false) " "OR (k.godisnjak_godine IS NOT NULL " "AND array_length(k.godisnjak_godine,1) > 0))") where = "WHERE c.aktivan = TRUE" if only: where += f" AND {priority_expr}" rows = db_query(f""" SELECT c.id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol, c.sport, c.pozicija, c.reprezentativac, c.kategoriziran, c.stipendiran, c.kategorija, c.kategorije, c.kategorija_hoo, c.hoo_kategorija, c.aktivan, c.klub_id, c.klub_naziv_godisnjak, c.slika_url, c.broj_dresa, c.uloga, k.naziv AS klub_naziv, COALESCE(k.pgz_sufinanciran,false) AS klub_financiran, (k.godisnjak_godine IS NOT NULL AND array_length(k.godisnjak_godine,1) > 0) AS klub_godisnjak, {priority_expr} AS priority FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id {where} ORDER BY {priority_expr} DESC, c.prezime, c.ime LIMIT %s """, (limit,)) return {"count": len(rows), "rows": rows} # =================================================================== # HNS-3 (2026-05-05) — explicit drill-down endpoints for sport2.html # Author: Damir Radulić (dradulic@outlook.com / damir@rinet.one) # Description: 3-tab drill-down (HNS Karijera / Utakmice / Profil) # =================================================================== @router.get("/clan/{clan_id}/hns-matches") def v2_clan_hns_matches(clan_id: int, limit: int = 30): """Posljednje N utakmica iz hns_player_matches za sportaša (sortirano DESC po datumu).""" if limit < 1: limit = 1 if limit > 500: limit = 500 rows = db_query(""" SELECT id, hns_igrac_id, clan_id, datum, natjecanje, domacin, gost, rezultat, pozicija, startna, minute_od, minute_do, CASE WHEN minute_od IS NOT NULL AND minute_do IS NOT NULL THEN (minute_do - minute_od) ELSE NULL END AS minute, golovi, asistencije, zuti, crveni, source_url, scraped_at FROM pgz_sport.hns_player_matches WHERE clan_id = %s ORDER BY datum DESC NULLS LAST, id DESC LIMIT %s """, (clan_id, limit)) return {"clan_id": clan_id, "limit": limit, "count": len(rows), "rows": rows} @router.get("/clan/{clan_id}/hns-profile") def v2_clan_hns_profile(clan_id: int): """Bio + HNS profil block za drill-down 'Profil' tab. Vraća sve relevantne kolone iz pgz_sport.clanovi + HNS deep link + agregirane HNS statistike. """ p = db_one(""" SELECT c.id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.datum_rodjenja, c.mjesto_rodenja, c.mjesto_rodjenja, c.spol, c.sport, c.pozicija, c.dominantna_noga, c.visina_cm, c.tezina_kg, c.broj_dresa, c.broj_dres, c.kategorija, c.podkategorija, c.kategorije, c.reprezentativac, c.kategoriziran, c.stipendiran, c.aktivan, c.aktivni_status, c.email, c.telefon, c.adresa, c.grad, c.biografija, c.slug, c.slika_url, c.hns_igrac_id, c.profile_url, c.source_url, c.klub_id, c.klub_naziv_godisnjak, k.naziv AS klub_naziv, c.dob_age, EXTRACT(YEAR FROM age(COALESCE(c.datum_rodjenja, c.datum_rodenja)))::int AS dob_calc FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id WHERE c.id = %s """, (clan_id,)) if not p: raise HTTPException(404, "Sportaš nije pronađen") # Aggregate HNS career stats (cheap, single row) summary = db_one(""" SELECT count(DISTINCT sezona) AS sezona_broj, COALESCE(sum(nastupi),0) AS ukupno_nastupa, COALESCE(sum(golovi),0) AS ukupno_golova, COALESCE(sum(asistencije),0) AS ukupno_asistencija, COALESCE(sum(zuti),0) AS ukupno_zutih, COALESCE(sum(crveni),0) AS ukupno_crvenih, COALESCE(sum(minute),0) AS ukupno_minuta, min(sezona) AS prva_sezona, max(sezona) AS zadnja_sezona FROM pgz_sport.hns_player_seasons WHERE clan_id = %s """, (clan_id,)) or {} # Build HNS deep link hns_url = p.get("profile_url") if not hns_url and p.get("hns_igrac_id"): slug = p.get("slug") or f"{(p.get('ime') or '').lower()}-{(p.get('prezime') or '').lower()}".replace(" ", "-") hns_url = f"https://semafor.hns.family/igraci/{p['hns_igrac_id']}/{slug}/" return { "clan_id": clan_id, "profile": p, "hns_summary": summary, "hns_url": hns_url, } @router.get("/manifestacije/meta") def manifestacije_meta(): """Dropdown options za manifestacije.""" mjesta = db_query("SELECT DISTINCT mjesto, count(*) AS broj FROM pgz_sport.manifestacije WHERE mjesto IS NOT NULL GROUP BY mjesto ORDER BY broj DESC LIMIT 100") razine = db_query("SELECT DISTINCT razina FROM pgz_sport.manifestacije WHERE razina IS NOT NULL ORDER BY razina") organizatori = db_query("SELECT DISTINCT organizator, count(*) AS broj FROM pgz_sport.manifestacije WHERE organizator IS NOT NULL GROUP BY organizator ORDER BY broj DESC LIMIT 50") return { "mjesta": [r["mjesto"] for r in mjesta], "razine": [r["razina"] for r in razine], "organizatori": [r["organizator"] for r in organizatori], } @router.get("/manifestacije") def manifestacije_list(mjesto: str = None, razina: str = None, organizator: str = None, q: str = None, limit: int = 200): """Lista manifestacija s filterima.""" where = ["m.aktivna = true"] params = [] if mjesto: where.append("m.mjesto = %s") params.append(mjesto) if razina: where.append("m.razina = %s") params.append(razina) if organizator: where.append("m.organizator ILIKE %s") params.append(f"%{organizator}%") if q: where.append("(m.naziv ILIKE %s OR m.napomena ILIKE %s)") params.extend([f"%{q}%", f"%{q}%"]) rows = db_query(f""" SELECT m.id, m.naziv, m.mjesto, m.organizator, m.razina, m.broj_ucesnika, m.godina_od, m.spol_kategorija, m.napomena, m.source_url, s.naziv AS savez_naziv, s.id AS 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 + [limit]) return {"count": len(rows), "rows": rows}