RUSH 4-sub: filteri Klubovi/Sportaši + manifestacije card view + CRM v2 redesign

RUSH-1 Klubovi: list_klubovi() LEFT JOIN v_klubovi_financiranje (prima_pgz/rss/grad_rijeka, u_godisnjaku, ukupno_potpora). financiran=true sad OR od 3 davatelja (drop legacy klubovi.pgz_sufinanciran s 1312 false-positive). Sort sort=potpora&order=desc. UI: gold ukupno_potpora + tooltip + sortable kolona. Defaults priority view (financirani+godišnjak ON, hns_roster OFF). Test: priority=604, +hns=36, all=1641, financiran=15 sorted ZAMET 80208€.

RUSH-2 Sportaši: SELECT widened (slika_url, reprezentativac, kategoriziran, broj_dresa). avatarUrl() helper s 3 forme (apsolutni / lokalni /sport/uploads/avatars / initials fallback) + 32px circular avatar lijevo od imena. Test: priority=3712, no-priority=6086, +hns=1439, 1990-2000=645.

RUSH-3 Manifestacije: bugfix razina filter HTTP 500 (ambiguous column nakon LEFT JOIN savezi → m.razina/mjesto/organizator). 3 dropdowna iz meta (26 mjesta / 8 razina / 50 organizatora), view toggle 🃏 Kartice / 📋 Tablica (localStorage), 🔗 link ikona u card+table, source_url → Google fallback. Test: default=3, mjesto=Lošinj=2, razina=Tradicionalna=3, organizator=AK Kvarner=1.

RUSH-4 CRM v2: tab strip rewrite (10 taba u spec redu Članarine|Liječnički|Obrasci|E-mail|Accounts|Contacts|Leads|Opps|Activities|Cases, sticky+scrollable+gold underline). Pipeline → Opps tab. Novi e-mail templates tab (5 endpointa, 3 seed templates, +Novi modal). Card layout (.cgrid/.ccard) za Accounts/Contacts/Leads/Opps. Export dropdown 📥 ▾ CSV/XLSX(SheetJS CDN)/PDF na svaki tab. Test: /crm_v2 200, 10/10 tab labela, 10 Export dropdowna + 31 exportTab() handlera.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Damir Radulić
2026-05-05 18:33:20 +02:00
parent b72d037141
commit 9b0ed43b92
8 changed files with 1203 additions and 135 deletions
+297
View File
@@ -0,0 +1,297 @@
#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════════════
# Fajl: routers/export_router.py | v1.0.0 | 05.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
# Lokacija: /opt/pgz-sport/routers/export_router.py
# Svrha: Univerzalni Export modul (CSV / XLSX / PDF) za sve tablice u PGŽ Sport
# ERP/CRM. Endpoint /api/v2/export?format=...&endpoint=...&filters=...
# proxy-aporta unutarnji JSON API i konvertira odgovor u traženi
# format. CSV (HR Excel friendly: ; delimiter, UTF-8 BOM), XLSX preko
# openpyxla (s graceful fallback na CSV ako openpyxl nije dostupan),
# PDF preko HTML print-to-PDF (mock — TODO: weasyprint/reportlab).
# Mount: /api/v2/export/*
# ═══════════════════════════════════════════════════════════════════════════
from __future__ import annotations
import csv
import io
import json
from datetime import datetime
from typing import Any, Dict, List, Optional
from urllib.parse import unquote
import requests
from fastapi import APIRouter, Header, HTTPException, Query
from fastapi.responses import HTMLResponse, Response, StreamingResponse
# openpyxl is optional — fall back to CSV if missing.
try:
from openpyxl import Workbook # type: ignore
from openpyxl.styles import Alignment, Font, PatternFill # type: ignore
OPENPYXL_AVAILABLE = True
except Exception: # pragma: no cover
OPENPYXL_AVAILABLE = False
router = APIRouter(prefix="/api/v2/export", tags=["export"])
INTERNAL_BASE = "http://127.0.0.1:8095"
# ───────────────────────────────────────────────────────────────────────────
# Utilities
# ───────────────────────────────────────────────────────────────────────────
def _ts() -> str:
return datetime.now().strftime("%Y%m%d_%H%M%S")
def _flatten_value(v: Any) -> str:
"""Normalize a single cell value to a flat string."""
if v is None:
return ""
if isinstance(v, (dict, list, tuple)):
try:
return json.dumps(v, ensure_ascii=False, default=str)
except Exception:
return str(v)
if isinstance(v, bool):
return "true" if v else "false"
return str(v)
def _coerce_rows(payload: Any) -> List[Dict[str, Any]]:
"""Accept many common JSON shapes and return a list of dicts."""
if payload is None:
return []
# Top-level list
if isinstance(payload, list):
return [r for r in payload if isinstance(r, dict)]
if isinstance(payload, dict):
# {rows:[...]} or {data:[...]} or {items:[...]} or {results:[...]}
for key in ("rows", "data", "items", "results", "list"):
v = payload.get(key)
if isinstance(v, list):
return [r for r in v if isinstance(r, dict)]
# {count, rows}
if "rows" in payload and isinstance(payload["rows"], list):
return [r for r in payload["rows"] if isinstance(r, dict)]
# Fallback: a single record
return [payload]
return []
def _ordered_columns(rows: List[Dict[str, Any]]) -> List[str]:
"""Use first row's keys as primary column order, then append any
extra keys discovered later (preserves order, no duplicates)."""
seen: List[str] = []
seen_set = set()
if rows:
for k in rows[0].keys():
if k not in seen_set:
seen.append(k)
seen_set.add(k)
for r in rows[1:]:
for k in r.keys():
if k not in seen_set:
seen.append(k)
seen_set.add(k)
return seen
def _fetch_internal(endpoint: str, authorization: Optional[str]) -> Any:
"""Call the local FastAPI server with the user's Authorization header
forwarded so existing auth/permissions are respected."""
if not endpoint:
raise HTTPException(status_code=400, detail="endpoint required")
# Normalize: must start with / — accept full URL only if it points at us.
ep = unquote(endpoint).strip()
if ep.startswith(("http://", "https://")):
# Only allow our own host to avoid SSRF.
if not ep.startswith(INTERNAL_BASE):
raise HTTPException(status_code=400, detail="external endpoint not allowed")
url = ep
else:
if not ep.startswith("/"):
ep = "/" + ep
url = INTERNAL_BASE + ep
headers: Dict[str, str] = {"Accept": "application/json"}
if authorization:
headers["Authorization"] = authorization
try:
r = requests.get(url, headers=headers, timeout=60)
except Exception as e:
raise HTTPException(status_code=502, detail=f"upstream fetch failed: {e}")
if r.status_code >= 400:
raise HTTPException(
status_code=r.status_code,
detail=f"upstream {r.status_code}: {r.text[:200]}",
)
try:
return r.json()
except Exception:
raise HTTPException(status_code=502, detail="upstream did not return JSON")
# ───────────────────────────────────────────────────────────────────────────
# Format builders
# ───────────────────────────────────────────────────────────────────────────
def _build_csv(rows: List[Dict[str, Any]], filename: str) -> Response:
cols = _ordered_columns(rows)
buf = io.StringIO()
# HR Excel friendly: ; delimiter
w = csv.writer(buf, delimiter=";", quoting=csv.QUOTE_MINIMAL, lineterminator="\r\n")
w.writerow(cols)
for r in rows:
w.writerow([_flatten_value(r.get(c)) for c in cols])
body = ("" + buf.getvalue()).encode("utf-8") # UTF-8 BOM
return Response(
content=body,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
def _build_xlsx(rows: List[Dict[str, Any]], filename: str) -> Response:
if not OPENPYXL_AVAILABLE:
# Graceful fallback: CSV with a warning header.
fallback = filename.rsplit(".", 1)[0] + ".csv"
resp = _build_csv(rows, fallback)
resp.headers["X-Export-Warning"] = "openpyxl unavailable — fell back to CSV"
return resp
cols = _ordered_columns(rows)
wb = Workbook()
ws = wb.active
ws.title = "Export"
# Header row (bold)
bold = Font(bold=True, color="FFFFFFFF")
fill = PatternFill(start_color="FF1F2937", end_color="FF1F2937", fill_type="solid")
align = Alignment(vertical="center", wrap_text=False)
ws.append(cols)
for cell in ws[1]:
cell.font = bold
cell.fill = fill
cell.alignment = align
for r in rows:
ws.append([_flatten_value(r.get(c)) for c in cols])
# Auto column widths (clamped)
for idx, col in enumerate(cols, start=1):
max_len = max(
[len(col)]
+ [len(_flatten_value(r.get(col))) for r in rows[:200]]
+ [10]
)
ws.column_dimensions[ws.cell(row=1, column=idx).column_letter].width = min(
max_len + 2, 60
)
bio = io.BytesIO()
wb.save(bio)
bio.seek(0)
return StreamingResponse(
bio,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
def _build_pdf_html(rows: List[Dict[str, Any]], title: str) -> HTMLResponse:
"""Return a print-friendly HTML page (mock PDF). User invokes
browser's Print → Save as PDF.
<!-- TODO: real PDF via weasyprint/reportlab -->
"""
cols = _ordered_columns(rows)
def _esc(s: Any) -> str:
return (
_flatten_value(s)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
head = "".join(f"<th>{_esc(c)}</th>" for c in cols)
body_rows = "".join(
"<tr>" + "".join(f"<td>{_esc(r.get(c))}</td>" for c in cols) + "</tr>"
for r in rows
)
when = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
html = f"""<!doctype html>
<html lang="hr"><head><meta charset="utf-8">
<title>{_esc(title)}</title>
<style>
body{{font-family:Arial,Helvetica,sans-serif;color:#111;margin:18px}}
h1{{font-size:16px;margin:0 0 8px}}
.meta{{font-size:11px;color:#555;margin-bottom:14px}}
table{{width:100%;border-collapse:collapse;font-size:10px}}
th,td{{border:1px solid #999;padding:4px 6px;text-align:left;vertical-align:top}}
th{{background:#1f2937;color:#fff;font-size:9px;text-transform:uppercase;letter-spacing:.5px}}
tr:nth-child(even) td{{background:#f5f5f5}}
.bar{{margin-bottom:14px}}
.bar button{{background:#0b5cff;color:#fff;border:0;padding:8px 14px;border-radius:4px;cursor:pointer;font-size:12px}}
@media print{{ .bar{{display:none}} body{{margin:6mm}} }}
</style>
</head><body>
<div class="bar"><button onclick="window.print()">🖨 Print / Save as PDF</button></div>
<h1>{_esc(title)}</h1>
<div class="meta">PGŽ Sport ERP/CRM — generirano {when}{len(rows)} redaka</div>
<table><thead><tr>{head}</tr></thead><tbody>{body_rows}</tbody></table>
<!-- TODO: real PDF via weasyprint/reportlab -->
</body></html>"""
return HTMLResponse(content=html, status_code=200)
# ───────────────────────────────────────────────────────────────────────────
# Endpoints
# ───────────────────────────────────────────────────────────────────────────
@router.get("/health")
def export_health():
return {
"ok": True,
"openpyxl_available": OPENPYXL_AVAILABLE,
"formats": ["csv", "xlsx", "pdf"],
"version": "1.0.0",
}
@router.get("")
@router.get("/")
def export_dispatch(
format: str = Query("csv", description="csv|xlsx|pdf"),
endpoint: str = Query(..., description="Internal API path, e.g. /api/v2/erp/payments?godina=2026"),
filters: Optional[str] = Query(None, description="Optional JSON of extra filters merged into endpoint"),
filename: Optional[str] = Query(None, description="Optional filename base (no extension)"),
authorization: Optional[str] = Header(None),
):
fmt = (format or "csv").lower().strip()
if fmt not in ("csv", "xlsx", "pdf"):
raise HTTPException(status_code=400, detail="format must be csv|xlsx|pdf")
# Optionally merge `filters` JSON into endpoint querystring.
target = endpoint
if filters:
try:
extra = json.loads(filters)
if isinstance(extra, dict) and extra:
from urllib.parse import urlencode
qs = urlencode({k: _flatten_value(v) for k, v in extra.items()})
sep = "&" if "?" in target else "?"
target = f"{target}{sep}{qs}"
except Exception:
# Ignore malformed filter JSON — still try the raw endpoint.
pass
payload = _fetch_internal(target, authorization)
rows = _coerce_rows(payload)
base = filename or "export"
stamp = _ts()
if fmt == "csv":
return _build_csv(rows, f"{base}_{stamp}.csv")
if fmt == "xlsx":
return _build_xlsx(rows, f"{base}_{stamp}.xlsx")
# pdf (HTML print-to-PDF mock)
return _build_pdf_html(rows, title=f"{base}{stamp}")