Files
leocrm/app/routes/system_settings.py
T
Agent Zero 4a25ac1379
Check Cross-Plugin Imports / check (push) Has been cancelled
fix(arch): externes Audit — 13 Backend-Fixes (Workspace-Modules, Tenant-Manifeste, Lifecycle, Contracts, Permissions)
Verifikation: Alle 17 Audit-Findings gegen den Code geprueft — alle bestaetigt.
Backend-Lifecycle-Fixes umgesetzt; 4 Frontend-Plugin-Architektur-Punkte
als Phase Q in die Roadmap eingeplant.

- P1 list_workspaces: Module + User-Counts gebuendelt laden (Editor-Overwrite-Bug)
- P1 active-manifests: Tenant-Deaktivierung (tenant_plugin_activation) filtern
- P1 uninstall: volle Service-Deactivation VOR registry.uninstall()
- P1 ContractRegistry: DB-Aktivstatus-Guard (Restart-Edge-Case) + Re-Activate
- P1/P2 Field-Definitions: voller Lifecycle (register/unregister) im Service
- P1/P2 Contact-Felddefinitionen (39) ins ContactsPlugin-Manifest verschoben
- P1 12 fehlende Permission-Keys registriert (AST-Scan: 0 fehlend)
- P2 contact_folder -> ContactsPlugin; ENTITY_PLUGIN_OWNERS wird befuellt
- P2 Entity-Permission-Fallback fail-closed statt contacts:read
- P2 forgejo_error_reporter is_core=False; DMS is_core=True (ADR-020)
- P2 Worker: Contacts-Trash-Cleanup ins Plugin (get_job_modules-Discovery)
- P1/P2 DSGVO-Export delegiert an DSAR-Collector (kein Core->Contacts)
- P2 False-green Tests korrigiert (or True, veraltete Route-Count-Assertion)

Verifikation: tests/test_audit_architecture_fixes.py 17/17; Regressionen
gruen (contacts_lifecycle, entity_registry, workspace_scopes, rbac,
lifecycle_service); Combo-Order-Test 35/35; Cross-Plugin-Checker 497/0;
compileall sauber; ruff auf 7-Error-Baseline.

Doku: PROGRESS.md Audit-Section, PLATFORM_ROADMAP.md Phase Q (Q1-Q4),
plugin-development-guide.md Lifecycle, permissions.md Katalog.
2026-09-13 02:25:01 +02:00

253 lines
8.9 KiB
Python

"""System settings routes — get, upsert, backup config (admin only)."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select as sa_select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.jobs import enqueue_job
from app.deps import require_permission
from app.models.audit import AuditLog
from app.schemas.system_settings import SystemSettingsResponse, SystemSettingsUpsert
from app.services import system_settings_service
router = APIRouter(prefix="/api/v1/system-settings", tags=["system-settings"])
class BackupConfigRequest(BaseModel):
"""Update backup configuration (all fields optional)."""
backup_enabled: bool | None = None
backup_interval: str | None = Field(None, max_length=20)
backup_retention_days: int | None = Field(None, ge=1, le=365)
backup_destination: str | None = Field(None, max_length=20)
class DsarRequest(BaseModel):
"""Submit a Data Subject Access Request."""
type: str = Field("access", pattern="^(access|deletion|rectification)$")
@router.get("", response_model=SystemSettingsResponse)
async def get_system_settings(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("settings:read")),
):
"""Get system settings for the current tenant.
Sensitive fields (tax_number, iban, bic) are masked for non-admin users.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
result = await system_settings_service.get_system_settings(db, tenant_id)
if result is None:
return SystemSettingsResponse()
# Mask sensitive fields for non-admin users
if not current_user.get("is_system_admin"):
if hasattr(result, "tax_number") and result.tax_number:
result.tax_number = "********"
if hasattr(result, "iban") and result.iban:
result.iban = "********"
if hasattr(result, "bic") and result.bic:
result.bic = "********"
return result
@router.put("", response_model=SystemSettingsResponse)
async def upsert_system_settings(
body: SystemSettingsUpsert,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("settings:write")),
):
"""Upsert system settings. Requires admin permission."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
data = body.model_dump()
return await system_settings_service.upsert_system_settings(db, tenant_id, user_id, data)
# ── Backup configuration endpoints ───────────────────────────────────────────
@router.get("/backup-config")
async def get_backup_config(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("settings:read")),
):
"""Get backup configuration for the current tenant."""
tenant_id = uuid.UUID(current_user["tenant_id"])
result = await system_settings_service.get_system_settings(db, tenant_id)
if result is None:
return {
"backup_enabled": False,
"backup_interval": "24h",
"backup_retention_days": 7,
"backup_destination": "local",
}
return {
"backup_enabled": result.get("backup_enabled", False),
"backup_interval": result.get("backup_interval", "24h"),
"backup_retention_days": result.get("backup_retention_days", 7),
"backup_destination": result.get("backup_destination", "local"),
}
@router.put("/backup-config")
async def update_backup_config(
body: BackupConfigRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("settings:write")),
):
"""Update backup configuration. Admin only.
Accepts: backup_enabled (bool), backup_interval (str),
backup_retention_days (int), backup_destination (str).
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
# Get existing settings
existing = await system_settings_service.get_system_settings(db, tenant_id)
if existing is None:
raise HTTPException(404, detail={"detail": "System settings not found. Please configure company settings first.", "code": "settings_not_found"})
# Merge backup fields into existing data
data = dict(existing)
updates = body.model_dump(exclude_unset=True)
for key in (
"backup_enabled",
"backup_interval",
"backup_retention_days",
"backup_destination",
):
if key in updates:
data[key] = updates[key]
return await system_settings_service.upsert_system_settings(db, tenant_id, user_id, data)
@router.post("/backup-now")
async def trigger_backup_now(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("settings:write")),
):
"""Trigger an immediate backup via ARQ job. Admin only."""
job_id = await enqueue_job("run_backup")
if job_id is None:
raise HTTPException(503, detail={"detail": "Failed to enqueue backup job. Worker may not be running.", "code": "enqueue_failed"})
return {"message": "Backup job enqueued", "job_id": job_id}
@router.get("/backup-history")
async def get_backup_history(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("settings:read")),
):
"""Get last 10 backup results from audit log."""
tenant_id = uuid.UUID(current_user["tenant_id"])
stmt = (
sa_select(AuditLog)
.where(
AuditLog.tenant_id == tenant_id,
AuditLog.entity_type == "backup",
AuditLog.action.in_(["backup_success", "backup_failed"]),
)
.order_by(AuditLog.timestamp.desc())
.limit(10)
)
result = await db.execute(stmt)
entries = result.scalars().all()
history = []
for entry in entries:
changes = entry.changes or {}
history.append({
"id": str(entry.id),
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
"action": entry.action,
"success": changes.get("success", entry.action == "backup_success"),
"destination": changes.get("destination", "local"),
"error": changes.get("error", ""),
})
return {"history": history}
# ── I.5 DSGVO Export ──
@router.get("/dsgvo-export/{user_id}")
async def dsgvo_export(
user_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("system:admin")),
):
"""Export all personal data for a user (DSGVO/GDPR data subject access request).
Audit P1/P2 (DSGVO duplicate): this route previously held a second,
contact-aware export implementation parallel to the newer DSAR job
pipeline. It now delegates to the single authoritative collector
``app.core.jobs._dsar_collect_user_data`` — core-owned categories are
collected there, plugin-owned categories (contacts, mail, tasks,
calendar, communication, ...) are contributed by the plugin contracts.
No core->contacts coupling here anymore.
"""
import io
import json
from fastapi.responses import StreamingResponse
from app.core.jobs import _dsar_collect_user_data
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
uid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"}) from None
export_data = await _dsar_collect_user_data(db, str(tenant_id), str(uid))
# Log the DSGVO export
from app.core.audit import log_audit
await log_audit(db, tenant_id, current_user.get("user_id", ""), "dsgvo_export", "user", uid, {"target_user": str(uid)})
await db.commit()
content = json.dumps(export_data, indent=2, default=str)
return StreamingResponse(
io.BytesIO(content.encode("utf-8")),
media_type="application/json",
headers={"Content-Disposition": f"attachment; filename=dsgvo_export_{user_id}.json"},
)
@router.post("/dsar/{user_id}")
async def dsar_request(
user_id: str,
body: DsarRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("system:admin")),
):
"""Submit a Data Subject Access Request (DSAR) for processing.
Queues a DSAR job that collects and exports all user data.
Types: access, deletion, rectification.
"""
from app.core.jobs import enqueue_job
tenant_id = uuid.UUID(current_user["tenant_id"])
request_type = body.type
try:
uid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"}) from None
job_id = await enqueue_job("process_dsar", user_id=str(uid), tenant_id=str(tenant_id), request_type=request_type)
return {"job_id": job_id, "status": "queued", "type": request_type, "user_id": str(uid)}