Files
pgz-sport/admin_router.py
T

402 lines
17 KiB
Python

#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════
# Fajl: admin_router.py | v1.1.0 | 04.05.2026
# Autor: Damir Radulić <dradulic@outlook.com>
# 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 = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
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("host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7", 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")