388 lines
15 KiB
Python
388 lines
15 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import httpx
|
||
from dotenv import load_dotenv
|
||
from fastapi import Depends, FastAPI, HTTPException, Query, Request
|
||
from fastapi.responses import JSONResponse, RedirectResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
|
||
from hockey_data import (
|
||
HockeyDataService,
|
||
HockeyDatabase,
|
||
HockeySettingsStore,
|
||
create_hockey_router,
|
||
)
|
||
from hockey_data.router import create_hockey_vmix_router
|
||
from hockey_data.vmix_portable import (
|
||
HockeyVmixPortable,
|
||
configure_hockey_vmix_settings,
|
||
create_hockey_vmix_settings_router,
|
||
)
|
||
from hockey_data.auth_bridge import HockeyAuthDependencies, create_auth_adapter
|
||
from hockey_data.auth_router import create_hockey_auth_router
|
||
from hockey_data.agent_bridge import VmixAgentHub, create_hockey_agent_router
|
||
from ui_builder import install_ui_builder
|
||
from khl_site.khl_data_center import APP as khl_site_app
|
||
|
||
BASE_DIR = Path(__file__).resolve().parent
|
||
BUILD_VERSION = "2026.08.20.15"
|
||
# compatibility: BUILD_VERSION = "2026.08.20.14"
|
||
# compatibility: BUILD_VERSION = "2026.08.20.13"
|
||
# compatibility: BUILD_VERSION = "2026.08.19.28"
|
||
# compatibility: BUILD_VERSION = "2026.08.19.24"
|
||
# compatibility: BUILD_VERSION = "2026.08.19.22"
|
||
load_dotenv(BASE_DIR / ".env.local", override=False)
|
||
load_dotenv(BASE_DIR / ".env", override=False)
|
||
|
||
# Production can keep secrets outside the Git working tree. systemd already
|
||
# injects EnvironmentFile=/mnt/khl/.env, but this fallback also makes a manual
|
||
# `python -m uvicorn app:app` launch read the same file. Existing process
|
||
# environment variables always win because override=False.
|
||
_external_env = Path(os.getenv("HOCKEY_ENV_FILE", "/mnt/khl/.env"))
|
||
if _external_env.is_file():
|
||
load_dotenv(_external_env, override=False)
|
||
VMIX_API_URL = os.getenv("VMIX_API_URL", "http://127.0.0.1:8088/api/")
|
||
configure_hockey_vmix_settings(BASE_DIR)
|
||
|
||
app = FastAPI(title=f"Хоккейная панель управления / Stat2TV — build {BUILD_VERSION}")
|
||
|
||
print(f"[Hockey Build] {BUILD_VERSION}")
|
||
hockey_settings = HockeySettingsStore(BASE_DIR / "settings")
|
||
hockey_database = HockeyDatabase(BASE_DIR)
|
||
hockey_auth = HockeyAuthDependencies(create_auth_adapter(hockey_database))
|
||
hockey_agent_hub = VmixAgentHub(hockey_database, settings=hockey_settings)
|
||
|
||
|
||
@app.on_event("startup")
|
||
async def hockey_mapping_auto_refresh_startup() -> None:
|
||
hockey_agent_hub.start_auto_refresh()
|
||
|
||
|
||
@app.on_event("shutdown")
|
||
async def hockey_mapping_auto_refresh_shutdown() -> None:
|
||
await hockey_agent_hub.stop_auto_refresh()
|
||
|
||
|
||
@app.middleware("http")
|
||
async def hockey_runtime_auth(request: Request, call_next):
|
||
"""Protect runtime/editor and prevent stale operator-interface assets."""
|
||
path = request.url.path
|
||
editor_path = (
|
||
path == "/editor"
|
||
or path.startswith("/api/ui-builder/editor/")
|
||
or path.startswith("/api/ui-builder/auth/")
|
||
)
|
||
khl_admin_path = path.startswith("/khl-site")
|
||
protected_path = path == "/" or khl_admin_path or editor_path
|
||
|
||
if protected_path:
|
||
try:
|
||
user = await hockey_auth.optional_user(request)
|
||
except HTTPException as error:
|
||
return JSONResponse({"detail": error.detail}, status_code=error.status_code)
|
||
if user is None:
|
||
if path in {"/", "/editor"}:
|
||
return RedirectResponse("/login?reason=expired", status_code=303)
|
||
return JSONResponse({"detail": "Требуется вход"}, status_code=401)
|
||
if (khl_admin_path or editor_path) and not user.is_admin:
|
||
if path == "/editor":
|
||
return RedirectResponse("/", status_code=303)
|
||
return JSONResponse({"detail": "Раздел доступен только администратору"}, status_code=403)
|
||
request.state.wfl_user = user
|
||
response = await call_next(request)
|
||
if (
|
||
path in {"/", "/editor"}
|
||
or path.startswith("/ui-builder-assets/")
|
||
or path.startswith("/hockey-assets/")
|
||
or path.startswith("/api/ui-builder/runtime/")
|
||
or path.startswith("/khl-site")
|
||
):
|
||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||
response.headers["Pragma"] = "no-cache"
|
||
response.headers["Expires"] = "0"
|
||
return response
|
||
hockey_service = HockeyDataService(
|
||
base_dir=BASE_DIR,
|
||
database=hockey_database,
|
||
settings=hockey_settings,
|
||
)
|
||
hockey_vmix_portable = HockeyVmixPortable(hockey_service)
|
||
HOCKEY_BOOTSTRAP = hockey_service.bootstrap()
|
||
_backfill = HOCKEY_BOOTSTRAP.get("normalized_backfill", {}) if isinstance(HOCKEY_BOOTSTRAP, dict) else {}
|
||
if any(int(value or 0) for value in _backfill.values() if isinstance(value, (int, float))):
|
||
print(
|
||
"[Hockey DB] normalized backfill updated "
|
||
+ " ".join(f"{key}={value}" for key, value in _backfill.items())
|
||
)
|
||
HOCKEY_SCHEMA_STATUS = hockey_database.schema_status()
|
||
print(
|
||
"[Hockey DB] schema ok="
|
||
f"{HOCKEY_SCHEMA_STATUS.get('ok')} "
|
||
f"repaired={len(HOCKEY_SCHEMA_STATUS.get('repaired', []))} "
|
||
f"rebuilt={HOCKEY_SCHEMA_STATUS.get('rebuilt', False)}"
|
||
)
|
||
|
||
app.mount("/khl-site", khl_site_app)
|
||
|
||
app.mount(
|
||
"/hockey-assets",
|
||
StaticFiles(directory=BASE_DIR / "hockey_data" / "static"),
|
||
name="hockey-assets",
|
||
)
|
||
|
||
|
||
def hockey_stat2tv_data() -> dict[str, Any]:
|
||
return hockey_service.data_for_builder()
|
||
|
||
|
||
def vmix_demo_data() -> dict[str, Any]:
|
||
home_players = [
|
||
{"id": f"H{i:02d}", "number": number, "name": name, "position": position}
|
||
for i, (number, name, position) in enumerate([
|
||
(1, "Максим Лебедев", "ВР"), (30, "Илья Зорин", "ВР"),
|
||
(2, "Алексей Титов", "ЗАЩ"), (4, "Егор Савельев", "ЗАЩ"),
|
||
(7, "Антон Крылов", "НАП"), (9, "Даниил Орехов", "НАП"),
|
||
(10, "Никита Сергеев", "НАП"), (11, "Михаил Сафонов", "НАП"),
|
||
(13, "Владимир Комаров", "ЗАЩ"), (15, "Роман Беляев", "НАП"),
|
||
(17, "Андрей Иванов", "НАП"), (19, "Степан Волков", "НАП"),
|
||
(21, "Кирилл Панов", "ЗАЩ"), (23, "Олег Миронов", "НАП"),
|
||
(25, "Арсений Белов", "НАП"), (27, "Матвей Фролов", "ЗАЩ"),
|
||
(29, "Глеб Мельников", "НАП"), (44, "Павел Кузнецов", "ЗАЩ"),
|
||
(71, "Иван Ларионов", "НАП"), (77, "Семён Жуков", "НАП"),
|
||
(81, "Фёдор Егоров", "НАП"), (91, "Артём Петров", "НАП"),
|
||
], start=1)
|
||
]
|
||
away_players = [
|
||
{"id": f"A{i:02d}", "number": number, "name": name, "position": position}
|
||
for i, (number, name, position) in enumerate([
|
||
(20, "Лев Корнеев", "ВР"), (35, "Тимур Котов", "ВР"),
|
||
(3, "Ярослав Сорокин", "ЗАЩ"), (5, "Денис Макаров", "ЗАЩ"),
|
||
(8, "Георгий Гусев", "НАП"), (12, "Виктор Назаров", "НАП"),
|
||
(14, "Руслан Власов", "НАП"), (16, "Борис Тарасов", "ЗАЩ"),
|
||
(18, "Марк Селезнёв", "НАП"), (22, "Станислав Никифоров", "ЗАЩ"),
|
||
(24, "Игорь Соколов", "НАП"), (28, "Вадим Фомин", "НАП"),
|
||
(32, "Пётр Громов", "ЗАЩ"), (37, "Леонид Васильев", "НАП"),
|
||
(41, "Константин Ершов", "ЗАЩ"), (55, "Захар Захаров", "ЗАЩ"),
|
||
(61, "Александр Осипов", "НАП"), (68, "Сергей Орлов", "НАП"),
|
||
(73, "Дмитрий Богданов", "НАП"), (84, "Валерий Маслов", "НАП"),
|
||
(88, "Николай Голубев", "НАП"), (97, "Анатолий Чернов", "НАП"),
|
||
], start=1)
|
||
]
|
||
return {
|
||
"vmix": {
|
||
"api_url": VMIX_API_URL,
|
||
"function": "OverlayInput4In",
|
||
"input": 2,
|
||
},
|
||
"hockey": {
|
||
"home": {"name": "СЕВЕР", "players": home_players},
|
||
"away": {"name": "ВОСТОК", "players": away_players},
|
||
},
|
||
}
|
||
|
||
|
||
@app.get("/api/vmix/command")
|
||
async def vmix_command(
|
||
function: str = Query(..., min_length=1),
|
||
input_value: str | None = Query(default=None),
|
||
) -> dict[str, Any]:
|
||
"""Send a shortcut command to the local vMix HTTP API.
|
||
|
||
The browser calls this same-origin FastAPI route. FastAPI then calls vMix,
|
||
so browser CORS restrictions do not block the command.
|
||
"""
|
||
params: dict[str, str] = {"Function": function}
|
||
if input_value not in (None, ""):
|
||
params["Input"] = input_value
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||
response = await client.get(VMIX_API_URL, params=params)
|
||
except httpx.RequestError as error:
|
||
raise HTTPException(
|
||
status_code=502,
|
||
detail=f"Не удалось подключиться к vMix по адресу {VMIX_API_URL}: {error}",
|
||
) from error
|
||
|
||
if response.status_code != 200:
|
||
raise HTTPException(
|
||
status_code=502,
|
||
detail={
|
||
"message": "vMix вернул ошибку",
|
||
"status": response.status_code,
|
||
"response": response.text[:1000],
|
||
},
|
||
)
|
||
|
||
return {
|
||
"ok": True,
|
||
"vmix_url": str(response.request.url),
|
||
"function": function,
|
||
"input": input_value,
|
||
}
|
||
|
||
|
||
install_ui_builder(
|
||
app,
|
||
build_version=BUILD_VERSION,
|
||
settings_dir=BASE_DIR / "settings",
|
||
data_providers={
|
||
"hockey_stat2tv": hockey_stat2tv_data,
|
||
"vmix_demo": vmix_demo_data,
|
||
},
|
||
source_labels={
|
||
"hockey_stat2tv": "Stat2TV — хоккей",
|
||
"vmix_demo": "vMix — тестовые данные",
|
||
},
|
||
editor_url="/editor",
|
||
runtime_url="/",
|
||
custom_script_urls=[
|
||
f"/hockey-assets/tournament-menu.js?v={BUILD_VERSION}",
|
||
f"/hockey-assets/admin-directories.js?v={BUILD_VERSION}",
|
||
"/hockey-assets/match-status-header.js",
|
||
"/hockey-assets/agent-devices.js",
|
||
],
|
||
custom_style_urls=[
|
||
"/hockey-assets/tournament-menu.css",
|
||
f"/hockey-assets/admin-directories.css?v={BUILD_VERSION}",
|
||
"/hockey-assets/match-status-header.css",
|
||
"/hockey-assets/agent-devices.css",
|
||
],
|
||
)
|
||
|
||
|
||
@app.post(
|
||
"/api/hockey/ui/prematch-buttons",
|
||
dependencies=[Depends(hockey_auth.require_user)],
|
||
tags=["Hockey UI"],
|
||
)
|
||
async def save_hockey_prematch_buttons(payload: dict[str, Any]) -> dict[str, Any]:
|
||
"""Save operator prematch buttons without replacing the rest of UI Builder config."""
|
||
buttons = payload.get("prematch_buttons")
|
||
groups = payload.get("prematch_groups", [])
|
||
selectors = payload.get("quick_panel_selectors", [])
|
||
player_panels = payload.get("player_selection_panels", [])
|
||
if not isinstance(buttons, list):
|
||
raise HTTPException(status_code=422, detail="prematch_buttons must be a list")
|
||
if not isinstance(groups, list):
|
||
raise HTTPException(status_code=422, detail="prematch_groups must be a list")
|
||
if not isinstance(selectors, list):
|
||
raise HTTPException(status_code=422, detail="quick_panel_selectors must be a list")
|
||
if not isinstance(player_panels, list):
|
||
raise HTTPException(status_code=422, detail="player_selection_panels must be a list")
|
||
|
||
draft_manager = app.state.ui_builder_draft_manager
|
||
published_manager = app.state.ui_builder_published_manager
|
||
|
||
draft_config = draft_manager.load()
|
||
draft_config["prematch_groups"] = groups
|
||
draft_config["prematch_buttons"] = buttons
|
||
draft_config["quick_panel_selectors"] = selectors
|
||
draft_config["player_selection_panels"] = player_panels
|
||
saved_draft = draft_manager.save(draft_config)
|
||
|
||
published_config = published_manager.load()
|
||
published_config["prematch_groups"] = saved_draft.get("prematch_groups", [])
|
||
published_config["prematch_buttons"] = saved_draft.get("prematch_buttons", [])
|
||
published_config["quick_panel_selectors"] = saved_draft.get("quick_panel_selectors", [])
|
||
published_config["player_selection_panels"] = saved_draft.get("player_selection_panels", [])
|
||
saved_published = published_manager.save(published_config, create_backup=True)
|
||
|
||
return {
|
||
"ok": True,
|
||
"prematch_groups": saved_published.get("prematch_groups", []),
|
||
"prematch_buttons": saved_published.get("prematch_buttons", []),
|
||
"quick_panel_selectors": saved_published.get("quick_panel_selectors", []),
|
||
"player_selection_panels": saved_published.get("player_selection_panels", []),
|
||
"config": saved_published,
|
||
}
|
||
|
||
|
||
@app.post(
|
||
"/api/hockey/ui/triggers",
|
||
dependencies=[Depends(hockey_auth.require_user)],
|
||
tags=["Hockey UI"],
|
||
)
|
||
async def save_hockey_ui_triggers(payload: dict[str, Any]) -> dict[str, Any]:
|
||
"""Save only UI triggers for an authenticated operator, preserving all other config."""
|
||
triggers = payload.get("triggers")
|
||
if not isinstance(triggers, list):
|
||
raise HTTPException(status_code=422, detail="triggers must be a list")
|
||
|
||
draft_manager = app.state.ui_builder_draft_manager
|
||
published_manager = app.state.ui_builder_published_manager
|
||
|
||
draft_config = draft_manager.load()
|
||
draft_config["triggers"] = triggers
|
||
saved_draft = draft_manager.save(draft_config)
|
||
|
||
published_config = published_manager.load()
|
||
published_config["triggers"] = saved_draft.get("triggers", [])
|
||
saved_published = published_manager.save(published_config, create_backup=True)
|
||
|
||
return {
|
||
"ok": True,
|
||
"triggers": saved_published.get("triggers", []),
|
||
}
|
||
|
||
|
||
async def _require_ui_builder_editor(request: Request) -> None:
|
||
await app.state.ui_builder_auth.require_editor(request)
|
||
|
||
|
||
@app.get(
|
||
"/api/ui-builder/editor/vmix-inventory",
|
||
dependencies=[Depends(_require_ui_builder_editor)],
|
||
tags=["UI Builder"],
|
||
)
|
||
async def ui_builder_vmix_inventory() -> dict[str, Any]:
|
||
return await hockey_agent_hub.latest_vmix_inventory()
|
||
|
||
|
||
app.include_router(
|
||
create_hockey_router(
|
||
hockey_service,
|
||
auth_dependency=hockey_auth.require_user,
|
||
admin_dependency=hockey_auth.require_admin,
|
||
agent_hub=hockey_agent_hub,
|
||
)
|
||
)
|
||
app.include_router(create_hockey_auth_router(hockey_auth))
|
||
app.include_router(
|
||
create_hockey_agent_router(
|
||
hockey_agent_hub,
|
||
auth_dependency=hockey_auth.require_user,
|
||
admin_dependency=hockey_auth.require_admin,
|
||
)
|
||
)
|
||
app.include_router(
|
||
create_hockey_vmix_settings_router(
|
||
hockey_vmix_portable,
|
||
admin_dependency=hockey_auth.require_admin,
|
||
)
|
||
)
|
||
app.include_router(create_hockey_vmix_router(hockey_service, hockey_vmix_portable))
|
||
|
||
app.state.hockey_service = hockey_service
|
||
app.state.hockey_database = hockey_database
|
||
app.state.hockey_settings = hockey_settings
|
||
app.state.hockey_auth = hockey_auth
|
||
app.state.hockey_vmix_portable = hockey_vmix_portable
|
||
app.state.hockey_agent_hub = hockey_agent_hub
|
||
|
||
|
||
@app.get("/runtime", include_in_schema=False)
|
||
async def legacy_runtime_redirect() -> RedirectResponse:
|
||
return RedirectResponse("/", status_code=302)
|
||
|
||
|
||
@app.get("/ui-builder", include_in_schema=False)
|
||
async def legacy_editor_redirect() -> RedirectResponse:
|
||
# Legacy/bookmarked UI Builder URLs should always land in the operator runtime.
|
||
# The editor is opened explicitly from the runtime toolbar after PIN auth.
|
||
return RedirectResponse("/", status_code=302)
|