Files
site/WESP_REL/app/routes/admin.py
T
2026-07-17 12:57:18 +03:00

3290 lines
127 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import datetime
import logging
import os
import re
import threading
import time
import sqlite3
import uuid
from io import BytesIO
from pathlib import Path
from urllib.parse import urlparse
from flask import Blueprint, current_app, jsonify, request, send_file, session
from sqlalchemy import func, or_, select
from sqlalchemy.engine.url import make_url
from werkzeug.security import generate_password_hash
from app import db
from app.services.lan_network import parse_wesp_listen
from app.services.network_settings_service import network_settings_snapshot
from app.services.mdns_service import reload_mdns
from app.services.auto_update_settings_service import (
apply_auto_update_admin_patch,
public_auto_update_settings_payload,
)
from app.services.gitea_secrets_file import patch_gitea_secrets_file
from app.models import (
Component,
FeedDispenser,
FeedingPeriod,
Ingredient,
Recipe,
SyncClient,
SyncQueue,
WebUser,
)
from app.routes.auth import _ensure_default_superuser
from app.routes.auth_decorators import require_superuser
from app.services.admin_dashboard_service import (
build_sqlite_backup_zip,
get_install_and_warranty,
restore_sqlite_from_zip,
run_service_command,
sqlite_bind_paths,
tail_text_file,
)
from app.services.client_log_upload import (
build_uploaded_logs_index,
client_logs_root_resolved,
client_upload_log_dir,
latest_uploaded_log_file,
resolve_uploaded_client_log_file,
)
from app.services.admin_sqlite_browser import (
build_cascade_delete_plan,
build_tree_payload,
execute_cascade_delete,
fetch_table_page,
is_safe_sql_identifier,
list_user_tables,
sqlite_connection_for_bind,
summarize_delete_plan,
update_row_cells,
)
from app.services.admin_hardware_log import maybe_append_hardware_sample, read_hardware_history
from app.services.admin_peripheral_monitor import (
_effective_simulation_mode,
build_hardware_status,
clear_peripheral_events,
read_peripheral_events,
)
from app.services.admin_activity_feed import build_activity_feed
from app.services.admin_llm_db_health import (
build_llm_db_health_block,
build_llm_db_health_payload,
)
from app.services.admin_llm_inventory_snapshot import (
build_llm_inventory_payload,
build_llm_inventory_snapshot_block,
)
from app.services.admin_llm_diagnostics import (
build_llm_diagnostics_block,
build_llm_diagnostics_payload,
merge_llm_diagnostics_into_system,
)
from app.services.admin_llm_context import (
SYSTEM_PROMPT_RU,
build_admin_llm_context,
ensure_russian_llm_messages,
)
from app.services.admin_llm_rate_limit import llm_post_rate_allow
from app.services.admin_llm_activity_log import (
append_admin_llm_activity,
read_admin_llm_activity,
)
from app.services.admin_ui_activity_log import append_admin_ui_activity
from app.services.admin_llm_orchestrator import run_llm_chat_with_tools
from app.services.local_llm_client import (
LocalLlmError,
model_in_openai_payload,
openai_chat_completion,
openai_list_models,
)
from app.services.admin_system_metrics import collect_host_hardware_overview, collect_system_metrics
from app.services.diagnostics import (
clean_target_host,
http_probe,
normalize_extra_targets,
ping_host,
tcp_probe,
trace_host,
validate_target_host,
)
from app.services.hardware import GPIOController, HX711UnavailableError, HX711Wrapper
from app.services.sync_error_display import sync_error_text_for_display
from app.services.traceroute_analyze import parse_traceroute_text
from app.services.weak_password_blocklist import (
diag_weak_hash_max_candidates,
hashed_password_matches_weak_blocklist,
login_matches_weak_blocklist,
password_meets_basic_complexity,
plaintext_in_weak_blocklist,
weak_blocklist_summary,
)
from config import (
Config,
NETWORK_SETTINGS_ENV_KEYS,
SECURITY_SETTINGS_ENV_KEYS,
_resolve_base_dir,
apply_network_settings_to_app,
apply_security_settings_to_app,
coerce_security_bool,
get_network_diagnostics_path,
get_network_settings_path,
network_env_lock_flags,
normalize_local_hostname,
read_network_diagnostics_settings,
read_security_settings,
read_sync_client_state,
resolve_effective_sync_role,
resolve_sync_server_url,
security_env_lock_flags,
validate_local_hostname,
write_network_diagnostics_settings,
write_network_settings,
write_security_settings,
write_sync_client_state,
)
bp = Blueprint("admin", __name__, url_prefix="/api/admin")
_WEB_USER_LOGIN_MAX_LEN = 64
# Согласовано с проверкой WESP_AUTH_PASSWORD в сводке безопасности (не короче 8 символов).
_WEB_USER_PASSWORD_MIN_LEN = 8
_COMPLEXITY_MSG = (
"Пароль не должен состоять только из цифр или только из букв. "
"Смешайте типы символов (например буквы и цифры, или добавьте дефис); "
"отдельные «спецсимволы» не обязательны."
)
def _reject_if_weak_login_or_password(login: str, password: str) -> tuple[dict | None, int]:
"""Создание пользователя: логин и пароль не должны совпадать со строками встроенного списка."""
if login_matches_weak_blocklist(login):
return {
"status": "error",
"message": "Логин совпадает со списком слабых паролей — задайте другой логин.",
}, 400
if plaintext_in_weak_blocklist(password):
return {
"status": "error",
"message": "Пароль совпадает со списком слабых — задайте другой пароль.",
}, 400
if not password_meets_basic_complexity(password):
return {"status": "error", "message": _COMPLEXITY_MSG}, 400
return None, 0
def _reject_if_weak_password_only(password: str) -> tuple[dict | None, int]:
"""Смена пароля: проверяем только новый пароль (логин в PATCH не меняется)."""
if plaintext_in_weak_blocklist(password):
return {
"status": "error",
"message": "Пароль совпадает со списком слабых — задайте другой пароль.",
}, 400
if not password_meets_basic_complexity(password):
return {"status": "error", "message": _COMPLEXITY_MSG}, 400
return None, 0
def _safe_db_descriptor(uri: str) -> dict:
parsed = urlparse(uri or "")
scheme = parsed.scheme or "unknown"
db_name = ""
if scheme.startswith("sqlite"):
db_name = (parsed.path or "").rsplit("/", 1)[-1]
else:
db_name = (parsed.path or "").strip("/")
return {
"engine": "sqlite" if scheme.startswith("sqlite") else scheme,
"database": db_name,
}
def _sqlite_db_bundle_size_bytes(uri: str, base_dir: str) -> int | None:
"""Суммарный размер файла SQLite и sidecar (-wal, -shm, -journal), если есть."""
raw = (uri or "").strip()
if not raw:
return None
try:
url = make_url(raw)
except Exception:
return None
if url.drivername != "sqlite":
return None
db = url.database
if not db or db == ":memory:":
return None
p = Path(db)
if not p.is_absolute():
p = Path(base_dir) / p
p = p.resolve()
if not p.is_file():
return None
total = 0
for part in (p, Path(str(p) + "-wal"), Path(str(p) + "-shm"), Path(str(p) + "-journal")):
try:
if part.is_file():
total += int(part.stat().st_size)
except OSError:
pass
return int(total)
def _database_descriptor_with_size(uri: str, base_dir: str) -> dict:
d = _safe_db_descriptor(uri)
sz = _sqlite_db_bundle_size_bytes(uri, base_dir)
if sz is not None:
d["size_bytes"] = sz
return d
def _recent_queue_sync_display_errors(limit: int = 25) -> list[dict]:
"""Последние failed-задачи с текстом ошибки, кроме сетевых сбоев до сервера."""
order_col = func.coalesce(
SyncQueue.processed_at,
SyncQueue.updated_at,
SyncQueue.created_at,
)
rows = db.session.execute(
select(SyncQueue)
.where(SyncQueue.is_deleted.is_(False))
.where(SyncQueue.status == "failed")
.order_by(order_col.desc())
.limit(400)
).scalars().all()
out: list[dict] = []
for r in rows:
text = sync_error_text_for_display(r.error_message)
if not text:
continue
ts = r.processed_at or r.updated_at or r.created_at
err = text if len(text) <= 4000 else text[:4000] + "…"
out.append(
{
"task_id": r.id,
"table_name": r.table_name,
"record_id": r.record_id,
"action": r.action,
"error": err,
"at": ts.isoformat() if ts else None,
}
)
if len(out) >= limit:
break
return out
def _effective_sync_connection(app) -> dict:
"""Роль и URL сервера с учётом env и sync_client_state.json (как в sync_client.py)."""
state = read_sync_client_state()
role_env = os.getenv("WESP_SYNC_ROLE", "").strip()
url_env = os.getenv("WESP_SYNC_SERVER_URL", "").strip()
role, role_locked = resolve_effective_sync_role(state=state, config_defaults=app.config)
server_url, explicit = resolve_sync_server_url(state=state, config_defaults=app.config)
return {
"role": role,
"server_url": server_url,
"sync_server_url_explicit": bool(explicit),
"role_locked_by_env": bool(role_locked),
"server_url_locked_by_env": bool(url_env),
"env_role": role_env or None,
"env_server_url": url_env or None,
"sync_client_autostart": bool(app.config.get("SYNC_CLIENT_AUTOSTART", True)),
}
def _modern_password_hash_scheme(password_hash: str) -> bool:
h = (password_hash or "").strip()
return h.startswith("pbkdf2:") or h.startswith("scrypt:")
def _diagnostics_security_checks(app) -> list[dict]:
cfg = app.config
testing = bool(cfg.get("TESTING"))
debug_on = bool(cfg.get("DEBUG"))
relaxed = testing or debug_on
secret = str(cfg.get("SECRET_KEY") or "").strip()
auth_login = str(cfg.get("AUTH_LOGIN") or "").strip()
auth_password = str(cfg.get("AUTH_PASSWORD") or "")
cal_public = bool(cfg.get("CALIBRATION_PUBLIC", False))
device_token = str(cfg.get("DEVICE_TOKEN") or "").strip()
mac_on = bool(cfg.get("MAC_LOCK_ENABLED", False))
macs = cfg.get("ALLOWED_MAC_ADDRESSES") or []
mac_list = macs if isinstance(macs, list) else []
kiosk_cookie_days = int(cfg.get("KIOSK_AUTH_COOKIE_DAYS", 0) or 0)
kiosk_ttl = int(cfg.get("KIOSK_PAIR_TOKEN_TTL_SECONDS", 0) or 0)
locks = security_env_lock_flags()
lock_count = sum(1 for v in locks.values() if v)
sec_file = read_security_settings()
has_sec_file = bool(sec_file)
weak_login_users = 0
weak_password_hash_users = 0
legacy_hash_users = 0
hash_diag_limit = diag_weak_hash_max_candidates()
blocklist_detail = weak_blocklist_summary()
blocklist_detail += f" Проверка сохранённых паролей по хешу — не более {hash_diag_limit} первых кандидатов из списка."
try:
users = db.session.execute(select(WebUser)).scalars().all()
for u in users:
if not _modern_password_hash_scheme(u.password_hash):
legacy_hash_users += 1
if login_matches_weak_blocklist(u.login):
weak_login_users += 1
if hashed_password_matches_weak_blocklist(u.password_hash):
weak_password_hash_users += 1
except Exception:
users = []
factory_auth = auth_login == "admin" and auth_password == "admin"
auth_pw_weak = len(str(auth_password).strip()) < 8
if not auth_pw_weak and plaintext_in_weak_blocklist(auth_password):
auth_pw_weak = True
if not auth_pw_weak and not password_meets_basic_complexity(auth_password):
auth_pw_weak = True
token_short_public = cal_public and len(device_token) < 16
token_trivial_public = bool(
cal_public and device_token and plaintext_in_weak_blocklist(device_token)
)
checks: list[dict] = [
{
"id": "secret_key_non_default",
"title": "SECRET_KEY не заводской",
"ok": bool(secret) and secret != "change-me-in-production",
"detail": "Замените значение по умолчанию (подпись сессий Flask).",
},
{
"id": "secret_key_min_length",
"title": "SECRET_KEY достаточной длины",
"ok": len(secret) >= 24,
"detail": "Рекомендуется не короче 24 символов случайных данных.",
},
{
"id": "secret_key_from_env",
"title": "SECRET_KEY из окружения",
"ok": relaxed or ("WESP_SECRET_KEY" in os.environ),
"detail": "В бою задайте WESP_SECRET_KEY, не храните секрет в репозитории.",
},
{
"id": "debug_disabled",
"title": "Режим DEBUG выключен",
"ok": relaxed or (not debug_on),
"detail": "DEBUG в продакшене раскрывает трассировки и опасен снаружи.",
},
{
"id": "weak_credential_blocklist",
"title": "Список слабых логинов и паролей",
"ok": True,
"detail": blocklist_detail,
},
{
"id": "factory_auth_credentials",
"title": "Не используются логин/пароль admin/admin",
"ok": not factory_auth,
"detail": "Смените WESP_AUTH_LOGIN / WESP_AUTH_PASSWORD для веб-входа.",
},
{
"id": "auth_password_strength",
"title": "Пароль веб-входа (env): длина, словарь, смесь букв/цифр",
"ok": not auth_pw_weak,
"detail": "Не короче 8 символов; не из списка слабых; не только цифры и не только буквы (спецсимволы не обязательны).",
},
{
"id": "calibration_public_requires_token",
"title": "Публичная калибровка и токен устройства",
"ok": (not cal_public) or bool(device_token),
"detail": "При CALIBRATION_PUBLIC нужен непустой DEVICE_TOKEN.",
},
{
"id": "device_token_length_public",
"title": "DEVICE_TOKEN достаточно длинный (при публичной калибровке)",
"ok": (not cal_public) or (not token_short_public),
"detail": "Для публичного API рекомендуется токен не короче 16 символов.",
},
{
"id": "device_token_not_obvious",
"title": "DEVICE_TOKEN не похож на простой пароль",
"ok": (not cal_public) or (not token_trivial_public),
"detail": "Избегайте словарных токенов вроде password / 12345678.",
},
{
"id": "device_token_env_when_public",
"title": "DEVICE_TOKEN задан через окружение (при публичной калибровке)",
"ok": (not cal_public) or relaxed or ("WESP_DEVICE_TOKEN" in os.environ),
"detail": "Токен из WESP_DEVICE_TOKEN сложнее случайно закоммитить, чем только файл.",
},
{
"id": "kiosk_pair_guard",
"title": "Киоск только для сопряжённых",
"ok": bool(cfg.get("KIOSK_ENFORCE_PAIRED_ONLY", True)),
"detail": "KIOSK_ENFORCE_PAIRED_ONLY должен быть включён в рабочем контуре.",
},
{
"id": "kiosk_pair_ttl_sane",
"title": "TTL токена сопряжения киоска в разумных пределах",
"ok": 30 <= kiosk_ttl <= 86400,
"detail": "Ожидается 3086400 с (см. WESP_KIOSK_PAIR_TOKEN_TTL_SECONDS).",
},
{
"id": "kiosk_cookie_not_excessive",
"title": "Срок cookie киоска не чрезмерный",
"ok": kiosk_cookie_days <= 3650,
"detail": "Сверх ~10 лет увеличивает риск при краже cookie; сократите WESP_KIOSK_AUTH_COOKIE_DAYS.",
},
{
"id": "mac_lock_has_allowlist",
"title": "MAC-lock с непустым списком (если включён)",
"ok": (not mac_on) or bool(mac_list),
"detail": "При MAC_LOCK_ENABLED нужны ALLOWED_MAC_ADDRESSES.",
},
{
"id": "session_cookie_secure_prod",
"title": "SESSION_COOKIE_SECURE при боевом профиле",
"ok": relaxed or bool(cfg.get("SESSION_COOKIE_SECURE")),
"detail": "За HTTPS-прокси включите SESSION_COOKIE_SECURE (или настройте прокси).",
},
{
"id": "web_users_password_hash_modern",
"title": "Хеши паролей пользователей (pbkdf2/scrypt)",
"ok": legacy_hash_users == 0,
"detail": "Устаревшие схемы хеширования не найдены."
if legacy_hash_users == 0
else f"Учёток с нестандартной схемой хеша: {legacy_hash_users}.",
},
{
"id": "web_users_login_not_weak_blocklist",
"title": "Логины пользователей БД не из списка слабых",
"ok": weak_login_users == 0,
"detail": "Нет логинов из встроенного списка."
if weak_login_users == 0
else f"Учёток с логином из списка: {weak_login_users}.",
},
{
"id": "web_users_password_not_in_weak_blocklist",
"title": "Пароли пользователей БД (сверка хеша со списком)",
"ok": weak_password_hash_users == 0,
"detail": "Совпадений с первыми кандидатами списка нет."
if weak_password_hash_users == 0
else f"Учёток с паролем из первых {hash_diag_limit} кандидатов: {weak_password_hash_users}.",
},
{
"id": "superuser_exists",
"title": "Есть хотя бы один суперпользователь",
"ok": any(getattr(u, "is_superuser", False) for u in users),
"detail": "Нужен доступ суперпользователя к админ-панели.",
},
{
"id": "security_settings_file_or_env",
"title": "Локальные настройки безопасности или блокировки env",
"ok": has_sec_file or lock_count > 0 or relaxed,
"detail": "Файл data/wesp_security_settings.json или переменные WESP_* для чувствительных полей.",
},
{
"id": "security_env_locks_summary",
"title": "Переменные окружения для полей безопасности",
"ok": True,
"detail": f"Заблокировано env-переменными: {lock_count} из {len(locks)} (приоритет над файлом и UI).",
},
]
return checks
def _read_diagnostics_extra_targets() -> list[dict]:
data = read_network_diagnostics_settings()
raw = data.get("extra_targets")
try:
return normalize_extra_targets(raw)
except ValueError:
return []
def _resolve_primary_diagnostics_targets(app) -> dict:
conn = _effective_sync_connection(app)
role = str(conn.get("role") or "server").strip().lower()
out: list[dict] = []
warnings: list[str] = []
if role == "client":
if not bool(conn.get("sync_server_url_explicit")):
warnings.append(
"Роль client: адрес главного узла не задан — фоновый sync не запускается. "
"Укажите URL в настройках синхронизации или WESP_SYNC_SERVER_URL."
)
host = clean_target_host(conn.get("server_url"))
if not host:
warnings.append("Не задан адрес sync-сервера: заполните URL в настройках синхронизации.")
else:
out.append(
{
"kind": "sync_server",
"title": "Главный сервер синхронизации",
"host": host,
"meta": {"source": "sync.connection.server_url"},
}
)
return {"node_role": "client", "targets": out, "warnings": warnings}
clients = (
db.session.execute(
select(SyncClient)
.where(SyncClient.is_deleted.is_(False))
.order_by(SyncClient.last_seen.desc().nullslast(), SyncClient.created_at.desc())
)
.scalars()
.all()
)
if not clients:
warnings.append("Клиенты не зарегистрированы в реестре синхронизации.")
for client in clients:
raw_host = (client.ip_address or "").strip()
if not raw_host:
warnings.append(
f"Клиент {client.client_name or client.node_id or '—'}: IP не заполнен, трассировка пропущена."
)
continue
host = clean_target_host(raw_host)
if not host:
warnings.append(
f"Клиент {client.client_name or client.node_id or '—'}: IP некорректен ({raw_host})."
)
continue
out.append(
{
"kind": "sync_client",
"title": client.client_name or client.node_id or host,
"host": host,
"meta": {
"node_id": client.node_id,
"last_seen": client.last_seen.isoformat() if client.last_seen else None,
"status": client.status,
},
}
)
return {"node_role": "server", "targets": out, "warnings": warnings}
def _diagnostics_node_hint(node_role: str) -> str:
if node_role == "client":
return "Этот узел работает как клиент: проверяем дорогу до главного sync-сервера."
return "Этот узел работает как сервер: проверяем дорогу до зарегистрированных клиентов."
def _diagnostics_hardware_block(app) -> dict:
from app.services.admin_peripheral_monitor import diagnostics_hardware_block
return diagnostics_hardware_block(app)
def _build_diagnostics_report(app) -> dict:
primary = _resolve_primary_diagnostics_targets(app)
return {
"status": "success",
"generated_at": datetime.utcnow().isoformat(),
"node_role": primary.get("node_role"),
"node_hint": _diagnostics_node_hint(primary.get("node_role") or "server"),
"primary_targets": primary.get("targets") or [],
"warnings": primary.get("warnings") or [],
"extra_targets": _read_diagnostics_extra_targets(),
"security_checks": _diagnostics_security_checks(app),
"hardware": _diagnostics_hardware_block(app),
"paths": {"network_settings_path": str(get_network_diagnostics_path())},
}
def _default_tcp_probe_port() -> int:
_, port = parse_wesp_listen(str(current_app.config.get("WESP_LISTEN") or ""))
return port
def _probe_target_host(
host: str,
*,
run_trace: bool = False,
timeout_sec: int = 3,
prefer_mtr: bool = False,
tcp_port: int | None = None,
) -> dict:
port = int(tcp_port if tcp_port is not None else _default_tcp_probe_port())
safe_host = validate_target_host(host)
ping = ping_host(safe_host, timeout_sec=max(1, timeout_sec))
tcp = tcp_probe(safe_host, int(tcp_port), timeout_sec=max(1, timeout_sec))
trace = None
if run_trace:
trace = trace_host(safe_host, timeout_sec=max(4, timeout_sec + 2), prefer_mtr=prefer_mtr)
return {"host": safe_host, "ping": ping, "tcp": tcp, "trace": trace}
def _security_env_lock_error(field: str) -> str:
env = SECURITY_SETTINGS_ENV_KEYS.get(field, "WESP_…")
return f"Параметр задан в окружении ({env}), снимите переменную, чтобы менять из админки."
def _security_summary_payload(app) -> dict:
"""Поля безопасности для админки: значения из app.config и флаги блокировки env."""
locks = security_env_lock_flags()
cfg = app.config
perm = cfg.get("PERMANENT_SESSION_LIFETIME")
try:
remember_days = max(1, int(perm.total_seconds() // 86400)) if perm else 7
except Exception:
remember_days = 7
macs = cfg.get("ALLOWED_MAC_ADDRESSES") or []
mac_str = ", ".join(str(x) for x in macs)
return {
"mac_lock_enabled": bool(cfg.get("MAC_LOCK_ENABLED", False)),
"mac_lock_enabled_locked_by_env": locks.get("mac_lock_enabled", False),
"allowed_mac_addresses": mac_str,
"allowed_mac_addresses_locked_by_env": locks.get("allowed_mac_addresses", False),
"kiosk_enforce_paired_only": bool(cfg.get("KIOSK_ENFORCE_PAIRED_ONLY", True)),
"kiosk_enforce_paired_only_locked_by_env": locks.get(
"kiosk_enforce_paired_only", False
),
"calibration_public": bool(cfg.get("CALIBRATION_PUBLIC", False)),
"calibration_public_locked_by_env": locks.get("calibration_public", False),
"device_token_configured": bool(str(cfg.get("DEVICE_TOKEN") or "").strip()),
"device_token_locked_by_env": locks.get("device_token", False),
"device_token_header": str(cfg.get("DEVICE_TOKEN_HEADER") or "X-Device-Token"),
"device_token_header_locked_by_env": locks.get("device_token_header", False),
"kiosk_pair_token_ttl_seconds": int(cfg.get("KIOSK_PAIR_TOKEN_TTL_SECONDS", 300)),
"kiosk_pair_token_ttl_seconds_locked_by_env": locks.get(
"kiosk_pair_token_ttl_seconds", False
),
"kiosk_auth_cookie_days": int(cfg.get("KIOSK_AUTH_COOKIE_DAYS", 18250)),
"kiosk_auth_cookie_days_locked_by_env": locks.get("kiosk_auth_cookie_days", False),
"kiosk_public_base_url": str(cfg.get("KIOSK_PUBLIC_BASE_URL") or "").strip(),
"kiosk_public_base_url_locked_by_env": locks.get("kiosk_public_base_url", False),
"session_remember_days": remember_days,
"session_remember_days_locked_by_env": locks.get("session_remember_days", False),
"llm_autostart": bool(cfg.get("WESP_LLM_AUTOSTART", True)),
"llm_autostart_locked_by_env": locks.get("llm_autostart", False),
"admin_llm_enabled": bool(cfg.get("WESP_ADMIN_LLM_ENABLED", True)),
"admin_llm_enabled_locked_by_env": locks.get("admin_llm_enabled", False),
"llm_chat_db_context": bool(cfg.get("WESP_LLM_CHAT_INCLUDE_DB_CONTEXT", False)),
"llm_chat_db_context_locked_by_env": locks.get("llm_chat_db_context", False),
"llm_tools_enabled": bool(cfg.get("WESP_LLM_TOOLS_ENABLED", True)),
"llm_tools_enabled_locked_by_env": locks.get("llm_tools_enabled", False),
}
@bp.patch("/security-settings")
@require_superuser
def admin_security_settings_patch():
"""
Сохраняет настройки безопасности/киоска/LLM в data/wesp_security_settings.json
и обновляет текущий процесс Flask. Переменные WESP_* в окружении имеют приоритет.
Поля llm_autostart, admin_llm_enabled, llm_chat_db_context, llm_tools_enabled (см. SECURITY_SETTINGS_ENV_KEYS).
"""
payload = request.get_json(silent=True) or {}
locks = security_env_lock_flags()
updates: dict = {}
def locked(field: str) -> bool:
return bool(locks.get(field))
if "mac_lock_enabled" in payload:
if locked("mac_lock_enabled"):
return jsonify({"status": "error", "message": _security_env_lock_error("mac_lock_enabled")}), 400
updates["mac_lock_enabled"] = coerce_security_bool(payload.get("mac_lock_enabled"))
if "allowed_mac_addresses" in payload:
if locked("allowed_mac_addresses"):
return jsonify(
{"status": "error", "message": _security_env_lock_error("allowed_mac_addresses")}
), 400
raw = payload.get("allowed_mac_addresses")
updates["allowed_mac_addresses"] = raw if isinstance(raw, str) else ", ".join(
str(x) for x in (raw or []) if str(x).strip()
)
if "kiosk_enforce_paired_only" in payload:
if locked("kiosk_enforce_paired_only"):
return jsonify(
{
"status": "error",
"message": _security_env_lock_error("kiosk_enforce_paired_only"),
}
), 400
updates["kiosk_enforce_paired_only"] = coerce_security_bool(
payload.get("kiosk_enforce_paired_only")
)
if "calibration_public" in payload:
if locked("calibration_public"):
return jsonify(
{"status": "error", "message": _security_env_lock_error("calibration_public")}
), 400
updates["calibration_public"] = coerce_security_bool(payload.get("calibration_public"))
if payload.get("device_token_clear") is True:
if locked("device_token"):
return jsonify({"status": "error", "message": _security_env_lock_error("device_token")}), 400
updates["device_token"] = ""
elif "device_token" in payload and str(payload.get("device_token") or "").strip():
if locked("device_token"):
return jsonify({"status": "error", "message": _security_env_lock_error("device_token")}), 400
updates["device_token"] = str(payload.get("device_token")).strip()
if "device_token_header" in payload:
if locked("device_token_header"):
return jsonify(
{"status": "error", "message": _security_env_lock_error("device_token_header")}
), 400
h = str(payload.get("device_token_header") or "").strip()
if not h or len(h) > 128:
return jsonify(
{
"status": "error",
"message": "Заголовок токена: непустая строка до 128 символов.",
}
), 400
updates["device_token_header"] = h
if "kiosk_pair_token_ttl_seconds" in payload:
if locked("kiosk_pair_token_ttl_seconds"):
return jsonify(
{
"status": "error",
"message": _security_env_lock_error("kiosk_pair_token_ttl_seconds"),
}
), 400
try:
ttl = int(payload.get("kiosk_pair_token_ttl_seconds"))
except (TypeError, ValueError):
return jsonify(
{"status": "error", "message": "TTL токена сопряжения: целое число секунд."}
), 400
if ttl < 30 or ttl > 86400:
return jsonify(
{
"status": "error",
"message": "TTL токена сопряжения: от 30 до 86400 с.",
}
), 400
updates["kiosk_pair_token_ttl_seconds"] = ttl
if "kiosk_auth_cookie_days" in payload:
if locked("kiosk_auth_cookie_days"):
return jsonify(
{"status": "error", "message": _security_env_lock_error("kiosk_auth_cookie_days")}
), 400
try:
days = int(payload.get("kiosk_auth_cookie_days"))
except (TypeError, ValueError):
return jsonify(
{"status": "error", "message": "Срок cookie киоска: целое число дней."}
), 400
if days < 1 or days > 36500:
return jsonify(
{"status": "error", "message": "Срок cookie киоска: от 1 до 36500 дней."}
), 400
updates["kiosk_auth_cookie_days"] = days
if "kiosk_public_base_url" in payload:
if locked("kiosk_public_base_url"):
return jsonify(
{"status": "error", "message": _security_env_lock_error("kiosk_public_base_url")}
), 400
url = str(payload.get("kiosk_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["kiosk_public_base_url"] = url
if "session_remember_days" in payload:
if locked("session_remember_days"):
return jsonify(
{"status": "error", "message": _security_env_lock_error("session_remember_days")}
), 400
try:
sd = int(payload.get("session_remember_days"))
except (TypeError, ValueError):
return jsonify(
{"status": "error", "message": "Срок «Запомнить» входа: целое число дней."}
), 400
if sd < 1 or sd > 3650:
return jsonify(
{"status": "error", "message": "Срок «Запомнить»: от 1 до 3650 дней."}
), 400
updates["session_remember_days"] = sd
if "llm_autostart" in payload:
if locked("llm_autostart"):
return jsonify(
{"status": "error", "message": _security_env_lock_error("llm_autostart")}
), 400
updates["llm_autostart"] = coerce_security_bool(payload.get("llm_autostart"))
if "admin_llm_enabled" in payload:
if locked("admin_llm_enabled"):
return jsonify(
{"status": "error", "message": _security_env_lock_error("admin_llm_enabled")}
), 400
updates["admin_llm_enabled"] = coerce_security_bool(payload.get("admin_llm_enabled"))
if "llm_chat_db_context" in payload:
if locked("llm_chat_db_context"):
return jsonify(
{"status": "error", "message": _security_env_lock_error("llm_chat_db_context")}
), 400
updates["llm_chat_db_context"] = coerce_security_bool(payload.get("llm_chat_db_context"))
if "llm_tools_enabled" in payload:
if locked("llm_tools_enabled"):
return jsonify(
{"status": "error", "message": _security_env_lock_error("llm_tools_enabled")}
), 400
updates["llm_tools_enabled"] = coerce_security_bool(payload.get("llm_tools_enabled"))
if not updates:
return jsonify(
{"status": "error", "message": "Передайте хотя бы одно настраиваемое поле."},
), 400
write_security_settings(updates)
apply_security_settings_to_app(current_app)
login = str(session.get("user_login") or "")
if "admin_llm_enabled" in updates:
on = bool(updates.get("admin_llm_enabled"))
append_admin_llm_activity(
current_app,
event="settings",
level="ok",
user_login=login,
detail="ИИ в админке включён" if on else "ИИ в админке выключен",
meta={"admin_llm_enabled": on},
)
if "llm_autostart" in updates:
on = bool(updates.get("llm_autostart"))
append_admin_llm_activity(
current_app,
event="settings",
level="ok",
user_login=login,
detail="автозапуск llama-server включён"
if on
else "автозапуск llama-server выключен",
meta={"llm_autostart": on},
)
return jsonify(
{
"status": "success",
"message": "Сохранено в data/wesp_security_settings.json. Уже действует в этом процессе.",
"security": _security_summary_payload(current_app),
}
)
def _network_env_lock_error(field: str) -> str:
env = NETWORK_SETTINGS_ENV_KEYS.get(field, "WESP_…")
return f"Параметр задан в окружении ({env}), снимите переменную, чтобы менять из админки."
@bp.get("/network-settings")
@require_superuser
def admin_network_settings_get():
snap = network_settings_snapshot(current_app)
snap["settings_path"] = str(get_network_settings_path())
return jsonify({"status": "success", "network": snap})
@bp.patch("/network-settings")
@require_superuser
def admin_network_settings_patch():
payload = request.get_json(silent=True) or {}
locks = network_env_lock_flags()
updates: dict = {}
def locked(field: str) -> bool:
return bool(locks.get(field))
if "local_hostname" in payload:
if locked("local_hostname"):
return jsonify(
{"status": "error", "message": _network_env_lock_error("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 "public_base_url" in payload:
if locked("public_base_url"):
return jsonify(
{"status": "error", "message": _network_env_lock_error("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 locked("mdns_enabled"):
return jsonify(
{"status": "error", "message": _network_env_lock_error("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)
login = str(session.get("user_login") or "")
from app.services.network_activity_log import log_network_settings_saved
log_network_settings_saved(current_app, updates=updates, user_login=login)
if not current_app.config.get("TESTING"):
append_admin_ui_activity(
current_app,
text=f"Сеть: сохранены настройки ({', '.join(updates.keys())})",
level="ok",
user_login=login,
)
try:
if any(k in updates for k in ("mdns_enabled", "local_hostname")):
reload_mdns(current_app)
except Exception as exc:
logging.getLogger(__name__).warning("mDNS reload after PATCH failed: %s", exc)
snap = network_settings_snapshot(current_app)
snap["settings_path"] = str(get_network_settings_path())
return jsonify(
{
"status": "success",
"message": "Сохранено в data/wesp_network_settings.json.",
"network": snap,
}
)
@bp.patch("/sync-settings")
@require_superuser
def admin_sync_settings_patch():
"""
Сохраняет поля в data/sync_client_state.json: role, server_url, client_id, client_name,
first_bootstrap_done (и др., по мере расширения).
WESP_SYNC_ROLE / WESP_SYNC_SERVER_URL блокируют роль и URL из этой формы.
После сохранения вызывается перезапуск sync_client в процессе (без os.exec).
"""
payload = request.get_json(silent=True) or {}
updates: dict = {}
if "role" in payload:
if os.getenv("WESP_SYNC_ROLE", "").strip():
return jsonify(
{
"status": "error",
"message": "Роль задана в окружении (WESP_SYNC_ROLE), снимите её чтобы менять из админки.",
}
), 400
role = str(payload.get("role") or "").strip().lower()
if role not in ("server", "client", "other"):
return jsonify(
{"status": "error", "message": "Роль: server, client или other"},
), 400
updates["role"] = role
if "server_url" in payload:
if os.getenv("WESP_SYNC_SERVER_URL", "").strip():
return jsonify(
{
"status": "error",
"message": "URL сервера задан в окружении (WESP_SYNC_SERVER_URL).",
}
), 400
raw = str(payload.get("server_url") or "").strip()
if raw and not (raw.startswith("http://") or raw.startswith("https://")):
return jsonify(
{
"status": "error",
"message": "Адрес сервера должен начинаться с http:// или https://",
}
), 400
updates["server_url"] = raw
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
updates["client_id"] = cid
if "client_name" in payload:
name = str(payload.get("client_name") or "").strip()
if len(name) > 200:
return jsonify(
{"status": "error", "message": "client_name: не длиннее 200 символов."}
), 400
updates["client_name"] = name
if "first_bootstrap_done" in payload:
updates["first_bootstrap_done"] = bool(payload.get("first_bootstrap_done"))
if not updates:
return jsonify(
{
"status": "error",
"message": "Передайте role, server_url, client_id, client_name и/или first_bootstrap_done",
},
), 400
if str(updates.get("role") or "").strip().lower() == "server":
updates["server_url"] = ""
try:
write_sync_client_state(updates)
except ValueError as exc:
return jsonify({"status": "error", "message": str(exc)}), 400
raw_state = read_sync_client_state()
sync_state_safe = {
key: value
for key, value in raw_state.items()
if key not in {"token", "auth_token", "password", "secret"}
}
sync_reloaded = False
msg = "Сохранено в data/sync_client_state.json."
if not current_app.config.get("TESTING"):
try:
from sync_client import apply_sync_client_runtime
sync_reloaded = bool(apply_sync_client_runtime(current_app))
if sync_reloaded:
msg += " Синхронизация в этом процессе перезапущена с новыми настройками."
elif not current_app.config.get("SYNC_CLIENT_AUTOSTART", True):
msg += " Фоновый sync отключён (SYNC_CLIENT_AUTOSTART) — перезапустите службу, чтобы подхватить настройки."
except Exception:
logging.getLogger("app").exception(
"PATCH /api/admin/sync-settings: не удалось перезапустить sync_client в процессе"
)
msg += " Файл сохранён; при необходимости перезапустите службу WESP вручную."
else:
msg += " В тестах перезапуск sync не выполняется."
return jsonify(
{
"status": "success",
"connection": _effective_sync_connection(current_app),
"state": sync_state_safe,
"restart_scheduled": False,
"sync_reloaded": sync_reloaded,
"message": msg,
}
)
@bp.patch("/sync-clients/<string:node_id>/ip")
@require_superuser
def admin_sync_client_ip_patch(node_id: str):
payload = request.get_json(silent=True) or {}
raw = str(payload.get("ip_address") or "").strip()
host = None
if raw:
try:
host = validate_target_host(raw)
except ValueError as exc:
return jsonify({"status": "error", "message": str(exc)}), 400
row = db.session.execute(
select(SyncClient).where(SyncClient.node_id == node_id, SyncClient.is_deleted.is_(False))
).scalar_one_or_none()
if row is None:
return jsonify({"status": "error", "message": "Клиент не найден"}), 404
row.ip_address = host
db.session.commit()
return jsonify(
{
"status": "success",
"message": "IP клиента обновлён",
"client": {
"node_id": row.node_id,
"client_name": row.client_name,
"ip_address": row.ip_address,
"last_seen": row.last_seen.isoformat() if row.last_seen else None,
},
}
)
@bp.get("/diagnostics/settings")
@require_superuser
def admin_diagnostics_settings_get():
return jsonify(
{
"status": "success",
"extra_targets": _read_diagnostics_extra_targets(),
"path": str(get_network_diagnostics_path()),
}
)
@bp.patch("/diagnostics/settings")
@require_superuser
def admin_diagnostics_settings_patch():
payload = request.get_json(silent=True) or {}
try:
extra_targets = normalize_extra_targets(payload.get("extra_targets"))
except ValueError as exc:
return jsonify({"status": "error", "message": str(exc)}), 400
write_network_diagnostics_settings({"extra_targets": extra_targets})
return jsonify(
{
"status": "success",
"message": "Список контрольных точек сохранён.",
"extra_targets": extra_targets,
"path": str(get_network_diagnostics_path()),
}
)
@bp.get("/diagnostics/report")
@require_superuser
def admin_diagnostics_report():
return jsonify(_build_diagnostics_report(current_app))
@bp.post("/diagnostics/network-run")
@require_superuser
def admin_diagnostics_network_run():
payload = request.get_json(silent=True) or {}
report = _build_diagnostics_report(current_app)
scope = str(payload.get("scope") or "all").strip().lower()
run_trace = bool(payload.get("run_trace", False))
try:
timeout_sec = max(1, min(int(payload.get("timeout_sec", 3)), 20))
except (TypeError, ValueError):
timeout_sec = 3
prefer_mtr = bool(payload.get("prefer_mtr", False))
try:
tcp_port = int(payload.get("tcp_port", _default_tcp_probe_port()))
except (TypeError, ValueError):
tcp_port = _default_tcp_probe_port()
if tcp_port < 1 or tcp_port > 65535:
return jsonify({"status": "error", "message": "tcp_port должен быть от 1 до 65535"}), 400
target_items: list[dict] = []
if scope in {"primary", "all"}:
target_items.extend(report.get("primary_targets") or [])
if scope in {"extra", "all"}:
target_items.extend(report.get("extra_targets") or [])
if scope == "custom":
raw_custom = payload.get("custom_targets")
if not isinstance(raw_custom, list):
return jsonify(
{"status": "error", "message": "Для scope=custom передайте массив custom_targets"}
), 400
for host in raw_custom:
if str(host or "").strip():
target_items.append({"title": str(host).strip(), "host": str(host).strip()})
results: list[dict] = []
for item in target_items[:80]:
try:
host = validate_target_host(item.get("host"))
except ValueError as exc:
results.append(
{
"title": str(item.get("title") or item.get("host") or "—"),
"host": str(item.get("host") or ""),
"ok": False,
"error": str(exc),
}
)
continue
probes = _probe_target_host(
host,
run_trace=run_trace,
timeout_sec=timeout_sec,
prefer_mtr=prefer_mtr,
tcp_port=tcp_port,
)
ping_ok = bool((probes.get("ping") or {}).get("ok"))
tcp_ok = bool((probes.get("tcp") or {}).get("ok"))
trace_ok = probes.get("trace") is None or bool((probes.get("trace") or {}).get("ok"))
results.append(
{
"title": str(item.get("title") or host),
"host": host,
"meta": item.get("meta") or {},
"ok": ping_ok and (tcp_ok or trace_ok),
"probes": probes,
}
)
internet_urls = payload.get("internet_urls")
if not isinstance(internet_urls, list) or not internet_urls:
internet_urls = ["https://www.google.com/generate_204", "https://1.1.1.1"]
if not target_items and not internet_urls:
return jsonify({"status": "error", "message": "Нет целей для проверки"}), 400
internet_results: list[dict] = []
for url in internet_urls[:8]:
val = str(url or "").strip()
if not val:
continue
try:
internet_results.append(http_probe(val, timeout_sec=max(2, timeout_sec)))
except ValueError as exc:
internet_results.append({"ok": False, "url": val, "error": str(exc)})
return jsonify(
{
"status": "success",
"generated_at": datetime.utcnow().isoformat(),
"scope": scope,
"node_role": report.get("node_role"),
"results": results,
"internet": internet_results,
}
)
@bp.post("/diagnostics/hx711-sample")
@require_superuser
def admin_diagnostics_hx711_sample():
cfg = current_app.config
simulation = _effective_simulation_mode(current_app)
dout = int(cfg.get("HX711_DOUT_PIN", 2))
pd_sck = int(cfg.get("HX711_PD_SCK_PIN", 3))
wrapper = HX711Wrapper(simulation_mode=simulation, dout_pin=dout, pd_sck_pin=pd_sck)
try:
value = float(wrapper.read_weight_sample())
except HX711UnavailableError as exc:
return jsonify(
{
"status": "error",
"message": str(exc),
"simulation_mode": simulation,
"driver_ready": bool(getattr(wrapper, "_device", None) is not None),
"pins": {"dout": dout, "pd_sck": pd_sck},
}
), 503
return jsonify(
{
"status": "success",
"sample": value,
"simulation_mode": simulation,
"driver_ready": bool(getattr(wrapper, "_device", None) is not None),
"pins": {"dout": dout, "pd_sck": pd_sck},
}
)
@bp.post("/diagnostics/gpio-blink")
@require_superuser
def admin_diagnostics_gpio_blink():
payload = request.get_json(silent=True) or {}
try:
times = max(1, min(int(payload.get("times", 3)), 20))
except (TypeError, ValueError):
return jsonify({"status": "error", "message": "times должен быть целым числом"}), 400
try:
delay = max(0.05, min(float(payload.get("delay", 0.2)), 3.0))
except (TypeError, ValueError):
return jsonify({"status": "error", "message": "delay должен быть числом"}), 400
cfg = current_app.config
simulation = _effective_simulation_mode(current_app)
pin = int(cfg.get("GPIO_LED_PIN", 18))
ctrl = GPIOController.get_instance(pin=pin, simulation_mode=simulation)
ctrl.blink(times=times, delay=delay)
return jsonify(
{
"status": "success",
"message": f"Запущено мигание: {times} раз, задержка {delay:.2f} с",
"pin": pin,
"simulation_mode": simulation,
"rpi_gpio_available": bool(ctrl.rpi_gpio_available),
}
)
@bp.post("/diagnostics/traceroute-analyze")
@require_superuser
def admin_diagnostics_traceroute_analyze():
"""Разбор текста вывода traceroute (сырой текст; UI в админке — модалка «Трассировка по IP»)."""
payload = request.get_json(silent=True) or {}
text = str(payload.get("text") or payload.get("traceroute") or "")
if not text.strip():
return jsonify(
{"status": "error", "message": "Укажите поле text с выводом traceroute"}
), 400
try:
parsed = parse_traceroute_text(text)
except Exception as exc:
return jsonify({"status": "error", "message": str(exc)}), 400
return jsonify({"status": "success", **parsed})
@bp.post("/diagnostics/traceroute-by-hosts")
@require_superuser
def admin_diagnostics_traceroute_by_hosts():
"""Запуск traceroute/tracepath/mtr на сервере по списку хостов и разбор вывода."""
payload = request.get_json(silent=True) or {}
raw = payload.get("hosts")
if isinstance(raw, str):
hosts = [ln.strip() for ln in raw.splitlines() if ln.strip()]
elif isinstance(raw, list):
hosts = [str(x).strip() for x in raw if str(x).strip()]
else:
hosts = []
if not hosts:
return jsonify(
{"status": "error", "message": "Укажите IP или хосты (по одному в строке)"}
), 400
if len(hosts) > 24:
return jsonify(
{"status": "error", "message": "Не более 24 адресов за один запрос"}
), 400
try:
timeout_sec = int(payload.get("timeout_sec", 12))
timeout_sec = max(4, min(timeout_sec, 60))
except (TypeError, ValueError):
timeout_sec = 12
prefer_mtr = bool(payload.get("prefer_mtr", False))
try:
tcp_port = int(payload.get("tcp_port", _default_tcp_probe_port()))
except (TypeError, ValueError):
tcp_port = _default_tcp_probe_port()
if tcp_port < 1 or tcp_port > 65535:
return jsonify({"status": "error", "message": "tcp_port должен быть от 1 до 65535"}), 400
probe_timeout = max(1, min(8, min(timeout_sec, 20) // 2 or 3))
results: list[dict] = []
seen: set[str] = set()
for h in hosts:
key = h.strip().lower()
if not key or key in seen:
continue
seen.add(key)
try:
safe = validate_target_host(h)
except ValueError as exc:
results.append(
{
"host": h.strip(),
"validate_error": str(exc),
"trace": None,
"parse": None,
}
)
continue
ping = ping_host(safe, timeout_sec=probe_timeout)
tcp = tcp_probe(safe, tcp_port, timeout_sec=probe_timeout)
tr = trace_host(
safe,
timeout_sec=timeout_sec,
prefer_mtr=prefer_mtr,
max_output_lines=128,
)
text = "\n".join(tr.get("lines") or [])
parsed = None
if text.strip():
try:
pr = parse_traceroute_text(text)
if pr.get("hops"):
parsed = pr
except Exception:
parsed = None
results.append(
{
"host": safe,
"validate_error": None,
"ping": ping,
"tcp": tcp,
"trace": {
"ok": bool(tr.get("ok")),
"tool": tr.get("tool"),
"exit_code": tr.get("exit_code"),
"error": tr.get("error"),
"command": tr.get("command"),
"lines": tr.get("lines") or [],
},
"parse": parsed,
}
)
return jsonify({"status": "success", "results": results})
def _count_active_rows(model) -> int:
query = select(func.count()).select_from(model)
if hasattr(model, "is_deleted"):
query = query.where(
or_(model.is_deleted.is_(False), model.is_deleted.is_(None))
)
return int(db.session.scalar(query) or 0)
def _valid_login(login: str) -> bool:
s = (login or "").strip()
return 0 < len(s) <= _WEB_USER_LOGIN_MAX_LEN
@bp.get("/summary")
@require_superuser
def admin_summary():
_ensure_default_superuser()
recipes_uri = current_app.config.get("SQLALCHEMY_DATABASE_URI", "")
reports_uri = (current_app.config.get("SQLALCHEMY_BINDS") or {}).get("reports", "")
queue_base = select(SyncQueue).where(SyncQueue.is_deleted.is_(False)).subquery()
queue_stats = {
"total": int(db.session.scalar(select(func.count()).select_from(queue_base)) or 0),
"pending": int(
db.session.scalar(
select(func.count()).select_from(queue_base).where(
queue_base.c.status == "pending"
)
)
or 0
),
"processing": int(
db.session.scalar(
select(func.count()).select_from(queue_base).where(
queue_base.c.status == "processing"
)
)
or 0
),
"failed": int(
db.session.scalar(
select(func.count()).select_from(queue_base).where(
queue_base.c.status == "failed"
)
)
or 0
),
}
sync_state = read_sync_client_state()
sync_state_safe = {
key: value
for key, value in sync_state.items()
if key not in {"token", "auth_token", "password", "secret"}
}
sync_connection = _effective_sync_connection(current_app)
effective_role = str(sync_connection.get("role") or "server").strip() or "server"
initial_sync_progress: dict = {"active": False}
try:
from sync_client import get_initial_sync_progress_snapshot
initial_sync_progress = get_initial_sync_progress_snapshot()
except Exception:
logging.getLogger("app").debug(
"admin summary: снимок прогресса первой синхронизации недоступен",
exc_info=True,
)
install_w = get_install_and_warranty(current_app)
_base_dir = str(current_app.config.get("BASE_DIR", ".") or ".")
data = {
"status": "success",
"generated_at": datetime.utcnow().isoformat(),
"app": {
"config_mode": current_app.config.get("WESP_CONFIG", "development"),
"debug": bool(current_app.config.get("DEBUG", False)),
"version": current_app.config.get("SYNC_CLIENT_VERSION", "1.0.0"),
"role": effective_role,
"first_launch_at": install_w["first_launch_at"],
"warranty_until": install_w["warranty_until"],
"warranty_days_remaining": install_w["warranty_days_remaining"],
},
"databases": {
"recipes": _database_descriptor_with_size(recipes_uri, _base_dir),
"reports": _database_descriptor_with_size(reports_uri, _base_dir),
},
"machine": collect_host_hardware_overview(),
"counts": {
"users": _count_active_rows(WebUser),
"recipes": _count_active_rows(Recipe),
"ingredients": _count_active_rows(Ingredient),
"components": _count_active_rows(Component),
"feed_dispensers": _count_active_rows(FeedDispenser),
"feeding_periods": _count_active_rows(FeedingPeriod),
},
"sync": {
"client_role": effective_role,
"connection": sync_connection,
"max_concurrent": int(current_app.config.get("SYNC_MAX_CONCURRENT", 4)),
"queue": queue_stats,
"client_poll_interval_sec": int(
current_app.config.get("SYNC_CLIENT_POLL_INTERVAL_SEC", 14)
),
"client_poll_interval_initial_sec": int(
current_app.config.get("SYNC_CLIENT_POLL_INTERVAL_INITIAL_SEC", 14)
),
"client_pull_limit_steady": int(
current_app.config.get("SYNC_CLIENT_DEFAULT_PULL_LIMIT", 100)
),
"client_pull_limit_initial": int(
current_app.config.get("SYNC_CLIENT_PULL_LIMIT_INITIAL", 50)
),
"first_sync_banner": bool(
effective_role == "client"
and not sync_state_safe.get("first_bootstrap_done", True)
),
"initial_sync_progress": initial_sync_progress,
"guarantees": {
"master_data": (
"Источник правды для recipes.db и мастер-таблиц — сервер; "
"клиенты получают копию через pull."
),
"reports": "Отчёты с полевых узлов принимаются на сервер через push.",
"identity": (
"Побайтовое совпадение файлов SQLite между узлами не гарантируется; "
"сверка digest — GET /api/sync/consistency."
),
},
"state": sync_state_safe,
"queue_errors": _recent_queue_sync_display_errors(25),
},
"security": _security_summary_payload(current_app),
"auto_update": public_auto_update_settings_payload(),
"hardware": _summary_hardware_payload(current_app),
}
return jsonify(data)
def _summary_hardware_payload(app) -> dict:
"""Краткий снимок периферии для GET /api/admin/summary."""
try:
snap = build_hardware_status(app)
cfg = snap.get("config") or {}
periph = snap.get("peripherals") or {}
scales = snap.get("scales") or {}
out = {**cfg, **periph}
if scales.get("available"):
out["weight_kg"] = scales.get("weight_kg")
out["hx711_ok"] = scales.get("hx711_ok")
out["hx711_error"] = scales.get("hx711_error")
out["counts_per_kg"] = scales.get("counts_per_kg")
out["tare_raw"] = scales.get("tare_raw")
out["error_streak"] = scales.get("error_streak")
else:
out["scales_error"] = scales.get("error")
return out
except Exception:
logging.getLogger("app").debug("summary hardware snapshot failed", exc_info=True)
return {
"simulation_mode": bool(app.config.get("SIMULATION_MODE", False)),
"read_interval": float(app.config.get("READ_INTERVAL", 0.05)),
"samples_per_read": int(app.config.get("SAMPLES_PER_READ", 3)),
"hx711_dout_pin": int(app.config.get("HX711_DOUT_PIN", 2)),
"hx711_pd_sck_pin": int(app.config.get("HX711_PD_SCK_PIN", 3)),
"gpio_led_pin": int(app.config.get("GPIO_LED_PIN", 18)),
}
@bp.get("/sync-diagnostics")
@require_superuser
def admin_sync_diagnostics():
"""Полная диагностика sync (superuser): очереди, runtime, bootstrap, отчёты, конфликты."""
_ensure_default_superuser()
from app.services.admin_sync_diagnostics_service import build_sync_diagnostics_payload
payload = build_sync_diagnostics_payload(current_app)
return jsonify({"status": "success", **payload})
@bp.post("/sync-actions/requeue-stuck")
@require_superuser
def admin_sync_action_requeue_stuck():
_ensure_default_superuser()
from app.services.sync_manager import requeue_stuck_processing
timeout = int(
current_app.config.get(
"SYNC_REQUEUE_TIMEOUT_MINUTES",
getattr(Config, "SYNC_REQUEUE_TIMEOUT_MINUTES", 15),
)
)
try:
n = int(requeue_stuck_processing(timeout_minutes=timeout))
except Exception as exc:
logging.getLogger("app").exception("POST /sync-actions/requeue-stuck")
return jsonify({"status": "error", "message": str(exc)}), 500
db.session.expire_all()
return jsonify(
{
"status": "success",
"requeued": n,
"timeout_minutes": timeout,
"message": f"Переставлено зависших processing→pending: {n}",
}
)
@bp.post("/sync-actions/restart-local-sync")
@require_superuser
def admin_sync_action_restart_local_sync():
"""Перечитать sync_client_state и перезапустить фоновый sync в этом процессе (клиент)."""
_ensure_default_superuser()
if current_app.config.get("TESTING"):
return jsonify(
{
"status": "success",
"reloaded": False,
"message": "В режиме TESTING перезапуск sync_client не выполняется.",
}
)
try:
from sync_client import apply_sync_client_runtime
ok = bool(apply_sync_client_runtime(current_app))
except Exception as exc:
logging.getLogger("app").exception("POST /sync-actions/restart-local-sync")
return jsonify({"status": "error", "message": str(exc)}), 500
return jsonify(
{
"status": "success",
"reloaded": ok,
"message": (
"Фоновый sync перезапущен с текущими настройками."
if ok
else "Перезапуск не выполнен (AUTOSTART=off, reloader parent или роль не client)."
),
}
)
@bp.post("/sync-actions/refresh-runtime")
@require_superuser
def admin_sync_action_refresh_runtime():
"""Вернуть актуальный снимок process-local runtime (без сброса счётчиков)."""
_ensure_default_superuser()
from app.services.sync_runtime import sync_runtime
return jsonify(
{
"status": "success",
"message": "Снимок runtime текущего воркера.",
"runtime": sync_runtime.diagnostics_snapshot(),
}
)
@bp.post("/sync-actions/retry-report-push")
@require_superuser
def admin_sync_action_retry_report_push():
"""Один цикл sync в фоне (на клиенте: push отчётов + pull + confirm)."""
_ensure_default_superuser()
if current_app.config.get("TESTING"):
return jsonify(
{
"status": "success",
"scheduled": False,
"message": "В TESTING фоновый sync_cycle не планируется.",
}
)
role = str(_effective_sync_connection(current_app).get("role") or "").strip().lower()
if role != "client":
return jsonify(
{"status": "error", "message": "Доступно только при эффективной роли client."}
), 400
import sync_client as sc
client = getattr(sc, "sync_client", None)
if client is None:
return jsonify({"status": "error", "message": "sync_client не инициализирован."}), 400
app_ref = current_app._get_current_object()
def _run() -> None:
with app_ref.app_context():
try:
client.sync_cycle()
except Exception:
logging.getLogger("app").exception("retry-report-push: sync_cycle")
threading.Thread(target=_run, daemon=True, name="wesp-admin-report-push").start()
return jsonify(
{
"status": "success",
"scheduled": True,
"message": "Запланирован sync_cycle() в фоновом потоке.",
}
)
@bp.patch("/auto-update-settings")
@require_superuser
def admin_auto_update_settings_patch():
"""
Публичные поля автообновления в recipes.db (таблица auto_update_settings).
Секреты API — PATCH /api/admin/gitea-secrets, GITEA_* в окружении или .secret/gitea_secrets.json.
"""
payload = request.get_json(silent=True) or {}
if not isinstance(payload, dict):
return jsonify({"status": "error", "message": "Ожидался JSON-объект"}), 400
allowed = {
k: payload[k]
for k in (
"enabled",
"auto_install",
"gitea_url",
"gitea_owner",
"gitea_repo",
"repository_url",
"check_interval_sec",
)
if k in payload
}
if not allowed:
return jsonify({"status": "error", "message": "Нет поддерживаемых полей для сохранения"}), 400
try:
payload_out = apply_auto_update_admin_patch(allowed)
except Exception as e:
return jsonify({"status": "error", "message": f"Не удалось записать в БД: {e}"}), 500
try:
from app.services.auto_update_runtime import reload_update_checker
reload_update_checker()
except Exception:
pass
return jsonify(
{
"status": "success",
"message": "Сохранено в recipes.db. Проверка обновлений перезагружена без перезапуска процесса.",
"auto_update": payload_out,
}
)
@bp.patch("/gitea-secrets")
@require_superuser
def admin_gitea_secrets_patch():
"""Зашифрованные секреты Gitea в .secret/gitea_secrets.json (ключ — SECRET_KEY / WESP_SECRET_KEY)."""
payload = request.get_json(silent=True) or {}
if not isinstance(payload, dict):
return jsonify({"status": "error", "message": "Ожидался JSON-объект"}), 400
clear = bool(payload.get("clear_secrets"))
token = payload.get("gitea_token")
user = payload.get("gitea_username")
pwd = payload.get("gitea_password")
has_new = (
(isinstance(token, str) and token.strip())
or (isinstance(user, str) and user.strip())
or (isinstance(pwd, str) and pwd.strip())
)
if not clear and not has_new:
return jsonify(
{
"status": "error",
"message": "Укажите токен или логин и пароль API, либо включите clear_secrets",
}
), 400
try:
patch_gitea_secrets_file(
str(_resolve_base_dir()),
current_app.config["SECRET_KEY"],
gitea_token=token if isinstance(token, str) else None,
gitea_username=user if isinstance(user, str) else None,
gitea_password=pwd if isinstance(pwd, str) else None,
clear_secrets=clear,
)
except ValueError as e:
return jsonify({"status": "error", "message": str(e)}), 400
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500
try:
from app.services.auto_update_runtime import reload_update_checker
reload_update_checker()
except Exception:
pass
return jsonify(
{
"status": "success",
"message": "Секреты Gitea сохранены. Проверка обновлений перезагружена без перезапуска процесса.",
"auto_update": public_auto_update_settings_payload(),
}
)
@bp.get("/system-metrics")
@require_superuser
def admin_system_metrics():
"""Метрики CPU/RAM/swap/диск и скорость сети (требуется psutil на сервере)."""
metrics = collect_system_metrics()
if metrics.get("available"):
maybe_append_hardware_sample(current_app, metrics)
return jsonify({"status": "success", "metrics": metrics})
@bp.get("/hardware-metrics-history")
@require_superuser
def admin_hardware_metrics_history():
"""История нагрузки из JSONL (последние N дней, см. WESP_ADMIN_HARDWARE_LOG_*)."""
points, path = read_hardware_history(current_app)
return jsonify(
{
"status": "success",
"points": points,
"path": path or None,
"retention_days": int(
current_app.config.get("WESP_ADMIN_HARDWARE_LOG_RETENTION_DAYS", 3) or 3
),
}
)
@bp.get("/hardware-status")
@require_superuser
def admin_hardware_status():
"""Полный снимок хоста, периферии и весов для вкладки «Железо»."""
payload = build_hardware_status(current_app)
return jsonify({"status": "success", **payload})
@bp.patch("/hardware/simulation")
@require_superuser
def admin_hardware_simulation_patch():
"""Включить/выключить симуляцию весов (сохраняется в hardware_settings)."""
payload = request.get_json(silent=True) or {}
if not isinstance(payload, dict) or "enabled" not in payload:
return jsonify({"status": "error", "message": "Укажите enabled: true или false"}), 400
from app.routes.scales import _get_reader
reader = _get_reader()
state = reader.set_simulation_mode(bool(payload.get("enabled")))
return jsonify(
{
"status": "success",
"hardware": state,
"simulation_mode": state.get("simulation_mode"),
"simulation_weight_kg": state.get("simulation_weight_kg"),
}
)
@bp.put("/hardware/simulation/weight")
@require_superuser
def admin_hardware_simulation_set_weight():
"""Задать симулируемый вес (кг) или изменить на delta_kg."""
payload = request.get_json(silent=True) or {}
if not isinstance(payload, dict):
return jsonify({"status": "error", "message": "Ожидался JSON-объект"}), 400
from app.routes.scales import _get_reader
reader = _get_reader()
try:
if "weight_kg" in payload:
try:
target = int(round(float(payload.get("weight_kg"))))
except (TypeError, ValueError):
return jsonify({"status": "error", "message": "weight_kg должен быть числом"}), 400
target = max(0, min(9999, target))
weight = reader.set_simulation_weight(target)
elif "delta_kg" in payload:
try:
delta = int(payload.get("delta_kg"))
except (TypeError, ValueError):
return jsonify({"status": "error", "message": "delta_kg должен быть целым числом"}), 400
if delta == 0:
return jsonify({"status": "error", "message": "delta_kg не может быть 0"}), 400
weight = reader.adjust_simulation_weight(delta)
else:
return jsonify(
{"status": "error", "message": "Укажите weight_kg или delta_kg"},
), 400
except ValueError as exc:
return jsonify({"status": "error", "message": str(exc)}), 400
state = reader.get_simulation_state()
return jsonify(
{
"status": "success",
"simulation_weight_kg": weight,
**state,
}
)
@bp.post("/hardware/simulation/adjust-weight")
@require_superuser
def admin_hardware_simulation_adjust_weight():
"""Изменить симулируемый вес (кг) при включённой симуляции."""
payload = request.get_json(silent=True) or {}
if not isinstance(payload, dict):
return jsonify({"status": "error", "message": "Ожидался JSON-объект"}), 400
try:
delta = int(payload.get("delta_kg", 0))
except (TypeError, ValueError):
return jsonify({"status": "error", "message": "delta_kg должен быть целым числом"}), 400
if delta == 0:
return jsonify({"status": "error", "message": "delta_kg не может быть 0"}), 400
from app.routes.scales import _get_reader
reader = _get_reader()
try:
weight = reader.adjust_simulation_weight(delta)
except ValueError as exc:
return jsonify({"status": "error", "message": str(exc)}), 400
return jsonify(
{
"status": "success",
"simulation_weight_kg": weight,
**reader.get_simulation_state(),
}
)
@bp.get("/peripheral-events")
@require_superuser
def admin_peripheral_events():
"""Журнал событий HX711/GPIO/хоста (JSONL)."""
try:
limit = int(request.args.get("limit", 80))
except (TypeError, ValueError):
limit = 80
limit = max(1, min(limit, 500))
component = (request.args.get("component") or "").strip() or None
exclude_component = (request.args.get("exclude_component") or "").strip() or None
if component and exclude_component:
return jsonify(
{"status": "error", "message": "Укажите component или exclude_component, не оба сразу."}
), 400
events, path = read_peripheral_events(
current_app,
limit=limit,
component=component,
exclude_component=exclude_component,
)
return jsonify(
{
"status": "success",
"events": events,
"path": path or None,
"retention_days": int(
current_app.config.get("WESP_ADMIN_PERIPHERAL_LOG_RETENTION_DAYS", 7) or 7
),
}
)
@bp.delete("/peripheral-events")
@require_superuser
def admin_peripheral_events_clear():
"""Очистить журнал событий периферии (wesp-peripherals.jsonl).
?component=network — только сеть;
?exclude_component=network — всё кроме сети (журнал оборудования).
"""
component = (request.args.get("component") or "").strip() or None
exclude_component = (request.args.get("exclude_component") or "").strip() or None
if component and exclude_component:
return jsonify(
{"status": "error", "message": "Укажите component или exclude_component, не оба сразу."}
), 400
try:
ok, path = clear_peripheral_events(
current_app, component=component, exclude_component=exclude_component
)
except OSError as exc:
return jsonify({"status": "error", "message": str(exc)}), 500
if not ok:
return jsonify(
{
"status": "error",
"message": "Журнал периферии не настроен (WESP_ADMIN_PERIPHERAL_LOG_PATH).",
}
), 400
msg = "Журнал очищен"
if component:
msg = f"Журнал «{component}» очищен"
elif exclude_component:
msg = f"Журнал очищен (кроме «{exclude_component}»)"
return jsonify(
{
"status": "success",
"message": msg,
"path": path or None,
"component": component,
"exclude_component": exclude_component,
}
)
@bp.get("/server-log")
@require_superuser
def admin_server_log():
"""Последние строки лог-файла (путь в WESP_ADMIN_LOG_PATH)."""
raw = (current_app.config.get("WESP_ADMIN_LOG_PATH") or "").strip()
if not raw:
return jsonify(
{
"status": "error",
"code": "no_path",
"message": "Задайте переменную окружения WESP_ADMIN_LOG_PATH к текстовому лог-файлу.",
"lines": [],
}
)
path = Path(raw)
if not path.is_absolute():
path = Path(current_app.config.get("BASE_DIR", ".")) / path
path = path.resolve()
if not path.is_file():
return jsonify(
{"status": "error", "code": "not_found", "message": f"Файл не найден: {path}", "lines": []}
), 404
max_lines = min(max(int(request.args.get("lines", 200)), 1), 500)
lines, err = tail_text_file(path, max_lines=max_lines)
if err:
return jsonify({"status": "error", "code": "read_error", "message": err, "lines": lines})
return jsonify(
{
"status": "success",
"path": str(path),
"lines": lines,
}
)
@bp.get("/server-log/download")
@require_superuser
def admin_server_log_download():
"""Скачивание полного файла WESP_ADMIN_LOG_PATH (text/plain)."""
raw = (current_app.config.get("WESP_ADMIN_LOG_PATH") or "").strip()
if not raw:
return jsonify(
{
"status": "error",
"code": "no_path",
"message": "Задайте WESP_ADMIN_LOG_PATH к текстовому лог-файлу.",
}
), 400
path = Path(raw)
if not path.is_absolute():
path = Path(current_app.config.get("BASE_DIR", ".")) / path
path = path.resolve()
if not path.is_file():
return jsonify(
{
"status": "error",
"code": "not_found",
"message": f"Файл не найден: {path}",
}
), 404
return send_file(
path,
as_attachment=True,
download_name=path.name,
mimetype="text/plain; charset=utf-8",
max_age=0,
)
@bp.get("/client-uploaded-logs")
@require_superuser
def admin_client_uploaded_logs_index():
"""Список каталогов логов, присланных клиентами (POST /api/sync/client-log)."""
upload_dir = (current_app.config.get("WESP_CLIENT_LOG_UPLOAD_DIR") or "").strip()
if not upload_dir:
return jsonify(
{
"status": "error",
"code": "no_dir",
"message": "WESP_CLIENT_LOG_UPLOAD_DIR не задан.",
"clients": [],
}
)
try:
root = client_logs_root_resolved(upload_dir, str(current_app.config.get("BASE_DIR", ".")))
except ValueError as e:
return jsonify({"status": "error", "message": str(e), "clients": []}), 400
clients = build_uploaded_logs_index(db.session, root)
return jsonify({"status": "success", "root": str(root), "clients": clients})
@bp.get("/client-uploaded-logs/tail")
@require_superuser
def admin_client_uploaded_logs_tail():
"""Хвост выгруженного лога; file опционален — берётся самый свежий файл в каталоге клиента."""
upload_dir = (current_app.config.get("WESP_CLIENT_LOG_UPLOAD_DIR") or "").strip()
if not upload_dir:
return jsonify(
{
"status": "error",
"code": "no_dir",
"message": "WESP_CLIENT_LOG_UPLOAD_DIR не задан.",
"lines": [],
}
)
client_id = (request.args.get("client_id") or "").strip()
if not client_id:
return jsonify({"status": "error", "message": "client_id обязателен", "lines": []}), 400
try:
root = client_logs_root_resolved(upload_dir, str(current_app.config.get("BASE_DIR", ".")))
except ValueError as e:
return jsonify({"status": "error", "message": str(e), "lines": []}), 400
filename = (request.args.get("file") or "").strip()
try:
if filename:
path = resolve_uploaded_client_log_file(root, client_id, filename)
else:
d = client_upload_log_dir(root, client_id)
latest = latest_uploaded_log_file(d)
if not latest:
return jsonify(
{
"status": "error",
"code": "empty",
"message": "Нет файлов логов для этого клиента.",
"lines": [],
}
), 404
path = latest
except FileNotFoundError:
return jsonify(
{"status": "error", "code": "not_found", "message": "Каталог или файл не найден.", "lines": []}
), 404
except ValueError as e:
return jsonify({"status": "error", "message": str(e), "lines": []}), 400
max_lines = min(max(int(request.args.get("lines", 200)), 1), 500)
lines, err = tail_text_file(path, max_lines=max_lines)
if err:
return jsonify({"status": "error", "code": "read_error", "message": err, "lines": lines})
return jsonify(
{
"status": "success",
"path": str(path),
"file": path.name,
"client_id": client_id,
"lines": lines,
}
)
@bp.get("/client-uploaded-logs/download")
@require_superuser
def admin_client_uploaded_logs_download():
"""Скачивание одного файла выгруженного лога."""
upload_dir = (current_app.config.get("WESP_CLIENT_LOG_UPLOAD_DIR") or "").strip()
if not upload_dir:
return jsonify(
{"status": "error", "code": "no_dir", "message": "WESP_CLIENT_LOG_UPLOAD_DIR не задан."}
), 400
client_id = (request.args.get("client_id") or "").strip()
filename = (request.args.get("file") or "").strip()
if not client_id or not filename:
return jsonify({"status": "error", "message": "Укажите client_id и file"}), 400
try:
root = client_logs_root_resolved(upload_dir, str(current_app.config.get("BASE_DIR", ".")))
path = resolve_uploaded_client_log_file(root, client_id, filename)
except FileNotFoundError:
return jsonify({"status": "error", "code": "not_found", "message": "Файл не найден."}), 404
except ValueError as e:
return jsonify({"status": "error", "message": str(e)}), 400
return send_file(
path,
as_attachment=True,
download_name=path.name,
mimetype="text/plain; charset=utf-8",
max_age=0,
)
@bp.post("/ui-activity")
@require_superuser
def admin_ui_activity_append():
"""Дописывает событие из браузера (setStatus) в WESP_ADMIN_UI_ACTIVITY_LOG_PATH (JSONL)."""
body = request.get_json(silent=True) or {}
text = str(body.get("text") or "").strip()
if not text:
return jsonify({"status": "error", "message": "text required"}), 400
if len(text) > 2000:
text = text[:2000]
level = body.get("level") if body.get("level") in ("ok", "err", "warn") else "ok"
login = str(session.get("user_login") or "")
append_admin_ui_activity(
current_app,
text=text,
level=level,
user_login=login,
)
return jsonify({"status": "success"})
@bp.get("/activity-feed")
@require_superuser
def admin_activity_feed():
"""Лента: JSONL (UI) + хвост wesp.log (WARNING+), без дублирования по level+текст."""
entries, meta = build_activity_feed(current_app)
return jsonify({"status": "success", "entries": entries, "meta": meta})
@bp.get("/llm/status")
@require_superuser
def admin_llm_status():
"""Проверка: включён ли LLM, доступен ли OpenAI-совместимый сервер, есть ли модель в /v1/models."""
cfg = current_app.config
enabled = bool(cfg.get("WESP_ADMIN_LLM_ENABLED"))
base = str(cfg.get("WESP_LLM_BASE_URL") or "http://127.0.0.1:8080").rstrip("/")
model = str(cfg.get("WESP_LLM_MODEL") or "").strip()
api_key = str(cfg.get("WESP_LLM_API_KEY") or "").strip()
assistant_dir = str(cfg.get("WESP_ASSISTANT_DIR") or "").strip()
status_timeout = float(cfg.get("WESP_LLM_STATUS_TIMEOUT_SEC") or 3.0)
locks = security_env_lock_flags()
out = {
"enabled": enabled,
"llm_base_url": base,
"model": model,
"assistant_dir": assistant_dir,
"llm_autostart": bool(cfg.get("WESP_LLM_AUTOSTART", True)),
"llm_autostart_locked_by_env": locks.get("llm_autostart", False),
"admin_llm_enabled_locked_by_env": locks.get("admin_llm_enabled", False),
"llm_chat_db_context": bool(cfg.get("WESP_LLM_CHAT_INCLUDE_DB_CONTEXT", False)),
"llm_chat_db_context_locked_by_env": locks.get("llm_chat_db_context", False),
"llm_tools_enabled": bool(cfg.get("WESP_LLM_TOOLS_ENABLED", True)),
"llm_tools_enabled_locked_by_env": locks.get("llm_tools_enabled", False),
"llm_reachable": False,
"model_present": False,
"llm_error": None,
}
if not enabled:
return jsonify({"status": "success", **out})
try:
payload = openai_list_models(
base_url=base, timeout_sec=status_timeout, api_key=api_key
)
out["llm_reachable"] = True
out["model_present"] = model_in_openai_payload(payload, model)
except LocalLlmError as e:
out["llm_error"] = str(e)
return jsonify({"status": "success", **out})
@bp.get("/llm/activity")
@require_superuser
def admin_llm_activity():
"""Последние записи журнала ИИ (JSONL) за окно хранения."""
rows, path = read_admin_llm_activity(current_app, max_lines=200)
days = int(current_app.config.get("WESP_ADMIN_LLM_LOG_RETENTION_DAYS", 7) or 7)
return jsonify(
{
"status": "success",
"entries": rows,
"path": path,
"retention_days": max(1, min(days, 30)),
}
)
@bp.get("/llm/diagnostics")
@require_superuser
def admin_llm_diagnostics():
"""Структурированная диагностика среды + проверка SQLite + тот же текст, что в system для LLM."""
payload = build_llm_diagnostics_payload(current_app)
db_payload = build_llm_db_health_payload(current_app)
inv_payload = build_llm_inventory_payload(current_app)
text = (
build_llm_diagnostics_block(current_app)
+ "\n\n---\n\n"
+ build_llm_db_health_block(current_app)
+ "\n\n---\n\n"
+ build_llm_inventory_snapshot_block(current_app)
)
return jsonify(
{
"status": "success",
"diagnostics": payload,
"db_health": db_payload,
"inventory": inv_payload,
"text_for_llm": text,
}
)
@bp.post("/llm/ping")
@require_superuser
def admin_llm_ping():
"""Короткий вызов локального LLM без контекста БД — проверка цепочки WESP → сервер."""
cfg = current_app.config
if not cfg.get("WESP_ADMIN_LLM_ENABLED"):
return jsonify(
{"status": "error", "message": "LLM отключён (WESP_ADMIN_LLM_ENABLED)."}
), 503
login = str(session.get("user_login") or "")
interval = float(cfg.get("WESP_ADMIN_LLM_RATE_LIMIT_SEC") or 0)
ok, wait = llm_post_rate_allow(login, interval, bucket="heavy")
if not ok:
return jsonify(
{
"status": "error",
"message": f"Слишком часто. Подождите {wait:.1f} с.",
}
), 429
base = str(cfg.get("WESP_LLM_BASE_URL") or "").rstrip("/")
model = str(cfg.get("WESP_LLM_MODEL") or "").strip()
api_key = str(cfg.get("WESP_LLM_API_KEY") or "").strip()
timeout_sec = min(float(cfg.get("WESP_LLM_TIMEOUT_SEC") or 120), 60.0)
messages = ensure_russian_llm_messages(
[
{
"role": "user",
"content": "Ответь одним словом: ок",
}
]
)
t0 = time.perf_counter()
# Без тяжёлого merge (снимок ORM) — только короткий system из ensure_russian_llm_messages.
try:
reply = openai_chat_completion(
base_url=base,
model=model,
messages=messages,
timeout_sec=timeout_sec,
max_tokens=32,
api_key=api_key,
)
except LocalLlmError as e:
append_admin_llm_activity(
current_app,
event="ping",
level="err",
user_login=login,
detail=str(e)[:500],
duration_ms=int((time.perf_counter() - t0) * 1000),
)
return jsonify({"status": "error", "message": str(e)}), 502
append_admin_llm_activity(
current_app,
event="ping",
level="ok",
user_login=login,
detail=(reply or "")[:240] or "ok",
duration_ms=int((time.perf_counter() - t0) * 1000),
meta={"model": model},
)
return jsonify({"status": "success", "reply": reply})
@bp.post("/llm/summary")
@require_superuser
def admin_llm_summary():
"""Сводка по отчётам/складу через локальный LLM (только суперпользователь)."""
cfg = current_app.config
if not cfg.get("WESP_ADMIN_LLM_ENABLED"):
return jsonify(
{"status": "error", "message": "LLM отключён (WESP_ADMIN_LLM_ENABLED)."}
), 503
body = request.get_json(silent=True) or {}
date_from = str(body.get("date_from") or "").strip()
date_to = str(body.get("date_to") or "").strip()
focus = str(body.get("focus") or "both").strip().lower()
if not date_from or not date_to:
return jsonify(
{"status": "error", "message": "Укажите date_from и date_to (YYYY-MM-DD)."}
), 400
try:
datetime.strptime(date_from, "%Y-%m-%d")
datetime.strptime(date_to, "%Y-%m-%d")
except ValueError:
return jsonify(
{"status": "error", "message": "Некорректный формат дат (YYYY-MM-DD)."}
), 400
login = str(session.get("user_login") or "")
interval = float(cfg.get("WESP_ADMIN_LLM_RATE_LIMIT_SEC") or 0)
ok, wait = llm_post_rate_allow(login, interval, bucket="heavy")
if not ok:
return jsonify(
{
"status": "error",
"message": f"Слишком часто. Подождите {wait:.1f} с.",
}
), 429
max_ctx = int(cfg.get("WESP_LLM_MAX_CONTEXT_CHARS") or 12000)
context_text = build_admin_llm_context(
date_from=date_from,
date_to=date_to,
focus=focus,
max_chars=max_ctx,
)
base = str(cfg.get("WESP_LLM_BASE_URL") or "").rstrip("/")
model = str(cfg.get("WESP_LLM_MODEL") or "").strip()
api_key = str(cfg.get("WESP_LLM_API_KEY") or "").strip()
timeout_sec = float(cfg.get("WESP_LLM_TIMEOUT_SEC") or 120)
max_tokens = int(cfg.get("WESP_LLM_MAX_TOKENS") or 512)
user_block = (
f"Данные за период {date_from}{date_to}, режим focus={focus}.\n\n{context_text}\n\n"
"Сформулируй краткую сводку для зоотехника: что важно по отклонениям (что и когда), "
"и по складу если есть строки. Не добавляй фактов вне данных."
)
messages = [
{"role": "system", "content": SYSTEM_PROMPT_RU},
{"role": "user", "content": user_block},
]
messages = merge_llm_diagnostics_into_system(messages, current_app, mode="compact")
t0 = time.perf_counter()
try:
reply = openai_chat_completion(
base_url=base,
model=model,
messages=messages,
timeout_sec=timeout_sec,
max_tokens=max_tokens,
api_key=api_key,
)
except LocalLlmError as e:
append_admin_llm_activity(
current_app,
event="summary",
level="err",
user_login=login,
detail=str(e)[:500],
duration_ms=int((time.perf_counter() - t0) * 1000),
meta={"focus": focus, "date_from": date_from, "date_to": date_to},
)
return jsonify({"status": "error", "message": str(e)}), 502
append_admin_llm_activity(
current_app,
event="summary",
level="ok",
user_login=login,
detail=f"focus={focus} {date_from}{date_to} ctx={len(context_text)} симв.",
duration_ms=int((time.perf_counter() - t0) * 1000),
meta={
"focus": focus,
"date_from": date_from,
"date_to": date_to,
"context_chars": len(context_text),
"reply_chars": len(reply or ""),
},
)
return jsonify(
{
"status": "success",
"reply": reply,
"context_chars": len(context_text),
}
)
def _parse_llm_chat_messages(body: dict):
raw = body.get("messages")
if not isinstance(raw, list) or len(raw) == 0:
return None, "Укажите messages: массив [{role, content}, …]."
out: list[dict[str, str]] = []
for m in raw[:64]:
if not isinstance(m, dict):
return None, "Каждый элемент messages должен быть объектом."
role = str(m.get("role") or "").strip().lower()
content = str(m.get("content") if m.get("content") is not None else "")
if role not in ("system", "user", "assistant"):
return None, f"Недопустимая роль «{role}» (ожидаются system, user, assistant)."
if len(content) > 48_000:
return None, "Слишком длинное сообщение."
if role in ("system", "user") and not content.strip():
return None, "Пустое сообщение недопустимо."
out.append({"role": role, "content": content})
if not out:
return None, "Нет допустимых сообщений."
return out, None
def _trim_llm_chat_messages_roll(msgs: list[dict[str, str]], *, max_messages: int = 12) -> list[dict[str, str]]:
"""Ограничивает длину диалога для локального LLM с небольшим n_ctx."""
if len(msgs) <= max_messages:
return msgs
return msgs[-max_messages:]
def _last_user_chat_text(msgs: list[dict[str, str]]) -> str:
for m in reversed(msgs):
if m.get("role") == "user":
return str(m.get("content") or "").strip()
return ""
def _looks_like_db_intent(text: str) -> bool:
q = (text or "").strip().lower()
if not q:
return False
return any(
k in q
for k in (
"бд",
"база",
"базе",
"базы",
"таблиц",
"table",
"sqlite",
"recipes",
"reports",
"кормораздат",
"раздатчик",
"рецепт",
"schema",
"схем",
"компонент",
"ингредиент",
"component",
)
)
@bp.post("/llm/chat")
@require_superuser
def admin_llm_chat():
"""Многоходовый чат с локальным LLM (OpenAI /v1/chat/completions)."""
cfg = current_app.config
if not cfg.get("WESP_ADMIN_LLM_ENABLED"):
return jsonify(
{"status": "error", "message": "LLM отключён (WESP_ADMIN_LLM_ENABLED)."}
), 503
body = request.get_json(silent=True) or {}
messages, err = _parse_llm_chat_messages(body)
if err:
return jsonify({"status": "error", "message": err}), 400
messages = _trim_llm_chat_messages_roll(messages, max_messages=12)
login = str(session.get("user_login") or "")
interval = float(cfg.get("WESP_ADMIN_LLM_CHAT_RATE_LIMIT_SEC") or 0)
ok, wait = llm_post_rate_allow(login, interval, bucket="chat")
if not ok:
return jsonify(
{
"status": "error",
"message": f"Слишком часто. Подождите {wait:.1f} с.",
}
), 429
base = str(cfg.get("WESP_LLM_BASE_URL") or "").rstrip("/")
model = str(cfg.get("WESP_LLM_MODEL") or "").strip()
api_key = str(cfg.get("WESP_LLM_API_KEY") or "").strip()
timeout_sec = min(
float(cfg.get("WESP_LLM_CHAT_TIMEOUT_SEC") or 45),
float(cfg.get("WESP_LLM_TIMEOUT_SEC") or 120),
)
chat_cap = int(cfg.get("WESP_LLM_CHAT_MAX_TOKENS") or 256)
max_tokens = int(body.get("max_tokens") or chat_cap)
max_tokens = max(16, min(int(max_tokens), 2048))
messages = ensure_russian_llm_messages(messages)
chat_ctx = bool(cfg.get("WESP_LLM_CHAT_INCLUDE_DB_CONTEXT"))
merge_mode = "compact" if chat_ctx else "minimal"
messages = merge_llm_diagnostics_into_system(
messages, current_app, mode=merge_mode
)
user_text = _last_user_chat_text(messages)
db_intent = _looks_like_db_intent(user_text)
tools_cfg = bool(cfg.get("WESP_LLM_TOOLS_ENABLED", True))
tools_req = body.get("tools_enabled")
if tools_req is None:
tools_enabled = tools_cfg
else:
tools_enabled = bool(tools_req) and tools_cfg
# Для вопросов про БД не даём UI-ошибке отключить инструменты.
if db_intent and tools_cfg:
tools_enabled = True
sql_mode = str(body.get("sql_mode") or "preview").strip().lower()
if sql_mode not in ("preview", "execute"):
return jsonify(
{"status": "error", "message": "sql_mode: ожидается preview или execute."}
), 400
# Для запросов про данные БД переключаемся на execute, если режим не задан явно.
if db_intent and "sql_mode" not in body:
sql_mode = "execute"
tool_trace_flag = body.get("tool_trace", True)
show_trace = bool(tool_trace_flag) if tool_trace_flag is not None else True
if db_intent:
show_trace = True
t0 = time.perf_counter()
try:
if tools_enabled:
reply, trace = run_llm_chat_with_tools(
current_app,
messages=messages,
base_url=base,
model=model,
api_key=api_key,
timeout_sec=timeout_sec,
max_tokens=max_tokens,
max_rounds=int(cfg.get("WESP_LLM_TOOL_MAX_ROUNDS") or 6),
chat_sql_mode=sql_mode,
sql_max_rows=int(cfg.get("WESP_LLM_SQL_MAX_ROWS") or 200),
log_max_bytes=int(cfg.get("WESP_LLM_LOG_MAX_BYTES") or 262144),
log_max_lines=int(cfg.get("WESP_LLM_LOG_TAIL_LINES") or 400),
)
else:
reply = openai_chat_completion(
base_url=base,
model=model,
messages=messages,
timeout_sec=timeout_sec,
max_tokens=max_tokens,
api_key=api_key,
)
trace = []
except LocalLlmError as e:
append_admin_llm_activity(
current_app,
event="chat",
level="err",
user_login=login,
detail=str(e)[:500],
duration_ms=int((time.perf_counter() - t0) * 1000),
meta={
"tools": tools_enabled,
"sql_mode": sql_mode,
"db_context": chat_ctx,
},
)
return jsonify({"status": "error", "message": str(e)}), 502
detail_bits = [f"ctx={'db' if chat_ctx else 'light'}"]
if tools_enabled:
detail_bits.append("tools=on")
detail_bits.append(f"sql={sql_mode}")
if trace:
detail_bits.append(f"steps={len(trace)}")
user_preview = _last_user_chat_text(messages)
if user_preview:
detail_bits.append(f{user_preview[:120]}»")
append_admin_llm_activity(
current_app,
event="chat",
level="ok",
user_login=login,
detail=" ".join(detail_bits),
duration_ms=int((time.perf_counter() - t0) * 1000),
meta={
"tools": tools_enabled,
"sql_mode": sql_mode,
"db_context": chat_ctx,
"tool_steps": len(trace) if trace else 0,
"reply_chars": len(reply or ""),
},
)
payload = {"status": "success", "reply": reply, "sql_mode": sql_mode}
if show_trace:
payload["tool_trace"] = trace
return jsonify(payload)
@bp.get("/sqlite-snapshot")
@require_superuser
def admin_sqlite_snapshot():
"""
Отдаёт файл SQLite (тот же, что в ZIP backup.sqlite), например для скачивания.
Только суперпользователь.
"""
bind = (request.args.get("bind") or "recipes").strip().lower()
paths = sqlite_bind_paths(current_app)
if bind not in paths:
known = ", ".join(sorted(paths)) or "—"
return jsonify(
{
"status": "error",
"message": f"Неизвестная база «{bind}». Доступны: {known}",
}
), 400
path = paths[bind]
if not path.is_file():
return jsonify(
{"status": "error", "message": f"Файл не найден: {path}"}
), 404
return send_file(
path,
mimetype="application/vnd.sqlite3",
as_attachment=False,
download_name=f"{bind}.sqlite",
max_age=0,
)
@bp.get("/db/tree")
@require_superuser
def admin_db_tree():
"""Дерево: bind → список таблиц (только суперпользователь)."""
try:
payload = build_tree_payload(current_app)
except OSError as exc:
return jsonify({"status": "error", "message": str(exc)}), 500
return jsonify({"status": "success", **payload})
@bp.get("/db/table")
@require_superuser
def admin_db_table():
bind = (request.args.get("bind") or "recipes").strip().lower()
table = (request.args.get("table") or "").strip()
if not is_safe_sql_identifier(table):
return jsonify({"status": "error", "message": "Укажите корректное имя table"}), 400
limit = min(max(int(request.args.get("limit", 50)), 1), 500)
offset = max(int(request.args.get("offset", 0)), 0)
try:
conn, _path = sqlite_connection_for_bind(current_app, bind)
except FileNotFoundError as exc:
return jsonify({"status": "error", "message": str(exc)}), 404
try:
names = list_user_tables(conn)
if table not in names:
return jsonify({"status": "error", "message": f"Нет таблицы «{table}»"}), 404
page = fetch_table_page(conn, table, limit=limit, offset=offset)
finally:
conn.close()
return jsonify({"status": "success", "bind": bind, "table": table, **page})
@bp.patch("/db/row")
@require_superuser
def admin_db_row_patch():
"""Точечное обновление ячеек строки (реальная запись в файл SQLite на сервере)."""
payload = request.get_json(silent=True) or {}
bind = str(payload.get("bind") or "recipes").strip().lower()
table = str(payload.get("table") or "").strip()
row_key = payload.get("row_key")
changes = payload.get("changes")
if not is_safe_sql_identifier(table):
return jsonify({"status": "error", "message": "Некорректное имя таблицы"}), 400
if not isinstance(row_key, dict) or not isinstance(changes, dict):
return jsonify({"status": "error", "message": "Нужны объекты row_key и changes"}), 400
if not changes:
return jsonify({"status": "error", "message": "changes пустой"}), 400
conn = None
updated = 0
try:
conn, _path = sqlite_connection_for_bind(current_app, bind)
names = list_user_tables(conn)
if table not in names:
return jsonify({"status": "error", "message": f"Нет таблицы «{table}»"}), 404
updated = update_row_cells(conn, table, row_key, changes)
if updated == 0:
conn.rollback()
return jsonify(
{
"status": "error",
"message": "Строка не обновлена (нет совпадения по ключу или значения совпадают)",
}
), 409
conn.commit()
except FileNotFoundError as exc:
return jsonify({"status": "error", "message": str(exc)}), 404
except ValueError as exc:
if conn is not None:
conn.rollback()
return jsonify({"status": "error", "message": str(exc)}), 400
except sqlite3.Error as exc:
if conn is not None:
conn.rollback()
return jsonify({"status": "error", "message": str(exc)}), 400
finally:
if conn is not None:
conn.close()
return jsonify({"status": "success", "updated": updated})
@bp.post("/db/row/delete-preview")
@require_superuser
def admin_db_row_delete_preview():
"""Сводка каскадного удаления: какие строки и из каких таблиц будут затронуты."""
payload = request.get_json(silent=True) or {}
bind = str(payload.get("bind") or "recipes").strip().lower()
table = str(payload.get("table") or "").strip()
row_key = payload.get("row_key")
if not is_safe_sql_identifier(table):
return jsonify({"status": "error", "message": "Некорректное имя таблицы"}), 400
if not isinstance(row_key, dict):
return jsonify({"status": "error", "message": "Нужен объект row_key"}), 400
conn = None
try:
conn, _path = sqlite_connection_for_bind(current_app, bind)
names = list_user_tables(conn)
if table not in names:
return jsonify({"status": "error", "message": f"Нет таблицы «{table}»"}), 404
plan = build_cascade_delete_plan(conn, table, row_key)
if not plan:
return jsonify(
{"status": "error", "message": "Строка не найдена или уже удалена"}
), 404
summary = summarize_delete_plan(plan)
return jsonify({"status": "success", **summary})
except FileNotFoundError as exc:
return jsonify({"status": "error", "message": str(exc)}), 404
except ValueError as exc:
return jsonify({"status": "error", "message": str(exc)}), 400
except sqlite3.Error as exc:
return jsonify({"status": "error", "message": str(exc)}), 400
finally:
if conn is not None:
conn.close()
@bp.post("/db/row/delete")
@require_superuser
def admin_db_row_delete():
"""Каскадное удаление строки и всех зависимых (по FK в схеме SQLite)."""
payload = request.get_json(silent=True) or {}
bind = str(payload.get("bind") or "recipes").strip().lower()
table = str(payload.get("table") or "").strip()
row_key = payload.get("row_key")
if not is_safe_sql_identifier(table):
return jsonify({"status": "error", "message": "Некорректное имя таблицы"}), 400
if not isinstance(row_key, dict):
return jsonify({"status": "error", "message": "Нужен объект row_key"}), 400
conn = None
try:
conn, _path = sqlite_connection_for_bind(current_app, bind)
names = list_user_tables(conn)
if table not in names:
return jsonify({"status": "error", "message": f"Нет таблицы «{table}»"}), 404
result = execute_cascade_delete(conn, table, row_key)
if result.get("deleted", 0) == 0:
conn.rollback()
return jsonify(
{"status": "error", "message": "Ни одна строка не удалена"}
), 409
conn.commit()
except FileNotFoundError as exc:
return jsonify({"status": "error", "message": str(exc)}), 404
except ValueError as exc:
if conn is not None:
conn.rollback()
return jsonify({"status": "error", "message": str(exc)}), 400
except sqlite3.Error as exc:
if conn is not None:
conn.rollback()
return jsonify({"status": "error", "message": str(exc)}), 400
finally:
if conn is not None:
conn.close()
return jsonify({"status": "success", **result})
@bp.get("/factory-reset/preview")
@require_superuser
def admin_factory_reset_preview():
"""Что будет удалено при сбросе (только каталог data/ проекта)."""
from pathlib import Path
from app.services.factory_reset import factory_reset_preview
data_dir = Path(str(current_app.config.get("DATA_DIR") or ""))
base_dir = Path(str(current_app.config.get("BASE_DIR") or "."))
try:
preview = factory_reset_preview(data_dir, base_dir)
except ValueError as exc:
return jsonify({"status": "error", "message": str(exc)}), 400
return jsonify({"status": "success", **preview})
@bp.post("/factory-reset")
@require_superuser
def admin_factory_reset():
"""
Полный сброс data/ (БД, JSON, логи по умолчанию).
Тело JSON: confirmation_phrase, remove_logs (bool), safety_sqlite_backup (bool).
"""
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
login = str(session.get("user_login") or "")
report = factory_reset_app(
current_app,
remove_logs=remove_logs,
safety_sqlite_backup=safety_backup,
)
if not current_app.config.get("TESTING"):
append_admin_ui_activity(
current_app,
text="Сброс данных (factory reset)",
level="warn" if report.get("ok") else "error",
user_login=login,
)
code = 200 if report.get("ok") else 500
return jsonify(
{
"status": "success" if report.get("ok") else "error",
**report,
}
), code
@bp.get("/backup.sqlite")
@require_superuser
def admin_backup_sqlite():
"""Скачивание ZIP с файлами SQLite (recipes и binds)."""
try:
data, name = build_sqlite_backup_zip(current_app)
except ValueError as exc:
return jsonify({"status": "error", "message": str(exc)}), 400
except FileNotFoundError as exc:
return jsonify({"status": "error", "message": str(exc)}), 404
return send_file(
BytesIO(data),
mimetype="application/zip",
as_attachment=True,
download_name=name,
)
@bp.post("/restore.sqlite")
@require_superuser
def admin_restore_sqlite():
"""Восстановление файлов SQLite из ZIP (тот же формат, что выдаёт /api/admin/backup.sqlite)."""
if "file" not in request.files:
return jsonify(
{"status": "error", "message": "Прикрепите ZIP (поле file)"}
), 400
up = request.files["file"]
if not up or not up.filename:
return jsonify({"status": "error", "message": "Файл не выбран"}), 400
data = up.read()
if not data:
return jsonify({"status": "error", "message": "Пустой файл"}), 400
try:
result = restore_sqlite_from_zip(current_app, data)
except ValueError as exc:
return jsonify({"status": "error", "message": str(exc)}), 400
return jsonify({"status": "success", **result})
@bp.get("/plymouth/status")
@require_superuser
def admin_plymouth_status():
from app.services.plymouth_theme_service import get_plymouth_status
return jsonify({"status": "success", **get_plymouth_status(current_app)})
@bp.get("/pi-boot/rainbow-splash/status")
@require_superuser
def admin_pi_boot_rainbow_splash_status():
from app.services.pi_boot_config_service import get_rainbow_splash_status
path = (current_app.config.get("WESP_PI_BOOT_CONFIG_PATH") or "").strip()
return jsonify(
{"status": "success", **get_rainbow_splash_status(path)},
)
@bp.post("/pi-boot/rainbow-splash")
@require_superuser
def admin_pi_boot_rainbow_splash_set():
"""disable_splash=1 в config.txt — убрать радужный квадрат прошивки до Plymouth."""
from app.services.pi_boot_config_service import set_rainbow_splash_disabled
payload = request.get_json(silent=True) or {}
if "enabled" not in payload:
return jsonify(
{"status": "error", "message": "Укажите enabled: true или false"},
), 400
enabled = bool(payload.get("enabled"))
path = (current_app.config.get("WESP_PI_BOOT_CONFIG_PATH") or "").strip()
write_cmd = (current_app.config.get("WESP_PI_BOOT_CONFIG_WRITE_CMD") or "").strip()
result = set_rainbow_splash_disabled(
enabled,
config_path=path,
write_cmd=write_cmd,
)
if result.get("ok"):
from app.services.system_restart_service import maybe_auto_reboot_after_pi_boot_change
reboot = maybe_auto_reboot_after_pi_boot_change(current_app.config)
result["reboot_scheduled"] = bool(reboot.get("scheduled"))
if reboot.get("scheduled") and reboot.get("message"):
result["message"] = (
f"{result.get('message', '').rstrip('.')}. {reboot['message']}"
)
return jsonify({"status": "success", **result})
return jsonify({"status": "error", **result}), 400
@bp.post("/plymouth/install")
@require_superuser
def admin_plymouth_install():
"""Собрать кадры 800×600 и установить тему Plymouth WESP (фон, нужен sudo)."""
from app.services.plymouth_theme_service import start_build_and_install
result = start_build_and_install(current_app)
if result.get("ok"):
return jsonify({"status": "success", **result})
return jsonify({"status": "error", **result}), 409
@bp.get("/kiosk-full-setup/status")
@require_superuser
def admin_kiosk_full_setup_status():
from app.services.kiosk_full_setup_service import get_full_kiosk_setup_status
return jsonify(
{"status": "success", **get_full_kiosk_setup_status(current_app.config)},
)
@bp.post("/kiosk-full-setup")
@require_superuser
def admin_kiosk_full_setup_apply():
"""wesp.env + systemd + киоск (без рабочего стола) + одна перезагрузка."""
from app.services.kiosk_full_setup_service import apply_full_kiosk_setup
payload = request.get_json(silent=True) or {}
hide_desktop = payload.get("hide_desktop")
if hide_desktop is None:
hide_desktop = True
else:
hide_desktop = bool(hide_desktop)
result = apply_full_kiosk_setup(
current_app.config,
hide_desktop=hide_desktop,
)
if result.get("ok"):
return jsonify({"status": "success", **result})
return jsonify({"status": "error", **result}), 400
@bp.get("/pi-platform/status")
@require_superuser
def admin_pi_platform_status():
from app.services.pi_platform_setup_service import get_pi_platform_setup_status
return jsonify(
{"status": "success", **get_pi_platform_setup_status(current_app.config)},
)
@bp.post("/pi-platform/apply")
@require_superuser
def admin_pi_platform_apply():
"""wesp.env + systemd unit (как install_fresh.sh)."""
from app.services.pi_platform_setup_service import apply_pi_platform_setup
result = apply_pi_platform_setup(current_app.config)
if result.get("ok"):
return jsonify({"status": "success", **result})
return jsonify({"status": "error", **result}), 400
@bp.get("/kiosk-boot/status")
@require_superuser
def admin_kiosk_boot_status():
from app.services.kiosk_boot_service import get_kiosk_boot_status
cfg = current_app.config
return jsonify(
{
"status": "success",
**get_kiosk_boot_status(cfg),
},
)
@bp.post("/kiosk-boot")
@require_superuser
def admin_kiosk_boot_set():
"""Включить/выключить chromium-kiosk.desktop для GUI-пользователя."""
from app.services.kiosk_boot_service import set_kiosk_boot
payload = request.get_json(silent=True) or {}
if "enabled" not in payload:
return jsonify(
{"status": "error", "message": "Укажите enabled: true или false"},
), 400
enabled = bool(payload.get("enabled"))
hide_desktop = payload.get("hide_desktop")
if hide_desktop is not None:
hide_desktop = bool(hide_desktop)
cfg = current_app.config
result = set_kiosk_boot(
enabled,
cfg,
hide_desktop=hide_desktop,
)
if result.get("ok"):
return jsonify({"status": "success", **result})
return jsonify({"status": "error", **result}), 400
@bp.post("/service-control")
@require_superuser
def admin_service_control():
"""Опционально: WESP_ADMIN_RESTART_CMD / WESP_ADMIN_STOP_CMD."""
payload = request.get_json(silent=True) or {}
action = (payload.get("action") or "").strip().lower()
if action not in ("restart", "stop"):
return jsonify({"status": "error", "message": "Укажите action: restart или stop"}), 400
result = run_service_command(current_app, action)
if result.get("ok"):
return jsonify({"status": "success", **result})
return jsonify({"status": "error", **result}), 400
@bp.get("/users")
@require_superuser
def list_users():
_ensure_default_superuser()
rows = db.session.execute(select(WebUser).order_by(WebUser.login.asc())).scalars().all()
return jsonify(
{
"status": "success",
"users": [
{
"id": row.id,
"login": row.login,
"is_superuser": bool(row.is_superuser),
"lab_access": bool(row.lab_access),
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
for row in rows
],
}
)
@bp.post("/users")
@require_superuser
def create_user():
_ensure_default_superuser()
payload = request.get_json(silent=True) or {}
login = (payload.get("login") or "").strip()
password = payload.get("password") or ""
is_superuser = bool(payload.get("is_superuser", False))
lab_access = bool(payload.get("lab_access", False))
if not login:
return jsonify({"status": "error", "message": "Логин обязателен"}), 400
if not _valid_login(login):
return jsonify({"status": "error", "message": "Логин не длиннее 64 символов"}), 400
if len(password) < _WEB_USER_PASSWORD_MIN_LEN:
return jsonify(
{
"status": "error",
"message": f"Пароль должен быть не короче {_WEB_USER_PASSWORD_MIN_LEN} символов",
}
), 400
err_body, err_code = _reject_if_weak_login_or_password(login, password)
if err_body is not None:
return jsonify(err_body), err_code
existing = db.session.execute(select(WebUser).where(WebUser.login == login)).scalar_one_or_none()
if existing is not None:
return jsonify({"status": "error", "message": "Пользователь с таким логином уже существует"}), 409
user = WebUser(
login=login,
password_hash=generate_password_hash(password),
is_superuser=is_superuser,
lab_access=lab_access if not is_superuser else False,
)
db.session.add(user)
db.session.commit()
return jsonify({"status": "success", "id": user.id}), 201
@bp.patch("/users/<string:user_id>")
@require_superuser
def update_user(user_id: str):
_ensure_default_superuser()
payload = request.get_json(silent=True) or {}
user = db.session.execute(select(WebUser).where(WebUser.id == user_id)).scalar_one_or_none()
if user is None:
return jsonify({"status": "error", "message": "Пользователь не найден"}), 404
new_password = payload.get("password")
if isinstance(new_password, str) and new_password:
if len(new_password) < _WEB_USER_PASSWORD_MIN_LEN:
return jsonify(
{
"status": "error",
"message": f"Пароль должен быть не короче {_WEB_USER_PASSWORD_MIN_LEN} символов",
}
), 400
err_body, err_code = _reject_if_weak_password_only(new_password)
if err_body is not None:
return jsonify(err_body), err_code
user.password_hash = generate_password_hash(new_password)
if "is_superuser" in payload:
target_flag = bool(payload.get("is_superuser"))
if user.login == session.get("user_login") and not target_flag:
return jsonify({"status": "error", "message": "Нельзя снять права суперпользователя у текущего пользователя"}), 400
if not target_flag and user.is_superuser:
superusers_count = int(
db.session.scalar(
select(func.count()).select_from(WebUser).where(WebUser.is_superuser.is_(True))
)
or 0
)
if superusers_count <= 1:
return jsonify({"status": "error", "message": "Нельзя убрать права у последнего суперпользователя"}), 400
user.is_superuser = target_flag
if "lab_access" in payload:
user.lab_access = bool(payload.get("lab_access"))
db.session.commit()
return jsonify({"status": "success"})
@bp.delete("/users/<string:user_id>")
@require_superuser
def delete_user(user_id: str):
_ensure_default_superuser()
user = db.session.execute(select(WebUser).where(WebUser.id == user_id)).scalar_one_or_none()
if user is None:
return jsonify({"status": "error", "message": "Пользователь не найден"}), 404
if user.login == session.get("user_login"):
return jsonify({"status": "error", "message": "Нельзя удалить текущего пользователя"}), 400
if user.is_superuser:
superusers_count = int(
db.session.scalar(
select(func.count()).select_from(WebUser).where(WebUser.is_superuser.is_(True))
)
or 0
)
if superusers_count <= 1:
return jsonify({"status": "error", "message": "Нельзя удалить последнего суперпользователя"}), 400
db.session.delete(user)
db.session.commit()
return jsonify({"status": "success"})