"""Резервное копирование: продуктовая база + конфигурация (+ по желанию фото и картинки) в один zip. База выгружается в переносимом виде (JSON по таблицам), поэтому копию с PostgreSQL можно восстановить в SQLite и наоборот. Конфигурация: настройки сервера (таблица settings — она входит в базу) и несекретные переменные окружения; секреты (SECRET_KEY, пароли, строка подключения к БД) не сохраняются — только их названия, чтобы при переезде знать, что задать. Копии лежат в DATA_DIR/backups (в Docker — том appdata).""" import io import json import logging import os import re import zipfile from datetime import date, datetime, time, timedelta from pathlib import Path from sqlalchemy import Boolean, Date, DateTime, Float, Integer, JSON, Time, text from sqlalchemy.orm import Session from . import config, mode, settings_store as ss from .db import Base log = logging.getLogger("backup") FORMAT = 1 NAME_RE = re.compile(r"backup_\d{8}_\d{6}(_[a-z0-9-]{1,20})?\.zip") SKIP_TABLES = {"refresh_tokens"} # сессии не переносим: после восстановления все входят заново ENV_PUBLIC = ("APP_ENV", "APP_TZ", "APP_PORT", "FORWARDED_ALLOW_IPS", "DATA_DIR", "PHOTO_DIR", "ACCESS_TTL_MIN", "REFRESH_TTL_DAYS", "LOGIN_MAX_FAILS", "LOGIN_WINDOW_SEC", "SCHEDULER_INTERVAL", "ADMIN_USERNAME") ENV_SECRET = ("SECRET_KEY", "DATABASE_URL", "DB_PASSWORD", "ADMIN_PASSWORD") SECRET_SETTINGS = {"assistant_api_key"} DEFAULTS = {"backup_auto_hours": "24", "backup_keep": "14", "backup_media": "0"} class BackupError(Exception): pass def folder() -> Path: p = Path(config.DATA_DIR) / "backups" p.mkdir(parents=True, exist_ok=True) return p def setting(db: Session, key: str) -> str: v = ss.get(db, key) return v if v != "" else DEFAULTS[key] def _enc(v): if isinstance(v, (datetime, date, time)): return v.isoformat() return v def _dec(col, v): if v is None: return None t = col.type if isinstance(t, DateTime): return datetime.fromisoformat(v) if isinstance(t, Date): return date.fromisoformat(v) if isinstance(t, Time): return time.fromisoformat(v) if isinstance(t, Boolean): return bool(v) if isinstance(t, Integer) and not isinstance(v, bool): return int(v) if isinstance(t, Float): return float(v) return v def tables(): return [t for t in Base.metadata.sorted_tables if t.name not in SKIP_TABLES] def list_backups() -> list[dict]: out = [] for f in sorted(folder().glob("backup_*.zip"), reverse=True): if not NAME_RE.fullmatch(f.name): continue info = {"name": f.name, "size": f.stat().st_size, "created_at": config.iso(datetime.fromtimestamp(f.stat().st_mtime, config.TZ).replace(tzinfo=None))} try: with zipfile.ZipFile(f) as z: m = json.loads(z.read("manifest.json")) info.update(note=m.get("note", ""), media=m.get("include_media", False), rows=sum(m.get("tables", {}).values()), dialect=m.get("db_dialect", "")) except Exception: info.update(note="повреждённый файл", media=False, rows=0) out.append(info) return out def path_of(name: str) -> Path: if not NAME_RE.fullmatch(name or ""): raise BackupError("Недопустимое имя файла копии") f = folder() / name if not f.is_file(): raise BackupError("Копия не найдена") return f def create(db: Session, *, include_media: bool = False, note: str = "", tag: str = "") -> Path: """Создать копию. В демо-режиме запрещено: копировалась бы демо-база, а не продуктовая.""" if mode.is_demo_request(): raise BackupError("Сейчас включён демонстрационный режим — копия продуктовой базы недоступна") stamp = config.now().strftime("%Y%m%d_%H%M%S") name = f"backup_{stamp}" + (f"_{tag}" if tag else "") + ".zip" dst = folder() / name tmp = dst.with_suffix(".part") counts = {} with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as z: for t in tables(): rows = [{c.name: _enc(r._mapping[c.name]) for c in t.columns} for r in db.execute(t.select().order_by(*t.primary_key.columns))] if t.name == "settings": # секрет провайдера AI-помощника в копию не кладём rows = [r for r in rows if r["key"] not in SECRET_SETTINGS] counts[t.name] = len(rows) z.writestr(f"db/{t.name}.json", json.dumps(rows, ensure_ascii=False)) env = {k: os.environ[k] for k in ENV_PUBLIC if k in os.environ} z.writestr("config/environment.json", json.dumps({ "variables": env, "secrets_not_saved": [k for k in ENV_SECRET if k in os.environ], "server_settings": {k: ss.get(db, k) for k in ss.EDITABLE}, "note": "Секреты (SECRET_KEY, пароли, DATABASE_URL) в копию не входят — задайте их заново при переезде.", }, ensure_ascii=False, indent=1)) if include_media: root = ss.photo_root(db) for f in root.rglob("*"): if f.is_file(): z.write(f, "media/" + f.relative_to(root).as_posix()) z.writestr("manifest.json", json.dumps({ "app": "checklists-portal", "format": FORMAT, "created_at": config.iso(config.now()), "db_dialect": db.get_bind().dialect.name, "tables": counts, "include_media": include_media, "note": note, }, ensure_ascii=False, indent=1)) tmp.replace(dst) log.info("Создана резервная копия %s", dst.name) return dst def prune(keep: int): files = [f for f in sorted(folder().glob("backup_*.zip"), reverse=True) if NAME_RE.fullmatch(f.name)] auto = [f for f in files if "_auto" in f.name] for f in auto[keep:]: # чистим только автоматические — ручные копии удаляет администратор f.unlink(missing_ok=True) def auto_backup(db: Session) -> Path | None: """Вызывается планировщиком: делает копию, если с последней автокопии прошло больше заданного интервала.""" if mode.is_demo_request(): return None hours = int(setting(db, "backup_auto_hours") or 0) if hours <= 0: return None last = [f for f in folder().glob("backup_*_auto.zip")] if last and max(f.stat().st_mtime for f in last) > (datetime.now().timestamp() - hours * 3600): return None p = create(db, include_media=setting(db, "backup_media") == "1", note="автоматическая копия", tag="auto") prune(max(1, int(setting(db, "backup_keep") or 14))) return p def save_upload(data: bytes) -> Path: try: with zipfile.ZipFile(io.BytesIO(data)) as z: m = json.loads(z.read("manifest.json")) except Exception: raise BackupError("Это не файл резервной копии портала") if m.get("app") != "checklists-portal": raise BackupError("Это не файл резервной копии портала") name = f"backup_{config.now():%Y%m%d_%H%M%S}_upload.zip" (folder() / name).write_bytes(data) return folder() / name def restore(db: Session, name: str) -> dict: """Полностью заменить данные продуктовой базы содержимым копии. Перед этим автоматически делается копия текущего состояния.""" if mode.is_demo_request(): raise BackupError("Выключите демонстрационный режим перед восстановлением") src = path_of(name) with zipfile.ZipFile(src) as z: m = json.loads(z.read("manifest.json")) if m.get("app") != "checklists-portal" or m.get("format", 0) > FORMAT: raise BackupError("Копия создана несовместимой версией портала") data = {t.name: json.loads(z.read(f"db/{t.name}.json")) if f"db/{t.name}.json" in z.namelist() else [] for t in tables()} safety = create(db, note=f"перед восстановлением из {name}", tag="before-restore") bind = db.get_bind() pg = bind.dialect.name == "postgresql" try: for t in Base.metadata.sorted_tables: # снять необязательные ссылки — иначе циклы (отдел ↔ руководитель) мешают удалению nullable = {c.name: None for c in t.columns if c.foreign_keys and c.nullable} if nullable: db.execute(t.update().values(nullable)) for t in reversed(Base.metadata.sorted_tables): db.execute(t.delete()) inserted, later = set(), [] for t in tables(): rows = data.get(t.name, []) cols = {c.name: c for c in t.columns} # ссылки на ещё не загруженные таблицы и на саму себя ставим потом (отделы, вложенные места и т. п.) deferred = [c.name for c in t.columns if c.foreign_keys and c.nullable and any(fk.column.table.name not in inserted or fk.column.table.name == t.name for fk in c.foreign_keys)] batch = [] for r in rows: row = {k: _dec(cols[k], v) for k, v in r.items() if k in cols} for k in deferred: if row.get(k) is not None: later.append((t, row["id"], k, row[k])) row[k] = None batch.append(row) if batch: db.execute(t.insert(), batch) inserted.add(t.name) for t, rid, col, val in later: db.execute(t.update().where(t.c.id == rid).values({col: val})) if pg: for t in tables(): if "id" in t.c and isinstance(t.c.id.type, Integer): db.execute(text(f"SELECT setval(pg_get_serial_sequence('{t.name}', 'id'), " f"COALESCE((SELECT MAX(id) FROM {t.name}), 1), (SELECT COUNT(*) > 0 FROM {t.name}))")) db.commit() except Exception as e: db.rollback() log.exception("Ошибка восстановления") raise BackupError(f"Восстановление не выполнено, данные не изменены: {e}") files = 0 if m.get("include_media"): root = ss.photo_root(db).resolve() for info in z.infolist(): if not info.filename.startswith("media/") or info.is_dir(): continue dst = (root / info.filename[6:]).resolve() if root not in dst.parents: # защита от путей вида ../ continue dst.parent.mkdir(parents=True, exist_ok=True) dst.write_bytes(z.read(info)) files += 1 return {"restored": name, "rows": sum(len(v) for v in data.values()), "media_files": files, "safety_copy": safety.name} def next_auto(db: Session) -> str | None: hours = int(setting(db, "backup_auto_hours") or 0) if hours <= 0: return None last = [f.stat().st_mtime for f in folder().glob("backup_*_auto.zip")] if not last: return config.iso(config.now()) return config.iso(datetime.fromtimestamp(max(last), config.TZ).replace(tzinfo=None) + timedelta(hours=hours))