Глобально уникальные ID (docs/changes/016): - у устройств, групп, задач, резервных копий и записей журнала ID вида <префикс>_<uuid7> (dev_, grp_, job_, bkp_, evt_): типы не пересекаются, внутри типа ID не повторяются и сортируются по времени; - миграция БД v1 заменяет числовые ID с пересчётом ссылок (копия файла БД перед миграцией, сверка числа строк, одна транзакция); - резервная копия = пара файлов с одним bkp_ ID, файлы в S3 получают метаданные backup-id/device-id; синхронизация метаданных с бакетом; - журнал событий в БД (events): создание/изменение/удаление, задачи, бэкапы, смена online/offline, вход в UI; API чтения GET /api/v1/events. Журнал в UI, ротация и очистка (docs/changes/017): - страница «Журнал»: фильтры, подгрузка «Показать ещё», окно записи; - настройки ротации (срок и максимум записей) хранятся в БД (схема v2), ротация при старте, раз в час и после сохранения настроек; - очистка журнала только через окно с паролем пользователя, блокировка после 5 неверных попыток, остаётся запись о факте очистки; через API очистки нет. Тесты: 20 из 20. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
109 lines
5.4 KiB
Python
109 lines
5.4 KiB
Python
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 <prefix>_<uuid7> (см. 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 с подробностями
|