"""Datenmodell. Inhalte sind content-addressed (Blob) und versioniert (Version).""" from __future__ import annotations from datetime import datetime, timezone from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db import Base def now() -> datetime: return datetime.now(timezone.utc) class Tenant(Base): __tablename__ = "tenants" id: Mapped[str] = mapped_column(String(64), primary_key=True) # Slug, == TENANT-Env im Container name: Mapped[str] = mapped_column(String(200)) status: Mapped[str] = mapped_column(String(20), default="active") # active | paused | archived source: Mapped[str] = mapped_column(String(10), default="git") # TENANT_SOURCE im Container: git | admin subdomain: Mapped[str] = mapped_column(String(100), default="") custom_domain: Mapped[str] = mapped_column(String(200), default="") domain_status: Mapped[str] = mapped_column(String(20), default="none") # none | pending | verified cpu: Mapped[float] = mapped_column(Float, default=0.5) memory: Mapped[str] = mapped_column(String(20), default="512M") current_version: Mapped[int | None] = mapped_column(Integer, nullable=True) deployed_version: Mapped[int | None] = mapped_column(Integer, nullable=True) deployed_image: Mapped[str] = mapped_column(String(100), default="") deployed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) draft: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {"config": {...}, "files": {path: sha}} preview_sample: Mapped[str | None] = mapped_column(Text, nullable=True) oidc: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Soll-Zustand des OIDC-Clients + letzter Abgleich core_pin: Mapped[str | None] = mapped_column(String(100), nullable=True) # fester Core-Tag (nimmt an Rollouts nicht teil) render_baseline: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {version, core, fp:{…}} Referenz für die Kompatibilitätsprüfung notes: Mapped[str] = mapped_column(Text, default="") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) versions: Mapped[list["Version"]] = relationship(back_populates="tenant", order_by="Version.number.desc()") class Blob(Base): """Dateiinhalt, adressiert über SHA-256. Später: große Binärdateien nach S3 auslagern.""" __tablename__ = "blobs" sha: Mapped[str] = mapped_column(String(64), primary_key=True) content: Mapped[bytes] = mapped_column(LargeBinary) size: Mapped[int] = mapped_column(Integer) is_text: Mapped[bool] = mapped_column(Boolean, default=True) class Version(Base): """Unveränderlicher Snapshot eines Tenants.""" __tablename__ = "versions" __table_args__ = (UniqueConstraint("tenant_id", "number"),) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), index=True) number: Mapped[int] = mapped_column(Integer) message: Mapped[str] = mapped_column(String(500), default="") author: Mapped[str] = mapped_column(String(100), default="") # "ruben" | "agent:" | "import" created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) config: Mapped[dict] = mapped_column(JSON) # Inhalt der tenant.json (ohne deploy-Block) files: Mapped[dict] = mapped_column(JSON) # {pfad: sha} tenant: Mapped[Tenant] = relationship(back_populates="versions") class Secret(Base): """Verschlüsselter Secret-Wert. tenant_id NULL = geteilt für alle Tenants.""" __tablename__ = "secrets" __table_args__ = (UniqueConstraint("tenant_id", "key"),) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True) key: Mapped[str] = mapped_column(String(100)) value_enc: Mapped[str | None] = mapped_column(Text, nullable=True) source: Mapped[str] = mapped_column(String(30), default="manual") # manual | sops-import updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now) class Setting(Base): __tablename__ = "settings" key: Mapped[str] = mapped_column(String(100), primary_key=True) value: Mapped[str] = mapped_column(Text, default="") class Deployment(Base): __tablename__ = "deployments" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) batch_id: Mapped[str] = mapped_column(String(40), default="", index=True) tenant_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) kind: Mapped[str] = mapped_column(String(20), default="deploy") # deploy | dry-run | offboard version: Mapped[int | None] = mapped_column(Integer, nullable=True) image_tag: Mapped[str] = mapped_column(String(100), default="") status: Mapped[str] = mapped_column(String(20), default="running") # running | success | failed simulated: Mapped[bool] = mapped_column(Boolean, default=True) log: Mapped[str] = mapped_column(Text, default="") actor: Mapped[str] = mapped_column(String(100), default="") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) class AuditLog(Base): __tablename__ = "audit_log" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True) actor: Mapped[str] = mapped_column(String(100), default="") action: Mapped[str] = mapped_column(String(60)) tenant_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) detail: Mapped[str] = mapped_column(Text, default="") class ChatMessage(Base): __tablename__ = "chat_messages" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), index=True) role: Mapped[str] = mapped_column(String(20)) # user | assistant content: Mapped[str] = mapped_column(Text, default="") steps: Mapped[list | None] = mapped_column(JSON, nullable=True) # Tool-Aufrufe (Anzeige) proposal: Mapped[dict | None] = mapped_column(JSON, nullable=True) # vorgeschlagene Änderung proposal_status: Mapped[str] = mapped_column(String(20), default="") # "" | open | applied | rejected usage: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {"in":…, "out":…, "backend":…, "model":…} created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) class CoreRelease(Base): """Vom Core-CI gemeldetes Release (Image-Tag). Wird geprüft, freigegeben und ausgerollt.""" __tablename__ = "core_releases" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) sha: Mapped[str] = mapped_column(String(64), unique=True, index=True) tag: Mapped[str] = mapped_column(String(100)) ref: Mapped[str] = mapped_column(String(100), default="") image: Mapped[str] = mapped_column(String(300), default="") message: Mapped[str] = mapped_column(String(500), default="") announced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) # new → checked → approved → rolling → active (rejected / failed / superseded) status: Mapped[str] = mapped_column(String(20), default="new") compat: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {checked_at, core_sha, tenants:{id:{status,…}}, summary:{…}} approved_by: Mapped[str] = mapped_column(String(100), default="") approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) previous_tag: Mapped[str] = mapped_column(String(100), default="") # aktiver Tag vor dem Rollout (für Rollback) rollout_batch: Mapped[str] = mapped_column(String(40), default="")