- docker-compose.yml: профили docs (Nextcloud), office (ONLYOFFICE Document Server), ai (Ollama + скачивание модели) - автонастройка: адреса Nextcloud, ONLYOFFICE, портала и модели подставляются из .env или по именам сервисов во внутренней сети Docker; значения из интерфейса важнее - локальная модель в своей сети (имя сервиса, частный IP) подключается без ключа API - .env.example, docs/DEPLOY.md (раздел 7а), README
247 lines
12 KiB
Python
247 lines
12 KiB
Python
"""Резервное копирование: продуктовая база + конфигурация (+ по желанию фото и картинки) в один 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", "doc_index"} # индекс документации строится заново # сессии не переносим: после восстановления все входят заново
|
|
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", "nc_password", "onlyoffice_jwt"}
|
|
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))
|