"""WESP-shaped admin endpoints for copied static UI (/api/admin/*).""" from __future__ import annotations import json import time from datetime import UTC, datetime from pathlib import Path from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi.responses import FileResponse from pydantic import BaseModel, Field from sqlalchemy import func, select from app.core.config import settings from app.core.server_logging import ensure_server_log_file from app.core.database import session_scope from app.core.dependencies import require_superuser from app.core.audit_log import read_audit_events, write_audit_event from app.modules.admin.service import ( create_admin_user, delete_admin_user, get_diagnostics_report, get_server_log_tail, patch_user, reset_user_password, ) from app.modules.zootech.orchestrator_system_metrics import collect_orchestrator_system_metrics from app.modules.sync.models import Enterprise, FarmHub, SyncConflict, SyncOutbox from app.modules.sync.service import get_sync_metrics from app.modules.users import repository as users_repository from app.modules.users.models import User from app.modules.zootech.catalog_models import ZootechFeedDispenser, ZootechFeedingPeriod from app.modules.zootech.models import ZootechComponent, ZootechIngredient, ZootechRecipe router = APIRouter(prefix="/admin", tags=["zootech-wesp-admin"]) def _wesp_error(message: str, code: int = 400) -> None: raise HTTPException(status_code=code, detail={"status": "error", "message": message}) def _count_zootech(model) -> int: with session_scope() as db: return ( db.scalar(select(func.count()).select_from(model).where(model.is_deleted.is_(False))) or 0 ) def _sync_queue_stats() -> dict[str, int]: with session_scope() as db: total = db.scalar(select(func.count()).select_from(SyncOutbox)) or 0 pending = ( db.scalar( select(func.count()).select_from(SyncOutbox).where(SyncOutbox.status == "pending") ) or 0 ) processing = ( db.scalar( select(func.count()) .select_from(SyncOutbox) .where(SyncOutbox.status == "processing") ) or 0 ) failed = ( db.scalar( select(func.count()).select_from(SyncOutbox).where(SyncOutbox.status == "failed") ) or 0 ) return { "total": int(total), "pending": int(pending), "processing": int(processing), "failed": int(failed), } def _user_to_wesp(user: User) -> dict[str, Any]: return { "id": user.id, "login": user.email, "is_superuser": bool(user.is_superuser), "lab_access": False, "created_at": user.created_at.isoformat() if user.created_at else None, "updated_at": user.updated_at.isoformat() if user.updated_at else None, } class WespUiActivityIn(BaseModel): text: str = Field(min_length=1) level: str = "ok" class WespUserCreateIn(BaseModel): login: str = Field(min_length=1) password: str = Field(min_length=8) is_superuser: bool = False class WespUserPatchIn(BaseModel): password: str | None = None is_superuser: bool | None = None lab_access: bool | None = None class WespNetworkPatchIn(BaseModel): local_hostname: str | None = None public_base_url: str | None = None mdns_enabled: bool | None = None class WespOrchestratorSyncPatchIn(BaseModel): upstream_url: str | None = None hub_site_id: str | None = None api_key: str | None = None @router.get("/users") def wesp_list_users(_admin: User = Depends(require_superuser)): users, _total = users_repository.list_users(page=1, limit=500) payload = {"status": "success", "users": [_user_to_wesp(u) for u in users]} return payload @router.post("/users", status_code=201) def wesp_create_user(payload: WespUserCreateIn, admin: User = Depends(require_superuser)): email = payload.login.strip() if "@" not in email: _wesp_error("Используйте email в поле логина (admin@compton.example)") try: create_admin_user( admin, email=email, password=payload.password, role="admin" if payload.is_superuser else "user", is_superuser=payload.is_superuser, status="active", ) except ValueError as exc: if str(exc) == "USER_EXISTS": _wesp_error("Пользователь с таким логином уже существует", 409) _wesp_error(str(exc)) return {"status": "success"} @router.patch("/users/{user_id}") def wesp_patch_user(user_id: str, payload: WespUserPatchIn, admin: User = Depends(require_superuser)): if payload.password: try: reset_user_password(admin, user_id, payload.password) except ValueError as exc: _wesp_error(str(exc), 404 if str(exc) == "USER_NOT_FOUND" else 400) return {"status": "success"} if payload.is_superuser is not None: try: patch_user(admin, user_id, None, None, payload.is_superuser) except ValueError as exc: _wesp_error(str(exc), 404 if str(exc) == "USER_NOT_FOUND" else 400) return {"status": "success"} if payload.lab_access is not None: return {"status": "success"} _wesp_error("Нет полей для обновления") @router.delete("/users/{user_id}") def wesp_delete_user(user_id: str, admin: User = Depends(require_superuser)): try: delete_admin_user(admin, user_id) except ValueError as exc: _wesp_error(str(exc), 404 if str(exc) == "USER_NOT_FOUND" else 400) return {"status": "success"} @router.get("/summary") def wesp_admin_summary(_admin: User = Depends(require_superuser)): queue = _sync_queue_stats() with session_scope() as db: enterprises = list(db.scalars(select(Enterprise).order_by(Enterprise.name))) hub_count = db.scalar(select(func.count()).select_from(FarmHub)) or 0 enterprise_ids = [e.id for e in enterprises] conflicts_pending = 0 for enterprise_id in enterprise_ids: conflicts_pending += int(get_sync_metrics(enterprise_id).get("conflicts_pending") or 0) payload = { "status": "success", "generated_at": datetime.now(UTC).isoformat(), "app": { "config_mode": settings.app_env, "debug": settings.app_env != "production", "version": "1.0.0-orchestrator", "role": "server", "first_launch_at": None, "warranty_until": None, "warranty_days_remaining": None, }, "databases": { "recipes": {"engine": "postgresql", "path": "orchestrator"}, "reports": {"engine": "postgresql", "path": "orchestrator"}, }, "machine": {"platform": "orchestrator"}, "counts": { "users": users_repository.count_users(), "recipes": _count_zootech(ZootechRecipe), "ingredients": _count_zootech(ZootechIngredient), "components": _count_zootech(ZootechComponent), "feed_dispensers": _count_zootech(ZootechFeedDispenser), "feeding_periods": _count_zootech(ZootechFeedingPeriod), }, "sync": { "client_role": "server", "connection": { "role": "server", "server_url": settings.public_base_url.rstrip("/"), "configured": True, }, "max_concurrent": 4, "queue": queue, "client_poll_interval_sec": 14, "first_sync_banner": False, "initial_sync_progress": {"active": False}, "guarantees": { "master_data": "Orchestrator — источник правды для предприятий и хабов.", "reports": "Отчёты принимаются через sync API.", }, "state": {"hub_count": int(hub_count), "enterprise_count": len(enterprise_ids)}, "queue_errors": [], }, "security": { "mac_lock_enabled": False, "calibration_public": False, "device_token_configured": False, "kiosk_enforce_paired_only": False, }, "auto_update": {"enabled": False, "note": "Обновления управляются деплоем orchestrator."}, "hardware": {"simulation_mode": True, "note": "Железо недоступно на orchestrator."}, } return payload @router.get("/system-metrics") def wesp_system_metrics(_admin: User = Depends(require_superuser)): metrics = collect_orchestrator_system_metrics() return {"status": "success", "metrics": metrics} @router.get("/factory-reset/preview") def wesp_factory_reset_preview(_admin: User = Depends(require_superuser)): return { "status": "success", "confirm_phrase": "СБРОС ДАННЫХ", "preserved_note": "Сброс data/ недоступен на orchestrator — данные в PostgreSQL и S3.", "files": [], "directories": [], } @router.get("/network-settings") def wesp_network_settings_get(_admin: User = Depends(require_superuser)): public_url = settings.public_base_url.rstrip("/") return { "status": "success", "network": { "local_hostname": "orchestrator.local", "default_local_hostname": "orchestrator.local", "public_base_url": public_url, "mdns_enabled": False, "local_hostname_locked_by_env": True, "public_base_url_locked_by_env": False, "mdns_enabled_locked_by_env": True, "detected": { "listen_port": 5173, "listen_host": "0.0.0.0", "os_hostname": "orchestrator", "mdns_available": False, "mdns_active": False, }, "suggested_public_url": public_url, "sync_url_by_ip": public_url, }, } @router.patch("/network-settings") def wesp_network_settings_patch(_payload: WespNetworkPatchIn, _admin: User = Depends(require_superuser)): return {"status": "success", "message": "Сетевые настройки orchestrator задаются через PUBLIC_BASE_URL / nginx."} @router.get("/peripheral-events") def wesp_peripheral_events( limit: int = Query(default=50, ge=1, le=200), component: str | None = None, exclude_component: str | None = None, _admin: User = Depends(require_superuser), ): _ = (limit, component, exclude_component) return {"status": "success", "events": []} @router.get("/sync-diagnostics") def wesp_sync_diagnostics(_admin: User = Depends(require_superuser)): queue = _sync_queue_stats() with session_scope() as db: enterprises = list(db.scalars(select(Enterprise).order_by(Enterprise.name))) hubs = list(db.scalars(select(FarmHub).order_by(FarmHub.created_at.desc()))) conflicts_total = db.scalar(select(func.count()).select_from(SyncConflict)) or 0 conflicts_pending = ( db.scalar( select(func.count()) .select_from(SyncConflict) .where(SyncConflict.status == "pending") ) or 0 ) hub_rows = [ { "farm_hub_id": h.id, "name": h.name, "hub_site_id": h.hub_site_id, "status": h.status, "last_seen": h.last_seen.isoformat() if h.last_seen else None, "enterprise_id": h.enterprise_id, } for h in hubs ] enterprise_rows = [{"id": e.id, "name": e.name, "slug": e.slug} for e in enterprises] for row in hub_rows: row.update(get_sync_metrics(row.pop("enterprise_id"))) payload = { "status": "success", "role": "server", "queues": { "summary": queue, "by_table": {}, "stuck_processing": [], "processing_missing_processed_at": [], "failed_recent": [], "high_retry_pending": [], }, "clients": {"recent_deliveries": []}, "conflicts": { "summary": {"total": int(conflicts_total), "pending": int(conflicts_pending)}, "recent": [], }, "report_push": { "scope": "orchestrator", "note": "Push отчётов обрабатывается через sync engine.", "total": 0, }, "engine_state": { "enterprises": enterprise_rows, "hubs": hub_rows, }, "initial_sync_progress": {"active": False}, "actions_capabilities": { "requeue_stuck": False, "restart_local_sync": False, "refresh_diagnostics": True, "retry_report_push": False, }, } return payload @router.get("/orchestrator-sync") def wesp_orchestrator_sync_get(_admin: User = Depends(require_superuser)): with session_scope() as db: hub = db.scalar(select(FarmHub).order_by(FarmHub.created_at.desc())) hub_site_id = hub.hub_site_id if hub else "" return { "status": "success", "orchestrator_sync": { "upstream_url": settings.public_base_url.rstrip("/"), "hub_site_id": hub_site_id, }, } @router.patch("/orchestrator-sync") def wesp_orchestrator_sync_patch(_payload: WespOrchestratorSyncPatchIn, _admin: User = Depends(require_superuser)): return { "status": "success", "message": "Параметры hub↔orchestrator настраиваются через pairing, не из этой формы.", } @router.get("/activity-feed") def wesp_activity_feed(limit: int = Query(default=200, ge=1, le=1000), _admin: User = Depends(require_superuser)): entries: list[dict[str, Any]] = [] for event in read_audit_events(limit=limit): ts_raw = event.get("timestamp") ts_ms = 0 if ts_raw: try: ts_ms = int(datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00")).timestamp() * 1000) except ValueError: ts_ms = int(time.time() * 1000) details = event.get("details") or {} text = str(details.get("text") or event.get("action") or "") level = str(details.get("level") or "ok") if level not in {"ok", "err", "warn"}: level = "ok" entries.append({"ts": ts_ms, "level": level, "text": text}) return {"status": "success", "entries": entries, "meta": "orchestrator-audit"} @router.post("/ui-activity") def wesp_ui_activity(payload: WespUiActivityIn, admin: User = Depends(require_superuser)): level = payload.level if payload.level in {"ok", "err", "warn"} else "ok" write_audit_event( action="ui.activity", actor_user_id=admin.id, actor_email=admin.email, details={"text": payload.text, "level": level}, ) return {"status": "success"} @router.get("/diagnostics/report") def wesp_diagnostics_report(_admin: User = Depends(require_superuser)): report = get_diagnostics_report() return {"status": "success", **report} def _security_settings_payload() -> dict[str, Any]: return { "mac_lock_enabled": False, "mac_lock_enabled_locked_by_env": True, "allowed_mac_addresses": "", "allowed_mac_addresses_locked_by_env": True, "kiosk_enforce_paired_only": False, "kiosk_enforce_paired_only_locked_by_env": True, "calibration_public": False, "calibration_public_locked_by_env": True, "device_token_configured": False, "device_token_locked_by_env": True, "device_token_header": "X-Device-Token", "device_token_header_locked_by_env": True, "kiosk_pair_token_ttl_seconds": 300, "kiosk_pair_token_ttl_seconds_locked_by_env": True, "kiosk_auth_cookie_days": 18250, "kiosk_auth_cookie_days_locked_by_env": True, "session_remember_days": 7, "session_remember_days_locked_by_env": True, "llm_autostart": False, "llm_autostart_locked_by_env": True, "admin_llm_enabled": False, "admin_llm_enabled_locked_by_env": True, "llm_chat_db_context": False, "llm_chat_db_context_locked_by_env": True, "llm_tools_enabled": False, "llm_tools_enabled_locked_by_env": True, } def _auto_update_payload() -> dict[str, Any]: return { "enabled": False, "auto_install": False, "gitea_url": "", "gitea_owner": "", "gitea_repo": "", "repository_url": "", "check_interval_sec": 3600, "note": "Автообновление недоступно на orchestrator.", } def _hub_only_stub(message: str, **extra: Any) -> dict[str, Any]: return {"status": "success", "available": False, "orchestrator": True, "message": message, **extra} def _patch_ok(message: str, **extra: Any) -> dict[str, Any]: return {"status": "success", "message": message, **extra} @router.get("/hardware-status") def wesp_hardware_status(_admin: User = Depends(require_superuser)): return { "status": "success", "config": {"simulation_mode": True, "read_interval": 0.05, "samples_per_read": 3}, "peripherals": {}, "scales": {"available": False, "error": "Железо недоступно на orchestrator (Docker)."}, } @router.get("/hardware-metrics-history") def wesp_hardware_metrics_history(_admin: User = Depends(require_superuser)): return {"status": "success", "points": [], "path": None, "retention_days": 0} @router.patch("/hardware/simulation") def wesp_hardware_simulation_patch(_admin: User = Depends(require_superuser)): return _patch_ok("Симуляция железа недоступна на orchestrator.") @router.put("/hardware/simulation/weight") @router.patch("/hardware/simulation/weight") def wesp_hardware_simulation_weight(_admin: User = Depends(require_superuser)): return _patch_ok("Симуляция весов недоступна на orchestrator.") @router.get("/llm/status") def wesp_llm_status(_admin: User = Depends(require_superuser)): return { "status": "success", "enabled": False, "llm_base_url": "", "model": "", "assistant_dir": "", "llm_autostart": False, "llm_autostart_locked_by_env": True, "admin_llm_enabled_locked_by_env": True, "llm_chat_db_context": False, "llm_chat_db_context_locked_by_env": True, "llm_tools_enabled": False, "llm_tools_enabled_locked_by_env": True, "llm_reachable": False, "model_present": False, "llm_error": "LLM недоступен на orchestrator.", } @router.get("/llm/activity") def wesp_llm_activity(_admin: User = Depends(require_superuser)): return {"status": "success", "entries": []} @router.post("/llm/chat") @router.post("/llm/ping") @router.post("/llm/diagnostics") @router.post("/llm/summary") def wesp_llm_actions(_admin: User = Depends(require_superuser)): return {"status": "error", "message": "LLM недоступен на orchestrator."} @router.patch("/security-settings") def wesp_security_settings_patch(_admin: User = Depends(require_superuser)): return _patch_ok( "Настройки безопасности hub недоступны на orchestrator.", security=_security_settings_payload(), ) @router.patch("/auto-update-settings") def wesp_auto_update_settings_patch(_admin: User = Depends(require_superuser)): return _patch_ok( "Автообновление недоступно на orchestrator.", auto_update=_auto_update_payload(), ) @router.patch("/gitea-secrets") def wesp_gitea_secrets_patch(_admin: User = Depends(require_superuser)): return _patch_ok("Gitea secrets недоступны на orchestrator.", auto_update=_auto_update_payload()) @router.get("/sync-settings") def wesp_sync_settings_get(_admin: User = Depends(require_superuser)): return { "status": "success", "connection": {"role": "server", "server_url": settings.public_base_url.rstrip("/"), "configured": True}, "state": {}, } @router.patch("/sync-settings") def wesp_sync_settings_patch(_admin: User = Depends(require_superuser)): return _patch_ok("Sync settings hub-client недоступны на orchestrator (роль server).") @router.post("/sync-actions/requeue-stuck") @router.post("/sync-actions/restart-local-sync") @router.post("/sync-actions/refresh-runtime") @router.post("/sync-actions/retry-report-push") def wesp_sync_actions(_admin: User = Depends(require_superuser)): return _patch_ok("Действие sync hub-client недоступно на orchestrator.") @router.get("/kiosk-full-setup/status") def wesp_kiosk_full_setup_status(_admin: User = Depends(require_superuser)): return _hub_only_stub( "Kiosk setup только на Raspberry Pi hub.", running_as_root=False, full_setup_ready=False, platform_ready=False, kiosk_enabled=False, chromium_found=False, ) @router.post("/kiosk-full-setup") def wesp_kiosk_full_setup_post(_admin: User = Depends(require_superuser)): _wesp_error("Kiosk setup только на Raspberry Pi hub.", 400) @router.get("/pi-platform/status") def wesp_pi_platform_status(_admin: User = Depends(require_superuser)): return _hub_only_stub("Pi platform setup только на hub.", running_as_root=False, platform_ready=False) @router.post("/pi-platform/apply") def wesp_pi_platform_apply(_admin: User = Depends(require_superuser)): _wesp_error("Pi platform setup только на hub.", 400) @router.get("/pi-boot/rainbow-splash/status") def wesp_pi_rainbow_status(_admin: User = Depends(require_superuser)): return _hub_only_stub("Pi boot config только на hub.", config_found=False, disable_splash=True) @router.post("/pi-boot/rainbow-splash") def wesp_pi_rainbow_post(_admin: User = Depends(require_superuser)): _wesp_error("Pi boot config только на hub.", 400) @router.get("/plymouth/status") def wesp_plymouth_status(_admin: User = Depends(require_superuser)): return _hub_only_stub("Plymouth только на hub.", installed=False, running=False) @router.post("/plymouth/install") def wesp_plymouth_install(_admin: User = Depends(require_superuser)): _wesp_error("Plymouth только на hub.", 400) @router.get("/kiosk-boot/status") def wesp_kiosk_boot_status(_admin: User = Depends(require_superuser)): return _hub_only_stub("Kiosk boot только на hub.", configured=False) @router.post("/kiosk-boot") def wesp_kiosk_boot_post(_admin: User = Depends(require_superuser)): _wesp_error("Kiosk boot только на hub.", 400) @router.post("/factory-reset") def wesp_factory_reset_post(_admin: User = Depends(require_superuser)): _wesp_error("Factory reset data/ недоступен на orchestrator.", 400) @router.post("/service-control") def wesp_service_control(_admin: User = Depends(require_superuser)): _wesp_error("Управление systemd недоступно на orchestrator.", 400) @router.get("/backup.sqlite") @router.post("/restore.sqlite") def wesp_sqlite_backup(_admin: User = Depends(require_superuser)): _wesp_error("SQLite backup/restore только на hub.", 400) @router.get("/client-uploaded-logs") def wesp_client_uploaded_logs(_admin: User = Depends(require_superuser)): return {"status": "success", "root": "", "clients": []} @router.get("/client-uploaded-logs/tail") def wesp_client_uploaded_logs_tail( client_id: str | None = None, file: str | None = None, lines: int = Query(default=300, ge=1, le=2000), _admin: User = Depends(require_superuser), ): _ = (client_id, file, lines) return { "status": "error", "message": "Логи клиентов (POST /api/sync/client-log) на orchestrator пока не настроены.", "lines": [], } @router.get("/client-uploaded-logs/download") def wesp_client_uploaded_logs_download( client_id: str | None = None, file: str | None = None, _admin: User = Depends(require_superuser), ): _ = (client_id, file) _wesp_error("Логи клиентов на orchestrator пока не настроены.", 404) @router.get("/server-log") def wesp_server_log(lines: int = Query(default=200, ge=1, le=1000), _admin: User = Depends(require_superuser)): log_path = ensure_server_log_file() tail = get_server_log_tail(lines=lines) payload = {"status": "success", "path": str(log_path), "lines": tail.get("lines") or []} return payload @router.get("/server-log/download") def wesp_server_log_download(_admin: User = Depends(require_superuser)): log_path = ensure_server_log_file() return FileResponse( log_path, media_type="text/plain; charset=utf-8", filename=log_path.name, ) @router.get("/diagnostics/settings") def wesp_diagnostics_settings_get(_admin: User = Depends(require_superuser)): return {"status": "success", "targets": [], "extra_targets": []} @router.patch("/diagnostics/settings") def wesp_diagnostics_settings_patch(_admin: User = Depends(require_superuser)): return _patch_ok("Diagnostics settings сохранены локально недоступны на orchestrator.") @router.post("/diagnostics/network-run") @router.post("/diagnostics/hx711-sample") @router.post("/diagnostics/gpio-blink") @router.post("/diagnostics/traceroute-by-hosts") def wesp_diagnostics_actions(_admin: User = Depends(require_superuser)): return _patch_ok("Диагностика железа/сети hub недоступна на orchestrator.") @router.patch("/sync-clients/{node_id}/ip") def wesp_sync_client_ip(_node_id: str, _admin: User = Depends(require_superuser)): return _patch_ok("Sync client IP управляется через hub sync API.")