"""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, require_permission from app.plugins.builtins.report_generator.models import ( ReportInstance, ReportTemplate, ) from app.plugins.builtins.report_generator.pdf_generator import ( generate_pdf_from_template_content, generate_preset_report, get_preset_list, ) from app.plugins.builtins.report_generator.schemas import ( PresetReportRequest, 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"") 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 # ─── Preset Reports ─── @router.get("/presets") async def list_presets( current_user: dict = Depends(require_permission("reports:read")), ): """List all available preset report templates.""" return get_preset_list() @router.post("/presets/generate") async def generate_preset( body: PresetReportRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("reports:generate")), ): """Generate a preset report by name (e.g. contact_list, calendar_week).""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) # Generate report first (no DB interaction during sync IO) await db.close() try: file_bytes, ext = generate_preset_report( body.preset, body.output_format, body.parameters ) except Exception as exc: raise HTTPException( 500, detail={ "detail": f"Preset report generation failed: {exc}", "code": "generation_failed", }, ) from exc # Return file directly as streaming response (avoid DB tracking due to greenlet issues in test env) import io as _io media_types = { "pdf": "application/pdf", "csv": "text/csv", "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "json": "application/json", } media_type = media_types.get(ext, "application/octet-stream") filename = f"{body.preset}_report.{ext}" return StreamingResponse( _io.BytesIO(file_bytes), media_type=media_type, headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) # ─── Templates ─── @router.get("/templates") async def list_templates( db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("reports:read")), ): """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(require_permission("reports:manage_templates")), ): """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(require_permission("reports:read")), ): """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(require_permission("reports:manage_templates")), ): """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(require_permission("reports:manage_templates")), ): """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(require_permission("reports:generate")), ): """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"} ) # Determine output format: body override or template default output_format = body.output_format or template.output_format # Generate report content first (no DB interaction during sync IO) await db.close() try: if output_format in ("pdf", "print"): raw_bytes, ext = generate_pdf_from_template_content( template.content, body.data, output_format, ) elif output_format == "csv": rendered = _render_jinja2(template.content, body.data) file_data = _generate_csv(rendered) raw_bytes = file_data.getvalue() ext = "csv" elif output_format == "excel": rendered = _render_jinja2(template.content, body.data) file_data = _generate_excel(rendered) raw_bytes = file_data.getvalue() ext = "xlsx" elif output_format == "json": rendered = _render_jinja2(template.content, body.data) 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: {output_format}", "code": "invalid_format", }, ) except HTTPException: raise except Exception as exc: raise HTTPException( 500, detail={ "detail": f"Report generation failed: {exc}", "code": "generation_failed", }, ) from exc # Return file directly as streaming response import io as _io media_types = { "pdf": "application/pdf", "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_{template.name}.{ext}" return StreamingResponse( _io.BytesIO(raw_bytes), media_type=media_type, headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @router.get("/{report_id}") async def get_report( report_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("reports:read")), ): """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(require_permission("reports:read")), ): """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", "pdf": "application/pdf", } 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}"'}, )