@@ -0,0 +1,285 @@
|
||||
"""Полный сброс пользовательских данных 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
|
||||
Reference in New Issue
Block a user