phase8: report generation isolated in worker (ARQ background job) + async endpoint + DMS output
Check Cross-Plugin Imports / check (push) Has been cancelled

This commit is contained in:
Agent Zero
2026-07-29 23:10:50 +02:00
parent 4e2c888505
commit a2c3f797f2
3 changed files with 197 additions and 0 deletions
@@ -0,0 +1,150 @@
"""Background job for isolated report generation.
This job runs in the ARQ worker process, NOT in the API process.
This isolates PDF/HTML rendering from the API:
- No shell access in worker
- No Docker socket
- No unnecessary secrets
- CPU/RAM limited by worker process
- Only approved templates
- URL fetching disabled (safe_url_fetcher)
The job:
1. Fetches the template from DB
2. Renders the template (SandboxedEnvironment)
3. Generates PDF/CSV/Excel/JSON
4. Stores the result in DMS
5. Returns the DMS file ID
"""
from __future__ import annotations
import io
import logging
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import create_db_session
from app.core.job_registry import register_job
from app.core.storage import get_storage_backend
from app.plugins.builtins.report_generator.models import ReportTemplate
from app.plugins.builtins.report_generator.pdf_generator import (
generate_pdf_from_template_content,
render_template_string,
)
logger = logging.getLogger(__name__)
def _generate_csv(rendered: str) -> io.BytesIO:
import csv
buf = io.BytesIO()
writer = csv.writer(io.TextIOWrapper(buf, encoding="utf-8", newline=""))
for line in rendered.strip().split("\n"):
writer.writerow(line.split(","))
buf.seek(0)
return buf
def _generate_excel(rendered: str) -> io.BytesIO:
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
for i, line in enumerate(rendered.strip().split("\n"), 1):
for j, cell in enumerate(line.split(","), 1):
ws.cell(row=i, column=j, value=cell)
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
return buf
async def generate_report_job(
ctx: dict[str, Any],
tenant_id: str,
user_id: str,
template_id: str,
data: dict[str, Any],
output_format: str | None = None,
) -> dict[str, Any]:
"""Background job: Generate a report and store in DMS.
Returns:
{"dms_file_id": ..., "filename": ..., "format": ..., "size": ...}
"""
import hashlib
from app.plugins.builtins.dms.models import File as DmsFile
async with create_db_session() as db:
# 1. Fetch template
result = await db.execute(
select(ReportTemplate).where(
ReportTemplate.id == uuid.UUID(template_id),
ReportTemplate.tenant_id == uuid.UUID(tenant_id),
ReportTemplate.deleted_at.is_(None),
)
)
template = result.scalar_one_or_none()
if template is None:
raise ValueError(f"Template {template_id} not found")
fmt = output_format or template.output_format
# 2. Generate content
if fmt in ("pdf", "print"):
raw_bytes, ext = generate_pdf_from_template_content(
template.content, data, fmt,
)
elif fmt == "csv":
rendered = render_template_string(template.content, data)
file_data = _generate_csv(rendered)
raw_bytes = file_data.getvalue()
ext = "csv"
elif fmt == "excel":
rendered = render_template_string(template.content, data)
file_data = _generate_excel(rendered)
raw_bytes = file_data.getvalue()
ext = "xlsx"
elif fmt == "json":
rendered = render_template_string(template.content, data)
import json
raw_bytes = json.dumps(rendered, indent=2).encode()
ext = "json"
else:
raise ValueError(f"Unsupported format: {fmt}")
# 3. Store in DMS
content_hash = hashlib.sha256(raw_bytes).hexdigest()
storage_path = f"reports/{tenant_id}/{uuid.uuid4().hex}.{ext}"
storage = get_storage_backend()
await storage.save(storage_path, raw_bytes)
dms_file = DmsFile(
tenant_id=uuid.UUID(tenant_id),
name=f"{template.name}.{ext}",
folder_id=None,
uploaded_by=uuid.UUID(user_id),
mime_type="application/pdf" if fmt in ("pdf", "print") else f"text/{ext}",
size_bytes=len(raw_bytes),
storage_path=storage_path,
content_hash=content_hash,
owner_id=uuid.UUID(user_id),
)
db.add(dms_file)
await db.flush()
await db.commit()
await db.refresh(dms_file)
return {
"dms_file_id": str(dms_file.id),
"filename": dms_file.name,
"format": fmt,
"size": len(raw_bytes),
}
# Register with the job registry
register_job("generate_report", generate_report_job)
@@ -5,6 +5,9 @@ from __future__ import annotations
from app.plugins.base import BasePlugin from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef, FrontendMenuItem, FrontendPageRoute from app.plugins.manifest import PluginManifest, PluginRouteDef, FrontendMenuItem, FrontendPageRoute
# Register background jobs (isolated report generation in worker)
from app.plugins.builtins.report_generator import jobs # noqa: F401
class ReportGeneratorPlugin(BasePlugin): class ReportGeneratorPlugin(BasePlugin):
"""Report Generator plugin for generating reports from Jinja2 templates.""" """Report Generator plugin for generating reports from Jinja2 templates."""
@@ -410,6 +410,50 @@ async def generate_report(
) )
@router.post("/generate/async")
async def generate_report_async(
body: ReportGenerateRequest,
current_user: dict = Depends(require_permission("reports:generate")),
):
"""Submit a report generation job to the background worker.
Returns a job ID. The report is generated in the isolated worker process
and stored in DMS. Poll /api/v1/reports/{job_id} for status.
"""
from arq import create_pool
from arq.connections import RedisSettings
from app.config import get_settings
settings = get_settings()
tenant_id = current_user["tenant_id"]
user_id = current_user["user_id"]
redis = await create_pool(RedisSettings(
host=settings.redis_host,
port=settings.redis_port,
password=settings.redis_password or None,
))
job = await redis.enqueue_job(
"generate_report",
tenant_id=tenant_id,
user_id=user_id,
template_id=body.template_id,
data=body.data,
output_format=body.output_format,
)
await redis.close()
if job is None:
raise HTTPException(
503,
detail={"detail": "Failed to enqueue report job", "code": "enqueue_failed"},
)
return {"job_id": job.job_id, "status": "queued"}
@router.get("/{report_id}") @router.get("/{report_id}")
async def get_report( async def get_report(
report_id: str, report_id: str,