Files
hockey_new/hockey_data/router.py
2026-08-19 15:08:39 +03:00

1151 lines
46 KiB
Python

from __future__ import annotations
from datetime import date
from typing import Any, Callable
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict, Field
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
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 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)
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.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)$"),
) -> dict[str, Any]:
try:
return await service.sync_selected_game_details(
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.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/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"):
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 ""),
)
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