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

75 lines
3.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Глобально уникальные идентификаторы сущностей: <префикс>_<uuid7>.
Пример: dev_0192f3a1-7c4e-7b1d-9a3f-5e2c1d0b8a47.
- Между типами — префикс: ID разных сущностей не пересекаются по построению.
- Внутри типа — UUIDv7 (RFC 9562): 48 бит времени (мс) + 12-битный счётчик + 62 случайных бита.
Внутри процесса ID строго возрастают (в том числе при откате часов), поэтому сортировка по ID
совпадает с порядком создания; между процессами уникальность держится на 62 случайных битах и PRIMARY KEY.
- ID не переиспользуются: значение не зависит от того, какие записи уже удалены.
"""
import re
import secrets
import threading
import time
import uuid
from datetime import datetime, timezone
PREFIXES = {
"dev": "устройство",
"grp": "группа",
"job": "задача",
"bkp": "резервная копия",
"evt": "запись журнала",
}
_TAIL = r"[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
_lock = threading.Lock()
_last_ms = 0
_seq = 0
_hist: dict[int, int] = {} # счётчики для «исторических» меток времени (миграция)
def _build(ms: int, seq: int) -> uuid.UUID:
rand_b = secrets.randbits(62)
return uuid.UUID(int=(ms << 80) | (0x7 << 76) | (seq << 64) | (0b10 << 62) | rand_b)
def new_id(prefix: str, at: datetime | None = None) -> str:
"""Новый ID. at — момент создания записи (для миграции старых данных: сохраняет порядок)."""
global _last_ms, _seq
if prefix not in PREFIXES:
raise ValueError(f"Неизвестный префикс ID: {prefix}")
with _lock:
if at is not None:
ms = int(at.replace(tzinfo=at.tzinfo or timezone.utc).timestamp() * 1000)
_hist[ms] = _hist.get(ms, -1) + 1
return f"{prefix}_{_build(ms, min(_hist[ms], 0xFFF))}"
now_ms = time.time_ns() // 1_000_000
if now_ms > _last_ms:
_last_ms, _seq = now_ms, secrets.randbits(11) # случайный старт: ≥2048 ID на миллисекунду
else: # тот же миллисекунда или часы ушли назад — время не уменьшаем
_seq += 1
if _seq > 0xFFF:
_last_ms, _seq = _last_ms + 1, 0
return f"{prefix}_{_build(_last_ms, _seq)}"
def is_id(value: object, prefix: str | None = None) -> bool:
"""Строка похожа на ID (нужного типа, если prefix задан)."""
if not isinstance(value, str):
return False
pat = rf"({'|'.join(PREFIXES)})_{_TAIL}" if prefix is None else rf"{prefix}_{_TAIL}"
return re.fullmatch(pat, value) is not None
def check(value: str, prefix: str) -> str:
"""Проверка ID из пути/запроса: чужой тип или мусор — LookupError (в API это 404 с пояснением)."""
if not is_id(value, prefix):
hint = f"ожидается {PREFIXES[prefix]} ({prefix}_…)"
raise LookupError(f"Неверный идентификатор «{str(value)[:60]}»: {hint}")
return value
def short_id(value: str | None) -> str:
"""Короткий вид для таблиц: последние 8 символов (случайная часть); полный ID — в подсказке."""
return (value or "")[-8:]