feat: /api/v2/analiza/* endpoints - sport analytics backend
This commit is contained in:
@@ -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 "", []
|
||||
@@ -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 (fine‑tune). Pokušajte ponovo za ~1 sat.",
|
||||
"question": q,
|
||||
"sources": [],
|
||||
"mode": "training"
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
)
|
||||
|
||||
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}")
|
||||
@@ -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"])
|
||||
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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)}
|
||||
Reference in New Issue
Block a user