"""Workspace löschen (→ Konzept 7.6, 13): erst in den Papierkorb, dann endgültig. Im Papierkorb ist ein Workspace für alle Funktionen unsichtbar, bleibt aber vollständig erhalten und lässt sich wiederherstellen. Endgültig gelöscht wird er von einem Admin aus dem Papierkorb oder vom Worker nach workspace_trash_days Tagen. Endgültig gelöscht werden Workspace, Mitgliedschaften, Quellen, Chats, Runs, Freigabeanträge, Uploads, Tool-Vorgaben und MCP-Freischaltungen (mit eigenen Keys), dazu die Dateien außerhalb der Datenbank: DuckDB-Dateien der Uploads und Git-Klone der Quellen. Erhalten bleiben Audit-Log und Verbrauch; ihr Workspace-Bezug wird geleert. """ import logging import shutil from dataclasses import dataclass from datetime import datetime, timedelta from pathlib import Path from sqlalchemy import select from sqlalchemy.orm import Session from app import audit from app.core.config import Settings from app.core.database import Database, now from app.core.errors import Forbidden, Invalid, NotFound from app.data_sources.models import DataSource from app.documents import service as documents from app.identity import Principal from app.mcp import memory from app.mcp import service as mcp from app.tasks.models import TaskRun from app.workspaces import service as workspaces from app.workspaces.models import Workspace, WorkspaceMembership log = logging.getLogger(__name__) ACTIVE = ("queued", "running", "waiting_approval", "waiting_input") @dataclass(frozen=True) class Removed: name: str memory_servers: list[str] # Gedächtnis-Server, deren Projekt und Key in Munin aufzuräumen sind @dataclass(frozen=True) class Trashed: workspace: Workspace purge_at: datetime def memory_servers(session: Session, workspace_id: int) -> list[str]: return [server.label for server, _ in mcp.enabled(session, workspace_id) if server.role == memory.MEMORY] def trash(session: Session, principal: Principal, workspace_id: int, confirmation: str) -> Workspace: """In den Papierkorb: sofort unsichtbar, aber wiederherstellbar.""" workspace = workspaces.require_admin(session, principal, workspace_id).workspace if confirmation.strip() != workspace.name: raise Invalid("Zur Bestätigung bitte den Namen des Workspace genau eingeben.") if session.scalar(select(TaskRun.id).where(TaskRun.workspace_id == workspace_id, TaskRun.status.in_(ACTIVE)).limit(1)): raise Invalid("Im Workspace laufen noch Antworten oder warten auf Freigabe. Bitte erst abbrechen.") workspace.deleted, workspace.deleted_by = now(), principal.user_id audit.record(session, "workspace.trashed", user_id=principal.user_id, workspace_id=workspace_id, target_kind="workspace", target_id=workspace_id, name=workspace.name) return workspace def trashed(session: Session, principal: Principal, settings: Settings) -> list[Trashed]: """Workspaces im Papierkorb, in denen der Nutzer Admin war.""" rows = session.scalars( select(Workspace) .join(WorkspaceMembership, WorkspaceMembership.workspace_id == Workspace.id) .where(WorkspaceMembership.user_id == principal.user_id, WorkspaceMembership.role == "admin", Workspace.team_id.in_(principal.team_ids), Workspace.deleted.is_not(None)) .order_by(Workspace.deleted.desc())) days = timedelta(days=settings.workspace_trash_days) return [Trashed(w, w.deleted + days) for w in rows] def require_trashed_admin(session: Session, principal: Principal, workspace_id: int) -> Workspace: workspace = session.get(Workspace, workspace_id) if workspace is None or workspace.deleted is None or workspace.team_id not in principal.team_ids: raise NotFound membership = session.get(WorkspaceMembership, (workspace_id, principal.user_id)) if membership is None: raise NotFound if membership.role != "admin": raise Forbidden return workspace def restore(session: Session, principal: Principal, workspace_id: int) -> Workspace: workspace = require_trashed_admin(session, principal, workspace_id) workspace.deleted, workspace.deleted_by = None, None audit.record(session, "workspace.restored", user_id=principal.user_id, workspace_id=workspace_id, target_kind="workspace", target_id=workspace_id, name=workspace.name) return workspace def purge(session: Session, principal: Principal, settings: Settings, workspace_id: int) -> Removed: """Endgültig löschen, nur aus dem Papierkorb.""" workspace = require_trashed_admin(session, principal, workspace_id) return remove(session, settings, workspace, principal.user_id) def remove(session: Session, settings: Settings, workspace: Workspace, user_id: int | None) -> Removed: workspace_id = workspace.id source_ids = list(session.scalars(select(DataSource.id).where(DataSource.workspace_id == workspace_id))) servers = memory_servers(session, workspace_id) pending = documents.pending_files(session, settings, workspace_id) # Vor dem Löschen festhalten: danach wird der Workspace-Bezug im Audit-Log geleert audit.record(session, "workspace.deleted", user_id=user_id, target_kind="workspace", target_id=workspace_id, name=workspace.name, team_id=workspace.team_id, sources=len(source_ids), memory_servers=servers, automatic=user_id is None) name = workspace.name session.delete(workspace) session.flush() remove_files(settings, workspace_id, source_ids) for path in pending: path.unlink(missing_ok=True) return Removed(name, servers) def purge_expired(db: Database, settings: Settings) -> int: """Vom Worker aufgerufen: Workspaces, deren Frist im Papierkorb abgelaufen ist, endgültig löschen.""" limit = now() - timedelta(days=settings.workspace_trash_days) with db.session() as session: expired = list(session.scalars(select(Workspace.id).where(Workspace.deleted < limit))) for workspace_id in expired: try: with db.session() as session: workspace = session.get(Workspace, workspace_id, with_for_update={"skip_locked": True}) if workspace is not None and workspace.deleted is not None and workspace.deleted < limit: remove(session, settings, workspace, None) except Exception: log.exception("Workspace %s konnte nicht endgültig gelöscht werden", workspace_id) return len(expired) def remove_files(settings: Settings, workspace_id: int, source_ids: list[int]) -> None: shutil.rmtree(Path(settings.upload_dir) / str(workspace_id), ignore_errors=True) for source_id in source_ids: shutil.rmtree(Path(settings.git_cache_dir) / f"{source_id}.git", ignore_errors=True)