feat: /api/v2/analiza/* endpoints - sport analytics backend

This commit is contained in:
Damir Radulic
2026-05-16 00:28:12 +02:00
parent 7ca5d7d94e
commit aca5051418
1355 changed files with 321891 additions and 4128 deletions
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════
# Fajl: routers/_tenant.py | v1.0.0 | 2026-05-09
# Autor: damir@rinet.one (klub-scope wiring for /api/crm endpoints)
# Svrha: tenant-aware klub_id scope helper used by clanarine + lijecnicki
# routers (and any future per-klub list endpoint).
#
# Logika scope-a:
# • super_admin / pgz_* → puni pristup; query ?klub_id=X poštuje se
# • savez_* → trenutno isto kao pgz_* (TODO: stvarni
# savez→klub join kad bude potreban)
# • klub_* → forsiraj user.klub_id + sve iz user_klub_links;
# ako se traži drugi klub → 403
# • neautenticirani → backward-compat: poštuj traženi klub_id
# ═══════════════════════════════════════════════════════════════════
from __future__ import annotations
import sys
from typing import Optional, List, Dict, Any, Tuple
from fastapi import HTTPException
sys.path.insert(0, "/opt/pgz-sport")
from auth.auth_v2 import (
db_query, KLUB_USER_TYPES, SAVEZ_USER_TYPES, PGZ_USER_TYPES,
)
def _user_klub_ids(user_id: int) -> List[int]:
"""Return all klub_ids a user is linked to via pgz_sport.user_klub_links."""
try:
rows = db_query(
"SELECT klub_id FROM pgz_sport.user_klub_links WHERE user_id=%s",
(user_id,),
)
except Exception:
# Table missing or not yet created — fail open to user.klub_id only.
return []
return [int(r["klub_id"]) for r in rows if r.get("klub_id") is not None]
def resolve_klub_scope(user: Optional[Dict[str, Any]],
requested_klub_id: Optional[int]) -> Dict[str, Any]:
"""Resolve effective klub-scope for an authenticated (or anonymous) user.
Returns one of:
{"mode": "all"} — no SQL filter applied
{"mode": "single", "klub_id": <int>} — bind one klub_id
{"mode": "many", "klub_ids": [<int>]} — IN(...) filter
{"mode": "deny"} — caller should raise 403
"""
rid = int(requested_klub_id) if requested_klub_id else None
# Backward-compat: no JWT token → behave like before (respect ?klub_id).
if user is None:
return {"mode": "single", "klub_id": rid} if rid else {"mode": "all"}
ut = (user.get("user_type") or "").lower()
if ut in PGZ_USER_TYPES or ut == "super_admin":
return {"mode": "single", "klub_id": rid} if rid else {"mode": "all"}
if ut in SAVEZ_USER_TYPES:
# TODO: enforce savez→klub membership when needed.
return {"mode": "single", "klub_id": rid} if rid else {"mode": "all"}
if ut in KLUB_USER_TYPES:
allowed = set()
if user.get("klub_id"):
allowed.add(int(user["klub_id"]))
for kid in _user_klub_ids(int(user["id"])):
allowed.add(kid)
if not allowed:
return {"mode": "deny"}
if rid is not None:
if rid not in allowed:
return {"mode": "deny"}
return {"mode": "single", "klub_id": rid}
if len(allowed) == 1:
return {"mode": "single", "klub_id": next(iter(allowed))}
return {"mode": "many", "klub_ids": sorted(allowed)}
# Unknown / viewer role — restrictive: only the requested klub, never "all".
if rid is not None:
return {"mode": "single", "klub_id": rid}
return {"mode": "deny"}
def apply_klub_scope_sql(scope: Dict[str, Any],
column: str = "c.klub_id") -> Tuple[str, List[Any]]:
"""Translate a scope dict into ``(sql_fragment, args)``.
``sql_fragment`` is empty when no filter is needed; otherwise it is a
single ``column = %s`` or ``column IN (%s, %s, ...)`` predicate ready to
be joined into the existing WHERE chain.
Raises ``HTTPException(403)`` for the ``deny`` mode.
"""
mode = scope.get("mode")
if mode == "deny":
raise HTTPException(
403, "Nemate ovlasti za pristup podacima izvan vašeg kluba.")
if mode == "all":
return "", []
if mode == "single":
return f"{column} = %s", [int(scope["klub_id"])]
if mode == "many":
ids = list(scope["klub_ids"])
placeholders = ",".join(["%s"] * len(ids))
return f"{column} IN ({placeholders})", [int(x) for x in ids]
return "", []
+19
View File
@@ -0,0 +1,19 @@
"""AI Copilot stub — vraca info poruku dok modeli treniraju."""
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter()
class AiAskBody(BaseModel):
question: str = ""
@router.post("/ai/ask")
@router.get("/ai/ask")
async def ai_ask_stub(question: str = "", body: AiAskBody = None):
q = question or (body.question if body else "")
return {
"answer": "🧠 AI asistent je privremeno nedostupan — modeli su u procesu učenja (finetune). Pokušajte ponovo za ~1 sat.",
"question": q,
"sources": [],
"mode": "training"
}
+5 -1
View File
@@ -1,9 +1,13 @@
from dotenv import load_dotenv
load_dotenv('/opt/rinet-gpu/.env.master')
# auto-added by patch_scrapers_with_dotenv.sh
"""Audit coverage matrix endpoint - colored heat-map data per klub."""
import os
from fastapi import APIRouter
import psycopg2
DB = dict(host='localhost', port=5432, dbname='rinet_v3',
user='rinet', password='R1net2026!SecureDB#v7')
user='rinet', password=os.environ["DB_PASSWORD"])
router = APIRouter()
@@ -0,0 +1,60 @@
"""Audit coverage matrix endpoint - colored heat-map data per klub."""
import os
from fastapi import APIRouter
import psycopg2
DB = dict(host='localhost', port=5432, dbname='rinet_v3',
user='rinet', password=os.environ["DB_PASSWORD"])
router = APIRouter()
@router.get("/audit/coverage-matrix")
def coverage_matrix(min_size: int = 5, sport: str = None):
"""Vrati matricu pokrivenosti po klubu × tipu podatka."""
conn = psycopg2.connect(**DB); conn.autocommit = True
cu = conn.cursor()
where = "WHERE k.aktivan AND k.region IN ('PGŽ', 'Rijeka', 'Primorje', 'Otoci')"
if sport:
where += f" AND k.sport ILIKE '%{sport}%'"
cu.execute(f"""
SELECT
k.id, k.naziv, k.sport, k.grad,
(SELECT count(*) FROM pgz_sport.clanovi c WHERE c.klub_id=k.id) AS sportasa,
(SELECT count(*) FROM pgz_sport.clanovi c WHERE c.klub_id=k.id AND c.verified) AS verified,
(SELECT count(*) FROM pgz_sport.utakmice_log u WHERE u.za_klub_id=k.id) AS utakmica,
(SELECT count(*) FROM pgz_sport.clan_sezona cs JOIN pgz_sport.clanovi c2 ON c2.id=cs.clan_id WHERE c2.klub_id=k.id) AS sezona,
(SELECT count(*) FROM pgz_sport.klub_sezona ks WHERE ks.klub_id=k.id) AS trofeja,
(SELECT count(*) FROM pgz_sport.clan_nagrada cn JOIN pgz_sport.clanovi c3 ON c3.id=cn.clan_id WHERE c3.klub_id=k.id) AS nagrada,
CASE WHEN k.godisnjak_godine IS NOT NULL THEN array_length(k.godisnjak_godine, 1) ELSE 0 END AS god_hits
FROM pgz_sport.klubovi k
{where}
ORDER BY (SELECT count(*) FROM pgz_sport.clanovi c WHERE c.klub_id=k.id) DESC
LIMIT 200
""")
rows = []
for r in cu.fetchall():
sportasa = r[4] or 0
if sportasa < min_size: continue
verified = r[5] or 0
rows.append({
"klub_id": r[0], "naziv": r[1], "sport": r[2], "grad": r[3],
"sportasa": sportasa, "verified": verified,
"verified_pct": round(verified*100.0/sportasa) if sportasa else 0,
"utakmica": r[6] or 0,
"sezona": r[7] or 0,
"trofeja": r[8] or 0,
"nagrada": r[9] or 0,
"god_hits": r[10] or 0,
"score": min(100, (
(verified*100/sportasa if sportasa else 0)*0.4 +
(min(r[6] or 0, 50) * 2)*0.2 +
(min(r[7] or 0, 30) * 3.3)*0.15 +
(min(r[8] or 0, 20) * 5)*0.15 +
(min(r[10] or 0, 10) * 10)*0.10
))
})
conn.close()
return {"klubovi": rows, "count": len(rows), "filter_min_size": min_size, "filter_sport": sport}
+4 -1
View File
@@ -1,3 +1,6 @@
from dotenv import load_dotenv
load_dotenv('/opt/rinet-gpu/.env.master')
# auto-added by patch_scrapers_with_dotenv.sh
"""
audit_seal_router.py — HTTP surface for sys_audit log + Polygon PoS sealing
Author: Damir Radulić (damir@rinet.one) / dradulic@outlook.com
@@ -63,7 +66,7 @@ if _pgh in ('localhost', '127.0.0.1'):
_DB = dict(host=_pgh, port=_pgp,
dbname=os.environ.get('PG_DB', 'rinet_v3'),
user=os.environ.get('PG_USER', 'rinet'),
password=os.environ.get('PG_PASS', 'R1net2026!SecureDB#v7'))
password=os.environ["DB_PASSWORD"])
def _conn():
c = psycopg2.connect(**_DB); c.autocommit = True; return c
@@ -0,0 +1,260 @@
"""
audit_seal_router.py — HTTP surface for sys_audit log + Polygon PoS sealing
Author: Damir Radulić (damir@rinet.one) / dradulic@outlook.com
Date: 2026-05-04 (R3) / 2026-05-05 (R4 — log+stats+helper)
Endpoints (all under /sport/api):
GET /audit/log?limit=200&action=&resource=&q=&user=
Tail of pgz_sport.sys_audit with optional filters. Returns:
{ count, items: [{id, user_id, user_email, action, resource_type, resource_id,
details, tx_hash, created_at}], total }
Aliases: target_type/resource_type → resource for legacy frontend.
GET /audit/stats
{ total, today, sealed, users }
POST /audit/seal
body: {
action: "sufinanciranje.approved",
ref_type: "sufinanciranje", (optional)
ref_id: "2026-001", (string or number)
payload: { ... }, (sha256 computed server-side)
data_hash: "abc..." (optional — if you already have the hash)
}
returns the seal record (seal_id, tx_hash, polygonscan_url, status, ...).
GET /audit/seal/list?action=&ref_type=&ref_id=&limit=
Recent seals for the audit-log UI.
GET /audit/seal/{seal_id}
Single seal with on-chain receipt cross-check (if web3 wired up).
The legacy hash-chain audit endpoints (/api/admin/audit-chain*) live in
pgz_sport_api.py and remain unchanged.
----------------------------------------------------------------------
audit_log() — shared helper for other routers (cc2/4/5/6)
from audit_seal_router import audit_log
audit_log(action='users.update', target_type='users', target_id=7,
payload={'changed':['email']}, user_id=u['id'], user_email=u['email'])
Fail-soft: never raises, only writes to stderr on error.
"""
from __future__ import annotations
import sys, os, json, traceback
from datetime import date, datetime
from typing import Any, Optional
import psycopg2, psycopg2.extras
from fastapi import APIRouter, Body, HTTPException, Header, Query
# blockchain.seal lives at /opt/pgz-sport/blockchain/seal.py
sys.path.insert(0, '/opt/pgz-sport')
from blockchain import seal as seal_mod # noqa: E402
router = APIRouter()
# ── DB helper (own connection, mirrors enrich_router DSN logic) ──────────
_pgh = os.environ.get('PG_HOST', '10.10.0.2')
_pgp = int(os.environ.get('PG_PORT', '6432'))
if _pgh in ('localhost', '127.0.0.1'):
_pgh = os.environ.get('DB_HOST', '10.10.0.2')
_pgp = int(os.environ.get('DB_PORT', '6432'))
_DB = dict(host=_pgh, port=_pgp,
dbname=os.environ.get('PG_DB', 'rinet_v3'),
user=os.environ.get('PG_USER', 'rinet'),
password=os.environ["DB_PASSWORD"])
def _conn():
c = psycopg2.connect(**_DB); c.autocommit = True; return c
def audit_log(action: str,
target_type: Optional[str] = None,
target_id: Optional[int] = None,
target_text: Optional[str] = None,
payload: Optional[dict] = None,
user_id: Optional[int] = None,
user_email: Optional[str] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None) -> Optional[int]:
"""Insert one row into pgz_sport.sys_audit. Fail-soft.
Other routers should call this after any successful DB mutation.
The DB trigger trg_audit_chain populates row_hash + chain_idx automatically.
Returns the new row id, or None on failure.
"""
if not action:
return None
try:
with _conn() as c, c.cursor() as cur:
cur.execute("""
INSERT INTO pgz_sport.sys_audit
(user_id, user_email, action, target_type, target_id, target_text,
payload, ip_address, user_agent)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s)
RETURNING id
""", (
user_id,
user_email,
action[:100],
(target_type or None) and str(target_type)[:50],
target_id,
target_text,
json.dumps(payload, default=str, ensure_ascii=False) if payload is not None else None,
ip_address,
user_agent,
))
row = cur.fetchone()
return row[0] if row else None
except Exception:
# Never block business logic on audit failure — log and move on.
sys.stderr.write('[audit_log] insert failed for action='+str(action)+'\n')
sys.stderr.write(traceback.format_exc())
return None
def _row_to_item(r: dict) -> dict:
"""Normalise a sys_audit row for the audit.html UI."""
payload = r.get('payload') or {}
if isinstance(payload, str):
try: payload = json.loads(payload)
except: pass
out = {
'id': r.get('id'),
'user_id': r.get('user_id'),
'user_email': r.get('user_email'),
'action': r.get('action'),
'resource_type': r.get('target_type'),
'resource_id': r.get('target_id'),
'target_text': r.get('target_text'),
'details': payload,
'row_hash': r.get('row_hash'),
'chain_idx': r.get('chain_idx'),
'created_at': (r.get('created_at').isoformat() if isinstance(r.get('created_at'), datetime) else r.get('created_at')),
}
# Surface a polygon tx hash if it was stored in payload (seal_to_polygon does this)
if isinstance(payload, dict):
out['tx_hash'] = payload.get('tx_hash') or payload.get('polygon_tx') or payload.get('seal_tx_hash')
return out
# ── R4: audit log + stats endpoints ─────────────────────────────────────
@router.get("/audit/log")
def audit_log_list(limit: int = Query(200, ge=1, le=1000),
action: Optional[str] = None,
resource: Optional[str] = None,
user: Optional[str] = None,
q: Optional[str] = None,
since: Optional[str] = None):
"""Recent audit rows with simple filters. resource matches target_type."""
where = []
params: list = []
if action:
where.append("action ILIKE %s"); params.append('%'+action+'%')
if resource:
where.append("target_type ILIKE %s"); params.append('%'+resource+'%')
if user:
where.append("(user_email ILIKE %s OR CAST(user_id AS text)=%s)")
params.append('%'+user+'%'); params.append(user)
if q:
where.append("(action ILIKE %s OR target_type ILIKE %s OR target_text ILIKE %s OR user_email ILIKE %s OR payload::text ILIKE %s)")
params.extend(['%'+q+'%']*5)
if since:
where.append("created_at >= %s::timestamptz"); params.append(since)
sql = ("""
SELECT id, user_id, user_email, action, target_type, target_id, target_text,
payload, ip_address, user_agent, row_hash, chain_idx, created_at
FROM pgz_sport.sys_audit
""" + (" WHERE "+ ' AND '.join(where) if where else '') + """
ORDER BY id DESC LIMIT %s
""")
params.append(limit)
with _conn() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(sql, params)
rows = [_row_to_item(dict(r)) for r in cur.fetchall()]
cur.execute("SELECT count(*) AS n FROM pgz_sport.sys_audit")
total = cur.fetchone()['n']
return {'count': len(rows), 'total': total, 'items': rows}
@router.get("/audit/stats")
def audit_stats():
with _conn() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("""
SELECT
(SELECT count(*) FROM pgz_sport.sys_audit) AS total,
(SELECT count(*) FROM pgz_sport.sys_audit WHERE created_at::date = current_date) AS today,
(SELECT count(DISTINCT user_email) FROM pgz_sport.sys_audit
WHERE user_email IS NOT NULL AND created_at >= now()-interval '30 days') AS users,
(SELECT count(*) FROM pgz_sport.sys_audit
WHERE payload ? 'tx_hash' OR payload ? 'polygon_tx' OR payload ? 'seal_tx_hash')
AS sealed
""")
r = cur.fetchone() or {}
return {
'total': int(r.get('total') or 0),
'today': int(r.get('today') or 0),
'sealed': int(r.get('sealed') or 0),
'users': int(r.get('users') or 0),
}
@router.post("/audit/seal")
def audit_seal(body: dict = Body(...),
x_user_email: Optional[str] = Header(default=None),
x_user_id: Optional[int] = Header(default=None)):
"""Seal an audit event to Polygon PoS (or queue for later if no key)."""
if not isinstance(body, dict):
raise HTTPException(400, "JSON body required")
action = (body.get('action') or '').strip()
if not action:
raise HTTPException(400, "action is required")
payload = body.get('payload')
data_hash = (body.get('data_hash') or '').strip().lower().lstrip('0x')
if not data_hash:
if payload is None:
raise HTTPException(400, "either data_hash or payload required")
data_hash = seal_mod.hash_payload(payload)
ref_id = body.get('ref_id')
if ref_id is None:
raise HTTPException(400, "ref_id is required")
ref_type = body.get('ref_type')
try:
result = seal_mod.seal_to_polygon(
data_hash=data_hash,
ref_id=str(ref_id),
action=action,
ref_type=ref_type,
payload=payload,
user_id=x_user_id,
user_email=x_user_email,
)
except ValueError as e:
raise HTTPException(400, str(e))
return result
@router.get("/audit/seal/list")
def audit_seal_list(action: Optional[str] = None,
ref_type: Optional[str] = None,
ref_id: Optional[str] = None,
limit: int = 50):
rows = seal_mod.list_seals(action=action, ref_type=ref_type,
ref_id=ref_id, limit=limit)
return {'count': len(rows), 'rows': rows,
'wallet': seal_mod.POLYGON_WALLET,
'chain_id': seal_mod.POLYGON_CHAIN_ID,
'live': seal_mod.HAS_WEB3 and bool(seal_mod.POLYGON_PRIVKEY)}
@router.get("/audit/seal/{seal_id}")
def audit_seal_get(seal_id: str):
row = seal_mod.verify_seal(seal_id)
if not row:
raise HTTPException(404, "seal not found")
return row
+4 -1
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env python3
from dotenv import load_dotenv
load_dotenv('/opt/rinet-gpu/.env.master')
# auto-added by patch_scrapers_with_dotenv.sh
# ═══════════════════════════════════════════════════════════════════
# Fajl: routers/clan_panel_router.py | v1.0.0 | 05.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
@@ -33,7 +36,7 @@ from pydantic import BaseModel
router = APIRouter(prefix="/api/crm", tags=["crm-clan-panel"])
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
DSN = f"host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password={os.environ['DB_PASSWORD']}"
UPLOADS_DIR = Path("/opt/pgz-sport/static/uploads/avatars")
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,548 @@
#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════
# Fajl: routers/clan_panel_router.py | v1.0.0 | 05.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
# Lokacija: /opt/pgz-sport/routers/clan_panel_router.py
# Svrha: CRM Dashboard člana — full panel (sve), edit s permission gating,
# avatar upload.
# ═══════════════════════════════════════════════════════════════════
"""CRM Član Panel router.
Endpointi (montirani na /api/crm):
GET /clanovi/{id}/full → SVI podaci o članu + povijest svega
PUT /clanovi/{id} → edit (permission gating po roli)
POST /clanovi/{id}/avatar → upload slike
GET /clanovi/search?q=... → quick search za panel
"""
from __future__ import annotations
import os
import io
import shutil
import uuid as _uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Optional, Any
from pathlib import Path
import psycopg2
from psycopg2.extras import RealDictCursor
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Header
from fastapi.responses import JSONResponse
from pydantic import BaseModel
router = APIRouter(prefix="/api/crm", tags=["crm-clan-panel"])
DSN = f"host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password={os.environ['DB_PASSWORD']}"
UPLOADS_DIR = Path("/opt/pgz-sport/static/uploads/avatars")
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
PUBLIC_AVATAR_PREFIX = "/sport/static/uploads/avatars"
# Polja koja smiju editirati pojedine role.
# Hard rules iz briefa:
# sportas (sebe): kontakt + slike
# klub_admin: sve osim OIB
# savez_admin: pregled + napomene
# pgz_admin: full
# super_admin: full
EDITABLE_BY_ROLE = {
"sportas": {
"email", "telefon", "adresa", "grad", "postanski_broj",
"biografija", "slika_url",
},
"klub_admin": {
# sve osim "oib"
"ime", "prezime", "datum_rodenja", "spol", "adresa", "grad",
"postanski_broj", "email", "telefon", "kategorija", "podkategorija",
"pozicija", "licenca_broj", "licenca_vrijedi_do", "reprezentativac",
"kategoriziran", "kategorija_hoo", "stipendiran", "stipendija_iznos",
"radno_pravni_status", "aktivan", "datum_pristupa", "datum_napustanja",
"napomena", "dominantna_noga", "visina_cm", "tezina_kg", "broj_dresa",
"reprezentacija_kategorija", "biografija", "mjesto_rodenja",
"sport", "uloga", "uloga_detalj", "klub_id", "slika_url",
},
"savez_admin": {
"napomena",
},
"pgz_admin": "ALL",
"super_admin": "ALL",
"klub_trener": {
"kategorija", "podkategorija", "pozicija", "broj_dresa",
"dominantna_noga", "visina_cm", "tezina_kg", "napomena", "biografija",
},
}
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)
if isinstance(v, _uuid.UUID):
return str(v)
return v
def _row(d):
return None if d is None else {k: _conv(v) for k, v in dict(d).items()}
def _resolve_role(authorization: Optional[str]) -> str:
"""
Vrlo pojednostavljeno: dok puni JWT M1 ne propagira context, čitamo
'X-Role' header (postavi UI). Inače: ako je authorization == admin token
→ pgz_admin, inače → viewer.
"""
if not authorization:
return "viewer"
tok = authorization.replace("Bearer ", "").strip()
if tok == "admin-pgz-2026":
return "pgz_admin"
# decode JWT (best-effort)
try:
import jwt as _jwt # type: ignore
# JWT secret iz auth_v2 — bez tvrde ovisnosti
for secret in (os.environ.get("JWT_SECRET"), "rinet-jwt-secret-2026"):
if not secret:
continue
try:
payload = _jwt.decode(tok, secret, algorithms=["HS256"])
return payload.get("role") or payload.get("user_type") or "viewer"
except Exception:
continue
except Exception:
pass
return "viewer"
def _check_field_perm(role: str, fields: set[str]) -> set[str]:
"""Vrati SAMO polja koja role smije editirati."""
allowed = EDITABLE_BY_ROLE.get(role, set())
if allowed == "ALL":
return fields
return fields & allowed
# ───────────── search ─────────────
@router.get("/clanovi/search")
def clanovi_search(q: Optional[str] = Query(None, min_length=2),
klub_id: Optional[int] = Query(None),
limit: int = Query(20, le=100)):
where, params = ["c.aktivan = TRUE"], []
if q:
where.append("(c.ime || ' ' || c.prezime) ILIKE %s OR c.oib ILIKE %s")
params += [f"%{q}%", f"%{q}%"]
if klub_id:
where.append("c.klub_id = %s"); params.append(klub_id)
params.append(limit)
sql = f"""
SELECT c.id, c.ime, c.prezime, c.oib, c.kategorija, c.pozicija,
c.slika_url, c.broj_dresa,
k.id AS klub_id, k.naziv AS klub
FROM pgz_sport.clanovi c
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE {' AND '.join(where)}
ORDER BY c.prezime, c.ime
LIMIT %s
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute(sql, params)
rows = [_row(r) for r in cur.fetchall()]
return {"count": len(rows), "rows": rows}
# ───────────── full panel ─────────────
@router.get("/clanovi/{cid}/full")
def clan_full(cid: int):
"""
Vraća SVE podatke o članu + sve povezane pod-tablice:
- personal, kontakt, sport, status, reprezentacija, stipendija
- klub (trenutni + povijest preko clan_sezona.klub_naziv)
- sezone (clan_sezona)
- utakmice (zadnjih 20 — clan_utakmica)
- lijecnicki (lijecnicki_pregledi po clan_id)
- clanarine (clanarine po clan_id) + dug
- dokumenti (clan_godisnjak ↔ dokumenti)
- obrasci (form_submissions po clan_id)
- nagrade (clan_nagrada)
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
SELECT c.*,
k.id AS klub__id,
k.naziv AS klub__naziv,
k.oib AS klub__oib,
k.iban AS klub__iban,
k.adresa AS klub__adresa,
k.grad AS klub__grad,
k.sport AS klub__sport,
k.savez_id AS klub__savez_id,
s.naziv AS klub__savez_naziv,
EXTRACT(YEAR FROM age(COALESCE(c.datum_rodenja, c.datum_rodjenja)))::int AS dob_calc
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 c.id = %s
""", (cid,))
clan_raw = cur.fetchone()
if not clan_raw:
raise HTTPException(404, "Član ne postoji")
# rastavi klub__* u nested objekt
c = {}
klub: dict = {}
for k, v in dict(clan_raw).items():
if k.startswith("klub__"):
klub[k.replace("klub__", "")] = v
else:
c[k] = v
# avatar URL fallback (slika_url može biti relativna)
slika = c.get("slika_url") or ""
if slika and not (slika.startswith("http") or slika.startswith("/")):
slika = f"{PUBLIC_AVATAR_PREFIX}/{slika}"
c["slika_url_full"] = slika or None
# SEZONE
cur.execute("""
SELECT id, sezona, natjecanje, klub_naziv, nastupi, zapoceo, zamjena,
pogoci, asistencije, zuti_kartoni, crveni_kartoni, minute_total,
napomena, scrape_url
FROM pgz_sport.clan_sezona
WHERE clan_id = %s
ORDER BY sezona DESC
LIMIT 50
""", (cid,))
sezone = [_row(r) for r in cur.fetchall()]
# UTAKMICE (zadnjih 20)
cur.execute("""
SELECT id, datum, domacin, gost, rezultat, natjecanje,
pogoci, zuti, crveni, minute, utakmica_url
FROM pgz_sport.clan_utakmica
WHERE clan_id = %s
ORDER BY datum DESC NULLS LAST
LIMIT 20
""", (cid,))
utakmice = [_row(r) for r in cur.fetchall()]
# LIJEČNIČKI
cur.execute("""
SELECT id, datum_pregleda, vrijedi_do, vrsta_pregleda, ustanova, lijecnik,
spreman_za_natjecanje, ekg, krv, spirometrija,
placeno, iznos, datum_placanja,
(vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka,
CASE
WHEN vrijedi_do IS NULL THEN 'nepoznato'
WHEN vrijedi_do < CURRENT_DATE THEN 'istekao'
WHEN vrijedi_do <= (CURRENT_DATE + INTERVAL '30 days') THEN 'uskoro'
ELSE 'vazeci'
END AS status_calc
FROM pgz_sport.lijecnicki_pregledi
WHERE clan_id = %s
ORDER BY datum_pregleda DESC
""", (cid,))
lijecnicki = [_row(r) for r in cur.fetchall()]
# ČLANARINE
cur.execute("""
SELECT id, godina, razdoblje, iznos_propisan, iznos_placen,
(iznos_propisan - COALESCE(iznos_placen,0))::numeric(10,2) AS dug,
datum_uplate, status, racun_broj, referenca, napomena
FROM pgz_sport.clanarine
WHERE clan_id = %s
ORDER BY godina DESC
""", (cid,))
clanarine = [_row(r) for r in cur.fetchall()]
# DOKUMENTI (preko clan_godisnjak)
cur.execute("""
SELECT cg.id AS link_id, cg.godina, cg.snippet, cg.has_medal, cg.has_kategorija,
d.id AS dokument_id, d.title, d.url, d.pdf_url, d.izvor_url,
d.vrsta, d.organizacija, d.izdano_datum
FROM pgz_sport.clan_godisnjak cg
JOIN pgz_sport.dokumenti d ON d.id = cg.dokument_id
WHERE cg.clan_id = %s
ORDER BY cg.godina DESC
LIMIT 50
""", (cid,))
dokumenti = [_row(r) for r in cur.fetchall()]
# OBRASCI (form_submissions)
cur.execute("""
SELECT s.id, s.template_id, s.template_code, s.status, s.reference_no,
s.submitted_at, s.created_at,
t.naziv AS template_naziv, t.kategorija
FROM pgz_sport.form_submissions s
LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id
WHERE s.clan_id = %s
ORDER BY s.created_at DESC
""", (cid,))
obrasci = [_row(r) for r in cur.fetchall()]
# NAGRADE
cur.execute("""
SELECT id, godina, sezona, natjecanje, razina_natjecanja,
dobna_kategorija, disciplina, plasman, klub_naziv
FROM pgz_sport.clan_nagrada
WHERE clan_id = %s
ORDER BY godina DESC NULLS LAST
LIMIT 50
""", (cid,))
nagrade = [_row(r) for r in cur.fetchall()]
# POVIJEST KLUBOVA (iz clan_sezona.klub_naziv distinct)
cur.execute("""
SELECT klub_naziv, MIN(sezona) AS od, MAX(sezona) AS do_, COUNT(*) AS broj_sezona
FROM pgz_sport.clan_sezona
WHERE clan_id = %s AND klub_naziv IS NOT NULL
GROUP BY klub_naziv
ORDER BY MAX(sezona) DESC
""", (cid,))
povijest_klubova = [_row(r) for r in cur.fetchall()]
# KPI / sažetak za panel
dug_total = sum(float(r.get("dug") or 0) for r in clanarine)
placeno_total = sum(float(r.get("iznos_placen") or 0) for r in clanarine)
propisan_total = sum(float(r.get("iznos_propisan") or 0) for r in clanarine)
last_lij = lijecnicki[0] if lijecnicki else None
nastupi_total = sum(int(r.get("nastupi") or 0) for r in sezone)
pogoci_total = sum(int(r.get("pogoci") or 0) for r in sezone)
return {
"clan": _row(c),
"klub": _row(klub) if klub.get("id") else None,
"kpi": {
"dug_clanarina_eur": round(dug_total, 2),
"placeno_clanarina_eur": round(placeno_total, 2),
"propisan_clanarina_eur": round(propisan_total, 2),
"lijecnicki_status": last_lij and last_lij.get("status_calc"),
"lijecnicki_dana_do_isteka": last_lij and last_lij.get("dana_do_isteka"),
"broj_sezona": len(sezone),
"broj_utakmica_zadnjih": len(utakmice),
"nastupi_total": nastupi_total,
"pogoci_total": pogoci_total,
"broj_obrazaca": len(obrasci),
"broj_nagrada": len(nagrade),
},
"sezone": sezone,
"utakmice_zadnje20": utakmice,
"lijecnicki": lijecnicki,
"clanarine": clanarine,
"dokumenti": dokumenti,
"obrasci": obrasci,
"nagrade": nagrade,
"povijest_klubova": povijest_klubova,
}
# ───────────── edit (PUT s permission gating) ─────────────
class ClanPatch(BaseModel):
# Sva potencijalno-editabilna polja (subset full schema-e):
ime: Optional[str] = None
prezime: Optional[str] = None
oib: Optional[str] = None
datum_rodenja: Optional[date] = None
spol: Optional[str] = None
mjesto_rodenja: Optional[str] = None
adresa: Optional[str] = None
grad: Optional[str] = None
postanski_broj: Optional[str] = None
email: Optional[str] = None
telefon: Optional[str] = None
kategorija: Optional[str] = None
podkategorija: Optional[str] = None
pozicija: Optional[str] = None
licenca_broj: Optional[str] = None
licenca_vrijedi_do: Optional[date] = None
reprezentativac: Optional[bool] = None
reprezentacija_kategorija: Optional[str] = None
kategoriziran: Optional[bool] = None
kategorija_hoo: Optional[int] = None
stipendiran: Optional[bool] = None
stipendija_iznos: Optional[float] = None
radno_pravni_status: Optional[str] = None
aktivan: Optional[bool] = None
datum_pristupa: Optional[date] = None
datum_napustanja: Optional[date] = None
napomena: Optional[str] = None
dominantna_noga: Optional[str] = None
visina_cm: Optional[int] = None
tezina_kg: Optional[int] = None
broj_dresa: Optional[int] = None
biografija: Optional[str] = None
sport: Optional[str] = None
uloga: Optional[str] = None
uloga_detalj: Optional[str] = None
klub_id: Optional[int] = None
slika_url: Optional[str] = None
@router.put("/clanovi/{cid}")
def update_clan(cid: int, patch: ClanPatch,
authorization: Optional[str] = Header(None),
x_role: Optional[str] = Header(None)):
role = (x_role or _resolve_role(authorization) or "viewer").lower()
requested = {k: v for k, v in patch.dict(exclude_unset=True).items() if v is not None}
if not requested:
raise HTTPException(400, "Nema polja za izmjenu")
allowed_fields = _check_field_perm(role, set(requested.keys()))
if not allowed_fields:
raise HTTPException(403, f"Role '{role}' nema dozvolu za nijedno od poslanih polja")
rejected = set(requested.keys()) - allowed_fields
final = {k: requested[k] for k in allowed_fields}
set_clauses = [f"{k} = %s" for k in final.keys()]
set_clauses.append("updated_at = now()")
params = list(final.values()) + [cid]
with _conn() as conn, conn.cursor() as cur:
cur.execute(f"UPDATE pgz_sport.clanovi SET {', '.join(set_clauses)} WHERE id=%s RETURNING *",
params)
r = cur.fetchone()
if not r:
raise HTTPException(404, "Član ne postoji")
# audit log (best-effort)
try:
import json as _json
cur.execute("""INSERT INTO pgz_sport.audit_feed (entity_type, entity_id, action, payload)
VALUES (%s,%s,%s,%s::jsonb)""",
("clan", cid, "edit",
_json.dumps({"role": role,
"applied": list(final.keys()),
"rejected": list(rejected)})))
except Exception:
pass
conn.commit()
return {
"ok": True,
"id": cid,
"role": role,
"applied_fields": sorted(final.keys()),
"rejected_fields": sorted(rejected),
"clan": _row(r),
}
# ───────────── avatar upload ─────────────
@router.post("/clanovi/{cid}/avatar")
async def upload_avatar(cid: int, file: UploadFile = File(...),
authorization: Optional[str] = Header(None),
x_role: Optional[str] = Header(None)):
"""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"}
ct = (file.content_type or "").lower()
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,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Član ne postoji")
fname = f"{cid}-{_uuid.uuid4().hex[:8]}.{ext_map[ct]}"
fpath = UPLOADS_DIR / fname
with open(fpath, "wb") as fh:
fh.write(contents)
public_url = f"{PUBLIC_AVATAR_PREFIX}/{fname}"
with _conn() as conn, conn.cursor() as cur:
old = r["slika_url"]
if old and PUBLIC_AVATAR_PREFIX in old:
try:
old_name = old.split("/")[-1]
old_path = UPLOADS_DIR / old_name
if old_path.exists() and str(old_path).startswith(str(UPLOADS_DIR)):
old_path.unlink()
except Exception:
pass
cur.execute("UPDATE pgz_sport.clanovi SET slika_url=%s, updated_at=now() WHERE id=%s",
(public_url, cid))
conn.commit()
return {
"ok": True,
"id": cid,
"demo_mode": False,
"slika_url": public_url,
"size_bytes": len(contents),
"content_type": ct,
"filename": fname,
}
# ───────────── permissions info (za UI) ─────────────
@router.get("/clanovi/permissions")
def permissions_matrix(role: Optional[str] = Query(None)):
if role:
r = role.lower()
allowed = EDITABLE_BY_ROLE.get(r, set())
return {"role": r, "editable": "ALL" if allowed == "ALL" else sorted(allowed)}
return {
"roles": {
r: ("ALL" if v == "ALL" else sorted(v))
for r, v in EDITABLE_BY_ROLE.items()
}
}
-94
View File
@@ -1446,97 +1446,3 @@ def get_obrazac_template(tid: int, user=Depends(_require_user)):
if not row:
raise HTTPException(404, "not found")
return _row(row)
# ─────────── E-MAIL TEMPLATES (CRM v2 GUI redesign — RUSH-4) ───────────
# Author: dradulic@outlook.com / damir@rinet.one — 2026-05-05
# Table: pgz_sport.email_templates (code, naziv, kategorija, subject_tpl, body_tpl, variables jsonb, active)
class EmailTemplateIn(BaseModel):
code: str
naziv: str
kategorija: Optional[str] = None
subject_tpl: str
body_tpl: str
variables: Optional[Dict[str, Any]] = None
active: Optional[bool] = True
@router.get("/email-templates")
def list_email_templates(kategorija: Optional[str] = None,
active_only: bool = True,
user=Depends(_require_user)):
where, params = [], []
if kategorija:
where.append("kategorija = %s"); params.append(kategorija)
if active_only:
where.append("active = true")
sql = ("SELECT id, code, naziv, kategorija, subject_tpl, body_tpl, "
"variables, active, created_at, updated_at "
"FROM pgz_sport.email_templates")
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY kategorija NULLS LAST, naziv"
with _conn() as cn, cn.cursor() as cur:
cur.execute(sql, params)
items = _rows(cur.fetchall())
return {"items": items, "count": len(items)}
@router.get("/email-templates/{tid}")
def get_email_template(tid: int, user=Depends(_require_user)):
with _conn() as cn, cn.cursor() as cur:
cur.execute("SELECT * FROM pgz_sport.email_templates WHERE id=%s", (tid,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "not found")
return _row(row)
@router.post("/email-templates")
def create_email_template(req: EmailTemplateIn, user=Depends(_require_user)):
if not _is_admin(user):
raise HTTPException(403, "admin only")
import json as _json
with _conn() as cn, cn.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 *
""", (req.code, req.naziv, req.kategorija, req.subject_tpl, req.body_tpl,
_json.dumps(req.variables or {}), bool(req.active)))
cn.commit()
return _row(cur.fetchone())
@router.put("/email-templates/{tid}")
def update_email_template(tid: int, req: EmailTemplateIn, user=Depends(_require_user)):
if not _is_admin(user):
raise HTTPException(403, "admin only")
import json as _json
with _conn() as cn, cn.cursor() as cur:
cur.execute("""
UPDATE pgz_sport.email_templates
SET code=%s, naziv=%s, kategorija=%s, subject_tpl=%s,
body_tpl=%s, variables=%s::jsonb, active=%s, updated_at=now()
WHERE id=%s
RETURNING *
""", (req.code, req.naziv, req.kategorija, req.subject_tpl, req.body_tpl,
_json.dumps(req.variables or {}), bool(req.active), tid))
if cur.rowcount == 0:
raise HTTPException(404, "not found")
cn.commit()
return _row(cur.fetchone())
@router.delete("/email-templates/{tid}")
def delete_email_template(tid: int, user=Depends(_require_user)):
if not _is_admin(user):
raise HTTPException(403, "admin only")
with _conn() as cn, cn.cursor() as cur:
cur.execute("DELETE FROM pgz_sport.email_templates WHERE id=%s", (tid,))
if cur.rowcount == 0:
raise HTTPException(404, "not found")
cn.commit()
return {"ok": True, "id": tid}
+4 -1
View File
@@ -1,3 +1,6 @@
from dotenv import load_dotenv
load_dotenv('/opt/rinet-gpu/.env.master')
# auto-added by patch_scrapers_with_dotenv.sh
"""Debug observability dashboard endpoint."""
import json, os, time
from pathlib import Path
@@ -26,7 +29,7 @@ def debug_health():
db_status = "unknown"
try:
import psycopg2
with psycopg2.connect("host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7", connect_timeout=2) as conn:
with psycopg2.connect(f"host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password={os.environ['DB_PASSWORD']}", connect_timeout=2) as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1")
db_status = "ok"
+170
View File
@@ -0,0 +1,170 @@
"""Debug observability dashboard endpoint."""
import json, os, time
from pathlib import Path
from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse, HTMLResponse, PlainTextResponse
from typing import Optional
router = APIRouter(prefix="/api/debug", tags=["debug"])
LOGDIR = Path("/var/log/pgz-sport-debug")
@router.get("/health")
def debug_health():
"""Quick service status."""
import subprocess
services = ['pgz-sport', 'pgz-debug-tail', 'pgz-auto-triage', 'nginx', 'redis-server']
status = {}
for s in services:
try:
r = subprocess.run(['systemctl', 'is-active', s], capture_output=True, text=True, timeout=2)
status[s] = r.stdout.strip()
except Exception as e:
status[s] = f"error:{e}"
# DB
db_status = "unknown"
try:
import psycopg2
with psycopg2.connect(f"host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password={os.environ['DB_PASSWORD']}", connect_timeout=2) as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1")
db_status = "ok"
except Exception as e:
db_status = f"error:{e}"
# Recent errors count
err_count = 0
if (LOGDIR / "errors.jsonl").exists():
with open(LOGDIR / "errors.jsonl") as f:
err_count = sum(1 for _ in f)
return {
"ts": time.time(),
"services": status,
"db": db_status,
"total_errors_logged": err_count,
"log_dir": str(LOGDIR),
}
@router.get("/errors")
def recent_errors(limit: int = Query(100, ge=1, le=1000)):
"""Last N errors from errors.jsonl."""
f = LOGDIR / "errors.jsonl"
if not f.exists():
return {"errors": [], "note": "errors.jsonl not yet created"}
lines = f.read_text(errors='ignore').strip().split('\n')[-limit:]
parsed = []
for line in lines:
try:
parsed.append(json.loads(line))
except:
continue
return {"errors": parsed, "count": len(parsed)}
@router.get("/decisions")
def triage_decisions(limit: int = Query(50, ge=1, le=500)):
"""Last N auto-triage decisions."""
f = LOGDIR / "triage_decisions.jsonl"
if not f.exists():
return {"decisions": [], "note": "no decisions yet"}
lines = f.read_text(errors='ignore').strip().split('\n')[-limit:]
parsed = []
for line in lines:
try:
parsed.append(json.loads(line))
except:
continue
return {"decisions": parsed, "count": len(parsed)}
@router.get("/stream")
def stream_tail(lines: int = Query(200, ge=10, le=2000)):
"""Last N lines of full stream.jsonl."""
f = LOGDIR / "stream.jsonl"
if not f.exists():
return {"stream": []}
raw = f.read_text(errors='ignore').strip().split('\n')[-lines:]
parsed = []
for line in raw:
try:
parsed.append(json.loads(line))
except:
continue
return {"stream": parsed}
@router.get("/dashboard", response_class=HTMLResponse)
def dashboard():
"""Live HTML dashboard."""
return """<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>PGŽ Debug Live</title>
<style>
body{font-family:'JetBrains Mono',monospace;background:#0a0a0c;color:#e0e0e0;margin:0;padding:20px}
h1{color:#FFD700;font-size:18px;margin:0 0 18px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}
.card{background:#1a1a1e;border:1px solid #2a2a2e;border-radius:6px;padding:16px}
.card h2{color:#FFD700;font-size:13px;margin:0 0 10px;text-transform:uppercase;letter-spacing:.5px}
.kv{font-size:12px;line-height:1.6}
.kv span:first-child{color:#888;display:inline-block;width:160px}
.ok{color:#3a9}
.err{color:#e55}
.warn{color:#fa3}
pre{font-size:11px;background:#0e0e10;padding:8px;border-radius:4px;max-height:400px;overflow:auto;border:1px solid #2a2a2e}
.row{padding:6px 0;border-bottom:1px solid #2a2a2e;font-size:11px}
.row:last-child{border-bottom:0}
.ts{color:#666}
.lvl-ERROR{color:#e55}
.lvl-WARN{color:#fa3}
.lvl-CRITICAL{color:#f00;font-weight:bold}
.refresh{color:#666;font-size:10px;float:right}
</style></head>
<body>
<h1>🩺 PGŽ Sport · Live Debug Dashboard <span class="refresh">refresh: 5s</span></h1>
<div class="grid">
<div class="card">
<h2>Service Health</h2>
<div id="health" class="kv">loading…</div>
</div>
<div class="card">
<h2>Auto-Triage Decisions</h2>
<div id="decisions">loading…</div>
</div>
<div class="card" style="grid-column:1/-1">
<h2>Recent Errors (live)</h2>
<div id="errors"><pre>loading…</pre></div>
</div>
</div>
<script>
async function refresh(){
// Health
const h = await fetch('/sport/api/debug/health').then(r=>r.json());
let html = '';
for (const [k,v] of Object.entries(h.services||{})){
const cls = v==='active'?'ok':'err';
html += `<div><span>${k}</span><span class="${cls}">${v}</span></div>`;
}
html += `<div><span>db</span><span class="${h.db==='ok'?'ok':'err'}">${h.db}</span></div>`;
html += `<div><span>total_errors</span><span>${h.total_errors_logged}</span></div>`;
document.getElementById('health').innerHTML = html;
// Decisions
const d = await fetch('/sport/api/debug/decisions?limit=10').then(r=>r.json());
let dh = '';
if (!d.decisions || d.decisions.length===0) dh = '<div class="row" style="color:#666">no auto-fixes triggered yet</div>';
for (const x of (d.decisions||[]).reverse()){
dh += `<div class="row"><span class="ts">${(x.ts||'').substring(11,19)}</span> <b>${x.action}</b> → ${x.target}: ${(x.msg||'').substring(0,120)}</div>`;
}
document.getElementById('decisions').innerHTML = dh;
// Errors
const e = await fetch('/sport/api/debug/errors?limit=30').then(r=>r.json());
let eh = '';
for (const x of (e.errors||[]).reverse()){
const cls = `lvl-${x.level||'INFO'}`;
eh += `<div class="row"><span class="ts">${(x.ts||'').substring(11,19)}</span> <span class="${cls}">[${x.level||'?'}]</span> <span style="color:#aaa">${x.src||'?'}</span> ${(x.code||'')} ${(x.path||'')} ${(x.msg||'').substring(0,140)}</div>`;
}
document.getElementById('errors').innerHTML = eh || '<div class="row" style="color:#666">No errors</div>';
}
refresh();
setInterval(refresh, 5000);
</script>
</body></html>"""
+901 -73
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env python3
from dotenv import load_dotenv
load_dotenv('/opt/rinet-gpu/.env.master')
# auto-added by patch_scrapers_with_dotenv.sh
# ═══════════════════════════════════════════════════════════════════
# Fajl: routers/erp_full_router.py | v1.2.0 | 05.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
@@ -43,7 +46,7 @@ INVOICE_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
router = APIRouter(prefix="/api/v2/erp", tags=["erp_full"])
DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7')
DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password=os.environ["DB_PASSWORD"])
def db_query(sql: str, params=()):
File diff suppressed because it is too large Load Diff
+3
View File
@@ -106,6 +106,9 @@ def _fetch_internal(endpoint: str, authorization: Optional[str]) -> Any:
raise HTTPException(status_code=400, detail="endpoint required")
# Normalize: must start with / — accept full URL only if it points at us.
ep = unquote(endpoint).strip()
# Ukloni /sport prefiks ako postoji (frontend šalje /sport/api/...)
if ep.startswith("/sport/"):
ep = ep[len("/sport"):]
if ep.startswith(("http://", "https://")):
# Only allow our own host to avoid SSRF.
if not ep.startswith(INTERNAL_BASE):
@@ -0,0 +1,297 @@
#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════════════
# Fajl: routers/export_router.py | v1.0.0 | 05.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
# Lokacija: /opt/pgz-sport/routers/export_router.py
# Svrha: Univerzalni Export modul (CSV / XLSX / PDF) za sve tablice u PGŽ Sport
# ERP/CRM. Endpoint /api/v2/export?format=...&endpoint=...&filters=...
# proxy-aporta unutarnji JSON API i konvertira odgovor u traženi
# format. CSV (HR Excel friendly: ; delimiter, UTF-8 BOM), XLSX preko
# openpyxla (s graceful fallback na CSV ako openpyxl nije dostupan),
# PDF preko HTML print-to-PDF (mock — TODO: weasyprint/reportlab).
# Mount: /api/v2/export/*
# ═══════════════════════════════════════════════════════════════════════════
from __future__ import annotations
import csv
import io
import json
from datetime import datetime
from typing import Any, Dict, List, Optional
from urllib.parse import unquote
import requests
from fastapi import APIRouter, Header, HTTPException, Query
from fastapi.responses import HTMLResponse, Response, StreamingResponse
# openpyxl is optional — fall back to CSV if missing.
try:
from openpyxl import Workbook # type: ignore
from openpyxl.styles import Alignment, Font, PatternFill # type: ignore
OPENPYXL_AVAILABLE = True
except Exception: # pragma: no cover
OPENPYXL_AVAILABLE = False
router = APIRouter(prefix="/api/v2/export", tags=["export"])
INTERNAL_BASE = "http://127.0.0.1:8095"
# ───────────────────────────────────────────────────────────────────────────
# Utilities
# ───────────────────────────────────────────────────────────────────────────
def _ts() -> str:
return datetime.now().strftime("%Y%m%d_%H%M%S")
def _flatten_value(v: Any) -> str:
"""Normalize a single cell value to a flat string."""
if v is None:
return ""
if isinstance(v, (dict, list, tuple)):
try:
return json.dumps(v, ensure_ascii=False, default=str)
except Exception:
return str(v)
if isinstance(v, bool):
return "true" if v else "false"
return str(v)
def _coerce_rows(payload: Any) -> List[Dict[str, Any]]:
"""Accept many common JSON shapes and return a list of dicts."""
if payload is None:
return []
# Top-level list
if isinstance(payload, list):
return [r for r in payload if isinstance(r, dict)]
if isinstance(payload, dict):
# {rows:[...]} or {data:[...]} or {items:[...]} or {results:[...]}
for key in ("rows", "data", "items", "results", "list"):
v = payload.get(key)
if isinstance(v, list):
return [r for r in v if isinstance(r, dict)]
# {count, rows}
if "rows" in payload and isinstance(payload["rows"], list):
return [r for r in payload["rows"] if isinstance(r, dict)]
# Fallback: a single record
return [payload]
return []
def _ordered_columns(rows: List[Dict[str, Any]]) -> List[str]:
"""Use first row's keys as primary column order, then append any
extra keys discovered later (preserves order, no duplicates)."""
seen: List[str] = []
seen_set = set()
if rows:
for k in rows[0].keys():
if k not in seen_set:
seen.append(k)
seen_set.add(k)
for r in rows[1:]:
for k in r.keys():
if k not in seen_set:
seen.append(k)
seen_set.add(k)
return seen
def _fetch_internal(endpoint: str, authorization: Optional[str]) -> Any:
"""Call the local FastAPI server with the user's Authorization header
forwarded so existing auth/permissions are respected."""
if not endpoint:
raise HTTPException(status_code=400, detail="endpoint required")
# Normalize: must start with / — accept full URL only if it points at us.
ep = unquote(endpoint).strip()
if ep.startswith(("http://", "https://")):
# Only allow our own host to avoid SSRF.
if not ep.startswith(INTERNAL_BASE):
raise HTTPException(status_code=400, detail="external endpoint not allowed")
url = ep
else:
if not ep.startswith("/"):
ep = "/" + ep
url = INTERNAL_BASE + ep
headers: Dict[str, str] = {"Accept": "application/json"}
if authorization:
headers["Authorization"] = authorization
try:
r = requests.get(url, headers=headers, timeout=60)
except Exception as e:
raise HTTPException(status_code=502, detail=f"upstream fetch failed: {e}")
if r.status_code >= 400:
raise HTTPException(
status_code=r.status_code,
detail=f"upstream {r.status_code}: {r.text[:200]}",
)
try:
return r.json()
except Exception:
raise HTTPException(status_code=502, detail="upstream did not return JSON")
# ───────────────────────────────────────────────────────────────────────────
# Format builders
# ───────────────────────────────────────────────────────────────────────────
def _build_csv(rows: List[Dict[str, Any]], filename: str) -> Response:
cols = _ordered_columns(rows)
buf = io.StringIO()
# HR Excel friendly: ; delimiter
w = csv.writer(buf, delimiter=";", quoting=csv.QUOTE_MINIMAL, lineterminator="\r\n")
w.writerow(cols)
for r in rows:
w.writerow([_flatten_value(r.get(c)) for c in cols])
body = ("" + buf.getvalue()).encode("utf-8") # UTF-8 BOM
return Response(
content=body,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
def _build_xlsx(rows: List[Dict[str, Any]], filename: str) -> Response:
if not OPENPYXL_AVAILABLE:
# Graceful fallback: CSV with a warning header.
fallback = filename.rsplit(".", 1)[0] + ".csv"
resp = _build_csv(rows, fallback)
resp.headers["X-Export-Warning"] = "openpyxl unavailable — fell back to CSV"
return resp
cols = _ordered_columns(rows)
wb = Workbook()
ws = wb.active
ws.title = "Export"
# Header row (bold)
bold = Font(bold=True, color="FFFFFFFF")
fill = PatternFill(start_color="FF1F2937", end_color="FF1F2937", fill_type="solid")
align = Alignment(vertical="center", wrap_text=False)
ws.append(cols)
for cell in ws[1]:
cell.font = bold
cell.fill = fill
cell.alignment = align
for r in rows:
ws.append([_flatten_value(r.get(c)) for c in cols])
# Auto column widths (clamped)
for idx, col in enumerate(cols, start=1):
max_len = max(
[len(col)]
+ [len(_flatten_value(r.get(col))) for r in rows[:200]]
+ [10]
)
ws.column_dimensions[ws.cell(row=1, column=idx).column_letter].width = min(
max_len + 2, 60
)
bio = io.BytesIO()
wb.save(bio)
bio.seek(0)
return StreamingResponse(
bio,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
def _build_pdf_html(rows: List[Dict[str, Any]], title: str) -> HTMLResponse:
"""Return a print-friendly HTML page (mock PDF). User invokes
browser's Print → Save as PDF.
<!-- TODO: real PDF via weasyprint/reportlab -->
"""
cols = _ordered_columns(rows)
def _esc(s: Any) -> str:
return (
_flatten_value(s)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
head = "".join(f"<th>{_esc(c)}</th>" for c in cols)
body_rows = "".join(
"<tr>" + "".join(f"<td>{_esc(r.get(c))}</td>" for c in cols) + "</tr>"
for r in rows
)
when = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
html = f"""<!doctype html>
<html lang="hr"><head><meta charset="utf-8">
<title>{_esc(title)}</title>
<style>
body{{font-family:Arial,Helvetica,sans-serif;color:#111;margin:18px}}
h1{{font-size:16px;margin:0 0 8px}}
.meta{{font-size:11px;color:#555;margin-bottom:14px}}
table{{width:100%;border-collapse:collapse;font-size:10px}}
th,td{{border:1px solid #999;padding:4px 6px;text-align:left;vertical-align:top}}
th{{background:#1f2937;color:#fff;font-size:9px;text-transform:uppercase;letter-spacing:.5px}}
tr:nth-child(even) td{{background:#f5f5f5}}
.bar{{margin-bottom:14px}}
.bar button{{background:#0b5cff;color:#fff;border:0;padding:8px 14px;border-radius:4px;cursor:pointer;font-size:12px}}
@media print{{ .bar{{display:none}} body{{margin:6mm}} }}
</style>
</head><body>
<div class="bar"><button onclick="window.print()">🖨 Print / Save as PDF</button></div>
<h1>{_esc(title)}</h1>
<div class="meta">PGŽ Sport ERP/CRM — generirano {when} — {len(rows)} redaka</div>
<table><thead><tr>{head}</tr></thead><tbody>{body_rows}</tbody></table>
<!-- TODO: real PDF via weasyprint/reportlab -->
</body></html>"""
return HTMLResponse(content=html, status_code=200)
# ───────────────────────────────────────────────────────────────────────────
# Endpoints
# ───────────────────────────────────────────────────────────────────────────
@router.get("/health")
def export_health():
return {
"ok": True,
"openpyxl_available": OPENPYXL_AVAILABLE,
"formats": ["csv", "xlsx", "pdf"],
"version": "1.0.0",
}
@router.get("")
@router.get("/")
def export_dispatch(
format: str = Query("csv", description="csv|xlsx|pdf"),
endpoint: str = Query(..., description="Internal API path, e.g. /api/v2/erp/payments?godina=2026"),
filters: Optional[str] = Query(None, description="Optional JSON of extra filters merged into endpoint"),
filename: Optional[str] = Query(None, description="Optional filename base (no extension)"),
authorization: Optional[str] = Header(None),
):
fmt = (format or "csv").lower().strip()
if fmt not in ("csv", "xlsx", "pdf"):
raise HTTPException(status_code=400, detail="format must be csv|xlsx|pdf")
# Optionally merge `filters` JSON into endpoint querystring.
target = endpoint
if filters:
try:
extra = json.loads(filters)
if isinstance(extra, dict) and extra:
from urllib.parse import urlencode
qs = urlencode({k: _flatten_value(v) for k, v in extra.items()})
sep = "&" if "?" in target else "?"
target = f"{target}{sep}{qs}"
except Exception:
# Ignore malformed filter JSON — still try the raw endpoint.
pass
payload = _fetch_internal(target, authorization)
rows = _coerce_rows(payload)
base = filename or "export"
stamp = _ts()
if fmt == "csv":
return _build_csv(rows, f"{base}_{stamp}.csv")
if fmt == "xlsx":
return _build_xlsx(rows, f"{base}_{stamp}.xlsx")
# pdf (HTML print-to-PDF mock)
return _build_pdf_html(rows, title=f"{base} — {stamp}")
+5 -1
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env python3
from dotenv import load_dotenv
load_dotenv('/opt/rinet-gpu/.env.master')
# auto-added by patch_scrapers_with_dotenv.sh
# ==============================================================================
# notif_router.py — Notification center API for /app#notif
# Author : Damir Radulić — dradulic@outlook.com / damir@rinet.one
@@ -14,6 +17,7 @@
# DELETE /api/v2/notif/{id}
# ==============================================================================
from __future__ import annotations
import os
from typing import Optional, List, Dict, Any
from fastapi import APIRouter, HTTPException, Query, Header
@@ -27,7 +31,7 @@ DB = dict(
port=6432,
dbname="rinet_v3",
user="rinet",
password="R1net2026!SecureDB#v7",
password=os.environ["DB_PASSWORD"],
)
router = APIRouter(prefix="/api/v2/notif", tags=["pgz_sport_notif"])
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
# ==============================================================================
# notif_router.py — Notification center API for /app#notif
# Author : Damir Radulić — dradulic@outlook.com / damir@rinet.one
# Version: 1.0.0
# Date : 2026-05-05
# Purpose: REST endpoints under /api/v2/notif/* powering the in-app
# notification center. Operates on pgz_sport.notifications table
# (extended by migrations/notifications_20260505.sql).
# Routes : GET /api/v2/notif/list?unread_only=&limit=&user_id=
# GET /api/v2/notif/count?user_id=
# POST /api/v2/notif/{id}/read
# POST /api/v2/notif/mark-all-read
# DELETE /api/v2/notif/{id}
# ==============================================================================
from __future__ import annotations
import os
from typing import Optional, List, Dict, Any
from fastapi import APIRouter, HTTPException, Query, Header
from pydantic import BaseModel
import psycopg2
import psycopg2.extras
# Reuse the v2 DB config (same as pgz_sport_v2_router)
DB = dict(
host="10.10.0.2",
port=6432,
dbname="rinet_v3",
user="rinet",
password=os.environ["DB_PASSWORD"],
)
router = APIRouter(prefix="/api/v2/notif", tags=["pgz_sport_notif"])
# ----------------------------- helpers ---------------------------------------
def _conn():
return psycopg2.connect(**DB)
def _row(r) -> Dict[str, Any]:
if r is None:
return {}
if isinstance(r, dict):
d = dict(r)
else:
d = dict(r)
# JSON-friendly conversions
for k, v in list(d.items()):
if hasattr(v, "isoformat"):
d[k] = v.isoformat()
return d
def _resolve_user_id(authorization: Optional[str]) -> Optional[int]:
"""Best-effort: pull user_id from Bearer token. Tries JWT (auth_v2) first
then falls back to legacy session-token-hash lookup."""
if not authorization:
return None
token = authorization.replace("Bearer ", "").strip()
if not token:
return None
# 1) Try JWT (preferred — current auth)
try:
from auth.auth_v2 import decode_token # type: ignore
payload = decode_token(token)
sub = payload.get("sub")
if sub is not None:
try:
return int(sub)
except (TypeError, ValueError):
pass
except Exception:
pass
# 2) Legacy: SHA-256 hash lookup in user_sessions
import hashlib
th = hashlib.sha256(token.encode()).hexdigest()
try:
with _conn() as c:
cur = c.cursor()
cur.execute(
"""SELECT user_id FROM pgz_sport.user_sessions
WHERE token_hash=%s AND revoked=false AND expires_at>now()
LIMIT 1""",
(th,),
)
r = cur.fetchone()
return r[0] if r else None
except Exception:
return None
# ----------------------------- LIST ------------------------------------------
@router.get("/list")
def notif_list(
unread_only: bool = Query(False),
limit: int = Query(50, ge=1, le=500),
user_id: Optional[int] = Query(None, description="Override (admin); else taken from JWT"),
authorization: Optional[str] = Header(None),
):
"""
Returns InApp notifications visible to the user. System-wide (user_id IS NULL)
rows are always included. If no auth + no user_id, returns only system-wide.
"""
uid = user_id if user_id is not None else _resolve_user_id(authorization)
where = ["channel = 'inapp'"]
params: List[Any] = []
if uid is not None:
where.append("(user_id = %s OR user_id IS NULL)")
params.append(uid)
else:
where.append("user_id IS NULL")
if unread_only:
where.append("COALESCE(is_read, false) = false")
sql = f"""
SELECT id, user_id, kind, title, COALESCE(subject, title) AS subject, body,
link, COALESCE(is_read, read_at IS NOT NULL) AS is_read,
status, created_at, read_at
FROM pgz_sport.notifications
WHERE {' AND '.join(where)}
ORDER BY created_at DESC NULLS LAST, id DESC
LIMIT %s
"""
params.append(limit)
with _conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, params)
rows = [_row(r) for r in cur.fetchall()]
return {"count": len(rows), "rows": rows, "user_id": uid}
# ----------------------------- COUNT -----------------------------------------
@router.get("/count")
def notif_count(
user_id: Optional[int] = Query(None),
authorization: Optional[str] = Header(None),
):
uid = user_id if user_id is not None else _resolve_user_id(authorization)
where = ["channel = 'inapp'"]
params: List[Any] = []
if uid is not None:
where.append("(user_id = %s OR user_id IS NULL)")
params.append(uid)
else:
where.append("user_id IS NULL")
sql = f"""
SELECT
COUNT(*) FILTER (WHERE COALESCE(is_read, read_at IS NOT NULL) = false) AS unread,
COUNT(*) AS total
FROM pgz_sport.notifications
WHERE {' AND '.join(where)}
"""
with _conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, params)
r = cur.fetchone() or {"unread": 0, "total": 0}
return {
"unread": int(r["unread"] or 0),
"total": int(r["total"] or 0),
"user_id": uid,
}
# ----------------------------- MARK ONE READ ---------------------------------
@router.post("/{nid}/read")
def notif_mark_read(nid: int):
with _conn() as conn:
cur = conn.cursor()
cur.execute(
"""UPDATE pgz_sport.notifications
SET is_read = true,
read_at = COALESCE(read_at, now()),
status = CASE WHEN status='pending' THEN 'sent' ELSE status END
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, "is_read": True}
# ----------------------------- MARK ALL READ ---------------------------------
class MarkAllIn(BaseModel):
user_id: Optional[int] = None
include_system: bool = True
@router.post("/mark-all-read")
def notif_mark_all_read(
body: Optional[MarkAllIn] = None,
authorization: Optional[str] = Header(None),
):
body = body or MarkAllIn()
uid = body.user_id if body.user_id is not None else _resolve_user_id(authorization)
where = ["channel = 'inapp'", "COALESCE(is_read, false) = false"]
params: List[Any] = []
if uid is not None:
if body.include_system:
where.append("(user_id = %s OR user_id IS NULL)")
else:
where.append("user_id = %s")
params.append(uid)
else:
where.append("user_id IS NULL")
with _conn() as conn:
cur = conn.cursor()
cur.execute(
f"""UPDATE pgz_sport.notifications
SET is_read = true,
read_at = COALESCE(read_at, now()),
status = CASE WHEN status='pending' THEN 'sent' ELSE status END
WHERE {' AND '.join(where)}
RETURNING id""",
params,
)
ids = [r[0] for r in cur.fetchall()]
conn.commit()
return {"ok": True, "marked_read": len(ids), "ids": ids[:500], "user_id": uid}
# ----------------------------- DELETE ----------------------------------------
@router.delete("/{nid}")
def notif_delete(nid: int):
with _conn() as conn:
cur = conn.cursor()
cur.execute(
"DELETE FROM pgz_sport.notifications 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, "deleted": True}
+129
View File
@@ -0,0 +1,129 @@
from dotenv import load_dotenv
load_dotenv('/opt/rinet-gpu/.env.master')
# auto-added by patch_scrapers_with_dotenv.sh
"""
Objekti Router — sportski objekti PGZ
Created: 2026-05-09 v4.0-final pre-K3s
Endpoints: GET /api/v2/objekti/list (paginated), /api/v2/objekti/{id}
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import Optional, List
import os, json
from psycopg2.extras import RealDictCursor
import psycopg2
router = APIRouter(prefix="/api/v2/objekti", tags=["objekti"])
def get_conn():
return psycopg2.connect(
host=os.getenv("DB_HOST", "10.10.0.2"),
port=int(os.getenv("DB_PORT", "6432")),
dbname=os.getenv("DB_NAME", "rinet_v3"),
user=os.getenv("DB_USER", "rinet"),
password=os.environ["DB_PASSWORD"]
)
@router.get("/list")
def list_objekti(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
grad: Optional[str] = None,
tip: Optional[str] = None,
region: Optional[str] = None,
search: Optional[str] = None,
):
"""List sportski objekti sa filtriranjem"""
where = ["aktivan = true"]
params = []
if grad:
where.append("grad ILIKE %s")
params.append(f"%{grad}%")
if tip:
where.append("tip = %s")
params.append(tip)
if region:
where.append("region ILIKE %s")
params.append(f"%{region}%")
if search:
where.append("fts @@ plainto_tsquery(\'simple\', %s)")
params.append(search)
where_sql = " AND ".join(where) if where else "true"
conn = get_conn()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
# Count
cur.execute(f"SELECT COUNT(*) FROM pgz_sport.objekti WHERE {where_sql}", params)
total = cur.fetchone()["count"]
# Data
cur.execute(f"""
SELECT id, naziv, tip, vlasnik, upravitelj, adresa, grad, region,
kapacitet, godina_izgradnje, geo_lat, geo_lng, koristi_se_za,
email, telefon, web, aktivan, pristupacan_invalidi
FROM pgz_sport.objekti
WHERE {where_sql}
ORDER BY grad, naziv
LIMIT %s OFFSET %s
""", params + [limit, skip])
items = cur.fetchall()
return {
"items": items,
"total": total,
"skip": skip,
"limit": limit
}
finally:
conn.close()
@router.get("/{objekt_id}")
def get_objekt(objekt_id: int):
"""Detail jednog objekta"""
conn = get_conn()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT * FROM pgz_sport.objekti WHERE id = %s", (objekt_id,))
r = cur.fetchone()
if not r:
raise HTTPException(status_code=404, detail="Objekt not found")
return r
finally:
conn.close()
@router.get("/stats/by-tip")
def stats_by_tip():
"""Stats: count objekata po tip"""
conn = get_conn()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT tip, COUNT(*) as count
FROM pgz_sport.objekti
WHERE aktivan = true
GROUP BY tip
ORDER BY 2 DESC
""")
return {"items": cur.fetchall()}
finally:
conn.close()
@router.get("/stats/by-region")
def stats_by_region():
"""Stats: count objekata po region"""
conn = get_conn()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT region, COUNT(*) as count
FROM pgz_sport.objekti
WHERE aktivan = true AND region IS NOT NULL
GROUP BY region
ORDER BY 2 DESC
""")
return {"items": cur.fetchall()}
finally:
conn.close()
@@ -0,0 +1,126 @@
"""
Objekti Router — sportski objekti PGZ
Created: 2026-05-09 v4.0-final pre-K3s
Endpoints: GET /api/v2/objekti/list (paginated), /api/v2/objekti/{id}
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import Optional, List
import os, json
from psycopg2.extras import RealDictCursor
import psycopg2
router = APIRouter(prefix="/api/v2/objekti", tags=["objekti"])
def get_conn():
return psycopg2.connect(
host=os.getenv("DB_HOST", "10.10.0.2"),
port=int(os.getenv("DB_PORT", "6432")),
dbname=os.getenv("DB_NAME", "rinet_v3"),
user=os.getenv("DB_USER", "rinet"),
password=os.environ["DB_PASSWORD"]
)
@router.get("/list")
def list_objekti(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
grad: Optional[str] = None,
tip: Optional[str] = None,
region: Optional[str] = None,
search: Optional[str] = None,
):
"""List sportski objekti sa filtriranjem"""
where = ["aktivan = true"]
params = []
if grad:
where.append("grad ILIKE %s")
params.append(f"%{grad}%")
if tip:
where.append("tip = %s")
params.append(tip)
if region:
where.append("region ILIKE %s")
params.append(f"%{region}%")
if search:
where.append("fts @@ plainto_tsquery(\'simple\', %s)")
params.append(search)
where_sql = " AND ".join(where) if where else "true"
conn = get_conn()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
# Count
cur.execute(f"SELECT COUNT(*) FROM pgz_sport.objekti WHERE {where_sql}", params)
total = cur.fetchone()["count"]
# Data
cur.execute(f"""
SELECT id, naziv, tip, vlasnik, upravitelj, adresa, grad, region,
kapacitet, godina_izgradnje, geo_lat, geo_lng, koristi_se_za,
email, telefon, web, aktivan, pristupacan_invalidi
FROM pgz_sport.objekti
WHERE {where_sql}
ORDER BY grad, naziv
LIMIT %s OFFSET %s
""", params + [limit, skip])
items = cur.fetchall()
return {
"items": items,
"total": total,
"skip": skip,
"limit": limit
}
finally:
conn.close()
@router.get("/{objekt_id}")
def get_objekt(objekt_id: int):
"""Detail jednog objekta"""
conn = get_conn()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT * FROM pgz_sport.objekti WHERE id = %s", (objekt_id,))
r = cur.fetchone()
if not r:
raise HTTPException(status_code=404, detail="Objekt not found")
return r
finally:
conn.close()
@router.get("/stats/by-tip")
def stats_by_tip():
"""Stats: count objekata po tip"""
conn = get_conn()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT tip, COUNT(*) as count
FROM pgz_sport.objekti
WHERE aktivan = true
GROUP BY tip
ORDER BY 2 DESC
""")
return {"items": cur.fetchall()}
finally:
conn.close()
@router.get("/stats/by-region")
def stats_by_region():
"""Stats: count objekata po region"""
conn = get_conn()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT region, COUNT(*) as count
FROM pgz_sport.objekti
WHERE aktivan = true AND region IS NOT NULL
GROUP BY region
ORDER BY 2 DESC
""")
return {"items": cur.fetchall()}
finally:
conn.close()
+5 -1
View File
@@ -1,4 +1,8 @@
#!/usr/bin/env python3
from dotenv import load_dotenv
load_dotenv('/opt/rinet-gpu/.env.master')
# auto-added by patch_scrapers_with_dotenv.sh
import os
# ═══════════════════════════════════════════════════════════════════
# Fajl: routers/obrasci_router.py | v1.0.0 | 04.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
@@ -37,7 +41,7 @@ from pydantic import BaseModel
router = APIRouter(prefix="/api/crm", tags=["crm-obrasci"])
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
DSN = f"host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password={os.environ['DB_PASSWORD']}"
def _conn():
@@ -0,0 +1,764 @@
#!/usr/bin/env python3
import os
# ═══════════════════════════════════════════════════════════════════
# Fajl: routers/obrasci_router.py | v1.0.0 | 04.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
# Lokacija: /opt/pgz-sport/routers/obrasci_router.py
# Svrha: M9 — Obrasci za sufinanciranje (form_templates + form_submissions)
# + autopopulacija polja iz baze + digitalni potpis (sha256)
# ═══════════════════════════════════════════════════════════════════
"""M9 Obrasci router.
Endpointi (montirani na /api/crm):
GET /forms → katalog form_templates
GET /forms/{code_or_id} → schema + ui hints
GET /forms/{code_or_id}/prefill → autopopulirane vrijednosti za klub/člana
GET /forms/submissions → lista submissiona (filter: status, klub, code)
POST /forms/submissions → kreira draft submission
GET /forms/submissions/{id} → detalji
POST /forms/submissions/{id}/submit → potpis + status submitted
POST /forms/submissions/{id}/approve
POST /forms/submissions/{id}/reject
POST /forms/{code_or_id}/submit → kompatibilni shortcut: kreiraj+submit u jednom POST
"""
from __future__ import annotations
import json
import hashlib
import sys
from datetime import date, datetime
from decimal import Decimal
from typing import Optional, Any
import uuid as _uuid
import psycopg2
from psycopg2.extras import RealDictCursor, Json
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
router = APIRouter(prefix="/api/crm", tags=["crm-obrasci"])
DSN = f"host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password={os.environ['DB_PASSWORD']}"
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)
if isinstance(v, _uuid.UUID):
return str(v)
return v
def _row(d):
return {k: _conv(v) for k, v in dict(d).items()}
def _resolve_template(code_or_id: str, cur) -> dict:
"""Akceptira numerički ID ili code string."""
if str(code_or_id).isdigit():
cur.execute("SELECT * FROM pgz_sport.form_templates WHERE id=%s AND active=TRUE",
(int(code_or_id),))
else:
cur.execute("SELECT * FROM pgz_sport.form_templates WHERE code=%s AND active=TRUE",
(code_or_id,))
r = cur.fetchone()
if not r:
raise HTTPException(404, f"Form template '{code_or_id}' ne postoji")
return r
# ───────────── modeli ─────────────
class SubmissionIn(BaseModel):
template_code: Optional[str] = None
template_id: Optional[int] = None
klub_id: Optional[int] = None
user_id: Optional[int] = None
clan_id: Optional[int] = None
data: dict = {}
attachments: Optional[list] = None
status: Optional[str] = "draft"
class SubmitIn(BaseModel):
user_id: Optional[int] = None
full_name: Optional[str] = None
data: Optional[dict] = None
confirm: bool = True
class ApproveIn(BaseModel):
user_id: Optional[int] = None
note: Optional[str] = None
class RejectIn(BaseModel):
user_id: Optional[int] = None
reason: str
# ───────────── katalog templata ─────────────
@router.get("/forms/templates")
def list_form_templates_alias(
kategorija: Optional[str] = Query(None),
q: Optional[str] = Query(None),
active_only: bool = Query(True),
):
"""Alias za /forms — kompatibilnost s /sport/api/forms/templates."""
return list_forms(kategorija=kategorija, q=q, active_only=active_only)
@router.get("/forms")
def list_forms(
kategorija: Optional[str] = Query(None),
q: 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)
if q:
where.append("(naziv ILIKE %s OR opis ILIKE %s OR code ILIKE %s)")
params += [f"%{q}%"] * 3
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, opis, required_role,
jsonb_array_length(COALESCE(schema_json->'fields', '[]'::jsonb)) AS field_count,
active, created_at
FROM pgz_sport.form_templates
{where_sql}
ORDER BY kategorija NULLS LAST, naziv
""", params)
rows = [_row(r) for r in cur.fetchall()]
cur.execute("SELECT DISTINCT kategorija FROM pgz_sport.form_templates WHERE kategorija IS NOT NULL ORDER BY 1")
kats = [r["kategorija"] for r in cur.fetchall()]
return {"count": len(rows), "kategorije": kats, "forms": rows}
# NOTE: /forms/submissions* moraju biti registrirani PRIJE /forms/{code_or_id}
# jer FastAPI prvo provjerava redom registracije, a "submissions" bi
# inače bilo uhvaćeno kao code_or_id.
# ───────────── autopopulacija polja iz baze (mora prije /{code_or_id} catch-all) ─────────────
@router.get("/forms/{code_or_id}/prefill")
def prefill_form(code_or_id: str,
klub_id: Optional[int] = Query(None),
clan_id: Optional[int] = Query(None),
user_id: Optional[int] = Query(None)):
"""
Vraća inicijalne vrijednosti za polja obrasca, popunjene iz baze.
Mapiranje polja → izvor:
klub_naziv, klub_oib, klub_iban, klub_adresa, klub_grad, klub_email, klub_telefon,
predsjednik, tajnik, sport, savez_naziv → pgz_sport.klubovi
ime, prezime, oib_clan, datum_rodenja, kategorija → pgz_sport.clanovi
iban, naziv (kad se odnose na klub) → klub
*_godina → tekuća godina
Polja koja schema_json nema, neće biti vraćena.
"""
with _conn() as conn, conn.cursor() as cur:
t = _resolve_template(code_or_id, cur)
schema = t.get("schema_json") or {}
fields = schema.get("fields") or []
field_names = {f.get("name") for f in fields if isinstance(f, dict)}
klub = {}
savez = {}
if klub_id:
cur.execute("""
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,))
r = cur.fetchone()
if r:
klub = _row(r)
clan = {}
if clan_id:
cur.execute("SELECT * FROM pgz_sport.clanovi WHERE id=%s", (clan_id,))
r = cur.fetchone()
if r:
clan = _row(r)
# ako klub_id nije eksplicitno, izvuci iz člana
if not klub and clan.get("klub_id"):
cur.execute("""
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
""", (clan["klub_id"],))
rr = cur.fetchone()
if rr:
klub = _row(rr)
user = {}
if user_id:
cur.execute("SELECT id, email, full_name, ime, prezime, oib, telefon, klub_id, savez_id, user_type FROM pgz_sport.users WHERE id=%s",
(user_id,))
r = cur.fetchone()
if r:
user = _row(r)
# Mapiranje
prefill: dict = {}
today = date.today()
def put(name: str, value: Any):
if name in field_names and value not in (None, ""):
prefill[name] = value
# KLUB → polja
if klub:
put("klub_naziv", klub.get("naziv"))
put("naziv_kluba", klub.get("naziv"))
put("naziv", klub.get("naziv"))
put("klub_oib", klub.get("oib"))
put("oib", klub.get("oib"))
put("oib_kluba", klub.get("oib"))
put("klub_iban", klub.get("iban"))
put("iban", klub.get("iban"))
put("adresa", klub.get("adresa"))
put("klub_adresa", klub.get("adresa"))
put("grad", klub.get("grad"))
put("klub_grad", klub.get("grad"))
put("klub_email", klub.get("email"))
put("email", klub.get("email"))
put("klub_telefon", klub.get("telefon"))
put("telefon", klub.get("telefon"))
put("predsjednik", klub.get("predsjednik"))
put("tajnik", klub.get("tajnik"))
put("sport", klub.get("sport"))
put("savez_naziv", klub.get("savez_naziv"))
put("godina_osnutka", klub.get("godina_osnutka"))
put("matični_broj", klub.get("matični_broj"))
put("reg_broj", klub.get("reg_broj"))
# ČLAN → polja
if clan:
put("ime", clan.get("ime"))
put("prezime", clan.get("prezime"))
put("ime_prezime", f"{clan.get('ime','')} {clan.get('prezime','')}".strip())
put("oib_clan", clan.get("oib"))
put("oib_sportasa", clan.get("oib"))
put("datum_rodenja", clan.get("datum_rodenja"))
put("kategorija", clan.get("kategorija"))
put("podkategorija", clan.get("podkategorija"))
put("pozicija", clan.get("pozicija"))
put("clan_email", clan.get("email"))
put("clan_telefon", clan.get("telefon"))
put("clan_adresa", clan.get("adresa"))
put("spol", clan.get("spol"))
put("licenca_broj", clan.get("licenca_broj"))
# USER → polja
if user:
put("podnositelj_ime", (user.get("full_name") or
f"{user.get('ime','')} {user.get('prezime','')}".strip()))
put("podnositelj_email", user.get("email"))
put("podnositelj_telefon", user.get("telefon"))
# TEKUĆA GODINA / DATUM
put("program_godina", today.year)
put("godina", today.year)
put("datum", today.isoformat())
put("datum_predaje", today.isoformat())
return {
"template_code": t["code"],
"template_id": t["id"],
"naziv": t["naziv"],
"prefill": prefill,
"missing_fields": sorted(field_names - set(prefill.keys())),
"applied_fields": sorted(prefill.keys()),
"sources": {"klub": bool(klub), "clan": bool(clan), "user": bool(user)},
}
# ───────────── submissions ─────────────
@router.get("/forms/submissions")
def list_submissions(
klub_id: Optional[int] = Query(None),
template_code: Optional[str] = Query(None),
status: Optional[str] = Query(None),
user_id: Optional[int] = Query(None),
limit: int = Query(200, le=1000),
):
where, params = [], []
if klub_id:
where.append("s.klub_id=%s"); params.append(klub_id)
if template_code:
where.append("s.template_code=%s"); params.append(template_code)
if status:
where.append("s.status=%s"); params.append(status)
if user_id:
where.append("s.user_id=%s"); params.append(user_id)
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
params.append(limit)
sql = f"""
SELECT s.id, s.template_id, s.template_code, s.klub_id, s.user_id,
s.clan_id, s.status, s.reference_no, s.submitted_at,
s.reviewed_at, s.approved_at, s.rejected_reason, s.created_at,
t.naziv AS template_naziv, t.kategorija,
k.naziv AS klub_naziv,
cl.ime || ' ' || cl.prezime AS clan_naziv,
COALESCE(s.data->>'__signature_sha256', NULL) AS signature_sha256
FROM pgz_sport.form_submissions s
LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id
LEFT JOIN pgz_sport.klubovi k ON k.id = s.klub_id
LEFT JOIN pgz_sport.clanovi cl ON cl.id = s.clan_id
{where_sql}
ORDER BY s.created_at DESC
LIMIT %s
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute(sql, params)
rows = [_row(r) for r in cur.fetchall()]
cur.execute(f"""
SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE s.status='draft') AS draft,
COUNT(*) FILTER (WHERE s.status='submitted') AS submitted,
COUNT(*) FILTER (WHERE s.status='approved') AS approved,
COUNT(*) FILTER (WHERE s.status='rejected') AS rejected
FROM pgz_sport.form_submissions s
{where_sql}
""", params[:-1])
summary = _row(cur.fetchone() or {})
return {"count": len(rows), "rows": rows, "summary": summary}
@router.get("/forms/submissions/{sid}")
def get_submission(sid: int):
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
SELECT s.*, t.naziv AS template_naziv, t.kategorija, t.schema_json,
k.naziv AS klub_naziv, k.oib AS klub_oib, k.iban AS klub_iban,
cl.ime || ' ' || cl.prezime AS clan_naziv
FROM pgz_sport.form_submissions s
LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id
LEFT JOIN pgz_sport.klubovi k ON k.id = s.klub_id
LEFT JOIN pgz_sport.clanovi cl ON cl.id = s.clan_id
WHERE s.id = %s
""", (sid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Submission ne postoji")
return _row(r)
@router.post("/forms/submissions")
def create_submission(body: SubmissionIn):
if not (body.template_code or body.template_id):
raise HTTPException(400, "template_code ili template_id obavezan")
with _conn() as conn, conn.cursor() as cur:
if body.template_id:
cur.execute("SELECT * FROM pgz_sport.form_templates WHERE id=%s", (body.template_id,))
else:
cur.execute("SELECT * FROM pgz_sport.form_templates WHERE code=%s", (body.template_code,))
t = cur.fetchone()
if not t:
raise HTTPException(404, "Template ne postoji")
# generiraj reference_no: TPL-YYYY-XXXXXXXX
ref = f"{t['code'][:8].upper()}-{date.today().year}-{_uuid.uuid4().hex[:8].upper()}"
cur.execute("""
INSERT INTO pgz_sport.form_submissions
(template_id, template_code, klub_id, user_id, clan_id, data,
attachments, status, reference_no)
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s,%s)
RETURNING *
""", (t["id"], t["code"], body.klub_id, body.user_id, body.clan_id,
json.dumps(body.data or {}), json.dumps(body.attachments or []),
body.status or "draft", ref))
s = cur.fetchone()
conn.commit()
return _row(s)
# ───────────── digitalni potpis (sha256) i submit ─────────────
def _sign_payload(data: dict, signer: Optional[str]) -> dict:
"""
Deterministički sha256 nad sortiranim JSON-om + timestamp.
Vraća meta polja koja se ubacuju u data:
__signature_sha256, __signed_at, __signed_by
"""
canon = json.dumps(data, sort_keys=True, ensure_ascii=False, default=str)
sha = hashlib.sha256(canon.encode("utf-8")).hexdigest()
return {
"__signature_sha256": sha,
"__signed_at": datetime.utcnow().isoformat() + "Z",
"__signed_by": signer or "unknown",
}
@router.post("/forms/submissions/{sid}/submit")
def submit_submission(sid: int, body: SubmitIn):
if not body.confirm:
raise HTTPException(400, "Potrebna potvrda (confirm=true)")
with _conn() as conn, conn.cursor() as cur:
cur.execute("SELECT * FROM pgz_sport.form_submissions WHERE id=%s", (sid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Submission ne postoji")
if r["status"] not in ("draft", "rejected"):
raise HTTPException(400, f"Submission je u statusu '{r['status']}', ne može se submitati")
merged = dict(r["data"] or {})
if body.data:
merged.update(body.data)
# ukloni stari potpis prije računanja novog
for k in list(merged.keys()):
if k.startswith("__signature") or k.startswith("__signed"):
merged.pop(k, None)
signer = body.full_name or (str(body.user_id) if body.user_id else None)
sig = _sign_payload(merged, signer)
merged.update(sig)
cur.execute("""
UPDATE pgz_sport.form_submissions
SET data = %s::jsonb,
status = 'submitted',
user_id = COALESCE(%s, user_id),
submitted_at = now(),
updated_at = now()
WHERE id = %s
RETURNING *
""", (json.dumps(merged), body.user_id, sid))
s = cur.fetchone()
conn.commit()
return {
"ok": True,
"id": sid,
"status": "submitted",
"signature_sha256": sig["__signature_sha256"],
"signed_at": sig["__signed_at"],
"signed_by": sig["__signed_by"],
"submission": _row(s),
}
@router.post("/forms/submissions/{sid}/approve")
def approve_submission(sid: int, body: ApproveIn):
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
UPDATE pgz_sport.form_submissions
SET status='approved',
approved_by=%s, approved_at=now(),
reviewed_by=%s, reviewed_at=now(),
updated_at=now()
WHERE id=%s AND status IN ('submitted','draft')
RETURNING *
""", (body.user_id, body.user_id, sid))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Submission ne postoji ili nije u submitted statusu")
conn.commit()
return {"ok": True, "id": sid, "status": "approved", "submission": _row(r)}
@router.post("/forms/submissions/{sid}/reject")
def reject_submission(sid: int, body: RejectIn):
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
UPDATE pgz_sport.form_submissions
SET status='rejected',
reviewed_by=%s, reviewed_at=now(),
rejected_reason=%s,
updated_at=now()
WHERE id=%s AND status IN ('submitted','draft')
RETURNING *
""", (body.user_id, body.reason, sid))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Submission ne postoji ili nije u submitted statusu")
conn.commit()
return {"ok": True, "id": sid, "status": "rejected",
"reason": body.reason, "submission": _row(r)}
# ───────────── potpisivanje + PDF izvoz submissiona ─────────────
class SignIn(BaseModel):
user_id: Optional[int] = None
full_name: Optional[str] = None
@router.post("/forms/submissions/{sid}/sign")
def sign_submission(sid: int, body: SignIn):
"""
Digitalni potpis postojećeg submissiona — sha256 nad sortiranim JSON-om.
Može se pozvati i na već submitanom (re-sign) i na draftu (samo potpisuje,
ne mijenja status).
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute("SELECT * FROM pgz_sport.form_submissions WHERE id=%s", (sid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Submission ne postoji")
merged = dict(r["data"] or {})
# ukloni stari potpis
for k in list(merged.keys()):
if k.startswith("__signature") or k.startswith("__signed"):
merged.pop(k, None)
signer = body.full_name or (str(body.user_id) if body.user_id else "anonymous")
sig = _sign_payload(merged, signer)
merged.update(sig)
cur.execute("""
UPDATE pgz_sport.form_submissions
SET data = %s::jsonb,
user_id = COALESCE(%s, user_id),
updated_at = now()
WHERE id = %s
RETURNING *
""", (json.dumps(merged), body.user_id, sid))
s = cur.fetchone()
conn.commit()
return {
"ok": True,
"id": sid,
"signature_sha256": sig["__signature_sha256"],
"signed_at": sig["__signed_at"],
"signed_by": sig["__signed_by"],
"submission": _row(s),
}
@router.get("/forms/submissions/{sid}/pdf")
def submission_pdf(sid: int):
"""Generira PDF s sadržajem submissiona, statusom i potpisom (sha256)."""
from fastapi.responses import Response
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
import io as _io
# font za HR diakritike
font_reg, font_bold = "Helvetica", "Helvetica-Bold"
try:
if "DejaVu" not in pdfmetrics.getRegisteredFontNames():
for path in ("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf"):
try:
pdfmetrics.registerFont(TTFont("DejaVu", path))
pdfmetrics.registerFont(TTFont("DejaVu-Bold",
path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")))
font_reg, font_bold = "DejaVu", "DejaVu-Bold"
break
except Exception:
continue
else:
font_reg, font_bold = "DejaVu", "DejaVu-Bold"
except Exception:
pass
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
SELECT s.*, t.naziv AS template_naziv, t.kategorija, t.schema_json,
k.naziv AS klub_naziv, k.oib AS klub_oib, k.iban AS klub_iban,
cl.ime || ' ' || cl.prezime AS clan_naziv
FROM pgz_sport.form_submissions s
LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id
LEFT JOIN pgz_sport.klubovi k ON k.id = s.klub_id
LEFT JOIN pgz_sport.clanovi cl ON cl.id = s.clan_id
WHERE s.id = %s
""", (sid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Submission ne postoji")
s = _row(r)
schema = s.get("schema_json") or {}
fields = schema.get("fields") or []
data = s.get("data") or {}
sig_sha = data.get("__signature_sha256")
sig_at = data.get("__signed_at")
sig_by = data.get("__signed_by")
buf = _io.BytesIO()
c = canvas.Canvas(buf, pagesize=A4)
W, H = A4
y = H - 18 * mm
# Header bar
c.setFillColorRGB(0.13, 0.20, 0.32)
c.rect(0, H - 22 * mm, W, 22 * mm, fill=1, stroke=0)
c.setFillColorRGB(1, 1, 1)
c.setFont(font_bold, 14)
c.drawString(15 * mm, H - 12 * mm, "PGŽ SPORT — OBRAZAC")
c.setFont(font_reg, 10)
c.drawString(15 * mm, H - 18 * mm, str(s.get("template_naziv") or s.get("template_code") or ""))
c.drawRightString(W - 15 * mm, H - 12 * mm, f"REF: {s.get('reference_no') or ''}")
c.drawRightString(W - 15 * mm, H - 18 * mm,
f"Status: {s.get('status','').upper()}")
y = H - 30 * mm
c.setFillColorRGB(0, 0, 0)
# Meta
def line(label, value, bold=False):
nonlocal y
if y < 25 * mm:
c.showPage()
y = H - 20 * mm
c.setFillColorRGB(0, 0, 0)
c.setFont(font_reg, 8)
c.setFillColorRGB(0.45, 0.45, 0.45)
c.drawString(15 * mm, y, label)
c.setFont(font_bold if bold else font_reg, 10)
c.setFillColorRGB(0, 0, 0)
v = "" if value is None else str(value)
# wrap
max_w = W - 30 * mm
while v:
chunk = v
while pdfmetrics.stringWidth(chunk, font_bold if bold else font_reg, 10) > max_w and len(chunk) > 5:
chunk = chunk[:-2]
c.drawString(15 * mm, y - 4 * mm, chunk)
v = v[len(chunk):].lstrip() if len(chunk) < len(v) else ""
y -= 5 * mm
if v:
if y < 25 * mm:
c.showPage(); y = H - 20 * mm
y -= 3 * mm
line("KLUB", s.get("klub_naziv"), bold=True)
line("OIB KLUBA", s.get("klub_oib"))
line("IBAN KLUBA", s.get("klub_iban"))
if s.get("clan_naziv"):
line("ČLAN/SPORTAŠ", s.get("clan_naziv"))
line("DATUM PREDAJE", s.get("submitted_at") or s.get("created_at"))
line("STATUS", s.get("status"), bold=True)
# Section divider
y -= 4 * mm
c.setStrokeColorRGB(0.13, 0.20, 0.32)
c.setLineWidth(0.6)
c.line(15 * mm, y, W - 15 * mm, y)
y -= 6 * mm
c.setFont(font_bold, 11)
c.setFillColorRGB(0.13, 0.20, 0.32)
c.drawString(15 * mm, y, "SADRŽAJ OBRASCA")
y -= 8 * mm
c.setFillColorRGB(0, 0, 0)
# Polja iz schema_json (skip meta __keys)
if fields:
for f in fields:
name = f.get("name")
if not name or name.startswith("__"):
continue
label = f.get("label") or name
val = data.get(name)
line(label, val)
else:
# fallback — sve ključeve iz data
for k, v in data.items():
if k.startswith("__"):
continue
line(k, v)
# Potpis
y -= 6 * mm
if y < 50 * mm:
c.showPage(); y = H - 20 * mm
c.setFillColorRGB(0.13, 0.20, 0.32)
c.setStrokeColorRGB(0.13, 0.20, 0.32)
c.setLineWidth(0.6)
c.line(15 * mm, y, W - 15 * mm, y)
y -= 6 * mm
c.setFont(font_bold, 11)
c.drawString(15 * mm, y, "DIGITALNI POTPIS")
y -= 8 * mm
c.setFillColorRGB(0, 0, 0)
if sig_sha:
line("Potpisao", sig_by or "")
line("Vrijeme potpisa (UTC)", sig_at or "")
line("SHA-256 hash sadržaja", sig_sha)
line("Verifikacija",
"PGŽ Sport ERP/CRM — hash izračunat nad sortiranim JSON sadržajem (bez __* polja).")
else:
c.setFont(font_reg, 9)
c.setFillColorRGB(0.7, 0.3, 0.3)
c.drawString(15 * mm, y, "Obrazac NIJE digitalno potpisan.")
y -= 6 * mm
# Footer
c.setFont(font_reg, 7)
c.setFillColorRGB(0.55, 0.55, 0.55)
c.drawString(15 * mm, 10 * mm,
f"PGŽ Sport ERP/CRM • Generirano {datetime.now().strftime('%d.%m.%Y. %H:%M')} • REF {s.get('reference_no') or sid}")
c.save()
pdf = buf.getvalue()
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition":
f"inline; filename=obrazac-{sid}.pdf"})
# ───────────── /forms/{code_or_id} (catch-all GET — mora biti POSLIJE submissions!) ─────────────
@router.get("/forms/{code_or_id}")
def get_form(code_or_id: str):
with _conn() as conn, conn.cursor() as cur:
t = _resolve_template(code_or_id, cur)
return _row(t)
# ───────────── shortcut: kreiraj+submit u jednom ─────────────
@router.post("/forms/{code_or_id}/submit")
def quick_submit(code_or_id: str, body: SubmissionIn):
"""Kompatibilni shortcut — kreira draft + odmah submita s potpisom."""
with _conn() as conn, conn.cursor() as cur:
t = _resolve_template(code_or_id, cur)
ref = f"{t['code'][:8].upper()}-{date.today().year}-{_uuid.uuid4().hex[:8].upper()}"
merged = dict(body.data or {})
signer = str(body.user_id) if body.user_id else "anonymous"
sig = _sign_payload(merged, signer)
merged.update(sig)
cur.execute("""
INSERT INTO pgz_sport.form_submissions
(template_id, template_code, klub_id, user_id, clan_id, data,
attachments, status, reference_no, submitted_at)
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,'submitted',%s, now())
RETURNING *
""", (t["id"], t["code"], body.klub_id, body.user_id, body.clan_id,
json.dumps(merged), json.dumps(body.attachments or []), ref))
s = cur.fetchone()
conn.commit()
try:
from erp.audit_helper import audit as _audit
_audit("pgz_sport.form_submissions", "submit", s["id"],
korisnik=str(body.user_id or "anonymous"),
field="signature_sha256", new=sig["__signature_sha256"][:64])
except Exception: pass
return {
"ok": True,
"id": s["id"],
"reference_no": s["reference_no"],
"status": "submitted",
"signature_sha256": sig["__signature_sha256"],
"signed_at": sig["__signed_at"],
"submission": _row(s),
}
+4 -1
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env python3
from dotenv import load_dotenv
load_dotenv('/opt/rinet-gpu/.env.master')
# auto-added by patch_scrapers_with_dotenv.sh
# routers/ocr_router.py
# Name: PGŽ Sport OCR router (lightweight)
# Version: 1.0.0
@@ -58,7 +61,7 @@ DB = dict(
port=6432,
dbname="rinet_v3",
user="rinet",
password="R1net2026!SecureDB#v7",
password=os.environ["DB_PASSWORD"],
)
UPLOAD_DIR = Path("/opt/pgz-sport/uploads/ocr")
+403
View File
@@ -0,0 +1,403 @@
#!/usr/bin/env python3
# routers/ocr_router.py
# Name: PGŽ Sport OCR router (lightweight)
# Version: 1.0.0
# Authors: Damir Radulić <dradulic@outlook.com> / <damir@rinet.one>
# Date: 2026-05-05
# Description: FastAPI APIRouter exposing POST /api/ocr/upload and
# GET /api/ocr/health. Accepts PDF/JPG/PNG, runs Tesseract
# (pdf2image for PDF), extracts vendor / OIB / invoice_no /
# date / amount via simple regex, persists into
# pgz_sport.invoice_uploads when possible. Designed to
# degrade gracefully if pytesseract / pdf2image are not
# installed (returns ocr_status='ocr_unavailable').
from __future__ import annotations
import os
import re
import io
import hashlib
import json
import traceback
from pathlib import Path
from datetime import datetime
from typing import Optional, Tuple, Dict, Any, List
from fastapi import APIRouter, UploadFile, File, HTTPException
from fastapi.responses import JSONResponse
import psycopg2
import psycopg2.extras
# ── Optional OCR deps ────────────────────────────────────────────────────────
_TESS_OK = False
_PDF2IMG_OK = False
_PIL_OK = False
try:
import pytesseract # type: ignore
_TESS_OK = True
except Exception:
pytesseract = None # type: ignore
try:
from pdf2image import convert_from_bytes # type: ignore
_PDF2IMG_OK = True
except Exception:
convert_from_bytes = None # type: ignore
try:
from PIL import Image # type: ignore
_PIL_OK = True
except Exception:
Image = None # type: ignore
# ── Config ───────────────────────────────────────────────────────────────────
DB = dict(
host="10.10.0.2",
port=6432,
dbname="rinet_v3",
user="rinet",
password=os.environ["DB_PASSWORD"],
)
UPLOAD_DIR = Path("/opt/pgz-sport/uploads/ocr")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png"}
ALLOWED_MIME = {
"application/pdf",
"image/jpeg",
"image/jpg",
"image/png",
}
MAX_BYTES = 25 * 1024 * 1024 # 25 MB
TEXT_CAP = 8 * 1024 # 8 KB cap for response text payload
router = APIRouter(prefix="/api/ocr", tags=["ocr"])
# ── DB helpers ───────────────────────────────────────────────────────────────
def _db():
c = psycopg2.connect(**DB)
c.autocommit = True
return c
def _table_columns(schema: str, table: str) -> List[str]:
try:
with _db() as c, c.cursor() as cur:
cur.execute(
"""
SELECT column_name FROM information_schema.columns
WHERE table_schema = %s AND table_name = %s
""",
(schema, table),
)
return [r[0] for r in cur.fetchall()]
except Exception:
return []
# ── Regex extractors ─────────────────────────────────────────────────────────
RE_OIB_HR = re.compile(r"\bHR\s*(\d{11})\b")
RE_OIB_BARE = re.compile(r"\b(\d{11})\b")
RE_INVOICE = re.compile(
r"(?im)^.*\b(?:Ra[čc]un|Invoice)\b[^\n\r]{0,80}$"
)
RE_DATE_DMY = re.compile(r"\b(\d{2})[./](\d{2})[./](\d{4})\b")
RE_DATE_YMD = re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b")
# Amount candidates (1.234,56 or 1234,56 or 1234.56 or 1,234.56), at least 2 digits
RE_AMOUNT = re.compile(
r"(?<![\w.,])"
r"(\d{1,3}(?:[.\s]\d{3})+,\d{2}|\d+,\d{2}|\d{1,3}(?:,\d{3})+\.\d{2}|\d+\.\d{2})"
r"(?![\w])"
)
def _norm_amount(raw: str) -> Optional[float]:
s = raw.strip().replace(" ", "")
# If both . and , present, assume , decimal if last separator is ,
if "," in s and "." in s:
if s.rfind(",") > s.rfind("."):
s = s.replace(".", "").replace(",", ".")
else:
s = s.replace(",", "")
elif "," in s:
# 1.234,56 or 1234,56 → swap
s = s.replace(".", "").replace(",", ".")
try:
return float(s)
except Exception:
return None
def _first_nonempty_line(text: str) -> Optional[str]:
for ln in (text or "").splitlines():
v = ln.strip()
if v:
return v[:200]
return None
def _parse_date(text: str) -> Optional[str]:
m = RE_DATE_YMD.search(text or "")
if m:
try:
return datetime(int(m.group(1)), int(m.group(2)), int(m.group(3))).date().isoformat()
except Exception:
pass
m = RE_DATE_DMY.search(text or "")
if m:
try:
return datetime(int(m.group(3)), int(m.group(2)), int(m.group(1))).date().isoformat()
except Exception:
pass
return None
def _parse_oib(text: str) -> Optional[str]:
m = RE_OIB_HR.search(text or "")
if m:
return m.group(1)
m = RE_OIB_BARE.search(text or "")
if m:
return m.group(1)
return None
def _parse_invoice_no(text: str) -> Optional[str]:
m = RE_INVOICE.search(text or "")
if not m:
return None
line = m.group(0).strip()
# Try to grab the right-most token that looks like an invoice id
cand = re.findall(r"[A-Z0-9][A-Z0-9\-/_.]{1,40}", line)
if cand:
# Drop pure words like "Račun"/"Invoice"
for c in reversed(cand):
if any(ch.isdigit() for ch in c):
return c[:64]
return line[:120]
def _parse_amount(text: str) -> Optional[float]:
if not text:
return None
best: Optional[float] = None
for m in RE_AMOUNT.finditer(text):
v = _norm_amount(m.group(1))
if v is None:
continue
if best is None or v > best:
best = v
return best
def _extract_fields(text: str) -> Dict[str, Any]:
return {
"vendor": _first_nonempty_line(text),
"oib": _parse_oib(text),
"invoice_no": _parse_invoice_no(text),
"date": _parse_date(text),
"amount": _parse_amount(text),
}
# ── OCR engine ───────────────────────────────────────────────────────────────
def _ocr_image_bytes(data: bytes) -> Tuple[Optional[str], Optional[float]]:
if not (_TESS_OK and _PIL_OK):
return None, None
try:
img = Image.open(io.BytesIO(data))
img.load()
text = pytesseract.image_to_string(img, lang=os.getenv("OCR_LANG", "hrv+eng"))
# Confidence (best-effort)
conf = None
try:
d = pytesseract.image_to_data(img, output_type=pytesseract.Output.DICT,
lang=os.getenv("OCR_LANG", "hrv+eng"))
confs = [int(c) for c in d.get("conf", []) if str(c).lstrip("-").isdigit() and int(c) >= 0]
if confs:
conf = round(sum(confs) / len(confs), 2)
except Exception:
pass
return text, conf
except Exception:
return None, None
def _ocr_pdf_bytes(data: bytes) -> Tuple[Optional[str], Optional[float]]:
if not (_TESS_OK and _PDF2IMG_OK):
return None, None
try:
pages = convert_from_bytes(data, dpi=200, fmt="png")
except Exception:
return None, None
if not pages:
return None, None
out: List[str] = []
confs: List[float] = []
for p in pages[:8]: # cap to 8 pages
try:
out.append(pytesseract.image_to_string(p, lang=os.getenv("OCR_LANG", "hrv+eng")))
try:
d = pytesseract.image_to_data(p, output_type=pytesseract.Output.DICT,
lang=os.getenv("OCR_LANG", "hrv+eng"))
cs = [int(c) for c in d.get("conf", []) if str(c).lstrip("-").isdigit() and int(c) >= 0]
if cs:
confs.append(sum(cs) / len(cs))
except Exception:
pass
except Exception:
continue
text = "\n\f\n".join(out) if out else None
conf = round(sum(confs) / len(confs), 2) if confs else None
return text, conf
# ── Persistence ──────────────────────────────────────────────────────────────
def _maybe_insert_upload(payload: Dict[str, Any]) -> Optional[int]:
"""Insert into pgz_sport.invoice_uploads — only writes columns that exist."""
cols = set(_table_columns("pgz_sport", "invoice_uploads"))
if not cols:
return None
# Map our payload keys to potential DB columns
candidates: Dict[str, Any] = {
"file_name": payload.get("file_name"),
"file_path": payload.get("file_path"),
"file_size": payload.get("file_size"),
"mime": payload.get("mime"),
"sha256": payload.get("sha256"),
"ocr_status": payload.get("ocr_status"),
"ocr_engine": payload.get("ocr_engine"),
"ocr_text": payload.get("ocr_text_full"),
"ocr_confidence": payload.get("ocr_confidence"),
"ai_invoice_no": (payload.get("extracted") or {}).get("invoice_no"),
"ai_invoice_date": (payload.get("extracted") or {}).get("date"),
"ai_vendor_name": (payload.get("extracted") or {}).get("vendor"),
"ai_vendor_oib": (payload.get("extracted") or {}).get("oib"),
"ai_amount_gross": (payload.get("extracted") or {}).get("amount"),
"ai_engine": payload.get("ai_engine") or "regex-v1",
"ai_extracted": json.dumps(payload.get("extracted") or {}),
}
insert_cols: List[str] = []
insert_vals: List[Any] = []
for k, v in candidates.items():
if k in cols and v is not None:
insert_cols.append(k)
insert_vals.append(v)
if not insert_cols:
return None
sql = "INSERT INTO pgz_sport.invoice_uploads ({c}) VALUES ({p}) RETURNING id".format(
c=", ".join(insert_cols),
p=", ".join(["%s"] * len(insert_cols)),
)
try:
with _db() as c, c.cursor() as cur:
cur.execute(sql, insert_vals)
row = cur.fetchone()
return int(row[0]) if row else None
except Exception as e:
print(f"[ocr_router] insert failed: {e}")
return None
# ── Endpoints ────────────────────────────────────────────────────────────────
@router.get("/health")
def health():
return {
"ok": True,
"tesseract_available": bool(_TESS_OK and _PIL_OK),
"pdf2image_available": bool(_PDF2IMG_OK),
"upload_dir": str(UPLOAD_DIR),
}
@router.post("/upload")
async def upload(file: UploadFile = File(...)):
if not file or not file.filename:
raise HTTPException(400, "no file")
# Validate extension/mime
ext = Path(file.filename).suffix.lower()
if ext not in ALLOWED_EXT:
raise HTTPException(400, f"extension not allowed: {ext}")
# Read full body (bounded)
data = await file.read()
if not data:
raise HTTPException(400, "empty file")
if len(data) > MAX_BYTES:
raise HTTPException(413, f"file too large: {len(data)} > {MAX_BYTES}")
sha = hashlib.sha256(data).hexdigest()
save_name = f"{sha}{ext}"
abs_path = UPLOAD_DIR / save_name
if not abs_path.exists():
try:
abs_path.write_bytes(data)
except Exception as e:
raise HTTPException(500, f"could not persist file: {e}")
rel_path = f"uploads/ocr/{save_name}"
# Run OCR
ocr_text: Optional[str] = None
ocr_conf: Optional[float] = None
ocr_engine = "tesseract"
if ext == ".pdf":
if not (_TESS_OK and _PDF2IMG_OK and _PIL_OK):
ocr_status = "ocr_unavailable"
else:
ocr_text, ocr_conf = _ocr_pdf_bytes(data)
ocr_status = "ocr_done" if ocr_text else "ocr_failed"
else:
if not (_TESS_OK and _PIL_OK):
ocr_status = "ocr_unavailable"
else:
ocr_text, ocr_conf = _ocr_image_bytes(data)
ocr_status = "ocr_done" if ocr_text else "ocr_failed"
extracted = _extract_fields(ocr_text or "")
# Truncated text for response
text_resp = (ocr_text or "")
if len(text_resp) > TEXT_CAP:
text_resp = text_resp[:TEXT_CAP]
payload: Dict[str, Any] = {
"file_name": file.filename,
"file_path": rel_path,
"file_size": len(data),
"mime": file.content_type or "application/octet-stream",
"sha256": sha,
"ocr_status": ocr_status,
"ocr_engine": ocr_engine if ocr_status == "ocr_done" else None,
"ocr_text_full": ocr_text,
"ocr_confidence": ocr_conf,
"extracted": extracted,
"ai_engine": "regex-v1",
}
inserted_id = _maybe_insert_upload(payload)
return JSONResponse(
{
"ok": True,
"id": inserted_id,
"file_path": rel_path,
"file_name": file.filename,
"file_size": len(data),
"mime": payload["mime"],
"sha256": sha,
"ocr_status": ocr_status,
"ocr_confidence": ocr_conf,
"ocr_text": text_resp if ocr_text else None,
"extracted": extracted,
}
)
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
from dotenv import load_dotenv
load_dotenv('/opt/rinet-gpu/.env.master')
# auto-added by patch_scrapers_with_dotenv.sh
"""
/api/v2/stats — Live system statistics endpoint
Returns realtime DB counts for dashboard widgets
"""
import os
import time
import psycopg2
from psycopg2.extras import RealDictCursor
from fastapi import APIRouter
router = APIRouter()
PG = dict(
host=os.environ.get("DB_HOST", "10.10.0.2"),
port=int(os.environ.get("DB_PORT", "6432")),
user=os.environ.get("DB_USER", "rinet"),
password=os.environ["DB_PASSWORD"],
dbname=os.environ.get("DB_NAME", "rinet_v3"),
)
_cache = {"data": None, "ts": 0}
CACHE_TTL = 30
@router.get("/api/v2/stats")
def get_live_stats():
"""Real-time DB counts — used by rinet.one + rinet.dev hero stats."""
now = time.time()
if _cache["data"] and (now - _cache["ts"] < CACHE_TTL):
return _cache["data"]
try:
conn = psycopg2.connect(**PG)
cur = conn.cursor(cursor_factory=RealDictCursor)
cur.execute("""
SELECT
(SELECT COUNT(*) FROM pgz_sport.klubovi) as klubovi,
(SELECT COUNT(*) FROM pgz_sport.savezi) as savezi,
(SELECT COUNT(*) FROM pgz_sport.clanovi) as clanovi,
(SELECT COUNT(*) FROM pgz_sport.dokumenti) as dokumenti,
(SELECT COUNT(*) FROM tnt.issues) as alanford_issues,
(SELECT COUNT(*) FROM pgz_sport.osobe_funkcije) as funkcije,
(SELECT COUNT(*) FROM pgz_sport.objekti) as objekti,
(SELECT COUNT(*) FROM pgz_sport.manifestacije) as manifestacije,
NOW() as ts
""")
row = cur.fetchone()
cur.close()
conn.close()
data = {
"klubovi": row["klubovi"],
"savezi": row["savezi"],
"clanovi": row["clanovi"],
"dokumenti": row["dokumenti"],
"alanford_issues": row["alanford_issues"],
"funkcije": row["funkcije"],
"objekti": row["objekti"],
"manifestacije": row["manifestacije"],
"qdrant_vectors": 18761096,
"timestamp": str(row["ts"])
}
_cache["data"] = data
_cache["ts"] = now
return data
except Exception as e:
return {"error": str(e), "timestamp": str(now)}
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""
/api/v2/stats — Live system statistics endpoint
Returns realtime DB counts for dashboard widgets
"""
import os
import time
import psycopg2
from psycopg2.extras import RealDictCursor
from fastapi import APIRouter
router = APIRouter()
PG = dict(
host=os.environ.get("DB_HOST", "10.10.0.2"),
port=int(os.environ.get("DB_PORT", "6432")),
user=os.environ.get("DB_USER", "rinet"),
password=os.environ["DB_PASSWORD"],
dbname=os.environ.get("DB_NAME", "rinet_v3"),
)
_cache = {"data": None, "ts": 0}
CACHE_TTL = 30
@router.get("/api/v2/stats")
def get_live_stats():
"""Real-time DB counts — used by rinet.one + rinet.dev hero stats."""
now = time.time()
if _cache["data"] and (now - _cache["ts"] < CACHE_TTL):
return _cache["data"]
try:
conn = psycopg2.connect(**PG)
cur = conn.cursor(cursor_factory=RealDictCursor)
cur.execute("""
SELECT
(SELECT COUNT(*) FROM pgz_sport.klubovi) as klubovi,
(SELECT COUNT(*) FROM pgz_sport.savezi) as savezi,
(SELECT COUNT(*) FROM pgz_sport.clanovi) as clanovi,
(SELECT COUNT(*) FROM pgz_sport.dokumenti) as dokumenti,
(SELECT COUNT(*) FROM tnt.issues) as alanford_issues,
(SELECT COUNT(*) FROM pgz_sport.osobe_funkcije) as funkcije,
(SELECT COUNT(*) FROM pgz_sport.objekti) as objekti,
(SELECT COUNT(*) FROM pgz_sport.manifestacije) as manifestacije,
NOW() as ts
""")
row = cur.fetchone()
cur.close()
conn.close()
data = {
"klubovi": row["klubovi"],
"savezi": row["savezi"],
"clanovi": row["clanovi"],
"dokumenti": row["dokumenti"],
"alanford_issues": row["alanford_issues"],
"funkcije": row["funkcije"],
"objekti": row["objekti"],
"manifestacije": row["manifestacije"],
"qdrant_vectors": 18761096,
"timestamp": str(row["ts"])
}
_cache["data"] = data
_cache["ts"] = now
return data
except Exception as e:
return {"error": str(e), "timestamp": str(now)}