Files
pgz-sport/routers/export_router.py
T
Damir Radulić 38383d07c5 Task 4: Universal Export ▾ — CSV/XLSX/PDF dropdown across all screens
- routers/export_router.py: /api/v2/export?format=...&endpoint=...&filters=...
- static/js/export_dropdown.js: shared attachExportDropdown helper
- sport2/app/crm_v2/erp_full: Export ▾ button wired to representative tables
- pgz_sport_api.py: mount export_router with try/except

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:33:36 +02:00

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("&", "&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}")