Files
leocrm/app/plugins/builtins/report_generator/jobs.py
T

153 lines
4.7 KiB
Python
Raw Normal View History

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