Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
+781
View File
@@ -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