3290 lines
127 KiB
Python
3290 lines
127 KiB
Python
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/<string:node_id>/ip")
|
||
@require_superuser
|
||
def admin_sync_client_ip_patch(node_id: str):
|
||
payload = request.get_json(silent=True) or {}
|
||
raw = str(payload.get("ip_address") or "").strip()
|
||
host = None
|
||
if raw:
|
||
try:
|
||
host = validate_target_host(raw)
|
||
except ValueError as exc:
|
||
return jsonify({"status": "error", "message": str(exc)}), 400
|
||
row = db.session.execute(
|
||
select(SyncClient).where(SyncClient.node_id == node_id, SyncClient.is_deleted.is_(False))
|
||
).scalar_one_or_none()
|
||
if row is None:
|
||
return jsonify({"status": "error", "message": "Клиент не найден"}), 404
|
||
row.ip_address = host
|
||
db.session.commit()
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"message": "IP клиента обновлён",
|
||
"client": {
|
||
"node_id": row.node_id,
|
||
"client_name": row.client_name,
|
||
"ip_address": row.ip_address,
|
||
"last_seen": row.last_seen.isoformat() if row.last_seen else None,
|
||
},
|
||
}
|
||
)
|
||
|
||
|
||
@bp.get("/diagnostics/settings")
|
||
@require_superuser
|
||
def admin_diagnostics_settings_get():
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"extra_targets": _read_diagnostics_extra_targets(),
|
||
"path": str(get_network_diagnostics_path()),
|
||
}
|
||
)
|
||
|
||
|
||
@bp.patch("/diagnostics/settings")
|
||
@require_superuser
|
||
def admin_diagnostics_settings_patch():
|
||
payload = request.get_json(silent=True) or {}
|
||
try:
|
||
extra_targets = normalize_extra_targets(payload.get("extra_targets"))
|
||
except ValueError as exc:
|
||
return jsonify({"status": "error", "message": str(exc)}), 400
|
||
write_network_diagnostics_settings({"extra_targets": extra_targets})
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"message": "Список контрольных точек сохранён.",
|
||
"extra_targets": extra_targets,
|
||
"path": str(get_network_diagnostics_path()),
|
||
}
|
||
)
|
||
|
||
|
||
@bp.get("/diagnostics/report")
|
||
@require_superuser
|
||
def admin_diagnostics_report():
|
||
return jsonify(_build_diagnostics_report(current_app))
|
||
|
||
|
||
@bp.post("/diagnostics/network-run")
|
||
@require_superuser
|
||
def admin_diagnostics_network_run():
|
||
payload = request.get_json(silent=True) or {}
|
||
report = _build_diagnostics_report(current_app)
|
||
scope = str(payload.get("scope") or "all").strip().lower()
|
||
run_trace = bool(payload.get("run_trace", False))
|
||
try:
|
||
timeout_sec = max(1, min(int(payload.get("timeout_sec", 3)), 20))
|
||
except (TypeError, ValueError):
|
||
timeout_sec = 3
|
||
prefer_mtr = bool(payload.get("prefer_mtr", False))
|
||
try:
|
||
tcp_port = int(payload.get("tcp_port", _default_tcp_probe_port()))
|
||
except (TypeError, ValueError):
|
||
tcp_port = _default_tcp_probe_port()
|
||
if tcp_port < 1 or tcp_port > 65535:
|
||
return jsonify({"status": "error", "message": "tcp_port должен быть от 1 до 65535"}), 400
|
||
|
||
target_items: list[dict] = []
|
||
if scope in {"primary", "all"}:
|
||
target_items.extend(report.get("primary_targets") or [])
|
||
if scope in {"extra", "all"}:
|
||
target_items.extend(report.get("extra_targets") or [])
|
||
if scope == "custom":
|
||
raw_custom = payload.get("custom_targets")
|
||
if not isinstance(raw_custom, list):
|
||
return jsonify(
|
||
{"status": "error", "message": "Для scope=custom передайте массив custom_targets"}
|
||
), 400
|
||
for host in raw_custom:
|
||
if str(host or "").strip():
|
||
target_items.append({"title": str(host).strip(), "host": str(host).strip()})
|
||
results: list[dict] = []
|
||
for item in target_items[:80]:
|
||
try:
|
||
host = validate_target_host(item.get("host"))
|
||
except ValueError as exc:
|
||
results.append(
|
||
{
|
||
"title": str(item.get("title") or item.get("host") or "—"),
|
||
"host": str(item.get("host") or ""),
|
||
"ok": False,
|
||
"error": str(exc),
|
||
}
|
||
)
|
||
continue
|
||
probes = _probe_target_host(
|
||
host,
|
||
run_trace=run_trace,
|
||
timeout_sec=timeout_sec,
|
||
prefer_mtr=prefer_mtr,
|
||
tcp_port=tcp_port,
|
||
)
|
||
ping_ok = bool((probes.get("ping") or {}).get("ok"))
|
||
tcp_ok = bool((probes.get("tcp") or {}).get("ok"))
|
||
trace_ok = probes.get("trace") is None or bool((probes.get("trace") or {}).get("ok"))
|
||
results.append(
|
||
{
|
||
"title": str(item.get("title") or host),
|
||
"host": host,
|
||
"meta": item.get("meta") or {},
|
||
"ok": ping_ok and (tcp_ok or trace_ok),
|
||
"probes": probes,
|
||
}
|
||
)
|
||
|
||
internet_urls = payload.get("internet_urls")
|
||
if not isinstance(internet_urls, list) or not internet_urls:
|
||
internet_urls = ["https://www.google.com/generate_204", "https://1.1.1.1"]
|
||
if not target_items and not internet_urls:
|
||
return jsonify({"status": "error", "message": "Нет целей для проверки"}), 400
|
||
internet_results: list[dict] = []
|
||
for url in internet_urls[:8]:
|
||
val = str(url or "").strip()
|
||
if not val:
|
||
continue
|
||
try:
|
||
internet_results.append(http_probe(val, timeout_sec=max(2, timeout_sec)))
|
||
except ValueError as exc:
|
||
internet_results.append({"ok": False, "url": val, "error": str(exc)})
|
||
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"generated_at": datetime.utcnow().isoformat(),
|
||
"scope": scope,
|
||
"node_role": report.get("node_role"),
|
||
"results": results,
|
||
"internet": internet_results,
|
||
}
|
||
)
|
||
|
||
|
||
@bp.post("/diagnostics/hx711-sample")
|
||
@require_superuser
|
||
def admin_diagnostics_hx711_sample():
|
||
cfg = current_app.config
|
||
simulation = _effective_simulation_mode(current_app)
|
||
dout = int(cfg.get("HX711_DOUT_PIN", 2))
|
||
pd_sck = int(cfg.get("HX711_PD_SCK_PIN", 3))
|
||
wrapper = HX711Wrapper(simulation_mode=simulation, dout_pin=dout, pd_sck_pin=pd_sck)
|
||
try:
|
||
value = float(wrapper.read_weight_sample())
|
||
except HX711UnavailableError as exc:
|
||
return jsonify(
|
||
{
|
||
"status": "error",
|
||
"message": str(exc),
|
||
"simulation_mode": simulation,
|
||
"driver_ready": bool(getattr(wrapper, "_device", None) is not None),
|
||
"pins": {"dout": dout, "pd_sck": pd_sck},
|
||
}
|
||
), 503
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"sample": value,
|
||
"simulation_mode": simulation,
|
||
"driver_ready": bool(getattr(wrapper, "_device", None) is not None),
|
||
"pins": {"dout": dout, "pd_sck": pd_sck},
|
||
}
|
||
)
|
||
|
||
|
||
@bp.post("/diagnostics/gpio-blink")
|
||
@require_superuser
|
||
def admin_diagnostics_gpio_blink():
|
||
payload = request.get_json(silent=True) or {}
|
||
try:
|
||
times = max(1, min(int(payload.get("times", 3)), 20))
|
||
except (TypeError, ValueError):
|
||
return jsonify({"status": "error", "message": "times должен быть целым числом"}), 400
|
||
try:
|
||
delay = max(0.05, min(float(payload.get("delay", 0.2)), 3.0))
|
||
except (TypeError, ValueError):
|
||
return jsonify({"status": "error", "message": "delay должен быть числом"}), 400
|
||
cfg = current_app.config
|
||
simulation = _effective_simulation_mode(current_app)
|
||
pin = int(cfg.get("GPIO_LED_PIN", 18))
|
||
ctrl = GPIOController.get_instance(pin=pin, simulation_mode=simulation)
|
||
ctrl.blink(times=times, delay=delay)
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"message": f"Запущено мигание: {times} раз, задержка {delay:.2f} с",
|
||
"pin": pin,
|
||
"simulation_mode": simulation,
|
||
"rpi_gpio_available": bool(ctrl.rpi_gpio_available),
|
||
}
|
||
)
|
||
|
||
|
||
@bp.post("/diagnostics/traceroute-analyze")
|
||
@require_superuser
|
||
def admin_diagnostics_traceroute_analyze():
|
||
"""Разбор текста вывода traceroute (сырой текст; UI в админке — модалка «Трассировка по IP»)."""
|
||
payload = request.get_json(silent=True) or {}
|
||
text = str(payload.get("text") or payload.get("traceroute") or "")
|
||
if not text.strip():
|
||
return jsonify(
|
||
{"status": "error", "message": "Укажите поле text с выводом traceroute"}
|
||
), 400
|
||
try:
|
||
parsed = parse_traceroute_text(text)
|
||
except Exception as exc:
|
||
return jsonify({"status": "error", "message": str(exc)}), 400
|
||
return jsonify({"status": "success", **parsed})
|
||
|
||
|
||
@bp.post("/diagnostics/traceroute-by-hosts")
|
||
@require_superuser
|
||
def admin_diagnostics_traceroute_by_hosts():
|
||
"""Запуск traceroute/tracepath/mtr на сервере по списку хостов и разбор вывода."""
|
||
payload = request.get_json(silent=True) or {}
|
||
raw = payload.get("hosts")
|
||
if isinstance(raw, str):
|
||
hosts = [ln.strip() for ln in raw.splitlines() if ln.strip()]
|
||
elif isinstance(raw, list):
|
||
hosts = [str(x).strip() for x in raw if str(x).strip()]
|
||
else:
|
||
hosts = []
|
||
if not hosts:
|
||
return jsonify(
|
||
{"status": "error", "message": "Укажите IP или хосты (по одному в строке)"}
|
||
), 400
|
||
if len(hosts) > 24:
|
||
return jsonify(
|
||
{"status": "error", "message": "Не более 24 адресов за один запрос"}
|
||
), 400
|
||
try:
|
||
timeout_sec = int(payload.get("timeout_sec", 12))
|
||
timeout_sec = max(4, min(timeout_sec, 60))
|
||
except (TypeError, ValueError):
|
||
timeout_sec = 12
|
||
prefer_mtr = bool(payload.get("prefer_mtr", False))
|
||
try:
|
||
tcp_port = int(payload.get("tcp_port", _default_tcp_probe_port()))
|
||
except (TypeError, ValueError):
|
||
tcp_port = _default_tcp_probe_port()
|
||
if tcp_port < 1 or tcp_port > 65535:
|
||
return jsonify({"status": "error", "message": "tcp_port должен быть от 1 до 65535"}), 400
|
||
probe_timeout = max(1, min(8, min(timeout_sec, 20) // 2 or 3))
|
||
|
||
results: list[dict] = []
|
||
seen: set[str] = set()
|
||
for h in hosts:
|
||
key = h.strip().lower()
|
||
if not key or key in seen:
|
||
continue
|
||
seen.add(key)
|
||
try:
|
||
safe = validate_target_host(h)
|
||
except ValueError as exc:
|
||
results.append(
|
||
{
|
||
"host": h.strip(),
|
||
"validate_error": str(exc),
|
||
"trace": None,
|
||
"parse": None,
|
||
}
|
||
)
|
||
continue
|
||
ping = ping_host(safe, timeout_sec=probe_timeout)
|
||
tcp = tcp_probe(safe, tcp_port, timeout_sec=probe_timeout)
|
||
tr = trace_host(
|
||
safe,
|
||
timeout_sec=timeout_sec,
|
||
prefer_mtr=prefer_mtr,
|
||
max_output_lines=128,
|
||
)
|
||
text = "\n".join(tr.get("lines") or [])
|
||
parsed = None
|
||
if text.strip():
|
||
try:
|
||
pr = parse_traceroute_text(text)
|
||
if pr.get("hops"):
|
||
parsed = pr
|
||
except Exception:
|
||
parsed = None
|
||
results.append(
|
||
{
|
||
"host": safe,
|
||
"validate_error": None,
|
||
"ping": ping,
|
||
"tcp": tcp,
|
||
"trace": {
|
||
"ok": bool(tr.get("ok")),
|
||
"tool": tr.get("tool"),
|
||
"exit_code": tr.get("exit_code"),
|
||
"error": tr.get("error"),
|
||
"command": tr.get("command"),
|
||
"lines": tr.get("lines") or [],
|
||
},
|
||
"parse": parsed,
|
||
}
|
||
)
|
||
return jsonify({"status": "success", "results": results})
|
||
|
||
|
||
def _count_active_rows(model) -> int:
|
||
query = select(func.count()).select_from(model)
|
||
if hasattr(model, "is_deleted"):
|
||
query = query.where(
|
||
or_(model.is_deleted.is_(False), model.is_deleted.is_(None))
|
||
)
|
||
return int(db.session.scalar(query) or 0)
|
||
|
||
|
||
def _valid_login(login: str) -> bool:
|
||
s = (login or "").strip()
|
||
return 0 < len(s) <= _WEB_USER_LOGIN_MAX_LEN
|
||
|
||
|
||
@bp.get("/summary")
|
||
@require_superuser
|
||
def admin_summary():
|
||
_ensure_default_superuser()
|
||
|
||
recipes_uri = current_app.config.get("SQLALCHEMY_DATABASE_URI", "")
|
||
reports_uri = (current_app.config.get("SQLALCHEMY_BINDS") or {}).get("reports", "")
|
||
|
||
queue_base = select(SyncQueue).where(SyncQueue.is_deleted.is_(False)).subquery()
|
||
queue_stats = {
|
||
"total": int(db.session.scalar(select(func.count()).select_from(queue_base)) or 0),
|
||
"pending": int(
|
||
db.session.scalar(
|
||
select(func.count()).select_from(queue_base).where(
|
||
queue_base.c.status == "pending"
|
||
)
|
||
)
|
||
or 0
|
||
),
|
||
"processing": int(
|
||
db.session.scalar(
|
||
select(func.count()).select_from(queue_base).where(
|
||
queue_base.c.status == "processing"
|
||
)
|
||
)
|
||
or 0
|
||
),
|
||
"failed": int(
|
||
db.session.scalar(
|
||
select(func.count()).select_from(queue_base).where(
|
||
queue_base.c.status == "failed"
|
||
)
|
||
)
|
||
or 0
|
||
),
|
||
}
|
||
|
||
sync_state = read_sync_client_state()
|
||
sync_state_safe = {
|
||
key: value
|
||
for key, value in sync_state.items()
|
||
if key not in {"token", "auth_token", "password", "secret"}
|
||
}
|
||
sync_connection = _effective_sync_connection(current_app)
|
||
effective_role = str(sync_connection.get("role") or "server").strip() or "server"
|
||
|
||
initial_sync_progress: dict = {"active": False}
|
||
try:
|
||
from sync_client import get_initial_sync_progress_snapshot
|
||
|
||
initial_sync_progress = get_initial_sync_progress_snapshot()
|
||
except Exception:
|
||
logging.getLogger("app").debug(
|
||
"admin summary: снимок прогресса первой синхронизации недоступен",
|
||
exc_info=True,
|
||
)
|
||
|
||
install_w = get_install_and_warranty(current_app)
|
||
_base_dir = str(current_app.config.get("BASE_DIR", ".") or ".")
|
||
data = {
|
||
"status": "success",
|
||
"generated_at": datetime.utcnow().isoformat(),
|
||
"app": {
|
||
"config_mode": current_app.config.get("WESP_CONFIG", "development"),
|
||
"debug": bool(current_app.config.get("DEBUG", False)),
|
||
"version": current_app.config.get("SYNC_CLIENT_VERSION", "1.0.0"),
|
||
"role": effective_role,
|
||
"first_launch_at": install_w["first_launch_at"],
|
||
"warranty_until": install_w["warranty_until"],
|
||
"warranty_days_remaining": install_w["warranty_days_remaining"],
|
||
},
|
||
"databases": {
|
||
"recipes": _database_descriptor_with_size(recipes_uri, _base_dir),
|
||
"reports": _database_descriptor_with_size(reports_uri, _base_dir),
|
||
},
|
||
"machine": collect_host_hardware_overview(),
|
||
"counts": {
|
||
"users": _count_active_rows(WebUser),
|
||
"recipes": _count_active_rows(Recipe),
|
||
"ingredients": _count_active_rows(Ingredient),
|
||
"components": _count_active_rows(Component),
|
||
"feed_dispensers": _count_active_rows(FeedDispenser),
|
||
"feeding_periods": _count_active_rows(FeedingPeriod),
|
||
},
|
||
"sync": {
|
||
"client_role": effective_role,
|
||
"connection": sync_connection,
|
||
"max_concurrent": int(current_app.config.get("SYNC_MAX_CONCURRENT", 4)),
|
||
"queue": queue_stats,
|
||
"client_poll_interval_sec": int(
|
||
current_app.config.get("SYNC_CLIENT_POLL_INTERVAL_SEC", 14)
|
||
),
|
||
"client_poll_interval_initial_sec": int(
|
||
current_app.config.get("SYNC_CLIENT_POLL_INTERVAL_INITIAL_SEC", 14)
|
||
),
|
||
"client_pull_limit_steady": int(
|
||
current_app.config.get("SYNC_CLIENT_DEFAULT_PULL_LIMIT", 100)
|
||
),
|
||
"client_pull_limit_initial": int(
|
||
current_app.config.get("SYNC_CLIENT_PULL_LIMIT_INITIAL", 50)
|
||
),
|
||
"first_sync_banner": bool(
|
||
effective_role == "client"
|
||
and not sync_state_safe.get("first_bootstrap_done", True)
|
||
),
|
||
"initial_sync_progress": initial_sync_progress,
|
||
"guarantees": {
|
||
"master_data": (
|
||
"Источник правды для recipes.db и мастер-таблиц — сервер; "
|
||
"клиенты получают копию через pull."
|
||
),
|
||
"reports": "Отчёты с полевых узлов принимаются на сервер через push.",
|
||
"identity": (
|
||
"Побайтовое совпадение файлов SQLite между узлами не гарантируется; "
|
||
"сверка digest — GET /api/sync/consistency."
|
||
),
|
||
},
|
||
"state": sync_state_safe,
|
||
"queue_errors": _recent_queue_sync_display_errors(25),
|
||
},
|
||
"security": _security_summary_payload(current_app),
|
||
"auto_update": public_auto_update_settings_payload(),
|
||
"hardware": _summary_hardware_payload(current_app),
|
||
}
|
||
return jsonify(data)
|
||
|
||
|
||
def _summary_hardware_payload(app) -> dict:
|
||
"""Краткий снимок периферии для GET /api/admin/summary."""
|
||
try:
|
||
snap = build_hardware_status(app)
|
||
cfg = snap.get("config") or {}
|
||
periph = snap.get("peripherals") or {}
|
||
scales = snap.get("scales") or {}
|
||
out = {**cfg, **periph}
|
||
if scales.get("available"):
|
||
out["weight_kg"] = scales.get("weight_kg")
|
||
out["hx711_ok"] = scales.get("hx711_ok")
|
||
out["hx711_error"] = scales.get("hx711_error")
|
||
out["counts_per_kg"] = scales.get("counts_per_kg")
|
||
out["tare_raw"] = scales.get("tare_raw")
|
||
out["error_streak"] = scales.get("error_streak")
|
||
else:
|
||
out["scales_error"] = scales.get("error")
|
||
return out
|
||
except Exception:
|
||
logging.getLogger("app").debug("summary hardware snapshot failed", exc_info=True)
|
||
return {
|
||
"simulation_mode": bool(app.config.get("SIMULATION_MODE", False)),
|
||
"read_interval": float(app.config.get("READ_INTERVAL", 0.05)),
|
||
"samples_per_read": int(app.config.get("SAMPLES_PER_READ", 3)),
|
||
"hx711_dout_pin": int(app.config.get("HX711_DOUT_PIN", 2)),
|
||
"hx711_pd_sck_pin": int(app.config.get("HX711_PD_SCK_PIN", 3)),
|
||
"gpio_led_pin": int(app.config.get("GPIO_LED_PIN", 18)),
|
||
}
|
||
|
||
|
||
@bp.get("/sync-diagnostics")
|
||
@require_superuser
|
||
def admin_sync_diagnostics():
|
||
"""Полная диагностика sync (superuser): очереди, runtime, bootstrap, отчёты, конфликты."""
|
||
_ensure_default_superuser()
|
||
from app.services.admin_sync_diagnostics_service import build_sync_diagnostics_payload
|
||
|
||
payload = build_sync_diagnostics_payload(current_app)
|
||
return jsonify({"status": "success", **payload})
|
||
|
||
|
||
@bp.post("/sync-actions/requeue-stuck")
|
||
@require_superuser
|
||
def admin_sync_action_requeue_stuck():
|
||
_ensure_default_superuser()
|
||
from app.services.sync_manager import requeue_stuck_processing
|
||
|
||
timeout = int(
|
||
current_app.config.get(
|
||
"SYNC_REQUEUE_TIMEOUT_MINUTES",
|
||
getattr(Config, "SYNC_REQUEUE_TIMEOUT_MINUTES", 15),
|
||
)
|
||
)
|
||
try:
|
||
n = int(requeue_stuck_processing(timeout_minutes=timeout))
|
||
except Exception as exc:
|
||
logging.getLogger("app").exception("POST /sync-actions/requeue-stuck")
|
||
return jsonify({"status": "error", "message": str(exc)}), 500
|
||
db.session.expire_all()
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"requeued": n,
|
||
"timeout_minutes": timeout,
|
||
"message": f"Переставлено зависших processing→pending: {n}",
|
||
}
|
||
)
|
||
|
||
|
||
@bp.post("/sync-actions/restart-local-sync")
|
||
@require_superuser
|
||
def admin_sync_action_restart_local_sync():
|
||
"""Перечитать sync_client_state и перезапустить фоновый sync в этом процессе (клиент)."""
|
||
_ensure_default_superuser()
|
||
if current_app.config.get("TESTING"):
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"reloaded": False,
|
||
"message": "В режиме TESTING перезапуск sync_client не выполняется.",
|
||
}
|
||
)
|
||
try:
|
||
from sync_client import apply_sync_client_runtime
|
||
|
||
ok = bool(apply_sync_client_runtime(current_app))
|
||
except Exception as exc:
|
||
logging.getLogger("app").exception("POST /sync-actions/restart-local-sync")
|
||
return jsonify({"status": "error", "message": str(exc)}), 500
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"reloaded": ok,
|
||
"message": (
|
||
"Фоновый sync перезапущен с текущими настройками."
|
||
if ok
|
||
else "Перезапуск не выполнен (AUTOSTART=off, reloader parent или роль не client)."
|
||
),
|
||
}
|
||
)
|
||
|
||
|
||
@bp.post("/sync-actions/refresh-runtime")
|
||
@require_superuser
|
||
def admin_sync_action_refresh_runtime():
|
||
"""Вернуть актуальный снимок process-local runtime (без сброса счётчиков)."""
|
||
_ensure_default_superuser()
|
||
from app.services.sync_runtime import sync_runtime
|
||
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"message": "Снимок runtime текущего воркера.",
|
||
"runtime": sync_runtime.diagnostics_snapshot(),
|
||
}
|
||
)
|
||
|
||
|
||
@bp.post("/sync-actions/retry-report-push")
|
||
@require_superuser
|
||
def admin_sync_action_retry_report_push():
|
||
"""Один цикл sync в фоне (на клиенте: push отчётов + pull + confirm)."""
|
||
_ensure_default_superuser()
|
||
if current_app.config.get("TESTING"):
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"scheduled": False,
|
||
"message": "В TESTING фоновый sync_cycle не планируется.",
|
||
}
|
||
)
|
||
role = str(_effective_sync_connection(current_app).get("role") or "").strip().lower()
|
||
if role != "client":
|
||
return jsonify(
|
||
{"status": "error", "message": "Доступно только при эффективной роли client."}
|
||
), 400
|
||
import sync_client as sc
|
||
|
||
client = getattr(sc, "sync_client", None)
|
||
if client is None:
|
||
return jsonify({"status": "error", "message": "sync_client не инициализирован."}), 400
|
||
app_ref = current_app._get_current_object()
|
||
|
||
def _run() -> None:
|
||
with app_ref.app_context():
|
||
try:
|
||
client.sync_cycle()
|
||
except Exception:
|
||
logging.getLogger("app").exception("retry-report-push: sync_cycle")
|
||
|
||
threading.Thread(target=_run, daemon=True, name="wesp-admin-report-push").start()
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"scheduled": True,
|
||
"message": "Запланирован sync_cycle() в фоновом потоке.",
|
||
}
|
||
)
|
||
|
||
|
||
@bp.patch("/auto-update-settings")
|
||
@require_superuser
|
||
def admin_auto_update_settings_patch():
|
||
"""
|
||
Публичные поля автообновления в recipes.db (таблица auto_update_settings).
|
||
Секреты API — PATCH /api/admin/gitea-secrets, GITEA_* в окружении или .secret/gitea_secrets.json.
|
||
"""
|
||
payload = request.get_json(silent=True) or {}
|
||
if not isinstance(payload, dict):
|
||
return jsonify({"status": "error", "message": "Ожидался JSON-объект"}), 400
|
||
allowed = {
|
||
k: payload[k]
|
||
for k in (
|
||
"enabled",
|
||
"auto_install",
|
||
"gitea_url",
|
||
"gitea_owner",
|
||
"gitea_repo",
|
||
"repository_url",
|
||
"check_interval_sec",
|
||
)
|
||
if k in payload
|
||
}
|
||
if not allowed:
|
||
return jsonify({"status": "error", "message": "Нет поддерживаемых полей для сохранения"}), 400
|
||
try:
|
||
payload_out = apply_auto_update_admin_patch(allowed)
|
||
except Exception as e:
|
||
return jsonify({"status": "error", "message": f"Не удалось записать в БД: {e}"}), 500
|
||
try:
|
||
from app.services.auto_update_runtime import reload_update_checker
|
||
|
||
reload_update_checker()
|
||
except Exception:
|
||
pass
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"message": "Сохранено в recipes.db. Проверка обновлений перезагружена без перезапуска процесса.",
|
||
"auto_update": payload_out,
|
||
}
|
||
)
|
||
|
||
|
||
@bp.patch("/gitea-secrets")
|
||
@require_superuser
|
||
def admin_gitea_secrets_patch():
|
||
"""Зашифрованные секреты Gitea в .secret/gitea_secrets.json (ключ — SECRET_KEY / WESP_SECRET_KEY)."""
|
||
payload = request.get_json(silent=True) or {}
|
||
if not isinstance(payload, dict):
|
||
return jsonify({"status": "error", "message": "Ожидался JSON-объект"}), 400
|
||
clear = bool(payload.get("clear_secrets"))
|
||
token = payload.get("gitea_token")
|
||
user = payload.get("gitea_username")
|
||
pwd = payload.get("gitea_password")
|
||
has_new = (
|
||
(isinstance(token, str) and token.strip())
|
||
or (isinstance(user, str) and user.strip())
|
||
or (isinstance(pwd, str) and pwd.strip())
|
||
)
|
||
if not clear and not has_new:
|
||
return jsonify(
|
||
{
|
||
"status": "error",
|
||
"message": "Укажите токен или логин и пароль API, либо включите clear_secrets",
|
||
}
|
||
), 400
|
||
try:
|
||
patch_gitea_secrets_file(
|
||
str(_resolve_base_dir()),
|
||
current_app.config["SECRET_KEY"],
|
||
gitea_token=token if isinstance(token, str) else None,
|
||
gitea_username=user if isinstance(user, str) else None,
|
||
gitea_password=pwd if isinstance(pwd, str) else None,
|
||
clear_secrets=clear,
|
||
)
|
||
except ValueError as e:
|
||
return jsonify({"status": "error", "message": str(e)}), 400
|
||
except Exception as e:
|
||
return jsonify({"status": "error", "message": str(e)}), 500
|
||
try:
|
||
from app.services.auto_update_runtime import reload_update_checker
|
||
|
||
reload_update_checker()
|
||
except Exception:
|
||
pass
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"message": "Секреты Gitea сохранены. Проверка обновлений перезагружена без перезапуска процесса.",
|
||
"auto_update": public_auto_update_settings_payload(),
|
||
}
|
||
)
|
||
|
||
|
||
@bp.get("/system-metrics")
|
||
@require_superuser
|
||
def admin_system_metrics():
|
||
"""Метрики CPU/RAM/swap/диск и скорость сети (требуется psutil на сервере)."""
|
||
metrics = collect_system_metrics()
|
||
if metrics.get("available"):
|
||
maybe_append_hardware_sample(current_app, metrics)
|
||
return jsonify({"status": "success", "metrics": metrics})
|
||
|
||
|
||
@bp.get("/hardware-metrics-history")
|
||
@require_superuser
|
||
def admin_hardware_metrics_history():
|
||
"""История нагрузки из JSONL (последние N дней, см. WESP_ADMIN_HARDWARE_LOG_*)."""
|
||
points, path = read_hardware_history(current_app)
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"points": points,
|
||
"path": path or None,
|
||
"retention_days": int(
|
||
current_app.config.get("WESP_ADMIN_HARDWARE_LOG_RETENTION_DAYS", 3) or 3
|
||
),
|
||
}
|
||
)
|
||
|
||
|
||
@bp.get("/hardware-status")
|
||
@require_superuser
|
||
def admin_hardware_status():
|
||
"""Полный снимок хоста, периферии и весов для вкладки «Железо»."""
|
||
payload = build_hardware_status(current_app)
|
||
return jsonify({"status": "success", **payload})
|
||
|
||
|
||
@bp.patch("/hardware/simulation")
|
||
@require_superuser
|
||
def admin_hardware_simulation_patch():
|
||
"""Включить/выключить симуляцию весов (сохраняется в hardware_settings)."""
|
||
payload = request.get_json(silent=True) or {}
|
||
if not isinstance(payload, dict) or "enabled" not in payload:
|
||
return jsonify({"status": "error", "message": "Укажите enabled: true или false"}), 400
|
||
from app.routes.scales import _get_reader
|
||
|
||
reader = _get_reader()
|
||
state = reader.set_simulation_mode(bool(payload.get("enabled")))
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"hardware": state,
|
||
"simulation_mode": state.get("simulation_mode"),
|
||
"simulation_weight_kg": state.get("simulation_weight_kg"),
|
||
}
|
||
)
|
||
|
||
|
||
@bp.put("/hardware/simulation/weight")
|
||
@require_superuser
|
||
def admin_hardware_simulation_set_weight():
|
||
"""Задать симулируемый вес (кг) или изменить на delta_kg."""
|
||
payload = request.get_json(silent=True) or {}
|
||
if not isinstance(payload, dict):
|
||
return jsonify({"status": "error", "message": "Ожидался JSON-объект"}), 400
|
||
from app.routes.scales import _get_reader
|
||
|
||
reader = _get_reader()
|
||
try:
|
||
if "weight_kg" in payload:
|
||
try:
|
||
target = int(round(float(payload.get("weight_kg"))))
|
||
except (TypeError, ValueError):
|
||
return jsonify({"status": "error", "message": "weight_kg должен быть числом"}), 400
|
||
target = max(0, min(9999, target))
|
||
weight = reader.set_simulation_weight(target)
|
||
elif "delta_kg" in payload:
|
||
try:
|
||
delta = int(payload.get("delta_kg"))
|
||
except (TypeError, ValueError):
|
||
return jsonify({"status": "error", "message": "delta_kg должен быть целым числом"}), 400
|
||
if delta == 0:
|
||
return jsonify({"status": "error", "message": "delta_kg не может быть 0"}), 400
|
||
weight = reader.adjust_simulation_weight(delta)
|
||
else:
|
||
return jsonify(
|
||
{"status": "error", "message": "Укажите weight_kg или delta_kg"},
|
||
), 400
|
||
except ValueError as exc:
|
||
return jsonify({"status": "error", "message": str(exc)}), 400
|
||
state = reader.get_simulation_state()
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"simulation_weight_kg": weight,
|
||
**state,
|
||
}
|
||
)
|
||
|
||
|
||
@bp.post("/hardware/simulation/adjust-weight")
|
||
@require_superuser
|
||
def admin_hardware_simulation_adjust_weight():
|
||
"""Изменить симулируемый вес (кг) при включённой симуляции."""
|
||
payload = request.get_json(silent=True) or {}
|
||
if not isinstance(payload, dict):
|
||
return jsonify({"status": "error", "message": "Ожидался JSON-объект"}), 400
|
||
try:
|
||
delta = int(payload.get("delta_kg", 0))
|
||
except (TypeError, ValueError):
|
||
return jsonify({"status": "error", "message": "delta_kg должен быть целым числом"}), 400
|
||
if delta == 0:
|
||
return jsonify({"status": "error", "message": "delta_kg не может быть 0"}), 400
|
||
from app.routes.scales import _get_reader
|
||
|
||
reader = _get_reader()
|
||
try:
|
||
weight = reader.adjust_simulation_weight(delta)
|
||
except ValueError as exc:
|
||
return jsonify({"status": "error", "message": str(exc)}), 400
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"simulation_weight_kg": weight,
|
||
**reader.get_simulation_state(),
|
||
}
|
||
)
|
||
|
||
|
||
@bp.get("/peripheral-events")
|
||
@require_superuser
|
||
def admin_peripheral_events():
|
||
"""Журнал событий HX711/GPIO/хоста (JSONL)."""
|
||
try:
|
||
limit = int(request.args.get("limit", 80))
|
||
except (TypeError, ValueError):
|
||
limit = 80
|
||
limit = max(1, min(limit, 500))
|
||
component = (request.args.get("component") or "").strip() or None
|
||
exclude_component = (request.args.get("exclude_component") or "").strip() or None
|
||
if component and exclude_component:
|
||
return jsonify(
|
||
{"status": "error", "message": "Укажите component или exclude_component, не оба сразу."}
|
||
), 400
|
||
events, path = read_peripheral_events(
|
||
current_app,
|
||
limit=limit,
|
||
component=component,
|
||
exclude_component=exclude_component,
|
||
)
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"events": events,
|
||
"path": path or None,
|
||
"retention_days": int(
|
||
current_app.config.get("WESP_ADMIN_PERIPHERAL_LOG_RETENTION_DAYS", 7) or 7
|
||
),
|
||
}
|
||
)
|
||
|
||
|
||
@bp.delete("/peripheral-events")
|
||
@require_superuser
|
||
def admin_peripheral_events_clear():
|
||
"""Очистить журнал событий периферии (wesp-peripherals.jsonl).
|
||
|
||
?component=network — только сеть;
|
||
?exclude_component=network — всё кроме сети (журнал оборудования).
|
||
"""
|
||
component = (request.args.get("component") or "").strip() or None
|
||
exclude_component = (request.args.get("exclude_component") or "").strip() or None
|
||
if component and exclude_component:
|
||
return jsonify(
|
||
{"status": "error", "message": "Укажите component или exclude_component, не оба сразу."}
|
||
), 400
|
||
try:
|
||
ok, path = clear_peripheral_events(
|
||
current_app, component=component, exclude_component=exclude_component
|
||
)
|
||
except OSError as exc:
|
||
return jsonify({"status": "error", "message": str(exc)}), 500
|
||
if not ok:
|
||
return jsonify(
|
||
{
|
||
"status": "error",
|
||
"message": "Журнал периферии не настроен (WESP_ADMIN_PERIPHERAL_LOG_PATH).",
|
||
}
|
||
), 400
|
||
msg = "Журнал очищен"
|
||
if component:
|
||
msg = f"Журнал «{component}» очищен"
|
||
elif exclude_component:
|
||
msg = f"Журнал очищен (кроме «{exclude_component}»)"
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"message": msg,
|
||
"path": path or None,
|
||
"component": component,
|
||
"exclude_component": exclude_component,
|
||
}
|
||
)
|
||
|
||
|
||
@bp.get("/server-log")
|
||
@require_superuser
|
||
def admin_server_log():
|
||
"""Последние строки лог-файла (путь в WESP_ADMIN_LOG_PATH)."""
|
||
raw = (current_app.config.get("WESP_ADMIN_LOG_PATH") or "").strip()
|
||
if not raw:
|
||
return jsonify(
|
||
{
|
||
"status": "error",
|
||
"code": "no_path",
|
||
"message": "Задайте переменную окружения WESP_ADMIN_LOG_PATH к текстовому лог-файлу.",
|
||
"lines": [],
|
||
}
|
||
)
|
||
|
||
path = Path(raw)
|
||
if not path.is_absolute():
|
||
path = Path(current_app.config.get("BASE_DIR", ".")) / path
|
||
path = path.resolve()
|
||
if not path.is_file():
|
||
return jsonify(
|
||
{"status": "error", "code": "not_found", "message": f"Файл не найден: {path}", "lines": []}
|
||
), 404
|
||
|
||
max_lines = min(max(int(request.args.get("lines", 200)), 1), 500)
|
||
lines, err = tail_text_file(path, max_lines=max_lines)
|
||
if err:
|
||
return jsonify({"status": "error", "code": "read_error", "message": err, "lines": lines})
|
||
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"path": str(path),
|
||
"lines": lines,
|
||
}
|
||
)
|
||
|
||
|
||
@bp.get("/server-log/download")
|
||
@require_superuser
|
||
def admin_server_log_download():
|
||
"""Скачивание полного файла WESP_ADMIN_LOG_PATH (text/plain)."""
|
||
raw = (current_app.config.get("WESP_ADMIN_LOG_PATH") or "").strip()
|
||
if not raw:
|
||
return jsonify(
|
||
{
|
||
"status": "error",
|
||
"code": "no_path",
|
||
"message": "Задайте WESP_ADMIN_LOG_PATH к текстовому лог-файлу.",
|
||
}
|
||
), 400
|
||
|
||
path = Path(raw)
|
||
if not path.is_absolute():
|
||
path = Path(current_app.config.get("BASE_DIR", ".")) / path
|
||
path = path.resolve()
|
||
if not path.is_file():
|
||
return jsonify(
|
||
{
|
||
"status": "error",
|
||
"code": "not_found",
|
||
"message": f"Файл не найден: {path}",
|
||
}
|
||
), 404
|
||
|
||
return send_file(
|
||
path,
|
||
as_attachment=True,
|
||
download_name=path.name,
|
||
mimetype="text/plain; charset=utf-8",
|
||
max_age=0,
|
||
)
|
||
|
||
|
||
@bp.get("/client-uploaded-logs")
|
||
@require_superuser
|
||
def admin_client_uploaded_logs_index():
|
||
"""Список каталогов логов, присланных клиентами (POST /api/sync/client-log)."""
|
||
upload_dir = (current_app.config.get("WESP_CLIENT_LOG_UPLOAD_DIR") or "").strip()
|
||
if not upload_dir:
|
||
return jsonify(
|
||
{
|
||
"status": "error",
|
||
"code": "no_dir",
|
||
"message": "WESP_CLIENT_LOG_UPLOAD_DIR не задан.",
|
||
"clients": [],
|
||
}
|
||
)
|
||
try:
|
||
root = client_logs_root_resolved(upload_dir, str(current_app.config.get("BASE_DIR", ".")))
|
||
except ValueError as e:
|
||
return jsonify({"status": "error", "message": str(e), "clients": []}), 400
|
||
clients = build_uploaded_logs_index(db.session, root)
|
||
return jsonify({"status": "success", "root": str(root), "clients": clients})
|
||
|
||
|
||
@bp.get("/client-uploaded-logs/tail")
|
||
@require_superuser
|
||
def admin_client_uploaded_logs_tail():
|
||
"""Хвост выгруженного лога; file опционален — берётся самый свежий файл в каталоге клиента."""
|
||
upload_dir = (current_app.config.get("WESP_CLIENT_LOG_UPLOAD_DIR") or "").strip()
|
||
if not upload_dir:
|
||
return jsonify(
|
||
{
|
||
"status": "error",
|
||
"code": "no_dir",
|
||
"message": "WESP_CLIENT_LOG_UPLOAD_DIR не задан.",
|
||
"lines": [],
|
||
}
|
||
)
|
||
client_id = (request.args.get("client_id") or "").strip()
|
||
if not client_id:
|
||
return jsonify({"status": "error", "message": "client_id обязателен", "lines": []}), 400
|
||
try:
|
||
root = client_logs_root_resolved(upload_dir, str(current_app.config.get("BASE_DIR", ".")))
|
||
except ValueError as e:
|
||
return jsonify({"status": "error", "message": str(e), "lines": []}), 400
|
||
|
||
filename = (request.args.get("file") or "").strip()
|
||
try:
|
||
if filename:
|
||
path = resolve_uploaded_client_log_file(root, client_id, filename)
|
||
else:
|
||
d = client_upload_log_dir(root, client_id)
|
||
latest = latest_uploaded_log_file(d)
|
||
if not latest:
|
||
return jsonify(
|
||
{
|
||
"status": "error",
|
||
"code": "empty",
|
||
"message": "Нет файлов логов для этого клиента.",
|
||
"lines": [],
|
||
}
|
||
), 404
|
||
path = latest
|
||
except FileNotFoundError:
|
||
return jsonify(
|
||
{"status": "error", "code": "not_found", "message": "Каталог или файл не найден.", "lines": []}
|
||
), 404
|
||
except ValueError as e:
|
||
return jsonify({"status": "error", "message": str(e), "lines": []}), 400
|
||
|
||
max_lines = min(max(int(request.args.get("lines", 200)), 1), 500)
|
||
lines, err = tail_text_file(path, max_lines=max_lines)
|
||
if err:
|
||
return jsonify({"status": "error", "code": "read_error", "message": err, "lines": lines})
|
||
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"path": str(path),
|
||
"file": path.name,
|
||
"client_id": client_id,
|
||
"lines": lines,
|
||
}
|
||
)
|
||
|
||
|
||
@bp.get("/client-uploaded-logs/download")
|
||
@require_superuser
|
||
def admin_client_uploaded_logs_download():
|
||
"""Скачивание одного файла выгруженного лога."""
|
||
upload_dir = (current_app.config.get("WESP_CLIENT_LOG_UPLOAD_DIR") or "").strip()
|
||
if not upload_dir:
|
||
return jsonify(
|
||
{"status": "error", "code": "no_dir", "message": "WESP_CLIENT_LOG_UPLOAD_DIR не задан."}
|
||
), 400
|
||
client_id = (request.args.get("client_id") or "").strip()
|
||
filename = (request.args.get("file") or "").strip()
|
||
if not client_id or not filename:
|
||
return jsonify({"status": "error", "message": "Укажите client_id и file"}), 400
|
||
try:
|
||
root = client_logs_root_resolved(upload_dir, str(current_app.config.get("BASE_DIR", ".")))
|
||
path = resolve_uploaded_client_log_file(root, client_id, filename)
|
||
except FileNotFoundError:
|
||
return jsonify({"status": "error", "code": "not_found", "message": "Файл не найден."}), 404
|
||
except ValueError as e:
|
||
return jsonify({"status": "error", "message": str(e)}), 400
|
||
|
||
return send_file(
|
||
path,
|
||
as_attachment=True,
|
||
download_name=path.name,
|
||
mimetype="text/plain; charset=utf-8",
|
||
max_age=0,
|
||
)
|
||
|
||
|
||
@bp.post("/ui-activity")
|
||
@require_superuser
|
||
def admin_ui_activity_append():
|
||
"""Дописывает событие из браузера (setStatus) в WESP_ADMIN_UI_ACTIVITY_LOG_PATH (JSONL)."""
|
||
body = request.get_json(silent=True) or {}
|
||
text = str(body.get("text") or "").strip()
|
||
if not text:
|
||
return jsonify({"status": "error", "message": "text required"}), 400
|
||
if len(text) > 2000:
|
||
text = text[:2000]
|
||
level = body.get("level") if body.get("level") in ("ok", "err", "warn") else "ok"
|
||
login = str(session.get("user_login") or "")
|
||
append_admin_ui_activity(
|
||
current_app,
|
||
text=text,
|
||
level=level,
|
||
user_login=login,
|
||
)
|
||
return jsonify({"status": "success"})
|
||
|
||
|
||
@bp.get("/activity-feed")
|
||
@require_superuser
|
||
def admin_activity_feed():
|
||
"""Лента: JSONL (UI) + хвост wesp.log (WARNING+), без дублирования по level+текст."""
|
||
entries, meta = build_activity_feed(current_app)
|
||
return jsonify({"status": "success", "entries": entries, "meta": meta})
|
||
|
||
|
||
@bp.get("/llm/status")
|
||
@require_superuser
|
||
def admin_llm_status():
|
||
"""Проверка: включён ли LLM, доступен ли OpenAI-совместимый сервер, есть ли модель в /v1/models."""
|
||
cfg = current_app.config
|
||
enabled = bool(cfg.get("WESP_ADMIN_LLM_ENABLED"))
|
||
base = str(cfg.get("WESP_LLM_BASE_URL") or "http://127.0.0.1:8080").rstrip("/")
|
||
model = str(cfg.get("WESP_LLM_MODEL") or "").strip()
|
||
api_key = str(cfg.get("WESP_LLM_API_KEY") or "").strip()
|
||
assistant_dir = str(cfg.get("WESP_ASSISTANT_DIR") or "").strip()
|
||
status_timeout = float(cfg.get("WESP_LLM_STATUS_TIMEOUT_SEC") or 3.0)
|
||
locks = security_env_lock_flags()
|
||
out = {
|
||
"enabled": enabled,
|
||
"llm_base_url": base,
|
||
"model": model,
|
||
"assistant_dir": assistant_dir,
|
||
"llm_autostart": bool(cfg.get("WESP_LLM_AUTOSTART", True)),
|
||
"llm_autostart_locked_by_env": locks.get("llm_autostart", False),
|
||
"admin_llm_enabled_locked_by_env": locks.get("admin_llm_enabled", False),
|
||
"llm_chat_db_context": bool(cfg.get("WESP_LLM_CHAT_INCLUDE_DB_CONTEXT", False)),
|
||
"llm_chat_db_context_locked_by_env": locks.get("llm_chat_db_context", False),
|
||
"llm_tools_enabled": bool(cfg.get("WESP_LLM_TOOLS_ENABLED", True)),
|
||
"llm_tools_enabled_locked_by_env": locks.get("llm_tools_enabled", False),
|
||
"llm_reachable": False,
|
||
"model_present": False,
|
||
"llm_error": None,
|
||
}
|
||
if not enabled:
|
||
return jsonify({"status": "success", **out})
|
||
try:
|
||
payload = openai_list_models(
|
||
base_url=base, timeout_sec=status_timeout, api_key=api_key
|
||
)
|
||
out["llm_reachable"] = True
|
||
out["model_present"] = model_in_openai_payload(payload, model)
|
||
except LocalLlmError as e:
|
||
out["llm_error"] = str(e)
|
||
return jsonify({"status": "success", **out})
|
||
|
||
|
||
@bp.get("/llm/activity")
|
||
@require_superuser
|
||
def admin_llm_activity():
|
||
"""Последние записи журнала ИИ (JSONL) за окно хранения."""
|
||
rows, path = read_admin_llm_activity(current_app, max_lines=200)
|
||
days = int(current_app.config.get("WESP_ADMIN_LLM_LOG_RETENTION_DAYS", 7) or 7)
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"entries": rows,
|
||
"path": path,
|
||
"retention_days": max(1, min(days, 30)),
|
||
}
|
||
)
|
||
|
||
|
||
@bp.get("/llm/diagnostics")
|
||
@require_superuser
|
||
def admin_llm_diagnostics():
|
||
"""Структурированная диагностика среды + проверка SQLite + тот же текст, что в system для LLM."""
|
||
payload = build_llm_diagnostics_payload(current_app)
|
||
db_payload = build_llm_db_health_payload(current_app)
|
||
inv_payload = build_llm_inventory_payload(current_app)
|
||
text = (
|
||
build_llm_diagnostics_block(current_app)
|
||
+ "\n\n---\n\n"
|
||
+ build_llm_db_health_block(current_app)
|
||
+ "\n\n---\n\n"
|
||
+ build_llm_inventory_snapshot_block(current_app)
|
||
)
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"diagnostics": payload,
|
||
"db_health": db_payload,
|
||
"inventory": inv_payload,
|
||
"text_for_llm": text,
|
||
}
|
||
)
|
||
|
||
|
||
@bp.post("/llm/ping")
|
||
@require_superuser
|
||
def admin_llm_ping():
|
||
"""Короткий вызов локального LLM без контекста БД — проверка цепочки WESP → сервер."""
|
||
cfg = current_app.config
|
||
if not cfg.get("WESP_ADMIN_LLM_ENABLED"):
|
||
return jsonify(
|
||
{"status": "error", "message": "LLM отключён (WESP_ADMIN_LLM_ENABLED)."}
|
||
), 503
|
||
login = str(session.get("user_login") or "")
|
||
interval = float(cfg.get("WESP_ADMIN_LLM_RATE_LIMIT_SEC") or 0)
|
||
ok, wait = llm_post_rate_allow(login, interval, bucket="heavy")
|
||
if not ok:
|
||
return jsonify(
|
||
{
|
||
"status": "error",
|
||
"message": f"Слишком часто. Подождите {wait:.1f} с.",
|
||
}
|
||
), 429
|
||
base = str(cfg.get("WESP_LLM_BASE_URL") or "").rstrip("/")
|
||
model = str(cfg.get("WESP_LLM_MODEL") or "").strip()
|
||
api_key = str(cfg.get("WESP_LLM_API_KEY") or "").strip()
|
||
timeout_sec = min(float(cfg.get("WESP_LLM_TIMEOUT_SEC") or 120), 60.0)
|
||
messages = ensure_russian_llm_messages(
|
||
[
|
||
{
|
||
"role": "user",
|
||
"content": "Ответь одним словом: ок",
|
||
}
|
||
]
|
||
)
|
||
t0 = time.perf_counter()
|
||
# Без тяжёлого merge (снимок ORM) — только короткий system из ensure_russian_llm_messages.
|
||
try:
|
||
reply = openai_chat_completion(
|
||
base_url=base,
|
||
model=model,
|
||
messages=messages,
|
||
timeout_sec=timeout_sec,
|
||
max_tokens=32,
|
||
api_key=api_key,
|
||
)
|
||
except LocalLlmError as e:
|
||
append_admin_llm_activity(
|
||
current_app,
|
||
event="ping",
|
||
level="err",
|
||
user_login=login,
|
||
detail=str(e)[:500],
|
||
duration_ms=int((time.perf_counter() - t0) * 1000),
|
||
)
|
||
return jsonify({"status": "error", "message": str(e)}), 502
|
||
append_admin_llm_activity(
|
||
current_app,
|
||
event="ping",
|
||
level="ok",
|
||
user_login=login,
|
||
detail=(reply or "")[:240] or "ok",
|
||
duration_ms=int((time.perf_counter() - t0) * 1000),
|
||
meta={"model": model},
|
||
)
|
||
return jsonify({"status": "success", "reply": reply})
|
||
|
||
|
||
@bp.post("/llm/summary")
|
||
@require_superuser
|
||
def admin_llm_summary():
|
||
"""Сводка по отчётам/складу через локальный LLM (только суперпользователь)."""
|
||
cfg = current_app.config
|
||
if not cfg.get("WESP_ADMIN_LLM_ENABLED"):
|
||
return jsonify(
|
||
{"status": "error", "message": "LLM отключён (WESP_ADMIN_LLM_ENABLED)."}
|
||
), 503
|
||
body = request.get_json(silent=True) or {}
|
||
date_from = str(body.get("date_from") or "").strip()
|
||
date_to = str(body.get("date_to") or "").strip()
|
||
focus = str(body.get("focus") or "both").strip().lower()
|
||
if not date_from or not date_to:
|
||
return jsonify(
|
||
{"status": "error", "message": "Укажите date_from и date_to (YYYY-MM-DD)."}
|
||
), 400
|
||
try:
|
||
datetime.strptime(date_from, "%Y-%m-%d")
|
||
datetime.strptime(date_to, "%Y-%m-%d")
|
||
except ValueError:
|
||
return jsonify(
|
||
{"status": "error", "message": "Некорректный формат дат (YYYY-MM-DD)."}
|
||
), 400
|
||
|
||
login = str(session.get("user_login") or "")
|
||
interval = float(cfg.get("WESP_ADMIN_LLM_RATE_LIMIT_SEC") or 0)
|
||
ok, wait = llm_post_rate_allow(login, interval, bucket="heavy")
|
||
if not ok:
|
||
return jsonify(
|
||
{
|
||
"status": "error",
|
||
"message": f"Слишком часто. Подождите {wait:.1f} с.",
|
||
}
|
||
), 429
|
||
|
||
max_ctx = int(cfg.get("WESP_LLM_MAX_CONTEXT_CHARS") or 12000)
|
||
context_text = build_admin_llm_context(
|
||
date_from=date_from,
|
||
date_to=date_to,
|
||
focus=focus,
|
||
max_chars=max_ctx,
|
||
)
|
||
base = str(cfg.get("WESP_LLM_BASE_URL") or "").rstrip("/")
|
||
model = str(cfg.get("WESP_LLM_MODEL") or "").strip()
|
||
api_key = str(cfg.get("WESP_LLM_API_KEY") or "").strip()
|
||
timeout_sec = float(cfg.get("WESP_LLM_TIMEOUT_SEC") or 120)
|
||
max_tokens = int(cfg.get("WESP_LLM_MAX_TOKENS") or 512)
|
||
user_block = (
|
||
f"Данные за период {date_from} — {date_to}, режим focus={focus}.\n\n{context_text}\n\n"
|
||
"Сформулируй краткую сводку для зоотехника: что важно по отклонениям (что и когда), "
|
||
"и по складу если есть строки. Не добавляй фактов вне данных."
|
||
)
|
||
messages = [
|
||
{"role": "system", "content": SYSTEM_PROMPT_RU},
|
||
{"role": "user", "content": user_block},
|
||
]
|
||
messages = merge_llm_diagnostics_into_system(messages, current_app, mode="compact")
|
||
t0 = time.perf_counter()
|
||
try:
|
||
reply = openai_chat_completion(
|
||
base_url=base,
|
||
model=model,
|
||
messages=messages,
|
||
timeout_sec=timeout_sec,
|
||
max_tokens=max_tokens,
|
||
api_key=api_key,
|
||
)
|
||
except LocalLlmError as e:
|
||
append_admin_llm_activity(
|
||
current_app,
|
||
event="summary",
|
||
level="err",
|
||
user_login=login,
|
||
detail=str(e)[:500],
|
||
duration_ms=int((time.perf_counter() - t0) * 1000),
|
||
meta={"focus": focus, "date_from": date_from, "date_to": date_to},
|
||
)
|
||
return jsonify({"status": "error", "message": str(e)}), 502
|
||
|
||
append_admin_llm_activity(
|
||
current_app,
|
||
event="summary",
|
||
level="ok",
|
||
user_login=login,
|
||
detail=f"focus={focus} {date_from}…{date_to} ctx={len(context_text)} симв.",
|
||
duration_ms=int((time.perf_counter() - t0) * 1000),
|
||
meta={
|
||
"focus": focus,
|
||
"date_from": date_from,
|
||
"date_to": date_to,
|
||
"context_chars": len(context_text),
|
||
"reply_chars": len(reply or ""),
|
||
},
|
||
)
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"reply": reply,
|
||
"context_chars": len(context_text),
|
||
}
|
||
)
|
||
|
||
|
||
def _parse_llm_chat_messages(body: dict):
|
||
raw = body.get("messages")
|
||
if not isinstance(raw, list) or len(raw) == 0:
|
||
return None, "Укажите messages: массив [{role, content}, …]."
|
||
out: list[dict[str, str]] = []
|
||
for m in raw[:64]:
|
||
if not isinstance(m, dict):
|
||
return None, "Каждый элемент messages должен быть объектом."
|
||
role = str(m.get("role") or "").strip().lower()
|
||
content = str(m.get("content") if m.get("content") is not None else "")
|
||
if role not in ("system", "user", "assistant"):
|
||
return None, f"Недопустимая роль «{role}» (ожидаются system, user, assistant)."
|
||
if len(content) > 48_000:
|
||
return None, "Слишком длинное сообщение."
|
||
if role in ("system", "user") and not content.strip():
|
||
return None, "Пустое сообщение недопустимо."
|
||
out.append({"role": role, "content": content})
|
||
if not out:
|
||
return None, "Нет допустимых сообщений."
|
||
return out, None
|
||
|
||
|
||
def _trim_llm_chat_messages_roll(msgs: list[dict[str, str]], *, max_messages: int = 12) -> list[dict[str, str]]:
|
||
"""Ограничивает длину диалога для локального LLM с небольшим n_ctx."""
|
||
if len(msgs) <= max_messages:
|
||
return msgs
|
||
return msgs[-max_messages:]
|
||
|
||
|
||
def _last_user_chat_text(msgs: list[dict[str, str]]) -> str:
|
||
for m in reversed(msgs):
|
||
if m.get("role") == "user":
|
||
return str(m.get("content") or "").strip()
|
||
return ""
|
||
|
||
|
||
def _looks_like_db_intent(text: str) -> bool:
|
||
q = (text or "").strip().lower()
|
||
if not q:
|
||
return False
|
||
return any(
|
||
k in q
|
||
for k in (
|
||
"бд",
|
||
"база",
|
||
"базе",
|
||
"базы",
|
||
"таблиц",
|
||
"table",
|
||
"sqlite",
|
||
"recipes",
|
||
"reports",
|
||
"кормораздат",
|
||
"раздатчик",
|
||
"рецепт",
|
||
"schema",
|
||
"схем",
|
||
"компонент",
|
||
"ингредиент",
|
||
"component",
|
||
)
|
||
)
|
||
|
||
|
||
@bp.post("/llm/chat")
|
||
@require_superuser
|
||
def admin_llm_chat():
|
||
"""Многоходовый чат с локальным LLM (OpenAI /v1/chat/completions)."""
|
||
cfg = current_app.config
|
||
if not cfg.get("WESP_ADMIN_LLM_ENABLED"):
|
||
return jsonify(
|
||
{"status": "error", "message": "LLM отключён (WESP_ADMIN_LLM_ENABLED)."}
|
||
), 503
|
||
body = request.get_json(silent=True) or {}
|
||
messages, err = _parse_llm_chat_messages(body)
|
||
if err:
|
||
return jsonify({"status": "error", "message": err}), 400
|
||
messages = _trim_llm_chat_messages_roll(messages, max_messages=12)
|
||
|
||
login = str(session.get("user_login") or "")
|
||
interval = float(cfg.get("WESP_ADMIN_LLM_CHAT_RATE_LIMIT_SEC") or 0)
|
||
ok, wait = llm_post_rate_allow(login, interval, bucket="chat")
|
||
if not ok:
|
||
return jsonify(
|
||
{
|
||
"status": "error",
|
||
"message": f"Слишком часто. Подождите {wait:.1f} с.",
|
||
}
|
||
), 429
|
||
|
||
base = str(cfg.get("WESP_LLM_BASE_URL") or "").rstrip("/")
|
||
model = str(cfg.get("WESP_LLM_MODEL") or "").strip()
|
||
api_key = str(cfg.get("WESP_LLM_API_KEY") or "").strip()
|
||
timeout_sec = min(
|
||
float(cfg.get("WESP_LLM_CHAT_TIMEOUT_SEC") or 45),
|
||
float(cfg.get("WESP_LLM_TIMEOUT_SEC") or 120),
|
||
)
|
||
chat_cap = int(cfg.get("WESP_LLM_CHAT_MAX_TOKENS") or 256)
|
||
max_tokens = int(body.get("max_tokens") or chat_cap)
|
||
max_tokens = max(16, min(int(max_tokens), 2048))
|
||
|
||
messages = ensure_russian_llm_messages(messages)
|
||
chat_ctx = bool(cfg.get("WESP_LLM_CHAT_INCLUDE_DB_CONTEXT"))
|
||
merge_mode = "compact" if chat_ctx else "minimal"
|
||
messages = merge_llm_diagnostics_into_system(
|
||
messages, current_app, mode=merge_mode
|
||
)
|
||
|
||
user_text = _last_user_chat_text(messages)
|
||
db_intent = _looks_like_db_intent(user_text)
|
||
tools_cfg = bool(cfg.get("WESP_LLM_TOOLS_ENABLED", True))
|
||
tools_req = body.get("tools_enabled")
|
||
if tools_req is None:
|
||
tools_enabled = tools_cfg
|
||
else:
|
||
tools_enabled = bool(tools_req) and tools_cfg
|
||
# Для вопросов про БД не даём UI-ошибке отключить инструменты.
|
||
if db_intent and tools_cfg:
|
||
tools_enabled = True
|
||
|
||
sql_mode = str(body.get("sql_mode") or "preview").strip().lower()
|
||
if sql_mode not in ("preview", "execute"):
|
||
return jsonify(
|
||
{"status": "error", "message": "sql_mode: ожидается preview или execute."}
|
||
), 400
|
||
# Для запросов про данные БД переключаемся на execute, если режим не задан явно.
|
||
if db_intent and "sql_mode" not in body:
|
||
sql_mode = "execute"
|
||
|
||
tool_trace_flag = body.get("tool_trace", True)
|
||
show_trace = bool(tool_trace_flag) if tool_trace_flag is not None else True
|
||
if db_intent:
|
||
show_trace = True
|
||
|
||
t0 = time.perf_counter()
|
||
try:
|
||
if tools_enabled:
|
||
reply, trace = run_llm_chat_with_tools(
|
||
current_app,
|
||
messages=messages,
|
||
base_url=base,
|
||
model=model,
|
||
api_key=api_key,
|
||
timeout_sec=timeout_sec,
|
||
max_tokens=max_tokens,
|
||
max_rounds=int(cfg.get("WESP_LLM_TOOL_MAX_ROUNDS") or 6),
|
||
chat_sql_mode=sql_mode,
|
||
sql_max_rows=int(cfg.get("WESP_LLM_SQL_MAX_ROWS") or 200),
|
||
log_max_bytes=int(cfg.get("WESP_LLM_LOG_MAX_BYTES") or 262144),
|
||
log_max_lines=int(cfg.get("WESP_LLM_LOG_TAIL_LINES") or 400),
|
||
)
|
||
else:
|
||
reply = openai_chat_completion(
|
||
base_url=base,
|
||
model=model,
|
||
messages=messages,
|
||
timeout_sec=timeout_sec,
|
||
max_tokens=max_tokens,
|
||
api_key=api_key,
|
||
)
|
||
trace = []
|
||
except LocalLlmError as e:
|
||
append_admin_llm_activity(
|
||
current_app,
|
||
event="chat",
|
||
level="err",
|
||
user_login=login,
|
||
detail=str(e)[:500],
|
||
duration_ms=int((time.perf_counter() - t0) * 1000),
|
||
meta={
|
||
"tools": tools_enabled,
|
||
"sql_mode": sql_mode,
|
||
"db_context": chat_ctx,
|
||
},
|
||
)
|
||
return jsonify({"status": "error", "message": str(e)}), 502
|
||
|
||
detail_bits = [f"ctx={'db' if chat_ctx else 'light'}"]
|
||
if tools_enabled:
|
||
detail_bits.append("tools=on")
|
||
detail_bits.append(f"sql={sql_mode}")
|
||
if trace:
|
||
detail_bits.append(f"steps={len(trace)}")
|
||
user_preview = _last_user_chat_text(messages)
|
||
if user_preview:
|
||
detail_bits.append(f"«{user_preview[:120]}»")
|
||
append_admin_llm_activity(
|
||
current_app,
|
||
event="chat",
|
||
level="ok",
|
||
user_login=login,
|
||
detail=" ".join(detail_bits),
|
||
duration_ms=int((time.perf_counter() - t0) * 1000),
|
||
meta={
|
||
"tools": tools_enabled,
|
||
"sql_mode": sql_mode,
|
||
"db_context": chat_ctx,
|
||
"tool_steps": len(trace) if trace else 0,
|
||
"reply_chars": len(reply or ""),
|
||
},
|
||
)
|
||
payload = {"status": "success", "reply": reply, "sql_mode": sql_mode}
|
||
if show_trace:
|
||
payload["tool_trace"] = trace
|
||
return jsonify(payload)
|
||
|
||
|
||
@bp.get("/sqlite-snapshot")
|
||
@require_superuser
|
||
def admin_sqlite_snapshot():
|
||
"""
|
||
Отдаёт файл SQLite (тот же, что в ZIP backup.sqlite), например для скачивания.
|
||
Только суперпользователь.
|
||
"""
|
||
bind = (request.args.get("bind") or "recipes").strip().lower()
|
||
paths = sqlite_bind_paths(current_app)
|
||
if bind not in paths:
|
||
known = ", ".join(sorted(paths)) or "—"
|
||
return jsonify(
|
||
{
|
||
"status": "error",
|
||
"message": f"Неизвестная база «{bind}». Доступны: {known}",
|
||
}
|
||
), 400
|
||
path = paths[bind]
|
||
if not path.is_file():
|
||
return jsonify(
|
||
{"status": "error", "message": f"Файл не найден: {path}"}
|
||
), 404
|
||
return send_file(
|
||
path,
|
||
mimetype="application/vnd.sqlite3",
|
||
as_attachment=False,
|
||
download_name=f"{bind}.sqlite",
|
||
max_age=0,
|
||
)
|
||
|
||
|
||
@bp.get("/db/tree")
|
||
@require_superuser
|
||
def admin_db_tree():
|
||
"""Дерево: bind → список таблиц (только суперпользователь)."""
|
||
try:
|
||
payload = build_tree_payload(current_app)
|
||
except OSError as exc:
|
||
return jsonify({"status": "error", "message": str(exc)}), 500
|
||
return jsonify({"status": "success", **payload})
|
||
|
||
|
||
@bp.get("/db/table")
|
||
@require_superuser
|
||
def admin_db_table():
|
||
bind = (request.args.get("bind") or "recipes").strip().lower()
|
||
table = (request.args.get("table") or "").strip()
|
||
if not is_safe_sql_identifier(table):
|
||
return jsonify({"status": "error", "message": "Укажите корректное имя table"}), 400
|
||
limit = min(max(int(request.args.get("limit", 50)), 1), 500)
|
||
offset = max(int(request.args.get("offset", 0)), 0)
|
||
try:
|
||
conn, _path = sqlite_connection_for_bind(current_app, bind)
|
||
except FileNotFoundError as exc:
|
||
return jsonify({"status": "error", "message": str(exc)}), 404
|
||
try:
|
||
names = list_user_tables(conn)
|
||
if table not in names:
|
||
return jsonify({"status": "error", "message": f"Нет таблицы «{table}»"}), 404
|
||
page = fetch_table_page(conn, table, limit=limit, offset=offset)
|
||
finally:
|
||
conn.close()
|
||
return jsonify({"status": "success", "bind": bind, "table": table, **page})
|
||
|
||
|
||
@bp.patch("/db/row")
|
||
@require_superuser
|
||
def admin_db_row_patch():
|
||
"""Точечное обновление ячеек строки (реальная запись в файл SQLite на сервере)."""
|
||
payload = request.get_json(silent=True) or {}
|
||
bind = str(payload.get("bind") or "recipes").strip().lower()
|
||
table = str(payload.get("table") or "").strip()
|
||
row_key = payload.get("row_key")
|
||
changes = payload.get("changes")
|
||
if not is_safe_sql_identifier(table):
|
||
return jsonify({"status": "error", "message": "Некорректное имя таблицы"}), 400
|
||
if not isinstance(row_key, dict) or not isinstance(changes, dict):
|
||
return jsonify({"status": "error", "message": "Нужны объекты row_key и changes"}), 400
|
||
if not changes:
|
||
return jsonify({"status": "error", "message": "changes пустой"}), 400
|
||
|
||
conn = None
|
||
updated = 0
|
||
try:
|
||
conn, _path = sqlite_connection_for_bind(current_app, bind)
|
||
names = list_user_tables(conn)
|
||
if table not in names:
|
||
return jsonify({"status": "error", "message": f"Нет таблицы «{table}»"}), 404
|
||
updated = update_row_cells(conn, table, row_key, changes)
|
||
if updated == 0:
|
||
conn.rollback()
|
||
return jsonify(
|
||
{
|
||
"status": "error",
|
||
"message": "Строка не обновлена (нет совпадения по ключу или значения совпадают)",
|
||
}
|
||
), 409
|
||
conn.commit()
|
||
except FileNotFoundError as exc:
|
||
return jsonify({"status": "error", "message": str(exc)}), 404
|
||
except ValueError as exc:
|
||
if conn is not None:
|
||
conn.rollback()
|
||
return jsonify({"status": "error", "message": str(exc)}), 400
|
||
except sqlite3.Error as exc:
|
||
if conn is not None:
|
||
conn.rollback()
|
||
return jsonify({"status": "error", "message": str(exc)}), 400
|
||
finally:
|
||
if conn is not None:
|
||
conn.close()
|
||
return jsonify({"status": "success", "updated": updated})
|
||
|
||
|
||
@bp.post("/db/row/delete-preview")
|
||
@require_superuser
|
||
def admin_db_row_delete_preview():
|
||
"""Сводка каскадного удаления: какие строки и из каких таблиц будут затронуты."""
|
||
payload = request.get_json(silent=True) or {}
|
||
bind = str(payload.get("bind") or "recipes").strip().lower()
|
||
table = str(payload.get("table") or "").strip()
|
||
row_key = payload.get("row_key")
|
||
if not is_safe_sql_identifier(table):
|
||
return jsonify({"status": "error", "message": "Некорректное имя таблицы"}), 400
|
||
if not isinstance(row_key, dict):
|
||
return jsonify({"status": "error", "message": "Нужен объект row_key"}), 400
|
||
|
||
conn = None
|
||
try:
|
||
conn, _path = sqlite_connection_for_bind(current_app, bind)
|
||
names = list_user_tables(conn)
|
||
if table not in names:
|
||
return jsonify({"status": "error", "message": f"Нет таблицы «{table}»"}), 404
|
||
plan = build_cascade_delete_plan(conn, table, row_key)
|
||
if not plan:
|
||
return jsonify(
|
||
{"status": "error", "message": "Строка не найдена или уже удалена"}
|
||
), 404
|
||
summary = summarize_delete_plan(plan)
|
||
return jsonify({"status": "success", **summary})
|
||
except FileNotFoundError as exc:
|
||
return jsonify({"status": "error", "message": str(exc)}), 404
|
||
except ValueError as exc:
|
||
return jsonify({"status": "error", "message": str(exc)}), 400
|
||
except sqlite3.Error as exc:
|
||
return jsonify({"status": "error", "message": str(exc)}), 400
|
||
finally:
|
||
if conn is not None:
|
||
conn.close()
|
||
|
||
|
||
@bp.post("/db/row/delete")
|
||
@require_superuser
|
||
def admin_db_row_delete():
|
||
"""Каскадное удаление строки и всех зависимых (по FK в схеме SQLite)."""
|
||
payload = request.get_json(silent=True) or {}
|
||
bind = str(payload.get("bind") or "recipes").strip().lower()
|
||
table = str(payload.get("table") or "").strip()
|
||
row_key = payload.get("row_key")
|
||
if not is_safe_sql_identifier(table):
|
||
return jsonify({"status": "error", "message": "Некорректное имя таблицы"}), 400
|
||
if not isinstance(row_key, dict):
|
||
return jsonify({"status": "error", "message": "Нужен объект row_key"}), 400
|
||
|
||
conn = None
|
||
try:
|
||
conn, _path = sqlite_connection_for_bind(current_app, bind)
|
||
names = list_user_tables(conn)
|
||
if table not in names:
|
||
return jsonify({"status": "error", "message": f"Нет таблицы «{table}»"}), 404
|
||
result = execute_cascade_delete(conn, table, row_key)
|
||
if result.get("deleted", 0) == 0:
|
||
conn.rollback()
|
||
return jsonify(
|
||
{"status": "error", "message": "Ни одна строка не удалена"}
|
||
), 409
|
||
conn.commit()
|
||
except FileNotFoundError as exc:
|
||
return jsonify({"status": "error", "message": str(exc)}), 404
|
||
except ValueError as exc:
|
||
if conn is not None:
|
||
conn.rollback()
|
||
return jsonify({"status": "error", "message": str(exc)}), 400
|
||
except sqlite3.Error as exc:
|
||
if conn is not None:
|
||
conn.rollback()
|
||
return jsonify({"status": "error", "message": str(exc)}), 400
|
||
finally:
|
||
if conn is not None:
|
||
conn.close()
|
||
return jsonify({"status": "success", **result})
|
||
|
||
|
||
@bp.get("/factory-reset/preview")
|
||
@require_superuser
|
||
def admin_factory_reset_preview():
|
||
"""Что будет удалено при сбросе (только каталог data/ проекта)."""
|
||
from pathlib import Path
|
||
|
||
from app.services.factory_reset import factory_reset_preview
|
||
|
||
data_dir = Path(str(current_app.config.get("DATA_DIR") or ""))
|
||
base_dir = Path(str(current_app.config.get("BASE_DIR") or "."))
|
||
try:
|
||
preview = factory_reset_preview(data_dir, base_dir)
|
||
except ValueError as exc:
|
||
return jsonify({"status": "error", "message": str(exc)}), 400
|
||
return jsonify({"status": "success", **preview})
|
||
|
||
|
||
@bp.post("/factory-reset")
|
||
@require_superuser
|
||
def admin_factory_reset():
|
||
"""
|
||
Полный сброс data/ (БД, JSON, логи по умолчанию).
|
||
Тело JSON: confirmation_phrase, remove_logs (bool), safety_sqlite_backup (bool).
|
||
"""
|
||
from app.services.factory_reset import (
|
||
factory_reset_app,
|
||
validate_factory_reset_request,
|
||
)
|
||
|
||
payload = request.get_json(silent=True) or {}
|
||
ok, err, remove_logs, safety_backup = validate_factory_reset_request(payload)
|
||
if not ok:
|
||
return jsonify({"status": "error", "message": err}), 400
|
||
|
||
login = str(session.get("user_login") or "")
|
||
report = factory_reset_app(
|
||
current_app,
|
||
remove_logs=remove_logs,
|
||
safety_sqlite_backup=safety_backup,
|
||
)
|
||
if not current_app.config.get("TESTING"):
|
||
append_admin_ui_activity(
|
||
current_app,
|
||
text="Сброс данных (factory reset)",
|
||
level="warn" if report.get("ok") else "error",
|
||
user_login=login,
|
||
)
|
||
code = 200 if report.get("ok") else 500
|
||
return jsonify(
|
||
{
|
||
"status": "success" if report.get("ok") else "error",
|
||
**report,
|
||
}
|
||
), code
|
||
|
||
|
||
@bp.get("/backup.sqlite")
|
||
@require_superuser
|
||
def admin_backup_sqlite():
|
||
"""Скачивание ZIP с файлами SQLite (recipes и binds)."""
|
||
try:
|
||
data, name = build_sqlite_backup_zip(current_app)
|
||
except ValueError as exc:
|
||
return jsonify({"status": "error", "message": str(exc)}), 400
|
||
except FileNotFoundError as exc:
|
||
return jsonify({"status": "error", "message": str(exc)}), 404
|
||
|
||
return send_file(
|
||
BytesIO(data),
|
||
mimetype="application/zip",
|
||
as_attachment=True,
|
||
download_name=name,
|
||
)
|
||
|
||
|
||
@bp.post("/restore.sqlite")
|
||
@require_superuser
|
||
def admin_restore_sqlite():
|
||
"""Восстановление файлов SQLite из ZIP (тот же формат, что выдаёт /api/admin/backup.sqlite)."""
|
||
if "file" not in request.files:
|
||
return jsonify(
|
||
{"status": "error", "message": "Прикрепите ZIP (поле file)"}
|
||
), 400
|
||
up = request.files["file"]
|
||
if not up or not up.filename:
|
||
return jsonify({"status": "error", "message": "Файл не выбран"}), 400
|
||
data = up.read()
|
||
if not data:
|
||
return jsonify({"status": "error", "message": "Пустой файл"}), 400
|
||
try:
|
||
result = restore_sqlite_from_zip(current_app, data)
|
||
except ValueError as exc:
|
||
return jsonify({"status": "error", "message": str(exc)}), 400
|
||
return jsonify({"status": "success", **result})
|
||
|
||
|
||
@bp.get("/plymouth/status")
|
||
@require_superuser
|
||
def admin_plymouth_status():
|
||
from app.services.plymouth_theme_service import get_plymouth_status
|
||
|
||
return jsonify({"status": "success", **get_plymouth_status(current_app)})
|
||
|
||
|
||
@bp.get("/pi-boot/rainbow-splash/status")
|
||
@require_superuser
|
||
def admin_pi_boot_rainbow_splash_status():
|
||
from app.services.pi_boot_config_service import get_rainbow_splash_status
|
||
|
||
path = (current_app.config.get("WESP_PI_BOOT_CONFIG_PATH") or "").strip()
|
||
return jsonify(
|
||
{"status": "success", **get_rainbow_splash_status(path)},
|
||
)
|
||
|
||
|
||
@bp.post("/pi-boot/rainbow-splash")
|
||
@require_superuser
|
||
def admin_pi_boot_rainbow_splash_set():
|
||
"""disable_splash=1 в config.txt — убрать радужный квадрат прошивки до Plymouth."""
|
||
from app.services.pi_boot_config_service import set_rainbow_splash_disabled
|
||
|
||
payload = request.get_json(silent=True) or {}
|
||
if "enabled" not in payload:
|
||
return jsonify(
|
||
{"status": "error", "message": "Укажите enabled: true или false"},
|
||
), 400
|
||
|
||
enabled = bool(payload.get("enabled"))
|
||
path = (current_app.config.get("WESP_PI_BOOT_CONFIG_PATH") or "").strip()
|
||
write_cmd = (current_app.config.get("WESP_PI_BOOT_CONFIG_WRITE_CMD") or "").strip()
|
||
result = set_rainbow_splash_disabled(
|
||
enabled,
|
||
config_path=path,
|
||
write_cmd=write_cmd,
|
||
)
|
||
if result.get("ok"):
|
||
from app.services.system_restart_service import maybe_auto_reboot_after_pi_boot_change
|
||
|
||
reboot = maybe_auto_reboot_after_pi_boot_change(current_app.config)
|
||
result["reboot_scheduled"] = bool(reboot.get("scheduled"))
|
||
if reboot.get("scheduled") and reboot.get("message"):
|
||
result["message"] = (
|
||
f"{result.get('message', '').rstrip('.')}. {reboot['message']}"
|
||
)
|
||
return jsonify({"status": "success", **result})
|
||
return jsonify({"status": "error", **result}), 400
|
||
|
||
|
||
@bp.post("/plymouth/install")
|
||
@require_superuser
|
||
def admin_plymouth_install():
|
||
"""Собрать кадры 800×600 и установить тему Plymouth WESP (фон, нужен sudo)."""
|
||
from app.services.plymouth_theme_service import start_build_and_install
|
||
|
||
result = start_build_and_install(current_app)
|
||
if result.get("ok"):
|
||
return jsonify({"status": "success", **result})
|
||
return jsonify({"status": "error", **result}), 409
|
||
|
||
|
||
@bp.get("/kiosk-full-setup/status")
|
||
@require_superuser
|
||
def admin_kiosk_full_setup_status():
|
||
from app.services.kiosk_full_setup_service import get_full_kiosk_setup_status
|
||
|
||
return jsonify(
|
||
{"status": "success", **get_full_kiosk_setup_status(current_app.config)},
|
||
)
|
||
|
||
|
||
@bp.post("/kiosk-full-setup")
|
||
@require_superuser
|
||
def admin_kiosk_full_setup_apply():
|
||
"""wesp.env + systemd + киоск (без рабочего стола) + одна перезагрузка."""
|
||
from app.services.kiosk_full_setup_service import apply_full_kiosk_setup
|
||
|
||
payload = request.get_json(silent=True) or {}
|
||
hide_desktop = payload.get("hide_desktop")
|
||
if hide_desktop is None:
|
||
hide_desktop = True
|
||
else:
|
||
hide_desktop = bool(hide_desktop)
|
||
|
||
result = apply_full_kiosk_setup(
|
||
current_app.config,
|
||
hide_desktop=hide_desktop,
|
||
)
|
||
if result.get("ok"):
|
||
return jsonify({"status": "success", **result})
|
||
return jsonify({"status": "error", **result}), 400
|
||
|
||
|
||
@bp.get("/pi-platform/status")
|
||
@require_superuser
|
||
def admin_pi_platform_status():
|
||
from app.services.pi_platform_setup_service import get_pi_platform_setup_status
|
||
|
||
return jsonify(
|
||
{"status": "success", **get_pi_platform_setup_status(current_app.config)},
|
||
)
|
||
|
||
|
||
@bp.post("/pi-platform/apply")
|
||
@require_superuser
|
||
def admin_pi_platform_apply():
|
||
"""wesp.env + systemd unit (как install_fresh.sh)."""
|
||
from app.services.pi_platform_setup_service import apply_pi_platform_setup
|
||
|
||
result = apply_pi_platform_setup(current_app.config)
|
||
if result.get("ok"):
|
||
return jsonify({"status": "success", **result})
|
||
return jsonify({"status": "error", **result}), 400
|
||
|
||
|
||
@bp.get("/kiosk-boot/status")
|
||
@require_superuser
|
||
def admin_kiosk_boot_status():
|
||
from app.services.kiosk_boot_service import get_kiosk_boot_status
|
||
|
||
cfg = current_app.config
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
**get_kiosk_boot_status(cfg),
|
||
},
|
||
)
|
||
|
||
|
||
@bp.post("/kiosk-boot")
|
||
@require_superuser
|
||
def admin_kiosk_boot_set():
|
||
"""Включить/выключить chromium-kiosk.desktop для GUI-пользователя."""
|
||
from app.services.kiosk_boot_service import set_kiosk_boot
|
||
|
||
payload = request.get_json(silent=True) or {}
|
||
if "enabled" not in payload:
|
||
return jsonify(
|
||
{"status": "error", "message": "Укажите enabled: true или false"},
|
||
), 400
|
||
|
||
enabled = bool(payload.get("enabled"))
|
||
hide_desktop = payload.get("hide_desktop")
|
||
if hide_desktop is not None:
|
||
hide_desktop = bool(hide_desktop)
|
||
|
||
cfg = current_app.config
|
||
result = set_kiosk_boot(
|
||
enabled,
|
||
cfg,
|
||
hide_desktop=hide_desktop,
|
||
)
|
||
if result.get("ok"):
|
||
return jsonify({"status": "success", **result})
|
||
return jsonify({"status": "error", **result}), 400
|
||
|
||
|
||
@bp.post("/service-control")
|
||
@require_superuser
|
||
def admin_service_control():
|
||
"""Опционально: WESP_ADMIN_RESTART_CMD / WESP_ADMIN_STOP_CMD."""
|
||
payload = request.get_json(silent=True) or {}
|
||
action = (payload.get("action") or "").strip().lower()
|
||
if action not in ("restart", "stop"):
|
||
return jsonify({"status": "error", "message": "Укажите action: restart или stop"}), 400
|
||
|
||
result = run_service_command(current_app, action)
|
||
if result.get("ok"):
|
||
return jsonify({"status": "success", **result})
|
||
return jsonify({"status": "error", **result}), 400
|
||
|
||
|
||
@bp.get("/users")
|
||
@require_superuser
|
||
def list_users():
|
||
_ensure_default_superuser()
|
||
rows = db.session.execute(select(WebUser).order_by(WebUser.login.asc())).scalars().all()
|
||
return jsonify(
|
||
{
|
||
"status": "success",
|
||
"users": [
|
||
{
|
||
"id": row.id,
|
||
"login": row.login,
|
||
"is_superuser": bool(row.is_superuser),
|
||
"lab_access": bool(row.lab_access),
|
||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||
}
|
||
for row in rows
|
||
],
|
||
}
|
||
)
|
||
|
||
|
||
@bp.post("/users")
|
||
@require_superuser
|
||
def create_user():
|
||
_ensure_default_superuser()
|
||
payload = request.get_json(silent=True) or {}
|
||
login = (payload.get("login") or "").strip()
|
||
password = payload.get("password") or ""
|
||
is_superuser = bool(payload.get("is_superuser", False))
|
||
lab_access = bool(payload.get("lab_access", False))
|
||
|
||
if not login:
|
||
return jsonify({"status": "error", "message": "Логин обязателен"}), 400
|
||
if not _valid_login(login):
|
||
return jsonify({"status": "error", "message": "Логин не длиннее 64 символов"}), 400
|
||
if len(password) < _WEB_USER_PASSWORD_MIN_LEN:
|
||
return jsonify(
|
||
{
|
||
"status": "error",
|
||
"message": f"Пароль должен быть не короче {_WEB_USER_PASSWORD_MIN_LEN} символов",
|
||
}
|
||
), 400
|
||
|
||
err_body, err_code = _reject_if_weak_login_or_password(login, password)
|
||
if err_body is not None:
|
||
return jsonify(err_body), err_code
|
||
|
||
existing = db.session.execute(select(WebUser).where(WebUser.login == login)).scalar_one_or_none()
|
||
if existing is not None:
|
||
return jsonify({"status": "error", "message": "Пользователь с таким логином уже существует"}), 409
|
||
|
||
user = WebUser(
|
||
login=login,
|
||
password_hash=generate_password_hash(password),
|
||
is_superuser=is_superuser,
|
||
lab_access=lab_access if not is_superuser else False,
|
||
)
|
||
db.session.add(user)
|
||
db.session.commit()
|
||
return jsonify({"status": "success", "id": user.id}), 201
|
||
|
||
|
||
@bp.patch("/users/<string:user_id>")
|
||
@require_superuser
|
||
def update_user(user_id: str):
|
||
_ensure_default_superuser()
|
||
payload = request.get_json(silent=True) or {}
|
||
user = db.session.execute(select(WebUser).where(WebUser.id == user_id)).scalar_one_or_none()
|
||
if user is None:
|
||
return jsonify({"status": "error", "message": "Пользователь не найден"}), 404
|
||
|
||
new_password = payload.get("password")
|
||
if isinstance(new_password, str) and new_password:
|
||
if len(new_password) < _WEB_USER_PASSWORD_MIN_LEN:
|
||
return jsonify(
|
||
{
|
||
"status": "error",
|
||
"message": f"Пароль должен быть не короче {_WEB_USER_PASSWORD_MIN_LEN} символов",
|
||
}
|
||
), 400
|
||
err_body, err_code = _reject_if_weak_password_only(new_password)
|
||
if err_body is not None:
|
||
return jsonify(err_body), err_code
|
||
user.password_hash = generate_password_hash(new_password)
|
||
|
||
if "is_superuser" in payload:
|
||
target_flag = bool(payload.get("is_superuser"))
|
||
if user.login == session.get("user_login") and not target_flag:
|
||
return jsonify({"status": "error", "message": "Нельзя снять права суперпользователя у текущего пользователя"}), 400
|
||
if not target_flag and user.is_superuser:
|
||
superusers_count = int(
|
||
db.session.scalar(
|
||
select(func.count()).select_from(WebUser).where(WebUser.is_superuser.is_(True))
|
||
)
|
||
or 0
|
||
)
|
||
if superusers_count <= 1:
|
||
return jsonify({"status": "error", "message": "Нельзя убрать права у последнего суперпользователя"}), 400
|
||
user.is_superuser = target_flag
|
||
|
||
if "lab_access" in payload:
|
||
user.lab_access = bool(payload.get("lab_access"))
|
||
|
||
db.session.commit()
|
||
return jsonify({"status": "success"})
|
||
|
||
|
||
@bp.delete("/users/<string:user_id>")
|
||
@require_superuser
|
||
def delete_user(user_id: str):
|
||
_ensure_default_superuser()
|
||
user = db.session.execute(select(WebUser).where(WebUser.id == user_id)).scalar_one_or_none()
|
||
if user is None:
|
||
return jsonify({"status": "error", "message": "Пользователь не найден"}), 404
|
||
if user.login == session.get("user_login"):
|
||
return jsonify({"status": "error", "message": "Нельзя удалить текущего пользователя"}), 400
|
||
if user.is_superuser:
|
||
superusers_count = int(
|
||
db.session.scalar(
|
||
select(func.count()).select_from(WebUser).where(WebUser.is_superuser.is_(True))
|
||
)
|
||
or 0
|
||
)
|
||
if superusers_count <= 1:
|
||
return jsonify({"status": "error", "message": "Нельзя удалить последнего суперпользователя"}), 400
|
||
|
||
db.session.delete(user)
|
||
db.session.commit()
|
||
return jsonify({"status": "success"})
|