Files
leocrm/app/routes/system_settings.py
T

285 lines
10 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).
Returns a JSON file with all data associated with the user:
- User profile
- Contacts owned by user
- Audit log entries
- Mail accounts
- Tasks assigned to user
- Calendar events
- Communication messages
"""
import io
import json
from datetime import UTC, datetime
from fastapi.responses import StreamingResponse
from sqlalchemy import select as sa_select
from app.models.audit import AuditLog
from app.models.contact import Contact
from app.models.user import User
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 = {"user_id": str(uid), "exported_at": datetime.now(UTC).isoformat(), "data": {}}
# User profile
user_result = await db.execute(sa_select(User).where(User.id == uid))
user = user_result.scalar_one_or_none()
if user:
export_data["data"]["profile"] = {
"email": user.email, "name": user.name, "role": user.role,
"is_active": user.is_active, "created_at": user.created_at.isoformat() if user.created_at else None,
}
# Contacts owned by user
contacts_result = await db.execute(
sa_select(Contact).where(Contact.tenant_id == tenant_id, Contact.owner_id == uid, Contact.deleted_at.is_(None))
)
export_data["data"]["contacts"] = [
{"id": str(c.id), "type": c.type, "displayname": c.displayname, "email_1": c.email_1, "email_2": c.email_2}
for c in contacts_result.scalars().all()
]
# Audit log entries
audit_result = await db.execute(
sa_select(AuditLog).where(AuditLog.tenant_id == tenant_id, AuditLog.user_id == uid).limit(1000)
)
export_data["data"]["audit_log"] = [
{"action": a.action, "entity_type": a.entity_type, "timestamp": a.timestamp.isoformat() if a.timestamp else None}
for a in audit_result.scalars().all()
]
# 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)}