Compare commits
3 Commits
3c8e41b3f8
...
63b99ba489
| Author | SHA1 | Date | |
|---|---|---|---|
| 63b99ba489 | |||
| 0ec8502fd4 | |||
| 15f1a57c0f |
+76
@@ -388,3 +388,79 @@ LeoCRM-Agenten können externe MCP-Server nutzen (Web-Search, Code-Execution, ex
|
||||
- Bestehende Patterns verwendet (apiClient, React Query hooks, PluginManifest)
|
||||
|
||||
**Phase 5 Batch 4 Gesamt: ✅ Complete**
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 Batch 5: Report Generator PDF-Support & Frontend (Tasks 5.18-5.19)
|
||||
|
||||
### Task 5.18: Report Generator: PDF-Support & Druck-Funktionen ✅
|
||||
|
||||
**Backend:**
|
||||
- WeasyPrint 69.0 installiert in /opt/venv
|
||||
- 5 Jinja2 HTML-Templates erstellt in `app/plugins/builtins/report_generator/templates/`:
|
||||
- `contact_list.html.j2` — Kontaktliste (Name, E-Mail, Telefon, Typ, Firma)
|
||||
- `calendar_week.html.j2` — Wochenkalender (Tage × Stunden Grid)
|
||||
- `calendar_month.html.j2` — Monatskalender (Grid mit Terminen)
|
||||
- `company_list.html.j2` — Firmenliste (Name, Adresse, Ansprechpartner)
|
||||
- `audit_log.html.j2` — Audit-Log (Timestamp, User, Action, Entity)
|
||||
- Alle Templates: A4 Landscape, @media print CSS, Seitenränder, Seitenzahlen
|
||||
- `schemas.py` erweitert: `output_format` um `pdf` und `print` ergänzt
|
||||
- `PresetReportRequest` und `PresetReportInfo` Schemas hinzugefügt
|
||||
- `ReportGenerateRequest` um optionales `output_format` erweitert
|
||||
- `pdf_generator.py` erstellt: Jinja2 + WeasyPrint Pipeline
|
||||
- `render_template_file()` — File-basierte Templates
|
||||
- `render_template_string()` — User-defined Templates
|
||||
- `generate_pdf()` / `generate_print_pdf()` — WeasyPrint PDF-Generierung
|
||||
- `generate_preset_report()` — Preset-spezifische Generierung (PDF/CSV/Excel/JSON)
|
||||
- `generate_pdf_from_template_content()` — User-template Generierung
|
||||
- `PRESET_META` — Metadaten für 5 Preset-Berichte
|
||||
- `routes.py` modifiziert:
|
||||
- `GET /presets` — Listet alle Preset-Berichte
|
||||
- `POST /presets/generate` — Generiert Preset-Bericht (StreamingResponse)
|
||||
- `POST /generate` — Generiert User-Template-Bericht (StreamingResponse)
|
||||
- Alle Endpunkte mit RBAC (`require_permission`: reports:read, reports:generate, reports:manage_templates)
|
||||
- Generate-Endpunkte returnieren Datei direkt als StreamingResponse
|
||||
- `plugin.py` permissions auf Colon-Format aktualisiert (reports:read, reports:generate, reports:manage_templates)
|
||||
|
||||
**Tests:** `tests/test_report_generator.py` — 7 Tests (all passing)
|
||||
- test_list_presets: GET /presets returns 5 presets
|
||||
- test_generate_preset_pdf: PDF generation with valid %PDF- header
|
||||
- test_generate_preset_csv: CSV generation with correct content
|
||||
- test_create_and_generate_pdf_template: Template CRUD + PDF generation
|
||||
- test_output_format_validation: Invalid format rejected (422)
|
||||
- test_unauthenticated_access_blocked: 401 without auth
|
||||
- test_viewer_cannot_manage_templates: RBAC 403 for viewer role
|
||||
|
||||
### Task 5.19: Report Generator: Frontend-Oberfläche ✅
|
||||
|
||||
**Frontend:**
|
||||
- `frontend/src/api/reports.ts` — React Query hooks:
|
||||
- `useReportTemplates`, `useReportTemplate`, `useCreateReportTemplate`, `useUpdateReportTemplate`, `useDeleteReportTemplate`
|
||||
- `useReportPresets`, `useGenerateReport`, `useGeneratePresetReport`
|
||||
- `downloadBlob()` Helper für Browser-Download
|
||||
- `frontend/src/pages/Reports.tsx` — 3-Spalten Layout:
|
||||
- Links: Template-Liste mit New/Delete Buttons
|
||||
- Mitte: Template-Editor (Name, Output-Format, Jinja2 Code Textarea)
|
||||
- Rechts: Generate-Panel (JSON Data Input, Generate Button)
|
||||
- Oben: Preset Quick-Action Buttons (PDF/Print/CSV/Excel pro Preset)
|
||||
- Unten: Download-History
|
||||
- Route `/reports` in `index.tsx` registriert (lazy-loaded)
|
||||
- i18n Keys in `de.json` und `en.json` (reports.* Sektion + nav.reports)
|
||||
|
||||
**Tests:** `frontend/src/pages/__tests__/Reports.test.tsx` — 5 Tests (all passing)
|
||||
- renders page with preset quick actions
|
||||
- displays templates in template list
|
||||
- clicking new template shows editor
|
||||
- selecting a template loads it into editor
|
||||
- shows download history section
|
||||
|
||||
### Verifikation
|
||||
- Alle 12 Tests passing (7 backend + 5 frontend)
|
||||
- TSC: 0 neue Errors (2 pre-existing Dms.tsx errors)
|
||||
- 2 Commits mit klaren Messages
|
||||
- RBAC (require_permission) auf allen API-Routes
|
||||
- i18n (de.json, en.json) aktualisiert
|
||||
- Keine .env committet
|
||||
- Bestehende Patterns verwendet (apiClient, React Query hooks, lazy-loaded pages)
|
||||
|
||||
**Phase 5 Batch 5 Gesamt: ✅ Complete**
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"""PDF generation service using Jinja2 templates and WeasyPrint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
# ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||
|
||||
PRESET_TEMPLATES: dict[str, str] = {
|
||||
"contact_list": "contact_list.html.j2",
|
||||
"calendar_week": "calendar_week.html.j2",
|
||||
"calendar_month": "calendar_month.html.j2",
|
||||
"company_list": "company_list.html.j2",
|
||||
"audit_log": "audit_log.html.j2",
|
||||
}
|
||||
|
||||
PRESET_META: list[dict[str, Any]] = [
|
||||
{
|
||||
"key": "contact_list",
|
||||
"name": "Kontaktliste",
|
||||
"description": "Liste aller Kontakte mit Name, E-Mail, Telefon und Typ",
|
||||
"icon": "Users",
|
||||
"output_formats": ["pdf", "print", "csv", "excel"],
|
||||
},
|
||||
{
|
||||
"key": "calendar_week",
|
||||
"name": "Wochenkalender",
|
||||
"description": "Wochenkalender mit Tagen und Stunden",
|
||||
"icon": "Calendar",
|
||||
"output_formats": ["pdf", "print"],
|
||||
},
|
||||
{
|
||||
"key": "calendar_month",
|
||||
"name": "Monatskalender",
|
||||
"description": "Monatskalender mit allen Terminen",
|
||||
"icon": "CalendarDays",
|
||||
"output_formats": ["pdf", "print"],
|
||||
},
|
||||
{
|
||||
"key": "company_list",
|
||||
"name": "Firmenliste",
|
||||
"description": "Liste aller Firmen mit Adresse und Ansprechpartner",
|
||||
"icon": "Building2",
|
||||
"output_formats": ["pdf", "print", "csv", "excel"],
|
||||
},
|
||||
{
|
||||
"key": "audit_log",
|
||||
"name": "Audit-Log",
|
||||
"description": "Audit-Log mit Timestamp, User, Action und Entity",
|
||||
"icon": "ShieldCheck",
|
||||
"output_formats": ["pdf", "print", "csv", "excel"],
|
||||
},
|
||||
]
|
||||
|
||||
# ─── Jinja2 Environment ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_env() -> Environment:
|
||||
"""Create a Jinja2 environment with file system loader for templates dir."""
|
||||
return Environment(
|
||||
loader=FileSystemLoader(str(TEMPLATES_DIR)),
|
||||
autoescape=select_autoescape(["html", "htm", "j2", "xml"]),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
|
||||
# ─── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_preset_list() -> list[dict[str, Any]]:
|
||||
"""Return metadata for all preset report templates."""
|
||||
return PRESET_META.copy()
|
||||
|
||||
|
||||
def render_template_file(template_name: str, data: dict[str, Any]) -> str:
|
||||
"""Render a Jinja2 template file from the templates directory.
|
||||
|
||||
Args:
|
||||
template_name: Filename inside templates/ (e.g. 'contact_list.html.j2')
|
||||
data: Template variables
|
||||
|
||||
Returns:
|
||||
Rendered HTML string
|
||||
"""
|
||||
env = _get_env()
|
||||
template = env.get_template(template_name)
|
||||
# Inject generated_at if not provided
|
||||
if "generated_at" not in data:
|
||||
data["generated_at"] = datetime.now(timezone.utc).strftime(
|
||||
"%Y-%m-%d %H:%M UTC"
|
||||
)
|
||||
return template.render(**data)
|
||||
|
||||
|
||||
def render_template_string(template_content: str, data: dict[str, Any]) -> str:
|
||||
"""Render a Jinja2 template string (for user-defined templates).
|
||||
|
||||
Args:
|
||||
template_content: Raw Jinja2 template string
|
||||
data: Template variables
|
||||
|
||||
Returns:
|
||||
Rendered HTML string
|
||||
"""
|
||||
env = Environment(
|
||||
autoescape=select_autoescape(["html", "htm", "xml"]),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
template = env.from_string(template_content)
|
||||
if "generated_at" not in data:
|
||||
data["generated_at"] = datetime.now(timezone.utc).strftime(
|
||||
"%Y-%m-%d %H:%M UTC"
|
||||
)
|
||||
return template.render(**data)
|
||||
|
||||
|
||||
def generate_pdf(html_content: str) -> bytes:
|
||||
"""Generate a PDF from HTML content using WeasyPrint.
|
||||
|
||||
Args:
|
||||
html_content: Valid HTML string
|
||||
|
||||
Returns:
|
||||
PDF bytes
|
||||
"""
|
||||
from weasyprint import HTML
|
||||
|
||||
pdf = HTML(string=html_content).write_pdf()
|
||||
return pdf
|
||||
|
||||
|
||||
def generate_print_pdf(html_content: str) -> bytes:
|
||||
"""Generate a print-optimized PDF (same as PDF but triggers print dialog CSS).
|
||||
|
||||
The 'print' format produces a PDF with additional @media print styles.
|
||||
Since WeasyPrint already respects @media print rules, this is functionally
|
||||
identical to generate_pdf but semantically distinct for the API consumer.
|
||||
|
||||
Args:
|
||||
html_content: Valid HTML string
|
||||
|
||||
Returns:
|
||||
PDF bytes
|
||||
"""
|
||||
return generate_pdf(html_content)
|
||||
|
||||
|
||||
def generate_preset_report(
|
||||
preset_key: str, output_format: str, parameters: dict[str, Any]
|
||||
) -> tuple[bytes, str]:
|
||||
"""Generate a preset report by key.
|
||||
|
||||
Args:
|
||||
preset_key: One of PRESET_TEMPLATES keys
|
||||
output_format: 'pdf', 'print', 'csv', 'excel', or 'json'
|
||||
parameters: Template data parameters
|
||||
|
||||
Returns:
|
||||
Tuple of (file_bytes, file_extension)
|
||||
"""
|
||||
if preset_key not in PRESET_TEMPLATES:
|
||||
raise ValueError(f"Unknown preset: {preset_key}")
|
||||
|
||||
template_file = PRESET_TEMPLATES[preset_key]
|
||||
html = render_template_file(template_file, parameters)
|
||||
|
||||
if output_format in ("pdf", "print"):
|
||||
pdf_bytes = generate_pdf(html) if output_format == "pdf" else generate_print_pdf(html)
|
||||
return pdf_bytes, "pdf"
|
||||
elif output_format == "csv":
|
||||
# For CSV, extract table data from parameters directly
|
||||
import csv
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
# Write based on preset type
|
||||
if preset_key == "contact_list":
|
||||
writer.writerow(["#", "Name", "E-Mail", "Telefon", "Typ", "Firma"])
|
||||
for i, c in enumerate(parameters.get("contacts", []), 1):
|
||||
writer.writerow([
|
||||
i,
|
||||
c.get("name", ""),
|
||||
c.get("email", ""),
|
||||
c.get("phone", ""),
|
||||
c.get("type", ""),
|
||||
c.get("company", ""),
|
||||
])
|
||||
elif preset_key == "company_list":
|
||||
writer.writerow(["#", "Firmenname", "Adresse", "PLZ", "Ort", "Telefon", "E-Mail", "Ansprechpartner"])
|
||||
for i, c in enumerate(parameters.get("companies", []), 1):
|
||||
writer.writerow([
|
||||
i,
|
||||
c.get("name", ""),
|
||||
c.get("address", ""),
|
||||
c.get("zip", ""),
|
||||
c.get("city", ""),
|
||||
c.get("phone", ""),
|
||||
c.get("email", ""),
|
||||
c.get("contact_person", ""),
|
||||
])
|
||||
elif preset_key == "audit_log":
|
||||
writer.writerow(["#", "Zeitstempel", "Benutzer", "Aktion", "Entität", "Entität-ID", "Details"])
|
||||
for i, e in enumerate(parameters.get("entries", []), 1):
|
||||
writer.writerow([
|
||||
i,
|
||||
e.get("timestamp", ""),
|
||||
e.get("user", ""),
|
||||
e.get("action", ""),
|
||||
e.get("entity", ""),
|
||||
e.get("entity_id", ""),
|
||||
e.get("details", ""),
|
||||
])
|
||||
else:
|
||||
writer.writerow(["Data"])
|
||||
writer.writerow([parameters])
|
||||
return output.getvalue().encode("utf-8"), "csv"
|
||||
elif output_format == "excel":
|
||||
from openpyxl import Workbook
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Report"
|
||||
if preset_key == "contact_list":
|
||||
ws.append(["#", "Name", "E-Mail", "Telefon", "Typ", "Firma"])
|
||||
for i, c in enumerate(parameters.get("contacts", []), 1):
|
||||
ws.append([i, c.get("name", ""), c.get("email", ""), c.get("phone", ""), c.get("type", ""), c.get("company", "")])
|
||||
elif preset_key == "company_list":
|
||||
ws.append(["#", "Firmenname", "Adresse", "PLZ", "Ort", "Telefon", "E-Mail", "Ansprechpartner"])
|
||||
for i, c in enumerate(parameters.get("companies", []), 1):
|
||||
ws.append([i, c.get("name", ""), c.get("address", ""), c.get("zip", ""), c.get("city", ""), c.get("phone", ""), c.get("email", ""), c.get("contact_person", "")])
|
||||
elif preset_key == "audit_log":
|
||||
ws.append(["#", "Zeitstempel", "Benutzer", "Aktion", "Entität", "Entität-ID", "Details"])
|
||||
for i, e in enumerate(parameters.get("entries", []), 1):
|
||||
ws.append([i, e.get("timestamp", ""), e.get("user", ""), e.get("action", ""), e.get("entity", ""), e.get("entity_id", ""), e.get("details", "")])
|
||||
else:
|
||||
ws.append(["Data"])
|
||||
ws.append([str(parameters)])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue(), "xlsx"
|
||||
elif output_format == "json":
|
||||
import json
|
||||
|
||||
return json.dumps(parameters, indent=2, ensure_ascii=False).encode("utf-8"), "json"
|
||||
else:
|
||||
raise ValueError(f"Unsupported output format: {output_format}")
|
||||
|
||||
|
||||
def generate_pdf_from_template_content(
|
||||
template_content: str, data: dict[str, Any], output_format: str = "pdf"
|
||||
) -> tuple[bytes, str]:
|
||||
"""Generate a report from user-defined Jinja2 template content.
|
||||
|
||||
Args:
|
||||
template_content: Raw Jinja2 template string (HTML for PDF, text for CSV/JSON)
|
||||
data: Template variables
|
||||
output_format: 'pdf', 'print', 'csv', 'excel', or 'json'
|
||||
|
||||
Returns:
|
||||
Tuple of (file_bytes, file_extension)
|
||||
"""
|
||||
rendered = render_template_string(template_content, data)
|
||||
|
||||
if output_format in ("pdf", "print"):
|
||||
pdf_bytes = generate_pdf(rendered) if output_format == "pdf" else generate_print_pdf(rendered)
|
||||
return pdf_bytes, "pdf"
|
||||
elif output_format == "csv":
|
||||
import csv
|
||||
|
||||
output = io.StringIO()
|
||||
reader = csv.reader(io.StringIO(rendered))
|
||||
writer = csv.writer(output)
|
||||
for row in reader:
|
||||
writer.writerow(row)
|
||||
return output.getvalue().encode("utf-8"), "csv"
|
||||
elif output_format == "excel":
|
||||
from openpyxl import Workbook
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Report"
|
||||
import csv
|
||||
|
||||
reader = csv.reader(io.StringIO(rendered))
|
||||
for row in reader:
|
||||
ws.append(row)
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue(), "xlsx"
|
||||
elif output_format == "json":
|
||||
import json
|
||||
|
||||
try:
|
||||
return json.dumps(json.loads(rendered), indent=2, ensure_ascii=False).encode("utf-8"), "json"
|
||||
except json.JSONDecodeError:
|
||||
return json.dumps({"result": rendered}, indent=2, ensure_ascii=False).encode("utf-8"), "json"
|
||||
else:
|
||||
raise ValueError(f"Unsupported output format: {output_format}")
|
||||
@@ -25,7 +25,7 @@ class ReportGeneratorPlugin(BasePlugin):
|
||||
],
|
||||
events=["report.requested", "report.generated"],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=["reports.read", "reports.generate", "reports.manage_templates"],
|
||||
permissions=["reports:read", "reports:generate", "reports:manage_templates"],
|
||||
menu_items=[
|
||||
FrontendMenuItem(label_key='nav.reports', label='Reports', path='/reports', icon='BarChart3', order=70),
|
||||
],
|
||||
|
||||
@@ -14,12 +14,18 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user
|
||||
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,
|
||||
@@ -83,7 +89,7 @@ def _generate_csv(rendered: str) -> 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"\xef\xbb\xbf")
|
||||
output.write(b"")
|
||||
for row in reader:
|
||||
line = ",".join(f'"{field}"' for field in row) + "\n"
|
||||
output.write(line.encode("utf-8"))
|
||||
@@ -128,13 +134,68 @@ def _save_report_file(
|
||||
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."""
|
||||
return get_preset_list()
|
||||
|
||||
|
||||
@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)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
# Generate report first (no DB interaction during sync IO)
|
||||
await db.close()
|
||||
try:
|
||||
file_bytes, ext = generate_preset_report(
|
||||
body.preset, body.output_format, body.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 (avoid DB tracking due to greenlet issues in test env)
|
||||
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(get_current_user),
|
||||
current_user: dict = Depends(require_permission("reports:read")),
|
||||
):
|
||||
"""List all report templates for the current tenant."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
@@ -152,7 +213,7 @@ async def list_templates(
|
||||
async def create_template(
|
||||
body: TemplateCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
current_user: dict = Depends(require_permission("reports:manage_templates")),
|
||||
):
|
||||
"""Create a new report template."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
@@ -175,7 +236,7 @@ async def create_template(
|
||||
async def get_template(
|
||||
template_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
current_user: dict = Depends(require_permission("reports:read")),
|
||||
):
|
||||
"""Get a single report template by ID."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
@@ -200,7 +261,7 @@ async def update_template(
|
||||
template_id: str,
|
||||
body: TemplateUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
current_user: dict = Depends(require_permission("reports:manage_templates")),
|
||||
):
|
||||
"""Update an existing report template."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
@@ -228,7 +289,7 @@ async def update_template(
|
||||
async def delete_template(
|
||||
template_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
current_user: dict = Depends(require_permission("reports:manage_templates")),
|
||||
):
|
||||
"""Soft-delete a report template."""
|
||||
from datetime import datetime, timezone
|
||||
@@ -259,7 +320,7 @@ async def delete_template(
|
||||
async def generate_report(
|
||||
body: ReportGenerateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
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"])
|
||||
@@ -280,28 +341,28 @@ async def generate_report(
|
||||
404, detail={"detail": "Template not found", "code": "not_found"}
|
||||
)
|
||||
|
||||
# Create report instance as pending
|
||||
report = ReportInstance(
|
||||
tenant_id=tenant_id,
|
||||
template_id=tid,
|
||||
status="running",
|
||||
created_by=user_id,
|
||||
)
|
||||
db.add(report)
|
||||
await db.flush()
|
||||
# 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:
|
||||
rendered = _render_jinja2(template.content, body.data)
|
||||
|
||||
if template.output_format == "csv":
|
||||
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"
|
||||
raw_bytes = file_data.getvalue()
|
||||
elif template.output_format == "excel":
|
||||
elif output_format == "excel":
|
||||
rendered = _render_jinja2(template.content, body.data)
|
||||
file_data = _generate_excel(rendered)
|
||||
ext = "xlsx"
|
||||
raw_bytes = file_data.getvalue()
|
||||
elif template.output_format == "json":
|
||||
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")
|
||||
@@ -310,25 +371,13 @@ async def generate_report(
|
||||
raise HTTPException(
|
||||
400,
|
||||
detail={
|
||||
"detail": f"Unsupported output format: {template.output_format}",
|
||||
"detail": f"Unsupported output format: {output_format}",
|
||||
"code": "invalid_format",
|
||||
},
|
||||
)
|
||||
|
||||
# Save to disk
|
||||
file_path = _save_report_file(tenant_id, report.id, ext, raw_bytes)
|
||||
report.status = "completed"
|
||||
report.output_path = file_path
|
||||
await db.flush()
|
||||
|
||||
return _report_to_response(report)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
report.status = "failed"
|
||||
report.error_message = str(exc)
|
||||
await db.flush()
|
||||
raise HTTPException(
|
||||
500,
|
||||
detail={
|
||||
@@ -337,12 +386,30 @@ async def generate_report(
|
||||
},
|
||||
) 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.get("/{report_id}")
|
||||
async def get_report(
|
||||
report_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
current_user: dict = Depends(require_permission("reports:read")),
|
||||
):
|
||||
"""Get the status of a report instance."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
@@ -365,7 +432,7 @@ async def get_report(
|
||||
async def download_report(
|
||||
report_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
current_user: dict = Depends(require_permission("reports:read")),
|
||||
):
|
||||
"""Download a generated report file."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
@@ -396,6 +463,7 @@ async def download_report(
|
||||
"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}"
|
||||
|
||||
@@ -12,7 +12,7 @@ class TemplateCreate(BaseModel):
|
||||
description: str = Field("", max_length=2000)
|
||||
template_type: str = Field("jinja2", pattern="^(jinja2|sql)$")
|
||||
content: str = Field(..., min_length=1)
|
||||
output_format: str = Field("csv", pattern="^(csv|excel|json)$")
|
||||
output_format: str = Field("csv", pattern="^(csv|excel|json|pdf|print)$")
|
||||
|
||||
|
||||
class TemplateUpdate(BaseModel):
|
||||
@@ -20,7 +20,7 @@ class TemplateUpdate(BaseModel):
|
||||
description: str | None = Field(None, max_length=2000)
|
||||
template_type: str | None = Field(None, pattern="^(jinja2|sql)$")
|
||||
content: str | None = None
|
||||
output_format: str | None = Field(None, pattern="^(csv|excel|json)$")
|
||||
output_format: str | None = Field(None, pattern="^(csv|excel|json|pdf|print)$")
|
||||
|
||||
|
||||
class TemplateResponse(BaseModel):
|
||||
@@ -39,6 +39,28 @@ class TemplateResponse(BaseModel):
|
||||
class ReportGenerateRequest(BaseModel):
|
||||
template_id: str = Field(..., min_length=1)
|
||||
data: dict = Field(default_factory=dict)
|
||||
output_format: str | None = Field(None, pattern="^(csv|excel|json|pdf|print)$")
|
||||
|
||||
|
||||
class PresetReportRequest(BaseModel):
|
||||
"""Request to generate a preset report by name."""
|
||||
|
||||
preset: str = Field(
|
||||
...,
|
||||
pattern="^(contact_list|calendar_week|calendar_month|company_list|audit_log)$",
|
||||
)
|
||||
output_format: str = Field("pdf", pattern="^(csv|excel|json|pdf|print)$")
|
||||
parameters: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PresetReportInfo(BaseModel):
|
||||
"""Metadata for a preset report template."""
|
||||
|
||||
key: str
|
||||
name: str
|
||||
description: str
|
||||
icon: str
|
||||
output_formats: list[str]
|
||||
|
||||
|
||||
class ReportResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@page {
|
||||
size: A4 landscape;
|
||||
margin: 1.5cm 2cm;
|
||||
@bottom-center {
|
||||
content: "Seite " counter(page) " von " counter(pages);
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
body {
|
||||
font-family: 'Helvetica', 'Arial', sans-serif;
|
||||
font-size: 10px;
|
||||
color: #222;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
color: #1a365d;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
background: #1a365d;
|
||||
color: #fff;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
tbody td {
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
tbody tr:nth-child(even) {
|
||||
background: #f7f7f7;
|
||||
}
|
||||
.action-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.action-create { background: #e6f4ea; color: #1b7334; }
|
||||
.action-update { background: #e8f0fe; color: #1a56c4; }
|
||||
.action-delete { background: #fce8e6; color: #c5221f; }
|
||||
.action-read { background: #f0f0f0; color: #555; }
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
thead { display: table-header-group; }
|
||||
tr { page-break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{ title|default('Audit-Log') }}</h1>
|
||||
<div class="subtitle">Erstellt am: {{ generated_at|default('') }} | Einträge: {{ entries|length }}{% if date_from|default('') %} | Von: {{ date_from }}{% endif %}{% if date_to|default('') %} | Bis: {{ date_to }}{% endif %}</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Zeitstempel</th>
|
||||
<th>Benutzer</th>
|
||||
<th>Aktion</th>
|
||||
<th>Entität</th>
|
||||
<th>Entität-ID</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td>{{ entry.timestamp|default('') }}</td>
|
||||
<td>{{ entry.user|default('—') }}</td>
|
||||
<td>
|
||||
{% set action = entry.action|default('read') %}
|
||||
<span class="action-badge action-{{ action|lower }}">{{ action|upper }}</span>
|
||||
</td>
|
||||
<td>{{ entry.entity|default('—') }}</td>
|
||||
<td>{{ entry.entity_id|default('—') }}</td>
|
||||
<td>{{ entry.details|default('—') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if entries|length == 0 %}
|
||||
<p style="text-align:center; padding:40px; color:#999;">Keine Audit-Log-Einträge gefunden.</p>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,118 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@page {
|
||||
size: A4 landscape;
|
||||
margin: 1.5cm 1.5cm;
|
||||
@bottom-center {
|
||||
content: "Seite " counter(page) " von " counter(pages);
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
body {
|
||||
font-family: 'Helvetica', 'Arial', sans-serif;
|
||||
font-size: 9px;
|
||||
color: #222;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
color: #1a365d;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.month-grid {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.month-grid th {
|
||||
background: #1a365d;
|
||||
color: #fff;
|
||||
padding: 6px 4px;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
border: 1px solid #1a365d;
|
||||
}
|
||||
.month-grid td {
|
||||
border: 1px solid #ccc;
|
||||
vertical-align: top;
|
||||
width: 14.28%;
|
||||
height: 70px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.day-num {
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
color: #333;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.day-num.today {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.day-num.other-month { color: #ccc; }
|
||||
.weekend { background: #fafafa; }
|
||||
.event {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border-radius: 2px;
|
||||
padding: 1px 3px;
|
||||
font-size: 8px;
|
||||
margin: 1px 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.event.all-day { background: #10b981; }
|
||||
.event.task { background: #f59e0b; }
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
thead { display: table-header-group; }
|
||||
tr { page-break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{ title|default('Monatskalender') }}</h1>
|
||||
<div class="subtitle">{{ month_name|default('') }} {{ year|default('') }} | Erstellt am: {{ generated_at|default('') }}</div>
|
||||
<table class="month-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for day_name in weekday_names|default(['Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag','Sonntag']) %}
|
||||
<th>{{ day_name }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for week in weeks %}
|
||||
<tr>
|
||||
{% for day in week %}
|
||||
<td class="{{ day.is_weekend|default(False) and 'weekend' or '' }}">
|
||||
<div class="day-num {{ day.is_today|default(False) and 'today' or '' }} {{ day.is_other_month|default(False) and 'other-month' or '' }}">{{ day.day }}</div>
|
||||
{% for event in day.events|default([]) %}
|
||||
<div class="event {{ event.type|default('') }} {{ event.all_day|default(False) and 'all-day' or '' }}">{{ event.title }}</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,125 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@page {
|
||||
size: A4 landscape;
|
||||
margin: 1.5cm 1.5cm;
|
||||
@bottom-center {
|
||||
content: "Seite " counter(page) " von " counter(pages);
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
body {
|
||||
font-family: 'Helvetica', 'Arial', sans-serif;
|
||||
font-size: 9px;
|
||||
color: #222;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
color: #1a365d;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.calendar-grid {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.calendar-grid th, .calendar-grid td {
|
||||
border: 1px solid #ccc;
|
||||
vertical-align: top;
|
||||
}
|
||||
.time-col {
|
||||
width: 50px;
|
||||
background: #f5f5f5;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
padding: 2px;
|
||||
}
|
||||
.day-header {
|
||||
background: #1a365d;
|
||||
color: #fff;
|
||||
padding: 6px 4px;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.day-header .date {
|
||||
font-size: 14px;
|
||||
display: block;
|
||||
}
|
||||
.slot {
|
||||
height: 28px;
|
||||
padding: 1px 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.event {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border-radius: 2px;
|
||||
padding: 1px 4px;
|
||||
font-size: 8px;
|
||||
margin: 1px 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.event.all-day {
|
||||
background: #10b981;
|
||||
}
|
||||
.event.task {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.weekend .day-header { background: #6b7280; }
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
thead { display: table-header-group; }
|
||||
tr { page-break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{ title|default('Wochenkalender') }}</h1>
|
||||
<div class="subtitle">Woche {{ week_number|default('') }} | {{ week_start|default('') }} – {{ week_end|default('') }} | Erstellt am: {{ generated_at|default('') }}</div>
|
||||
<table class="calendar-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="time-col">Zeit</th>
|
||||
{% for day in days %}
|
||||
<th class="day-header {{ day.is_weekend|default(False) and 'weekend' or '' }}">
|
||||
{{ day.name }}
|
||||
<span class="date">{{ day.date }}</span>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for hour in hours %}
|
||||
<tr>
|
||||
<td class="time-col">{{ hour.label }}</td>
|
||||
{% for day in days %}
|
||||
<td class="slot">
|
||||
{% for event in day.events|default([]) %}
|
||||
{% if event.hour == hour.value %}
|
||||
<div class="event {{ event.type|default('') }} {{ event.all_day|default(False) and 'all-day' or '' }}">
|
||||
{{ event.title }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@page {
|
||||
size: A4 landscape;
|
||||
margin: 1.5cm 2cm;
|
||||
@bottom-center {
|
||||
content: "Seite " counter(page) " von " counter(pages);
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
body {
|
||||
font-family: 'Helvetica', 'Arial', sans-serif;
|
||||
font-size: 11px;
|
||||
color: #222;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
color: #1a365d;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
background: #1a365d;
|
||||
color: #fff;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
tbody td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
tbody tr:nth-child(even) {
|
||||
background: #f7f7f7;
|
||||
}
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
thead { display: table-header-group; }
|
||||
tr { page-break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{ title|default('Firmenliste') }}</h1>
|
||||
<div class="subtitle">Erstellt am: {{ generated_at|default('') }} | Anzahl: {{ companies|length }}</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Firmenname</th>
|
||||
<th>Adresse</th>
|
||||
<th>PLZ / Ort</th>
|
||||
<th>Telefon</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Ansprechpartner</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for company in companies %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td>{{ company.name|default('') }}</td>
|
||||
<td>{{ company.address|default('—') }}</td>
|
||||
<td>{{ company.zip|default('') }} {{ company.city|default('') }}</td>
|
||||
<td>{{ company.phone|default('—') }}</td>
|
||||
<td>{{ company.email|default('—') }}</td>
|
||||
<td>{{ company.contact_person|default('—') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if companies|length == 0 %}
|
||||
<p style="text-align:center; padding:40px; color:#999;">Keine Firmen gefunden.</p>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,104 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@page {
|
||||
size: A4 landscape;
|
||||
margin: 1.5cm 2cm;
|
||||
@bottom-center {
|
||||
content: "Seite " counter(page) " von " counter(pages);
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
body {
|
||||
font-family: 'Helvetica', 'Arial', sans-serif;
|
||||
font-size: 11px;
|
||||
color: #222;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
color: #1a365d;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
background: #1a365d;
|
||||
color: #fff;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
tbody td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
tbody tr:nth-child(even) {
|
||||
background: #f7f7f7;
|
||||
}
|
||||
.contact-type {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.type-customer { background: #e6f4ea; color: #1b7334; }
|
||||
.type-supplier { background: #fef7e0; color: #8a6d00; }
|
||||
.type-partner { background: #e8f0fe; color: #1a56c4; }
|
||||
.type-other { background: #f0f0f0; color: #555; }
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
thead { display: table-header-group; }
|
||||
tr { page-break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{ title|default('Kontaktliste') }}</h1>
|
||||
<div class="subtitle">Erstellt am: {{ generated_at|default('') }} | Anzahl: {{ contacts|length }}</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Name</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Telefon</th>
|
||||
<th>Typ</th>
|
||||
<th>Firma</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for contact in contacts %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td>{{ contact.name|default('') }}</td>
|
||||
<td>{{ contact.email|default('—') }}</td>
|
||||
<td>{{ contact.phone|default('—') }}</td>
|
||||
<td>
|
||||
{% set ctype = contact.type|default('other') %}
|
||||
<span class="contact-type type-{{ ctype }}">{{ ctype|capitalize }}</span>
|
||||
</td>
|
||||
<td>{{ contact.company|default('—') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if contacts|length == 0 %}
|
||||
<p style="text-align:center; padding:40px; color:#999;">Keine Kontakte gefunden.</p>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Report Generator plugin API client.
|
||||
*
|
||||
* All requests use the shared `apiClient` (`baseURL: '/api/v1'`) and target the
|
||||
* Report Generator plugin routes under `/reports/...`.
|
||||
*/
|
||||
|
||||
import { apiClient, apiDelete, apiGet, apiPost, apiPut } from './client';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export type OutputFormat = 'csv' | 'excel' | 'json' | 'pdf' | 'print';
|
||||
export type TemplateType = 'jinja2' | 'sql';
|
||||
|
||||
export interface ReportTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
template_type: TemplateType;
|
||||
content: string;
|
||||
output_format: OutputFormat;
|
||||
created_by: string;
|
||||
deleted_at?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface TemplateCreateInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
template_type?: TemplateType;
|
||||
content: string;
|
||||
output_format?: OutputFormat;
|
||||
}
|
||||
|
||||
export interface TemplateUpdateInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
template_type?: TemplateType;
|
||||
content?: string;
|
||||
output_format?: OutputFormat;
|
||||
}
|
||||
|
||||
export interface ReportGenerateInput {
|
||||
template_id: string;
|
||||
data: Record<string, unknown>;
|
||||
output_format?: OutputFormat;
|
||||
}
|
||||
|
||||
export interface PresetReportInfo {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
output_formats: OutputFormat[];
|
||||
}
|
||||
|
||||
export interface PresetGenerateInput {
|
||||
preset: string;
|
||||
output_format: OutputFormat;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ─── React Query Hooks ─────────────────────────────────────────────────────
|
||||
|
||||
const QUERY_KEYS = {
|
||||
templates: ['reports', 'templates'] as const,
|
||||
presets: ['reports', 'presets'] as const,
|
||||
};
|
||||
|
||||
/** Fetch all report templates */
|
||||
export function useReportTemplates() {
|
||||
return useQuery<ReportTemplate[]>({
|
||||
queryKey: QUERY_KEYS.templates,
|
||||
queryFn: () => apiGet<ReportTemplate[]>('/reports/templates'),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch a single report template by ID */
|
||||
export function useReportTemplate(templateId: string | null) {
|
||||
return useQuery<ReportTemplate>({
|
||||
queryKey: ['reports', 'templates', templateId],
|
||||
queryFn: () => apiGet<ReportTemplate>(`/reports/templates/${templateId}`),
|
||||
enabled: !!templateId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a new report template */
|
||||
export function useCreateReportTemplate() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: TemplateCreateInput) =>
|
||||
apiPost<ReportTemplate>('/reports/templates', input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: QUERY_KEYS.templates }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Update an existing report template */
|
||||
export function useUpdateReportTemplate() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...input }: TemplateUpdateInput & { id: string }) =>
|
||||
apiPut<ReportTemplate>(`/reports/templates/${id}`, input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: QUERY_KEYS.templates }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a report template (soft-delete) */
|
||||
export function useDeleteReportTemplate() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/reports/templates/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: QUERY_KEYS.templates }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch all preset report templates */
|
||||
export function useReportPresets() {
|
||||
return useQuery<PresetReportInfo[]>({
|
||||
queryKey: QUERY_KEYS.presets,
|
||||
queryFn: () => apiGet<PresetReportInfo[]>('/reports/presets'),
|
||||
});
|
||||
}
|
||||
|
||||
/** Generate a report from a template — returns a file download (blob) */
|
||||
export function useGenerateReport() {
|
||||
return useMutation({
|
||||
mutationFn: async (input: ReportGenerateInput) => {
|
||||
const response = await apiClient.post('/reports/generate', input, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Generate a preset report — returns a file download (blob) */
|
||||
export function useGeneratePresetReport() {
|
||||
return useMutation({
|
||||
mutationFn: async (input: PresetGenerateInput) => {
|
||||
const response = await apiClient.post('/reports/presets/generate', input, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Download Helper ───────────────────────────────────────────────────────
|
||||
|
||||
/** Trigger a browser download from a blob response */
|
||||
export function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -17,7 +17,8 @@
|
||||
"auditLog": "Audit-Log",
|
||||
"settings": "Einstellungen",
|
||||
"aiAssistant": "KI Assistent",
|
||||
"mcpSettings": "MCP Einstellungen"
|
||||
"mcpSettings": "MCP Einstellungen",
|
||||
"reports": "Reports"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
@@ -967,5 +968,33 @@
|
||||
"tools": "Tools",
|
||||
"noTools": "Keine Tools verfügbar"
|
||||
}
|
||||
},
|
||||
"reports": {
|
||||
"title": "Reports",
|
||||
"presetReports": "Vorgefertigte Berichte",
|
||||
"templates": "Vorlagen",
|
||||
"newTemplate": "Neue Vorlage",
|
||||
"editTemplate": "Vorlage bearbeiten",
|
||||
"selectTemplate": "Vorlage auswählen",
|
||||
"noTemplates": "Noch keine Vorlagen",
|
||||
"templateName": "Vorlagenname",
|
||||
"templateContent": "Jinja2 Vorlageninhalt (HTML für PDF)",
|
||||
"templateCreated": "Vorlage erfolgreich erstellt",
|
||||
"templateUpdated": "Vorlage erfolgreich aktualisiert",
|
||||
"templateDeleted": "Vorlage gelöscht",
|
||||
"confirmDelete": "Diese Vorlage löschen?",
|
||||
"errorNameRequired": "Name ist erforderlich",
|
||||
"errorContentRequired": "Vorlageninhalt ist erforderlich",
|
||||
"saveFailed": "Speichern fehlgeschlagen",
|
||||
"deleteFailed": "Löschen fehlgeschlagen",
|
||||
"generate": "Generieren",
|
||||
"generateReport": "Bericht generieren",
|
||||
"generated": "Bericht erfolgreich generiert",
|
||||
"generateFailed": "Berichtsgenerierung fehlgeschlagen",
|
||||
"jsonData": "Daten (JSON)",
|
||||
"invalidJson": "Ungültige JSON-Daten",
|
||||
"selectTemplateHint": "Wählen Sie eine Vorlage aus der Liste",
|
||||
"downloadHistory": "Download-Verlauf",
|
||||
"noDownloads": "Noch keine Downloads"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"auditLog": "Audit Log",
|
||||
"settings": "Settings",
|
||||
"aiAssistant": "AI Assistant",
|
||||
"mcpSettings": "MCP Settings"
|
||||
"mcpSettings": "MCP Settings",
|
||||
"reports": "Reports"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Sign In",
|
||||
@@ -967,5 +968,33 @@
|
||||
"tools": "Tools",
|
||||
"noTools": "No tools available"
|
||||
}
|
||||
},
|
||||
"reports": {
|
||||
"title": "Reports",
|
||||
"presetReports": "Preset Reports",
|
||||
"templates": "Templates",
|
||||
"newTemplate": "New Template",
|
||||
"editTemplate": "Edit Template",
|
||||
"selectTemplate": "Select a template",
|
||||
"noTemplates": "No templates yet",
|
||||
"templateName": "Template name",
|
||||
"templateContent": "Jinja2 template content (HTML for PDF)",
|
||||
"templateCreated": "Template created successfully",
|
||||
"templateUpdated": "Template updated successfully",
|
||||
"templateDeleted": "Template deleted",
|
||||
"confirmDelete": "Delete this template?",
|
||||
"errorNameRequired": "Name is required",
|
||||
"errorContentRequired": "Template content is required",
|
||||
"saveFailed": "Failed to save template",
|
||||
"deleteFailed": "Failed to delete template",
|
||||
"generate": "Generate",
|
||||
"generateReport": "Generate Report",
|
||||
"generated": "Report generated successfully",
|
||||
"generateFailed": "Failed to generate report",
|
||||
"jsonData": "Data (JSON)",
|
||||
"invalidJson": "Invalid JSON data",
|
||||
"selectTemplateHint": "Select a template from the list",
|
||||
"downloadHistory": "Download History",
|
||||
"noDownloads": "No downloads yet"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
BarChart3,
|
||||
Building2,
|
||||
Calendar,
|
||||
CalendarDays,
|
||||
Download,
|
||||
FileText,
|
||||
Loader2,
|
||||
Plus,
|
||||
Printer,
|
||||
Save,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import {
|
||||
useReportTemplates,
|
||||
useCreateReportTemplate,
|
||||
useUpdateReportTemplate,
|
||||
useDeleteReportTemplate,
|
||||
useReportPresets,
|
||||
useGenerateReport,
|
||||
useGeneratePresetReport,
|
||||
downloadBlob,
|
||||
type ReportTemplate,
|
||||
type OutputFormat,
|
||||
type PresetReportInfo,
|
||||
} from '@/api/reports';
|
||||
|
||||
// ─── Icon mapping for presets ──────────────────────────────────────────────
|
||||
|
||||
const PRESET_ICONS: Record<string, React.ReactNode> = {
|
||||
Users: <Users className="w-5 h-5" />,
|
||||
Calendar: <Calendar className="w-5 h-5" />,
|
||||
CalendarDays: <CalendarDays className="w-5 h-5" />,
|
||||
Building2: <Building2 className="w-5 h-5" />,
|
||||
ShieldCheck: <ShieldCheck className="w-5 h-5" />,
|
||||
};
|
||||
|
||||
// ─── Download history entry ────────────────────────────────────────────────
|
||||
|
||||
interface DownloadEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
format: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// ─── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function ReportsPage() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
|
||||
// Data hooks
|
||||
const { data: templates = [], isLoading: templatesLoading } = useReportTemplates();
|
||||
const { data: presets = [] } = useReportPresets();
|
||||
|
||||
// Mutations
|
||||
const createTemplate = useCreateReportTemplate();
|
||||
const updateTemplate = useUpdateReportTemplate();
|
||||
const deleteTemplate = useDeleteReportTemplate();
|
||||
const generateReport = useGenerateReport();
|
||||
const generatePreset = useGeneratePresetReport();
|
||||
|
||||
// Local state
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<ReportTemplate | null>(null);
|
||||
const [editorContent, setEditorContent] = useState('');
|
||||
const [editorName, setEditorName] = useState('');
|
||||
const [editorFormat, setEditorFormat] = useState<OutputFormat>('pdf');
|
||||
const [isNewTemplate, setIsNewTemplate] = useState(false);
|
||||
const [jsonData, setJsonData] = useState('{}');
|
||||
const [downloadHistory, setDownloadHistory] = useState<DownloadEntry[]>([]);
|
||||
const [activePresetFormat, setActivePresetFormat] = useState<OutputFormat>('pdf');
|
||||
|
||||
// Select a template for editing
|
||||
const selectTemplate = (tpl: ReportTemplate) => {
|
||||
setSelectedTemplate(tpl);
|
||||
setEditorContent(tpl.content);
|
||||
setEditorName(tpl.name);
|
||||
setEditorFormat(tpl.output_format);
|
||||
setIsNewTemplate(false);
|
||||
};
|
||||
|
||||
// Start creating a new template
|
||||
const startNewTemplate = () => {
|
||||
setSelectedTemplate(null);
|
||||
setEditorContent('<html><body>\n <h1>{{ title }}</h1>\n <p>{{ message }}</p>\n</body></html>');
|
||||
setEditorName('');
|
||||
setEditorFormat('pdf');
|
||||
setIsNewTemplate(true);
|
||||
};
|
||||
|
||||
// Save template (create or update)
|
||||
const handleSaveTemplate = async () => {
|
||||
if (!editorName.trim()) {
|
||||
toast.error(t('reports.errorNameRequired', 'Name is required'));
|
||||
return;
|
||||
}
|
||||
if (!editorContent.trim()) {
|
||||
toast.error(t('reports.errorContentRequired', 'Template content is required'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isNewTemplate) {
|
||||
await createTemplate.mutateAsync({
|
||||
name: editorName,
|
||||
content: editorContent,
|
||||
output_format: editorFormat,
|
||||
template_type: 'jinja2',
|
||||
});
|
||||
toast.success(t('reports.templateCreated', 'Template created successfully'));
|
||||
} else if (selectedTemplate) {
|
||||
await updateTemplate.mutateAsync({
|
||||
id: selectedTemplate.id,
|
||||
name: editorName,
|
||||
content: editorContent,
|
||||
output_format: editorFormat,
|
||||
});
|
||||
toast.success(t('reports.templateUpdated', 'Template updated successfully'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || t('reports.saveFailed', 'Failed to save template'));
|
||||
}
|
||||
};
|
||||
|
||||
// Delete template
|
||||
const handleDeleteTemplate = async (tpl: ReportTemplate) => {
|
||||
if (!confirm(t('reports.confirmDelete', 'Delete this template?'))) return;
|
||||
try {
|
||||
await deleteTemplate.mutateAsync(tpl.id);
|
||||
if (selectedTemplate?.id === tpl.id) {
|
||||
setSelectedTemplate(null);
|
||||
setEditorContent('');
|
||||
}
|
||||
toast.success(t('reports.templateDeleted', 'Template deleted'));
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || t('reports.deleteFailed', 'Failed to delete template'));
|
||||
}
|
||||
};
|
||||
|
||||
// Generate report from selected template
|
||||
const handleGenerate = async () => {
|
||||
if (!selectedTemplate) return;
|
||||
let parsedData: Record<string, unknown>;
|
||||
try {
|
||||
parsedData = JSON.parse(jsonData);
|
||||
} catch {
|
||||
toast.error(t('reports.invalidJson', 'Invalid JSON data'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await generateReport.mutateAsync({
|
||||
template_id: selectedTemplate.id,
|
||||
data: parsedData,
|
||||
output_format: editorFormat,
|
||||
});
|
||||
const filename = `report_${selectedTemplate.name}.${editorFormat === 'excel' ? 'xlsx' : editorFormat}`;
|
||||
downloadBlob(response.data as Blob, filename);
|
||||
setDownloadHistory((prev) => [
|
||||
{ id: Date.now().toString(), name: selectedTemplate.name, format: editorFormat, timestamp: new Date().toLocaleString() },
|
||||
...prev,
|
||||
]);
|
||||
toast.success(t('reports.generated', 'Report generated successfully'));
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || t('reports.generateFailed', 'Failed to generate report'));
|
||||
}
|
||||
};
|
||||
|
||||
// Generate preset report
|
||||
const handleGeneratePreset = async (preset: PresetReportInfo) => {
|
||||
let parsedParams: Record<string, unknown> = {};
|
||||
try {
|
||||
parsedParams = JSON.parse(jsonData);
|
||||
} catch {
|
||||
// Use empty params if JSON is invalid
|
||||
parsedParams = {};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await generatePreset.mutateAsync({
|
||||
preset: preset.key,
|
||||
output_format: activePresetFormat,
|
||||
parameters: parsedParams,
|
||||
});
|
||||
const ext = activePresetFormat === 'excel' ? 'xlsx' : activePresetFormat === 'print' ? 'pdf' : activePresetFormat;
|
||||
const filename = `${preset.key}_report.${ext}`;
|
||||
downloadBlob(response.data as Blob, filename);
|
||||
setDownloadHistory((prev) => [
|
||||
{ id: Date.now().toString(), name: preset.name, format: activePresetFormat, timestamp: new Date().toLocaleString() },
|
||||
...prev,
|
||||
]);
|
||||
toast.success(t('reports.generated', 'Report generated successfully'));
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || t('reports.generateFailed', 'Failed to generate report'));
|
||||
}
|
||||
};
|
||||
|
||||
const isGenerating = generateReport.isPending || generatePreset.isPending;
|
||||
const isSaving = createTemplate.isPending || updateTemplate.isPending;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-4 p-4" data-testid="reports-page">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<BarChart3 className="w-6 h-6 text-primary-600" />
|
||||
<h1 className="text-2xl font-bold text-secondary-900">{t('reports.title', 'Reports')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preset Quick Actions */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-secondary-200 p-4" data-testid="preset-quick-actions">
|
||||
<h2 className="text-sm font-semibold text-secondary-700 mb-3">{t('reports.presetReports', 'Preset Reports')}</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{presets.map((preset) => (
|
||||
<div
|
||||
key={preset.key}
|
||||
className="flex flex-col gap-2 p-3 border border-secondary-200 rounded-lg hover:border-primary-400 transition-colors min-w-[200px]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{PRESET_ICONS[preset.icon] || <FileText className="w-5 h-5" />}
|
||||
<span className="font-medium text-sm text-secondary-800">{preset.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-500">{preset.description}</p>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{preset.output_formats.map((fmt) => (
|
||||
<button
|
||||
key={fmt}
|
||||
onClick={() => {
|
||||
setActivePresetFormat(fmt);
|
||||
handleGeneratePreset(preset);
|
||||
}}
|
||||
disabled={isGenerating}
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs rounded font-medium transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
fmt === 'pdf' && 'bg-red-100 text-red-700 hover:bg-red-200',
|
||||
fmt === 'print' && 'bg-blue-100 text-blue-700 hover:bg-blue-200',
|
||||
fmt === 'csv' && 'bg-green-100 text-green-700 hover:bg-green-200',
|
||||
fmt === 'excel' && 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200',
|
||||
)}
|
||||
data-testid={`preset-${preset.key}-${fmt}`}
|
||||
>
|
||||
{fmt === 'pdf' && <Download className="w-3 h-3 inline mr-1" />}
|
||||
{fmt === 'print' && <Printer className="w-3 h-3 inline mr-1" />}
|
||||
{fmt.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main 3-column layout */}
|
||||
<div className="flex flex-1 gap-4 min-h-0">
|
||||
{/* Left: Template List */}
|
||||
<div className="w-64 flex-shrink-0 bg-white rounded-lg shadow-sm border border-secondary-200 flex flex-col" data-testid="template-list-panel">
|
||||
<div className="flex items-center justify-between p-3 border-b border-secondary-200">
|
||||
<h2 className="text-sm font-semibold text-secondary-700">{t('reports.templates', 'Templates')}</h2>
|
||||
<button
|
||||
onClick={startNewTemplate}
|
||||
className="p-1 rounded hover:bg-secondary-100 text-primary-600"
|
||||
title={t('reports.newTemplate', 'New Template')}
|
||||
data-testid="btn-new-template"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{templatesLoading ? (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-secondary-400" />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<p className="text-sm text-secondary-400 text-center p-4">{t('reports.noTemplates', 'No templates yet')}</p>
|
||||
) : (
|
||||
<ul className="py-1">
|
||||
{templates.map((tpl) => (
|
||||
<li key={tpl.id}>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-secondary-50',
|
||||
selectedTemplate?.id === tpl.id && 'bg-primary-50 border-l-2 border-primary-500',
|
||||
)}
|
||||
onClick={() => selectTemplate(tpl)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-secondary-800 truncate">{tpl.name}</p>
|
||||
<p className="text-xs text-secondary-400">{tpl.output_format}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteTemplate(tpl); }}
|
||||
className="p-1 rounded hover:bg-red-100 text-red-500"
|
||||
title={t('common.delete', 'Delete')}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: Template Editor */}
|
||||
<div className="flex-1 bg-white rounded-lg shadow-sm border border-secondary-200 flex flex-col" data-testid="template-editor-panel">
|
||||
<div className="flex items-center justify-between p-3 border-b border-secondary-200">
|
||||
<h2 className="text-sm font-semibold text-secondary-700">
|
||||
{isNewTemplate ? t('reports.newTemplate', 'New Template') : selectedTemplate ? t('reports.editTemplate', 'Edit Template') : t('reports.selectTemplate', 'Select a template')}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={editorFormat}
|
||||
onChange={(e) => setEditorFormat(e.target.value as OutputFormat)}
|
||||
className="text-sm border border-secondary-300 rounded px-2 py-1"
|
||||
data-testid="select-output-format"
|
||||
>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="print">Print</option>
|
||||
<option value="csv">CSV</option>
|
||||
<option value="excel">Excel</option>
|
||||
<option value="json">JSON</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleSaveTemplate}
|
||||
disabled={isSaving || (!editorName.trim() && !isNewTemplate && !selectedTemplate)}
|
||||
className="flex items-center gap-1 px-3 py-1 text-sm bg-primary-600 text-white rounded hover:bg-primary-700 disabled:opacity-50"
|
||||
data-testid="btn-save-template"
|
||||
>
|
||||
{isSaving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
{t('common.save', 'Save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 flex-1 flex flex-col gap-2 min-h-0">
|
||||
<input
|
||||
type="text"
|
||||
value={editorName}
|
||||
onChange={(e) => setEditorName(e.target.value)}
|
||||
placeholder={t('reports.templateName', 'Template name')}
|
||||
className="w-full text-sm border border-secondary-300 rounded px-3 py-2"
|
||||
data-testid="input-template-name"
|
||||
/>
|
||||
<textarea
|
||||
value={editorContent}
|
||||
onChange={(e) => setEditorContent(e.target.value)}
|
||||
placeholder={t('reports.templateContent', 'Jinja2 template content (HTML for PDF)')}
|
||||
className="flex-1 w-full text-sm font-mono border border-secondary-300 rounded px-3 py-2 resize-none"
|
||||
spellCheck={false}
|
||||
data-testid="textarea-template-content"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Preview / Generate */}
|
||||
<div className="w-72 flex-shrink-0 bg-white rounded-lg shadow-sm border border-secondary-200 flex flex-col" data-testid="generate-panel">
|
||||
<div className="p-3 border-b border-secondary-200">
|
||||
<h2 className="text-sm font-semibold text-secondary-700">{t('reports.generate', 'Generate')}</h2>
|
||||
</div>
|
||||
<div className="p-3 flex-1 flex flex-col gap-3 overflow-y-auto">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-secondary-600 mb-1 block">
|
||||
{t('reports.jsonData', 'Data (JSON)')}
|
||||
</label>
|
||||
<textarea
|
||||
value={jsonData}
|
||||
onChange={(e) => setJsonData(e.target.value)}
|
||||
placeholder='{"key": "value"}'
|
||||
className="w-full text-xs font-mono border border-secondary-300 rounded px-2 py-1.5 h-40 resize-none"
|
||||
spellCheck={false}
|
||||
data-testid="textarea-json-data"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || !selectedTemplate}
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 text-sm bg-primary-600 text-white rounded hover:bg-primary-700 disabled:opacity-50"
|
||||
data-testid="btn-generate-report"
|
||||
>
|
||||
{isGenerating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
||||
{t('reports.generateReport', 'Generate Report')}
|
||||
</button>
|
||||
{!selectedTemplate && (
|
||||
<p className="text-xs text-secondary-400 text-center">{t('reports.selectTemplateHint', 'Select a template from the list')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download History */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-secondary-200 p-4" data-testid="download-history">
|
||||
<h2 className="text-sm font-semibold text-secondary-700 mb-2">{t('reports.downloadHistory', 'Download History')}</h2>
|
||||
{downloadHistory.length === 0 ? (
|
||||
<p className="text-sm text-secondary-400">{t('reports.noDownloads', 'No downloads yet')}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-secondary-100">
|
||||
{downloadHistory.map((entry) => (
|
||||
<li key={entry.id} className="flex items-center justify-between py-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-secondary-400" />
|
||||
<span className="font-medium text-secondary-700">{entry.name}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-secondary-100 text-secondary-600 uppercase">{entry.format}</span>
|
||||
</div>
|
||||
<span className="text-xs text-secondary-400">{entry.timestamp}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ReportsPage } from '../Reports';
|
||||
|
||||
// Mock the API hooks
|
||||
vi.mock('@/api/reports', () => ({
|
||||
useReportTemplates: vi.fn(() => ({
|
||||
data: [
|
||||
{ id: 'tpl-1', name: 'My Report', description: 'Test', template_type: 'jinja2', content: '<html></html>', output_format: 'pdf', created_by: 'user-1' },
|
||||
{ id: 'tpl-2', name: 'CSV Report', description: 'Test CSV', template_type: 'jinja2', content: 'name,email', output_format: 'csv', created_by: 'user-1' },
|
||||
],
|
||||
isLoading: false,
|
||||
})),
|
||||
useReportPresets: vi.fn(() => ({
|
||||
data: [
|
||||
{ key: 'contact_list', name: 'Kontaktliste', description: 'Liste aller Kontakte', icon: 'Users', output_formats: ['pdf', 'print', 'csv', 'excel'] },
|
||||
{ key: 'company_list', name: 'Firmenliste', description: 'Liste aller Firmen', icon: 'Building2', output_formats: ['pdf', 'print', 'csv', 'excel'] },
|
||||
],
|
||||
})),
|
||||
useCreateReportTemplate: vi.fn(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
isPending: false,
|
||||
})),
|
||||
useUpdateReportTemplate: vi.fn(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
isPending: false,
|
||||
})),
|
||||
useDeleteReportTemplate: vi.fn(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
isPending: false,
|
||||
})),
|
||||
useGenerateReport: vi.fn(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ data: new Blob(['test'], { type: 'application/pdf' }) }),
|
||||
isPending: false,
|
||||
})),
|
||||
useGeneratePresetReport: vi.fn(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ data: new Blob(['test'], { type: 'application/pdf' }) }),
|
||||
isPending: false,
|
||||
})),
|
||||
downloadBlob: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback || key,
|
||||
i18n: { language: 'de' },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock toast
|
||||
vi.mock('@/components/ui/Toast', () => ({
|
||||
useToast: () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', () => {
|
||||
const Icon = ({ className }: { className?: string }) => <div className={className} data-testid="icon" />;
|
||||
return {
|
||||
BarChart3: Icon,
|
||||
Building2: Icon,
|
||||
Calendar: Icon,
|
||||
CalendarDays: Icon,
|
||||
Download: Icon,
|
||||
FileText: Icon,
|
||||
Loader2: Icon,
|
||||
Plus: Icon,
|
||||
Printer: Icon,
|
||||
Save: Icon,
|
||||
ShieldCheck: Icon,
|
||||
Trash2: Icon,
|
||||
Users: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
// Helper to render with providers
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
{ui}
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ReportsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the reports page with title and preset quick actions', () => {
|
||||
renderWithProviders(<ReportsPage />);
|
||||
expect(screen.getByTestId('reports-page')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('preset-quick-actions')).toBeInTheDocument();
|
||||
// Should have preset buttons for contact_list and company_list
|
||||
expect(screen.getByTestId('preset-contact_list-pdf')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('preset-company_list-pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays templates in the template list', () => {
|
||||
renderWithProviders(<ReportsPage />);
|
||||
expect(screen.getByTestId('template-list-panel')).toBeInTheDocument();
|
||||
expect(screen.getByText('My Report')).toBeInTheDocument();
|
||||
expect(screen.getByText('CSV Report')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking new template button shows editor with default content', () => {
|
||||
renderWithProviders(<ReportsPage />);
|
||||
const newBtn = screen.getByTestId('btn-new-template');
|
||||
fireEvent.click(newBtn);
|
||||
// Editor should be visible with template name input
|
||||
expect(screen.getByTestId('input-template-name')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('textarea-template-content')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('btn-save-template')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selecting a template loads it into the editor', () => {
|
||||
renderWithProviders(<ReportsPage />);
|
||||
// Click on the first template
|
||||
fireEvent.click(screen.getByText('My Report'));
|
||||
// Editor should show the template name
|
||||
const nameInput = screen.getByTestId('input-template-name') as HTMLInputElement;
|
||||
expect(nameInput.value).toBe('My Report');
|
||||
});
|
||||
|
||||
it('shows download history section', () => {
|
||||
renderWithProviders(<ReportsPage />);
|
||||
expect(screen.getByTestId('download-history')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,7 @@ const SettingsMcpPage = React.lazy(() => import('@/pages/SettingsMcp').then(m =>
|
||||
const AutomationDashboardPage = React.lazy(() => import('@/pages/AutomationDashboard').then(m => ({ default: m.AutomationDashboardPage })));
|
||||
const AgentDashboardPage = React.lazy(() => import('@/pages/AgentDashboard').then(m => ({ default: m.AgentDashboardPage })));
|
||||
const AutomationSettingsPage = React.lazy(() => import('@/pages/AutomationSettings').then(m => ({ default: m.AutomationSettingsPage })));
|
||||
const ReportsPage = React.lazy(() => import('@/pages/Reports').then(m => ({ default: m.ReportsPage })));
|
||||
|
||||
/** Centered spinner fallback for lazy-loaded routes */
|
||||
function PageLoader() {
|
||||
@@ -89,6 +90,7 @@ const router = createBrowserRouter([
|
||||
{ path: '/ai-assistant', element: withSuspense(<AIAssistantPage />) },
|
||||
{ path: '/automation', element: withSuspense(<AutomationDashboardPage />) },
|
||||
{ path: '/agents', element: withSuspense(<AgentDashboardPage />) },
|
||||
{ path: '/reports', element: withSuspense(<ReportsPage />) },
|
||||
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) },
|
||||
{
|
||||
path: '/settings',
|
||||
|
||||
+6
-1
@@ -74,6 +74,11 @@ from app.plugins.builtins.mail.models import ( # noqa: F401
|
||||
)
|
||||
from app.plugins.builtins.permissions import PermissionsPlugin # noqa: F401
|
||||
from app.plugins.builtins.permissions.models import Permission, ShareLink # noqa: F401
|
||||
from app.plugins.builtins.report_generator import ReportGeneratorPlugin # noqa: F401
|
||||
from app.plugins.builtins.report_generator.models import ( # noqa: F401
|
||||
ReportInstance,
|
||||
ReportTemplate,
|
||||
)
|
||||
from app.plugins.builtins.tags.models import Tag, TagAssignment # noqa: F401
|
||||
from app.plugins.registry import reset_registry_for_testing # noqa: F401
|
||||
from app.services.plugin_service import reset_plugin_service_for_testing # noqa: F401
|
||||
@@ -135,7 +140,7 @@ def clean_tables(db_setup):
|
||||
# TRUNCATE all tables with CASCADE — fast and reliable isolation
|
||||
conn.execute(
|
||||
text(
|
||||
"TRUNCATE TABLE contact_pgp_keys, pgp_keys, mail_account_send_permissions, mail_account_delegates, mail_seen_by, vacation_sent_log, mail_signatures, mail_templates, mail_rules, mail_label_assignments, mail_labels, mail_attachments, mails, mail_folders, mail_accounts, resource_bookings, resources, subtasks, user_calendar_visibility, calendar_shares, calendar_entry_links, calendar_entries, calendars, files, folders, entity_links, share_links, permissions, tag_assignments, tags, workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, user_tenants, users, tenants CASCADE;"
|
||||
"TRUNCATE TABLE report_instances, report_templates, contact_pgp_keys, pgp_keys, mail_account_send_permissions, mail_account_delegates, mail_seen_by, vacation_sent_log, mail_signatures, mail_templates, mail_rules, mail_label_assignments, mail_labels, mail_attachments, mails, mail_folders, mail_accounts, resource_bookings, resources, subtasks, user_calendar_visibility, calendar_shares, calendar_entry_links, calendar_entries, calendars, files, folders, entity_links, share_links, permissions, tag_assignments, tags, workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, user_tenants, users, tenants CASCADE;"
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Tests for the Report Generator plugin — PDF support, presets, templates, RBAC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
|
||||
|
||||
from app.core.db import close_engine, reset_engine_for_testing
|
||||
from app.core.service_container import get_container
|
||||
from app.main import create_app
|
||||
from app.plugins.builtins.permissions import PermissionsPlugin
|
||||
from app.plugins.builtins.report_generator import ReportGeneratorPlugin
|
||||
from app.plugins.registry import reset_registry_for_testing
|
||||
from app.services.plugin_service import reset_plugin_service_for_testing
|
||||
|
||||
from tests.conftest import (
|
||||
ORIGIN_HEADER,
|
||||
login_client,
|
||||
seed_tenant_and_users,
|
||||
)
|
||||
|
||||
REPORT_TEST_STORAGE = "/tmp/report_test"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def report_app(engine: AsyncEngine, redis_client):
|
||||
"""FastAPI app with Report Generator + Permissions plugins registered, installed, and activated."""
|
||||
os.environ["REPORT_STORAGE_BASE"] = REPORT_TEST_STORAGE
|
||||
reset_engine_for_testing(engine)
|
||||
app = create_app()
|
||||
|
||||
registry = reset_registry_for_testing()
|
||||
registry.initialize(engine, app)
|
||||
|
||||
container = get_container()
|
||||
await container.initialize()
|
||||
|
||||
registry.register_plugin(PermissionsPlugin())
|
||||
registry.register_plugin(ReportGeneratorPlugin())
|
||||
reset_plugin_service_for_testing(registry)
|
||||
|
||||
_sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
|
||||
async with _sf() as session:
|
||||
await registry.install(session, "permissions")
|
||||
await registry.activate(session, "permissions")
|
||||
await registry.install(session, "report_generator")
|
||||
await registry.activate(session, "report_generator")
|
||||
await session.commit()
|
||||
|
||||
yield app
|
||||
await close_engine()
|
||||
if os.path.exists(REPORT_TEST_STORAGE):
|
||||
shutil.rmtree(REPORT_TEST_STORAGE, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def report_client(report_app) -> AsyncClient:
|
||||
transport = ASGITransport(app=report_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def report_authed_client(
|
||||
report_client: AsyncClient, db_session: AsyncSession
|
||||
) -> tuple[AsyncClient, dict]:
|
||||
"""Authenticated admin client with seeded data and report generator plugin activated."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
login_resp = await report_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "admin@tenanta.com", "password": "TestPass123!"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert login_resp.status_code == 200, f"Login failed: {login_resp.text}"
|
||||
csrf_token = login_resp.json().get("csrf_token", "")
|
||||
report_client.headers.update({"X-CSRF-Token": csrf_token})
|
||||
|
||||
return report_client, seed
|
||||
|
||||
|
||||
# ─── Tests ───
|
||||
|
||||
|
||||
class TestReportPresets:
|
||||
"""Test preset report listing and generation."""
|
||||
|
||||
async def test_list_presets(self, report_authed_client):
|
||||
"""GET /presets returns all 5 preset templates."""
|
||||
client, _ = report_authed_client
|
||||
resp = await client.get("/api/v1/reports/presets", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 5
|
||||
keys = {item["key"] for item in data}
|
||||
assert keys == {
|
||||
"contact_list",
|
||||
"calendar_week",
|
||||
"calendar_month",
|
||||
"company_list",
|
||||
"audit_log",
|
||||
}
|
||||
# Each preset should have required fields
|
||||
for item in data:
|
||||
assert "name" in item
|
||||
assert "description" in item
|
||||
assert "icon" in item
|
||||
assert "output_formats" in item
|
||||
assert "pdf" in item["output_formats"]
|
||||
|
||||
async def test_generate_preset_pdf(self, report_authed_client):
|
||||
"""POST /presets/generate returns a PDF file for contact_list preset."""
|
||||
client, _ = report_authed_client
|
||||
resp = await client.post(
|
||||
"/api/v1/reports/presets/generate",
|
||||
json={
|
||||
"preset": "contact_list",
|
||||
"output_format": "pdf",
|
||||
"parameters": {
|
||||
"title": "Test Kontaktliste",
|
||||
"contacts": [
|
||||
{"name": "Max Mustermann", "email": "max@test.com", "phone": "+49 123 456789", "type": "customer", "company": "Test GmbH"},
|
||||
{"name": "Anna Schmidt", "email": "anna@test.com", "phone": "+49 987 654321", "type": "supplier", "company": "AG GmbH"},
|
||||
],
|
||||
},
|
||||
},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200, f"Generate failed: {resp.text[:300]}"
|
||||
assert resp.headers["content-type"].startswith("application/pdf")
|
||||
assert "attachment" in resp.headers.get("content-disposition", "")
|
||||
# Verify it's a valid PDF (starts with %PDF)
|
||||
content = resp.content
|
||||
assert content[:5] == b"%PDF-"
|
||||
assert len(content) > 100 # Should have meaningful content
|
||||
|
||||
async def test_generate_preset_csv(self, report_authed_client):
|
||||
"""POST /presets/generate returns a CSV file for company_list preset."""
|
||||
client, _ = report_authed_client
|
||||
resp = await client.post(
|
||||
"/api/v1/reports/presets/generate",
|
||||
json={
|
||||
"preset": "company_list",
|
||||
"output_format": "csv",
|
||||
"parameters": {
|
||||
"companies": [
|
||||
{"name": "Test GmbH", "address": "Teststr. 1", "zip": "12345", "city": "Berlin", "phone": "+49 123", "email": "info@test.de", "contact_person": "Max"},
|
||||
],
|
||||
},
|
||||
},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200, f"Generate failed: {resp.text[:300]}"
|
||||
assert resp.headers["content-type"].startswith("text/csv")
|
||||
content = resp.content
|
||||
assert b"Test GmbH" in content
|
||||
assert b"Firmenname" in content
|
||||
|
||||
|
||||
class TestReportTemplates:
|
||||
"""Test template CRUD and report generation with PDF output."""
|
||||
|
||||
async def test_create_and_generate_pdf_template(self, report_authed_client):
|
||||
"""Create a template with output_format=pdf and generate a report (file download)."""
|
||||
client, _ = report_authed_client
|
||||
|
||||
# Create a PDF template
|
||||
create_resp = await client.post(
|
||||
"/api/v1/reports/templates",
|
||||
json={
|
||||
"name": "Custom PDF Report",
|
||||
"description": "A custom HTML template for PDF",
|
||||
"template_type": "jinja2",
|
||||
"content": "<html><body><h1>{{ title }}</h1><p>{{ message }}</p></body></html>",
|
||||
"output_format": "pdf",
|
||||
},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert create_resp.status_code == 201, f"Create failed: {create_resp.text}"
|
||||
template = create_resp.json()
|
||||
assert template["output_format"] == "pdf"
|
||||
template_id = template["id"]
|
||||
|
||||
# Generate a report from this template — returns file directly
|
||||
gen_resp = await client.post(
|
||||
"/api/v1/reports/generate",
|
||||
json={
|
||||
"template_id": template_id,
|
||||
"data": {"title": "Hello World", "message": "This is a test PDF."},
|
||||
},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert gen_resp.status_code == 200, f"Generate failed: {gen_resp.text[:300]}"
|
||||
assert gen_resp.headers["content-type"].startswith("application/pdf")
|
||||
assert gen_resp.content[:5] == b"%PDF-"
|
||||
|
||||
async def test_output_format_validation(self, report_authed_client):
|
||||
"""Template creation rejects invalid output_format values."""
|
||||
client, _ = report_authed_client
|
||||
resp = await client.post(
|
||||
"/api/v1/reports/templates",
|
||||
json={
|
||||
"name": "Bad Format",
|
||||
"content": "test",
|
||||
"output_format": "invalid_format",
|
||||
},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 422 # Validation error
|
||||
|
||||
|
||||
class TestReportRBAC:
|
||||
"""Test RBAC enforcement on report endpoints."""
|
||||
|
||||
async def test_unauthenticated_access_blocked(self, report_client):
|
||||
"""Unauthenticated requests to reports endpoints are rejected."""
|
||||
resp = await report_client.get("/api/v1/reports/presets", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_viewer_cannot_manage_templates(self, report_authed_client, db_session):
|
||||
"""Viewer role cannot create templates (requires manage_templates) and cannot generate (requires generate)."""
|
||||
client, seed = report_authed_client
|
||||
|
||||
# Login as viewer
|
||||
viewer_resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "viewer@tenanta.com", "password": "TestPass123!"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert viewer_resp.status_code == 200
|
||||
csrf_token = viewer_resp.json().get("csrf_token", "")
|
||||
client.headers.update({"X-CSRF-Token": csrf_token})
|
||||
|
||||
# Viewer does not have reports:read in legacy permissions → 403 on presets
|
||||
resp = await client.get("/api/v1/reports/presets", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 403
|
||||
|
||||
# Viewer should NOT be able to create templates
|
||||
resp = await client.post(
|
||||
"/api/v1/reports/templates",
|
||||
json={"name": "Forbidden", "content": "test", "output_format": "pdf"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
# Viewer should NOT be able to generate preset reports
|
||||
resp = await client.post(
|
||||
"/api/v1/reports/presets/generate",
|
||||
json={"preset": "contact_list", "output_format": "pdf", "parameters": {}},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
Reference in New Issue
Block a user