Files
leocrm/app/plugins/builtins/report_generator/routes.py
T
Agent Zero e17b9c9e56
Check Cross-Plugin Imports / check (push) Has been cancelled
Phase 2: Visibility Filter & Owner ID
- Add OwnedMixin to 15 models (contact_folder, user_preference, workspace,
  mcp_server_config, agent_definition, automation_definition, report_template,
  report_instance, entity_link, comm_conversation, proactive_suggestion,
  ai_agent, ai_chat_session, tag, share_link)
- Migration 0102: Add owner_id column to 15 tables with backfill from user_id
- Fix EntityPermission Registry: remove notification, add entity_attachment,
  entity_history, subtask, calendar, folder; fix wrong class names
  (DmsFile→File, CalendarEvent→CalendarEntry, Mailbox→MailAccount)
- Add apply_visibility_filter to list endpoints in tags, tasks, mcp_client,
  automation, report_generator, ai_assistant routes
- Add owner_id to create handlers for all new OwnedMixin models
- Patch tasks/services.py and automation/services.py list methods with
  user_id and is_system_admin parameters
2026-08-04 00:03:29 +02:00

627 lines
21 KiB
Python

"""Report Generator plugin routes — templates CRUD, report generation, download."""
from __future__ import annotations
import csv
import io
import json
import os
import uuid
import aiofiles
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.contact import Contact
from app.core.visibility import apply_visibility_filter
from app.models.audit import AuditLog
from app.core.db import get_db, set_tenant_context
from app.deps import get_current_user, require_permission
from app.plugins.builtins.report_generator.models import (
ReportInstance,
ReportTemplate,
)
from app.plugins.builtins.report_generator.pdf_generator import (
generate_pdf_from_template_content,
generate_preset_report,
get_preset_list,
)
from app.plugins.builtins.report_generator.schemas import (
PresetReportRequest,
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 (sandboxed)."""
from jinja2 import StrictUndefined
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment(autoescape=True, undefined=StrictUndefined)
env.globals.clear()
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"")
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}
async 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}")
async with aiofiles.open(file_path, "wb") as f:
await f.write(data)
return file_path
# ─── Preset Reports ───
@router.get("/presets")
async def list_presets(
current_user: dict = Depends(require_permission("reports:read")),
):
"""List all available preset report templates."""
items = get_preset_list()
return {"items": items, "total": len(items)}
@router.post("/presets/generate")
async def generate_preset(
body: PresetReportRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:generate")),
):
"""Generate a preset report by name (e.g. contact_list, calendar_week).
Fetches live data from the database based on the preset type,
merges with user-provided parameters, and generates the report.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
# Set tenant context for RLS
await set_tenant_context(db, tenant_id)
# Fetch data based on preset type
parameters = dict(body.parameters) # copy user params
try:
if body.preset == "contact_list":
# Fetch all contacts (type='person') for this tenant
q = select(Contact).where(
Contact.tenant_id == tenant_id,
Contact.type == "person",
Contact.deleted_at.is_(None),
).order_by(Contact.displayname)
result = await db.execute(q)
contacts = result.scalars().all()
parameters["contacts"] = [
{
"name": f"{c.firstname or ''} {c.surname or ''}".strip() or c.displayname or "",
"email": c.email_1 or "",
"phone": c.phone_1 or "",
"type": c.type or "person",
"company": "", # contact persons are separate
}
for c in contacts
]
parameters["title"] = parameters.get("title", "Kontaktliste")
elif body.preset == "company_list":
# Fetch all companies (type='company') for this tenant
q = select(Contact).where(
Contact.tenant_id == tenant_id,
Contact.type == "company",
Contact.deleted_at.is_(None),
).order_by(Contact.name)
result = await db.execute(q)
companies = result.scalars().all()
parameters["companies"] = [
{
"name": c.name or c.displayname or "",
"address": (
f"{c.mailing_street or ''} {c.mailing_number or ''}".strip()
or f"{c.visit_street or ''} {c.visit_number or ''}".strip()
or ""
),
"zip": c.mailing_postalcode or "",
"city": c.mailing_city or "",
"phone": c.phone_1 or "",
"email": c.email_1 or "",
"contact_person": "", # could be extended with ContactPerson lookup
}
for c in companies
]
parameters["title"] = parameters.get("title", "Firmenliste")
elif body.preset == "audit_log":
# Fetch recent audit log entries for this tenant
q = select(AuditLog).where(
AuditLog.tenant_id == tenant_id,
).order_by(AuditLog.timestamp.desc()).limit(500)
result = await db.execute(q)
entries = result.scalars().all()
parameters["entries"] = [
{
"timestamp": str(e.timestamp) if e.timestamp else "",
"user": str(e.user_id) if e.user_id else "",
"action": e.action or "",
"entity": e.entity_type or "",
"entity_id": str(e.entity_id) if e.entity_id else "",
"details": str(e.changes) if e.changes else "",
}
for e in entries
]
parameters["title"] = parameters.get("title", "Audit-Log")
elif body.preset in ("calendar_week", "calendar_month"):
# Calendar presets: pass user params through (no DB model yet)
parameters.setdefault("title", "Wochenkalender" if body.preset == "calendar_week" else "Monatskalender")
parameters.setdefault("days", [])
parameters.setdefault("hours", [])
parameters.setdefault("weeks", [])
parameters.setdefault("weekday_names", [])
# Generate report
file_bytes, ext = generate_preset_report(
body.preset, body.output_format, parameters
)
except Exception as exc:
raise HTTPException(
500,
detail={
"detail": f"Preset report generation failed: {exc}",
"code": "generation_failed",
},
) from exc
# Return file directly as streaming response
import io as _io
media_types = {
"pdf": "application/pdf",
"csv": "text/csv",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"json": "application/json",
}
media_type = media_types.get(ext, "application/octet-stream")
filename = f"{body.preset}_report.{ext}"
return StreamingResponse(
_io.BytesIO(file_bytes),
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ─── Templates ───
@router.get("/templates")
async def list_templates(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:read")),
):
"""List all report templates for the current tenant."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("is_system_admin", False)
query = select(ReportTemplate).where(
ReportTemplate.tenant_id == tenant_id,
ReportTemplate.deleted_at.is_(None),
)
query = await apply_visibility_filter(
db, query, "report_template", ReportTemplate, user_id, tenant_id, is_system_admin
)
result = await db.execute(query)
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(require_permission("reports:manage_templates")),
):
"""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,
owner_id=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(require_permission("reports:read")),
):
"""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(require_permission("reports:manage_templates")),
):
"""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(require_permission("reports:manage_templates")),
):
"""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(require_permission("reports:generate")),
):
"""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"}
)
# Determine output format: body override or template default
output_format = body.output_format or template.output_format
# Generate report content first (no DB interaction during sync IO)
await db.close()
try:
if output_format in ("pdf", "print"):
raw_bytes, ext = generate_pdf_from_template_content(
template.content, body.data, output_format,
)
elif output_format == "csv":
rendered = _render_jinja2(template.content, body.data)
file_data = _generate_csv(rendered)
raw_bytes = file_data.getvalue()
ext = "csv"
elif output_format == "excel":
rendered = _render_jinja2(template.content, body.data)
file_data = _generate_excel(rendered)
raw_bytes = file_data.getvalue()
ext = "xlsx"
elif output_format == "json":
rendered = _render_jinja2(template.content, body.data)
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: {output_format}",
"code": "invalid_format",
},
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(
500,
detail={
"detail": f"Report generation failed: {exc}",
"code": "generation_failed",
},
) from exc
# Return file directly as streaming response
import io as _io
media_types = {
"pdf": "application/pdf",
"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_{template.name}.{ext}"
return StreamingResponse(
_io.BytesIO(raw_bytes),
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@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}")
async def get_report(
report_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:read")),
):
"""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(require_permission("reports:read")),
):
"""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",
"pdf": "application/pdf",
}
media_type = media_types.get(ext, "application/octet-stream")
filename = f"report_{report.id}.{ext}"
async def _iter_file():
async with aiofiles.open(report.output_path, "rb") as f: # type: ignore[arg-type]
while True:
chunk = await f.read(65536)
if not chunk:
break
yield chunk
return StreamingResponse(
_iter_file(),
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)