"""Setup wizard API (/api/setup/*).""" from __future__ import annotations import logging import platform import threading import uuid from typing import Any, Dict from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen from flask import Blueprint, current_app, jsonify, make_response, request from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from werkzeug.security import generate_password_hash from app import db from app.kiosk_device_cookie import set_kiosk_device_cookie from app.models import KioskDevice, WebUser from app.routes.auth import _ensure_default_superuser, load_credentials from app.routes.admin import ( _WEB_USER_PASSWORD_MIN_LEN, _effective_sync_connection, _reject_if_weak_login_or_password, _valid_login, ) from app.routes.auth_decorators import _is_localhost_request, is_superuser_session, require_superuser from app.services.admin_dashboard_service import get_install_and_warranty from app.services.admin_peripheral_monitor import build_hardware_status from app.services.hardware_settings_service import ( is_scale_hardware_platform, setup_complete_redirect, ) from app.services.lan_network import parse_wesp_listen from app.services.network_settings_service import build_public_url_from_hostname, network_settings_snapshot from app.services.setup_state import ( is_setup_completed, mark_setup_complete, mark_step_completed, read_install_state, reset_setup, setup_snapshot, write_install_state, ) from app.services.mdns_service import reload_mdns from config import ( DEFAULT_NETWORK_LOCAL_HOSTNAME, apply_network_settings_to_app, coerce_security_bool, normalize_local_hostname, read_sync_client_state, validate_local_hostname, write_network_settings, write_sync_client_state, ) bp = Blueprint("setup", __name__, url_prefix="/api/setup") logger = logging.getLogger(__name__) _USER_CREATE_ATTEMPTS: Dict[str, int] = {} _USER_CREATE_LIMIT = 20 def _setup_connection_snapshot() -> dict: conn = _effective_sync_connection(current_app) state = read_sync_client_state() return { **conn, "client_id": state.get("client_id"), "client_name": state.get("client_name"), } def _default_setup_client_name() -> str: snap = network_settings_snapshot(current_app) host = normalize_local_hostname(str(snap.get("local_hostname") or "")) default_host = normalize_local_hostname(DEFAULT_NETWORK_LOCAL_HOSTNAME) if host and host != default_host: return host.replace(".local", "")[:200] os_h = str((snap.get("detected") or {}).get("os_hostname") or "").strip().lower() os_h = os_h.replace(" ", "_")[:200] return os_h or "vesy_1" def _setup_blocked_response(): return jsonify({"status": "error", "message": "Первоначальная настройка уже завершена."}), 403 def _localhost_required_response(): return jsonify( { "status": "error", "message": "Настройка доступна только с localhost или для суперпользователя.", } ), 403 def _is_setup_local_request() -> bool: if bool(current_app.config.get("TESTING")): return True return _is_localhost_request() def _require_setup_access(*, allow_completed: bool = False): if not allow_completed and is_setup_completed(current_app): return _setup_blocked_response() if is_superuser_session(): return None if not is_setup_completed(current_app): # Wizard is open to the LAN until first-time setup finishes (see pages guard). return None if not _is_setup_local_request(): return _localhost_required_response() return None def _require_setup_mutation(): return _require_setup_access(allow_completed=False) def _rate_limit_user_create() -> tuple[dict | None, int]: key = request.remote_addr or "unknown" count = _USER_CREATE_ATTEMPTS.get(key, 0) + 1 _USER_CREATE_ATTEMPTS[key] = count if count > _USER_CREATE_LIMIT: return {"status": "error", "message": "Слишком много попыток создания пользователей."}, 429 return None, 0 def _validate_user_fields(login: str, password: str, confirm: str, label: str) -> tuple[dict | None, int]: login = (login or "").strip() password = password or "" confirm = confirm or "" if not login: return {"status": "error", "message": f"{label}: укажите логин."}, 400 if not _valid_login(login): return { "status": "error", "message": f"{label}: логин не длиннее 64 символов.", }, 400 if not password: return {"status": "error", "message": f"{label}: укажите пароль."}, 400 if password != confirm: return {"status": "error", "message": f"{label}: пароли не совпадают."}, 400 if len(password) < _WEB_USER_PASSWORD_MIN_LEN: return { "status": "error", "message": f"{label}: пароль не короче {_WEB_USER_PASSWORD_MIN_LEN} символов.", }, 400 err_body, err_code = _reject_if_weak_login_or_password(login, password) if err_body is not None: err_body["message"] = f"{label}: {err_body.get('message', 'слабый пароль')}" return err_body, err_code return None, 0 def _hardware_platform_snapshot() -> dict: machine = platform.machine() or "unknown" system = platform.system() or "unknown" arm = is_scale_hardware_platform() return { "scale_hardware_platform": arm, "machine": machine, "system": system, "label": f"{machine} ({system})", "message": ( "Платформа ARM — можно подключить весы HX711 к Raspberry Pi." if arm else "Весы не найдены" ), } def _ping_sync_server_once(server_url: str, *, timeout: float = 5.0) -> tuple[bool, str]: url = str(server_url or "").strip().rstrip("/") if not url: return False, "Укажите адрес сервера." ping_url = f"{url}/api/sync/ping" try: req = Request(ping_url, method="GET", headers={"Accept": "application/json"}) with urlopen(req, timeout=timeout) as resp: resp.read() return True, "" except HTTPError as exc: if exc.code in (401, 403, 404, 405): return True, "" return False, f"Сервер ответил с ошибкой HTTP {exc.code}." except URLError as exc: reason = str(exc.reason or exc) low = reason.lower() if "timed out" in low or "timeout" in low: return False, "Сервер не отвечает. Проверьте адрес и что главный сервер включён в сети." if "name or service not known" in low or "nodename nor servname" in low: return False, "Сервер не найден. Проверьте имя (например komton_srv_1.local) или IP." return False, f"Не удалось подключиться: {reason}." except Exception as exc: return False, f"Не удалось подключиться: {exc}" def _ping_sync_server(server_url: str, *, timeout: float = 5.0) -> tuple[bool, str]: from app.services.sync_url_resolve import first_reachable_sync_url ok, err, _effective = first_reachable_sync_url( server_url, ping_fn=lambda u: _ping_sync_server_once(u, timeout=timeout), ) return ok, err def _ping_sync_server_with_effective( server_url: str, *, timeout: float = 5.0 ) -> tuple[bool, str, str]: from app.services.sync_url_resolve import first_reachable_sync_url return first_reachable_sync_url( server_url, ping_fn=lambda u: _ping_sync_server_once(u, timeout=timeout), ) def _restart_local_sync_if_needed(*, async_start: bool = False) -> bool: if current_app.config.get("TESTING"): return False try: from sync_client import apply_sync_client_runtime app = current_app._get_current_object() if async_start: def _run() -> None: with app.app_context(): try: apply_sync_client_runtime(app) except Exception: logger.exception("setup: фоновый перезапуск sync_client") threading.Thread( target=_run, daemon=True, name="wesp-setup-sync-restart", ).start() return True return bool(apply_sync_client_runtime(app)) except Exception: logger.exception("setup: не удалось перезапустить sync_client") return False def _sync_progress_payload() -> dict: state = read_sync_client_state() progress: dict = {"active": False} try: from sync_client import get_initial_sync_progress_snapshot progress = get_initial_sync_progress_snapshot() except Exception: logger.debug("setup: initial sync progress unavailable", exc_info=True) return { "first_bootstrap_done": bool(state.get("first_bootstrap_done")), "initial_sync_progress": progress, "connection": _setup_connection_snapshot(), } @bp.get("/status") def setup_status(): err = _require_setup_access(allow_completed=True) if err is not None: return err install_w = get_install_and_warranty(current_app) snap = setup_snapshot(current_app) snap["first_launch_at"] = install_w.get("first_launch_at") user_count = db.session.scalar(select(func.count()).select_from(WebUser)) or 0 try: from app.services.update_health import build_health_payload health = build_health_payload(current_app) except Exception: health = {"ok": False} zootech_logins = list( db.session.scalars( select(WebUser.login).where(WebUser.is_superuser.is_(False)).order_by(WebUser.login) ).all() ) return jsonify( { "status": "success", "setup": snap, "health": health, "network": network_settings_snapshot(current_app), "sync": _sync_progress_payload(), "connection": _setup_connection_snapshot(), "users_exist": int(user_count) > 0, "zootech_users": zootech_logins, "user_rules": { "password_min_len": _WEB_USER_PASSWORD_MIN_LEN, }, "hardware": _hardware_platform_snapshot(), } ) @bp.post("/device-role") def setup_device_role(): err = _require_setup_mutation() if err is not None: return err payload = request.get_json(silent=True) or {} role = str(payload.get("device_role") or payload.get("role") or "").strip().lower() if role not in ("server", "client"): return jsonify( {"status": "error", "message": "device_role: server или client"}, ), 400 conn = _effective_sync_connection(current_app) if conn.get("role_locked_by_env") and role in ("server", "client"): env_role = str(conn.get("env_role") or conn.get("role") or "").strip().lower() if env_role and env_role != role: return jsonify( { "status": "error", "message": "Роль задана в WESP_SYNC_ROLE — измените окружение или выберите совпадающую роль.", } ), 400 updates: Dict[str, Any] = {} if role == "server": updates["role"] = "server" updates["server_url"] = "" elif role == "client": updates["role"] = "client" if updates: try: write_sync_client_state(updates) except ValueError as exc: return jsonify({"status": "error", "message": str(exc)}), 400 if role == "server" and not current_app.config.get("TESTING"): try: from sync_client import stop_sync_client stop_sync_client() except Exception: pass # Sync перезапускается на шаге «Подключение к серверу» (async). Здесь не трогаем: # при role=client и сохранённом server_url register() блокирует HTTP-ответ на таймауты. write_install_state(current_app, {"device_role": role}) mark_step_completed(current_app, "device") return jsonify( { "status": "success", "device_role": role, "connection": _setup_connection_snapshot(), } ) @bp.post("/network") def setup_network(): err = _require_setup_mutation() if err is not None: return err payload = request.get_json(silent=True) or {} snap = network_settings_snapshot(current_app) updates: dict = {} if "local_hostname" in payload: if snap.get("local_hostname_locked_by_env"): return jsonify( {"status": "error", "message": "local_hostname задан в окружении."}, ), 400 ok, host_or_err = validate_local_hostname(payload.get("local_hostname")) if not ok: return jsonify({"status": "error", "message": host_or_err}), 400 updates["local_hostname"] = host_or_err if not snap.get("public_base_url_locked_by_env"): _, listen_port = parse_wesp_listen(str(current_app.config.get("WESP_LISTEN") or "")) updates["public_base_url"] = build_public_url_from_hostname( updates["local_hostname"], listen_port, ) if not snap.get("mdns_enabled_locked_by_env"): updates["mdns_enabled"] = True elif "public_base_url" in payload: if snap.get("public_base_url_locked_by_env"): return jsonify( {"status": "error", "message": "public_base_url задан в окружении."}, ), 400 url = str(payload.get("public_base_url") or "").strip().rstrip("/") if url and not (url.startswith("http://") or url.startswith("https://")): return jsonify( {"status": "error", "message": "Публичный URL: пусто или http(s)://…"}, ), 400 updates["public_base_url"] = url if "mdns_enabled" in payload: if snap.get("mdns_enabled_locked_by_env"): return jsonify( {"status": "error", "message": "mdns_enabled задан в окружении."}, ), 400 updates["mdns_enabled"] = coerce_security_bool(payload.get("mdns_enabled")) if not updates: return jsonify( {"status": "error", "message": "Передайте local_hostname, public_base_url и/или mdns_enabled."}, ), 400 write_network_settings(updates) apply_network_settings_to_app(current_app) if not current_app.config.get("TESTING"): try: if any(k in updates for k in ("mdns_enabled", "local_hostname")): reload_mdns(current_app) except Exception as exc: logger.warning("setup network: mDNS reload failed: %s", exc) mark_step_completed(current_app, "network") out = network_settings_snapshot(current_app) out["settings_path"] = str(current_app.config.get("DATA_DIR", "")) return jsonify({"status": "success", "network": out}) @bp.post("/sync") def setup_sync(): err = _require_setup_mutation() if err is not None: return err payload = request.get_json(silent=True) or {} conn = _effective_sync_connection(current_app) updates: dict = {"role": "client"} offline = bool(payload.get("offline")) if conn.get("server_url_locked_by_env"): if "server_url" in payload and str(payload.get("server_url") or "").strip(): return jsonify( {"status": "error", "message": "server_url задан в WESP_SYNC_SERVER_URL."}, ), 400 elif "server_url" in payload: raw = str(payload.get("server_url") or "").strip().rstrip("/") if raw and not (raw.startswith("http://") or raw.startswith("https://")): return jsonify( {"status": "error", "message": "Адрес сервера должен начинаться с http:// или https://"}, ), 400 updates["server_url"] = raw server_url_check = str(updates.get("server_url") or conn.get("server_url") or "").strip().rstrip("/") if not offline and server_url_check and not current_app.config.get("TESTING"): reachable, ping_error, effective_url = _ping_sync_server_with_effective( server_url_check, timeout=5.0 ) if not reachable: return jsonify( { "status": "error", "reachable": False, "message": ping_error, "server_url": server_url_check, } ), 502 if effective_url and effective_url != server_url_check: updates["server_url"] = effective_url logger.info( "setup: имя %s не резолвится — сохранён адрес по IP %s", server_url_check, effective_url, ) if "client_id" in payload: cid = str(payload.get("client_id") or "").strip() if cid: try: uuid.UUID(cid) except ValueError: return jsonify( {"status": "error", "message": "client_id: ожидается UUID."}, ), 400 else: cid = str(uuid.uuid4()) updates["client_id"] = cid elif not read_sync_client_state().get("client_id"): updates["client_id"] = str(uuid.uuid4()) if "client_name" in payload: name = str(payload.get("client_name") or "").strip() if len(name) > 200: return jsonify( {"status": "error", "message": "Название терминала: не длиннее 200 символов."}, ), 400 if name: updates["client_name"] = name if "client_name" not in updates and not read_sync_client_state().get("client_name"): updates["client_name"] = _default_setup_client_name() write_sync_client_state(updates) reloaded = _restart_local_sync_if_needed(async_start=True) mark_step_completed(current_app, "sync") write_install_state( current_app, {"sync_server_connected": (not offline and bool(server_url_check))}, ) state = read_sync_client_state() return jsonify( { "status": "success", "sync_reloaded": reloaded, "offline": offline, "reachable": not offline and bool(server_url_check), "client_id": state.get("client_id"), "connection": _setup_connection_snapshot(), } ) @bp.post("/sync/test") def setup_sync_test(): err = _require_setup_access(allow_completed=True) if err is not None: return err payload = request.get_json(silent=True) or {} server_url = str(payload.get("server_url") or "").strip().rstrip("/") if not server_url: conn = _effective_sync_connection(current_app) server_url = str(conn.get("server_url") or "").strip().rstrip("/") if not server_url: return jsonify({"status": "error", "message": "Укажите адрес сервера."}), 400 reachable, ping_error, effective_url = _ping_sync_server_with_effective( server_url, timeout=5.0 ) ping_url = f"{(effective_url or server_url)}/api/sync/ping" if not reachable: return jsonify( { "status": "error", "reachable": False, "message": ping_error, "ping_url": ping_url, } ), 502 return jsonify( { "status": "success", "reachable": True, "ping_url": ping_url, "effective_server_url": effective_url if effective_url != server_url else None, } ) @bp.get("/sync/progress") def setup_sync_progress(): err = _require_setup_access(allow_completed=True) if err is not None: return err return jsonify({"status": "success", **_sync_progress_payload()}) @bp.post("/users") def setup_users(): err = _require_setup_mutation() if err is not None: return err rate_err, rate_code = _rate_limit_user_create() if rate_err is not None: return jsonify(rate_err), rate_code payload = request.get_json(silent=True) or {} zootech = payload.get("zootech") if isinstance(payload.get("zootech"), dict) else payload err_body, err_code = _validate_user_fields( zootech.get("login"), zootech.get("password"), zootech.get("confirm") or zootech.get("password_confirm"), "Зоотехник", ) if err_body is not None: return jsonify(err_body), err_code zootech_login = zootech.get("login", "").strip() env_login, _ = load_credentials() if zootech_login.lower() == env_login.strip().lower(): return jsonify( { "status": "error", "message": "Логин зоотехника не должен совпадать с учётной записью администратора.", }, ), 400 _ensure_default_superuser() existing = db.session.execute( select(WebUser).where(WebUser.login == zootech_login) ).scalar_one_or_none() try: if existing is not None: if existing.is_superuser: return jsonify( { "status": "error", "message": "Этот логин занят учётной записью администратора.", }, ), 400 existing.password_hash = generate_password_hash(zootech["password"]) db.session.commit() action = "updated" message = f"Пароль зоотехника «{zootech_login}» обновлён." else: user = WebUser( login=zootech_login, password_hash=generate_password_hash(zootech["password"]), is_superuser=False, ) db.session.add(user) db.session.commit() action = "created" message = "Создана учётная запись зоотехника." except IntegrityError: db.session.rollback() return jsonify( {"status": "error", "message": "Не удалось сохранить пользователя (дубликат логина?)."}, ), 409 mark_step_completed(current_app, "users") return jsonify( { "status": "success", "action": action, "login": zootech_login, "message": message, } ) @bp.post("/hardware/check") def setup_hardware_check(): err = _require_setup_access(allow_completed=True) if err is not None: return err payload = build_hardware_status(current_app) scales = payload.get("scales") or {} platform_info = _hardware_platform_snapshot() ok = bool(scales.get("available")) and not scales.get("error") weight = scales.get("weight_kg") return jsonify( { "status": "success", "ok": ok, "platform": platform_info, "hardware": { "simulation_mode": scales.get("simulation_mode"), "current_weight_kg": weight, "available": scales.get("available"), "error": scales.get("error"), }, } ) @bp.post("/kiosk/prepare") def setup_kiosk_prepare(): err = _require_setup_mutation() if err is not None: return err from datetime import datetime device_id = (request.cookies.get("wesp_kiosk_device_id") or "").strip() if not device_id: device_id = str(uuid.uuid4()) row = db.session.execute( select(KioskDevice).where(KioskDevice.id == device_id) ).scalar_one_or_none() if row is None: row = KioskDevice( id=device_id, display_name=f"terminal-{device_id[:8]}", status="pending", last_ip=request.remote_addr, last_seen_at=datetime.utcnow(), ) db.session.add(row) else: row.last_ip = request.remote_addr row.last_seen_at = datetime.utcnow() db.session.commit() response = make_response( jsonify({"status": "success", "device_id": device_id, "paired": row.status == "active"}) ) set_kiosk_device_cookie(response, device_id) return response @bp.post("/complete") def setup_complete(): err = _require_setup_mutation() if err is not None: return err state = read_install_state(current_app) device_role = state.get("device_role") if not device_role: return jsonify({"status": "error", "message": "Сначала выберите тип устройства."}), 400 if device_role == "server": has_users = db.session.scalar(select(WebUser.id).limit(1)) is not None if not has_users: return jsonify( {"status": "error", "message": "Создайте учётную запись зоотехника."}, ), 400 write_sync_client_state({"role": "server", "server_url": ""}) if not current_app.config.get("TESTING"): try: from sync_client import stop_sync_client stop_sync_client() except Exception: pass mark_step_completed(current_app, "done") final = mark_setup_complete(current_app) return jsonify( { "status": "success", "setup": setup_snapshot(current_app), "redirect": setup_complete_redirect(device_role), "message": "Первоначальная настройка завершена.", } ) @bp.post("/reset") @require_superuser def setup_reset(): """Только флаг мастера (БД не трогает). Для полного сброса — POST /api/setup/factory-reset.""" reset_setup(current_app) return jsonify( { "status": "success", "message": "Флаг setup_completed сброшен. Откройте /setup для повторной настройки.", "setup": setup_snapshot(current_app), } ) @bp.post("/factory-reset") @require_superuser def setup_factory_reset(): """Удаляет БД, JSON в data/, логи; sync по умолчанию role=server. Нужен перезапуск процесса.""" from app.services.factory_reset import ( factory_reset_app, validate_factory_reset_request, ) payload = request.get_json(silent=True) or {} ok, err, remove_logs, safety_backup = validate_factory_reset_request(payload) if not ok: return jsonify({"status": "error", "message": err}), 400 report = factory_reset_app( current_app, remove_logs=remove_logs, safety_sqlite_backup=safety_backup, ) code = 200 if report.get("ok") else 500 return jsonify({"status": "success" if report.get("ok") else "error", **report}), code