9b0ed43b92
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>
298 lines
12 KiB
Python
298 lines
12 KiB
Python
#!/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("&", "&")
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
.replace('"', """)
|
|
)
|
|
|
|
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}")
|