e17b9c9e56
Check Cross-Plugin Imports / check (push) Has been cancelled
- 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
627 lines
21 KiB
Python
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}"'},
|
|
)
|