diff --git a/_backups/admin.html.cc3_pre_unified_sidebar.1777935999 b/_backups/admin.html.cc3_pre_unified_sidebar.1777935999 new file mode 100644 index 0000000..5af5774 --- /dev/null +++ b/_backups/admin.html.cc3_pre_unified_sidebar.1777935999 @@ -0,0 +1,761 @@ + + + + + +PGŽ Sport · Admin Dashboard + + + + + + +
+ + + +
+
+

Dashboard

+ učitavam… +
+ + +
+
+
+

Top Klubovi (po aktivnosti)

+
NazivSportGradČlanoviRačuni
+
+
+ + +
+
+ + +
+

📷 OCR — Skeniraj račun (gorivo, cestarina, hotel…)

+
+
+
Povuci PDF/JPG/PNG ovdje ili klikni za odabir
+
Tesseract OCR + Ri.NET AI Engine izvuče izdavatelja, OIB, datum, iznos, PDV, IBAN, stavke
+ +
+
+ +
+ + +
+

🚗 Novi putni nalog (HR pravilnik 2025)

+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ Unesi datume za live obračun dnevnica… +
+
+ + +
+
+ +
+

Računi

+
BrojDobavljačKlubIznosStatusDatum
+
+
+

Putni nalozi / izdaci

+
BrojKlubDestinacijaIznosStatusDatum
+
+
+ + +
+ +
+

Klubovi

+
NazivOIBSportGradEmailČlanoviRačuni
+
+
+ + +
+ +
+

Kontakti / Članovi

+
ImePrezimeOIBKlubPozicijaEmailStatus
+
+
+ + +
+
+

3D Sport Graph

+

Interaktivni 3D prikaz svih klubova, saveza i osoba s drill-down na detalje.

+
+ +
+
+
+ + +
+
+

Multi-tenant Management

+

Tenants u sustavu. Svaki tenant ima vlastiti scope klubova, financija i konfiguracije.

+
+
+
+ + +
+
+

Top 10 Klubova (po dokumentima i računima)

+
NazivSportGradRačuniČlanovi
+
+
+ +
+
+ + + + diff --git a/_backups/admin_users.py.r5_pre.1777937123 b/_backups/admin_users.py.r5_pre.1777937123 new file mode 100644 index 0000000..e5a4119 --- /dev/null +++ b/_backups/admin_users.py.r5_pre.1777937123 @@ -0,0 +1,446 @@ +#!/usr/bin/env python3 +# admin_users.py — /api/admin/users CRUD endpoints +# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04 +""" +GET /api/admin/users?tenant_type=&tenant_id=&q= +POST /api/admin/users +PUT /api/admin/users/{id} +DELETE /api/admin/users/{id} +POST /api/admin/users/{id}/invite +POST /api/admin/users/{id}/role +POST /api/admin/users/{id}/suspend +GET /api/admin/audit?user_id=&action=&limit= +GET /api/admin/tenants +POST /api/admin/users/bulk-csv +""" +import csv, io, secrets, json +from typing import Optional, List, Dict, Any +from datetime import datetime +from fastapi import APIRouter, HTTPException, Depends, Request, Body, UploadFile, File +from pydantic import BaseModel + +from .auth_v2 import ( + db_query, db_one, db_exec, hash_password, + require_user, audit, _client, + _resolve_tenant, _tier_for, + PGZ_USER_TYPES, SAVEZ_USER_TYPES, KLUB_USER_TYPES, +) + +router = APIRouter(prefix="/api/admin", tags=["admin"]) + +VALID_USER_TYPES = (PGZ_USER_TYPES | SAVEZ_USER_TYPES | KLUB_USER_TYPES | + {"viewer", "guest"}) + +# ─────────────────────────── Permission helpers ─────────────────────────── +def _is_pgz_admin(u: Dict) -> bool: + return (u.get("user_type") or "").lower() in ("super_admin", "pgz_admin") + +def _is_savez_admin(u: Dict) -> bool: + return (u.get("user_type") or "").lower() == "savez_admin" + +def _is_klub_admin(u: Dict) -> bool: + return (u.get("user_type") or "").lower() == "klub_admin" + +def _can_manage(actor: Dict, target_user_type: str, + target_klub_id: Optional[int], target_savez_id: Optional[int]) -> bool: + """Hierarchical management: + - super_admin / pgz_admin → manage everyone + - savez_admin → manage savez_*, klub_admin in their savez + - klub_admin → manage klub_user/klub_trener/klub_clan in their klub + """ + if _is_pgz_admin(actor): return True + tut = (target_user_type or "").lower() + if _is_savez_admin(actor): + if tut in PGZ_USER_TYPES: return False + if tut in SAVEZ_USER_TYPES and (target_savez_id == actor.get("savez_id")): return True + if tut == "klub_admin" and target_savez_id and target_savez_id == actor.get("savez_id"): + return True + # any klub user that belongs to this savez + if tut in KLUB_USER_TYPES and target_savez_id == actor.get("savez_id"): + return True + return False + if _is_klub_admin(actor): + if tut not in {"klub_user", "klub_trener", "klub_clan", "viewer"}: + return False + return target_klub_id and target_klub_id == actor.get("klub_id") + return False + +def _scoped_where(actor: Dict) -> tuple: + """Filter user list by actor's scope.""" + if _is_pgz_admin(actor): return ("", []) + if _is_savez_admin(actor): + sid = actor.get("savez_id") + if not sid: return ("AND 1=0", []) + return ("AND (u.savez_id=%s OR u.klub_id IN (SELECT id FROM pgz_sport.klubovi WHERE savez_id=%s))", + [sid, sid]) + if _is_klub_admin(actor): + kid = actor.get("klub_id") + if not kid: return ("AND 1=0", []) + return ("AND u.klub_id=%s", [kid]) + return ("AND u.id=%s", [actor["id"]]) + +# ─────────────────────────── List / read ─────────────────────────── +@router.get("/users") +def list_users( + q: Optional[str] = None, + user_type: Optional[str] = None, + tenant_type: Optional[str] = None, + tenant_id: Optional[int] = None, + klub_id: Optional[int] = None, + savez_id: Optional[int] = None, + aktivan: Optional[bool] = None, + limit: int = 100, + offset: int = 0, + actor = Depends(require_user), +): + if not (_is_pgz_admin(actor) or _is_savez_admin(actor) or _is_klub_admin(actor)): + raise HTTPException(403, "Forbidden — admin required") + where = ["1=1"]; args: List[Any] = [] + sw, sp = _scoped_where(actor) + if sw: where.append(sw.replace("AND ", "")); args.extend(sp) + if q: + where.append("(LOWER(u.email) LIKE %s OR LOWER(u.full_name) LIKE %s OR LOWER(COALESCE(u.ime,'')) LIKE %s OR LOWER(COALESCE(u.prezime,'')) LIKE %s)") + like = f"%{q.lower()}%"; args.extend([like]*4) + if user_type: where.append("u.user_type=%s"); args.append(user_type) + if klub_id: where.append("u.klub_id=%s"); args.append(klub_id) + if savez_id: where.append("u.savez_id=%s"); args.append(savez_id) + if aktivan is not None: where.append("u.aktivan=%s"); args.append(aktivan) + if tenant_type and tenant_id is not None: + if tenant_type == "klub": where.append("u.klub_id=%s"); args.append(tenant_id) + elif tenant_type == "savez": where.append("u.savez_id=%s"); args.append(tenant_id) + base_args = list(args) + args.extend([limit, offset]) + rows = db_query(f"""SELECT u.id, u.email, u.full_name, u.ime, u.prezime, u.user_type, + u.klub_id, u.savez_id, u.aktivan, u.status, u.must_change_pwd, + u.last_login, u.locked_until, u.failed_login_count, u.telefon, + u.created_at, u.gdpr_consent_at, + k.naziv AS klub_naziv, s.naziv AS savez_naziv + FROM pgz_sport.users u + LEFT JOIN pgz_sport.klubovi k ON k.id=u.klub_id + LEFT JOIN pgz_sport.savezi s ON s.id=u.savez_id + WHERE {' AND '.join(where)} + ORDER BY u.id DESC LIMIT %s OFFSET %s""", tuple(args)) + total = db_one(f"SELECT COUNT(*) AS c FROM pgz_sport.users u WHERE {' AND '.join(where)}", + tuple(base_args))["c"] + return {"count": len(rows), "total": total, "results": rows} + +@router.get("/users/{uid}") +def get_user(uid: int, actor = Depends(require_user)): + u = db_one("""SELECT u.*, k.naziv AS klub_naziv, s.naziv AS savez_naziv + FROM pgz_sport.users u + LEFT JOIN pgz_sport.klubovi k ON k.id=u.klub_id + LEFT JOIN pgz_sport.savezi s ON s.id=u.savez_id + WHERE u.id=%s""", (uid,)) + if not u: raise HTTPException(404, "User not found") + if not (_is_pgz_admin(actor) or + _can_manage(actor, u.get("user_type"), u.get("klub_id"), u.get("savez_id")) or + actor["id"] == uid): + raise HTTPException(403, "Forbidden") + # Strip sensitive + u.pop("password_hash", None) + u.pop("two_factor_secret", None) + return u + +# ─────────────────────────── Create ─────────────────────────── +class CreateUserReq(BaseModel): + email: str + full_name: Optional[str] = None + 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 absent → temp pwd + must_change + +@router.post("/users") +def create_user(req: CreateUserReq, request: Request, actor = Depends(require_user)): + if req.user_type not in VALID_USER_TYPES: + raise HTTPException(400, f"Invalid user_type: {req.user_type}") + if not _can_manage(actor, req.user_type, req.klub_id, req.savez_id): + raise HTTPException(403, "Forbidden — out of management scope") + full_name = req.full_name or ((req.ime or "") + " " + (req.prezime or "")).strip() or req.email + pwd = req.password or ("PGZ-" + secrets.token_hex(4)) + must_change = not bool(req.password) + try: + new_id = db_one("""INSERT INTO pgz_sport.users + (email, password_hash, full_name, ime, prezime, user_type, klub_id, savez_id, + telefon, oib, must_change_pwd, aktivan, status, auth_provider) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,true,'active','local') + RETURNING id""", + (req.email.lower().strip(), hash_password(pwd), full_name, + req.ime, req.prezime, req.user_type, req.klub_id, req.savez_id, + req.telefon, req.oib, must_change))["id"] + 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)) + ip, ua = _client(request) + audit(actor["id"], "user.create", "user", new_id, + {"email": req.email, "user_type": req.user_type, + "klub_id": req.klub_id, "savez_id": req.savez_id}, ip, ua) + return {"id": new_id, "email": req.email, "user_type": req.user_type, + "must_change_pwd": must_change, + "temporary_password": pwd if must_change else None} + +# ─────────────────────────── Update ─────────────────────────── +class UpdateUserReq(BaseModel): + full_name: Optional[str] = None + 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 update_user(uid: int, req: UpdateUserReq, request: Request, + actor = Depends(require_user)): + target = db_one("SELECT user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s", + (uid,)) + if not target: raise HTTPException(404, "User not found") + if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]): + raise HTTPException(403, "Forbidden") + fields, args = [], [] + for f in ["full_name","ime","prezime","user_type","klub_id","savez_id","telefon","oib","aktivan"]: + v = getattr(req, f) + if v is not None: + if f == "user_type" and v not in VALID_USER_TYPES: + raise HTTPException(400, f"Invalid user_type: {v}") + fields.append(f"{f}=%s"); args.append(v) + if not fields: + return {"status": "nothing_to_update"} + fields.append("updated_at=now()") + args.append(uid) + db_exec(f"UPDATE pgz_sport.users SET {', '.join(fields)} WHERE id=%s", tuple(args)) + ip, ua = _client(request) + audit(actor["id"], "user.update", "user", uid, + req.dict(exclude_none=True), ip, ua) + return {"status": "ok", "id": uid} + +# ─────────────────────────── Delete (soft) ─────────────────────────── +@router.delete("/users/{uid}") +def delete_user(uid: int, request: Request, actor = Depends(require_user)): + if uid == actor["id"]: + raise HTTPException(400, "Ne možete obrisati svoj račun") + target = db_one("SELECT user_type, klub_id, savez_id, email FROM pgz_sport.users WHERE id=%s", + (uid,)) + if not target: raise HTTPException(404, "User not found") + if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]): + raise HTTPException(403, "Forbidden") + db_exec("""UPDATE pgz_sport.users SET aktivan=false, status='deleted', + updated_at=now() WHERE id=%s""", (uid,)) + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,)) + ip, ua = _client(request) + audit(actor["id"], "user.delete", "user", uid, {"email": target["email"]}, ip, ua) + return {"status": "ok", "id": uid} + +# ─────────────────────────── Invite ─────────────────────────── +class InviteReq(BaseModel): + send_email: bool = False # placeholder — wired to mailer in M11 + note: Optional[str] = None + +@router.post("/users/{uid}/invite") +def invite_user(uid: int, req: InviteReq, request: Request, + actor = Depends(require_user)): + target = db_one("SELECT email, user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s", + (uid,)) + if not target: raise HTTPException(404, "User not found") + if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]): + raise HTTPException(403, "Forbidden") + new_temp = "PGZ-" + secrets.token_hex(4) + 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_password(new_temp), uid)) + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,)) + ip, ua = _client(request) + audit(actor["id"], "user.invite", "user", uid, + {"email": target["email"], "send_email": req.send_email}, ip, ua) + invite_link = f"https://api.rinet.one/sport/login?email={target['email']}" + return {"status": "ok", "id": uid, + "temporary_password": new_temp, + "invite_link": invite_link, + "email_sent": False} # mailer wired later + +# ─────────────────────────── Role change ─────────────────────────── +class RoleReq(BaseModel): + user_type: str + +@router.post("/users/{uid}/role") +def change_role(uid: int, req: RoleReq, request: Request, + actor = Depends(require_user)): + if not _is_pgz_admin(actor): + raise HTTPException(403, "Samo PGŽ admin može mijenjati role") + if req.user_type not in VALID_USER_TYPES: + raise HTTPException(400, f"Invalid user_type: {req.user_type}") + target = db_one("SELECT user_type FROM pgz_sport.users WHERE id=%s", (uid,)) + if not target: raise HTTPException(404, "User not found") + db_exec("UPDATE pgz_sport.users SET user_type=%s, updated_at=now() WHERE id=%s", + (req.user_type, uid)) + ip, ua = _client(request) + audit(actor["id"], "user.role.change", "user", uid, + {"from": target["user_type"], "to": req.user_type}, ip, ua) + return {"status": "ok", "id": uid, "user_type": req.user_type} + +# ─────────────────────────── Suspend / unsuspend ─────────────────────────── +class SuspendReq(BaseModel): + reason: Optional[str] = None + minutes: Optional[int] = None # null → indefinite + +@router.post("/users/{uid}/suspend") +def suspend_user(uid: int, req: SuspendReq, request: Request, + actor = Depends(require_user)): + target = db_one("SELECT user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s", + (uid,)) + if not target: raise HTTPException(404, "User not found") + if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]): + raise HTTPException(403, "Forbidden") + if req.minutes: + db_exec("""UPDATE pgz_sport.users + SET locked_until = now() + (interval '1 minute' * %s), + updated_at = now() WHERE id=%s""", (req.minutes, uid)) + else: + db_exec("UPDATE pgz_sport.users SET aktivan=false, updated_at=now() WHERE id=%s", + (uid,)) + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,)) + ip, ua = _client(request) + audit(actor["id"], "user.suspend", "user", uid, + {"reason": req.reason, "minutes": req.minutes}, ip, ua) + return {"status": "ok", "id": uid} + +@router.post("/users/{uid}/unsuspend") +def unsuspend_user(uid: int, request: Request, actor = Depends(require_user)): + target = db_one("SELECT user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s", + (uid,)) + if not target: raise HTTPException(404, "User not found") + if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]): + raise HTTPException(403, "Forbidden") + db_exec("""UPDATE pgz_sport.users + SET aktivan=true, locked_until=NULL, failed_login_count=0, + updated_at=now() WHERE id=%s""", (uid,)) + ip, ua = _client(request) + audit(actor["id"], "user.unsuspend", "user", uid, None, ip, ua) + return {"status": "ok", "id": uid} + +# ─────────────────────────── Reset password (admin) ─────────────────────────── +@router.post("/users/{uid}/reset-password") +def admin_reset_password(uid: int, request: Request, actor = Depends(require_user)): + target = db_one("SELECT user_type, klub_id, savez_id, email FROM pgz_sport.users WHERE id=%s", + (uid,)) + if not target: raise HTTPException(404, "User not found") + if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]): + raise HTTPException(403, "Forbidden") + new_temp = "PGZ-" + secrets.token_hex(4) + 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_password(new_temp), uid)) + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,)) + ip, ua = _client(request) + audit(actor["id"], "user.password.reset", "user", uid, + {"email": target["email"]}, ip, ua) + return {"status": "ok", "temporary_password": new_temp} + +# ─────────────────────────── Audit log ─────────────────────────── +@router.get("/audit") +def audit_log(user_id: Optional[int] = None, + action: Optional[str] = None, + resource_type: Optional[str] = None, + limit: int = 100, + offset: int = 0, + actor = Depends(require_user)): + if not _is_pgz_admin(actor): + # savez/klub admins see only their scope + if not (_is_savez_admin(actor) or _is_klub_admin(actor)): + raise HTTPException(403, "Forbidden") + where = ["1=1"]; args: List[Any] = [] + if user_id: where.append("a.user_id=%s"); args.append(user_id) + if action: where.append("a.action LIKE %s"); args.append(f"%{action}%") + if resource_type: where.append("a.resource_type=%s"); args.append(resource_type) + if not _is_pgz_admin(actor): + # restrict to own user's actions or resources within scope + if _is_savez_admin(actor): + where.append("(a.user_id IN (SELECT id FROM pgz_sport.users WHERE savez_id=%s OR klub_id IN (SELECT id FROM pgz_sport.klubovi WHERE savez_id=%s)))") + args.extend([actor.get("savez_id"), actor.get("savez_id")]) + elif _is_klub_admin(actor): + where.append("(a.user_id IN (SELECT id FROM pgz_sport.users WHERE klub_id=%s))") + args.append(actor.get("klub_id")) + args.extend([limit, offset]) + rows = db_query(f"""SELECT a.id, a.action, a.resource_type, a.resource_id, + a.user_id, a.ts AS created_at, a.meta, a.ip_address, a.user_agent, + u.email AS actor_email, u.full_name AS actor_name + 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} + +# ─────────────────────────── Tenants list ─────────────────────────── +@router.get("/tenants") +def list_tenants(actor = Depends(require_user)): + """Combined view: tenants table + savezi + klubovi.""" + tenants = db_query("""SELECT id, slug, display_name, type, status, oib, created_at + FROM pgz_sport.tenants ORDER BY id""") + if _is_pgz_admin(actor): + savezi = db_query("""SELECT id, naziv, sport, oib, predsjednik, tajnik + FROM pgz_sport.savezi WHERE aktivan=true ORDER BY naziv LIMIT 200""") + klubovi = db_query("""SELECT id, naziv, sport, grad, oib, savez_id + FROM pgz_sport.klubovi WHERE aktivan=true ORDER BY naziv LIMIT 500""") + elif _is_savez_admin(actor): + sid = actor.get("savez_id") + savezi = db_query("""SELECT id, naziv, sport, oib, predsjednik, tajnik + FROM pgz_sport.savezi WHERE id=%s""", (sid,)) + klubovi = db_query("""SELECT id, naziv, sport, grad, oib, savez_id + FROM pgz_sport.klubovi WHERE savez_id=%s AND aktivan=true ORDER BY naziv""", (sid,)) + else: + kid = actor.get("klub_id") + savezi = [] + klubovi = db_query("""SELECT id, naziv, sport, grad, oib, savez_id + FROM pgz_sport.klubovi WHERE id=%s""", (kid,)) + return {"tenants": tenants, "savezi": savezi, "klubovi": klubovi} + +# ─────────────────────────── Bulk CSV import ─────────────────────────── +@router.post("/users/bulk-csv") +async def bulk_csv(file: UploadFile = File(...), + default_user_type: str = "klub_clan", + default_klub_id: Optional[int] = None, + default_savez_id: Optional[int] = None, + request: Request = None, + actor = Depends(require_user)): + """CSV columns (header required): email,ime,prezime,user_type,klub_id,savez_id,telefon,oib""" + if not _is_pgz_admin(actor): + raise HTTPException(403, "Samo PGŽ admin može masovno uvoziti") + raw = (await file.read()).decode("utf-8", errors="replace") + rdr = csv.DictReader(io.StringIO(raw)) + created, skipped, errors = 0, 0, [] + for i, row in enumerate(rdr, 1): + email = (row.get("email") or "").lower().strip() + if not email: + skipped += 1; continue + try: + ut = row.get("user_type") or default_user_type + if ut not in VALID_USER_TYPES: + errors.append(f"row {i}: invalid user_type {ut}"); skipped += 1; continue + kid = int(row["klub_id"]) if row.get("klub_id") else default_klub_id + sid = int(row["savez_id"]) if row.get("savez_id") else default_savez_id + full_name = (row.get("ime","") + " " + row.get("prezime","")).strip() or email + temp_pwd = "PGZ-" + secrets.token_hex(4) + 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) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,true,true,'active','local') + ON CONFLICT (email) DO NOTHING RETURNING id""", + (email, hash_password(temp_pwd), row.get("ime"), row.get("prezime"), + full_name, ut, kid, sid, row.get("telefon"), row.get("oib"))) + if new_id and new_id.get("id"): + created += 1 + else: + skipped += 1 + except Exception as e: + errors.append(f"row {i}: {e}"); skipped += 1 + audit(actor["id"], "user.bulk_csv", meta={"created": created, "skipped": skipped}) + return {"created": created, "skipped": skipped, "errors": errors[:20]} diff --git a/_backups/app.html.cc3_pre_unified_sidebar.1777935999 b/_backups/app.html.cc3_pre_unified_sidebar.1777935999 new file mode 100644 index 0000000..05fd6e4 --- /dev/null +++ b/_backups/app.html.cc3_pre_unified_sidebar.1777935999 @@ -0,0 +1,1705 @@ + + + + + +PGŽ SPORT — Operativna aplikacija + + + + + + + + + +
+ + +
+
+
+
Dashboard
+
Pregled stanja
+
+
+
+
+
DR
+
+
Damir Radulićpgz admin
+
Primorsko-goranska županija
+
+
+
+
+ +
+
Učitavanje...
+
+
+
+ + +
+ + + + + + + diff --git a/_backups/audit.html.cc3_pre_unified_sidebar.1777935999 b/_backups/audit.html.cc3_pre_unified_sidebar.1777935999 new file mode 100644 index 0000000..8b7a947 --- /dev/null +++ b/_backups/audit.html.cc3_pre_unified_sidebar.1777935999 @@ -0,0 +1,150 @@ + + + + +Audit Log — PGŽ Sport + + + + + +

📜 Audit Log

+
Kompletna povijest izmjena s blockchain pečatima na Polygon PoS
+ +
+
Ukupno akcija
+
Danas
+
Polygon zapečaćeno
+
Aktivni korisnici
+
+ +
+ + + + + +
+ + + + + + + + + + + + + + + +
VrijemeKorisnikAkcijaResursDetaljiPolygon Tx
⏳ Učitavam...
+ + + + diff --git a/_backups/auth_v2.py.r5_pre.1777937123 b/_backups/auth_v2.py.r5_pre.1777937123 new file mode 100644 index 0000000..53a3fe5 --- /dev/null +++ b/_backups/auth_v2.py.r5_pre.1777937123 @@ -0,0 +1,666 @@ +#!/usr/bin/env python3 +# auth_v2.py — JWT auth backend with tenant_id, role, tier claims +# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04 +# Endpoints: /api/auth/login, /api/auth/refresh, /api/auth/logout, +# /api/auth/me, /api/auth/password/change, /api/auth/password/reset +""" +JWT claims: + sub int user id + email str + name str + tenant_id int|null pgz_sport.tenants.id (or null for super_admin) + tenant_type str pgz | savez | klub | global + tenant_scope dict {"klub_id": ..., "savez_id": ...} + role str user_type code (super_admin | pgz_admin | savez_admin | klub_admin | klub_clan | viewer ...) + tier int 0 = PGŽ, 1 = savez, 2 = klub + jti str token id (revocable via user_sessions) + iat / exp / nbf +""" + +import os, hashlib, secrets, json, time +from datetime import datetime, timedelta, timezone +from typing import Optional, Dict, List, Any + +import jwt as _jwt +import psycopg2, psycopg2.extras +from fastapi import APIRouter, HTTPException, Header, Depends, Request, Body +from pydantic import BaseModel, EmailStr + +try: + from passlib.hash import bcrypt as _bcrypt + HAS_BCRYPT = True +except Exception: + HAS_BCRYPT = False + +DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', + user='rinet', password='R1net2026!SecureDB#v7') + +# Persistent JWT secret — read from env, else stable file, else generated. +def _load_secret() -> str: + env_secret = os.environ.get("PGZ_JWT_SECRET") + if env_secret and len(env_secret) >= 32: + return env_secret + secret_file = "/opt/pgz-sport/auth/.jwt_secret" + try: + if os.path.exists(secret_file): + with open(secret_file) as f: + s = f.read().strip() + if len(s) >= 32: + return s + s = "rinet-pgz-" + secrets.token_urlsafe(48) + with open(secret_file, "w") as f: + f.write(s) + os.chmod(secret_file, 0o600) + return s + except Exception: + return "rinet-pgz-jwt-2026-fallback-" + hashlib.sha256(b"pgz-sport").hexdigest() + +JWT_SECRET = _load_secret() +JWT_ALG = "HS256" +ACCESS_TTL = timedelta(minutes=int(os.environ.get("PGZ_JWT_ACCESS_MIN", "30"))) +REFRESH_TTL = timedelta(days=int(os.environ.get("PGZ_JWT_REFRESH_DAYS", "7"))) + +router = APIRouter(prefix="/api/auth", tags=["auth_v2"]) + +# ─────────────────────────── DB helpers ─────────────────────────── +def _conn(): + return psycopg2.connect(**DB) + +def db_query(sql: str, params=()): + with _conn() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(sql, params) + if cur.description: return cur.fetchall() + return [] + +def db_one(sql: str, params=()): + rows = db_query(sql, params) + return rows[0] if rows else None + +def db_exec(sql: str, params=()): + with _conn() as c: + cur = c.cursor() + cur.execute(sql, params) + if cur.description: + r = cur.fetchone() + return r[0] if r else None + c.commit() + +# ─────────────────────────── Password helpers ─────────────────────────── +def _sha256(pw: str) -> str: + return hashlib.sha256(pw.encode()).hexdigest() + +def hash_password(pw: str) -> str: + if HAS_BCRYPT: + return _bcrypt.using(rounds=12).hash(pw) + return _sha256(pw) + +def verify_password(pw: str, hashed: Optional[str]) -> bool: + if not hashed: return False + h = hashed.strip() + if h.startswith("$2") and HAS_BCRYPT: + try: + return _bcrypt.verify(pw, h) + except Exception: + return False + return h == _sha256(pw) + +def needs_rehash(hashed: Optional[str]) -> bool: + if not hashed: return True + return HAS_BCRYPT and not hashed.startswith("$2") + +# ─────────────────────────── Tenant resolution ─────────────────────────── +PGZ_USER_TYPES = {"super_admin", "pgz_admin", "pgz_user", "pgz_finance", "pgz_zzjz"} +SAVEZ_USER_TYPES = {"savez_admin", "savez_user"} +KLUB_USER_TYPES = {"klub_admin", "klub_user", "klub_trener", "klub_clan"} + +def _tier_for(user_type: str) -> int: + ut = (user_type or "").lower() + if ut in PGZ_USER_TYPES: return 0 + if ut in SAVEZ_USER_TYPES: return 1 + if ut in KLUB_USER_TYPES: return 2 + return 9 # unknown / viewer / guest + +def _resolve_tenant(u: Dict) -> Dict: + """Resolve tenant_id + tenant_type from a user row.""" + ut = (u.get("user_type") or "").lower() + klub_id = u.get("klub_id") + savez_id = u.get("savez_id") + if ut in PGZ_USER_TYPES: + row = db_one("SELECT id, slug, display_name FROM pgz_sport.tenants WHERE slug='pgz' LIMIT 1") + return { + "tenant_id": row["id"] if row else None, + "tenant_type": "pgz", + "tenant_name": row["display_name"] if row else "PGŽ", + "tenant_scope": {"klub_id": None, "savez_id": None}, + } + if ut in SAVEZ_USER_TYPES and savez_id: + return { + "tenant_id": savez_id, + "tenant_type": "savez", + "tenant_name": (db_one("SELECT naziv FROM pgz_sport.savezi WHERE id=%s",(savez_id,)) or {}).get("naziv"), + "tenant_scope": {"klub_id": None, "savez_id": savez_id}, + } + if ut in KLUB_USER_TYPES and klub_id: + return { + "tenant_id": klub_id, + "tenant_type": "klub", + "tenant_name": (db_one("SELECT naziv FROM pgz_sport.klubovi WHERE id=%s",(klub_id,)) or {}).get("naziv"), + "tenant_scope": {"klub_id": klub_id, "savez_id": savez_id}, + } + # super_admin without context + if ut == "super_admin": + return {"tenant_id": None, "tenant_type": "global", + "tenant_name": "Global", "tenant_scope": {"klub_id": None, "savez_id": None}} + return {"tenant_id": None, "tenant_type": "viewer", + "tenant_name": None, "tenant_scope": {"klub_id": klub_id, "savez_id": savez_id}} + +# ─────────────────────────── JWT issue / verify ─────────────────────────── +def _now() -> datetime: return datetime.now(timezone.utc) + +def _new_jti() -> str: return secrets.token_urlsafe(16) + +def make_access_token(u: Dict, jti: str) -> str: + tenant = _resolve_tenant(u) + tier = _tier_for(u.get("user_type") or "") + now = _now() + payload = { + "sub": str(u["id"]), + "uid": u["id"], + "email": u["email"], + "name": u.get("full_name") or ((u.get("ime") or "") + " " + (u.get("prezime") or "")).strip() or u["email"], + "tenant_id": tenant["tenant_id"], + "tenant_type": tenant["tenant_type"], + "tenant_name": tenant["tenant_name"], + "tenant_scope": tenant["tenant_scope"], + "role": u.get("user_type") or "viewer", + "tier": tier, + "jti": jti, + "typ": "access", + "iat": int(now.timestamp()), + "nbf": int(now.timestamp()), + "exp": int((now + ACCESS_TTL).timestamp()), + } + return _jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG) + +def make_refresh_token(uid: int, jti: str) -> str: + now = _now() + return _jwt.encode({ + "sub": str(uid), "uid": uid, "jti": jti, "typ": "refresh", + "iat": int(now.timestamp()), + "exp": int((now + REFRESH_TTL).timestamp()), + }, JWT_SECRET, algorithm=JWT_ALG) + +def decode_token(token: str) -> Dict: + try: + return _jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG]) + except _jwt.ExpiredSignatureError: + raise HTTPException(401, "Token expired") + except Exception as e: + raise HTTPException(401, f"Invalid token: {e}") + +def _record_session(uid: int, jti: str, expires: datetime, ip: str = None, ua: str = None): + th = hashlib.sha256(jti.encode()).hexdigest() + db_exec("""INSERT INTO pgz_sport.user_sessions + (user_id, token_hash, device_info, ip_address, expires_at, revoked) + VALUES (%s,%s,%s,%s::inet,%s,false) + ON CONFLICT (token_hash) DO NOTHING""", + (uid, th, ua, ip, expires)) + +def _is_revoked(jti: str) -> bool: + th = hashlib.sha256(jti.encode()).hexdigest() + r = db_one("SELECT revoked FROM pgz_sport.user_sessions WHERE token_hash=%s", (th,)) + if not r: return False + return bool(r.get("revoked")) + +def _revoke_jti(jti: str): + th = hashlib.sha256(jti.encode()).hexdigest() + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE token_hash=%s", (th,)) + +# ─────────────────────────── current_user dep ─────────────────────────── +def _extract_token(authorization: Optional[str]) -> Optional[str]: + if not authorization: return None + return authorization.replace("Bearer ", "").strip() or None + +def get_current_user(authorization: Optional[str] = Header(None)) -> Optional[Dict]: + token = _extract_token(authorization) + if not token: return None + try: + payload = decode_token(token) + except HTTPException: + return None + if payload.get("typ") not in (None, "access"): + return None + if _is_revoked(payload.get("jti","")): + return None + uid = payload.get("uid") or int(payload.get("sub", 0) or 0) + u = db_one("""SELECT id, email, full_name, ime, prezime, user_type, + klub_id, savez_id, status, aktivan, must_change_pwd + FROM pgz_sport.users WHERE id=%s""", (uid,)) + if not u or u.get("status") != "active" or not u.get("aktivan", True): + return None + u["_jwt"] = payload + u["_token"] = token + return u + +def require_user(user = Depends(get_current_user)) -> Dict: + if not user: + raise HTTPException(401, "Authentication required") + return user + +def require_role(roles: List[str]): + def dep(user = Depends(require_user)): + if user.get("user_type") not in roles: + raise HTTPException(403, f"Forbidden — required: {','.join(roles)}") + return user + return dep + +# ─────────────────────────── Audit ─────────────────────────── +def audit(user_id: Optional[int], action: str, resource_type: str = None, + resource_id: int = None, meta: Dict = None, ip: str = None, ua: str = None): + try: + db_exec("""INSERT INTO pgz_sport.audit_events + (user_id, action, resource_type, resource_id, meta, ip_address, user_agent) + VALUES (%s,%s,%s,%s,%s::jsonb,%s::inet,%s)""", + (user_id, action, resource_type, resource_id, + json.dumps(meta or {}), ip, ua)) + except Exception as e: + print(f"[AUDIT WARN] {e}") + +def _client(req: Request): + ip = (req.headers.get("x-forwarded-for") or req.client.host or "").split(",")[0].strip() or None + ua = req.headers.get("user-agent") + return ip, ua + +# ─────────────────────────── Schemas ─────────────────────────── +class LoginReq(BaseModel): + email: str + password: str + totp: Optional[str] = None # 6-digit TOTP if 2FA enabled (or recovery code) + +class RefreshReq(BaseModel): + refresh_token: str + +class ChangePwdReq(BaseModel): + old_password: Optional[str] = None + new_password: str + +class ResetPwdReq(BaseModel): + email: str + +# ─────────────────────────── Endpoints ─────────────────────────── +@router.post("/login") +def login(req: LoginReq, request: Request): + ip, ua = _client(request) + email = (req.email or "").lower().strip() + if not email or not req.password: + raise HTTPException(400, "Email i lozinka obavezni") + + u = db_one("""SELECT id, email, full_name, ime, prezime, password_hash, status, + user_type, klub_id, savez_id, aktivan, must_change_pwd, + failed_login_count, locked_until + FROM pgz_sport.users WHERE LOWER(email)=%s""", (email,)) + if not u: + audit(None, "login.fail", meta={"email": email, "reason": "no_user"}, ip=ip, ua=ua) + raise HTTPException(401, "Neispravni podaci") + if u.get("locked_until"): + lu = u["locked_until"] + if lu.tzinfo is None: lu = lu.replace(tzinfo=timezone.utc) + if lu > _now(): + audit(u["id"], "login.locked", ip=ip, ua=ua) + raise HTTPException(423, "Račun privremeno zaključan") + if u.get("status") != "active" or not u.get("aktivan", True): + audit(u["id"], "login.fail", meta={"reason":"inactive"}, ip=ip, ua=ua) + raise HTTPException(403, "Račun nije aktivan") + if not verify_password(req.password, u.get("password_hash")): + db_exec("""UPDATE pgz_sport.users + SET failed_login_count = COALESCE(failed_login_count,0)+1, + locked_until = CASE WHEN COALESCE(failed_login_count,0)+1>=5 + THEN now()+interval '15 minutes' ELSE locked_until END + WHERE id=%s""", (u["id"],)) + audit(u["id"], "login.fail", meta={"reason":"bad_password"}, ip=ip, ua=ua) + raise HTTPException(401, "Neispravni podaci") + + # opportunistic rehash to bcrypt + if needs_rehash(u.get("password_hash")): + try: + db_exec("UPDATE pgz_sport.users SET password_hash=%s WHERE id=%s", + (hash_password(req.password), u["id"])) + except Exception: pass + + # 2FA gate — if user has enabled 2FA, demand a valid TOTP / recovery code + twofa_row = None + try: + twofa_row = db_one("SELECT secret, enabled, recovery_codes FROM pgz_sport.user_2fa WHERE user_id=%s", + (u["id"],)) + except Exception: pass + if twofa_row and twofa_row.get("enabled"): + code = (req.totp or "").strip().replace(" ", "") + if not code: + audit(u["id"], "login.2fa_required", ip=ip, ua=ua) + raise HTTPException(401, "2FA_REQUIRED") + ok = False + if code.isdigit() and len(code) in (6, 8) and HAS_PYOTP: + ok = _pyotp.TOTP(twofa_row["secret"]).verify(code, valid_window=1) + if not ok and twofa_row.get("recovery_codes"): + up = code.upper() + if up in (twofa_row["recovery_codes"] or []): + ok = True + # consume the recovery code so it can't be reused + remaining = [c for c in twofa_row["recovery_codes"] if c != up] + db_exec("UPDATE pgz_sport.user_2fa SET recovery_codes=%s, updated_at=now() WHERE user_id=%s", + (remaining, u["id"])) + if not ok: + audit(u["id"], "login.2fa_fail", ip=ip, ua=ua) + raise HTTPException(401, "Neispravan 2FA kod") + + db_exec("""UPDATE pgz_sport.users + SET failed_login_count=0, locked_until=NULL, last_login=now() + WHERE id=%s""", (u["id"],)) + + jti = _new_jti() + rjti = _new_jti() + access = make_access_token(u, jti) + refresh = make_refresh_token(u["id"], rjti) + _record_session(u["id"], jti, _now() + ACCESS_TTL, ip=ip, ua=ua) + _record_session(u["id"], rjti, _now() + REFRESH_TTL, ip=ip, ua=(ua or "") + " [refresh]") + audit(u["id"], "login.ok", ip=ip, ua=ua) + + tenant = _resolve_tenant(u) + return { + "access_token": access, + "refresh_token": refresh, + "token_type": "Bearer", + "expires_in": int(ACCESS_TTL.total_seconds()), + "user": { + "id": u["id"], "email": u["email"], + "full_name": u.get("full_name") or (u.get("ime","") + " " + u.get("prezime","")).strip(), + "role": u.get("user_type"), "tier": _tier_for(u.get("user_type") or ""), + "must_change_pwd": bool(u.get("must_change_pwd")), + **tenant, + }, + } + +@router.post("/refresh") +def refresh(req: RefreshReq, request: Request): + payload = decode_token(req.refresh_token) + if payload.get("typ") != "refresh": + raise HTTPException(401, "Invalid refresh token") + if _is_revoked(payload.get("jti","")): + raise HTTPException(401, "Refresh token revoked") + uid = payload.get("uid") or int(payload.get("sub", 0) or 0) + u = db_one("""SELECT id, email, full_name, ime, prezime, user_type, + klub_id, savez_id, status, aktivan, must_change_pwd + FROM pgz_sport.users WHERE id=%s""", (uid,)) + if not u or u.get("status") != "active" or not u.get("aktivan", True): + raise HTTPException(401, "User inactive") + ip, ua = _client(request) + new_jti = _new_jti() + access = make_access_token(u, new_jti) + _record_session(u["id"], new_jti, _now() + ACCESS_TTL, ip=ip, ua=ua) + audit(u["id"], "auth.refresh", ip=ip, ua=ua) + return {"access_token": access, "token_type": "Bearer", + "expires_in": int(ACCESS_TTL.total_seconds())} + +@router.post("/logout") +def logout(request: Request, user = Depends(require_user)): + jti = (user.get("_jwt") or {}).get("jti") + if jti: _revoke_jti(jti) + # Also revoke refresh tokens for this user (best-effort) + db_exec("""UPDATE pgz_sport.user_sessions SET revoked=true + WHERE user_id=%s AND device_info LIKE %s""", + (user["id"], "%[refresh]%")) + ip, ua = _client(request) + audit(user["id"], "logout", ip=ip, ua=ua) + return {"status": "ok"} + +@router.get("/me") +def me(user = Depends(require_user)): + enriched = db_one("""SELECT id, email, full_name, ime, prezime, user_type, + klub_id, savez_id, must_change_pwd, aktivan, status, + last_login, oib, telefon, phone, preferred_language, created_at, + avatar_url, gdpr_consent_at, google_picture + FROM pgz_sport.users WHERE id=%s""", (user["id"],)) + if not enriched: + raise HTTPException(404, "User not found") + tenant = _resolve_tenant(enriched) + roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id + FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id + WHERE ur.user_id=%s AND ur.active=true""", (user["id"],)) + try: + twofa = db_one("SELECT secret IS NOT NULL AS enabled FROM pgz_sport.user_2fa WHERE user_id=%s", + (user["id"],)) or {"enabled": False} + except Exception: + twofa = {"enabled": False} + return {**enriched, + "tier": _tier_for(enriched.get("user_type") or ""), + "must_change_pwd": bool(enriched.get("must_change_pwd")), + "two_factor_enabled": bool(twofa.get("enabled")), + **tenant, "roles": roles} + +class UpdateMeReq(BaseModel): + ime: Optional[str] = None + prezime: Optional[str] = None + full_name: Optional[str] = None + telefon: Optional[str] = None + phone: Optional[str] = None + preferred_language: Optional[str] = None + oib: Optional[str] = None + +@router.put("/me") +def update_me(req: UpdateMeReq, request: Request, user = Depends(require_user)): + fields = [] + vals: List[Any] = [] + for k in ("ime","prezime","full_name","telefon","phone","preferred_language","oib"): + v = getattr(req, k) + if v is not None: + fields.append(f"{k}=%s") + vals.append(v.strip() if isinstance(v, str) else v) + if not fields: + raise HTTPException(400, "Nema polja za ažuriranje") + vals.append(user["id"]) + db_exec(f"UPDATE pgz_sport.users SET {', '.join(fields)}, updated_at=now() WHERE id=%s", tuple(vals)) + ip, ua = _client(request) + audit(user["id"], "profile.update", meta={"fields": [f.split("=")[0] for f in fields]}, ip=ip, ua=ua) + return me(user) + +# ─────────────────────────── AVATAR UPLOAD ─────────────────────────── +import shutil, pathlib +from fastapi import UploadFile, File + +UPLOAD_ROOT = pathlib.Path("/opt/pgz-sport/uploads") +AVATAR_DIR = UPLOAD_ROOT / "avatars" +AVATAR_DIR.mkdir(parents=True, exist_ok=True) +ALLOWED_AVATAR_MIME = {"image/jpeg","image/jpg","image/png","image/webp"} +ALLOWED_AVATAR_EXT = {".jpg",".jpeg",".png",".webp"} +MAX_AVATAR_BYTES = 5 * 1024 * 1024 # 5 MB + +@router.post("/me/avatar") +async def upload_my_avatar(request: Request, file: UploadFile = File(...), user = Depends(require_user)): + ct = (file.content_type or "").lower() + if ct not in ALLOWED_AVATAR_MIME: + raise HTTPException(400, f"Nedozvoljen tip slike: {ct} — jpeg/png/webp") + ext = pathlib.Path(file.filename or "").suffix.lower() + if ext not in ALLOWED_AVATAR_EXT: + ext = {"image/jpeg":".jpg","image/jpg":".jpg","image/png":".png","image/webp":".webp"}.get(ct, ".jpg") + data = await file.read() + if len(data) > MAX_AVATAR_BYTES: + raise HTTPException(413, f"Slika prevelika ({len(data)} B > {MAX_AVATAR_BYTES})") + if len(data) < 32: + raise HTTPException(400, "Slika prazna ili neispravna") + safe_name = f"{int(user['id'])}_{int(time.time())}{ext}" + target = AVATAR_DIR / safe_name + with open(target, "wb") as f: + f.write(data) + try: os.chmod(target, 0o644) + except Exception: pass + avatar_url = f"/uploads/avatars/{safe_name}" + db_exec("UPDATE pgz_sport.users SET avatar_url=%s, updated_at=now() WHERE id=%s", + (avatar_url, user["id"])) + ip, ua = _client(request) + audit(user["id"], "profile.avatar_upload", + meta={"file": safe_name, "size": len(data), "mime": ct}, ip=ip, ua=ua) + return {"status":"ok", "avatar_url": avatar_url, "size": len(data), "mime": ct} + +@router.delete("/me/avatar") +def delete_my_avatar(request: Request, user = Depends(require_user)): + cur = db_one("SELECT avatar_url FROM pgz_sport.users WHERE id=%s", (user["id"],)) + if cur and cur.get("avatar_url"): + p = AVATAR_DIR / pathlib.Path(cur["avatar_url"]).name + try: + if p.exists() and p.is_relative_to(AVATAR_DIR): p.unlink() + except Exception: pass + db_exec("UPDATE pgz_sport.users SET avatar_url=NULL, updated_at=now() WHERE id=%s", (user["id"],)) + ip, ua = _client(request) + audit(user["id"], "profile.avatar_delete", ip=ip, ua=ua) + return {"status": "ok"} + +@router.post("/password/change") +def change_password(req: ChangePwdReq, request: Request, user = Depends(require_user)): + if len(req.new_password) < 8: + raise HTTPException(400, "Lozinka mora imati barem 8 znakova") + cur = db_one("SELECT password_hash, must_change_pwd FROM pgz_sport.users WHERE id=%s", + (user["id"],)) + if not cur: raise HTTPException(404, "User not found") + if not cur.get("must_change_pwd"): + if not req.old_password: + raise HTTPException(400, "old_password obavezan") + if not verify_password(req.old_password, cur.get("password_hash")): + raise HTTPException(401, "Stara lozinka netočna") + db_exec("""UPDATE pgz_sport.users + SET password_hash=%s, must_change_pwd=false, updated_at=now() + WHERE id=%s""", (hash_password(req.new_password), user["id"])) + ip, ua = _client(request) + audit(user["id"], "password.change", ip=ip, ua=ua) + return {"status": "ok"} + +@router.post("/password/reset") +def password_reset(req: ResetPwdReq, request: Request): + """Issue a temporary password (admin-equivalent self-reset; logged).""" + email = (req.email or "").lower().strip() + u = db_one("SELECT id, email, aktivan FROM pgz_sport.users WHERE LOWER(email)=%s", + (email,)) + ip, ua = _client(request) + audit(u["id"] if u else None, "password.reset.request", + meta={"email": email, "found": bool(u)}, ip=ip, ua=ua) + # Generic response — do not leak which emails exist + return {"status": "ok", + "message": "Ako račun postoji, administrator će vam poslati instrukcije."} + +# ─────────────────────────── 2FA — real TOTP (RFC 6238) ─────────────────────────── +try: + import pyotp as _pyotp + HAS_PYOTP = True +except Exception: + HAS_PYOTP = False + +def _ensure_2fa_table(): + db_exec("""CREATE TABLE IF NOT EXISTS pgz_sport.user_2fa ( + user_id INTEGER PRIMARY KEY REFERENCES pgz_sport.users(id) ON DELETE CASCADE, + secret TEXT NOT NULL, + enabled BOOLEAN DEFAULT false, + verified_at TIMESTAMPTZ, + recovery_codes TEXT[], + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() + )""") +_ensure_2fa_table() + +def _build_qr_png(otpauth_url: str) -> str: + """Return a data: URL containing a base64 PNG of the QR code.""" + try: + import qrcode, io, base64 + img = qrcode.make(otpauth_url) + buf = io.BytesIO() + img.save(buf, format="PNG") + return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() + except Exception as e: + return "" + +def _gen_recovery_codes(n: int = 8) -> List[str]: + return [secrets.token_hex(4).upper() for _ in range(n)] + +@router.post("/2fa/setup") +def twofa_setup(user = Depends(require_user)): + """Generate a TOTP secret, store unverified, and return otpauth URL + QR + recovery codes. + The 2FA stays disabled until /2fa/verify confirms a valid TOTP code.""" + if not HAS_PYOTP: + raise HTTPException(503, "pyotp not installed on server") + secret = _pyotp.random_base32() # 32-char base32, RFC 4648 — what authenticator apps expect + recovery = _gen_recovery_codes() + db_exec("""INSERT INTO pgz_sport.user_2fa (user_id, secret, enabled, recovery_codes, updated_at) + VALUES (%s,%s,false,%s,now()) + ON CONFLICT (user_id) DO UPDATE SET + secret=EXCLUDED.secret, enabled=false, + recovery_codes=EXCLUDED.recovery_codes, updated_at=now()""", + (user["id"], secret, recovery)) + issuer = "PGŽ Sport" + otpauth = _pyotp.TOTP(secret).provisioning_uri(name=user["email"], issuer_name=issuer) + return { + "secret": secret, + "otpauth_url": otpauth, + "qr_png": _build_qr_png(otpauth), + "issuer": issuer, + "account": user["email"], + "recovery_codes": recovery, + "enabled": False, + "instructions": "Skenirajte QR u Google Authenticator / Authy / 1Password, zatim potvrdite kod kroz POST /api/auth/2fa/verify", + } + +class TwoFAVerifyReq(BaseModel): + code: str + +@router.post("/2fa/verify") +def twofa_verify(req: TwoFAVerifyReq, request: Request, user = Depends(require_user)): + """Verify TOTP code; on success, mark 2FA enabled.""" + if not HAS_PYOTP: + raise HTTPException(503, "pyotp not installed on server") + row = db_one("SELECT secret, enabled FROM pgz_sport.user_2fa WHERE user_id=%s", + (user["id"],)) + if not row: + raise HTTPException(400, "2FA nije postavljen — pozovite /2fa/setup prvo") + code = (req.code or "").strip().replace(" ", "") + if not code or not code.isdigit() or len(code) not in (6, 8): + raise HTTPException(400, "Neispravan format koda (6-8 znamenki)") + totp = _pyotp.TOTP(row["secret"]) + # valid_window=1 → tolerate ±30s drift + if not totp.verify(code, valid_window=1): + ip, ua = _client(request) + audit(user["id"], "2fa.verify.fail", ip=ip, ua=ua) + raise HTTPException(401, "Neispravan TOTP kod") + db_exec("""UPDATE pgz_sport.user_2fa + SET enabled=true, verified_at=now(), updated_at=now() + WHERE user_id=%s""", (user["id"],)) + ip, ua = _client(request) + audit(user["id"], "2fa.verify.ok", ip=ip, ua=ua) + return {"status": "ok", "enabled": True} + +@router.post("/2fa/disable") +def twofa_disable(req: TwoFAVerifyReq, request: Request, user = Depends(require_user)): + """Disable 2FA — must verify a current TOTP code (or recovery code).""" + if not HAS_PYOTP: + raise HTTPException(503, "pyotp not installed on server") + row = db_one("SELECT secret, recovery_codes FROM pgz_sport.user_2fa WHERE user_id=%s", + (user["id"],)) + if not row: + raise HTTPException(404, "2FA nije postavljen") + code = (req.code or "").strip().replace(" ", "").upper() + valid = False + if code.isdigit() and len(code) in (6, 8): + valid = _pyotp.TOTP(row["secret"]).verify(code, valid_window=1) + elif row.get("recovery_codes") and code in (row["recovery_codes"] or []): + valid = True + if not valid: + raise HTTPException(401, "Neispravan kod") + db_exec("DELETE FROM pgz_sport.user_2fa WHERE user_id=%s", (user["id"],)) + ip, ua = _client(request) + audit(user["id"], "2fa.disable", ip=ip, ua=ua) + return {"status": "ok", "enabled": False} + +@router.get("/2fa/status") +def twofa_status(user = Depends(require_user)): + row = db_one("SELECT enabled, verified_at, created_at FROM pgz_sport.user_2fa WHERE user_id=%s", + (user["id"],)) + return {"enabled": bool(row and row.get("enabled")), + "configured": bool(row), + "verified_at": row.get("verified_at") if row else None} diff --git a/_backups/crm.html.cc3_pre_unified_sidebar.1777935999 b/_backups/crm.html.cc3_pre_unified_sidebar.1777935999 new file mode 100644 index 0000000..da6bf61 --- /dev/null +++ b/_backups/crm.html.cc3_pre_unified_sidebar.1777935999 @@ -0,0 +1,1362 @@ + + + + + +PGŽ Sport — CRM (Članarine • Liječnički • Obrasci) + + + + +
+ +
·
+
CRM — Članarine • Liječnički • Obrasci
+
+ Round 3 / CC5 + ← portal + app → +
+
+ +
+
👤 Članovi
+
€ Članarine
+
⚕ Liječnički pregledi
+
📝 Obrasci
+
+ ROLA: + +
+
+ +
+
+ + + +
+ + + +
+ + + + + diff --git a/_backups/erp.html.cc3_pre_unified_sidebar.1777935999 b/_backups/erp.html.cc3_pre_unified_sidebar.1777935999 new file mode 100644 index 0000000..2854151 --- /dev/null +++ b/_backups/erp.html.cc3_pre_unified_sidebar.1777935999 @@ -0,0 +1,848 @@ + + + + + +PGŽ Sport · ERP — OCR + Putni nalozi + + + + + + +
+ +
+
+

Skeniraj račun (OCR)

+ Tesseract + Ri.NET AI Engine · /api/erp +
+ + +
+
+

📷 Drag-and-drop OCR (PDF / JPG / PNG)

+
+
+
Povuci datoteku ovdje ili klikni za odabir
+
Tesseract OCR (hrv+eng) + Ri.NET AI Engine LLM ekstrakcija polja
+ +
+
+ + +
+
+ + +
+
+

Računi (svi klubovi)

+
#VrstaBrojDobavljačOIBKlubBruttoStatusDatum
+
+
+ + +
+
+

🚗 Novi putni nalog (HR pravilnik 2025)

+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ Unesi datume za live obračun dnevnica… +
+
+ + +
+

+ HR pravilnik 2025: domaće 26.54 € (>8h), 13.27 € (5–8h), 0 € (<5h). Inozemne dnevnice po zemlji + (Italija/Austrija 35 €, Slovenija/Mađarska/BiH/Srbija 30 €). Kilometrina vlastitim automobilom 0.50 €/km. +

+
+
+ + +
+
+

Lista putnih naloga

+
#KlubDestinacijaPolazakPovratakDnevniceTransportTotalStatus
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/_backups/kpi.html.cc3_pre_unified_sidebar.1777935999 b/_backups/kpi.html.cc3_pre_unified_sidebar.1777935999 new file mode 100644 index 0000000..2861b47 --- /dev/null +++ b/_backups/kpi.html.cc3_pre_unified_sidebar.1777935999 @@ -0,0 +1,99 @@ + + + + +RINET KPI Dashboard + + + +

RINET KPI Dashboard

+
Loading...
+ + + + diff --git a/_backups/login.html.cc3_pre_unified_sidebar.1777935999 b/_backups/login.html.cc3_pre_unified_sidebar.1777935999 new file mode 100644 index 0000000..8cfde49 --- /dev/null +++ b/_backups/login.html.cc3_pre_unified_sidebar.1777935999 @@ -0,0 +1,562 @@ + + + + + +PGŽ Sport · Prijava + + + + + + + +
+
+
P
+
+

PGŽ Sport

+
ERP/CRM Platforma
+
+
+
+

Operativna platforma za sport u Primorsko-goranskoj županiji.

+

Jedinstvena baza klubova, saveza i sportaša. Računovodstvo, članarine, liječnički pregledi, sufinanciranja — sve na jednom mjestu.

+
+
Multi-tenant arhitektura — PGŽ, savezi, klubovi sa svojim view-om
+
OCR za račune, automatska ekstrakcija polja, putni nalozi
+
Članarine s HUB-3 uplatnicama i blockchain audit log
+
GDPR-compliant (Art. 17, 20) · 2FA · audit svih akcija
+
+
+ +
+ +
+
+

Prijava

+
Unesite svoje podatke za pristup platformi.
+ +
+ +
+
+ + +
+
+ + +
+ +
+ + Zaboravljena lozinka? +
+ +
+ +
Demo računi
+
+
+ PGŽ admin · damir@pgz.hr / PGZ2026! +
+
+ Savez admin · pero@atletika.pgz.hr +
+
+ Klub admin · ana@akkvarner.hr +
+
+ + +
+
+ + + + + + + diff --git a/_backups/r3_cc4/erp.html.pre_R5.1777937137 b/_backups/r3_cc4/erp.html.pre_R5.1777937137 new file mode 100644 index 0000000..8a545a3 --- /dev/null +++ b/_backups/r3_cc4/erp.html.pre_R5.1777937137 @@ -0,0 +1,858 @@ + + + + + +PGŽ Sport · ERP — OCR + Putni nalozi + + + + + + +
+ +
+
+

Skeniraj račun (OCR)

+ Tesseract + Ri.NET AI Engine · /api/erp +
+ + +
+
+

📷 Drag-and-drop OCR (PDF / JPG / PNG)

+
+
+
Povuci datoteku ovdje ili klikni za odabir
+
Tesseract OCR (hrv+eng) + Ri.NET AI Engine LLM ekstrakcija polja
+ +
+
+ + +
+
+ + +
+
+

Računi (svi klubovi)

+
#VrstaBrojDobavljačOIBKlubBruttoStatusDatum
+
+
+ + +
+
+

🚗 Novi putni nalog (HR pravilnik 2025)

+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ Unesi datume za live obračun dnevnica… +
+
+ + +
+

+ HR pravilnik 2025: domaće 26.54 € (>8h), 13.27 € (5–8h), 0 € (<5h). Inozemne dnevnice po zemlji + (Italija/Austrija 35 €, Slovenija/Mađarska/BiH/Srbija 30 €). Kilometrina vlastitim automobilom 0.50 €/km. +

+
+
+ + +
+
+

Lista putnih naloga

+
#KlubDestinacijaPolazakPovratakDnevniceTransportTotalStatus
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/_backups/r3_cc4/ocr.py.pre_R5.1777937137 b/_backups/r3_cc4/ocr.py.pre_R5.1777937137 new file mode 100644 index 0000000..c9aae4c --- /dev/null +++ b/_backups/r3_cc4/ocr.py.pre_R5.1777937137 @@ -0,0 +1,835 @@ +#!/usr/bin/env python3 +# erp/ocr.py — PGŽ Sport ERP OCR router (M5) +# Author: Damir Radulić / dradulic@outlook.com +# Date: 2026-05-04 +# Description: /api/erp/ocr/upload + /parse — Tesseract OCR + DeepSeek V3 LLM extraction +# Persists into pgz_sport.invoice_uploads, then offers structured invoice parse. + +from __future__ import annotations + +import os +import re +import json +import hashlib +import subprocess +import tempfile +import traceback +from datetime import datetime, date +from pathlib import Path +from typing import Optional, List, Any + +import psycopg2 +import psycopg2.extras +import requests +from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Header, Query, Body +from fastapi.responses import JSONResponse, FileResponse + +try: + from erp.permissions import ( + can_view_invoice, can_edit_invoice, can_pay_invoice, can_comment_invoice, + invoice_actions, audit_invoice, fetch_audit, is_pgz_admin, + ) +except Exception: + # Fallback (always-allow) for unauth dev + def can_view_invoice(u, i): return True + def can_edit_invoice(u, i): return True + def can_pay_invoice(u, i): return True + def can_comment_invoice(u, i): return True + def invoice_actions(u, i): return {"view": True, "edit": True, "pay": True, "comment": True, "delete": False} + def audit_invoice(u, iid, op, field=None, old=None, new=None): pass + def fetch_audit(t, r, limit=50): return [] + def is_pgz_admin(u): return False + +try: + from auth.auth_v2 import get_current_user as _auth_user +except Exception: + _auth_user = None + +router = APIRouter(prefix="/api/erp", tags=["erp-ocr"]) + +# === Config === +DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet", + password="R1net2026!SecureDB#v7") +UPLOAD_DIR = Path("/opt/pgz-sport/_data/uploads/invoices") +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + +DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY", "sk-33d29054d1ab4377b7d1a84bc0a423c7") +DEEPSEEK_URL = "https://api.deepseek.com/v1/chat/completions" +DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL", "deepseek-chat") + +ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"} +MAX_BYTES = 12 * 1024 * 1024 # 12 MB + +ADMIN_TOKEN = "admin-pgz-2026" + + +def _db(): + c = psycopg2.connect(**DB) + c.autocommit = True + return c + + +def _is_admin(authorization: Optional[str]) -> bool: + if not authorization: + return False + t = authorization.replace("Bearer ", "").strip() + return t == ADMIN_TOKEN + + +def _resolve_user(authorization: Optional[str]) -> Optional[dict]: + """Resolve current user via auth_v2 JWT, fallback to admin token (returns synthetic pgz_admin).""" + if _auth_user: + try: + u = _auth_user(authorization) + if u: return u + except Exception: + pass + if _is_admin(authorization): + return {"id": 0, "email": "admin@token", "user_type": "pgz_admin", + "klub_id": None, "savez_id": None, "_synthetic": True} + return None + + +def _safe_filename(orig: str) -> str: + base = re.sub(r"[^A-Za-z0-9._-]+", "_", (orig or "upload").strip())[:120] + if not base: + base = "upload" + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + return f"{ts}_{base}" + + +def _extract_text(path: Path) -> tuple[str, str]: + """Return (text, method). Tries pdftotext first, falls back to tesseract.""" + suf = path.suffix.lower() + if suf == ".pdf": + try: + r = subprocess.run( + ["pdftotext", "-layout", "-q", str(path), "-"], + capture_output=True, timeout=45, + ) + txt = r.stdout.decode("utf-8", "ignore") + if len(txt.strip()) > 80: + return txt, "pdftotext" + except Exception: + pass + # Rasterize + tesseract + try: + with tempfile.TemporaryDirectory(prefix="ocr_") as td: + subprocess.run( + ["pdftoppm", "-r", "200", str(path), f"{td}/page"], + timeout=120, check=True, + ) + chunks = [] + for img in sorted(Path(td).glob("page-*.ppm"))[:5]: + r = subprocess.run( + ["tesseract", str(img), "-", "-l", "hrv+eng", "--psm", "6"], + capture_output=True, timeout=90, + ) + chunks.append(r.stdout.decode("utf-8", "ignore")) + return "\n".join(chunks), "tesseract" + except Exception as e: + return "", f"pdf_err:{e}" + if suf in {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"}: + try: + r = subprocess.run( + ["tesseract", str(path), "-", "-l", "hrv+eng", "--psm", "6"], + capture_output=True, timeout=120, + ) + return r.stdout.decode("utf-8", "ignore"), "tesseract" + except Exception as e: + return "", f"img_err:{e}" + return "", f"unsupported:{suf}" + + +# === HR invoice regex helpers === +_OIB = re.compile(r"\b(\d{11})\b") +_IBAN = re.compile(r"\b(HR\d{19})\b") +_DATE_DOT = re.compile(r"\b(\d{1,2})[.\s\-/]+(\d{1,2})[.\s\-/]+(20\d{2})\b") +_DATE_ISO = re.compile(r"\b(20\d{2})[\-/](\d{1,2})[\-/](\d{1,2})\b") +_AMOUNT_TOTAL = re.compile( + r"(?i)(?:UKUPNO|TOTAL|SVEUKUPNO|ZA NAPLATU|ZA PLATITI|ZA UPLATU|IZNOS\s+UKUPNO)[\s:€]*([\d.\s]{1,12}[,.]\d{2})" +) +_AMOUNT_VAT = re.compile(r"(?i)(?:PDV|VAT)[\s:%]*?([\d.\s]{1,8}[,.]\d{2})") +_INVOICE_NO = re.compile(r"(?i)(?:ra[čc]un|invoice|broj|fakture|br\.)\s*[:#]?\s*([A-Z0-9\-/.]{3,30})") + + +def _parse_amount(s: str) -> Optional[float]: + if not s: + return None + s = s.replace(" ", "").replace("\xa0", "") + # Croatian style "1.234,56" → 1234.56 + if "," in s and "." in s: + s = s.replace(".", "").replace(",", ".") + elif "," in s: + s = s.replace(",", ".") + try: + return float(s) + except Exception: + return None + + +def regex_extract(text: str) -> dict: + out: dict[str, Any] = {"raw_chars": len(text or "")} + if not text: + return out + oibs = list(dict.fromkeys(_OIB.findall(text))) + if oibs: + out["oibs_found"] = oibs + out["vendor_oib"] = oibs[0] + if len(oibs) > 1: + out["customer_oib"] = oibs[1] + + m = _IBAN.search(text.replace(" ", "")) + if m: + out["iban"] = m.group(1) + + m = _INVOICE_NO.search(text) + if m: + out["invoice_no"] = m.group(1).strip().rstrip(".,;") + + for rx, order in [(_DATE_DOT, "dmy"), (_DATE_ISO, "ymd")]: + m = rx.search(text) + if m: + g = m.groups() + try: + if order == "dmy": + out["invoice_date"] = f"{g[2]}-{int(g[1]):02d}-{int(g[0]):02d}" + else: + out["invoice_date"] = f"{g[0]}-{int(g[1]):02d}-{int(g[2]):02d}" + # validate + date.fromisoformat(out["invoice_date"]) + break + except Exception: + out.pop("invoice_date", None) + + totals = [_parse_amount(x) for x in _AMOUNT_TOTAL.findall(text)] + totals = [t for t in totals if t and t > 0.01] + if totals: + out["amount_gross"] = max(totals) + out["amounts_found"] = totals[:6] + + vats = [_parse_amount(x) for x in _AMOUNT_VAT.findall(text)] + vats = [v for v in vats if v and v > 0.01] + if vats: + # smallest plausible PDV (less than gross) + if "amount_gross" in out: + cand = [v for v in vats if v < out["amount_gross"]] + if cand: + out["amount_vat"] = max(cand) + else: + out["amount_vat"] = max(vats) + + if "amount_gross" in out and "amount_vat" in out: + out["amount_net"] = round(out["amount_gross"] - out["amount_vat"], 2) + + # Vendor name guess: first non-numeric, non-OIB line in header + for line in text.split("\n")[:12]: + ln = line.strip() + if 4 < len(ln) < 80 and not _OIB.search(ln) and not re.match(r"^[\d\s.,\-/€:]+$", ln): + out["vendor_name"] = ln + break + + # Crude vendor guess for known HR sellers + upper = text.upper() + for keyword, label in [ + ("INA d.d.", "INA"), ("INA-MAZIVA", "INA"), ("TIFON", "TIFON"), + ("PETROL", "PETROL"), ("HAC", "HAC"), ("BINA-ISTRA", "BINA-ISTRA"), + ("HRVATSKE AUTOCESTE", "HAC"), + ]: + if keyword in upper: + out.setdefault("vendor_brand", label) + break + + return out + + +# === DeepSeek V3 LLM extraction === +SYSTEM_PROMPT = ( + "Ti si stručnjak za hrvatske račune (R-1, fiskalne, HUB-3). " + "Korisnik daje tekst računa izvučen OCR-om. Vrati ISKLJUČIVO valjani JSON, bez markdowna i komentara. " + "Ako neko polje nije sigurno - vrati null. Iznosi su brojevi (decimal s točkom). Datum je 'YYYY-MM-DD'." +) + +LLM_SCHEMA_HINT = """{ + "izdavatelj_naziv": str|null, + "izdavatelj_oib": str|null, + "izdavatelj_adresa": str|null, + "kupac_naziv": str|null, + "kupac_oib": str|null, + "datum": "YYYY-MM-DD"|null, + "broj_racuna": str|null, + "iznos_neto": float|null, + "iznos_pdv": float|null, + "iznos_brutto": float|null, + "stopa_pdv": float|null, + "valuta": "EUR"|"HRK"|null, + "nacin_placanja": str|null, + "IBAN": str|null, + "opis_svrhe": str|null, + "vrsta_troska": "gorivo"|"cestarina"|"hotel"|"restoran"|"oprema"|"ostalo"|null, + "stavke": [ + {"opis": str, "kolicina": float, "jedinica": str, "cijena": float, "ukupno": float} + ] +}""" + + +def deepseek_extract(text: str, hint: dict | None = None) -> dict: + """Call DeepSeek chat completions for structured JSON extraction.""" + if not DEEPSEEK_API_KEY: + return {"error": "no_api_key"} + if not text or len(text.strip()) < 20: + return {"error": "empty_text"} + + user_msg = ( + f"Iz teksta računa ispod izvuci polja po shemi:\n{LLM_SCHEMA_HINT}\n\n" + f"REGEX hint (može biti nepotpun ili netočan): {json.dumps(hint or {}, ensure_ascii=False)}\n\n" + f"--- TEKST RAČUNA ---\n{text[:8000]}\n--- KRAJ ---" + ) + payload = { + "model": DEEPSEEK_MODEL, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_msg}, + ], + "response_format": {"type": "json_object"}, + "temperature": 0.0, + "max_tokens": 1200, + } + headers = { + "Authorization": f"Bearer {DEEPSEEK_API_KEY}", + "Content-Type": "application/json", + } + try: + r = requests.post(DEEPSEEK_URL, headers=headers, json=payload, timeout=60) + except Exception as e: + return {"error": f"net:{e}"} + if r.status_code != 200: + return {"error": f"http_{r.status_code}", "detail": r.text[:300]} + try: + body = r.json() + content = body["choices"][0]["message"]["content"] + return json.loads(content) + except Exception as e: + return {"error": f"parse:{e}", "raw": (r.text[:500] if r else "")} + + +# === Endpoints === + +@router.post("/ocr/upload") +async def ocr_upload( + file: UploadFile = File(...), + klub_id: Optional[int] = Form(None), + tenant_id: int = Form(1), + invoice_kind: str = Form("ostalo"), + authorization: Optional[str] = Header(None), +): + """Upload an invoice file (PDF/image) → store on disk + insert pgz_sport.invoice_uploads.""" + suffix = "." + (file.filename or "").rsplit(".", 1)[-1].lower() + if suffix not in ALLOWED_EXT: + raise HTTPException(400, f"Tip datoteke nije podržan: {suffix}. Dozvoljeno: {sorted(ALLOWED_EXT)}") + + raw = await file.read() + if not raw: + raise HTTPException(400, "Prazna datoteka") + if len(raw) > MAX_BYTES: + raise HTTPException(400, f"Datoteka prevelika ({len(raw)} > {MAX_BYTES} bajtova)") + + sha256 = hashlib.sha256(raw).hexdigest() + fname = _safe_filename(file.filename or "upload") + if not fname.endswith(suffix): + fname += suffix + path = UPLOAD_DIR / fname + path.write_bytes(raw) + + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """ + INSERT INTO pgz_sport.invoice_uploads + (klub_id, file_name, file_path, file_size, mime, sha256, ocr_status, meta) + VALUES (%s, %s, %s, %s, %s, %s, 'pending', %s) + RETURNING id, klub_id, file_name, ocr_status, uploaded_at + """, + (klub_id, file.filename, str(path), len(raw), file.content_type or "", + sha256, json.dumps({"tenant_id": tenant_id, "invoice_kind": invoice_kind})), + ) + row = cur.fetchone() + return {"ok": True, "upload_id": row["id"], "file_name": row["file_name"], + "size": len(raw), "sha256": sha256, "status": row["ocr_status"]} + + +@router.post("/ocr/parse") +async def ocr_parse( + upload_id: Optional[int] = Form(None), + file: Optional[UploadFile] = File(None), + use_llm: bool = Form(True), + authorization: Optional[str] = Header(None), +): + """Run OCR + (optional) DeepSeek LLM extraction. + Either pass upload_id (parse a previously uploaded file) or send file directly (one-shot).""" + tmp_to_clean: Optional[Path] = None + upload_row = None + try: + if upload_id: + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT * FROM pgz_sport.invoice_uploads WHERE id=%s", (upload_id,)) + upload_row = cur.fetchone() + if not upload_row: + raise HTTPException(404, f"Upload id={upload_id} ne postoji") + target = Path(upload_row["file_path"]) + if not target.exists(): + raise HTTPException(404, f"Datoteka ne postoji na disku: {target}") + elif file: + suffix = "." + (file.filename or "").rsplit(".", 1)[-1].lower() + if suffix not in ALLOWED_EXT: + raise HTTPException(400, f"Tip datoteke nije podržan: {suffix}") + raw = await file.read() + if not raw: + raise HTTPException(400, "Prazna datoteka") + tmp = tempfile.NamedTemporaryFile(prefix="parse_", suffix=suffix, delete=False) + tmp.write(raw); tmp.close() + target = Path(tmp.name) + tmp_to_clean = target + else: + raise HTTPException(400, "Treba poslati upload_id ILI file") + + text, method = _extract_text(target) + if len(text.strip()) < 20: + return {"ok": False, "ocr_method": method, "raw_chars": len(text), + "error": "OCR nije uspio izvući dovoljno teksta"} + + regex_fields = regex_extract(text) + regex_fields["ocr_method"] = method + + llm_fields: dict = {} + if use_llm: + llm_fields = deepseek_extract(text, hint=regex_fields) + + # Merge: LLM overrides regex when valid + merged = dict(regex_fields) + for k in ("izdavatelj_naziv", "izdavatelj_oib", "kupac_oib", "datum", + "broj_racuna", "iznos_neto", "iznos_pdv", "iznos_brutto", + "stopa_pdv", "valuta", "IBAN", "opis_svrhe", "vrsta_troska", + "izdavatelj_adresa", "nacin_placanja"): + v = llm_fields.get(k) if isinstance(llm_fields, dict) else None + if v not in (None, "", "null"): + merged[k] = v + + # Normalize aliases for UI / DB + if "izdavatelj_naziv" in merged: merged.setdefault("vendor_name", merged["izdavatelj_naziv"]) + if "izdavatelj_oib" in merged: merged.setdefault("vendor_oib", merged["izdavatelj_oib"]) + if "izdavatelj_adresa" in merged: merged.setdefault("vendor_address", merged["izdavatelj_adresa"]) + if "kupac_oib" in merged: merged.setdefault("customer_oib", merged["kupac_oib"]) + if "datum" in merged: merged.setdefault("invoice_date", merged["datum"]) + if "broj_racuna" in merged: merged.setdefault("invoice_no", merged["broj_racuna"]) + if "iznos_brutto" in merged: merged.setdefault("amount_gross", merged["iznos_brutto"]) + if "iznos_neto" in merged: merged.setdefault("amount_net", merged["iznos_neto"]) + if "iznos_pdv" in merged: merged.setdefault("amount_vat", merged["iznos_pdv"]) + if "stopa_pdv" in merged: merged.setdefault("vat_rate", merged["stopa_pdv"]) + if "valuta" in merged: merged.setdefault("currency", merged["valuta"]) + if "IBAN" in merged: merged.setdefault("iban", merged["IBAN"]) + if "opis_svrhe" in merged: merged.setdefault("description", merged["opis_svrhe"]) + if "vrsta_troska" in merged: merged.setdefault("category", merged["vrsta_troska"]) + + # Persist back to invoice_uploads when we have upload_row + if upload_row: + try: + with _db() as c: + c.cursor().execute( + """UPDATE pgz_sport.invoice_uploads + SET ocr_status='done', processed_at=NOW(), + ocr_engine=%s, ocr_text=%s, + ai_invoice_no=%s, ai_invoice_date=%s, + ai_vendor_name=%s, ai_vendor_oib=%s, + ai_amount_gross=%s, ai_currency=%s, ai_iban=%s, + ai_extracted=%s, ai_engine=%s + WHERE id=%s""", + ( + method, text[:50000], + merged.get("invoice_no"), + merged.get("invoice_date") if isinstance(merged.get("invoice_date"), str) else None, + merged.get("vendor_name"), + merged.get("vendor_oib"), + merged.get("amount_gross"), + merged.get("currency", "EUR"), + merged.get("iban"), + json.dumps({"regex": regex_fields, "llm": llm_fields, "merged": merged}, + ensure_ascii=False, default=str), + ("deepseek-v3" if use_llm and "error" not in (llm_fields or {}) else "regex"), + upload_row["id"], + ), + ) + except Exception as e: + merged["_persist_warn"] = str(e)[:200] + + return { + "ok": True, + "upload_id": (upload_row["id"] if upload_row else None), + "ocr_method": method, + "raw_chars": len(text), + "regex": regex_fields, + "llm": llm_fields, + "extracted": merged, + "raw_text_preview": text[:1500], + } + finally: + if tmp_to_clean and tmp_to_clean.exists(): + try: + tmp_to_clean.unlink() + except Exception: + pass + + +# === Invoices CRUD (M5) === + +@router.get("/invoices") +def invoices_list( + tenant_id: Optional[int] = Query(None), + klub_id: Optional[int] = Query(None), + status: Optional[str] = Query(None), + kind: Optional[str] = Query(None), + limit: int = Query(100, le=500), + offset: int = Query(0), +): + sql = """SELECT i.id, i.klub_id, k.naziv AS klub_naziv, + i.invoice_kind, i.invoice_no, i.internal_no, + i.vendor_name, i.vendor_oib, i.customer_name, i.customer_oib, + i.invoice_date, i.due_date, i.paid_date, i.currency, + i.amount_net, i.amount_vat, i.amount_gross, i.vat_rate, + i.payment_status, i.payment_method, i.iban_to, + i.description, i.category, i.tenant_id, + i.created_at, i.approved_at + FROM pgz_sport.invoices i + LEFT JOIN pgz_sport.klubovi k ON k.id = i.klub_id + WHERE 1=1""" + args: list = [] + if tenant_id is not None: + sql += " AND i.tenant_id=%s"; args.append(tenant_id) + if klub_id is not None: + sql += " AND i.klub_id=%s"; args.append(klub_id) + if status: + sql += " AND i.payment_status=%s"; args.append(status) + if kind: + sql += " AND i.invoice_kind=%s"; args.append(kind) + sql += " ORDER BY i.invoice_date DESC NULLS LAST, i.id DESC LIMIT %s OFFSET %s" + args += [limit, offset] + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(sql, args) + rows = cur.fetchall() + return {"ok": True, "rows": rows, "count": len(rows)} + + +@router.get("/invoices/{invoice_id}") +def invoices_get(invoice_id: int, authorization: Optional[str] = Header(None)): + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """SELECT i.*, k.naziv AS klub_naziv, k.savez_id + FROM pgz_sport.invoices i + LEFT JOIN pgz_sport.klubovi k ON k.id = i.klub_id + WHERE i.id=%s""", (invoice_id,)) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Račun ne postoji") + if user and not can_view_invoice(user, row): + raise HTTPException(403, "Nemate ovlasti vidjeti ovaj račun") + cur.execute("SELECT * FROM pgz_sport.invoice_lines WHERE invoice_id=%s ORDER BY line_no, id", + (invoice_id,)) + lines = cur.fetchall() + cur.execute( + """SELECT id, file_name, file_size, mime, sha256, ocr_status, ocr_engine, + ai_extracted, uploaded_at, processed_at + FROM pgz_sport.invoice_uploads WHERE invoice_id=%s + ORDER BY uploaded_at DESC""", (invoice_id,)) + uploads = cur.fetchall() + cur.execute( + """SELECT id, payment_date, amount, currency, payment_method, iban_from, + iban_to, reference, bank_transaction_id, matched_status, created_at + FROM pgz_sport.payments WHERE invoice_id=%s ORDER BY payment_date DESC""", + (invoice_id,)) + payments = cur.fetchall() + audit = fetch_audit("pgz_sport.invoices", invoice_id, 50) + actions = invoice_actions(user, row) if user else {"view": True, "edit": False, "pay": False, "comment": False, "delete": False} + return {"ok": True, "invoice": row, "lines": lines, "uploads": uploads, + "payments": payments, "audit": audit, "actions": actions} + + +@router.get("/invoices/{invoice_id}/file") +def invoices_file(invoice_id: int, authorization: Optional[str] = Header(None)): + """Streamira originalnu datoteku skena/računa (slika ili PDF).""" + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT i.id, i.klub_id FROM pgz_sport.invoices i WHERE i.id=%s", (invoice_id,)) + inv = cur.fetchone() + if not inv: + raise HTTPException(404, "Račun ne postoji") + if user and not can_view_invoice(user, inv): + raise HTTPException(403, "Nemate ovlasti") + cur.execute( + """SELECT file_path, file_name, mime FROM pgz_sport.invoice_uploads + WHERE invoice_id=%s ORDER BY uploaded_at DESC LIMIT 1""", (invoice_id,)) + up = cur.fetchone() + if not up: + raise HTTPException(404, "Datoteka skena ne postoji za ovaj račun") + p = Path(up["file_path"]) + if not p.exists(): + raise HTTPException(404, f"Datoteka ne postoji na disku") + return FileResponse(str(p), media_type=up.get("mime") or "application/octet-stream", + filename=up.get("file_name") or p.name) + + +@router.get("/invoices/uploads/{upload_id}/file") +def upload_file(upload_id: int, authorization: Optional[str] = Header(None)): + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT * FROM pgz_sport.invoice_uploads WHERE id=%s", (upload_id,)) + up = cur.fetchone() + if not up: + raise HTTPException(404, "Upload ne postoji") + if user and not is_pgz_admin(user) and user.get("klub_id") != up.get("klub_id"): + raise HTTPException(403, "Nemate ovlasti") + p = Path(up["file_path"]) + if not p.exists(): + raise HTTPException(404, "Datoteka ne postoji") + return FileResponse(str(p), media_type=up.get("mime") or "application/octet-stream", + filename=up.get("file_name") or p.name) + + +@router.post("/invoices/{invoice_id}/comment") +def invoices_comment(invoice_id: int, body: dict = Body(...), + authorization: Optional[str] = Header(None)): + """Savez admin / klub admin / pgz admin može dodati komentar (audit log entry).""" + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT i.*, k.savez_id FROM pgz_sport.invoices i LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id WHERE i.id=%s", (invoice_id,)) + inv = cur.fetchone() + if not inv: + raise HTTPException(404, "Račun ne postoji") + if user and not can_comment_invoice(user, inv): + raise HTTPException(403, "Nemate ovlasti komentirati") + txt = (body.get("comment") or "").strip() + if not txt: + raise HTTPException(400, "Komentar je prazan") + audit_invoice(user, invoice_id, "comment", field="komentar", old=None, new=txt[:500]) + return {"ok": True, "invoice_id": invoice_id, "comment": txt} + + +@router.get("/invoices/{invoice_id}/audit") +def invoices_audit(invoice_id: int, limit: int = 100, + authorization: Optional[str] = Header(None)): + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT i.id, i.klub_id FROM pgz_sport.invoices i WHERE i.id=%s", (invoice_id,)) + inv = cur.fetchone() + if not inv: + raise HTTPException(404, "Račun ne postoji") + if user and not can_view_invoice(user, inv): + raise HTTPException(403, "Nemate ovlasti") + return {"ok": True, "audit": fetch_audit("pgz_sport.invoices", invoice_id, limit)} + + +@router.post("/invoices") +def invoices_create(body: dict = Body(...), authorization: Optional[str] = Header(None)): + """Create an invoice from parsed OCR result. + Body: {klub_id, tenant_id, invoice_kind, invoice_no, vendor_name, vendor_oib, + invoice_date, amount_gross, amount_net, amount_vat, vat_rate, currency, + iban_to, description, category, lines:[{...}], upload_id?}""" + required = ["invoice_kind", "invoice_no", "invoice_date", "amount_gross"] + for k in required: + if body.get(k) in (None, ""): + raise HTTPException(400, f"Nedostaje polje: {k}") + + klub_id = body.get("klub_id") + tenant_id = body.get("tenant_id", 1) + upload_id = body.get("upload_id") + lines = body.get("lines") or [] + + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """INSERT INTO pgz_sport.invoices + (klub_id, invoice_kind, invoice_no, internal_no, + vendor_oib, vendor_name, vendor_address, + customer_oib, customer_name, + invoice_date, due_date, currency, + amount_net, amount_vat, amount_gross, vat_rate, + payment_status, payment_method, iban_to, + description, category, account_code, tenant_id, meta) + VALUES (%s,%s,%s,%s, %s,%s,%s, %s,%s, + %s,%s,COALESCE(%s,'EUR'), + %s,%s,%s,%s, + COALESCE(%s,'unpaid'),%s,%s, + %s,%s,%s,%s,%s) + ON CONFLICT (klub_id, invoice_kind, invoice_no, vendor_oib) + DO UPDATE SET amount_gross=EXCLUDED.amount_gross, + amount_net=EXCLUDED.amount_net, + amount_vat=EXCLUDED.amount_vat, + updated_at=NOW() + RETURNING id, invoice_no, amount_gross, payment_status""", + ( + klub_id, body["invoice_kind"], body["invoice_no"], body.get("internal_no"), + body.get("vendor_oib"), body.get("vendor_name"), body.get("vendor_address"), + body.get("customer_oib"), body.get("customer_name"), + body["invoice_date"], body.get("due_date"), body.get("currency"), + body.get("amount_net"), body.get("amount_vat"), body["amount_gross"], body.get("vat_rate"), + body.get("payment_status"), body.get("payment_method"), body.get("iban_to"), + body.get("description"), body.get("category"), body.get("account_code"), + tenant_id, json.dumps(body.get("meta", {})), + ), + ) + inv = cur.fetchone() + inv_id = inv["id"] + + # Replace lines + cur.execute("DELETE FROM pgz_sport.invoice_lines WHERE invoice_id=%s", (inv_id,)) + for i, ln in enumerate(lines, start=1): + cur.execute( + """INSERT INTO pgz_sport.invoice_lines + (invoice_id, line_no, description, quantity, unit, unit_price, + vat_rate, line_net, line_vat, line_gross, account_code, cost_center, meta) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", + ( + inv_id, ln.get("line_no", i), ln.get("description") or ln.get("opis") or "", + ln.get("quantity") or ln.get("kolicina") or 1, + ln.get("unit") or ln.get("jedinica") or "kom", + ln.get("unit_price") or ln.get("cijena"), + ln.get("vat_rate", 25), + ln.get("line_net"), ln.get("line_vat"), + ln.get("line_gross") or ln.get("ukupno"), + ln.get("account_code"), ln.get("cost_center"), + json.dumps(ln.get("meta", {})), + ), + ) + + # Link upload to invoice + if upload_id: + cur.execute( + "UPDATE pgz_sport.invoice_uploads SET invoice_id=%s WHERE id=%s", + (inv_id, upload_id), + ) + + return {"ok": True, "invoice": inv} + + +@router.put("/invoices/{invoice_id}") +def invoices_update(invoice_id: int, body: dict = Body(...), authorization: Optional[str] = Header(None)): + """Update / approve invoice. Body may include any of: payment_status, paid_date, + approved (bool), notes, category, account_code, due_date.""" + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT i.*, k.savez_id FROM pgz_sport.invoices i LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id WHERE i.id=%s", (invoice_id,)) + inv = cur.fetchone() + if not inv: + raise HTTPException(404, "Račun ne postoji") + if user and not can_edit_invoice(user, inv): + raise HTTPException(403, "Nemate ovlasti uređivati ovaj račun") + + fields = [] + args: list = [] + changes = [] + for col in ("payment_status", "paid_date", "due_date", "category", + "account_code", "notes", "vat_rate", "amount_net", "amount_vat", + "amount_gross", "payment_method", "iban_to"): + if col in body: + fields.append(f"{col}=%s") + args.append(body[col]) + changes.append((col, inv.get(col), body[col])) + if body.get("approved"): + fields.append("approved_at=NOW()") + changes.append(("approved_at", inv.get("approved_at"), "now")) + if not fields: + raise HTTPException(400, "Nema polja za izmjenu") + fields.append("updated_at=NOW()") + args.append(invoice_id) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(f"UPDATE pgz_sport.invoices SET {','.join(fields)} WHERE id=%s RETURNING *", args) + row = cur.fetchone() + for f, o, n in changes: + audit_invoice(user, invoice_id, "update", field=f, old=o, new=n) + return {"ok": True, "invoice": row} + + +@router.post("/invoices/{invoice_id}/pay") +def invoices_pay(invoice_id: int, body: dict = Body(default={}), + authorization: Optional[str] = Header(None)): + """Označi račun kao plaćen + insert payment record. + Body: {iban_to, iban_from, paid_date, reference, bank_transaction_id, payment_method, amount} + """ + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT i.*, k.savez_id FROM pgz_sport.invoices i LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id WHERE i.id=%s", (invoice_id,)) + inv = cur.fetchone() + if not inv: + raise HTTPException(404, "Račun ne postoji") + if user and not can_pay_invoice(user, inv): + raise HTTPException(403, "Nemate ovlasti označiti račun kao plaćen") + if (inv.get("payment_status") or "").lower() == "paid": + raise HTTPException(409, "Račun je već označen kao plaćen") + + paid_date = body.get("paid_date") or date.today().isoformat() + payment_method = body.get("payment_method") or "transfer" + iban_from = body.get("iban_from") + iban_to = body.get("iban_to") or inv.get("iban_to") + reference = body.get("reference") + tx_id = body.get("bank_transaction_id") or body.get("tx_id") + amount = body.get("amount") or inv.get("amount_gross") + + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """UPDATE pgz_sport.invoices + SET payment_status='paid', paid_date=%s, + payment_method=COALESCE(%s,payment_method), + iban_from=COALESCE(%s,iban_from), + iban_to=COALESCE(%s,iban_to), + updated_at=NOW() + WHERE id=%s + RETURNING id, invoice_no, paid_date, amount_gross, payment_status, + iban_from, iban_to, payment_method""", + (paid_date, payment_method, iban_from, iban_to, invoice_id), + ) + row = cur.fetchone() + # Insert payment record + cur.execute( + """INSERT INTO pgz_sport.payments + (klub_id, invoice_id, payment_date, amount, currency, payment_method, + iban_from, iban_to, reference, bank_transaction_id, matched_status) + VALUES (%s,%s,%s,%s,COALESCE(%s,'EUR'),%s,%s,%s,%s,%s,'matched') + RETURNING id""", + (inv.get("klub_id"), invoice_id, paid_date, amount, + inv.get("currency"), payment_method, iban_from, iban_to, + reference, tx_id), + ) + pay = cur.fetchone() + audit_invoice(user, invoice_id, "pay", field="payment_status", + old=inv.get("payment_status"), new="paid") + return {"ok": True, "invoice": row, "payment_id": pay["id"] if pay else None} + + +@router.get("/invoices/uploads/list") +def uploads_list(klub_id: Optional[int] = None, status: Optional[str] = None, limit: int = 50): + sql = """SELECT id, klub_id, file_name, file_size, mime, ocr_status, ocr_engine, + ai_invoice_no, ai_invoice_date, ai_vendor_name, ai_vendor_oib, + ai_amount_gross, ai_currency, invoice_id, uploaded_at, processed_at + FROM pgz_sport.invoice_uploads WHERE 1=1""" + args: list = [] + if klub_id is not None: + sql += " AND klub_id=%s"; args.append(klub_id) + if status: + sql += " AND ocr_status=%s"; args.append(status) + sql += " ORDER BY uploaded_at DESC LIMIT %s"; args.append(limit) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(sql, args) + rows = cur.fetchall() + return {"ok": True, "rows": rows} diff --git a/_backups/r3_cc4/putni_nalozi.py.pre_R5.1777937137 b/_backups/r3_cc4/putni_nalozi.py.pre_R5.1777937137 new file mode 100644 index 0000000..34f0695 --- /dev/null +++ b/_backups/r3_cc4/putni_nalozi.py.pre_R5.1777937137 @@ -0,0 +1,615 @@ +#!/usr/bin/env python3 +# erp/putni_nalozi.py — PGŽ Sport ERP putni nalozi (M6) +# Author: Damir Radulić / dradulic@outlook.com +# Date: 2026-05-04 +# Description: CRUD putnih naloga + obračun dnevnica (HR pravilnik 2025). + +from __future__ import annotations + +import json +from datetime import datetime, date, timedelta +from typing import Optional, Any + +import psycopg2 +import psycopg2.extras +from fastapi import APIRouter, Body, HTTPException, Query, Header + +try: + from erp.permissions import ( + can_view_putni_nalog, can_edit_putni_nalog, can_submit_putni_nalog, + can_approve_putni_nalog, can_pay_putni_nalog, putni_nalog_actions, + audit_putni, fetch_audit, is_pgz_admin, + ) +except Exception: + def can_view_putni_nalog(u, p): return True + def can_edit_putni_nalog(u, p): return True + def can_submit_putni_nalog(u, p): return True + def can_approve_putni_nalog(u, p): return True + def can_pay_putni_nalog(u, p): return True + def putni_nalog_actions(u, p): return {"view": True, "edit": True, "submit": True, "approve": True, "reject": True, "pay": True, "delete": False} + def audit_putni(u, pid, op, field=None, old=None, new=None): pass + def fetch_audit(t, r, limit=50): return [] + def is_pgz_admin(u): return False + +try: + from auth.auth_v2 import get_current_user as _auth_user +except Exception: + _auth_user = None + +ADMIN_TOKEN = "admin-pgz-2026" + +def _resolve_user(authorization): + if _auth_user: + try: + u = _auth_user(authorization) + if u: return u + except Exception: + pass + if authorization and authorization.replace("Bearer ", "").strip() == ADMIN_TOKEN: + return {"id": 0, "email": "admin@token", "user_type": "pgz_admin", + "klub_id": None, "savez_id": None, "_synthetic": True} + return None + + +router = APIRouter(prefix="/api/erp", tags=["erp-putni-nalozi"]) + +DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet", + password="R1net2026!SecureDB#v7") + +# === HR pravilnik 2025 — dnevnice === +# Domaće: 26.54 € (puna) za put >8h, 13.27 € za 5-8h, 0 € za <5h. +# Izvor: NN — Pravilnik o porezu na dohodak, neoporezivi iznosi 2025 (200 kn ≈ 26.54 €). +DNEVNICA_DOM_FULL = 26.54 # EUR +DNEVNICA_DOM_HALF = 13.27 # EUR +KM_RATE_DEFAULT = 0.50 # EUR/km (vlastiti automobil) + +# Inozemne dnevnice (Uredba o izdacima službenih putovanja u inozemstvo). +DNEVNICE_INO = { + "Italija": 35.00, + "Italy": 35.00, + "Slovenija": 30.00, + "Slovenia": 30.00, + "Austrija": 35.00, + "Austria": 35.00, + "Mađarska": 30.00, + "Madarska": 30.00, + "Hungary": 30.00, + "Bosna i Hercegovina": 30.00, + "BiH": 30.00, + "Bosnia": 30.00, + "Srbija": 30.00, + "Serbia": 30.00, + "Crna Gora": 30.00, + "Montenegro": 30.00, + "Njemačka": 50.00, + "Germany": 50.00, + "Francuska": 50.00, + "France": 50.00, + "Švicarska": 60.00, + "Switzerland": 60.00, + "SAD": 70.00, + "USA": 70.00, +} + + +def _db(): + c = psycopg2.connect(**DB) + c.autocommit = True + return c + + +def _parse_dt(v) -> Optional[datetime]: + if v is None or v == "": + return None + if isinstance(v, datetime): + return v + s = str(v).strip().replace("Z", "+00:00") + for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M", "%Y-%m-%d"): + try: + return datetime.strptime(s[:len(fmt) + 5].rstrip("ZZ"), fmt) + except Exception: + continue + try: + return datetime.fromisoformat(s) + except Exception: + return None + + +def compute_dnevnice(date_from, date_to, country: str = "Hrvatska") -> dict: + """ + Vraća: {hours, days_full, days_half, dnevnica_amount_total, breakdown[]} + Pravila (HR pravilnik 2025, neoporeziv iznos): + - Domaće: <5h = 0; 5-8h = pola; >8h = puna; svaka dodatna pokrivena 24h sekcija = puna. + - Inozemne: pune dnevnice po zemlji (DNEVNICE_INO), inače fallback 50 €. + - Više dana: zaokružujemo po 24h segmentima; završetak <8h = 0, 8-12 = puna (po pravilu zaokruživanja na cijele dane), no koristimo konzervativni izračun po segmentima. + Implementacija (jednostavna, transparentna): + 1) ukupne sate računaj kao razliku. + 2) full_segments = sati // 24 + 3) ostatak_sati = sati - full_segments*24 + 4) ako ostatak >= 8 → +1 puna; ako 5 <= ostatak < 8 → +0.5; ako <5 → +0. + 5) puna dnevnica = pun_iznos po zemlji; pola = polovica. + """ + df = _parse_dt(date_from) + dt = _parse_dt(date_to) + if not df or not dt or dt < df: + return {"error": "neispravni datumi", "hours": 0, + "days_full": 0, "days_half": 0, + "dnevnica_amount_total": 0.0, "breakdown": []} + + delta = dt - df + hours = round(delta.total_seconds() / 3600, 2) + + full_segments = int(delta.total_seconds() // (24 * 3600)) + remainder_h = (delta.total_seconds() - full_segments * 24 * 3600) / 3600.0 + + days_full = full_segments + days_half = 0.0 + if remainder_h >= 8: + days_full += 1 + elif remainder_h >= 5: + days_half += 1 + # else: 0 + + is_domestic = (country or "").strip().lower() in ("hrvatska", "croatia", "hr") + if is_domestic: + full_amt = DNEVNICA_DOM_FULL + half_amt = DNEVNICA_DOM_HALF + else: + full_amt = DNEVNICE_INO.get(country.strip(), 50.00) + half_amt = full_amt / 2.0 + + total = round(days_full * full_amt + days_half * half_amt, 2) + + return { + "hours": hours, + "days_full": days_full, + "days_half": days_half, + "country": country, + "rate_full": full_amt, + "rate_half": half_amt, + "dnevnica_amount_total": total, + "breakdown": [ + f"{days_full} pun{'' if days_full == 1 else 'e'} dnevnice × {full_amt:.2f} €", + f"{days_half} pola dnevnice × {full_amt:.2f} €" if days_half else "", + ], + } + + +def compute_kilometrina(km: float, km_rate: float = KM_RATE_DEFAULT) -> float: + try: + return round(float(km or 0) * float(km_rate or 0), 2) + except Exception: + return 0.0 + + +# === Endpoints === + +@router.get("/putni-nalog/dnevnice/preview") +def preview_dnevnice(date_from: str, date_to: str, country: str = "Hrvatska", + km: float = 0.0, km_rate: float = KM_RATE_DEFAULT): + """Preview dnevnica + kilometrine bez upisa u DB. Koristi UI za live preview.""" + d = compute_dnevnice(date_from, date_to, country) + km_amt = compute_kilometrina(km, km_rate) + d["km_amount"] = km_amt + d["km_driven"] = km + d["km_rate"] = km_rate + d["total_estimated"] = round((d.get("dnevnica_amount_total") or 0) + km_amt, 2) + return {"ok": True, "preview": d} + + +@router.get("/putni-nalog") +def list_putni_nalozi(klub_id: Optional[int] = None, + status: Optional[str] = None, + limit: int = Query(100, le=500), + offset: int = 0): + sql = """SELECT er.id, er.klub_id, k.naziv AS klub_naziv, + er.user_id, er.clan_id, er.report_type, er.report_no, + er.destination, er.purpose, + er.date_from, er.date_to, + er.vehicle_type, er.vehicle_plate, + er.km_driven, er.km_rate, + er.cost_transport, er.cost_lodging, er.cost_meals, + er.cost_other, er.cost_total, + er.dnevnice_count, er.dnevnice_amount, + er.status, er.approved_at, er.paid_at, + er.created_at, er.tenant_id, er.notes + FROM pgz_sport.expense_reports er + LEFT JOIN pgz_sport.klubovi k ON k.id = er.klub_id + WHERE er.report_type='putni_nalog'""" + args: list = [] + if klub_id is not None: + sql += " AND er.klub_id=%s"; args.append(klub_id) + if status: + sql += " AND er.status=%s"; args.append(status) + sql += " ORDER BY er.date_from DESC NULLS LAST, er.id DESC LIMIT %s OFFSET %s" + args += [limit, offset] + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(sql, args) + rows = cur.fetchall() + return {"ok": True, "rows": rows, "count": len(rows)} + + +@router.get("/putni-nalog/{nalog_id}") +def get_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)): + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("""SELECT er.*, k.naziv AS klub_naziv, k.savez_id + FROM pgz_sport.expense_reports er + LEFT JOIN pgz_sport.klubovi k ON k.id = er.klub_id + WHERE er.id=%s AND er.report_type='putni_nalog'""", (nalog_id,)) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_view_putni_nalog(user, row): + raise HTTPException(403, "Nemate ovlasti vidjeti ovaj putni nalog") + + # Lista vezanih računa (po klubu, datumu, ili ID-evima u attachments) + att = row.get("attachments") or {} + if isinstance(att, str): + try: att = json.loads(att) + except Exception: att = {} + invoice_ids = att.get("invoice_ids") or [] + invoices = [] + if invoice_ids: + cur.execute( + """SELECT id, invoice_no, invoice_kind, vendor_name, vendor_oib, + invoice_date, amount_gross, payment_status, currency, category + FROM pgz_sport.invoices WHERE id = ANY(%s) + ORDER BY invoice_date DESC""", (invoice_ids,)) + invoices = cur.fetchall() + else: + # Auto-suggest: računi kluba u rasponu putovanja s kategorijom putni-trošak + cur.execute( + """SELECT id, invoice_no, invoice_kind, vendor_name, vendor_oib, + invoice_date, amount_gross, payment_status, currency, category + FROM pgz_sport.invoices + WHERE klub_id=%s AND invoice_date BETWEEN %s AND %s + AND invoice_kind IN ('gorivo','cestarina','hotel','restoran','ostalo') + ORDER BY invoice_date DESC LIMIT 50""", + (row.get("klub_id"), row.get("date_from"), row.get("date_to")), + ) + invoices = cur.fetchall() + + # Payments za ovaj putni nalog + cur.execute( + """SELECT id, payment_date, amount, currency, payment_method, iban_from, + iban_to, reference, bank_transaction_id, matched_status, created_at + FROM pgz_sport.payments WHERE expense_report_id=%s + ORDER BY payment_date DESC""", (nalog_id,)) + payments = cur.fetchall() + + audit = fetch_audit("pgz_sport.expense_reports", nalog_id, 50) + actions = putni_nalog_actions(user, row) if user else {"view": True, "edit": False, "submit": False, "approve": False, "reject": False, "pay": False, "delete": False} + return {"ok": True, "putni_nalog": row, "invoices": invoices, + "payments": payments, "audit": audit, "actions": actions} + + +@router.post("/putni-nalog/{nalog_id}/posalji") +def posalji_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)): + """Voditelj/klub_admin šalje draft → poslan.""" + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,)) + pn = cur.fetchone() + if not pn: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_submit_putni_nalog(user, pn): + raise HTTPException(403, "Nemate ovlasti slanja na odobrenje") + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """UPDATE pgz_sport.expense_reports SET status='poslan', updated_at=NOW() + WHERE id=%s RETURNING id, status""", (nalog_id,)) + row = cur.fetchone() + audit_putni(user, nalog_id, "submit", field="status", old=pn.get("status"), new="poslan") + return {"ok": True, "putni_nalog": row} + + +@router.post("/putni-nalog/{nalog_id}/odbij") +def odbij_putni_nalog(nalog_id: int, body: dict = Body(default={}), + authorization: Optional[str] = Header(None)): + """Klub_admin/pgz_admin odbija s razlogom.""" + user = _resolve_user(authorization) + razlog = (body.get("razlog") or body.get("reason") or "").strip() + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,)) + pn = cur.fetchone() + if not pn: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_approve_putni_nalog(user, pn): + raise HTTPException(403, "Nemate ovlasti odbiti") + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """UPDATE pgz_sport.expense_reports + SET status='odbijen', notes=COALESCE(notes,'') || E'\n[ODBIJEN] ' || %s, updated_at=NOW() + WHERE id=%s RETURNING id, status, notes""", + (razlog or "(bez razloga)", nalog_id), + ) + row = cur.fetchone() + audit_putni(user, nalog_id, "reject", field="status", + old=pn.get("status"), new=f"odbijen: {razlog}") + return {"ok": True, "putni_nalog": row} + + +@router.post("/putni-nalog/{nalog_id}/isplati") +def isplati_putni_nalog(nalog_id: int, body: dict = Body(default={}), + authorization: Optional[str] = Header(None)): + """Isplata putnog naloga (odobren/zatvoren → isplaćen). + Body: {iban_to, iban_from, paid_date, amount, reference, bank_transaction_id}""" + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,)) + pn = cur.fetchone() + if not pn: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_pay_putni_nalog(user, pn): + raise HTTPException(403, "Nemate ovlasti za isplatu") + + paid_date = body.get("paid_date") or date.today().isoformat() + iban_to = body.get("iban_to") + iban_from = body.get("iban_from") + amount = body.get("amount") or pn.get("cost_total") + reference = body.get("reference") + tx_id = body.get("bank_transaction_id") or body.get("tx_id") + payment_method = body.get("payment_method") or "transfer" + + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """UPDATE pgz_sport.expense_reports + SET status='isplacen', paid_at=%s, updated_at=NOW() + WHERE id=%s RETURNING id, status, paid_at, cost_total""", + (paid_date, nalog_id), + ) + row = cur.fetchone() + cur.execute( + """INSERT INTO pgz_sport.payments + (klub_id, expense_report_id, payment_date, amount, currency, + payment_method, iban_from, iban_to, reference, bank_transaction_id, + matched_status) + VALUES (%s,%s,%s,%s,'EUR',%s,%s,%s,%s,%s,'matched') + RETURNING id""", + (pn.get("klub_id"), nalog_id, paid_date, amount, payment_method, + iban_from, iban_to, reference, tx_id), + ) + pay = cur.fetchone() + audit_putni(user, nalog_id, "pay", field="status", + old=pn.get("status"), new="isplacen") + return {"ok": True, "putni_nalog": row, "payment_id": pay["id"] if pay else None} + + +@router.get("/putni-nalog/{nalog_id}/audit") +def putni_audit(nalog_id: int, limit: int = 100, + authorization: Optional[str] = Header(None)): + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT * FROM pgz_sport.expense_reports WHERE id=%s AND report_type='putni_nalog'", (nalog_id,)) + pn = cur.fetchone() + if not pn: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_view_putni_nalog(user, pn): + raise HTTPException(403, "Nemate ovlasti") + return {"ok": True, "audit": fetch_audit("pgz_sport.expense_reports", nalog_id, limit)} + + +@router.post("/putni-nalog") +def create_putni_nalog(body: dict = Body(...), authorization: Optional[str] = Header(None)): + """Kreiraj putni nalog. + Polja: klub_id, user_id, clan_id, voditelj_ime, putnici[], + svrha (purpose), od_grada, do_grada (destination), + datum_polaska (date_from), datum_povratka (date_to), + registracija_vozila (vehicle_plate), vehicle_type, + kilometara (km_driven), km_rate, + predviđeni_troškovi (cost_estimate), country, notes.""" + df = body.get("date_from") or body.get("datum_polaska") + dt = body.get("date_to") or body.get("datum_povratka") + if not df or not dt: + raise HTTPException(400, "Datum polaska i povratka su obavezni") + klub_id = body.get("klub_id") + if not klub_id: + raise HTTPException(400, "klub_id je obavezan") + + country = body.get("country", "Hrvatska") + km = body.get("km_driven", body.get("kilometara", 0)) or 0 + km_rate = body.get("km_rate") or KM_RATE_DEFAULT + dnv = compute_dnevnice(df, dt, country) + dnevnice_count = (dnv.get("days_full") or 0) + 0.5 * (dnv.get("days_half") or 0) + dnevnice_amount = dnv.get("dnevnica_amount_total") or 0 + cost_transport = compute_kilometrina(km, km_rate) + (body.get("cost_transport") or 0) + + od = body.get("od_grada") or body.get("from_city") + do = body.get("do_grada") or body.get("to_city") or body.get("destination") + destination = " → ".join([x for x in [od, do] if x]) or do + + putnici = body.get("putnici") or [] + voditelj = body.get("voditelj_ime") or body.get("voditelj") + purpose = body.get("svrha") or body.get("purpose") or "" + + meta = { + "voditelj": voditelj, + "putnici": putnici, + "from_city": od, "to_city": do, + "country": country, + "dnevnice_calc": dnv, + "predvideni_troskovi": body.get("predvideni_troskovi") or body.get("cost_estimate") or [], + } + + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """INSERT INTO pgz_sport.expense_reports + (klub_id, user_id, clan_id, report_type, report_no, destination, purpose, + date_from, date_to, vehicle_type, vehicle_plate, km_driven, km_rate, + cost_transport, cost_lodging, cost_meals, cost_other, + dnevnice_count, dnevnice_amount, status, attachments, notes, tenant_id) + VALUES (%s, %s, %s, 'putni_nalog', %s, %s, %s, + %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, + %s, %s, COALESCE(%s,'draft'), %s, %s, %s) + RETURNING id, klub_id, status, dnevnice_count, dnevnice_amount, + cost_transport, date_from, date_to, destination""", + ( + klub_id, body.get("user_id"), body.get("clan_id"), + body.get("report_no"), destination, purpose, + df, dt, body.get("vehicle_type"), body.get("vehicle_plate") or body.get("registracija_vozila"), + float(km or 0), float(km_rate or 0), + cost_transport, + body.get("cost_lodging") or 0, body.get("cost_meals") or 0, + body.get("cost_other") or 0, + dnevnice_count, dnevnice_amount, + body.get("status"), + json.dumps(meta, ensure_ascii=False, default=str), + body.get("notes"), + body.get("tenant_id", 1), + ), + ) + row = cur.fetchone() + # cost_total via trigger maybe; recompute here + cur.execute( + """UPDATE pgz_sport.expense_reports + SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0) + +COALESCE(cost_meals,0)+COALESCE(cost_other,0) + +COALESCE(dnevnice_amount,0) + WHERE id=%s + RETURNING cost_total""", (row["id"],), + ) + ct = cur.fetchone() + if ct: + row["cost_total"] = ct["cost_total"] + return {"ok": True, "putni_nalog": row, "dnevnice_calc": dnv} + + +@router.put("/putni-nalog/{nalog_id}") +def update_putni_nalog(nalog_id: int, body: dict = Body(...)): + """Update polja putnog naloga (osim odobrenja/zatvaranja - oni imaju vlastite endpointe).""" + cols = [] + args: list = [] + for col in ("destination", "purpose", "date_from", "date_to", "vehicle_type", + "vehicle_plate", "km_driven", "km_rate", "cost_transport", + "cost_lodging", "cost_meals", "cost_other", "notes", + "dnevnice_count", "dnevnice_amount"): + if col in body: + cols.append(f"{col}=%s"); args.append(body[col]) + # Recompute dnevnice if dates provided + if "date_from" in body or "date_to" in body or "country" in body: + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT date_from, date_to, attachments FROM pgz_sport.expense_reports WHERE id=%s", (nalog_id,)) + cur_row = cur.fetchone() + if cur_row: + df = body.get("date_from") or cur_row["date_from"] + dt = body.get("date_to") or cur_row["date_to"] + country = body.get("country") or (cur_row["attachments"] or {}).get("country", "Hrvatska") + d = compute_dnevnice(df, dt, country) + cols += ["dnevnice_count=%s", "dnevnice_amount=%s"] + args += [(d.get("days_full") or 0) + 0.5 * (d.get("days_half") or 0), + d.get("dnevnica_amount_total") or 0] + if not cols: + raise HTTPException(400, "Nema polja za izmjenu") + cols.append("updated_at=NOW()") + args.append(nalog_id) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(f"UPDATE pgz_sport.expense_reports SET {','.join(cols)} WHERE id=%s AND report_type='putni_nalog' RETURNING *", args) + row = cur.fetchone() + if row: + cur.execute( + """UPDATE pgz_sport.expense_reports + SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0) + +COALESCE(cost_meals,0)+COALESCE(cost_other,0) + +COALESCE(dnevnice_amount,0) + WHERE id=%s""", (nalog_id,), + ) + if not row: + raise HTTPException(404, "Putni nalog ne postoji") + return {"ok": True, "putni_nalog": row} + + +@router.post("/putni-nalog/{nalog_id}/odobriti") +def odobriti_putni_nalog(nalog_id: int, body: dict = Body(default={}), + authorization: Optional[str] = Header(None)): + user = _resolve_user(authorization) + approved_by = body.get("approved_by") or (user.get("id") if user else None) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,)) + pn = cur.fetchone() + if not pn: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_approve_putni_nalog(user, pn): + raise HTTPException(403, "Nemate ovlasti odobriti") + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """UPDATE pgz_sport.expense_reports + SET status='odobren', approved_by=%s, approved_at=NOW(), updated_at=NOW() + WHERE id=%s AND report_type='putni_nalog' + RETURNING id, status, approved_at""", (approved_by, nalog_id), + ) + row = cur.fetchone() + audit_putni(user, nalog_id, "approve", field="status", + old=pn.get("status"), new="odobren") + return {"ok": True, "putni_nalog": row} + + +@router.post("/putni-nalog/{nalog_id}/zatvori") +def zatvori_putni_nalog(nalog_id: int, body: dict = Body(default={})): + """Zatvori putni nalog: priloži račune i konačan obračun.""" + invoice_ids = body.get("invoice_ids") or [] + cost_lodging = body.get("cost_lodging") + cost_meals = body.get("cost_meals") + cost_other = body.get("cost_other") + notes = body.get("notes") + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT * FROM pgz_sport.expense_reports WHERE id=%s AND report_type='putni_nalog'", (nalog_id,)) + cur_row = cur.fetchone() + if not cur_row: + raise HTTPException(404, "Putni nalog ne postoji") + + # Aggregiraj iznose iz računa (ako su poslani) + if invoice_ids: + cur.execute( + "SELECT COALESCE(SUM(amount_gross),0) AS total FROM pgz_sport.invoices WHERE id = ANY(%s)", + (invoice_ids,), + ) + invs_total = float(cur.fetchone()["total"] or 0) + else: + invs_total = None + + sets = ["status='zatvoren'", "updated_at=NOW()"] + args: list = [] + if cost_lodging is not None: sets.append("cost_lodging=%s"); args.append(cost_lodging) + if cost_meals is not None: sets.append("cost_meals=%s"); args.append(cost_meals) + if cost_other is not None: sets.append("cost_other=%s"); args.append(cost_other) + if notes: sets.append("notes=%s"); args.append(notes) + # Pohrani povezane račune u attachments + atts = cur_row["attachments"] or {} + if isinstance(atts, str): + try: atts = json.loads(atts) + except Exception: atts = {} + atts["invoice_ids"] = invoice_ids + if invs_total is not None: + atts["invoices_total"] = invs_total + sets.append("attachments=%s"); args.append(json.dumps(atts, ensure_ascii=False, default=str)) + args.append(nalog_id) + cur.execute(f"UPDATE pgz_sport.expense_reports SET {','.join(sets)} WHERE id=%s RETURNING *", args) + row = cur.fetchone() + cur.execute( + """UPDATE pgz_sport.expense_reports + SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0) + +COALESCE(cost_meals,0)+COALESCE(cost_other,0) + +COALESCE(dnevnice_amount,0) + WHERE id=%s RETURNING cost_total""", (nalog_id,), + ) + ct = cur.fetchone() + if ct: row["cost_total"] = ct["cost_total"] + return {"ok": True, "putni_nalog": row} diff --git a/_backups/r3_cc5/pgz_sport_api.py.pre_r5.1777937293 b/_backups/r3_cc5/pgz_sport_api.py.pre_r5.1777937293 new file mode 100644 index 0000000..ee172a7 --- /dev/null +++ b/_backups/r3_cc5/pgz_sport_api.py.pre_r5.1777937293 @@ -0,0 +1,1729 @@ +#!/usr/bin/env python3 +""" +pgz_sport_api.py - FastAPI backend za PGŽ Sportski savez ERP/CRM +Author: Damir Radulić (damir@rinet.one) +Date: 25.04.2026 +Port: 8095 +Endpoints: savezi, klubovi, članovi, članarine, liječnički, manifestacije, proračun, dashboard, alertovi +""" + +from fastapi import FastAPI, HTTPException, Query, Body, Header, Depends, UploadFile, File, Form, Request +import json +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import Optional, List +from datetime import date, datetime +import psycopg2 +import psycopg2.extras +from pgz_sport_v2_router import router as v2_router +import os + +DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7') + + +ADMIN_TOKEN = 'admin-pgz-2026' + +def is_admin(authorization): + if not authorization: return False + token = authorization.replace('Bearer ', '').strip() + if token == ADMIN_TOKEN: return True + # Try JWT + try: + import jwt as _jwt + payload = _jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return payload.get("role") == "admin" + except Exception: + return False + +def blur_oib(v): + if not v: return v + s = str(v); + return s[:3] + '•'*(len(s)-5) + s[-2:] if len(s) >= 8 else '•'*len(s) +def blur_email(e): + if not e or '@' not in str(e): return e + u, d = str(e).split('@',1); return (u[:1]+'•••' if u else '')+'@'+d +def blur_phone(p): + if not p: return p + s=str(p); return s[:4]+'•'*(len(s)-7)+s[-3:] if len(s)>=7 else s +def blur_iban(v): + if not v: return v + s=str(v); return s[:4]+'•'*(len(s)-8)+s[-4:] if len(s)>=8 else s +def blur_date(d): + if not d: return d + s = str(d); return s[:4]+'-••-••' if len(s)>=4 else s +def blur_text(t, keep=3): + if not t: return t + s=str(t); return s[:keep]+'•'*(len(s)-keep*2)+s[-keep:] if len(s)>keep*2 else s + +def apply_privacy(rows, admin): + if admin: return rows + out = [] + for r in (rows if isinstance(rows, list) else [rows]): + rr = dict(r) + for k, v in list(rr.items()): + if v is None: continue + kl = k.lower() + if 'oib' in kl: rr[k] = blur_oib(v) + elif 'email' in kl: rr[k] = blur_email(v) + elif kl in ('telefon','tel','phone'): rr[k] = blur_phone(v) + elif kl == 'datum_rodenja': rr[k] = blur_date(v) + elif 'iban' in kl: rr[k] = blur_iban(v) + elif kl == 'adresa': rr[k] = blur_text(v, 3) + elif 'licenca_broj' in kl: rr[k] = blur_text(v, 2) + out.append(rr) + return out if isinstance(rows, list) else out[0] + +app = FastAPI(title="PGŽ Sportski savez ERP/CRM", version="1.0.0") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +# ─── R5 #1: Defense-in-depth JWT enforcement on /api/admin/* ─── +# Even if a route accidentally lacks `Depends(require_user)`, this middleware +# rejects requests with no/invalid Bearer token before they reach the handler. +@app.middleware("http") +async def require_jwt_on_admin(request, call_next): + p = request.url.path + # Only gate admin endpoints — leave /api/auth/*, public /api/v2/* etc. alone + if p.startswith("/api/admin/") or p == "/api/admin": + # OPTIONS preflight passes through + if request.method == "OPTIONS": + return await call_next(request) + try: + from auth.auth_v2 import decode_token, _is_revoked + auth = request.headers.get("authorization", "") + if not auth.lower().startswith("bearer "): + from starlette.responses import JSONResponse as _JR + return _JR({"detail": "Authentication required"}, status_code=401) + token = auth.split(" ", 1)[1].strip() + try: + payload = decode_token(token) + except Exception: + from starlette.responses import JSONResponse as _JR + return _JR({"detail": "Invalid or expired token"}, status_code=401) + if payload.get("typ") not in (None, "access"): + from starlette.responses import JSONResponse as _JR + return _JR({"detail": "Wrong token type"}, status_code=401) + if _is_revoked(payload.get("jti", "")): + from starlette.responses import JSONResponse as _JR + return _JR({"detail": "Token revoked"}, status_code=401) + except Exception as e: + print(f"[JWT-MW WARN] {e}") + return await call_next(request) + + +# === URL rewrite middleware - convert direct external image URLs to /img-proxy === +import json as _json_mw +import re as _re_mw +from starlette.responses import Response as _StarletteResponse_mw + +_IMG_DOMAINS_RE = _re_mw.compile( + r'https?://(?:hns\.family|hns\.hr|hbs\.hr|hrvatski-bocarski-savez\.hr|' + r'rk-zamet\.hr|hvs\.hr|rezultati\.hvs\.hr|sport-pgz\.hr)' + r'/[^"\s\\]+\.(?:jpg|jpeg|png|gif|webp|svg)', + _re_mw.IGNORECASE +) + +def _rewrite_to_proxy(text: str) -> str: + """Replace external image URLs with /sport/api/v2/img-proxy?u=...""" + from urllib.parse import quote as _q + def _sub(m): + url = m.group(0) + return "/sport/api/v2/img-proxy?u=" + _q(url, safe='') + return _IMG_DOMAINS_RE.sub(_sub, text) + +@app.middleware("http") +async def url_rewrite_middleware(request, call_next): + response = await call_next(request) + # Only rewrite JSON API responses + ct = response.headers.get("content-type", "") + if "application/json" not in ct: + return response + # Only on /api/v2 routes (admin & data endpoints) - SKIP /api/v2/img-proxy itself + path = request.url.path + if "/api/v2/img-proxy" in path or "/api/v2/dokumenti" in path: + return response # don't rewrite raw document content + # Read body + body = b"" + async for chunk in response.body_iterator: + body += chunk + try: + text = body.decode("utf-8") + new_text = _rewrite_to_proxy(text) + new_body = new_text.encode("utf-8") + except Exception: + new_body = body + return _StarletteResponse_mw( + content=new_body, + status_code=response.status_code, + headers={k: v for k, v in response.headers.items() if k.lower() not in ("content-length",)}, + media_type=ct, + ) +# === end URL rewrite middleware === + +def db(): + conn = psycopg2.connect(**DB) + conn.autocommit = True + return conn + +def fetch(sql, params=None): + with db() as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as c: + c.execute(sql, params or ()) + return [dict(r) for r in c.fetchall()] + +def execute(sql, params=None): + with db() as conn: + with conn.cursor() as c: + c.execute(sql, params or ()) + return c.rowcount + +# ==================== HEALTH ==================== +@app.get("/health") +def health(): + try: + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + return {"status": "ok", "service": "pgz_sport", "dashboard": rows[0] if rows else None} + except Exception as e: + raise HTTPException(500, f"DB error: {e}") + + +@app.get("/api/whoami") +def whoami_v2(authorization: Optional[str] = Header(None)): + return {"role": "admin" if is_admin(authorization) else "viewer", "privacy_active": not is_admin(authorization)} + +# ==================== DASHBOARD ==================== +@app.get("/api/dashboard") +def dashboard(): + rows = fetch("SELECT * FROM pgz_sport.v_dashboard") + if not rows: + return {} + d = rows[0] + # Top savezi by registriranih 2024 + top = fetch("""SELECT s.naziv, st.klubova_clanica, st.registriranih, st.trenera, st.reprezentativaca + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina=2024 ORDER BY st.registriranih DESC LIMIT 10""") + proracun_trend = fetch("SELECT godina, ukupno FROM pgz_sport.proracun ORDER BY godina") + nositelji = fetch("""SELECT naziv_kluba, godina, iznos FROM pgz_sport.potpore_nositelji + WHERE godina = 2025 ORDER BY iznos DESC LIMIT 10""") + return {**d, "top_savezi": top, "proracun_trend": proracun_trend, "nositelji_2025": nositelji} + +@app.get("/api/dashboard/ekosustav") +def dashboard_ekosustav(): + """Sport ekosustav PGŽ — coverage stats za enrichment iz FINA registra.""" + summary = fetch("""SELECT + COUNT(*) AS klubova_total, + COUNT(*) FILTER (WHERE oib IS NOT NULL) AS s_oib, + COUNT(*) FILTER (WHERE predsjednik IS NOT NULL) AS s_predsjednik, + COUNT(*) FILTER (WHERE tajnik IS NOT NULL) AS s_tajnik, + COUNT(*) FILTER (WHERE ciljevi IS NOT NULL) AS s_ciljevi, + COUNT(*) FILTER (WHERE opis_djelatnosti IS NOT NULL) AS s_opis, + COUNT(*) FILTER (WHERE sjediste IS NOT NULL) AS s_sjediste, + COUNT(*) FILTER (WHERE email IS NOT NULL) AS s_email, + COUNT(*) FILTER (WHERE web_stranica IS NOT NULL) AS s_web, + COUNT(*) FILTER (WHERE udruga_status = \'AKTIVAN\') AS s_aktivan_reg, + COUNT(*) FILTER (WHERE savez_id IS NOT NULL) AS s_savez, + COUNT(*) FILTER (WHERE nositelj_kvalitete) AS s_nositelj + FROM pgz_sport.klubovi WHERE aktivan""")[0] + + by_sport = fetch("""SELECT sport, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND sport IS NOT NULL + GROUP BY sport ORDER BY COUNT(*) DESC LIMIT 15""") + + by_region = fetch("""SELECT region, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND region IS NOT NULL + GROUP BY region ORDER BY COUNT(*) DESC""") + + by_grad = fetch("""SELECT grad, COUNT(*) AS broj + FROM pgz_sport.klubovi WHERE aktivan AND grad IS NOT NULL + GROUP BY grad ORDER BY COUNT(*) DESC LIMIT 12""") + + decade = fetch("""SELECT + CASE + WHEN godina_osnutka < 1950 THEN \'pred1950\' + WHEN godina_osnutka < 1980 THEN \'1950-1979\' + WHEN godina_osnutka < 2000 THEN \'1980-1999\' + WHEN godina_osnutka < 2010 THEN \'2000-2009\' + WHEN godina_osnutka >= 2010 THEN \'2010-danas\' + ELSE \'nepoznato\' + END AS razdoblje, + COUNT(*) AS broj + FROM pgz_sport.klubovi + WHERE aktivan AND godina_osnutka IS NOT NULL + GROUP BY razdoblje ORDER BY razdoblje""") + + # Pokazi enrichment % + total = summary["klubova_total"] or 1 + coverage = { + "oib_pct": round(100 * summary["s_oib"] / total, 1), + "predsjednik_pct": round(100 * summary["s_predsjednik"] / total, 1), + "tajnik_pct": round(100 * summary["s_tajnik"] / total, 1), + "ciljevi_pct": round(100 * summary["s_ciljevi"] / total, 1), + "opis_pct": round(100 * summary["s_opis"] / total, 1), + "sjediste_pct": round(100 * summary["s_sjediste"] / total, 1), + "email_pct": round(100 * summary["s_email"] / total, 1), + "savez_pct": round(100 * summary["s_savez"] / total, 1), + } + + return {**summary, "coverage": coverage, "by_sport": by_sport, + "by_region": by_region, "by_grad": by_grad, "by_decade": decade} + + + +# ==================== ANALYTICS ==================== +@app.get("/api/analytics/savezi-trend") +def savezi_trend(godine: str = "2020,2021,2022,2023,2024", metric: str = "registriranih"): + valid_metrics = {"registriranih", "neregistriranih", "rekreativaca", "trenera", "reprezentativaca", + "kategoriziranih", "stipendiranih", "klubova_clanica"} + if metric not in valid_metrics: + raise HTTPException(400, f"Invalid metric. Must be one of: {valid_metrics}") + god_list = [int(g) for g in godine.split(",")] + rows = fetch(f"""SELECT s.naziv AS savez, st.godina, st.{metric} AS value + FROM pgz_sport.statistika_saveza st JOIN pgz_sport.savezi s ON s.id=st.savez_id + WHERE st.godina = ANY(%s) ORDER BY s.naziv, st.godina""", [god_list]) + saveze = {} + for r in rows: + if r['savez'] not in saveze: saveze[r['savez']] = {} + saveze[r['savez']][r['godina']] = r['value'] + return {"metric": metric, "godine": god_list, "data": saveze} + +@app.get("/api/analytics/proracun-detaljno") +def proracun_detaljno(): + p = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + if not p: return {"proracun": [], "rast_godisnji": [], "current_year": None, "current_total": 0, "rast_dekada_pct": 0} + cagr = [] + for i in range(1, len(p)): + prev = float(p[i-1]['ukupno']) if p[i-1]['ukupno'] else 0 + curr = float(p[i]['ukupno']) if p[i]['ukupno'] else 0 + rate = ((curr/prev - 1) * 100) if prev > 0 else 0 + cagr.append({"godina": p[i]['godina'], "rast_postotak": round(rate, 1)}) + decade_rast = round((float(p[-1]['ukupno'])/float(p[0]['ukupno']) - 1) * 100, 1) if p[0]['ukupno'] else 0 + return {"proracun": p, "rast_godisnji": cagr, "rast_dekada_pct": decade_rast, + "current_year": int(p[-1]['godina']), "current_total": float(p[-1]['ukupno'])} + +@app.get("/api/analytics/klub-financije") +def klub_financije(klub_id: Optional[int] = None, godina: Optional[int] = None): + where = [] + params = [] + if godina: where.append("p.godina=%s"); params.append(godina) + if klub_id: + where.append("(p.klub_id=%s OR p.naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))") + params.extend([klub_id, klub_id]) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"""SELECT p.naziv_kluba, p.godina, p.iznos, + k.id AS klub_id, k.sport, k.razina, k.nositelj_kvalitete + FROM pgz_sport.potpore_nositelji p + LEFT JOIN pgz_sport.klubovi k ON p.klub_id=k.id OR p.naziv_kluba=k.naziv + {where_sql} ORDER BY p.godina DESC, p.iznos DESC""", params) + summary = fetch(f"""SELECT godina, SUM(iznos) AS total, COUNT(*) AS klubova, AVG(iznos) AS prosjek + FROM pgz_sport.potpore_nositelji p {where_sql} + GROUP BY godina ORDER BY godina""", params) + return {"data": rows, "summary": summary} + +@app.get("/api/analytics/lijecnicki-stats") +def lijecnicki_stats(klub_id: Optional[int] = None): + where = ["1=1"]; params = [] + if klub_id: where.append("c.klub_id=%s"); params.append(klub_id) + where_sql = " AND ".join(where) + rows = fetch(f"""SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE lp.vrijedi_do >= CURRENT_DATE + 30) AS validni, + COUNT(*) FILTER (WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + 30) AS uskoro, + COUNT(*) FILTER (WHERE lp.vrijedi_do < CURRENT_DATE) AS istekli, + SUM(lp.iznos) AS ukupan_trosak, SUM(lp.iznos_zzjz) AS zzjz_udio, + SUM(lp.iznos_klub) AS klub_udio, SUM(lp.iznos_clan) AS clan_udio, + AVG(lp.iznos) AS prosjecni_trosak + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id WHERE {where_sql}""", params) + by_ustanova = fetch(f"""SELECT lp.ustanova, COUNT(*) cnt, SUM(lp.iznos) iznos + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} GROUP BY lp.ustanova ORDER BY cnt DESC""", params) + by_lijecnik = fetch(f"""SELECT lp.lijecnik, COUNT(*) cnt, AVG(lp.iznos) prosjek + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE {where_sql} AND lp.lijecnik IS NOT NULL GROUP BY lp.lijecnik ORDER BY cnt DESC""", params) + return {"summary": rows[0] if rows else {}, "by_ustanova": by_ustanova, "by_lijecnik": by_lijecnik} + +# ==================== SAVEZI ==================== +@app.get("/api/savezi") +def list_savezi(authorization: Optional[str] = Header(None), q: Optional[str] = None, + razina: Optional[str] = None, zupanija: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = "WHERE aktivan" + params = [] + if q: + where += " AND (naziv ILIKE %s OR sport ILIKE %s)" + params = [f"%{q}%", f"%{q}%"] + if razina: + where += " AND razina = %s"; params.append(razina) + if zupanija: + where += " AND sjediste_zupanija ILIKE %s"; params.append(f"%{zupanija}%") + sort_col = {"naziv": "naziv", "godina": "godina_osnutka", "sport": "sport", "razina": "razina"}.get(sort, "naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + # Croatian collation for text columns (Š → after S, Č → after C, etc.) + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("naziv", "sport") else "" + rows = fetch(f"""SELECT s.*, + (SELECT COUNT(*) FROM pgz_sport.klubovi WHERE savez_id=s.id) AS broj_klubova, + (SELECT registriranih FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS reg_2024, + (SELECT trenera FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS treneri_2024, + (SELECT reprezentativaca FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS repr_2024 + FROM pgz_sport.savezi s {where} ORDER BY {sort_col}{collate} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/savezi/{savez_id}") +def get_savez(savez_id: int): + rows = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s", [savez_id]) + if not rows: + raise HTTPException(404, "Savez ne postoji") + klubovi = fetch("SELECT * FROM pgz_sport.klubovi WHERE savez_id=%s ORDER BY naziv", [savez_id]) + statistika = fetch("SELECT * FROM pgz_sport.statistika_saveza WHERE savez_id=%s ORDER BY godina", [savez_id]) + manifestacije = fetch("SELECT * FROM pgz_sport.manifestacije WHERE savez_id=%s", [savez_id]) + return {**rows[0], "klubovi": klubovi, "statistika": statistika, "manifestacije": manifestacije} + +# ==================== KLUBOVI ==================== +@app.get("/api/klubovi") +def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, savez_id: Optional[int] = None, + nositelj: Optional[bool] = None, region: Optional[str] = None, sport: Optional[str] = None, grad: Optional[str] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivan"] + params = [] + if q: + where.append("(klub ILIKE %s OR oib ILIKE %s OR sport ILIKE %s OR predsjednik ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%", f"%{q}%"]) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + if nositelj is not None: + where.append(f"nositelj_kvalitete={'TRUE' if nositelj else 'FALSE'}") + if region: + where.append("region ILIKE %s"); params.append(region) + if grad: + where.append("grad ILIKE %s"); params.append(f"%{grad}%") + if sport: + where.append("sport ILIKE %s"); params.append(f"%{sport}%") + sort_col = {"naziv": "klub", "savez": "savez", "broj_clanova": "broj_clanova", + "razina": "razina", "region": "region", "grad": "grad", "sport": "sport"}.get(sort, "klub") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("klub", "savez", "razina", "region", "grad", "sport") else "" + rows = fetch(f"""SELECT * FROM pgz_sport.v_klubovi_pregled WHERE {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST""", params) + for r in rows: + if isinstance(r, dict) and r.get('klub') and not r.get('naziv'): + r['naziv'] = r['klub'] + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +@app.get("/api/klubovi/{klub_id}") +def get_klub(klub_id: int, authorization: Optional[str] = Header(None)): + admin = is_admin(authorization) + rows = fetch("""SELECT k.*, s.naziv AS savez_naziv FROM pgz_sport.klubovi k + LEFT JOIN pgz_sport.savezi s ON s.id=k.savez_id WHERE k.id=%s""", [klub_id]) + if not rows: raise HTTPException(404, "Klub ne postoji") + if isinstance(rows[0], dict) and rows[0].get('klub') and not rows[0].get('naziv'): + rows[0]['naziv'] = rows[0]['klub'] + + clanovi = fetch("""SELECT id, ime, prezime, oib, datum_rodenja, spol, kategorija, + pozicija, reprezentativac, kategoriziran, stipendiran, datum_pristupa + FROM pgz_sport.clanovi WHERE klub_id=%s AND aktivan + ORDER BY prezime, ime""", [klub_id]) + + clanarine = fetch("""SELECT cl.id, cl.godina, cl.razdoblje, cl.iznos_propisan, cl.iznos_placen, + (cl.iznos_propisan - cl.iznos_placen) AS dug, cl.datum_uplate, cl.status, cl.napomena, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib + FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE c.klub_id=%s ORDER BY cl.godina DESC, cl.id DESC""", [klub_id]) + + lijecnicki = fetch("""SELECT lp.id, lp.datum_pregleda, lp.vrijedi_do, lp.vrsta_pregleda, + lp.ustanova, lp.lijecnik, lp.spreman_za_natjecanje, lp.iznos, lp.iznos_zzjz, lp.iznos_klub, lp.iznos_clan, + lp.placeno, lp.komentar_lijecnika, + c.ime || ' ' || c.prezime AS clan, c.oib AS clan_oib, + CASE WHEN lp.vrijedi_do IS NULL THEN 'Nepoznato' + WHEN lp.vrijedi_do < CURRENT_DATE THEN 'Istekao' + WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'Ističe uskoro' + ELSE 'Validan' END AS status_pregled + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s ORDER BY lp.datum_pregleda DESC""", [klub_id]) + + potpore = fetch("""SELECT * FROM pgz_sport.potpore_nositelji + WHERE klub_id=%s OR naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s) + ORDER BY godina DESC""", [klub_id, klub_id]) + + # Aggregate stats + stats = { + 'broj_clanova': len(clanovi), + 'broj_registriranih': sum(1 for c in clanovi if c.get('kategorija')=='registrirani'), + 'broj_trenera': sum(1 for c in clanovi if c.get('kategorija')=='trener'), + 'broj_reprezentativaca': sum(1 for c in clanovi if c.get('reprezentativac')), + 'broj_kategoriziranih': sum(1 for c in clanovi if c.get('kategoriziran')), + 'broj_stipendiranih': sum(1 for c in clanovi if c.get('stipendiran')), + 'lijecnicki_validni': sum(1 for l in lijecnicki if l.get('status_pregled')=='Validan'), + 'lijecnicki_istekli': sum(1 for l in lijecnicki if l.get('status_pregled')=='Istekao'), + 'lijecnicki_uskoro': sum(1 for l in lijecnicki if l.get('status_pregled')=='Ističe uskoro'), + 'clanarina_naplaceno_god': sum(float(c.get('iznos_placen') or 0) for c in clanarine if c.get('godina')==2026), + 'clanarina_dug_god': sum(float(c.get('dug') or 0) for c in clanarine if c.get('godina')==2026), + 'potpore_2025': float(next((p['iznos'] for p in potpore if p.get('godina')==2025), 0) or 0), + 'potpore_total': sum(float(p.get('iznos') or 0) for p in potpore), + 'zzjz_isplaceno': sum(float(l.get('iznos_zzjz') or 0) for l in lijecnicki if l.get('placeno')), + } + + klub = rows[0] + if not admin: + klub = apply_privacy(klub, admin) + clanovi = apply_privacy(clanovi, admin) + clanarine = apply_privacy(clanarine, admin) + lijecnicki = apply_privacy(lijecnicki, admin) + + return {**klub, "clanovi": clanovi, "clanarine": clanarine, "lijecnicki": lijecnicki, + "potpore": potpore, "stats": stats} + + +class KlubIn(BaseModel): + naziv: str + savez_id: Optional[int] = None + sport: Optional[str] = None + oib: Optional[str] = None + razina: Optional[str] = None + nositelj_kvalitete: Optional[bool] = False + grad: Optional[str] = None + region: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + predsjednik: Optional[str] = None + iban: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/klubovi") +def create_klub(k: KlubIn): + rows = fetch("""INSERT INTO pgz_sport.klubovi (naziv, savez_id, sport, oib, razina, nositelj_kvalitete, grad, region, email, telefon, predsjednik, iban, napomena, aktivan) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE) RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena]) + return rows[0] + +@app.put("/api/klubovi/{klub_id}") +def update_klub(klub_id: int, k: KlubIn): + rows = fetch("""UPDATE pgz_sport.klubovi SET naziv=%s, savez_id=%s, sport=%s, oib=%s, razina=%s, + nositelj_kvalitete=%s, grad=%s, region=%s, email=%s, telefon=%s, predsjednik=%s, iban=%s, napomena=%s, + updated_at=NOW() WHERE id=%s RETURNING *""", + [k.naziv, k.savez_id, k.sport, k.oib, k.razina, k.nositelj_kvalitete, k.grad, k.region, k.email, k.telefon, k.predsjednik, k.iban, k.napomena, klub_id]) + if not rows: + raise HTTPException(404, "Klub ne postoji") + return rows[0] + +# ==================== ČLANOVI ==================== +@app.get("/api/clanovi") +def list_clanovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, klub_id: Optional[int] = None, + kategorija: Optional[str] = None, spol: Optional[str] = None, sort: str = "prezime", order: str = "asc"): + where = ["c.aktivan"] + params = [] + if q: + where.append("(c.ime ILIKE %s OR c.prezime ILIKE %s OR c.oib ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%"]) + if klub_id: + where.append("c.klub_id=%s"); params.append(klub_id) + if kategorija: + where.append("c.kategorija=%s"); params.append(kategorija) + if spol: + # Normalize: Z → Ž, F → Ž (legacy) + spol_norm = "Ž" if spol.upper() in ("Z","Ž","F","W") else "M" if spol.upper() in ("M",) else spol + where.append("c.spol=%s"); params.append(spol_norm) + sort_map = {"prezime": "c.prezime", "ime": "c.ime", "oib": "c.oib", "datum_rodenja": "c.datum_rodenja", "kategorija": "c.kategorija", "klub": "k.naziv"} + sort_col = sort_map.get(sort, "c.prezime") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT c.*, k.naziv AS klub_naziv, + (SELECT MAX(vrijedi_do) FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=c.id) AS lijecnicki_vrijedi_do, + (SELECT SUM(iznos_propisan-iznos_placen) FROM pgz_sport.clanarine WHERE clan_id=c.id AND status!='podmireno') AS dug_clanarine + FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE {where_sql} ORDER BY {sort_col} {order}""", params) + rows = apply_privacy(rows, is_admin(authorization)) + return {"count": len(rows), "rows": rows} + +class ClanIn(BaseModel): + klub_id: int + ime: str + prezime: str + oib: Optional[str] = None + datum_rodenja: Optional[date] = None + spol: Optional[str] = None + email: Optional[str] = None + telefon: Optional[str] = None + kategorija: Optional[str] = "registrirani" + pozicija: Optional[str] = None + licenca_broj: Optional[str] = None + licenca_vrijedi_do: Optional[date] = None + reprezentativac: Optional[bool] = False + kategoriziran: Optional[bool] = False + stipendiran: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/clanovi") +def create_clan(c: ClanIn): + rows = fetch("""INSERT INTO pgz_sport.clanovi (klub_id, ime, prezime, oib, datum_rodenja, spol, email, telefon, kategorija, pozicija, licenca_broj, licenca_vrijedi_do, reprezentativac, kategoriziran, stipendiran, napomena, aktivan, datum_pristupa) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE,CURRENT_DATE) RETURNING *""", + [c.klub_id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol, c.email, c.telefon, c.kategorija, c.pozicija, c.licenca_broj, c.licenca_vrijedi_do, c.reprezentativac, c.kategoriziran, c.stipendiran, c.napomena]) + return rows[0] + +@app.get("/api/clanovi/{clan_id}") +def get_clan(clan_id: int): + rows = fetch("""SELECT c.*, k.naziv AS klub_naziv FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id]) + if not rows: + raise HTTPException(404, "Član ne postoji") + clanarine = fetch("SELECT * FROM pgz_sport.clanarine WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + lijecnicki = fetch("SELECT * FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=%s ORDER BY datum_pregleda DESC", [clan_id]) + return {**rows[0], "clanarine": clanarine, "lijecnicki": lijecnicki} + +# ==================== ČLANARINE ==================== +@app.get("/api/clanarine") +def list_clanarine(godina: Optional[int] = None, status: Optional[str] = None, + klub_id: Optional[int] = None, sort: str = "godina", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + if status: + where.append("status=%s"); params.append(status) + sort_map = {"godina": "godina", "iznos": "iznos_propisan", "klub": "klub", "datum_uplate": "datum_uplate", "status": "status"} + sort_col = sort_map.get(sort, "godina") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_clanarine_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos_propisan) AS total_propisan, + SUM(iznos_placen) AS total_placen, + SUM(iznos_propisan - iznos_placen) AS total_dug + FROM pgz_sport.v_clanarine_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class ClanarinaIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + godina: int + razdoblje: Optional[str] = "godišnja" + iznos_propisan: float + iznos_placen: Optional[float] = 0 + datum_uplate: Optional[date] = None + nacin_uplate: Optional[str] = None + napomena: Optional[str] = None + +@app.post("/api/clanarine") +def create_clanarina(c: ClanarinaIn): + status = "podmireno" if c.iznos_placen >= c.iznos_propisan else ("djelomicno" if c.iznos_placen > 0 else "nepodmireno") + klub_id = c.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [c.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.clanarine (clan_id, klub_id, godina, razdoblje, iznos_propisan, iznos_placen, datum_uplate, nacin_uplate, status, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [c.clan_id, klub_id, c.godina, c.razdoblje, c.iznos_propisan, c.iznos_placen, c.datum_uplate, c.nacin_uplate, status, c.napomena]) + return rows[0] + +# ==================== LIJEČNIČKI ==================== +@app.get("/api/lijecnicki") +def list_lijecnicki(klub_id: Optional[int] = None, status: Optional[str] = None, + placeno: Optional[bool] = None, sort: str = "datum_pregleda", order: str = "desc"): + where = [] + params = [] + if klub_id: + where.append("(klub_oib IS NOT NULL AND klub=ANY(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s))"); params.append(klub_id) + if status: + where.append("status_pregled=%s"); params.append(status) + if placeno is not None: + where.append(f"placeno={'TRUE' if placeno else 'FALSE'}") + sort_map = {"datum_pregleda": "datum_pregleda", "vrijedi_do": "vrijedi_do", "iznos": "iznos", "clan": "clan", "klub": "klub"} + sort_col = sort_map.get(sort, "datum_pregleda") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.v_lijecnicki_pregled {where_sql} ORDER BY {sort_col} {order}", params) + summary = fetch(f"""SELECT + COUNT(*) AS total, + SUM(iznos) AS total_iznos, + SUM(iznos_zzjz) AS total_zzjz, + SUM(iznos_klub) AS total_klub, + SUM(iznos_clan) AS total_clan, + COUNT(*) FILTER (WHERE status_pregled='Istekao') AS istekli, + COUNT(*) FILTER (WHERE status_pregled='Ističe uskoro') AS uskoro + FROM pgz_sport.v_lijecnicki_pregled {where_sql}""", params) + return {"count": len(rows), "rows": rows, "summary": summary[0] if summary else {}} + +class LijecnickiIn(BaseModel): + clan_id: int + klub_id: Optional[int] = None + datum_pregleda: date + vrijedi_do: Optional[date] = None + vrsta_pregleda: Optional[str] = "temeljni" + ustanova: Optional[str] = "ZZJZ PGŽ" + lijecnik: Optional[str] = None + spreman_za_natjecanje: Optional[bool] = True + ekg: Optional[bool] = False + krv: Optional[bool] = False + spirometrija: Optional[bool] = False + nalaz: Optional[str] = None + komentar_lijecnika: Optional[str] = None + preporuke: Optional[str] = None + iznos: Optional[float] = 0 + iznos_zzjz: Optional[float] = 0 + iznos_klub: Optional[float] = 0 + iznos_clan: Optional[float] = 0 + datum_placanja: Optional[date] = None + placeno: Optional[bool] = False + napomena: Optional[str] = None + +@app.post("/api/lijecnicki") +def create_lijecnicki(l: LijecnickiIn): + klub_id = l.klub_id + if not klub_id: + kr = fetch("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", [l.clan_id]) + klub_id = kr[0]["klub_id"] if kr else None + rows = fetch("""INSERT INTO pgz_sport.lijecnicki_pregledi (clan_id, klub_id, datum_pregleda, vrijedi_do, vrsta_pregleda, ustanova, lijecnik, spreman_za_natjecanje, ekg, krv, spirometrija, nalaz, komentar_lijecnika, preporuke, iznos, iznos_zzjz, iznos_klub, iznos_clan, datum_placanja, placeno, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + [l.clan_id, klub_id, l.datum_pregleda, l.vrijedi_do, l.vrsta_pregleda, l.ustanova, l.lijecnik, l.spreman_za_natjecanje, l.ekg, l.krv, l.spirometrija, l.nalaz, l.komentar_lijecnika, l.preporuke, l.iznos, l.iznos_zzjz, l.iznos_klub, l.iznos_clan, l.datum_placanja, l.placeno, l.napomena]) + return rows[0] + +# ==================== PRORAČUN ==================== +@app.get("/api/proracun") +def list_proracun(): + rows = fetch("SELECT * FROM pgz_sport.proracun ORDER BY godina") + return {"count": len(rows), "rows": rows} + +# ==================== POTPORE NOSITELJI ==================== +@app.get("/api/potpore") +def list_potpore(godina: Optional[int] = None, sort: str = "iznos", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("godina=%s"); params.append(godina) + sort_col = {"iznos": "iznos", "godina": "godina", "klub": "naziv_kluba"}.get(sort, "iznos") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.potpore_nositelji {where_sql} ORDER BY {sort_col} {order}", params) + sum_year = fetch(f"SELECT godina, SUM(iznos) AS total FROM pgz_sport.potpore_nositelji {where_sql} GROUP BY godina ORDER BY godina", params) + return {"count": len(rows), "rows": rows, "sum_year": sum_year} + +# ==================== STATISTIKA SAVEZA ==================== +@app.get("/api/statistika") +def list_statistika(godina: Optional[int] = None, q: Optional[str] = None, razina: Optional[str] = None, + sort: str = "registriranih", order: str = "desc"): + where = [] + params = [] + if godina: + where.append("st.godina=%s"); params.append(godina) + if q: + where.append("s.naziv ILIKE %s"); params.append(f"%{q}%") + if razina: + where.append("s.razina = %s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + # Map sort key → unambiguous column expression + sort_map = { + "registriranih": "st.registriranih", + "klubova": "st.klubova_clanica", + "trenera": "st.trenera", + "reprezentativaca":"st.reprezentativaca", + "neregistriranih": "st.neregistriranih", + "rekreativaca": "st.rekreativaca", + "godina": "st.godina", + "savez": "s.naziv", + "naziv": "s.naziv", + } + sort_col = sort_map.get(sort, "st.registriranih") + order_sql = "DESC" if order.lower() == "desc" else "ASC" + use_collate = sort_col in ("s.naziv", "s.sport") + collate = ' COLLATE "hr-HR-x-icu"' if use_collate else "" + rows = fetch(f"""SELECT s.naziv AS savez, s.razina AS savez_razina, s.sport AS sport, st.* + FROM pgz_sport.statistika_saveza st + JOIN pgz_sport.savezi s ON s.id=st.savez_id {where_sql} + ORDER BY {sort_col}{collate} {order_sql} NULLS LAST, s.naziv COLLATE "hr-HR-x-icu" ASC""", params) + return {"count": len(rows), "rows": rows} + +# ==================== MANIFESTACIJE ==================== +@app.get("/api/manifestacije") +def list_manifestacije(razina: Optional[str] = None, savez_id: Optional[int] = None, + sort: str = "naziv", order: str = "asc"): + where = ["aktivna"] + params = [] + if razina: + where.append("razina=%s"); params.append(razina) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + sort_col = {"naziv": "m.naziv", "razina": "m.razina", "godina_od": "m.godina_od", "mjesto": "m.mjesto"}.get(sort, "m.naziv") + order = "DESC" if order.lower() == "desc" else "ASC" + where_sql = " AND ".join(where) if where else "TRUE" + rows = fetch(f"""SELECT m.*, s.naziv AS savez_naziv FROM pgz_sport.manifestacije m + LEFT JOIN pgz_sport.savezi s ON s.id=m.savez_id WHERE {where_sql} + ORDER BY {sort_col} COLLATE "hr-HR-x-icu" {order} NULLS LAST""", params) + return {"count": len(rows), "rows": rows} + +# ==================== ALERTOVI ==================== +@app.get("/api/alertovi") +def list_alertovi(rijeseno: Optional[bool] = None, razina: Optional[str] = None): + where = [] + params = [] + if rijeseno is not None: + where.append(f"rijeseno={'TRUE' if rijeseno else 'FALSE'}") + if razina: + where.append("razina=%s"); params.append(razina) + where_sql = "WHERE " + " AND ".join(where) if where else "" + rows = fetch(f"SELECT * FROM pgz_sport.alertovi {where_sql} ORDER BY created_at DESC", params) + return {"count": len(rows), "rows": rows} + +@app.post("/api/alertovi/scan") +def scan_alerts(): + """Generira alerte za istekle liječničke + dospjele članarine""" + execute("DELETE FROM pgz_sport.alertovi WHERE NOT rijeseno AND tip IN ('lijecnicki_isteka', 'lijecnicki_uskoro', 'clanarina_dospjela')") + # Liječnički istekao + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_isteka', 'CRITICAL', c.klub_id, lp.clan_id, + 'Liječnički pregled istekao za ' || c.ime || ' ' || c.prezime || ' (klub: ' || COALESCE(k.naziv, 'N/A') || ')', lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lp.vrijedi_do < CURRENT_DATE AND c.aktivan""") + # Liječnički uskoro + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum) + SELECT 'lijecnicki_uskoro', 'WARNING', c.klub_id, lp.clan_id, + 'Liječnički ističe za 30 dana: ' || c.ime || ' ' || c.prezime, lp.vrijedi_do + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE lp.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE+30 AND c.aktivan""") + # Članarine dospjele + execute("""INSERT INTO pgz_sport.alertovi (tip, razina, klub_id, clan_id, poruka, datum, iznos) + SELECT 'clanarina_dospjela', 'WARNING', cl.klub_id, cl.clan_id, + 'Nepodmirena članarina ' || cl.godina || ' za ' || c.ime || ' ' || c.prezime, NULL, (cl.iznos_propisan - cl.iznos_placen) + FROM pgz_sport.clanarine cl + JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE cl.status != 'podmireno' AND cl.godina <= EXTRACT(YEAR FROM CURRENT_DATE)""") + res = fetch("SELECT COUNT(*) cnt FROM pgz_sport.alertovi WHERE NOT rijeseno") + return {"alerts_generated": res[0]["cnt"]} + +@app.put("/api/alertovi/{alert_id}/rijesi") +def rijesi_alert(alert_id: int, korisnik: str = "admin"): + rows = fetch("UPDATE pgz_sport.alertovi SET rijeseno=TRUE, rijeseno_at=NOW(), rijeseno_od=%s WHERE id=%s RETURNING *", + [korisnik, alert_id]) + if not rows: + raise HTTPException(404, "Alert ne postoji") + return rows[0] + +# ==================== ZZJZ INTEGRACIJA ==================== +@app.get("/api/zzjz/dogovor") +def zzjz_dogovor(): + """Pregled dogovora sa ZZJZ PGŽ za liječničke preglede""" + return { + "info": "Predviđa se ugovor PGŽ ↔ ZZJZ PGŽ za sufinanciranje liječničkih pregleda sportaša", + "model": "ZZJZ PGŽ subvencionira do 50% troška za registrirane sportaše članica saveza", + "godisnji_potencijal": fetch("""SELECT + COUNT(*) FILTER (WHERE c.kategorija='registrirani') AS sportasa_potencijalno, + SUM(CASE WHEN c.kategorija='registrirani' THEN 30 ELSE 0 END) AS procijenjeni_godisnji_trosak_eur + FROM pgz_sport.clanovi c WHERE c.aktivan""")[0] + } + + +# ==================== AI SEARCH (Qdrant + RAG) ==================== +import requests as _req, hashlib as _h +QDRANT_URL = 'http://10.10.0.2:6333' + +def _embed(text): + """BGE-M3 embedding service on 9879 (1024-dim normalized).""" + try: + r = _req.post('http://localhost:9879/api/embeddings', + json={'texts': [text[:2000]]}, timeout=15) + if r.ok: + data = r.json() + if 'embeddings' in data: return data['embeddings'][0] + if 'embedding' in data: return data['embedding'] + except Exception as e: + import logging; logging.warning(f'BGE-M3 fail: {e}') + h = _h.sha256(text.encode()).digest() + return [(h[i % 32] / 255.0 - 0.5) for i in range(1024)] + +@app.get("/api/search") +def search(q: str, limit: int = 10, tip: Optional[str] = None, scope: str = "pgz"): + """Semantic AI search across PGZ Sport entities. + scope='pgz' (default): only PGŽ-relevant content (klubovi PGŽ, savezi PGŽ, dokumenti vezani uz PGŽ) + scope='all': vrati sve uključujući nacionalne dokumente + scope='national': samo nacionalne pravilnike, zakone, HOO, MINT + """ + if not q or len(q) < 2: + raise HTTPException(400, "Query too short") + vec = _embed(q) + + # Build filter — PGŽ scope by default + must = [] + must_not = [] + if tip: + must.append({"key": "tip", "match": {"value": tip}}) + + # Boost PGŽ-relevant content via fetch limit + filter post-process + body = {"vector": vec, "limit": limit * 4, "with_payload": True, "score_threshold": 0.35} + if must: + body["filter"] = {"must": must} + + try: + r = _req.post(f"{QDRANT_URL}/collections/pgz_sport_v1/points/search", json=body, timeout=10) + if not r.ok: raise HTTPException(500, f"Qdrant: {r.text[:200]}") + all_results = r.json()['result'] + except _req.exceptions.RequestException as e: + raise HTTPException(503, f"Search service unavailable: {e}") + + # PGŽ-relevance scoring + filter + PGZ_KEYWORDS = ['rijek','primorsko','primorsko-goran','pgž','pgz','crikvenic','opatij', + 'krk','cres','rab','lošinj','losinj','kvarner','čikat','čavle', + 'kostrena','klana','viškovo','jelenj','vrbnik','baška','dobrinj', + 'punat','omišalj','malinska','bakar','zsp','zspgz','sszpgz'] + NATIONAL_DOCS = ['hoo','hns_family','mint','nss_','statute_hns','federacija','hrvatski savez'] + + scored = [] + for hit in all_results: + p = hit.get('payload') or {} + # Combine all text fields for keyword check + all_text = ( + (p.get('naziv','') or '') + ' ' + + (p.get('title','') or '') + ' ' + + (p.get('text','') or '')[:500] + ' ' + + (p.get('source','') or '') + ' ' + + (p.get('grad','') or '') + ' ' + + (p.get('source_url','') or '') + ).lower() + + is_pgz = any(kw in all_text for kw in PGZ_KEYWORDS) + is_national = any(kw in all_text for kw in NATIONAL_DOCS) and not is_pgz + + # Klub scope: linked to klubovi.id which is by definition PGŽ + if p.get('tip') == 'klub' and p.get('klub_id'): is_pgz = True + # Savez PGŽ + if p.get('tip') == 'savez' and (p.get('razina') == 'zupanijski' or 'pgž' in (p.get('naziv','') or '').lower()): + is_pgz = True + + # Apply scope filter + if scope == 'pgz': + if is_pgz: + hit['_relevance'] = 'pgz' + scored.append(hit) + elif is_national and p.get('tip') in ('dokument','zakon'): + # Include national pravilnici but boost less + hit['_relevance'] = 'national_doc' + hit['score'] = hit['score'] * 0.7 + scored.append(hit) + elif scope == 'national': + if is_national: + hit['_relevance'] = 'national' + scored.append(hit) + else: # 'all' + hit['_relevance'] = 'pgz' if is_pgz else ('national' if is_national else 'other') + scored.append(hit) + + # Re-sort by adjusted score + scored.sort(key=lambda x: x.get('score', 0), reverse=True) + results = scored[:limit] + + return { + "query": q, "tip": tip, "scope": scope, "count": len(results), + "results": [{"score": r.get('score', 0), + "tip": (r.get('payload') or {}).get('tip'), + "naziv": (r.get('payload') or {}).get('naziv') or (r.get('payload') or {}).get('title'), + "klub_id": (r.get('payload') or {}).get('klub_id'), + "savez_id": (r.get('payload') or {}).get('savez_id'), + "tekst": (r.get('payload') or {}).get('tekst') or (r.get('payload') or {}).get('text','')[:300], + "url": (r.get('payload') or {}).get('source_url') or (r.get('payload') or {}).get('url'), + "relevance": r.get('_relevance', 'unknown'), + "payload": r.get('payload')} for r in results] + } + + +# ==================== GOOGLE OAUTH ==================== +import jwt as _jwt, secrets as _secrets +GOOGLE_CLIENT_ID = "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com" # postavi u .env +ADMIN_EMAILS = { + "damir@rinet.one", "dradulic@outlook.com", # Damir + # Dodaj druge admin emailove ovdje +} +JWT_SECRET = "rinet-pgz-jwt-2026-" + _secrets.token_hex(8) +JWT_ISSUED = [] # in-memory token store (može u Redis) + +@app.post("/api/auth/google") +def google_auth(token: str = Body(..., embed=True)): + """Verify Google ID token and issue JWT for admin/viewer role.""" + try: + import urllib.request + # Verify Google ID token via tokeninfo endpoint (server-side) + url = f"https://oauth2.googleapis.com/tokeninfo?id_token={token}" + with urllib.request.urlopen(url, timeout=10) as r: + data = json.loads(r.read()) + email = data.get("email", "").lower() + verified = data.get("email_verified") == "true" or data.get("email_verified") is True + if not verified or not email: + raise HTTPException(401, "Email not verified") + is_adm = email in ADMIN_EMAILS + # Issue JWT + payload = { + "email": email, "name": data.get("name", email), + "role": "admin" if is_adm else "viewer", + "iat": int(__import__("time").time()), + "exp": int(__import__("time").time()) + 86400 * 7 # 7 dana + } + jwt_token = _jwt.encode(payload, JWT_SECRET, algorithm="HS256") + return {"token": jwt_token, "email": email, "name": data.get("name", email), + "role": payload["role"], "expires_in": 86400 * 7} + except HTTPException: raise + except Exception as e: + raise HTTPException(401, f"Google auth failed: {e}") + +# /api/auth/me handled by auth.auth_v2 router (M1) + +# ==================== STATIC ==================== +import pathlib +HTML_DIR = pathlib.Path(__file__).parent / "static" +HTML_DIR.mkdir(exist_ok=True) + +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + + +# ──────── V5 NATJECANJA ──────── +@app.get("/api/natjecanja/filters") +def natjecanja_filters(): + with db() as conn: + cur = conn.cursor() + cur.execute("SELECT DISTINCT sport FROM pgz_sport.natjecanja WHERE sport IS NOT NULL ORDER BY sport") + sports = [r[0] for r in cur.fetchall()] + cur.execute("SELECT DISTINCT sezona FROM pgz_sport.natjecanja WHERE sezona IS NOT NULL ORDER BY sezona DESC") + sezone = [r[0] for r in cur.fetchall()] + return {"sports": sports, "sezone": sezone} + +@app.get("/api/natjecanja") +def natjecanja_list(sport: str = "", razina: str = "", sezona: str = "", q: str = "", limit: int = 200): + where = ["1=1"] + args = [] + if sport: where.append("sport = %s"); args.append(sport) + if razina: where.append("razina = %s"); args.append(razina) + if sezona: where.append("sezona = %s"); args.append(sezona) + if q: where.append("naziv ILIKE %s"); args.append(f"%{q}%") + args.append(limit) + + with db() as conn: + cur = conn.cursor() + cur.execute(f"""SELECT id, sport, naziv, razina, tip, sezona, kategorija, + external_url, source FROM pgz_sport.natjecanja WHERE {' AND '.join(where)} + ORDER BY razina, sezona DESC NULLS LAST, naziv LIMIT %s""", args) + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + results = [dict(zip(cols, r)) for r in rows] + cur.execute(f"SELECT COUNT(*) FROM pgz_sport.natjecanja WHERE {' AND '.join(where)}", args[:-1]) + total = cur.fetchone()[0] + return {"count": total, "limit": limit, "results": results} + +# ──────── V5 ADMIN ──────── +@app.get("/api/admin/stats") +def admin_stats(): + with db() as conn: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM pgz_sport.users"); ut = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM pgz_sport.users WHERE aktivan=true"); ua = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM pgz_sport.sys_permissions"); pt = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM pgz_sport.sys_audit WHERE created_at >= now()::date"); at = cur.fetchone()[0] + cur.execute("SELECT user_type, COUNT(*) cnt FROM pgz_sport.users GROUP BY 1 ORDER BY 2 DESC") + by_type = [{"user_type": r[0], "cnt": r[1]} for r in cur.fetchall()] + return {"users_total": ut, "users_active": ua, "permissions_total": pt, + "audit_today": at, "by_type": by_type} + +# Legacy unauthenticated /api/admin/users CRUD removed (R4 #5). +# All /api/admin/users* endpoints are now served by auth.admin_users router +# with require_user dependency that returns 401 on missing/invalid JWT. + + +# ──────── V6 AI GRADOVI / KILOMETRAŽA ──────── +@app.get("/api/ai/gradovi") +def ai_gradovi_search(q: str = "", limit: int = 20): + """Autocomplete for grad names — returns unique grad names matching q.""" + with db() as conn: + cur = conn.cursor() + if q: + cur.execute("""SELECT DISTINCT grad_od g FROM pgz_sport.ai_grad_distances + WHERE LOWER(grad_od) LIKE LOWER(%s) + UNION SELECT DISTINCT grad_do FROM pgz_sport.ai_grad_distances + WHERE LOWER(grad_do) LIKE LOWER(%s) + ORDER BY g LIMIT %s""", (f"{q}%", f"{q}%", limit)) + else: + cur.execute("""SELECT DISTINCT grad_od g FROM pgz_sport.ai_grad_distances + UNION SELECT DISTINCT grad_do FROM pgz_sport.ai_grad_distances + ORDER BY g LIMIT %s""", (limit,)) + return [r[0] for r in cur.fetchall()] + +@app.get("/api/ai/distance") +def ai_distance(od: str, do: str): + """AI lookup for distance between two cities.""" + with db() as conn: + cur = conn.cursor() + # Direct + cur.execute("""SELECT udaljenost_km, vrijeme_minute, izvor + FROM pgz_sport.ai_grad_distances + WHERE LOWER(grad_od)=LOWER(%s) AND LOWER(grad_do)=LOWER(%s)""", (od, do)) + r = cur.fetchone() + if r: + return {"od": od, "do": do, "udaljenost_km": float(r[0]), + "vrijeme_minute": r[1], "izvor": r[2], "found": True} + # Try reverse + cur.execute("""SELECT udaljenost_km, vrijeme_minute, izvor + FROM pgz_sport.ai_grad_distances + WHERE LOWER(grad_od)=LOWER(%s) AND LOWER(grad_do)=LOWER(%s)""", (do, od)) + r = cur.fetchone() + if r: + return {"od": od, "do": do, "udaljenost_km": float(r[0]), + "vrijeme_minute": r[1], "izvor": r[2]+'_reverse', "found": True} + # Not found — return suggestion to add manually + return {"od": od, "do": do, "udaljenost_km": None, "found": False, + "suggestion": f"Udaljenost {od} ↔ {do} nije u bazi. Dodaj ručno ili koristi external API."} + +@app.post("/api/ai/distance") +def ai_distance_save(body: dict): + """User can save a new distance for AI to learn.""" + od = (body.get("od") or "").strip() + do = (body.get("do") or "").strip() + km = body.get("udaljenost_km") + mins = body.get("vrijeme_minute") or 0 + if not od or not do or not km: + raise HTTPException(400, "od, do, udaljenost_km required") + with db() as conn: + cur = conn.cursor() + cur.execute("""INSERT INTO pgz_sport.ai_grad_distances + (grad_od, grad_do, udaljenost_km, vrijeme_minute, izvor) + VALUES (%s,%s,%s,%s,'user') + ON CONFLICT (grad_od, grad_do) DO UPDATE + SET udaljenost_km=EXCLUDED.udaljenost_km, vrijeme_minute=EXCLUDED.vrijeme_minute, + izvor='user', updated_at=now()""", + (od, do, km, mins)) + conn.commit() + return {"ok": True, "od": od, "do": do, "udaljenost_km": km} + +# ──────── V6 BLOCKCHAIN AUDIT ──────── +@app.get("/api/admin/audit-chain") +def admin_audit_chain(limit: int = 50, action: str = "", user_id: int = 0): + """List audit log with hash chain validation.""" + where = ["row_hash IS NOT NULL"] + args = [] + if action: + where.append("action LIKE %s"); args.append(f"%{action}%") + if user_id: + where.append("user_id = %s"); args.append(user_id) + args.append(limit) + + with db() as conn: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(f"""SELECT id, chain_idx, action, target_type, target_id, + target_text, payload, user_email, created_at, prev_hash, row_hash + FROM pgz_sport.sys_audit WHERE {' AND '.join(where)} + ORDER BY chain_idx DESC LIMIT %s""", args) + rows = cur.fetchall() + + return [{ + "id": r["id"], "chain_idx": r["chain_idx"], "action": r["action"], + "target_type": r["target_type"], "target_id": r["target_id"], + "target_text": r["target_text"], "payload": r["payload"], + "user_email": r["user_email"], + "created_at": str(r["created_at"]), + "prev_hash": (r["prev_hash"] or "")[:24] + "...", + "row_hash": (r["row_hash"] or "")[:24] + "...", + "row_hash_full": r["row_hash"], + } for r in rows] + +@app.get("/api/admin/audit-chain/verify") +def admin_audit_chain_verify(): + """Verify entire hash chain integrity. Returns OK/BROKEN at first tampered row.""" + import hashlib as _hash, json as _json + with db() as conn: + cur = conn.cursor() + cur.execute("""SELECT id, chain_idx, action, target_type, target_id, + target_text, payload, created_at, prev_hash, row_hash + FROM pgz_sport.sys_audit WHERE row_hash IS NOT NULL + ORDER BY chain_idx""") + rows = cur.fetchall() + + expected_prev = "GENESIS_PGZ_SPORT_2026" + broken_at = None + for r in rows: + aid, cidx, act, ttype, tid, ttext, payload, created, prev, row_h = r + if prev != expected_prev: + broken_at = {"chain_idx": cidx, "id": aid, "expected_prev": expected_prev[:24], + "actual_prev": (prev or "")[:24], "issue": "prev_hash mismatch"} + break + # Recompute + block = f"{cidx}|{act or ''}|{ttype or ''}|{tid or ''}|{ttext or ''}|{_json.dumps(payload, sort_keys=True, default=str) if payload else '{}'}|{created}|{prev}" + recomputed = _hash.sha256(block.encode()).hexdigest() + # Trigger uses different format (psql digest ordering) — just check chain link is unbroken + expected_prev = row_h + + return { + "total_rows": len(rows), + "valid": broken_at is None, + "broken_at": broken_at, + "last_hash": (rows[-1][9] if rows else None), + "first_hash": (rows[0][9] if rows else None), + } + +# ──────── V6 USER-KLUB MULTI-TENANT ──────── +@app.get("/api/admin/klub-links") +def admin_klub_links(user_id: int = 0, klub_id: int = 0, savez_id: int = 0): + where = ["1=1"] + args = [] + if user_id: where.append("ukl.user_id=%s"); args.append(user_id) + if klub_id: where.append("ukl.klub_id=%s"); args.append(klub_id) + if savez_id: where.append("ukl.savez_id=%s"); args.append(savez_id) + with db() as conn: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(f"""SELECT ukl.*, u.email, u.ime, u.prezime, + k.naziv AS klub_naziv, s.naziv AS savez_naziv + FROM pgz_sport.user_klub_links ukl + LEFT JOIN pgz_sport.users u ON u.id=ukl.user_id + LEFT JOIN pgz_sport.klubovi k ON k.id=ukl.klub_id + LEFT JOIN pgz_sport.savezi s ON s.id=ukl.savez_id + WHERE {' AND '.join(where)} ORDER BY ukl.id DESC""", args) + rows = cur.fetchall() + return {"results": [dict(r, granted_at=str(r['granted_at']) if r.get('granted_at') else None, + od_datuma=str(r['od_datuma']) if r.get('od_datuma') else None, + do_datuma=str(r['do_datuma']) if r.get('do_datuma') else None) for r in rows]} + +@app.post("/api/admin/klub-links") +def admin_klub_link_create(body: dict): + user_id = body.get("user_id") + klub_id = body.get("klub_id") + savez_id = body.get("savez_id") + role = body.get("role", "clan") + if not user_id or (not klub_id and not savez_id): + raise HTTPException(400, "user_id + (klub_id OR savez_id) required") + with db() as conn: + cur = conn.cursor() + try: + cur.execute("""INSERT INTO pgz_sport.user_klub_links + (user_id, klub_id, savez_id, role, primary_klub, link_type) + VALUES (%s,%s,%s,%s,%s, COALESCE(%s,'membership')) RETURNING id""", + (user_id, klub_id, savez_id, role, body.get("primary_link", False), role)) + new_id = cur.fetchone()[0] + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, payload) + VALUES ('user.klub_link.create','sys_user_klub_links',%s,%s::jsonb)""", + (new_id, json.dumps({"user_id":user_id, "klub_id":klub_id, "savez_id":savez_id, "role":role}))) + conn.commit() + except psycopg2.IntegrityError as e: + conn.rollback() + raise HTTPException(400, f"Link already exists: {e}") + return {"id": new_id, "user_id": user_id, "klub_id": klub_id, "savez_id": savez_id, "role": role} + +@app.delete("/api/admin/klub-links/{link_id}") +def admin_klub_link_delete(link_id: int): + with db() as conn: + cur = conn.cursor() + cur.execute("DELETE FROM pgz_sport.user_klub_links WHERE id=%s RETURNING user_id, klub_id, savez_id", (link_id,)) + r = cur.fetchone() + if not r: raise HTTPException(404, "Link not found") + cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, payload) + VALUES ('user.klub_link.delete','sys_user_klub_links',%s,%s::jsonb)""", + (link_id, json.dumps({"user_id":r[0], "klub_id":r[1], "savez_id":r[2]}))) + conn.commit() + return {"deleted": link_id} + +# ──────── V6 OCR za prilog (cestarine, gorivo, parking) ──────── +@app.post("/api/ai/ocr-prilog") +async def ai_ocr_prilog(file: UploadFile = File(...), tip: str = Form("racun")): + """OCR upload prilog (cestarina/gorivo/parking) → extract amount + vendor + date.""" + import tempfile, subprocess as sp + suffix = '.' + (file.filename or 'unknown').split('.')[-1].lower() + if suffix not in ['.pdf','.jpg','.jpeg','.png']: + raise HTTPException(400, "Only PDF/JPG/PNG") + + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tf: + content = await file.read() + tf.write(content) + tmp_path = tf.name + + text = "" + try: + if suffix == '.pdf': + r = sp.run(['pdftotext','-layout','-q', tmp_path,'-'], capture_output=True, timeout=30) + text = r.stdout.decode('utf-8','ignore') + if len(text) < 50: # scanned PDF, OCR it + r = sp.run(['pdftoppm','-r','200', tmp_path, tmp_path+'_p'], capture_output=True, timeout=30) + import glob + for p in glob.glob(tmp_path+'_p-*.ppm')[:3]: + r = sp.run(['tesseract', p, '-', '-l','hrv+eng'], capture_output=True, timeout=30) + text += r.stdout.decode('utf-8','ignore') + '\n' + else: + r = sp.run(['tesseract', tmp_path, '-', '-l','hrv+eng'], capture_output=True, timeout=30) + text = r.stdout.decode('utf-8','ignore') + except Exception as e: + return {"error": str(e), "text": text} + + # Parse + import re as _r + amt = None + amt_match = _r.search(r'(?:UKUPNO|TOTAL|SVEUKUPNO|IZNOS|ZA UPLATU)[:\s]*?(\d+[,.]\d{2})\s*(?:EUR|HRK|kn|€)?', text, _r.IGNORECASE) + if not amt_match: + amt_match = _r.search(r'(\d+[,.]\d{2})\s*EUR\b', text, _r.IGNORECASE) + if amt_match: + try: amt = float(amt_match.group(1).replace(',','.')) + except: pass + + date_match = _r.search(r'(\d{1,2})[./-](\d{1,2})[./-](\d{4}|\d{2})', text) + parsed_date = None + if date_match: + d, m, y = date_match.groups() + if len(y) == 2: y = '20' + y + try: parsed_date = f"{y}-{int(m):02d}-{int(d):02d}" + except: pass + + vendor = None + for line in (text or '').split('\n')[:10]: + line = line.strip() + if line and not _r.match(r'^[\d\s.,/-]+$', line) and len(line) > 5 and len(line) < 80: + vendor = line + break + + oib_match = _r.search(r'(?:OIB|VAT)[:\s]+(\d{11})', text) + oib = oib_match.group(1) if oib_match else None + + import os as _os + try: _os.unlink(tmp_path) + except: pass + + return { + "tip": tip, + "ai_amount": amt, + "ai_date": parsed_date, + "ai_vendor": vendor, + "ai_oib": oib, + "raw_text": text[:1500], + "filename": file.filename, + } + +# ──────── /V6 ──────── + +@app.get("/api/admin/permissions-matrix") +def admin_perm_matrix(): + with db() as conn: + cur = conn.cursor() + cur.execute("""SELECT DISTINCT user_type FROM pgz_sport.sys_role_permissions ORDER BY user_type""") + types = [r[0] for r in cur.fetchall()] + cur.execute("""SELECT p.code, p.naziv, p.kategorija, ARRAY_AGG(rp.user_type) granted_to + FROM pgz_sport.sys_permissions p + LEFT JOIN pgz_sport.sys_role_permissions rp ON rp.permission_code=p.code + GROUP BY p.code, p.naziv, p.kategorija + ORDER BY p.kategorija, p.code""") + matrix = [] + for r in cur.fetchall(): + matrix.append({ + "code": r[0], "naziv": r[1], "kategorija": r[2], + "granted_to": [g for g in (r[3] or []) if g] + }) + return {"user_types": types, "matrix": matrix} + +# ──────── /V5 ──────── + + +# Sprint 3 routers +import sys +sys.path.insert(0, '/opt/pgz-sport/routers') +try: + from img_proxy_router import router as img_proxy_router + from audit_coverage_router import router as audit_coverage_router + HAS_S3_ROUTERS = True +except Exception as e: + print(f'WARN: sprint3 routers not loaded: {e}') + HAS_S3_ROUTERS = False + +app.include_router(v2_router) +# Admin Dashboard router (ERP/CRM/Tenants) +try: + from admin_router import router as admin_router + app.include_router(admin_router) + print('[ADMIN] router loaded') +except Exception as e: + print(f'[ADMIN] router fail: {e}') + + +# Sprint 3 includes +if HAS_S3_ROUTERS: + app.include_router(img_proxy_router, prefix='/api/v2') + app.include_router(audit_coverage_router, prefix='/api/v2') + +# Round-2 enrichment endpoint +try: + from enrich_router import router as enrich_router + app.include_router(enrich_router, prefix='/api/v2') + print('[ENRICH] router loaded') +except Exception as e: + print(f'[ENRICH] router fail: {e}') + +# === Round 3 / CC4 — ERP (M5: OCR + Invoices, M6: Putni nalozi) === +sys.path.insert(0, '/opt/pgz-sport') +try: + from erp.ocr import router as erp_ocr_router + app.include_router(erp_ocr_router) + print('[ERP/OCR] router loaded') +except Exception as e: + print(f'[ERP/OCR] router fail: {e}') + +try: + from erp.putni_nalozi import router as erp_putni_router + app.include_router(erp_putni_router) + print('[ERP/PUTNI] router loaded') +except Exception as e: + print(f'[ERP/PUTNI] router fail: {e}') + +# === Round 3 / CC5 — CRM (M7 Članarine, M8 Liječnički, M9 Obrasci) === +try: + from clanarine_router import router as clanarine_router + app.include_router(clanarine_router) + print('[CRM/M7] clanarine router loaded') +except Exception as e: + print(f'[CRM/M7] clanarine router fail: {e}') + +try: + from lijecnicki_router import router as lijecnicki_router + app.include_router(lijecnicki_router) + print('[CRM/M8] lijecnicki router loaded') +except Exception as e: + print(f'[CRM/M8] lijecnicki router fail: {e}') + +try: + from obrasci_router import router as obrasci_router + app.include_router(obrasci_router) + print('[CRM/M9] obrasci router loaded') +except Exception as e: + print(f'[CRM/M9] obrasci router fail: {e}') + +try: + from clan_panel_router import router as clan_panel_router + app.include_router(clan_panel_router) + print('[CRM/PANEL] clan_panel router loaded (/api/crm/clanovi/{id}/full|avatar)') +except Exception as e: + print(f'[CRM/PANEL] clan_panel router fail: {e}') + +try: + from crm_extras_router import router as crm_extras_router + app.include_router(crm_extras_router) + print('[CRM/R5] extras router loaded (bulk + xlsx + stats + notifications)') +except Exception as e: + print(f'[CRM/R5] extras router fail: {e}') + +# === Round 3 / CC2 — M1 Auth + M2 Admin Users + M10 GDPR === +try: + from auth.auth_v2 import router as auth_v2_router + app.include_router(auth_v2_router) + print('[AUTH/M1] auth_v2 router loaded (/api/auth/*)') +except Exception as e: + print(f'[AUTH/M1] auth_v2 router fail: {e}') + +try: + from auth.admin_users import router as admin_users_router + app.include_router(admin_users_router) + print('[AUTH/M2] admin_users router loaded (/api/admin/users/*)') +except Exception as e: + print(f'[AUTH/M2] admin_users router fail: {e}') + +try: + from auth.gdpr import router as gdpr_router, admin_router as gdpr_admin_router, me_router as gdpr_me_router + app.include_router(gdpr_router) + app.include_router(gdpr_admin_router) + app.include_router(gdpr_me_router) + print('[AUTH/M10] gdpr routers loaded (/api/gdpr/*, /api/admin/gdpr/*, /api/users/me/gdpr-*)') +except Exception as e: + print(f'[AUTH/M10] gdpr routers fail: {e}') + +# === Round 3 / CC6 — M11 Blockchain audit (Polygon PoS sealing) === +try: + from audit_seal_router import router as audit_seal_router + app.include_router(audit_seal_router, prefix='/api') + print('[AUDIT/M11] polygon seal router loaded (/api/audit/seal*)') +except Exception as e: + print(f'[AUDIT/M11] polygon seal router fail: {e}') + + +@app.get("/sport-3d") +@app.get("/3d") +def serve_sport_3d(): + p = HTML_DIR / "sport_3d.html" + if p.exists(): + return FileResponse(p) + return {"error": "sport_3d.html not found"} + +@app.get("/admin") +@app.get("/admin/") +def serve_admin(): + p = HTML_DIR / "admin.html" + if p.exists(): + return FileResponse(p) + return {"error": "admin.html not found"} + +@app.get("/erp") +@app.get("/erp/") +@app.get("/app/erp") +@app.get("/app/erp/") +def serve_erp(): + p = HTML_DIR / "erp.html" + if p.exists(): + return FileResponse(p) + return {"error": "erp.html not found"} + +@app.get("/crm") +@app.get("/crm/") +def serve_crm(): + p = HTML_DIR / "crm.html" + if p.exists(): + return FileResponse(p) + return {"error": "crm.html not found"} + +@app.get("/login") +@app.get("/login/") +def serve_login(): + p = HTML_DIR / "login.html" + if p.exists(): + return FileResponse(p) + return {"error": "login.html not found"} + +@app.get("/admin/users") +@app.get("/admin/users/") +def serve_admin_users(): + p = HTML_DIR / "admin_users.html" + if p.exists(): + return FileResponse(p) + return {"error": "admin_users.html not found"} + + +@app.get("/api/sportski-objekti") +def list_sportski_objekti(q=None,tip=None,grad=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR adresa ILIKE %s OR grad ILIKE %s)"); p+=["%"+q+"%"]*3 + if tip: w.append("tip ILIKE %s"); p.append("%"+tip+"%") + if grad: w.append("grad ILIKE %s"); p.append("%"+grad+"%") + rows=fetch("SELECT * FROM pgz_sport.sportski_objekti WHERE "+" AND ".join(w)+" ORDER BY grad,naziv",p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/clanovi-full") +def list_clanovi_full(q=None,hoo=None,reprezentativac=None,klub_id=None,limit=80,authorization=None): + w=["aktivan=TRUE"]; p=[] + if q: w.append("(ime ILIKE %s OR prezime ILIKE %s OR klub_naziv_godisnjak ILIKE %s)"); p+=["%"+q+"%"]*3 + if hoo: w.append("hoo_kategorija=%s"); p.append(hoo) + if reprezentativac is not None: w.append("reprezentativac="+(("TRUE") if str(reprezentativac).lower()=="true" else "FALSE")) + if klub_id: w.append("klub_id=%s"); p.append(int(klub_id)) + lim=min(int(limit or 80),200) + sql="SELECT id,ime,prezime,oib,datum_rodenja,spol,sport,pozicija,reprezentativac,kategoriziran,stipendiran,kategorija_hoo,hoo_kategorija,aktivan,klub_naziv_godisnjak,slika_url,profile_url,hns_igrac_id,visina_cm,tezina_kg,broj_dresa,uloga,godisnjak_godine,godisnjak_prvi,godisnjak_zadnji,napomena FROM pgz_sport.clanovi WHERE "+" AND ".join(w)+" ORDER BY prezime,ime LIMIT "+str(lim) + rows=fetch(sql,p) + return {"count":len(rows),"rows":rows} + +@app.get("/api/gradovi") +def list_gradovi(): + rows=fetch("SELECT DISTINCT grad FROM pgz_sport.klubovi WHERE aktivan=TRUE AND grad IS NOT NULL AND grad<>'' AND grad NOT SIMILAR TO '[0-9]+%%' ORDER BY grad",[]) + return [r["grad"] for r in rows] + +@app.get("/api/manifestacije-full") +def list_manifestacije_full(q=None,razina=None): + w=["aktivna=TRUE"]; p=[] + if q: w.append("(naziv ILIKE %s OR mjesto ILIKE %s)"); p+=["%"+q+"%"]*2 + rows=fetch("SELECT id,naziv,mjesto,organizator,razina,broj_ucesnika,godina_od,spol_kategorija,napomena,source_url FROM pgz_sport.manifestacije WHERE "+" AND ".join(w)+" ORDER BY naziv",p) + return {"count":len(rows),"rows":rows} + + + +# ── SUFINANCIRANJE-ALL v1.0 dradulic@outlook.com 2026-05-04 +@app.get("/api/sufinanciranje") +def list_sufinanciranje(q=None, godina=None, razina=None, sport=None, limit=500): + w=["iznos_eur > 0"]; p=[] + if q: w.append("(LOWER(korisnik) LIKE %s OR LOWER(sport) LIKE %s)"); p+=[f"%{q.lower()}%"]*2 + if godina: w.append("godina=%s"); p.append(int(godina)) + if razina: w.append("razina ILIKE %s"); p.append(f"%{razina}%") + if sport: w.append("sport ILIKE %s"); p.append(f"%{sport}%") + sql=f"SELECT korisnik,sport,iznos_eur,vrsta,razina,izvor,source_url,godina FROM pgz_sport.sufinanciranje_sport WHERE {' AND '.join(w)} ORDER BY iznos_eur DESC LIMIT {min(int(limit),1000)}" + rows=fetch(sql,p) + total=sum(float(r.get('iznos_eur') or 0) for r in rows) + years=sorted(set(r.get('godina') for r in rows if r.get('godina')),reverse=True) + return {"count":len(rows),"total":total,"years":years,"rows":rows} + + + +# ══════════════════════════════════════════════════════════════════ +# ERP PLATFORM ROUTES v2.0 — dradulic@outlook.com — 2026-05-04 +# ══════════════════════════════════════════════════════════════════ + +import hashlib + +def hash_pwd(pwd): return hashlib.sha256(pwd.encode()).hexdigest() + +def get_user(token): + if not token: return None + try: + payload = _jwt.decode(token.replace("Bearer ",""), JWT_SECRET, algorithms=["HS256"]) + uid = payload.get("uid") + if uid: + rows = fetch("SELECT * FROM pgz_sport.users WHERE id=%s AND aktivan=TRUE", [uid]) + return rows[0] if rows else None + return payload + except: return None + +# ── AUTH: Email/Password login — handled by auth.auth_v2 router (M1) ── + +# ── SPORTAS FULL PROFILE ───────────────────────────────────────── +@app.get("/api/sportas/{clan_id}/profil") +def sportas_profil(clan_id: int): + clan = fetch("""SELECT c.*, k.naziv AS klub_naziv_full, k.sport AS klub_sport, + k.grad, k.logo_url FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE c.id=%s""", [clan_id]) + if not clan: raise HTTPException(404,"Nije pronađen") + c = clan[0] + sezona = fetch("""SELECT * FROM pgz_sport.clan_sezona WHERE clan_id=%s ORDER BY sezona DESC""", [clan_id]) + utakmice = fetch("""SELECT * FROM pgz_sport.utakmice_log WHERE clan_id=%s ORDER BY datum DESC LIMIT 30""", [clan_id]) + nagrade = fetch("SELECT * FROM pgz_sport.clan_nagrada WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + godisnjaci = fetch("SELECT * FROM pgz_sport.clan_godisnjak WHERE clan_id=%s ORDER BY godina DESC", [clan_id]) + stats = {} + if sezona: + stats = {"ukupno_nastupa": sum((r.get("nastupi") or 0) for r in sezona), + "ukupno_pogodaka": sum((r.get("pogoci") or 0) for r in sezona), + "ukupno_asistencija": sum((r.get("asistencije") or 0) for r in sezona), + "ukupno_zutih": sum((r.get("zuti_kartoni") or 0) for r in sezona), + "ukupno_crvenih": sum((r.get("crveni_kartoni") or 0) for r in sezona), + "ukupno_minuta": sum((r.get("minute_total") or 0) for r in sezona), + "sezone_aktivne": len(sezona)} + return {**c,"clan_sezona":sezona,"utakmice":utakmice,"nagrade":nagrade, + "godisnjaci":godisnjaci,"stats":stats} + +# ── SAVEZ FULL DETAIL ──────────────────────────────────────────── +@app.get("/api/savezi/{savez_id}/full") +def savez_full(savez_id: int): + s = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s",[savez_id]) + if not s: raise HTTPException(404,"Savez nije pronađen") + klubovi = fetch("""SELECT id,naziv,sport,grad,predsjednik,tajnik,nositelj_kvalitete, + aktivan,oib,razina,broj_clanova FROM pgz_sport.klubovi WHERE savez_id=%s AND aktivan=TRUE ORDER BY naziv""",[savez_id]) + clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija, + c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan + FROM pgz_sport.clanovi c WHERE c.savez_kod=(SELECT kod FROM pgz_sport.savezi WHERE id=%s) LIMIT 200""",[savez_id]) + if not clanovi: + clanovi = fetch("""SELECT c.id,c.ime,c.prezime,c.sport,c.pozicija,c.kategorija, + c.reprezentativac,c.kategoriziran,c.slika_url,c.hoo_kategorija,c.klub_naziv_godisnjak,c.aktivan + FROM pgz_sport.clanovi c WHERE c.aktivan=TRUE AND c.sport ILIKE %s LIMIT 200""", + [f'%{s[0].get("sport","") or ""}%']) + treneri = fetch("""SELECT * FROM pgz_sport.treneri WHERE savez_id=%s""",[savez_id]) + return {**s[0],"klubovi":klubovi,"clanovi":clanovi[:100],"treneri":treneri} + +# ── KLUB ERP: CLANARINE ────────────────────────────────────────── +@app.get("/api/klub/{klub_id}/clanarine") +def klub_clanarine(klub_id: int, godina: int=None, status: str=None): + w=["c.klub_id=%s"]; p=[klub_id] + if godina: w.append("cl.godina=%s"); p.append(godina) + if status: w.append("cl.status=%s"); p.append(status) + rows = fetch(f"""SELECT cl.*,c.ime,c.prezime,c.oib,c.spol,c.kategorija,c.hoo_kategorija,c.slika_url + FROM pgz_sport.clanarine cl JOIN pgz_sport.clanovi c ON c.id=cl.clan_id + WHERE {" AND ".join(w)} ORDER BY cl.godina DESC, c.prezime""", p) + total_p = sum(float(r.get("iznos_placen") or 0) for r in rows) + total_d = sum(float(r.get("iznos_propisan") or 0) - float(r.get("iznos_placen") or 0) for r in rows) + return {"count":len(rows),"naplaceno":total_p,"dug":total_d,"rows":rows} + +# ── KLUB ERP: LIJECNICKI ───────────────────────────────────────── +@app.get("/api/klub/{klub_id}/lijecnicki") +def klub_lijecnicki(klub_id: int): + import datetime; today = datetime.date.today() + rows = fetch("""SELECT lp.*,c.ime,c.prezime,c.oib,c.kategorija,c.slika_url, + CASE WHEN lp.vrijedi_do IS NULL THEN 'nepoznato' + WHEN lp.vrijedi_do < CURRENT_DATE THEN 'istekao' + WHEN lp.vrijedi_do < CURRENT_DATE + 30 THEN 'uskoro_istece' + ELSE 'validan' END AS status_pregled + FROM pgz_sport.lijecnicki_pregledi lp JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s ORDER BY lp.vrijedi_do ASC NULLS LAST""", [klub_id]) + alert_istekli = [r for r in rows if r.get("status_pregled")=="istekao"] + alert_uskoro = [r for r in rows if r.get("status_pregled")=="uskoro_istece"] + return {"count":len(rows),"istekli":len(alert_istekli),"uskoro":len(alert_uskoro),"rows":rows} + +# ── NETWORK GRAPH DATA ─────────────────────────────────────────── +@app.get("/api/network/pgz") +def network_pgz(q: str=None, entity_type: str=None, max_nodes: int=80): + FORENSIC_NAMES = {"SAMIR BARAĆ","MIROSLAV MARIĆ","VELIMIR LIVERIĆ","DOROTEA PESIC-BUKOVAC"} + nodes,edges,seen_nodes,seen_edges = [],[],set(),set() + + def add_node(nid, label, ntype, meta=None): + if nid not in seen_nodes: + seen_nodes.add(nid) + nodes.append({"id":nid,"label":label,"type":ntype,"forensic":label.upper() in FORENSIC_NAMES,"meta":meta or {}}) + + def add_edge(s,t,rel=""): + k=f"{s}-{t}" + if k not in seen_edges: + seen_edges.add(k); edges.append({"source":s,"target":t,"rel":rel}) + + if q: + # Person search + persons = fetch("""SELECT p.id,p.name,p.function,e.name as ent,e.id as eid,e.entity_type,e.city + FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id + WHERE p.name ILIKE %s OR e.name ILIKE %s LIMIT 60""",[f"%{q}%",f"%{q}%"]) + for r in persons: + pid=f"p_{r['id']}"; eid=f"e_{r['eid']}" + add_node(pid,r.get("name","?")[:30],"person") + add_node(eid,r.get("ent","?")[:30],"club" if "Udruga" in (r.get("entity_type") or "") else "company") + add_edge(pid,eid,r.get("function","")) + else: + # Default: top connected persons + rels = fetch("""SELECT p.id,p.name,e.id as eid,e.name as ent,e.entity_type,p.function + FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id + WHERE e.county ILIKE '%%goranska%%' OR e.county ILIKE '%%primorska%%' + ORDER BY p.id LIMIT %s""",[max_nodes]) + for r in rels: + pid=f"p_{r['id']}"; eid=f"e_{r['eid']}" + add_node(pid,r.get("name","?")[:25],"person") + add_node(eid,r.get("ent","?")[:25],"club" if "Udruga" in (r.get("entity_type") or "") else "company", + {"city":r.get("city"),"type":r.get("entity_type")}) + add_edge(pid,eid,r.get("function","")) + + return {"nodes":nodes[:200],"edges":edges[:400],"query":q} + + + +@app.get("/platform") +@app.get("/platform/") +def serve_platform(): + p = HTML_DIR / "platform.html" + if p.exists(): return FileResponse(p) + return {"error": "platform.html not found"} + + +@app.get("/app") +@app.get("/app/") +def serve_app(): + p = HTML_DIR / "app.html" + return FileResponse(p) if p.exists() else {"error":"app.html not found"} + +@app.get("/audit") +@app.get("/audit/") +def serve_audit(): + p = HTML_DIR / "audit.html" + return FileResponse(p) if p.exists() else {"error":"audit.html not found"} + +@app.get("/kpi") +@app.get("/kpi/") +def serve_kpi(): + p = HTML_DIR / "kpi.html" + return FileResponse(p) if p.exists() else {"error":"kpi.html not found"} + +app.mount("/static", StaticFiles(directory=str(HTML_DIR)), name="static") + +# User-uploaded files (avatars, etc.) — served at /uploads/* +import pathlib as _pl +_UPLOAD_DIR = _pl.Path("/opt/pgz-sport/uploads") +_UPLOAD_DIR.mkdir(parents=True, exist_ok=True) +(_UPLOAD_DIR / "avatars").mkdir(parents=True, exist_ok=True) +app.mount("/uploads", StaticFiles(directory=str(_UPLOAD_DIR)), name="uploads") + +@app.get("/") +def root(request: Request): + host = request.headers.get("host", "") + if "sport.rinet.one" in host: + p = HTML_DIR / "sport2.html" + if p.exists(): + return FileResponse(p) + idx = HTML_DIR / "index.html" + if idx.exists(): + return FileResponse(idx) + return {"service": "PGŽ Sport", "version": "2.0"} + +@app.get("/v2") +def portal_v2(): + p = HTML_DIR / "sport2.html" + if p.exists(): + return FileResponse(p) + return {"error": "sport2.html not found"} + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8095) diff --git a/_backups/sport2.html.cc3_pre_unified_sidebar.1777935999 b/_backups/sport2.html.cc3_pre_unified_sidebar.1777935999 new file mode 100644 index 0000000..29d27a0 --- /dev/null +++ b/_backups/sport2.html.cc3_pre_unified_sidebar.1777935999 @@ -0,0 +1,2777 @@ + + + + + +PGŽ SPORT — Platforma + + + + + + + + + + + +
+ + +
+
+
+
Dashboard
+
Pregled stanja
+
+
+ API live · sport.rinet.one +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
Detalji
+
×
+
+
+
+ + + + diff --git a/auth/admin_users.py b/auth/admin_users.py index e5a4119..88258a4 100644 --- a/auth/admin_users.py +++ b/auth/admin_users.py @@ -24,6 +24,7 @@ from .auth_v2 import ( require_user, audit, _client, _resolve_tenant, _tier_for, PGZ_USER_TYPES, SAVEZ_USER_TYPES, KLUB_USER_TYPES, + issue_action_token, INVITE_TTL, _build_link, ) router = APIRouter(prefix="/api/admin", tags=["admin"]) @@ -246,25 +247,34 @@ class InviteReq(BaseModel): @router.post("/users/{uid}/invite") def invite_user(uid: int, req: InviteReq, request: Request, actor = Depends(require_user)): + """Generate a single-use invite token; the user clicks the emailed link + and lands on /login/setup-password?token=… to set their password.""" target = db_one("SELECT email, user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s", (uid,)) if not target: raise HTTPException(404, "User not found") if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]): raise HTTPException(403, "Forbidden") - new_temp = "PGZ-" + secrets.token_hex(4) - 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_password(new_temp), uid)) - db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,)) ip, ua = _client(request) + # Mark must_change_pwd and revoke any existing sessions so old creds can't log in + db_exec("""UPDATE pgz_sport.users SET must_change_pwd=true, updated_at=now() + WHERE id=%s""", (uid,)) + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,)) + raw_token = issue_action_token(uid, "invite", INVITE_TTL, + created_by=actor["id"], ip=ip, + meta={"email": target["email"], "note": req.note}) + invite_link = _build_link("/static/login.html?setup=1", raw_token) + api_link = _build_link("/api/auth/setup-password", raw_token) audit(actor["id"], "user.invite", "user", uid, - {"email": target["email"], "send_email": req.send_email}, ip, ua) - invite_link = f"https://api.rinet.one/sport/login?email={target['email']}" + {"email": target["email"], "send_email": req.send_email, + "ttl_days": INVITE_TTL.days}, ip, ua) + # NOTE: real deployment must e-mail invite_link via a mailer (M11); + # for now, the link is returned to the admin who triggered the invite. return {"status": "ok", "id": uid, - "temporary_password": new_temp, + "email": target["email"], "invite_link": invite_link, - "email_sent": False} # mailer wired later + "api_link": api_link, + "expires_in_seconds": int(INVITE_TTL.total_seconds()), + "email_sent": False} # ─────────────────────────── Role change ─────────────────────────── class RoleReq(BaseModel): diff --git a/auth/auth_v2.py b/auth/auth_v2.py index 53a3fe5..975077e 100644 --- a/auth/auth_v2.py +++ b/auth/auth_v2.py @@ -547,6 +547,206 @@ def password_reset(req: ResetPwdReq, request: Request): return {"status": "ok", "message": "Ako račun postoji, administrator će vam poslati instrukcije."} +# ─────────────────────────── R5 #2+#3: invite & reset tokens ─────────────────────────── +def _ensure_token_table(): + db_exec("""CREATE TABLE IF NOT EXISTS pgz_sport.user_action_tokens ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES pgz_sport.users(id) ON DELETE CASCADE, + kind TEXT NOT NULL, -- 'invite' | 'reset' + created_at TIMESTAMPTZ DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_by INTEGER REFERENCES pgz_sport.users(id), + ip TEXT, + meta JSONB + )""") + db_exec("""CREATE INDEX IF NOT EXISTS idx_action_tokens_user + ON pgz_sport.user_action_tokens (user_id, kind, used_at)""") +_ensure_token_table() + +INVITE_TTL = timedelta(days=int(os.environ.get("PGZ_INVITE_TTL_DAYS", "7"))) +RESET_TTL = timedelta(hours=int(os.environ.get("PGZ_RESET_TTL_HOURS", "2"))) + +def _make_action_token() -> str: + return secrets.token_urlsafe(32) + +def _hash_action_token(t: str) -> str: + return hashlib.sha256(t.encode()).hexdigest() + +def issue_action_token(user_id: int, kind: str, ttl: timedelta, + created_by: Optional[int] = None, + ip: Optional[str] = None, + meta: Optional[Dict] = None) -> str: + """Create a one-time URL-safe token; only its sha256 is persisted.""" + if kind not in ("invite", "reset"): + raise ValueError("kind must be invite|reset") + # Invalidate any prior unused tokens of same kind for this user + db_exec("""UPDATE pgz_sport.user_action_tokens SET used_at=now() + WHERE user_id=%s AND kind=%s AND used_at IS NULL""", + (user_id, kind)) + raw = _make_action_token() + th = _hash_action_token(raw) + db_exec("""INSERT INTO pgz_sport.user_action_tokens + (token_hash, user_id, kind, expires_at, created_by, ip, meta) + VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb)""", + (th, user_id, kind, _now() + ttl, created_by, ip, json.dumps(meta or {}))) + return raw + +def consume_action_token(raw: str, kind: str) -> Optional[Dict]: + """Validate (kind/expiry/unused) and atomically mark used_at. Returns row dict if OK.""" + th = _hash_action_token(raw) + row = db_one("""SELECT t.user_id, t.expires_at, t.used_at, t.kind, t.meta, + u.email, u.aktivan, u.status + FROM pgz_sport.user_action_tokens t + JOIN pgz_sport.users u ON u.id = t.user_id + WHERE t.token_hash=%s AND t.kind=%s""", (th, kind)) + if not row: return None + if row["used_at"] is not None: return None + exp = row["expires_at"] + if exp.tzinfo is None: exp = exp.replace(tzinfo=timezone.utc) + if exp <= _now(): return None + db_exec("UPDATE pgz_sport.user_action_tokens SET used_at=now() WHERE token_hash=%s", (th,)) + return row + +def _build_link(path: str, token: str) -> str: + base = os.environ.get("PGZ_PUBLIC_BASE", "https://api.rinet.one/sport") + sep = '&' if '?' in path else '?' + return f"{base}{path}{sep}token={token}" + +# ─────────────────────────── /auth/forgot-password ─────────────────────────── +class ForgotPwdReq(BaseModel): + email: str + +@router.post("/forgot-password") +def forgot_password(req: ForgotPwdReq, request: Request): + """Always returns a generic message — never leaks which emails exist. + Issues a reset token only if the user exists and is active.""" + email = (req.email or "").lower().strip() + ip, ua = _client(request) + u = db_one("SELECT id, email, aktivan, status FROM pgz_sport.users WHERE LOWER(email)=%s", + (email,)) + token = None + if u and u.get("aktivan") and u.get("status") == "active": + token = issue_action_token(u["id"], "reset", RESET_TTL, ip=ip, + meta={"email": email}) + audit(u["id"], "password.forgot.issue", + meta={"email": email, "ttl_hours": RESET_TTL.total_seconds()/3600}, + ip=ip, ua=ua) + else: + audit(u["id"] if u else None, "password.forgot.miss", + meta={"email": email}, ip=ip, ua=ua) + # Generic response — do not leak account existence + resp = {"status": "ok", + "message": "Ako račun postoji, poslan je e-mail s linkom za promjenu lozinke."} + # In production, e-mailer would deliver the link. For demo / dev, + # return it only if header X-Demo-Reveal-Token is set OR caller is from + # localhost (rare). Easier: always include it but document that real + # deployment must remove it from the response. + if token and (os.environ.get("PGZ_REVEAL_RESET_TOKEN") == "1" or + (request.client.host in ("127.0.0.1", "::1"))): + resp["reset_link"] = _build_link("/auth/reset-password", token) + resp["expires_in_seconds"] = int(RESET_TTL.total_seconds()) + return resp + +class ResetTokenReq(BaseModel): + token: str + new_password: str + +@router.post("/reset-password") +def reset_password_with_token(req: ResetTokenReq, request: Request): + """Consume a reset token and set a new password.""" + if len(req.new_password or "") < 8: + raise HTTPException(400, "Lozinka mora imati barem 8 znakova") + row = consume_action_token(req.token, "reset") + ip, ua = _client(request) + if not row: + audit(None, "password.reset.fail", + meta={"reason": "invalid_or_expired_token"}, ip=ip, ua=ua) + raise HTTPException(400, "Token je nevažeći ili istekao") + if not row.get("aktivan") or row.get("status") != "active": + audit(row["user_id"], "password.reset.fail", + meta={"reason": "user_inactive"}, ip=ip, ua=ua) + raise HTTPException(403, "Račun nije aktivan") + db_exec("""UPDATE pgz_sport.users + SET password_hash=%s, must_change_pwd=false, + failed_login_count=0, locked_until=NULL, updated_at=now() + WHERE id=%s""", (hash_password(req.new_password), row["user_id"])) + # Revoke all active sessions for safety + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", + (row["user_id"],)) + audit(row["user_id"], "password.reset.ok", ip=ip, ua=ua) + return {"status": "ok", "email": row["email"]} + +@router.get("/reset-password") +def reset_password_check(token: str, request: Request): + """Pre-flight: validate that the token exists and isn't expired/used. + Does NOT consume the token.""" + th = _hash_action_token(token) + row = db_one("""SELECT t.user_id, t.expires_at, t.used_at, u.email + FROM pgz_sport.user_action_tokens t + JOIN pgz_sport.users u ON u.id = t.user_id + WHERE t.token_hash=%s AND t.kind='reset'""", (th,)) + if not row: + raise HTTPException(404, "Token nije pronađen") + if row["used_at"] is not None: + raise HTTPException(410, "Token je već iskorišten") + exp = row["expires_at"] + if exp.tzinfo is None: exp = exp.replace(tzinfo=timezone.utc) + if exp <= _now(): + raise HTTPException(410, "Token je istekao") + return {"status": "ok", "email": row["email"], "expires_at": row["expires_at"].isoformat()} + +# ─────────────────────────── /auth/setup-password (invite) ─────────────────────────── +class SetupPwdReq(BaseModel): + token: str + new_password: str + +@router.get("/setup-password") +def setup_password_check(token: str, request: Request): + """Pre-flight: validate an invite token without consuming it.""" + th = _hash_action_token(token) + row = db_one("""SELECT t.user_id, t.expires_at, t.used_at, u.email, u.full_name, u.user_type + FROM pgz_sport.user_action_tokens t + JOIN pgz_sport.users u ON u.id = t.user_id + WHERE t.token_hash=%s AND t.kind='invite'""", (th,)) + if not row: + raise HTTPException(404, "Pozivnica nije pronađena") + if row["used_at"] is not None: + raise HTTPException(410, "Pozivnica je već iskorištena") + exp = row["expires_at"] + if exp.tzinfo is None: exp = exp.replace(tzinfo=timezone.utc) + if exp <= _now(): + raise HTTPException(410, "Pozivnica je istekla") + return {"status": "ok", + "email": row["email"], + "full_name": row["full_name"], + "user_type": row["user_type"], + "expires_at": row["expires_at"].isoformat()} + +@router.post("/setup-password") +def setup_password_consume(req: SetupPwdReq, request: Request): + """Consume an invite token and set the user's first password.""" + if len(req.new_password or "") < 8: + raise HTTPException(400, "Lozinka mora imati barem 8 znakova") + row = consume_action_token(req.token, "invite") + ip, ua = _client(request) + if not row: + audit(None, "invite.consume.fail", + meta={"reason": "invalid_or_expired_token"}, ip=ip, ua=ua) + raise HTTPException(400, "Pozivnica je nevažeća ili istekla") + if not row.get("aktivan") or row.get("status") != "active": + audit(row["user_id"], "invite.consume.fail", + meta={"reason": "user_inactive"}, ip=ip, ua=ua) + raise HTTPException(403, "Račun nije aktivan") + db_exec("""UPDATE pgz_sport.users + SET password_hash=%s, must_change_pwd=false, + email_verified=true, + failed_login_count=0, locked_until=NULL, updated_at=now() + WHERE id=%s""", (hash_password(req.new_password), row["user_id"])) + audit(row["user_id"], "invite.consume.ok", + meta={"email": row["email"]}, ip=ip, ua=ua) + return {"status": "ok", "email": row["email"]} + # ─────────────────────────── 2FA — real TOTP (RFC 6238) ─────────────────────────── try: import pyotp as _pyotp diff --git a/data/sport_federations.json b/data/sport_federations.json new file mode 100644 index 0000000..d1c7d04 --- /dev/null +++ b/data/sport_federations.json @@ -0,0 +1,221 @@ +{ + "_meta": { + "version": 1, + "author": "dradulic@outlook.com", + "date": "2026-05-04", + "purpose": "Sport-aware enrichment routing for /v2/enrich/sportas. Each entry maps a sport name (lower-case, multiple aliases supported) to its national federation, optional PGŽ regional federation, and a list of search/scrape URLs. Used by routers/enrich_router.py and workers/enrichment_worker.py." + }, + "_aliases": { + "kosarkaski": "košarka", + "košarkaški": "košarka", + "nogometni": "nogomet", + "rukometni": "rukomet", + "stoni tenis": "stolni tenis", + "stolnotenis": "stolni tenis", + "bocanje": "boćanje", + "boćanje (boules)": "boćanje", + "kuglacki": "kuglanje", + "vaterpolski": "vaterpolo", + "konjicki sport": "konjički sport", + "auto-sport": "auto sport", + "skijaski sport": "skijanje" + }, + "boćanje": { + "national": { + "name": "HBS", + "long_name": "Hrvatski boćarski savez", + "url": "https://hrvatski-bocarski-savez.hr", + "search_url": "https://hrvatski-bocarski-savez.hr/?s={q}", + "profile_url_pattern": "https://hrvatski-bocarski-savez.hr/igraci/{slug}/" + }, + "pgz": { + "name": "BS PGŽ", + "url": "https://hrvatski-bocarski-savez.hr/savez/zupanijski-savezi/" + } + }, + "nogomet": { + "national": { + "name": "HNS", + "long_name": "Hrvatski nogometni savez", + "url": "https://hns-cff.hr", + "search_url": "https://semafor.hns.family/?s={q}", + "profile_search": "https://semafor.hns.family/igraci/?ime={q}", + "profile_url_pattern": "https://semafor.hns.family/igraci/{hns_pid}/{slug}/" + }, + "pgz": {"name": "NS PGŽ", "url": "https://nogomet-pgz.hr"} + }, + "košarka": { + "national": { + "name": "HKS", + "long_name": "Hrvatski košarkaški savez", + "url": "https://hks-cbf.hr", + "search_url": "https://hks-cbf.hr/?s={q}" + }, + "pgz": {"name": "KS PGŽ", "url": "https://kosarka-pgz.hr"} + }, + "rukomet": { + "national": { + "name": "HRS", + "long_name": "Hrvatski rukometni savez", + "url": "https://hrs.hr", + "search_url": "https://hrs.hr/?s={q}" + }, + "pgz": {"name": "RS PGŽ", "url": "https://rs-pgz.hr"} + }, + "odbojka": { + "national": { + "name": "HOS", + "long_name": "Hrvatski odbojkaški savez", + "url": "https://hos-cvf.hr", + "search_url": "https://hos-cvf.hr/?s={q}" + }, + "pgz": {"name": "OS PGŽ", "url": "https://odbojkaski-savez-pgz.hr"} + }, + "vaterpolo": { + "national": { + "name": "HVS", + "long_name": "Hrvatski vaterpolski savez", + "url": "https://hvs.hr", + "search_url": "https://hvs.hr/?s={q}" + } + }, + "plivanje": { + "national": { + "name": "HPS", + "long_name": "Hrvatski plivački savez", + "url": "https://hps.hr", + "search_url": "https://hps.hr/?s={q}" + } + }, + "atletika": { + "national": { + "name": "HAS", + "long_name": "Hrvatski atletski savez", + "url": "https://atletika.hr", + "search_url": "https://atletika.hr/?s={q}" + } + }, + "tenis": { + "national": { + "name": "HTS", + "long_name": "Hrvatski teniski savez", + "url": "https://htsavez.hr", + "search_url": "https://htsavez.hr/?s={q}" + } + }, + "judo": { + "national": { + "name": "HJS", + "long_name": "Hrvatski judo savez", + "url": "https://judo-savez.hr", + "search_url": "https://judo-savez.hr/?s={q}" + } + }, + "karate": { + "national": { + "name": "HKaS", + "long_name": "Hrvatski karate savez", + "url": "https://karate.hr", + "search_url": "https://karate.hr/?s={q}" + } + }, + "veslanje": { + "national": { + "name": "HVeS", + "long_name": "Hrvatski veslački savez", + "url": "https://veslacki-savez.hr", + "search_url": "https://veslacki-savez.hr/?s={q}" + } + }, + "jedrenje": { + "national": { + "name": "HJedS", + "long_name": "Hrvatski jedriličarski savez", + "url": "https://hjs.hr", + "search_url": "https://hjs.hr/?s={q}" + } + }, + "gimnastika": { + "national": { + "name": "HGS", + "long_name": "Hrvatski gimnastički savez", + "url": "https://gimnastika.hr", + "search_url": "https://gimnastika.hr/?s={q}" + } + }, + "streličarstvo": { + "national": { + "name": "HStS", + "long_name": "Hrvatski streličarski savez", + "url": "https://hss.hr", + "search_url": "https://hss.hr/?s={q}" + } + }, + "biciklizam": { + "national": { + "name": "HBciS", + "long_name": "Hrvatski biciklistički savez", + "url": "https://hbs.hr", + "search_url": "https://hbs.hr/?s={q}" + } + }, + "stolni tenis": { + "national": { + "name": "HSTS", + "long_name": "Hrvatski stolnoteniski savez", + "url": "https://stolni-tenis.hr", + "search_url": "https://stolni-tenis.hr/?s={q}" + } + }, + "triatlon": { + "national": { + "name": "HTrS", + "long_name": "Hrvatski triatlon savez", + "url": "https://triatlon.hr", + "search_url": "https://triatlon.hr/?s={q}" + } + }, + "skijanje": { + "national": { + "name": "HZS", + "long_name": "Hrvatski skijaški savez", + "url": "https://skijaski-savez.hr", + "search_url": "https://skijaski-savez.hr/?s={q}" + } + }, + "kuglanje": { + "national": { + "name": "HKgS", + "long_name": "Hrvatski kuglački savez", + "url": "https://kuglanje.hr", + "search_url": "https://kuglanje.hr/?s={q}" + } + }, + "šah": { + "national": { + "name": "HŠS", + "long_name": "Hrvatski šahovski savez", + "url": "https://hsk.hr", + "search_url": "https://hsk.hr/?s={q}" + } + }, + "konjički sport": { + "national": { + "name": "HKonjS", + "long_name": "Hrvatski konjički sportski savez", + "url": "https://konjs.hr" + } + }, + "auto sport": { + "national": { + "name": "HAKS", + "long_name": "Hrvatski auto klub savez", + "url": "https://hsa.hr" + } + }, + "_local_media_pgz": [ + {"name": "Novi list", "search_url": "https://www.novilist.hr/?s={q}"}, + {"name": "Glas Istre", "search_url": "https://www.glasistre.hr/pretraga?q={q}"}, + {"name": "Rijeka.danas","search_url": "https://www.rijeka-danas.com/?s={q}"} + ] +} diff --git a/erp/ocr.py b/erp/ocr.py index c9aae4c..71540f1 100644 --- a/erp/ocr.py +++ b/erp/ocr.py @@ -816,6 +816,297 @@ def invoices_pay(invoice_id: int, body: dict = Body(default={}), return {"ok": True, "invoice": row, "payment_id": pay["id"] if pay else None} +# ── R5.3 BULK OPERATIONS ────────────────────────────────────────────── +@router.post("/invoices/bulk-pay") +def invoices_bulk_pay(body: dict = Body(...), authorization: Optional[str] = Header(None)): + """Bulk označi listu računa kao plaćene. + Body: {ids: [int], paid_date?, payment_method?, iban_from?, iban_to?, reference?, tx_id?}""" + user = _resolve_user(authorization) + ids = body.get("ids") or [] + if not ids or not isinstance(ids, list): + raise HTTPException(400, "ids je obavezna ne-prazna lista") + paid_date = body.get("paid_date") or date.today().isoformat() + payment_method = body.get("payment_method") or "transfer" + iban_from = body.get("iban_from") + iban_to = body.get("iban_to") + reference = body.get("reference") + tx_id = body.get("bank_transaction_id") or body.get("tx_id") + + results = {"paid": [], "skipped": [], "forbidden": [], "errors": []} + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """SELECT i.*, k.savez_id FROM pgz_sport.invoices i + LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id + WHERE i.id = ANY(%s)""", (ids,)) + rows = cur.fetchall() + for inv in rows: + if (inv.get("payment_status") or "").lower() == "paid": + results["skipped"].append(inv["id"]); continue + if user and not can_pay_invoice(user, inv): + results["forbidden"].append(inv["id"]); continue + try: + with _db() as c: + cur = c.cursor() + cur.execute( + """UPDATE pgz_sport.invoices + SET payment_status='paid', paid_date=%s, + payment_method=COALESCE(%s,payment_method), + iban_from=COALESCE(%s,iban_from), + iban_to=COALESCE(%s,iban_to), + updated_at=NOW() + WHERE id=%s""", + (paid_date, payment_method, iban_from, iban_to, inv["id"]), + ) + cur.execute( + """INSERT INTO pgz_sport.payments + (klub_id, invoice_id, payment_date, amount, currency, payment_method, + iban_from, iban_to, reference, bank_transaction_id, matched_status) + VALUES (%s,%s,%s,%s,COALESCE(%s,'EUR'),%s,%s,%s,%s,%s,'matched')""", + (inv.get("klub_id"), inv["id"], paid_date, inv.get("amount_gross"), + inv.get("currency"), payment_method, iban_from, iban_to, reference, tx_id), + ) + audit_invoice(user, inv["id"], "bulk_pay", + field="payment_status", old=inv.get("payment_status"), new="paid") + results["paid"].append(inv["id"]) + except Exception as e: + results["errors"].append({"id": inv["id"], "err": str(e)[:200]}) + return {"ok": True, "summary": {k: len(v) for k, v in results.items()}, "details": results} + + +@router.post("/invoices/bulk-cancel") +def invoices_bulk_cancel(body: dict = Body(...), authorization: Optional[str] = Header(None)): + """Bulk otkaži (status='cancelled') — samo pgz_admin ili klub_admin svog kluba.""" + user = _resolve_user(authorization) + ids = body.get("ids") or [] + razlog = body.get("razlog") or body.get("reason") or "(bulk cancel)" + if not ids: + raise HTTPException(400, "ids je obavezna ne-prazna lista") + results = {"cancelled": [], "skipped": [], "forbidden": [], "errors": []} + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """SELECT i.*, k.savez_id FROM pgz_sport.invoices i + LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id + WHERE i.id = ANY(%s)""", (ids,)) + rows = cur.fetchall() + for inv in rows: + if (inv.get("payment_status") or "").lower() in ("paid", "cancelled"): + results["skipped"].append(inv["id"]); continue + if user and not can_edit_invoice(user, inv): + results["forbidden"].append(inv["id"]); continue + try: + with _db() as c: + c.cursor().execute( + """UPDATE pgz_sport.invoices + SET payment_status='cancelled', + notes = COALESCE(notes,'') || E'\n[CANCEL] ' || %s, + updated_at=NOW() WHERE id=%s""", + (razlog, inv["id"]), + ) + audit_invoice(user, inv["id"], "bulk_cancel", + field="payment_status", old=inv.get("payment_status"), + new=f"cancelled: {razlog}") + results["cancelled"].append(inv["id"]) + except Exception as e: + results["errors"].append({"id": inv["id"], "err": str(e)[:200]}) + return {"ok": True, "summary": {k: len(v) for k, v in results.items()}, "details": results} + + +# ── R5.4 XLSX EXPORT ─────────────────────────────────────────────────── +@router.get("/invoices/export.xlsx") +def invoices_export_xlsx( + tenant_id: Optional[int] = Query(None), + klub_id: Optional[int] = Query(None), + od: Optional[str] = Query(None, description="datum od YYYY-MM-DD"), + do: Optional[str] = Query(None, description="datum do YYYY-MM-DD"), + status: Optional[str] = None, + kind: Optional[str] = None, + authorization: Optional[str] = Header(None), +): + """XLSX export računa za knjigovodstvo. Stupci: ID, datum, vrsta, broj, + izdavatelj, OIB, klub, neto, PDV, brutto, valuta, status, IBAN, opis.""" + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment + from io import BytesIO + from fastapi.responses import StreamingResponse + + user = _resolve_user(authorization) + sql = """SELECT i.id, i.invoice_date, i.invoice_kind, i.invoice_no, + i.vendor_name, i.vendor_oib, i.customer_oib, + i.amount_net, i.amount_vat, i.amount_gross, i.vat_rate, + i.currency, i.payment_status, i.payment_method, + i.iban_to, i.description, i.category, + i.paid_date, i.tenant_id, i.klub_id, + k.naziv AS klub_naziv, k.savez_id + FROM pgz_sport.invoices i + LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id + WHERE 1=1""" + args: list = [] + if tenant_id is not None: sql += " AND i.tenant_id=%s"; args.append(tenant_id) + if klub_id is not None: sql += " AND i.klub_id=%s"; args.append(klub_id) + if od: sql += " AND i.invoice_date >= %s"; args.append(od) + if do: sql += " AND i.invoice_date <= %s"; args.append(do) + if status: sql += " AND i.payment_status=%s"; args.append(status) + if kind: sql += " AND i.invoice_kind=%s"; args.append(kind) + sql += " ORDER BY i.invoice_date DESC, i.id DESC" + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(sql, args) + rows = cur.fetchall() + + # Filter po user permissions + if user and not is_pgz_admin(user): + rows = [r for r in rows if can_view_invoice(user, r)] + + wb = Workbook() + ws = wb.active + ws.title = "Računi" + headers = ["ID", "Datum", "Vrsta", "Broj računa", "Izdavatelj", "OIB", + "Klub", "Iznos neto", "PDV", "Brutto", "Stopa PDV", + "Valuta", "Status", "Datum uplate", "IBAN primatelja", + "Opis", "Kategorija"] + bold = Font(bold=True, color="FFFFFF") + fill = PatternFill("solid", fgColor="003087") + for col_idx, h in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_idx, value=h) + cell.font = bold; cell.fill = fill + cell.alignment = Alignment(horizontal="center") + for r_idx, r in enumerate(rows, 2): + ws.cell(row=r_idx, column=1, value=r.get("id")) + ws.cell(row=r_idx, column=2, value=str(r.get("invoice_date") or "")) + ws.cell(row=r_idx, column=3, value=r.get("invoice_kind")) + ws.cell(row=r_idx, column=4, value=r.get("invoice_no")) + ws.cell(row=r_idx, column=5, value=r.get("vendor_name")) + ws.cell(row=r_idx, column=6, value=r.get("vendor_oib")) + ws.cell(row=r_idx, column=7, value=r.get("klub_naziv")) + ws.cell(row=r_idx, column=8, value=float(r["amount_net"]) if r.get("amount_net") is not None else None) + ws.cell(row=r_idx, column=9, value=float(r["amount_vat"]) if r.get("amount_vat") is not None else None) + ws.cell(row=r_idx, column=10, value=float(r["amount_gross"]) if r.get("amount_gross") is not None else None) + ws.cell(row=r_idx, column=11, value=float(r["vat_rate"]) if r.get("vat_rate") is not None else None) + ws.cell(row=r_idx, column=12, value=r.get("currency")) + ws.cell(row=r_idx, column=13, value=r.get("payment_status")) + ws.cell(row=r_idx, column=14, value=str(r.get("paid_date") or "")) + ws.cell(row=r_idx, column=15, value=r.get("iban_to")) + ws.cell(row=r_idx, column=16, value=r.get("description")) + ws.cell(row=r_idx, column=17, value=r.get("category")) + # Auto width + widths = [6, 12, 12, 18, 28, 14, 24, 12, 12, 12, 8, 6, 11, 12, 22, 30, 12] + for i, w in enumerate(widths, 1): + ws.column_dimensions[ws.cell(row=1, column=i).column_letter].width = w + ws.freeze_panes = "A2" + + buf = BytesIO() + wb.save(buf); buf.seek(0) + fname = f"racuni_{date.today().isoformat()}.xlsx" + return StreamingResponse( + buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{fname}"'}, + ) + + +# ── R5.6 STATS ───────────────────────────────────────────────────────── +@router.get("/stats") +def erp_stats( + klub_id: Optional[int] = Query(None), + tenant_id: Optional[int] = Query(None), + authorization: Optional[str] = Header(None), +): + """Statistika ERP-a: ukupno troškova mjesec/kvartal/godina po klubu/savezu, + breakdown po vrstama (gorivo/cestarina/hotel/oprema/ostalo).""" + user = _resolve_user(authorization) + today = date.today() + month_start = today.replace(day=1).isoformat() + qmonth = ((today.month - 1) // 3) * 3 + 1 + quarter_start = today.replace(month=qmonth, day=1).isoformat() + year_start = today.replace(month=1, day=1).isoformat() + + where = ["1=1"]; args: list = [] + if klub_id is not None: + where.append("klub_id=%s"); args.append(klub_id) + if tenant_id is not None: + where.append("tenant_id=%s"); args.append(tenant_id) + where_sql = " AND ".join(where) + + def q_sum(date_from): + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + f"""SELECT COUNT(*) AS n, + COALESCE(SUM(amount_gross),0)::float AS total, + COALESCE(SUM(CASE WHEN payment_status='paid' THEN amount_gross END),0)::float AS paid, + COALESCE(SUM(CASE WHEN payment_status<>'paid' THEN amount_gross END),0)::float AS unpaid + FROM pgz_sport.invoices + WHERE {where_sql} AND invoice_date >= %s""", + args + [date_from], + ) + return cur.fetchone() + + def q_breakdown(date_from): + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + f"""SELECT invoice_kind, COUNT(*) AS n, + COALESCE(SUM(amount_gross),0)::float AS total + FROM pgz_sport.invoices + WHERE {where_sql} AND invoice_date >= %s + GROUP BY invoice_kind ORDER BY total DESC""", + args + [date_from], + ) + return cur.fetchall() + + def q_top(date_from): + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + f"""SELECT i.klub_id, k.naziv AS klub_naziv, + COUNT(*) AS n, COALESCE(SUM(i.amount_gross),0)::float AS total + FROM pgz_sport.invoices i + LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id + WHERE {where_sql} AND i.invoice_date >= %s + GROUP BY i.klub_id, k.naziv ORDER BY total DESC LIMIT 10""", + args + [date_from], + ) + return cur.fetchall() + + # Putni nalozi totals + def q_pn(date_from): + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + pn_where = ["report_type='putni_nalog'"]; pn_args: list = [] + if klub_id is not None: + pn_where.append("klub_id=%s"); pn_args.append(klub_id) + if tenant_id is not None: + pn_where.append("tenant_id=%s"); pn_args.append(tenant_id) + cur.execute( + f"""SELECT COUNT(*) AS n, + COALESCE(SUM(cost_total),0)::float AS total, + COALESCE(SUM(dnevnice_amount),0)::float AS dnevnice, + COALESCE(SUM(cost_transport),0)::float AS transport + FROM pgz_sport.expense_reports + WHERE {' AND '.join(pn_where)} AND date_from >= %s""", + pn_args + [date_from], + ) + return cur.fetchone() + + return { + "ok": True, + "as_of": today.isoformat(), + "filters": {"klub_id": klub_id, "tenant_id": tenant_id}, + "invoices": { + "month": {"since": month_start, **q_sum(month_start), "by_kind": q_breakdown(month_start)}, + "quarter": {"since": quarter_start, **q_sum(quarter_start), "by_kind": q_breakdown(quarter_start)}, + "year": {"since": year_start, **q_sum(year_start), "by_kind": q_breakdown(year_start)}, + }, + "top_klubovi_godina": q_top(year_start), + "putni_nalozi": { + "month": {"since": month_start, **q_pn(month_start)}, + "quarter": {"since": quarter_start, **q_pn(quarter_start)}, + "year": {"since": year_start, **q_pn(year_start)}, + }, + } + + @router.get("/invoices/uploads/list") def uploads_list(klub_id: Optional[int] = None, status: Optional[str] = None, limit: int = 50): sql = """SELECT id, klub_id, file_name, file_size, mime, ocr_status, ocr_engine, diff --git a/erp/putni_nalozi.py b/erp/putni_nalozi.py index 34f0695..a2c86e1 100644 --- a/erp/putni_nalozi.py +++ b/erp/putni_nalozi.py @@ -246,32 +246,32 @@ def get_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)): if user and not can_view_putni_nalog(user, row): raise HTTPException(403, "Nemate ovlasti vidjeti ovaj putni nalog") - # Lista vezanih računa (po klubu, datumu, ili ID-evima u attachments) - att = row.get("attachments") or {} - if isinstance(att, str): - try: att = json.loads(att) - except Exception: att = {} - invoice_ids = att.get("invoice_ids") or [] - invoices = [] - if invoice_ids: - cur.execute( - """SELECT id, invoice_no, invoice_kind, vendor_name, vendor_oib, - invoice_date, amount_gross, payment_status, currency, category - FROM pgz_sport.invoices WHERE id = ANY(%s) - ORDER BY invoice_date DESC""", (invoice_ids,)) - invoices = cur.fetchall() - else: - # Auto-suggest: računi kluba u rasponu putovanja s kategorijom putni-trošak - cur.execute( - """SELECT id, invoice_no, invoice_kind, vendor_name, vendor_oib, - invoice_date, amount_gross, payment_status, currency, category - FROM pgz_sport.invoices - WHERE klub_id=%s AND invoice_date BETWEEN %s AND %s - AND invoice_kind IN ('gorivo','cestarina','hotel','restoran','ostalo') - ORDER BY invoice_date DESC LIMIT 50""", - (row.get("klub_id"), row.get("date_from"), row.get("date_to")), - ) - invoices = cur.fetchall() + # Vezani računi iz m2m tablice + cur.execute( + """SELECT i.id, i.invoice_no, i.invoice_kind, i.vendor_name, i.vendor_oib, + i.invoice_date, i.amount_gross, i.payment_status, i.currency, i.category, + pnr.kategorija AS attached_kategorija, pnr.attached_at + FROM pgz_sport.putni_nalog_racuni pnr + JOIN pgz_sport.invoices i ON i.id = pnr.invoice_id + WHERE pnr.putni_nalog_id=%s + ORDER BY i.invoice_date DESC""", (nalog_id,)) + invoices = cur.fetchall() + + # Auto-suggest: računi kluba u rasponu putovanja koji NISU jos vezani + cur.execute( + """SELECT i.id, i.invoice_no, i.invoice_kind, i.vendor_name, i.vendor_oib, + i.invoice_date, i.amount_gross, i.payment_status, i.currency, i.category + FROM pgz_sport.invoices i + LEFT JOIN pgz_sport.putni_nalog_racuni pnr + ON pnr.invoice_id=i.id AND pnr.putni_nalog_id=%s + WHERE i.klub_id=%s + AND i.invoice_date BETWEEN %s AND %s + AND i.invoice_kind IN ('gorivo','cestarina','hotel','restoran','oprema','ostalo') + AND pnr.id IS NULL + ORDER BY i.invoice_date DESC LIMIT 50""", + (nalog_id, row.get("klub_id"), row.get("date_from"), row.get("date_to")), + ) + suggested = cur.fetchall() # Payments za ovaj putni nalog cur.execute( @@ -284,9 +284,64 @@ def get_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)): audit = fetch_audit("pgz_sport.expense_reports", nalog_id, 50) actions = putni_nalog_actions(user, row) if user else {"view": True, "edit": False, "submit": False, "approve": False, "reject": False, "pay": False, "delete": False} return {"ok": True, "putni_nalog": row, "invoices": invoices, + "suggested_invoices": suggested, "payments": payments, "audit": audit, "actions": actions} +@router.post("/putni-nalog/{nalog_id}/attach-invoice") +def attach_invoice(nalog_id: int, body: dict = Body(...), + authorization: Optional[str] = Header(None)): + """Veži postojeći račun na putni nalog (m2m).""" + user = _resolve_user(authorization) + inv_id = body.get("invoice_id") + kategorija = body.get("kategorija") or body.get("category") + if not inv_id: + raise HTTPException(400, "invoice_id je obavezan") + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,)) + pn = cur.fetchone() + if not pn: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_edit_putni_nalog(user, pn) and not is_pgz_admin(user): + raise HTTPException(403, "Nemate ovlasti za vezivanje računa") + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """INSERT INTO pgz_sport.putni_nalog_racuni + (putni_nalog_id, invoice_id, kategorija, attached_by) + VALUES (%s,%s,%s,%s) + ON CONFLICT (putni_nalog_id, invoice_id) DO UPDATE SET kategorija=EXCLUDED.kategorija + RETURNING id, attached_at""", + (nalog_id, inv_id, kategorija, (user.get("id") if user else None)), + ) + link = cur.fetchone() + audit_putni(user, nalog_id, "attach_invoice", field="invoice_id", new=inv_id) + return {"ok": True, "link_id": link["id"], "attached_at": link["attached_at"]} + + +@router.delete("/putni-nalog/{nalog_id}/invoice/{invoice_id}") +def detach_invoice(nalog_id: int, invoice_id: int, + authorization: Optional[str] = Header(None)): + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,)) + pn = cur.fetchone() + if not pn: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_edit_putni_nalog(user, pn) and not is_pgz_admin(user): + raise HTTPException(403, "Nemate ovlasti") + with _db() as c: + cur = c.cursor() + cur.execute( + "DELETE FROM pgz_sport.putni_nalog_racuni WHERE putni_nalog_id=%s AND invoice_id=%s", + (nalog_id, invoice_id), + ) + audit_putni(user, nalog_id, "detach_invoice", field="invoice_id", old=invoice_id) + return {"ok": True} + + @router.post("/putni-nalog/{nalog_id}/posalji") def posalji_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)): """Voditelj/klub_admin šalje draft → poslan.""" @@ -385,6 +440,60 @@ def isplati_putni_nalog(nalog_id: int, body: dict = Body(default={}), return {"ok": True, "putni_nalog": row, "payment_id": pay["id"] if pay else None} +@router.get("/putni-nalog/{nalog_id}/hub3.pdf") +def putni_hub3(nalog_id: int, iban: Optional[str] = None, + authorization: Optional[str] = Header(None)): + """HUB-3 uplatnica + EPC QR za isplatu putnog naloga voditelju.""" + user = _resolve_user(authorization) + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """SELECT er.*, k.naziv AS klub_naziv, k.savez_id, k.adresa AS klub_adresa + FROM pgz_sport.expense_reports er + LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id + WHERE er.id=%s AND er.report_type='putni_nalog'""", (nalog_id,)) + pn = cur.fetchone() + if not pn: + raise HTTPException(404, "Putni nalog ne postoji") + if user and not can_view_putni_nalog(user, pn): + raise HTTPException(403, "Nemate ovlasti") + + try: + from crm.payments import build_hub3_pdf + except Exception as e: + raise HTTPException(500, f"HUB-3 helper nije dostupan: {e}") + from fastapi.responses import Response + + att = pn.get("attachments") or {} + if isinstance(att, str): + try: att = json.loads(att) + except Exception: att = {} + voditelj = att.get("voditelj") or "Voditelj putovanja" + iban_to = (iban or "").strip() or att.get("iban_voditelja") or "HR0000000000000000000" + iznos = float(pn.get("cost_total") or 0) + if iznos <= 0: + raise HTTPException(400, "Iznos isplate mora biti veći od 0") + + poziv = f"{nalog_id:08d}" + opis = f"Putni nalog #{nalog_id}: {pn.get('destination') or ''} ({pn.get('date_from')}–{pn.get('date_to')})"[:140] + + pdf = build_hub3_pdf( + platitelj_naziv=pn.get("klub_naziv") or "PGŽ Sport klub", + platitelj_adresa=pn.get("klub_adresa") or "—", + primatelj_naziv=voditelj, + primatelj_adresa="—", + iban=iban_to, + amount_eur=iznos, + model="HR99", + poziv_na_broj=poziv, + opis=opis, + sifra_namjene="SALA", + datum=date.today(), + ) + return Response(content=pdf, media_type="application/pdf", + headers={"Content-Disposition": f'inline; filename="putni-nalog-{nalog_id}-HUB3.pdf"'}) + + @router.get("/putni-nalog/{nalog_id}/audit") def putni_audit(nalog_id: int, limit: int = 100, authorization: Optional[str] = Header(None)): diff --git a/pgz_sport_api.py b/pgz_sport_api.py index 94aca58..ee172a7 100644 --- a/pgz_sport_api.py +++ b/pgz_sport_api.py @@ -76,6 +76,39 @@ def apply_privacy(rows, admin): app = FastAPI(title="PGŽ Sportski savez ERP/CRM", version="1.0.0") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) +# ─── R5 #1: Defense-in-depth JWT enforcement on /api/admin/* ─── +# Even if a route accidentally lacks `Depends(require_user)`, this middleware +# rejects requests with no/invalid Bearer token before they reach the handler. +@app.middleware("http") +async def require_jwt_on_admin(request, call_next): + p = request.url.path + # Only gate admin endpoints — leave /api/auth/*, public /api/v2/* etc. alone + if p.startswith("/api/admin/") or p == "/api/admin": + # OPTIONS preflight passes through + if request.method == "OPTIONS": + return await call_next(request) + try: + from auth.auth_v2 import decode_token, _is_revoked + auth = request.headers.get("authorization", "") + if not auth.lower().startswith("bearer "): + from starlette.responses import JSONResponse as _JR + return _JR({"detail": "Authentication required"}, status_code=401) + token = auth.split(" ", 1)[1].strip() + try: + payload = decode_token(token) + except Exception: + from starlette.responses import JSONResponse as _JR + return _JR({"detail": "Invalid or expired token"}, status_code=401) + if payload.get("typ") not in (None, "access"): + from starlette.responses import JSONResponse as _JR + return _JR({"detail": "Wrong token type"}, status_code=401) + if _is_revoked(payload.get("jti", "")): + from starlette.responses import JSONResponse as _JR + return _JR({"detail": "Token revoked"}, status_code=401) + except Exception as e: + print(f"[JWT-MW WARN] {e}") + return await call_next(request) + # === URL rewrite middleware - convert direct external image URLs to /img-proxy === import json as _json_mw @@ -1361,6 +1394,13 @@ try: except Exception as e: print(f'[CRM/PANEL] clan_panel router fail: {e}') +try: + from crm_extras_router import router as crm_extras_router + app.include_router(crm_extras_router) + print('[CRM/R5] extras router loaded (bulk + xlsx + stats + notifications)') +except Exception as e: + print(f'[CRM/R5] extras router fail: {e}') + # === Round 3 / CC2 — M1 Auth + M2 Admin Users + M10 GDPR === try: from auth.auth_v2 import router as auth_v2_router diff --git a/routers/crm_extras_router.py b/routers/crm_extras_router.py new file mode 100644 index 0000000..bfbc965 --- /dev/null +++ b/routers/crm_extras_router.py @@ -0,0 +1,588 @@ +#!/usr/bin/env python3 +# ═══════════════════════════════════════════════════════════════════ +# Fajl: routers/crm_extras_router.py | v1.0.0 | 05.05.2026 +# Autor: Damir Radulić / damir@rinet.one +# Lokacija: /opt/pgz-sport/routers/crm_extras_router.py +# Svrha: R5 — bulk akcije za članarine, XLSX export članova, /crm/stats, +# notifikacije za isteke liječničkih (Email + InApp) +# ═══════════════════════════════════════════════════════════════════ +"""R5 CRM extras. + +Endpointi (montirani na /api/crm): + POST /clanarine/bulk/notify → opomena svim koji duguju (mock email + InApp) + POST /clanarine/bulk/uplatnice → batch HUB-3 PDF (zip ili JSON s URL-ovima) + GET /clanovi/export.xlsx → XLSX svih članova (filteri klub, aktivan) + GET /stats → aktivni vs neaktivni, trend uplata, ... + + POST /lijecnicki/notify-scan → skenira pretvorbe < N dana, kreira notifikacije + GET /notifications → lista (filter user/status/channel) + POST /notifications/{id}/read → mark read + POST /notifications/mark-all-read → mark all read za usera +""" +from __future__ import annotations + +import io +import json as _json +import sys +from datetime import date, datetime, timedelta +from decimal import Decimal +from typing import Optional + +import psycopg2 +from psycopg2.extras import RealDictCursor +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import Response +from pydantic import BaseModel + +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + +sys.path.insert(0, "/opt/pgz-sport") +from crm.payments import ( + build_hub3_pdf, make_poziv_na_broj, normalize_iban, +) + +router = APIRouter(prefix="/api/crm", tags=["crm-extras"]) + +DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7" + +# Pragovi za scan liječničkih (dana do isteka) +LIJEC_THRESHOLDS = (30, 15, 7) + + +def _conn(): + return psycopg2.connect(DSN, cursor_factory=RealDictCursor) + + +def _conv(v): + if isinstance(v, (date, datetime)): + return v.isoformat() + if isinstance(v, Decimal): + return float(v) + return v + + +def _row(d): + return None if d is None else {k: _conv(v) for k, v in dict(d).items()} + + +# ════════════════════════════════════════════════════ +# #3 — BULK AKCIJE ZA ČLANARINE +# ════════════════════════════════════════════════════ + +class BulkOpomenaIn(BaseModel): + klub_id: Optional[int] = None + godina: Optional[int] = None + ids: Optional[list[int]] = None # specifične clanarina ID + template: Optional[str] = "Poštovani, podsjećamo na nepodmirenu članarinu." + + +@router.post("/clanarine/bulk/notify") +def bulk_opomena(body: BulkOpomenaIn): + """Pošalji opomenu (mock e-mail + InApp notification) svim dužnicima.""" + where = ["c.status IN ('nepodmireno','djelomicno')"] + params: list = [] + if body.ids: + where.append("c.id = ANY(%s)"); params.append(body.ids) + if body.klub_id: + where.append("c.klub_id = %s"); params.append(body.klub_id) + if body.godina: + where.append("c.godina = %s"); params.append(body.godina) + where_sql = "WHERE " + " AND ".join(where) + with _conn() as conn, conn.cursor() as cur: + cur.execute(f""" + SELECT c.id, c.godina, c.iznos_propisan, + (c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug, + cl.id AS clan_id, cl.ime || ' ' || cl.prezime AS clan, + cl.email AS clan_email, k.naziv AS klub + FROM pgz_sport.clanarine c + JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id + {where_sql} + ORDER BY dug DESC + LIMIT 1000 + """, params) + rows = [_row(r) for r in cur.fetchall()] + + # Insert notifications za one s e-mailom + n_email, n_inapp = 0, 0 + for r in rows: + subject = f"Opomena: nepodmirena članarina {r['godina']} ({r['dug']:.2f} €)" + body_txt = (f"{body.template}\n\n" + f"Klub: {r.get('klub')}\n" + f"Iznos duga: {r['dug']:.2f} EUR\n" + f"Godina: {r['godina']}\n\n" + f"PGŽ Sport ERP/CRM") + meta = _json.dumps({ + "clanarina_id": r["id"], "clan_id": r["clan_id"], + "iznos_dug": float(r["dug"]), + "uplatnica_url": f"/sport/api/crm/clanarine/{r['id']}/uplatnica.pdf", + }) + # InApp uvijek + cur.execute("""INSERT INTO pgz_sport.notifications + (channel, subject, body, status, scheduled_at, meta) + VALUES ('inapp', %s, %s, 'pending', now(), %s::jsonb)""", + (subject, body_txt, meta)) + n_inapp += 1 + # Email mock — samo log + if r.get("clan_email"): + cur.execute("""INSERT INTO pgz_sport.notifications + (channel, subject, body, status, scheduled_at, meta) + VALUES ('email', %s, %s, 'pending', now(), %s::jsonb)""", + (subject, body_txt, _json.dumps({**_json.loads(meta), + "to": r["clan_email"]}))) + n_email += 1 + conn.commit() + return { + "ok": True, + "matched": len(rows), + "queued_inapp": n_inapp, + "queued_email": n_email, + "note": "Mock — SMTP nije konfiguriran; e-mail je upisan u notifications tablicu sa status='pending'.", + "recipients_preview": rows[:20], + } + + +class BulkUplatniceIn(BaseModel): + ids: Optional[list[int]] = None + klub_id: Optional[int] = None + godina: Optional[int] = None + + +@router.post("/clanarine/bulk/uplatnice") +def bulk_uplatnice(body: BulkUplatniceIn): + """ + Vraća JSON s listom uplatnica + linkovima na pojedinačne PDF-ove. + (PDF-ovi se generiraju on-demand kroz /clanarine/{id}/uplatnica.pdf.) + """ + where = ["c.status IN ('nepodmireno','djelomicno')"] + params: list = [] + if body.ids: + where = ["c.id = ANY(%s)"]; params = [body.ids] + else: + if body.klub_id: + where.append("c.klub_id = %s"); params.append(body.klub_id) + if body.godina: + where.append("c.godina = %s"); params.append(body.godina) + where_sql = "WHERE " + " AND ".join(where) + with _conn() as conn, conn.cursor() as cur: + cur.execute(f""" + SELECT c.id, c.godina, c.iznos_propisan, c.iznos_placen, + (c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug, + cl.ime || ' ' || cl.prezime AS clan, + k.naziv AS klub, k.iban AS klub_iban + FROM pgz_sport.clanarine c + JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id + {where_sql} + ORDER BY k.naziv, cl.prezime + LIMIT 500 + """, params) + rows = [_row(r) for r in cur.fetchall()] + return { + "ok": True, + "count": len(rows), + "total_dug_eur": round(sum(float(r["dug"] or 0) for r in rows), 2), + "uplatnice": [{ + "id": r["id"], "clan": r["clan"], "klub": r["klub"], + "godina": r["godina"], "iznos_eur": float(r["dug"] or 0), + "pdf_url": f"/sport/api/crm/clanarine/{r['id']}/uplatnica.pdf", + "qr_url": f"/sport/api/crm/clanarine/{r['id']}/qr.png", + } for r in rows], + } + + +# ════════════════════════════════════════════════════ +# #4 — XLSX EXPORT ČLANOVA +# ════════════════════════════════════════════════════ + +@router.get("/clanovi/export.xlsx") +def export_clanovi_xlsx( + klub_id: Optional[int] = Query(None), + aktivan: Optional[bool] = Query(None), + sport: Optional[str] = Query(None), + q: Optional[str] = Query(None), + limit: int = Query(5000, le=20000), +): + where, params = ["1=1"], [] + if klub_id: where.append("c.klub_id = %s"); params.append(klub_id) + if aktivan is not None: where.append("c.aktivan = %s"); params.append(aktivan) + if sport: where.append("(c.sport ILIKE %s OR k.sport ILIKE %s)"); params += [f"%{sport}%", f"%{sport}%"] + if q: where.append("(c.ime || ' ' || c.prezime) ILIKE %s"); params.append(f"%{q}%") + params.append(limit) + where_sql = "WHERE " + " AND ".join(where) + sql = f""" + SELECT c.id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol, + c.email, c.telefon, c.adresa, c.grad, c.postanski_broj, + c.kategorija, c.podkategorija, c.pozicija, c.broj_dresa, + c.visina_cm, c.tezina_kg, c.dominantna_noga, + c.aktivan, c.datum_pristupa, c.reprezentativac, + c.kategoriziran, c.kategorija_hoo, + c.stipendiran, c.stipendija_iznos, + c.licenca_broj, c.licenca_vrijedi_do, + k.naziv AS klub, k.oib AS klub_oib, + s.naziv AS savez + FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id + LEFT JOIN pgz_sport.savezi s ON s.id = k.savez_id + {where_sql} + ORDER BY k.naziv NULLS LAST, c.prezime, c.ime + LIMIT %s + """ + with _conn() as conn, conn.cursor() as cur: + cur.execute(sql, params) + rows = [_row(r) for r in cur.fetchall()] + + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Članovi PGŽ" + + headers = [ + "ID", "Ime", "Prezime", "OIB", "Datum rođ.", "Spol", + "E-mail", "Telefon", "Adresa", "Grad", "Pošt.", + "Kategorija", "Podkat.", "Pozicija", "Dres", + "Vis. (cm)", "Tež. (kg)", "Dom. noga", + "Aktivan", "Datum prist.", "Repr.", + "Kategoriziran", "HOO kat.", + "Stipendiran", "Stipendija (€)", + "Licenca", "Licenca do", + "Klub", "OIB kluba", "Savez", + ] + for col, h in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=h) + cell.font = Font(bold=True, color="FFFFFF", size=10) + cell.fill = PatternFill(start_color="1E3A8A", end_color="1E3A8A", fill_type="solid") + cell.alignment = Alignment(horizontal="center", vertical="center") + cell.border = Border(bottom=Side(border_style="thin", color="FFFFFF")) + + keys = [ + "id", "ime", "prezime", "oib", "datum_rodenja", "spol", + "email", "telefon", "adresa", "grad", "postanski_broj", + "kategorija", "podkategorija", "pozicija", "broj_dresa", + "visina_cm", "tezina_kg", "dominantna_noga", + "aktivan", "datum_pristupa", "reprezentativac", + "kategoriziran", "kategorija_hoo", + "stipendiran", "stipendija_iznos", + "licenca_broj", "licenca_vrijedi_do", + "klub", "klub_oib", "savez", + ] + + for ridx, r in enumerate(rows, start=2): + for cidx, k in enumerate(keys, 1): + v = r.get(k) + if isinstance(v, bool): + v = "DA" if v else "NE" + ws.cell(row=ridx, column=cidx, value=v) + + # Auto column widths + for col_letter, h in zip("ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "AA AB AC AD".split(), headers): + ws.column_dimensions[col_letter].width = max(10, min(28, len(h) + 4)) + + ws.freeze_panes = "A2" + ws.auto_filter.ref = ws.dimensions + + buf = io.BytesIO() + wb.save(buf) + fname = f"clanovi-pgz-{date.today().isoformat()}.xlsx" + return Response( + content=buf.getvalue(), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{fname}"'}, + ) + + +# ════════════════════════════════════════════════════ +# #5 — /crm/stats +# ════════════════════════════════════════════════════ + +@router.get("/stats") +def crm_stats(klub_id: Optional[int] = Query(None)): + """Aktivni/neaktivni članovi, trend uplata, KPI summary.""" + klub_filter = "AND klub_id = %s" if klub_id else "" + klub_params = [klub_id] if klub_id else [] + + with _conn() as conn, conn.cursor() as cur: + # aktivni vs neaktivni + cur.execute(f""" + SELECT + COUNT(*) FILTER (WHERE aktivan = TRUE) AS aktivni, + COUNT(*) FILTER (WHERE aktivan = FALSE) AS neaktivni, + COUNT(*) AS total, + COUNT(*) FILTER (WHERE reprezentativac = TRUE) AS reprezentativci, + COUNT(*) FILTER (WHERE kategoriziran = TRUE) AS kategorizirani, + COUNT(*) FILTER (WHERE stipendiran = TRUE) AS stipendirani + FROM pgz_sport.clanovi + WHERE 1=1 {klub_filter} + """, klub_params) + clanovi_summary = _row(cur.fetchone()) + + # po spolu + cur.execute(f""" + SELECT spol, COUNT(*) AS n + FROM pgz_sport.clanovi + WHERE aktivan = TRUE {klub_filter} + GROUP BY spol ORDER BY n DESC + """, klub_params) + po_spolu = [_row(r) for r in cur.fetchall()] + + # po kategoriji + cur.execute(f""" + SELECT COALESCE(kategorija, '(nepoznato)') AS kategorija, COUNT(*) AS n + FROM pgz_sport.clanovi + WHERE aktivan = TRUE {klub_filter} + GROUP BY kategorija ORDER BY n DESC LIMIT 12 + """, klub_params) + po_kategoriji = [_row(r) for r in cur.fetchall()] + + # trend uplata po mjesecu — zadnjih 12 + cur.execute(f""" + SELECT to_char(date_trunc('month', datum_uplate), 'YYYY-MM') AS mjesec, + COUNT(*) AS broj_uplata, + SUM(iznos_placen)::numeric(10,2) AS iznos_total + FROM pgz_sport.clanarine + WHERE datum_uplate IS NOT NULL + AND datum_uplate >= (CURRENT_DATE - INTERVAL '12 months') + {('AND klub_id = %s' if klub_id else '')} + GROUP BY date_trunc('month', datum_uplate) + ORDER BY mjesec + """, klub_params) + trend_uplata = [_row(r) for r in cur.fetchall()] + + # članarine summary + cur.execute(f""" + SELECT COUNT(*) AS total, + SUM(iznos_propisan)::numeric(10,2) AS propisan, + SUM(iznos_placen)::numeric(10,2) AS placen, + SUM(iznos_propisan - COALESCE(iznos_placen,0))::numeric(10,2) AS dug, + COUNT(*) FILTER (WHERE status='nepodmireno') AS n_nepodmireno, + COUNT(*) FILTER (WHERE status='djelomicno') AS n_djelomicno, + COUNT(*) FILTER (WHERE status='podmireno') AS n_podmireno + FROM pgz_sport.clanarine + WHERE 1=1 {klub_filter} + """, klub_params) + clanarine_summary = _row(cur.fetchone()) + + # liječnički status + cur.execute(f""" + SELECT + COUNT(*) FILTER (WHERE vrijedi_do > CURRENT_DATE + INTERVAL '30 days') AS vazeci, + COUNT(*) FILTER (WHERE vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '30 days') AS uskoro, + COUNT(*) FILTER (WHERE vrijedi_do < CURRENT_DATE) AS istekli, + COUNT(*) AS total + FROM pgz_sport.lijecnicki_pregledi + WHERE 1=1 {klub_filter} + """, klub_params) + lijecnicki_summary = _row(cur.fetchone()) + + # najnovije uplate (zadnjih 10) + cur.execute(f""" + SELECT c.id, c.iznos_placen, c.datum_uplate, c.godina, + cl.ime||' '||cl.prezime AS clan, k.naziv AS klub + FROM pgz_sport.clanarine c + LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id + WHERE c.datum_uplate IS NOT NULL {klub_filter.replace('klub_id', 'c.klub_id')} + ORDER BY c.datum_uplate DESC + LIMIT 10 + """, klub_params) + najnovije_uplate = [_row(r) for r in cur.fetchall()] + + return { + "klub_id": klub_id, + "clanovi": clanovi_summary, + "po_spolu": po_spolu, + "po_kategoriji": po_kategoriji, + "trend_uplata_12m": trend_uplata, + "clanarine": clanarine_summary, + "lijecnicki": lijecnicki_summary, + "najnovije_uplate": najnovije_uplate, + } + + +# ════════════════════════════════════════════════════ +# #6 — NOTIFIKACIJE LIJEČNIČKI ISTECI +# ════════════════════════════════════════════════════ + +class NotifScanIn(BaseModel): + klub_id: Optional[int] = None + thresholds: Optional[list[int]] = None # default = LIJEC_THRESHOLDS + + +@router.post("/lijecnicki/notify-scan") +def lijecnicki_notify_scan(body: NotifScanIn): + """ + Skenira nadolazeće isteke i kreira notifikacije (InApp + Email mock) + za pragove 30/15/7 dana. Ne duplicira: gleda meta.lijecnicki_id+threshold + u zadnjih 7 dana. + """ + thresholds = sorted(set(body.thresholds or LIJEC_THRESHOLDS), reverse=True) + klub_filter = "AND l.klub_id = %s" if body.klub_id else "" + klub_params = [body.klub_id] if body.klub_id else [] + + created = [] + with _conn() as conn, conn.cursor() as cur: + for thr in thresholds: + cur.execute(f""" + SELECT l.id, l.vrijedi_do, l.clan_id, + (l.vrijedi_do - CURRENT_DATE)::int AS dana, + cl.ime || ' ' || cl.prezime AS clan, + cl.email AS clan_email, + k.naziv AS klub + FROM pgz_sport.lijecnicki_pregledi l + LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id + WHERE l.vrijedi_do IS NOT NULL + AND (l.vrijedi_do - CURRENT_DATE) BETWEEN 0 AND %s + AND (l.vrijedi_do - CURRENT_DATE) > %s + {klub_filter} + """, [thr, thr - 1] + klub_params if False else + ([thr - (thresholds[thresholds.index(thr)+1] if thresholds.index(thr)+1 < len(thresholds) else 0), + -1] + klub_params)) + # Pojednostavljen scan: samo "≤ thr & > prev_thr" dovodi do duplika; + # umjesto toga samo gledamo "u prozoru ≤ thr". + cur.execute(f""" + SELECT l.id, l.vrijedi_do, l.clan_id, + (l.vrijedi_do - CURRENT_DATE)::int AS dana, + cl.ime || ' ' || cl.prezime AS clan, + cl.email AS clan_email, + k.naziv AS klub + FROM pgz_sport.lijecnicki_pregledi l + LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id + WHERE l.vrijedi_do IS NOT NULL + AND (l.vrijedi_do - CURRENT_DATE) BETWEEN 0 AND %s + {klub_filter} + """, [thr] + klub_params) + kandidati = [_row(r) for r in cur.fetchall()] + + for r in kandidati: + # de-dup: već postoji notifikacija za ovaj lijec_id+threshold u <7 dana? + cur.execute(""" + SELECT 1 FROM pgz_sport.notifications + WHERE meta->>'lijecnicki_id' = %s + AND meta->>'threshold' = %s + AND scheduled_at > now() - INTERVAL '7 days' + LIMIT 1 + """, (str(r["id"]), str(thr))) + if cur.fetchone(): + continue + + subject = f"⚕ Liječnički pregled ističe za {r['dana']} dana: {r['clan']}" + body_txt = ( + f"Liječnički pregled za sportaša {r['clan']} " + f"({r.get('klub') or '(bez kluba)'}) ističe {r['vrijedi_do']} " + f"— {r['dana']} dana ostalo.\n\n" + f"Molimo zakažite novi termin u ZZJZ PGŽ " + f"(ili koristite /sport/api/crm/lijecnicki/{r['id']}/zakazi).\n\n" + f"PGŽ Sport ERP/CRM" + ) + meta = _json.dumps({ + "lijecnicki_id": r["id"], + "clan_id": r["clan_id"], + "threshold": thr, + "vrijedi_do": str(r["vrijedi_do"]), + "dana": r["dana"], + "zakazi_url": f"/sport/api/crm/lijecnicki/{r['id']}/zakazi", + "klub": r.get("klub"), + }) + cur.execute("""INSERT INTO pgz_sport.notifications + (channel, subject, body, status, scheduled_at, meta) + VALUES ('inapp', %s, %s, 'pending', now(), %s::jsonb) + RETURNING id""", (subject, body_txt, meta)) + inapp_id = cur.fetchone()["id"] + created.append({"channel": "inapp", "id": inapp_id, "lijec_id": r["id"], "thr": thr}) + + if r.get("clan_email"): + cur.execute("""INSERT INTO pgz_sport.notifications + (channel, subject, body, status, scheduled_at, meta) + VALUES ('email', %s, %s, 'pending', now(), %s::jsonb) + RETURNING id""", + (subject, body_txt, + _json.dumps({**_json.loads(meta), "to": r["clan_email"]}))) + em_id = cur.fetchone()["id"] + created.append({"channel": "email", "id": em_id, "lijec_id": r["id"], "thr": thr, + "to": r["clan_email"]}) + conn.commit() + + return { + "ok": True, + "thresholds_dana": thresholds, + "created": len(created), + "items": created[:50], + "note": "Mock — SMTP nije konfiguriran. Email notifikacije su upisane u DB sa status='pending'.", + } + + +@router.get("/notifications") +def list_notifications( + user_id: Optional[int] = Query(None), + status: Optional[str] = Query(None, description="pending|sent|read"), + channel: Optional[str] = Query(None, description="inapp|email"), + limit: int = Query(100, le=500), +): + where, params = [], [] + if user_id is not None: + where.append("user_id = %s"); params.append(user_id) + if status: + where.append("status = %s"); params.append(status) + if channel: + where.append("channel = %s"); params.append(channel) + where_sql = ("WHERE " + " AND ".join(where)) if where else "" + params.append(limit) + with _conn() as conn, conn.cursor() as cur: + cur.execute(f""" + SELECT id, user_id, channel, subject, body, status, + scheduled_at, sent_at, read_at, meta + FROM pgz_sport.notifications + {where_sql} + ORDER BY scheduled_at DESC NULLS LAST + LIMIT %s + """, params) + rows = [_row(r) for r in cur.fetchall()] + cur.execute(f""" + SELECT COUNT(*) AS total, + COUNT(*) FILTER (WHERE status='pending') AS pending, + COUNT(*) FILTER (WHERE status='sent') AS sent, + COUNT(*) FILTER (WHERE read_at IS NULL AND channel='inapp') AS unread_inapp + FROM pgz_sport.notifications + {where_sql} + """, params[:-1]) + summary = _row(cur.fetchone()) + return {"count": len(rows), "summary": summary, "rows": rows} + + +@router.post("/notifications/{nid}/read") +def mark_read(nid: int): + with _conn() as conn, conn.cursor() as cur: + cur.execute("""UPDATE pgz_sport.notifications + SET read_at = now(), status = 'sent' + WHERE id = %s + RETURNING id""", (nid,)) + r = cur.fetchone() + if not r: + raise HTTPException(404, "Notifikacija ne postoji") + conn.commit() + return {"ok": True, "id": nid, "status": "read"} + + +class MarkAllReadIn(BaseModel): + user_id: Optional[int] = None + channel: Optional[str] = "inapp" + + +@router.post("/notifications/mark-all-read") +def mark_all_read(body: MarkAllReadIn): + where = ["read_at IS NULL"] + params = [] + if body.user_id is not None: + where.append("user_id = %s"); params.append(body.user_id) + if body.channel: + where.append("channel = %s"); params.append(body.channel) + with _conn() as conn, conn.cursor() as cur: + cur.execute(f"""UPDATE pgz_sport.notifications + SET read_at = now(), status = 'sent' + WHERE {' AND '.join(where)} + RETURNING id""", params) + ids = [r["id"] for r in cur.fetchall()] + conn.commit() + return {"ok": True, "marked_read": len(ids), "ids": ids[:200]} diff --git a/routers/enrich_router.py b/routers/enrich_router.py index dc59e09..9b53942 100644 --- a/routers/enrich_router.py +++ b/routers/enrich_router.py @@ -308,13 +308,19 @@ def _load_row(kind: str, eid: int) -> dict: adresa, godina_osnutka, source_url, metadata FROM pgz_sport.savezi WHERE id=%s""", (eid,)) elif kind == 'sportas': - row = _fetch_one("""SELECT id, ime, prezime, sport, klub_id, profile_url, - slika_url, source_url, source, source_id, - hns_igrac_id, biografija, - datum_rodenja, mjesto_rodenja, broj_dresa, - visina_cm, tezina_kg, dominantna_noga, oib, - vanjski_id, metadata - FROM pgz_sport.clanovi WHERE id=%s""", (eid,)) + row = _fetch_one("""SELECT c.id, c.ime, c.prezime, c.sport, c.klub_id, c.profile_url, + c.slika_url, c.source_url, c.source, c.source_id, + c.hns_igrac_id, c.biografija, + c.datum_rodenja, c.mjesto_rodenja, c.broj_dresa, + c.visina_cm, c.tezina_kg, c.dominantna_noga, c.oib, + c.vanjski_id, c.metadata, + k.sport AS klub_sport, k.naziv AS klub_naziv + FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id + WHERE c.id=%s""", (eid,)) + # Fall back to klub.sport when c.sport is empty + if row and not row.get('sport') and row.get('klub_sport'): + row['sport'] = row['klub_sport'] else: raise HTTPException(400, "kind must be klub|savez|sportas") if not row: @@ -328,7 +334,54 @@ def _display_name(kind: str, row: dict) -> str: return row.get('naziv', '') or '' -def _research_links(naziv, kind, grad=None): +# ─── Sport federations map (loaded once, refresh on file mtime) ───────── +_SPORT_FED_PATH = '/opt/pgz-sport/data/sport_federations.json' +_SPORT_FED_CACHE: dict[str, Any] = {'mtime': 0, 'data': {}, 'aliases': {}, 'media': []} + + +def _load_sport_feds() -> tuple[dict, dict, list]: + """Return (feds, aliases, local_media) — refreshed when JSON changes.""" + try: + st = os.stat(_SPORT_FED_PATH) + except FileNotFoundError: + return ({}, {}, []) + if st.st_mtime != _SPORT_FED_CACHE['mtime']: + try: + with open(_SPORT_FED_PATH, 'r', encoding='utf-8') as f: + raw = json.load(f) + except Exception: + return (_SPORT_FED_CACHE['data'], + _SPORT_FED_CACHE['aliases'], + _SPORT_FED_CACHE['media']) + aliases = raw.pop('_aliases', {}) if isinstance(raw, dict) else {} + media = raw.pop('_local_media_pgz', []) if isinstance(raw, dict) else [] + raw.pop('_meta', None) + _SPORT_FED_CACHE.update(mtime=st.st_mtime, data=raw, aliases=aliases, media=media) + return (_SPORT_FED_CACHE['data'], + _SPORT_FED_CACHE['aliases'], + _SPORT_FED_CACHE['media']) + + +def _normalize_sport(sport: Optional[str]) -> Optional[str]: + if not sport: return None + s = sport.strip().lower() + feds, aliases, _ = _load_sport_feds() + while s in aliases: + nxt = aliases[s] + if nxt == s: break + s = nxt + return s if s in feds else None + + +def _sport_fed(sport: Optional[str]) -> Optional[dict]: + """Resolve sport → federations entry (or None).""" + norm = _normalize_sport(sport) + if not norm: return None + feds, _, _ = _load_sport_feds() + return feds.get(norm) + + +def _research_links(naziv, kind, grad=None, sport: Optional[str] = None): base_q = (naziv or '').strip() q = (base_q + ' ' + grad) if grad else base_q qenc = urllib.parse.quote(q) @@ -340,9 +393,33 @@ def _research_links(naziv, kind, grad=None): if kind == 'klub': out.append({'label': 'Sportilus', 'icon': '⬡', 'url': 'https://www.sportilus.com/?s=' + qenc}) out.append({'label': 'Sudski registar', 'icon': '⚖', 'url': 'https://sudreg.pravosudje.hr/registar/oc/index.html'}) + + # Sport-specific federation links (replace static HNS/transfermarkt for sportas) + fed = _sport_fed(sport) if sport else None if kind == 'sportas': - out.append({'label': 'HNS Semafor', 'icon': '⚽', 'url': 'https://semafor.hns.family/?s=' + qenc}) - out.append({'label': 'transfermarkt','icon': '⚽', 'url': 'https://www.transfermarkt.com/schnellsuche/ergebnis/schnellsuche?query=' + qenc}) + if fed and isinstance(fed.get('national'), dict): + nat = fed['national'] + search = (nat.get('search_url') or nat.get('url') or '').replace('{q}', qenc) + if search: + out.append({'label': nat.get('name', 'Nacionalni savez'), + 'icon': '🏆', 'url': search}) + if fed and isinstance(fed.get('pgz'), dict): + pgz = fed['pgz'] + url = pgz.get('search_url') or pgz.get('url') or '' + if url: + out.append({'label': pgz.get('name', 'PGŽ savez'), + 'icon': '🏟', 'url': url.replace('{q}', qenc)}) + if not fed: + # No mapping for this sport → keep transfermarkt as legacy fallback + out.append({'label': 'HNS Semafor', 'icon': '⚽', 'url': 'https://semafor.hns.family/?s=' + qenc}) + out.append({'label': 'transfermarkt','icon': '⚽', 'url': 'https://www.transfermarkt.com/schnellsuche/ergebnis/schnellsuche?query=' + qenc}) + # Local PGŽ media for any sportas + _, _, media = _load_sport_feds() + for m in media: + url = (m.get('search_url') or '').replace('{q}', qenc) + if url: + out.append({'label': m.get('name', 'Lokalni medij'), + 'icon': '📰', 'url': url}) if kind == 'savez': out.append({'label': 'sport-pgz.hr savezi', 'icon': '🏅', 'url': 'https://sport-pgz.hr/savezi'}) return out @@ -591,38 +668,219 @@ def _hns_fetch_player(url: str) -> Optional[dict]: return _parse_hns_player(body, url) if body else None +# ─── Generic sport-federation scraper ─────────────────────────────────── +def _fed_url_from_row(row: dict) -> Optional[str]: + """If the row already points to a federation profile (source_url / + profile_url on a known fed host), return it.""" + feds, _, _ = _load_sport_feds() + fed_hosts = set() + for entry in feds.values(): + if not isinstance(entry, dict): continue + for which in ('national', 'pgz'): + sub = entry.get(which) or {} + for k in ('url', 'search_url', 'profile_url_pattern'): + v = sub.get(k) + if v: + try: + h = urllib.parse.urlparse(v.replace('{q}', 'x').replace('{slug}', 'x').replace('{hns_pid}', '1')).hostname + if h: fed_hosts.add(h) + except Exception: + pass + for k in ('source_url', 'profile_url'): + u = row.get(k) + if not u: continue + try: + h = urllib.parse.urlparse(u).hostname or '' + except Exception: + continue + if h in fed_hosts: + return u + return None + + +def _parse_federation_profile(html_doc: str, url: str, ime: str, prezime: str) -> Optional[dict]: + """Best-effort parser for a generic sport-federation profile page. + + Returns {source, url, slika_url, datum_rodenja, mjesto_rodenja, klub, + extract, raw_text}. Tolerant of varied page structures. + """ + if not html_doc: return None + host = urllib.parse.urlparse(url).hostname or '' + out: dict[str, Any] = { + 'source': host, + 'url': url, + } + # Title + m = re.search(r']*>([^<]+)', html_doc, re.I) + if m: out['title'] = html.unescape(m.group(1).strip())[:300] + # Meta description + m = re.search(r'= 3: + name_tokens.append(re.escape(t)) + + # Pick the first content image whose filename contains the player's name, + # or fall back to the first non-asset image. + img_candidates = re.findall(r']+src=["\']([^"\']+)["\']', html_doc, re.I) + chosen_img = None + for src in img_candidates: + low = src.lower() + if any(b in low for b in ('logo', 'icon', 'admin-ajax', 'spinner', 'loader', + 'sprite', '/themes/', '/icons/', 'gdpr', 'banner', + 'header', 'footer', 'placeholder', 'avatar-default')): + continue + if not low.endswith(('.jpg', '.jpeg', '.png', '.webp')): + continue + # Prefer matches on player name in URL + if name_tokens and any(re.search(t, src, re.I) for t in name_tokens): + chosen_img = src; break + if chosen_img is None: + chosen_img = src + if chosen_img: + if not chosen_img.startswith('http'): + chosen_img = urllib.parse.urljoin(url, chosen_img) + out['slika_url'] = chosen_img + + # Plain text body for evidence + label scraping + text = re.sub(r']*>.*?', ' ', html_doc, flags=re.S | re.I) + text = re.sub(r']*>.*?', ' ', text, flags=re.S | re.I) + text = re.sub(r'<[^>]+>', ' ', text) + text = html.unescape(re.sub(r'\s+', ' ', text)).strip() + out['raw_text'] = text[:4000] + out['extract'] = (out.get('description') + or text[max(0, text.find(prezime)-30):max(0, text.find(prezime)-30)+500] + or text[:500]) + + # Common label-driven fields (HBS layout: "Godina rođenja: 1979.", "Matični klub: …") + m = re.search(r'Datum\s+ro[đdj]?enja[:\s]+(\d{1,2}[.\-/]\d{1,2}[.\-/]\d{4})', text, re.I) + if m: + try: + from datetime import date as _date + d = re.split(r'[.\-/]', m.group(1)) + out['datum_rodenja'] = _date(int(d[2]), int(d[1]), int(d[0])).isoformat() + except Exception: + pass + if 'datum_rodenja' not in out: + m = re.search(r'Godina\s+ro[đdj]?enja[:\s]+(\d{4})', text, re.I) + if m: + try: + from datetime import date as _date + out['datum_rodenja'] = _date(int(m.group(1)), 1, 1).isoformat() + except Exception: + pass + m = re.search(r'Mjesto\s+ro[đdj]?enja[:\s]+([A-ZČĆŠĐŽ][^,\n.]{2,40})', text) + if m: out['mjesto_rodenja'] = m.group(1).strip() + m = re.search(r'Mati[čc]ni\s+klub[:\s]+([^\n]{3,60}?)(?:\s+(?:Sportski|Datum|Liječni|Reprezent|Sezona|Domaće|Nastupi))', text, re.I) + if m: out['klub_naziv'] = m.group(1).strip().rstrip('.') + + return out + + +def _slugify_simple(s: str) -> str: + import unicodedata + s = unicodedata.normalize('NFKD', s or '').encode('ascii', 'ignore').decode('ascii').lower() + return re.sub(r'[^a-z0-9]+', '-', s).strip('-') + + +def scrape_sport_federation(sport: Optional[str], ime: str, prezime: str) -> Optional[dict]: + """Try to find and parse the athlete's federation profile page.""" + fed = _sport_fed(sport) if sport else None + if not fed: return None + nat = (fed or {}).get('national') or {} + full_name = (ime + ' ' + prezime).strip() + + # 1) Direct profile URL via {slug} pattern (works for HBS at least) + pattern = nat.get('profile_url_pattern') + if pattern and '{slug}' in pattern: + slug = _slugify_simple(full_name) + url = pattern.replace('{slug}', slug) + body = _http_get(url, timeout=8) + if body and prezime.lower() in body.lower(): + return _parse_federation_profile(body, url, ime, prezime) + + # 2) Search URL → first /igraci|/profil|/clan link that mentions the surname + search = nat.get('search_url') + if search: + body = _http_get(search.replace('{q}', urllib.parse.quote(full_name)), timeout=10) + if body: + for href_re in (r'href="([^"]*?/igraci/[^"]+)"', + r'href="([^"]*?/igrac/[^"]+)"', + r'href="([^"]*?/sportasi/[^"]+)"', + r'href="([^"]*?/clanovi/[^"]+)"', + r'href="([^"]*?/profil/[^"]+)"'): + for m in re.finditer(href_re, body, re.I): + cand = m.group(1) + if not cand.startswith('http'): + cand = urllib.parse.urljoin(nat.get('url', search), cand) + if _slugify_simple(prezime) in _slugify_simple(cand): + b2 = _http_get(cand, timeout=8) + if b2: + return _parse_federation_profile(b2, cand, ime, prezime) + return None + + def _propose_for_sportas(row: dict) -> dict: naziv = ((row.get('ime') or '') + ' ' + (row.get('prezime') or '')).strip() + ime, prezime = (row.get('ime') or ''), (row.get('prezime') or '') + sport = row.get('sport') sources, evidence = [], [] proposed: dict[str, Any] = {} - # 1) Resolve a HNS Semafor URL for this athlete (column / vanjski_id / source_id) - hns_url = _hns_url_from_row(row) + # 1) HNS Semafor — only meaningful when sport is football OR row already + # carries an HNS link. hns_doc: Optional[dict] = None - if hns_url: - hns_doc = _hns_fetch_player(hns_url) - if hns_doc: - sources.append(hns_doc) - evidence.append(hns_doc.get('raw_text') or hns_doc.get('extract') or '') + if _normalize_sport(sport) == 'nogomet' or _hns_url_from_row(row): + hns_url = _hns_url_from_row(row) + if hns_url: + hns_doc = _hns_fetch_player(hns_url) + if hns_doc: + sources.append(hns_doc) + evidence.append(hns_doc.get('raw_text') or hns_doc.get('extract') or '') - # Field-level proposals from HNS Semafor (only when DB is empty) - if hns_doc: - if not row.get('profile_url') and hns_doc.get('url'): - proposed['profile_url'] = hns_doc['url'] - if not row.get('source_url') and hns_doc.get('url'): - proposed['source_url'] = hns_doc['url'] - if not row.get('slika_url') and hns_doc.get('slika_url'): - proposed['slika_url'] = hns_doc['slika_url'] - if not row.get('hns_igrac_id') and hns_doc.get('hns_igrac_id'): - proposed['hns_igrac_id'] = hns_doc['hns_igrac_id'] - if not row.get('datum_rodenja') and hns_doc.get('datum_rodenja'): - proposed['datum_rodenja'] = hns_doc['datum_rodenja'] - if not row.get('mjesto_rodenja') and hns_doc.get('mjesto_rodenja'): - proposed['mjesto_rodenja'] = hns_doc['mjesto_rodenja'] - if not row.get('broj_dresa') and hns_doc.get('broj_dresa'): - proposed['broj_dresa'] = hns_doc['broj_dresa'] + # 2) Sport-aware federation scrape (HBS, HKS, etc.) — also use existing + # source_url/profile_url if it points at a known federation host. + fed_doc: Optional[dict] = None + direct_fed_url = _fed_url_from_row(row) + if direct_fed_url and (not hns_doc or hns_doc.get('url') != direct_fed_url): + body = _http_get(direct_fed_url, timeout=8) + if body: + fed_doc = _parse_federation_profile(body, direct_fed_url, ime, prezime) + if not fed_doc: + fed_doc = scrape_sport_federation(sport, ime, prezime) + if fed_doc: + sources.append(fed_doc) + evidence.append(fed_doc.get('raw_text') or fed_doc.get('extract') or '') - # 2) Wikipedia HR for biografija + # Helper: pick from hns_doc first then fed_doc + def _pick(field): + if hns_doc and hns_doc.get(field): return hns_doc[field] + if fed_doc and fed_doc.get(field): return fed_doc[field] + return None + + if not row.get('profile_url'): + v = _pick('url') or (hns_doc and hns_doc.get('url')) or (fed_doc and fed_doc.get('url')) + if v: proposed['profile_url'] = v + if not row.get('source_url'): + v = (hns_doc and hns_doc.get('url')) or (fed_doc and fed_doc.get('url')) + if v: proposed['source_url'] = v + if not row.get('slika_url'): + v = _pick('slika_url') + if v: proposed['slika_url'] = v + if not row.get('hns_igrac_id') and hns_doc and hns_doc.get('hns_igrac_id'): + proposed['hns_igrac_id'] = hns_doc['hns_igrac_id'] + if not row.get('datum_rodenja'): + v = _pick('datum_rodenja') + if v: proposed['datum_rodenja'] = v + if not row.get('mjesto_rodenja'): + v = _pick('mjesto_rodenja') + if v: proposed['mjesto_rodenja'] = v + if not row.get('broj_dresa') and hns_doc and hns_doc.get('broj_dresa'): + proposed['broj_dresa'] = hns_doc['broj_dresa'] + + # 3) Wikipedia HR for biografija if not row.get('biografija'): wiki = _wiki_summary(naziv) if wiki: @@ -631,7 +889,7 @@ def _propose_for_sportas(row: dict) -> dict: # Description: prefer DeepSeek synthesis from all evidence; fallback to first long snippet if not row.get('biografija'): - descr = _deepseek_describe(naziv, 'sportaš', evidence) if evidence else None + descr = _deepseek_describe(naziv, f'sportaš ({sport})' if sport else 'sportaš', evidence) if evidence else None if not descr: for s in sources: ext = s.get('extract') @@ -863,7 +1121,13 @@ def enrich_preview(kind: str = _FPath(..., regex='^(klub|savez|sportas)$'), eid: 'coverage': coverage, 'filled_fields': filled, 'total_fields': len(keys), 'missing_fields': missing, 'live_snippet': _fetch_title(primary) if primary else None, - 'research_links': _research_links(naziv, kind, grad), + 'research_links': _research_links(naziv, kind, grad, sport=row.get('sport')), + 'sport': row.get('sport'), + 'sport_federation': (lambda f: { + 'national': (f.get('national') or {}).get('name') if f else None, + 'national_url': (f.get('national') or {}).get('url') if f else None, + 'pgz': (f.get('pgz') or {}).get('name') if f else None, + })(_sport_fed(row.get('sport'))), 'sources': res['sources'], 'current': current, 'proposed': proposed, diff --git a/scripts/cleanup_garbage_clubs.py b/scripts/cleanup_garbage_clubs.py new file mode 100755 index 0000000..c8e3ed0 --- /dev/null +++ b/scripts/cleanup_garbage_clubs.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +""" +cleanup_garbage_clubs.py — fix klubovi where naziv is an address + +Author: Damir Radulić (dradulic@outlook.com / damir@rinet.one) +Date: 2026-05-05 + +Symptoms (R3B/R4 cleanup pass): + - 14 odbojkaški klubovi imaju adresu u polju `naziv` + - others: null/empty naziv, naziv equal to grad, naziv only digits + - sportaši with email/phone in ime/prezime + +Strategy: + 1) For each problem klub, look up civic.entities by address fragment. + 2) If exactly one candidate → swap (naziv ← candidate.name, adresa ← old naziv, + oib ← candidate.oib if missing) with confidence 0.95. + 3) If multiple candidates → mark metadata.manual_review=true with candidates list. + 4) If zero candidates → broader fallback (city + sport=odbojka) and same logic. + +Backup: pgz_sport.klubovi_backup_20260505 must already exist (run from SQL). + +Reports written to /opt/pgz-sport/data_cleanup_report.md (separate driver). +""" +from __future__ import annotations +import os, json, sys +from datetime import datetime, timezone +import psycopg2, psycopg2.extras + +PG = dict(host=os.environ.get('PG_HOST','10.10.0.2'), + port=int(os.environ.get('PG_PORT','6432')), + dbname=os.environ.get('PG_DB','rinet_v3'), + user=os.environ.get('PG_USER','rinet'), + password=os.environ.get('PG_PASS','')) + +PROBLEM_IDS = [2613, 2616, 2618, 2619, 2622, 2624, 2626, 2630, 2632, 2634, 2636, 2638, 2641, 2643] + +# Hand-curated picks where DB has multiple candidates at same address. +# Source: cross-reference with HOS (Hrvatski odbojkaški savez) member roster. +MANUAL_PICKS = { + # 2613 = Trg Viktora Bubnja 1 — savez address; primary host is HAOK Rijeka. + 2613: 100700, # Hrvatski Akademski Odbojkaški Klub "Rijeka" + # 2618 = Zdravka Kučića 1 — both ŽOK and MOK Gornja Vežica share. The municipal + # club entry is MOK Gornja Vežica which is the registered active senior team. + 2618: 82677, # Muški Odbojkaški Klub "Gornja Vežica" +} + +# Hand-curated for zero-match cases (verified via HOS public list) +ZERO_MATCH_HINTS = { + 2619: {'name': 'Odbojkaški Klub Čavle', 'note':'Vrh Čavje 31, Čavle'}, + 2630: {'name': 'Odbojkaški Klub Opatija', 'note':'1. Istarske čete 3, Opatija'}, + 2636: {'name': 'Odbojkaški Klub Rijeka', 'note':'Sv. Križ 24, Rijeka — possibly OK Rijeka senior'}, + 2641: {'name': 'Odbojkaški Klub Crikvenica','note':'Kotorska 15a, Crikvenica'}, +} + + +def db(): + c = psycopg2.connect(**PG); c.autocommit = False; return c + + +def fetch_candidates(cur, addr_fragment, sport='odbojka'): + cur.execute(""" + SELECT id, name, oib, address, city, entity_type + FROM civic.entities + WHERE address ILIKE %s + AND (name ILIKE '%%odbojk%%' OR name ILIKE 'OK %%' OR name ILIKE 'ŽOK%%' + OR name ILIKE 'MOK %%' OR name ILIKE '%%volley%%') + ORDER BY length(name) + LIMIT 5 + """, ('%'+addr_fragment+'%',)) + return [dict(r) for r in cur.fetchall()] + + +def update_klub(cur, kid, new_naziv, old_naziv_as_address, oib, manual_review=False, candidates=None, source=None): + """Move old naziv → adresa, set new naziv, optionally set oib + metadata.""" + md = { + 'cleanup_at': datetime.now(timezone.utc).isoformat(), + 'cleanup_reason': 'naziv_is_address', + 'cleanup_source': source or 'civic.entities', + } + if manual_review: + md['manual_review'] = True + if candidates: + md['candidates'] = candidates + set_parts = ["naziv=%s", "adresa=%s", + "metadata = COALESCE(metadata,'{}'::jsonb) || %s::jsonb"] + params = [new_naziv, old_naziv_as_address, json.dumps(md, ensure_ascii=False)] + if oib: + set_parts.append("oib=COALESCE(NULLIF(oib,''), %s)") + params.append(oib) + params.append(kid) + cur.execute(f"UPDATE pgz_sport.klubovi SET {', '.join(set_parts)} WHERE id=%s", params) + + +def address_fragment(addr): + """Extract the most distinctive piece of an address for ILIKE matching. + e.g. 'Trg Viktora Bubnja 1, 51000 Rijeka' → 'Trg Viktora Bubnja 1' + """ + return (addr or '').split(',')[0].strip() + + +def run(): + conn = db() + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + report = { + 'started_at': datetime.now(timezone.utc).isoformat(), + 'problem_ids': PROBLEM_IDS, + 'fixed': [], + 'manual_review': [], + 'failed': [], + } + + cur.execute(""" + SELECT id, naziv, adresa, grad, sport, oib FROM pgz_sport.klubovi + WHERE id = ANY(%s) ORDER BY id + """, (PROBLEM_IDS,)) + problems = [dict(r) for r in cur.fetchall()] + + for p in problems: + kid = p['id'] + addr = p['naziv'] # the bad value + frag = address_fragment(addr) + cands = fetch_candidates(cur, frag) + + chosen = None + confidence = 0.0 + path = '' + + if len(cands) == 1: + chosen = cands[0] + confidence = 0.95 + path = 'single_match' + elif len(cands) > 1 and kid in MANUAL_PICKS: + for c in cands: + if c['id'] == MANUAL_PICKS[kid]: + chosen = c + confidence = 0.90 + path = 'curated_pick' + break + elif len(cands) > 1: + # Mark manual review with all candidates + update_klub(cur, kid, + new_naziv=f'[MANUAL REVIEW] {addr}', + old_naziv_as_address=addr, + oib=None, manual_review=True, + candidates=[{'id':c['id'],'name':c['name'],'oib':c['oib']} for c in cands], + source='multi_candidate') + report['manual_review'].append({ + 'klub_id': kid, 'address': addr, + 'candidates': [{'id':c['id'],'name':c['name'],'oib':c['oib']} for c in cands], + 'reason': f'{len(cands)} candidates at same address — operator must pick', + }) + continue + elif kid in ZERO_MATCH_HINTS: + # Use hint name and mark it for verification + update_klub(cur, kid, + new_naziv=f"[VERIFY] {ZERO_MATCH_HINTS[kid]['name']}", + old_naziv_as_address=addr, + oib=None, manual_review=True, + candidates=None, + source='heuristic_hint') + report['manual_review'].append({ + 'klub_id': kid, 'address': addr, + 'suggested_name': ZERO_MATCH_HINTS[kid]['name'], + 'note': ZERO_MATCH_HINTS[kid]['note'], + 'reason': 'no civic.entities match — heuristic suggestion needs verification', + }) + continue + else: + update_klub(cur, kid, + new_naziv=f'[UNRESOLVED] {addr}', + old_naziv_as_address=addr, + oib=None, manual_review=True, + candidates=None, source='no_match') + report['failed'].append({ + 'klub_id': kid, 'address': addr, + 'reason': 'no candidates found anywhere', + }) + continue + + if chosen: + update_klub(cur, kid, + new_naziv=chosen['name'], + old_naziv_as_address=addr, + oib=chosen.get('oib'), + manual_review=False, + source=f"civic.entities#{chosen['id']}") + report['fixed'].append({ + 'klub_id': kid, + 'old_naziv': addr, + 'new_naziv': chosen['name'], + 'oib_set': chosen.get('oib'), + 'civic_entity_id': chosen['id'], + 'confidence': confidence, + 'path': path, + }) + + conn.commit() + cur.close(); conn.close() + + report['completed_at'] = datetime.now(timezone.utc).isoformat() + report['summary'] = { + 'total': len(problems), + 'fixed': len(report['fixed']), + 'manual_review': len(report['manual_review']), + 'failed': len(report['failed']), + } + print(json.dumps(report, indent=2, ensure_ascii=False)) + + +if __name__ == '__main__': + run() diff --git a/uploads/avatars/11_1777935064.png b/uploads/avatars/11_1777935064.png new file mode 100644 index 0000000..059cc98 Binary files /dev/null and b/uploads/avatars/11_1777935064.png differ