import json from datetime import datetime, timezone from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column from app.db import Base from app.ids import new_id def now() -> datetime: return datetime.now(timezone.utc) def _pk(prefix: str): """Первичный ключ-строка: глобально уникальный ID _ (см. app/ids.py).""" return mapped_column(String(40), primary_key=True, default=lambda: new_id(prefix)) class Group(Base): __tablename__ = "device_groups" id: Mapped[str] = _pk("grp") name: Mapped[str] = mapped_column(String(64), unique=True) class Device(Base): __tablename__ = "devices" id: Mapped[str] = _pk("dev") name: Mapped[str] = mapped_column(String(64), unique=True) host: Mapped[str] = mapped_column(String(255)) port: Mapped[int] = mapped_column(Integer, default=443) username: Mapped[str] = mapped_column(String(64)) password_enc: Mapped[str] = mapped_column(Text) verify_tls: Mapped[bool] = mapped_column(Boolean, default=False) use_tls: Mapped[bool] = mapped_column(Boolean, default=True, server_default="1") group_id: Mapped[str | None] = mapped_column(String(40), nullable=True) # FK не включены — см. groups.delete_group note: Mapped[str | None] = mapped_column(Text, nullable=True) # свободное примечание к устройству created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) # кэш последнего опроса устройства online: Mapped[bool | None] = mapped_column(Boolean, nullable=True) status_json: Mapped[str] = mapped_column(Text, default="{}") status_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_error: Mapped[str | None] = mapped_column(Text, nullable=True) last_backup_requested_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) @property def status(self) -> dict: return json.loads(self.status_json or "{}") class Backup(Base): """Резервная копия = пара файлов (.backup + .rsc). Строка не удаляется: при удалении файлов из бакета ставится deleted_at, ID остаётся в истории.""" __tablename__ = "backups" id: Mapped[str] = _pk("bkp") device_id: Mapped[str | None] = mapped_column(String(40), ForeignKey("devices.id", ondelete="SET NULL"), nullable=True) device_name: Mapped[str] = mapped_column(String(64), default="") # снимок имени: устройство могут удалить job_id: Mapped[str | None] = mapped_column(String(40), nullable=True) # задача, создавшая копию deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) status: Mapped[str] = mapped_column(String(16), default="running") # running|done|failed key_binary: Mapped[str | None] = mapped_column(String(512), nullable=True) key_rsc: Mapped[str | None] = mapped_column(String(512), nullable=True) error: Mapped[str | None] = mapped_column(Text, nullable=True) class Job(Base): __tablename__ = "jobs" id: Mapped[str] = _pk("job") device_id: Mapped[str | None] = mapped_column(String(40), nullable=True) device_name: Mapped[str] = mapped_column(String(64), default="") type: Mapped[str] = mapped_column(String(32)) # backup|ros_update|fw_update status: Mapped[str] = mapped_column(String(16), default="pending") # pending|running|done|failed message: Mapped[str] = mapped_column(Text, 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 AppSetting(Base): """Настройки, редактируемые из UI (ключ → JSON-значение); значения по умолчанию — из .env.""" __tablename__ = "app_settings" key: Mapped[str] = mapped_column(String(64), primary_key=True) value: Mapped[str] = mapped_column(Text) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) class Event(Base): """Журнал событий: каждая запись имеет свой ID и ссылается на ID сущности.""" __tablename__ = "events" id: Mapped[str] = _pk("evt") ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True) type: Mapped[str] = mapped_column(String(48), index=True) # device.created, job.failed, … entity_type: Mapped[str] = mapped_column(String(16)) # device|group|job|backup|session|system entity_id: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) device_id: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) job_id: Mapped[str | None] = mapped_column(String(40), nullable=True) actor: Mapped[str] = mapped_column(String(80), default="system") # ui:<пользователь> | api | poller | system message: Mapped[str] = mapped_column(Text, default="") data: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON с подробностями