Files
site/WESP_REL/app/services/factory_reset.py
T
2026-07-17 12:57:18 +03:00

286 lines
9.7 KiB
Python

"""Полный сброс пользовательских данных WESP (БД, JSON настроек, логи) для повторного /setup."""
from __future__ import annotations
import logging
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from flask import Flask
_log = logging.getLogger(__name__)
# Фраза для POST (должна совпадать посимвольно).
FACTORY_RESET_CONFIRM_PHRASE = "СБРОС ДАННЫХ"
# Только эти пути внутри data/ (код приложения, vendor, static не трогаем).
_DATA_REL_PATHS = (
"recipes.db",
"recipes.db-wal",
"recipes.db-shm",
"recipes.db-journal",
"reports.db",
"reports.db-wal",
"reports.db-shm",
"reports.db-journal",
"sync_client_state.json",
"wesp_install_state.json",
"wesp_network_settings.json",
"wesp_security_settings.json",
"wesp_network_diagnostics.json",
"config.json",
)
_DATA_REL_DIRS = (
"logs",
"backups",
"clients",
"assistant/models",
)
_PRESERVED_DESCRIPTION = (
"Не удаляется: исходный код (/app, /static, /templates), vendor/, конфигурация процесса (WESP_* env)."
)
def default_sync_client_state_after_reset() -> Dict[str, Any]:
"""Стартовое состояние sync: центральный узел, без URL удалённого сервера."""
return {
"role": "server",
"server_url": "",
"client_name": "WESP Server",
"first_bootstrap_done": True,
}
def assert_safe_factory_reset_target(data_dir: Path, base_dir: Path) -> None:
"""data/ должен лежать внутри BASE_DIR проекта — защита от опечатки в пути."""
data_resolved = data_dir.expanduser().resolve()
base_resolved = base_dir.expanduser().resolve()
if not str(data_resolved).startswith(str(base_resolved)):
raise ValueError(
f"Каталог данных вне проекта: {data_resolved} (ожидался под {base_resolved})"
)
# Имя каталога — data (или заканчивается на /data)
if data_resolved.name != "data":
raise ValueError(
f"Сброс разрешён только для каталога data/, получено: {data_resolved.name!r}"
)
def factory_reset_preview(data_dir: Path, base_dir: Path) -> Dict[str, Any]:
"""Список того, что будет удалено (без изменений на диске)."""
assert_safe_factory_reset_target(data_dir, base_dir)
data_dir = data_dir.expanduser().resolve()
files: List[Dict[str, str]] = []
dirs: List[Dict[str, str]] = []
for rel in _DATA_REL_PATHS:
p = data_dir / rel
if p.is_file() or p.is_symlink():
try:
size = p.stat().st_size
except OSError:
size = 0
files.append({"path": rel, "size_bytes": size})
for rel in _DATA_REL_DIRS:
p = data_dir / rel
if p.is_dir():
try:
n_files = sum(1 for _ in p.rglob("*") if _.is_file())
except OSError:
n_files = -1
dirs.append({"path": rel + "/", "files_inside": n_files})
return {
"data_dir": str(data_dir),
"base_dir": str(base_dir.resolve()),
"confirm_phrase": FACTORY_RESET_CONFIRM_PHRASE,
"files": files,
"directories": dirs,
"will_clear_setup_flag": True,
"default_sync_after": default_sync_client_state_after_reset(),
"preserved_note": _PRESERVED_DESCRIPTION,
"has_anything_to_reset": bool(files or dirs),
}
def _write_safety_sqlite_backup(app: Flask, data_dir: Path) -> Optional[str]:
"""ZIP снимок БД в data/backups/ до удаления; возвращает относительный путь."""
try:
from app.services.admin_dashboard_service import build_sqlite_backup_zip
except Exception as exc:
_log.warning("Предсбросный бэкап БД недоступен: %s", exc)
return None
try:
raw, _ = build_sqlite_backup_zip(app)
except (ValueError, FileNotFoundError) as exc:
_log.warning("Предсбросный бэкап БД пропущен: %s", exc)
return None
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
backups = data_dir / "backups"
backups.mkdir(parents=True, exist_ok=True)
rel_name = f"backups/wesp-pre-reset-{stamp}.zip"
dest = data_dir / rel_name
dest.write_bytes(raw)
return rel_name
def _clear_backups_dir_except(data_dir: Path, keep_names: frozenset[str]) -> None:
backups = data_dir / "backups"
if not backups.is_dir():
return
for child in backups.iterdir():
if child.name in keep_names:
continue
try:
if child.is_dir():
shutil.rmtree(child)
else:
child.unlink()
except OSError as exc:
_log.warning("Не удалось удалить %s: %s", child, exc)
def factory_reset_data_dir(
data_dir: Path,
base_dir: Path,
*,
remove_logs: bool = True,
app: Optional[Flask] = None,
safety_sqlite_backup: bool = False,
) -> Dict[str, Any]:
"""Удаляет БД и пользовательские JSON; возвращает отчёт."""
assert_safe_factory_reset_target(data_dir, base_dir)
data_dir = data_dir.expanduser().resolve()
safety_path: Optional[str] = None
if safety_sqlite_backup and app is not None:
try:
from app import db
from app.services.migration_pace import release_sqlalchemy_sqlite_locks
with app.app_context():
release_sqlalchemy_sqlite_locks(app)
db.engine.dispose()
for eng in list(db.engines.values()):
eng.dispose()
except Exception:
_log.debug("dispose перед предсбросным бэкапом", exc_info=True)
safety_path = _write_safety_sqlite_backup(app, data_dir)
removed: List[str] = []
errors: List[str] = []
keep_in_backups = frozenset({Path(safety_path).name}) if safety_path else frozenset()
for rel in _DATA_REL_PATHS:
p = data_dir / rel
if p.is_file() or p.is_symlink():
try:
p.unlink()
removed.append(str(p))
except OSError as exc:
errors.append(f"{p}: {exc}")
dirs = list(_DATA_REL_DIRS)
if not remove_logs:
dirs = [d for d in dirs if d != "logs"]
for rel in dirs:
p = data_dir / rel
if not p.is_dir():
continue
if rel == "backups" and keep_in_backups:
_clear_backups_dir_except(data_dir, keep_in_backups)
removed.append(str(p) + "/ (частично, сохранён предсбросный ZIP)")
continue
try:
shutil.rmtree(p)
removed.append(str(p) + "/")
except OSError as exc:
errors.append(f"{p}: {exc}")
data_dir.mkdir(parents=True, exist_ok=True)
(data_dir / "logs").mkdir(parents=True, exist_ok=True)
if safety_path:
(data_dir / "backups").mkdir(parents=True, exist_ok=True)
from config import get_sync_client_state_path, write_sync_client_state
write_sync_client_state(default_sync_client_state_after_reset())
removed.append(str(get_sync_client_state_path()))
msg = (
"Данные сброшены. Перезапустите WESP и откройте /setup. "
"Роль sync: server (без фонового pull к удалённому узлу)."
)
if safety_path:
msg += f" Копия БД сохранена: data/{safety_path}"
if errors:
msg += f" Ошибки: {len(errors)}."
return {
"data_dir": str(data_dir),
"removed": removed,
"errors": errors,
"ok": not errors,
"message": msg,
"safety_backup_path": safety_path,
}
def factory_reset_app(
app: Flask,
*,
remove_logs: bool = True,
safety_sqlite_backup: bool = True,
) -> Dict[str, Any]:
"""Сброс data/ + флаг setup; остановите sync перед вызовом."""
from app.services.setup_state import reset_setup
try:
from sync_client import apply_sync_client_runtime, stop_sync_client
stop_sync_client()
apply_sync_client_runtime(app)
except Exception:
_log.debug("sync client stop/reload перед factory reset", exc_info=True)
data_dir = Path(str(app.config.get("DATA_DIR") or ""))
base_dir = Path(str(app.config.get("BASE_DIR") or "."))
report = factory_reset_data_dir(
data_dir,
base_dir,
remove_logs=remove_logs,
app=app,
safety_sqlite_backup=safety_sqlite_backup,
)
reset_setup(app)
report["setup_reset"] = True
report["requires_process_restart"] = True
report["redirect_after_restart"] = "/setup"
return report
def validate_factory_reset_request(
payload: Dict[str, Any],
) -> Tuple[bool, str, bool, bool]:
"""(ok, error_message, remove_logs, safety_sqlite_backup)."""
phrase = str(payload.get("confirmation_phrase") or payload.get("confirm") or "").strip()
if phrase != FACTORY_RESET_CONFIRM_PHRASE:
return (
False,
f'Введите фразу подтверждения точно: «{FACTORY_RESET_CONFIRM_PHRASE}»',
True,
True,
)
remove_logs = bool(payload.get("remove_logs", True))
safety = bool(payload.get("safety_sqlite_backup", True))
return True, "", remove_logs, safety