2026-08-20 13:47:35 +02:00
|
|
|
"""System settings routes — get, upsert, backup config (admin only)."""
|
2026-07-04 00:25:39 +00:00
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
2026-08-20 13:47:35 +02:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
2026-08-24 10:55:22 +02:00
|
|
|
from pydantic import BaseModel, Field
|
2026-08-20 13:47:35 +02:00
|
|
|
from sqlalchemy import select as sa_select
|
2026-07-04 00:25:39 +00:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
from app.core.db import get_db
|
2026-08-20 13:47:35 +02:00
|
|
|
from app.core.jobs import enqueue_job
|
2026-07-15 22:35:50 +02:00
|
|
|
from app.deps import require_permission
|
2026-08-20 13:47:35 +02:00
|
|
|
from app.models.audit import AuditLog
|
2026-08-16 01:17:18 +02:00
|
|
|
from app.schemas.system_settings import SystemSettingsResponse, SystemSettingsUpsert
|
2026-07-04 00:25:39 +00:00
|
|
|
from app.services import system_settings_service
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/v1/system-settings", tags=["system-settings"])
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 10:55:22 +02:00
|
|
|
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)$")
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 22:44:54 +02:00
|
|
|
@router.get("", response_model=SystemSettingsResponse)
|
2026-07-04 00:25:39 +00:00
|
|
|
async def get_system_settings(
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
2026-07-15 22:35:50 +02:00
|
|
|
current_user: dict = Depends(require_permission("settings:read")),
|
2026-07-04 00:25:39 +00:00
|
|
|
):
|
2026-08-03 22:32:03 +02:00
|
|
|
"""Get system settings for the current tenant.
|
|
|
|
|
|
|
|
|
|
Sensitive fields (tax_number, iban, bic) are masked for non-admin users.
|
|
|
|
|
"""
|
2026-07-04 00:25:39 +00:00
|
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
|
|
|
result = await system_settings_service.get_system_settings(db, tenant_id)
|
|
|
|
|
if result is None:
|
2026-07-24 10:34:38 +02:00
|
|
|
return SystemSettingsResponse()
|
2026-08-03 22:32:03 +02:00
|
|
|
|
|
|
|
|
# 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 = "********"
|
|
|
|
|
|
2026-07-04 00:25:39 +00:00
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 22:44:54 +02:00
|
|
|
@router.put("", response_model=SystemSettingsResponse)
|
2026-07-04 00:25:39 +00:00
|
|
|
async def upsert_system_settings(
|
|
|
|
|
body: SystemSettingsUpsert,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
2026-07-15 22:35:50 +02:00
|
|
|
current_user: dict = Depends(require_permission("settings:write")),
|
2026-07-04 00:25:39 +00:00
|
|
|
):
|
|
|
|
|
"""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)
|
2026-08-20 13:47:35 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 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(
|
2026-08-24 10:55:22 +02:00
|
|
|
body: BackupConfigRequest,
|
2026-08-20 13:47:35 +02:00
|
|
|
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)
|
2026-08-24 10:55:22 +02:00
|
|
|
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]
|
2026-08-20 13:47:35 +02:00
|
|
|
|
|
|
|
|
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}
|
2026-08-21 00:33:34 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 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
|
2026-08-24 10:55:22 +02:00
|
|
|
import json
|
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
|
2026-08-21 00:33:34 +02:00
|
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
|
from sqlalchemy import select as sa_select
|
2026-08-24 10:55:22 +02:00
|
|
|
|
2026-08-21 00:33:34 +02:00
|
|
|
from app.models.audit import AuditLog
|
2026-08-24 10:55:22 +02:00
|
|
|
from app.models.contact import Contact
|
|
|
|
|
from app.models.user import User
|
2026-08-21 00:33:34 +02:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-08-24 10:55:22 +02:00
|
|
|
export_data = {"user_id": str(uid), "exported_at": datetime.now(UTC).isoformat(), "data": {}}
|
2026-08-21 00:33:34 +02:00
|
|
|
|
|
|
|
|
# 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,
|
2026-08-24 10:55:22 +02:00
|
|
|
body: DsarRequest,
|
2026-08-21 00:33:34 +02:00
|
|
|
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"])
|
2026-08-24 10:55:22 +02:00
|
|
|
request_type = body.type
|
2026-08-21 00:33:34 +02:00
|
|
|
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)}
|