bulid 63
This commit is contained in:
355
app.py
Normal file
355
app.py
Normal file
@@ -0,0 +1,355 @@
|
||||
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.19.21"
|
||||
load_dotenv(BASE_DIR / ".env.local")
|
||||
load_dotenv(BASE_DIR / ".env")
|
||||
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 and prevent stale operator-interface assets."""
|
||||
if request.url.path == "/" or request.url.path.startswith("/khl-site"):
|
||||
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 request.url.path.startswith("/khl-site"):
|
||||
return JSONResponse({"detail": "Требуется вход"}, status_code=401)
|
||||
return RedirectResponse("/login?reason=expired", status_code=303)
|
||||
if request.url.path.startswith("/khl-site") and not user.is_admin:
|
||||
return JSONResponse({"detail": "Раздел КХЛ доступен только администратору"}, status_code=403)
|
||||
request.state.wfl_user = user
|
||||
response = await call_next(request)
|
||||
path = request.url.path
|
||||
if (
|
||||
path == "/"
|
||||
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,
|
||||
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", [])
|
||||
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")
|
||||
|
||||
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
|
||||
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", [])
|
||||
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", []),
|
||||
"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:
|
||||
return RedirectResponse("/editor", status_code=302)
|
||||
Reference in New Issue
Block a user