"""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 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.contracts import get_contract_registry _dms_contract = get_contract_registry().get("dms") dms_file = _dms_contract.dms_file 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 = dms_file( 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)