@@ -0,0 +1,781 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user