#!/usr/bin/env python3 import os # ═══════════════════════════════════════════════════════════════════ # Fajl: admin_router.py | v1.1.0 | 04.05.2026 # Autor: Damir Radulić # Lokacija: /opt/pgz-sport/admin_router.py # Svrha: Admin Dashboard ERP+CRM+Tenants — pravo schema # ═══════════════════════════════════════════════════════════════════ """Admin dashboard backend.""" from fastapi import APIRouter, Query, HTTPException from typing import Optional import psycopg2 from psycopg2.extras import RealDictCursor from datetime import datetime import decimal, uuid router = APIRouter(prefix="/admin/api", tags=["admin"]) DSN = f"host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password={os.environ['DB_PASSWORD']}" def db(): return psycopg2.connect(DSN, cursor_factory=RealDictCursor) def conv(v): if isinstance(v, datetime): return v.isoformat() if isinstance(v, decimal.Decimal): return float(v) if isinstance(v, uuid.UUID): return str(v) return v def jsonify(rows): return [{k: conv(v) for k, v in dict(r).items()} for r in rows] # ════════ DASHBOARD ════════ @router.get("/dashboard") def dashboard(tenant_id: int = Query(1)): with db() as conn, conn.cursor() as cur: cur.execute("SELECT * FROM pgz_sport.tenants WHERE id = %s", (tenant_id,)) tenant = cur.fetchone() if not tenant: raise HTTPException(404, "Tenant not found") cur.execute("SELECT count(*) AS n FROM pgz_sport.klubovi WHERE tenant_id = %s", (tenant_id,)) klubovi = cur.fetchone()['n'] cur.execute(""" SELECT count(*) AS n FROM pgz_sport.klubovi k WHERE k.tenant_id = %s AND k.last_scraped_at > now() - interval '90 days' """, (tenant_id,)) aktivni = cur.fetchone()['n'] cur.execute("SELECT count(*) AS n FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE k.tenant_id = %s", (tenant_id,)) osobe = cur.fetchone()['n'] cur.execute(""" SELECT count(*) AS n, COALESCE(SUM(amount_gross), 0) AS total_eur FROM pgz_sport.invoices WHERE tenant_id = %s """, (tenant_id,)) inv = cur.fetchone() cur.execute(""" SELECT count(*) AS n, COALESCE(SUM(cost_total), 0) AS total_eur FROM pgz_sport.expense_reports WHERE tenant_id = %s """, (tenant_id,)) exp = cur.fetchone() cur.execute(""" SELECT count(*) AS n FROM pgz_sport.audit_events WHERE ts > now() - interval '30 days' """) activity = cur.fetchone()['n'] cur.execute("SELECT id, slug, display_name, type, status FROM pgz_sport.tenants ORDER BY id") tenants = jsonify(cur.fetchall()) cur.execute(""" SELECT count(*) AS n FROM pgz_sport.dokumenti WHERE scraped_at > now() - interval '7 days' """) docs_7d = cur.fetchone()['n'] return { "tenant": jsonify([tenant])[0], "kpi": { "klubovi_total": klubovi, "klubovi_aktivni_90d": aktivni, "osobe": osobe, "invoices": inv['n'], "invoices_total_eur": float(inv['total_eur'] or 0), "expenses": exp['n'], "expenses_total_eur": float(exp['total_eur'] or 0), "activity_30d": activity, "dokumenti_7d": docs_7d }, "tenants": tenants } # ════════ ERP ════════ @router.get("/erp/summary") def erp_summary(tenant_id: int = Query(1)): with db() as conn, conn.cursor() as cur: cur.execute(""" SELECT count(*) AS total, count(*) FILTER (WHERE payment_status = 'paid') AS paid, count(*) FILTER (WHERE payment_status = 'pending') AS pending, count(*) FILTER (WHERE payment_status = 'overdue') AS overdue, count(*) FILTER (WHERE payment_status NOT IN ('paid','pending','overdue') OR payment_status IS NULL) AS other, COALESCE(SUM(amount_gross), 0) AS sum_total, COALESCE(SUM(amount_gross) FILTER (WHERE payment_status = 'paid'), 0) AS sum_paid, COALESCE(SUM(amount_gross) FILTER (WHERE payment_status != 'paid' OR payment_status IS NULL), 0) AS sum_unpaid FROM pgz_sport.invoices WHERE tenant_id = %s """, (tenant_id,)) inv = cur.fetchone() cur.execute(""" SELECT count(*) AS total, COALESCE(SUM(cost_total), 0) AS sum_total, count(*) FILTER (WHERE status = 'approved') AS approved, count(*) FILTER (WHERE status = 'paid') AS paid_status FROM pgz_sport.expense_reports WHERE tenant_id = %s """, (tenant_id,)) exp = cur.fetchone() cur.execute(""" SELECT count(*) AS total, COALESCE(SUM(amount), 0) AS sum_total FROM pgz_sport.payments p JOIN pgz_sport.klubovi k ON k.id = p.klub_id WHERE k.tenant_id = %s AND p.payment_date > now() - interval '90 days' """, (tenant_id,)) pay = cur.fetchone() cur.execute(""" SELECT count(*) AS n, COALESCE(SUM(proracun_pgz), 0) AS sum_planirano, COALESCE(SUM(ukupno), 0) AS sum_izvrseno FROM pgz_sport.proracun """) prc = cur.fetchone() return { "invoices": jsonify([inv])[0], "expenses": jsonify([exp])[0], "payments_90d": jsonify([pay])[0], "proracun": jsonify([prc])[0] } @router.get("/erp/invoices") def erp_invoices(tenant_id: int = Query(1), limit: int = Query(50), status: Optional[str] = None): with db() as conn, conn.cursor() as cur: sql = """ SELECT i.id, i.invoice_no, i.vendor_name, i.amount_gross, i.currency, i.payment_status, i.invoice_date, i.due_date, i.paid_date, i.klub_id, k.naziv AS klub_naziv FROM pgz_sport.invoices i LEFT JOIN pgz_sport.klubovi k ON k.id = i.klub_id WHERE i.tenant_id = %s """ params = [tenant_id] if status: sql += " AND i.payment_status = %s" params.append(status) sql += " ORDER BY i.invoice_date DESC NULLS LAST LIMIT %s" params.append(limit) cur.execute(sql, params) rows = jsonify(cur.fetchall()) return {"invoices": rows, "count": len(rows)} @router.get("/erp/expenses") def erp_expenses(tenant_id: int = Query(1), limit: int = Query(50)): with db() as conn, conn.cursor() as cur: cur.execute(""" SELECT e.id, e.klub_id, k.naziv AS klub_naziv, e.report_no, e.destination, e.purpose, e.cost_total, e.dnevnice_amount, e.date_from, e.date_to, e.status, e.created_at FROM pgz_sport.expense_reports e LEFT JOIN pgz_sport.klubovi k ON k.id = e.klub_id WHERE e.tenant_id = %s ORDER BY e.created_at DESC NULLS LAST LIMIT %s """, (tenant_id, limit)) rows = jsonify(cur.fetchall()) return {"expenses": rows, "count": len(rows)} # ════════ CRM ════════ @router.get("/crm/klubovi") def crm_klubovi(tenant_id: int = Query(1), limit: int = Query(50), q: Optional[str] = None): with db() as conn, conn.cursor() as cur: sql = """ SELECT k.id, k.naziv, k.oib, k.adresa, k.grad, k.email, k.telefon, k.web, k.sport, k.savez_id, k.aktivan, 0 AS dokumenti, (SELECT count(*) FROM pgz_sport.invoices i WHERE i.klub_id = k.id) AS invoices_count, (SELECT count(*) FROM pgz_sport.clanovi c WHERE c.klub_id = k.id) AS clanovi, k.last_scraped_at AS last_activity FROM pgz_sport.klubovi k WHERE k.tenant_id = %s """ params = [tenant_id] if q: sql += " AND (k.naziv ILIKE %s OR k.oib LIKE %s OR k.grad ILIKE %s OR k.sport ILIKE %s)" params.extend([f"%{q}%", f"%{q}%", f"%{q}%", f"%{q}%"]) sql += " ORDER BY k.naziv LIMIT %s" params.append(limit) cur.execute(sql, params) rows = jsonify(cur.fetchall()) return {"klubovi": rows, "count": len(rows)} @router.get("/crm/klub/{klub_id}") def crm_klub_detail(klub_id: int): with db() as conn, conn.cursor() as cur: cur.execute("SELECT * FROM pgz_sport.klubovi WHERE id = %s", (klub_id,)) klub = cur.fetchone() if not klub: raise HTTPException(404, "Klub not found") cur.execute(""" SELECT id, title AS naziv, vrsta, sport AS kategorija, scraped_at AS created_at FROM pgz_sport.dokumenti WHERE FALSE LIMIT 20 """, (klub_id,)) dokumenti = jsonify(cur.fetchall()) cur.execute("SELECT count(*) AS n FROM pgz_sport.clanovi WHERE klub_id = %s", (klub_id,)) clanovi_n = cur.fetchone()['n'] cur.execute(""" SELECT id, invoice_no, vendor_name, amount_gross, payment_status, invoice_date FROM pgz_sport.invoices WHERE klub_id = %s ORDER BY invoice_date DESC LIMIT 10 """, (klub_id,)) invoices = jsonify(cur.fetchall()) cur.execute(""" SELECT id, report_no, destination, cost_total, status, created_at FROM pgz_sport.expense_reports WHERE klub_id = %s ORDER BY created_at DESC LIMIT 10 """, (klub_id,)) expenses = jsonify(cur.fetchall()) return { "klub": jsonify([klub])[0], "dokumenti": dokumenti, "clanovi_count": clanovi_n, "invoices": invoices, "expenses": expenses } @router.get("/crm/osobe") def crm_osobe(limit: int = Query(50), q: Optional[str] = None, klub_id: Optional[int] = None): with db() as conn, conn.cursor() as cur: sql = """ SELECT c.id, c.ime, c.prezime, c.oib, c.email, c.telefon, c.klub_id, k.naziv AS klub_naziv, c.pozicija, c.kategorija, c.aktivan, c.datum_rodenja FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id WHERE 1=1 """ params = [] if q: sql += " AND (c.ime ILIKE %s OR c.prezime ILIKE %s OR c.oib LIKE %s)" params.extend([f"%{q}%", f"%{q}%", f"%{q}%"]) if klub_id: sql += " AND c.klub_id = %s" params.append(klub_id) sql += " ORDER BY c.prezime, c.ime LIMIT %s" params.append(limit) cur.execute(sql, params) rows = jsonify(cur.fetchall()) return {"osobe": rows, "count": len(rows)} # ════════ TENANTS ════════ @router.get("/tenants") def tenants_list(): with db() as conn, conn.cursor() as cur: cur.execute("SELECT * FROM pgz_sport.tenants ORDER BY id") rows = jsonify(cur.fetchall()) # Add live KPIs for t in rows: cur.execute("SELECT count(*) AS n FROM pgz_sport.klubovi WHERE tenant_id = %s", (t['id'],)) t['klubovi_count'] = cur.fetchone()['n'] return {"tenants": rows, "count": len(rows)} @router.get("/tenants/{tenant_id}") def tenant_detail(tenant_id: int): with db() as conn, conn.cursor() as cur: cur.execute("SELECT * FROM pgz_sport.tenants WHERE id = %s", (tenant_id,)) t = cur.fetchone() if not t: raise HTTPException(404, "Not found") return {"tenant": jsonify([t])[0]} @router.post("/tenants") def tenants_create(slug: str, display_name: str, oib: Optional[str] = None, type: str = "custom"): with db() as conn: conn.autocommit = True with conn.cursor() as cur: cur.execute(""" INSERT INTO pgz_sport.tenants (slug, display_name, oib, type) VALUES (%s, %s, %s, %s) RETURNING id """, (slug, display_name, oib, type)) new_id = cur.fetchone()['id'] return {"id": new_id, "slug": slug, "status": "created"} # ════════ REPORTS ════════ @router.get("/reports/top_klubovi") def reports_top_klubovi(tenant_id: int = Query(1), limit: int = Query(10)): with db() as conn, conn.cursor() as cur: cur.execute(""" SELECT k.id, k.naziv, k.grad, k.sport, count(DISTINCT d.id) AS dokumenti, count(DISTINCT i.id) AS invoices, count(DISTINCT c.id) AS clanovi FROM pgz_sport.klubovi k LEFT JOIN pgz_sport.dokumenti d ON FALSE LEFT JOIN pgz_sport.invoices i ON i.klub_id = k.id LEFT JOIN pgz_sport.clanovi c ON c.klub_id = k.id WHERE k.tenant_id = %s GROUP BY k.id, k.naziv, k.grad, k.sport ORDER BY (count(DISTINCT d.id) + count(DISTINCT i.id)) DESC LIMIT %s """, (tenant_id, limit)) rows = jsonify(cur.fetchall()) return {"top_klubovi": rows} @router.get("/health") def admin_health(): return {"status": "ok", "module": "admin", "version": "1.1.0", "ts": datetime.utcnow().isoformat()} # ═══════════════════════════════════════════════════════════════════ # KPI Dashboard endpoint (added 04.05.2026 evening sprint) # ═══════════════════════════════════════════════════════════════════ import psycopg2 as _kpi_pg @router.get("/kpi") async def admin_kpi(): """Live KPI metrics — JSON za dashboard.""" try: conn = _kpi_pg.connect(f"host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password={os.environ['DB_PASSWORD']}", connect_timeout=4) cur = conn.cursor() out = {} # Capture stats cur.execute(""" SELECT count(*) FILTER (WHERE created_at > now() - interval '1 hour') AS h1, count(*) FILTER (WHERE created_at > now() - interval '24 hours') AS h24, count(*) FILTER (WHERE created_at > now() - interval '24 hours' AND is_hallucination) AS halu24, round((avg(processing_time) FILTER (WHERE created_at > now() - interval '24 hours'))::numeric, 1) AS avg_lat, round((avg(confidence) FILTER (WHERE created_at > now() - interval '24 hours'))::numeric, 2) AS avg_conf FROM dabi.input_log """) r = cur.fetchone() out["queries"] = {"h1": r[0], "h24": r[1], "halucinacije_h24": r[2] or 0, "avg_latency_sec": float(r[3]) if r[3] else 0, "avg_confidence": float(r[4]) if r[4] else 0, "halu_pct": round(100*(r[2] or 0)/max(r[1],1), 2)} # Knowledge cur.execute(""" SELECT count(*), count(*) FILTER (WHERE created_at > now() - interval '1 hour'), count(*) FILTER (WHERE created_at > now() - interval '24 hours'), count(*) FILTER (WHERE embedded_at IS NULL), round(100.0 * count(*) FILTER (WHERE embedded_at IS NOT NULL) / count(*), 2) FROM dabi.knowledge """) r = cur.fetchone() out["knowledge"] = {"total": r[0], "added_h1": r[1], "added_h24": r[2], "embed_pending": r[3], "embed_pct": float(r[4])} # Cluster cur.execute("SELECT health_status, count(*) FROM cluster.services GROUP BY health_status") out["cluster"] = {row[0]: row[1] for row in cur.fetchall()} cur.execute("SELECT count(*) FROM deploys.incidents WHERE resolved_at IS NULL") out["open_incidents"] = cur.fetchone()[0] # Training cur.execute(""" SELECT count(*), count(*) FILTER (WHERE source_type='capture_promoted'), count(*) FILTER (WHERE created_at > now() - interval '24 hours') FROM dabi.training_qa """) r = cur.fetchone() out["training"] = {"total": r[0], "from_capture": r[1], "added_h24": r[2]} # Top sources last 24h cur.execute(""" SELECT source, count(*) FROM dabi.knowledge WHERE created_at > now() - interval '24 hours' GROUP BY source ORDER BY 2 DESC LIMIT 10 """) out["top_sources_h24"] = [{"source": s, "count": n} for s, n in cur.fetchall()] # Top models last 24h cur.execute(""" SELECT model_used, count(*), round(avg(processing_time)::numeric, 1) FROM dabi.input_log WHERE created_at > now() - interval '24 hours' GROUP BY model_used ORDER BY 2 DESC LIMIT 5 """) out["top_models_h24"] = [{"model": m, "count": n, "avg_latency": float(l) if l else 0} for m, n, l in cur.fetchall()] cur.close(); conn.close() return out except Exception as e: return {"error": str(e)} @router.get("/kpi-page", include_in_schema=False) async def admin_kpi_html(): """HTML KPI dashboard page.""" from fastapi.responses import FileResponse return FileResponse("/opt/pgz-sport/static/kpi.html")