feat: /api/v2/analiza/* endpoints - sport analytics backend
This commit is contained in:
+287
-24
@@ -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
|
||||
"""
|
||||
pgz_sport_api.py - FastAPI backend za PGŽ Sportski savez ERP/CRM
|
||||
Author: Damir Radulić (damir@rinet.one / dradulic@outlook.com)
|
||||
@@ -28,7 +31,7 @@ import psycopg2.extras
|
||||
from pgz_sport_v2_router import router as v2_router
|
||||
import os
|
||||
|
||||
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"])
|
||||
|
||||
|
||||
ADMIN_TOKEN = 'admin-pgz-2026'
|
||||
@@ -624,7 +627,7 @@ def list_savezi(authorization: Optional[str] = Header(None), q: Optional[str] =
|
||||
where = "WHERE aktivan"
|
||||
params = []
|
||||
if q:
|
||||
where += " AND (naziv ILIKE %s OR sport ILIKE %s)"
|
||||
where += " AND (public.f_unaccent(naziv) ILIKE public.f_unaccent(%s) OR sport ILIKE %s)"
|
||||
params = [f"%{q}%", f"%{q}%"]
|
||||
if razina:
|
||||
where += " AND razina = %s"; params.append(razina)
|
||||
@@ -711,7 +714,7 @@ def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] =
|
||||
if region:
|
||||
where.append("v.region ILIKE %s"); params.append(region)
|
||||
if grad:
|
||||
where.append("v.grad ILIKE %s"); params.append(f"%{grad}%")
|
||||
where.append("public.f_unaccent(v.grad) ILIKE public.f_unaccent(%s)"); params.append(f"%{grad}%")
|
||||
if sport:
|
||||
where.append("v.sport ILIKE %s"); params.append(f"%{sport}%")
|
||||
if financiran is not None:
|
||||
@@ -794,11 +797,46 @@ def get_klub(klub_id: int, authorization: Optional[str] = Header(None)):
|
||||
WHERE klub_id=%s OR naziv_kluba=(SELECT naziv FROM pgz_sport.klubovi WHERE id=%s)
|
||||
ORDER BY godina DESC""", [klub_id, klub_id])
|
||||
|
||||
# Aggregate stats
|
||||
# Aggregate stats — registrirani/trener computed from real signals
|
||||
# (hns_igrac_id / licenca_broj / kategorija / uloga); kategorija alone is
|
||||
# almost always NULL (4921/6090 rows) so the old kategorija=='registrirani'
|
||||
# check returned 0 for nearly every klub including HNK Rijeka. See task N3.
|
||||
def _is_registriran(c):
|
||||
if c.get('hns_igrac_id') or c.get('licenca_broj'):
|
||||
return True
|
||||
kat = (c.get('kategorija') or '').lower()
|
||||
return kat in ('igrac', 'sportas', 'sportaš', 'registrirani')
|
||||
def _is_trener(c):
|
||||
u = (c.get('uloga') or '').lower()
|
||||
ud = (c.get('uloga_detalj') or '').lower()
|
||||
kat = (c.get('kategorija') or '').lower()
|
||||
return ('trener' in u) or ('trener' in ud) or kat in ('trener', 'vodstvo')
|
||||
# The base SELECT in the clanovi fetch above does not include uloga/uloga_detalj/
|
||||
# licenca_broj/hns_igrac_id, so we fetch the few flags we need in one extra query.
|
||||
flag_rows = fetch("""SELECT id, ime, prezime, oib,
|
||||
kategorija, uloga, uloga_detalj, licenca_broj, hns_igrac_id
|
||||
FROM pgz_sport.clanovi WHERE klub_id=%s AND aktivan""", [klub_id])
|
||||
# N3 (2026-05-10): trainers also live in the dedicated treneri table
|
||||
# whose klub_id is rarely populated; the canonical link is klub_naziv
|
||||
# ILIKE. Dedupe by oib (or "ime|prezime" lower-cased) so a trainer who
|
||||
# is in BOTH clanovi and treneri counts once.
|
||||
_kn = (rows[0].get('naziv') or '')
|
||||
_trener_keys = set()
|
||||
for c in flag_rows:
|
||||
if _is_trener(c):
|
||||
_trener_keys.add( (c.get('oib') or
|
||||
( (c.get('ime') or '') + '|' + (c.get('prezime') or '') )).lower() )
|
||||
treneri_rows = fetch("""SELECT ime, prezime, oib FROM pgz_sport.treneri
|
||||
WHERE aktivan IS NOT FALSE
|
||||
AND (klub_id=%s OR klub_naziv ILIKE %s)""",
|
||||
[klub_id, '%' + _kn + '%'])
|
||||
for t in (treneri_rows or []):
|
||||
_trener_keys.add( (t.get('oib') or
|
||||
( (t.get('ime') or '') + '|' + (t.get('prezime') or '') )).lower() )
|
||||
stats = {
|
||||
'broj_clanova': len(clanovi),
|
||||
'broj_registriranih': sum(1 for c in clanovi if c.get('kategorija')=='registrirani'),
|
||||
'broj_trenera': sum(1 for c in clanovi if c.get('kategorija')=='trener'),
|
||||
'broj_registriranih': sum(1 for c in flag_rows if _is_registriran(c)),
|
||||
'broj_trenera': len(_trener_keys),
|
||||
'broj_reprezentativaca': sum(1 for c in clanovi if c.get('reprezentativac')),
|
||||
'broj_kategoriziranih': sum(1 for c in clanovi if c.get('kategoriziran')),
|
||||
'broj_stipendiranih': sum(1 for c in clanovi if c.get('stipendiran')),
|
||||
@@ -869,7 +907,7 @@ def list_clanovi(authorization: Optional[str] = Header(None), q: Optional[str] =
|
||||
where = ["c.aktivan"]
|
||||
params = []
|
||||
if q:
|
||||
where.append("(c.ime ILIKE %s OR c.prezime ILIKE %s OR c.oib ILIKE %s)")
|
||||
where.append("(public.f_unaccent(c.ime) ILIKE public.f_unaccent(%s) OR public.f_unaccent(c.prezime) ILIKE public.f_unaccent(%s) OR c.oib ILIKE %s)")
|
||||
params.extend([f"%{q}%", f"%{q}%", f"%{q}%"])
|
||||
if klub_id:
|
||||
where.append("c.klub_id=%s"); params.append(klub_id)
|
||||
@@ -901,23 +939,49 @@ class ClanIn(BaseModel):
|
||||
oib: Optional[str] = None
|
||||
datum_rodenja: Optional[date] = None
|
||||
spol: 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] = "registrirani"
|
||||
podkategorija: Optional[str] = None
|
||||
pozicija: Optional[str] = None
|
||||
licenca_broj: Optional[str] = None
|
||||
licenca_vrijedi_do: Optional[date] = None
|
||||
reprezentativac: Optional[bool] = False
|
||||
kategoriziran: Optional[bool] = False
|
||||
stipendiran: Optional[bool] = False
|
||||
aktivan: Optional[bool] = True
|
||||
napomena: Optional[str] = None
|
||||
|
||||
@app.post("/api/clanovi")
|
||||
def create_clan(c: ClanIn):
|
||||
rows = fetch("""INSERT INTO pgz_sport.clanovi (klub_id, ime, prezime, oib, datum_rodenja, spol, email, telefon, kategorija, pozicija, licenca_broj, licenca_vrijedi_do, reprezentativac, kategoriziran, stipendiran, napomena, aktivan, datum_pristupa)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,TRUE,CURRENT_DATE) RETURNING *""",
|
||||
[c.klub_id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol, c.email, c.telefon, c.kategorija, c.pozicija, c.licenca_broj, c.licenca_vrijedi_do, c.reprezentativac, c.kategoriziran, c.stipendiran, c.napomena])
|
||||
return rows[0]
|
||||
rows = fetch("""INSERT INTO pgz_sport.clanovi
|
||||
(klub_id, ime, prezime, oib, datum_rodenja, spol,
|
||||
adresa, grad, postanski_broj,
|
||||
email, telefon, kategorija, podkategorija, pozicija,
|
||||
licenca_broj, licenca_vrijedi_do,
|
||||
reprezentativac, kategoriziran, stipendiran,
|
||||
aktivan, datum_pristupa, napomena)
|
||||
VALUES (%s,%s,%s,%s,%s,%s, %s,%s,%s, %s,%s,%s,%s,%s, %s,%s,
|
||||
%s,%s,%s, %s, CURRENT_DATE, %s) RETURNING *""",
|
||||
[c.klub_id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol,
|
||||
c.adresa, c.grad, c.postanski_broj,
|
||||
c.email, c.telefon, c.kategorija, c.podkategorija, c.pozicija,
|
||||
c.licenca_broj, c.licenca_vrijedi_do,
|
||||
c.reprezentativac, c.kategoriziran, c.stipendiran,
|
||||
(c.aktivan if c.aktivan is not None else True),
|
||||
c.napomena])
|
||||
row = rows[0]
|
||||
try:
|
||||
_audit_log("clan.create", "clanovi", row["id"],
|
||||
f"{row.get('ime','')} {row.get('prezime','')}".strip(),
|
||||
{"new": {k: row.get(k) for k in
|
||||
('klub_id','ime','prezime','oib','kategorija','aktivan')}})
|
||||
except Exception:
|
||||
pass
|
||||
return row
|
||||
|
||||
@app.get("/api/clanovi/{clan_id}")
|
||||
def get_clan(clan_id: int):
|
||||
@@ -1066,7 +1130,7 @@ def list_statistika(godina: Optional[int] = None, q: Optional[str] = None, razin
|
||||
if godina:
|
||||
where.append("st.godina=%s"); params.append(godina)
|
||||
if q:
|
||||
where.append("s.naziv ILIKE %s"); params.append(f"%{q}%")
|
||||
where.append("public.f_unaccent(s.naziv) ILIKE public.f_unaccent(%s)"); params.append(f"%{q}%")
|
||||
if razina:
|
||||
where.append("s.razina = %s"); params.append(razina)
|
||||
where_sql = "WHERE " + " AND ".join(where) if where else ""
|
||||
@@ -1353,7 +1417,7 @@ def natjecanja_list(sport: str = "", razina: str = "", sezona: str = "", q: str
|
||||
if sport: where.append("sport = %s"); args.append(sport)
|
||||
if razina: where.append("razina = %s"); args.append(razina)
|
||||
if sezona: where.append("sezona = %s"); args.append(sezona)
|
||||
if q: where.append("naziv ILIKE %s"); args.append(f"%{q}%")
|
||||
if q: where.append("public.f_unaccent(naziv) ILIKE public.f_unaccent(%s)"); args.append(f"%{q}%")
|
||||
args.append(limit)
|
||||
|
||||
with db() as conn:
|
||||
@@ -1685,6 +1749,15 @@ except Exception as e:
|
||||
HAS_S3_ROUTERS = False
|
||||
|
||||
app.include_router(v2_router)
|
||||
|
||||
# Stats router (live system counts for hero stats)
|
||||
try:
|
||||
from routers import stats_router
|
||||
app.include_router(stats_router.router, tags=["stats"])
|
||||
print("[STATS] router loaded")
|
||||
except Exception as e:
|
||||
print(f"[STATS] router fail: {e}")
|
||||
|
||||
# Admin Dashboard router (ERP/CRM/Tenants)
|
||||
try:
|
||||
from admin_router import router as admin_router
|
||||
@@ -1826,6 +1899,15 @@ def serve_sport_3d():
|
||||
|
||||
@app.get("/admin")
|
||||
@app.get("/admin/")
|
||||
|
||||
@app.get("/sport/sportas/{sid:int}", include_in_schema=False)
|
||||
def sport_sportas_page(sid: int):
|
||||
p = "/opt/pgz-sport/static/sportas_profile.html"
|
||||
if os.path.exists(p):
|
||||
return FileResponse(p)
|
||||
return HTMLResponse("<h1>sportas profile page missing</h1>", status_code=404)
|
||||
|
||||
|
||||
@app.get("/sport/admin")
|
||||
@app.get("/sport/admin/")
|
||||
def serve_admin():
|
||||
@@ -1881,6 +1963,8 @@ except Exception as e:
|
||||
|
||||
@app.get("/crm")
|
||||
@app.get("/crm/")
|
||||
@app.get("/sport/crm")
|
||||
@app.get("/sport/crm/")
|
||||
def serve_crm():
|
||||
p = HTML_DIR / "crm.html"
|
||||
if p.exists():
|
||||
@@ -1892,8 +1976,6 @@ def serve_crm():
|
||||
@app.get("/crm_v2")
|
||||
@app.get("/crm_v2/")
|
||||
@app.get("/crm/v2")
|
||||
@app.get("/crm")
|
||||
@app.get("/sport/crm")
|
||||
@app.get("/sport/crm/v2")
|
||||
@app.get("/sport/crm_v2")
|
||||
def serve_crm_v2():
|
||||
@@ -1924,9 +2006,9 @@ def serve_admin_users():
|
||||
@app.get("/api/sportski-objekti")
|
||||
def list_sportski_objekti(q=None,tip=None,grad=None):
|
||||
w=["aktivan=TRUE"]; p=[]
|
||||
if q: w.append("(naziv ILIKE %s OR adresa ILIKE %s OR grad ILIKE %s)"); p+=["%"+q+"%"]*3
|
||||
if q: w.append("(public.f_unaccent(naziv) ILIKE public.f_unaccent(%s) OR public.f_unaccent(adresa) ILIKE public.f_unaccent(%s) OR public.f_unaccent(grad) ILIKE public.f_unaccent(%s))"); p+=["%"+q+"%"]*3
|
||||
if tip: w.append("tip ILIKE %s"); p.append("%"+tip+"%")
|
||||
if grad: w.append("grad ILIKE %s"); p.append("%"+grad+"%")
|
||||
if grad: w.append("public.f_unaccent(grad) ILIKE public.f_unaccent(%s)"); p.append("%"+grad+"%")
|
||||
rows=fetch("SELECT * FROM pgz_sport.sportski_objekti WHERE "+" AND ".join(w)+" ORDER BY grad,naziv",p)
|
||||
return {"count":len(rows),"rows":rows}
|
||||
|
||||
@@ -1935,7 +2017,7 @@ def list_clanovi_full(q=None,hoo=None,reprezentativac=None,klub_id=None,
|
||||
sport=None,kategorija=None,godina_rodenja=None,status=None,
|
||||
limit=80,authorization=None):
|
||||
w=["aktivan=TRUE"]; p=[]
|
||||
if q: w.append("(ime ILIKE %s OR prezime ILIKE %s OR klub_naziv_godisnjak ILIKE %s)"); p+=["%"+q+"%"]*3
|
||||
if q: w.append("(public.f_unaccent(ime) ILIKE public.f_unaccent(%s) OR public.f_unaccent(prezime) ILIKE public.f_unaccent(%s) OR klub_naziv_godisnjak ILIKE %s)"); p+=["%"+q+"%"]*3
|
||||
if hoo: w.append("hoo_kategorija=%s"); p.append(hoo)
|
||||
if reprezentativac is not None: w.append("reprezentativac="+(("TRUE") if str(reprezentativac).lower()=="true" else "FALSE"))
|
||||
if klub_id: w.append("klub_id=%s"); p.append(int(klub_id))
|
||||
@@ -1968,7 +2050,7 @@ def list_gradovi():
|
||||
@app.get("/api/manifestacije-full")
|
||||
def list_manifestacije_full(q=None,razina=None):
|
||||
w=["aktivna=TRUE"]; p=[]
|
||||
if q: w.append("(naziv ILIKE %s OR mjesto ILIKE %s)"); p+=["%"+q+"%"]*2
|
||||
if q: w.append("(public.f_unaccent(naziv) ILIKE public.f_unaccent(%s) OR mjesto ILIKE %s)"); p+=["%"+q+"%"]*2
|
||||
rows=fetch("SELECT id,naziv,mjesto,organizator,razina,broj_ucesnika,godina_od,spol_kategorija,napomena,source_url FROM pgz_sport.manifestacije WHERE "+" AND ".join(w)+" ORDER BY naziv",p)
|
||||
return {"count":len(rows),"rows":rows}
|
||||
|
||||
@@ -2101,7 +2183,7 @@ def network_pgz(q: str=None, entity_type: str=None, max_nodes: int=80):
|
||||
# Person search
|
||||
persons = fetch("""SELECT p.id,p.name,p.function,e.name as ent,e.id as eid,e.entity_type,e.city
|
||||
FROM civic.persons p JOIN civic.entities e ON e.id=p.entity_id
|
||||
WHERE p.name ILIKE %s OR e.name ILIKE %s LIMIT 60""",[f"%{q}%",f"%{q}%"])
|
||||
WHERE public.f_unaccent(p.name) ILIKE public.f_unaccent(%s) OR public.f_unaccent(e.name) ILIKE public.f_unaccent(%s) LIMIT 60""",[f"%{q}%",f"%{q}%"])
|
||||
for r in persons:
|
||||
pid=f"p_{r['id']}"; eid=f"e_{r['eid']}"
|
||||
add_node(pid,r.get("name","?")[:30],"person")
|
||||
@@ -2126,6 +2208,8 @@ def network_pgz(q: str=None, entity_type: str=None, max_nodes: int=80):
|
||||
|
||||
@app.get("/platform")
|
||||
@app.get("/platform/")
|
||||
@app.get("/sport/platform")
|
||||
@app.get("/sport/platform/")
|
||||
def serve_platform():
|
||||
p = HTML_DIR / "platform.html"
|
||||
if p.exists(): return FileResponse(p)
|
||||
@@ -2158,6 +2242,39 @@ def serve_dokumenti():
|
||||
p = HTML_DIR / "dokumenti.html"
|
||||
return FileResponse(p) if p.exists() else {"error":"dokumenti.html not found"}
|
||||
|
||||
# ─── Avatar fallback (CC26) ─────────────────────────────────────────────
|
||||
# Routes registered BEFORE the StaticFiles mount so they intercept /static/...
|
||||
# for the avatar subpaths. When the requested file is missing on disk we
|
||||
# 302-redirect to /static/avatars/default.png instead of returning 404 — that
|
||||
# keeps <img> tags rendering and lets the FE onError handler show initials.
|
||||
import re as _re_av
|
||||
_AVATAR_NAME_RE = _re_av.compile(r"^[A-Za-z0-9._-]+\.(png|jpe?g|webp|gif)$")
|
||||
_DEFAULT_AVATAR_URL = "/static/avatars/default.png"
|
||||
|
||||
@app.get("/static/uploads/avatars/{name}")
|
||||
def _serve_clan_avatar(name: str):
|
||||
if not _AVATAR_NAME_RE.match(name or ""):
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(_DEFAULT_AVATAR_URL, status_code=302)
|
||||
p = HTML_DIR / "uploads" / "avatars" / name
|
||||
if p.exists() and p.is_file():
|
||||
return FileResponse(p)
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(_DEFAULT_AVATAR_URL, status_code=302)
|
||||
|
||||
@app.get("/uploads/avatars/{name}")
|
||||
def _serve_user_avatar(name: str):
|
||||
if not _AVATAR_NAME_RE.match(name or ""):
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(_DEFAULT_AVATAR_URL, status_code=302)
|
||||
import pathlib as _pl_av
|
||||
p = _pl_av.Path("/opt/pgz-sport/uploads/avatars") / name
|
||||
if p.exists() and p.is_file():
|
||||
return FileResponse(p)
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(_DEFAULT_AVATAR_URL, status_code=302)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.mount("/static", StaticFiles(directory=str(HTML_DIR)), name="static")
|
||||
|
||||
# User-uploaded files (avatars, etc.) — served at /uploads/*
|
||||
@@ -2445,7 +2562,7 @@ def dokumenti_list(vrsta: str = None, sport: str = None, godina: int = None, q:
|
||||
if izvor:
|
||||
where.append("organizacija ILIKE %s"); params.append(f"%%{izvor}%%")
|
||||
if q:
|
||||
where.append("(title ILIKE %s OR sadrzaj_summary ILIKE %s OR organizacija ILIKE %s)")
|
||||
where.append("(public.f_unaccent(title) ILIKE public.f_unaccent(%s) OR sadrzaj_summary ILIKE %s OR organizacija ILIKE %s)")
|
||||
params.extend([f"%%{q}%%"]*3)
|
||||
|
||||
rows = fetch(f"""
|
||||
@@ -2480,7 +2597,9 @@ def dokument_godisnjak_pdf(godina: int):
|
||||
import os
|
||||
pdf_path = f"/opt/pgz-sport/_data/godisnjaci/godisnjak_{godina}.pdf"
|
||||
if os.path.exists(pdf_path):
|
||||
return FileResponse(pdf_path, media_type="application/pdf", filename=f"godisnjak_{godina}.pdf")
|
||||
# inline disposition → render in-tab; not download
|
||||
return FileResponse(pdf_path, media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'inline; filename="godisnjak_{godina}.pdf"'})
|
||||
return {"error": "not_found", "godina": godina}
|
||||
|
||||
|
||||
@@ -2555,7 +2674,7 @@ def sportasi_filtered(sport: str = None, klub_id: int = None, kategorija: str =
|
||||
where.append("(EXTRACT(YEAR FROM c.datum_rodenja) <= %s OR c.godina_rodenja <= %s)")
|
||||
params.extend([godina_rod_do, godina_rod_do])
|
||||
if q:
|
||||
where.append("(c.ime ILIKE %s OR c.prezime ILIKE %s)")
|
||||
where.append("(public.f_unaccent(c.ime) ILIKE public.f_unaccent(%s) OR public.f_unaccent(c.prezime) ILIKE public.f_unaccent(%s))")
|
||||
params.extend([f"%{q}%", f"%{q}%"])
|
||||
if samo_priority:
|
||||
# Igrač iz kluba koji prima novac ili je u godišnjaku ili ima HNS roster
|
||||
@@ -2848,7 +2967,7 @@ def sportski_objekti_v2_list(tip: str = None, grad: str = None, sport: str = Non
|
||||
if sport:
|
||||
where.append("%s = ANY(sportovi)"); params.append(sport)
|
||||
if q:
|
||||
where.append("(naziv ILIKE %s OR adresa ILIKE %s OR upravitelj ILIKE %s)")
|
||||
where.append("(public.f_unaccent(naziv) ILIKE public.f_unaccent(%s) OR public.f_unaccent(adresa) ILIKE public.f_unaccent(%s) OR upravitelj ILIKE %s)")
|
||||
params.extend([f"%{q}%"]*3)
|
||||
|
||||
rows = fetch(f"""
|
||||
@@ -2904,6 +3023,150 @@ def portal_v2():
|
||||
return FileResponse(p)
|
||||
return {"error": "sport2.html not found"}
|
||||
|
||||
|
||||
@app.get("/api/v2/export")
|
||||
def export_generic(
|
||||
format: str = "csv",
|
||||
endpoint: str = Query(..., description="Interni API path"),
|
||||
filename: str = "export",
|
||||
authorization: str = Header(None),
|
||||
):
|
||||
import csv, io
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
if not endpoint:
|
||||
return {"error": "Missing endpoint parameter"}
|
||||
|
||||
# Prepoznaj šta treba vratiti po endpointu
|
||||
if "savezi" in endpoint:
|
||||
rows = fetch("SELECT * FROM pgz_sport.savezi ORDER BY naziv")
|
||||
elif "klubovi" in endpoint:
|
||||
rows = fetch("SELECT * FROM pgz_sport.klubovi ORDER BY naziv")
|
||||
elif "sportasi" in endpoint:
|
||||
rows = fetch("SELECT id, ime, prezime, sport, klub_naziv_godisnjak FROM pgz_sport.clanovi LIMIT 1000")
|
||||
else:
|
||||
return {"error": "Nepoznat endpoint"}
|
||||
|
||||
if not rows:
|
||||
return {"error": "Nema podataka"}
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.DictWriter(output, fieldnames=rows[0].keys())
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
output.seek(0)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}.csv"}
|
||||
)
|
||||
|
||||
|
||||
# ════ SPORT-S3: audit helper + zzjz kalendar stub ════
|
||||
def _audit_log(action: str, target_type: str, target_id: int,
|
||||
target_text: str = "", payload: dict = None):
|
||||
"""Append a row into pgz_sport.sys_audit. Never raises — best-effort logging."""
|
||||
try:
|
||||
import json as _j
|
||||
fetch("""INSERT INTO pgz_sport.sys_audit
|
||||
(action, target_type, target_id, target_text, payload)
|
||||
VALUES (%s, %s, %s, %s, %s::jsonb)""",
|
||||
[action, target_type, int(target_id) if target_id else None,
|
||||
(target_text or "")[:500],
|
||||
_j.dumps(payload or {}, default=str, ensure_ascii=False)])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@app.get("/api/zzjz/kalendar")
|
||||
def zzjz_kalendar(dana: int = 14):
|
||||
"""Stub: 14-day calendar grid of slots for sportski liječnički pregledi.
|
||||
Real ZZJZ PGŽ API is not yet available (contract pending — see /api/zzjz/dogovor).
|
||||
Until then this returns a synthesized weekday-only grid; the frontend can show
|
||||
it as a placeholder and let the user request a slot internally."""
|
||||
from datetime import date as _d, timedelta as _td
|
||||
today = _d.today()
|
||||
out = []
|
||||
for i in range(max(1, min(60, dana))):
|
||||
d = today + _td(days=i)
|
||||
is_weekend = d.weekday() >= 5
|
||||
slots = [] if is_weekend else [
|
||||
{"time": "08:00", "available": True, "klinika": "ZZJZ PGŽ — Rijeka"},
|
||||
{"time": "09:00", "available": True, "klinika": "ZZJZ PGŽ — Rijeka"},
|
||||
{"time": "10:00", "available": False, "klinika": "ZZJZ PGŽ — Rijeka"},
|
||||
{"time": "11:00", "available": True, "klinika": "ZZJZ PGŽ — Rijeka"},
|
||||
{"time": "13:00", "available": True, "klinika": "ZZJZ PGŽ — Krk"},
|
||||
{"time": "14:00", "available": True, "klinika": "ZZJZ PGŽ — Krk"},
|
||||
]
|
||||
out.append({"date": str(d), "weekday": d.strftime("%A"),
|
||||
"is_weekend": is_weekend, "slots": slots})
|
||||
return {"note": "Stub data — real ZZJZ PGŽ booking API not yet contracted.",
|
||||
"see_also": "/api/zzjz/dogovor",
|
||||
"days": out}
|
||||
|
||||
@app.post("/api/zzjz/zatrazi-termin")
|
||||
def zzjz_zatrazi_termin(payload: dict):
|
||||
"""Stub: log an internal request for a ZZJZ medical appointment.
|
||||
Audits the request and returns confirmation. Once real ZZJZ API exists,
|
||||
this endpoint should forward the booking and return their reference."""
|
||||
try:
|
||||
_audit_log("zzjz.zatrazi-termin", "lijecnicki",
|
||||
int(payload.get("clan_id") or 0),
|
||||
f"{payload.get('date','')} {payload.get('time','')} @ {payload.get('klinika','')}",
|
||||
payload)
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "status": "queued",
|
||||
"note": "Zahtjev evidentiran u sys_audit. Stvarna rezervacija slijedi nakon potpisa ugovora sa ZZJZ PGŽ."}
|
||||
# ════ end SPORT-S3 ════
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8095)
|
||||
|
||||
|
||||
# === AI STUB (privremeni) ===
|
||||
try:
|
||||
from routers.ai_stub_router import router as ai_stub_router
|
||||
app.include_router(ai_stub_router, prefix='/api/v2')
|
||||
print('[AI-STUB] router loaded (/api/v2/ai/ask)')
|
||||
except Exception as e:
|
||||
print(f'[AI-STUB] router fail: {e}')
|
||||
|
||||
# ═══ Generički CSV/XLSX/PDF export (za frontend) ═══
|
||||
@app.get("/api/v2/export")
|
||||
def export_generic(
|
||||
format: str = "csv",
|
||||
endpoint: str = Query(..., description="Interni API path, npr. /sport/api/v2/savezi/priority-sort"),
|
||||
filename: str = "export",
|
||||
authorization: str = Header(None),
|
||||
):
|
||||
import requests as req, csv, io
|
||||
if not endpoint:
|
||||
return {"error": "Missing endpoint parameter"}
|
||||
internal = f"http://127.0.0.1:8095{endpoint}"
|
||||
try:
|
||||
resp = req.get(internal, timeout=60, headers={"Authorization": authorization} if authorization else {})
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
output = io.StringIO()
|
||||
rows = []
|
||||
if isinstance(data, list) and data:
|
||||
rows = data
|
||||
elif isinstance(data, dict):
|
||||
for key in ("rows", "data", "items", "results", "savezi"):
|
||||
if key in data and isinstance(data[key], list):
|
||||
rows = data[key]
|
||||
break
|
||||
if rows:
|
||||
writer = csv.DictWriter(output, fieldnames=rows[0].keys())
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
else:
|
||||
output.write("No data")
|
||||
output.seek(0)
|
||||
from fastapi.responses import StreamingResponse
|
||||
return StreamingResponse(iter([output.getvalue()]), media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}.csv"})
|
||||
|
||||
Reference in New Issue
Block a user