403 lines
17 KiB
Python
403 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
import os
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# 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 = 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")
|