Files
hockey_new/hockey_data/router.py

1388 lines
56 KiB
Python

from __future__ import annotations
import asyncio
from datetime import date
from time import perf_counter
from typing import Any, Callable
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import text
from .auth_bridge import HockeyUser
from .service import HockeyDataService
from .vmix_portable import HockeyVmixPortable
class PublicSettingsPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
base_url: str | None = None
auth_mode: str | None = None
ui_language: str | None = None
fallback_language: str | None = None
store_ru: bool | None = None
store_en: bool | None = None
sync_interval_seconds: int | None = None
request_timeout_seconds: int | None = None
verify_ssl: bool | None = None
timezone: str | None = None
auto_sync_on_startup: bool | None = None
games_endpoint_template: str | None = None
games_auto_discovery: bool | None = None
games_cache_seconds: int | None = None
players_endpoint_template: str | None = None
player_countries_endpoint_template: str | None = None
referees_endpoint_template: str | None = None
match_json_endpoint_template: str | None = None
match_json_en_endpoint_template: str | None = None
shots_endpoint_template: str | None = None
match_directories_cache_seconds: int | None = None
standings_endpoint_template: str | None = None
standings_cache_seconds: int | None = None
powerplay_endpoint_template: str | None = None
rank_endpoint_template: str | None = None
tournament_statistics_cache_seconds: int | None = None
optional_resource_negative_cache_seconds: int | None = None
timer_period_minutes: int | None = None
timer_regular_overtime_minutes: int | None = None
timer_playoff_overtime_minutes: int | None = None
timer_reset_on_period_change: bool | None = None
strength_regulation_skaters: int | None = None
strength_regular_overtime_skaters: int | None = None
strength_playoff_overtime_skaters: int | None = None
strength_min_skaters: int | None = None
strength_regulation_display: str | None = None
strength_regular_overtime_display: str | None = None
strength_playoff_overtime_display: str | None = None
strength_powerplay_label: str | None = None
strength_shorthanded_label: str | None = None
strength_show_shorthanded: bool | None = None
strength_state_labels: dict[str, Any] | None = None
period_status_labels: dict[str, Any] | None = None
scoreboard_team_states: dict[str, Any] | None = None
score_external_home_key: str | None = None
score_external_away_key: str | None = None
class CredentialsPayload(BaseModel):
username: str | None = None
password: str | None = Field(default=None, max_length=1024)
clear_password: bool = False
class PreferencePayload(BaseModel):
display_language: str = "ru"
vmix_language: str = "ru"
tournament_external_id: str = ""
class TriggerPreferencePayload(BaseModel):
enabled: bool = True
class SessionPayload(BaseModel):
tournament_external_id: str = Field(min_length=1, max_length=64)
game_external_id: str = Field(default="", max_length=64)
display_language: str = "ru"
vmix_language: str = "ru"
vmix_device_id: str = Field(default="", max_length=128)
replace_existing: bool = True
class GamePeriodPayload(BaseModel):
current_period: str = Field(min_length=1, max_length=16)
language: str = Field(default="ru", pattern="^(ru|en)$")
class GameTimersPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
main_timer: dict[str, Any] = Field(default_factory=dict)
penalty_board: dict[str, Any] = Field(default_factory=dict)
language: str = Field(default="ru", pattern="^(ru|en)$")
class GameFlagsPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
flags: dict[str, bool] = Field(default_factory=dict)
language: str = Field(default="ru", pattern="^(ru|en)$")
class GameValuesPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
values: dict[str, Any] = Field(default_factory=dict)
language: str = Field(default="ru", pattern="^(ru|en)$")
class GameScorePayload(BaseModel):
command: str = Field(pattern="^(home_plus|home_minus|away_plus|away_minus|sync)$")
language: str = Field(default="ru", pattern="^(ru|en)$")
class GameLineAssignmentPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
player_external_id: str = Field(min_length=1, max_length=64)
line_number: str = Field(default="", max_length=16)
sort_order: int = Field(default=0, ge=0, le=999)
class GameLinesPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
side: str = Field(pattern="^(home|away)$")
assignments: list[GameLineAssignmentPayload] = Field(default_factory=list)
reset: bool = False
language: str = Field(default="ru", pattern="^(ru|en)$")
class ShootoutSetupPayload(BaseModel):
initial_attempts: int = Field(default=3)
reset: bool = False
language: str = Field(default="ru", pattern="^(ru|en)$")
class ShootoutAttemptPayload(BaseModel):
side: str = Field(pattern="^(home|away)$")
player_external_id: str = Field(min_length=1, max_length=64)
scored: bool
language: str = Field(default="ru", pattern="^(ru|en)$")
class TeamDirectoryPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
external_id: str = Field(default="", max_length=64)
league_key: str = Field(default="", max_length=32)
tournament_external_id: str = Field(default="", max_length=64)
name_ru: str = Field(default="", max_length=500)
name_en: str = Field(default="", max_length=500)
short_name_ru: str = Field(default="", max_length=255)
short_name_en: str = Field(default="", max_length=255)
city_ru: str = Field(default="", max_length=255)
city_en: str = Field(default="", max_length=255)
color_hex: str = Field(default="", max_length=9)
logo_url: str = Field(default="", max_length=2000)
active: bool = True
class CountryDirectoryPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
external_id: str = Field(default="", max_length=64)
iso2: str = Field(default="", max_length=2)
iso3: str = Field(default="", max_length=3)
name_ru: str = Field(default="", max_length=255)
name_en: str = Field(default="", max_length=255)
active: bool = True
class PlayerDirectoryPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
external_id: str = Field(default="", max_length=64)
tournament_external_id: str = Field(default="", max_length=64)
first_name_ru: str = Field(default="", max_length=255)
first_name_en: str = Field(default="", max_length=255)
last_name_ru: str = Field(default="", max_length=255)
last_name_en: str = Field(default="", max_length=255)
full_name_ru: str = Field(default="", max_length=500)
full_name_en: str = Field(default="", max_length=500)
position_ru: str = Field(default="", max_length=128)
position_en: str = Field(default="", max_length=128)
birth_date: str = Field(default="", max_length=32)
height_cm: int = Field(default=0, ge=0, le=300)
weight_kg: int = Field(default=0, ge=0, le=300)
stick: str = Field(default="", max_length=32)
country_external_id: str = Field(default="", max_length=64)
country_code: str = Field(default="", max_length=8)
photo_url: str = Field(default="", max_length=2000)
club_external_id: str = Field(default="", max_length=64)
team_entry_external_id: str = Field(default="", max_length=64)
team_name_ru: str = Field(default="", max_length=255)
team_name_en: str = Field(default="", max_length=255)
jersey_number: str = Field(default="", max_length=16)
active: bool = True
class RefereeDirectoryPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
external_id: str = Field(default="", max_length=64)
first_name_ru: str = Field(default="", max_length=255)
first_name_en: str = Field(default="", max_length=255)
last_name_ru: str = Field(default="", max_length=255)
last_name_en: str = Field(default="", max_length=255)
full_name_ru: str = Field(default="", max_length=500)
full_name_en: str = Field(default="", max_length=500)
position_code: str = Field(default="", max_length=32)
number: str = Field(default="", max_length=16)
birth_date: str = Field(default="", max_length=32)
country_ru: str = Field(default="", max_length=255)
country_en: str = Field(default="", max_length=255)
country_code: str = Field(default="", max_length=8)
town_ru: str = Field(default="", max_length=255)
town_en: str = Field(default="", max_length=255)
active: bool = True
class CoachDirectoryPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
external_id: str = Field(default="", max_length=64)
tournament_external_id: str = Field(default="", max_length=64)
first_name_ru: str = Field(default="", max_length=255)
first_name_en: str = Field(default="", max_length=255)
last_name_ru: str = Field(default="", max_length=255)
last_name_en: str = Field(default="", max_length=255)
full_name_ru: str = Field(default="", max_length=500)
full_name_en: str = Field(default="", max_length=500)
birth_date: str = Field(default="", max_length=32)
country_external_id: str = Field(default="", max_length=64)
country_code: str = Field(default="", max_length=8)
photo_url: str = Field(default="", max_length=2000)
team_external_id: str = Field(default="", max_length=64)
team_name_ru: str = Field(default="", max_length=255)
team_name_en: str = Field(default="", max_length=255)
role_ru: str = Field(default="Главный тренер", max_length=128)
role_en: str = Field(default="Head coach", max_length=128)
active: bool = True
class PenaltyDirectoryPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
code: str = Field(min_length=1, max_length=64)
name_ru: str = Field(min_length=1, max_length=500)
name_en: str = Field(min_length=1, max_length=500)
default_preset: str = Field(default="2", max_length=32)
team_penalty: bool = False
sort_order: int | None = Field(default=None, ge=0, le=100000)
active: bool = True
def create_hockey_router(
service: HockeyDataService,
*,
auth_dependency: Callable[..., Any] | None = None,
admin_dependency: Callable[..., Any] | None = None,
agent_hub: Any | None = None,
) -> APIRouter:
authenticated = [Depends(auth_dependency)] if auth_dependency else []
router = APIRouter(
prefix="/api/hockey",
tags=["Hockey / Stat2TV"],
dependencies=authenticated,
)
admin = [Depends(admin_dependency)] if admin_dependency else []
@router.get("/me")
async def current_user(
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return {
"id": user.id,
"login": user.login,
"display_name": user.display_name,
"role": user.role,
"is_admin": user.is_admin,
}
@router.get("/vmix-channel")
async def current_vmix_channel(
request: Request,
language: str = Query("ru", pattern="^(ru|en|bilingual)$"),
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
channel = service.get_user_vmix_channel(user.id)
return {
"channel": channel,
"language": language,
"links": (
service.vmix_links(
token=str(channel.get("channel_key") or channel.get("token")),
base_url=str(request.base_url).rstrip("/"),
language=language,
)
if channel is not None
else {}
),
}
@router.get("/status")
async def status() -> dict[str, Any]:
return {
"settings": service.settings.status(),
"last_sync": service.last_sync(),
"database": service.database.schema_status(),
}
@router.get("/settings")
async def get_settings() -> dict[str, Any]:
return service.settings.status()
@router.get("/database/status")
async def database_status() -> dict[str, Any]:
return service.database.schema_status()
@router.get("/database/statistics-status")
async def database_statistics_status(
tournament_id: str = Query("", max_length=64),
) -> dict[str, Any]:
return service.normalized_storage_status(
tournament_external_id=tournament_id
)
@router.post("/database/statistics-backfill", dependencies=admin)
async def database_statistics_backfill() -> dict[str, Any]:
try:
return service.backfill_normalized_statistics()
except Exception as error:
raise HTTPException(status_code=500, detail=str(error)) from error
@router.post("/database/repair", dependencies=admin)
async def repair_database() -> dict[str, Any]:
try:
return service.database.ensure_game_schema()
except Exception as error:
raise HTTPException(status_code=500, detail=str(error)) from error
@router.put("/settings", dependencies=admin)
async def update_settings(payload: PublicSettingsPayload) -> dict[str, Any]:
values = {
key: value
for key, value in payload.model_dump().items()
if value is not None
}
return service.settings.update_public(values)
@router.put("/credentials", dependencies=admin)
async def update_credentials(payload: CredentialsPayload) -> dict[str, Any]:
return service.settings.update_credentials(
username=payload.username,
password=payload.password,
clear_password=payload.clear_password,
)
@router.post("/connection/test", dependencies=admin)
async def test_connection() -> dict[str, Any]:
try:
return await service.test_connection()
except Exception as error:
raise HTTPException(status_code=502, detail=str(error)) from error
@router.get("/admin/connection-diagnostics", dependencies=admin)
async def admin_connection_diagnostics(
tournament_id: str = Query("", max_length=64),
game_id: str = Query("", max_length=64),
language: str = Query("ru", pattern="^(ru|en)$"),
deep: bool = False,
) -> dict[str, Any]:
"""Admin-only health check for the dependencies used by match loading.
The lightweight check never requires an Agent/vMix to open a match: the
database is the only hard dependency for cached schedule selection, while
Stat2TV is required only for remote enrichment/sync. ``deep=true`` also
exercises the selected match enrichment path and returns its warnings.
"""
checks: dict[str, Any] = {}
started = perf_counter()
try:
with service.database.engine.connect() as connection:
connection.execute(text("SELECT 1"))
checks["database"] = {
"ok": True,
"latency_ms": round((perf_counter() - started) * 1000, 1),
"target": service.database.schema_status(),
}
except Exception as error:
checks["database"] = {
"ok": False,
"latency_ms": round((perf_counter() - started) * 1000, 1),
"error": str(error),
}
settings_status = service.settings.status()
checks["settings"] = {
"ok": bool(settings_status.get("base_url")),
"base_url": settings_status.get("base_url", ""),
"credentials_configured": bool(settings_status.get("credentials_configured")),
"credentials_source": settings_status.get("credentials_source", "missing"),
"username_masked": settings_status.get("username_masked", ""),
"verify_ssl": bool(settings_status.get("verify_ssl")),
}
started = perf_counter()
try:
stat2tv = await asyncio.wait_for(service.test_connection(), timeout=12.0)
checks["stat2tv"] = {
"ok": True,
"latency_ms": round((perf_counter() - started) * 1000, 1),
"status_code": stat2tv.get("status_code"),
"auth_mode": stat2tv.get("auth_mode"),
"content_type": stat2tv.get("content_type"),
"items_found": stat2tv.get("items_found", 0),
"url": stat2tv.get("url", ""),
}
except Exception as error:
checks["stat2tv"] = {
"ok": False,
"latency_ms": round((perf_counter() - started) * 1000, 1),
"error": str(error),
}
if agent_hub is not None:
try:
devices_payload = await agent_hub.list_mapping_devices()
devices = list(devices_payload.get("devices") or [])
checks["agents"] = {
"ok": any(bool(item.get("online")) for item in devices),
"optional": True,
"online": sum(1 for item in devices if item.get("online")),
"vmix_connected": sum(1 for item in devices if item.get("online") and item.get("vmix_connected")),
"devices": [
{
"device_id": item.get("device_id", ""),
"name": item.get("name", ""),
"owner": item.get("owner", ""),
"online": bool(item.get("online")),
"vmix_connected": bool(item.get("vmix_connected")),
"vmix_version": item.get("vmix_version", ""),
}
for item in devices
],
}
except Exception as error:
checks["agents"] = {"ok": False, "optional": True, "error": str(error), "devices": []}
else:
checks["agents"] = {"ok": False, "optional": True, "error": "Agent hub недоступен", "devices": []}
tournament_id = str(tournament_id or "").strip()
game_id = str(game_id or "").strip()
if tournament_id:
tournament = service.tournament(tournament_id, language=language)
endpoint = service.endpoint_diagnostics(tournament_external_id=tournament_id)
checks["tournament"] = {
"ok": tournament is not None,
"tournament_id": tournament_id,
"local_found": tournament is not None,
"endpoint_diagnostics": endpoint,
}
if game_id:
local_game = service.game(game_id, language=language)
match_check: dict[str, Any] = {
"ok": local_game is not None,
"game_id": game_id,
"tournament_id": tournament_id,
"local_found": local_game is not None,
"deep_requested": bool(deep),
}
if deep and tournament_id:
started = perf_counter()
try:
payload = await asyncio.wait_for(
service.sync_selected_game_details(
tournament_external_id=tournament_id,
game_external_id=game_id,
language=language,
),
timeout=20.0,
)
details = payload.get("details") if isinstance(payload, dict) else None
match_check.update({
"ok": True,
"latency_ms": round((perf_counter() - started) * 1000, 1),
"degraded": bool(payload.get("degraded")) if isinstance(payload, dict) else False,
"details_sync_failed": bool(payload.get("details_sync_failed")) if isinstance(payload, dict) else False,
"warnings": list(payload.get("warnings") or []) if isinstance(payload, dict) else [],
"details_loaded": bool(details.get("loaded")) if isinstance(details, dict) else False,
})
except Exception as error:
match_check.update({
"ok": False,
"latency_ms": round((perf_counter() - started) * 1000, 1),
"error": str(error),
})
checks["match"] = match_check
database_ok = bool(checks.get("database", {}).get("ok"))
stat2tv_ok = bool(checks.get("stat2tv", {}).get("ok"))
return {
"ok": database_ok,
"required_for_match_selection": {
"ok": database_ok,
"database": database_ok,
},
"required_for_remote_sync": {
"ok": database_ok and stat2tv_ok,
"database": database_ok,
"stat2tv": stat2tv_ok,
},
"checks": checks,
}
@router.post("/sync/tournaments", dependencies=admin)
async def sync_tournaments() -> dict[str, Any]:
try:
return await service.sync_tournaments()
except Exception as error:
raise HTTPException(status_code=502, detail=str(error)) from error
@router.post("/sync/tournaments/seed", dependencies=admin)
async def import_seed() -> dict[str, Any]:
if not service.seed_file.exists():
raise HTTPException(status_code=404, detail="Seed XML не найден")
return service.import_tournaments_file(service.seed_file, source="seed")
@router.get("/directories/teams")
async def directory_teams(
language: str = Query("ru", pattern="^(ru|en)$"),
tournament_external_id: str = Query("", max_length=64),
league_key: str = Query("", max_length=32),
include_inactive: bool = False,
) -> dict[str, Any]:
return service.team_directory(
language=language,
tournament_external_id=tournament_external_id,
league_key=league_key,
include_inactive=include_inactive,
)
@router.post("/directories/teams/sync", dependencies=admin)
async def sync_directory_teams(
tournament_external_id: str = Query("", max_length=64),
) -> dict[str, Any]:
return service.sync_team_directory(
tournament_external_id=tournament_external_id,
)
@router.post("/directories/teams", dependencies=admin)
async def create_directory_team(
payload: TeamDirectoryPayload,
language: str = Query("ru", pattern="^(ru|en)$"),
) -> dict[str, Any]:
try:
return service.save_directory_team(
payload.model_dump(),
language=language,
)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.put("/directories/teams/{membership_id}", dependencies=admin)
async def update_directory_team(
membership_id: int,
payload: TeamDirectoryPayload,
language: str = Query("ru", pattern="^(ru|en)$"),
) -> dict[str, Any]:
try:
return service.save_directory_team(
payload.model_dump(),
membership_id=membership_id,
language=language,
)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.get("/directories/countries")
async def directory_countries(
language: str = Query("ru", pattern="^(ru|en)$"),
include_inactive: bool = False,
search: str = Query("", max_length=200),
) -> dict[str, Any]:
return service.country_directory(language=language, include_inactive=include_inactive, search=search)
@router.post("/directories/countries/sync", dependencies=admin)
async def sync_directory_countries() -> dict[str, Any]:
try:
return await service.sync_country_directory()
except Exception as error:
raise HTTPException(status_code=502, detail=str(error)) from error
@router.post("/directories/countries", dependencies=admin)
async def create_directory_country(payload: CountryDirectoryPayload, language: str = Query("ru", pattern="^(ru|en)$")) -> dict[str, Any]:
try:
return service.save_country(payload.model_dump(), language=language)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.put("/directories/countries/{country_id}", dependencies=admin)
async def update_directory_country(country_id: int, payload: CountryDirectoryPayload, language: str = Query("ru", pattern="^(ru|en)$")) -> dict[str, Any]:
try:
return service.save_country(payload.model_dump(), country_id=country_id, language=language)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.get("/directories/players")
async def directory_players(
tournament_external_id: str = Query("", max_length=64),
language: str = Query("ru", pattern="^(ru|en)$"),
include_inactive: bool = False,
search: str = Query("", max_length=200),
) -> dict[str, Any]:
return service.player_directory(tournament_external_id=tournament_external_id, language=language, include_inactive=include_inactive, search=search)
@router.post("/directories/players/sync", dependencies=admin)
async def sync_directory_players(tournament_external_id: str = Query(..., min_length=1, max_length=64)) -> dict[str, Any]:
try:
return await service.sync_player_directory(tournament_external_id=tournament_external_id)
except Exception as error:
raise HTTPException(status_code=502, detail=str(error)) from error
@router.post("/directories/players", dependencies=admin)
async def create_directory_player(payload: PlayerDirectoryPayload) -> dict[str, Any]:
try:
return service.save_player(payload.model_dump())
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.put("/directories/players/{player_id}", dependencies=admin)
async def update_directory_player(player_id: int, payload: PlayerDirectoryPayload) -> dict[str, Any]:
try:
return service.save_player(payload.model_dump(), player_id=player_id)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.get("/directories/referees")
async def directory_referees(
tournament_external_id: str = Query("", max_length=64),
language: str = Query("ru", pattern="^(ru|en)$"),
include_inactive: bool = False,
search: str = Query("", max_length=200),
) -> dict[str, Any]:
return service.referee_directory(tournament_external_id=tournament_external_id, language=language, include_inactive=include_inactive, search=search)
@router.post("/directories/referees/sync", dependencies=admin)
async def sync_directory_referees(tournament_external_id: str = Query(..., min_length=1, max_length=64)) -> dict[str, Any]:
try:
return await service.sync_referee_directory(tournament_external_id=tournament_external_id)
except Exception as error:
raise HTTPException(status_code=502, detail=str(error)) from error
@router.post("/directories/referees", dependencies=admin)
async def create_directory_referee(payload: RefereeDirectoryPayload) -> dict[str, Any]:
try:
return service.save_referee(payload.model_dump())
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.put("/directories/referees/{referee_id}", dependencies=admin)
async def update_directory_referee(referee_id: int, payload: RefereeDirectoryPayload) -> dict[str, Any]:
try:
return service.save_referee(payload.model_dump(), referee_id=referee_id)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.get("/directories/coaches")
async def directory_coaches(
tournament_external_id: str = Query("", max_length=64),
language: str = Query("ru", pattern="^(ru|en)$"),
include_inactive: bool = False,
search: str = Query("", max_length=200),
) -> dict[str, Any]:
return service.coach_directory(tournament_external_id=tournament_external_id, language=language, include_inactive=include_inactive, search=search)
@router.post("/directories/coaches/sync", dependencies=admin)
async def sync_directory_coaches(tournament_external_id: str = Query(..., min_length=1, max_length=64)) -> dict[str, Any]:
try:
return await service.sync_coach_directory(tournament_external_id=tournament_external_id)
except Exception as error:
raise HTTPException(status_code=502, detail=str(error)) from error
@router.post("/directories/coaches", dependencies=admin)
async def create_directory_coach(payload: CoachDirectoryPayload) -> dict[str, Any]:
try:
return service.save_coach(payload.model_dump())
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.put("/directories/coaches/{coach_id}", dependencies=admin)
async def update_directory_coach(coach_id: int, payload: CoachDirectoryPayload) -> dict[str, Any]:
try:
return service.save_coach(payload.model_dump(), coach_id=coach_id)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.get("/directories/penalties")
async def directory_penalties(
language: str = Query("ru", pattern="^(ru|en)$"),
include_inactive: bool = False,
) -> dict[str, Any]:
return service.penalty_directory(
language=language,
include_inactive=include_inactive,
)
@router.post("/directories/penalties", dependencies=admin)
async def create_directory_penalty(
payload: PenaltyDirectoryPayload,
language: str = Query("ru", pattern="^(ru|en)$"),
) -> dict[str, Any]:
try:
return service.save_penalty_type(
payload.model_dump(),
language=language,
)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.put("/directories/penalties/{penalty_id}", dependencies=admin)
async def update_directory_penalty(
penalty_id: int,
payload: PenaltyDirectoryPayload,
language: str = Query("ru", pattern="^(ru|en)$"),
) -> dict[str, Any]:
try:
return service.save_penalty_type(
payload.model_dump(),
penalty_id=penalty_id,
language=language,
)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.get("/navigation")
async def navigation(
language: str = Query("ru", pattern="^(ru|en)$"),
on_date: date = Query(default_factory=date.today),
search: str = Query("", max_length=200),
league: str = Query("", max_length=32),
season: str = Query("", max_length=32),
stage: str = Query("", max_length=32),
limit: int = Query(600, ge=1, le=2000),
) -> dict[str, Any]:
return service.navigation(
language=language,
on_date=on_date,
search=search,
league=league,
season=season,
stage=stage,
limit=limit,
)
@router.get("/games")
async def games_by_date(
on_date: date = Query(default_factory=date.today),
language: str = Query("ru", pattern="^(ru|en)$"),
) -> dict[str, Any]:
return service.games(
on_date=on_date,
language=language,
)
@router.get("/tournaments/{external_id}/games")
async def tournament_games(
external_id: str,
on_date: date = Query(default_factory=date.today),
language: str = Query("ru", pattern="^(ru|en)$"),
all_dates: bool = False,
) -> dict[str, Any]:
if service.tournament(external_id, language=language) is None:
raise HTTPException(status_code=404, detail="Турнир не найден")
return service.games(
tournament_external_id=external_id,
on_date=on_date,
language=language,
all_dates=all_dates,
)
@router.post("/tournaments/{external_id}/games/sync")
async def sync_tournament_games(
external_id: str,
on_date: date = Query(default_factory=date.today),
force_discovery: bool = False,
lightweight: bool = False,
) -> dict[str, Any]:
if service.tournament(external_id, language="ru") is None:
raise HTTPException(status_code=404, detail="Турнир не найден")
try:
return await service.sync_games(
tournament_external_id=external_id,
on_date=on_date,
force_discovery=force_discovery,
lightweight=lightweight,
)
except Exception as error:
raise HTTPException(status_code=502, detail=str(error)) from error
@router.get("/tournaments/{external_id}/standings")
async def tournament_standings(
external_id: str,
language: str = Query("ru", pattern="^(ru|en)$"),
game_id: str = Query("", max_length=64),
) -> dict[str, Any]:
value = service.standings(
external_id,
language=language,
game_external_id=game_id,
)
if value is None:
raise HTTPException(status_code=404, detail="Турнирная таблица ещё не загружена")
return value
@router.post("/tournaments/{external_id}/standings/sync")
async def sync_tournament_standings(
external_id: str,
language: str = Query("ru", pattern="^(ru|en)$"),
game_id: str = Query("", max_length=64),
) -> dict[str, Any]:
if service.tournament(external_id, language=language) is None:
raise HTTPException(status_code=404, detail="Турнир не найден")
try:
return await service.sync_standings(
external_id,
language=language,
game_external_id=game_id,
)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
except Exception as error:
raise HTTPException(status_code=502, detail=str(error)) from error
@router.get("/tournaments/{external_id}/statistics")
async def tournament_statistics(
external_id: str,
language: str = Query("ru", pattern="^(ru|en)$"),
) -> dict[str, Any]:
if service.tournament(external_id, language=language) is None:
raise HTTPException(status_code=404, detail="Турнир не найден")
return service.tournament_statistics(external_id, language=language)
@router.get("/tournaments/{external_id}/navigation-labels")
async def tournament_navigation_labels(
external_id: str,
force: bool = False,
) -> dict[str, Any]:
if service.tournament(external_id, language="ru") is None:
raise HTTPException(status_code=404, detail="Турнир не найден")
return await service.statistic_navigation_labels(external_id, force=force)
@router.post("/tournaments/{external_id}/statistics/sync")
async def sync_tournament_statistics(
external_id: str,
language: str = Query("ru", pattern="^(ru|en)$"),
force: bool = False,
) -> dict[str, Any]:
if service.tournament(external_id, language=language) is None:
raise HTTPException(status_code=404, detail="Турнир не найден")
try:
return await service.sync_tournament_statistics(
external_id, language=language, force=force
)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
except Exception as error:
raise HTTPException(status_code=502, detail=str(error)) from error
@router.get(
"/tournaments/{external_id}/games/diagnostics",
dependencies=admin,
)
async def games_diagnostics(external_id: str) -> dict[str, Any]:
return service.endpoint_diagnostics(
tournament_external_id=external_id
)
@router.post("/games/{external_id}/live")
async def refresh_selected_game(
external_id: str,
tournament_id: str = Query(..., min_length=1, max_length=64),
language: str = Query("ru", pattern="^(ru|en)$"),
) -> dict[str, Any]:
try:
return await service.sync_live_game(
tournament_external_id=tournament_id,
game_external_id=external_id,
language=language,
)
except ValueError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
except Exception as error:
raise HTTPException(status_code=502, detail=str(error)) from error
@router.post("/games/manual/{game_external_id}")
async def sync_manual_game_global(
game_external_id: str,
language: str = Query("ru", pattern="^(ru|en)$"),
) -> dict[str, Any]:
try:
return await service.sync_game_by_id_global(
game_external_id=game_external_id,
language=language,
)
except ValueError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
except Exception as error:
raise HTTPException(status_code=502, detail=str(error)) from error
@router.post("/tournaments/{tournament_external_id}/games/manual/{game_external_id}")
async def sync_manual_game(
tournament_external_id: str,
game_external_id: str,
language: str = Query("ru", pattern="^(ru|en)$"),
) -> dict[str, Any]:
if service.tournament(tournament_external_id, language=language) is None:
raise HTTPException(status_code=404, detail="Турнир не найден")
try:
return await service.sync_game_by_id(
tournament_external_id=tournament_external_id,
game_external_id=game_external_id,
language=language,
)
except ValueError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
except Exception as error:
raise HTTPException(status_code=502, detail=str(error)) from error
@router.post("/games/{external_id}/details/sync")
async def sync_game_details(
external_id: str,
tournament_id: str = Query(..., min_length=1, max_length=64),
language: str = Query("ru", pattern="^(ru|en)$"),
opening: bool = False,
) -> dict[str, Any]:
try:
return await service.sync_selected_game_details(
tournament_external_id=tournament_id,
game_external_id=external_id,
language=language,
opening=opening,
)
except ValueError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
except Exception as error:
raise HTTPException(status_code=502, detail=str(error)) from error
@router.get("/games/{external_id}/details")
async def game_details(
external_id: str,
language: str = Query("ru", pattern="^(ru|en)$"),
) -> dict[str, Any]:
value = service.game_details(external_id, language=language)
if value is None:
raise HTTPException(status_code=404, detail="Матч не найден")
return value
@router.get("/games/{external_id}/control")
async def game_control(
external_id: str,
language: str = Query("ru", pattern="^(ru|en)$"),
) -> dict[str, Any]:
value = service.game_control(external_id, language=language)
if value is None:
raise HTTPException(status_code=404, detail="Матч не найден")
return value
@router.put("/games/{external_id}/control/period")
async def update_game_period(
external_id: str,
payload: GamePeriodPayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
try:
return service.set_game_period(
external_id,
current_period=payload.current_period,
updated_by=user.id,
language=payload.language,
)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.put("/games/{external_id}/control/timers")
async def update_game_timers(
external_id: str,
payload: GameTimersPayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
try:
return service.save_game_timers(
external_id,
main_timer=payload.main_timer,
penalty_board=payload.penalty_board,
updated_by=user.id,
language=payload.language,
)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.put("/games/{external_id}/control/flags")
async def update_game_flags(
external_id: str,
payload: GameFlagsPayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
try:
return service.set_game_flags(
external_id,
flags=payload.flags,
updated_by=user.id,
language=payload.language,
)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.put("/games/{external_id}/control/score")
async def update_game_score(
external_id: str,
payload: GameScorePayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
try:
return service.set_game_score(
external_id,
command=payload.command,
updated_by=user.id,
language=payload.language,
)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.put("/games/{external_id}/lines")
async def update_game_lines(
external_id: str,
payload: GameLinesPayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
try:
if payload.reset:
return service.reset_game_lines(
external_id,
side=payload.side,
updated_by=user.id,
language=payload.language,
)
return service.save_game_lines(
external_id,
side=payload.side,
assignments=[item.model_dump() for item in payload.assignments],
updated_by=user.id,
language=payload.language,
)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.put("/games/{external_id}/control/values")
async def update_game_values(
external_id: str,
payload: GameValuesPayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
try:
return service.set_game_values(
external_id,
values=payload.values,
updated_by=user.id,
language=payload.language,
)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.put("/games/{external_id}/shootout/setup")
async def setup_shootout(
external_id: str,
payload: ShootoutSetupPayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
if payload.initial_attempts not in {3, 5}:
raise HTTPException(status_code=400, detail="Допустимо 3 или 5 буллитов")
try:
return service.configure_shootout(
external_id,
initial_attempts=payload.initial_attempts,
reset=payload.reset,
updated_by=user.id,
language=payload.language,
)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.post("/games/{external_id}/shootout/round")
async def add_shootout_round(
external_id: str,
language: str = Query("ru", pattern="^(ru|en)$"),
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
try:
return service.add_shootout_round(
external_id,
updated_by=user.id,
language=language,
)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.post("/games/{external_id}/shootout/attempts")
async def add_shootout_attempt(
external_id: str,
payload: ShootoutAttemptPayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
try:
return service.add_shootout_attempt(
external_id,
side=payload.side,
player_external_id=payload.player_external_id,
scored=payload.scored,
created_by=user.id,
language=payload.language,
)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.delete("/games/{external_id}/shootout/attempts/{attempt_id}")
async def delete_shootout_attempt(
external_id: str,
attempt_id: int,
language: str = Query("ru", pattern="^(ru|en)$"),
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
try:
return service.delete_shootout_attempt(
external_id,
attempt_id,
updated_by=user.id,
language=language,
)
except ValueError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
@router.get("/games/{external_id}")
async def game(
external_id: str,
language: str = Query("ru", pattern="^(ru|en)$"),
) -> dict[str, Any]:
value = service.game(external_id, language=language)
if value is None:
raise HTTPException(status_code=404, detail="Матч не найден")
return value
@router.get("/tournaments/{external_id}")
async def tournament(
external_id: str,
language: str = Query("ru", pattern="^(ru|en)$"),
) -> dict[str, Any]:
value = service.tournament(external_id, language=language)
if value is None:
raise HTTPException(status_code=404, detail="Турнир не найден")
return value
@router.get("/preferences")
async def user_preference(
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return service.user_preference(wfl_user_id=user.id)
@router.put("/preferences")
async def save_preference(
payload: PreferencePayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return service.save_preference(
wfl_user_id=user.id,
**payload.model_dump(),
)
@router.put("/preferences/toasts")
async def save_toasts_preference(
payload: TriggerPreferencePayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return service.save_toasts_preference(
wfl_user_id=user.id,
enabled=payload.enabled,
)
@router.put("/preferences/events")
async def save_events_preference(
payload: TriggerPreferencePayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return service.save_toasts_preference(
wfl_user_id=user.id,
enabled=payload.enabled,
)
@router.post("/sessions")
async def open_session(
payload: SessionPayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
try:
session_data = payload.model_dump()
vmix_device_id = str(session_data.pop("vmix_device_id", "") or "").strip()
result = service.open_session(
wfl_user_id=user.id,
login_snapshot=user.login,
display_name_snapshot=user.display_name,
vmix_device_uuid=vmix_device_id,
**session_data,
)
if agent_hub is not None and result.get("game_external_id"):
try:
result["agent_assignment"] = await agent_hub.assign_match(
wfl_user_id=user.id,
game_external_id=str(result.get("game_external_id") or ""),
tournament_external_id=str(result.get("tournament_external_id") or ""),
device_id=vmix_device_id,
operator_session_token=str(result.get("token") or ""),
# BUILD111: session opening sends only match.assign. Mapping
# is applied after the selected match roster/details are in
# the database, otherwise vMix can receive stale match data.
wait_for_mapping=False,
)
assignment = result.get("agent_assignment") or {}
result["agent_status"] = (
"delivered" if assignment.get("delivered")
else ("offline" if assignment.get("device_id") else "unassigned")
)
except Exception as agent_error:
# The match/session must remain usable even when a browser
# carries a stale Agent id from another WFL account.
result["agent_assignment"] = None
result["agent_warning"] = str(agent_error)[:300]
return result
except ValueError as error:
raise HTTPException(status_code=409, detail=str(error)) from error
@router.get("/sessions/{token}")
async def get_session(
token: str,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
value = service.get_session(token)
if value is None or value["wfl_user_id"] != user.id:
raise HTTPException(status_code=404, detail="Сессия не найдена")
return value
@router.delete("/sessions/{token}")
async def close_session(
token: str,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
if not service.close_session(token, wfl_user_id=user.id):
raise HTTPException(status_code=404, detail="Сессия не найдена")
return {"ok": True}
@router.get("/sessions/{token}/vmix-links")
async def vmix_links(
token: str,
request: Request,
language: str = Query("ru", pattern="^(ru|en|bilingual)$"),
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
operator_session = service.get_session(token)
if operator_session is None or operator_session["wfl_user_id"] != user.id:
raise HTTPException(status_code=404, detail="Сессия не найдена")
return {
"token": token, # legacy field
"channel_key": token,
"channel_owner": operator_session.get("channel_owner", ""),
"language": language,
"links": service.vmix_links(
token=token,
base_url=str(request.base_url).rstrip("/"),
language=language,
),
}
return router
def create_hockey_vmix_router(
service: HockeyDataService,
portable: HockeyVmixPortable | None = None,
) -> APIRouter:
portable = portable or HockeyVmixPortable(service)
router = APIRouter(tags=["Hockey vMix JSON"])
def translate_error(error: Exception) -> HTTPException:
if isinstance(error, KeyError):
return HTTPException(status_code=404, detail=str(error).strip("'"))
return HTTPException(status_code=400, detail=str(error))
@router.get("/vmix/hockey/session/{token}/{language}/info.json", include_in_schema=False)
@router.get("/vmix/hockey/channel/{token}/{language}/info.json")
async def info(token: str, language: str) -> dict[str, Any]:
try:
return portable.info_payload(token, language)
except (KeyError, ValueError) as error:
raise translate_error(error) from error
@router.get("/vmix/hockey/session/{token}/{language}/scoreboard.json", include_in_schema=False)
@router.get("/vmix/hockey/channel/{token}/{language}/scoreboard.json")
async def scoreboard(token: str, language: str) -> dict[str, Any]:
try:
return portable.scoreboard_payload(token, language)
except (KeyError, ValueError) as error:
raise translate_error(error) from error
@router.get("/vmix/hockey/session/{token}/{language}/home-lineup.json", include_in_schema=False)
@router.get("/vmix/hockey/channel/{token}/{language}/home-lineup.json")
async def home_lineup(token: str, language: str) -> dict[str, Any]:
try:
return portable.line_up_payload(token, language, "home")
except (KeyError, ValueError) as error:
raise translate_error(error) from error
@router.get("/vmix/hockey/session/{token}/{language}/away-lineup.json", include_in_schema=False)
@router.get("/vmix/hockey/channel/{token}/{language}/away-lineup.json")
async def away_lineup(token: str, language: str) -> dict[str, Any]:
try:
return portable.line_up_payload(token, language, "away")
except (KeyError, ValueError) as error:
raise translate_error(error) from error
@router.get("/vmix/hockey/session/{token}/{language}/officials.json", include_in_schema=False)
@router.get("/vmix/hockey/channel/{token}/{language}/officials.json")
async def officials(token: str, language: str) -> dict[str, Any]:
try:
return portable.officials_payload(token, language)
except (KeyError, ValueError) as error:
raise translate_error(error) from error
@router.get("/vmix/hockey/session/{token}/{language}/penalties.json", include_in_schema=False)
@router.get("/vmix/hockey/channel/{token}/{language}/penalties.json")
async def penalties(token: str, language: str) -> dict[str, Any]:
try:
return portable.penalties_payload(token, language)
except (KeyError, ValueError) as error:
raise translate_error(error) from error
@router.get("/vmix/hockey/session/{token}/{language}/events.json", include_in_schema=False)
@router.get("/vmix/hockey/channel/{token}/{language}/events.json")
async def events(token: str, language: str) -> dict[str, Any]:
try:
return portable.events_payload(token, language)
except (KeyError, ValueError) as error:
raise translate_error(error) from error
@router.get("/vmix/hockey/session/{token}/{language}/custom/{json_key}.json", include_in_schema=False)
@router.get("/vmix/hockey/channel/{token}/{language}/custom/{json_key}.json")
async def custom_json(
token: str,
language: str,
json_key: str,
limit: int | None = Query(None, ge=0, le=1000),
) -> list[dict[str, Any]]:
try:
return portable.configured_output(token, language, json_key, limit=limit)
except (KeyError, ValueError) as error:
raise translate_error(error) from error
except Exception as error:
raise HTTPException(
status_code=500,
detail=f"Ошибка сборки vMix JSON {json_key}: {error}",
) from error
return router