"""Freigabeanträge (→ Konzept 10.4, 10.5, 9.2). Ein Antrag bezieht sich auf genau einen Tool-Aufruf. Parameter, Vorschau (Diff) und Vorbedingungen sind ein unveränderlicher Snapshot; ausgeführt wird nur, was zum gespeicherten Hash passt. """ import uuid from datetime import datetime from typing import Any from sqlalchemy import JSON, Boolean, ForeignKey, Index, String, Text, Uuid, false, text from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base, now # pending → approved → executing → executed | failed # pending → rejected | expired | cancelled (Run abgebrochen) # executing → unknown: Ausgang unklar, Run wechselt nach needs_reconciliation (→ Konzept 9.2) PENDING, APPROVED, REJECTED, EXPIRED, CANCELLED = "pending", "approved", "rejected", "expired", "cancelled" EXECUTING, EXECUTED, FAILED, UNKNOWN = "executing", "executed", "failed", "unknown" NOT_EXECUTED = (REJECTED, EXPIRED, CANCELLED) LABELS = {PENDING: "offen", APPROVED: "genehmigt", REJECTED: "abgelehnt", EXPIRED: "abgelaufen", CANCELLED: "zurückgezogen", EXECUTING: "wird ausgeführt", EXECUTED: "ausgeführt", FAILED: "fehlgeschlagen", UNKNOWN: "Ausgang unklar"} class ApprovalRequest(Base): __tablename__ = "approval_requests" __table_args__ = ( Index("ix_approval_requests_pending", "workspace_id", postgresql_where=text("status = 'pending'")), Index("ix_approval_requests_run_id", "run_id"), ) id: Mapped[int] = mapped_column(primary_key=True) run_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("task_runs.id", ondelete="CASCADE")) workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id", ondelete="CASCADE")) source_id: Mapped[int | None] = mapped_column(ForeignKey("data_sources.id", ondelete="SET NULL")) call_id: Mapped[str] = mapped_column(String(200)) tool: Mapped[str] = mapped_column(String(100)) risk_class: Mapped[str] = mapped_column(String(20)) reason: Mapped[str] = mapped_column(String(200)) # warum Freigabe nötig (Policy) # Snapshot: geprüfte Argumente, echte Vorschau, Vorbedingungen im Zielsystem arguments: Mapped[dict[str, Any]] = mapped_column(JSON) preview: Mapped[str] = mapped_column(Text) preconditions: Mapped[dict[str, Any]] = mapped_column(JSON) # Vom Modell formulierte Beschreibung, nur als gekennzeichneter Zusatz angezeigt model_summary: Mapped[str] = mapped_column(Text, default="") # Policy zum Zeitpunkt des Antrags: Tool-Hash, Risikoklasse, Entscheidung, Modus (→ Konzept 10.4) policy: Mapped[dict[str, Any]] = mapped_column(JSON) hash: Mapped[str] = mapped_column(String(64)) status: Mapped[str] = mapped_column(String(20), default=PENDING) requested_by: Mapped[int] = mapped_column(ForeignKey("users.id")) requested_at: Mapped[datetime] = mapped_column(default=now) expires_at: Mapped[datetime] decided_by: Mapped[int | None] = mapped_column(ForeignKey("users.id")) decided_at: Mapped[datetime | None] comment: Mapped[str] = mapped_column(Text, default="") self_approved: Mapped[bool] = mapped_column(Boolean, default=False) # Wer entscheiden darf (→ Konzept 10.5): Workspace-Admins oder globale Approver; mit four_eyes # nie die Person, die den Antrag gestellt hat approval_level: Mapped[str] = mapped_column(String(20), default="workspace_admin", server_default="workspace_admin") four_eyes: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false()) # Ergebnis der Ausführung (Branch, Commit, Pull Request) oder Fehlermeldung result: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) # Erneut beantragt aus einem abgelaufenen Antrag replaces_id: Mapped[int | None] = mapped_column(ForeignKey("approval_requests.id"))