Files
ayurishchevandClaude Sonnet 5 26dd1f8be4 Уникальные ID сущностей, журнал событий и его UI с ротацией и очисткой
Глобально уникальные 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>
2026-09-19 17:01:52 +03:00

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 с подробностями