feat: report_generator core plugin — Jinja2 templates, CSV/Excel/JSON output, 8 API endpoints

This commit is contained in:
leocrm-bot
2026-07-08 23:50:59 +02:00
parent bb4b0ce514
commit 6710480527
7 changed files with 598 additions and 1 deletions
@@ -0,0 +1,5 @@
"""Report Generator builtin plugin."""
from app.plugins.builtins.report_generator.plugin import ReportGeneratorPlugin
__all__ = ["ReportGeneratorPlugin"]
@@ -0,0 +1,32 @@
-- Report Generator plugin initial migration: creates report_templates and report_instances tables
CREATE TABLE IF NOT EXISTS report_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
template_type VARCHAR(50) NOT NULL DEFAULT 'jinja2',
content TEXT NOT NULL,
output_format VARCHAR(50) NOT NULL DEFAULT 'csv',
tenant_id UUID NOT NULL,
created_by UUID NOT NULL,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_report_templates_tenant ON report_templates(tenant_id);
CREATE INDEX IF NOT EXISTS ix_report_templates_name ON report_templates(name);
CREATE TABLE IF NOT EXISTS report_instances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
template_id UUID NOT NULL REFERENCES report_templates(id) ON DELETE CASCADE,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
output_path VARCHAR(1024),
error_message TEXT,
tenant_id UUID NOT NULL,
created_by UUID NOT NULL,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_report_instances_tenant ON report_instances(tenant_id);
CREATE INDEX IF NOT EXISTS ix_report_instances_template ON report_instances(template_id);
CREATE INDEX IF NOT EXISTS ix_report_instances_status ON report_instances(status);
@@ -0,0 +1,63 @@
"""Report Template and Report Instance models for the Report Generator plugin."""
from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, Index, String, Text
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
class ReportTemplate(Base, TenantMixin):
"""Report template entity — Jinja2 or SQL template, tenant-scoped, soft-deletable."""
__tablename__ = "report_templates"
__table_args__ = (
Index("ix_report_templates_tenant", "tenant_id"),
Index("ix_report_templates_name", "name"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
template_type: Mapped[str] = mapped_column(
String(50), nullable=False, default="jinja2"
)
content: Mapped[str] = mapped_column(Text, nullable=False)
output_format: Mapped[str] = mapped_column(
String(50), nullable=False, default="csv"
)
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
class ReportInstance(Base, TenantMixin):
"""Report instance entity — a generated report, tenant-scoped."""
__tablename__ = "report_instances"
__table_args__ = (
Index("ix_report_instances_tenant", "tenant_id"),
Index("ix_report_instances_template", "template_id"),
Index("ix_report_instances_status", "status"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
template_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("report_templates.id", ondelete="CASCADE"),
nullable=False,
)
status: Mapped[str] = mapped_column(
String(50), nullable=False, default="pending"
)
output_path: Mapped[str | None] = mapped_column(
String(1024), nullable=True
)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
@@ -0,0 +1,29 @@
"""Report Generator plugin — PDF/Excel/CSV reports from Jinja2 templates."""
from __future__ import annotations
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
class ReportGeneratorPlugin(BasePlugin):
"""Report Generator plugin for generating reports from Jinja2 templates."""
manifest = PluginManifest(
name="report_generator",
version="1.0.0",
display_name="Report Generator",
description="Generates PDF/Excel/CSV reports from Jinja2 templates and data sources",
is_core=True,
dependencies=["permissions"],
routes=[
PluginRouteDef(
path="/api/v1/reports",
module="app.plugins.builtins.report_generator.routes",
router_attr="router",
),
],
events=["report.requested", "report.generated"],
migrations=["0001_initial.sql"],
permissions=["reports.read", "reports.generate", "reports.manage_templates"],
)
@@ -0,0 +1,415 @@
"""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}"'},
)
@@ -0,0 +1,52 @@
"""Pydantic schemas for the Report Generator plugin."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class TemplateCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: str = Field("", max_length=2000)
template_type: str = Field("jinja2", pattern="^(jinja2|sql)$")
content: str = Field(..., min_length=1)
output_format: str = Field("csv", pattern="^(csv|excel|json)$")
class TemplateUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
description: str | None = Field(None, max_length=2000)
template_type: str | None = Field(None, pattern="^(jinja2|sql)$")
content: str | None = None
output_format: str | None = Field(None, pattern="^(csv|excel|json)$")
class TemplateResponse(BaseModel):
id: str
name: str
description: str
template_type: str
content: str
output_format: str
created_by: str
deleted_at: datetime | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class ReportGenerateRequest(BaseModel):
template_id: str = Field(..., min_length=1)
data: dict = Field(default_factory=dict)
class ReportResponse(BaseModel):
id: str
template_id: str
status: str
output_path: str | None = None
error_message: str | None = None
created_by: str
created_at: datetime | None = None
updated_at: datetime | None = None