feat: /api/v2/analiza/* endpoints - sport analytics backend

This commit is contained in:
Damir Radulic
2026-05-16 00:28:12 +02:00
parent 7ca5d7d94e
commit aca5051418
1355 changed files with 321891 additions and 4128 deletions
+671 -34
View File
@@ -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.",
}