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 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}/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