"""Report Generator plugin routes — templates CRUD, report generation, download.""" from __future__ import annotations import csv import io import json import os import uuid from fastapi import APIRouter, Depends, HTTPException, status from fastapi.responses import StreamingResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.deps import get_current_user from app.plugins.builtins.report_generator.models import ( ReportInstance, ReportTemplate, ) from app.plugins.builtins.report_generator.schemas import ( ReportGenerateRequest, TemplateCreate, TemplateResponse, TemplateUpdate, ) router = APIRouter(prefix="/api/v1/reports", tags=["reports"]) REPORT_STORAGE_BASE = os.environ.get("REPORT_STORAGE_BASE", "/data/storage/reports") def _parse_uuid(val: str, field: str) -> uuid.UUID: try: return uuid.UUID(val) except (ValueError, TypeError): raise HTTPException( 400, detail={"detail": f"Invalid {field}", "code": "invalid_id"} ) from None def _template_to_response(t: ReportTemplate) -> TemplateResponse: return TemplateResponse( id=str(t.id), name=t.name, description=t.description, template_type=t.template_type, content=t.content, output_format=t.output_format, created_by=str(t.created_by), deleted_at=t.deleted_at, created_at=t.created_at, updated_at=t.updated_at, ) def _report_to_response(r: ReportInstance) -> dict: return { "id": str(r.id), "template_id": str(r.template_id), "status": r.status, "output_path": r.output_path, "error_message": r.error_message, "created_by": str(r.created_by), "created_at": r.created_at, "updated_at": r.updated_at, } def _render_jinja2(template_content: str, data: dict) -> str: """Render a Jinja2 template string with the given data.""" from jinja2 import Environment, StrictUndefined env = Environment(autoescape=False, undefined=StrictUndefined) template = env.from_string(template_content) return template.render(**data) def _generate_csv(rendered: str) -> io.BytesIO: """Parse rendered Jinja2 output as CSV and return BytesIO.""" output = io.BytesIO() reader = csv.reader(io.StringIO(rendered)) writer = csv.writer(io.BytesIO()) # temporary, will write directly # Write to BytesIO with UTF-8 BOM for Excel compatibility output.write(b"\xef\xbb\xbf") for row in reader: line = ",".join(f'"{field}"' for field in row) + "\n" output.write(line.encode("utf-8")) output.seek(0) return output def _generate_excel(rendered: str) -> io.BytesIO: """Parse rendered Jinja2 output as CSV and create an Excel workbook.""" from openpyxl import Workbook wb = Workbook() ws = wb.active ws.title = "Report" reader = csv.reader(io.StringIO(rendered)) for row in reader: ws.append(row) output = io.BytesIO() wb.save(output) output.seek(0) return output def _generate_json(rendered: str) -> dict: """Parse rendered Jinja2 output as JSON.""" try: return json.loads(rendered) except json.JSONDecodeError: # If not valid JSON, wrap as plain text result return {"result": rendered} def _save_report_file( tenant_id: uuid.UUID, report_id: uuid.UUID, ext: str, data: bytes ) -> str: """Save report data to disk and return the path.""" dir_path = os.path.join(REPORT_STORAGE_BASE, str(tenant_id)) os.makedirs(dir_path, exist_ok=True) file_path = os.path.join(dir_path, f"{report_id}.{ext}") with open(file_path, "wb") as f: f.write(data) return file_path # ─── Templates ─── @router.get("/templates") async def list_templates( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """List all report templates for the current tenant.""" tenant_id = uuid.UUID(current_user["tenant_id"]) result = await db.execute( select(ReportTemplate).where( ReportTemplate.tenant_id == tenant_id, ReportTemplate.deleted_at.is_(None), ) ) templates = result.scalars().all() return [_template_to_response(t).model_dump() for t in templates] @router.post("/templates", status_code=status.HTTP_201_CREATED) async def create_template( body: TemplateCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Create a new report template.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) template = ReportTemplate( tenant_id=tenant_id, name=body.name, description=body.description, template_type=body.template_type, content=body.content, output_format=body.output_format, created_by=user_id, ) db.add(template) await db.flush() return _template_to_response(template).model_dump() @router.get("/templates/{template_id}") async def get_template( template_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Get a single report template by ID.""" tenant_id = uuid.UUID(current_user["tenant_id"]) tid = _parse_uuid(template_id, "template_id") result = await db.execute( select(ReportTemplate).where( ReportTemplate.id == tid, ReportTemplate.tenant_id == tenant_id, ReportTemplate.deleted_at.is_(None), ) ) template = result.scalar_one_or_none() if template is None: raise HTTPException( 404, detail={"detail": "Template not found", "code": "not_found"} ) return _template_to_response(template).model_dump() @router.put("/templates/{template_id}") async def update_template( template_id: str, body: TemplateUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Update an existing report template.""" tenant_id = uuid.UUID(current_user["tenant_id"]) tid = _parse_uuid(template_id, "template_id") result = await db.execute( select(ReportTemplate).where( ReportTemplate.id == tid, ReportTemplate.tenant_id == tenant_id, ReportTemplate.deleted_at.is_(None), ) ) template = result.scalar_one_or_none() if template is None: raise HTTPException( 404, detail={"detail": "Template not found", "code": "not_found"} ) update_data = body.model_dump(exclude_unset=True) for key, value in update_data.items(): setattr(template, key, value) await db.flush() return _template_to_response(template).model_dump() @router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_template( template_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Soft-delete a report template.""" from datetime import datetime, timezone tenant_id = uuid.UUID(current_user["tenant_id"]) tid = _parse_uuid(template_id, "template_id") result = await db.execute( select(ReportTemplate).where( ReportTemplate.id == tid, ReportTemplate.tenant_id == tenant_id, ReportTemplate.deleted_at.is_(None), ) ) template = result.scalar_one_or_none() if template is None: raise HTTPException( 404, detail={"detail": "Template not found", "code": "not_found"} ) template.deleted_at = datetime.now(timezone.utc) await db.flush() return None # ─── Report Generation ─── @router.post("/generate") async def generate_report( body: ReportGenerateRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Generate a report from a template and data (synchronous).""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) tid = _parse_uuid(body.template_id, "template_id") # Fetch template result = await db.execute( select(ReportTemplate).where( ReportTemplate.id == tid, ReportTemplate.tenant_id == tenant_id, ReportTemplate.deleted_at.is_(None), ) ) template = result.scalar_one_or_none() if template is None: raise HTTPException( 404, detail={"detail": "Template not found", "code": "not_found"} ) # Create report instance as pending report = ReportInstance( tenant_id=tenant_id, template_id=tid, status="running", created_by=user_id, ) db.add(report) await db.flush() try: rendered = _render_jinja2(template.content, body.data) if template.output_format == "csv": file_data = _generate_csv(rendered) ext = "csv" raw_bytes = file_data.getvalue() elif template.output_format == "excel": file_data = _generate_excel(rendered) ext = "xlsx" raw_bytes = file_data.getvalue() elif template.output_format == "json": raw_bytes = json.dumps( _generate_json(rendered), indent=2, ensure_ascii=False ).encode("utf-8") ext = "json" else: raise HTTPException( 400, detail={ "detail": f"Unsupported output format: {template.output_format}", "code": "invalid_format", }, ) # Save to disk file_path = _save_report_file(tenant_id, report.id, ext, raw_bytes) report.status = "completed" report.output_path = file_path await db.flush() return _report_to_response(report) except HTTPException: raise except Exception as exc: report.status = "failed" report.error_message = str(exc) await db.flush() raise HTTPException( 500, detail={ "detail": f"Report generation failed: {exc}", "code": "generation_failed", }, ) from exc @router.get("/{report_id}") async def get_report( report_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Get the status of a report instance.""" tenant_id = uuid.UUID(current_user["tenant_id"]) rid = _parse_uuid(report_id, "report_id") result = await db.execute( select(ReportInstance).where( ReportInstance.id == rid, ReportInstance.tenant_id == tenant_id, ) ) report = result.scalar_one_or_none() if report is None: raise HTTPException( 404, detail={"detail": "Report not found", "code": "not_found"} ) return _report_to_response(report) @router.get("/{report_id}/download") async def download_report( report_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Download a generated report file.""" tenant_id = uuid.UUID(current_user["tenant_id"]) rid = _parse_uuid(report_id, "report_id") result = await db.execute( select(ReportInstance).where( ReportInstance.id == rid, ReportInstance.tenant_id == tenant_id, ) ) report = result.scalar_one_or_none() if report is None: raise HTTPException( 404, detail={"detail": "Report not found", "code": "not_found"} ) if report.status != "completed" or not report.output_path: raise HTTPException( 400, detail={"detail": "Report not ready for download", "code": "not_ready"}, ) if not os.path.exists(report.output_path): raise HTTPException( 404, detail={"detail": "Report file missing on disk", "code": "file_missing"} ) ext = report.output_path.rsplit(".", 1)[-1].lower() media_types = { "csv": "text/csv", "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "json": "application/json", } media_type = media_types.get(ext, "application/octet-stream") filename = f"report_{report.id}.{ext}" def _iter_file(): with open(report.output_path, "rb") as f: # type: ignore[arg-type] while True: chunk = f.read(65536) if not chunk: break yield chunk return StreamingResponse( _iter_file(), media_type=media_type, headers={"Content-Disposition": f'attachment; filename="{filename}"'}, )