286 lines
9.7 KiB
Python
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
|