feat: АРМ администратора — записи, состояние стенда, аудит, копии (lct-24)
Целый раздел ТЗ, от которого не было ни одной функции. - Учётные записи: завести, сменить роль и службу, заблокировать, сбросить пароль. Обучающемуся заводится и карточка курсанта — на ней висят профиль и проверка «это твой разбор». - Состояние компонентов: база, модели речи, эмбеддинги, провайдер LLM, версия классификатора, число живых занятий. Отдельной строкой — секрет сессии: значение по умолчанию не ошибка запуска, но на стенде это дыра, и увидеть её должен администратор, а не проверяющий на защите. - Журнал действий с фильтром, только на чтение. - Резервные копии: кнопка и make backup для cron. pg_dump берётся локальный, а если его нет — из контейнера базы; если нет ни того ни другого, администратор видит, чего не хватает, а кнопка не молчит. Границы роли выражены отсутствием маршрутов, а не проверками внутри них: в разделе нет ни одной точки, трогающей оценки или пишущей в аудит. Два теста проверяют это перебором маршрутов роутера — так запрет нельзя ослабить, случайно добавив обработчик. Найдено по ходу: тесты с базой падали в компании и проходили поодиночке. Движок SQLAlchemy кэшировался на процесс, а каждый TestClient поднимает свой событийный цикл — пул привязан к первому. Появился db.base.reset() и фикстура, дающая движок на тест. 193 теста зелёных (12 новых).
This commit is contained in:
parent
d55c8bc8cd
commit
641e505c67
13 changed files with 948 additions and 1 deletions
93
backend/app/admin/backup.py
Normal file
93
backend/app/admin/backup.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""Резервные копии базы.
|
||||
|
||||
ТЗ требует копирования не реже раза в сутки. Копия — это `pg_dump`, и брать
|
||||
его неоткуда, кроме самой базы: на машине с нативным бэкендом клиента Postgres
|
||||
может не быть вовсе. Поэтому сначала пробуем локальный `pg_dump`, потом —
|
||||
тот, что лежит в контейнере базы, и только если нет ни того ни другого,
|
||||
честно говорим, чего не хватает.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DIR = Path(__file__).resolve().parents[2] / "backups"
|
||||
#: Контейнер базы в docker compose проекта.
|
||||
CONTAINER = os.environ.get("POSTGRES_CONTAINER", "lct-hack-postgres-1")
|
||||
TIMEOUT_S = 120
|
||||
|
||||
|
||||
class BackupError(RuntimeError):
|
||||
"""Копию снять нечем или не удалось. Сообщение показывается администратору."""
|
||||
|
||||
|
||||
def _dsn() -> tuple[str, str, str, str, str]:
|
||||
"""Разобрать DATABASE_URL на части для pg_dump."""
|
||||
url = get_settings().database_url
|
||||
match = re.match(r".*://([^:]+):([^@]+)@([^:/]+):(\d+)/(\w+)", url)
|
||||
if not match:
|
||||
raise BackupError(f"не разобрать DATABASE_URL: {url}")
|
||||
return match.groups() # user, password, host, port, database
|
||||
|
||||
|
||||
def listing() -> list[dict]:
|
||||
"""Готовые копии, новые первыми."""
|
||||
if not DIR.exists():
|
||||
return []
|
||||
items = [
|
||||
{
|
||||
"name": path.name,
|
||||
"size_bytes": path.stat().st_size,
|
||||
"at": datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc),
|
||||
}
|
||||
for path in DIR.glob("*.sql")
|
||||
]
|
||||
return sorted(items, key=lambda item: item["at"], reverse=True)
|
||||
|
||||
|
||||
def create() -> dict:
|
||||
"""Снять копию. Возвращает описание файла, как в `listing()`."""
|
||||
user, password, host, port, database = _dsn()
|
||||
DIR.mkdir(parents=True, exist_ok=True)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
target = DIR / f"lct-{stamp}.sql"
|
||||
|
||||
if shutil.which("pg_dump"):
|
||||
command = ["pg_dump", "-h", host, "-p", port, "-U", user, "-d", database]
|
||||
env = {**os.environ, "PGPASSWORD": password}
|
||||
elif shutil.which("docker"):
|
||||
# В контейнере база доступна как localhost, а не по хосту из DSN.
|
||||
command = ["docker", "exec", "-e", f"PGPASSWORD={password}", CONTAINER,
|
||||
"pg_dump", "-U", user, "-d", database]
|
||||
env = dict(os.environ)
|
||||
else:
|
||||
raise BackupError(
|
||||
"нечем снять копию: нет ни pg_dump, ни docker. "
|
||||
"Поставьте postgresql-client или запускайте базу в контейнере проекта"
|
||||
)
|
||||
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, timeout=TIMEOUT_S, env=env)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise BackupError(f"pg_dump не уложился в {TIMEOUT_S} с") from exc
|
||||
except OSError as exc:
|
||||
raise BackupError(f"не запустился pg_dump: {exc}") from exc
|
||||
|
||||
if result.returncode != 0:
|
||||
raise BackupError(result.stderr.decode(errors="replace")[:300] or "pg_dump завершился с ошибкой")
|
||||
|
||||
target.write_bytes(result.stdout)
|
||||
log.info("резервная копия: %s, %d КБ", target.name, target.stat().st_size // 1024)
|
||||
return {
|
||||
"name": target.name,
|
||||
"size_bytes": target.stat().st_size,
|
||||
"at": datetime.fromtimestamp(target.stat().st_mtime, tz=timezone.utc),
|
||||
}
|
||||
Loading…
Reference in a new issue