CI / ruff · mypy · pytest (pull_request) Successful in 1h3m54s
- invites.max_uses/uses: обычное приглашение — 1 вход, демо-QR — до DEMO_INVITE_MAX_USES (50), живёт DEMO_INVITE_TTL_HOURS (3 ч); захват атомарный, UPDATE … WHERE uses < max_uses - users.guest: вошедшие по демо-QR; «🧹 Отключить гостей демо» отзывает всех и гасит демо-QR - «👥 Участники»: кнопка «🎤 Демо-QR», гости в списке — 🎟 - миграция 0003 (ADD COLUMN без batch — SQLite не пересоздаёт users); проверена на Postgres - SPEC A-3a, ADR-017, DEMO.md, .env.example Refs #55 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
295 lines
10 KiB
Python
295 lines
10 KiB
Python
"""Data model (ARCHITECTURE §2.10). One schema for PostgreSQL (prod) and SQLite (tests).
|
|
|
|
Enums are stored as VARCHAR + CHECK rather than native PG enums: portable and painless to migrate.
|
|
"""
|
|
|
|
from datetime import UTC, datetime
|
|
from enum import StrEnum
|
|
from typing import Any, ClassVar
|
|
|
|
from sqlalchemy import (
|
|
JSON,
|
|
BigInteger,
|
|
Boolean,
|
|
DateTime,
|
|
Enum,
|
|
Float,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
LargeBinary,
|
|
MetaData,
|
|
String,
|
|
Text,
|
|
UniqueConstraint,
|
|
false,
|
|
)
|
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
|
|
|
# Stable constraint names so Alembic migrations stay deterministic across databases.
|
|
NAMING = {
|
|
"ix": "ix_%(column_0_label)s",
|
|
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
|
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
|
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
|
"pk": "pk_%(table_name)s",
|
|
}
|
|
|
|
# Surrogate keys: BIGINT on Postgres, INTEGER on SQLite (only INTEGER PRIMARY KEY autoincrements there).
|
|
Id = BigInteger().with_variant(Integer(), "sqlite")
|
|
Json = JSON().with_variant(JSONB(), "postgresql")
|
|
TgId = BigInteger
|
|
|
|
|
|
def utcnow() -> datetime:
|
|
return datetime.now(UTC)
|
|
|
|
|
|
def as_utc(dt: datetime) -> datetime:
|
|
"""SQLite hands timestamps back naive (they are stored in UTC); Postgres — aware. Arithmetic needs aware."""
|
|
return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC)
|
|
|
|
|
|
def enum(cls: type[StrEnum], name: str) -> Enum:
|
|
return Enum(
|
|
cls,
|
|
name=name,
|
|
native_enum=False,
|
|
create_constraint=True, # SQLAlchemy 2 skips the CHECK for non-native enums unless asked
|
|
length=20,
|
|
values_callable=lambda e: [m.value for m in e],
|
|
)
|
|
|
|
|
|
class Role(StrEnum):
|
|
MEMBER = "member"
|
|
ADMIN = "admin"
|
|
|
|
|
|
class ChangeOp(StrEnum):
|
|
ADD = "add"
|
|
MODIFY = "modify"
|
|
DELETE = "delete"
|
|
|
|
|
|
class Stage(StrEnum):
|
|
CI = "ci"
|
|
CD = "cd"
|
|
|
|
|
|
class PipelineStatus(StrEnum):
|
|
PENDING = "pending"
|
|
RUNNING = "running"
|
|
SUCCESS = "success"
|
|
FAILED = "failed"
|
|
TIMEOUT = "timeout"
|
|
SKIPPED = "skipped"
|
|
|
|
|
|
class AlertKind(StrEnum):
|
|
CRASH = "crash"
|
|
OOM = "oom"
|
|
RESTART_LOOP = "restart_loop"
|
|
UNHEALTHY = "unhealthy"
|
|
OVERLOAD = "overload"
|
|
DEPLOY_REFUSED = "deploy_refused"
|
|
CD_FAILED = "cd_failed"
|
|
DISK_LOW = "disk_low"
|
|
DEPENDENCY_DOWN = "dependency_down"
|
|
AGENT_ATTACK = "agent_attack"
|
|
|
|
|
|
class LogMode(StrEnum):
|
|
TAIL = "tail"
|
|
GREP = "grep"
|
|
AGENT = "agent"
|
|
|
|
|
|
class Verdict(StrEnum):
|
|
SAFE = "safe"
|
|
ATTACK = "attack"
|
|
ERROR = "error"
|
|
|
|
|
|
class Base(DeclarativeBase):
|
|
metadata = MetaData(naming_convention=NAMING)
|
|
type_annotation_map: ClassVar[dict[Any, Any]] = {
|
|
datetime: DateTime(timezone=True),
|
|
dict[str, Any]: Json,
|
|
list[str]: Json,
|
|
}
|
|
|
|
|
|
class User(Base):
|
|
__tablename__ = "users"
|
|
|
|
tg_id: Mapped[int] = mapped_column(TgId, primary_key=True, autoincrement=False)
|
|
username: Mapped[str | None] = mapped_column(String(64))
|
|
full_name: Mapped[str] = mapped_column(String(256))
|
|
role: Mapped[Role] = mapped_column(enum(Role, "role"), default=Role.MEMBER)
|
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
# Came in by a multi-use demo invite: cut off all at once after the talk (#55)
|
|
guest: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
|
|
invited_by: Mapped[int | None] = mapped_column(TgId, ForeignKey("users.tg_id"))
|
|
joined_at: Mapped[datetime] = mapped_column(default=utcnow)
|
|
last_seen_at: Mapped[datetime | None]
|
|
|
|
|
|
class Invite(Base):
|
|
__tablename__ = "invites"
|
|
|
|
id: Mapped[int] = mapped_column(Id, primary_key=True)
|
|
# sha256 hex of the token; the token itself is never stored (SPEC A-3)
|
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
|
created_by: Mapped[int] = mapped_column(TgId, ForeignKey("users.tg_id"))
|
|
created_at: Mapped[datetime] = mapped_column(default=utcnow)
|
|
expires_at: Mapped[datetime]
|
|
# 1 — a personal invite; more — the demo QR shown to an audience (#55). used_by/used_at — the latest entrant.
|
|
max_uses: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
|
|
uses: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
used_by: Mapped[int | None] = mapped_column(TgId, ForeignKey("users.tg_id"))
|
|
used_at: Mapped[datetime | None]
|
|
|
|
|
|
class AccessAttempt(Base):
|
|
__tablename__ = "access_attempts"
|
|
__table_args__ = (Index("ix_access_attempts_tg_id_at", "tg_id", "at"),)
|
|
|
|
id: Mapped[int] = mapped_column(Id, primary_key=True)
|
|
tg_id: Mapped[int] = mapped_column(TgId)
|
|
at: Mapped[datetime] = mapped_column(default=utcnow)
|
|
ok: Mapped[bool] = mapped_column(Boolean)
|
|
|
|
|
|
class FsmState(Base):
|
|
__tablename__ = "fsm_state"
|
|
|
|
key: Mapped[str] = mapped_column(String(255), primary_key=True)
|
|
state: Mapped[str | None] = mapped_column(String(255))
|
|
data: Mapped[dict[str, Any]] = mapped_column(default=dict)
|
|
|
|
|
|
class Draft(Base):
|
|
__tablename__ = "drafts"
|
|
|
|
id: Mapped[int] = mapped_column(Id, primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(TgId, ForeignKey("users.tg_id", ondelete="CASCADE"), unique=True)
|
|
base_sha: Mapped[str] = mapped_column(String(40))
|
|
message: Mapped[str | None] = mapped_column(Text)
|
|
created_at: Mapped[datetime] = mapped_column(default=utcnow)
|
|
expires_at: Mapped[datetime]
|
|
|
|
changes: Mapped[list["DraftChange"]] = relationship(
|
|
back_populates="draft", cascade="all, delete-orphan", order_by="DraftChange.id"
|
|
)
|
|
|
|
|
|
class DraftChange(Base):
|
|
__tablename__ = "draft_changes"
|
|
__table_args__ = (UniqueConstraint("draft_id", "path"),)
|
|
|
|
id: Mapped[int] = mapped_column(Id, primary_key=True)
|
|
draft_id: Mapped[int] = mapped_column(Id, ForeignKey("drafts.id", ondelete="CASCADE"))
|
|
path: Mapped[str] = mapped_column(String(500))
|
|
op: Mapped[ChangeOp] = mapped_column(enum(ChangeOp, "change_op"))
|
|
content: Mapped[bytes | None] = mapped_column(LargeBinary)
|
|
|
|
draft: Mapped[Draft] = relationship(back_populates="changes")
|
|
|
|
|
|
class Push(Base):
|
|
__tablename__ = "pushes"
|
|
|
|
id: Mapped[int] = mapped_column(Id, primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(TgId, ForeignKey("users.tg_id"))
|
|
sha: Mapped[str] = mapped_column(String(40), unique=True)
|
|
message: Mapped[str] = mapped_column(Text)
|
|
paths: Mapped[list[str]] = mapped_column(default=list)
|
|
chat_id: Mapped[int] = mapped_column(TgId)
|
|
message_id: Mapped[int | None] = mapped_column(BigInteger)
|
|
ci_status: Mapped[PipelineStatus] = mapped_column(enum(PipelineStatus, "ci_status"), default=PipelineStatus.PENDING)
|
|
cd_status: Mapped[PipelineStatus] = mapped_column(enum(PipelineStatus, "cd_status"), default=PipelineStatus.PENDING)
|
|
created_at: Mapped[datetime] = mapped_column(default=utcnow)
|
|
|
|
|
|
class PipelineRun(Base):
|
|
__tablename__ = "pipeline_runs"
|
|
__table_args__ = (UniqueConstraint("push_id", "stage"),)
|
|
|
|
id: Mapped[int] = mapped_column(Id, primary_key=True)
|
|
push_id: Mapped[int] = mapped_column(Id, ForeignKey("pushes.id", ondelete="CASCADE"))
|
|
# CI and CD are tasks of one SourceCraft workflow (check-and-deploy), hence "stage", not "workflow"
|
|
stage: Mapped[Stage] = mapped_column(enum(Stage, "stage"))
|
|
run_slug: Mapped[str] = mapped_column(String(50))
|
|
status: Mapped[PipelineStatus] = mapped_column(enum(PipelineStatus, "run_status"))
|
|
failed_cube: Mapped[str | None] = mapped_column(String(100))
|
|
url: Mapped[str | None] = mapped_column(String(500))
|
|
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
|
|
|
|
|
|
class ContainerSnapshot(Base):
|
|
__tablename__ = "container_snapshots"
|
|
__table_args__ = (Index("ix_container_snapshots_container_at", "container", "at"),)
|
|
|
|
id: Mapped[int] = mapped_column(Id, primary_key=True)
|
|
container: Mapped[str] = mapped_column(String(100))
|
|
at: Mapped[datetime] = mapped_column(default=utcnow)
|
|
restart_count: Mapped[int] = mapped_column(Integer)
|
|
cpu_pct: Mapped[float] = mapped_column(Float)
|
|
mem_pct: Mapped[float] = mapped_column(Float)
|
|
health: Mapped[str | None] = mapped_column(String(20))
|
|
|
|
|
|
class Alert(Base):
|
|
__tablename__ = "alerts"
|
|
__table_args__ = (Index("ix_alerts_kind_service_resolved_at", "kind", "service", "resolved_at"),)
|
|
|
|
id: Mapped[int] = mapped_column(Id, primary_key=True)
|
|
kind: Mapped[AlertKind] = mapped_column(enum(AlertKind, "alert_kind"))
|
|
service: Mapped[str | None] = mapped_column(String(100))
|
|
payload: Mapped[dict[str, Any]] = mapped_column(default=dict)
|
|
count: Mapped[int] = mapped_column(Integer, default=1)
|
|
opened_at: Mapped[datetime] = mapped_column(default=utcnow)
|
|
last_at: Mapped[datetime] = mapped_column(default=utcnow)
|
|
resolved_at: Mapped[datetime | None]
|
|
|
|
deliveries: Mapped[list["AlertDelivery"]] = relationship(back_populates="alert", cascade="all, delete-orphan")
|
|
|
|
|
|
class AlertDelivery(Base):
|
|
__tablename__ = "alert_deliveries"
|
|
__table_args__ = (UniqueConstraint("alert_id", "chat_id"),)
|
|
|
|
id: Mapped[int] = mapped_column(Id, primary_key=True)
|
|
alert_id: Mapped[int] = mapped_column(Id, ForeignKey("alerts.id", ondelete="CASCADE"))
|
|
chat_id: Mapped[int] = mapped_column(TgId)
|
|
message_id: Mapped[int] = mapped_column(BigInteger)
|
|
|
|
alert: Mapped[Alert] = relationship(back_populates="deliveries")
|
|
|
|
|
|
class LogQuery(Base):
|
|
__tablename__ = "log_queries"
|
|
|
|
id: Mapped[int] = mapped_column(Id, primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(TgId, ForeignKey("users.tg_id"))
|
|
container: Mapped[str] = mapped_column(String(100))
|
|
mode: Mapped[LogMode] = mapped_column(enum(LogMode, "log_mode"))
|
|
query: Mapped[dict[str, Any]] = mapped_column(default=dict)
|
|
lines: Mapped[int] = mapped_column(Integer, default=0)
|
|
at: Mapped[datetime] = mapped_column(default=utcnow)
|
|
|
|
|
|
class AgentRequest(Base):
|
|
__tablename__ = "agent_requests"
|
|
|
|
id: Mapped[int] = mapped_column(Id, primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(TgId, ForeignKey("users.tg_id"))
|
|
prompt: Mapped[str] = mapped_column(Text)
|
|
verdict: Mapped[Verdict] = mapped_column(enum(Verdict, "verdict"))
|
|
reason: Mapped[str | None] = mapped_column(Text)
|
|
log_query: Mapped[dict[str, Any] | None]
|
|
tokens: Mapped[int] = mapped_column(Integer, default=0)
|
|
at: Mapped[datetime] = mapped_column(default=utcnow)
|