#!/usr/bin/env python3 """ pgz_sport_extended_api.py - Multi-tenant + ERP/CRM extension for /api/v1/* Author: Damir Radulić (damir@rinet.one) Date: 28.04.2026 Port: 8095 (mounted under /api/v2/) Endpoints: auth, users, roles, klub-access, invoices, forms, alerts, expense reports, RAG sport agent """ from fastapi import APIRouter, HTTPException, Query, Body, Header, Depends, UploadFile, File, Form from pydantic import BaseModel from typing import Optional, List, Dict, Any from datetime import date, datetime, timedelta import psycopg2, psycopg2.extras import hashlib, secrets, json, requests, os, re, time DB = dict(host='localhost', port=5432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7') QDRANT = "http://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 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: return None return s 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 FROM pgz_sport.users WHERE email=%s""", (req.email.lower().strip(),)) if not u or u['status'] != 'active': raise HTTPException(401, "Invalid credentials") if not u['password_hash'] or u['password_hash'] != hash_pw(req.password): raise HTTPException(401, "Invalid credentials") 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'],"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)): 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 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'],)) return {**user, "roles":roles, "klubovi":klubovi} # ============== 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.""" # Embed query 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}") # Search qdrant r = requests.post(f"{QDRANT}/collections/{COLL}/points/search", json={"vector": emb, "limit": req.limit, "with_payload": True}, timeout=30) if r.status_code >= 400: raise HTTPException(503, f"Qdrant: {r.text[:200]}") hits = r.json()['result'] return { "query": req.query, "results": [ {"score": h['score'], "type": h['payload'].get('tip', h['payload'].get('type')), "title": h['payload'].get('naziv') or h['payload'].get('title') or '?', "snippet": (h['payload'].get('tekst') or '')[:500], "payload": {k:v for k,v in h['payload'].items() if k != 'tekst'}} for h in hits ] } # ============== 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") # 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='localhost', dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7') cu = conn.cursor() cu.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_text, payload) VALUES (%s,%s,%s,%s::jsonb)""", ('lawyer.query', 'sport_lawyer', q[:500], json.dumps({"llm": llm_used, "hits": len(hits), "sources": len(sources)}))) conn.commit(); conn.close() except Exception: pass return { "query": q, "answer": answer, "sources": sources, "llm": llm_used, "hits_count": len(hits), } @router.get("/calendar/upcoming") def calendar_upcoming(klub_id: Optional[int]=None, days_ahead: int=30): end = datetime.now() + timedelta(days=days_ahead) out = [] # Liječnički pregledi koji ističu where = ""; args = [] if klub_id: where = "AND c.klub_id=%s"; args = [klub_id] args = [end] + args rows = db_query(f"""SELECT 'lijecnicki_istek' AS type, 'Liječnički istječe — '||c.ime||' '||c.prezime AS title, lp.vrijedi_do AS date, c.klub_id, lp.clan_id, k.naziv AS klub_naziv FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE lp.vrijedi_do BETWEEN now()::date AND %s::date {where} ORDER BY lp.vrijedi_do""", args) out.extend(rows) # Računi koji dospijevaju args2 = [end] if klub_id: args2.append(klub_id) where2 = " AND klub_id=%s" if klub_id else "" rows = db_query(f"""SELECT 'invoice_due' AS type, 'Račun '||invoice_no||' — '||COALESCE(vendor_name,'?')||' — '||amount_gross::text||' EUR' AS title, due_date AS date, klub_id, NULL::int AS clan_id, NULL::text AS klub_naziv FROM pgz_sport.invoices WHERE due_date BETWEEN now()::date AND %s::date AND payment_status='unpaid'{where2} ORDER BY due_date""", args2) out.extend(rows) # Alerts otvorene args3 = [end] if klub_id: args3.append(klub_id) where3 = " AND klub_id=%s" if klub_id else "" rows = db_query(f"""SELECT 'alert' AS type, poruka AS title, due_date AS date, klub_id, clan_id, NULL::text FROM pgz_sport.alertovi WHERE rijeseno=false AND due_date IS NOT NULL AND due_date<=%s::date{where3} ORDER BY due_date""", args3) out.extend(rows) return sorted(out, key=lambda x: x.get('date') or date.max) # ============== EKOSUSTAV STATS (extended) ============== @router.get("/ekosustav/v2") def ekosustav_v2(): return { "savezi": db_one("""SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE razina='nacional') AS nacional, COUNT(*) FILTER (WHERE razina='zupanijski') AS zupanijski, COUNT(*) FILTER (WHERE razina='gradski') AS gradski FROM pgz_sport.savezi"""), "klubovi": db_one("""SELECT COUNT(*) AS total, COUNT(oib) AS s_oib, COUNT(*) FILTER (WHERE entity_id IS NOT NULL) AS linked FROM pgz_sport.klubovi"""), "documents": db_one("""SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE LENGTH(COALESCE(text_extracted,''))>=200) AS extracted FROM sport.documents"""), "qdrant_points": (lambda: requests.get(f"{QDRANT}/collections/{COLL}").json()['result']['points_count'])(), "users": db_one("""SELECT COUNT(*) FROM pgz_sport.users WHERE status='active'"""), "alerts_open": db_one("""SELECT COUNT(*) FILTER (WHERE razina='CRITICAL') AS critical, COUNT(*) FILTER (WHERE razina='WARNING') AS warning, COUNT(*) FILTER (WHERE razina='INFO') AS info FROM pgz_sport.alertovi WHERE rijeseno=false"""), "forms_templates": db_one("SELECT COUNT(*) FROM pgz_sport.form_templates WHERE active=true"), "invoices": db_one("""SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE payment_status='unpaid') AS unpaid, COALESCE(SUM(amount_gross),0)::numeric AS total_eur FROM pgz_sport.invoices"""), } # =========== MULTIPART UPLOAD + USER CREATE (added 28apr) =========== UPLOAD_DIR = "/var/lib/pgz-sport/invoices" os.makedirs(UPLOAD_DIR, exist_ok=True) @router.post("/invoice-uploads/file") async def upload_invoice_file( file: UploadFile = File(...), klub_id: int = Form(...), invoice_kind: str = Form("ulazni"), user = Depends(require_user) ): """Multipart upload — saves to disk + queues for OCR.""" if not (user_has_role(user['user_id'],'super_admin') or user_has_role(user['user_id'],'klub_admin','klub',klub_id) or user_has_role(user['user_id'],'klub_user','klub',klub_id)): raise HTTPException(403, "Forbidden") 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}