2026-07-29 23:10:50 +02:00
|
|
|
"""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
|
2026-07-31 01:57:51 +02:00
|
|
|
from app.plugins.builtins.contracts import get_contract_registry
|
|
|
|
|
_dms_contract = get_contract_registry().get("dms")
|
|
|
|
|
DmsFile = _dms_contract.DmsFile
|
2026-07-29 23:10:50 +02:00
|
|
|
|
|
|
|
|
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)
|