145 lines
6.7 KiB
Python
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)
|