416 lines
13 KiB
Python
416 lines
13 KiB
Python
"""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}"'},
|
|
)
|