Files
leocrm/app/plugins/builtins/report_generator/routes.py
T

416 lines
13 KiB
Python
Raw Normal View History

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