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.entity_links import EntityLinksPlugin
|
||||
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.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