Целый раздел ТЗ, от которого не было ни одной функции. - Учётные записи: завести, сменить роль и службу, заблокировать, сбросить пароль. Обучающемуся заводится и карточка курсанта — на ней висят профиль и проверка «это твой разбор». - Состояние компонентов: база, модели речи, эмбеддинги, провайдер LLM, версия классификатора, число живых занятий. Отдельной строкой — секрет сессии: значение по умолчанию не ошибка запуска, но на стенде это дыра, и увидеть её должен администратор, а не проверяющий на защите. - Журнал действий с фильтром, только на чтение. - Резервные копии: кнопка и make backup для cron. pg_dump берётся локальный, а если его нет — из контейнера базы; если нет ни того ни другого, администратор видит, чего не хватает, а кнопка не молчит. Границы роли выражены отсутствием маршрутов, а не проверками внутри них: в разделе нет ни одной точки, трогающей оценки или пишущей в аудит. Два теста проверяют это перебором маршрутов роутера — так запрет нельзя ослабить, случайно добавив обработчик. Найдено по ходу: тесты с базой падали в компании и проходили поодиночке. Движок SQLAlchemy кэшировался на процесс, а каждый TestClient поднимает свой событийный цикл — пул привязан к первому. Появился db.base.reset() и фикстура, дающая движок на тест. 193 теста зелёных (12 новых).
93 lines
3.8 KiB
Python
93 lines
3.8 KiB
Python
"""Резервные копии базы.
|
||
|
||
ТЗ требует копирования не реже раза в сутки. Копия — это `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),
|
||
}
|