#!/usr/bin/env python3 # ═══════════════════════════════════════════════════════════════════════════ # Fajl: routers/export_router.py | v1.0.0 | 05.05.2026 # Autor: Damir Radulić / 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. """ cols = _ordered_columns(rows) def _esc(s: Any) -> str: return ( _flatten_value(s) .replace("&", "&") .replace("<", "<") .replace(">", ">") .replace('"', """) ) head = "".join(f"{_esc(c)}" for c in cols) body_rows = "".join( "" + "".join(f"{_esc(r.get(c))}" for c in cols) + "" for r in rows ) when = datetime.now().strftime("%Y-%m-%d %H:%M:%S") html = f""" {_esc(title)}

{_esc(title)}

PGŽ Sport ERP/CRM — generirano {when} — {len(rows)} redaka
{head}{body_rows}
""" 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}")