bifroest/app/workspaces/removal.py
2026-10-27 08:34:17 +01:00

145 lines
6.7 KiB
Python

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