diff --git a/_backups/admin.html.cc3_pre_redesign.1777937786 b/_backups/admin.html.cc3_pre_redesign.1777937786 new file mode 100644 index 0000000..f4523b7 --- /dev/null +++ b/_backups/admin.html.cc3_pre_redesign.1777937786 @@ -0,0 +1,771 @@ + + + + + +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/app.html.cc3_pre_redesign.1777937786 b/_backups/app.html.cc3_pre_redesign.1777937786 new file mode 100644 index 0000000..fd20395 --- /dev/null +++ b/_backups/app.html.cc3_pre_redesign.1777937786 @@ -0,0 +1,1854 @@ + + + + + +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_redesign.1777937786 b/_backups/audit.html.cc3_pre_redesign.1777937786 new file mode 100644 index 0000000..918b352 --- /dev/null +++ b/_backups/audit.html.cc3_pre_redesign.1777937786 @@ -0,0 +1,153 @@ + + + + +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.r6_pre.1777937855 b/_backups/auth_v2.py.r6_pre.1777937855 new file mode 100644 index 0000000..975077e --- /dev/null +++ b/_backups/auth_v2.py.r6_pre.1777937855 @@ -0,0 +1,866 @@ +#!/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."} + +# ─────────────────────────── 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 + 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_redesign.1777937786 b/_backups/crm.html.cc3_pre_redesign.1777937786 new file mode 100644 index 0000000..612574c --- /dev/null +++ b/_backups/crm.html.cc3_pre_redesign.1777937786 @@ -0,0 +1,1620 @@ + + + + + +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
+
📊 Statistika
+
🔔 Notifikacije
+
+ ROLA: + +
+
+ +
+
+ + + + + +
+ + + +
+ + + + + diff --git a/_backups/erp.html.cc3_pre_redesign.1777937786 b/_backups/erp.html.cc3_pre_redesign.1777937786 new file mode 100644 index 0000000..74b5adb --- /dev/null +++ b/_backups/erp.html.cc3_pre_redesign.1777937786 @@ -0,0 +1,1006 @@ + + + + + +PGŽ Sport · ERP — OCR + Putni nalozi + + + + + + +
+ +
+
+

Skeniraj račun (OCR)

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

📊 ERP statistika — mjesec / kvartal / godina

+
+
+ + 📥 Export XLSX +
+
+
+

Top klubovi (godina)

+
KlubBr. računaTotal
+
+
+

Putni nalozi

+
+
+
+
+ +
+
+

📷 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)

+
+ Označeno: 0 + + + + 📥 Export XLSX (svi) +
+
#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_redesign.1777937786 b/_backups/kpi.html.cc3_pre_redesign.1777937786 new file mode 100644 index 0000000..5f57fab --- /dev/null +++ b/_backups/kpi.html.cc3_pre_redesign.1777937786 @@ -0,0 +1,102 @@ + + + + +RINET KPI Dashboard + + + + + +

RINET KPI Dashboard

+
Loading...
+ + + + diff --git a/_backups/login.html.cc3_pre_redesign.1777937786 b/_backups/login.html.cc3_pre_redesign.1777937786 new file mode 100644 index 0000000..4bd33b7 --- /dev/null +++ b/_backups/login.html.cc3_pre_redesign.1777937786 @@ -0,0 +1,564 @@ + + + + + +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/pgz_sport_api.py.r6_pre.1777937855 b/_backups/pgz_sport_api.py.r6_pre.1777937855 new file mode 100644 index 0000000..ee172a7 --- /dev/null +++ b/_backups/pgz_sport_api.py.r6_pre.1777937855 @@ -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/r3_cc4/erp.html.pre_R6.1777937864 b/_backups/r3_cc4/erp.html.pre_R6.1777937864 new file mode 100644 index 0000000..74b5adb --- /dev/null +++ b/_backups/r3_cc4/erp.html.pre_R6.1777937864 @@ -0,0 +1,1006 @@ + + + + + +PGŽ Sport · ERP — OCR + Putni nalozi + + + + + + +
+ +
+
+

Skeniraj račun (OCR)

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

📊 ERP statistika — mjesec / kvartal / godina

+
+
+ + 📥 Export XLSX +
+
+
+

Top klubovi (godina)

+
KlubBr. računaTotal
+
+
+

Putni nalozi

+
+
+
+
+ +
+
+

📷 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)

+
+ Označeno: 0 + + + + 📥 Export XLSX (svi) +
+
#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_R6.1777937864 b/_backups/r3_cc4/ocr.py.pre_R6.1777937864 new file mode 100644 index 0000000..5fdb682 --- /dev/null +++ b/_backups/r3_cc4/ocr.py.pre_R6.1777937864 @@ -0,0 +1,1126 @@ +#!/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} + + +# ── 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("/export/invoices.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, + 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/permissions.py.pre_R6.1777937864 b/_backups/r3_cc4/permissions.py.pre_R6.1777937864 new file mode 100644 index 0000000..62b610f --- /dev/null +++ b/_backups/r3_cc4/permissions.py.pre_R6.1777937864 @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +# erp/permissions.py — PGŽ Sport ERP RBAC helpers (M5/M6) +# Author: Damir Radulić / dradulic@outlook.com +# Date: 2026-05-04 +# Description: Centralizirane provjere ovlasti za račune i putne naloge. +# +# Uloge (pgz_sport.roles): +# super_admin, pgz_admin, savez_admin, klub_admin, klub_user, clan, viewer +# +# Korisnik (dict iz auth_v2.get_current_user) ima: id, user_type, klub_id, savez_id. + +from __future__ import annotations +from typing import Optional, Dict, Any +import psycopg2, psycopg2.extras + +DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet", + password="R1net2026!SecureDB#v7") + + +def _db(): + c = psycopg2.connect(**DB); c.autocommit = True; return c + + +# ── role helpers ────────────────────────────────────────────────────── +def is_super(user) -> bool: + return bool(user) and user.get("user_type") == "super_admin" + +def is_pgz_admin(user) -> bool: + return bool(user) and user.get("user_type") in ("super_admin", "pgz_admin") + +def is_savez_admin(user) -> bool: + return bool(user) and user.get("user_type") == "savez_admin" + +def is_klub_admin(user) -> bool: + return bool(user) and user.get("user_type") == "klub_admin" + +def is_klub_user(user) -> bool: + return bool(user) and user.get("user_type") in ("klub_admin", "klub_user") + + +def klub_savez(klub_id: int) -> Optional[int]: + """Vraća savez_id kojem klub pripada (preko klubovi.savez_id ili user_klub_links).""" + if not klub_id: return None + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT savez_id FROM pgz_sport.klubovi WHERE id=%s", (klub_id,)) + r = cur.fetchone() + return r["savez_id"] if r else None + + +def user_can_see_klub(user, klub_id: Optional[int]) -> bool: + """Tko može VIDJETI klub: super, pgz, savez (ako klub u savezu), klub_admin/user (ako vlastiti klub).""" + if not user or not klub_id: + return is_pgz_admin(user) + if is_pgz_admin(user): + return True + if is_klub_user(user): + return user.get("klub_id") == klub_id + if is_savez_admin(user): + return klub_savez(klub_id) == user.get("savez_id") + return False + + +# ── INVOICES ────────────────────────────────────────────────────────── +def can_view_invoice(user, invoice: Dict[str, Any]) -> bool: + """Pgž admin vidi sve. Savez admin svoje saveze. Klub admin/user vlastiti klub.""" + if not invoice: return False + if is_pgz_admin(user): return True + return user_can_see_klub(user, invoice.get("klub_id")) + + +def can_edit_invoice(user, invoice: Dict[str, Any]) -> bool: + """ + Edit (izmjena polja, korekcija OCR-a) — samo klub_admin vlastitog kluba ILI pgz_admin. + Savez admin može komentirati, ali NE editirati. + Plaćeni računi su read-only osim za pgz_admin. + """ + if not invoice: return False + if is_pgz_admin(user): return True + if invoice.get("payment_status") in ("paid",): + return False + if is_klub_admin(user): + return user.get("klub_id") == invoice.get("klub_id") + return False + + +def can_pay_invoice(user, invoice: Dict[str, Any]) -> bool: + """Označi kao plaćen — klub_admin vlastitog kluba ili pgz_admin.""" + if not invoice: return False + if is_pgz_admin(user): return True + if is_klub_admin(user): + return user.get("klub_id") == invoice.get("klub_id") + return False + + +def can_comment_invoice(user, invoice: Dict[str, Any]) -> bool: + """Komentirati može pgz_admin, savez_admin (svog saveza) i klub_admin (svog kluba).""" + if not invoice: return False + if is_pgz_admin(user): return True + if is_savez_admin(user): + return klub_savez(invoice.get("klub_id")) == user.get("savez_id") + if is_klub_admin(user): + return user.get("klub_id") == invoice.get("klub_id") + return False + + +def invoice_actions(user, invoice: Dict[str, Any]) -> Dict[str, bool]: + """UI hint — koji gumbi su dostupni.""" + return { + "view": can_view_invoice(user, invoice), + "edit": can_edit_invoice(user, invoice), + "pay": can_pay_invoice(user, invoice) and invoice.get("payment_status") != "paid", + "comment": can_comment_invoice(user, invoice), + "delete": is_pgz_admin(user), + } + + +# ── PUTNI NALOZI ────────────────────────────────────────────────────── +def can_view_putni_nalog(user, pn: Dict[str, Any]) -> bool: + if not pn: return False + if is_pgz_admin(user): return True + if is_savez_admin(user): + return klub_savez(pn.get("klub_id")) == user.get("savez_id") + if is_klub_user(user): + if user.get("klub_id") == pn.get("klub_id"): + return True + # Voditelj vidi svoj + return pn.get("user_id") == user.get("id") if user else False + + +def can_edit_putni_nalog(user, pn: Dict[str, Any]) -> bool: + """Edit dozvoljen samo na statusima draft/odbijen, i samo voditelju ili klub_admin/pgz.""" + if not pn: return False + status = (pn.get("status") or "draft").lower() + if status not in ("draft", "odbijen"): + return is_pgz_admin(user) + if is_pgz_admin(user): return True + if is_klub_admin(user): + return user.get("klub_id") == pn.get("klub_id") + # Voditelj + return pn.get("user_id") == user.get("id") if user else False + + +def can_submit_putni_nalog(user, pn: Dict[str, Any]) -> bool: + """Slanje (draft → poslan) — voditelj ili klub_admin.""" + if not pn: return False + if (pn.get("status") or "draft").lower() not in ("draft",): + return False + if is_pgz_admin(user): return True + if is_klub_admin(user): + return user.get("klub_id") == pn.get("klub_id") + return pn.get("user_id") == user.get("id") if user else False + + +def can_approve_putni_nalog(user, pn: Dict[str, Any]) -> bool: + """Odobravanje (poslan → odobren ili odbijen) — klub_admin svog kluba ili pgz_admin.""" + if not pn: return False + if (pn.get("status") or "").lower() not in ("poslan", "submitted", "draft"): + return False + if is_pgz_admin(user): return True + if is_klub_admin(user): + return user.get("klub_id") == pn.get("klub_id") + return False + + +def can_pay_putni_nalog(user, pn: Dict[str, Any]) -> bool: + """Isplata (odobren → isplaćen) — klub_admin ili pgz_admin.""" + if not pn: return False + if (pn.get("status") or "").lower() not in ("odobren", "approved", "zatvoren"): + return False + if is_pgz_admin(user): return True + if is_klub_admin(user): + return user.get("klub_id") == pn.get("klub_id") + return False + + +def putni_nalog_actions(user, pn: Dict[str, Any]) -> Dict[str, bool]: + return { + "view": can_view_putni_nalog(user, pn), + "edit": can_edit_putni_nalog(user, pn), + "submit": can_submit_putni_nalog(user, pn), + "approve": can_approve_putni_nalog(user, pn), + "reject": can_approve_putni_nalog(user, pn), + "pay": can_pay_putni_nalog(user, pn), + "delete": is_pgz_admin(user), + } + + +# ── Audit logging helper ────────────────────────────────────────────── +def audit_invoice(user, invoice_id: int, op: str, field: Optional[str] = None, + old=None, new=None): + try: + with _db() as c: + c.cursor().execute( + """INSERT INTO pgz_sport.audit_log + (tablica, operacija, record_id, korisnik, promijenjeno_polje, + stara_vrijednost, nova_vrijednost) + VALUES ('pgz_sport.invoices', %s, %s, %s, %s, %s, %s)""", + (op, invoice_id, + (user.get("email") if user else "anon"), + field, + None if old is None else str(old)[:500], + None if new is None else str(new)[:500]), + ) + except Exception: + pass + + +def audit_putni(user, pn_id: int, op: str, field: Optional[str] = None, + old=None, new=None): + try: + with _db() as c: + c.cursor().execute( + """INSERT INTO pgz_sport.audit_log + (tablica, operacija, record_id, korisnik, promijenjeno_polje, + stara_vrijednost, nova_vrijednost) + VALUES ('pgz_sport.expense_reports', %s, %s, %s, %s, %s, %s)""", + (op, pn_id, + (user.get("email") if user else "anon"), + field, + None if old is None else str(old)[:500], + None if new is None else str(new)[:500]), + ) + except Exception: + pass + + +def fetch_audit(table: str, record_id: int, limit: int = 50): + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + """SELECT timestamp, operacija, korisnik, promijenjeno_polje, + stara_vrijednost, nova_vrijednost + FROM pgz_sport.audit_log + WHERE tablica=%s AND record_id=%s + ORDER BY timestamp DESC LIMIT %s""", + (table, record_id, limit), + ) + return cur.fetchall() diff --git a/_backups/r3_cc4/putni_nalozi.py.pre_R6.1777937864 b/_backups/r3_cc4/putni_nalozi.py.pre_R6.1777937864 new file mode 100644 index 0000000..a2c86e1 --- /dev/null +++ b/_backups/r3_cc4/putni_nalozi.py.pre_R6.1777937864 @@ -0,0 +1,724 @@ +#!/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") + + # 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( + """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, + "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.""" + 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}/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)): + 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/app.html.r5_kalendar.1777937576 b/_backups/r3_cc5/app.html.r5_kalendar.1777937576 new file mode 100644 index 0000000..fd20395 --- /dev/null +++ b/_backups/r3_cc5/app.html.r5_kalendar.1777937576 @@ -0,0 +1,1854 @@ + + + + + +PGŽ SPORT — Operativna aplikacija + + + + + + + + + +
+ + +
+
+
+
Dashboard
+
Pregled stanja
+
+
+
+
+
DR
+
+
Damir Radulićpgz admin
+
Primorsko-goranska županija
+
+
+
+
+ +
+
Učitavanje...
+
+
+
+ + +
+ + + + + + + diff --git a/_backups/r3_cc5/crm.html.r5_extras.1777937776 b/_backups/r3_cc5/crm.html.r5_extras.1777937776 new file mode 100644 index 0000000..612574c --- /dev/null +++ b/_backups/r3_cc5/crm.html.r5_extras.1777937776 @@ -0,0 +1,1620 @@ + + + + + +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
+
📊 Statistika
+
🔔 Notifikacije
+
+ ROLA: + +
+
+ +
+
+ + + + + +
+ + + +
+ + + + + diff --git a/_backups/r3_cc5/crm_extras_router.py.pre_r6.1777937999 b/_backups/r3_cc5/crm_extras_router.py.pre_r6.1777937999 new file mode 100644 index 0000000..3db5f44 --- /dev/null +++ b/_backups/r3_cc5/crm_extras_router.py.pre_r6.1777937999 @@ -0,0 +1,1009 @@ +#!/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 re as _re +import sys +import zipfile +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, Header +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, +) + +DEFAULT_PRIMATELJ_IBAN = "HR0000000000000000000" +DEFAULT_PRIMATELJ_NAZIV = "PGŽ Odjel za sport" +DEFAULT_PRIMATELJ_ADRESA = "Adamićeva 10, 51000 Rijeka" + +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 + col_letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ") + ["AA", "AB", "AC", "AD", "AE", "AF"] + for col_letter, h in zip(col_letters, 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 + include_expired: bool = True # uključi i one koji su već istekli + + +@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. Ako include_expired=True, isto kreira jednu + notifikaciju (threshold=0) za već istekle. + 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 [] + + # threshold=0 → već istekli (poseban "expired" bucket) + scan_buckets = [(thr, "uskoro") for thr in thresholds] + if body.include_expired: + scan_buckets.append((0, "expired")) + + created = [] + with _conn() as conn, conn.cursor() as cur: + for thr, kind in scan_buckets: + if kind == "expired": + where_window = "(l.vrijedi_do - CURRENT_DATE) < 0" + where_params = [] + else: + where_window = "(l.vrijedi_do - CURRENT_DATE) BETWEEN 0 AND %s" + where_params = [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 {where_window} + {klub_filter} + """, where_params + 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 + + if r['dana'] is not None and r['dana'] < 0: + subject = f"⚠ Liječnički pregled ISTEKAO ({-r['dana']} dana): {r['clan']}" + msg_dana = f"istekao prije {-r['dana']} dana" + else: + subject = f"⚕ Liječnički pregled ističe za {r['dana']} dana: {r['clan']}" + msg_dana = f"{r['dana']} dana ostalo" + body_txt = ( + f"Liječnički pregled za sportaša {r['clan']} " + f"({r.get('klub') or '(bez kluba)'}) — vrijedi do {r['vrijedi_do']} " + f"— {msg_dana}.\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]} + + +# ════════════════════════════════════════════════════ +# R6 #2 — BATCH HUB-3 PDFs ZIP +# ════════════════════════════════════════════════════ + +class BulkZipIn(BaseModel): + ids: Optional[list[int]] = None + klub_id: Optional[int] = None + godina: Optional[int] = None + only_unpaid: bool = True + limit: int = 200 + + +def _safe_filename(s: str) -> str: + s = (s or "x").strip() + s = _re.sub(r"[^\w\-\.]+", "_", s, flags=_re.UNICODE) + return s[:80] or "x" + + +@router.post("/clanarine/bulk/uplatnice.zip") +def bulk_uplatnice_zip(body: BulkZipIn): + """ + Generira ZIP archive sa svim HUB-3 PDF uplatnicama za odabrane članarine. + Filename pattern: /--.pdf + """ + where, params = [], [] + 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) + if body.only_unpaid and not body.ids: + where.append("c.status IN ('nepodmireno','djelomicno')") + where_sql = ("WHERE " + " AND ".join(where)) if where else "" + params.append(body.limit) + + sql = f""" + SELECT c.id, c.godina, c.razdoblje, + c.iznos_propisan, c.iznos_placen, + (c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug, + cl.ime, cl.prezime, cl.adresa AS clan_adresa, cl.grad AS clan_grad, + k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban, + k.adresa AS klub_adresa, k.grad AS klub_grad + 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_sql} + ORDER BY k.naziv NULLS LAST, cl.prezime, cl.ime + LIMIT %s + """ + with _conn() as conn, conn.cursor() as cur: + cur.execute(sql, params) + rows = [_row(r) for r in cur.fetchall()] + if not rows: + raise HTTPException(404, "Nema članarina za batch") + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as z: + manifest = [] + for r in rows: + dug = float(r["dug"] or 0) + if dug <= 0: + dug = float(r["iznos_propisan"] or 0) + iban = normalize_iban(r["klub_iban"] or DEFAULT_PRIMATELJ_IBAN) + primatelj_naziv = r.get("klub") or DEFAULT_PRIMATELJ_NAZIV + primatelj_adresa = ", ".join( + [x for x in [r.get("klub_adresa"), r.get("klub_grad")] if x] + ) or DEFAULT_PRIMATELJ_ADRESA + platitelj_naziv = f"{r.get('ime') or ''} {r.get('prezime') or ''}".strip() or "Član" + platitelj_adresa = ", ".join( + [x for x in [r.get("clan_adresa"), r.get("clan_grad")] if x] + ) or "—" + poziv = make_poziv_na_broj(r.get("klub_oib"), int(r["godina"]), int(r["id"])) + try: + pdf = build_hub3_pdf( + platitelj_naziv=platitelj_naziv, + platitelj_adresa=platitelj_adresa, + primatelj_naziv=primatelj_naziv, + primatelj_adresa=primatelj_adresa, + iban=iban, + amount_eur=dug, + model="HR00", + poziv_na_broj=poziv, + opis=f"Članarina {r['godina']} — {r.get('razdoblje') or 'godišnja'}", + sifra_namjene="OTHR", + ) + except Exception as e: + manifest.append(f"{r['id']}\tERROR\t{e}") + continue + klub_dir = _safe_filename(primatelj_naziv) + fname = (f"{klub_dir}/" + f"{_safe_filename(r.get('prezime') or 'X')}_" + f"{_safe_filename(r.get('ime') or 'X')}-" + f"{r['id']}-{r['godina']}.pdf") + z.writestr(fname, pdf) + manifest.append(f"{r['id']}\t{fname}\t{dug:.2f} EUR\t{poziv}") + # Manifest TXT + z.writestr("_manifest.txt", + "ID\tFILENAME\tIZNOS\tPOZIV_NA_BROJ\n" + "\n".join(manifest)) + # Manifest JSON + z.writestr("_manifest.json", _json.dumps( + {"count": len(rows), + "generated_at": datetime.now().isoformat(), + "items": [{"id": r["id"], "klub": r.get("klub"), + "clan": f"{r.get('ime','')} {r.get('prezime','')}".strip(), + "godina": r["godina"], "iznos_eur": float(r["dug"] or r["iznos_propisan"] or 0)} + for r in rows]}, + ensure_ascii=False, indent=2)) + + fname = f"hub3-batch-{date.today().isoformat()}-{len(rows)}.zip" + return Response( + content=buf.getvalue(), + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{fname}"', + "X-Batch-Count": str(len(rows))}, + ) + + +# ════════════════════════════════════════════════════ +# R6 #3 — E-MAIL TEMPLATES (CRUD + render + send-mock) +# ════════════════════════════════════════════════════ + +def _render(tpl: str, vars: dict) -> str: + """Vrlo jednostavan {{key}} render.""" + if not tpl: + return "" + out = tpl + for k, v in (vars or {}).items(): + out = out.replace("{{" + str(k) + "}}", "" if v is None else str(v)) + return out + + +class EmailTemplateIn(BaseModel): + code: str + naziv: str + kategorija: Optional[str] = None + subject_tpl: str + body_tpl: str + variables: Optional[list[str]] = None + active: bool = True + + +class EmailTemplatePatch(BaseModel): + naziv: Optional[str] = None + kategorija: Optional[str] = None + subject_tpl: Optional[str] = None + body_tpl: Optional[str] = None + variables: Optional[list[str]] = None + active: Optional[bool] = None + + +@router.get("/email-templates") +def list_email_templates(kategorija: Optional[str] = Query(None), + active_only: bool = Query(True)): + where, params = [], [] + if active_only: + where.append("active = TRUE") + if kategorija: + where.append("kategorija = %s"); params.append(kategorija) + where_sql = ("WHERE " + " AND ".join(where)) if where else "" + with _conn() as conn, conn.cursor() as cur: + cur.execute(f""" + SELECT id, code, naziv, kategorija, subject_tpl, body_tpl, + variables, active, created_at, updated_at + FROM pgz_sport.email_templates + {where_sql} + ORDER BY kategorija NULLS LAST, naziv + """, params) + rows = [_row(r) for r in cur.fetchall()] + return {"count": len(rows), "templates": rows} + + +@router.get("/email-templates/{code_or_id}") +def get_email_template(code_or_id: str): + with _conn() as conn, conn.cursor() as cur: + if code_or_id.isdigit(): + cur.execute("SELECT * FROM pgz_sport.email_templates WHERE id=%s", (int(code_or_id),)) + else: + cur.execute("SELECT * FROM pgz_sport.email_templates WHERE code=%s", (code_or_id,)) + r = cur.fetchone() + if not r: + raise HTTPException(404, "Email template ne postoji") + return _row(r) + + +@router.post("/email-templates") +def create_email_template(body: EmailTemplateIn): + with _conn() as conn, conn.cursor() as cur: + cur.execute(""" + INSERT INTO pgz_sport.email_templates + (code, naziv, kategorija, subject_tpl, body_tpl, variables, active) + VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s) + RETURNING * + """, (body.code, body.naziv, body.kategorija, body.subject_tpl, + body.body_tpl, _json.dumps(body.variables or []), body.active)) + r = cur.fetchone(); conn.commit() + return _row(r) + + +@router.put("/email-templates/{code_or_id}") +def update_email_template(code_or_id: str, body: EmailTemplatePatch): + fields, params = [], [] + for f in ("naziv", "kategorija", "subject_tpl", "body_tpl", "active"): + v = getattr(body, f) + if v is not None: + fields.append(f"{f} = %s"); params.append(v) + if body.variables is not None: + fields.append("variables = %s::jsonb"); params.append(_json.dumps(body.variables)) + if not fields: + raise HTTPException(400, "Nema polja za izmjenu") + fields.append("updated_at = now()") + where_col = "id" if code_or_id.isdigit() else "code" + where_val = int(code_or_id) if code_or_id.isdigit() else code_or_id + params.append(where_val) + with _conn() as conn, conn.cursor() as cur: + cur.execute(f"UPDATE pgz_sport.email_templates SET {', '.join(fields)} WHERE {where_col}=%s RETURNING *", + params) + r = cur.fetchone() + if not r: + raise HTTPException(404, "Template ne postoji") + conn.commit() + return _row(r) + + +class EmailRenderIn(BaseModel): + variables: dict = {} + + +@router.post("/email-templates/{code_or_id}/render") +def render_email_template(code_or_id: str, body: EmailRenderIn): + """Vrati subject/body s popunjenim {{vars}}.""" + with _conn() as conn, conn.cursor() as cur: + if code_or_id.isdigit(): + cur.execute("SELECT * FROM pgz_sport.email_templates WHERE id=%s", (int(code_or_id),)) + else: + cur.execute("SELECT * FROM pgz_sport.email_templates WHERE code=%s", (code_or_id,)) + t = cur.fetchone() + if not t: + raise HTTPException(404, "Template ne postoji") + return { + "code": t["code"], + "naziv": t["naziv"], + "subject": _render(t["subject_tpl"], body.variables), + "body": _render(t["body_tpl"], body.variables), + "variables_provided": list(body.variables.keys()), + "variables_required": t.get("variables") or [], + } + + +class EmailSendIn(BaseModel): + to: Optional[str] = None + user_id: Optional[int] = None + variables: dict = {} + schedule_inapp: bool = True + + +@router.post("/email-templates/{code_or_id}/send") +def send_email_template(code_or_id: str, body: EmailSendIn): + """ + Mock send: rendera template i upiše u notifications (channel=email + inapp). + Stvarni SMTP nije konfiguriran. + """ + with _conn() as conn, conn.cursor() as cur: + if code_or_id.isdigit(): + cur.execute("SELECT * FROM pgz_sport.email_templates WHERE id=%s", (int(code_or_id),)) + else: + cur.execute("SELECT * FROM pgz_sport.email_templates WHERE code=%s", (code_or_id,)) + t = cur.fetchone() + if not t: + raise HTTPException(404, "Template ne postoji") + + subject = _render(t["subject_tpl"], body.variables) + body_txt = _render(t["body_tpl"], body.variables) + meta = _json.dumps({"template_code": t["code"], + "to": body.to, + "variables": body.variables}) + ids = [] + if body.to: + cur.execute("""INSERT INTO pgz_sport.notifications + (user_id, channel, subject, body, status, scheduled_at, meta) + VALUES (%s,'email',%s,%s,'pending',now(),%s::jsonb) + RETURNING id""", + (body.user_id, subject, body_txt, meta)) + ids.append({"channel": "email", "id": cur.fetchone()["id"]}) + if body.schedule_inapp: + cur.execute("""INSERT INTO pgz_sport.notifications + (user_id, channel, subject, body, status, scheduled_at, meta) + VALUES (%s,'inapp',%s,%s,'pending',now(),%s::jsonb) + RETURNING id""", + (body.user_id, subject, body_txt, meta)) + ids.append({"channel": "inapp", "id": cur.fetchone()["id"]}) + conn.commit() + return {"ok": True, "queued": ids, "subject": subject, + "body_preview": body_txt[:200]} + + +# ════════════════════════════════════════════════════ +# R6 #4 — /api/notifications/me (alias na /api/crm/notifications/me) +# ════════════════════════════════════════════════════ + +def _resolve_user_id(authorization: Optional[str], x_user_id: Optional[str]) -> Optional[int]: + """ + Priority: + 1) X-User-Id header (UI / debug) + 2) JWT 'sub' claim iz Bearer tokena (auth_v2) + """ + if x_user_id: + try: + return int(x_user_id) + except (TypeError, ValueError): + pass + if not authorization: + return None + tok = authorization.replace("Bearer ", "").strip() + try: + import jwt as _jwt # type: ignore + for secret in ( + __import__("os").environ.get("JWT_SECRET"), + "rinet-jwt-secret-2026", + ): + if not secret: + continue + try: + payload = _jwt.decode(tok, secret, algorithms=["HS256"]) + sub = payload.get("sub") or payload.get("user_id") + if sub is not None: + return int(sub) + except Exception: + continue + except Exception: + pass + return None + + +@router.get("/notifications/me") +def my_notifications( + only_unread: bool = Query(True), + channel: Optional[str] = Query(None), + limit: int = Query(50, le=200), + authorization: Optional[str] = Header(None), + x_user_id: Optional[str] = Header(None), +): + """ + Lista notifikacija za current usera (iz JWT sub ili X-User-Id headera). + Kao fallback (kad nije autentikiran) vraća notifikacije BEZ user_id + (broadcast / system). + """ + user_id = _resolve_user_id(authorization, x_user_id) + where = [] + params: list = [] + if user_id is None: + # broadcast: notifs bez user_id + where.append("user_id IS NULL") + else: + where.append("(user_id = %s OR user_id IS NULL)"); params.append(user_id) + if only_unread: + where.append("read_at IS NULL") + if channel: + where.append("channel = %s"); params.append(channel) + 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 {' AND '.join(where)} + ORDER BY scheduled_at DESC NULLS LAST + LIMIT %s + """, params) + rows = [_row(r) for r in cur.fetchall()] + # summary za badge + sum_where = ["read_at IS NULL"] + sum_params = [] + if user_id is not None: + sum_where.append("(user_id = %s OR user_id IS NULL)") + sum_params.append(user_id) + else: + sum_where.append("user_id IS NULL") + cur.execute(f""" + SELECT COUNT(*) AS unread, + COUNT(*) FILTER (WHERE channel='inapp') AS unread_inapp, + COUNT(*) FILTER (WHERE channel='email') AS unread_email + FROM pgz_sport.notifications + WHERE {' AND '.join(sum_where)} + """, sum_params) + summary = _row(cur.fetchone()) + return { + "user_id": user_id, + "count": len(rows), + "summary": summary, + "rows": rows, + } + + +# ════════════════════════════════════════════════════ +# Alias router: /api/notifications/me (bez /crm prefiksa) +# ════════════════════════════════════════════════════ + +alias_router = APIRouter(prefix="/api/notifications", tags=["notifications-alias"]) + + +@alias_router.get("/me") +def my_notifications_alias( + only_unread: bool = Query(True), + channel: Optional[str] = Query(None), + limit: int = Query(50, le=200), + authorization: Optional[str] = Header(None), + x_user_id: Optional[str] = Header(None), +): + """Alias za /api/crm/notifications/me — kompatibilnost s /api/notifications/me.""" + return my_notifications(only_unread=only_unread, channel=channel, limit=limit, + authorization=authorization, x_user_id=x_user_id) diff --git a/_backups/r3_cc5/pgz_sport_api.py.pre_r6.1777937999 b/_backups/r3_cc5/pgz_sport_api.py.pre_r6.1777937999 new file mode 100644 index 0000000..7b6c4a8 --- /dev/null +++ b/_backups/r3_cc5/pgz_sport_api.py.pre_r6.1777937999 @@ -0,0 +1,1730 @@ +#!/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, alias_router as crm_extras_alias_router + app.include_router(crm_extras_router) + app.include_router(crm_extras_alias_router) + print('[CRM/R5] extras router loaded (bulk + xlsx + stats + notifications + ZIP + email tpl + /me)') +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/sidebar.css.cc3_pre_redesign.1777937786 b/_backups/sidebar.css.cc3_pre_redesign.1777937786 new file mode 100644 index 0000000..0ed0118 --- /dev/null +++ b/_backups/sidebar.css.cc3_pre_redesign.1777937786 @@ -0,0 +1,148 @@ +/* PGŽ SPORT — Unified Sidebar v1.0 + * dradulic@outlook.com / damir@rinet.one — 2026-05-05 + * Used by: sport2.html, app.html, admin.html, crm.html, erp.html, audit.html, kpi.html, login.html + * Reference: app.rinet.one/klasik/control + */ + +:root{ + --pgz-blue:#003087; --pgz-blue2:#004CC4; --pgz-gold:#F4C430; + --bg0:#08090e; --bg1:#0d1021; --bg2:#111628; --bg3:#161d35; --bg4:#1c2542; + --rim:#1e2a50; --rim2:#283560; + --t0:#fff; --t1:#e2e6f0; --t2:#8a95b4; --t4:#4e5a7a; + --green:#00e88f; --red:#ff2d55; --amber:#f59e0b; --cyan:#00c8e8; + --sb-w-exp:230px; --sb-w-col:58px; +} + +#pgz-sb{ + position:fixed; top:0; left:0; bottom:0; width:var(--sb-w-exp); + background:linear-gradient(180deg,var(--bg1) 0%,var(--bg0) 100%); + border-right:1px solid var(--rim); + display:flex; flex-direction:column; z-index:100; + font-family:'Inter',sans-serif; font-size:13px; color:var(--t1); + transition:width .22s ease, transform .22s ease; +} +#pgz-sb *{box-sizing:border-box} +#pgz-sb a{text-decoration:none;color:inherit} + +/* Header */ +.pgz-sb-h{padding:18px 18px 14px;border-bottom:1px solid var(--rim);position:relative;flex-shrink:0} +.pgz-sb-h .pgz-logo{font-weight:800;font-size:14px;color:var(--t0);letter-spacing:.5px;white-space:nowrap;overflow:hidden} +.pgz-sb-h .pgz-logo .g{color:var(--pgz-gold)} +.pgz-sb-h .pgz-sub{font-size:10px;color:var(--t2);margin-top:4px;text-transform:uppercase;letter-spacing:1px;white-space:nowrap;overflow:hidden} +.pgz-sb-toggle{ + position:absolute;top:14px;right:8px;width:24px;height:24px; + display:flex;align-items:center;justify-content:center;cursor:pointer; + color:var(--t2);background:var(--bg2);border:1px solid var(--rim); + border-radius:5px;font-size:14px;font-weight:700; + transition:all .15s;user-select:none; +} +.pgz-sb-toggle:hover{background:var(--bg3);color:var(--pgz-gold);border-color:var(--pgz-gold)} + +/* Section label / separator */ +.pgz-sb-sep{padding:14px 14px 4px 14px;font-size:9.5px;color:var(--t4); + text-transform:uppercase;letter-spacing:1.2px;font-weight:700; + white-space:nowrap;overflow:hidden} + +/* Nav */ +.pgz-sb-nav{flex:1;padding:6px 8px;overflow-y:auto;overflow-x:hidden} +.pgz-sb-nav::-webkit-scrollbar{width:6px} +.pgz-sb-nav::-webkit-scrollbar-thumb{background:var(--rim2);border-radius:3px} +.pgz-nav-i{ + padding:9px 12px;border-radius:6px;color:var(--t2); + cursor:pointer;display:flex;align-items:center;gap:10px; + font-size:12.5px;margin-bottom:2px;white-space:nowrap; + transition:background .15s,color .15s;position:relative; +} +.pgz-nav-i:hover{background:var(--bg2);color:var(--t1)} +.pgz-nav-i.active{ + background:linear-gradient(90deg,var(--pgz-blue) 0%,var(--pgz-blue2) 100%); + color:#fff;font-weight:600; +} +.pgz-nav-i .ic{width:20px;text-align:center;font-size:14px;flex-shrink:0} +.pgz-nav-i .lbl{overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0} +.pgz-nav-i .badge{margin-left:auto;background:var(--red);color:#fff;font-size:9px;font-weight:700;padding:1px 6px;border-radius:8px;flex-shrink:0} +.pgz-nav-ext{color:var(--cyan)} +.pgz-nav-ext::after{content:"↗";font-size:10px;opacity:.5;margin-left:auto;flex-shrink:0} +.pgz-nav-ext:hover{color:var(--pgz-gold);background:var(--bg2)} +.pgz-nav-ext.active{background:linear-gradient(90deg,var(--pgz-blue) 0%,var(--pgz-blue2) 100%);color:#fff} +.pgz-nav-ext.active::after{opacity:.85} + +/* Footer (user) */ +.pgz-sb-foot{padding:10px 12px;border-top:1px solid var(--rim); + display:flex;align-items:center;gap:8px; + white-space:nowrap;overflow:hidden;flex-shrink:0} +.pgz-sb-foot .av{ + width:30px;height:30px;border-radius:50%; + background:linear-gradient(135deg,var(--pgz-blue),var(--pgz-gold)); + color:#fff;font-weight:800;display:flex;align-items:center;justify-content:center; + font-size:11px;flex-shrink:0;overflow:hidden; +} +.pgz-sb-foot .av img{width:100%;height:100%;object-fit:cover} +.pgz-sb-foot .ui{flex:1;min-width:0;overflow:hidden} +.pgz-sb-foot .un{font-size:11.5px;color:var(--t1);font-weight:600;line-height:1.2;overflow:hidden;text-overflow:ellipsis} +.pgz-sb-foot .ur{font-size:9.5px;color:var(--t4);text-transform:uppercase;letter-spacing:.5px;line-height:1.2;overflow:hidden;text-overflow:ellipsis} +.pgz-sb-foot .lo{cursor:pointer;color:var(--t4);font-size:14px; + padding:6px 8px;border-radius:5px;transition:all .15s;flex-shrink:0} +.pgz-sb-foot .lo:hover{background:rgba(255,45,85,.15);color:var(--red)} + +/* Mobile burger (shown <768px when sidebar is offscreen) */ +.pgz-sb-burger{ + position:fixed;top:10px;left:10px;z-index:99; + width:36px;height:36px;display:none;align-items:center;justify-content:center; + background:var(--bg2);border:1px solid var(--rim);border-radius:6px; + color:var(--t1);font-size:18px;cursor:pointer; +} +.pgz-sb-burger:hover{background:var(--bg3);color:var(--pgz-gold)} + +/* Mobile X (shown <768px when sidebar is open) */ +.pgz-sb-mx{display:none;cursor:pointer;color:var(--t2);font-size:18px; + width:24px;height:24px;align-items:center;justify-content:center; + border-radius:5px;transition:all .15s} +.pgz-sb-mx:hover{background:var(--bg3);color:var(--red)} + +/* ─── Collapsed state ─── */ +#pgz-sb.pgz-collapsed{width:var(--sb-w-col)} +#pgz-sb.pgz-collapsed .pgz-sb-h{padding:18px 6px 14px;text-align:center} +#pgz-sb.pgz-collapsed .pgz-sb-h .pgz-logo{font-size:0} +#pgz-sb.pgz-collapsed .pgz-sb-h .pgz-logo::before{content:"PG";font-size:13px;color:var(--pgz-gold);font-weight:800} +#pgz-sb.pgz-collapsed .pgz-sb-h .pgz-sub{display:none} +#pgz-sb.pgz-collapsed .pgz-sb-toggle{position:static;margin:6px auto 0;display:flex} +#pgz-sb.pgz-collapsed .pgz-sb-sep{font-size:0;padding:6px 0;text-align:center;border-top:1px dashed var(--rim);margin:6px 8px 4px} +#pgz-sb.pgz-collapsed .pgz-nav-i{justify-content:center;padding:10px 6px} +#pgz-sb.pgz-collapsed .pgz-nav-i .lbl, +#pgz-sb.pgz-collapsed .pgz-nav-i .badge, +#pgz-sb.pgz-collapsed .pgz-nav-ext::after{display:none} +#pgz-sb.pgz-collapsed .pgz-sb-foot{padding:10px 6px;justify-content:center} +#pgz-sb.pgz-collapsed .pgz-sb-foot .ui, +#pgz-sb.pgz-collapsed .pgz-sb-foot .lo{display:none} + +/* Tooltip when collapsed */ +#pgz-sb.pgz-collapsed .pgz-nav-i:hover::after{ + content:attr(data-label); + position:absolute;left:calc(var(--sb-w-col) - 4px);top:50%;transform:translateY(-50%); + background:var(--bg3);color:var(--t0); + padding:5px 10px;border-radius:4px; + font-size:11.5px;white-space:nowrap; + border:1px solid var(--rim);font-weight:600; + box-shadow:2px 2px 10px rgba(0,0,0,.45); + pointer-events:none;z-index:200; +} + +/* Layout helper — apply on body to push content right of sidebar */ +body.pgz-has-sb{padding-left:var(--sb-w-exp);transition:padding-left .22s ease} +body.pgz-has-sb.pgz-sb-col{padding-left:var(--sb-w-col)} + +/* Mobile: <768px */ +@media (max-width:768px){ + #pgz-sb{transform:translateX(-100%)} + #pgz-sb.pgz-mobile-open{transform:translateX(0)} + #pgz-sb.pgz-collapsed{width:var(--sb-w-exp)} /* full width on mobile when open */ + body.pgz-has-sb,body.pgz-has-sb.pgz-sb-col{padding-left:0} + .pgz-sb-burger{display:flex} + .pgz-sb-mx{display:flex} + .pgz-sb-toggle{display:none} + /* overlay backdrop */ + body.pgz-mobile-sb-open::before{ + content:"";position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:99;backdrop-filter:blur(2px) + } +} diff --git a/_backups/sidebar.js.cc3_pre_redesign.1777937786 b/_backups/sidebar.js.cc3_pre_redesign.1777937786 new file mode 100644 index 0000000..c1549dd --- /dev/null +++ b/_backups/sidebar.js.cc3_pre_redesign.1777937786 @@ -0,0 +1,214 @@ +/* PGŽ SPORT — Unified Sidebar v1.0 + * dradulic@outlook.com / damir@rinet.one — 2026-05-05 + * + * Usage on each page: + * + * // 0 (default) = render on load. 1 = call PGZSidebar.mount() yourself + * + * The script renders #pgz-sb at start of , adds class "pgz-has-sb" to body + * (so existing layouts can be migrated). Pages that already have their own sidebar + * should pass data-skip="1" — only NAV_EXTERNAL portal links will be appended to + * an element with id="pgz-portal-mount" if present. + */ +(function(){ + 'use strict'; + + // ────────── Configuration ────────── + // Per-portal "internal" sections (left as a hint; pages typically own their own internal nav) + // External portal links — same on every page + const NAV_EXTERNAL = [ + {id:'login', href:'/sport/login', ic:'\u{1F511}', label:'Prijava'}, + {id:'app', href:'/sport/app', ic:'\u{1F4F1}', label:'Aplikacija'}, + {id:'admin', href:'/sport/admin', ic:'\u{1F6E1}', label:'Administracija'}, + {id:'crm', href:'/sport/crm', ic:'\u{1F465}', label:'CRM'}, + {id:'erp', href:'/sport/erp', ic:'\u{1F4B0}', label:'ERP'}, + {id:'kpi', href:'/sport/kpi', ic:'\u{1F4C8}', label:'KPI'}, + {id:'audit', href:'/sport/audit', ic:'\u{1F4CB}', label:'Audit'}, + {id:'sport2', href:'/sport/static/sport2.html', ic:'\u{1F310}', label:'Public portal'} + ]; + + const STATE_KEY = 'sidebarCollapsed'; // shared across all pages + const $ = (s, root) => (root||document).querySelector(s); + + function readToken(){ + try { return localStorage.getItem('jwt') || localStorage.getItem('access_token') || ''; } + catch(e){ return ''; } + } + function logout(){ + if(!confirm('Odjava iz aplikacije?')) return; + try { localStorage.removeItem('jwt'); localStorage.removeItem('access_token'); localStorage.removeItem('app-role'); } catch(e){} + location.href = '/sport/login'; + } + function initials(n){ + if(!n) return '?'; + const p = String(n).trim().split(/\s+/); + return ((p[0]||'')[0]||'').toUpperCase() + ((p[1]||'')[0]||'').toUpperCase(); + } + function esc(s){ + return String(s==null?'':s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); + } + + // Try to read /api/auth/me for footer display (best effort) + async function tryLoadMe(){ + const tok = readToken(); if(!tok) return null; + try { + const r = await fetch('/sport/api/auth/me', {headers:{'Authorization':'Bearer '+tok}}); + if(!r.ok) return null; + return await r.json(); + } catch(e){ return null; } + } + + function renderShell(activeKey, internalNavHTML){ + const sb = document.createElement('aside'); + sb.id = 'pgz-sb'; + sb.innerHTML = ` +
+ +
Operativna platforma
+
+
+
+ ${internalNavHTML ? `
Sekcije
` : ''} + +
+
PG
+
+
Gost
+
Demo
+
+
+
+ `; + return sb; + } + function renderExternal(activeKey){ + return NAV_EXTERNAL.map(n => ` + + ${n.ic} + ${esc(n.label)} + `).join(''); + } + function renderBurger(){ + if(document.getElementById('pgz-sb-burger')) return; + const b = document.createElement('div'); + b.id = 'pgz-sb-burger'; + b.className = 'pgz-sb-burger'; + b.innerHTML = '≡'; + b.onclick = () => PGZSidebar.openMobile(); + document.body.appendChild(b); + } + + function setUserDisplay(me){ + if(!me){ + $('#pgz-sb-un') && ($('#pgz-sb-un').textContent = 'Gost'); + $('#pgz-sb-ur') && ($('#pgz-sb-ur').textContent = 'Demo · click Prijava'); + $('#pgz-sb-av') && ($('#pgz-sb-av').textContent = '?'); + return; + } + const name = me.full_name || ((me.ime||'')+' '+(me.prezime||'')).trim() || me.email || '—'; + const role = me.user_type || ''; + const av = me.avatar_url || me.google_picture; + if($('#pgz-sb-un')) $('#pgz-sb-un').textContent = name; + if($('#pgz-sb-ur')) $('#pgz-sb-ur').textContent = role; + const avEl = $('#pgz-sb-av'); + if(avEl){ + if(av) avEl.innerHTML = ``; + else avEl.textContent = initials(name); + } + } + + function applyCollapsedFromStorage(){ + let col = false; + try { col = localStorage.getItem(STATE_KEY) === '1'; } catch(e){} + const sb = document.getElementById('pgz-sb'); + if(!sb) return; + sb.classList.toggle('pgz-collapsed', col); + document.body.classList.toggle('pgz-sb-col', col); + } + + // ────────── Public API ────────── + const PGZSidebar = { + NAV_EXTERNAL, + + // Render: insert sidebar shell at document start; if a page provides internalNavHTML, use it + mount(opts){ + opts = opts || {}; + const activeKey = opts.activeKey || (document.currentScript && document.currentScript.dataset.active) || ''; + const internalNavHTML = opts.internalNavHTML || ''; + // Skip mount if the page already has its own sidebar AND a portal mount point is provided + if(opts.skipShell){ + const mount = document.getElementById('pgz-portal-mount'); + if(mount){ mount.innerHTML = renderExternal(activeKey); } + return; + } + const existing = document.getElementById('pgz-sb'); + if(existing) existing.remove(); + const sb = renderShell(activeKey, internalNavHTML); + document.body.insertBefore(sb, document.body.firstChild); + document.body.classList.add('pgz-has-sb'); + renderBurger(); + applyCollapsedFromStorage(); + tryLoadMe().then(setUserDisplay); + }, + + // Append portal links to an existing custom sidebar (call this from a page's own buildNav) + appendPortalLinksTo(navEl, activeKey){ + if(!navEl) return; + activeKey = activeKey || ''; + navEl.insertAdjacentHTML('beforeend', + '
Portali
' + ); + navEl.insertAdjacentHTML('beforeend', renderExternal(activeKey)); + }, + + toggle(){ + const sb = document.getElementById('pgz-sb'); + if(!sb) return; + const col = sb.classList.toggle('pgz-collapsed'); + document.body.classList.toggle('pgz-sb-col', col); + try { localStorage.setItem(STATE_KEY, col ? '1' : '0'); } catch(e){} + }, + openMobile(){ + const sb = document.getElementById('pgz-sb'); + if(!sb) return; + sb.classList.add('pgz-mobile-open'); + document.body.classList.add('pgz-mobile-sb-open'); + // close on backdrop click + const closer = (ev) => { + if(!sb.contains(ev.target) && ev.target.id !== 'pgz-sb-burger'){ + PGZSidebar.closeMobile(); + document.removeEventListener('click', closer, true); + } + }; + setTimeout(() => document.addEventListener('click', closer, true), 50); + }, + closeMobile(){ + const sb = document.getElementById('pgz-sb'); + if(!sb) return; + sb.classList.remove('pgz-mobile-open'); + document.body.classList.remove('pgz-mobile-sb-open'); + }, + logout + }; + window.PGZSidebar = PGZSidebar; + + // Auto-mount unless data-inline=1 + function autoMount(){ + const cs = document.currentScript || Array.from(document.scripts).find(s => /sidebar\.js/.test(s.src||'')); + const inline = cs && cs.dataset && cs.dataset.inline === '1'; + if(inline) return; // page will call PGZSidebar.mount() itself + if(document.readyState === 'loading'){ + document.addEventListener('DOMContentLoaded', () => PGZSidebar.mount({})); + } else { + PGZSidebar.mount({}); + } + } + autoMount(); +})(); diff --git a/_backups/sport2.html.cc3_pre_redesign.1777937786 b/_backups/sport2.html.cc3_pre_redesign.1777937786 new file mode 100644 index 0000000..8203a52 --- /dev/null +++ b/_backups/sport2.html.cc3_pre_redesign.1777937786 @@ -0,0 +1,2791 @@ + + + + + +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 88258a4..4cc59df 100644 --- a/auth/admin_users.py +++ b/auth/admin_users.py @@ -264,17 +264,32 @@ def invite_user(uid: int, req: InviteReq, request: Request, 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) + # R6 #3: send invite email (mock in dev) + mail_result = None + if req.send_email: + try: + from .mailer import send_invite + mail_result = send_invite( + target["email"], invite_link, + int(INVITE_TTL.total_seconds()), + inviter=actor.get("email"), + role=target.get("user_type"), + ) + except Exception as e: + print(f"[invite mail WARN] {e}") audit(actor["id"], "user.invite", "user", uid, {"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. + "ttl_days": INVITE_TTL.days, + "mail_sent": bool(mail_result and mail_result.get("sent")), + "mail_mock": bool(mail_result and mail_result.get("mock"))}, ip, ua) return {"status": "ok", "id": uid, "email": target["email"], "invite_link": invite_link, "api_link": api_link, "expires_in_seconds": int(INVITE_TTL.total_seconds()), - "email_sent": False} + "email_sent": bool(mail_result and mail_result.get("sent")), + "email_mock": bool(mail_result and mail_result.get("mock")), + "email_file": (mail_result or {}).get("file")} # ─────────────────────────── Role change ─────────────────────────── class RoleReq(BaseModel): diff --git a/auth/auth_v2.py b/auth/auth_v2.py index 975077e..4af5761 100644 --- a/auth/auth_v2.py +++ b/auth/auth_v2.py @@ -288,6 +288,36 @@ class ChangePwdReq(BaseModel): class ResetPwdReq(BaseModel): email: str +# ─────────────────────────── Rate limiting (R6 #5) ─────────────────────────── +LOCK_THRESHOLD = int(os.environ.get("PGZ_LOGIN_LOCK_THRESHOLD", "5")) +LOCK_MINUTES = int(os.environ.get("PGZ_LOGIN_LOCK_MINUTES", "5")) +IP_THRESHOLD = int(os.environ.get("PGZ_LOGIN_IP_THRESHOLD", "10")) +IP_WINDOW_SEC = int(os.environ.get("PGZ_LOGIN_IP_WINDOW_SEC", "300")) # 5 min + +# In-memory IP throttle: ip → list[float fail timestamps within window] +_ip_fail_log: Dict[str, List[float]] = {} + +def _ip_record_fail(ip: Optional[str]): + if not ip: return + now = time.time() + arr = [t for t in _ip_fail_log.get(ip, []) if now - t < IP_WINDOW_SEC] + arr.append(now) + _ip_fail_log[ip] = arr + +def _ip_blocked(ip: Optional[str]) -> Optional[int]: + """Return seconds-until-unblock, or None if not blocked.""" + if not ip: return None + now = time.time() + arr = [t for t in _ip_fail_log.get(ip, []) if now - t < IP_WINDOW_SEC] + _ip_fail_log[ip] = arr + if len(arr) < IP_THRESHOLD: return None + oldest = min(arr) + return max(1, int(IP_WINDOW_SEC - (now - oldest))) + +def _ip_clear(ip: Optional[str]): + if ip and ip in _ip_fail_log: + _ip_fail_log.pop(ip, None) + # ─────────────────────────── Endpoints ─────────────────────────── @router.post("/login") def login(req: LoginReq, request: Request): @@ -296,11 +326,20 @@ def login(req: LoginReq, request: Request): if not email or not req.password: raise HTTPException(400, "Email i lozinka obavezni") + # R6 #5: per-IP throttle (stops brute-force across many emails) + blocked_for = _ip_blocked(ip) + if blocked_for: + audit(None, "login.ratelimit.ip", + meta={"email": email, "ip": ip, "block_seconds": blocked_for}, + ip=ip, ua=ua) + raise HTTPException(429, f"Previše pokušaja s ove IP adrese — pokušajte za {blocked_for}s") + 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: + _ip_record_fail(ip) audit(None, "login.fail", meta={"email": email, "reason": "no_user"}, ip=ip, ua=ua) raise HTTPException(401, "Neispravni podaci") if u.get("locked_until"): @@ -313,13 +352,25 @@ def login(req: LoginReq, request: Request): 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")): + # R6 #5: 5 fails → 5-minute lockout + new_fails = (u.get("failed_login_count") or 0) + 1 + will_lock = new_fails >= LOCK_THRESHOLD 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") + SET failed_login_count = %s, + locked_until = CASE WHEN %s + THEN now() + (interval '1 minute' * %s) + ELSE locked_until END + WHERE id=%s""", + (new_fails, will_lock, LOCK_MINUTES, u["id"])) + _ip_record_fail(ip) + audit(u["id"], "login.fail", + meta={"reason":"bad_password", "fails": new_fails, + "locked": bool(will_lock), + "lock_minutes": LOCK_MINUTES if will_lock else 0}, + ip=ip, ua=ua) + raise HTTPException(401, + f"Neispravni podaci ({new_fails}/{LOCK_THRESHOLD})" + + (f" — račun je zaključan na {LOCK_MINUTES} minuta" if will_lock else "")) # opportunistic rehash to bcrypt if needs_rehash(u.get("password_hash")): @@ -357,6 +408,7 @@ def login(req: LoginReq, request: Request): db_exec("""UPDATE pgz_sport.users SET failed_login_count=0, locked_until=NULL, last_login=now() WHERE id=%s""", (u["id"],)) + _ip_clear(ip) # successful login clears IP throttle jti = _new_jti() rjti = _new_jti() @@ -620,17 +672,29 @@ class ForgotPwdReq(BaseModel): @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.""" + Issues a reset token only if the user exists and is active, then + sends a (mock) e-mail with the reset link.""" 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 + mail_result = 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}) + reset_link = _build_link("/static/login.html?reset=1", token) + try: + from .mailer import send_password_reset + mail_result = send_password_reset(email, reset_link, + int(RESET_TTL.total_seconds())) + except Exception as e: + print(f"[forgot_password mail WARN] {e}") audit(u["id"], "password.forgot.issue", - meta={"email": email, "ttl_hours": RESET_TTL.total_seconds()/3600}, + meta={"email": email, + "ttl_hours": RESET_TTL.total_seconds()/3600, + "mail_sent": bool(mail_result and mail_result.get("sent")), + "mail_mock": bool(mail_result and mail_result.get("mock"))}, ip=ip, ua=ua) else: audit(u["id"] if u else None, "password.forgot.miss", @@ -638,14 +702,13 @@ def forgot_password(req: ForgotPwdReq, request: Request): # 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. + # Reveal link only on localhost or with explicit env flag (debugging). + # Real users get it via e-mail. 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["reset_link"] = _build_link("/static/login.html?reset=1", token) resp["expires_in_seconds"] = int(RESET_TTL.total_seconds()) + resp["mail_mock"] = bool(mail_result and mail_result.get("mock")) return resp class ResetTokenReq(BaseModel): diff --git a/auth/mailer.py b/auth/mailer.py new file mode 100644 index 0000000..6cd1eda --- /dev/null +++ b/auth/mailer.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# mailer.py — Mock e-mail sender for dev/demo (R6 #3) +# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04 +""" +In dev/demo mode, e-mails are appended to: + - /tmp/pgz_mailbox/-.eml (raw .eml file) + - /tmp/pgz_mailbox/INDEX.jsonl (one JSON line per send) +plus printed to stdout (visible in journalctl). + +Set PGZ_SMTP_HOST=... to switch to real SMTP (skipped here — out of scope +for R6 demo). The function ALWAYS returns a result; never raises. +""" + +import os, json, time, smtplib +from email.message import EmailMessage +from datetime import datetime +from pathlib import Path +from typing import Dict, Optional + +MAILBOX_DIR = Path(os.environ.get("PGZ_MAILBOX_DIR", "/tmp/pgz_mailbox")) +MAILBOX_DIR.mkdir(parents=True, exist_ok=True) +INDEX_FILE = MAILBOX_DIR / "INDEX.jsonl" + +DEFAULT_FROM = os.environ.get("PGZ_MAIL_FROM", "no-reply@pgz.hr") +DEFAULT_SENDER = os.environ.get("PGZ_MAIL_SENDER", "PGŽ Sport platforma") + +def send_email(to: str, subject: str, body: str, + html: Optional[str] = None, + from_addr: Optional[str] = None, + metadata: Optional[Dict] = None) -> Dict: + """Send (or mock-send) an email. Returns a dict with status + storage info.""" + sender = from_addr or DEFAULT_FROM + msg = EmailMessage() + msg["From"] = f"{DEFAULT_SENDER} <{sender}>" + msg["To"] = to + msg["Subject"] = subject + msg["Date"] = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000") + msg.set_content(body) + if html: + msg.add_alternative(html, subtype="html") + + smtp_host = os.environ.get("PGZ_SMTP_HOST") + use_real = bool(smtp_host) + sent_at = int(time.time()) + fname = f"{sent_at}-{to.replace('@','_at_')}.eml" + fpath = MAILBOX_DIR / fname + + try: + with open(fpath, "wb") as f: + f.write(bytes(msg)) + except Exception as e: + print(f"[MAILER WARN] cannot write {fpath}: {e}") + + rec = { + "ts": sent_at, + "to": to, "from": sender, "subject": subject, + "file": str(fpath), + "real_send": use_real, + "metadata": metadata or {}, + } + try: + with open(INDEX_FILE, "a") as f: + f.write(json.dumps(rec, ensure_ascii=False) + "\n") + except Exception: pass + + if use_real: + try: + port = int(os.environ.get("PGZ_SMTP_PORT", "587")) + user = os.environ.get("PGZ_SMTP_USER") + pwd = os.environ.get("PGZ_SMTP_PASS") + with smtplib.SMTP(smtp_host, port, timeout=10) as s: + s.starttls() + if user: s.login(user, pwd or "") + s.send_message(msg) + rec["sent"] = True + except Exception as e: + rec["sent"] = False + rec["error"] = str(e) + print(f"[MAILER ERROR] {e}") + else: + # demo / dev mode — print preview to stdout + print(f"[MOCK-MAIL] → {to} | {subject}") + print(f"[MOCK-MAIL] stored at {fpath}") + rec["sent"] = True + rec["mock"] = True + + return rec + +# ─────────────────────────── Convenience helpers ─────────────────────────── +def send_password_reset(email: str, reset_link: str, expires_in_seconds: int) -> Dict: + hours = expires_in_seconds / 3600 + body = ( + f"Pozdrav,\n\n" + f"Zatraženo je resetiranje lozinke za vaš račun na PGŽ Sport platformi.\n\n" + f"Otvorite ovaj link za postavljanje nove lozinke (vrijedi {hours:.0f} h):\n" + f"{reset_link}\n\n" + f"Ako niste vi tražili promjenu, ignorirajte ovu poruku.\n\n" + f"— PGŽ Sport platforma" + ) + html = ( + f'

Pozdrav,

' + f'

Zatraženo je resetiranje lozinke za vaš račun na PGŽ Sport platformi.

' + f'

Postavi novu lozinku

' + f'

Link vrijedi {hours:.0f} h.

' + ) + return send_email(email, "Resetiranje lozinke — PGŽ Sport", body, html=html, + metadata={"kind": "password_reset"}) + +def send_invite(email: str, invite_link: str, expires_in_seconds: int, + inviter: Optional[str] = None, + role: Optional[str] = None) -> Dict: + days = expires_in_seconds / 86400 + by = f" od {inviter}" if inviter else "" + body = ( + f"Pozdrav,\n\n" + f"Pozvani ste{by} u PGŽ Sport platformu" + + (f" kao {role}" if role else "") + ".\n\n" + f"Otvorite ovaj link i postavite svoju lozinku (vrijedi {days:.0f} dana):\n" + f"{invite_link}\n\n" + f"— PGŽ Sport platforma" + ) + html = ( + f'

Pozdrav,

' + f'

Pozvani ste{by} u PGŽ Sport platformu' + + (f' kao {role}' if role else '') + '.

' + f'

Postavi lozinku i prijavi se

' + f'

Pozivnica vrijedi {days:.0f} dana.

' + ) + return send_email(email, "Pozivnica — PGŽ Sport", body, html=html, + metadata={"kind": "invite", "role": role}) diff --git a/data_quality_report.md b/data_quality_report.md new file mode 100644 index 0000000..e9a3bbd --- /dev/null +++ b/data_quality_report.md @@ -0,0 +1,100 @@ +# Data Quality Report — pgz_sport schema +**Generirano:** 2026-05-04T23:40:32.512034+00:00 +**Skripta:** `/opt/pgz-sport/scripts/coverage_report.py` +**Ukupno entiteta:** 5952 + +## Sažetak po tipu + +| Tip | n | Polja po entitetu | Srednje (%) | Median (%) | Praznih | Potpunih (≥99%) | +|---|---:|---:|---:|---:|---:|---:| +| savez | 246 | 10 | 59.8% | 60.0% | 0 | 24 | +| klub | 2244 | 12 | 57.1% | 66.7% | 0 | 8 | +| sportas | 3243 | 10 | 46.2% | 40.0% | 0 | 0 | +| objekt | 106 | 10 | 79.7% | 80.0% | 0 | 14 | +| manifestacija | 113 | 7 | 81.9% | 85.7% | 0 | 0 | + +**Ponderirana srednja popunjenost svih 5952 zapisa:** **52.1%** + +## Distribucija po tipu (postotak entiteta u svakom rasponu) + +| Tip | 0-9% | 10-19% | 20-29% | 30-39% | 40-49% | 50-59% | 60-69% | 70-79% | 80-89% | 90-100% | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| savez | 0 (0.0%) | 0 (0.0%) | 21 (8.5%) | 9 (3.7%) | 19 (7.7%) | 55 (22.4%) | 42 (17.1%) | 55 (22.4%) | 16 (6.5%) | 29 (11.8%) | +| klub | 2 (0.1%) | 115 (5.1%) | 412 (18.4%) | 91 (4.1%) | 45 (2.0%) | 294 (13.1%) | 528 (23.5%) | 560 (25.0%) | 169 (7.5%) | 28 (1.2%) | +| sportas | 0 (0.0%) | 0 (0.0%) | 4 (0.1%) | 323 (10.0%) | 1462 (45.1%) | 951 (29.3%) | 297 (9.2%) | 50 (1.5%) | 144 (4.4%) | 12 (0.4%) | +| objekt | 0 (0.0%) | 0 (0.0%) | 0 (0.0%) | 0 (0.0%) | 0 (0.0%) | 0 (0.0%) | 28 (26.4%) | 12 (11.3%) | 15 (14.2%) | 51 (48.1%) | +| manifestacija | 0 (0.0%) | 0 (0.0%) | 0 (0.0%) | 0 (0.0%) | 0 (0.0%) | 4 (3.5%) | 0 (0.0%) | 22 (19.5%) | 87 (77.0%) | 0 (0.0%) | + +## Definicija "popunjenog polja" + +| Tip | Polja koja čine coverage | +|---|---| +| **savez** (10) | naziv, sport, predsjednik, tajnik, email, telefon, web, oib, adresa, godina_osnutka | +| **klub** (12) | naziv, sport, grad, oib, predsjednik, tajnik, email, telefon, web/web_stranica, sjediste/adresa, ciljevi, opis_djelatnosti | +| **sportas** (10) | ime, prezime, sport, klub_id, datum_rodenja, slika_url, oib, profile_url, biografija, hns_igrac_id | +| **objekt** (10) | naziv, tip, grad, adresa, lat, lng, upravitelj, kapacitet, sportovi, izgradeno | +| **manifestacija** (7) | naziv, mjesto, organizator, razina, broj_ucesnika, godina_od, source_url | + +## TOP 50 entiteta za manualnu reviziju +Sortirano po najniže popunjenosti, zatim po veličini definicije (najviše-polja-prazno prvo). +Klikni link da otvori detaljni panel u portalu. + +| # | Tip | ID | Naziv | Popunjeno | Postotak | Otvori | +|---:|---|---:|---|---:|---:|---| +| 1 | klub | 4250 | Streljački klub DVD Opatija | 1/12 | 8% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/4250) | +| 2 | klub | 4249 | Streljački klub DVD svojevrstan vodič za roditelje | 1/12 | 8% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/4249) | +| 3 | klub | 4530 | MOK RIJEKA II | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/4530) | +| 4 | klub | 4532 | MOK RIJEKA III | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/4532) | +| 5 | klub | 4531 | OK KASTAV 1998 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/4531) | +| 6 | klub | 3758 | BK Podhum | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3758) | +| 7 | klub | 2290 | KK Metal - Jurdani | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2290) | +| 8 | klub | 2315 | RK PŠR SELCE 5. u III HRL Zapad od 8 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2315) | +| 9 | klub | 2356 | ŽRK MURVICA 6. u II HRL Zapad od 9 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2356) | +| 10 | klub | 2360 | ŽRK ZAMET II 3. u III HRL Zapad od 8 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2360) | +| 11 | klub | 3898 | VK Primorjem [MERGED→3896] | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3898) | +| 12 | klub | 2311 | RK LIBURNIJA 8. u II HRL Zapad od 12 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2311) | +| 13 | klub | 2312 | RK MORNAR 3. u II HRL Zapad od 10 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2312) | +| 14 | klub | 2324 | RK ČAVLE 2. u II HRL Zapad od 10 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2324) | +| 15 | klub | 2325 | RK ČAVLE 7. u III HRL Zapad od 8 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2325) | +| 16 | klub | 2331 | SK IJANJE | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2331) | +| 17 | klub | 2332 | SK IJAŠKO ROLKANJE | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2332) | +| 18 | klub | 2333 | SK RAD | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2333) | +| 19 | klub | 2355 | ŽRK MURVICA 6. u II HRL Zapad od 12 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2355) | +| 20 | klub | 3749 | AK Velenje | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3749) | +| 21 | klub | 2291 | KK OI KOSTRENA | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2291) | +| 22 | klub | 3797 | JK Špinut | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3797) | +| 23 | klub | 3890 | VK Lošinj | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3890) | +| 24 | klub | 4533 | HNK Goranin | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/4533) | +| 25 | klub | 3899 | VK Šilo | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3899) | +| 26 | klub | 3741 | AK Elena Ban | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3741) | +| 27 | klub | 2321 | RK ZAMET 10. u Premijer ligi od 16 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2321) | +| 28 | klub | 2322 | RK ZAMET II 6. u II HRL Zapad od 10 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2322) | +| 29 | klub | 3744 | AK Koper | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3744) | +| 30 | klub | 3748 | AK Rijeka | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3748) | +| 31 | klub | 3761 | BK SVETA JELENA | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3761) | +| 32 | klub | 3753 | BK Vjekoslav Mance | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3753) | +| 33 | klub | 3754 | BK BROD MORAVICE | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3754) | +| 34 | klub | 2352 | ŠK Volosko - Volosko | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2352) | +| 35 | klub | 3763 | BK Sivke Postojna | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3763) | +| 36 | klub | 3764 | BK Zameta | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3764) | +| 37 | klub | 3750 | AK Viškovo | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3750) | +| 38 | klub | 3747 | AK Kvarnera | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3747) | +| 39 | klub | 3765 | BK Čavle | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3765) | +| 40 | klub | 3917 | BK Boćari | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3917) | +| 41 | klub | 3918 | BK Sivke | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3918) | +| 42 | klub | 3759 | BK Predator | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3759) | +| 43 | klub | 3795 | JK Vega | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3795) | +| 44 | klub | 3780 | JK Neverin | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3780) | +| 45 | klub | 3792 | JK Trogir | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3792) | +| 46 | klub | 3919 | JK Labud | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3919) | +| 47 | klub | 3794 | JK Val | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3794) | +| 48 | klub | 3790 | JK Split | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3790) | +| 49 | klub | 3784 | JK Optimist | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3784) | +| 50 | klub | 4426 | [UNRESOLVED] empty naziv & grad — id 4426 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/4426) | + +## Akcije za poboljšanje coverage-a + +1. **Pokreni "Obogati podatke" na top 50 zapisa** — `POST /sport/api/v2/enrich/{kind}/{id}/apply` već puni nedostajuće web/email/telefon/opis polja iz CSE+Wikipedia+sport-pgz.hr. +2. **CC6 enrichment loop** trenutno targetira coverage<70 i confidence>=0.7 — proširiti na coverage<80 nakon QA prolaza. +3. **HNS/HOS scraper** za sportaše: hns_igrac_id i slika_url stvaraju 70% coverage skoka za nogometaše. CC2-tip rader na ovome. +4. **OIB validacija** preko sudreg API-ja — popraviti `naziv`/`adresa` paralelno (vidi `data_cleanup_report.md`). diff --git a/erp/notifications.py b/erp/notifications.py new file mode 100644 index 0000000..4cbc83c --- /dev/null +++ b/erp/notifications.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +# erp/notifications.py — PGŽ Sport ERP mock e-mail notifikacije (R6) +# Author: Damir Radulić / dradulic@outlook.com +# Date: 2026-05-04 +# Description: Mock e-mail / channel='email' notifikacije pri promjeni statusa +# ERP entiteta. Upisuje u pgz_sport.notifications + log line. +# U produkciji se može zamijeniti pravim SMTP/Mailgun adapterom. + +from __future__ import annotations + +import json +import logging +import os +from datetime import datetime +from typing import Optional, Iterable + +import psycopg2 +import psycopg2.extras + +DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet", + password="R1net2026!SecureDB#v7") + +LOG_PATH = os.environ.get("ERP_NOTIFY_LOG", "/var/log/pgz-sport-erp-notify.log") +logger = logging.getLogger("erp.notifications") +if not logger.handlers: + logger.setLevel(logging.INFO) + try: + fh = logging.FileHandler(LOG_PATH) + fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) + logger.addHandler(fh) + except Exception: + pass + sh = logging.StreamHandler() + sh.setFormatter(logging.Formatter("[ERP-NOTIFY] %(message)s")) + logger.addHandler(sh) + + +def _db(): + c = psycopg2.connect(**DB); c.autocommit = True; return c + + +def _resolve_recipients(klub_id: Optional[int], user_id: Optional[int]) -> list[dict]: + """Vrati listu primatelja: voditelj putovanja (user_id), klub_admin svog kluba, + + pgz_admin kao info kopija.""" + out: list[dict] = [] + seen = set() + try: + with _db() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + if user_id: + cur.execute( + "SELECT id, email, COALESCE(full_name, ime||' '||prezime, email) AS name " + "FROM pgz_sport.users WHERE id=%s AND status='active'", (user_id,)) + r = cur.fetchone() + if r and r["email"] and r["id"] not in seen: + out.append({**r, "rola": "voditelj"}); seen.add(r["id"]) + if klub_id: + cur.execute( + """SELECT id, email, COALESCE(full_name, ime||' '||prezime, email) AS name + FROM pgz_sport.users + WHERE klub_id=%s AND user_type='klub_admin' AND status='active'""", + (klub_id,)) + for r in cur.fetchall(): + if r["email"] and r["id"] not in seen: + out.append({**r, "rola": "klub_admin"}); seen.add(r["id"]) + cur.execute( + """SELECT id, email, COALESCE(full_name, ime||' '||prezime, email) AS name + FROM pgz_sport.users + WHERE user_type='pgz_admin' AND status='active' LIMIT 5""") + for r in cur.fetchall(): + if r["email"] and r["id"] not in seen: + out.append({**r, "rola": "pgz_admin"}); seen.add(r["id"]) + except Exception as e: + logger.warning("recipients fetch fail: %s", e) + return out + + +def _store(user_id: Optional[int], subject: str, body: str, meta: dict, + channel: str = "email", status: str = "queued") -> Optional[int]: + try: + with _db() as c: + cur = c.cursor() + cur.execute( + """INSERT INTO pgz_sport.notifications + (user_id, channel, subject, body, status, scheduled_at, meta) + VALUES (%s,%s,%s,%s,%s,NOW(),%s) + RETURNING id""", + (user_id, channel, subject[:200], body[:5000], status, + json.dumps(meta, ensure_ascii=False, default=str)), + ) + return cur.fetchone()[0] + except Exception as e: + logger.warning("notification insert fail: %s", e) + return None + + +def _dispatch(subject: str, body: str, *, klub_id: Optional[int] = None, + user_id: Optional[int] = None, meta: Optional[dict] = None) -> dict: + meta = dict(meta or {}) + recipients = _resolve_recipients(klub_id, user_id) + delivered = [] + if not recipients: + # Mock: nemamo korisnika, samo log + jedan info zapis bez user_id + nid = _store(None, subject, body, + {**meta, "to": "(no_recipient)", "klub_id": klub_id}) + logger.info("MOCK email (no recipient) [%s] %s", nid, subject) + return {"sent": 0, "queued": 1 if nid else 0, "ids": [nid] if nid else [], + "recipients": []} + for r in recipients: + nid = _store(r["id"], subject, body, + {**meta, "to": r["email"], "rola": r.get("rola"), + "name": r.get("name")}) + if nid: + delivered.append({"id": nid, "user_id": r["id"], "email": r["email"]}) + logger.info( + "MOCK email queued [%s] to=%s rola=%s subj=%r", + nid, r["email"], r.get("rola"), subject, + ) + return {"sent": 0, "queued": len(delivered), "ids": [d["id"] for d in delivered], + "recipients": [d["email"] for d in delivered]} + + +# ─── Public helpers ──────────────────────────────────────────────────── + +def notify_invoice_created(invoice: dict) -> dict: + """Račun spremljen iz OCR-a — info klub_admin.""" + subj = f"Novi račun #{invoice.get('id')}: {invoice.get('vendor_name','')} (€{invoice.get('amount_gross')})" + body = ( + f"Račun {invoice.get('invoice_no')} od {invoice.get('vendor_name')} " + f"(OIB {invoice.get('vendor_oib')}) iznosa €{invoice.get('amount_gross')} " + f"na datum {invoice.get('invoice_date')} unesen je u sustav.\n\n" + f"Klub: {invoice.get('klub_id')} · Vrsta: {invoice.get('invoice_kind')} · Status: {invoice.get('payment_status')}" + ) + return _dispatch(subj, body, klub_id=invoice.get("klub_id"), + meta={"event": "invoice_created", "invoice_id": invoice.get("id")}) + + +def notify_invoice_paid(invoice: dict, payment: Optional[dict] = None) -> dict: + iban = (payment or {}).get("iban_to") or invoice.get("iban_to") or "—" + subj = f"Račun #{invoice.get('id')} označen kao plaćen — €{invoice.get('amount_gross')}" + body = ( + f"Račun {invoice.get('invoice_no')} izdan od {invoice.get('vendor_name')} " + f"je označen kao plaćen.\n" + f"Iznos: €{invoice.get('amount_gross')}\n" + f"Datum uplate: {invoice.get('paid_date')}\n" + f"IBAN primatelja: {iban}\n" + f"Referenca: {(payment or {}).get('reference','—')}" + ) + return _dispatch(subj, body, klub_id=invoice.get("klub_id"), + meta={"event": "invoice_paid", "invoice_id": invoice.get("id")}) + + +def notify_invoice_cancelled(invoice: dict, razlog: str = "") -> dict: + subj = f"Račun #{invoice.get('id')} otkazan" + body = f"Račun {invoice.get('invoice_no')} ({invoice.get('vendor_name')}) je otkazan.\nRazlog: {razlog or '—'}" + return _dispatch(subj, body, klub_id=invoice.get("klub_id"), + meta={"event": "invoice_cancelled", "invoice_id": invoice.get("id"), + "razlog": razlog}) + + +def notify_pn_submitted(pn: dict) -> dict: + subj = f"Putni nalog #{pn.get('id')} poslan na odobrenje (€{pn.get('cost_total')})" + body = ( + f"Putni nalog za destinaciju '{pn.get('destination')}' " + f"({pn.get('date_from')} – {pn.get('date_to')}) " + f"poslan je na odobrenje.\nIznos: €{pn.get('cost_total')}" + ) + return _dispatch(subj, body, klub_id=pn.get("klub_id"), user_id=pn.get("user_id"), + meta={"event": "pn_submitted", "pn_id": pn.get("id")}) + + +def notify_pn_approved(pn: dict) -> dict: + subj = f"Putni nalog #{pn.get('id')} ODOBREN — €{pn.get('cost_total')}" + body = ( + f"Putni nalog za '{pn.get('destination')}' " + f"({pn.get('date_from')} – {pn.get('date_to')}) je odobren.\n" + f"Iznos: €{pn.get('cost_total')}" + ) + return _dispatch(subj, body, klub_id=pn.get("klub_id"), user_id=pn.get("user_id"), + meta={"event": "pn_approved", "pn_id": pn.get("id")}) + + +def notify_pn_rejected(pn: dict, razlog: str = "") -> dict: + subj = f"Putni nalog #{pn.get('id')} ODBIJEN" + body = f"Putni nalog za '{pn.get('destination')}' je odbijen.\nRazlog: {razlog or '—'}" + return _dispatch(subj, body, klub_id=pn.get("klub_id"), user_id=pn.get("user_id"), + meta={"event": "pn_rejected", "pn_id": pn.get("id"), "razlog": razlog}) + + +def notify_pn_paid(pn: dict, payment: Optional[dict] = None) -> dict: + iban = (payment or {}).get("iban_to") or "—" + subj = f"Putni nalog #{pn.get('id')} ISPLAĆEN — €{pn.get('cost_total')}" + body = ( + f"Putni nalog za '{pn.get('destination')}' isplaćen je voditelju.\n" + f"Iznos: €{(payment or {}).get('amount') or pn.get('cost_total')}\n" + f"IBAN primatelja: {iban}\n" + f"Datum isplate: {pn.get('paid_at') or (payment or {}).get('payment_date')}\n" + f"Referenca: {(payment or {}).get('reference','—')}" + ) + return _dispatch(subj, body, klub_id=pn.get("klub_id"), user_id=pn.get("user_id"), + meta={"event": "pn_paid", "pn_id": pn.get("id")}) + + +__all__ = [ + "notify_invoice_created", "notify_invoice_paid", "notify_invoice_cancelled", + "notify_pn_submitted", "notify_pn_approved", "notify_pn_rejected", "notify_pn_paid", +] diff --git a/erp/ocr.py b/erp/ocr.py index 5fdb682..12433fe 100644 --- a/erp/ocr.py +++ b/erp/ocr.py @@ -45,6 +45,15 @@ try: except Exception: _auth_user = None +try: + from erp.notifications import ( + notify_invoice_created, notify_invoice_paid, notify_invoice_cancelled, + ) +except Exception: + def notify_invoice_created(*a, **k): return {} + def notify_invoice_paid(*a, **k): return {} + def notify_invoice_cancelled(*a, **k): return {} + router = APIRouter(prefix="/api/erp", tags=["erp-ocr"]) # === Config === @@ -324,6 +333,11 @@ async def ocr_upload( authorization: Optional[str] = Header(None), ): """Upload an invoice file (PDF/image) → store on disk + insert pgz_sport.invoice_uploads.""" + user = _resolve_user(authorization) + # Permission: pgz_admin uvijek; klub_admin/klub_user samo za vlastiti klub (ako je naveden) + if user and not is_pgz_admin(user): + if klub_id and user.get("klub_id") != klub_id: + raise HTTPException(403, "Nemate ovlasti uploadati za ovaj klub") 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)}") @@ -354,6 +368,18 @@ async def ocr_upload( sha256, json.dumps({"tenant_id": tenant_id, "invoice_kind": invoice_kind})), ) row = cur.fetchone() + # Audit log za OCR upload + try: + with _db() as c: + c.cursor().execute( + """INSERT INTO pgz_sport.audit_log + (tablica, operacija, record_id, korisnik, promijenjeno_polje, nova_vrijednost) + VALUES ('pgz_sport.invoice_uploads','create',%s,%s,'file_name',%s)""", + (row["id"], (user.get("email") if user else "anon"), + f"{file.filename} ({len(raw)} B, sha={sha256[:12]})"), + ) + except Exception: + pass return {"ok": True, "upload_id": row["id"], "file_name": row["file_name"], "size": len(raw), "sha256": sha256, "status": row["ocr_status"]} @@ -646,11 +672,17 @@ def invoices_create(body: dict = Body(...), authorization: Optional[str] = Heade if body.get(k) in (None, ""): raise HTTPException(400, f"Nedostaje polje: {k}") + user = _resolve_user(authorization) klub_id = body.get("klub_id") tenant_id = body.get("tenant_id", 1) upload_id = body.get("upload_id") lines = body.get("lines") or [] + # Permission: pgz_admin uvijek; klub_admin samo za vlastiti klub + if user and not is_pgz_admin(user): + if not (user.get("user_type") == "klub_admin" and klub_id == user.get("klub_id")): + raise HTTPException(403, "Nemate ovlasti kreirati račun za ovaj klub") + with _db() as c: cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute( @@ -715,7 +747,10 @@ def invoices_create(body: dict = Body(...), authorization: Optional[str] = Heade (inv_id, upload_id), ) - return {"ok": True, "invoice": inv} + audit_invoice(user, inv_id, "create", field="invoice_no", + new=f"{body.get('invoice_no')} €{body.get('amount_gross')}") + notif = notify_invoice_created({**body, "id": inv_id, "klub_id": klub_id}) + return {"ok": True, "invoice": inv, "notification": notif} @router.put("/invoices/{invoice_id}") @@ -813,7 +848,13 @@ def invoices_pay(invoice_id: int, body: dict = Body(default={}), 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} + notif = notify_invoice_paid( + {**inv, **(row or {}), "id": invoice_id}, + {"iban_to": iban_to, "iban_from": iban_from, "reference": reference, + "payment_date": paid_date, "amount": amount}, + ) + return {"ok": True, "invoice": row, "payment_id": pay["id"] if pay else None, + "notification": notif} # ── R5.3 BULK OPERATIONS ────────────────────────────────────────────── @@ -868,6 +909,14 @@ def invoices_bulk_pay(body: dict = Body(...), authorization: Optional[str] = Hea ) audit_invoice(user, inv["id"], "bulk_pay", field="payment_status", old=inv.get("payment_status"), new="paid") + try: + notify_invoice_paid( + {**inv, "paid_date": paid_date}, + {"iban_to": iban_to, "iban_from": iban_from, "reference": reference, + "payment_date": paid_date, "amount": inv.get("amount_gross")}, + ) + except Exception: + pass results["paid"].append(inv["id"]) except Exception as e: results["errors"].append({"id": inv["id"], "err": str(e)[:200]}) @@ -907,6 +956,8 @@ def invoices_bulk_cancel(body: dict = Body(...), authorization: Optional[str] = audit_invoice(user, inv["id"], "bulk_cancel", field="payment_status", old=inv.get("payment_status"), new=f"cancelled: {razlog}") + try: notify_invoice_cancelled(inv, razlog) + except Exception: pass results["cancelled"].append(inv["id"]) except Exception as e: results["errors"].append({"id": inv["id"], "err": str(e)[:200]}) diff --git a/erp/putni_nalozi.py b/erp/putni_nalozi.py index a2c86e1..f704f0f 100644 --- a/erp/putni_nalozi.py +++ b/erp/putni_nalozi.py @@ -36,6 +36,16 @@ try: except Exception: _auth_user = None +try: + from erp.notifications import ( + notify_pn_submitted, notify_pn_approved, notify_pn_rejected, notify_pn_paid, + ) +except Exception: + def notify_pn_submitted(*a, **k): return {} + def notify_pn_approved(*a, **k): return {} + def notify_pn_rejected(*a, **k): return {} + def notify_pn_paid(*a, **k): return {} + ADMIN_TOKEN = "admin-pgz-2026" def _resolve_user(authorization): @@ -361,7 +371,8 @@ def posalji_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(Non 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} + notif = notify_pn_submitted({**pn, "status": "poslan"}) + return {"ok": True, "putni_nalog": row, "notification": notif} @router.post("/putni-nalog/{nalog_id}/odbij") @@ -389,7 +400,8 @@ def odbij_putni_nalog(nalog_id: int, body: dict = Body(default={}), 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} + notif = notify_pn_rejected({**pn, "status": "odbijen"}, razlog=razlog) + return {"ok": True, "putni_nalog": row, "notification": notif} @router.post("/putni-nalog/{nalog_id}/isplati") @@ -437,7 +449,13 @@ def isplati_putni_nalog(nalog_id: int, body: dict = Body(default={}), 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} + notif = notify_pn_paid( + {**pn, **(row or {}), "id": nalog_id}, + {"iban_to": iban_to, "iban_from": iban_from, "amount": amount, + "reference": reference, "payment_date": paid_date}, + ) + return {"ok": True, "putni_nalog": row, "payment_id": pay["id"] if pay else None, + "notification": notif} @router.get("/putni-nalog/{nalog_id}/hub3.pdf") @@ -526,6 +544,12 @@ def create_putni_nalog(body: dict = Body(...), authorization: Optional[str] = He if not klub_id: raise HTTPException(400, "klub_id je obavezan") + user = _resolve_user(authorization) + # Permission: pgz_admin uvijek; klub_admin/klub_user samo za vlastiti klub + if user and not is_pgz_admin(user): + if user.get("user_type") not in ("klub_admin", "klub_user") or user.get("klub_id") != klub_id: + raise HTTPException(403, "Nemate ovlasti kreirati putni nalog za ovaj klub") + 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 @@ -593,6 +617,8 @@ def create_putni_nalog(body: dict = Body(...), authorization: Optional[str] = He ct = cur.fetchone() if ct: row["cost_total"] = ct["cost_total"] + audit_putni(user, row["id"], "create", field="status", + new=f"draft (€{row.get('cost_total')})") return {"ok": True, "putni_nalog": row, "dnevnice_calc": dnv} @@ -647,6 +673,8 @@ 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) + if approved_by == 0 or (user and user.get("_synthetic")): + approved_by = None # admin token nema realnog user_id u DB 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,)) @@ -666,7 +694,27 @@ def odobriti_putni_nalog(nalog_id: int, body: dict = Body(default={}), row = cur.fetchone() audit_putni(user, nalog_id, "approve", field="status", old=pn.get("status"), new="odobren") - return {"ok": True, "putni_nalog": row} + notif = notify_pn_approved({**pn, "status": "odobren"}) + return {"ok": True, "putni_nalog": row, "notification": notif} + + +# R6.2 — PUT alias za simetriju s briefom +@router.put("/putni-nalog/{nalog_id}/odobri") +def odobri_putni_nalog_put(nalog_id: int, body: dict = Body(default={}), + authorization: Optional[str] = Header(None)): + return odobriti_putni_nalog(nalog_id, body, authorization) + + +@router.put("/putni-nalog/{nalog_id}/odbij") +def odbij_putni_nalog_put(nalog_id: int, body: dict = Body(default={}), + authorization: Optional[str] = Header(None)): + return odbij_putni_nalog(nalog_id, body, authorization) + + +@router.put("/putni-nalog/{nalog_id}/isplati") +def isplati_putni_nalog_put(nalog_id: int, body: dict = Body(default={}), + authorization: Optional[str] = Header(None)): + return isplati_putni_nalog(nalog_id, body, authorization) @router.post("/putni-nalog/{nalog_id}/zatvori") diff --git a/pgz_sport_api.py b/pgz_sport_api.py index ee172a7..0f311fa 100644 --- a/pgz_sport_api.py +++ b/pgz_sport_api.py @@ -76,37 +76,55 @@ 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. +# ─── R5 #1 + R6 #1: Defense-in-depth JWT enforcement ─── +# Mutating requests (POST/PUT/PATCH/DELETE) under /api/* require a valid +# Bearer JWT, except for explicitly-public auth & consent endpoints. +# All /api/admin/* requests (any method) also require auth. +_PUBLIC_MUTATING_PATHS = { + "/api/auth/login", "/api/auth/refresh", "/api/auth/forgot-password", + "/api/auth/password/reset", "/api/auth/reset-password", + "/api/auth/setup-password", "/api/auth/google", + "/api/gdpr/consent", +} +_PUBLIC_MUTATING_SUFFIXES = ( + "/avatar", # /api/crm/clanovi/{id}/avatar — demo mode handled in handler +) + @app.middleware("http") -async def require_jwt_on_admin(request, call_next): +async def require_jwt_middleware(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}") + method = request.method.upper() + if method == "OPTIONS": + return await call_next(request) + + admin_gate = p.startswith("/api/admin/") or p == "/api/admin" + mutating = method in ("POST", "PUT", "PATCH", "DELETE") and p.startswith("/api/") + if mutating and (p in _PUBLIC_MUTATING_PATHS or + any(p.endswith(s) for s in _PUBLIC_MUTATING_SUFFIXES)): + mutating = False + + if not (admin_gate or mutating): + return await call_next(request) + + try: + from auth.auth_v2 import decode_token, _is_revoked + except Exception as e: + print(f"[JWT-MW import WARN] {e}") + return await call_next(request) + + from starlette.responses import JSONResponse as _JR + auth_h = request.headers.get("authorization", "") + if not auth_h.lower().startswith("bearer "): + return _JR({"detail": "Authentication required"}, status_code=401) + token = auth_h.split(" ", 1)[1].strip() + try: + payload = decode_token(token) + except Exception: + return _JR({"detail": "Invalid or expired token"}, status_code=401) + if payload.get("typ") not in (None, "access"): + return _JR({"detail": "Wrong token type"}, status_code=401) + if _is_revoked(payload.get("jti", "")): + return _JR({"detail": "Token revoked"}, status_code=401) return await call_next(request) @@ -1395,9 +1413,10 @@ except Exception as e: print(f'[CRM/PANEL] clan_panel router fail: {e}') try: - from crm_extras_router import router as crm_extras_router + from crm_extras_router import router as crm_extras_router, alias_router as crm_extras_alias_router app.include_router(crm_extras_router) - print('[CRM/R5] extras router loaded (bulk + xlsx + stats + notifications)') + app.include_router(crm_extras_alias_router) + print('[CRM/R5] extras router loaded (bulk + xlsx + stats + notifications + ZIP + email tpl + /me)') except Exception as e: print(f'[CRM/R5] extras router fail: {e}') diff --git a/routers/clan_panel_router.py b/routers/clan_panel_router.py index b570244..711923d 100644 --- a/routers/clan_panel_router.py +++ b/routers/clan_panel_router.py @@ -442,14 +442,10 @@ def update_clan(cid: int, patch: ClanPatch, async def upload_avatar(cid: int, file: UploadFile = File(...), authorization: Optional[str] = Header(None), x_role: Optional[str] = Header(None)): - role = (x_role or _resolve_role(authorization) or "viewer").lower() - if role not in EDITABLE_BY_ROLE and role not in ("pgz_admin", "super_admin"): - # sportas/klub_admin/savez_admin/pgz_admin/super_admin svi smiju - # (sportas ako je 'sebe' — UI to validira preko user_id, ovdje server - # primarno gata po roli; future M1 JWT propagacija će validirati clan_id == self) - raise HTTPException(403, f"Role '{role}' nema dozvolu za upload avatara") - - # validate file type + """Upload avatar. R6 #2 demo mode: if there is no/invalid token, + accept upload but DO NOT persist (FS or DB) — return demo flag + mock URL. + Real save (FS + DB) requires a valid Bearer JWT for an authorized role.""" + # validate file type early — applies to both demo and real allowed_ct = {"image/jpeg", "image/png", "image/webp", "image/gif"} ext_map = {"image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", "image/gif": "gif"} @@ -457,6 +453,47 @@ async def upload_avatar(cid: int, file: UploadFile = File(...), if ct not in allowed_ct: raise HTTPException(400, f"Nedozvoljeni tip slike: {ct}. Dozvoljeno: jpeg/png/webp/gif") + contents = await file.read() + if len(contents) > 5 * 1024 * 1024: + raise HTTPException(413, "Slika prevelika (max 5 MB)") + + # Try to resolve role from JWT (via auth_v2 — proper secret + revocation check) + resolved_role = "" + has_valid_auth = False + if authorization and authorization.lower().startswith("bearer "): + tok = authorization.split(" ", 1)[1].strip() + try: + import sys as _s; _s.path.insert(0, '/opt/pgz-sport') + from auth.auth_v2 import decode_token as _dt, _is_revoked as _rev + payload = _dt(tok) + if payload.get("typ") in (None, "access") and not _rev(payload.get("jti","")): + resolved_role = (payload.get("role") or "").lower() + has_valid_auth = True + except Exception: + has_valid_auth = False + role = (x_role or resolved_role or "").lower() + + # ───── DEMO MODE: no/invalid token → mock storage ───── + if not has_valid_auth: + import hashlib as _h + digest = _h.sha256(contents).hexdigest()[:12] + mock_fname = f"demo-{cid}-{digest}.{ext_map[ct]}" + return { + "ok": True, + "id": cid, + "demo_mode": True, + "message": "Demo mode — slika nije spremljena. Prijavite se za pravu pohranu.", + "slika_url": None, + "mock_filename": mock_fname, + "size_bytes": len(contents), + "content_type": ct, + "sha256": digest, + } + + # ───── REAL SAVE: valid auth + role check ───── + if role not in EDITABLE_BY_ROLE and role not in ("pgz_admin", "super_admin"): + raise HTTPException(403, f"Role '{role}' nema dozvolu za upload avatara") + # provjeri da član postoji with _conn() as conn, conn.cursor() as cur: cur.execute("SELECT id, slika_url FROM pgz_sport.clanovi WHERE id=%s", (cid,)) @@ -464,20 +501,14 @@ async def upload_avatar(cid: int, file: UploadFile = File(...), if not r: raise HTTPException(404, "Član ne postoji") - # save file fname = f"{cid}-{_uuid.uuid4().hex[:8]}.{ext_map[ct]}" fpath = UPLOADS_DIR / fname - contents = await file.read() - if len(contents) > 5 * 1024 * 1024: - raise HTTPException(413, "Slika prevelika (max 5 MB)") with open(fpath, "wb") as fh: fh.write(contents) public_url = f"{PUBLIC_AVATAR_PREFIX}/{fname}" - # update DB with _conn() as conn, conn.cursor() as cur: - # obriši staru sliku (best-effort, samo ako je u uploads/avatars/) old = r["slika_url"] if old and PUBLIC_AVATAR_PREFIX in old: try: @@ -493,6 +524,7 @@ async def upload_avatar(cid: int, file: UploadFile = File(...), return { "ok": True, "id": cid, + "demo_mode": False, "slika_url": public_url, "size_bytes": len(contents), "content_type": ct, diff --git a/routers/crm_extras_router.py b/routers/crm_extras_router.py index 9c851dd..3db5f44 100644 --- a/routers/crm_extras_router.py +++ b/routers/crm_extras_router.py @@ -23,14 +23,16 @@ from __future__ import annotations import io import json as _json +import re as _re import sys +import zipfile 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 import APIRouter, HTTPException, Query, Header from fastapi.responses import Response from pydantic import BaseModel @@ -42,6 +44,10 @@ from crm.payments import ( build_hub3_pdf, make_poziv_na_broj, normalize_iban, ) +DEFAULT_PRIMATELJ_IBAN = "HR0000000000000000000" +DEFAULT_PRIMATELJ_NAZIV = "PGŽ Odjel za sport" +DEFAULT_PRIMATELJ_ADRESA = "Adamićeva 10, 51000 Rijeka" + 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" @@ -587,3 +593,417 @@ def mark_all_read(body: MarkAllReadIn): ids = [r["id"] for r in cur.fetchall()] conn.commit() return {"ok": True, "marked_read": len(ids), "ids": ids[:200]} + + +# ════════════════════════════════════════════════════ +# R6 #2 — BATCH HUB-3 PDFs ZIP +# ════════════════════════════════════════════════════ + +class BulkZipIn(BaseModel): + ids: Optional[list[int]] = None + klub_id: Optional[int] = None + godina: Optional[int] = None + only_unpaid: bool = True + limit: int = 200 + + +def _safe_filename(s: str) -> str: + s = (s or "x").strip() + s = _re.sub(r"[^\w\-\.]+", "_", s, flags=_re.UNICODE) + return s[:80] or "x" + + +@router.post("/clanarine/bulk/uplatnice.zip") +def bulk_uplatnice_zip(body: BulkZipIn): + """ + Generira ZIP archive sa svim HUB-3 PDF uplatnicama za odabrane članarine. + Filename pattern: /--.pdf + """ + where, params = [], [] + 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) + if body.only_unpaid and not body.ids: + where.append("c.status IN ('nepodmireno','djelomicno')") + where_sql = ("WHERE " + " AND ".join(where)) if where else "" + params.append(body.limit) + + sql = f""" + SELECT c.id, c.godina, c.razdoblje, + c.iznos_propisan, c.iznos_placen, + (c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug, + cl.ime, cl.prezime, cl.adresa AS clan_adresa, cl.grad AS clan_grad, + k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban, + k.adresa AS klub_adresa, k.grad AS klub_grad + 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_sql} + ORDER BY k.naziv NULLS LAST, cl.prezime, cl.ime + LIMIT %s + """ + with _conn() as conn, conn.cursor() as cur: + cur.execute(sql, params) + rows = [_row(r) for r in cur.fetchall()] + if not rows: + raise HTTPException(404, "Nema članarina za batch") + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as z: + manifest = [] + for r in rows: + dug = float(r["dug"] or 0) + if dug <= 0: + dug = float(r["iznos_propisan"] or 0) + iban = normalize_iban(r["klub_iban"] or DEFAULT_PRIMATELJ_IBAN) + primatelj_naziv = r.get("klub") or DEFAULT_PRIMATELJ_NAZIV + primatelj_adresa = ", ".join( + [x for x in [r.get("klub_adresa"), r.get("klub_grad")] if x] + ) or DEFAULT_PRIMATELJ_ADRESA + platitelj_naziv = f"{r.get('ime') or ''} {r.get('prezime') or ''}".strip() or "Član" + platitelj_adresa = ", ".join( + [x for x in [r.get("clan_adresa"), r.get("clan_grad")] if x] + ) or "—" + poziv = make_poziv_na_broj(r.get("klub_oib"), int(r["godina"]), int(r["id"])) + try: + pdf = build_hub3_pdf( + platitelj_naziv=platitelj_naziv, + platitelj_adresa=platitelj_adresa, + primatelj_naziv=primatelj_naziv, + primatelj_adresa=primatelj_adresa, + iban=iban, + amount_eur=dug, + model="HR00", + poziv_na_broj=poziv, + opis=f"Članarina {r['godina']} — {r.get('razdoblje') or 'godišnja'}", + sifra_namjene="OTHR", + ) + except Exception as e: + manifest.append(f"{r['id']}\tERROR\t{e}") + continue + klub_dir = _safe_filename(primatelj_naziv) + fname = (f"{klub_dir}/" + f"{_safe_filename(r.get('prezime') or 'X')}_" + f"{_safe_filename(r.get('ime') or 'X')}-" + f"{r['id']}-{r['godina']}.pdf") + z.writestr(fname, pdf) + manifest.append(f"{r['id']}\t{fname}\t{dug:.2f} EUR\t{poziv}") + # Manifest TXT + z.writestr("_manifest.txt", + "ID\tFILENAME\tIZNOS\tPOZIV_NA_BROJ\n" + "\n".join(manifest)) + # Manifest JSON + z.writestr("_manifest.json", _json.dumps( + {"count": len(rows), + "generated_at": datetime.now().isoformat(), + "items": [{"id": r["id"], "klub": r.get("klub"), + "clan": f"{r.get('ime','')} {r.get('prezime','')}".strip(), + "godina": r["godina"], "iznos_eur": float(r["dug"] or r["iznos_propisan"] or 0)} + for r in rows]}, + ensure_ascii=False, indent=2)) + + fname = f"hub3-batch-{date.today().isoformat()}-{len(rows)}.zip" + return Response( + content=buf.getvalue(), + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{fname}"', + "X-Batch-Count": str(len(rows))}, + ) + + +# ════════════════════════════════════════════════════ +# R6 #3 — E-MAIL TEMPLATES (CRUD + render + send-mock) +# ════════════════════════════════════════════════════ + +def _render(tpl: str, vars: dict) -> str: + """Vrlo jednostavan {{key}} render.""" + if not tpl: + return "" + out = tpl + for k, v in (vars or {}).items(): + out = out.replace("{{" + str(k) + "}}", "" if v is None else str(v)) + return out + + +class EmailTemplateIn(BaseModel): + code: str + naziv: str + kategorija: Optional[str] = None + subject_tpl: str + body_tpl: str + variables: Optional[list[str]] = None + active: bool = True + + +class EmailTemplatePatch(BaseModel): + naziv: Optional[str] = None + kategorija: Optional[str] = None + subject_tpl: Optional[str] = None + body_tpl: Optional[str] = None + variables: Optional[list[str]] = None + active: Optional[bool] = None + + +@router.get("/email-templates") +def list_email_templates(kategorija: Optional[str] = Query(None), + active_only: bool = Query(True)): + where, params = [], [] + if active_only: + where.append("active = TRUE") + if kategorija: + where.append("kategorija = %s"); params.append(kategorija) + where_sql = ("WHERE " + " AND ".join(where)) if where else "" + with _conn() as conn, conn.cursor() as cur: + cur.execute(f""" + SELECT id, code, naziv, kategorija, subject_tpl, body_tpl, + variables, active, created_at, updated_at + FROM pgz_sport.email_templates + {where_sql} + ORDER BY kategorija NULLS LAST, naziv + """, params) + rows = [_row(r) for r in cur.fetchall()] + return {"count": len(rows), "templates": rows} + + +@router.get("/email-templates/{code_or_id}") +def get_email_template(code_or_id: str): + with _conn() as conn, conn.cursor() as cur: + if code_or_id.isdigit(): + cur.execute("SELECT * FROM pgz_sport.email_templates WHERE id=%s", (int(code_or_id),)) + else: + cur.execute("SELECT * FROM pgz_sport.email_templates WHERE code=%s", (code_or_id,)) + r = cur.fetchone() + if not r: + raise HTTPException(404, "Email template ne postoji") + return _row(r) + + +@router.post("/email-templates") +def create_email_template(body: EmailTemplateIn): + with _conn() as conn, conn.cursor() as cur: + cur.execute(""" + INSERT INTO pgz_sport.email_templates + (code, naziv, kategorija, subject_tpl, body_tpl, variables, active) + VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s) + RETURNING * + """, (body.code, body.naziv, body.kategorija, body.subject_tpl, + body.body_tpl, _json.dumps(body.variables or []), body.active)) + r = cur.fetchone(); conn.commit() + return _row(r) + + +@router.put("/email-templates/{code_or_id}") +def update_email_template(code_or_id: str, body: EmailTemplatePatch): + fields, params = [], [] + for f in ("naziv", "kategorija", "subject_tpl", "body_tpl", "active"): + v = getattr(body, f) + if v is not None: + fields.append(f"{f} = %s"); params.append(v) + if body.variables is not None: + fields.append("variables = %s::jsonb"); params.append(_json.dumps(body.variables)) + if not fields: + raise HTTPException(400, "Nema polja za izmjenu") + fields.append("updated_at = now()") + where_col = "id" if code_or_id.isdigit() else "code" + where_val = int(code_or_id) if code_or_id.isdigit() else code_or_id + params.append(where_val) + with _conn() as conn, conn.cursor() as cur: + cur.execute(f"UPDATE pgz_sport.email_templates SET {', '.join(fields)} WHERE {where_col}=%s RETURNING *", + params) + r = cur.fetchone() + if not r: + raise HTTPException(404, "Template ne postoji") + conn.commit() + return _row(r) + + +class EmailRenderIn(BaseModel): + variables: dict = {} + + +@router.post("/email-templates/{code_or_id}/render") +def render_email_template(code_or_id: str, body: EmailRenderIn): + """Vrati subject/body s popunjenim {{vars}}.""" + with _conn() as conn, conn.cursor() as cur: + if code_or_id.isdigit(): + cur.execute("SELECT * FROM pgz_sport.email_templates WHERE id=%s", (int(code_or_id),)) + else: + cur.execute("SELECT * FROM pgz_sport.email_templates WHERE code=%s", (code_or_id,)) + t = cur.fetchone() + if not t: + raise HTTPException(404, "Template ne postoji") + return { + "code": t["code"], + "naziv": t["naziv"], + "subject": _render(t["subject_tpl"], body.variables), + "body": _render(t["body_tpl"], body.variables), + "variables_provided": list(body.variables.keys()), + "variables_required": t.get("variables") or [], + } + + +class EmailSendIn(BaseModel): + to: Optional[str] = None + user_id: Optional[int] = None + variables: dict = {} + schedule_inapp: bool = True + + +@router.post("/email-templates/{code_or_id}/send") +def send_email_template(code_or_id: str, body: EmailSendIn): + """ + Mock send: rendera template i upiše u notifications (channel=email + inapp). + Stvarni SMTP nije konfiguriran. + """ + with _conn() as conn, conn.cursor() as cur: + if code_or_id.isdigit(): + cur.execute("SELECT * FROM pgz_sport.email_templates WHERE id=%s", (int(code_or_id),)) + else: + cur.execute("SELECT * FROM pgz_sport.email_templates WHERE code=%s", (code_or_id,)) + t = cur.fetchone() + if not t: + raise HTTPException(404, "Template ne postoji") + + subject = _render(t["subject_tpl"], body.variables) + body_txt = _render(t["body_tpl"], body.variables) + meta = _json.dumps({"template_code": t["code"], + "to": body.to, + "variables": body.variables}) + ids = [] + if body.to: + cur.execute("""INSERT INTO pgz_sport.notifications + (user_id, channel, subject, body, status, scheduled_at, meta) + VALUES (%s,'email',%s,%s,'pending',now(),%s::jsonb) + RETURNING id""", + (body.user_id, subject, body_txt, meta)) + ids.append({"channel": "email", "id": cur.fetchone()["id"]}) + if body.schedule_inapp: + cur.execute("""INSERT INTO pgz_sport.notifications + (user_id, channel, subject, body, status, scheduled_at, meta) + VALUES (%s,'inapp',%s,%s,'pending',now(),%s::jsonb) + RETURNING id""", + (body.user_id, subject, body_txt, meta)) + ids.append({"channel": "inapp", "id": cur.fetchone()["id"]}) + conn.commit() + return {"ok": True, "queued": ids, "subject": subject, + "body_preview": body_txt[:200]} + + +# ════════════════════════════════════════════════════ +# R6 #4 — /api/notifications/me (alias na /api/crm/notifications/me) +# ════════════════════════════════════════════════════ + +def _resolve_user_id(authorization: Optional[str], x_user_id: Optional[str]) -> Optional[int]: + """ + Priority: + 1) X-User-Id header (UI / debug) + 2) JWT 'sub' claim iz Bearer tokena (auth_v2) + """ + if x_user_id: + try: + return int(x_user_id) + except (TypeError, ValueError): + pass + if not authorization: + return None + tok = authorization.replace("Bearer ", "").strip() + try: + import jwt as _jwt # type: ignore + for secret in ( + __import__("os").environ.get("JWT_SECRET"), + "rinet-jwt-secret-2026", + ): + if not secret: + continue + try: + payload = _jwt.decode(tok, secret, algorithms=["HS256"]) + sub = payload.get("sub") or payload.get("user_id") + if sub is not None: + return int(sub) + except Exception: + continue + except Exception: + pass + return None + + +@router.get("/notifications/me") +def my_notifications( + only_unread: bool = Query(True), + channel: Optional[str] = Query(None), + limit: int = Query(50, le=200), + authorization: Optional[str] = Header(None), + x_user_id: Optional[str] = Header(None), +): + """ + Lista notifikacija za current usera (iz JWT sub ili X-User-Id headera). + Kao fallback (kad nije autentikiran) vraća notifikacije BEZ user_id + (broadcast / system). + """ + user_id = _resolve_user_id(authorization, x_user_id) + where = [] + params: list = [] + if user_id is None: + # broadcast: notifs bez user_id + where.append("user_id IS NULL") + else: + where.append("(user_id = %s OR user_id IS NULL)"); params.append(user_id) + if only_unread: + where.append("read_at IS NULL") + if channel: + where.append("channel = %s"); params.append(channel) + 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 {' AND '.join(where)} + ORDER BY scheduled_at DESC NULLS LAST + LIMIT %s + """, params) + rows = [_row(r) for r in cur.fetchall()] + # summary za badge + sum_where = ["read_at IS NULL"] + sum_params = [] + if user_id is not None: + sum_where.append("(user_id = %s OR user_id IS NULL)") + sum_params.append(user_id) + else: + sum_where.append("user_id IS NULL") + cur.execute(f""" + SELECT COUNT(*) AS unread, + COUNT(*) FILTER (WHERE channel='inapp') AS unread_inapp, + COUNT(*) FILTER (WHERE channel='email') AS unread_email + FROM pgz_sport.notifications + WHERE {' AND '.join(sum_where)} + """, sum_params) + summary = _row(cur.fetchone()) + return { + "user_id": user_id, + "count": len(rows), + "summary": summary, + "rows": rows, + } + + +# ════════════════════════════════════════════════════ +# Alias router: /api/notifications/me (bez /crm prefiksa) +# ════════════════════════════════════════════════════ + +alias_router = APIRouter(prefix="/api/notifications", tags=["notifications-alias"]) + + +@alias_router.get("/me") +def my_notifications_alias( + only_unread: bool = Query(True), + channel: Optional[str] = Query(None), + limit: int = Query(50, le=200), + authorization: Optional[str] = Header(None), + x_user_id: Optional[str] = Header(None), +): + """Alias za /api/crm/notifications/me — kompatibilnost s /api/notifications/me.""" + return my_notifications(only_unread=only_unread, channel=channel, limit=limit, + authorization=authorization, x_user_id=x_user_id) diff --git a/routers/enrich_router.py b/routers/enrich_router.py index 9b53942..ccc0e7c 100644 --- a/routers/enrich_router.py +++ b/routers/enrich_router.py @@ -1229,22 +1229,30 @@ def enrich_apply(kind: str = _FPath(..., regex='^(klub|savez|sportas)$'), fields = res['proposed'] sources = res['sources'] out = _apply_to_db(kind, eid, fields or {}, sources or [], x_user_email) + applied = out.get('applied') or {} # R4-A3: write to pgz_sport.sys_audit so the audit page sees enrichment events try: from audit_seal_router import audit_log as _audit_log - if out.get('applied'): + if applied: _audit_log( action='enrich.apply', target_type=kind, target_id=eid, - payload={'applied': out.get('applied'), + payload={'applied': applied, 'sources': [s.get('url') for s in (sources or []) if isinstance(s, dict)]}, user_id=x_user_id, user_email=x_user_email, ) except Exception: pass - return {'kind': kind, 'id': eid, **out} + return { + 'status': 'success' if applied else 'no_changes', + 'kind': kind, + 'id': eid, + 'applied_count': len(applied), + 'applied_fields': list(applied.keys()), + **out, + } @router.get("/enrich/log") @@ -1478,3 +1486,205 @@ def forensic_scan(req: dict = Body(...)): 'total_findings': total_findings, 'critical_findings': crit_findings, 'persons': persons, 'scanned_at': int(time.time())} + + +# ─── SB-3 — Bulk enrichment ───────────────────────────────────────────── +_BULK_KEY_MAP = { + 'klub': ('pgz_sport.klubovi', + ('oib','sport','grad','predsjednik','tajnik','web','email','telefon', + 'sjediste','godina_osnutka','ciljevi','opis_djelatnosti')), + 'savez': ('pgz_sport.savezi', + ('oib','sport','predsjednik','tajnik','email','telefon','web', + 'adresa','godina_osnutka')), + 'sportas': ('pgz_sport.clanovi', + ('sport','profile_url','slika_url','hns_igrac_id','biografija', + 'datum_rodenja','mjesto_rodenja','broj_dresa')), +} + + +def _coverage_sql(prefix: str, keys: tuple[str, ...]) -> str: + parts = [f"(CASE WHEN {prefix}{k} IS NOT NULL AND ({prefix}{k}::text) <> '' THEN 1 ELSE 0 END)" + for k in keys] + return f"((({' + '.join(parts)})::numeric * 100) / {len(keys)})" + + +def _bulk_pick(kind: str, limit: int, coverage_max: int) -> list[int]: + if kind not in _BULK_KEY_MAP: + raise HTTPException(400, "kind must be klub|savez|sportas") + table, keys = _BULK_KEY_MAP[kind] + cov = _coverage_sql('', keys) + extra_where = '' + if kind == 'klub': + extra_where = "AND aktivan = TRUE" + elif kind == 'sportas': + extra_where = "AND aktivan = TRUE" + sql = (f"SELECT id FROM {table} " + f"WHERE 1=1 {extra_where} " + f"AND {cov} < %s " + f"ORDER BY random() LIMIT %s") + with _db() as c, c.cursor() as cur: + cur.execute(sql, (coverage_max, limit)) + return [r[0] for r in cur.fetchall()] + + +@router.post("/enrich/bulk") +def enrich_bulk(body: dict = Body(default=None), + x_user_email: Optional[str] = Header(default=None), + x_user_id: Optional[int] = Header(default=None)): + """Run preview+apply over N random under-enriched rows of one kind. + + Body: {kind: 'klub'|'savez'|'sportas', limit: 50, coverage_max: 70} + Returns aggregate stats. Synchronous (use polling, not SSE). + """ + body = body or {} + kind = (body.get('kind') or '').strip().lower() + if kind not in _BULK_KEY_MAP: + raise HTTPException(400, "kind must be klub|savez|sportas") + limit = max(1, min(int(body.get('limit') or 50), 200)) + coverage_max = max(0, min(int(body.get('coverage_max') or 70), 100)) + + ids = _bulk_pick(kind, limit, coverage_max) + items: list[dict] = [] + fields_total = 0 + started = time.time() + + for eid in ids: + try: + row = _load_row(kind, eid) + if kind == 'klub': res = _propose_for_klub(row) + elif kind == 'savez': res = _propose_for_savez(row) + else: res = _propose_for_sportas(row) + proposed = res.get('proposed') or {} + srcs = res.get('sources') or [] + if not proposed: + items.append({'id': eid, 'applied': 0, 'fields': []}) + continue + out = _apply_to_db(kind, eid, proposed, srcs, x_user_email) + applied = out.get('applied') or {} + fields_total += len(applied) + items.append({'id': eid, 'applied': len(applied), 'fields': list(applied.keys())}) + try: + from audit_seal_router import audit_log as _audit_log + if applied: + _audit_log(action='enrich.bulk.apply', + target_type=kind, target_id=eid, + payload={'applied': applied}, + user_id=x_user_id, user_email=x_user_email) + except Exception: + pass + except HTTPException as e: + items.append({'id': eid, 'error': e.detail}) + except Exception as e: + items.append({'id': eid, 'error': f'{type(e).__name__}: {e}'}) + + return { + 'status': 'success', + 'kind': kind, + 'requested': limit, + 'processed': len(items), + 'fields_total': fields_total, + 'elapsed_s': round(time.time() - started, 1), + 'items': items, + } + + +# ─── SB-4 — Worker status / control ───────────────────────────────────── +_REDIS_KEYS = { + 'heartbeat': 'cc:pgz-enricher:heartbeat', + 'pause': 'cc:pgz-enricher:pause', + 'run_now': 'cc:pgz-enricher:run_now', + 'last_cycle': 'cc:pgz-enricher:last_cycle', + 'confidence': 'cc:pgz-enricher:confidence', + 'fields_24h': 'cc:pgz-enricher:fields_24h', +} + + +def _redis_client(): + try: + import redis + except Exception: + return None + host = os.environ.get('REDIS_HOST', 'localhost') + port = int(os.environ.get('REDIS_PORT', '6379')) + pwd = (os.environ.get('REDIS_PASS') or '').strip().strip("'").strip('"') or None + # Try with password first (prod); fall back to anonymous (dev box) on AUTH failure. + for p in (pwd, None): + try: + r = redis.Redis(host=host, port=port, password=p, + decode_responses=True, socket_connect_timeout=2) + r.ping() + return r + except Exception: + continue + return None + + +@router.get("/enrich/worker/status") +def enrich_worker_status(): + r = _redis_client() + out = {'available': bool(r)} + if not r: + return out + try: + hb = r.get(_REDIS_KEYS['heartbeat']) + out['heartbeat'] = int(hb) if hb else None + out['heartbeat_age_s'] = (int(time.time()) - int(hb)) if hb else None + out['paused'] = (r.get(_REDIS_KEYS['pause']) or '0') == '1' + out['run_now_pending'] = (r.get(_REDIS_KEYS['run_now']) or '0') == '1' + last = r.get(_REDIS_KEYS['last_cycle']) + if last: + try: out['last_cycle'] = json.loads(last) + except: out['last_cycle'] = last + conf = r.get(_REDIS_KEYS['confidence']) + out['confidence_threshold'] = float(conf) if conf else 0.7 + f24 = r.get(_REDIS_KEYS['fields_24h']) + out['fields_24h'] = int(f24) if f24 and f24.isdigit() else 0 + except Exception as e: + out['error'] = f'{type(e).__name__}: {e}' + # Recent enrichment_log rows for live activity + try: + with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute("""SELECT id, kind, target_id, source, fields_set, user_email, created_at + FROM pgz_sport.enrichment_log + ORDER BY id DESC LIMIT 25""") + rows = [] + for rr in cur.fetchall(): + rr = dict(rr) + if rr.get('created_at'): rr['created_at'] = rr['created_at'].isoformat() + rows.append(rr) + out['recent'] = rows + except Exception: + out['recent'] = [] + return out + + +@router.post("/enrich/worker/pause") +def enrich_worker_pause(body: dict = Body(default=None)): + body = body or {} + pause = bool(body.get('paused', True)) + r = _redis_client() + if not r: raise HTTPException(503, 'redis unavailable') + r.set(_REDIS_KEYS['pause'], '1' if pause else '0') + return {'status': 'success', 'paused': pause} + + +@router.post("/enrich/worker/run-now") +def enrich_worker_run_now(): + r = _redis_client() + if not r: raise HTTPException(503, 'redis unavailable') + r.set(_REDIS_KEYS['run_now'], '1') + return {'status': 'success', 'queued': True} + + +@router.post("/enrich/worker/confidence") +def enrich_worker_confidence(body: dict = Body(...)): + try: + v = float(body.get('value')) + except Exception: + raise HTTPException(400, 'value must be number 0..1') + if not (0.0 <= v <= 1.0): + raise HTTPException(400, 'value out of range 0..1') + r = _redis_client() + if not r: raise HTTPException(503, 'redis unavailable') + r.set(_REDIS_KEYS['confidence'], str(v)) + return {'status': 'success', 'confidence_threshold': v} diff --git a/scripts/coverage_report.py b/scripts/coverage_report.py new file mode 100755 index 0000000..5c6ebb5 --- /dev/null +++ b/scripts/coverage_report.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +""" +coverage_report.py — Per-entity coverage scoring across pgz_sport schema + +Fills /opt/pgz-sport/data_quality_report.md with: + - per-type aggregate (n, mean coverage, median, # zero-coverage, # complete) + - distribution histogram + - top 50 entities most needing manual review (lowest coverage AND non-empty name) + - link to detail panel for each (so audit.html-style triage is one click away) +""" +import os, json +from collections import Counter +from datetime import datetime, timezone +import psycopg2, psycopg2.extras + +PG = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', + user='rinet', password='R1net2026!SecureDB#v7') + +# Per-type coverage definition: list of fields that count toward coverage +DEFS = { + 'savez': { + 'table': 'pgz_sport.savezi', + 'name_col': 'naziv', + 'fields': ['naziv','sport','predsjednik','tajnik','email','telefon','web','oib','adresa','godina_osnutka'], + 'panel_path': lambda i: f'/?nav=savezi&open={i}', + }, + 'klub': { + 'table': 'pgz_sport.klubovi', + 'name_col': 'naziv', + # Use COALESCE-ish: web OR web_stranica counts; sjediste OR adresa counts + 'fields': ['naziv','sport','grad','oib','predsjednik','tajnik','email','telefon', + 'web_or_stranica','sjediste_or_adresa','ciljevi','opis_djelatnosti'], + 'panel_path': lambda i: f'/?nav=klubovi&open={i}', + }, + 'sportas': { + 'table': 'pgz_sport.clanovi', + 'name_col': "ime||' '||prezime", + 'fields': ['ime','prezime','sport','klub_id','datum_rodenja','slika_url','oib','profile_url','biografija','hns_igrac_id'], + 'panel_path': lambda i: f'/?nav=sportasi&open={i}', + }, + 'objekt': { + 'table': 'pgz_sport.sportski_objekti', + 'name_col': 'naziv', + 'fields': ['naziv','tip','grad','adresa','lat','lng','upravitelj','kapacitet','sportovi','izgradeno'], + 'panel_path': lambda i: f'/?nav=objekti&open={i}', + }, + 'manifestacija': { + 'table': 'pgz_sport.manifestacije', + 'name_col': 'naziv', + 'fields': ['naziv','mjesto','organizator','razina','broj_ucesnika','godina_od','source_url'], + 'panel_path': lambda i: f'/?nav=manifestacije&open={i}', + }, +} + +def fetch_rows(cur, kind: str): + spec = DEFS[kind] + table = spec['table'] + if kind == 'klub': + sql = f""" + SELECT id, naziv, + (CASE WHEN naziv IS NOT NULL AND naziv<>'' THEN 1 ELSE 0 END + + CASE WHEN sport IS NOT NULL AND sport<>'' THEN 1 ELSE 0 END + + CASE WHEN grad IS NOT NULL AND grad<>'' THEN 1 ELSE 0 END + + CASE WHEN oib IS NOT NULL AND oib<>'' THEN 1 ELSE 0 END + + CASE WHEN predsjednik IS NOT NULL AND predsjednik<>'' THEN 1 ELSE 0 END + + CASE WHEN tajnik IS NOT NULL AND tajnik<>'' THEN 1 ELSE 0 END + + CASE WHEN email IS NOT NULL AND email<>'' THEN 1 ELSE 0 END + + CASE WHEN telefon IS NOT NULL AND telefon<>'' THEN 1 ELSE 0 END + + CASE WHEN COALESCE(web, web_stranica) IS NOT NULL AND COALESCE(web, web_stranica)<>'' THEN 1 ELSE 0 END + + CASE WHEN COALESCE(sjediste, adresa) IS NOT NULL AND COALESCE(sjediste, adresa)<>'' THEN 1 ELSE 0 END + + CASE WHEN ciljevi IS NOT NULL AND ciljevi<>'' THEN 1 ELSE 0 END + + CASE WHEN opis_djelatnosti IS NOT NULL AND opis_djelatnosti<>'' THEN 1 ELSE 0 END + ) AS filled + FROM {table} + """ + elif kind == 'sportas': + sql = f""" + SELECT id, (COALESCE(ime,'')||' '||COALESCE(prezime,'')) AS naziv, + (CASE WHEN ime IS NOT NULL AND ime<>'' THEN 1 ELSE 0 END + + CASE WHEN prezime IS NOT NULL AND prezime<>'' THEN 1 ELSE 0 END + + CASE WHEN sport IS NOT NULL AND sport<>'' THEN 1 ELSE 0 END + + CASE WHEN klub_id IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN datum_rodenja IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN slika_url IS NOT NULL AND slika_url<>'' THEN 1 ELSE 0 END + + CASE WHEN oib IS NOT NULL AND oib<>'' THEN 1 ELSE 0 END + + CASE WHEN profile_url IS NOT NULL AND profile_url<>'' THEN 1 ELSE 0 END + + CASE WHEN biografija IS NOT NULL AND biografija<>'' THEN 1 ELSE 0 END + + CASE WHEN hns_igrac_id IS NOT NULL AND hns_igrac_id<>'' THEN 1 ELSE 0 END + ) AS filled + FROM {table} + """ + elif kind == 'objekt': + sql = f""" + SELECT id, naziv, + (CASE WHEN naziv IS NOT NULL AND naziv<>'' THEN 1 ELSE 0 END + + CASE WHEN tip IS NOT NULL AND tip<>'' THEN 1 ELSE 0 END + + CASE WHEN grad IS NOT NULL AND grad<>'' THEN 1 ELSE 0 END + + CASE WHEN adresa IS NOT NULL AND adresa<>'' THEN 1 ELSE 0 END + + CASE WHEN lat IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN lng IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN upravitelj IS NOT NULL AND upravitelj<>'' THEN 1 ELSE 0 END + + CASE WHEN kapacitet IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN sportovi IS NOT NULL AND array_length(sportovi,1)>0 THEN 1 ELSE 0 END + + CASE WHEN izgradeno IS NOT NULL THEN 1 ELSE 0 END + ) AS filled + FROM {table} + """ + elif kind == 'manifestacija': + sql = f""" + SELECT id, naziv, + (CASE WHEN naziv IS NOT NULL AND naziv<>'' THEN 1 ELSE 0 END + + CASE WHEN mjesto IS NOT NULL AND mjesto<>'' THEN 1 ELSE 0 END + + CASE WHEN organizator IS NOT NULL AND organizator<>'' THEN 1 ELSE 0 END + + CASE WHEN razina IS NOT NULL AND razina<>'' THEN 1 ELSE 0 END + + CASE WHEN broj_ucesnika IS NOT NULL AND broj_ucesnika::text<>'' THEN 1 ELSE 0 END + + CASE WHEN godina_od IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN source_url IS NOT NULL AND source_url<>'' THEN 1 ELSE 0 END + ) AS filled + FROM {table} + """ + else: # savez + sql = f""" + SELECT id, naziv, + (CASE WHEN naziv IS NOT NULL AND naziv<>'' THEN 1 ELSE 0 END + + CASE WHEN sport IS NOT NULL AND sport<>'' THEN 1 ELSE 0 END + + CASE WHEN predsjednik IS NOT NULL AND predsjednik<>'' THEN 1 ELSE 0 END + + CASE WHEN tajnik IS NOT NULL AND tajnik<>'' THEN 1 ELSE 0 END + + CASE WHEN email IS NOT NULL AND email<>'' THEN 1 ELSE 0 END + + CASE WHEN telefon IS NOT NULL AND telefon<>'' THEN 1 ELSE 0 END + + CASE WHEN web IS NOT NULL AND web<>'' THEN 1 ELSE 0 END + + CASE WHEN oib IS NOT NULL AND oib<>'' THEN 1 ELSE 0 END + + CASE WHEN adresa IS NOT NULL AND adresa<>'' THEN 1 ELSE 0 END + + CASE WHEN godina_osnutka IS NOT NULL THEN 1 ELSE 0 END + ) AS filled + FROM {table} + """ + cur.execute(sql) + rows = [] + for r in cur.fetchall(): + rows.append({'kind': kind, 'id': r['id'], 'naziv': r['naziv'] or '', + 'filled': int(r['filled']), + 'total': len(spec['fields'])}) + return rows + + +def stats(rows): + if not rows: return {} + pcts = [r['filled']/r['total']*100 for r in rows] + pcts.sort() + n = len(pcts) + mean = sum(pcts)/n + median = pcts[n//2] + zero = sum(1 for p in pcts if p == 0) + complete = sum(1 for p in pcts if p >= 99.0) + bins = Counter() + for p in pcts: + b = int(p // 10) * 10 + if b == 100: b = 90 + bins[b] += 1 + return {'n': n, 'mean': round(mean,1), 'median': round(median,1), + 'zero': zero, 'complete': complete, + 'distribution': dict(sorted(bins.items()))} + + +def main(): + conn = psycopg2.connect(**PG) + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + all_rows = [] + by_kind = {} + for kind in DEFS: + rows = fetch_rows(cur, kind) + by_kind[kind] = rows + all_rows.extend(rows) + print(f'{kind:14s} n={len(rows):5d} mean={stats(rows)["mean"]:.1f}% complete={stats(rows)["complete"]}') + + # Top 50 worst — exclude rows with empty naziv (those are flagged separately) + valid = [r for r in all_rows if (r['naziv'] or '').strip()] + # Sort by coverage ASC, then by total DESC + worst = sorted(valid, key=lambda r: (r['filled']/r['total'], -r['total']))[:50] + out = { + 'generated_at': datetime.now(timezone.utc).isoformat(), + 'totals': {k: len(v) for k,v in by_kind.items()}, + 'total_entities': len(all_rows), + 'per_type_stats': {k: stats(v) for k,v in by_kind.items()}, + 'top50_review': worst, + } + print(f'\nTotal entities: {len(all_rows)}') + print(f'Top 50 worst — sample:') + for r in worst[:5]: + pct = r['filled']/r['total']*100 + print(f" {r['kind']:14s} id={r['id']:7d} {r['naziv'][:50]:50s} {r['filled']}/{r['total']} ({pct:.0f}%)") + json.dump(out, open('/tmp/coverage_data.json','w'), ensure_ascii=False, default=str) + cur.close(); conn.close() + + +if __name__ == '__main__': + main() diff --git a/static/admin_users.html b/static/admin_users.html index ef286e8..944284a 100644 --- a/static/admin_users.html +++ b/static/admin_users.html @@ -164,7 +164,7 @@ td.actions-col .btn { padding: 4px 8px; font-size: 11px; } - ERP / CRM / OCR + ERP / CRM / OCR Javni portal
diff --git a/static/app.html.bak2.1777937982 b/static/app.html.bak2.1777937982 new file mode 100644 index 0000000..fd20395 --- /dev/null +++ b/static/app.html.bak2.1777937982 @@ -0,0 +1,1854 @@ + + + + + +PGŽ SPORT — Operativna aplikacija + + + + + + + + + +
+ + +
+
+
+
Dashboard
+
Pregled stanja
+
+
+
+
+
DR
+
+
Damir Radulićpgz admin
+
Primorsko-goranska županija
+
+
+
+
+ +
+
Učitavanje...
+
+
+
+ + +
+ + + + + + + diff --git a/static/sport2.html b/static/sport2.html index b7f0204..2216e7c 100644 --- a/static/sport2.html +++ b/static/sport2.html @@ -408,10 +408,11 @@ async function enrichEntity(kind, id){ PoljeTrenutnoPredloženo ${rows} -
- - - +
+ + + +
`; } else { @@ -454,11 +455,39 @@ function enrichSelectAll(kind, id, on){ tbody.querySelectorAll('input[type=checkbox]').forEach(cb => { cb.checked = !!on; }); } +// Reusable toast component (success / error / info / warn). +window.toast = function(msg, type, duration){ + type = type || 'success'; + duration = duration || 3000; + const palette = { + success: ['#1ec773', '#0b1a16'], + error: ['#ff6b6b', '#1a0b0b'], + info: ['#4a9eff', '#04132b'], + warn: ['#ffb84a', '#1a1004'], + }[type] || ['#4a9eff', '#04132b']; + const t = document.createElement('div'); + t.className = 'pgz-toast pgz-toast-' + type; + t.style.cssText = 'position:fixed;right:20px;bottom:20px;'+ + 'background:'+palette[0]+';color:'+palette[1]+';'+ + 'padding:12px 18px;border-radius:8px;font-weight:700;font-size:14px;'+ + 'z-index:99999;box-shadow:0 6px 22px rgba(0,0,0,.45);'+ + 'transform:translateY(40px);opacity:0;transition:all .25s ease-out;'+ + 'max-width:380px;line-height:1.45;'; + t.innerHTML = msg; + document.body.appendChild(t); + requestAnimationFrame(()=>{ t.style.transform='translateY(0)'; t.style.opacity='1'; }); + setTimeout(()=>{ + t.style.transform='translateY(40px)'; t.style.opacity='0'; + setTimeout(()=>t.remove(), 280); + }, duration); + return t; +}; + async function enrichApply(kind, id){ const target = document.getElementById('enrich-out-'+kind+'-'+id); const tbody = document.getElementById('enrich-diff-'+kind+'-'+id); const preview = (window._enrichPreviews||{})[kind+':'+id]; - if(!preview){ alert('Prvo pokreni "▶ Pokreni"'); return; } + if(!preview){ toast('⚠ Prvo pokreni "▶ Pokreni"', 'warn'); return; } const proposed = preview.proposed || {}; const fields = {}; if(tbody){ @@ -469,7 +498,7 @@ async function enrichApply(kind, id){ } else { Object.assign(fields, proposed); } - if(!Object.keys(fields).length){ alert('Označi barem jedno polje za primjenu.'); return; } + if(!Object.keys(fields).length){ toast('Označi barem jedno polje za primjenu.', 'warn'); return; } if(target) target.innerHTML = '
⏳ Spremam u bazu…
'; try{ const r = await fetch(API+'/v2/enrich/'+kind+'/'+id+'/apply', { @@ -484,18 +513,46 @@ async function enrichApply(kind, id){ else if(kind === 'savez' && typeof openSavez === 'function') await openSavez(id); else if(kind === 'sportas' && typeof openSportas === 'function') await openSportas(id); setTimeout(() => enrichEntity(kind, id), 350); - const cnt = Object.keys(data.applied||{}).length; - const t = document.createElement('div'); - t.style.cssText = 'position:fixed;bottom:20px;right:20px;background:var(--ok,#1ec773);color:#0b1a16;padding:10px 16px;border-radius:6px;font-weight:700;z-index:9999;box-shadow:0 4px 16px rgba(0,0,0,.4)'; - t.textContent = '✓ Spremljeno '+cnt+' polja u bazu'; - document.body.appendChild(t); - setTimeout(()=>t.remove(), 3500); + const cnt = data.applied_count != null ? data.applied_count : Object.keys(data.applied||{}).length; + const fieldsList = (data.applied_fields || Object.keys(data.applied||{})).join(', '); + if(cnt){ + toast('✅ Spremljeno '+cnt+' polja u bazu' + + (fieldsList ? '
'+esc(fieldsList)+'' : ''), + 'success', 3500); + } else { + toast('Nema novih izmjena za spremiti.', 'info', 2500); + } }catch(e){ console.error(e); + toast('❌ Greška pri spremanju: '+esc(e.message||String(e)), 'error', 4500); if(target) target.innerHTML = '
Greška pri spremanju: '+esc(e.message||String(e))+'
'; } } +// Bulk enrichment — used by "Obogati sve" buttons in list views +async function enrichBulk(kind, limit, coverage_max){ + limit = limit || 50; coverage_max = coverage_max || 70; + if(!confirm('Pokreni obogaćivanje za '+limit+' nasumično odabranih ('+kind+', coverage<'+coverage_max+'%)?')) return; + toast('⏳ Pokrećem bulk obogaćivanje za '+limit+' '+kind+'…', 'info', 2500); + try{ + const r = await fetch(API+'/v2/enrich/bulk', { + method:'POST', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify({kind, limit, coverage_max}), + }); + const data = await r.json(); + if(!r.ok) throw new Error(data.detail || ('HTTP '+r.status)); + toast('✅ Bulk gotov: '+data.processed+'/'+data.requested+' obrađeno, '+ + 'dodano '+data.fields_total+' polja u DB ('+data.elapsed_s+'s)', + 'success', 5000); + // Reload the section so new values appear + if(typeof loadSection === 'function' && _state && _state.section) loadSection(_state.section); + }catch(e){ + console.error(e); + toast('❌ Bulk greška: '+esc(e.message||String(e)), 'error', 5000); + } +} + function enrichBlock(kind, id){ return `
diff --git a/static/uploads/avatars/99-3a8466b0.png b/static/uploads/avatars/99-3a8466b0.png new file mode 100644 index 0000000..6bb84ce Binary files /dev/null and b/static/uploads/avatars/99-3a8466b0.png differ diff --git a/static/uploads/avatars/99-68860ddb.png b/static/uploads/avatars/99-68860ddb.png deleted file mode 100644 index f88bc3b..0000000 Binary files a/static/uploads/avatars/99-68860ddb.png and /dev/null differ diff --git a/workers/enrichment_worker.py b/workers/enrichment_worker.py index ce005bf..42af399 100644 --- a/workers/enrichment_worker.py +++ b/workers/enrichment_worker.py @@ -69,13 +69,78 @@ def _log(msg: str) -> None: pass -def _heartbeat() -> None: +def _redis(): try: import redis - r = redis.Redis(host=os.environ.get('REDIS_HOST', 'localhost'), - port=int(os.environ.get('REDIS_PORT', '6379')), - password=os.environ.get('REDIS_PASS', None)) + except Exception: + return None + host = os.environ.get('REDIS_HOST', 'localhost') + port = int(os.environ.get('REDIS_PORT', '6379')) + pwd = (os.environ.get('REDIS_PASS') or '').strip().strip("'").strip('"') or None + for p in (pwd, None): + try: + r = redis.Redis(host=host, port=port, password=p, + decode_responses=True, socket_connect_timeout=2) + r.ping() + return r + except Exception: + continue + return None + + +def _heartbeat(meta: dict | None = None) -> None: + r = _redis() + if not r: return + try: r.set('cc:pgz-enricher:heartbeat', str(int(time.time()))) + if meta is not None: + r.set('cc:pgz-enricher:last_cycle', json.dumps(meta, default=str)) + except Exception: + pass + + +def _is_paused() -> bool: + r = _redis() + if not r: return False + try: + return (r.get('cc:pgz-enricher:pause') or '0') == '1' + except Exception: + return False + + +def _consume_run_now() -> bool: + r = _redis() + if not r: return False + try: + v = r.get('cc:pgz-enricher:run_now') + if v == '1': + r.set('cc:pgz-enricher:run_now', '0') + return True + except Exception: + return False + return False + + +def _refresh_confidence() -> None: + """Read live confidence override from redis (set by /worker/confidence).""" + global CONFIDENCE_MIN + r = _redis() + if not r: return + try: + v = r.get('cc:pgz-enricher:confidence') + if v: + CONFIDENCE_MIN = float(v) + except Exception: + pass + + +def _bump_fields_24h(n: int) -> None: + if n <= 0: return + r = _redis() + if not r: return + try: + r.incrby('cc:pgz-enricher:fields_24h', n) + r.expire('cc:pgz-enricher:fields_24h', 86400) except Exception: pass @@ -264,8 +329,10 @@ def _process(kind: str, eid: int) -> tuple[int, list[str]]: def _cycle() -> dict: + _refresh_confidence() started = time.time() - out = {'sportas': 0, 'klub': 0, 'savez': 0, 'fields_total': 0} + out = {'sportas': 0, 'klub': 0, 'savez': 0, 'fields_total': 0, + 'started_at': datetime.now(timezone.utc).isoformat()} fields_total = 0 for kind, picker, limit in ( ('sportas', _pick_sportas, 50), @@ -278,26 +345,45 @@ def _cycle() -> dict: for eid in ids: if DRY: continue + if _is_paused(): + _log("paused → break out of cycle") + break n, fields = _process(kind, eid) out[kind] += 1 fields_total += n + if n: _bump_fields_24h(n) time.sleep(1.5) # gentle pacing _heartbeat() out['fields_total'] = fields_total out['elapsed_s'] = round(time.time() - started, 1) + out['ended_at'] = datetime.now(timezone.utc).isoformat() return out def main() -> int: _log(f"enrichment_worker starting | API_BASE={API_BASE} | sleep={SLEEP_S}s | dry={DRY}") while True: + if _is_paused(): + _log("paused (cc:pgz-enricher:pause=1) — sleeping 30s") + _heartbeat({'paused': True}) + time.sleep(30) + continue try: stats = _cycle() _log(f"cycle done: {json.dumps(stats)}") + _heartbeat(stats) except Exception as e: _log(f"cycle FAILED: {type(e).__name__}: {e}") - _heartbeat() - time.sleep(SLEEP_S) + _heartbeat({'error': str(e)[:200]}) + # Sleep in 5-second slices so /worker/run-now and /pause respond fast. + elapsed = 0 + while elapsed < SLEEP_S: + if _consume_run_now(): + _log("run-now signal received → starting next cycle early") + break + if _is_paused(): + break + time.sleep(5); elapsed += 5 if __name__ == '__main__':