feat: /api/v2/analiza/* endpoints - sport analytics backend
This commit is contained in:
+671
-34
@@ -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_extended_api.py - Multi-tenant + ERP/CRM extension for /api/v1/*
|
||||
Author: Damir Radulić (damir@rinet.one)
|
||||
@@ -13,13 +16,20 @@ from datetime import date, datetime, timedelta
|
||||
import psycopg2, psycopg2.extras
|
||||
import hashlib, secrets, json, requests, os, re, time
|
||||
|
||||
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"])
|
||||
QDRANT = "http://10.10.0.2:6333"
|
||||
EMBED = "http://localhost:9879/api/embeddings"
|
||||
COLL = "pgz_sport_v1"
|
||||
|
||||
router = APIRouter(prefix="/api/v2", tags=["pgz_sport_v2"])
|
||||
|
||||
# Tenant-scope helper — added 2026-05-09 for klub-aware /klubovi endpoint.
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, "/opt/pgz-sport")
|
||||
from auth.auth_v2 import get_current_user as _v2_get_current_user
|
||||
from routers._tenant import resolve_klub_scope as _v2_resolve_klub_scope
|
||||
from routers._tenant import apply_klub_scope_sql as _v2_apply_klub_scope_sql
|
||||
|
||||
# ---------------- DB helpers ----------------
|
||||
def db_query(sql: str, params=()):
|
||||
with psycopg2.connect(**DB) as c:
|
||||
@@ -788,11 +798,51 @@ def trigger_scan(user = Depends(require_user)):
|
||||
def klub_dashboard(klub_id: int):
|
||||
klub = db_one("SELECT * FROM pgz_sport.v_klub_full WHERE id=%s", (klub_id,))
|
||||
if not klub: raise HTTPException(404)
|
||||
# N3 fix (2026-05-10): expose registrirani_count + trenera_count so the
|
||||
# klub-detail card stops rendering "Registriranih 0 · Trenera 0".
|
||||
# Trainers come from two sources — clanovi.uloga (in-house roster) and
|
||||
# the dedicated treneri table (PGŽ-wide directory whose klub_id is
|
||||
# rarely populated; we fall back to klub_naziv ILIKE).
|
||||
_kn = klub.get("naziv") or ""
|
||||
return {
|
||||
"klub": klub,
|
||||
"clanovi_count": db_one("SELECT COUNT(*) AS n FROM pgz_sport.clanovi WHERE klub_id=%s AND aktivan=true", (klub_id,)),
|
||||
"lijecnicki_isteka_30d": db_one("""SELECT COUNT(*) AS n FROM pgz_sport.lijecnicki_pregledi lp
|
||||
JOIN pgz_sport.clanovi c ON c.id=lp.clan_id
|
||||
# Mirror the rule used in /api/klubovi/{id} stats so cortex-sport
|
||||
# and sport2.html agree on the same "registrirani" semantics:
|
||||
# any of HNS roster id, federation licenca_broj, or explicit
|
||||
# kategorija ∈ {igrac, sportas, sportaš, registrirani}.
|
||||
"registrirani_count": db_one("""
|
||||
SELECT COUNT(*) AS n FROM pgz_sport.clanovi
|
||||
WHERE klub_id=%s AND aktivan=true AND (
|
||||
hns_igrac_id IS NOT NULL
|
||||
OR licenca_broj IS NOT NULL
|
||||
OR lower(kategorija) IN ('igrac','sportas','sportaš','registrirani')
|
||||
)""", (klub_id,)),
|
||||
# Same trener rule as /api/klubovi/{id} stats: any of
|
||||
# uloga ILIKE 'trener', uloga_detalj ILIKE 'trener',
|
||||
# kategorija ∈ {trener, vodstvo} — plus the dedicated treneri
|
||||
# table (deduped by oib or "ime|prezime").
|
||||
"trenera_count": db_one("""
|
||||
WITH from_clanovi AS (
|
||||
SELECT lower(coalesce(oib, ime||'|'||prezime)) AS k
|
||||
FROM pgz_sport.clanovi
|
||||
WHERE klub_id=%s AND aktivan=true AND (
|
||||
uloga ILIKE '%%trener%%'
|
||||
OR uloga_detalj ILIKE '%%trener%%'
|
||||
OR lower(kategorija) IN ('trener','vodstvo')
|
||||
)
|
||||
),
|
||||
from_treneri AS (
|
||||
SELECT lower(coalesce(oib, ime||'|'||prezime)) AS k
|
||||
FROM pgz_sport.treneri
|
||||
WHERE aktivan IS NOT FALSE
|
||||
AND (klub_id=%s OR klub_naziv ILIKE %s)
|
||||
)
|
||||
SELECT COUNT(DISTINCT k) AS n FROM (
|
||||
SELECT k FROM from_clanovi UNION SELECT k FROM from_treneri
|
||||
) u""", (klub_id, klub_id, '%' + _kn + '%')),
|
||||
"lijecnicki_isteka_30d": db_one("""SELECT COUNT(*) AS n FROM pgz_sport.lijecnicki_pregledi lp
|
||||
JOIN pgz_sport.clanovi c ON c.id=lp.clan_id
|
||||
WHERE c.klub_id=%s AND lp.vrijedi_do BETWEEN now()::date AND now()::date+interval '30 days'""", (klub_id,)),
|
||||
"alerts_open": db_query("""SELECT id, tip, razina, poruka, due_date FROM pgz_sport.alertovi
|
||||
WHERE klub_id=%s AND rijeseno=false ORDER BY
|
||||
@@ -853,7 +903,7 @@ def sport_ask(req: AskReq):
|
||||
prezime = ' '.join(parts[1:])
|
||||
resolved = db_one("""SELECT c.id, c.klub_id, k.naziv AS klub
|
||||
FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id
|
||||
WHERE LOWER(c.ime)=LOWER(%s) AND LOWER(c.prezime)=LOWER(%s)
|
||||
WHERE lower(public.f_unaccent(c.ime)) = lower(public.f_unaccent(%s)) AND lower(public.f_unaccent(c.prezime)) = lower(public.f_unaccent(%s))
|
||||
ORDER BY (c.slika_url IS NOT NULL) DESC, c.id ASC LIMIT 1""", (ime, prezime))
|
||||
if resolved:
|
||||
cid = resolved['id']
|
||||
@@ -1098,7 +1148,7 @@ Ako tražitelj pita o konkretnom iznosu/rokovima a nemaš to u izvorima, **nemoj
|
||||
# Audit
|
||||
try:
|
||||
from psycopg2 import connect
|
||||
conn = connect(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7')
|
||||
conn = connect(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password=os.environ["DB_PASSWORD"])
|
||||
cu = conn.cursor()
|
||||
cu.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_text, payload)
|
||||
VALUES (%s,%s,%s,%s::jsonb)""",
|
||||
@@ -1274,6 +1324,99 @@ def api_create_user(req: CreateUserReq, user = Depends(require_user)):
|
||||
# SPORTAŠ (Player) ENDPOINTS — semafor.hns.family style
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
|
||||
# ─── S4 — Sport-aware sportas profile (2026-05-11) ──────────────
|
||||
# Reads pgz_sport.sportasi (modern table, 2 043 rows from hns_semafor,
|
||||
# hks_cbf, hrs federations). Joins klub + federation_source. Adds
|
||||
# best-effort name-OIB based join into pgz_sport.clan_* legacy tables
|
||||
# for history (sezona/utakmica/nagrada) — empty if no match.
|
||||
@router.get("/sport/sportas/{sid}")
|
||||
def sport_sportas_profile(sid: int):
|
||||
"""GET /api/v2/sport/sportas/{id} — sportas full profile.
|
||||
|
||||
OIB redacted (last 5 of 11 digits masked) per privacy default.
|
||||
"""
|
||||
from fastapi import HTTPException as _HTTPException
|
||||
rows = db_query("""
|
||||
SELECT s.id, s.ime, s.prezime, s.oib, s.rod_godina, s.rod_datum,
|
||||
s.visina_cm, s.tezina_kg, s.pozicija, s.pozicija_specific,
|
||||
s.sport, s.ekipa, s.sezona, s.source, s.federation_url,
|
||||
s.federation_player_id, s.scraped_at, s.raw_data,
|
||||
s.klub_id, k.naziv AS klub_naziv, k.grad AS klub_grad,
|
||||
k.sport AS klub_sport,
|
||||
s.federation_id, fs.short_code AS fed_code, fs.full_name AS fed_name,
|
||||
fs.web_url AS fed_web
|
||||
FROM pgz_sport.sportasi s
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = s.klub_id
|
||||
LEFT JOIN civic.federation_sources fs ON fs.id = s.federation_id
|
||||
WHERE s.id = %s
|
||||
""", (sid,))
|
||||
if not rows:
|
||||
raise _HTTPException(404, f"sportas id={sid} not found")
|
||||
row = rows[0]
|
||||
# OIB redaction: keep first 6 chars, mask last 5
|
||||
if row.get("oib") and len(row["oib"]) == 11:
|
||||
row["oib_redacted"] = row["oib"][:6] + "*****"
|
||||
else:
|
||||
row["oib_redacted"] = None
|
||||
row.pop("oib", None)
|
||||
|
||||
# Best-effort legacy clan_* lookup by name (no FK)
|
||||
clan_id = None
|
||||
if row.get("ime") and row.get("prezime"):
|
||||
clan_row = db_query("""
|
||||
SELECT id FROM pgz_sport.clanovi
|
||||
WHERE lower(ime) = lower(%s) AND lower(prezime) = lower(%s)
|
||||
ORDER BY id LIMIT 1
|
||||
""", (row["ime"], row["prezime"]))
|
||||
if clan_row:
|
||||
clan_id = clan_row[0]["id"]
|
||||
|
||||
sezone = []
|
||||
nagrade = []
|
||||
utakmice = []
|
||||
stats = {}
|
||||
if clan_id is not None:
|
||||
sezone = db_query("SELECT sezona, klub_naziv, natjecanje, nastupi, pogoci, asistencije "
|
||||
"FROM pgz_sport.clan_sezona WHERE clan_id=%s ORDER BY sezona DESC LIMIT 50",
|
||||
(clan_id,))
|
||||
nagrade = db_query("SELECT godina, sezona, natjecanje, razina_natjecanja, plasman "
|
||||
"FROM pgz_sport.clan_nagrada WHERE clan_id=%s ORDER BY godina DESC LIMIT 50",
|
||||
(clan_id,))
|
||||
utakmice = db_query("SELECT datum, domacin, gost, rezultat, natjecanje, pogoci, zuti, crveni "
|
||||
"FROM pgz_sport.clan_utakmica WHERE clan_id=%s ORDER BY datum DESC LIMIT 20",
|
||||
(clan_id,))
|
||||
if sezone:
|
||||
stats = {
|
||||
"nastupi_total": sum((r.get("nastupi") or 0) for r in sezone),
|
||||
"pogoci_total": sum((r.get("pogoci") or 0) for r in sezone),
|
||||
"asistencije_total": sum((r.get("asistencije") or 0) for r in sezone),
|
||||
"sezone_count": len(sezone),
|
||||
}
|
||||
|
||||
# Social: from raw_data JSONB if scraper populated it
|
||||
socials = {}
|
||||
raw = row.get("raw_data") or {}
|
||||
if isinstance(raw, dict):
|
||||
for k in ("instagram", "twitter", "facebook", "x", "linkedin", "tiktok"):
|
||||
if k in raw and raw[k]:
|
||||
socials[k] = raw[k]
|
||||
|
||||
return {
|
||||
"sportas": row,
|
||||
"klub": {"id": row.get("klub_id"), "naziv": row.get("klub_naziv"),
|
||||
"grad": row.get("klub_grad"), "sport": row.get("klub_sport")},
|
||||
"federation": {"id": row.get("federation_id"), "code": row.get("fed_code"),
|
||||
"name": row.get("fed_name"), "web": row.get("fed_web")},
|
||||
"linked_legacy_clan_id": clan_id,
|
||||
"sezone": sezone,
|
||||
"nagrade": nagrade,
|
||||
"utakmice": utakmice,
|
||||
"stats": stats,
|
||||
"socials": socials,
|
||||
}
|
||||
# ─── end S4 ─────────────────────────────────────────────────
|
||||
|
||||
@router.get("/sportas/{cid}/profile")
|
||||
def sportas_profile(cid: int):
|
||||
"""Full player profile + per-season stats + match log. Public read."""
|
||||
@@ -1672,9 +1815,12 @@ def dokumenti_pdf(did: int):
|
||||
candidates.append(f"/opt/pgz-sport/_data/godisnjaci/godisnjak_{rec['godina']}.pdf")
|
||||
for path in candidates:
|
||||
if os.path.isfile(path):
|
||||
# inline disposition → render in-tab; not download
|
||||
return FileResponse(path, media_type="application/pdf",
|
||||
filename=os.path.basename(path),
|
||||
headers={"Cache-Control": "max-age=3600"})
|
||||
headers={
|
||||
"Content-Disposition": f'inline; filename="{os.path.basename(path)}"',
|
||||
"Cache-Control": "max-age=3600",
|
||||
})
|
||||
return JSONResponse({"error": "PDF file not found locally", "candidates": candidates}, status_code=404)
|
||||
|
||||
|
||||
@@ -1840,6 +1986,103 @@ class CreateSportasReq(BaseModel):
|
||||
reprezentativac: Optional[bool] = False
|
||||
reprezentacija_kategorija: Optional[str] = None
|
||||
|
||||
|
||||
|
||||
# === ANALIZA ENDPOINTS (2026-05-16) ===
|
||||
|
||||
@router.get("/analiza/options")
|
||||
def get_analiza_options():
|
||||
"""Opcije za filtere: savezi, sportovi, kategorije"""
|
||||
rows_savezi = db_query("SELECT id, naziv FROM pgz_sport.savezi WHERE aktivan=true ORDER BY naziv")
|
||||
rows_sportovi = db_query("SELECT DISTINCT sport FROM pgz_sport.klubovi WHERE sport IS NOT NULL AND sport != '' ORDER BY sport")
|
||||
rows_kat = db_query("SELECT DISTINCT kategorija FROM pgz_sport.clanovi WHERE kategorija IS NOT NULL AND kategorija != '' ORDER BY kategorija")
|
||||
rows_godine = db_query("SELECT DISTINCT EXTRACT(year FROM datum_rodenja)::int as g FROM pgz_sport.clanovi WHERE datum_rodenja IS NOT NULL ORDER BY g")
|
||||
return {
|
||||
"savezi": rows_savezi,
|
||||
"sportovi": [r["sport"] for r in rows_sportovi],
|
||||
"kategorije": [r["kategorija"] for r in rows_kat],
|
||||
"godista": [r["g"] for r in rows_godine if r["g"]],
|
||||
}
|
||||
|
||||
@router.get("/analiza/summary")
|
||||
def get_analiza_summary(
|
||||
savez_id: Optional[int] = None,
|
||||
sport: Optional[str] = None,
|
||||
klub_id: Optional[int] = None,
|
||||
godiste_od: Optional[int] = None,
|
||||
godiste_do: Optional[int] = None,
|
||||
kategorija: Optional[str] = None,
|
||||
liga: Optional[str] = None,
|
||||
limit: int = 500
|
||||
):
|
||||
"""Analiza - zbrojevi igraca po grupiranim kriterijima"""
|
||||
where = ["c.aktivan = true", "c.datum_rodenja IS NOT NULL"]
|
||||
args = []
|
||||
if savez_id:
|
||||
where.append("s.id = %s"); args.append(savez_id)
|
||||
if sport:
|
||||
where.append("k.sport = %s"); args.append(sport)
|
||||
if klub_id:
|
||||
where.append("k.id = %s"); args.append(klub_id)
|
||||
if godiste_od:
|
||||
where.append("EXTRACT(year FROM c.datum_rodenja) >= %s"); args.append(godiste_od)
|
||||
if godiste_do:
|
||||
where.append("EXTRACT(year FROM c.datum_rodenja) <= %s"); args.append(godiste_do)
|
||||
if kategorija:
|
||||
where.append("c.kategorija = %s"); args.append(kategorija)
|
||||
if liga:
|
||||
where.append("c.podkategorija = %s"); args.append(liga)
|
||||
w = " AND ".join(where)
|
||||
sql = f"""
|
||||
SELECT
|
||||
s.id as savez_id, s.naziv as savez,
|
||||
k.id as klub_id, k.naziv as klub, k.sport,
|
||||
EXTRACT(year FROM c.datum_rodenja)::int AS godiste,
|
||||
c.kategorija, c.podkategorija as liga,
|
||||
COUNT(*) AS broj_igraca
|
||||
FROM pgz_sport.clanovi c
|
||||
JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
||||
JOIN pgz_sport.savezi s ON s.id = k.savez_id
|
||||
WHERE {w}
|
||||
GROUP BY s.id, s.naziv, k.id, k.naziv, k.sport,
|
||||
EXTRACT(year FROM c.datum_rodenja), c.kategorija, c.podkategorija
|
||||
ORDER BY s.naziv, k.sport, k.naziv, godiste
|
||||
LIMIT %s
|
||||
"""
|
||||
args.append(limit)
|
||||
rows = db_query(sql, args)
|
||||
total_igraci = sum(r.get("broj_igraca", 0) for r in rows)
|
||||
return {"rows": rows, "total_rows": len(rows), "total_igraca": total_igraci}
|
||||
|
||||
@router.get("/analiza/klubovi-options")
|
||||
def get_analiza_klubovi(savez_id: Optional[int] = None, sport: Optional[str] = None):
|
||||
"""Klubovi za analiza dropdown"""
|
||||
where, args = ["aktivan = true"], []
|
||||
if savez_id:
|
||||
where.append("savez_id = %s"); args.append(savez_id)
|
||||
if sport:
|
||||
where.append("sport = %s"); args.append(sport)
|
||||
w = " AND ".join(where)
|
||||
return db_query(f"SELECT id, naziv, sport FROM pgz_sport.klubovi WHERE {w} ORDER BY naziv", args)
|
||||
|
||||
@router.put("/analiza/sportas/{clan_id}")
|
||||
def edit_sportas_analiza(clan_id: int, body: dict, user=Depends(require_user)):
|
||||
"""Editor: rucni unos podataka za sportasa (samo admin)"""
|
||||
allowed = {"kategorija", "podkategorija", "pozicija", "licenca_broj", "licenca_vrijedi_do", "klub_id", "aktivan"}
|
||||
sets, args = [], []
|
||||
for k, v in body.items():
|
||||
if k in allowed:
|
||||
sets.append(f"{k} = %s"); args.append(v)
|
||||
if not sets:
|
||||
return {"ok": True, "updated": 0}
|
||||
args.append(clan_id)
|
||||
old = db_query_one("SELECT * FROM pgz_sport.clanovi WHERE id=%s", [clan_id])
|
||||
db_exec(f"UPDATE pgz_sport.clanovi SET {', '.join(sets)}, updated_at=NOW() WHERE id=%s", args)
|
||||
_audit("analiza_edit", "clanovi", clan_id, old, body, user.get("user_id"))
|
||||
return {"ok": True, "updated": len(sets)}
|
||||
|
||||
|
||||
|
||||
@router.post("/sportas/create")
|
||||
def create_sportas(req: CreateSportasReq, user = Depends(require_user)):
|
||||
"""Klub admin or pgz_admin can manually add a player to their club."""
|
||||
@@ -1941,7 +2184,7 @@ def list_osobe_funkcije(sport: Optional[str] = None, savez_id: Optional[int] = N
|
||||
if savez_id:
|
||||
where.append("savez_id = %s"); params.append(savez_id)
|
||||
if q:
|
||||
where.append("(ime ILIKE %s OR prezime ILIKE %s OR funkcija ILIKE %s OR organizacija ILIKE %s)")
|
||||
where.append("(public.f_unaccent(ime) ILIKE public.f_unaccent(%s) OR public.f_unaccent(prezime) ILIKE public.f_unaccent(%s) OR funkcija ILIKE %s OR organizacija ILIKE %s)")
|
||||
params.extend([f"%{q}%", f"%{q}%", f"%{q}%", f"%{q}%"])
|
||||
where.append("o.aktivan = true")
|
||||
sql = f"""
|
||||
@@ -2231,7 +2474,7 @@ def list_dokumenti(razina: Optional[str] = None, vrsta: Optional[str] = None,
|
||||
if organizacija: where.append("organizacija = %s"); params.append(organizacija)
|
||||
if sport: where.append("LOWER(sport) = LOWER(%s)"); params.append(sport)
|
||||
if q:
|
||||
where.append("(title ILIKE %s OR kratak_opis ILIKE %s OR organizacija ILIKE %s)")
|
||||
where.append("(public.f_unaccent(title) ILIKE public.f_unaccent(%s) OR kratak_opis ILIKE %s OR organizacija ILIKE %s)")
|
||||
params.extend([f'%{q}%', f'%{q}%', f'%{q}%'])
|
||||
sql = f"""SELECT id, title AS naziv, kratak_opis, vrsta, razina, organizacija,
|
||||
sport, sluzbeni_glasnik, izvor_url, kljucne_rijeci, izdano_datum,
|
||||
@@ -2305,7 +2548,7 @@ def dokumenti_unified(
|
||||
if godina_max:
|
||||
where.append("godina <= %s"); params.append(godina_max)
|
||||
if q:
|
||||
where.append("(title ILIKE %s OR kratak_opis ILIKE %s OR organizacija ILIKE %s)")
|
||||
where.append("(public.f_unaccent(title) ILIKE public.f_unaccent(%s) OR kratak_opis ILIKE %s OR organizacija ILIKE %s)")
|
||||
params.extend([f"%{q}%", f"%{q}%", f"%{q}%"])
|
||||
|
||||
sql = f"""SELECT id, title, kratak_opis, vrsta,
|
||||
@@ -2769,7 +3012,7 @@ def list_vijesti(limit: int = 30):
|
||||
def list_suci(sport: Optional[str] = None, grad: Optional[str] = None):
|
||||
where = ["aktivan=true"]; params = []
|
||||
if sport: where.append("LOWER(sport)=LOWER(%s)"); params.append(sport)
|
||||
if grad: where.append("LOWER(grad)=LOWER(%s)"); params.append(grad)
|
||||
if grad: where.append("lower(public.f_unaccent(grad)) = lower(public.f_unaccent(%s))"); params.append(grad)
|
||||
sql = f"""SELECT id, ime, prezime, sport, licenca, kategorija, organizacija, grad
|
||||
FROM pgz_sport.suci WHERE {' AND '.join(where)}
|
||||
ORDER BY sport, prezime, ime"""
|
||||
@@ -2801,7 +3044,7 @@ def list_sponzori(klub: Optional[str] = None):
|
||||
def list_mediji(tip: Optional[str] = None, grad: Optional[str] = None):
|
||||
where = ["aktivan=true"]; params = []
|
||||
if tip: where.append("tip=%s"); params.append(tip)
|
||||
if grad: where.append("LOWER(grad)=LOWER(%s)"); params.append(grad)
|
||||
if grad: where.append("lower(public.f_unaccent(grad)) = lower(public.f_unaccent(%s))"); params.append(grad)
|
||||
sql = f"""SELECT id, naziv, tip, grad, vlasnik, web, sport_fokus, pokrivenost
|
||||
FROM pgz_sport.mediji WHERE {' AND '.join(where)}
|
||||
ORDER BY tip, naziv"""
|
||||
@@ -2889,6 +3132,30 @@ pgz_sport.dokumenti (id, title, kratak_opis, vrsta, razina, organizacija, sport,
|
||||
|
||||
-- UTAKMICE log: 5017
|
||||
pgz_sport.utakmice_log (id, datum, sport, klub_dom, klub_gost, rezultat, klub_id, sezona)
|
||||
|
||||
-- HNS IGRAČI PO SEZONI: ~200k redaka (NB: ~22 600 redaka su OCR garbage iz HNS scrape-a — UVIJEK filtriraj!)
|
||||
pgz_sport.hns_player_seasons (id, clan_id, sezona, klub_naziv, natjecanje, nastupi, golovi, asistencije, zuti, crveni, minute)
|
||||
-- !!! OBAVEZNI FILTRI kad sumiramo statistiku igrača:
|
||||
-- WHERE klub_naziv IS NOT NULL
|
||||
-- AND klub_naziv NOT ILIKE 'STATISTIKA%'
|
||||
-- AND natjecanje IS NOT NULL
|
||||
-- AND natjecanje NOT ILIKE 'STATISTIKA%'
|
||||
-- !!! Pri zbrajanju golova / nastupa za jednog igrača: koristi DISTINCT
|
||||
-- (sezona, natjecanje, klub_naziv) — inače duplicirane scrape verzije
|
||||
-- udvostruče zbroj.
|
||||
-- !!! Klub se mapira fuzzy preko klub_naziv: WHERE klub_naziv ILIKE '%orijent%'
|
||||
-- NE oslanjaj se na clanovi.klub_id — neki igrači su krivo linkani
|
||||
-- (npr. Franko Andrijašević je u clanovi vezan na Orijent iako nikad
|
||||
-- nije igrao za njih).
|
||||
-- Ispravan SUM primjer:
|
||||
-- SELECT COALESCE(SUM(golovi), 0) FROM (
|
||||
-- SELECT DISTINCT ON (clan_id, sezona, natjecanje, klub_naziv) golovi
|
||||
-- FROM pgz_sport.hns_player_seasons
|
||||
-- WHERE clan_id IN (SELECT id FROM pgz_sport.clanovi WHERE prezime ILIKE 'Andrijašević')
|
||||
-- AND klub_naziv ILIKE '%orijent%'
|
||||
-- AND klub_naziv NOT ILIKE 'STATISTIKA%'
|
||||
-- AND natjecanje NOT ILIKE 'STATISTIKA%'
|
||||
-- ) d;
|
||||
"""
|
||||
|
||||
class HybridAskReq(BaseModel):
|
||||
@@ -2948,7 +3215,7 @@ PRIMJERI:
|
||||
"Koje obveze ima sportski klub po Zakonu o sportu" → RAG: question
|
||||
"Suci nogometa u PGŽ" → SQL: SELECT ime, prezime, licenca, kategorija FROM pgz_sport.suci WHERE sport='nogomet'
|
||||
"Sponzori HNK Rijeka" → SQL: SELECT sponzor, tip, razdoblje_od FROM pgz_sport.sponzori WHERE naziv_kluba ILIKE '%HNK Rijeka%'
|
||||
"Koliko klubova ima Boćarski savez PGŽ" → SQL: SELECT klubova_clanica FROM pgz_sport.statistika_saveza ss JOIN pgz_sport.savezi s ON s.id=ss.savez_id WHERE s.naziv ILIKE '%Boćarski savez%' ORDER BY godina DESC LIMIT 1
|
||||
"Koliko klubova ima Boćarski savez PGŽ" → SQL: SELECT klubova_clanica FROM pgz_sport.statistika_saveza ss JOIN pgz_sport.savezi s ON s.id=ss.savez_id WHERE public.f_unaccent(s.naziv) ILIKE public.f_unaccent('%Boćarski savez%') ORDER BY godina DESC LIMIT 1
|
||||
"""
|
||||
|
||||
try:
|
||||
@@ -2988,6 +3255,44 @@ PRIMJERI:
|
||||
sql_results = _cur.fetchall() if _cur.description else []
|
||||
except Exception as e:
|
||||
return {"answer":f"SQL greška: {e}","mode":"sql_error","sql":sql}
|
||||
|
||||
# Post-SQL sanity guard — added 2026-05-09 for Andrijašević hallucination fix.
|
||||
# Common LLM mistake: SUM golovi from hns_player_seasons without filtering
|
||||
# the OCR-scrape garbage. Per-player career totals in PGŽ are in single-
|
||||
# to-low-double digits in our window; >1000 is almost always duplicated
|
||||
# scrape rows. Flag the result so the answering LLM doesn't quote the bad
|
||||
# number with confidence.
|
||||
try:
|
||||
_SUSPICIOUS_KEYS = {"golovi", "sum_golovi", "total_golovi", "ukupno_golovi", "asistencije", "sum_asistencije"}
|
||||
_warnings = []
|
||||
_saw_hns = ("hns_player_seasons" in sql.lower()) or ("hns_player_matches" in sql.lower())
|
||||
if _saw_hns:
|
||||
_missing_filters = []
|
||||
_sql_low = sql.lower()
|
||||
if "statistika" not in _sql_low:
|
||||
_missing_filters.append("klub_naziv NOT ILIKE 'STATISTIKA%'")
|
||||
if "klub_naziv is not null" not in _sql_low and "is not null" not in _sql_low:
|
||||
_missing_filters.append("klub_naziv IS NOT NULL")
|
||||
if _missing_filters:
|
||||
_warnings.append(
|
||||
"SQL nije primijenio obavezne filtere za hns_player_seasons (" +
|
||||
", ".join(_missing_filters) + ") — broj može biti naduvan zbog OCR garbage redaka."
|
||||
)
|
||||
for _row in (sql_results or [])[:50]:
|
||||
if not isinstance(_row, dict): continue
|
||||
for _k, _v in _row.items():
|
||||
if _k.lower() in _SUSPICIOUS_KEYS and isinstance(_v, (int, float)) and _v > 1000:
|
||||
_warnings.append(
|
||||
f"Sumnjivo veliki broj za {_k}={_v}. Realan career-gol-rekord pojedinog "
|
||||
f"igrača rijetko prelazi 200; vjerojatno duplikati ili OCR garbage. "
|
||||
f"Provjeri filter klub_naziv NOT ILIKE 'STATISTIKA%' i DISTINCT po (sezona, natjecanje, klub_naziv)."
|
||||
)
|
||||
break
|
||||
if _warnings:
|
||||
# Prepend a synthetic warning row so the final-answer LLM sees it.
|
||||
sql_results = [{"_data_quality_warning": w} for w in _warnings] + list(sql_results or [])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# STEP 2b: Execute RAG if needed
|
||||
rag_context = ""
|
||||
@@ -3194,11 +3499,11 @@ P: "Trofeji HNK Rijeka 2024/25?" → SELECT natjecanje, plasiranje, trofej FROM
|
||||
P: "Nagrade za životno djelo 2025?" → SELECT ime_prezime, klub, sport, napomena FROM pgz_sport.najbolji_sportasi WHERE godina=2025 AND kategorija ILIKE '%životno djelo%'
|
||||
P: "Sport-spec za Sara Kolak?" → SELECT s.* FROM pgz_sport.sportas_specifika s JOIN pgz_sport.clanovi c ON c.id=s.clan_id WHERE c.ime='Sara' AND c.prezime='Kolak'
|
||||
P: "Top saveza po klubovima 2025" → SELECT s.naziv, ss.klubova_clanica FROM pgz_sport.statistika_saveza ss JOIN pgz_sport.savezi s ON s.id=ss.savez_id WHERE ss.godina=2025 AND ss.klubova_clanica IS NOT NULL ORDER BY ss.klubova_clanica DESC NULLS LAST LIMIT 10
|
||||
P: "Predsjednik X saveza" → SELECT o.ime, o.prezime, o.funkcija FROM pgz_sport.osobe_funkcije o JOIN pgz_sport.savezi s ON s.id=o.savez_id WHERE s.naziv ILIKE '%X%' AND o.funkcija ILIKE '%predsjednik%'
|
||||
P: "Predsjednik X saveza" → SELECT o.ime, o.prezime, o.funkcija FROM pgz_sport.osobe_funkcije o JOIN pgz_sport.savezi s ON s.id=o.savez_id WHERE public.f_unaccent(s.naziv) ILIKE public.f_unaccent('%X%') AND o.funkcija ILIKE '%predsjednik%'
|
||||
P: "Tko je predsjednik Parasportskog saveza" → SELECT o.ime, o.prezime FROM pgz_sport.osobe_funkcije o JOIN pgz_sport.savezi s ON s.id=o.savez_id WHERE s.naziv ILIKE 'Parasportski%' AND o.funkcija ILIKE '%predsjednik%'
|
||||
P: "Klubovi u parasportskom savezu" → SELECT k.naziv, k.sport, k.grad FROM pgz_sport.klubovi k JOIN pgz_sport.savezi s ON s.id=k.savez_id WHERE s.naziv ILIKE 'Parasportski%' ORDER BY k.naziv
|
||||
P: "Koje sportove pokriva X savez" → SELECT DISTINCT k.sport FROM pgz_sport.klubovi k JOIN pgz_sport.savezi s ON s.id=k.savez_id WHERE s.naziv ILIKE '%X%'
|
||||
P: "Sport sportaša X" → SELECT DISTINCT sport FROM pgz_sport.najbolji_sportasi WHERE ime_prezime ILIKE '%X%' UNION SELECT DISTINCT sport FROM pgz_sport.clanovi WHERE (ime || ' ' || prezime) ILIKE '%X%'
|
||||
P: "Koje sportove pokriva X savez" → SELECT DISTINCT k.sport FROM pgz_sport.klubovi k JOIN pgz_sport.savezi s ON s.id=k.savez_id WHERE public.f_unaccent(s.naziv) ILIKE public.f_unaccent('%X%')
|
||||
P: "Sport sportaša X" → SELECT DISTINCT sport FROM pgz_sport.najbolji_sportasi WHERE public.f_unaccent(ime_prezime) ILIKE public.f_unaccent('%X%') UNION SELECT DISTINCT sport FROM pgz_sport.clanovi WHERE (ime || ' ' || prezime) ILIKE '%X%'
|
||||
|
||||
|
||||
Pitanje: {req.q}
|
||||
@@ -3381,7 +3686,7 @@ def list_clanovi(sport: Optional[str] = None, klub_id: Optional[int] = None,
|
||||
if spol: where.append("c.spol = %s"); params.append(spol)
|
||||
if uloga: where.append("c.uloga = %s"); params.append(uloga)
|
||||
if q:
|
||||
where.append("(c.ime ILIKE %s OR c.prezime ILIKE %s OR k.naziv 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 public.f_unaccent(k.naziv) ILIKE public.f_unaccent(%s))")
|
||||
params.extend([f"%{q}%", f"%{q}%", f"%{q}%"])
|
||||
|
||||
sql = f"""SELECT c.id, c.ime, c.prezime, c.sport, c.uloga, c.spol,
|
||||
@@ -3488,7 +3793,7 @@ def clan_full_profile(cid: int):
|
||||
priznanja = db_query("""
|
||||
SELECT godina, kategorija, klub, sport, napomena
|
||||
FROM pgz_sport.najbolji_sportasi
|
||||
WHERE clan_id=%s OR (clan_id IS NULL AND LOWER(ime_prezime) = LOWER(%s))
|
||||
WHERE clan_id=%s OR (clan_id IS NULL AND lower(public.f_unaccent(ime_prezime)) = lower(public.f_unaccent(%s)))
|
||||
ORDER BY godina DESC""", (cid, f"{sp.get('ime','')} {sp.get('prezime','')}"))
|
||||
|
||||
return {
|
||||
@@ -3979,7 +4284,7 @@ Vrati SAMO JSON array, bez markdown, bez objašnjenja."""
|
||||
|
||||
# Check if exists by name + klub
|
||||
existing = db_one("""SELECT id, uloga FROM pgz_sport.clanovi
|
||||
WHERE LOWER(ime) = LOWER(%s) AND LOWER(prezime) = LOWER(%s)
|
||||
WHERE lower(public.f_unaccent(ime)) = lower(public.f_unaccent(%s)) AND lower(public.f_unaccent(prezime)) = lower(public.f_unaccent(%s))
|
||||
AND klub_id = %s LIMIT 1""", (ime, prezime, req.klub_id))
|
||||
|
||||
source_url = fetched_urls[0] if fetched_urls else None
|
||||
@@ -4183,7 +4488,7 @@ def rno_udruge(sport: str = None, grad: str = None):
|
||||
sql = "SELECT * FROM pgz_sport.rno_sportske_udruge WHERE 1=1"
|
||||
params = []
|
||||
if sport: sql += " AND djelatnost ILIKE %s"; params.append(f'%{sport}%')
|
||||
if grad: sql += " AND grad ILIKE %s"; params.append(f'%{grad}%')
|
||||
if grad: sql += " AND public.f_unaccent(grad) ILIKE public.f_unaccent(%s)"; params.append(f'%{grad}%')
|
||||
sql += " ORDER BY naziv"
|
||||
rows = db_query(sql, params)
|
||||
return {"count": len(rows), "data": rows}
|
||||
@@ -4340,7 +4645,7 @@ def get_rno(q: str = "", status: str = "", sort: str = "naziv", limit: int = 100
|
||||
filters = ["1=1"]
|
||||
params = []
|
||||
if q:
|
||||
filters.append("(o.naziv ILIKE %s OR o.oib ILIKE %s OR o.mjesto ILIKE %s)")
|
||||
filters.append("(public.f_unaccent(o.naziv) ILIKE public.f_unaccent(%s) OR o.oib ILIKE %s OR o.mjesto ILIKE %s)")
|
||||
params += [f"%{q}%", f"%{q}%", f"%{q}%"]
|
||||
if status == "active":
|
||||
filters.append("aktivna = true")
|
||||
@@ -4723,7 +5028,7 @@ def get_sport_objekti(tip: str = "", grad: str = "", q: str = ""):
|
||||
filters.append("LOWER(tip) = LOWER(%s)")
|
||||
params.append(tip)
|
||||
if grad:
|
||||
filters.append("LOWER(grad) = LOWER(%s)")
|
||||
filters.append("lower(public.f_unaccent(grad)) = lower(public.f_unaccent(%s))")
|
||||
params.append(grad)
|
||||
if q:
|
||||
filters.append("(LOWER(naziv) LIKE LOWER(%s) OR LOWER(adresa) LIKE LOWER(%s))")
|
||||
@@ -4874,8 +5179,9 @@ def godisnjak_pdf(god: int):
|
||||
path = os.path.join(GODISNJACI_DIR, f"godisnjak_{god}.pdf")
|
||||
if not os.path.exists(path):
|
||||
raise HTTPException(404, f"Godišnjak {god} nije pronađen")
|
||||
# inline disposition → render in-tab; not download
|
||||
return FileResponse(path, media_type="application/pdf",
|
||||
filename=f"godisnjak_ZSP_PGZ_{god}.pdf")
|
||||
headers={"Content-Disposition": f'inline; filename="godisnjak_ZSP_PGZ_{god}.pdf"'})
|
||||
|
||||
@router.get("/godisnjaci/txt/{god}")
|
||||
def godisnjak_txt(god: int):
|
||||
@@ -4922,8 +5228,9 @@ def godisnjak_pgz_pdf(god: int):
|
||||
if god not in m:
|
||||
raise HTTPException(404, f"Godišnjak {god} nije dostupan")
|
||||
path = _os.path.join(GODISNJACI_PGZ_DIR, m[god]["file"])
|
||||
# inline disposition → render in-tab; not download
|
||||
return FileResponse(path, media_type="application/pdf",
|
||||
filename=f"ZSP-PGZ-Sportski-godisnjak-{god}.pdf")
|
||||
headers={"Content-Disposition": f'inline; filename="ZSP-PGZ-Sportski-godisnjak-{god}.pdf"'})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
@@ -4986,7 +5293,11 @@ def potpore_by_year(godina: int = None, q: str = "", samo_klubovi: bool = True,
|
||||
params = [yr, like]
|
||||
|
||||
if samo_klubovi:
|
||||
where.append("(je_klub IS NULL OR je_klub = true)")
|
||||
# SPORT-S4 2026-05-16: tightened. Old filter `(je_klub IS NULL OR je_klub = true)` let through
|
||||
# 257 polluted rows (scraped PDF section headers like "A) Aktivnosti", "Datum sklapanja",
|
||||
# "ukupno:") that had je_klub=true set incorrectly. Real klub linkage = klub_id IS NOT NULL.
|
||||
# For 2025 this drops total €43.3M → €5.6M (real klub payments only).
|
||||
where.append("klub_id IS NOT NULL")
|
||||
|
||||
if sport:
|
||||
where.append("LOWER(sport) = LOWER(%s)")
|
||||
@@ -5014,6 +5325,23 @@ def potpore_by_year(godina: int = None, q: str = "", samo_klubovi: bool = True,
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# MULTI-CHAIR conflict of interest
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/potpore/{id}")
|
||||
def get_potpora(id: int):
|
||||
rows = db_query("""SELECT p.id, p.naziv_kluba, p.godina, p.iznos, p.napomena, p.klub_id,
|
||||
p.davatelj, p.vrsta, p.je_klub, p.doc_id,
|
||||
k.sport, k.naziv AS klub_naziv_kanonski
|
||||
FROM pgz_sport.potpore_nositelji p
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = p.klub_id
|
||||
WHERE p.id = %s""", [id])
|
||||
if not rows:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(404, f"potpora id={id} not found")
|
||||
row = rows[0]
|
||||
row["klub_povezan"] = row.get("klub_id") is not None
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/graph/multi-chair")
|
||||
def multi_chair():
|
||||
"""Persons sitting in multiple organizations."""
|
||||
@@ -5507,25 +5835,95 @@ def graph_3d_iframe(min_orgs: int = 2, top_n: int = 100, sport: str = ""):
|
||||
# Author: cc4-sub1@rinet.one
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# ─── Map endpoints (Task C — Leaflet integration) ──────────────────────
|
||||
# Public — no auth, returns only lat/lng + minimal display fields. Lat/lng
|
||||
# of public sport clubs is non-sensitive.
|
||||
@router.get("/klubovi/map")
|
||||
def v2_klubovi_map(sport: Optional[str] = None,
|
||||
savez_id: Optional[int] = None,
|
||||
limit: int = 5000):
|
||||
"""All geocoded klubovi as a flat JSON for Leaflet markers."""
|
||||
where = ["lat IS NOT NULL", "lng IS NOT NULL"]
|
||||
args: list = []
|
||||
if sport:
|
||||
where.append("sport = %s"); args.append(sport)
|
||||
if savez_id is not None:
|
||||
where.append("savez_id = %s"); args.append(savez_id)
|
||||
sql = f"""
|
||||
SELECT id, naziv, sport, adresa, grad, lat, lng, geocoding_source
|
||||
FROM pgz_sport.klubovi
|
||||
WHERE {" AND ".join(where)}
|
||||
ORDER BY id
|
||||
LIMIT %s
|
||||
"""
|
||||
args.append(limit)
|
||||
rows = db_query(sql, tuple(args)) or []
|
||||
return {"count": len(rows), "results": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/klubovi/{klub_id}/map")
|
||||
def v2_klub_one_map(klub_id: int):
|
||||
"""Single klub geo info for an embedded mini-map on a detail page."""
|
||||
rows = db_query(
|
||||
"""SELECT id, naziv, sport, adresa, grad, lat, lng, geocoding_source,
|
||||
geocoded_at
|
||||
FROM pgz_sport.klubovi WHERE id=%s""",
|
||||
(klub_id,),
|
||||
) or []
|
||||
if not rows:
|
||||
raise HTTPException(status_code=404, detail="klub not found")
|
||||
return dict(rows[0])
|
||||
|
||||
|
||||
@router.get("/klubovi")
|
||||
def v2_klubovi_list(q: Optional[str] = None, savez_id: Optional[int] = None,
|
||||
sport: Optional[str] = None, grad: Optional[str] = None,
|
||||
limit: int = 500):
|
||||
"""v2 alias for /api/klubovi — minimal listing for portal/CRM panels."""
|
||||
is_pgz: Optional[bool] = True,
|
||||
financirani: Optional[bool] = None,
|
||||
limit: int = 500,
|
||||
user=Depends(_v2_get_current_user)):
|
||||
"""v2 alias for /api/klubovi — minimal listing for portal/CRM panels.
|
||||
|
||||
Tenant-scoped: PGŽ users (super_admin / pgz_*) see all klubovi; klub users
|
||||
see only their own (via users.klub_id + user_klub_links). Anonymous calls
|
||||
keep the prior public behavior for backward compatibility.
|
||||
|
||||
is_pgz filter (per Damir 2026-05-09):
|
||||
- omitted or true (default) → only PGŽ klubovi (is_pgz=TRUE)
|
||||
- false → no filter, show all klubovi (PGŽ + non-PGŽ)
|
||||
|
||||
financirani filter (Task D):
|
||||
- omitted (default) → all klubovi regardless of pgz_sufinanciran
|
||||
- true → only klubovi where pgz_sufinanciran=TRUE (1375/2186)
|
||||
- false → only klubovi where pgz_sufinanciran=FALSE/NULL
|
||||
"""
|
||||
scope = _v2_resolve_klub_scope(user, None)
|
||||
scope_sql, scope_args = _v2_apply_klub_scope_sql(scope, "id")
|
||||
|
||||
where = ["aktivan"]
|
||||
params: List[Any] = []
|
||||
if scope_sql:
|
||||
where.append(scope_sql); params.extend(scope_args)
|
||||
# Default to PGŽ-only; passing ?is_pgz=false drops the filter entirely.
|
||||
if is_pgz is None or is_pgz is True:
|
||||
where.append("is_pgz = TRUE")
|
||||
if financirani is True:
|
||||
where.append("pgz_sufinanciran = TRUE")
|
||||
elif financirani is False:
|
||||
where.append("(pgz_sufinanciran = FALSE OR pgz_sufinanciran IS NULL)")
|
||||
if q:
|
||||
where.append("(naziv ILIKE %s OR oib ILIKE %s OR sport ILIKE %s)")
|
||||
where.append("(public.f_unaccent(naziv) ILIKE public.f_unaccent(%s) OR oib ILIKE %s OR sport ILIKE %s)")
|
||||
params.extend([f"%{q}%", f"%{q}%", f"%{q}%"])
|
||||
if savez_id:
|
||||
where.append("savez_id=%s"); params.append(savez_id)
|
||||
if sport:
|
||||
where.append("sport ILIKE %s"); params.append(f"%{sport}%")
|
||||
if grad:
|
||||
where.append("grad ILIKE %s"); params.append(f"%{grad}%")
|
||||
where.append("public.f_unaccent(grad) ILIKE public.f_unaccent(%s)"); params.append(f"%{grad}%")
|
||||
params.append(max(1, min(limit, 2000)))
|
||||
sql = f"""SELECT id, naziv, oib, sport, grad, savez_id,
|
||||
region, broj_clanova, predsjednik, email, telefon, web
|
||||
region, broj_clanova, predsjednik, email, telefon, web,
|
||||
is_pgz, pgz_sufinanciran
|
||||
FROM pgz_sport.klubovi
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY naziv COLLATE "hr-HR-x-icu"
|
||||
@@ -5541,7 +5939,7 @@ def v2_savezi_list(q: Optional[str] = None, razina: Optional[str] = None,
|
||||
where = ["aktivan"]
|
||||
params: List[Any] = []
|
||||
if q:
|
||||
where.append("(naziv ILIKE %s OR sport ILIKE %s)")
|
||||
where.append("(public.f_unaccent(naziv) ILIKE public.f_unaccent(%s) OR sport ILIKE %s)")
|
||||
params.extend([f"%{q}%", f"%{q}%"])
|
||||
if razina:
|
||||
where.append("razina = %s"); params.append(razina)
|
||||
@@ -5929,14 +6327,34 @@ def manifestacije_meta():
|
||||
mjesta = db_query("SELECT DISTINCT mjesto, count(*) AS broj FROM pgz_sport.manifestacije WHERE mjesto IS NOT NULL GROUP BY mjesto ORDER BY broj DESC LIMIT 100")
|
||||
razine = db_query("SELECT DISTINCT razina FROM pgz_sport.manifestacije WHERE razina IS NOT NULL ORDER BY razina")
|
||||
organizatori = db_query("SELECT DISTINCT organizator, count(*) AS broj FROM pgz_sport.manifestacije WHERE organizator IS NOT NULL GROUP BY organizator ORDER BY broj DESC LIMIT 50")
|
||||
godine = db_query("SELECT DISTINCT godina_od FROM pgz_sport.manifestacije WHERE godina_od IS NOT NULL ORDER BY godina_od DESC")
|
||||
sportovi = db_query("""
|
||||
SELECT DISTINCT s.sport
|
||||
FROM pgz_sport.manifestacije m
|
||||
JOIN pgz_sport.savezi s ON s.id = m.savez_id
|
||||
WHERE s.sport IS NOT NULL AND s.sport <> ''
|
||||
ORDER BY s.sport
|
||||
""")
|
||||
savezi = db_query("""
|
||||
SELECT s.id, s.naziv, count(*) AS broj
|
||||
FROM pgz_sport.manifestacije m
|
||||
JOIN pgz_sport.savezi s ON s.id = m.savez_id
|
||||
GROUP BY s.id, s.naziv
|
||||
ORDER BY broj DESC, s.naziv
|
||||
""")
|
||||
return {
|
||||
"mjesta": [r["mjesto"] for r in mjesta],
|
||||
"razine": [r["razina"] for r in razine],
|
||||
"organizatori": [r["organizator"] for r in organizatori],
|
||||
"godine": [r["godina_od"] for r in godine],
|
||||
"sportovi": [r["sport"] for r in sportovi],
|
||||
"savezi": [{"id": r["id"], "naziv": r["naziv"]} for r in savezi],
|
||||
}
|
||||
|
||||
@router.get("/manifestacije")
|
||||
def manifestacije_list(mjesto: str = None, razina: str = None, organizator: str = None, q: str = None, limit: int = 200):
|
||||
def manifestacije_list(mjesto: str = None, razina: str = None, organizator: str = None,
|
||||
q: str = None, godina: int = None, sport: str = None,
|
||||
savez_id: int = None, limit: int = 500):
|
||||
"""Lista manifestacija s filterima."""
|
||||
where = ["m.aktivna = true"]
|
||||
params = []
|
||||
@@ -5950,13 +6368,24 @@ def manifestacije_list(mjesto: str = None, razina: str = None, organizator: str
|
||||
where.append("m.organizator ILIKE %s")
|
||||
params.append(f"%{organizator}%")
|
||||
if q:
|
||||
where.append("(m.naziv ILIKE %s OR m.napomena ILIKE %s)")
|
||||
where.append("(public.f_unaccent(m.naziv) ILIKE public.f_unaccent(%s) OR m.napomena ILIKE %s)")
|
||||
params.extend([f"%{q}%", f"%{q}%"])
|
||||
if godina is not None:
|
||||
where.append("m.godina_od = %s")
|
||||
params.append(godina)
|
||||
if sport:
|
||||
where.append("s.sport = %s")
|
||||
params.append(sport)
|
||||
if savez_id is not None:
|
||||
where.append("m.savez_id = %s")
|
||||
params.append(savez_id)
|
||||
|
||||
rows = db_query(f"""
|
||||
SELECT m.id, m.naziv, m.mjesto, m.organizator, m.razina, m.broj_ucesnika,
|
||||
m.godina_od, m.spol_kategorija, m.napomena, m.source_url,
|
||||
s.naziv AS savez_naziv, s.id AS savez_id
|
||||
m.source, m.last_scraped_at, m.created_at,
|
||||
s.id AS savez_id, s.naziv AS savez_naziv, s.sport AS sport,
|
||||
s.web AS savez_web
|
||||
FROM pgz_sport.manifestacije m
|
||||
LEFT JOIN pgz_sport.savezi s ON s.id = m.savez_id
|
||||
WHERE {' AND '.join(where)}
|
||||
@@ -5965,3 +6394,211 @@ def manifestacije_list(mjesto: str = None, razina: str = None, organizator: str
|
||||
""", params + [limit])
|
||||
return {"count": len(rows), "rows": rows}
|
||||
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# W6 (2026-05-09) — /saveza alias + drill-down. Same shape as /savezi for the
|
||||
# list; detail returns savez + funkcioneri + clanice klubovi.
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/saveza")
|
||||
def v2_saveza_list(q: Optional[str] = None, razina: Optional[str] = None,
|
||||
sport: Optional[str] = None,
|
||||
limit: int = 500, offset: int = 0):
|
||||
"""List saveza (alias of /savezi with pagination). Returns rows with klub count."""
|
||||
where = ["aktivan"]
|
||||
params: List[Any] = []
|
||||
if q:
|
||||
where.append("(public.f_unaccent(naziv) ILIKE public.f_unaccent(%s) OR sport ILIKE %s)")
|
||||
params.extend([f"%{q}%", f"%{q}%"])
|
||||
if razina:
|
||||
where.append("razina = %s"); params.append(razina)
|
||||
if sport:
|
||||
where.append("sport ILIKE %s"); params.append(f"%{sport}%")
|
||||
|
||||
total = db_query(
|
||||
f"SELECT COUNT(*) AS n FROM pgz_sport.savezi s WHERE {' AND '.join(where)}",
|
||||
params,
|
||||
)[0]["n"]
|
||||
|
||||
params_page = list(params) + [max(1, min(limit, 2000)), max(0, offset)]
|
||||
sql = f"""SELECT id, naziv, sport, razina, sjediste_zupanija,
|
||||
godina_osnutka, predsjednik, email, web,
|
||||
(SELECT COUNT(*) FROM pgz_sport.klubovi
|
||||
WHERE savez_id = s.id AND aktivan) AS broj_klubova
|
||||
FROM pgz_sport.savezi s
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY naziv COLLATE "hr-HR-x-icu"
|
||||
LIMIT %s OFFSET %s"""
|
||||
rows = db_query(sql, params_page)
|
||||
return {
|
||||
"ok": True,
|
||||
"count": len(rows),
|
||||
"total": total,
|
||||
"limit": max(1, min(limit, 2000)),
|
||||
"offset": max(0, offset),
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/saveza/{savez_id}")
|
||||
def v2_saveza_detail(savez_id: int):
|
||||
"""Savez detail: full row + funkcioneri + clanice klubovi."""
|
||||
rows = db_query(
|
||||
"SELECT * FROM pgz_sport.savezi WHERE id = %s", [savez_id]
|
||||
)
|
||||
if not rows:
|
||||
raise HTTPException(status_code=404, detail=f"Savez {savez_id} ne postoji")
|
||||
s = rows[0]
|
||||
|
||||
klubovi = db_query(
|
||||
"""SELECT id, naziv, sport, grad, predsjednik,
|
||||
COALESCE(broj_clanova, 0) AS broj_clanova, aktivan
|
||||
FROM pgz_sport.klubovi
|
||||
WHERE savez_id = %s AND aktivan
|
||||
ORDER BY naziv COLLATE "hr-HR-x-icu" """,
|
||||
[savez_id],
|
||||
)
|
||||
|
||||
funkcioneri = {
|
||||
"predsjednik": s.get("predsjednik"),
|
||||
"tajnik": s.get("tajnik"),
|
||||
"email": s.get("email"),
|
||||
"telefon": s.get("telefon"),
|
||||
"web": s.get("web"),
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"savez": s,
|
||||
"funkcioneri": funkcioneri,
|
||||
"klubovi": klubovi,
|
||||
"broj_klubova": len(klubovi),
|
||||
}
|
||||
|
||||
|
||||
# === Notif count stub (Damir 2026-05-10 fix sidebar.js 404) ===
|
||||
@router.get("/notif/count")
|
||||
async def notif_count_stub_damir():
|
||||
return {"unread": 0, "total": 0, "user_id": None}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# SPORT-S4 — Financije: manual entry + cleanup audit
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# Damir issued task 2026-05-16: financije tab pokazuje netočne podatke. Root cause
|
||||
# (audited this turn): pgz_sport.sufinanciranje_sport has 326/917 rows with
|
||||
# klub_id=NULL of which ~50 are PDF section-header scraping artifacts (e.g.
|
||||
# "Datum sklapanja €30M", "A) Aktivnosti €12.8M", "ukupno: €657K") with je_klub
|
||||
# incorrectly set to true. /potpore/by-year filter was loosened to `klub_id IS NOT NULL`
|
||||
# (see line ~5296). These endpoints add manual entry + cleanup audit surface.
|
||||
|
||||
class ManualFinancijeEntry(BaseModel):
|
||||
godina: int
|
||||
klub_id: int
|
||||
iznos_eur: float
|
||||
opis: str = ""
|
||||
sport: Optional[str] = None
|
||||
vrsta: str = "ručni_unos"
|
||||
razina: str = "ručni_unos"
|
||||
napomena: Optional[str] = None
|
||||
izvor: Optional[str] = None
|
||||
source_url: Optional[str] = None
|
||||
|
||||
@router.post("/financije/manual-entry")
|
||||
def financije_manual_entry(payload: ManualFinancijeEntry, user=Depends(require_user)):
|
||||
"""Add a manual financije entry for a known klub. Audit-logged via provenance trigger.
|
||||
|
||||
Requires logged-in user. RBAC: only pgz_admin / super_admin can write to financije.
|
||||
"""
|
||||
if not is_pgz_admin(user):
|
||||
raise HTTPException(403, "Samo pgz_admin/super_admin može unositi financije ručno")
|
||||
if payload.godina < 2000 or payload.godina > 2100:
|
||||
raise HTTPException(422, f"godina van raspona: {payload.godina}")
|
||||
if payload.iznos_eur <= 0:
|
||||
raise HTTPException(422, f"iznos_eur mora biti > 0 (dobiveno {payload.iznos_eur})")
|
||||
|
||||
klub = db_one("SELECT id, naziv, sport FROM pgz_sport.klubovi WHERE id=%s",
|
||||
(payload.klub_id,))
|
||||
if not klub:
|
||||
raise HTTPException(404, f"klub_id={payload.klub_id} ne postoji u pgz_sport.klubovi")
|
||||
|
||||
# Unique constraint = (godina, razina, korisnik, vrsta). We use klub.naziv as korisnik
|
||||
# so the constraint distinguishes manual entries by year+kub.
|
||||
new_id = db_exec("""
|
||||
INSERT INTO pgz_sport.sufinanciranje_sport
|
||||
(godina, razina, korisnik, sport, iznos_eur, vrsta, napomena, izvor,
|
||||
source_url, je_klub, klub_id, unos_datum)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, true, %s, now())
|
||||
ON CONFLICT (godina, razina, korisnik, vrsta) DO UPDATE
|
||||
SET iznos_eur = EXCLUDED.iznos_eur,
|
||||
napomena = EXCLUDED.napomena,
|
||||
sport = COALESCE(EXCLUDED.sport, pgz_sport.sufinanciranje_sport.sport)
|
||||
RETURNING id
|
||||
""", (
|
||||
payload.godina,
|
||||
payload.razina,
|
||||
klub["naziv"],
|
||||
payload.sport or klub.get("sport"),
|
||||
payload.iznos_eur,
|
||||
payload.vrsta,
|
||||
payload.napomena or payload.opis or None,
|
||||
payload.izvor or f"manual_user_{user['user_id']}",
|
||||
payload.source_url,
|
||||
payload.klub_id,
|
||||
))
|
||||
return {
|
||||
"ok": True,
|
||||
"id": new_id,
|
||||
"klub_id": payload.klub_id,
|
||||
"klub_naziv": klub["naziv"],
|
||||
"godina": payload.godina,
|
||||
"iznos_eur": payload.iznos_eur,
|
||||
"msg": "Unos spremljen (provenance trigger zabilježio promjenu)",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/financije/cleanup-candidates")
|
||||
def financije_cleanup_candidates(min_iznos: float = 0):
|
||||
"""Read-only audit. Lists rows that look like PDF-parsing pollution (no klub_id +
|
||||
korisnik starts with 'A)' / 'a)' / 'ukupno' / all-caps section header / 'Datum').
|
||||
NEVER deletes; operator reviews and decides.
|
||||
"""
|
||||
# NOTE: psycopg2 interprets `%` in SQL as param placeholder — every literal `%`
|
||||
# in LIKE/ILIKE patterns MUST be doubled to `%%`.
|
||||
rows = db_query("""
|
||||
SELECT id, godina, korisnik, iznos_eur, je_klub, klub_id, razina, vrsta,
|
||||
CASE
|
||||
WHEN korisnik ~ '^[A-Za-z]\\)' THEN 'pdf_section_label'
|
||||
WHEN korisnik ILIKE 'ukupno%%' THEN 'total_row'
|
||||
WHEN korisnik ILIKE '%%aktivnost:%%' THEN 'activity_label'
|
||||
WHEN korisnik ILIKE 'datum%%' THEN 'column_header'
|
||||
WHEN korisnik = upper(korisnik) AND length(korisnik) > 6 AND korisnik !~ '\\d'
|
||||
THEN 'allcaps_section'
|
||||
WHEN klub_id IS NULL AND je_klub IS NOT TRUE THEN 'non_klub_program'
|
||||
WHEN klub_id IS NULL THEN 'unlinked'
|
||||
END AS poll_class
|
||||
FROM pgz_sport.sufinanciranje_sport
|
||||
WHERE klub_id IS NULL
|
||||
AND iznos_eur >= %s
|
||||
ORDER BY iznos_eur DESC NULLS LAST
|
||||
LIMIT 500
|
||||
""", (min_iznos,))
|
||||
summary = db_one("""
|
||||
SELECT
|
||||
count(*) FILTER (WHERE klub_id IS NULL) AS unlinked,
|
||||
count(*) FILTER (WHERE klub_id IS NOT NULL) AS klub_linked,
|
||||
count(*) FILTER (WHERE klub_id IS NULL AND je_klub IS NOT TRUE) AS non_klub,
|
||||
sum(iznos_eur) FILTER (WHERE klub_id IS NOT NULL) AS klub_linked_eur,
|
||||
sum(iznos_eur) FILTER (WHERE klub_id IS NULL) AS unlinked_eur,
|
||||
count(*) AS total_rows,
|
||||
sum(iznos_eur) AS total_eur
|
||||
FROM pgz_sport.sufinanciranje_sport
|
||||
""", ())
|
||||
return {
|
||||
"summary": summary,
|
||||
"candidates": rows,
|
||||
"candidate_count": len(rows),
|
||||
"candidate_sum_eur": sum(float(r.get("iznos_eur") or 0) for r in rows),
|
||||
"note": "Read-only audit. Do NOT auto-delete — operator reviews each row in admin UI.",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user