PDF link target=_blank + nginx timeouts + priority filteri (samo s podacima)

nginx (sport.rinet.one):
- proxy_read_timeout 60s → 300s
- proxy_send_timeout 300s
- proxy_buffering off (PDF stream)
- client_max_body_size 50M → 100M

Endpoints:
- /api/v2/klubovi/financirani: +with_data filter (samo s potporama/godišnjakom/HNS)
- /api/v2/sportasi/filtered: +samo_priority +samo_s_hns

Frontend:
- PDF link target=_blank rel=noopener
- window._klub_only_priority = true (default)
- window._sportas_only_priority = true (default)

DB View:
- pgz_sport.v_nogomet_priority (prima_potpore, u_godisnjaku, ima_hns_roster)
This commit is contained in:
2026-05-05 13:51:07 +02:00
parent c6a5ec62aa
commit f7b5114f58
289 changed files with 37204 additions and 363 deletions
+266 -6
View File
@@ -1727,6 +1727,14 @@ try:
except Exception as e:
print(f'[CRM/R5] extras router fail: {e}')
# === W5 / Notification Center — /api/v2/notif/* ===
try:
from routers.notif_router import router as notif_router
app.include_router(notif_router)
print('[NOTIF] notif_router loaded (/api/v2/notif/list|count|{id}/read|mark-all-read|DELETE)')
except Exception as e:
print(f'[NOTIF] notif_router fail: {e}')
# === CRM v2 — Salesforce-Lite (Accounts/Contacts/Leads/Opportunities/Activities/Cases) ===
try:
from crm_router import router as crm_v2_router
@@ -1811,6 +1819,14 @@ try:
except Exception as e:
print(f'[ERP-FULL] router fail: {e}')
# ═══ KALENDAR (CRUD events) router — /api/v2/kalendar/events
try:
from routers.kalendar_router import router as kalendar_router
app.include_router(kalendar_router)
print('[KALENDAR] router loaded (/api/v2/kalendar/*)')
except Exception as e:
print(f'[KALENDAR] router fail: {e}')
@app.get("/crm")
@app.get("/crm/")
def serve_crm():
@@ -2216,16 +2232,30 @@ def klubovi_priority_sort(sport: str = None, limit: int = 500):
@app.get("/api/v2/clan/{clan_id}/full")
def clan_full(clan_id: int):
"""Punu sliku igrača: profil + kategorije + sezone + utakmice + potpore."""
profile = fetch("SELECT * FROM pgz_sport.clanovi WHERE id = %s", (clan_id,))
"""Punu sliku igrača: profil + kategorije + sezone + utakmice + potpore.
SUB6 (2026-05-05): join klub_naziv u kategorije za drill-down panel."""
profile = fetch("""
SELECT c.*, k.naziv AS klub_naziv
FROM pgz_sport.clanovi c
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE c.id = %s
""", (clan_id,))
if not profile: return {"error": "not_found"}
p = profile[0]
kategorije = fetch("SELECT * FROM pgz_sport.clan_kategorije WHERE clan_id = %s ORDER BY sezona DESC", (clan_id,))
kategorije = fetch("""
SELECT ck.id, ck.clan_id, ck.kategorija, ck.sezona, ck.klub_id,
ck.source, ck.source_url, ck.scraped_at,
k.naziv AS klub_naziv
FROM pgz_sport.clan_kategorije ck
LEFT JOIN pgz_sport.klubovi k ON k.id = ck.klub_id
WHERE ck.clan_id = %s
ORDER BY ck.sezona DESC NULLS LAST, ck.kategorija
""", (clan_id,))
seasons = fetch("SELECT * FROM pgz_sport.hns_player_seasons WHERE clan_id = %s ORDER BY sezona DESC", (clan_id,))
matches = fetch("SELECT * FROM pgz_sport.hns_player_matches WHERE clan_id = %s ORDER BY datum DESC NULLS LAST LIMIT 30", (clan_id,))
multi_stats = fetch("SELECT * FROM pgz_sport.player_stats WHERE clan_id = %s ORDER BY sezona DESC", (clan_id,))
return {
"profile": p,
"kategorije": kategorije,
@@ -2405,7 +2435,7 @@ def serve_favicon():
return FileResponse("/opt/pgz-sport/static/favicon.ico", media_type="image/x-icon")
@app.get("/api/v2/klubovi/financirani")
def klubovi_financirani(sport: str = None, davatelj: str = None, godina: int = None, limit: int = 1000):
def klubovi_financirani(sport: str = None, davatelj: str = None, godina: int = None, with_data: bool = False, limit: int = 1000):
"""Klubovi koji su primili novac od PGŽ/RSS/Grad Rijeka. davatelj: pgz|rss|grad_rijeka|any"""
where = []
params = []
@@ -2420,6 +2450,9 @@ def klubovi_financirani(sport: str = None, davatelj: str = None, godina: int = N
where.append("k.prima_grad_rijeka = true")
elif davatelj == 'any':
where.append("(k.prima_pgz OR k.prima_rss OR k.prima_grad_rijeka)")
if with_data:
# Default for demo: only klubovi koji imaju nešto (potpore || godišnjak || HNS roster)
where.append("(k.prima_pgz OR k.prima_rss OR k.prima_grad_rijeka OR k.u_godisnjaku OR EXISTS (SELECT 1 FROM pgz_sport.hns_klub_roster WHERE klub_id = k.id))")
where_sql = "WHERE " + " AND ".join(where) if where else ""
rows = fetch(f"""
@@ -2437,6 +2470,7 @@ def klubovi_financirani(sport: str = None, davatelj: str = None, godina: int = N
@app.get("/api/v2/sportasi/filtered")
def sportasi_filtered(sport: str = None, klub_id: int = None, kategorija: str = None,
samo_priority: bool = False, samo_s_hns: bool = False,
godina_rod_od: int = None, godina_rod_do: int = None,
q: str = None, limit: int = 500):
"""Sportaši s range filter za godinu rođenja + kategorije."""
@@ -2460,6 +2494,11 @@ def sportasi_filtered(sport: str = None, klub_id: int = None, kategorija: str =
if q:
where.append("(c.ime ILIKE %s OR c.prezime ILIKE %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
where.append("c.klub_id IN (SELECT id FROM pgz_sport.v_klubovi_financiranje WHERE prima_pgz OR prima_rss OR prima_grad_rijeka OR u_godisnjaku UNION SELECT klub_id FROM pgz_sport.hns_klub_roster)")
if samo_s_hns:
where.append("c.hns_igrac_id IS NOT NULL")
where_sql = "WHERE " + " AND ".join(where)
rows = fetch(f"""
@@ -2475,6 +2514,227 @@ def sportasi_filtered(sport: str = None, klub_id: int = None, kategorija: str =
return {"count": len(rows), "rows": rows}
# ═══════════════════════════════════════════════════════════════════
# XLSX EXPORT — sportaši & klubovi-roster (SUB8)
# Author: Damir Radulić (damir@rinet.one / dradulic@outlook.com)
# Date: 2026-05-05 v1.0.0
# Description: openpyxl-based XLSX exports with HNS data,
# dark header (Palantir vibe) + auto-width columns.
# ═══════════════════════════════════════════════════════════════════
def _xlsx_style_header(ws, ncols):
"""Apply dark Palantir-style header styling to row 1."""
from openpyxl.styles import PatternFill, Font, Alignment, Border, Side
fill = PatternFill(start_color="1A1F2E", end_color="1A1F2E", fill_type="solid")
font = Font(bold=True, color="FFFFFF", name="Calibri", size=11)
align = Alignment(horizontal="left", vertical="center", wrap_text=False)
side = Side(style="thin", color="3A4356")
border = Border(left=side, right=side, top=side, bottom=side)
for col_idx in range(1, ncols + 1):
cell = ws.cell(row=1, column=col_idx)
cell.fill = fill
cell.font = font
cell.alignment = align
cell.border = border
ws.row_dimensions[1].height = 22
ws.freeze_panes = "A2"
def _xlsx_autosize(ws):
"""Approximate auto-width based on cell content length."""
from openpyxl.utils import get_column_letter
for col_idx in range(1, ws.max_column + 1):
max_len = 8
letter = get_column_letter(col_idx)
for row_idx in range(1, min(ws.max_row, 500) + 1):
v = ws.cell(row=row_idx, column=col_idx).value
if v is None:
continue
try:
ln = len(str(v))
except Exception:
ln = 8
if ln > max_len:
max_len = ln
ws.column_dimensions[letter].width = min(max_len + 2, 60)
_SPORTASI_COLS = [
("ime", "Ime"),
("prezime", "Prezime"),
("klub_naziv", "Klub"),
("datum_rodenja", "Datum rođenja"),
("visina_cm", "Visina (cm)"),
("tezina_kg", "Težina (kg)"),
("dominantna_noga", "Dominantna noga"),
("pozicija", "Pozicija"),
("hns_url", "HNS URL"),
("broj_sezona", "Broj sezona"),
("broj_utakmica", "Broj utakmica"),
]
@app.get("/api/v2/export/sportasi")
def export_sportasi_xlsx(klub_id: Optional[int] = None, sport: Optional[str] = None, limit: int = 10000):
"""XLSX export svih sportaša + HNS data.
Filter: klub_id (opcija), sport (opcija). Default vrača sve sportaše."""
import io
from openpyxl import Workbook
from fastapi.responses import StreamingResponse
where = ["1=1"]
params: list = []
if klub_id:
where.append("c.klub_id = %s"); params.append(klub_id)
if sport:
where.append("(c.sport = %s OR k.sport = %s)"); params.extend([sport, sport])
sql = f"""
SELECT c.id, c.ime, c.prezime,
COALESCE(k.naziv, '') AS klub_naziv,
c.datum_rodenja, c.visina_cm, c.tezina_kg,
c.dominantna_noga, c.pozicija,
COALESCE(c.source_url, '') AS hns_url,
(SELECT count(*) FROM pgz_sport.hns_player_seasons s WHERE s.clan_id = c.id) AS broj_sezona,
(SELECT count(*) FROM pgz_sport.hns_player_matches m WHERE m.clan_id = c.id) AS broj_utakmica
FROM pgz_sport.clanovi c
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE {' AND '.join(where)}
ORDER BY c.prezime, c.ime
LIMIT %s
"""
rows = fetch(sql, tuple(params) + (limit,))
wb = Workbook()
ws = wb.active
ws.title = "Sportaši"
headers_hr = [lbl for _key, lbl in _SPORTASI_COLS]
ws.append(headers_hr)
for r in rows:
ws.append([r.get(key) for key, _ in _SPORTASI_COLS])
_xlsx_style_header(ws, len(headers_hr))
_xlsx_autosize(ws)
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
fname = f"pgz_sportasi_{datetime.now().strftime('%Y%m%d')}.xlsx"
return StreamingResponse(
buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f'attachment; filename="{fname}"',
"X-Row-Count": str(len(rows)),
},
)
@app.get("/api/v2/export/klubovi-roster")
def export_klubovi_roster_xlsx(klub_id: Optional[int] = None, sport: Optional[str] = None, limit_per_klub: int = 5000):
"""XLSX export rostera po klubu.
- Bez klub_id: jedan workbook s više sheet-ova (jedan po klubu).
- S klub_id: jedan sheet s rosterom kluba.
"""
import io
import re
from openpyxl import Workbook
from fastapi.responses import StreamingResponse
if klub_id:
klubovi_rows = fetch(
"SELECT id, naziv, sport FROM pgz_sport.klubovi WHERE id = %s",
(klub_id,),
)
else:
sport_where = ""
sport_params: tuple = ()
if sport:
sport_where = "AND k.sport = %s"
sport_params = (sport,)
klubovi_rows = fetch(
f"""
SELECT k.id, k.naziv, k.sport
FROM pgz_sport.klubovi k
WHERE EXISTS (SELECT 1 FROM pgz_sport.clanovi c WHERE c.klub_id = k.id)
{sport_where}
ORDER BY k.naziv
""",
sport_params,
)
wb = Workbook()
default_ws = wb.active
wb.remove(default_ws)
headers_hr = [lbl for _key, lbl in _SPORTASI_COLS]
used_titles: set = set()
total_rows = 0
def _safe_sheet_title(naziv: str, kid: int) -> str:
# Excel: max 31 chars, no : \ / ? * [ ]
t = re.sub(r"[:\\/\?\*\[\]]", " ", naziv or f"Klub_{kid}")
t = t.strip()[:28] or f"Klub_{kid}"
base = t
i = 2
while t in used_titles:
suf = f"_{i}"
t = (base[: 31 - len(suf)] + suf)
i += 1
used_titles.add(t)
return t
if not klubovi_rows:
ws = wb.create_sheet(title="Prazno")
ws.append(headers_hr)
_xlsx_style_header(ws, len(headers_hr))
else:
for kr in klubovi_rows:
kid = kr["id"]
kn = kr.get("naziv") or f"Klub {kid}"
title = _safe_sheet_title(kn, kid)
ws = wb.create_sheet(title=title)
ws.append(headers_hr)
roster = fetch(
"""
SELECT c.id, c.ime, c.prezime,
%s::text AS klub_naziv,
c.datum_rodenja, c.visina_cm, c.tezina_kg,
c.dominantna_noga, c.pozicija,
COALESCE(c.source_url, '') AS hns_url,
(SELECT count(*) FROM pgz_sport.hns_player_seasons s WHERE s.clan_id = c.id) AS broj_sezona,
(SELECT count(*) FROM pgz_sport.hns_player_matches m WHERE m.clan_id = c.id) AS broj_utakmica
FROM pgz_sport.clanovi c
WHERE c.klub_id = %s
ORDER BY c.prezime, c.ime
LIMIT %s
""",
(kn, kid, limit_per_klub),
)
for r in roster:
ws.append([r.get(key) for key, _ in _SPORTASI_COLS])
total_rows += len(roster)
_xlsx_style_header(ws, len(headers_hr))
_xlsx_autosize(ws)
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
suffix = f"klub{klub_id}" if klub_id else "svi"
fname = f"pgz_klubovi_roster_{suffix}_{datetime.now().strftime('%Y%m%d')}.xlsx"
return StreamingResponse(
buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f'attachment; filename="{fname}"',
"X-Klubovi-Count": str(len(klubovi_rows)),
"X-Row-Count": str(total_rows),
},
)
@app.get("/")
def root(request: Request):
host = request.headers.get("host", "")