"""Полный сброс пользовательских данных 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