"""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)}