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": "Ожидается 30–86400 с (см. 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//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/") @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/") @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"})