feat: report_generator core plugin — Jinja2 templates, CSV/Excel/JSON output, 8 API endpoints
This commit is contained in:
@@ -11,7 +11,8 @@ from app.plugins.builtins.calendar import CalendarPlugin
|
|||||||
from app.plugins.builtins.dms import DmsPlugin
|
from app.plugins.builtins.dms import DmsPlugin
|
||||||
from app.plugins.builtins.entity_links import EntityLinksPlugin
|
from app.plugins.builtins.entity_links import EntityLinksPlugin
|
||||||
from app.plugins.builtins.mail import MailPlugin
|
from app.plugins.builtins.mail import MailPlugin
|
||||||
|
from app.plugins.builtins.report_generator import ReportGeneratorPlugin
|
||||||
from app.plugins.builtins.permissions import PermissionsPlugin
|
from app.plugins.builtins.permissions import PermissionsPlugin
|
||||||
from app.plugins.builtins.tags import TagsPlugin
|
from app.plugins.builtins.tags import TagsPlugin
|
||||||
|
|
||||||
__all__ = ["TagsPlugin", "PermissionsPlugin", "EntityLinksPlugin", "DmsPlugin", "CalendarPlugin", "MailPlugin"]
|
__all__ = ["TagsPlugin", "PermissionsPlugin", "EntityLinksPlugin", "DmsPlugin", "CalendarPlugin", "MailPlugin", "ReportGeneratorPlugin"]
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user