782 lines
27 KiB
Python
782 lines
27 KiB
Python
"""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
|