1667 lines
50 KiB
Python
1667 lines
50 KiB
Python
from fastapi import FastAPI, Form, Request, Query, Body, HTTPException, WebSocket, WebSocketDisconnect
|
||
from datetime import datetime, timezone
|
||
from fastapi.responses import (
|
||
HTMLResponse,
|
||
RedirectResponse,
|
||
JSONResponse,
|
||
RedirectResponse,
|
||
StreamingResponse,
|
||
)
|
||
from fastapi.templating import Jinja2Templates
|
||
from fastapi.staticfiles import StaticFiles
|
||
from pydantic import BaseModel, Field
|
||
from collections import defaultdict
|
||
from typing import List, Optional, Dict, Any
|
||
import json
|
||
from pathlib import Path
|
||
import io
|
||
import asyncio
|
||
import time
|
||
from urllib.parse import quote
|
||
|
||
from repositories.match_formation_repository import (
|
||
get_match_formations,
|
||
replace_match_formations,
|
||
apply_formation_preset_to_players,
|
||
)
|
||
|
||
from parsers.parser_game import run_parser_game
|
||
from repositories.match_session_repository import (
|
||
create_match_session,
|
||
get_match_session_by_token,
|
||
deactivate_match_session,
|
||
update_match_session_vmix_path,
|
||
get_match_by_id,
|
||
list_matches_for_admin,
|
||
list_available_tours,
|
||
)
|
||
from repositories.match_view_repository import get_match_lineups_grouped
|
||
from vmix.vmix_service import build_vmix_project_bytes, build_vmix_filename
|
||
from repositories.match_referee_repository import get_match_referees
|
||
from repositories.match_repository import get_tour_schedule_by_match_id
|
||
from repositories.standings_repository import get_standings_by_match_id
|
||
from repositories.match_coach_repository import get_match_coaches_grouped
|
||
from repositories.player_repository import (
|
||
search_players_for_admin,
|
||
get_player_by_id,
|
||
update_player_admin,
|
||
)
|
||
|
||
from repositories.referee_repository import (
|
||
search_referees_for_admin,
|
||
get_referee_by_id,
|
||
update_referee_admin,
|
||
get_all_referees,
|
||
# replace_match_referees,
|
||
)
|
||
|
||
from repositories.match_referee_repository import replace_match_referees
|
||
|
||
from repositories.coach_repository import (
|
||
search_coaches_for_admin,
|
||
get_coach_by_id,
|
||
update_coach_admin,
|
||
)
|
||
|
||
from repositories.stadium_repository import (
|
||
search_stadiums_for_admin,
|
||
get_stadium_by_id,
|
||
update_stadium_admin,
|
||
)
|
||
|
||
from repositories.team_repository import (
|
||
search_teams_for_admin,
|
||
get_team_by_id,
|
||
update_team_admin,
|
||
)
|
||
|
||
from repositories.match_event_repository import (
|
||
create_event,
|
||
get_events,
|
||
delete_event,
|
||
clear_events,
|
||
update_event,
|
||
)
|
||
from repositories.auth_repository import get_user_by_username
|
||
from services.auth_service import (
|
||
create_auth_session,
|
||
verify_password,
|
||
get_current_user_from_request,
|
||
build_not_authenticated_response,
|
||
revoke_auth_session_by_request,
|
||
)
|
||
|
||
from repositories.audit_log_repository import create_audit_log
|
||
|
||
from repositories.team_squad_repository import get_team_players_for_match_editor
|
||
from repositories.team_coach_repository import get_team_coaches_for_match_editor
|
||
from repositories.match_lineup_repository import (
|
||
get_match_lineup_for_editor,
|
||
save_match_lineup_for_editor,
|
||
)
|
||
|
||
from services.vmix_json_service import (
|
||
build_lineup_json,
|
||
get_vmix_match_info_by_token,
|
||
get_vmix_standings,
|
||
get_vmix_schedule,
|
||
get_vmix_team_formations,
|
||
get_vmix_scoreboard_info,
|
||
get_vmix_players_goal,
|
||
)
|
||
|
||
BASE_DIR = Path(__file__).resolve().parent
|
||
app = FastAPI()
|
||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||
|
||
class PublishVmixCommandPayload(BaseModel):
|
||
match_id: int
|
||
session_token: str | None = None
|
||
commands: List[str] = Field(default_factory=list)
|
||
target_client_id: str | None = None
|
||
target_group: str | None = None
|
||
target_login: str | None = None
|
||
meta: dict | None = None
|
||
|
||
|
||
class RegisterVmixClientPayload(BaseModel):
|
||
client_id: str
|
||
match_id: int | None = None
|
||
session_token: str | None = None
|
||
operator_name: str | None = None
|
||
group_name: str | None = None
|
||
|
||
|
||
class VmixConnectionManager:
|
||
def __init__(self):
|
||
self.connections: Dict[str, dict] = {}
|
||
self.lock = asyncio.Lock()
|
||
|
||
async def connect(
|
||
self,
|
||
websocket: WebSocket,
|
||
client_id: str,
|
||
match_id: int | None = None,
|
||
session_token: str | None = None,
|
||
operator_name: str | None = None,
|
||
group_name: str | None = None,
|
||
):
|
||
await websocket.accept()
|
||
|
||
async with self.lock:
|
||
old = self.connections.get(client_id)
|
||
if old:
|
||
try:
|
||
await old["websocket"].close(code=4000)
|
||
except Exception:
|
||
pass
|
||
|
||
self.connections[client_id] = {
|
||
"websocket": websocket,
|
||
"client_id": client_id,
|
||
"match_id": match_id,
|
||
"session_token": session_token,
|
||
"operator_name": operator_name,
|
||
"group_name": group_name,
|
||
"connected_at": time.time(),
|
||
"last_seen": time.time(),
|
||
}
|
||
|
||
async def disconnect(self, client_id: str):
|
||
async with self.lock:
|
||
self.connections.pop(client_id, None)
|
||
|
||
async def heartbeat(self, client_id: str):
|
||
async with self.lock:
|
||
if client_id in self.connections:
|
||
self.connections[client_id]["last_seen"] = time.time()
|
||
|
||
async def update_meta(
|
||
self,
|
||
client_id: str,
|
||
match_id: int | None = None,
|
||
session_token: str | None = None,
|
||
operator_name: str | None = None,
|
||
group_name: str | None = None,
|
||
):
|
||
async with self.lock:
|
||
conn = self.connections.get(client_id)
|
||
if not conn:
|
||
return
|
||
|
||
conn["last_seen"] = time.time()
|
||
if match_id is not None:
|
||
conn["match_id"] = match_id
|
||
if session_token is not None:
|
||
conn["session_token"] = session_token
|
||
if operator_name is not None:
|
||
conn["operator_name"] = operator_name
|
||
if group_name is not None:
|
||
conn["group_name"] = group_name
|
||
|
||
async def list_clients(self):
|
||
async with self.lock:
|
||
return [
|
||
{
|
||
"client_id": c["client_id"],
|
||
"match_id": c["match_id"],
|
||
"session_token": c["session_token"],
|
||
"operator_name": c["operator_name"],
|
||
"group_name": c["group_name"],
|
||
"connected_at": c["connected_at"],
|
||
"last_seen": c["last_seen"],
|
||
}
|
||
for c in self.connections.values()
|
||
]
|
||
|
||
async def send_to_client(self, client_id: str, message: dict) -> bool:
|
||
async with self.lock:
|
||
conn = self.connections.get(client_id)
|
||
if not conn:
|
||
return False
|
||
ws = conn["websocket"]
|
||
|
||
try:
|
||
await ws.send_json(message)
|
||
await self.heartbeat(client_id)
|
||
return True
|
||
except Exception:
|
||
await self.disconnect(client_id)
|
||
return False
|
||
|
||
async def send_to_match(
|
||
self,
|
||
match_id: int,
|
||
message: dict,
|
||
target_group: str | None = None,
|
||
target_login: str | None = None,
|
||
):
|
||
async with self.lock:
|
||
items = list(self.connections.values())
|
||
|
||
matched = []
|
||
for conn in items:
|
||
if conn["match_id"] != match_id:
|
||
continue
|
||
if target_group and conn.get("group_name") != target_group:
|
||
continue
|
||
if target_login and conn.get("operator_name") != target_login:
|
||
continue
|
||
matched.append(conn["client_id"])
|
||
|
||
results = []
|
||
for client_id in matched:
|
||
ok = await self.send_to_client(client_id, message)
|
||
results.append({"client_id": client_id, "ok": ok})
|
||
|
||
return results
|
||
|
||
|
||
vmix_ws_manager = VmixConnectionManager()
|
||
|
||
def get_current_user(request: Request):
|
||
return getattr(request.state, "current_user", None)
|
||
|
||
|
||
def require_role(request: Request, allowed_roles: set[str]):
|
||
user = get_current_user(request)
|
||
if not user or user["role"] not in allowed_roles:
|
||
if request.headers.get(
|
||
"x-requested-with"
|
||
) == "XMLHttpRequest" or "application/json" in request.headers.get(
|
||
"accept", ""
|
||
):
|
||
return JSONResponse({"error": "forbidden"}, status_code=403)
|
||
return RedirectResponse("/admin/matches", status_code=303)
|
||
return None
|
||
|
||
@app.get("/")
|
||
def root(request: Request):
|
||
user = get_current_user_from_request(request)
|
||
if user:
|
||
return RedirectResponse(url="/admin/matches")
|
||
return RedirectResponse(url="/login")
|
||
|
||
|
||
def log_action(
|
||
request: Request,
|
||
action: str,
|
||
entity_type=None,
|
||
entity_id=None,
|
||
match_id=None,
|
||
session_token=None,
|
||
details=None,
|
||
):
|
||
user = getattr(request.state, "current_user", None) or {}
|
||
create_audit_log(
|
||
user_id=user.get("id") or user.get("user_id"),
|
||
username=user.get("username"),
|
||
role=user.get("role"),
|
||
action=action,
|
||
entity_type=entity_type,
|
||
entity_id=str(entity_id) if entity_id is not None else None,
|
||
match_id=match_id,
|
||
session_token=session_token,
|
||
ip_address=request.client.host if request.client else None,
|
||
user_agent=request.headers.get("user-agent"),
|
||
details=details,
|
||
)
|
||
|
||
|
||
@app.middleware("http")
|
||
async def admin_auth_middleware(request: Request, call_next):
|
||
path = request.url.path
|
||
|
||
if path.startswith("/admin"):
|
||
user = get_current_user_from_request(request)
|
||
if not user:
|
||
return build_not_authenticated_response(request)
|
||
|
||
current_user = get_current_user_from_request(request)
|
||
request.state.current_user = {
|
||
"id": current_user.get("user_id") or current_user.get("id"),
|
||
"username": current_user.get("username"),
|
||
"role": current_user.get("role", "operator"),
|
||
}
|
||
|
||
response = await call_next(request)
|
||
return response
|
||
|
||
|
||
@app.get("/login", response_class=HTMLResponse)
|
||
def login_page(request: Request, reason: str | None = Query(default=None)):
|
||
if get_current_user_from_request(request):
|
||
return RedirectResponse(url="/admin/matches", status_code=303)
|
||
|
||
reason_map = {
|
||
"expired": "Сессия истекла. Войдите заново.",
|
||
"idle": "Сессия завершена из-за 2 часов бездействия.",
|
||
"logged_out": "Вы вышли из системы.",
|
||
"invalid": "Нужно войти в систему.",
|
||
}
|
||
|
||
return templates.TemplateResponse(
|
||
name="login.html",
|
||
request=request,
|
||
context={
|
||
"error": "",
|
||
"message": reason_map.get(reason or "", ""),
|
||
},
|
||
)
|
||
|
||
|
||
@app.post("/login", response_class=HTMLResponse)
|
||
def login_submit(
|
||
request: Request,
|
||
username: str = Form(...),
|
||
password: str = Form(...),
|
||
):
|
||
user = get_user_by_username(username.strip())
|
||
|
||
if not user or not verify_password(password, user[2]) or not user[3]:
|
||
return templates.TemplateResponse(
|
||
request=request,
|
||
name="login.html",
|
||
context={
|
||
"error": "Неверный логин или пароль",
|
||
"message": "",
|
||
},
|
||
status_code=401,
|
||
)
|
||
|
||
token = create_auth_session(
|
||
user_id=user[0],
|
||
ip_address=request.client.host if request.client else None,
|
||
user_agent=request.headers.get("user-agent"),
|
||
)
|
||
|
||
response = RedirectResponse(url="/admin/matches", status_code=303)
|
||
response.set_cookie(
|
||
key="auth_token",
|
||
value=token,
|
||
httponly=True,
|
||
secure=True,
|
||
samesite="lax",
|
||
max_age=7200,
|
||
)
|
||
return response
|
||
|
||
|
||
@app.post("/logout")
|
||
def logout(request: Request):
|
||
revoke_auth_session_by_request(request)
|
||
response = RedirectResponse(url="/login?reason=logged_out", status_code=303)
|
||
response.delete_cookie("auth_token", path="/")
|
||
return response
|
||
|
||
|
||
class MatchEventPayload(BaseModel):
|
||
side: str
|
||
type: str
|
||
player_name: str = ""
|
||
minute: int | None = None
|
||
seconds: int = 0
|
||
meta: str | None = None
|
||
|
||
player_id: int | None = None
|
||
player_out_id: int | None = None
|
||
player_in_id: int | None = None
|
||
|
||
|
||
class FormationApplyPayload(BaseModel):
|
||
side: str
|
||
preset: str
|
||
|
||
|
||
class FormationPlayerPayload(BaseModel):
|
||
player_id: int | None = None
|
||
player_name: str = ""
|
||
number: str = ""
|
||
position: str = ""
|
||
is_captain: bool = False
|
||
x: float
|
||
y: float
|
||
|
||
|
||
class FormationSavePayload(BaseModel):
|
||
side: str
|
||
players: list[FormationPlayerPayload]
|
||
|
||
|
||
class SquadEditorPlayerPayload(BaseModel):
|
||
player_id: int | None = None
|
||
player_name: str = ""
|
||
last_name: str = ""
|
||
first_name: str = ""
|
||
number: str = ""
|
||
position: str = ""
|
||
is_captain: bool = False
|
||
|
||
|
||
class SquadEditorCoachPayload(BaseModel):
|
||
coach_id: int | None = None
|
||
coach_name: str = ""
|
||
role: str = ""
|
||
|
||
|
||
class SquadEditorSavePayload(BaseModel):
|
||
home_starting: list[SquadEditorPlayerPayload] = Field(default_factory=list)
|
||
home_bench: list[SquadEditorPlayerPayload] = Field(default_factory=list)
|
||
away_starting: list[SquadEditorPlayerPayload] = Field(default_factory=list)
|
||
away_bench: list[SquadEditorPlayerPayload] = Field(default_factory=list)
|
||
|
||
home_coaches: list[SquadEditorCoachPayload] = Field(default_factory=list)
|
||
away_coaches: list[SquadEditorCoachPayload] = Field(default_factory=list)
|
||
|
||
|
||
@app.get("/admin/matches", response_class=HTMLResponse)
|
||
def admin_matches(
|
||
request: Request,
|
||
today_only: bool = Query(default=False),
|
||
tour: str | None = Query(default=None),
|
||
hide_finished: str = Query(default="true"),
|
||
):
|
||
hide_finished_bool = str(hide_finished).lower() == "true"
|
||
|
||
matches = list_matches_for_admin(
|
||
today_only=today_only,
|
||
tour=tour,
|
||
hide_finished=hide_finished_bool,
|
||
)
|
||
tours = list_available_tours()
|
||
|
||
return templates.TemplateResponse(
|
||
name="matches.html",
|
||
request=request,
|
||
context={
|
||
"matches": matches,
|
||
"today_only": today_only,
|
||
"hide_finished": hide_finished_bool,
|
||
"selected_tour": tour or "",
|
||
"tours": tours,
|
||
"current_user": getattr(request.state, "current_user", None),
|
||
},
|
||
)
|
||
|
||
|
||
# @app.get("/admin/matches/{match_id}/select")
|
||
# def select_match(
|
||
# request: Request, match_id: int, operator_name: str = Form(default="")
|
||
# ):
|
||
# match_row = get_match_by_id(match_id)
|
||
# if not match_row:
|
||
# return RedirectResponse(url="/admin/matches", status_code=303)
|
||
|
||
# _, match_external_id, _, _, _, _, _ = match_row
|
||
|
||
# vmix_result = prepare_vmix_project(match_row)
|
||
|
||
# session_row = create_match_session(
|
||
# match_id=match_id,
|
||
# operator_name=operator_name.strip() or None,
|
||
# vmix_project_path=(
|
||
# vmix_result["project_path"] if vmix_result["success"] else None
|
||
# ),
|
||
# )
|
||
|
||
# session_token = session_row[3]
|
||
# log_action(
|
||
# request,
|
||
# action="match_selected",
|
||
# entity_type="match",
|
||
# entity_id=match_id,
|
||
# match_id=match_id,
|
||
# session_token=session_row[3],
|
||
# details={"operator_name": operator_name.strip() or None},
|
||
# )
|
||
# return RedirectResponse(url=f"/admin/session/{session_token}", status_code=303)
|
||
|
||
|
||
@app.get("/admin/matches/{match_id}/select")
|
||
def select_match(
|
||
request: Request, match_id: int, operator_name: str = Query(default="")
|
||
):
|
||
match_row = get_match_by_id(match_id)
|
||
if not match_row:
|
||
return JSONResponse({"error": "match_not_found"}, status_code=404)
|
||
|
||
session_row = create_match_session(
|
||
match_id=match_id,
|
||
operator_name=None,
|
||
vmix_project_path=None,
|
||
)
|
||
|
||
session_token = session_row[3]
|
||
|
||
log_action(
|
||
request,
|
||
action="match_selected",
|
||
entity_type="match",
|
||
entity_id=match_id,
|
||
match_id=match_id,
|
||
session_token=session_token,
|
||
details={
|
||
"operator_name": None,
|
||
},
|
||
)
|
||
|
||
return JSONResponse({
|
||
"success": True,
|
||
"session_token": session_token,
|
||
"session_url": f"/admin/session/{session_token}",
|
||
})
|
||
|
||
|
||
@app.get("/admin/session/{session_token}/download-vmix-page", response_class=HTMLResponse)
|
||
def download_vmix_project_page(request: Request, session_token: str):
|
||
session_row = get_match_session_by_token(session_token)
|
||
if not session_row:
|
||
return RedirectResponse(url="/admin/matches", status_code=303)
|
||
|
||
return templates.TemplateResponse(
|
||
name="download_vmix.html",
|
||
request=request,
|
||
context={
|
||
"session": session_row,
|
||
"session_token": session_token,
|
||
"download_url": f"/admin/session/{session_token}/download-vmix",
|
||
"back_url": f"/admin/session/{session_token}?tab=game",
|
||
},
|
||
)
|
||
|
||
|
||
@app.get("/admin/session/{session_token}/download-vmix")
|
||
def download_vmix_project(request: Request, session_token: str):
|
||
session_row = get_match_session_by_token(session_token)
|
||
if not session_row:
|
||
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
||
|
||
current_user = getattr(request.state, "current_user", None) or {}
|
||
operator_login = current_user.get("username") or None
|
||
|
||
try:
|
||
vmix_bytes = build_vmix_project_bytes(
|
||
session_token=session_token,
|
||
match_id=session_row[1],
|
||
operator_login=operator_login,
|
||
)
|
||
filename = build_vmix_filename(session_row)
|
||
|
||
return StreamingResponse(
|
||
io.BytesIO(vmix_bytes),
|
||
media_type="application/octet-stream",
|
||
headers={
|
||
"Content-Disposition": (
|
||
f"attachment; filename*=UTF-8''{quote(filename)}"
|
||
)
|
||
},
|
||
)
|
||
except Exception as e:
|
||
return JSONResponse({"error": str(e)}, status_code=500)
|
||
|
||
|
||
@app.get("/admin/session/{session_token}", response_class=HTMLResponse)
|
||
def session_workspace(
|
||
request: Request, session_token: str, tab: str = Query(default="game")
|
||
):
|
||
session_row = get_match_session_by_token(session_token)
|
||
|
||
if not session_row:
|
||
return RedirectResponse(url="/admin/matches", status_code=303)
|
||
|
||
match_id = session_row[1]
|
||
home_team_id = session_row[13]
|
||
away_team_id = session_row[16]
|
||
referees = get_match_referees(match_id)
|
||
referee_pool = get_all_referees()
|
||
tour_schedule = get_tour_schedule_by_match_id(match_id)
|
||
standings = get_standings_by_match_id(match_id)
|
||
coaches = get_match_coaches_grouped(
|
||
match_id=match_id,
|
||
home_team_id=home_team_id,
|
||
away_team_id=away_team_id,
|
||
)
|
||
|
||
lineups = get_match_lineup_for_editor(
|
||
match_id=match_id,
|
||
home_team_id=home_team_id,
|
||
away_team_id=away_team_id,
|
||
)
|
||
|
||
home_formations = get_match_formations(match_id, home_team_id)
|
||
away_formations = get_match_formations(match_id, away_team_id)
|
||
|
||
|
||
return templates.TemplateResponse(
|
||
name="match_workspace.html",
|
||
request=request,
|
||
context={
|
||
"session": session_row,
|
||
"tab": tab,
|
||
"lineups": lineups,
|
||
"referees": referees,
|
||
"referee_pool": referee_pool,
|
||
"tour_schedule": tour_schedule,
|
||
"standings": standings,
|
||
"coaches": coaches,
|
||
"home_formations": home_formations,
|
||
"away_formations": away_formations,
|
||
"current_user": getattr(request.state, "current_user", None),
|
||
"auth_idle_timeout_seconds": 7200,
|
||
},
|
||
)
|
||
|
||
|
||
@app.post("/admin/session/{session_token}/load-match-data")
|
||
def load_match_data(request: Request, session_token: str):
|
||
session_row = get_match_session_by_token(session_token)
|
||
if not session_row:
|
||
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
||
return JSONResponse(
|
||
{
|
||
"success": False,
|
||
"error": "Сессия не найдена",
|
||
"redirect_url": "/admin/matches",
|
||
},
|
||
status_code=404,
|
||
)
|
||
return RedirectResponse(url="/admin/matches", status_code=303)
|
||
|
||
match_id = session_row[1]
|
||
match_external_id = session_row[8]
|
||
|
||
try:
|
||
from db import get_connection
|
||
|
||
# Сначала очищаем ручные данные матча,
|
||
# чтобы парсер потом записал свежие составы и тренеров
|
||
conn = get_connection()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"DELETE FROM match_lineup_players WHERE match_id = %s",
|
||
(match_id,),
|
||
)
|
||
cur.execute(
|
||
"DELETE FROM match_coaches WHERE match_id = %s",
|
||
(match_id,),
|
||
)
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
# Потом загружаем свежие данные с сайта
|
||
run_parser_game(str(match_external_id))
|
||
|
||
except Exception as e:
|
||
error_text = str(e) or "Ошибка загрузки данных с сайта"
|
||
|
||
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
||
return JSONResponse(
|
||
{
|
||
"success": False,
|
||
"error": error_text,
|
||
"redirect_url": f"/admin/session/{session_token}?tab=game",
|
||
},
|
||
status_code=500,
|
||
)
|
||
|
||
return RedirectResponse(
|
||
url=f"/admin/session/{session_token}?tab=game",
|
||
status_code=303,
|
||
)
|
||
|
||
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
||
return JSONResponse(
|
||
{
|
||
"success": True,
|
||
"message": "Данные успешно загружены",
|
||
"redirect_url": f"/admin/session/{session_token}?tab=game",
|
||
}
|
||
)
|
||
|
||
return RedirectResponse(
|
||
url=f"/admin/session/{session_token}?tab=game",
|
||
status_code=303,
|
||
)
|
||
|
||
|
||
@app.post("/admin/session/{session_token}/close")
|
||
def close_session(session_token: str):
|
||
deactivate_match_session(session_token)
|
||
return RedirectResponse(url="/admin/matches", status_code=303)
|
||
|
||
|
||
@app.get("/admin/db", response_class=HTMLResponse)
|
||
def admin_db_index(request: Request):
|
||
return templates.TemplateResponse(
|
||
name="admin_db_index.html",
|
||
request=request,
|
||
context={
|
||
"current_user": getattr(request.state, "current_user", None),
|
||
},
|
||
)
|
||
|
||
|
||
@app.get("/admin/db/players", response_class=HTMLResponse)
|
||
def admin_db_players(request: Request, q: str = Query(default="")):
|
||
# denied = require_role(request, {"admin"})
|
||
# if denied:
|
||
# return denied
|
||
|
||
players = search_players_for_admin(q)
|
||
|
||
return templates.TemplateResponse(
|
||
name="admin_db_players.html",
|
||
request=request,
|
||
context={
|
||
"q": q,
|
||
"players": players,
|
||
},
|
||
)
|
||
|
||
|
||
@app.get("/admin/db/players/{player_id}/edit", response_class=HTMLResponse)
|
||
def admin_db_player_edit(request: Request, player_id: int):
|
||
player = get_player_by_id(player_id)
|
||
if not player:
|
||
return RedirectResponse(url="/admin/db/players", status_code=303)
|
||
|
||
return templates.TemplateResponse(
|
||
name="admin_db_player_edit.html",
|
||
request=request,
|
||
context={
|
||
"player": player,
|
||
},
|
||
)
|
||
|
||
|
||
@app.post("/admin/db/players/{player_id}/edit")
|
||
def admin_db_player_edit_submit(
|
||
player_id: int,
|
||
full_name: str = Form(default=""),
|
||
first_name: str = Form(default=""),
|
||
last_name: str = Form(default=""),
|
||
external_id: str = Form(default=""),
|
||
position: str = Form(default=""),
|
||
birth_date: str = Form(default=""),
|
||
photo: str = Form(default=""),
|
||
video: str = Form(default=""),
|
||
):
|
||
update_player_admin(
|
||
player_id=player_id,
|
||
full_name=full_name,
|
||
first_name=first_name,
|
||
last_name=last_name,
|
||
external_id=external_id,
|
||
position=position,
|
||
birth_date=birth_date,
|
||
photo=photo,
|
||
video=video,
|
||
)
|
||
|
||
return RedirectResponse(
|
||
url=f"/admin/db/players/{player_id}/edit",
|
||
status_code=303,
|
||
)
|
||
|
||
|
||
@app.get("/admin/db/referees", response_class=HTMLResponse)
|
||
def admin_db_referees(request: Request, q: str = Query(default="")):
|
||
referees = search_referees_for_admin(q)
|
||
return templates.TemplateResponse(
|
||
name="admin_db_referees.html",
|
||
request=request,
|
||
context={
|
||
"q": q,
|
||
"referees": referees,
|
||
},
|
||
)
|
||
|
||
|
||
@app.get("/admin/db/referees/{referee_id}/edit", response_class=HTMLResponse)
|
||
def admin_db_referee_edit(request: Request, referee_id: int):
|
||
referee = get_referee_by_id(referee_id)
|
||
if not referee:
|
||
return RedirectResponse(url="/admin/db/referees", status_code=303)
|
||
|
||
return templates.TemplateResponse(
|
||
name="admin_db_referee_edit.html",
|
||
request=request,
|
||
context={
|
||
"referee": referee,
|
||
},
|
||
)
|
||
|
||
|
||
@app.post("/admin/db/referees/{referee_id}/edit")
|
||
def admin_db_referee_edit_submit(
|
||
referee_id: int,
|
||
full_name: str = Form(default=""),
|
||
external_id: str = Form(default=""),
|
||
):
|
||
update_referee_admin(
|
||
referee_id=referee_id,
|
||
full_name=full_name,
|
||
external_id=external_id,
|
||
)
|
||
return RedirectResponse(
|
||
url=f"/admin/db/referees/{referee_id}/edit",
|
||
status_code=303,
|
||
)
|
||
|
||
|
||
@app.get("/admin/db/coaches", response_class=HTMLResponse)
|
||
def admin_db_coaches(request: Request, q: str = Query(default="")):
|
||
coaches = search_coaches_for_admin(q)
|
||
return templates.TemplateResponse(
|
||
name="admin_db_coaches.html",
|
||
request=request,
|
||
context={
|
||
"q": q,
|
||
"coaches": coaches,
|
||
},
|
||
)
|
||
|
||
|
||
@app.get("/admin/db/coaches/{coach_id}/edit", response_class=HTMLResponse)
|
||
def admin_db_coach_edit(request: Request, coach_id: int):
|
||
coach = get_coach_by_id(coach_id)
|
||
if not coach:
|
||
return RedirectResponse(url="/admin/db/coaches", status_code=303)
|
||
|
||
return templates.TemplateResponse(
|
||
name="admin_db_coach_edit.html",
|
||
request=request,
|
||
context={
|
||
"coach": coach,
|
||
},
|
||
)
|
||
|
||
|
||
@app.post("/admin/db/coaches/{coach_id}/edit")
|
||
def admin_db_coach_edit_submit(
|
||
coach_id: int,
|
||
full_name: str = Form(default=""),
|
||
external_id: str = Form(default=""),
|
||
role: str = Form(default=""),
|
||
):
|
||
update_coach_admin(
|
||
coach_id=coach_id,
|
||
full_name=full_name,
|
||
external_id=external_id,
|
||
role=role,
|
||
)
|
||
return RedirectResponse(
|
||
url=f"/admin/db/coaches/{coach_id}/edit",
|
||
status_code=303,
|
||
)
|
||
|
||
|
||
@app.get("/admin/db/stadiums", response_class=HTMLResponse)
|
||
def admin_db_stadiums(request: Request, q: str = Query(default="")):
|
||
stadiums = search_stadiums_for_admin(q)
|
||
return templates.TemplateResponse(
|
||
name="admin_db_stadiums.html",
|
||
request=request,
|
||
context={
|
||
"q": q,
|
||
"stadiums": stadiums,
|
||
},
|
||
)
|
||
|
||
|
||
@app.get("/admin/db/stadiums/{stadium_id}/edit", response_class=HTMLResponse)
|
||
def admin_db_stadium_edit(request: Request, stadium_id: int):
|
||
stadium = get_stadium_by_id(stadium_id)
|
||
if not stadium:
|
||
return RedirectResponse(url="/admin/db/stadiums", status_code=303)
|
||
|
||
return templates.TemplateResponse(
|
||
name="admin_db_stadium_edit.html",
|
||
request=request,
|
||
context={
|
||
"stadium": stadium,
|
||
},
|
||
)
|
||
|
||
|
||
@app.post("/admin/db/stadiums/{stadium_id}/edit")
|
||
def admin_db_stadium_edit_submit(
|
||
stadium_id: int,
|
||
stadium_gfx: str = Form(default=""),
|
||
city: str = Form(default=""),
|
||
address: str = Form(default=""),
|
||
external_id: str = Form(default=""),
|
||
):
|
||
update_stadium_admin(
|
||
stadium_id=stadium_id,
|
||
stadium_gfx=stadium_gfx,
|
||
city=city,
|
||
address=address,
|
||
external_id=external_id,
|
||
)
|
||
return RedirectResponse(
|
||
url=f"/admin/db/stadiums/{stadium_id}/edit",
|
||
status_code=303,
|
||
)
|
||
|
||
|
||
@app.get("/admin/db/teams", response_class=HTMLResponse)
|
||
def admin_db_teams(request: Request, q: str = Query(default="")):
|
||
teams = search_teams_for_admin(q)
|
||
return templates.TemplateResponse(
|
||
name="admin_db_teams.html",
|
||
request=request,
|
||
context={
|
||
"q": q,
|
||
"teams": teams,
|
||
},
|
||
)
|
||
|
||
|
||
@app.get("/admin/db/teams/{team_id}/edit", response_class=HTMLResponse)
|
||
def admin_db_team_edit(request: Request, team_id: int):
|
||
team = get_team_by_id(team_id)
|
||
if not team:
|
||
return RedirectResponse(url="/admin/db/teams", status_code=303)
|
||
|
||
return templates.TemplateResponse(
|
||
name="admin_db_team_edit.html",
|
||
request=request,
|
||
context={
|
||
"team": team,
|
||
},
|
||
)
|
||
|
||
|
||
@app.post("/admin/db/teams/{team_id}/edit")
|
||
def admin_db_team_edit_submit(
|
||
team_id: int,
|
||
name: str = Form(default=""),
|
||
full_name: str = Form(default=""),
|
||
short_name_3: str = Form(default=""),
|
||
city: str = Form(default=""),
|
||
logo_path: str = Form(default=""),
|
||
external_id: str = Form(default=""),
|
||
):
|
||
update_team_admin(
|
||
team_id=team_id,
|
||
name=name,
|
||
full_name=full_name,
|
||
short_name_3=short_name_3,
|
||
city=city,
|
||
logo_path=logo_path,
|
||
external_id=external_id,
|
||
)
|
||
return RedirectResponse(
|
||
url=f"/admin/db/teams/{team_id}/edit",
|
||
status_code=303,
|
||
)
|
||
|
||
|
||
@app.post("/admin/session/{session_token}/formations/apply")
|
||
def apply_formation(session_token: str, payload: FormationApplyPayload):
|
||
session_row = get_match_session_by_token(session_token)
|
||
if not session_row:
|
||
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
||
|
||
match_id = session_row[1]
|
||
home_team_id = session_row[13]
|
||
away_team_id = session_row[16]
|
||
|
||
lineups = get_match_lineup_for_editor(
|
||
match_id=match_id,
|
||
home_team_id=home_team_id,
|
||
away_team_id=away_team_id,
|
||
)
|
||
|
||
if payload.side == "home":
|
||
team_id = home_team_id
|
||
players = lineups["home_starting"]
|
||
elif payload.side == "away":
|
||
team_id = away_team_id
|
||
players = lineups["away_starting"]
|
||
else:
|
||
return JSONResponse({"error": "invalid_side"}, status_code=400)
|
||
|
||
applied = apply_formation_preset_to_players(players, payload.preset)
|
||
replace_match_formations(match_id, team_id, applied)
|
||
|
||
return {"players": applied}
|
||
|
||
|
||
@app.post("/admin/session/{session_token}/formations/save")
|
||
def save_formation(session_token: str, payload: FormationSavePayload):
|
||
session_row = get_match_session_by_token(session_token)
|
||
if not session_row:
|
||
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
||
|
||
match_id = session_row[1]
|
||
home_team_id = session_row[13]
|
||
away_team_id = session_row[16]
|
||
|
||
if payload.side == "home":
|
||
team_id = home_team_id
|
||
elif payload.side == "away":
|
||
team_id = away_team_id
|
||
else:
|
||
return JSONResponse({"error": "invalid_side"}, status_code=400)
|
||
|
||
players = [
|
||
{
|
||
"player_id": p.player_id,
|
||
"player_name": p.player_name,
|
||
"number": p.number,
|
||
"position": p.position,
|
||
"is_captain": p.is_captain,
|
||
"x": p.x,
|
||
"y": p.y,
|
||
}
|
||
for p in payload.players
|
||
]
|
||
|
||
replace_match_formations(match_id, team_id, players)
|
||
|
||
return {"success": True}
|
||
|
||
|
||
@app.get("/admin/session/{session_token}/events")
|
||
def api_get_events(session_token: str):
|
||
session_row = get_match_session_by_token(session_token)
|
||
if not session_row:
|
||
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
||
|
||
match_id = session_row[1]
|
||
return get_events(match_id)
|
||
|
||
|
||
@app.post("/admin/session/{session_token}/event")
|
||
def api_add_event(session_token: str, payload: MatchEventPayload):
|
||
session_row = get_match_session_by_token(session_token)
|
||
if not session_row:
|
||
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
||
|
||
match_id = session_row[1]
|
||
|
||
event_id = create_event(
|
||
match_id=match_id,
|
||
side=payload.side,
|
||
type_=payload.type,
|
||
player_name=payload.player_name,
|
||
minute=payload.minute,
|
||
seconds=payload.seconds,
|
||
meta=payload.meta,
|
||
player_id=payload.player_id,
|
||
player_out_id=payload.player_out_id,
|
||
player_in_id=payload.player_in_id,
|
||
)
|
||
return {"id": event_id}
|
||
|
||
|
||
@app.put("/admin/session/{session_token}/event/{event_id}")
|
||
def api_update_event(session_token: str, event_id: int, payload: MatchEventPayload):
|
||
session_row = get_match_session_by_token(session_token)
|
||
if not session_row:
|
||
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
||
|
||
update_event(
|
||
event_id=event_id,
|
||
side=payload.side,
|
||
type_=payload.type,
|
||
player_name=payload.player_name,
|
||
minute=payload.minute,
|
||
seconds=payload.seconds,
|
||
meta=payload.meta,
|
||
player_id=payload.player_id,
|
||
player_out_id=payload.player_out_id,
|
||
player_in_id=payload.player_in_id,
|
||
)
|
||
return {"ok": True}
|
||
|
||
|
||
@app.delete("/admin/session/{session_token}/events")
|
||
def api_clear_events(session_token: str):
|
||
session_row = get_match_session_by_token(session_token)
|
||
if not session_row:
|
||
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
||
|
||
match_id = session_row[1]
|
||
clear_events(match_id)
|
||
return {"ok": True}
|
||
|
||
|
||
@app.delete("/admin/session/{session_token}/event/{event_id}")
|
||
def api_delete_event(session_token: str, event_id: int):
|
||
session_row = get_match_session_by_token(session_token)
|
||
if not session_row:
|
||
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
||
|
||
delete_event(event_id)
|
||
return {"ok": True}
|
||
|
||
|
||
@app.get("/admin/session/{session_token}/squad-editor-data")
|
||
def api_get_squad_editor_data(session_token: str):
|
||
session_row = get_match_session_by_token(session_token)
|
||
if not session_row:
|
||
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
||
|
||
match_id = session_row[1]
|
||
home_team_id = session_row[13]
|
||
away_team_id = session_row[16]
|
||
|
||
current_lineup = get_match_lineup_for_editor(
|
||
match_id=match_id,
|
||
home_team_id=home_team_id,
|
||
away_team_id=away_team_id,
|
||
)
|
||
|
||
home_squad_pool = get_team_players_for_match_editor(
|
||
home_team_id,
|
||
match_id=match_id,
|
||
home_team_id=home_team_id,
|
||
away_team_id=away_team_id,
|
||
)
|
||
|
||
away_squad_pool = get_team_players_for_match_editor(
|
||
away_team_id,
|
||
match_id=match_id,
|
||
home_team_id=home_team_id,
|
||
away_team_id=away_team_id,
|
||
)
|
||
|
||
home_coach_pool = get_team_coaches_for_match_editor(
|
||
home_team_id,
|
||
match_id=match_id,
|
||
home_team_id=home_team_id,
|
||
away_team_id=away_team_id,
|
||
)
|
||
|
||
away_coach_pool = get_team_coaches_for_match_editor(
|
||
away_team_id,
|
||
match_id=match_id,
|
||
home_team_id=home_team_id,
|
||
away_team_id=away_team_id,
|
||
)
|
||
|
||
return {
|
||
"home_starting": current_lineup.get("home_starting", []),
|
||
"home_bench": current_lineup.get("home_bench", []),
|
||
"away_starting": current_lineup.get("away_starting", []),
|
||
"away_bench": current_lineup.get("away_bench", []),
|
||
"home_coaches": current_lineup.get("home_coaches", []),
|
||
"away_coaches": current_lineup.get("away_coaches", []),
|
||
"home_squad_pool": home_squad_pool,
|
||
"away_squad_pool": away_squad_pool,
|
||
"home_coach_pool": home_coach_pool,
|
||
"away_coach_pool": away_coach_pool,
|
||
}
|
||
|
||
|
||
@app.post("/admin/session/{session_token}/squad-editor-save")
|
||
def api_save_squad_editor_data(
|
||
session_token: str,
|
||
payload: SquadEditorSavePayload,
|
||
):
|
||
session_row = get_match_session_by_token(session_token)
|
||
if not session_row:
|
||
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
||
|
||
match_id = session_row[1]
|
||
home_team_id = session_row[13]
|
||
away_team_id = session_row[16]
|
||
|
||
save_match_lineup_for_editor(
|
||
match_id=match_id,
|
||
home_team_id=home_team_id,
|
||
away_team_id=away_team_id,
|
||
home_starting=[p.model_dump() for p in payload.home_starting],
|
||
home_bench=[p.model_dump() for p in payload.home_bench],
|
||
away_starting=[p.model_dump() for p in payload.away_starting],
|
||
away_bench=[p.model_dump() for p in payload.away_bench],
|
||
home_coaches=[c.model_dump() for c in payload.home_coaches],
|
||
away_coaches=[c.model_dump() for c in payload.away_coaches],
|
||
)
|
||
|
||
return {"success": True}
|
||
|
||
|
||
@app.post("/admin/session/{session_token}/referees/save")
|
||
async def save_session_referees(request: Request, session_token: str):
|
||
session_row = get_match_session_by_token(session_token)
|
||
if not session_row:
|
||
return {"success": False, "error": "Сессия не найдена"}
|
||
|
||
match_id = session_row[1]
|
||
payload = await request.json()
|
||
referees = payload.get("referees", [])
|
||
|
||
replace_match_referees(match_id, referees)
|
||
|
||
return {"success": True}
|
||
|
||
|
||
EMPTY_PLAYER = {
|
||
"last_name": " ",
|
||
"first_name": " ",
|
||
"full_name": " ",
|
||
"full_name_K": " ",
|
||
"number": " ",
|
||
"position": " ",
|
||
"pos": " ",
|
||
"number_lastname_amp_K": " ",
|
||
"number_fullname": " ",
|
||
"photo": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo\EMPTY.png",
|
||
}
|
||
|
||
|
||
def get_roster_data(session_token: str, name: str, count_player: int):
|
||
session_row = get_match_session_by_token(session_token)
|
||
if not session_row:
|
||
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
||
|
||
match_id = session_row[1]
|
||
home_team_id = session_row[13]
|
||
home_team_name = session_row[14].replace("«", "").replace("»", "")
|
||
away_team_id = session_row[16]
|
||
away_team_name = session_row[17].replace("«", "").replace("»", "")
|
||
# print(session_row)
|
||
|
||
data = build_lineup_json(
|
||
match_id, home_team_id, away_team_id, name, home_team_name, away_team_name
|
||
)
|
||
|
||
players = data.get("players", [])
|
||
players.extend([EMPTY_PLAYER.copy() for _ in range(count_player - len(players))])
|
||
|
||
data["players"] = players[:count_player]
|
||
return data
|
||
|
||
|
||
@app.get("/vmix/session/{session_token}/home-lineup")
|
||
def vmix_home_lineup(session_token: str):
|
||
return get_roster_data(session_token, "home_starting", 11)
|
||
|
||
|
||
@app.get("/vmix/session/{session_token}/away-lineup")
|
||
def vmix_away_lineup(session_token: str):
|
||
return get_roster_data(session_token, "away_starting", 11)
|
||
|
||
|
||
@app.get("/vmix/session/{session_token}/home-bench")
|
||
def vmix_home_bench(session_token: str):
|
||
return get_roster_data(session_token, "home_bench", 12)
|
||
|
||
|
||
@app.get("/vmix/session/{session_token}/away-bench")
|
||
def vmix_away_bench(session_token: str):
|
||
return get_roster_data(session_token, "away_bench", 12)
|
||
|
||
|
||
MONTHS_RU = [
|
||
"", # чтобы месяц = 1 → январь
|
||
"января",
|
||
"февраля",
|
||
"марта",
|
||
"апреля",
|
||
"мая",
|
||
"июня",
|
||
"июля",
|
||
"августа",
|
||
"сентября",
|
||
"октября",
|
||
"ноября",
|
||
"декабря",
|
||
]
|
||
|
||
|
||
def format_date_ru(value) -> str:
|
||
if isinstance(value, datetime):
|
||
dt = value
|
||
else:
|
||
dt = datetime.fromisoformat(value)
|
||
|
||
return f"{dt.day} {MONTHS_RU[dt.month]}"
|
||
|
||
|
||
@app.get("/vmix/session/{session_token}/info")
|
||
def vmix_info(session_token: str):
|
||
session_row = get_match_session_by_token(session_token)
|
||
if not session_row:
|
||
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
||
|
||
row = get_vmix_match_info_by_token(session_token)
|
||
|
||
if not row:
|
||
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
||
|
||
return [
|
||
{
|
||
"date_and_stadium": f'{format_date_ru(row[1])} | {row[12] or ""}',
|
||
"tour": row[13].replace(" ", "-й ") or "",
|
||
"tour_ID": f'{row[6] or ""} {row[13].replace(" ", "-й ") or ""}',
|
||
# "tour_ID": f'{row[6] or ""} {row[13].replace(" ", "-й ") or ""}',
|
||
"home_team": {
|
||
"full_name": row[6] or "",
|
||
"short_name": row[7] or "",
|
||
"logo": row[14] or "",
|
||
"logo_1": row[20] or "",
|
||
"logo_2": row[22] or "",
|
||
"city": f'{row[6] or ""} {row[24] or ""}',
|
||
},
|
||
"away_team": {
|
||
"full_name": row[10] or "",
|
||
"short_name": row[11] or "",
|
||
"logo": row[15] or "",
|
||
"logo_1": row[21] or "",
|
||
"logo_2": row[23] or "",
|
||
"city": f'{row[10] or ""} {row[25] or ""}',
|
||
},
|
||
"referees1": row[16] or "",
|
||
"referees2": row[17] or "",
|
||
"referees3": row[18] or "",
|
||
"referees4": row[19] or "",
|
||
"coach1": row[26] or "",
|
||
"coach2": row[28] or "",
|
||
"amplua1": row[27] or "",
|
||
"amplua2": row[29] or "",
|
||
}
|
||
]
|
||
|
||
|
||
@app.get("/vmix/session/{session_token}/standings")
|
||
def vmix_standings(session_token: str):
|
||
session_row = get_match_session_by_token(session_token)
|
||
if not session_row:
|
||
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
||
|
||
home_team_id = session_row[13]
|
||
away_team_id = session_row[16]
|
||
|
||
rows = get_vmix_standings(session_row)
|
||
return [
|
||
{
|
||
"position": row[0],
|
||
"team_name": row[1],
|
||
"logo": row[2],
|
||
"played": row[3],
|
||
"wins": row[4],
|
||
"losses": row[5],
|
||
"draws": row[6],
|
||
"score": row[7],
|
||
"points": row[8],
|
||
"color": (
|
||
"#FF00FF"
|
||
if row[9] == home_team_id
|
||
else "#7300FF" if row[9] == away_team_id else "#FFFFFF00"
|
||
),
|
||
}
|
||
for row in rows
|
||
]
|
||
|
||
|
||
@app.get("/vmix/session/{session_token}/schedule")
|
||
def vmix_schedule(session_token: str):
|
||
session_row = get_match_session_by_token(session_token)
|
||
if not session_row:
|
||
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
||
|
||
rows = get_vmix_schedule(session_row)
|
||
return [
|
||
{
|
||
"logo1": row[0],
|
||
"logo2": row[1],
|
||
"score": (
|
||
f"{row[2]} - {row[3]}"
|
||
if row[2] is not None and row[3] is not None
|
||
else " "
|
||
),
|
||
"date": format_date_ru(row[4]),
|
||
"time": (
|
||
row[4].strftime("%H:%M") if row[2] is None and row[3] is None else " "
|
||
),
|
||
"status": "ИДЁТ" if row[5] not in ["scheduled", "finished"] else " ",
|
||
"color": (
|
||
"#37F193" if row[5] not in ["scheduled", "finished"] else "#FFFFFF00"
|
||
),
|
||
}
|
||
for row in rows
|
||
]
|
||
|
||
|
||
EMPTY_PLAYER_FORMATION = {
|
||
"number": " ",
|
||
"number_lastname_amp_K": " ",
|
||
"position": " ",
|
||
"photo": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo\EMPTY.png",
|
||
}
|
||
|
||
|
||
def build_vmix_formation_response(rows, team_id, team_name):
|
||
result = []
|
||
|
||
for p in rows:
|
||
suffix = []
|
||
lastname = p[0] or ""
|
||
is_captain = p[1]
|
||
number = p[2]
|
||
position = p[3] or ""
|
||
|
||
if "вратарь" in position.lower():
|
||
suffix.append("ВР")
|
||
|
||
if is_captain:
|
||
suffix.append("К")
|
||
|
||
suffix_str = f' ({", ".join(suffix)})' if suffix else ""
|
||
|
||
result.append(
|
||
{
|
||
"number_lastname_amp_K": f"{number} {lastname}{suffix_str}".strip(),
|
||
"number": number,
|
||
"position": position,
|
||
"photo": (
|
||
r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo"
|
||
+ "\\"
|
||
+ team_name
|
||
+ "\\"
|
||
+ (p[0] + " " + p[4]).strip()
|
||
+ ".png"
|
||
),
|
||
}
|
||
)
|
||
result.extend([EMPTY_PLAYER_FORMATION.copy() for _ in range(11 - len(result))])
|
||
|
||
return result
|
||
|
||
|
||
@app.get("/vmix/session/{session_token}/home-formations")
|
||
def vmix_home_formations(session_token: str):
|
||
session_row = get_match_session_by_token(session_token)
|
||
if not session_row:
|
||
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
||
|
||
home_team_id = session_row[13]
|
||
home_team = session_row[14].replace("«", "").replace("»", "")
|
||
rows = get_vmix_team_formations(session_row, home_team_id)
|
||
return build_vmix_formation_response(rows, home_team_id, home_team)
|
||
|
||
|
||
@app.get("/vmix/session/{session_token}/away-formations")
|
||
def vmix_away_formations(session_token: str):
|
||
session_row = get_match_session_by_token(session_token)
|
||
if not session_row:
|
||
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
||
|
||
away_team_id = session_row[16]
|
||
away_team = session_row[17].replace("«", "").replace("»", "")
|
||
rows = get_vmix_team_formations(session_row, away_team_id)
|
||
return build_vmix_formation_response(rows, away_team_id, away_team)
|
||
|
||
|
||
@app.get("/vmix/session/{session_token}/scoreboard")
|
||
def vmix_scoreboard(session_token: str):
|
||
session_row = get_match_session_by_token(session_token)
|
||
if not session_row:
|
||
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
||
|
||
row = get_vmix_scoreboard_info(session_row)
|
||
if not row:
|
||
return JSONResponse({"error": "data_not_found"}, status_code=404)
|
||
return [
|
||
{
|
||
"home_score": row[0],
|
||
"away_score": row[1],
|
||
"red_home_1": row[2],
|
||
"red_home_2": row[3],
|
||
"red_home_3": row[4],
|
||
"red_home_4": row[5],
|
||
"red_away_1": row[6],
|
||
"red_away_2": row[7],
|
||
"red_away_3": row[8],
|
||
"red_away_4": row[9],
|
||
}
|
||
]
|
||
|
||
|
||
EMPTY_GOALS = {
|
||
"player_home": " ",
|
||
"player_away": " ",
|
||
}
|
||
|
||
|
||
@app.get("/vmix/session/{session_token}/match-events")
|
||
def vmix_match_events(session_token: str):
|
||
session_row = get_match_session_by_token(session_token)
|
||
if not session_row:
|
||
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
||
|
||
match_id = session_row[1]
|
||
events = get_vmix_players_goal(match_id)
|
||
new_events = [
|
||
{
|
||
"player_home": event[0],
|
||
"player_away": event[1],
|
||
}
|
||
for event in events
|
||
]
|
||
new_events.extend([EMPTY_GOALS.copy() for _ in range(12 - len(new_events))])
|
||
|
||
return new_events
|
||
|
||
@app.websocket("/ws/vmix-client")
|
||
async def ws_vmix_client(
|
||
websocket: WebSocket,
|
||
client_id: str = Query(...),
|
||
match_id: int | None = Query(default=None),
|
||
session_token: str | None = Query(default=None),
|
||
operator_name: str | None = Query(default=None),
|
||
group_name: str | None = Query(default=None),
|
||
):
|
||
await vmix_ws_manager.connect(
|
||
websocket=websocket,
|
||
client_id=client_id,
|
||
match_id=match_id,
|
||
session_token=session_token,
|
||
operator_name=operator_name,
|
||
group_name=group_name,
|
||
)
|
||
|
||
try:
|
||
await websocket.send_json({
|
||
"type": "connected",
|
||
"client_id": client_id,
|
||
"match_id": match_id,
|
||
"session_token": session_token,
|
||
"operator_name": operator_name,
|
||
})
|
||
|
||
while True:
|
||
msg = await websocket.receive_json()
|
||
|
||
msg_type = str(msg.get("type") or "").strip()
|
||
|
||
if msg_type == "ping":
|
||
await vmix_ws_manager.heartbeat(client_id)
|
||
await websocket.send_json({"type": "pong"})
|
||
continue
|
||
|
||
if msg_type == "register":
|
||
await vmix_ws_manager.update_meta(
|
||
client_id=client_id,
|
||
match_id=msg.get("match_id"),
|
||
session_token=msg.get("session_token"),
|
||
operator_name=msg.get("operator_name"),
|
||
group_name=msg.get("group_name"),
|
||
)
|
||
await websocket.send_json({"type": "registered", "client_id": client_id})
|
||
continue
|
||
|
||
if msg_type == "vmix_result":
|
||
# тут можно логировать delivery/result при желании
|
||
await vmix_ws_manager.heartbeat(client_id)
|
||
continue
|
||
|
||
except WebSocketDisconnect:
|
||
await vmix_ws_manager.disconnect(client_id)
|
||
except Exception:
|
||
await vmix_ws_manager.disconnect(client_id)
|
||
try:
|
||
await websocket.close()
|
||
except Exception:
|
||
pass
|
||
|
||
@app.post("/api/vmix/publish-command")
|
||
async def publish_vmix_command(payload: PublishVmixCommandPayload, request: Request):
|
||
commands = [str(x).strip() for x in (payload.commands or []) if str(x).strip()]
|
||
if not commands:
|
||
raise HTTPException(status_code=400, detail="commands is empty")
|
||
|
||
message = {
|
||
"type": "vmix_command",
|
||
"match_id": payload.match_id,
|
||
"commands": commands,
|
||
"target_login": payload.target_login,
|
||
"meta": payload.meta or {},
|
||
"sent_at": time.time(),
|
||
}
|
||
|
||
if payload.target_client_id:
|
||
ok = await vmix_ws_manager.send_to_client(payload.target_client_id, message)
|
||
results = [{"client_id": payload.target_client_id, "ok": ok}]
|
||
else:
|
||
results = await vmix_ws_manager.send_to_match(
|
||
match_id=payload.match_id,
|
||
message=message,
|
||
target_group=payload.target_group,
|
||
target_login=payload.target_login,
|
||
)
|
||
|
||
log_action(
|
||
request,
|
||
action="vmix_publish_command",
|
||
entity_type="match",
|
||
entity_id=payload.match_id,
|
||
match_id=payload.match_id,
|
||
session_token=payload.session_token,
|
||
details={
|
||
"target_client_id": payload.target_client_id,
|
||
"target_group": payload.target_group,
|
||
"target_login": payload.target_login,
|
||
"commands_count": len(commands),
|
||
"results": results,
|
||
},
|
||
)
|
||
|
||
return {
|
||
"success": True,
|
||
"delivered_to": results,
|
||
"commands_count": len(commands),
|
||
}
|
||
|
||
@app.get("/api/vmix/clients")
|
||
async def list_vmix_clients():
|
||
clients = await vmix_ws_manager.list_clients()
|
||
return {"items": clients} |