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

489 lines
15 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
2026-07-25 09:19:32 +02:00
import aiofiles
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 (sandboxed)."""
from jinja2 import StrictUndefined
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment(autoescape=True, undefined=StrictUndefined)
env.globals.clear()
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}
2026-07-25 09:19:32 +02:00
async 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}")
2026-07-25 09:19:32 +02:00
async with aiofiles.open(file_path, "wb") as f:
await 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."""
2026-07-25 09:19:32 +02:00
items = get_preset_list()
return {"items": items, "total": len(items)}
@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}"
2026-07-25 09:19:32 +02:00
async def _iter_file():
async with aiofiles.open(report.output_path, "rb") as f: # type: ignore[arg-type]
while True:
2026-07-25 09:19:32 +02:00
chunk = await f.read(65536)
if not chunk:
break
yield chunk
return StreamingResponse(
_iter_file(),
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)