From 8d80fadb56d4e8598b7d8f6b8958d7a82f5c02fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AE=D1=80=D0=B8=D0=B9=20=D0=A7=D0=B5=D1=80=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE?= Date: Mon, 20 Apr 2026 11:56:11 +0300 Subject: [PATCH] first commit --- .gitignore | 2 + README_AUTH.md | 45 + agent.py | 165 + app.py | 1576 ++++++++ db.py | 37 + main.py | 96 + parsers/__init__.py | 0 parsers/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 128 bytes .../__pycache__/parser_game.cpython-312.pyc | Bin 0 -> 8657 bytes .../parser_players.cpython-312.pyc | Bin 0 -> 8017 bytes .../parser_schedule.cpython-312.pyc | Bin 0 -> 7454 bytes .../parser_standings.cpython-312.pyc | Bin 0 -> 3523 bytes .../__pycache__/parser_teams.cpython-312.pyc | Bin 0 -> 4180 bytes parsers/parser_game.py | 226 ++ parsers/parser_players.py | 174 + parsers/parser_schedule.py | 173 + parsers/parser_standings.py | 81 + parsers/parser_teams.py | 88 + repositories/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 133 bytes .../audit_log_repository.cpython-312.pyc | Bin 0 -> 1443 bytes .../auth_repository.cpython-312.pyc | Bin 0 -> 6547 bytes .../coach_repository.cpython-312.pyc | Bin 0 -> 9563 bytes .../match_clock_repository.cpython-312.pyc | Bin 0 -> 7633 bytes .../match_clock_repository.cpython-313.pyc | Bin 0 -> 7739 bytes .../match_coach_repository.cpython-312.pyc | Bin 0 -> 5182 bytes .../match_coach_repository.cpython-313.pyc | Bin 0 -> 2887 bytes .../match_event_repository.cpython-312.pyc | Bin 0 -> 4919 bytes .../match_event_repository.cpython-313.pyc | Bin 0 -> 8745 bytes ...match_formation_repository.cpython-312.pyc | Bin 0 -> 11935 bytes .../match_lineup_repository.cpython-312.pyc | Bin 0 -> 16803 bytes .../match_referee_repository.cpython-312.pyc | Bin 0 -> 4444 bytes .../match_repository.cpython-312.pyc | Bin 0 -> 9201 bytes .../match_session_repository.cpython-312.pyc | Bin 0 -> 8192 bytes .../match_view_repository.cpython-312.pyc | Bin 0 -> 4032 bytes .../player_repository.cpython-312.pyc | Bin 0 -> 13386 bytes .../referee_repository.cpython-312.pyc | Bin 0 -> 9288 bytes .../stadium_repository.cpython-312.pyc | Bin 0 -> 10041 bytes .../standings_repository.cpython-312.pyc | Bin 0 -> 5235 bytes .../team_coach_repository.cpython-312.pyc | Bin 0 -> 2153 bytes .../team_repository.cpython-312.pyc | Bin 0 -> 8603 bytes .../team_squad_repository.cpython-312.pyc | Bin 0 -> 2542 bytes repositories/audit_log_repository.py | 45 + repositories/auth_repository.py | 159 + repositories/coach_repository.py | 286 ++ repositories/match_clock_repository.py | 178 + repositories/match_coach_repository.py | 155 + repositories/match_event_repository.py | 184 + repositories/match_formation_repository.py | 369 ++ repositories/match_lineup_repository.py | 460 +++ repositories/match_referee_repository.py | 130 + repositories/match_repository.py | 269 ++ repositories/match_session_repository.py | 209 + repositories/match_view_repository.py | 96 + repositories/player_repository.py | 373 ++ repositories/referee_repository.py | 268 ++ repositories/stadium_repository.py | 291 ++ repositories/standings_repository.py | 153 + repositories/team_coach_repository.py | 50 + repositories/team_repository.py | 240 ++ repositories/team_squad_repository.py | 58 + .../__pycache__/create_admin.cpython-312.pyc | Bin 0 -> 2589 bytes .../__pycache__/create_admin.cpython-313.pyc | Bin 0 -> 603 bytes scripts/create_admin.py | 59 + services/__init__.py | 0 services/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 129 bytes .../__pycache__/auth_service.cpython-312.pyc | Bin 0 -> 5734 bytes .../__pycache__/game_service.cpython-312.pyc | Bin 0 -> 5277 bytes .../players_service.cpython-312.pyc | Bin 0 -> 2560 bytes .../schedule_service.cpython-312.pyc | Bin 0 -> 1139 bytes .../standings_service.cpython-312.pyc | Bin 0 -> 481 bytes .../__pycache__/teams_service.cpython-312.pyc | Bin 0 -> 932 bytes .../vmix_json_service.cpython-312.pyc | Bin 0 -> 17822 bytes services/auth_service.py | 155 + services/game_service.py | 211 + services/players_service.py | 46 + services/schedule_service.py | 19 + services/standings_service.py | 8 + services/teams_service.py | 14 + services/vmix_json_service.py | 424 ++ sql/001_auth.sql | 23 + static/script.js | 3579 +++++++++++++++++ static/styles.css | 2177 ++++++++++ templates/admin_db_coach_edit.html | 154 + templates/admin_db_coaches.html | 178 + templates/admin_db_index.html | 92 + templates/admin_db_player_edit.html | 226 ++ templates/admin_db_players.html | 186 + templates/admin_db_referee_edit.html | 55 + templates/admin_db_referees.html | 72 + templates/admin_db_stadium_edit.html | 163 + templates/admin_db_stadiums.html | 78 + templates/admin_db_team_edit.html | 103 + templates/admin_db_teams.html | 93 + templates/login.html | 110 + templates/match_workspace copy.html | 2908 ++++++++++++++ templates/match_workspace.html | 1180 ++++++ templates/matches.html | 554 +++ vmix/__init__.py | 0 vmix/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 125 bytes vmix/__pycache__/vmix_service.cpython-312.pyc | Bin 0 -> 6906 bytes vmix/vmix_service.py | 169 + wfl.sql | 84 + 103 files changed, 19756 insertions(+) create mode 100644 .gitignore create mode 100644 README_AUTH.md create mode 100644 agent.py create mode 100644 app.py create mode 100644 db.py create mode 100644 main.py create mode 100644 parsers/__init__.py create mode 100644 parsers/__pycache__/__init__.cpython-312.pyc create mode 100644 parsers/__pycache__/parser_game.cpython-312.pyc create mode 100644 parsers/__pycache__/parser_players.cpython-312.pyc create mode 100644 parsers/__pycache__/parser_schedule.cpython-312.pyc create mode 100644 parsers/__pycache__/parser_standings.cpython-312.pyc create mode 100644 parsers/__pycache__/parser_teams.cpython-312.pyc create mode 100644 parsers/parser_game.py create mode 100644 parsers/parser_players.py create mode 100644 parsers/parser_schedule.py create mode 100644 parsers/parser_standings.py create mode 100644 parsers/parser_teams.py create mode 100644 repositories/__init__.py create mode 100644 repositories/__pycache__/__init__.cpython-312.pyc create mode 100644 repositories/__pycache__/audit_log_repository.cpython-312.pyc create mode 100644 repositories/__pycache__/auth_repository.cpython-312.pyc create mode 100644 repositories/__pycache__/coach_repository.cpython-312.pyc create mode 100644 repositories/__pycache__/match_clock_repository.cpython-312.pyc create mode 100644 repositories/__pycache__/match_clock_repository.cpython-313.pyc create mode 100644 repositories/__pycache__/match_coach_repository.cpython-312.pyc create mode 100644 repositories/__pycache__/match_coach_repository.cpython-313.pyc create mode 100644 repositories/__pycache__/match_event_repository.cpython-312.pyc create mode 100644 repositories/__pycache__/match_event_repository.cpython-313.pyc create mode 100644 repositories/__pycache__/match_formation_repository.cpython-312.pyc create mode 100644 repositories/__pycache__/match_lineup_repository.cpython-312.pyc create mode 100644 repositories/__pycache__/match_referee_repository.cpython-312.pyc create mode 100644 repositories/__pycache__/match_repository.cpython-312.pyc create mode 100644 repositories/__pycache__/match_session_repository.cpython-312.pyc create mode 100644 repositories/__pycache__/match_view_repository.cpython-312.pyc create mode 100644 repositories/__pycache__/player_repository.cpython-312.pyc create mode 100644 repositories/__pycache__/referee_repository.cpython-312.pyc create mode 100644 repositories/__pycache__/stadium_repository.cpython-312.pyc create mode 100644 repositories/__pycache__/standings_repository.cpython-312.pyc create mode 100644 repositories/__pycache__/team_coach_repository.cpython-312.pyc create mode 100644 repositories/__pycache__/team_repository.cpython-312.pyc create mode 100644 repositories/__pycache__/team_squad_repository.cpython-312.pyc create mode 100644 repositories/audit_log_repository.py create mode 100644 repositories/auth_repository.py create mode 100644 repositories/coach_repository.py create mode 100644 repositories/match_clock_repository.py create mode 100644 repositories/match_coach_repository.py create mode 100644 repositories/match_event_repository.py create mode 100644 repositories/match_formation_repository.py create mode 100644 repositories/match_lineup_repository.py create mode 100644 repositories/match_referee_repository.py create mode 100644 repositories/match_repository.py create mode 100644 repositories/match_session_repository.py create mode 100644 repositories/match_view_repository.py create mode 100644 repositories/player_repository.py create mode 100644 repositories/referee_repository.py create mode 100644 repositories/stadium_repository.py create mode 100644 repositories/standings_repository.py create mode 100644 repositories/team_coach_repository.py create mode 100644 repositories/team_repository.py create mode 100644 repositories/team_squad_repository.py create mode 100644 scripts/__pycache__/create_admin.cpython-312.pyc create mode 100644 scripts/__pycache__/create_admin.cpython-313.pyc create mode 100644 scripts/create_admin.py create mode 100644 services/__init__.py create mode 100644 services/__pycache__/__init__.cpython-312.pyc create mode 100644 services/__pycache__/auth_service.cpython-312.pyc create mode 100644 services/__pycache__/game_service.cpython-312.pyc create mode 100644 services/__pycache__/players_service.cpython-312.pyc create mode 100644 services/__pycache__/schedule_service.cpython-312.pyc create mode 100644 services/__pycache__/standings_service.cpython-312.pyc create mode 100644 services/__pycache__/teams_service.cpython-312.pyc create mode 100644 services/__pycache__/vmix_json_service.cpython-312.pyc create mode 100644 services/auth_service.py create mode 100644 services/game_service.py create mode 100644 services/players_service.py create mode 100644 services/schedule_service.py create mode 100644 services/standings_service.py create mode 100644 services/teams_service.py create mode 100644 services/vmix_json_service.py create mode 100644 sql/001_auth.sql create mode 100644 static/script.js create mode 100644 static/styles.css create mode 100644 templates/admin_db_coach_edit.html create mode 100644 templates/admin_db_coaches.html create mode 100644 templates/admin_db_index.html create mode 100644 templates/admin_db_player_edit.html create mode 100644 templates/admin_db_players.html create mode 100644 templates/admin_db_referee_edit.html create mode 100644 templates/admin_db_referees.html create mode 100644 templates/admin_db_stadium_edit.html create mode 100644 templates/admin_db_stadiums.html create mode 100644 templates/admin_db_team_edit.html create mode 100644 templates/admin_db_teams.html create mode 100644 templates/login.html create mode 100644 templates/match_workspace copy.html create mode 100644 templates/match_workspace.html create mode 100644 templates/matches.html create mode 100644 vmix/__init__.py create mode 100644 vmix/__pycache__/__init__.cpython-312.pyc create mode 100644 vmix/__pycache__/vmix_service.cpython-312.pyc create mode 100644 vmix/vmix_service.py create mode 100644 wfl.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75803ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/__pycache__ +*.env \ No newline at end of file diff --git a/README_AUTH.md b/README_AUTH.md new file mode 100644 index 0000000..a8b52c6 --- /dev/null +++ b/README_AUTH.md @@ -0,0 +1,45 @@ +# WFL auth + idle session timeout + +Этот набор добавляет: +- страницу `/login` +- `HttpOnly` cookie `auth_token` +- серверную проверку всех `/admin/*` +- автоматический logout после 2 часов бездействия +- rolling session timeout +- logout кнопку +- обработку `401` для AJAX/fetch + +## Что заменить в проекте + +Скопируй файлы в свой проект: +- `app.py` -> заменить текущий +- `repositories/auth_repository.py` -> новый файл +- `services/auth_service.py` -> новый файл +- `templates/login.html` -> новый файл +- `templates/matches.html` -> заменить +- `templates/match_workspace.html` -> заменить + +## SQL + +Выполни файл: +- `sql/001_auth.sql` + +## Первый админ + +1. Запусти: + `python scripts/create_admin.py` +2. Скопируй выведенный SQL +3. Выполни его в PostgreSQL + +## Важно + +В `app.py` cookie сейчас создаётся так: +- `secure=False` + +Для production под HTTPS поменяй на: +- `secure=True` + +## Что не трогалось + +Твои `match_sessions` оставлены как рабочие сессии матча. +Пользовательская авторизация вынесена отдельно в `admin_users` и `auth_sessions`. diff --git a/agent.py b/agent.py new file mode 100644 index 0000000..c69c7b3 --- /dev/null +++ b/agent.py @@ -0,0 +1,165 @@ +import asyncio +import json +import time +import socket +import uuid +from urllib.parse import urlencode +from xml.etree import ElementTree as ET + +import requests +import websockets + +VMIX_API = "http://127.0.0.1:8088/api" +# WS_BASE = "wss://hostname.tvstart.ru/ws/vmix-client" +WS_BASE = "ws://127.0.0.1:8000/ws/vmix-client" + +CLIENT_ID = f"vmix-{socket.gethostname().lower()}-{uuid.uuid4().hex[:6]}" +POLL_INTERVAL = 3 +REQUEST_TIMEOUT = 3 +RECONNECT_DELAY = 5 + +def read_vmix_dynamic_values(): + try: + resp = requests.get(VMIX_API, timeout=REQUEST_TIMEOUT) + resp.raise_for_status() + xml_text = resp.text + + root = ET.fromstring(xml_text) + + def get_text(path: str) -> str | None: + node = root.find(path) + if node is None or node.text is None: + return None + value = node.text.strip() + return value or None + + session_token = get_text("./dynamic/value1") + match_id = get_text("./dynamic/value2") + group_name = get_text("./dynamic/value3") + + return { + "ok": True, + "session_token": session_token, + "match_id": int(match_id) if match_id and match_id.isdigit() else None, + "group_name": group_name, + } + except Exception as e: + return { + "ok": False, + "error": str(e), + "session_token": None, + "match_id": None, + "group_name": None, + } + +def execute_vmix_command(path: str): + path = str(path or "").strip() + if not path.startswith("/api/"): + return {"ok": False, "error": "invalid command", "path": path} + + url = f"http://127.0.0.1:8088{path}" + try: + resp = requests.get(url, timeout=REQUEST_TIMEOUT) + return { + "ok": resp.ok, + "status_code": resp.status_code, + "path": path, + "response": resp.text[:300], + } + except Exception as e: + return { + "ok": False, + "path": path, + "error": str(e), + } + +async def wait_for_session(): + while True: + data = read_vmix_dynamic_values() + if data["ok"] and data["session_token"]: + return data + + print("[agent] waiting for vMix session...", data) + await asyncio.sleep(POLL_INTERVAL) + +def build_ws_url(session_token: str, match_id: int | None, group_name: str | None): + params = { + "client_id": CLIENT_ID, + "session_token": session_token, + } + if match_id is not None: + params["match_id"] = match_id + if group_name: + params["group_name"] = group_name + return f"{WS_BASE}?{urlencode(params)}" + +async def ping_loop(ws): + while True: + await asyncio.sleep(15) + await ws.send(json.dumps({"type": "ping"})) + +async def run_agent(): + current_session = None + + while True: + vmix_data = await wait_for_session() + + session_token = vmix_data["session_token"] + match_id = vmix_data["match_id"] + group_name = vmix_data["group_name"] + + if current_session != session_token: + print(f"[agent] found session: {session_token}") + current_session = session_token + + ws_url = build_ws_url(session_token, match_id, group_name) + + try: + async with websockets.connect(ws_url, ping_interval=None, max_size=2**20) as ws: + print("[agent] connected to server") + + await ws.send(json.dumps({ + "type": "register", + "client_id": CLIENT_ID, + "session_token": session_token, + "match_id": match_id, + "group_name": group_name, + })) + + pinger = asyncio.create_task(ping_loop(ws)) + + try: + while True: + # следим, не сменился ли проект/session в vMix + latest = read_vmix_dynamic_values() + if latest["ok"] and latest["session_token"] and latest["session_token"] != current_session: + print("[agent] session changed in vMix, reconnecting") + break + + try: + raw = await asyncio.wait_for(ws.recv(), timeout=3) + except asyncio.TimeoutError: + continue + + msg = json.loads(raw) + if msg.get("type") == "vmix_command": + print("[agent] got commands:", msg.get("commands", [])) + results = [execute_vmix_command(cmd) for cmd in msg.get("commands", [])] + print("[agent] results:", results) + await ws.send(json.dumps({ + "type": "vmix_result", + "client_id": CLIENT_ID, + "session_token": session_token, + "results": results, + "sent_at": time.time(), + })) + finally: + pinger.cancel() + + except Exception as e: + print("[agent] websocket error:", e) + + await asyncio.sleep(RECONNECT_DELAY) + +if __name__ == "__main__": + asyncio.run(run_agent()) \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..1618ce8 --- /dev/null +++ b/app.py @@ -0,0 +1,1576 @@ +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, +) + +app = FastAPI() +templates = Jinja2Templates(directory="templates") +app.mount("/static", StaticFiles(directory="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 + 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, + session_token: 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 session_token and conn.get("session_token") != session_token: + 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 + + +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( + "login.html", + { + "request": request, + "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( + "login.html", + { + "request": request, + "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), +): + matches = list_matches_for_admin(today_only=today_only, tour=tour) + tours = list_available_tours() + + return templates.TemplateResponse( + "matches.html", + { + "request": request, + "matches": matches, + "today_only": today_only, + "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=operator_name.strip() or 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": operator_name.strip() or None, + }, + ) + + return JSONResponse({ + "success": True, + "session_token": session_token, + "session_url": f"/admin/session/{session_token}", + "download_url": f"/admin/session/{session_token}/download-vmix", + }) + + +@app.get("/admin/session/{session_token}/download-vmix") +def download_vmix_project(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) + + try: + vmix_bytes = build_vmix_project_bytes(session_token=session_token, match_id=session_row[1]) + 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( + "match_workspace.html", + { + "request": request, + "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(session_token: str): + 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] + match_external_id = session_row[8] + + run_parser_game(str(match_external_id)) + + # сброс ручного состава и тренеров, чтобы подтянулись свежие данные парсера + 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() + + return RedirectResponse(url=f"/admin/session/{session_token}", 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( + "admin_db_index.html", + { + "request": request, + "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( + "admin_db_players.html", + { + "request": request, + "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( + "admin_db_player_edit.html", + { + "request": request, + "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( + "admin_db_referees.html", + { + "request": request, + "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( + "admin_db_referee_edit.html", + { + "request": request, + "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( + "admin_db_coaches.html", + { + "request": request, + "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( + "admin_db_coach_edit.html", + { + "request": request, + "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( + "admin_db_stadiums.html", + { + "request": request, + "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( + "admin_db_stadium_edit.html", + { + "request": request, + "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( + "admin_db_teams.html", + { + "request": request, + "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( + "admin_db_team_edit.html", + { + "request": request, + "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 ""}', + "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": " ", +} + + +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, + }) + + 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, + "session_token": payload.session_token, + "commands": commands, + "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, + session_token=payload.session_token, + ) + + 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, + "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} \ No newline at end of file diff --git a/db.py b/db.py new file mode 100644 index 0000000..690f797 --- /dev/null +++ b/db.py @@ -0,0 +1,37 @@ +import psycopg2 +from psycopg2.extras import RealDictCursor +from dotenv import load_dotenv +import os + +load_dotenv() + +def get_connection(): + # return psycopg2.connect( + # host="localhost", + # port=5432, + # dbname="wfl_db", + # user="postgres", + # password="159753" + # ) + return psycopg2.connect( + host=os.getenv("DB_HOST"), + port=os.getenv("DB_PORT"), + dbname=os.getenv("DB_NAME"), + user=os.getenv("DB_USER"), + password=os.getenv("DB_PASSWORD") + ) + + +def test_connection(): + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute("SELECT current_database() AS db_name;") + row = cur.fetchone() + print(f"Connected to database: {row['db_name']}") + finally: + conn.close() + + +if __name__ == "__main__": + test_connection() \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..52a7197 --- /dev/null +++ b/main.py @@ -0,0 +1,96 @@ +from services.teams_service import sync_teams +from services.schedule_service import sync_matches +from services.standings_service import sync_standings +from services.players_service import sync_players + + +def run_demo(): + sync_teams([ + { + "external_id": "team_001", + "name": "West Football Club", + "short_name": "WFC", + "logo_url": None, + }, + { + "external_id": "team_002", + "name": "East Football Club", + "short_name": "EFC", + "logo_url": None, + }, + ]) + + sync_matches([ + { + "external_id": "match_100", + "home_team_external_id": "team_001", + "away_team_external_id": "team_002", + "match_date": "2026-03-18 20:00:00", + "status": "scheduled", + "home_score": None, + "away_score": None, + "tour": "1", + "season": "2025/2026", + } + ]) + + sync_players([ + { + "external_id": "player_001", + "team_external_id": "team_001", + "full_name": "John Smith", + "first_name": "John", + "last_name": "Smith", + "number": "10", + "position": "FW", + "birth_date": "2000-05-12", + "height_cm": 182, + "weight_kg": 76, + "is_active": True, + }, + { + "external_id": "player_002", + "team_external_id": "team_002", + "full_name": "Mike Brown", + "first_name": "Mike", + "last_name": "Brown", + "number": "8", + "position": "MF", + "birth_date": "1999-09-20", + "height_cm": 178, + "weight_kg": 73, + "is_active": True, + }, + ]) + + sync_standings( + season="2025/2026", + standings_rows=[ + { + "team_external_id": "team_001", + "played": 10, + "wins": 7, + "losses": 2, + "draws": 1, + "points_for": 22, + "points_against": 11, + "points": 22, + "position": 1, + }, + { + "team_external_id": "team_002", + "played": 10, + "wins": 6, + "losses": 3, + "draws": 1, + "points_for": 19, + "points_against": 12, + "points": 19, + "position": 2, + }, + ], + ) + + +if __name__ == "__main__": + run_demo() \ No newline at end of file diff --git a/parsers/__init__.py b/parsers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/parsers/__pycache__/__init__.cpython-312.pyc b/parsers/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6bc10a526b00047d58f2a7f1d73325ee119c49b6 GIT binary patch literal 128 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 literal 0 HcmV?d00001 diff --git a/parsers/__pycache__/parser_players.cpython-312.pyc b/parsers/__pycache__/parser_players.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f0302a6e68c7b6f28d9ac922f60027edb3e33163 GIT binary patch literal 8017 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 hSbKBVlwrkpTqs#aFn{{c5phWP*h literal 0 HcmV?d00001 diff --git a/parsers/__pycache__/parser_standings.cpython-312.pyc b/parsers/__pycache__/parser_standings.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dfdf845358f3d62329381e00b731577d9452e4bc GIT binary patch literal 3523 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 literal 0 HcmV?d00001 diff --git a/parsers/parser_game.py b/parsers/parser_game.py new file mode 100644 index 0000000..57bfe15 --- /dev/null +++ b/parsers/parser_game.py @@ -0,0 +1,226 @@ +import requests +from bs4 import BeautifulSoup + +from services.game_service import sync_match_page + +BASE_MATCH_URL = "https://wfl.rfs.ru/match/" + + +def fetch_html(url: str) -> str: + headers = {"User-Agent": "Mozilla/5.0", "Accept": "*/*"} + r = requests.get(url, headers=headers, timeout=20) + r.raise_for_status() + return r.text + + +def extract_player_id_from_href(href: str) -> str: + if not href: + return "" + return href.rstrip("/").split("/")[-1].strip() + + +def detect_captain(item) -> bool: + if not item: + return False + + text = item.get_text(" ", strip=True).lower() + + return any(x in text for x in ["(к)", "(c)"]) + +def parse_starting_teams(soup: BeautifulSoup) -> tuple[list[dict], list[dict]]: + home_starting = [] + away_starting = [] + + start_teams = soup.select("div.protocol__block--main div.protocol__unit") + if len(start_teams) < 2: + return home_starting, away_starting + + def parse_protocol_unit(unit): + bench = [] + + items = unit.select("ul.protocol__list li.protocol__item") + for item in items: + link = item.select_one("a.protocol__link") + if not link: + continue + + href = link.get("href", "") + player_id = extract_player_id_from_href(href) + + name_el = link.select_one("div.protocol__name") + role_el = link.select_one("div.protocol__role") + num_el = item.select_one("span.protocol__number-text") + # cap_el = link.select_one("div.protocol__captain") + + bench.append( + { + "player_external_id": player_id, + "player_name": name_el.get_text(strip=True) if name_el else "", + "number": num_el.get_text(strip=True) if num_el else "", + "position": role_el.get_text(strip=True) if role_el else "", + "is_captain": detect_captain(item), + } + ) + + return bench + + home_starting = parse_protocol_unit(start_teams[0]) + away_starting = parse_protocol_unit(start_teams[1]) + + return home_starting, away_starting + + +def parse_bench(soup: BeautifulSoup) -> tuple[list[dict], list[dict]]: + home_bench = [] + away_bench = [] + + units = soup.select("div.protocol__block--additional div.protocol__unit") + if len(units) < 2: + return home_bench, away_bench + + def parse_protocol_unit(unit): + bench = [] + + items = unit.select("ul.protocol__list li.protocol__item") + for item in items: + link = item.select_one("a.protocol__link") + if not link: + continue + + href = link.get("href", "") + player_id = extract_player_id_from_href(href) + + name_el = link.select_one("div.protocol__name") + role_el = link.select_one("div.protocol__role") + num_el = item.select_one("span.protocol__number-text") + # cap_el = link.select_one("div.protocol__captain") + + + bench.append( + { + "player_external_id": player_id, + "player_name": name_el.get_text(strip=True) if name_el else "", + "number": num_el.get_text(strip=True) if num_el else "", + "position": role_el.get_text(strip=True) if role_el else "", + "is_captain": detect_captain(item), + } + ) + + return bench + + home_bench = parse_protocol_unit(units[0]) + away_bench = parse_protocol_unit(units[1]) + + return home_bench, away_bench + + +def parse_coaches(soup: BeautifulSoup) -> tuple[list[dict], list[dict]]: + home_coaches = [] + away_coaches = [] + + units = soup.select("div.protocol__block--staff div.protocol__unit") + if len(units) < 2: + return home_coaches, away_coaches + + def parse_staff_unit(unit): + coaches = [] + + items = unit.select("ul.protocol__list li.protocol__item a.protocol__link") + for link in items: + href = link.get("href", "") + coach_id = extract_player_id_from_href(href) + + name_el = link.select_one("div.protocol__name") + role_el = link.select_one("div.protocol__staff-position") + + coaches.append( + { + "coach_external_id": coach_id, + "coach_name": name_el.get_text(strip=True) if name_el else "", + "role": role_el.get_text(strip=True) if role_el else "", + } + ) + + return coaches + + home_coaches = parse_staff_unit(units[0]) + away_coaches = parse_staff_unit(units[1]) + + return home_coaches, away_coaches + + +def parse_referees(soup: BeautifulSoup) -> list[dict]: + referees = [] + + nodes = soup.select("div.protocol__block--referees div.referee") + for node in nodes: + role_el = node.select_one("p.referee__position") + first_el = node.select_one("span.referee__name") + last_el = node.select_one("span.referee__last-name") + + first = first_el.get_text(strip=True) if first_el else "" + last = last_el.get_text(strip=True).split("(")[0] if last_el else "" + full_name = f"{first} {last}".strip() + + if not full_name: + continue + + referees.append( + { + "referee_name": full_name, + "role": ( + role_el.get_text(strip=True).replace(":", "") if role_el else "" + ), + } + ) + + return referees + + +def parse_game_page(html: str) -> dict: + soup = BeautifulSoup(html, "html.parser") + + home_starting, away_starting = parse_starting_teams(soup) + home_bench, away_bench = parse_bench(soup) + home_coaches, away_coaches = parse_coaches(soup) + referees = parse_referees(soup) + + return { + "home_starting": home_starting, + "away_starting": away_starting, + "home_bench": home_bench, + "away_bench": away_bench, + "home_coaches": home_coaches, + "away_coaches": away_coaches, + "referees": referees, + } + + +def run_parser_game(match_external_id: str) -> None: + url = f"{BASE_MATCH_URL}{str(match_external_id).strip()}" + html = fetch_html(url) + data = parse_game_page(html) + + sync_match_page( + match_external_id=str(match_external_id).strip(), + home_starting=data["home_starting"], + away_starting=data["away_starting"], + home_bench=data["home_bench"], + away_bench=data["away_bench"], + home_coaches=data["home_coaches"], + away_coaches=data["away_coaches"], + referees=data["referees"], + ) + + print( + f"[parser_game] match={match_external_id} " + f"home_starting={len(data['home_starting'])} " + f"away_starting={len(data['away_starting'])} " + f"home_bench={len(data['home_bench'])} " + f"away_bench={len(data['away_bench'])} " + f"home_coaches={len(data['home_coaches'])} " + f"away_coaches={len(data['away_coaches'])} " + f"referees={len(data['referees'])}" + ) + + diff --git a/parsers/parser_players.py b/parsers/parser_players.py new file mode 100644 index 0000000..3f4073a --- /dev/null +++ b/parsers/parser_players.py @@ -0,0 +1,174 @@ +import requests +from bs4 import BeautifulSoup +from concurrent.futures import ThreadPoolExecutor + +from services.players_service import sync_team_roster + + +URL_TEAMS = "https://wfl.rfs.ru/tournament/1061879/teams" + +AMPLUA_FULL = { + "Пз.": "Полузащитник", + "Вр.": "Вратарь", + "Зщ.": "Защитник", + "Нп.": "Нападающий", + "": "", +} + + +def fetch_html(url: str) -> str: + headers = {"User-Agent": "Mozilla/5.0", "Accept": "*/*"} + r = requests.get(url, headers=headers, timeout=20) + r.raise_for_status() + return r.text + + +def get_links(html: str) -> list[dict]: + soup = BeautifulSoup(html, "html.parser") + links: list[dict] = [] + + items = soup.find("ul", class_="teams__list").find_all("li") + for i in items: + href = i.find("a", class_="teams__link").get("href") + team_external_id = href.split("team_id=")[-1].strip() + + links.append( + { + "team_external_id": team_external_id, + "url": "https://wfl.rfs.ru" + href, + } + ) + + return links + + +def parse_team(html: str) -> dict: + soup = BeautifulSoup(html, "html.parser") + + team_el = soup.find("a", class_="team-promo__team-name") + team_name = team_el.get_text(strip=True) if team_el else None + + tabs = soup.find("div", class_="tabs__content") + tbody = tabs.find("tbody") if tabs else None + row_players = tbody.find_all("tr", class_="table__row") if tbody else [] + + coaches_root = soup.find("ul", class_="composition-list") + row_coaches = ( + coaches_root.find_all("div", class_="composition-list__item-back") + if coaches_root + else [] + ) + + players = [] + for row in row_players: + player_td = row.find("td", class_="table__cell table__cell--player") + player_a = player_td.find("a") if player_td else None + href = player_a.get("href") if player_a else None + player_id = href.split("/")[-1] if href else None + + vars_td = row.find_all("td", class_="table__cell table__cell--variable") + + games = vars_td[0].get_text(strip=True) if len(vars_td) > 0 else "0" + + goals_raw = vars_td[1].get_text(strip=True) if len(vars_td) > 1 else "" + goals_parts = goals_raw.split() + goals = goals_parts[0] if len(goals_parts) > 0 else "0" + penaltys = ( + goals_parts[1].replace("(", "").replace(")", "") + if len(goals_parts) > 1 + else "0" + ) + + assists = vars_td[2].get_text(strip=True) if len(vars_td) > 2 else "0" + yellows = vars_td[3].get_text(strip=True) if len(vars_td) > 3 else "0" + reds = vars_td[4].get_text(strip=True) if len(vars_td) > 4 else "0" + + number_td = row.find("td", class_="table__cell table__cell--number") + pos_td = row.find( + "td", class_="table__cell table__cell--amplua table__cell--amplua" + ) + name_p = row.find("p", class_="table__player-name") + born_td = row.find("td", class_="table__cell table__cell--middle table__cell--birth mobile-hide") + + full_player = name_p.get_text(strip=True) if name_p else "" + parts = full_player.split() + + players.append( + { + "player_id": player_id or "", + "number": number_td.get_text(strip=True) if number_td else "", + "pos": pos_td.get_text(strip=True) if pos_td else "", + "amplua": AMPLUA_FULL[pos_td.get_text(strip=True) if pos_td else ""], + "player": full_player, + "lastname": parts[0] if len(parts) >= 1 else "", + "name": parts[-1] if len(parts) >= 2 else "", + "born": born_td.get_text(strip=True).split(",")[0] if born_td else "", + "games": games, + "goals": goals, + "penaltys": penaltys, + "assists": assists, + "yellows": yellows, + "reds": reds, + } + ) + + coaches = [] + for coach in row_coaches: + coach_link = coach.find("a", class_="composition-list__player") + href = coach_link.get("href") if coach_link else "" + coach_id = href.split("/")[-1] if href else "" + + name = coach.find("span", class_="composition-list__player-first-name") + lastname = coach.find("span", class_="composition-list__player-last-name") + born = coach.find("span", class_="composition-list__player-birth-date") + amplua = coach.find("span", class_="composition-list__player-games-text") + + first_name = name.get_text(strip=True) if name else "" + last_name = lastname.get_text(strip=True) if lastname else "" + + coaches.append( + { + "coach_id": coach_id, + "name": first_name, + "lastname": last_name, + "player": f"{last_name} {first_name}".strip(), + "born": born.get_text(strip=True).replace(",", "") if born else "", + "amplua": amplua.get_text(strip=True) if amplua else "", + } + ) + + return { + "team_name": team_name, + "players": players, + "coaches": coaches, + } + + +def run_parser_players() -> None: + html = fetch_html(URL_TEAMS) + links = get_links(html) + + with ThreadPoolExecutor(max_workers=8) as pool: + futures = {pool.submit(fetch_html, item["url"]): item for item in links} + + for future, item in futures.items(): + try: + html = future.result() + team_data = parse_team(html) + + sync_team_roster( + team_external_id=item["team_external_id"], + team_data=team_data, + ) + + print( + f"[parser_players] team={item['team_external_id']} " + f"players={len(team_data.get('players') or [])} " + f"coaches={len(team_data.get('coaches') or [])}" + ) + except Exception as e: + print(f"[parser_players] error team={item['team_external_id']}: {e}") + + +if __name__ == "__main__": + run_parser_players() diff --git a/parsers/parser_schedule.py b/parsers/parser_schedule.py new file mode 100644 index 0000000..3a9e7a7 --- /dev/null +++ b/parsers/parser_schedule.py @@ -0,0 +1,173 @@ +import requests +from bs4 import BeautifulSoup +from datetime import datetime +from zoneinfo import ZoneInfo + +from services.schedule_service import sync_matches +from repositories.team_repository import get_team_external_id_by_name, get_team_id_by_external_id + + +MONTHS_RU = { + "янв.": 1, + "февр.": 2, + "мар.": 3, + "апр.": 4, + "мая": 5, + "июн.": 6, + "июл.": 7, + "авг.": 8, + "сент.": 9, + "окт.": 10, + "нояб.": 11, + "дек.": 12, +} + +TZ = ZoneInfo("Europe/Moscow") + +URL_SCHEDULE = ( + "https://wfl.rfs.ru/tournament/1061879/calendar?round_id=1117550&type=tours" +) + +SEASON = "2025/2026" + + +def parse_russian_date(date_str: str, year: int | None = None) -> datetime: + # пример: "07 мар., пт, 13:00" + if year is None: + year = datetime.now(TZ).year + + parts = [p.strip() for p in date_str.split(",")] + if len(parts) < 3: + raise ValueError(f"Некорректный формат даты: {date_str}") + + day_month = parts[0] + time_part = parts[2] + + day, month_str = day_month.split() + month = MONTHS_RU[month_str] + + hh, mm = time_part.split(":") + return datetime(year, month, int(day), int(hh), int(mm), tzinfo=TZ) + + +def fetch_html(url: str) -> str: + headers = {"User-Agent": "Mozilla/5.0", "Accept": "*/*"} + response = requests.get(url, headers=headers, timeout=20) + response.raise_for_status() + return response.text + + +def normalize_status(score1: str | None, score2: str | None, dt: datetime) -> str: + now = datetime.now(TZ) + + if score1 is not None and score2 is not None: + return "finished" + + if dt <= now: + return "live" + + return "scheduled" + + +def safe_int(value: str | None) -> int | None: + if value is None: + return None + value = value.strip() + return int(value) if value.isdigit() else None + + +def parse_schedule(html: str) -> list[dict]: + soup = BeautifulSoup(html, "html.parser") + matches_data: list[dict] = [] + + table_div = soup.find("div", class_="timetable__main") + if not table_div: + return matches_data + + rows = table_div.find_all("div", class_="timetable__unit js-schedule-games-cont") + + for row in rows: + tour_el = row.find("span", class_="timetable__head-text") + tour = tour_el.get_text(strip=True) if tour_el else None + + matches = row.find_all("li", class_="timetable__item") + for match in matches: + score_link = match.find("a", class_="timetable__score") + href = score_link.get("href") if score_link else "" + match_id = href.split("/")[-1] if href else None + + time_el = match.find("span", class_="timetable__time") + time_site = time_el.get_text(strip=True) if time_el else None + if not time_site or not match_id: + continue + + try: + dt = parse_russian_date(time_site) + except Exception as exc: + print(f"[parser_schedule] Ошибка даты для матча {match_id}: {exc}") + continue + + teams = match.find_all("div", class_="timetable__team-name") + team1_name = teams[0].get_text(strip=True) if len(teams) > 0 else None + team2_name = teams[1].get_text(strip=True) if len(teams) > 1 else None + + if not team1_name or not team2_name: + print(f"[parser_schedule] Пропуск матча {match_id}: нет названий команд") + continue + + home_team_external_id = get_team_external_id_by_name(team1_name) + away_team_external_id = get_team_external_id_by_name(team2_name) + + if not home_team_external_id or not away_team_external_id: + print( + f"[parser_schedule] Пропуск матча {match_id}: " + f"не найдены команды в БД ({team1_name} vs {team2_name})" + ) + continue + + score1 = score2 = None + score_main_el = match.find("div", class_="timetable__score-main") + if score_main_el: + score_main = score_main_el.get_text(strip=True) + if "-" in score_main and "- : -" not in score_main: + parts = [s.strip() for s in score_main.split("-")] + if len(parts) == 2: + score1, score2 = parts[0], parts[1] + + home_score = safe_int(score1) + away_score = safe_int(score2) + status = normalize_status(home_score, away_score, dt) + place_el = match.find("span", class_="timetable__place-name") + place = place_el.get_text(strip=True) if place_el else None + score_add_el = match.find("div", class_="timetable__score-additional") + score_add = score_add_el.get_text(strip=True) if score_add_el else None + matches_data.append( + { + "external_id": str(match_id), + "home_team_external_id": str(home_team_external_id), + "away_team_external_id": str(away_team_external_id), + "match_date": dt.strftime("%Y-%m-%d %H:%M:%S"), + "status": status, + "home_score": home_score, + "away_score": away_score, + "tour": tour, + "season": SEASON, + "place": place, + "date_raw": time_site, + "score_add": score_add, + } + ) + + matches_data.sort(key=lambda x: x["match_date"] or "") + return matches_data + + +def run_parser_schedule() -> None: + html = fetch_html(URL_SCHEDULE) + matches_data = parse_schedule(html) + sync_matches(matches_data) + print(f"[parser_schedule] Matches synced: {len(matches_data)}") + + +if __name__ == "__main__": + run_parser_schedule() \ No newline at end of file diff --git a/parsers/parser_standings.py b/parsers/parser_standings.py new file mode 100644 index 0000000..41ac1ea --- /dev/null +++ b/parsers/parser_standings.py @@ -0,0 +1,81 @@ +import requests +from bs4 import BeautifulSoup + +from services.standings_service import sync_standings + +URL_STANDINGS = "https://wfl.rfs.ru/tournament/1061879/tables" +SEASON = "2025/2026" + + +def fetch_html(url: str) -> str: + headers = {"User-Agent": "Mozilla/5.0", "Accept": "*/*"} + r = requests.get(url, headers=headers, timeout=20) + r.raise_for_status() + return r.text + + +def parse_standings(html: str) -> list[dict]: + soup = BeautifulSoup(html, "html.parser") + standings: list[dict] = [] + + table_div = soup.find("ul", class_="custom-table__body") + if not table_div: + return standings + + rows = table_div.find_all("li", class_="custom-table__line") + + for row in rows: + rank = row.find("div", class_="custom-table__number-wrapper").get_text(strip=True) + + team_name = row.find("div", class_="custom-table__team-name").get_text(strip=True) + + team_external_id = ( + row.find("a", class_="custom-table__team custom-table__cell") + .get("href") + .split("?team_id=")[-1] + ) + + vars_td = row.find_all("div", class_="custom-table__content")[1:] + + games = int(vars_td[0].get_text(strip=True)) + wins = int(vars_td[1].get_text(strip=True)) + draws = int(vars_td[2].get_text(strip=True)) + losses = int(vars_td[3].get_text(strip=True)) + + plus_minus = vars_td[4].get_text(strip=True) + gf = int(plus_minus.split("-")[0].strip()) + ga = int(plus_minus.split("-")[1].strip()) + + points = int(vars_td[5].get_text(strip=True)) + + standings.append( + { + "team_external_id": str(team_external_id), + "played": games, + "wins": wins, + "losses": losses, + "draws": draws, + "points_for": gf, + "points_against": ga, + "points": points, + "position": int(rank), + } + ) + + return standings + + +def run_parser_standings() -> None: + html = fetch_html(URL_STANDINGS) + standings_rows = parse_standings(html) + + sync_standings( + season=SEASON, + standings_rows=standings_rows, + ) + + print(f"[parser_standings] Synced rows: {len(standings_rows)}") + + +if __name__ == "__main__": + run_parser_standings() \ No newline at end of file diff --git a/parsers/parser_teams.py b/parsers/parser_teams.py new file mode 100644 index 0000000..1abae50 --- /dev/null +++ b/parsers/parser_teams.py @@ -0,0 +1,88 @@ +import requests +from bs4 import BeautifulSoup +from concurrent.futures import ThreadPoolExecutor + +from services.teams_service import sync_teams + + +TEAMS_URL = "https://wfl.rfs.ru/tournament/1061879/teams" + + +def fetch_html(url: str) -> str: + headers = {"User-Agent": "Mozilla/5.0", "Accept": "*/*"} + response = requests.get(url, timeout=30, headers=headers) + response.raise_for_status() + return response.text + + +def get_links(html) -> list: + soup = BeautifulSoup(html, "html.parser") + links: list[dict] = [] + items = soup.find("ul", class_="teams__list").find_all("li") + for i in items: + links.append( + "https://wfl.rfs.ru/team/" + + i.find("a", class_="teams__link").get("href").split("team_id=")[-1] + ) + return links + + +def get_url_teams() -> list[dict]: + html = fetch_html(TEAMS_URL) + links = get_links(html) + teams_data: list[dict] = [] + + with ThreadPoolExecutor() as pool: + responses = [ + pool.submit( + fetch_html, + link, + ) + for link in links + ] + for result in responses: + try: + html = result.result() + team_data = parse_teams_html(html) + teams_data.append(team_data) + except Exception as e: + print(f"Error fetching team data: {e}") + + return teams_data + + +def parse_teams_html(html: str) -> dict: + soup = BeautifulSoup(html, "html.parser") + teams_data: dict = {} + name = soup.find("a", class_="team-promo__team-name").text.strip() + external_id = soup.find("a", class_="team-promo__logo").get("href").split("/")[-1] + stat_info = soup.find("ul", class_="stats-info").find_all( + "div", class_="stats-info__number" + ) + logo_url = soup.find("img", class_="team-promo__img").get("src") + games = stat_info[0].text.strip() + wins = stat_info[1].text.strip() + goals = stat_info[2].text.strip() + tournaments = stat_info[3].text.strip() + + teams_data = { + "external_id": str(external_id), + "name": name, + "logo_url": logo_url, + "games": games, + "wins": wins, + "goals": goals, + "tournaments": tournaments, + } + + return teams_data + + +def run_parser_teams() -> None: + teams_data = get_url_teams() + sync_teams(teams_data) + print(f"Teams synced: {len(teams_data)}") + + +if __name__ == "__main__": + run_parser_teams() diff --git a/repositories/__init__.py b/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/repositories/__pycache__/__init__.cpython-312.pyc b/repositories/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f6d1eb51450a925ab5065f85c383a5369cbc0e5f GIT binary patch literal 133 zcmX@j%ge<80`i07a%VR5EBX`mJOrVgm9$gVg*|akh$a&QD2=33u~} zDM~HKFU~BLp`WA?9SZ2 zwQP`Ggi1aa8wV)Bfe?s6B9MY)kP6a4#!tn0i|N?PP?yGz<`UY!hrEZVj{_o<8e_yIurzBxI$NZrPKZ@AOY#aYszEsq!`wH&9E^ms3_q}B4M>#-DBCRWQQ zEWuHjWVh-~$|S4iQc_Y`s>>lubAxlp5}ZSp+~SZaJ2+&SZ4OyF=8CDGaK+@0TroL9 zkGNu~E>|qUKR71x^QmysPa0wK@TtkUQ?5j8? zXL-c*q58OEygh)*zczXw)OEBmgt9}gl;P{Yc7!3R$kcq8FRl#V9lkg6a4gIXtSEPt zj*u*BogCVuhZ>B%p*9+7W1+S$au0;sei;90dmqUE$`2g>)I2RHJ? zeBqsl3O|7AD|CKD8TGdOk9;%x?g#|$jBG~nzh~a>xV1@tfy3PpD*fnIz9NbbCQeRB ztAlAUS4YzjU7e6BD6@Jj1(7vTs$^4Z=@i7*`U{nPVo9l1s|7&-6`tMYa`_`tG$ktfvE;QK*f}+2WZe!Z!LZ8ML>W+S)@RLo_u3K0WN&%o82Xs@AJ<>2>plN)RW!HT({||PhhBno zK)3FJaa>XWT0LfK5L&(F_`Oi}t-GV)XLNp1Mhjk-)l5z)7Ui6pSBjdaL}XmduE;rt zTA0ZVR}TKQL8yI%)JEGA)8IDaMP+80+O(y*#e3*BTV|K}wr04Mm@-;qp0Gv>gTM-2 zwmsFy$+7SW;n!r(EPLCn5vtoWD2y6Z$eMzs%u<#t#l$?xWa0DQkYi#F%UM;H zGFjC&(uMiCi(*z<$rm$pBw{nT{9$rF*?5Zh8*$ujGdpu}WQ#C+VH(HjBzhKH>CxLOb9sijH>)mE^5B~Y9=d57%DZNKGE!Txv1uv z-7$4?x|5&p?9L{IEK!YklULUv&^TMkH&^ojmyYcgpq%?#AhXRgYODD;F?%VQ7AM9@ z41!inOwC=Mh#Ezs%HCx@xo~MdHIw=sgjU;ln2L$0TMucNiV5tY8ATFAIAvueuVO09 zq8@|R{j|Yny)uqabIM?g@fwGfHA2Usi$IgX{b3_yXPTD|VqFfxJ01b+3HXx-kljJM zLDVyB2DFHCK=VUD8${uu%@bb_e|zO0!`q?R-H{+fwlUN<@F?~$_GSF;^iEHRhQIq4 ze;5@vPH+6_i!)zI&&Q2;H(|xQXd~WPl~%kXM`2F)LsR|eaRMdIaFv5zC|Az#iQfv9 zUkgxv+8>@e#=Qx2KO@j((||x5ER9%atEN~YaZrdITJ|P@o2aZ6WrddmLX0*PBJ z=`<3SZ$QwY8n%-~H|Ig(miurXqoi9<&Dg2k64j2MwG_unLAEs+BAd$9h8MPr=-C} zAZwP_!_M9)4BqG%3Ln}IiM!DVc%hi3uTL8O=9iYgIoTD>AiPU+~@+IO-T1;J6-T3EmOpS0* zRZ6*~R#N5{GeudJWC_zid>#Je4rI-&3^}t>09J-j_~7QXZ>G0Teehi@u^pP)?ehUY z2M}zMb!o5zasf8N&qoZJj#)H4)S#(VY0;D%fk}WY2%^Vl6Q{V!fES9DQ+(oALghUH z%1?vgDY1hpIMs=^uk(0_*=xt3A0JVX)|MOy%Dlq9pi5tKTfFDKqluum{$hjSzS-GC z5R;d-v0gvrwrtXx@&i-o=(-w@La8+>^%)aw;Q&4cW@T!61F~l12c3~`9{1?+$UN_9 z$NO6b-iIx`8w{YwK31iL_nL`!4n01Z80RW}?-YkB<9s46RNfJw{FDn%Mc&|jPb|MI z-nVQRef@YhRr4zb7d#Hg#9@k2DpVRCeXtJaLcuu2tjk3l1v9$cBPGc8kLjkfpI3!6 zJ`PWY)96%`c{WLlevOqrGs4eYezzJ@s z%E9&DeFIe`RQDkiG0gd5nQ&@AJ^!$ X`c4R*NNNZ!)nl}r+ofe3hiUx}J^tOg literal 0 HcmV?d00001 diff --git a/repositories/__pycache__/coach_repository.cpython-312.pyc b/repositories/__pycache__/coach_repository.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0af5d4d421bddc101ad584b57e6f2c092855c7e0 GIT binary patch literal 9563 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 PFGo*2Q5(gBFvmXu@A9>? literal 0 HcmV?d00001 diff --git a/repositories/__pycache__/match_clock_repository.cpython-313.pyc b/repositories/__pycache__/match_clock_repository.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..93c42809a268d6c35b21be01e3134f24f0db1258 GIT binary patch literal 7739 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 literal 0 HcmV?d00001 diff --git a/repositories/__pycache__/match_coach_repository.cpython-312.pyc b/repositories/__pycache__/match_coach_repository.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..262e8400e1327d7c26dd3e956cd349e0943eaf08 GIT binary patch literal 5182 zcmbVQYfKy26~6QE_-%}Xd6~x$AlQT@Vw2rGMAIeMC?phGAe)j+O1(1!abj#VV-i4i z8%2>8cG<=>s@6nNLsqTADt}0|ZStdCwcGA$rS=De-R@+hN=W@t<)102X|sQN?$~2H zVAv=#^4xpRJ?Grlx!*a*f3;do2%f!r{vP;x7efEU4ec?eE9)nq@&V!zPXy5jafTp} z5nkTNQv%7;@FOSn2=Tg1jS;9J1FyfPJ435H3{MWxyx|&sMmIrvOi!?4524tGg~%Wm z4uu3R5(tO76~l->!kr%s@QUSpctjYC2>ubZ<-hEo$g~(yh>VFLj!MrD7YzJxdwr3> zbB(|^_1sIBG0KRd)P-D`O&qVG7#*cA=+fkzf+wPAh`2|lNCW~dSnuX1Gm;K=3f?UL zFd9ZYd5erOKh=5Bxjuwq`lz0#%0LB_G%m*pi6meTq0QvBdHpRaW&o-IQ+eY>H$tK_ z%J4?qzM!2E)U_vSxR+i%fo2Y7Bu!XSB4*wISB6vL1J zpx_S%6~;e0Duj4N#|6WZ;4z9c7RDD=#P?sJF_tJ4D8M6JmJ~n=X)G8~C}=5kAR>%N z_`=}rQ=ezAwjVj$9_EF!r#rgNio$4E3Pi$UK#f9MSI~wDS;|+;t}EUoxku-et%cX$ntp5MT!ND=1=mkbpG=bG!lGm; za+Jr-8!nsGmaIZWB_FiTw%#~8<$YR%Y_8-UMYbt#(t_-};w{sc62*@Ss%3j|{L-xLYkU2& zy?&nm%Dz`FEQ-2dO`yHXGDGEhxd?gSvL4|Fl&P^*>XNb|=MUfElDL@WVj5egsbb z9rTVZFL09p+~m%{{jP1=mLIsMp9Ahl2DYQbz4T0YQiarCM~M_5r+|1E#V8&e8&3ml z(@_dwdlq0@7uDs1TwbSXi!lJN^d&+p+lG_LaAFrgUqT`k)yJ3+G1Ib*ggzr-h#COE zecPYS>1bQPT$Nbb>3(zaCQ#7j{eLAzjv;;W5U^aZ+_i2d7#ep2nsp z%Q0cz;sW<-E-+H0iPa&|0sBo}DA6d{GOOz<(wri=Px8W!<7%9qU1JL9GGRzty1n$t z(#P=rcKOQE@0K1e{bp$q>Q`AH-(Ol}aT|Ib0`(8eS5?CDdrJ?X7dnopDq1`&0AA3~ z3D2XPTXP}c>Qv{7A=aacy!!1Tx$#^Rn;XwHv1*^HQ;^bq=~hbpF^==}zqm*~SXs^&XP*C*gU{bUL&EK0Z+(zwtIo=&LrH&!M=Z|qTZF$2GtuwX3S}V7Fv#sltua9-t!;w7y+g?U= z;QeM&aZvXY>*K4p{x|sif`kl%h>=l317LvM9|BZR5Jl!X z4dBHOPu@#Yanug5STe24=OlP)nu)?y0?^8@ht~9@0Wm*U2H;b(?9q!gP>A*LQB2v}27x68HWeX6483ZADPhCK%GfJc#40Re z4um8Dt|;Ep$;P~3+1wfobN--ov^j@248qbReExd{$u+y-;hlWW~~ z58pYwaAu|UP{Q!IxSHRgazvkA)(FD2X_bRSiQis=fjruWAd5buf2j(9HE;>HjLyFsE45J`MC5u}*AC zQUQE^teTPbc_U>_&b&t;;&^I57W@S8QpP`6yM4@_FFsJY!a*Ev75Qt`^8impZHgR=W& z4-c)hc%k(L4Tf7*4u3bY*JPgm8ZIcJZ8`_)Q$WVmlQn3v_nKk zVJ#8GPMoEWqG{?FnzZ&1apI8NfWQVTdk&gcMbpS04Za7)V0#8!v0K5xNHj7Q5r^WV z7sc|fQF5|!Rn^7u^O1O@qD!a5vtoyS`qu9*5o9wFOt2n@8a$HPS zDPTQ^O-{Y3Dg{F|X*j8%&1JJ`!>oziRMdR=#KJ6@E3$IkWRy%!R|A~2I3|1B6jEwN zHPqyX1r^VlLRy0rj7rZ*j=f34IwnodIyo&Q{UGM4P*yWSkPA69J`_#naqe0OtNEOs zHgY(v>Y;5fJXtl&2J^FK6Ld4Oq9(Vws5)+i*-27#6N))h^oHl6C5O<+&qX)ekkGQ` zZk-z}IU1R!qUX@UHry8cXf`{%>0_vm81_ksJqyVxkc@U&d}*F{v(r@nx#W4|O`mYHAnulY`vJX}-rpN#8J!#a(8Z7&&A z+gfTvHJ%0cy!)=F`?kU^ZNp1ns-}n|zKimS;|IXYKJDAJtM?RZb8v9=v7eXPpt%44X8)t#$ zK*|E_i2Qv1>qJe$b(X5K4X6(Ur4#PGI>{NNHDThDz6pfnW}rPMF=VI5-B9e3gkUoq zxO@tsiBHL>6wH3{J!_=J|9>5MsZ8_)cN`r+;E%U4^gz;A8Op^fOq2km;jt?0vPy6d z??7iGWD8bR~64B`JkY5SP*?guTcZ;2!fGHS7xDycjC6P3&b@zdI zR9H8zhw(JOefjsrju-N69s5Y z5{1c)g~{u~M@b<<5nwF{V)CJZ$&F<4(<*$I$e9doBwbd{=T$8g@RV8CM*NNt17nd6 zA0`SPDJ%?fGdNest0~MA)y`E-XWCG+I=LeW`l^CkKx4i}zK?d0ENRcx3HEOI>pws- zhf0nb4?h>#Y~F$yvoFk;#gCo|T}5y6gERNfe17isU>QF)MD7SB79H{y>l@Y?x{jTD zf5U}N^gQLeZgY!$FMMLL<@ocKHPuDRQbg7@Cg{OEI` z{i)Et)bo?zFM1k(^c>%-UvGkGOFkGj7ke2hQ*H;z+TpQ2s}H-;qc;Zn=7&-6`uX$@^2}LA#F;OB}$fDF-{!Ibz>uOoRSt(2XX0El55zpA<*1)LX{$w zS*o&;Q+UiEp1=9|ZHZN(Y!BeLq({PRMa8M>NRBO0Tz8mIA^pt&?r zb8DWP-Y@O{%Mh3+?w#kXEVKP!T$R8d@N1gooA*3@7DGOg;KaUeBWB29GD%csnPtco z!oY9y=q>IFS!NEClHZv*MraVu=$nW4)n1%4GUQ{jOq{vM&+?=%UY>|sQ>Rn2m!{KirhgL4$*-D=Q4FNGn__etuIv(3i_{R6TS^xG zr{TV-fHR8)1x&?-gVP49$jk)=+ZgE z*W^pXW%>A^z|~!U_T1?cb6%q|F}PE!6~2d{cARhd)t|!O1`C zP4eX4@x*Da66l9!<+PAw#L8u*J+qGcV zF1r@(+HKb!yY||(4{O=~Tkmal^Ccd3ScRST;3wN1KWAVbW6oXPgOdf$!aCDmT~T-i z=7LYVj;Z6Jl+L&uJe)~gNS&Ll!(21ad^aJ_Jn9?M7k}Cg-=^X6`P6i(4&SlY0QOA% z#p$WkbS!x#=D=_(tnFYB?;ybKVEFD}7VqFCyW>KE*XE8J1v)tHHQ1a?C1@Hv!WPA_ zr@bipPy|stha!X`3?eR=xJhwMifh_{naSs=xu%QaO-K)*IEW&GgLqn8Rt<)#IK{#$ zI)DZ*at-U|qDMh-79RC05CA)Dj|n@Y4cG~Ff}L%l?B4$1@BX9ua|iC6xqasIv+Gk2 z2FbwCZZr@IJQ#r1uM@R{Bs#dAxI3l~9@@^_opP$fW96UUKdBE5mzle=J{&9e-7Pt` zkr2O9fS$%B0oVh=>ix5_ez?u(m^~a)#OSL0)==t}H$zt(vR z&7Pe%iq#*+Fm%mQCs|zyTN}yhx^Ep+*mOp8 zK;_cUrV_JG8rJ!hnTRu~2G}6Lm?_$zL6}Z$swPIzX0hl&o6=73HZ2l|HkHE_BDN=^Nk3QhbIBf3?FlAFS}6!3 zrAa|vcC!0_OhNYKGP`dsv)^Jav)wz_{?X=qFPgCXsuOb^NK<>vW%EjgI$Y_8aPr^7Q;)f`|A&~PlW>AKpN0|_ z4OYHk-4*!xXp%XKcug@qD4zEVvI~+}Ddax0P?VSQ%2|2}bVys(AA_iI48wdy4%I@$ r8_)wmJ$OLx3-5+cG2w?L<_Ao;c7tiPoB+!_<7dLVLm=qOu*iP^w(-qZ literal 0 HcmV?d00001 diff --git a/repositories/__pycache__/match_event_repository.cpython-313.pyc b/repositories/__pycache__/match_event_repository.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..14277c3c0b4936b4b69485e4790f356c500c9d36 GIT binary patch literal 8745 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 J;0Mw5e*ur1WwZbQ literal 0 HcmV?d00001 diff --git a/repositories/__pycache__/match_formation_repository.cpython-312.pyc b/repositories/__pycache__/match_formation_repository.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4bc90397e9a34afb49e873ba3c7aee9deaee07e5 GIT binary patch literal 11935 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 literal 0 HcmV?d00001 diff --git a/repositories/__pycache__/match_referee_repository.cpython-312.pyc b/repositories/__pycache__/match_referee_repository.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0f6646624e3ef2dc2ea4b42f07020d5460cd5860 GIT binary patch literal 4444 zcmcgvYfM|$9Y6Q&XTrn8jt!ENkbp-XgVJb8lSqKwb}cm$u%VegRe`|x%qoihOiJoO5FiN-EnddBP zL{auCku7Rji6_x47v-*UMbj{h(or-@-Ol)>P?Ykbp%)OE82s{FSiP|{mh9>&&;W^pdr8coW2hA= z)D%sj%ZP&CLd!IB8BI~gP((jTM7t5;8rUQDDtR|WE${6*nnGFK{rqMnJx2e|8LzL~ z*Y9&b-P`kmJAFd1JSt;ZR@?*6`g(mCWvK0BS589oyZ`>4fo9K1xBl=vrHf-0Hdr;~!&EI=AUom|PXr zBFRBHA`kyyQpPi?B`^*iBAF3OuCvO3AC)0@nA`=j(a?6DcsCF0zM?`semypti>%lbH4CRl|&7oAScdlEb%{Iea_hTor zIM>atIj=_7a&;+d_2P^3FD?wl#FXXW;`8&*$1L&b_$z;~wEe~IT4GXFwJYXjbIRu4 zaJXX6-8`6bxZV|R3USN2;}p;_f6~#CbE&` zz@<5n(Yk1!H@|7sOoTMeo8Gi+ABU+ohQ5AWk1TbYh~^HY43N!f%G?(mK;LiWI?oID&kBUT=<0GX4;)OF3q5evbe&?#W99Dv z$pF#+PgprkDS#Ja2r%gB6B-~i6CDM_)%Uehn%xQ1qrUePIE;&Ib;QIbrq$A z0$Z>K$*>oK&KfF2Wc)@}j8Oxoqeg{Vh*iukC@Mq^QDX&vCdr&d{asn%vkFx}NorEL5h)ZH_ZW3F#3U|UMLObD<&+2xOyC;As3o0^bal{qOnLOfhX~hB zItS@UF2_#NkvQ}ibcYHV>bR$O*abuNPventEPUuVwc7S)dwbH} zzHUFe#QxoW>{ffqR=-hzsqU2HXv)!;a@1!*)UevL?r1AOP8V_<-4+OPo-K=<9aIK7 zzw?ct)7ysPT`unl=D`IA(4U{+yk~^ZTLeP4xqMdU3oGNRLSIzX_>MASu7reFL5g9$ z%)A8L6;!6Yq~M}Z`;?b-lrC3ZN>ovKNtY}3vKMZaDK6|@W4vgQ73nb~(nD}UXW1w# z(c~zmp;uyHte5K9vBbSYFVhQl7BoPb+6&ew)K^;eollo;B7h>XFa;9(S;<{j-aq*e z5|Al>9nw@E6n0(xC2TSo!lw(%ntTmaXoCnrwNqlCT%{PoC1RQ45ueUhfqMN|BG%LE z_4T?tFPBV^P`Wz$p#H27M>bY)9!n9eC&I;Z552&t|0Orj7< zKRfBHQA0kHcUQl-8AOKQue=Z49Qx9HI8*MH%@G=1U2W4EQH^uS{J6M&{(VzTXFcOFsI>G_`n!b*j7L;97*L>>0%An3{q?M=Z^SLM1?EwDw+t6TVQDfb zU&5q%Ca*;y8>KN6Mg0{uX#6p%E-q>a9&5HW>5fw;KV!EDM|+NHh3P&G!DEeG>%U{t c$-9v)!qHx)h<2^6vT9qEZaE(lHB6TGUp_3e2LJ#7 literal 0 HcmV?d00001 diff --git a/repositories/__pycache__/match_repository.cpython-312.pyc b/repositories/__pycache__/match_repository.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..70c044ebdd4175518ab08bdd4cd17547a33a0aca GIT binary patch literal 9201 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 literal 0 HcmV?d00001 diff --git a/repositories/__pycache__/referee_repository.cpython-312.pyc b/repositories/__pycache__/referee_repository.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e3ca8046c83c49a676b3eda7b29400804e55f1cd GIT binary patch literal 9288 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 PQvD#mgC)`+%+TKf2ZsHM literal 0 HcmV?d00001 diff --git a/repositories/__pycache__/stadium_repository.cpython-312.pyc b/repositories/__pycache__/stadium_repository.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b204e908fffd5ba75b445f0af71f1f56e5d73a8f GIT binary patch literal 10041 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 literal 0 HcmV?d00001 diff --git a/repositories/__pycache__/team_coach_repository.cpython-312.pyc b/repositories/__pycache__/team_coach_repository.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..953fb7844b5ccf68724b5f228bc2517255ea7f97 GIT binary patch literal 2153 zcmbVNO=ufe5PthF$ty*66ep?kvyS5`GO4L4?V-d5|Ey_boLjJ-@oz$AJI|D+An8f5PlH(>h4rSirbHbzmvOq;sq+&)i zrF-I} z(gkBCW!PzBhB~R+%r4GR6O17=8g@QUjY8JWyPV<5j24P4Z*b^eBN;{k-rXf&C9*_j z8?7~v$vv%UKVaug;VEAQFK`ZzNB#g(Y(|>WBOys*a>DiPA0w4D-9S~!r*qULuE=bQ zJ`tD?mSBElEx_QA0vs~NF~|#XaEIEvzTvN@hHaBh-58BeF*;{E*@Dfo)R`*Kbgp_& zsRR9D@7$srPUUTuOIz7Hv}$L^Ae4wP2Y}rruOlZ)`i5Ta*^YEq+E13~1}(=QU)XN{ z>}C5G&)O>C){?X#t!K)&w!@t-!_m$09Ys`SkBE}I3lT`dRrXhhgZZlY0!f{*b2L@g ztLgdlVtr=d;&p=3sAgysPFNy^gZwwG3OVTV}(;+gsC6eLl zx#I|+aJ@rRkFzX8S;O-l|D8NS))a%kO$>e->U3FIQcNBT7DfTbT0Mv`mm24${7Bf~ zS35-T1TS$G^LmKt>vAMXx=0`7cphqNf^hJx4lD>0WS%oXgAwKd@v$iLr0Nq>62%DA zYd||hNjt#v2%20*$H%^{?uj~@(PX~yjXti!7HVat^=sWu6KHIFL?73NCYlU* z?%~0texw175I&*B$8PB3ml{squiRMi zT5#I3T*HKM|X;D}Xolvw6oAkYh{3n?zzE#C2lhe&4L1sb_3f zYxC5^ZpJjg;g+1AfZZi~Dp5b#Zt1u?;_>ID6O~}=+Rc@ltJ7tpq8?kjwsOtmL#+|- z9BDhf9@y(_4TijnByzHJW#h_^1NTSXMoFk+=PPpR%x+K#2X<7D)& z@0?!P?rL}_L>^rn>=&LB6~yQ2xgo#!yoX1*UmB8?7d+};gxZEQAtodeiJ0H*I%wI@ z%6l9B^BQ_N!w%^3=B;X;L^Ep?_FK6tWb*}Aw6acN|LUF)76xebx_wu2wpq030E>bS z^X^!{Jb~l5SLEs|a@G?iPA-ccfnhVUi-Q*-XSB-sF8J;EKjq-c!=3=+>rm_Z$jYoI jf$YiTczapg=L^V literal 0 HcmV?d00001 diff --git a/repositories/__pycache__/team_repository.cpython-312.pyc b/repositories/__pycache__/team_repository.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2775798b2c4e5c321e18614b89d10181a4dffd07 GIT binary patch literal 8603 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 YvDAZ98H_{47GOj+=eiFlF$$CWUxB`>b^rhX literal 0 HcmV?d00001 diff --git a/repositories/__pycache__/team_squad_repository.cpython-312.pyc b/repositories/__pycache__/team_squad_repository.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..561414fba927c0bff0c4dab89bd6097e17e8dcfa GIT binary patch literal 2542 zcmah~%TF6e7@vLkwU}o~+mH|zLTk23Nl?^CAWDmkQKL9Uf+!IIYAw5CvSjUbX4eHO z)6&NwNK_Ez5TH4@w^UrJ{s-aEOD{oHC3n?Im3rc4LZnnt5BGR5XC5QiZk9wQa0jE-W=9fm=k+^2d}4znr`fA({jtvX7S zvqZTthxyasAu6cuZyk3WOROa9Q`Zt>H_Txxq8RZwRxHhkhi#XI9O+_nOe1Mi6 zDaX8{LJh3Sq)?REVe=XWffXcP*@}LaolrFRj_kj5j-Yjy!pQr%(x1c;b4~#$pBx znn34KA2OK*KsAXN_|}v~ZW1jp3P`Aj1+X~D%L2;Jk?O+9Z(y|qv7$``L&3n!DlxT6 z^lGX&JRv4@c?lEP5*KTl1;&O$!HGbyr=~XVD4NvuNL-HL+AgTk&GBnDdP=?$y}E2# z`6;5$CyA+)=z|Z2GMlJI%8jI&2&A$cj)zd@wIVW9;7C$4LfPC3L^O1n=%m^4WNa3b zfdLCIS{HL^!~DFkcHzn*Ck#`gXSp=5B&Zp6bT~8-r0_H|qR0tL*5XxmqvK;Y3af95 zi7NH&PlDq?*d>K4Li|vC$E-3M8y^Xdi~h+f1zq#N@I^i&9p! zJd$`VEcW=G|Gc;2gS_e3ONqIAx!#cVdl)A$DJq};wD&dqSN7ImN8P0UFCQB|NbA#A zQymPC9J3rJ=2Ny{)rnKBT(+ll@7Q$( zk{(~%Q#y^daD>8k<|}2pi#x;?j<&YsBs7TaRFcFri0#6QSV>ygc1JNhce1YA{H$T< zwnNbk6H9JF;SdUkP&kA>Ha1UROq&BAHcyNN)8=UJ1{GZa+Fths2Brgsil=XnhNcP5 zVKIn?&FS1JG4Cg3HByo<^(L0=PWl$-wD~N}NYo&a{4=%p8V z+kUG%voexpFF8(S>YLZ6R;SixHkFLGasB%0^(@=g+?u_LT25~WFZbo17&Oh;F`Z|k$|)YFzsyGSM5 z(`k|cW#?9OTYt)C+S)het#`LCru~_-9cgB3bUT!u{qtN``bzr#4_Cj}4;$SOvjyB9 z&;Z#sbjj$xR;P None: + team_id = get_team_id_by_external_id(str(team_external_id).strip()) + if team_id is None: + raise ValueError(f"Team not found by external_id: {team_external_id}") + + query = """ + INSERT INTO coaches ( + external_id, + team_id, + player, + lastname, + name, + born, + amplua, + is_active, + created_at, + updated_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) + ON CONFLICT (external_id) + DO UPDATE SET + team_id = EXCLUDED.team_id, + player = EXCLUDED.player, + lastname = EXCLUDED.lastname, + name = EXCLUDED.name, + born = EXCLUDED.born, + amplua = EXCLUDED.amplua, + is_active = EXCLUDED.is_active, + updated_at = NOW(); + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + query, + ( + str(external_id).strip(), + team_id, + player.strip(), + lastname.strip(), + name.strip(), + born.strip(), + amplua.strip(), + is_active, + ), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def get_player_id_by_name_and_team(full_name: str, team_id: int) -> int | None: + query = """ + SELECT id + FROM players + WHERE team_id = %s + AND LOWER(full_name) = LOWER(%s) + LIMIT 1; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (team_id, full_name.strip())) + row = cur.fetchone() + return row[0] if row else None + finally: + conn.close() + + +def get_coach_id_by_name_and_team(full_name: str, team_id: int) -> int | None: + query = """ + SELECT id + FROM coaches + WHERE team_id = %s + AND LOWER(player) = LOWER(%s) + LIMIT 1; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (team_id, full_name.strip())) + row = cur.fetchone() + return row[0] if row else None + finally: + conn.close() + +def get_coach_id_by_external_id(external_id: str) -> int | None: + query = """ + SELECT id + FROM coaches + WHERE external_id = %s + LIMIT 1; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (str(external_id).strip(),)) + row = cur.fetchone() + return row[0] if row else None + finally: + conn.close() + + + +def search_coaches_for_admin(q: str = "") -> list[dict]: + conn = get_connection() + try: + with conn.cursor() as cur: + if q.strip(): + pattern = f"%{q.strip()}%" + cur.execute( + """ + SELECT + c.id, + c.player, + c.external_id, + c.amplua, + t.name AS team_name + FROM coaches c + LEFT JOIN teams t ON t.id = c.team_id + WHERE + c.player ILIKE %s + OR COALESCE(c.external_id, '') ILIKE %s + OR COALESCE(c.amplua, '') ILIKE %s + OR COALESCE(t.name, '') ILIKE %s + ORDER BY c.player ASC, c.id ASC + LIMIT 200 + """, + (pattern, pattern, pattern, pattern), + ) + else: + cur.execute( + """ + SELECT + c.id, + c.player, + c.external_id, + c.amplua, + t.name AS team_name + FROM coaches c + LEFT JOIN teams t ON t.id = c.team_id + ORDER BY c.id DESC + LIMIT 200 + """ + ) + rows = cur.fetchall() + + return [ + { + "id": row[0], + "full_name": row[1] or "", + "external_id": row[2] or "", + "role": row[3] or "", + "team_name": row[4] or "", + } + for row in rows + ] + finally: + conn.close() + + +def get_coach_by_id(coach_id: int) -> dict | None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + c.id, + c.player, + c.external_id, + c.amplua, + c.team_id, + t.name AS team_name + FROM coaches c + LEFT JOIN teams t ON t.id = c.team_id + WHERE c.id = %s + LIMIT 1 + """, + (coach_id,), + ) + row = cur.fetchone() + + if not row: + return None + + return { + "id": row[0], + "full_name": row[1] or "", + "external_id": row[2] or "", + "role": row[3] or "", + "team_id": row[4], + "team_name": row[5] or "", + } + finally: + conn.close() + + +def get_coach_by_id(coach_id: int) -> dict | None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + c.id, + c.player, + c.external_id, + c.amplua, + c.team_id, + t.name AS team_name + FROM coaches c + LEFT JOIN teams t ON t.id = c.team_id + WHERE c.id = %s + LIMIT 1 + """, + (coach_id,), + ) + row = cur.fetchone() + + if not row: + return None + + return { + "id": row[0], + "full_name": row[1] or "", + "external_id": row[2] or "", + "role": row[3] or "", + "team_id": row[4], + "team_name": row[5] or "", + } + finally: + conn.close() + + +def update_coach_admin( + coach_id: int, + full_name: str = "", + external_id: str = "", + role: str = "", +) -> None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE coaches + SET + player = %s, + external_id = NULLIF(%s, ''), + amplua = %s + WHERE id = %s + """, + ( + full_name.strip(), + external_id.strip(), + role.strip(), + coach_id, + ), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() \ No newline at end of file diff --git a/repositories/match_clock_repository.py b/repositories/match_clock_repository.py new file mode 100644 index 0000000..0aa6d34 --- /dev/null +++ b/repositories/match_clock_repository.py @@ -0,0 +1,178 @@ +from db import get_connection + + +def create_match_clock_table() -> None: + query = """ + CREATE TABLE IF NOT EXISTS match_clocks ( + match_id BIGINT PRIMARY KEY REFERENCES matches(id) ON DELETE CASCADE, + current_period VARCHAR(10) NOT NULL DEFAULT '1H', + timer_running BOOLEAN NOT NULL DEFAULT FALSE, + accumulated_seconds INTEGER NOT NULL DEFAULT 0, + period_started_at TIMESTAMP NULL, + updated_at TIMESTAMP NOT NULL DEFAULT NOW() + ); + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query) + conn.commit() + finally: + conn.close() + + +def ensure_match_clock(match_id: int) -> None: + query = """ + INSERT INTO match_clocks (match_id, current_period, timer_running, accumulated_seconds, period_started_at, updated_at) + VALUES (%s, '1H', FALSE, 0, NULL, NOW()) + ON CONFLICT (match_id) DO NOTHING; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (match_id,)) + conn.commit() + finally: + conn.close() + + +def get_match_clock(match_id: int) -> dict: + ensure_match_clock(match_id) + + query = """ + SELECT + match_id, + current_period, + timer_running, + accumulated_seconds, + period_started_at, + CASE + WHEN timer_running = TRUE AND period_started_at IS NOT NULL + THEN accumulated_seconds + FLOOR(EXTRACT(EPOCH FROM (NOW() - period_started_at)))::INT + ELSE accumulated_seconds + END AS current_seconds + FROM match_clocks + WHERE match_id = %s; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (match_id,)) + row = cur.fetchone() + finally: + conn.close() + + return { + "match_id": row[0], + "current_period": row[1], + "timer_running": bool(row[2]), + "accumulated_seconds": row[3] or 0, + "period_started_at": row[4].isoformat() if row[4] else None, + "current_seconds": row[5] or 0, + } + + +def update_match_clock(match_id: int, action: str, seconds: int | None = None) -> dict: + ensure_match_clock(match_id) + + conn = get_connection() + try: + with conn.cursor() as cur: + if action == "start_1h": + cur.execute( + """ + UPDATE match_clocks + SET current_period = '1H', timer_running = TRUE, accumulated_seconds = 0, + period_started_at = NOW(), updated_at = NOW() + WHERE match_id = %s; + """, + (match_id,), + ) + elif action == "pause": + cur.execute( + """ + UPDATE match_clocks + SET accumulated_seconds = CASE + WHEN timer_running = TRUE AND period_started_at IS NOT NULL + THEN accumulated_seconds + FLOOR(EXTRACT(EPOCH FROM (NOW() - period_started_at)))::INT + ELSE accumulated_seconds + END, + timer_running = FALSE, + period_started_at = NULL, + updated_at = NOW() + WHERE match_id = %s; + """, + (match_id,), + ) + elif action == "halftime": + cur.execute( + """ + UPDATE match_clocks + SET accumulated_seconds = CASE + WHEN timer_running = TRUE AND period_started_at IS NOT NULL + THEN accumulated_seconds + FLOOR(EXTRACT(EPOCH FROM (NOW() - period_started_at)))::INT + ELSE accumulated_seconds + END, + current_period = 'HT', + timer_running = FALSE, + period_started_at = NULL, + updated_at = NOW() + WHERE match_id = %s; + """, + (match_id,), + ) + elif action == "start_2h": + cur.execute( + """ + UPDATE match_clocks + SET current_period = '2H', timer_running = TRUE, + accumulated_seconds = CASE WHEN accumulated_seconds < 2700 THEN 2700 ELSE accumulated_seconds END, + period_started_at = NOW(), + updated_at = NOW() + WHERE match_id = %s; + """, + (match_id,), + ) + elif action == "finish": + cur.execute( + """ + UPDATE match_clocks + SET accumulated_seconds = CASE + WHEN timer_running = TRUE AND period_started_at IS NOT NULL + THEN accumulated_seconds + FLOOR(EXTRACT(EPOCH FROM (NOW() - period_started_at)))::INT + ELSE accumulated_seconds + END, + current_period = 'FT', + timer_running = FALSE, + period_started_at = NULL, + updated_at = NOW() + WHERE match_id = %s; + """, + (match_id,), + ) + elif action == "set_time": + if seconds is None or not isinstance(seconds, int) or seconds < 0: + raise ValueError("invalid_seconds") + cur.execute( + """ + UPDATE match_clocks + SET accumulated_seconds = %s, + period_started_at = CASE WHEN timer_running THEN NOW() ELSE NULL END, + updated_at = NOW() + WHERE match_id = %s; + """, + (seconds, match_id), + ) + else: + raise ValueError("invalid_clock_action") + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + return get_match_clock(match_id) diff --git a/repositories/match_coach_repository.py b/repositories/match_coach_repository.py new file mode 100644 index 0000000..5d4d04c --- /dev/null +++ b/repositories/match_coach_repository.py @@ -0,0 +1,155 @@ +from db import get_connection + + +def get_match_coaches_grouped( + match_id: int, + home_team_id: int | None = None, + away_team_id: int | None = None, +) -> dict: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + mc.side, + mc.coach_id, + COALESCE(c.player, c.name, '') AS coach_name, + COALESCE(mc.role, c.amplua, '') AS role + FROM match_coaches mc + JOIN coaches c + ON c.id = mc.coach_id + WHERE mc.match_id = %s + ORDER BY mc.side, mc.sort_order, mc.id + """, + (match_id,), + ) + rows = cur.fetchall() + + result = { + "home": [], + "away": [], + } + + for row in rows: + item = { + "coach_id": row[1], + "coach_name": row[2] or "", + "role": row[3] or "", + } + + if row[0] == "home": + result["home"].append(item) + elif row[0] == "away": + result["away"].append(item) + + return result + finally: + conn.close() + + +def replace_match_coaches(match_id: int, *args) -> None: + """ + Поддерживает оба варианта вызова: + 1) replace_match_coaches(match_id, coach_rows) + 2) replace_match_coaches(match_id, home_team_id, away_team_id, home_coaches, away_coaches) + """ + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM match_coaches WHERE match_id = %s", + (match_id,), + ) + + if len(args) == 1: + coach_rows = args[0] or [] + sort_counters = { + "home": 1, + "away": 1, + } + + for coach in coach_rows: + side = coach.get("side") + + if side not in ("home", "away"): + team_side = coach.get("team_id") + if team_side in ("home", "away"): + side = team_side + else: + continue + + coach_id = coach.get("coach_id") + if not coach_id: + continue + + cur.execute( + """ + INSERT INTO match_coaches ( + match_id, + side, + sort_order, + coach_id, + role + ) + VALUES (%s, %s, %s, %s, %s) + """, + ( + match_id, + side, + sort_counters[side], + coach_id, + coach.get("role") or coach.get("amplua") or None, + ), + ) + + sort_counters[side] += 1 + + elif len(args) == 4: + _home_team_id, _away_team_id, home_coaches, away_coaches = args + + def insert_side(side: str, coaches: list[dict]) -> None: + sort_order = 1 + + for coach in coaches or []: + coach_id = coach.get("coach_id") + if not coach_id: + continue + + cur.execute( + """ + INSERT INTO match_coaches ( + match_id, + side, + sort_order, + coach_id, + amplua + ) + VALUES (%s, %s, %s, %s, %s) + """, + ( + match_id, + side, + sort_order, + coach_id, + coach.get("role") or coach.get("amplua") or None, + ), + ) + sort_order += 1 + + insert_side("home", home_coaches) + insert_side("away", away_coaches) + + else: + raise TypeError( + "replace_match_coaches() expected either " + "(match_id, coach_rows) or " + "(match_id, home_team_id, away_team_id, home_coaches, away_coaches)" + ) + + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() \ No newline at end of file diff --git a/repositories/match_event_repository.py b/repositories/match_event_repository.py new file mode 100644 index 0000000..e254632 --- /dev/null +++ b/repositories/match_event_repository.py @@ -0,0 +1,184 @@ +from db import get_connection + + +def create_event( + match_id, + side, + type_, + player_name, + minute, + seconds, + meta=None, + player_id=None, + player_out_id=None, + player_in_id=None, +): + query = """ + INSERT INTO match_events_ui ( + match_id, + side, + type, + player_name, + minute, + seconds, + meta, + player_id, + player_out_id, + player_in_id, + created_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()) + RETURNING id; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + query, + ( + match_id, + side, + type_, + player_name, + minute, + seconds, + meta, + player_id, + player_out_id, + player_in_id, + ), + ) + event_id = cur.fetchone()[0] + conn.commit() + return event_id + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def get_events(match_id): + query = """ + SELECT + id, + side, + type, + player_name, + minute, + seconds, + meta, + player_id, + player_out_id, + player_in_id + FROM match_events_ui + WHERE match_id = %s + ORDER BY seconds, id; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (match_id,)) + rows = cur.fetchall() + return [ + { + "id": r[0], + "side": r[1], + "type": r[2], + "player_name": r[3], + "minute": r[4], + "seconds": r[5], + "meta": r[6], + "player_id": r[7], + "player_out_id": r[8], + "player_in_id": r[9], + } + for r in rows + ] + finally: + conn.close() + + +def update_event( + event_id, + side, + type_, + player_name, + minute, + seconds, + meta=None, + player_id=None, + player_out_id=None, + player_in_id=None, +): + query = """ + UPDATE match_events_ui + SET + side = %s, + type = %s, + player_name = %s, + minute = %s, + seconds = %s, + meta = %s, + player_id = %s, + player_out_id = %s, + player_in_id = %s + WHERE id = %s; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + query, + ( + side, + type_, + player_name, + minute, + seconds, + meta, + player_id, + player_out_id, + player_in_id, + event_id, + ), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def delete_event(event_id): + query = "DELETE FROM match_events_ui WHERE id = %s;" + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (event_id,)) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def clear_events(match_id): + query = "DELETE FROM match_events_ui WHERE match_id = %s;" + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (match_id,)) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() diff --git a/repositories/match_formation_repository.py b/repositories/match_formation_repository.py new file mode 100644 index 0000000..d3989ae --- /dev/null +++ b/repositories/match_formation_repository.py @@ -0,0 +1,369 @@ +from itertools import count + +from db import get_connection + + +FORMATION_PRESETS = { + "4-4-2": { + "gk": [{"x": 50, "y": 10}], + "def": [ + {"x": 18, "y": 28}, + {"x": 39, "y": 24}, + {"x": 61, "y": 24}, + {"x": 82, "y": 28}, + ], + "mid": [ + {"x": 18, "y": 48}, + {"x": 39, "y": 44}, + {"x": 61, "y": 44}, + {"x": 82, "y": 48}, + ], + "fwd": [ + {"x": 38, "y": 70}, + {"x": 62, "y": 70}, + ], + }, + "4-3-3": { + "gk": [{"x": 50, "y": 10}], + "def": [ + {"x": 18, "y": 28}, + {"x": 39, "y": 24}, + {"x": 61, "y": 24}, + {"x": 82, "y": 28}, + ], + "mid": [ + {"x": 30, "y": 47}, + {"x": 50, "y": 42}, + {"x": 70, "y": 47}, + ], + "fwd": [ + {"x": 20, "y": 72}, + {"x": 50, "y": 66}, + {"x": 80, "y": 72}, + ], + }, + "4-2-3-1": { + "gk": [{"x": 0, "y": 0}], + "def": [ + {"x": 18, "y": 28}, + {"x": 39, "y": 24}, + {"x": 61, "y": 24}, + {"x": 82, "y": 28}, + ], + "mid": [ + {"x": 35, "y": 42}, # опорник + {"x": 65, "y": 42}, # опорник + {"x": 20, "y": 58}, # левый + {"x": 50, "y": 52}, # центр + {"x": 80, "y": 58}, # правый + ], + "fwd": [ + {"x": 50, "y": 72}, + ], + }, + "3-5-2": { + "gk": [{"x": 50, "y": 10}], + "def": [ + {"x": 30, "y": 26}, + {"x": 50, "y": 22}, + {"x": 70, "y": 26}, + ], + "mid": [ + {"x": 10, "y": 50}, # левый фланг + {"x": 35, "y": 46}, + {"x": 50, "y": 42}, + {"x": 65, "y": 46}, + {"x": 90, "y": 50}, # правый фланг + ], + "fwd": [ + {"x": 38, "y": 72}, + {"x": 62, "y": 72}, + ], + }, + "5-3-2": { + "gk": [{"x": 50, "y": 10}], + "def": [ + {"x": 10, "y": 30}, # левый латераль + {"x": 30, "y": 26}, + {"x": 50, "y": 22}, + {"x": 70, "y": 26}, + {"x": 90, "y": 30}, # правый латераль + ], + "mid": [ + {"x": 30, "y": 48}, + {"x": 50, "y": 44}, + {"x": 70, "y": 48}, + ], + "fwd": [ + {"x": 38, "y": 72}, + {"x": 62, "y": 72}, + ], + }, +} + + +def detect_player_line(position: str) -> str: + pos = (position or "").strip().lower() + + if not pos: + return "mid" + + if "вр" in pos or "gk" in pos or "goalkeeper" in pos: + return "gk" + + defender_markers = ["цз", "лз", "пз", "з", "def", "cb", "lb", "rb", "wb"] + midfielder_markers = [ + "цп", + "цоп", + "оп", + "п", + "пзщ", + "mid", + "cm", + "dm", + "am", + "lm", + "rm", + ] + forward_markers = ["н", "цф", "ф", "lf", "rf", "fw", "st", "cf", "нап"] + + if any(marker in pos for marker in defender_markers): + return "def" + + if any(marker in pos for marker in midfielder_markers): + return "mid" + + if any(marker in pos for marker in forward_markers): + return "fwd" + + return "mid" + + +def get_match_formations(match_id: int, team_id: int) -> list[dict]: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + mf.player_id, + mf.player_name, + mf.number, + mf.position, + mf.is_captain, + mf.x, + mf.y, + p.last_name + FROM match_formations mf + LEFT JOIN players p ON p.id = mf.player_id + WHERE mf.match_id = %s + AND mf.team_id = %s + ORDER BY mf.id + """, + (match_id, team_id), + ) + rows = cur.fetchall() + + return [ + { + "player_id": row[0], + "player_name": row[1] or "", + "number": row[2] or "", + "position": row[3] or "", + "is_captain": bool(row[4]), + "x": float(row[5]), + "y": float(row[6]), + "last_name": row[7] or "", + } + for row in rows + ] + finally: + conn.close() + + +def replace_match_formations(match_id: int, team_id: int, players: list[dict]) -> None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + DELETE FROM match_formations + WHERE match_id = %s AND team_id = %s + """, + (match_id, team_id), + ) + for player in players: + cur.execute( + """ + INSERT INTO match_formations ( + match_id, + team_id, + player_id, + player_name, + number, + position, + is_captain, + x, + y, + created_at, + updated_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) + """, + ( + match_id, + team_id, + player.get("player_id"), + (player.get("player_name") or "").strip(), + (player.get("number") or "").strip(), + (player.get("position") or "").strip(), + bool(player.get("is_captain")), + float(player.get("x", 50)), + float(player.get("y", 50)), + ), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def parse_position(position: str) -> tuple[str, str]: + """ + Возвращает: + line: gk / def / mid / fwd + side: left / center / right + """ + pos = (position or "").strip().lower() + + if not pos: + return "mid", "center" + + # Вратарь + if "вр" in pos or "gk" in pos or "goalkeeper" in pos: + return "gk", "center" + + # Сторона + if pos.startswith("л"): + side = "left" + elif pos.startswith("п"): + side = "right" + else: + side = "center" + + # Линия + # Защита: ЛЗ, ПЗ, ЦЗ, ЛЦЗ, ПЦЗ и т.п. + if "з" in pos: + return "def", side + + # Нападение: Н, Ф, ЦФ, ЛФ, ПФ и т.п. + if "ф" in pos or "н" in pos: + return "fwd", side + + # Полузащита: П, ЦП, ЦОП, ЛП, ПП, ПЦП, ЛЦП и т.п. + if "п" in pos: + return "mid", side + + return "mid", side + + +def sort_players_by_side(players: list[dict]) -> list[dict]: + side_order = { + "left": 0, + "center": 1, + "right": 2, + } + + return sorted( + players, + key=lambda p: ( + side_order.get(parse_position(p.get("position", ""))[1], 1), + str(p.get("number", "")), + str(p.get("last_name", "") or p.get("player_name", "")), + ), + ) + + +def apply_formation_preset_to_players( + players: list[dict], preset_name: str +) -> list[dict]: + preset = FORMATION_PRESETS.get(preset_name) + if not preset: + raise ValueError(f"Unknown formation preset: {preset_name}") + + players = players[:11] + + gk, defs, mids, fwds = [], [], [], [] + + for p in players: + line, _ = parse_position(p.get("position")) + + if line == "gk": + gk.append(p) + elif line == "def": + defs.append(p) + elif line == "mid": + mids.append(p) + elif line == "fwd": + fwds.append(p) + else: + mids.append(p) + + defs = sort_players_by_side(defs) + mids = sort_players_by_side(mids) + fwds = sort_players_by_side(fwds) + + leftovers = [] + + def trim_or_collect(group: list[dict], count: int) -> list[dict]: + if len(group) > count: + leftovers.extend(group[count:]) + return group[:count] + return group + + gk = trim_or_collect(gk, len(preset["gk"])) + defs = trim_or_collect(defs, len(preset["def"])) + mids = trim_or_collect(mids, len(preset["mid"])) + fwds = trim_or_collect(fwds, len(preset["fwd"])) + + # если вратарь не найден — берём первого доступного + if not gk: + source = defs or mids or fwds or leftovers + if source: + gk = [source.pop(0)] + + def fill(group: list[dict], count: int) -> list[dict]: + while len(group) < count and leftovers: + group.append(leftovers.pop(0)) + return group + + gk = fill(gk, len(preset["gk"])) + defs = fill(defs, len(preset["def"])) + mids = fill(mids, len(preset["mid"])) + fwds = fill(fwds, len(preset["fwd"])) + + result = [] + + def assign(group: list[dict], coords: list[dict]): + for p, c in zip(group, coords): + result.append( + { + "player_id": p.get("player_id"), + "player_name": p.get("player_name") or "", + "last_name": p.get("last_name") or "", + "number": p.get("number") or "", + "position": p.get("position") or "", + "is_captain": bool(p.get("is_captain")), + "x": c["x"], + "y": c["y"], + } + ) + + assign(gk, preset["gk"]) + assign(defs, preset["def"]) + assign(mids, preset["mid"]) + assign(fwds, preset["fwd"]) + + return result diff --git a/repositories/match_lineup_repository.py b/repositories/match_lineup_repository.py new file mode 100644 index 0000000..69101de --- /dev/null +++ b/repositories/match_lineup_repository.py @@ -0,0 +1,460 @@ +from db import get_connection +from repositories.match_view_repository import get_match_lineups_grouped +from repositories.match_coach_repository import get_match_coaches_grouped + + +def replace_match_lineups(match_id: int, rows: list[dict]) -> None: + delete_query = "DELETE FROM match_lineups WHERE match_id = %s;" + insert_query = """ + INSERT INTO match_lineups ( + match_id, + team_id, + player_id, + player_name, + number, + position, + is_captain, + lineup_type, + source, + created_at, + updated_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()); + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(delete_query, (match_id,)) + for row in rows: + cur.execute( + insert_query, + ( + row["match_id"], + row["team_id"], + row.get("player_id"), + row["player_name"], + row.get("number"), + row.get("position"), + row.get("is_captain"), + row["lineup_type"], + row.get("source", "parser"), + ), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def _player_to_editor_dict(p: dict) -> dict: + return { + "player_id": p.get("player_id"), + "player_name": p.get("player_name") + or f"{p.get('last_name', '')} {p.get('first_name', '')}".strip(), + "last_name": p.get("last_name", "") or p.get("player_name", "") or "", + "first_name": p.get("first_name", "") or "", + "number": str(p.get("number", "") or ""), + "position": p.get("position", "") or "", + "is_captain": bool(p.get("is_captain")), + } + + +def _coach_to_editor_dict(c: dict) -> dict: + return { + "coach_id": c.get("coach_id"), + "coach_name": c.get("coach_name", "") or "", + "role": c.get("role", "") or "", + } + + +def get_match_lineup_for_editor( + match_id: int, + home_team_id: int, + away_team_id: int, +) -> dict: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + mlp.side, + mlp.role, + mlp.sort_order, + p.id AS player_id, + COALESCE(p.full_name, TRIM(COALESCE(p.last_name, '') || ' ' || COALESCE(p.first_name, ''))) AS player_name, + COALESCE(p.last_name, '') AS last_name, + COALESCE(p.first_name, '') AS first_name, + COALESCE(mlp.number::text, '') AS number, + COALESCE(mlp.position, '') AS position, + COALESCE(mlp.is_captain, FALSE) AS is_captain + FROM match_lineup_players mlp + JOIN players p + ON p.id = mlp.player_id + WHERE mlp.match_id = %s + ORDER BY mlp.side, mlp.role, mlp.sort_order, mlp.id + """, + (match_id,), + ) + player_rows = cur.fetchall() + + cur.execute( + """ + SELECT + mc.side, + mc.sort_order, + c.id AS coach_id, + COALESCE(c.player, '') AS coach_name, + COALESCE(mc.role, c.amplua, '') AS role + FROM match_coaches mc + JOIN coaches c + ON c.id = mc.coach_id + WHERE mc.match_id = %s + ORDER BY mc.side, mc.sort_order, mc.id + """, + (match_id,), + ) + coach_rows = cur.fetchall() + finally: + conn.close() + + result = { + "home_starting": [], + "home_bench": [], + "away_starting": [], + "away_bench": [], + "home_coaches": [], + "away_coaches": [], + } + + # Если редактор уже сохранял состав — берём игроков из новой таблицы + if player_rows: + for row in player_rows: + player = { + "player_id": row[3], + "player_name": row[4] or "", + "last_name": row[5] or "", + "first_name": row[6] or "", + "number": row[7] or "", + "position": row[8] or "", + "is_captain": bool(row[9]), + } + + side = row[0] + role = row[1] + + if side == "home" and role == "starting": + result["home_starting"].append(player) + elif side == "home" and role == "bench": + result["home_bench"].append(player) + elif side == "away" and role == "starting": + result["away_starting"].append(player) + elif side == "away" and role == "bench": + result["away_bench"].append(player) + else: + # Иначе берём загруженный с сайта состав из старой таблицы + lineups = get_match_lineups_grouped( + match_id=match_id, + home_team_id=home_team_id, + away_team_id=away_team_id, + ) + + result["home_starting"] = [ + _player_to_editor_dict(p) for p in lineups.get("home_starting", []) + ] + result["home_bench"] = [ + _player_to_editor_dict(p) for p in lineups.get("home_bench", []) + ] + result["away_starting"] = [ + _player_to_editor_dict(p) for p in lineups.get("away_starting", []) + ] + result["away_bench"] = [ + _player_to_editor_dict(p) for p in lineups.get("away_bench", []) + ] + + # Тренеров берём из match_coaches, если они уже есть + if coach_rows: + for row in coach_rows: + coach = { + "coach_id": row[2], + "coach_name": row[3] or "", + "role": row[4] or "", + } + + side = row[0] + if side == "home": + result["home_coaches"].append(coach) + elif side == "away": + result["away_coaches"].append(coach) + else: + coaches = get_match_coaches_grouped( + match_id=match_id, + home_team_id=home_team_id, + away_team_id=away_team_id, + ) + + result["home_coaches"] = [ + _coach_to_editor_dict(c) for c in coaches.get("home", []) + ] + result["away_coaches"] = [ + _coach_to_editor_dict(c) for c in coaches.get("away", []) + ] + + return result + +def get_match_lineup_for_vmix( + match_id: int, + home_team_id: int, + away_team_id: int, +) -> dict: + conn = get_connection() + try: + with conn.cursor() as cur: + # Сначала пробуем взять ручную версию из редактора + cur.execute( + """ + SELECT + mlp.side, + mlp.role, + mlp.sort_order, + p.id AS player_id, + COALESCE( + p.full_name, + TRIM(COALESCE(p.last_name, '') || ' ' || COALESCE(p.first_name, '')) + ) AS player_name, + COALESCE(p.last_name, '') AS last_name, + COALESCE(p.first_name, '') AS first_name, + COALESCE(mlp.number::text, '') AS number, + COALESCE(mlp.position, '') AS position, + COALESCE(mlp.is_captain, FALSE) AS is_captain, + COALESCE(p.position, '') AS pos + FROM match_lineup_players mlp + JOIN players p + ON p.id = mlp.player_id + WHERE mlp.match_id = %s + ORDER BY + CASE + WHEN LOWER(COALESCE(p.position, '')) IN ('вр', 'вр.', 'gk', 'goalkeeper', 'вратарь') THEN 0 + ELSE 1 + END, + CASE + WHEN COALESCE(p.number::text, '') ~ '^[0-9]+$' THEN p.number::integer + ELSE 999 + END, + COALESCE(p.last_name, ''), + COALESCE(p.first_name, ''), + p.id + """, + (match_id,), + ) + player_rows = cur.fetchall() + + cur.execute( + """ + SELECT + mc.side, + mc.sort_order, + c.id AS coach_id, + COALESCE(c.player, '') AS coach_name, + COALESCE(mc.role, c.amplua, '') AS role + FROM match_coaches mc + JOIN coaches c + ON c.id = mc.coach_id + WHERE mc.match_id = %s + ORDER BY mc.side, mc.sort_order, mc.id + """, + (match_id,), + ) + coach_rows = cur.fetchall() + finally: + conn.close() + + result = { + "home_starting": [], + "home_bench": [], + "away_starting": [], + "away_bench": [], + "home_coaches": [], + "away_coaches": [], + } + + # 1. Если есть ручной состав — берём его + if player_rows: + for row in player_rows: + player = { + "player_id": row[3], + "player_name": row[4] or "", + "last_name": row[5] or "", + "first_name": row[6] or "", + "number": row[7] or "", + "position": row[8] or "", + "is_captain": bool(row[9]), + "pos": row[10] or "", + } + + side = row[0] + role = row[1] + + if side == "home" and role == "starting": + result["home_starting"].append(player) + elif side == "home" and role == "bench": + result["home_bench"].append(player) + elif side == "away" and role == "starting": + result["away_starting"].append(player) + elif side == "away" and role == "bench": + result["away_bench"].append(player) + + else: + # 2. Иначе — fallback на сайт + lineups = get_match_lineups_grouped( + match_id=match_id, + home_team_id=home_team_id, + away_team_id=away_team_id, + ) + + result["home_starting"] = lineups.get("home_starting", []) + result["home_bench"] = lineups.get("home_bench", []) + result["away_starting"] = lineups.get("away_starting", []) + result["away_bench"] = lineups.get("away_bench", []) + + if coach_rows: + for row in coach_rows: + coach = { + "coach_id": row[2], + "coach_name": row[3] or "", + "role": row[4] or "", + } + + if row[0] == "home": + result["home_coaches"].append(coach) + elif row[0] == "away": + result["away_coaches"].append(coach) + else: + coaches = get_match_coaches_grouped( + match_id=match_id, + home_team_id=home_team_id, + away_team_id=away_team_id, + ) + result["home_coaches"] = coaches.get("home", []) + result["away_coaches"] = coaches.get("away", []) + + return result + +def save_match_lineup_for_editor( + match_id: int, + home_team_id: int, + away_team_id: int, + home_starting: list[dict], + home_bench: list[dict], + away_starting: list[dict], + away_bench: list[dict], + home_coaches: list[dict], + away_coaches: list[dict], +) -> None: + def is_goalkeeper(position: str) -> bool: + if not position: + return False + return str(position).strip().lower() in { + "вр", "вр.", "вратарь", "gk", "goalkeeper" + } + + def safe_number(value) -> int: + try: + return int(str(value).strip()) + except Exception: + return 9999 + + def lineup_sort_key(player: dict): + return ( + 0 if is_goalkeeper(player.get("position", "")) else 1, + safe_number(player.get("number")), + (player.get("last_name") or player.get("player_name") or "").lower(), + (player.get("first_name") or "").lower(), + player.get("player_id") or 0, + ) + + # Пересортировка перед сохранением + home_starting = sorted(home_starting, key=lineup_sort_key) + home_bench = sorted(home_bench, key=lineup_sort_key) + away_starting = sorted(away_starting, key=lineup_sort_key) + away_bench = sorted(away_bench, key=lineup_sort_key) + + 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,), + ) + + def insert_players(side: str, role: str, players: list[dict]) -> None: + for sort_order, p in enumerate(players, start=1): + cur.execute( + """ + INSERT INTO match_lineup_players ( + match_id, + side, + role, + sort_order, + player_id, + number, + position, + is_captain + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + match_id, + side, + role, + sort_order, + p.get("player_id"), + p.get("number", "") or None, + p.get("position", "") or None, + bool(p.get("is_captain")), + ), + ) + + def insert_coaches(side: str, coaches: list[dict]) -> None: + for sort_order, c in enumerate(coaches, start=1): + cur.execute( + """ + INSERT INTO match_coaches ( + match_id, + side, + sort_order, + coach_id, + role + ) + VALUES (%s, %s, %s, %s, %s) + """, + ( + match_id, + side, + sort_order, + c.get("coach_id"), + c.get("role", "") or None, + ), + ) + + insert_players("home", "starting", home_starting) + insert_players("home", "bench", home_bench) + insert_players("away", "starting", away_starting) + insert_players("away", "bench", away_bench) + + insert_coaches("home", home_coaches) + insert_coaches("away", away_coaches) + + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() diff --git a/repositories/match_referee_repository.py b/repositories/match_referee_repository.py new file mode 100644 index 0000000..48e1f5a --- /dev/null +++ b/repositories/match_referee_repository.py @@ -0,0 +1,130 @@ +from db import get_connection + + +def replace_match_referees2(match_id: int, rows: list[dict]) -> None: + delete_query = "DELETE FROM match_referees WHERE match_id = %s;" + insert_query = """ + INSERT INTO match_referees ( + match_id, + referee_id, + referee_name, + role, + source, + created_at, + updated_at + ) + VALUES (%s, %s, %s, %s, %s, NOW(), NOW()); + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(delete_query, (match_id,)) + for row in rows: + cur.execute( + insert_query, + ( + row["match_id"], + row.get("referee_id"), + row["referee_name"], + row.get("role"), + row.get("source", "parser"), + ), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + +def replace_match_referees(match_id: int, rows: list[dict]) -> None: + delete_query = "DELETE FROM match_referees WHERE match_id = %s;" + insert_query = """ + INSERT INTO match_referees ( + match_id, + referee_id, + referee_name, + role, + source, + created_at, + updated_at + ) + VALUES (%s, %s, %s, %s, %s, NOW(), NOW()); + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(delete_query, (match_id,)) + + for row in rows or []: + referee_id = row.get("referee_id") or row.get("id") + referee_name = ( + row.get("referee_name") + or row.get("full_name") + or row.get("name") + or "" + ).strip() + role = (row.get("role") or "").strip() + + if not referee_name or not role: + continue + + cur.execute( + insert_query, + ( + match_id, + referee_id, + referee_name, + role, + row.get("source", "admin"), + ), + ) + + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def get_match_referees(match_id: int) -> list[dict]: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + mr.referee_name, + mr.role + FROM match_referees mr + WHERE mr.match_id = %s + ORDER BY + CASE mr.role + WHEN 'Главный судья' THEN 1 + WHEN 'Ассистент судьи №1' THEN 2 + WHEN 'Ассистент судьи №2' THEN 3 + WHEN 'Резервный судья' THEN 4 + WHEN 'Инспектор' THEN 5 + WHEN 'Делегат' THEN 6 + ELSE 99 + END, + mr.referee_name + """, + (match_id,), + ) + rows = cur.fetchall() + + return [ + { + "referee_name": row[0] or "", + "role": row[1] or "", + } + for row in rows + ] + finally: + conn.close() + + diff --git a/repositories/match_repository.py b/repositories/match_repository.py new file mode 100644 index 0000000..4be9d98 --- /dev/null +++ b/repositories/match_repository.py @@ -0,0 +1,269 @@ +from numpy import place + +from db import get_connection +from repositories.team_repository import get_team_id_by_external_id +from repositories.stadium_repository import get_or_create_stadium + + + +def upsert_match( + external_id: str, + home_team_id: int, + away_team_id: int, + match_date=None, + status: str = "scheduled", + home_score: int | None = None, + away_score: int | None = None, + tour: str | None = None, + season: str | None = None, + place: str | None = None, + stadium_id: int | None = None, + date_raw: str | None = None, + score_add: str | None = None, +) -> None: + query = """ +INSERT INTO matches ( + external_id, + home_team_id, + away_team_id, + match_date, + status, + home_score, + away_score, + tour, + season, + place, + stadium_id, + date_raw, + score_add, + created_at, + updated_at +) +VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) +ON CONFLICT (external_id) +DO UPDATE SET + home_team_id = EXCLUDED.home_team_id, + away_team_id = EXCLUDED.away_team_id, + match_date = EXCLUDED.match_date, + status = EXCLUDED.status, + home_score = EXCLUDED.home_score, + away_score = EXCLUDED.away_score, + tour = EXCLUDED.tour, + season = EXCLUDED.season, + place = EXCLUDED.place, + stadium_id = EXCLUDED.stadium_id, + date_raw = EXCLUDED.date_raw, + score_add = EXCLUDED.score_add, + updated_at = NOW(); + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + query, + ( + external_id, + home_team_id, + away_team_id, + match_date, + status, + home_score, + away_score, + tour, + season, + place, + stadium_id, + date_raw, + score_add, + ), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def upsert_match_by_team_external_ids( + external_id: str, + home_team_external_id: str, + away_team_external_id: str, + match_date=None, + status: str = "scheduled", + home_score: int | None = None, + away_score: int | None = None, + tour: str | None = None, + season: str | None = None, + place: str | None = None, + stadium_id: int | None = None, + date_raw: str | None = None, + score_add: str | None = None, +) -> None: + home_team_id = get_team_id_by_external_id(home_team_external_id) + away_team_id = get_team_id_by_external_id(away_team_external_id) + stadium_id = get_or_create_stadium(place) + + if home_team_id is None: + raise ValueError(f"Home team not found by external_id: {home_team_external_id}") + + if away_team_id is None: + raise ValueError(f"Away team not found by external_id: {away_team_external_id}") + + upsert_match( + external_id=external_id, + home_team_id=home_team_id, + away_team_id=away_team_id, + match_date=match_date, + status=status, + home_score=home_score, + away_score=away_score, + tour=tour, + season=season, + place=place, + stadium_id=stadium_id, + date_raw=date_raw, + score_add=score_add, + ) + + +def clear_match_squad_data(match_id: int) -> None: + 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,)) + cur.execute("DELETE FROM match_lineups WHERE match_id = %s", (match_id,)) + + # если есть таблица судей матча + cur.execute("DELETE FROM match_referees WHERE match_id = %s", (match_id,)) + + # если есть матчевые расстановки + cur.execute("DELETE FROM match_formations WHERE match_id = %s", (match_id,)) + + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def get_match_by_external_id(external_id: str): + query = """ + SELECT id, external_id, home_team_id, away_team_id + FROM matches + WHERE external_id = %s + LIMIT 1; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (str(external_id).strip(),)) + return cur.fetchone() + finally: + conn.close() + + +def mark_match_parsed(external_id: str) -> None: + query = """ + UPDATE matches + SET parsed = TRUE, + parse_error = NULL, + updated_at = NOW() + WHERE external_id = %s; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (str(external_id).strip(),)) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def mark_match_parse_error(external_id: str, error_text: str) -> None: + query = """ + UPDATE matches + SET parsed = FALSE, + parse_error = %s, + updated_at = NOW() + WHERE external_id = %s; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (str(error_text)[:2000], str(external_id).strip())) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def get_tour_schedule_by_match_id(match_id: int) -> list[dict]: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT m.tour + FROM matches m + WHERE m.id = %s + """, + (match_id,), + ) + row = cur.fetchone() + if not row or not row[0]: + return [] + + tour = row[0] + + cur.execute( + """ + SELECT + m.id, + m.external_id, + m.match_date, + m.place, + m.status, + m.home_score, + m.away_score, + ht.name AS home_team_name, + at.name AS away_team_name + FROM matches m + LEFT JOIN teams ht ON ht.id = m.home_team_id + LEFT JOIN teams at ON at.id = m.away_team_id + WHERE m.tour = %s + ORDER BY + m.match_date NULLS LAST, + m.id + """, + (tour,), + ) + rows = cur.fetchall() + + return [ + { + "match_id": r[0], + "match_external_id": r[1], + "match_date": r[2], + "stadium_name": r[3] or "", + "status": r[4] or "", + "home_score": r[5], + "away_score": r[6], + "home_team_name": r[7] or "", + "away_team_name": r[8] or "", + } + for r in rows + ] + finally: + conn.close() diff --git a/repositories/match_session_repository.py b/repositories/match_session_repository.py new file mode 100644 index 0000000..9ba0497 --- /dev/null +++ b/repositories/match_session_repository.py @@ -0,0 +1,209 @@ +import secrets + +from db import get_connection + + +def create_match_session( + match_id: int, + operator_name: str | None = None, + vmix_project_path: str | None = None, +): + session_token = secrets.token_urlsafe(24) + + query = """ + INSERT INTO match_sessions ( + match_id, + operator_name, + session_token, + vmix_project_path, + is_active, + created_at, + updated_at + ) + VALUES (%s, %s, %s, %s, TRUE, NOW(), NOW()) + RETURNING id, match_id, operator_name, session_token, vmix_project_path, is_active, created_at, updated_at; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + query, (match_id, operator_name, session_token, vmix_project_path) + ) + row = cur.fetchone() + conn.commit() + return row + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def get_match_session_by_token(session_token: str): + query = """ + SELECT + ms.id, + ms.match_id, + ms.operator_name, + ms.session_token, + ms.vmix_project_path, + ms.is_active, + ms.created_at, + ms.updated_at, + + m.external_id AS match_external_id, + m.match_date, + m.tour, + m.season, + m.place, + + ht.id AS home_team_id, + ht.name AS home_team_name, + ht.logo_url AS home_team_logo, + + at.id AS away_team_id, + at.name AS away_team_name, + at.logo_url AS away_team_logo + + FROM match_sessions ms + JOIN matches m ON m.id = ms.match_id + JOIN teams ht ON ht.id = m.home_team_id + JOIN teams at ON at.id = m.away_team_id + WHERE ms.session_token = %s + LIMIT 1; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (session_token,)) + return cur.fetchone() + finally: + conn.close() + + +def deactivate_match_session(session_token: str) -> None: + query = """ + UPDATE match_sessions + SET is_active = FALSE, + updated_at = NOW() + WHERE session_token = %s; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (session_token,)) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def update_match_session_vmix_path(session_token: str, vmix_project_path: str) -> None: + query = """ + UPDATE match_sessions + SET vmix_project_path = %s, + updated_at = NOW() + WHERE session_token = %s; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (vmix_project_path, session_token)) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def get_match_by_id(match_id: int): + query = """ + SELECT + m.id, + m.external_id, + m.match_date, + m.tour, + m.season, + ht.name AS home_team_name, + at.name AS away_team_name + FROM matches m + JOIN teams ht ON ht.id = m.home_team_id + JOIN teams at ON at.id = m.away_team_id + WHERE m.id = %s + LIMIT 1; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (match_id,)) + return cur.fetchone() + finally: + conn.close() + + +def list_matches_for_admin(today_only: bool = False, tour: str | None = None): + query = """ + SELECT + m.id, + m.external_id, + m.match_date, + m.status, + m.tour, + m.season, + ht.name AS home_team_name, + at.name AS away_team_name + FROM matches m + JOIN teams ht ON ht.id = m.home_team_id + JOIN teams at ON at.id = m.away_team_id + WHERE 1=1 + """ + params = [] + + if today_only: + query += " AND DATE(m.match_date) = CURRENT_DATE " + + if tour: + query += " AND m.tour = %s " + params.append(tour) + + query += " ORDER BY m.match_date ASC NULLS LAST, m.id ASC; " + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, params) + return cur.fetchall() + finally: + conn.close() + + +def list_available_tours(): + query = """ + SELECT DISTINCT tour + FROM matches + WHERE tour IS NOT NULL AND tour <> ''; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query) + tours = [row[0] for row in cur.fetchall()] + + def extract_tour_number(value: str): + value = str(value).strip() + digits = "".join(ch for ch in value if ch.isdigit()) + return int(digits) if digits else 999999 + + tours.sort(key=lambda x: (extract_tour_number(x), str(x))) + return tours + finally: + conn.close() diff --git a/repositories/match_view_repository.py b/repositories/match_view_repository.py new file mode 100644 index 0000000..b2fd792 --- /dev/null +++ b/repositories/match_view_repository.py @@ -0,0 +1,96 @@ +from db import get_connection + + +def get_match_lineups_grouped(match_id: int, home_team_id: int, away_team_id: int): + query = """ + SELECT + ml.team_id, + ml.player_id, + ml.player_name, + ml.number, + ml.position, + ml.lineup_type, + ml.is_captain, + p.last_name, + p.first_name, + p.position as pos + FROM match_lineups ml + LEFT JOIN players p ON p.id = ml.player_id + WHERE ml.match_id = %s; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (match_id,)) + rows = cur.fetchall() + finally: + conn.close() + + grouped = { + "home_starting": [], + "away_starting": [], + "home_bench": [], + "away_bench": [], + } + + for row in rows: + ( + team_id, + player_id, + player_name, + number, + position, + lineup_type, + is_captain, + last_name, + first_name, + pos, + ) = row + + item = { + "player_id": player_id, + "number": number or "", + "last_name": last_name or "", + "first_name": first_name or "", + "player_name": player_name or "", + "position": position or "", + "is_captain": bool(is_captain), + "pos": pos, + } + + if team_id == home_team_id and lineup_type == "starting": + grouped["home_starting"].append(item) + elif team_id == away_team_id and lineup_type == "starting": + grouped["away_starting"].append(item) + elif team_id == home_team_id and lineup_type == "bench": + grouped["home_bench"].append(item) + elif team_id == away_team_id and lineup_type == "bench": + grouped["away_bench"].append(item) + + def is_goalkeeper(position: str) -> int: + pos = (position or "").strip().lower() + goalkeeper_values = {"вр.", "вр", "вратарь", "goalkeeper", "gk"} + return 0 if pos in goalkeeper_values else 1 + + def player_number_value(number: str) -> int: + number = str(number or "").strip() + return int(number) if number.isdigit() else 999 + + def sort_players(players: list[dict]) -> list[dict]: + return sorted( + players, + key=lambda p: ( + is_goalkeeper(p.get("position", "")), + player_number_value(p.get("number", "")), + p.get("last_name", "") or p.get("player_name", ""), + p.get("first_name", ""), + ), + ) + + grouped["home_starting"] = sort_players(grouped["home_starting"]) + grouped["away_starting"] = sort_players(grouped["away_starting"]) + grouped["home_bench"] = sort_players(grouped["home_bench"]) + grouped["away_bench"] = sort_players(grouped["away_bench"]) + + return grouped \ No newline at end of file diff --git a/repositories/player_repository.py b/repositories/player_repository.py new file mode 100644 index 0000000..3b9f250 --- /dev/null +++ b/repositories/player_repository.py @@ -0,0 +1,373 @@ +from db import get_connection +from repositories.team_repository import get_team_id_by_external_id + + +def normalize_birth_date_for_input(value) -> str: + if not value: + return "" + + text = str(value).strip() + + if not text: + return "" + + # Уже нормальный формат для input[type=date] + if len(text) == 10 and text[4] == "-" and text[7] == "-": + return text + + # Формат ДД.ММ.ГГГГ -> YYYY-MM-DD + if len(text) == 10 and text[2] == "." and text[5] == ".": + dd, mm, yyyy = text.split(".") + return f"{yyyy}-{mm}-{dd}" + + return "" + + +def upsert_player( + external_id: str, + team_external_id: str, + player: str, + lastname: str = "", + name: str = "", + number: str = "", + pos: str = "", + amplua: str = "", + born: str = "", + games: int = 0, + goals: int = 0, + penaltys: int = 0, + assists: int = 0, + yellows: int = 0, + reds: int = 0, + is_active: bool = True, +) -> None: + team_id = get_team_id_by_external_id(str(team_external_id).strip()) + if team_id is None: + raise ValueError(f"Team not found by external_id: {team_external_id}") + + query = """ + INSERT INTO players ( + external_id, + team_id, + full_name, + first_name, + last_name, + number, + position, + is_active, + pos, + amplua, + born, + games, + goals, + penaltys, + assists, + yellows, + reds, + created_at, + updated_at + ) + VALUES ( + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, + NOW(), NOW() + ) + ON CONFLICT (external_id) + DO UPDATE SET + team_id = EXCLUDED.team_id, + full_name = EXCLUDED.full_name, + first_name = EXCLUDED.first_name, + last_name = EXCLUDED.last_name, + number = EXCLUDED.number, + position = EXCLUDED.position, + is_active = EXCLUDED.is_active, + pos = EXCLUDED.pos, + amplua = EXCLUDED.amplua, + born = EXCLUDED.born, + games = EXCLUDED.games, + goals = EXCLUDED.goals, + penaltys = EXCLUDED.penaltys, + assists = EXCLUDED.assists, + yellows = EXCLUDED.yellows, + reds = EXCLUDED.reds, + updated_at = NOW(); + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + query, + ( + str(external_id).strip(), + team_id, + player.strip(), + name.strip(), + lastname.strip(), + str(number).strip(), + amplua.strip(), + is_active, + pos.strip(), + amplua.strip(), + born.strip(), + games, + goals, + penaltys, + assists, + yellows, + reds, + ), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def get_match_by_external_id(external_id: str): + query = """ + SELECT id, external_id, home_team_id, away_team_id + FROM matches + WHERE external_id = %s + LIMIT 1; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (str(external_id).strip(),)) + return cur.fetchone() + finally: + conn.close() + + +def mark_match_parsed(external_id: str) -> None: + query = """ + UPDATE matches + SET parsed = TRUE, + parse_error = NULL, + updated_at = NOW() + WHERE external_id = %s; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (str(external_id).strip(),)) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def mark_match_parse_error(external_id: str, error_text: str) -> None: + query = """ + UPDATE matches + SET parsed = FALSE, + parse_error = %s, + updated_at = NOW() + WHERE external_id = %s; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (str(error_text)[:2000], str(external_id).strip())) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def get_player_id_by_name_and_team(full_name: str, team_id: int) -> int | None: + query = """ + SELECT id + FROM players + WHERE team_id = %s + AND LOWER(full_name) = LOWER(%s) + LIMIT 1; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (team_id, full_name.strip())) + row = cur.fetchone() + return row[0] if row else None + finally: + conn.close() + + +def get_player_id_by_external_id(external_id: str) -> int | None: + query = """ + SELECT id + FROM players + WHERE external_id = %s + LIMIT 1; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (str(external_id).strip(),)) + row = cur.fetchone() + return row[0] if row else None + finally: + conn.close() + + +from db import get_connection + + +def search_players_for_admin(q: str = "") -> list[dict]: + conn = get_connection() + try: + with conn.cursor() as cur: + if q.strip(): + pattern = f"%{q.strip()}%" + cur.execute( + """ + SELECT + p.id, + p.full_name, + p.first_name, + p.last_name, + p.external_id, + p.position + FROM players p + WHERE + p.full_name ILIKE %s + OR p.first_name ILIKE %s + OR p.last_name ILIKE %s + OR COALESCE(p.external_id, '') ILIKE %s + ORDER BY p.full_name ASC, p.id ASC + LIMIT 200 + """, + (pattern, pattern, pattern, pattern), + ) + else: + cur.execute( + """ + SELECT + p.id, + p.full_name, + p.first_name, + p.last_name, + p.external_id, + p.position + FROM players p + ORDER BY p.id DESC + LIMIT 200 + """ + ) + + rows = cur.fetchall() + + return [ + { + "id": row[0], + "full_name": row[1] or "", + "first_name": row[2] or "", + "last_name": row[3] or "", + "external_id": row[4] or "", + "position": row[5] or "", + } + for row in rows + ] + finally: + conn.close() + + +def get_player_by_id(player_id: int) -> dict | None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + p.id, + p.full_name, + p.first_name, + p.last_name, + p.external_id, + p.position, + p.born, + p.photo, + p.video + FROM players p + WHERE p.id = %s + LIMIT 1 + """, + (player_id,), + ) + row = cur.fetchone() + + if not row: + return None + + return { + "id": row[0], + "full_name": row[1] or "", + "first_name": row[2] or "", + "last_name": row[3] or "", + "external_id": row[4] or "", + "position": row[5] or "", + "birth_date": normalize_birth_date_for_input(row[6]), + "photo": row[7] or "", + "video": row[8] or "", + } + finally: + conn.close() + + +def update_player_admin( + player_id: int, + full_name: str = "", + first_name: str = "", + last_name: str = "", + external_id: str = "", + position: str = "", + birth_date: str = "", + photo: str = "", + video: str = "", +) -> None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE players + SET + full_name = %s, + first_name = %s, + last_name = %s, + external_id = NULLIF(%s, ''), + position = %s, + born = NULLIF(%s, '')::date, + photo = NULLIF(%s, ''), + video = NULLIF(%s, '') + WHERE id = %s + """, + ( + full_name.strip(), + first_name.strip(), + last_name.strip(), + external_id.strip(), + position.strip(), + birth_date.strip(), + photo.strip(), + video.strip(), + player_id, + ), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() diff --git a/repositories/referee_repository.py b/repositories/referee_repository.py new file mode 100644 index 0000000..74335c2 --- /dev/null +++ b/repositories/referee_repository.py @@ -0,0 +1,268 @@ +from db import get_connection + + +def upsert_referee( + full_name: str, + lastname: str = "", + name: str = "", + middle_name: str = "", + city: str = "", + external_id: str | None = None, + is_active: bool = True, +) -> int: + query = """ + INSERT INTO referees ( + external_id, + full_name, + lastname, + name, + middle_name, + city, + is_active, + created_at, + updated_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) + ON CONFLICT (external_id) + DO UPDATE SET + full_name = EXCLUDED.full_name, + lastname = EXCLUDED.lastname, + name = EXCLUDED.name, + middle_name = EXCLUDED.middle_name, + city = EXCLUDED.city, + is_active = EXCLUDED.is_active, + updated_at = NOW() + RETURNING id; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + query, + ( + str(external_id).strip() if external_id else None, + full_name.strip(), + lastname.strip(), + name.strip(), + middle_name.strip(), + city.strip(), + is_active, + ), + ) + row = cur.fetchone() + conn.commit() + return row[0] + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def get_referee_id_by_name(full_name: str) -> int | None: + query = """ + SELECT id + FROM referees + WHERE LOWER(full_name) = LOWER(%s) + LIMIT 1; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (full_name.strip(),)) + row = cur.fetchone() + return row[0] if row else None + finally: + conn.close() + + +def search_referees_for_admin(q: str = "") -> list[dict]: + conn = get_connection() + try: + with conn.cursor() as cur: + if q.strip(): + pattern = f"%{q.strip()}%" + cur.execute( + """ + SELECT + r.id, + r.full_name, + r.external_id + FROM referees r + WHERE + r.full_name ILIKE %s + OR COALESCE(r.external_id, '') ILIKE %s + ORDER BY r.full_name ASC, r.id ASC + LIMIT 200 + """, + (pattern, pattern), + ) + else: + cur.execute( + """ + SELECT + r.id, + r.full_name, + r.external_id + FROM referees r + ORDER BY r.id DESC + LIMIT 200 + """ + ) + rows = cur.fetchall() + + return [ + { + "id": row[0], + "full_name": row[1] or "", + "external_id": row[2] or "", + } + for row in rows + ] + finally: + conn.close() + + +def get_referee_by_id(referee_id: int) -> dict | None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + r.id, + r.full_name, + r.external_id + FROM referees r + WHERE r.id = %s + LIMIT 1 + """, + (referee_id,), + ) + row = cur.fetchone() + + if not row: + return None + + return { + "id": row[0], + "full_name": row[1] or "", + "external_id": row[2] or "", + } + finally: + conn.close() + + +def update_referee_admin( + referee_id: int, + full_name: str = "", + external_id: str = "", +) -> None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE referees + SET + full_name = %s, + external_id = NULLIF(%s, '') + WHERE id = %s + """, + ( + full_name.strip(), + external_id.strip(), + referee_id, + ), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def get_all_referees() -> list[dict]: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + r.id, + r.full_name, + r.external_id + FROM referees r + WHERE COALESCE(r.is_active, TRUE) = TRUE + ORDER BY r.full_name ASC, r.id ASC + """ + ) + rows = cur.fetchall() + + return [ + { + "referee_id": row[0], + "id": row[0], + "referee_name": row[1] or "", + "full_name": row[1] or "", + "external_id": row[2] or "", + } + for row in rows + ] + finally: + conn.close() + + +def replace_match_referees(match_id: int, rows: list[dict]) -> None: + delete_query = "DELETE FROM match_referees WHERE match_id = %s;" + insert_query = """ + INSERT INTO match_referees ( + match_id, + referee_id, + referee_name, + role, + source, + created_at, + updated_at + ) + VALUES (%s, %s, %s, %s, %s, NOW(), NOW()); + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(delete_query, (match_id,)) + + for row in rows or []: + referee_id = row.get("referee_id") or row.get("id") + referee_name = ( + row.get("referee_name") + or row.get("full_name") + or row.get("name") + or "" + ).strip() + role = (row.get("role") or "").strip() + + if not referee_id or not referee_name or not role: + continue + + cur.execute( + insert_query, + ( + match_id, + referee_id, + referee_name, + role, + row.get("source", "admin"), + ), + ) + + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() \ No newline at end of file diff --git a/repositories/stadium_repository.py b/repositories/stadium_repository.py new file mode 100644 index 0000000..d7d39cc --- /dev/null +++ b/repositories/stadium_repository.py @@ -0,0 +1,291 @@ +from db import get_connection + + +def search_stadiums_for_admin(q: str = "") -> list[dict]: + conn = get_connection() + try: + with conn.cursor() as cur: + if q.strip(): + pattern = f"%{q.strip()}%" + cur.execute( + """ + SELECT + s.id, + s.name, + s.stadium_gfx, + s.city, + s.address, + s.external_id + FROM stadiums s + WHERE + s.name ILIKE %s + OR COALESCE(s.stadium_gfx, '') ILIKE %s + OR COALESCE(s.city, '') ILIKE %s + OR COALESCE(s.address, '') ILIKE %s + OR COALESCE(s.external_id, '') ILIKE %s + ORDER BY s.name ASC, s.id ASC + LIMIT 200 + """, + (pattern, pattern, pattern, pattern, pattern), + ) + else: + cur.execute( + """ + SELECT + s.id, + s.name, + s.stadium_gfx, + s.city, + s.address, + s.external_id + FROM stadiums s + ORDER BY s.id DESC + LIMIT 200 + """ + ) + + rows = cur.fetchall() + + return [ + { + "id": row[0], + "name": row[1] or "", + "stadium_gfx": row[2] or "", + "city": row[3] or "", + "address": row[4] or "", + "external_id": row[5] or "", + } + for row in rows + ] + finally: + conn.close() + + +def get_stadium_by_id(stadium_id: int) -> dict | None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + s.id, + s.name, + s.stadium_gfx, + s.city, + s.address, + s.external_id + FROM stadiums s + WHERE s.id = %s + LIMIT 1 + """, + (stadium_id,), + ) + row = cur.fetchone() + + if not row: + return None + + return { + "id": row[0], + "name": row[1] or "", + "stadium_gfx": row[2] or "", + "city": row[3] or "", + "address": row[4] or "", + "external_id": row[5] or "", + } + finally: + conn.close() + + +def get_stadium_by_name(name: str): + stadium_name = (name or "").strip() + if not stadium_name: + return None + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + s.id, + s.name, + s.stadium_gfx, + s.city, + s.address, + s.external_id + FROM stadiums s + WHERE LOWER(s.name) = LOWER(%s) + LIMIT 1 + """, + (stadium_name,), + ) + return cur.fetchone() + finally: + conn.close() + + +def get_stadium_by_external_id(external_id: str): + ext_id = (external_id or "").strip() + if not ext_id: + return None + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + s.id, + s.name, + s.stadium_gfx, + s.city, + s.address, + s.external_id + FROM stadiums s + WHERE s.external_id = %s + LIMIT 1 + """, + (ext_id,), + ) + return cur.fetchone() + finally: + conn.close() + + +def create_stadium( + name: str, + stadium_gfx: str | None = None, + external_id: str | None = None, + city: str | None = None, + address: str | None = None, +) -> int: + stadium_name = (name or "").strip() + if not stadium_name: + raise ValueError("Stadium name is required") + + ext_value = (external_id or "").strip() or None + city_value = (city or "").strip() or None + address_value = (address or "").strip() or None + gfx_value = (stadium_gfx or "").strip() or None + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO stadiums ( + name, + stadium_gfx, + external_id, + city, + address, + created_at, + updated_at + ) + VALUES (%s, %s, %s, %s, %s, NOW(), NOW()) + RETURNING id + """, + ( + stadium_name, + gfx_value, + ext_value, + city_value, + address_value, + ), + ) + stadium_id = cur.fetchone()[0] + + conn.commit() + return stadium_id + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def get_or_create_stadium( + name: str, + external_id: str | None = None, + city: str | None = None, + address: str | None = None, +) -> int | None: + stadium_name = (name or "").strip() + if not stadium_name: + return None + + ext_id = (external_id or "").strip() + + if ext_id: + existing_by_external = get_stadium_by_external_id(ext_id) + if existing_by_external: + return existing_by_external[0] + + existing_by_name = get_stadium_by_name(stadium_name) + if existing_by_name: + return existing_by_name[0] + + return create_stadium( + name=stadium_name, + stadium_gfx=stadium_name, + external_id=ext_id or None, + city=city, + address=address, + ) + + +def update_stadium_admin( + stadium_id: int, + stadium_gfx: str = "", + city: str = "", + address: str = "", + external_id: str = "", +) -> None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE stadiums + SET + stadium_gfx = %s, + city = %s, + address = %s, + external_id = NULLIF(%s, ''), + updated_at = NOW() + WHERE id = %s + """, + ( + stadium_gfx.strip(), + city.strip(), + address.strip(), + external_id.strip(), + stadium_id, + ), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def get_stadium_display_name_by_id(stadium_id: int) -> str | None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT COALESCE(NULLIF(stadium_gfx, ''), name) + FROM stadiums + WHERE id = %s + LIMIT 1 + """, + (stadium_id,), + ) + row = cur.fetchone() + return row[0] if row else None + finally: + conn.close() diff --git a/repositories/standings_repository.py b/repositories/standings_repository.py new file mode 100644 index 0000000..8ce0b98 --- /dev/null +++ b/repositories/standings_repository.py @@ -0,0 +1,153 @@ +from db import get_connection +from repositories.team_repository import get_team_id_by_external_id + + +def replace_standings_for_season(season: str, standings_rows: list[dict]) -> None: + delete_query = """ + DELETE FROM standings + WHERE season = %s; + """ + + insert_query = """ + INSERT INTO standings ( + team_id, + season, + played, + wins, + losses, + draws, + points_for, + points_against, + points, + position, + created_at, + updated_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()); + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(delete_query, (season,)) + + for row in standings_rows: + team_external_id = row["team_external_id"] + team_id = get_team_id_by_external_id(team_external_id) + + if team_id is None: + raise ValueError(f"Team not found by external_id: {team_external_id}") + + cur.execute( + insert_query, + ( + team_id, + season, + row.get("played", 0), + row.get("wins", 0), + row.get("losses", 0), + row.get("draws", 0), + row.get("points_for", 0), + row.get("points_against", 0), + row.get("points", 0), + row.get("position"), + ), + ) + + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def get_standings_by_season(season: str): + query = """ + SELECT + s.id, + s.team_id, + t.name AS team_name, + s.season, + s.played, + s.wins, + s.losses, + s.draws, + s.points_for, + s.points_against, + s.points, + s.position, + s.created_at, + s.updated_at + FROM standings s + JOIN teams t ON t.id = s.team_id + WHERE s.season = %s + ORDER BY s.position ASC NULLS LAST, s.id ASC; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (season,)) + return cur.fetchall() + finally: + conn.close() + + +def get_standings_by_match_id(match_id: int) -> list[dict]: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT m.season, m.tour + FROM matches m + WHERE m.id = %s + """, + (match_id,), + ) + base = cur.fetchone() + if not base: + return [] + + season = base[0] + + cur.execute( + """ + SELECT + s.position, + t.logo_url, + t.name, + s.played, + s.wins, + s.draws, + s.losses, + s.points_for, + s.points_against, + s.points + FROM standings s + JOIN teams t ON t.id = s.team_id + WHERE s.season = %s + ORDER BY s.position ASC, s.points DESC, s.team_id ASC + """, + (season,), + ) + rows = cur.fetchall() + + return [ + { + "position": r[0], + "team_logo": r[1] or "", + "team_name": r[2] or "", + "played": r[3] or 0, + "wins": r[4] or 0, + "draws": r[5] or 0, + "losses": r[6] or 0, + "goals_for": r[7] or 0, + "goals_against": r[8] or 0, + "points": r[9] or 0, + } + for r in rows + ] + finally: + conn.close() \ No newline at end of file diff --git a/repositories/team_coach_repository.py b/repositories/team_coach_repository.py new file mode 100644 index 0000000..9f49fb4 --- /dev/null +++ b/repositories/team_coach_repository.py @@ -0,0 +1,50 @@ +from repositories.match_coach_repository import get_match_coaches_grouped +from db import get_connection + +def _normalize_coach(c: dict) -> dict: + return { + "coach_id": c.get("coach_id"), + "coach_name": c.get("coach_name", "") or "", + "role": c.get("role", "") or "", + } + + +def get_team_coaches_for_match_editor( + team_id: int, + match_id: int | None = None, + home_team_id: int | None = None, + away_team_id: int | None = None, +) -> list[dict]: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + c.id AS coach_id, + COALESCE(c.player, c.name, '') AS coach_name, + COALESCE(c.amplua, '') AS role + FROM coaches c + WHERE c.team_id = %s + ORDER BY + CASE + WHEN LOWER(COALESCE(c.amplua, '')) LIKE '%%глав%%' THEN 0 + ELSE 1 + END, + COALESCE(c.player, c.name, ''), + c.id + """, + (team_id,), + ) + rows = cur.fetchall() + + return [ + { + "coach_id": row[0], + "coach_name": row[1] or "", + "role": row[2] or "", + } + for row in rows + ] + finally: + conn.close() \ No newline at end of file diff --git a/repositories/team_repository.py b/repositories/team_repository.py new file mode 100644 index 0000000..dfd6da8 --- /dev/null +++ b/repositories/team_repository.py @@ -0,0 +1,240 @@ +from db import get_connection + + +def upsert_team( + external_id: str, + name: str, + short_name: str | None = None, + logo_url: str | None = None, + games: int | None = 0, + wins: int | None = 0, + goals: int | None = 0, + tournaments: int | None = 0, +) -> None: + query = """ + INSERT INTO teams ( + external_id, + name, + short_name, + logo_url, + games, + wins, + goals, + tournaments, + created_at, + updated_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) + ON CONFLICT (external_id) + DO UPDATE SET + name = EXCLUDED.name, + short_name = EXCLUDED.short_name, + logo_url = EXCLUDED.logo_url, + games = EXCLUDED.games, + wins = EXCLUDED.wins, + goals = EXCLUDED.goals, + tournaments = EXCLUDED.tournaments, + updated_at = NOW(); + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + query, + ( + external_id, + name, + short_name, + logo_url, + games, + wins, + goals, + tournaments, + ), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def get_team_external_id_by_name(name: str) -> str | None: + query = """ + SELECT external_id + FROM teams + WHERE LOWER(name) = LOWER(%s) + LIMIT 1; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (name,)) + row = cur.fetchone() + return row[0] if row else None + finally: + conn.close() + +def get_team_id_by_external_id(external_id: str) -> int | None: + external_id = str(external_id).strip() + + query = """ + SELECT id + FROM teams + WHERE TRIM(external_id) = %s + LIMIT 1; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + print(f"[get_team_id_by_external_id] search external_id = '{external_id}'") + cur.execute(query, (external_id,)) + row = cur.fetchone() + print(f"[get_team_id_by_external_id] result = {row}") + return row[0] if row else None + finally: + conn.close() + + +def search_teams_for_admin(q: str = "") -> list[dict]: + conn = get_connection() + try: + with conn.cursor() as cur: + if q.strip(): + pattern = f"%{q.strip()}%" + cur.execute( + """ + SELECT + t.id, + t.name, + t.full_name, + t.short_name_3, + t.city, + t.logo_path, + t.external_id + FROM teams t + WHERE + t.name ILIKE %s + OR COALESCE(t.full_name, '') ILIKE %s + OR COALESCE(t.short_name_3, '') ILIKE %s + OR COALESCE(t.city, '') ILIKE %s + OR COALESCE(t.external_id, '') ILIKE %s + ORDER BY t.name ASC, t.id ASC + LIMIT 200 + """, + (pattern, pattern, pattern, pattern, pattern), + ) + else: + cur.execute( + """ + SELECT + t.id, + t.name, + t.full_name, + t.short_name_3, + t.city, + t.logo_path, + t.external_id + FROM teams t + ORDER BY t.id DESC + LIMIT 200 + """ + ) + rows = cur.fetchall() + + return [ + { + "id": row[0], + "name": row[1] or "", + "full_name": row[2] or "", + "short_name_3": row[3] or "", + "city": row[4] or "", + "logo_path": row[5] or "", + "external_id": row[6] or "", + } + for row in rows + ] + finally: + conn.close() + +def get_team_by_id(team_id: int) -> dict | None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + t.id, + t.name, + t.full_name, + t.short_name_3, + t.city, + t.logo_path, + t.external_id + FROM teams t + WHERE t.id = %s + LIMIT 1 + """, + (team_id,), + ) + row = cur.fetchone() + + if not row: + return None + + return { + "id": row[0], + "name": row[1] or "", + "full_name": row[2] or "", + "short_name_3": row[3] or "", + "city": row[4] or "", + "logo_path": row[5] or "", + "external_id": row[6] or "", + } + finally: + conn.close() + +def update_team_admin( + team_id: int, + name: str = "", + full_name: str = "", + short_name_3: str = "", + city: str = "", + logo_path: str = "", + external_id: str = "", +) -> None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE teams + SET + name = %s, + full_name = %s, + short_name_3 = %s, + city = NULLIF(%s, ''), + logo_path = %s, + external_id = NULLIF(%s, '') + WHERE id = %s + """, + ( + name.strip(), + full_name.strip(), + short_name_3.strip().upper(), + city.strip(), + logo_path.strip(), + external_id.strip(), + team_id, + ), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() \ No newline at end of file diff --git a/repositories/team_squad_repository.py b/repositories/team_squad_repository.py new file mode 100644 index 0000000..98a4848 --- /dev/null +++ b/repositories/team_squad_repository.py @@ -0,0 +1,58 @@ +from db import get_connection + + +def get_team_players_for_match_editor( + team_id: int, + match_id: int | None = None, + home_team_id: int | None = None, + away_team_id: int | None = None, +) -> list[dict]: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + p.id AS player_id, + COALESCE( + p.full_name, + TRIM(COALESCE(p.last_name, '') || ' ' || COALESCE(p.first_name, '')) + ) AS player_name, + COALESCE(p.last_name, '') AS last_name, + COALESCE(p.first_name, '') AS first_name, + COALESCE(p.number::text, '') AS number, + COALESCE(p.position, '') AS position, + FALSE AS is_captain + FROM players p + WHERE p.team_id = %s + ORDER BY + CASE + WHEN LOWER(COALESCE(p.position, '')) IN ('вр', 'вр.', 'gk', 'goalkeeper', 'вратарь') THEN 0 + ELSE 1 + END, + CASE + WHEN COALESCE(p.number::text, '') ~ '^[0-9]+$' THEN p.number::integer + ELSE 999 + END, + COALESCE(p.last_name, ''), + COALESCE(p.first_name, ''), + p.id + """, + (team_id,), + ) + rows = cur.fetchall() + + return [ + { + "player_id": row[0], + "player_name": row[1] or "", + "last_name": row[2] or "", + "first_name": row[3] or "", + "number": row[4] or "", + "position": row[5] or "", + "is_captain": bool(row[6]), + } + for row in rows + ] + finally: + conn.close() \ No newline at end of file diff --git a/scripts/__pycache__/create_admin.cpython-312.pyc b/scripts/__pycache__/create_admin.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1a9135de311b0f6d2e3172584c9bcfb88a99bfac GIT binary patch literal 2589 zcmb7FUrbY17(e&_ZEwr9P?$iRcLLH@WMPI4QA}8Y!J@5!G6Q3|sl6AuthC*|7qO%y zm~2s#EkayUlEtJx>B1g-bZ>J!?gdvC-4T;Hec0X_H8F9?vU6^GDIlBfG(G40^ZUMY z&Ue1=yFa(h zth`P-%Ct?XU`M22>&xr^K(fn3(Qf-n6i>^Ia_F>FoS$-xI*^S19E>^@2F-%%QrJ&@ z0O&jkRAITaJps~0n(%XfVm{<2Aa_PJQ&Gb}q8=r}zb4Gz2@xf=NXkJJol5%!uh)$~4Aex*9cAIjT%) ziPlsaRv;U>;jm?`fbBb?m19SD3oNSB>hhdA_EMUX*UE^O12d$6p$gd=;+NlbE5@hHqJIj znj7W5aVE+EGE_St&^+nhc5uYnxOOl{XXr`sWrV_HK!zOOC>rF6p7gwovpvl*8Ah+! zbSE|gz5xoCVH6%m9MO)eO!+PeaTC^QVTG%k;(!_q`q`ymHJ&Xzx>wb|p9aUV0ki>w zm_hAy1jT^>U+z0cjer^ACHa0^{wkj{pom1pIO`lnwSlXoQ<`<2EL8}iy|!G^3`ti% zo7Qwynf2O;Ez&=n(hg%FgbFqy4TK_L$*L9^m!To~@Pc=2k?e|;aqnzFKILf4)Xu8@ z9UXqueSElgNb)rqEfW4m28VlhMutODFdXVTJ`jwQxARLqVJQ@j^bdqS*wO!BZ)A8d zgtU^T9N7^uTY;k@6JO*kE>>EBAHMj6N@Je)z3oS z4xf(m2D-Pz5Yo64K??bSl%AY1@aMPqHhLX~(^F9`p$8C&`YbT;Cmi)C(Yr8~7x zeRo7(UJT6luZuMcnY`F|R}9<{19`D!zWgRh3WEE2l-JW}%>TLaD{nvYP4fl3cWrandKr+?43ocOe=F;4%_uw;;_f!@t z!5eki_+tD@Vu5;CRkQA`T?*akxDmM7d#mAA^!xp{cK_g6tqcC*?Rkt;eYuWYV72Aw zYF*cw_t^8jpmOhX3RF}U8iB)o*?GzNmFsx}5-#j{VaYE>{@SE^VRWI3-)DwMpG#@p+HX2taQf~<62ymW!;PM)hLzDYBAMljZT{ra)~QvgY`<- z2-gZS+Kr5?reAzX{^(RvnU1T+^g~F2yS4ENnhF#_5ch%OK5#w&^}hk?0)L*rAf6W& z$Sk+WWyM7?M_%Wyao5Fb;!Qp;9nSCSTmwfQ0r3HN`w7bt>=W-kqPp-Q0ch97*`KM} QUuph}z5)g4I&1rX0JOm-sQ>@~ literal 0 HcmV?d00001 diff --git a/scripts/__pycache__/create_admin.cpython-313.pyc b/scripts/__pycache__/create_admin.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a5a91c30f4f918a31ca590c53c05ea7b4ae446be GIT binary patch literal 603 zcmey&%ge<81gX7;GhG=O7#@Q-Fu)FFd|n1*Ol1gW2xjzV@M0`tRA7ig{duKi(v=a!vf<2?FI2!V_5hYg4tr&fF!#y zT(ts2I)^6bOOU;qjJJ3*5{on93lfWq%kzs;s<=XnQ;YHvb5pGps<;B6qCmDRS8$+@ zLRx;2LS|lONoHb>LSjm8W}ZSRP`MRXm9?i|uxn6=f~Q}IKSU-TBvY)Q0irOeA@qw9i07a%VR5EBX`mJOrVgm9$gVg*|aJGtZ&QD2=33u~} zDNZda%S=u!j){-Y%*!l^kJl@xyv1RYo1apelWJGQ3e>;|#Kj=SM`lJw#v*1Q3jj&U B8(07U literal 0 HcmV?d00001 diff --git a/services/__pycache__/auth_service.cpython-312.pyc b/services/__pycache__/auth_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6d1532ae1531c8fae19d014700197c2e1424a826 GIT binary patch literal 5734 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 F{|z9Z{`dd@ literal 0 HcmV?d00001 diff --git a/services/__pycache__/game_service.cpython-312.pyc b/services/__pycache__/game_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4ab551a1ac190c17f8e3d2f18ce5f9e2664e765f GIT binary patch literal 5277 zcmd5=-)|d99iO$=_O92zf9IDIk~EEzTm-qaT+#@{l~!#pO?$`D9->G(EAp^q zEB|Pj7g7SR@Cgfwlj#dtz9>RGCs`uLOJa)82`NS7CDi?bzRd+ZzK7`3c^Vu;rL=DA75sG5lc;s0w$RadVc%3j5v6pGFwTEmbCt@4aE%Waml%*E)FeM5ZB@YwS z;bEGiqKp-hgY&g~kV#NUsN38`?wffW5n17(lF2P0H^0Ic_gSZ=T@Z8Wo5-oDblRoK zTMHtWrZG6iT+R+fWf|t?(hNE7R`cMe7611B<{)=kp$vrz9=GTcqcXBbAy!%wC>8t^ z{LEtL?@}r=PXkb5OU@Ekaw#^$Qe_Pr3M_$=Tcy4YzF)?c9s!iRs^=D7^KcW)d7(#P z4Xx@CEacW3^Wo9nRj8(Vsa*r*#3|qKwxefjPQ)N|7#cKVjOS@rdJOziq2<*eQ$lU*s2CC(yN5=JEM8|@#9<*Q2j^d8qRntajx;3Z?a$D4&<7E8o;?G*rs{_}B*EQOV*Ayvahgh~-Lvg%UEV6Ca)U-;T|!DG%KqK4Jbk>}qYd;dc-tK-<; zpaBaMi~w6Jv8vn1pA=AicrW2xR08si3%^-kXrI>Qc;8mcUHtCoVk^MREVoX|!-HEMGE8w>E zU9dtWB6A<~T_y0NFPB&P7W2zFp>Lts*M>!=`%s_-*4m_qYsm<7L-yRVv>-yn6!LOL z$>ej$nUPazzM$}#9AZoyW7jYvOt6U2F%O264ubgzwq#962)pqvAPE?Zla7Q4aXS7= zDa8U_P|h#Iw1{jv@bT+tu-!kWF4EkiVLmG+?2rTw4v@(hU7HbLg2+ZlH$)UJ0~wNm z7_+g9%h{}kPZKt6JaQTqc<|VP!TQuR0bz(H;0w`mMi3JrNH7y}-r%##;spYZp`8e< z7>Pmx!XUvv$j%oEVopHxLO!2`3+dl}jFbAy9qqE^^7g-r`G93^ryuA0WtilH9BabjS_3Pg!Ad5ijvYhMnYpPoOT z7sUB@F3!$lxc+`7Ez0vt8vuHyWNBJuG96Np?dGBO{v;DS?}z7G_o~g-3rtVEu!oxdUq7j|gK2w>Rt-gA- z7M&|!+U0^3uB#E9-2U!wW~yf{eL7Z)zJtX~;3?BlWjb1S4&S`^z~*c<{d0uWt-}YA>H(vw!$T z(+WcIzi5`u9p=^NcEI+o-`?upVa_&~j!Ng~ADOX#HMr0}fQ8H$uk?QPR|DJpFNb!R zsXF8SchiRDUusG2_sq9!*F4~fhrKpoeG-jad&T2ZpptsVOUJB}OYYhbGqxVVfbTIa>8Wy{43SVU#^nSllm3Xwa1I zJKU<*rlzPDF7i(U8Zf2%_AhusN*p?Mgyj*gXqE6aGJwuzWChVeCaq|B{}Zr>#Q;|Lj^n>Z9jWA6+2*otkp|K3`hu7YI;B2xQ>oizSV_hH^T~i*6G~F8O eF<7t0`hYjMHhJ$>)331sV0S(b5)!_Nz5Nd!`2uhN literal 0 HcmV?d00001 diff --git a/services/__pycache__/players_service.cpython-312.pyc b/services/__pycache__/players_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..20e834590474463b467439760f749f91c130fc14 GIT binary patch literal 2560 zcmai0O-vg{6rT0|+Sta%*w_XWij+{3Knqb&L#rH;G*P379#W|dBC;0mLKgW?Giwq@ zM&gh|s-QMj4mMIHe5$DEA?KcY@1?CR)fy#-s=f5KDNzr-v~Sj4f&q(>_Pv>JzW3hj z%-c8nlh5Zt(Ej`O2V+Y@=npn<8jS6>e+5((X-MNTD9bH!9P7I>t}MUEL*K3Oy0GHX zgva8dsEN9yNxH1@n!F-u%41JtR%OP6ev$jOhphzfyxHaj8(&=ypc1LE-J38nW9c*OW@6}zx4J$ z6QADt%{%gocckGR-Tb%}8{J84C%!qeb@55@>HN>arQKB!`(TJ@z|_@jN`GkTIHzV( zhDO~sqN%33(6zljpxfJPfrRbl)($q7d1^O}bGG;)M_e3QMH_GkCo)~fn0(oF9X;dj z-9~6bFb|GMiyBDd%fg9X1WnxQB*Ak|lgbFB+=DB3ag`n{rHke6!E$%8JUv*RE>>R; zR$mv(+k@rpV)=Tod|fQRd639G_Vs1v>o<$jz zPv#uT$sUYD`PhSTC>MJ$4rN6T#-Z%e!ru?=Dedo>Lt767R6@zv0+l=Eh{|>i)(P$F zsvGTTRzeUCfH{@g2vssFF>`8Gr-Ch|Trs<>W6Brugi30*kSVHESkB`d6<2{qK;_j8 zp-MrAqGXl`l~qCvVnU~+XEON*gbG;K2z47IrKU||UH^y6nBv&u*Vq?Aji z?AnR*1j;F%1lS7?v7k^wiS`eDo&7Rf`=sGN_pATH?)c`-6QDl_Vo#)(O89RSbbDIS z(H+mWr~cs!S2KFi;sN?SF=dIMEeQ?AtMirl+WlQuGj!gP8K|JhNOiHYSoiE+ZHC{o z+zj-ffoL^eiPvt_uQr1dRv!bsN6dW;^rPTV)l>1*F4f1IfwNXW0|Q5xK?V*S@fiZ} z%^(WJYLT7zcD#PG5xdYFn6knQj~vH`7#@Yy)^6mk9KDp;ptW|QjJw& zwYmCiBY5t}sM{_Q|0n2%xI6^H{c!S04zs%{>87Jthh#U*JT`PP-T6`uu#%mAG;shh zQahUuppu=KnEi&e^OXIBP(d@&rk!bcg3Yj~?R-=|%I39VM!$lm0b^G+nE_$(9LK#v jAG|_yuO)$-tMmH^l;t1iJau9rP%lhd{V2R)BirTw*e($@ literal 0 HcmV?d00001 diff --git a/services/__pycache__/schedule_service.cpython-312.pyc b/services/__pycache__/schedule_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f2ffa8fb8a6cfe846c97599d66fb7a557aa80142 GIT binary patch literal 1139 zcmah|zi-n(6uwKGG;xxaB+4&npj4>TRh0z^sR%)(Rxp6NLaQLl^&P}0iKDv<#id9L z3=Axp93+N}K>Js)vQT-zI$&aAD=K1O;?7Qgh^opPzW45Z_dUJyd;XNks6g14#gEop z6o4P{aA3+X*q;%B3lKoC30iOsLVzMTf}<6YM^U6aR8Z`tvZgqS7XK+l8srzd9f}Fl zTLv>5`nsbtY_xRzlwr~~n!1H3PX`>PIx?8S;{>xVX|I@2Rv21Vc+nQvUl&;yY=LF) z-~#~9Av?Ua1rd~u2p<{xps_9ZSStTBq-+nBr|>Zp-;RW$Q*MlnsE`=JCr9w=2tGA} zPmka;2mH?Xb~=111h01#-~er6g{O|qh!+}m3!fMrFBluf$%%3`9JUxI=5fjl)}=g2 z%?3u@CPrKhic!-hn5#jBFjkn|B|?A=YPWf;(=<%X6S9grAgQ6IfF^BRP*y?ccHh?zdv~9<*)e1nd5P`=vI1_{#7qKeW>vAGk&h* zYCWxg+sj?@3uoQc-fDl-E6n-DbFS611}U#N|EH2ks=uNjcYa^=2ecTk65O$=#cX0> zS_`Lkm~z6;q%!Q2OL=Zt^ z(x!DviEyMNTS*KcQUiAvLu|X4~wW^S5d3zZpbSujaJr2 z8@peg>+uP4#OG!Q&rVgOQ;4K0&vgIk46KH6l?KD&NNK}F6zZw^fWQ(K!G4RGKWe9< p8_Q!@!_?la_Rx)8O6eOp9dkzc-NBe34B7Dfu`msv^rvN}I=_(+dU5~& literal 0 HcmV?d00001 diff --git a/services/__pycache__/teams_service.cpython-312.pyc b/services/__pycache__/teams_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..63628fe8294e7753d09aa90ada74f161a9bf3194 GIT binary patch literal 932 zcmbtSzi(1O9KYio@OVfnR2phYtcf+g8nHv;P#cq`jp<-InbjXJ;pLkAS#9)tkvLVGLAKj_7V2KLVKu;%)n1YGNA~Uii}X6 z<-{roLm|szi(4W<2`^E1Cnw8IWky<>{DvzsS_c)d1u``NUO+xQRe>0)f>Qe?dei_{ zypdGkGykc5rm<|U?Hl_?kojcu{`Ms6f4PARaDx}%>gRJmhT6gLG!VRfhd>#a#*PS+ zBTlGmIawPEfG~*g~rzV z?c?oc=F>(ud9Rba|M_}1vHoX94;jBS5M3C`c7W*@NlJ@-py-wAST z#s$K5j*rIq0O+hF)2IZYuNORAa>xUkkgi%kc1^-SgAjfPtAl_6jmByd9j^_5G+Q@* ID>X{+4{V9iLI3~& literal 0 HcmV?d00001 diff --git a/services/__pycache__/vmix_json_service.cpython-312.pyc b/services/__pycache__/vmix_json_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..28ef24bc232db6f7206c9a097b6e68265e6afe1c GIT binary patch literal 17822 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 str: + salt = secrets.token_bytes(16) + derived = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt, + PBKDF2_ITERATIONS, + ) + return ( + f"pbkdf2_sha256${PBKDF2_ITERATIONS}$" + f"{base64.b64encode(salt).decode()}$" + f"{base64.b64encode(derived).decode()}" + ) + + +def verify_password(password: str, stored_hash: str) -> bool: + try: + algorithm, iterations_raw, salt_b64, hash_b64 = stored_hash.split("$", 3) + if algorithm != "pbkdf2_sha256": + return False + iterations = int(iterations_raw) + salt = base64.b64decode(salt_b64) + expected = base64.b64decode(hash_b64) + except Exception: + return False + + actual = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt, + iterations, + ) + return hmac.compare_digest(actual, expected) + + +def create_auth_session( + user_id: int, + ip_address: str | None = None, + user_agent: str | None = None, +) -> str: + token = secrets.token_urlsafe(48) + expires_at = datetime.now(timezone.utc) + timedelta(seconds=IDLE_TIMEOUT_SECONDS) + create_auth_session_record( + user_id=user_id, + session_token=token, + expires_at=expires_at, + ip_address=ip_address, + user_agent=user_agent, + ) + return token + + +def _is_api_request(request: Request) -> bool: + path = request.url.path + if request.method in {"POST", "PUT", "PATCH", "DELETE"}: + return True + if path.endswith("/events") or "/event/" in path or path.endswith("/event"): + return True + accept = request.headers.get("accept", "") + requested_with = request.headers.get("x-requested-with", "") + return "application/json" in accept or requested_with.lower() == "xmlhttprequest" + + +def build_not_authenticated_response(request: Request): + if _is_api_request(request): + return JSONResponse({"error": "auth_expired"}, status_code=401) + return RedirectResponse(url="/login?reason=idle", status_code=303) + + +def revoke_auth_session_by_request(request: Request): + token = request.cookies.get("auth_token") + if token: + revoke_auth_session(token) + + +def get_current_user_from_request(request: Request): + token = request.cookies.get("auth_token") + if not token: + return None + + row = get_auth_session_by_token(token) + if not row: + return None + + ( + session_id, + user_id, + session_token, + created_at, + last_activity_at, + expires_at, + revoked_at, + ip_address, + user_agent, + username, + is_active, + role, + ) = row + + if revoked_at is not None or not is_active: + return None + + now = datetime.now(timezone.utc) + + if last_activity_at.tzinfo is None: + last_activity_at = last_activity_at.replace(tzinfo=timezone.utc) + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + + idle_seconds = (now - last_activity_at).total_seconds() + if idle_seconds > IDLE_TIMEOUT_SECONDS or expires_at < now: + revoke_auth_session(token) + return None + + new_expires_at = now + timedelta(seconds=IDLE_TIMEOUT_SECONDS) + from repositories.auth_repository import touch_auth_session_if_needed + touch_auth_session_if_needed( + session_token=token, + expires_at=new_expires_at, + throttle_seconds=SESSION_TOUCH_THROTTLE_SECONDS, + ) + + return { + "session_id": session_id, + "user_id": user_id, + "username": username, + "created_at": created_at, + "last_activity_at": last_activity_at, + "expires_at": expires_at, + "ip_address": ip_address, + "user_agent": user_agent, + "role": role, + } diff --git a/services/game_service.py b/services/game_service.py new file mode 100644 index 0000000..c5c585e --- /dev/null +++ b/services/game_service.py @@ -0,0 +1,211 @@ +from repositories.match_repository import ( + get_match_by_external_id, + mark_match_parsed, + mark_match_parse_error, + clear_match_squad_data, +) +from repositories.player_repository import ( + get_player_id_by_name_and_team, + get_player_id_by_external_id, +) +from repositories.coach_repository import ( + get_coach_id_by_name_and_team, + get_coach_id_by_external_id, +) +from repositories.referee_repository import get_referee_id_by_name, upsert_referee +from repositories.match_lineup_repository import replace_match_lineups, save_match_lineup_for_editor +from repositories.match_coach_repository import replace_match_coaches +from repositories.match_referee_repository import replace_match_referees + + +def sync_match_page( + match_external_id: str, + home_starting: list[dict], + away_starting: list[dict], + home_bench: list[dict], + away_bench: list[dict], + home_coaches: list[dict], + away_coaches: list[dict], + referees: list[dict], +) -> None: + match_row = get_match_by_external_id(match_external_id) + if not match_row: + raise ValueError(f"Match not found by external_id: {match_external_id}") + + match_id, _, home_team_id, away_team_id = match_row + clear_match_squad_data(match_id) + + lineup_rows = [] + for player in home_starting: + player_id = None + + if player.get("player_external_id"): + player_id = get_player_id_by_external_id(player["player_external_id"]) + + if player_id is None: + player_id = get_player_id_by_name_and_team( + player["player_name"], home_team_id + ) + + lineup_rows.append( + { + "match_id": match_id, + "team_id": home_team_id, + "player_id": player_id, + "player_name": player["player_name"], + "number": player.get("number"), + "position": player.get("position"), + "is_captain": bool(player.get("is_captain")), + "lineup_type": "starting", + "source": "parser", + } + ) + + for player in away_starting: + player_id = None + + if player.get("player_external_id"): + player_id = get_player_id_by_external_id(player["player_external_id"]) + + if player_id is None: + player_id = get_player_id_by_name_and_team( + player["player_name"], away_team_id + ) + + lineup_rows.append( + { + "match_id": match_id, + "team_id": away_team_id, + "player_id": player_id, + "player_name": player["player_name"], + "number": player.get("number"), + "position": player.get("position"), + "is_captain": bool(player.get("is_captain")), + "lineup_type": "starting", + "source": "parser", + } + ) + + for player in home_bench: + player_id = None + + if player.get("player_external_id"): + player_id = get_player_id_by_external_id(player["player_external_id"]) + + if player_id is None: + player_id = get_player_id_by_name_and_team( + player["player_name"], home_team_id + ) + + lineup_rows.append( + { + "match_id": match_id, + "team_id": home_team_id, + "player_id": player_id, + "player_name": player["player_name"], + "number": player.get("number"), + "position": player.get("position"), + "is_captain": bool(player.get("is_captain")), + "lineup_type": "bench", + "source": "parser", + } + ) + + for player in away_bench: + player_id = None + + if player.get("player_external_id"): + player_id = get_player_id_by_external_id(player["player_external_id"]) + + if player_id is None: + player_id = get_player_id_by_name_and_team( + player["player_name"], away_team_id + ) + + lineup_rows.append( + { + "match_id": match_id, + "team_id": away_team_id, + "player_id": player_id, + "player_name": player["player_name"], + "number": player.get("number"), + "position": player.get("position"), + "is_captain": bool(player.get("is_captain")), + "lineup_type": "bench", + "source": "parser", + } + ) + + coach_rows = [] + + for coach in home_coaches: + coach_id = None + + if coach.get("coach_external_id"): + coach_id = get_coach_id_by_external_id(coach["coach_external_id"]) + + if coach_id is None: + coach_id = get_coach_id_by_name_and_team(coach["coach_name"], home_team_id) + + coach_rows.append( + { + "match_id": match_id, + "team_id": home_team_id, + "coach_id": coach_id, + "coach_name": coach["coach_name"], + "role": coach.get("role"), + "source": "parser", + } + ) + + for coach in away_coaches: + coach_id = None + + if coach.get("coach_external_id"): + coach_id = get_coach_id_by_external_id(coach["coach_external_id"]) + + if coach_id is None: + coach_id = get_coach_id_by_name_and_team(coach["coach_name"], away_team_id) + + coach_rows.append( + { + "match_id": match_id, + "team_id": away_team_id, + "coach_id": coach_id, + "coach_name": coach["coach_name"], + "role": coach.get("role"), + "source": "parser", + } + ) + + referee_rows = [] + + for referee in referees: + referee_name = referee["referee_name"].strip() + referee_id = get_referee_id_by_name(referee_name) + + if referee_id is None: + referee_id = upsert_referee(full_name=referee_name) + + referee_rows.append( + { + "match_id": match_id, + "referee_id": referee_id, + "referee_name": referee_name, + "role": referee.get("role"), + "source": "parser", + } + ) + + try: + replace_match_lineups(match_id, lineup_rows) + for row in coach_rows: + row["side"] = "home" if row["team_id"] == home_team_id else "away" + replace_match_coaches(match_id, coach_rows) + + + replace_match_referees(match_id, referee_rows) + mark_match_parsed(match_external_id) + except Exception as e: + mark_match_parse_error(match_external_id, str(e)) + raise diff --git a/services/players_service.py b/services/players_service.py new file mode 100644 index 0000000..5049646 --- /dev/null +++ b/services/players_service.py @@ -0,0 +1,46 @@ +from repositories.player_repository import upsert_player +from repositories.coach_repository import upsert_coach + + +def to_int(value, default=0) -> int: + if value is None: + return default + value = str(value).strip() + return int(value) if value.isdigit() else default + + +def sync_team_roster(team_external_id: str, team_data: dict) -> None: + players = team_data.get("players") or [] + coaches = team_data.get("coaches") or [] + + for player in players: + upsert_player( + external_id=player.get("player_id", ""), + team_external_id=team_external_id, + player=player.get("player", ""), + lastname=player.get("lastname", ""), + name=player.get("name", ""), + number=player.get("number", ""), + pos=player.get("pos", ""), + amplua=player.get("amplua", ""), + born=player.get("born", ""), + games=to_int(player.get("games")), + goals=to_int(player.get("goals")), + penaltys=to_int(player.get("penaltys")), + assists=to_int(player.get("assists")), + yellows=to_int(player.get("yellows")), + reds=to_int(player.get("reds")), + is_active=True, + ) + + for coach in coaches: + upsert_coach( + external_id=coach.get("coach_id", ""), + team_external_id=team_external_id, + player=coach.get("player", ""), + lastname=coach.get("lastname", ""), + name=coach.get("name", ""), + born=coach.get("born", ""), + amplua=coach.get("amplua", ""), + is_active=True, + ) \ No newline at end of file diff --git a/services/schedule_service.py b/services/schedule_service.py new file mode 100644 index 0000000..f8c6fa9 --- /dev/null +++ b/services/schedule_service.py @@ -0,0 +1,19 @@ +from repositories.match_repository import upsert_match_by_team_external_ids + + +def sync_matches(matches_data: list[dict]) -> None: + for match in matches_data: + upsert_match_by_team_external_ids( + external_id=match["external_id"], + home_team_external_id=match["home_team_external_id"], + away_team_external_id=match["away_team_external_id"], + match_date=match.get("match_date"), + status=match.get("status", "scheduled"), + home_score=match.get("home_score"), + away_score=match.get("away_score"), + tour=match.get("tour"), + season=match.get("season"), + place=match.get("place"), + date_raw=match.get("date_raw"), + score_add=match.get("score_add"), + ) \ No newline at end of file diff --git a/services/standings_service.py b/services/standings_service.py new file mode 100644 index 0000000..d97aa4f --- /dev/null +++ b/services/standings_service.py @@ -0,0 +1,8 @@ +from repositories.standings_repository import replace_standings_for_season + + +def sync_standings(season: str, standings_rows: list[dict]) -> None: + replace_standings_for_season( + season=season, + standings_rows=standings_rows, + ) \ No newline at end of file diff --git a/services/teams_service.py b/services/teams_service.py new file mode 100644 index 0000000..3748d2c --- /dev/null +++ b/services/teams_service.py @@ -0,0 +1,14 @@ +from repositories.team_repository import upsert_team + + +def sync_teams(teams_data: list[dict]) -> None: + for team in teams_data: + upsert_team( + external_id=team["external_id"], + name=team["name"], + logo_url=team["logo_url"], + games=int(team.get("games", 0) or 0), + wins=int(team.get("wins", 0) or 0), + goals=int(team.get("goals", 0) or 0), + tournaments=int(team.get("tournaments", 0) or 0), + ) \ No newline at end of file diff --git a/services/vmix_json_service.py b/services/vmix_json_service.py new file mode 100644 index 0000000..d8fa880 --- /dev/null +++ b/services/vmix_json_service.py @@ -0,0 +1,424 @@ +# services/vmix_json_service.py +from db import get_connection +from repositories.match_lineup_repository import get_match_lineup_for_vmix + + +def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, team_b_name): + lineups = get_match_lineup_for_vmix( + match_id=match_id, + home_team_id=home_team_id, + away_team_id=away_team_id, + ) + + players = lineups.get(name, []) + + result = [] + + for p in players: + suffix = [] + if "вратарь" in (p.get("pos") or "").lower(): + suffix.append("ВР") + + if p.get("is_captain"): + suffix.append("К") + number = p.get("number", "") + lastname = p.get("last_name", "") + suffix_str = f'({", ".join(suffix)})' if suffix else "" + result.append( + { + "number": p.get("number", ""), + "number_lastname_amp_K": f"{number} {lastname} {suffix_str}".strip(), + "first_name": p.get("first_name", ""), + "last_name": p.get("last_name", ""), + "number_fullname": f"{number} {p.get('first_name', '')} {p.get('last_name', '')}".strip(), + "full_name": ( + p.get("first_name", "") + " " + p.get("last_name", "") + ).strip(), + "full_name_K": ( + p.get("first_name", "") + " " + p.get("last_name", "") + ).strip() + + (f" {', '.join(suffix)}" if suffix else ""), + "pos": p.get("pos", ""), + "position": p.get("position", ""), + "photo": ( + r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo" + + "\\" + + (team_a_name if "home" in name else team_b_name) + + "\\" + + (p.get("last_name", "") + + " " + + p.get("first_name", "")).strip() + + ".png" + ), + } + ) + + return {"players": result} + + +def get_vmix_match_info_by_token(session_token: str): + query = """ + SELECT + ms.match_id, + m.match_date, + m.place, + m.stadium_id, + ht.id AS home_team_id, + ht.name AS home_team_name, + + COALESCE(ht.full_name, ht.name) AS home_team_full_name, + COALESCE(ht.short_name_3, '') AS home_team_short_name, + + at.id AS away_team_id, + at.name AS away_team_name, + + COALESCE(at.full_name, at.name) AS away_team_full_name, + COALESCE(at.short_name_3, '') AS away_team_short_name, + COALESCE(s.stadium_gfx, s.name, m.place, '') AS stadium_name, + + m.tour, + ht.logo_path AS home_logo, + REPLACE(at.logo_path, 'HOME', 'AWAY') AS away_logo, + ref1.referee_name AS referee1, + ref2.referee_name AS referee2, + ref3.referee_name AS referee3, + ref4.referee_name AS referee4, + + CASE + WHEN ht.name ILIKE '%%динамо%%' + THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Белый') + WHEN ht.name ILIKE '%%зенит%%' + THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый') + ELSE REPLACE(ht.logo_path, 'HOME\\', '') + END AS home_logo1, + + CASE + WHEN at.name ILIKE '%%динамо%%' + THEN REPLACE(REPLACE(at.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Белый') + WHEN at.name ILIKE '%%зенит%%' + THEN REPLACE(REPLACE(at.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый') + ELSE REPLACE(at.logo_path, 'HOME\\', '') + END AS away_logo1, + + CASE + WHEN ht.name ILIKE '%%динамо%%' + THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Синий') + WHEN ht.name ILIKE '%%зенит%%' + THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Синий') + ELSE REPLACE(ht.logo_path, 'HOME\\', '') + END AS home_logo2, + + CASE + WHEN at.name ILIKE '%%динамо%%' + THEN REPLACE(REPLACE(at.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Синий') + WHEN at.name ILIKE '%%зенит%%' + THEN REPLACE(REPLACE(at.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Синий') + ELSE REPLACE(at.logo_path, 'HOME\\', '') + END AS away_logo2, + + ht.city AS home_city, + at.city AS away_city, + + TRIM(COALESCE(c1.name, '') || ' ' || COALESCE(c1.lastname, '')) AS coach_name1, + c1.amplua AS coach_amplua1, + + TRIM(COALESCE(c2.name, '') || ' ' || COALESCE(c2.lastname, '')) AS coach_name2, + c2.amplua AS coach_amplua2 + + FROM match_sessions ms + JOIN matches m ON m.id = ms.match_id + JOIN teams ht ON ht.id = m.home_team_id + JOIN teams at ON at.id = m.away_team_id + + LEFT JOIN match_referees ref1 ON ref1.match_id = m.id AND ref1.role = 'Главный судья' + LEFT JOIN match_referees ref2 ON ref2.match_id = m.id AND ref2.role = 'Ассистент судьи №1' + LEFT JOIN match_referees ref3 ON ref3.match_id = m.id AND ref3.role = 'Ассистент судьи №2' + LEFT JOIN match_referees ref4 ON ref4.match_id = m.id AND ref4.role = 'Резервный судья' + + LEFT JOIN match_coaches mc1 ON mc1.match_id = m.id AND mc1.side = 'home' + LEFT JOIN coaches c1 ON c1.id = mc1.coach_id + + LEFT JOIN match_coaches mc2 ON mc2.match_id = m.id AND mc2.side = 'away' + LEFT JOIN coaches c2 ON c2.id = mc2.coach_id + + LEFT JOIN stadiums s ON s.id = m.stadium_id + + WHERE ms.session_token = %s + LIMIT 1; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (session_token,)) + return cur.fetchone() + finally: + conn.close() + + +def get_vmix_standings(session_token: str): + query = """ + SELECT + s.position, + t.full_name, + CASE + WHEN t.full_name ILIKE '%%динамо%%' + THEN REPLACE(REPLACE(t.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Белый') + WHEN t.full_name ILIKE '%%зенит%%' + THEN REPLACE(REPLACE(t.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый') + ELSE REPLACE(t.logo_path, 'HOME\\', '') + END AS logo, + s.played, + s.wins, + s.losses, + s.draws, + s.points_for || ' - ' || s.points_against AS score, + s.points, + s.team_id + FROM standings s + LEFT JOIN teams t ON s.team_id = t.id + ORDER BY s.position + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (session_token,)) + return cur.fetchall() + finally: + conn.close() + + +def get_vmix_schedule(session_token: str): + query = """ + SELECT + CASE + WHEN t1.full_name ILIKE '%%динамо%%' + THEN REPLACE(REPLACE(t1.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Белый') + WHEN t1.full_name ILIKE '%%зенит%%' + THEN REPLACE(REPLACE(t1.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый') + ELSE REPLACE(t1.logo_path, 'HOME\\', '') + END AS logo1, + CASE + WHEN t2.full_name ILIKE '%%динамо%%' + THEN REPLACE(REPLACE(t2.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Белый') + WHEN t2.full_name ILIKE '%%зенит%%' + THEN REPLACE(REPLACE(t2.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый') + ELSE REPLACE(t2.logo_path, 'HOME\\', '') + END AS logo2, + m.home_score, + m.away_score, + m.match_date, + m.status, + m.id + FROM matches m + LEFT JOIN teams t1 ON m.home_team_id = t1.id + LEFT JOIN teams t2 ON m.away_team_id = t2.id + WHERE m.tour = %s + ORDER BY m.match_date, m.id + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (session_token[10],)) + return cur.fetchall() + finally: + conn.close() + + +def get_vmix_team_formations(session_token: str, team_id: int): + query = """ + SELECT + p.last_name, + mf.is_captain, + p.number, + p.position, + p.first_name + FROM match_formations mf + LEFT JOIN players p ON p.id = mf.player_id + WHERE mf.match_id = %s and mf.team_id = %s + ORDER BY mf.id + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (session_token[1], team_id)) + return cur.fetchall() + finally: + conn.close() + + +def get_vmix_scoreboard_info(session_token: str): + query = """ +WITH yellow_reds AS ( + SELECT + side, + player_id + FROM match_events_ui + WHERE match_id = %s + AND type = 'yellow' + AND player_id IS NOT NULL + GROUP BY side, player_id + HAVING COUNT(*) >= 2 +), +direct_reds AS ( + SELECT + side, + COUNT(*) AS cnt + FROM match_events_ui + WHERE match_id = %s + AND type = 'red' + GROUP BY side +), +two_yellow_reds AS ( + SELECT + side, + COUNT(*) AS cnt + FROM yellow_reds + GROUP BY side +), +red_totals AS ( + SELECT + s.side, + COALESCE(dr.cnt, 0) + COALESCE(tyr.cnt, 0) AS red_count + FROM ( + SELECT 'home' AS side + UNION ALL + SELECT 'away' AS side + ) s + LEFT JOIN direct_reds dr ON dr.side = s.side + LEFT JOIN two_yellow_reds tyr ON tyr.side = s.side +) +SELECT + -- голы + COALESCE(SUM( + CASE + WHEN m.side = 'home' AND m.type IN ('goal', 'penalty', 'own_goal') + THEN 1 ELSE 0 + END + ), 0) AS home_goals, + + COALESCE(SUM( + CASE + WHEN m.side = 'away' AND m.type IN ('goal', 'penalty', 'own_goal') + THEN 1 ELSE 0 + END + ), 0) AS away_goals, + + -- домашние карточки + CASE WHEN (SELECT red_count FROM red_totals WHERE side = 'home') >= 1 THEN '#FF0000' ELSE '#FF000000' END AS home_red_1, + CASE WHEN (SELECT red_count FROM red_totals WHERE side = 'home') >= 2 THEN '#FF0000' ELSE '#FF000000' END AS home_red_2, + CASE WHEN (SELECT red_count FROM red_totals WHERE side = 'home') >= 3 THEN '#FF0000' ELSE '#FF000000' END AS home_red_3, + CASE WHEN (SELECT red_count FROM red_totals WHERE side = 'home') >= 4 THEN '#FF0000' ELSE '#FF000000' END AS home_red_4, + + -- гостевые карточки + CASE WHEN (SELECT red_count FROM red_totals WHERE side = 'away') >= 1 THEN '#FF0000' ELSE '#FF000000' END AS away_red_1, + CASE WHEN (SELECT red_count FROM red_totals WHERE side = 'away') >= 2 THEN '#FF0000' ELSE '#FF000000' END AS away_red_2, + CASE WHEN (SELECT red_count FROM red_totals WHERE side = 'away') >= 3 THEN '#FF0000' ELSE '#FF000000' END AS away_red_3, + CASE WHEN (SELECT red_count FROM red_totals WHERE side = 'away') >= 4 THEN '#FF0000' ELSE '#FF000000' END AS away_red_4 + +FROM match_events_ui m +WHERE m.match_id = %s; +""" + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (session_token[1],session_token[1],session_token[1])) + return cur.fetchone() + finally: + conn.close() + + +def get_vmix_players_goal(match_id: int): + query = """ +WITH goal_events AS ( + SELECT + meu.side, + COALESCE(p.last_name, meu.player_name) AS last_name, + meu.seconds, + meu.id, + meu.type, + CASE + WHEN EXISTS ( + SELECT 1 + FROM match_events_ui m2 + WHERE m2.match_id = meu.match_id + AND m2.type = 'period_start_2h' + AND m2.seconds <= meu.seconds + ) THEN 2 + WHEN EXISTS ( + SELECT 1 + FROM match_events_ui m2 + WHERE m2.match_id = meu.match_id + AND m2.type = 'period_start_1h' + AND m2.seconds <= meu.seconds + ) THEN 1 + ELSE NULL + END AS period_no + FROM match_events_ui meu + LEFT JOIN players p ON p.id = meu.player_id + WHERE meu.match_id = %s + AND meu.type IN ('goal', 'penalty', 'own_goal') +), +grouped_players AS ( + SELECT + side, + last_name, + MIN(seconds) AS first_goal_seconds, + STRING_AGG( + ( + CASE + WHEN period_no = 1 AND seconds > 2700 + THEN '45''' || '+ ' || ((seconds - 2700) / 60)::int::text + + WHEN period_no = 2 AND seconds > 5400 + THEN '90''' || '+ ' || ((seconds - 5400) / 60)::int::text + + WHEN period_no = 2 + THEN (46 + ((seconds - 2700) / 60)::int)::text || '''' + + ELSE (1 + (seconds / 60)::int)::text || '''' + END + ) || + CASE + WHEN type = 'own_goal' THEN ' (АГ)' + WHEN type = 'penalty' THEN ' (П)' + ELSE '' + END, + ', ' ORDER BY seconds, id + ) AS goal_minutes + FROM goal_events + GROUP BY side, last_name +), +home_rows AS ( + SELECT + ROW_NUMBER() OVER (ORDER BY first_goal_seconds, last_name) AS rn, + last_name || ' ' || goal_minutes AS player_name1 + FROM grouped_players + WHERE side = 'home' +), +away_rows AS ( + SELECT + ROW_NUMBER() OVER (ORDER BY first_goal_seconds, last_name) AS rn, + last_name || ' ' || goal_minutes AS player_name2 + FROM grouped_players + WHERE side = 'away' +) +SELECT + COALESCE(h.player_name1, '') AS player_name1, + COALESCE(a.player_name2, '') AS player_name2 +FROM home_rows h +FULL OUTER JOIN away_rows a ON a.rn = h.rn +ORDER BY COALESCE(h.rn, a.rn); +""" + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute(query, (match_id,)) + return cur.fetchall() + finally: + conn.close() diff --git a/sql/001_auth.sql b/sql/001_auth.sql new file mode 100644 index 0000000..3eda35c --- /dev/null +++ b/sql/001_auth.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS admin_users ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS auth_sessions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, + session_token TEXT NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + last_activity_at TIMESTAMP NOT NULL DEFAULT NOW(), + expires_at TIMESTAMP NOT NULL, + revoked_at TIMESTAMP NULL, + ip_address VARCHAR(100), + user_agent TEXT +); + +CREATE INDEX IF NOT EXISTS idx_auth_sessions_token ON auth_sessions(session_token); +CREATE INDEX IF NOT EXISTS idx_auth_sessions_user_id ON auth_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_auth_sessions_last_activity ON auth_sessions(last_activity_at); diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..6f0f02b --- /dev/null +++ b/static/script.js @@ -0,0 +1,3579 @@ +const MATCH_DATA = window.MATCH_DATA || {}; + +const TEAM_NAMES = MATCH_DATA.teamNames || { + home: "", + away: "" +}; + +const goalEditorState = { + open: false, + eventId: null, + side: null, + type: null +}; + +const squadEditState = { + open: false, + side: "home", + starting: { home: [], away: [] }, + bench: { home: [], away: [] }, + coaches: { home: [], away: [] } +}; + +const refereeEditorState = { + items: Array.isArray(MATCH_DATA.referees) ? [...MATCH_DATA.referees] : [], + pool: Array.isArray(MATCH_DATA.refereePool) ? [...MATCH_DATA.refereePool] : [] +}; +refereeEditorState.items = refereeEditorState.items.map(normalizeReferee); +refereeEditorState.pool = refereeEditorState.pool.map(normalizeReferee); + +const REFEREE_ROLE_OPTIONS = [ + "Главный судья", + "Ассистент 1", + "Ассистент 2", + "Резервный судья", + "VAR", + "AVAR", + "Инспектор" +]; + +const VMIX_TARGET_CLIENT_ID = MATCH_DATA.vmixTargetClientId || null; +const VMIX_TARGET_GROUP = MATCH_DATA.vmixTargetGroup || null; + +function vmixCmd(path) { + const value = String(path || "").trim(); + if (!value) return ""; + if (value.startsWith("/api/")) return value; + return `/api/?${value.replace(/^\?/, "")}`; +} + + +let currentPeriod = null; +let matchClockSeconds = 0; +let timerId = null; +let periodStartedAt = null; +let pausedAccumulatedSeconds = 0; +let eventSeq = 1; +let matchEvents = []; +let scoreState = { home: 0, away: 0 }; +let activeSubstitution = null; +let extraTimeMinutes = { first: 0, second: 0 }; +let originalLineupState = null; + +const SESSION_TOKEN = MATCH_DATA.sessionToken ?? null; +const MATCH_ID = MATCH_DATA.matchId ?? null; +const STORAGE_KEY = `match_admin_state_match_${MATCH_ID}`; + +const formationState = { + home: Array.isArray(MATCH_DATA.homeFormations) ? MATCH_DATA.homeFormations : [], + away: Array.isArray(MATCH_DATA.awayFormations) ? MATCH_DATA.awayFormations : [], +}; + +const fallbackLineups = { + home: Array.isArray(MATCH_DATA.homeStarting) ? MATCH_DATA.homeStarting : [], + away: Array.isArray(MATCH_DATA.awayStarting) ? MATCH_DATA.awayStarting : [], +}; + +function getLiveClockSeconds() { + if (!periodStartedAt) { + return pausedAccumulatedSeconds; + } + + return pausedAccumulatedSeconds + Math.max(0, Math.floor((Date.now() - periodStartedAt) / 1000)); +} + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function formatClock(totalSeconds) { + const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0"); + const seconds = String(totalSeconds % 60).padStart(2, "0"); + return `${minutes}:${seconds}`; +} + +function getFootballMinute(totalSeconds) { + if (currentPeriod === "2H" || currentPeriod === "FT") { + const secondHalfSeconds = Math.max(0, totalSeconds - 45 * 60); + return 46 + Math.floor(secondHalfSeconds / 60); + } + + return Math.floor(totalSeconds / 60) + 1; +} + + +function formatMinuteLabel(totalSeconds) { + return getEventDisplayMinuteBySeconds(totalSeconds); +} + + + +function getEventDisplayMinuteBySeconds(totalSeconds, periodType = null) { + const safeSeconds = Math.max(0, Number(totalSeconds) || 0); + + if (periodType === "period_start_1h") { + return "1'"; + } + + if (periodType === "period_start_2h") { + return "46'"; + } + + const minute = Math.floor(safeSeconds / 60) + 1; + + // Добавленное время 1-го тайма — только если ещё не начался 2-й тайм + if ( + safeSeconds > 45 * 60 && + safeSeconds < 90 * 60 && + (currentPeriod === "1H" || currentPeriod === "HT") + ) { + const extra = Math.floor((safeSeconds - 45 * 60) / 60); + return `45'+${Math.max(1, extra)}`; + } + + // Добавленное время 2-го тайма — только после 90 минут + if ( + safeSeconds > 90 * 60 && + (currentPeriod === "2H" || currentPeriod === "FT") + ) { + const extra = Math.floor((safeSeconds - 90 * 60) / 60); + return `90'+${Math.max(1, extra)}`; + } + + return `${minute}'`; +} + + +function getEventSortPriority(event) { + const type = String(event?.type || ""); + + if (type === "period_ht" || type === "period_ft") { + return 3; + } + + if (type === "period_start_1h" || type === "period_start_2h") { + return 1; + } + + return 2; +} + + + + +function sortEventsForStorage(events) { + return [...events].sort((a, b) => { + const secondsDiff = (a.seconds || 0) - (b.seconds || 0); + if (secondsDiff !== 0) return secondsDiff; + + const priorityDiff = getEventSortPriority(a) - getEventSortPriority(b); + if (priorityDiff !== 0) return priorityDiff; + + return (a.id || 0) - (b.id || 0); + }); +} + +function sortEventsForDisplay(events) { + return [...events].sort((a, b) => { + const secondsDiff = (b.seconds || 0) - (a.seconds || 0); + if (secondsDiff !== 0) return secondsDiff; + + const priorityDiff = getEventSortPriority(b) - getEventSortPriority(a); + if (priorityDiff !== 0) return priorityDiff; + + return (b.id || 0) - (a.id || 0); + }); +} + + + +function addCoachEvent(side, cardType, coachName, btn = null) { + console.log("addCoachEvent called", { side, cardType, coachName, btn }); + + const counts = getCoachCardCounts(side, coachName); + + const sentOffByTwoYellows = counts.yellow >= 2; + const sentOffByRed = counts.red >= 1; + const sentOff = sentOffByTwoYellows || sentOffByRed; + + if (sentOff) { + console.log("blocked: sent off"); + return; + } + + if (cardType === "yellow" && counts.yellow >= 2) { + console.log("blocked: yellow limit"); + return; + } + + if (cardType === "red" && counts.red >= 1) { + console.log("blocked: red limit"); + return; + } + + const type = cardType === "yellow" ? "coach_yellow" : "coach_red"; + + console.log("pushEvent", { side, type, coachName }); + + pushEvent(side, type, coachName, "Тренер", null).then(() => { + console.log("pushEvent done"); + updateCoachCardButtons(); + }); + + if (btn) { + setTimeout(updateCoachCardButtons, 0); + } +} + + +function updateTimerUI() { + matchClockSeconds = getLiveClockSeconds(); + const timer = document.getElementById("matchTimer"); + if (timer) timer.textContent = formatClock(matchClockSeconds); +} + +function updateScoreUI() { + const score = document.getElementById("matchScore"); + if (score) score.textContent = `${scoreState.home} : ${scoreState.away}`; +} + +function updatePeriodBadge(text) { + const badge = document.getElementById("periodBadge"); + if (badge) badge.textContent = text; +} + +function getPersistedState() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : null; + } catch (e) { + return null; + } +} + +function saveMatchState() { + try { + matchClockSeconds = getLiveClockSeconds(); + + const payload = { + currentPeriod, + matchClockSeconds, + timerRunning: !!timerId, + savedAt: Date.now(), + periodStartedAt, + pausedAccumulatedSeconds, + eventSeq, + matchEvents, + scoreState, + extraTimeMinutes, + originalLineupState + }; + + localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); + } catch (e) { + console.warn("Не удалось сохранить состояние матча", e); + } +} + +function restoreMatchState() { + const state = getPersistedState(); + if (!state) return false; + + currentPeriod = state.currentPeriod ?? null; + eventSeq = Number(state.eventSeq || 1); + matchEvents = Array.isArray(state.matchEvents) ? state.matchEvents : []; + scoreState = state.scoreState || { home: 0, away: 0 }; + extraTimeMinutes = state.extraTimeMinutes || { first: 0, second: 0 }; + // originalLineupState = state.originalLineupState || originalLineupState; + + pausedAccumulatedSeconds = Number(state.pausedAccumulatedSeconds ?? 0); + periodStartedAt = state.periodStartedAt ?? null; + + if (!state.timerRunning) { + periodStartedAt = null; + } + + updatePeriodBadge( + currentPeriod === "1H" ? (extraTimeMinutes.first ? `1-й тайм +${extraTimeMinutes.first}` : "1-й тайм") : + currentPeriod === "HT" ? "Перерыв" : + currentPeriod === "2H" ? (extraTimeMinutes.second ? `2-й тайм +${extraTimeMinutes.second}` : "2-й тайм") : + currentPeriod === "FT" ? "Матч завершён" : + "Матч не начат" +); + + updateTimerUI(); + updateScoreUI(); + renderEvents(); + + if (typeof rebuildLineupsFromEvents === "function") { + rebuildLineupsFromEvents(); + } + + if (state.timerRunning && (currentPeriod === "1H" || currentPeriod === "2H")) { + if (timerId) clearInterval(timerId); + timerId = setInterval(() => { + updateTimerUI(); + saveMatchState(); + }, 1000); + } + updateCoachCardButtons(); + updatePeriodButtons(); + return true; +} + +function stopTimer() { + if (timerId) { + clearInterval(timerId); + timerId = null; + } + + pausedAccumulatedSeconds = getLiveClockSeconds(); + periodStartedAt = null; + matchClockSeconds = pausedAccumulatedSeconds; +} + +function startTimer() { + if (timerId) { + clearInterval(timerId); + timerId = null; + } + + periodStartedAt = Date.now(); + timerId = setInterval(() => { + updateTimerUI(); + saveMatchState(); + }, 1000); + + updateTimerUI(); + saveMatchState(); +} + +function applyExtraTime() { + const input = document.getElementById("extraTimeMinutes"); + const value = Math.max(0, Number(input ? input.value : 0)); + + if (!value) { + saveMatchState(); + return; + } + + if (currentPeriod === "1H" || currentPeriod === "HT") { + extraTimeMinutes.first = value; + updatePeriodBadge(`1-й тайм +${value}`); + + addPeriodMarker( + "extra_time_first", + `Добавлено ${value} мин. к 1-му тайму`, + matchClockSeconds + ); + } else if (currentPeriod === "2H" || currentPeriod === "FT") { + extraTimeMinutes.second = value; + updatePeriodBadge(`2-й тайм +${value}`); + + addPeriodMarker( + "extra_time_second", + `Добавлено ${value} мин. ко 2-му тайму`, + matchClockSeconds + ); + } + + saveMatchState(); +} + +async function saveEventToServer(eventData) { + if (!SESSION_TOKEN) return null; + try { + const res = await fetch(`/admin/session/${SESSION_TOKEN}/event`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(eventData) + }); + if (!res.ok) return null; + return await res.json(); + } catch (e) { + console.warn("Не удалось сохранить событие в БД", e); + return null; + } +} + +async function deleteEvent(eventId) { + if (eventId === null || eventId === undefined || eventId === "") return; + + try { + const res = await fetch(`/admin/session/${SESSION_TOKEN}/event/${eventId}`, { + method: "DELETE" + }); + if (!res.ok) { + console.warn("Удаление события не удалось", res.status); + return; + } + } catch (e) { + console.warn("Не удалось удалить событие", e); + return; + } + + matchEvents = matchEvents.filter((event) => String(event.id) !== String(eventId)); + + scoreState = { home: 0, away: 0 }; + matchEvents.forEach((event) => { + if (["goal", "penalty", "own_goal"].includes(event.type)) { + scoreState[event.side] = (scoreState[event.side] || 0) + 1; + } + }); + + updateScoreUI(); + renderEvents(); + rebuildLineupsFromEvents(); + saveMatchState(); + updateCoachCardButtons(); + updatePeriodButtons(); +} + +async function clearEventsOnServer() { + if (!SESSION_TOKEN) return; + try { + await fetch(`/admin/session/${SESSION_TOKEN}/events`, { method: "DELETE" }); + } catch (e) { + console.warn("Не удалось очистить события в БД", e); + } +} + +function getOppositeSide(side) { + return side === "home" ? "away" : "home"; +} + +function getEventIcon(type) { + const map = { + goal: "⚽", + own_goal: "АГ", + penalty: "П", + sub: "⇄", + yellow: "🟨", + red: "🟥", + coach_yellow: "🟨", + coach_red: "🟥", + period_start_1h: "", + period_ht: "", + period_start_2h: "", + period_ft: "", + extra_time_first: "", + extra_time_second: "" + }; + return map[type] || "•"; +} + +function getEventTitle(type, name) { + const map = { + goal: "Гол", + own_goal: "Автогол", + penalty: "Пенальти", + sub: "Замена", + yellow: "Жёлтая карточка", + red: "Красная карточка", + coach_yellow: "Жёлтая карточка тренеру", + coach_red: "Красная карточка тренеру", + period_start_1h: "Начало 1-го тайма", + period_ht: "Конец 1-го тайма", + period_start_2h: "Начало 2-го тайма", + period_ft: "Конец 2-го тайма", + extra_time_first: "Добавлено время к 1-му тайму", + extra_time_second: "Добавлено время ко 2-му тайму" + }; + return `${map[type] || "Событие"}${name ? " — " + name : ""}`; +} + + +function addPeriodMarker(type, title, forcedSeconds = null) { + const markerSeconds = forcedSeconds === null ? matchClockSeconds : forcedSeconds; + + const markerMinute = getEventDisplayMinuteBySeconds(markerSeconds, type); + + const item = { + id: null, + side: "center", + type, + icon: "", + title, + minute: markerMinute, + seconds: markerSeconds, + meta: null, + player_name: title, + player_id: null, + player_out_id: null, + player_in_id: null + }; + + matchEvents.push(item); + // matchEvents = sortEventsForStorage(matchEvents); + + renderEvents(); + saveMatchState(); + + const numericMinute = + type === "period_start_1h" ? 1 : + type === "period_start_2h" ? 46 : + Math.floor(markerSeconds / 60) + 1; + + saveEventToServer({ + side: "center", + type, + player_name: title, + minute: numericMinute, + seconds: markerSeconds, + meta: null, + player_id: null, + player_out_id: null, + player_in_id: null + }).then((saved) => { + if (saved && saved.id) { + item.id = saved.id; + renderEvents(); + saveMatchState(); + } + }); +} + + + + + +function setMatchPeriod(period) { + const markerMap = { + "1H": "period_start_1h", + "HT": "period_ht", + "2H": "period_start_2h", + "FT": "period_ft" + }; + + const markerType = markerMap[period]; + const alreadyUsed = matchEvents.some((event) => event.type === markerType); + if (alreadyUsed) { + return; + } + + currentPeriod = period; + + if (period === "1H") { + if (timerId) { + clearInterval(timerId); + timerId = null; + } + + pausedAccumulatedSeconds = 0; + periodStartedAt = null; + matchClockSeconds = 0; + + updateTimerUI(); + updatePeriodBadge(extraTimeMinutes.first ? `1-й тайм +${extraTimeMinutes.first}` : "1-й тайм"); + startTimer(); + addPeriodMarker("period_start_1h", "Начало 1-го тайма", 0); + triggerVmixTimerAction("first_half_start"); + saveMatchState(); + clearExtraTimeInput(); + updatePeriodButtons(); + return; + } + + if (period === "HT") { + stopTimer(); + + const actualEndSeconds = matchClockSeconds; + + updateTimerUI(); + updatePeriodBadge("Перерыв"); + addPeriodMarker("period_ht", "Конец 1-го тайма", actualEndSeconds); + triggerVmixTimerAction("halftime"); + saveMatchState(); + clearExtraTimeInput(); + updatePeriodButtons(); + return; +} + + + if (period === "2H") { + if (timerId) { + clearInterval(timerId); + timerId = null; + } + + pausedAccumulatedSeconds = 45 * 60; + periodStartedAt = null; + matchClockSeconds = 45 * 60; + + updateTimerUI(); + updatePeriodBadge(extraTimeMinutes.second ? `2-й тайм +${extraTimeMinutes.second}` : "2-й тайм"); + addPeriodMarker("period_start_2h", "Начало 2-го тайма", 45 * 60); + triggerVmixTimerAction("second_half_start"); + startTimer(); + saveMatchState(); + updatePeriodButtons(); + return; + } + + if (period === "FT") { + stopTimer(); + updatePeriodBadge("Матч завершён"); + addPeriodMarker("period_ft", "Конец 2-го тайма", getLiveClockSeconds()); + triggerVmixTimerAction("full_time"); + saveMatchState(); + clearExtraTimeInput(); + updatePeriodButtons(); + } +} + +function setBenchHint(side, visible) { + const hint = document.getElementById(side === "home" ? "homeBenchHint" : "awayBenchHint"); + if (hint) hint.style.display = visible ? "block" : "none"; +} + +function clearBenchSelection() { + document.querySelectorAll(".bench-pick-active").forEach(el => el.classList.remove("bench-pick-active")); + setBenchHint("home", false); + setBenchHint("away", false); +} + +function beginSubstitution(side, playerId, playerName, btn) { + const row = btn ? btn.closest("tr.lineup-row") : null; + activeSubstitution = { + side, + outPlayerId: playerId, + outPlayerName: playerName, + outRow: row + }; + clearBenchSelection(); + setBenchHint(side, true); +} + +async function pushEvent(side, type, personName, meta = "", playerId = null) { + let eventSide = side; + + if (type === "own_goal") { + eventSide = getOppositeSide(side); + } + + if (type === "goal" || type === "penalty" || type === "own_goal") { + scoreState[eventSide] += 1; + updateScoreUI(); + } + + const minuteLabel = formatMinuteLabel(matchClockSeconds); + const numericMinute = + parseInt(String(minuteLabel).split("+").pop().replace("'", ""), 10) || + getFootballMinute(matchClockSeconds); + + const item = { + id: null, + side: eventSide, + type, + icon: getEventIcon(type), + title: getEventTitle(type, personName), + teamName: TEAM_NAMES[eventSide] || "Команда", + minute: minuteLabel, + seconds: matchClockSeconds, + meta, + player_name: personName, + player_id: playerId, + player_out_id: null, + player_in_id: null + }; + + matchEvents.push(item); + + renderEvents(); + rebuildLineupsFromEvents(); + saveMatchState(); + + const saved = await saveEventToServer({ + side: eventSide, + type, + player_name: personName, + minute: numericMinute, + seconds: matchClockSeconds, + meta, + player_id: playerId, + player_out_id: null, + player_in_id: null + }); + + if (saved && saved.id) { + item.id = saved.id; + renderEvents(); + rebuildLineupsFromEvents(); + saveMatchState(); + } +} + +function getCoachCardCounts(side, coachName) { + const safeName = String(coachName || "").trim(); + + let yellow = 0; + let red = 0; + + matchEvents.forEach((event) => { + if (event.side !== side) return; + if (String(event.player_name || "").trim() !== safeName) return; + + if (event.type === "coach_yellow") yellow += 1; + if (event.type === "coach_red") red += 1; + }); + + return { yellow, red }; +} + +function updateCoachCardButtons() { + document.querySelectorAll("[data-card-type][data-coach-side][data-coach-name]").forEach((btn) => { + const side = btn.dataset.coachSide; + const coachName = btn.dataset.coachName; + const cardType = btn.dataset.cardType; + + const counts = getCoachCardCounts(side, coachName); + + const sentOffByTwoYellows = counts.yellow >= 2; + const sentOffByRed = counts.red >= 1; + const sentOff = sentOffByTwoYellows || sentOffByRed; + + let disabled = false; + + if (sentOff) { + disabled = true; + } else if (cardType === "yellow") { + disabled = counts.yellow >= 2; + } else if (cardType === "red") { + disabled = counts.red >= 1; + } + + btn.disabled = disabled; + btn.style.opacity = disabled ? "0.45" : ""; + btn.style.cursor = disabled ? "not-allowed" : ""; + }); +} + + +function updatePeriodButtons() { + const usedPeriods = new Set( + matchEvents + .filter((event) => + ["period_start_1h", "period_ht", "period_start_2h", "period_ft"].includes(event.type) + ) + .map((event) => event.type) + ); + + const map = { + "1H": "period_start_1h", + "HT": "period_ht", + "2H": "period_start_2h", + "FT": "period_ft" + }; + + document.querySelectorAll("[data-period-btn]").forEach((btn) => { + const period = btn.dataset.periodBtn; + const eventType = map[period]; + const disabled = usedPeriods.has(eventType); + + btn.disabled = disabled; + btn.style.opacity = disabled ? "0.45" : ""; + btn.style.cursor = disabled ? "not-allowed" : ""; + }); +} + +function addPlayerEvent(side, type, playerId, playerName) { + pushEvent(side, type, playerName, `Игрок #${playerId ?? ""}`, playerId); +} + + +let editModeEnabled = false; + +function isGameTabActive() { + const activeTab = document.querySelector(".tab-link.active"); + return activeTab && activeTab.textContent.trim() === "Игра"; +} + +function setEditMode(enabled) { + editModeEnabled = !!enabled; + + document.body.classList.toggle("edit-mode-on", editModeEnabled); + document.querySelectorAll("[data-edit-lock-wrap]").forEach((el) => { + el.style.display = editModeEnabled ? "flex" : "none"; +}); + + const btn = document.getElementById("editModeToggle"); + if (btn) { + btn.title = editModeEnabled + ? "Режим редактирования: ВКЛ" + : "Режим редактирования: ВЫКЛ"; + + btn.classList.toggle("active", editModeEnabled); + } + + document.querySelectorAll("[data-edit-lock]").forEach((el) => { + if ("disabled" in el) { + el.disabled = !editModeEnabled; + } + }); + renderRefereesEditor(); + + if (editModeEnabled && isGameTabActive()) { + openSquadEditor(); + } else { + closeSquadEditor(); + } +} + +function toggleEditMode() { + setEditMode(!editModeEnabled); +} + + +function updateEditModeUI() { + const btn = document.getElementById("editModeToggle"); + if (btn) { + btn.innerHTML = "✏️"; + btn.title = editModeEnabled + ? "Режим редактирования: ВКЛ" + : "Режим редактирования: ВЫКЛ"; + + btn.classList.toggle("active", editModeEnabled); + } + + // document.querySelectorAll("[data-edit-only]").forEach((el) => { + // el.style.display = editModeEnabled ? "" : "none"; + // }); + + document.querySelectorAll("[data-edit-lock]").forEach((el) => { + if ("disabled" in el) { + el.disabled = !editModeEnabled; + } + }); +} + +function stepExtraTime(step) { + const input = document.getElementById("extraTimeMinutes"); + if (!input) return; + + let value = Number(input.value) || 0; + value = Math.max(0, value + step); + + input.value = value; +} + +function clearMatchEvents() { + if (timerId) { + clearInterval(timerId); + timerId = null; + } + + periodStartedAt = null; + pausedAccumulatedSeconds = 0; + + matchEvents = []; + scoreState = { home: 0, away: 0 }; + eventSeq = 1; + activeSubstitution = null; + currentPeriod = null; + matchClockSeconds = 0; + extraTimeMinutes = { first: 0, second: 0 }; + + clearBenchSelection(); + restoreOriginalLineupRows(); + + const extraInput = document.getElementById("extraTimeMinutes"); + if (extraInput) extraInput.value = ""; + + updateTimerUI(); + updatePeriodBadge("Матч не начат"); + updateScoreUI(); + renderEvents(); + + saveMatchState(); + clearEventsOnServer(); + updateCoachCardButtons(); + updatePeriodButtons(); + triggerVmixTimerAction("clear_match"); +} + +async function updateEventOnServer(eventId, eventData) { + if (!SESSION_TOKEN || !eventId) return false; + + try { + const res = await fetch(`/admin/session/${SESSION_TOKEN}/event/${eventId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(eventData) + }); + + return res.ok; + } catch (e) { + console.warn("Не удалось обновить событие в БД", e); + return false; + } +} + +function getPlayersForSide(side) { + return Array.from(document.querySelectorAll(`tr.lineup-row[data-side="${side}"]`)) + .filter((row) => row.dataset.temporary !== "1") + .map((row) => ({ + player_id: row.dataset.playerId || "", + player_name: row.dataset.playerName || "", + player_number: row.dataset.playerNumber || "" + })) + .filter((p) => p.player_name.trim() !== ""); +} + + +function openGoalEditor(eventId) { + const event = matchEvents.find((e) => String(e.id) === String(eventId)); + if (!event) return; + if (!["goal", "penalty", "own_goal"].includes(event.type)) return; + + const editorSide = event.type === "own_goal" + ? getOppositeSide(event.side) + : event.side; + + goalEditorState.open = true; + goalEditorState.eventId = event.id; + goalEditorState.side = editorSide; + goalEditorState.type = event.type; + + const players = getPlayersForSide(editorSide); + const wrap = document.getElementById("goalEditorList"); + const title = document.getElementById("goalEditorTitle"); + + if (title) { + title.textContent = `Изменить автора: ${event.player_name || "без игрока"}`; + } + + if (wrap) { + wrap.innerHTML = players.map((p) => ` + + `).join(""); + } + + document.getElementById("goalEditorModal")?.classList.add("show"); +} + + + + +async function selectGoalScorer(playerId, playerName) { + const event = matchEvents.find((e) => String(e.id) === String(goalEditorState.eventId)); + if (!event) return; + + const updated = { + side: event.side, + type: event.type, + player_name: playerName, + minute: parseInt(String(event.minute).split("+").pop().replace("'", ""), 10) || getFootballMinute(event.seconds || 0), + seconds: event.seconds || 0, + meta: event.meta || "", + player_id: playerId ? Number(playerId) : null, + player_out_id: event.player_out_id ?? null, + player_in_id: event.player_in_id ?? null + }; + + const ok = await updateEventOnServer(event.id, updated); + if (!ok) { + alert("Не удалось обновить автора гола"); + return; + } + + event.player_name = playerName; + event.player_id = playerId ? Number(playerId) : null; + event.title = getEventTitle(event.type, playerName); + + renderEvents(); + saveMatchState(); + closeGoalEditor(); +} + +function closeGoalEditor() { + goalEditorState.open = false; + goalEditorState.eventId = null; + goalEditorState.side = null; + goalEditorState.type = null; + document.getElementById("goalEditorModal")?.classList.remove("show"); +} + + +function getVmixTeamLogo(teamName) { + const basePath = "D:\\Графика\\ФУТБОЛ\\Женская Суперлига 2026\\Teams Logos"; + let clean = String(teamName || "").replaceAll("«", "").replaceAll("»", "").trim(); + + if (clean === "Зенит" || clean === "Динамо") { + clean += "_Синий"; + } + + return `${basePath}\\${clean}.png`; +} + +function getScoreSum() { + return Number(scoreState?.home || 0) + Number(scoreState?.away || 0); +} + +function getSubOutName(payload) { + const raw = String(payload.player_name || ""); + if (raw.includes("→")) { + return raw.split("→")[0].trim(); + } + return String(payload.player_name_out || "").trim(); +} + +function getSubInName(payload) { + const raw = String(payload.player_name || ""); + if (raw.includes("→")) { + return raw.split("→")[1].trim(); + } + return String(payload.player_name_in || "").trim(); +} + +function escapeVmixValue(value) { + return String(value ?? "").trim(); +} + +function getVmixTeamLogo(teamName) { + const basePath = "D:\\Графика\\ФУТБОЛ\\Женская Суперлига 2026\\Teams Logos"; + let clean = String(teamName || "").replaceAll("«", "").replaceAll("»", "").trim(); + + if (clean === "Зенит" || clean === "Динамо") { + clean += "_Синий"; + } + + return `${basePath}\\${clean}.png`; +} + +function getScoreSum() { + return Number(scoreState?.home || 0) + Number(scoreState?.away || 0); +} + +function getSubOutName(payload) { + const raw = String(payload.player_name || ""); + if (raw.includes("→")) return raw.split("→")[0].trim(); + return String(payload.player_name_out || "").trim(); +} + +function getSubInName(payload) { + const raw = String(payload.player_name || ""); + if (raw.includes("→")) return raw.split("→")[1].trim(); + return String(payload.player_name_in || "").trim(); +} + +function buildVmixCommandsFromEvent(payload) { + const eventType = String(payload?.event_type || ""); + const playerName = escapeVmixValue(payload?.player_name || ""); + const teamName = escapeVmixValue(payload?.team_name || ""); + const playerNumber = escapeVmixValue(payload?.player_number || ""); + const playerOutNumber = escapeVmixValue(payload?.player_out_number || ""); + const playerInNumber = escapeVmixValue(payload?.player_in_number || ""); + + const teamLogo = getVmixTeamLogo(teamName); + const scoreSum = getScoreSum(); + + if (eventType === "period_start_1h") { + return [ + vmixCmd("Function=SetText&Input=НИЖНИЙ СЧЕТ ВО ВРЕМЯ ИГРЫ&SelectedName=СтатусМатча.Text&Value=1-й тайм"), + vmixCmd("Function=PreviewInput&Input=НИЖНИЙ СЧЕТ ВО ВРЕМЯ ИГРЫ") + ]; + } + + if (eventType === "period_ht") { + return [ + scoreSum === 0 + ? vmixCmd("Function=PreviewInput&Input=НИЖНИЙ СЧЕТ БЕЗ АВТОРОВ ГОЛОВ - ПЕРЕРЫВ") + : vmixCmd("Function=PreviewInput&Input=НИЖНИЙ СЧЕТ С АВТОРАМИ - ПЕРЕРЫВ") + ]; + } + + if (eventType === "period_start_2h") { + return [ + vmixCmd("Function=SetText&Input=НИЖНИЙ СЧЕТ ВО ВРЕМЯ ИГРЫ&SelectedName=СтатусМатча.Text&Value=2-й тайм"), + vmixCmd("Function=PreviewInput&Input=НИЖНИЙ СЧЕТ ВО ВРЕМЯ ИГРЫ") + ]; + } + + if (eventType === "period_ft") { + return [ + scoreSum === 0 + ? vmixCmd("Function=PreviewInput&Input=НИЖНИЙ СЧЕТ БЕЗ АВТОРОВ ГОЛОВ - МАТЧ ОКОНЧЕН") + : vmixCmd("Function=PreviewInput&Input=НИЖНИЙ СЧЕТ С АВТОРАМИ ГОЛОВ - МАТЧ ОКОНЧЕН") + ]; + } + + if (eventType === "goal" || eventType === "penalty") { + return [ + vmixCmd(`Function=SetText&Input=ГОЛ - НЕЙТРАЛЬНЫЙ&SelectedName=Игрок.Text&Value=${playerNumber} ${playerName}`), + vmixCmd(`Function=SetImage&Input=ГОЛ - НЕЙТРАЛЬНЫЙ&SelectedName=Лого.Source&Value=${teamLogo}`), + vmixCmd("Function=PreviewInput&Input=ГОЛ - НЕЙТРАЛЬНЫЙ") + ]; + } + + if (eventType === "yellow") { + return [ + vmixCmd(`Function=SetText&Input=ЖЕЛТАЯ - НЕЙТРАЛЬНАЯ&SelectedName=Игрок.Text&Value=${playerNumber} ${playerName}`), + vmixCmd(`Function=SetImage&Input=ЖЕЛТАЯ - НЕЙТРАЛЬНАЯ&SelectedName=Лого.Source&Value=${teamLogo}`), + vmixCmd("Function=PreviewInput&Input=ЖЕЛТАЯ - НЕЙТРАЛЬНАЯ") + ]; + } + + if (eventType === "red") { + return [ + vmixCmd(`Function=SetText&Input=КРАСНАЯ - НЕЙТРАЛЬНАЯ&SelectedName=Игрок.Text&Value=${playerNumber} ${playerName}`), + vmixCmd(`Function=SetImage&Input=КРАСНАЯ - НЕЙТРАЛЬНАЯ&SelectedName=Лого.Source&Value=${teamLogo}`), + vmixCmd("Function=PreviewInput&Input=КРАСНАЯ - НЕЙТРАЛЬНАЯ") + ]; + } + + if (eventType === "sub") { + const outName = escapeVmixValue(getSubOutName(payload)); + const inName = escapeVmixValue(getSubInName(payload)); + + return [ + vmixCmd(`Function=SetText&Input=ЗАМЕНА - НЕЙТРАЛЬНАЯ&SelectedName=НомерИгрокУход.Text&Value=${playerOutNumber}`), + vmixCmd(`Function=SetText&Input=ЗАМЕНА - НЕЙТРАЛЬНАЯ&SelectedName=ИгрокУходящий.Text&Value=${outName}`), + vmixCmd(`Function=SetText&Input=ЗАМЕНА - НЕЙТРАЛЬНАЯ&SelectedName=НомерИгрокВыход.Text&Value=${playerInNumber}`), + vmixCmd(`Function=SetText&Input=ЗАМЕНА - НЕЙТРАЛЬНАЯ&SelectedName=ИгрокВыходящий.Text&Value=${inName}`), + vmixCmd(`Function=SetImage&Input=ЗАМЕНА - НЕЙТРАЛЬНАЯ&SelectedName=Лого.Source&Value=${teamLogo}`), + vmixCmd("Function=PreviewInput&Input=ЗАМЕНА - НЕЙТРАЛЬНАЯ") + ]; + } + + return []; +} + +async function triggerVmixEventCommand(eventPayload) { + const commands = buildVmixCommandsFromEvent(eventPayload); + + if (!commands.length) { + console.warn("Для события нет команд vMix", eventPayload); + return; + } + + console.log("VMIX DIRECT EVENT:", eventPayload); + console.log("VMIX DIRECT COMMANDS:", commands); + + // await executeVmixCommands(commands.join("\n")); + await sendDirectVmixCommands(commands); +} + + +function getPlayerNumberByEvent(event) { + if (!event) return ""; + + if (event.type === "sub") { + const outRow = findLineupRowByPlayerId(event.side, "starting", event.player_out_id) + || findLineupRowByPlayerId(event.side, "bench", event.player_out_id); + const inRow = findLineupRowByPlayerId(event.side, "starting", event.player_in_id) + || findLineupRowByPlayerId(event.side, "bench", event.player_in_id); + + return { + out: outRow?.dataset.playerNumber || "", + in: inRow?.dataset.playerNumber || "" + }; + } + + const row = findLineupRowByPlayerId(event.side, "starting", event.player_id) + || findLineupRowByPlayerId(event.side, "bench", event.player_id); + + return row?.dataset.playerNumber || ""; +} + + +function buildVmixClickAttr(payload) { + const json = JSON.stringify(payload); + const encoded = btoa(unescape(encodeURIComponent(json))); + return `onclick="triggerVmixEventCommandFromEncoded('${encoded}')"` +} + +function triggerVmixEventCommandFromEncoded(encodedPayload) { + try { + const json = decodeURIComponent(escape(atob(encodedPayload))); + const payload = JSON.parse(json); + triggerVmixEventCommand(payload); + } catch (e) { + console.warn("Не удалось разобрать payload для vMix", e); + } +} + +function syncFormationCaptainsInState(side) { + const starting = side === "home" + ? (window.MATCH_DATA.homeStarting || []) + : (window.MATCH_DATA.awayStarting || []); + + const captainIds = new Set( + starting + .filter(p => !!p.is_captain) + .map(p => String(p.player_id ?? p.id ?? p.number ?? "")) + ); + + if (!Array.isArray(formationState[side])) { + formationState[side] = []; + } + + formationState[side] = formationState[side].map(player => { + const playerKey = String(player.player_id ?? player.id ?? player.number ?? ""); + return { + ...player, + is_captain: captainIds.has(playerKey) + }; + }); +} + +function getPlayerNamesByEvent(event) { + if (!event) return { out: "", in: "" }; + + if (event.type !== "sub") { + return { + out: event.player_name || "", + in: "" + }; + } + + const outRow = + findLineupRowByPlayerId(event.side, "starting", event.player_out_id) || + findLineupRowByPlayerId(event.side, "bench", event.player_out_id); + + const inRow = + findLineupRowByPlayerId(event.side, "starting", event.player_in_id) || + findLineupRowByPlayerId(event.side, "bench", event.player_in_id); + + return { + out: outRow?.dataset.playerName || "", + in: inRow?.dataset.playerName || "" + }; +} + +function renderEvents() { + const list = document.getElementById("eventsList"); + if (!list) return; + + if (!matchEvents.length) { + list.innerHTML = '

Событий пока нет.
'; + return; + } + + const displayEvents = [...matchEvents].reverse(); + + + + list.innerHTML = displayEvents.map((event) => { + const compactTitle = String(event.title || "").includes(" — ") + ? String(event.title).split(" — ").slice(1).join(" — ") + : String(event.title || ""); + + const deleteHtml = event.id !== null && event.id !== undefined && event.id !== "" + ? `` + : ""; + + const canEditScorer = event.id !== null && + event.id !== undefined && + event.id !== "" && + ["goal", "penalty", "own_goal"].includes(event.type); + + const editHtml = canEditScorer + ? `` + : ""; + + if (event.side === "center") { + const clickableTypes = [ + "period_start_1h", + "period_ht", + "period_start_2h", + "period_ft", + "extra_time_first", + "extra_time_second" + ]; + + const isClickable = clickableTypes.includes(event.type); + const eventNameData = getPlayerNamesByEvent(event); + + const clickAttr = isClickable + ? buildVmixClickAttr({ + event_type: event.type, + event_id: event.id ?? null, + side: event.side ?? "center", + minute: event.minute ?? "", + seconds: event.seconds ?? 0, + player_name: event.player_name ?? "", + player_id: event.player_id ?? null, + player_out_id: event.player_out_id ?? null, + player_in_id: event.player_in_id ?? null, + player_name_out: eventNameData.out ?? "", + player_name_in: eventNameData.in ?? "", + title: compactTitle ?? "", + team_name: "" + }) + : ""; + + const clickableClass = isClickable ? " event-card-clickable" : ""; + + return ` +
+
+
+
+
${deleteHtml}
+
${escapeHtml(compactTitle)}
+
+
+
+
+ `; + } + + let actionsHtml = ""; + if (event.side === "home") { + actionsHtml = `${deleteHtml}${editHtml}`; + } else { + actionsHtml = `${editHtml}${deleteHtml}`; + } + + const clickableTypes = [ + "goal", + "penalty", + "yellow", + "red", + "sub" + ]; + + const eventNumberData = getPlayerNumberByEvent(event); + + const isClickable = clickableTypes.includes(event.type); + + const clickAttr = isClickable + ? buildVmixClickAttr({ + event_type: event.type, + event_id: event.id ?? null, + side: event.side ?? "", + minute: event.minute ?? "", + seconds: event.seconds ?? 0, + player_name: event.player_name ?? "", + player_id: event.player_id ?? null, + player_out_id: event.player_out_id ?? null, + player_in_id: event.player_in_id ?? null, + player_number: typeof eventNumberData === "string" ? eventNumberData : "", + player_out_number: typeof eventNumberData === "object" ? (eventNumberData.out ?? "") : "", + player_in_number: typeof eventNumberData === "object" ? (eventNumberData.in ?? "") : "", + title: compactTitle ?? "", + team_name: event.teamName ?? TEAM_NAMES[event.side] ?? "" + }) + : ""; + + const clickableClass = isClickable ? " event-card-clickable" : ""; + + const cardHtml = ` +
+
${actionsHtml}
+
${escapeHtml(event.icon)}
+
${escapeHtml(compactTitle)}
+
+ `; + + return ` +
+
${event.side === "home" ? cardHtml : ""}
+
${escapeHtml(event.minute)}
+
${event.side === "away" ? cardHtml : ""}
+
+ `; + }).join(""); +} + +async function loadEventsFromServer() { + if (!SESSION_TOKEN) return; + + try { + const persisted = getPersistedState(); + const persistedEvents = Array.isArray(persisted?.matchEvents) ? persisted.matchEvents : []; + + const res = await fetch(`/admin/session/${SESSION_TOKEN}/events`); + if (!res.ok) return; + + const data = await res.json(); + if (!Array.isArray(data)) return; + + matchEvents = data.map((event) => { + const fallback = persistedEvents.find((p) => + String(p.type || "") === String(event.type || "") && + String(p.player_name || "") === String(event.player_name || "") && + String(p.seconds || "") === String(event.seconds || "") + ); + const displayPlayerName = getDisplayNameForEvent(event); + return { + ...event, + id: event.id ?? fallback?.id ?? null, + side: event.side, + icon: getEventIcon(event.type), + title: getEventTitle(event.type, event.player_name), + player_name: displayPlayerName, + minute: typeof event.minute === "string" && event.minute.includes("'") + ? event.minute + : (fallback?.minute || getEventDisplayMinuteBySeconds(event.seconds || 0, event.type)), + + + player_id: event.player_id ?? fallback?.player_id ?? null, + player_out_id: event.player_out_id ?? fallback?.player_out_id ?? null, + player_in_id: event.player_in_id ?? fallback?.player_in_id ?? null + }; + }); + + matchEvents = sortEventsForStorage(matchEvents); + + + + scoreState = { home: 0, away: 0 }; + matchEvents.forEach((event) => { + if (["goal", "penalty", "own_goal"].includes(event.type)) { + scoreState[event.side] = (scoreState[event.side] || 0) + 1; + } + }); + + updateScoreUI(); + renderEvents(); + rebuildLineupsFromEvents(); + saveMatchState(); + updateCoachCardButtons(); + updatePeriodButtons(); + } catch (e) { + console.warn("Не удалось загрузить события из БД", e); + } +} + +async function sendDirectVmixCommands(commands) { + const items = Array.isArray(commands) + ? commands.map(x => String(x || "").trim()).filter(Boolean) + : String(commands || "").split("\n").map(x => x.trim()).filter(Boolean); + + if (!items.length) return; + + try { + const resp = await fetch("/api/vmix/publish-command", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + match_id: MATCH_ID, + session_token: SESSION_TOKEN, + target_client_id: VMIX_TARGET_CLIENT_ID, + target_group: VMIX_TARGET_GROUP, + commands: items + }) + }); + + const data = await resp.json(); + + if (!resp.ok) { + console.warn("Ошибка публикации команд vMix:", data); + alert("Не удалось отправить команды в vMix"); + return; + } + + console.log("VMIX PUBLISH OK:", data); + } catch (e) { + console.warn("Ошибка отправки команд на сервер:", e); + alert("Ошибка связи с сервером при отправке команд vMix"); + } +} + + +function syncFormationCaptainsFromMatchData(side) { + const starting = side === "home" + ? (window.MATCH_DATA.homeStarting || []) + : (window.MATCH_DATA.awayStarting || []); + + if (!Array.isArray(formationState[side]) || formationState[side].length === 0) { + formationState[side] = starting.slice(0, 11).map((p, idx) => ({ + player_id: p.player_id || p.id || p.number || null, + player_name: p.player_name || `${p.last_name || ""} ${p.first_name || ""}`.trim(), + last_name: p.last_name || p.player_name || "", + first_name: p.first_name || "", + number: p.number || "", + position: p.position || "", + is_captain: !!p.is_captain, + photo: p.photo || "", + x: 50, + y: 12 + idx * 6, + })); + + renderFormation(side); + return; + } + + const captainIds = new Set( + starting + .filter(p => !!p.is_captain) + .map(p => String(p.player_id ?? p.id ?? p.number ?? "")) + ); + + formationState[side] = formationState[side].map(player => ({ + ...player, + is_captain: captainIds.has(String(player.player_id ?? player.id ?? player.number ?? "")) + })); + + renderFormation(side); +} + + +async function triggerVmixTimerAction(action) { + const map = { + first_half_start: [ + vmixCmd("Function=SetCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=02:00:00"), + vmixCmd("Function=ChangeCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=00:00:00"), + vmixCmd("Function=StartCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text"), + vmixCmd("Function=PreviewInput&Input=ВЕРХНИЙ СЧЕТ") + ], + halftime: [ + vmixCmd("Function=PauseCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text"), + vmixCmd("Function=OverlayInput1Out"), + vmixCmd("Function=OverlayInput2Out") + ], + second_half_start: [ + vmixCmd("Function=SetCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=02:00:00"), + vmixCmd("Function=ChangeCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=00:45:00"), + vmixCmd("Function=StartCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text"), + vmixCmd("Function=PreviewInput&Input=ВЕРХНИЙ СЧЕТ") + ], + full_time: [ + vmixCmd("Function=PauseCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text"), + vmixCmd("Function=OverlayInput1Out"), + vmixCmd("Function=OverlayInput2Out") + ], + clear_match: [ + vmixCmd("Function=SetCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=02:00:00"), + vmixCmd("Function=ChangeCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=00:00:00"), + vmixCmd("Function=PauseCountdown&Input=ВЕРХНИЙ СЧЕТ&SelectedName=Таймер.Text&Value=00:00:00"), + ] + }; + + const commands = map[action] || []; + if (!commands.length) return; + + await sendDirectVmixCommands(commands); +} + +function clearExtraTimeInput() { + const input = document.getElementById("extraTimeMinutes"); + if (input) input.value = ""; +} + +function clearTemporaryExpelledRows() { + document.querySelectorAll('tr.lineup-row[data-temporary="1"]').forEach((row) => row.remove()); +} + +function getBenchTbody(side) { + const benchTable = Array.from(document.querySelectorAll("tr.lineup-row")) + .find((row) => row.dataset.side === side && row.dataset.role === "bench") + ?.closest("tbody"); + return benchTable || null; +} + +function createTemporaryExpelledRow(sourceRow, disciplineType) { + const benchTbody = getBenchTbody(sourceRow.dataset.side); + if (!benchTbody) return null; + + const tempRow = document.createElement("tr"); + tempRow.className = "lineup-row"; + tempRow.dataset.side = sourceRow.dataset.side || ""; + tempRow.dataset.role = "expelled"; + tempRow.dataset.temporary = "1"; + + const player = getRowPlayer(sourceRow); + player.disciplineType = disciplineType; + setRowPlayer(tempRow, player); + renderLineupRow(tempRow); + tempRow.classList.add("lineup-row-sub-out"); + + benchTbody.appendChild(tempRow); + return tempRow; +} + +function getPlayerDisciplineMap() { + const map = new Map(); + + matchEvents + .filter((event) => event.side !== "center" && event.player_id) + .sort((a, b) => { + if ((a.seconds || 0) !== (b.seconds || 0)) return (a.seconds || 0) - (b.seconds || 0); + return (a.id || 0) - (b.id || 0); + }) + .forEach((event) => { + const key = `${event.side}::${event.player_id}`; + const current = map.get(key) || { yellow: 0, red: false, disciplineType: "" }; + + if (event.type === "yellow") { + current.yellow += 1; + if (current.yellow >= 2 && !current.red) { + current.disciplineType = "yellow_red"; + } + } + + if (event.type === "red") { + current.red = true; + current.disciplineType = "red"; + } + + map.set(key, current); + }); + + return map; +} + +function applyDisciplinaryEvents() { + clearTemporaryExpelledRows(); + + Array.from(document.querySelectorAll("tr.lineup-row")) + .filter((row) => row.dataset.temporary !== "1") + .forEach((row) => { + row.classList.remove("lineup-row-expelled-hidden"); + row.dataset.disciplineType = ""; + renderLineupRow(row); + }); + + const disciplineMap = getPlayerDisciplineMap(); + + disciplineMap.forEach((value, key) => { + if (!value.disciplineType) return; + + const [side, playerId] = key.split("::"); + + let row = findLineupRowByPlayerId(side, "starting", playerId); + if (row) { + row.dataset.disciplineType = value.disciplineType; + row.classList.add("lineup-row-expelled-hidden"); + createTemporaryExpelledRow(row, value.disciplineType); + return; + } + + row = findLineupRowByPlayerId(side, "bench", playerId); + if (row) { + row.dataset.disciplineType = value.disciplineType; + renderLineupRow(row); + } + }); + + sortLineupRows("home", "starting"); + sortLineupRows("home", "bench"); + sortLineupRows("away", "starting"); + sortLineupRows("away", "bench"); +} + +function sortLineupRows(side, role) { + const rows = Array.from(document.querySelectorAll( + `tr.lineup-row[data-side="${side}"][data-role="${role}"]` + )); + + const tbody = rows[0]?.closest("tbody"); + if (!tbody) return; + + rows.sort((a, b) => { + const pa = getRowPlayer(a); + const pb = getRowPlayer(b); + + const gkA = isGoalkeeperPosition(pa.position) ? 1 : 0; + const gkB = isGoalkeeperPosition(pb.position) ? 1 : 0; + + if (gkA !== gkB) return gkB - gkA; + + const numA = parseInt(pa.number, 10); + const numB = parseInt(pb.number, 10); + + const safeA = Number.isFinite(numA) ? numA : 999; + const safeB = Number.isFinite(numB) ? numB : 999; + + return safeA - safeB; + }); + + rows.forEach(row => tbody.appendChild(row)); +} + +function captureCurrentLineupState() { + const rows = Array.from(document.querySelectorAll("tr.lineup-row")); + return rows.map((row, index) => ({ + index, + side: row.dataset.side || "", + role: row.dataset.role || "", + player: getRowPlayer(row) + })); +} + +function restoreOriginalLineupRows() { + if (!Array.isArray(originalLineupState) || !originalLineupState.length) return; + + clearTemporaryExpelledRows(); + + const rows = Array.from(document.querySelectorAll("tr.lineup-row")) + .filter((row) => row.dataset.temporary !== "1"); + + originalLineupState.forEach((item) => { + const row = rows[item.index]; + if (!row) return; + row.classList.remove("lineup-row-expelled-hidden", "lineup-row-sub-in", "lineup-row-sub-out", "bench-pick-active"); + setRowPlayer(row, { ...item.player, disciplineType: "" }); + renderLineupRow(row); + }); + + sortLineupRows("home", "starting"); + sortLineupRows("home", "bench"); + sortLineupRows("away", "starting"); + sortLineupRows("away", "bench"); +} + +function findLineupRowByPlayerId(side, role, playerId) { + if (playerId === null || playerId === undefined || playerId === "") return null; + + return Array.from(document.querySelectorAll("tr.lineup-row")).find((row) => + row.dataset.temporary !== "1" && + String(row.dataset.side || "") === String(side) && + String(row.dataset.role || "") === String(role) && + String(row.dataset.playerId || "") === String(playerId) + ) || null; +} + +function swapLineupPlayers(startingRow, benchRow) { + const startingPlayer = getRowPlayer(startingRow); + const benchPlayer = getRowPlayer(benchRow); + + setRowPlayer(startingRow, benchPlayer); + setRowPlayer(benchRow, startingPlayer); + + renderLineupRow(startingRow); + renderLineupRow(benchRow); + sortLineupRows(startingRow.dataset.side, "starting"); + sortLineupRows(startingRow.dataset.side, "bench"); + startingRow.classList.add("lineup-row-sub-in"); + benchRow.classList.add("lineup-row-sub-out"); + saveMatchState(); +} + +function rebuildLineupsFromEvents() { + restoreOriginalLineupRows(); + + const subEvents = matchEvents + .filter((event) => event.type === "sub" && event.player_out_id && event.player_in_id) + .sort((a, b) => { + if ((a.seconds || 0) !== (b.seconds || 0)) return (a.seconds || 0) - (b.seconds || 0); + return (a.id || 0) - (b.id || 0); + }); + + subEvents.forEach((event) => { + const outRow = findLineupRowByPlayerId(event.side, "starting", event.player_out_id); + const inRow = findLineupRowByPlayerId(event.side, "bench", event.player_in_id); + if (outRow && inRow) swapLineupPlayers(outRow, inRow); + }); + + applyDisciplinaryEvents(); +} + +async function selectBenchPlayer(side, playerId, playerName, btn) { + if (!activeSubstitution || activeSubstitution.side !== side) return; + + clearBenchSelection(); + if (btn) btn.classList.add("bench-pick-active"); + + const benchRow = btn ? btn.closest("tr.lineup-row") : null; + const outRow = activeSubstitution.outRow || null; + + const outName = outRow + ? getRowDisplayName(outRow) + : activeSubstitution.outPlayerName; + + const inName = benchRow + ? getRowDisplayName(benchRow) + : playerName; + + if (outRow && benchRow) { + swapLineupPlayers(outRow, benchRow); + } + + const minuteLabel = formatMinuteLabel(matchClockSeconds); + const numericMinute = + parseInt(String(minuteLabel).split("+").pop().replace("'", ""), 10) || + getFootballMinute(matchClockSeconds); + + const item = { + id: null, + side, + type: "sub", + icon: getEventIcon("sub"), + title: `${outName} → ${inName}`, + minute: minuteLabel, + seconds: matchClockSeconds, + player_name: `${outName} → ${inName}`, + player_out_id: activeSubstitution.outPlayerId, + player_in_id: playerId, + player_id: null + }; + + matchEvents.push(item); + renderEvents(); + rebuildLineupsFromEvents(); + saveMatchState(); + + const saved = await saveEventToServer({ + side, + type: "sub", + player_name: `${outName} → ${inName}`, + player_out_id: activeSubstitution.outPlayerId, + player_in_id: playerId, + player_id: null, + minute: numericMinute, + seconds: matchClockSeconds, + meta: null + }); + + if (saved && saved.id) { + item.id = saved.id; + renderEvents(); + rebuildLineupsFromEvents(); + saveMatchState(); + } + + activeSubstitution = null; + clearBenchSelection(); +} + + +function ensureInitialState(side) { + if (!formationState[side] || formationState[side].length === 0) { + const currentStarting = side === "home" + ? (window.MATCH_DATA.homeStarting || []) + : (window.MATCH_DATA.awayStarting || []); + + formationState[side] = currentStarting.slice(0, 11).map((p, idx) => ({ + player_id: p.player_id || p.id || p.number || null, + player_name: p.player_name || `${p.last_name || ""} ${p.first_name || ""}`.trim(), + last_name: p.last_name || p.player_name || "", + first_name: p.first_name || "", + number: p.number || "", + position: p.position || "", + is_captain: !!p.is_captain, + photo: p.photo || "", + x: 50, + y: 12 + idx * 6, + })); + } +} + +function extractLastName(player) { + return String(player.last_name || "").trim().toUpperCase(); +} + +function buildPlayerSuffix(player) { + const pos = String(player.position || "").trim().toLowerCase(); + const isGoalkeeper = ["вр.", "вр", "вратарь", "gk", "goalkeeper"].includes(pos); + const isCaptain = !!player.is_captain; + + if (isGoalkeeper && isCaptain) return " (ВР, К)"; + if (isGoalkeeper) return " (ВР)"; + if (isCaptain) return " (К)"; + return ""; +} + +function buildPlayerLabel(player) { + const number = player.number || ""; + const lastName = extractLastName(player); + return `${number} ${lastName}${buildPlayerSuffix(player)}`.trim(); +} + +function createPlayerNode(player, side) { + const el = document.createElement("div"); + el.className = "player-node"; + el.dataset.playerId = player.player_id ?? ""; + el.dataset.side = side; + const safeX = clampWebPercent(player.x); + const safeY = clampWebPercent(player.y); + + el.style.left = `${safeX}%`; + el.style.top = `${safeY}%`; + + + const visual = player.photo + ? `${player.player_name || ` + : `
`; + + el.innerHTML = ` + ${visual} +
+ ${buildPlayerLabel(player)} +
+ `; + + makeDraggable(el); + return el; +} + + +function clampWebPercent(value) { + const n = Number(value); + if (!Number.isFinite(n)) return 50; + return Math.max(4, Math.min(96, n)); +} + +function renderFormation(side) { + ensureInitialState(side); + syncFormationCaptainsInState(side); + + const pitch = document.getElementById(`pitch-${side}`); + if (!pitch) return; + + const overlays = pitch.querySelectorAll(".player-node"); + overlays.forEach(node => node.remove()); + + const players = Array.isArray(formationState[side]) ? formationState[side] : []; + + players.forEach(player => { + pitch.appendChild(createPlayerNode(player, side)); + }); +} + + +function isGoalkeeperPosition(position) { + const pos = String(position || "").trim().toLowerCase(); + return ["вр.", "вр", "вратарь", "gk", "goalkeeper"].includes(pos); +} + +function getRowPlayer(row) { + return { + id: row.dataset.playerId || "", + name: row.dataset.playerName || "", + lastName: row.dataset.playerLast || "", + firstName: row.dataset.playerFirst || "", + number: row.dataset.playerNumber || "", + position: row.dataset.playerPosition || "", + isCaptain: row.dataset.playerCaptain === "1", + disciplineType: row.dataset.disciplineType || "" + }; +} + +function setRowPlayer(row, player) { + row.dataset.playerId = player.id ?? ""; + row.dataset.playerName = player.name ?? ""; + row.dataset.playerLast = player.lastName ?? ""; + row.dataset.playerFirst = player.firstName ?? ""; + row.dataset.playerNumber = player.number ?? ""; + row.dataset.playerPosition = player.position ?? ""; + row.dataset.playerCaptain = player.isCaptain ? "1" : "0"; + row.dataset.disciplineType = player.disciplineType ?? ""; +} + +function getDisplayNameForEvent(event) { + if (!event) return ""; + + if (event.type === "sub") { + const subNames = getSubstitutionDisplayNames(event); + if (subNames.out || subNames.in) { + return `${subNames.out} → ${subNames.in}`.trim(); + } + return String(event.player_name || "").trim(); + } + + if (event.player_id) { + const row = + findLineupRowByPlayerId(event.side, "starting", event.player_id) || + findLineupRowByPlayerId(event.side, "bench", event.player_id); + + if (row) { + return getRowDisplayName(row); + } + } + + return String(event.player_name || "").trim(); +} + +function buildPersonDisplayName(firstName, lastName, fallback = "") { + const first = String(firstName || "").trim(); + const last = String(lastName || "").trim(); + const safeFallback = String(fallback || "").trim(); + + if (first && last) return `${first} ${last}`; + if (last) return last; + if (first) return first; + return safeFallback; +} + +function getRowDisplayName(row) { + if (!row) return ""; + return buildPersonDisplayName( + row.dataset.playerFirst || "", + row.dataset.playerLast || "", + row.dataset.playerName || "" + ); +} + +function getSubstitutionDisplayNames(event) { + if (!event || event.type !== "sub") { + return { out: "", in: "" }; + } + + const outRow = + findLineupRowByPlayerId(event.side, "starting", event.player_out_id) || + findLineupRowByPlayerId(event.side, "bench", event.player_out_id); + + const inRow = + findLineupRowByPlayerId(event.side, "starting", event.player_in_id) || + findLineupRowByPlayerId(event.side, "bench", event.player_in_id); + + return { + out: getRowDisplayName(outRow), + in: getRowDisplayName(inRow) + }; +} + +function renderLineupRow(row) { + const side = row.dataset.side; + const role = row.dataset.role; + const player = getRowPlayer(row); + const fullName = (player.name || `${player.lastName || ""} ${player.firstName || ""}`).trim(); + const displayName = buildPersonDisplayName( + player.firstName, + player.lastName, + player.name || fullName + ); + const gkBadge = isGoalkeeperPosition(player.position) ? 'ВР' : ''; + const captainBadge = player.isCaptain ? 'К' : ''; + const disciplineBadge = player.disciplineType === "yellow_red" + ? '2ЖК' + : (player.disciplineType === "red" + ? 'КК' + : ''); + const firstNameHtml = player.firstName ? ` ${escapeHtml(player.firstName)}` : ''; + const idJs = JSON.stringify(player.id || player.number || ""); + const nameJs = JSON.stringify(displayName); + + let actionsHtml = ''; + if (role === "starting") { + actionsHtml = ` +
+ + + + + + +
+ `; + } else if (role === "bench") { + actionsHtml = ` +
+ +
+ `; + } else { + actionsHtml = `
`; + } + + row.innerHTML = ` + ${escapeHtml(player.number)} + +
+
+ ${escapeHtml(player.lastName || player.name)}${firstNameHtml} +
+ ${gkBadge}${captainBadge}${disciplineBadge} +
+ + ${actionsHtml} + `; +} + +function makeDraggable(el) { + let dragging = false; + + el.addEventListener("pointerdown", (e) => { + if (!editModeEnabled) return; + + e.preventDefault(); + dragging = true; + el.setPointerCapture(e.pointerId); + el.classList.add("dragging"); + }); + + el.addEventListener("pointermove", (e) => { + if (!editModeEnabled || !dragging) return; + + const pitch = el.closest(".pitch-half"); + if (!pitch) return; + + const rect = pitch.getBoundingClientRect(); + + let x = ((e.clientX - rect.left) / rect.width) * 100; + let y = ((e.clientY - rect.top) / rect.height) * 100; + + x = Math.max(4, Math.min(96, x)); + y = Math.max(4, Math.min(96, y)); + + el.style.left = `${x}%`; + el.style.top = `${y}%`; + + const side = el.dataset.side; + const playerId = el.dataset.playerId; + const list = formationState[side] || []; + const item = list.find((p) => String(p.player_id) === String(playerId)); + + if (item) { + item.x = x; + item.y = y; + } + }); + + const stopDrag = (e) => { + if (!dragging) return; + + dragging = false; + el.classList.remove("dragging"); + + try { + el.releasePointerCapture(e.pointerId); + } catch (_) {} + + saveMatchState(); + }; + + el.addEventListener("pointerup", stopDrag); + el.addEventListener("pointercancel", stopDrag); +} + +document.addEventListener("DOMContentLoaded", () => { + setEditMode(false); + renderRefereesEditor(); +}); + + +async function saveFormation(side) { + syncFormationCaptainsInState(side); + + const response = await fetch(`${window.location.pathname}/formations/save`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + side, + players: formationState[side] + }) + }); + + const data = await response.json(); + if (data.success) { + await sendFormationToVmix(side); + alert("Расстановка сохранена"); + } else { + alert("Ошибка сохранения"); + } +} + + +restoreSelectedPreset("home"); +restoreSelectedPreset("away"); +rebuildFormationFromMatchData("home"); +rebuildFormationFromMatchData("away"); +sortLineupRows("home", "starting"); +sortLineupRows("home", "bench"); +sortLineupRows("away", "starting"); +sortLineupRows("away", "bench"); + +// берём актуальный состав прямо из HTML, который пришёл с сервера +originalLineupState = captureCurrentLineupState(); + +const restored = restoreMatchState(); +saveMatchState(); + +if (!restored) { + updateTimerUI(); + updateScoreUI(); + renderEvents(); + saveMatchState(); +} + +loadEventsFromServer(); +window.addEventListener("beforeunload", saveMatchState); +window.addEventListener("pagehide", saveMatchState); +document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") { + saveMatchState(); + } +}); + +const VMIX_ORIGIN = { + x: 0.003, + y: -0.075 +}; + +const VMIX_X_MIN = -0.338; +const VMIX_X_MAX = 0.346; +const VMIX_Y_TOP = 0; +const VMIX_Y_BOTTOM = -1.535; +// const VMIX_Y_OFFSET = -0.225; // подгонишь руками + +function getFormationVmixInputName(side) { + return side === "home" + ? "РАССТАНОВКА - ХОЗЯЕВА" + : "РАССТАНОВКА - ГОСТИ"; +} + + +function clampVmixPercent(value) { + const num = Number(value); + if (!Number.isFinite(num)) return 0; + return Math.max(0, Math.min(100, num)); +} + +function convertPitchXToPan(x) { + const percent = clampVmixPercent(x) / 100; + return VMIX_X_MIN + percent * (VMIX_X_MAX - VMIX_X_MIN); +} + +function convertPitchYToPan(y) { + const percent = clampVmixPercent(y) / 100; + return VMIX_Y_TOP + percent * (VMIX_Y_BOTTOM - VMIX_Y_TOP); +} + + +function buildVmixCommandsFromFormation(side) { + const players = Array.isArray(formationState[side]) ? formationState[side] : []; + const inputName = getFormationVmixInputName(side); + const commands = []; + const playerPrefix = side === "home" ? "HOME" : "AWAY"; + + if (players.length < 2) { + return []; + } + + // Сбрасываем только свою сторону + for (let i = 1; i <= 10; i++) { + commands.push( + vmixCmd(`Function=ResetInput&Input=${encodeURIComponent(`${playerPrefix} - Игрок${i}`)}`) + ); + } + + commands.push( + vmixCmd(`Function=ResetInput&Input=${encodeURIComponent(inputName)}`) + ); + + // Вратарь players[0], полевые players[1..10] + players.slice(1, 11).forEach((player, index) => { + const layer = index + 1; + const x = convertPitchXToPan(player.x).toFixed(3); + const y = convertPitchYToPan(player.y).toFixed(3); + + commands.push( + vmixCmd(`Function=SetLayer${layer}PanX&Input=${encodeURIComponent(inputName)}&Value=${x}`) + ); + + commands.push( + vmixCmd(`Function=SetLayer${layer}PanY&Input=${encodeURIComponent(inputName)}&Value=${y}`) + ); + }); + + commands.push( + vmixCmd(`Function=PreviewInput&Input=${encodeURIComponent(inputName)}`) + ); + + return commands; +} + + +async function sendFormationToVmix(side) { + syncFormationCaptainsInState(side); + + const commands = buildVmixCommandsFromFormation(side); + + if (!commands.length) { + alert("Нет данных для отправки расстановки в vMix"); + return; + } + + console.log(`FORMATION VMIX ${side}`, formationState[side]); + console.log(`FORMATION VMIX COMMANDS ${side}`, commands); + + await sendDirectVmixCommands(commands); +} +const FORMATION_COORDS = { + "4-4-2": [ + { x: 50, y: 10 }, + { x: 0, y: 40 }, { x: 30, y: 30 }, { x: 70, y: 30 }, { x: 100, y: 40 }, + { x: 0, y: 75 }, { x: 30, y: 65 }, { x: 70, y: 65 }, { x: 100, y: 75 }, + { x: 25, y: 100 }, { x: 75, y: 100 } + ] +}; + +const BASE_GEOMETRY = { + goalkeeper: { x: 50, y: 10 }, + + rowsY: { + defenders: 35, + midfielders: 70, + forwards: 100 + }, + + rowProfiles: { + 1: { xSlots: [50], arc: 0 }, + 2: { xSlots: [25, 75], arc: 0 }, + + 3: { xSlots: [8, 50, 92], arc: 0 }, + 4: { xSlots: [0, 30, 70, 100], arc: 10 }, + 5: { xSlots: [0, 24, 50, 78, 100], arc: 20, yOffset: 5 }, + 6: { xSlots: [0, 16, 36, 64, 84, 100], arc: 0 } + } +}; + +function distributeLine(count, yCenter, profile) { + const { minX, maxX, arc = 0, xSlots = null, yOffset = 0 } = profile; + + if (count <= 0) return []; + if (count === 1) return [{ x: 50, y: yCenter }]; + + const xs = Array.isArray(xSlots) && xSlots.length === count + ? xSlots + : Array.from({ length: count }, (_, i) => minX + ((maxX - minX) * i / (count - 1))); + + // Специальная полудуга для линии из 5 игроков + if (count === 5 && arc > 0) { + const offsets = [ + arc * 0.11, // левый фланг чуть ниже базы + -arc * 0.5, // левый полуфланг выше + -arc, // центр выше всех + -arc * 0.5, // правый полуфланг выше + arc * 0.11 // правый фланг чуть ниже базы + ]; + + return xs.map((x, i) => ({ + x, + y: yCenter + offsets[i] + yOffset + })); + } + + // Общая логика для остальных линий + const center = (count - 1) / 2; + + return xs.map((x, i) => { + let y = yCenter; + + if (arc > 0) { + const dist = Math.abs(i - center); + const maxDist = center || 1; + const normalized = 1 - dist / maxDist; + y = yCenter - arc * normalized; + } + + return { x, y: y + yOffset }; + }); +} + +const FORMATION_TEMPLATE_442 = { + gk: { x: 50, y: 10 }, + + rows: [ + { y: 35, xSlots: [0, 30, 70, 100] }, // защита 4 + { y: 70, xSlots: [0, 30, 70, 100] }, // полузащита 4 + { y: 100, xSlots: [25, 75] } // нападение 2 + ] +}; + +// function getDistributedXSlots(count) { +// if (count <= 0) return []; +// if (count === 1) return [50]; +// if (count === 2) return [25, 75]; +// if (count === 3) return [8, 50, 92]; +// if (count === 4) return [0, 30, 70, 100]; +// if (count === 5) return [0, 18, 50, 82, 100]; +// if (count === 6) return [0, 16, 36, 64, 84, 100]; + +// return Array.from({ length: count }, (_, i) => (i / (count - 1)) * 100); +// } + +// function getArcYOffsetSlots(count, strength = 10) { +// if (count < 4) { +// return Array(count).fill(0); +// } + +// if (count === 5) { +// return [ +// strength * 0.11, // левый фланг ниже +// -strength * 0.5, // левый полуфланг чуть выше базы +// -strength, // центр выше всех +// -strength * 0.5, // правый полуфланг чуть выше базы +// strength * 0.11 // правый фланг ниже +// ]; +// } + +// const center = (count - 1) / 2; + +// return Array.from({ length: count }, (_, i) => { +// const dist = Math.abs(i - center); +// const maxDist = center || 1; +// const normalized = dist / maxDist; + +// return -strength * Math.pow(1 - normalized, 1.5); +// }); +// } + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function lerp(a, b, t) { + return a + (b - a) * t; +} + +function distributeEvenly(count, start, end) { + if (count <= 0) return []; + if (count === 1) return [(start + end) / 2]; + + const step = (end - start) / (count - 1); + return Array.from({ length: count }, (_, i) => start + step * i); +} + +function getRowSpacingFactor(rowIndex, rowCount, options = {}) { + // можно управлять “сжатием” ближе к воротам или к атаке + const { + curve = 1 // 1 = линейно, >1 сильнее прижимает первые линии + } = options; + + if (rowCount <= 1) return 1; + const t = rowIndex / (rowCount - 1); + return Math.pow(t, curve); +} + +function getHorizontalInsetsForRow(count, rowType, options = {}) { + const { + sideInset = 8, + wideRowExtraInset = 0, + strikerInset = 18 + } = options; + + // можно по типу линии менять ширину доступной зоны + if (rowType === "forwards" && count <= 2) { + return { left: strikerInset, right: strikerInset }; + } + + if (count >= 5) { + return { + left: sideInset + wideRowExtraInset, + right: sideInset + wideRowExtraInset + }; + } + + return { left: sideInset, right: sideInset }; +} + +function getRowArcOffsets(count, arcStrength = 0, direction = "up") { + if (count <= 1 || arcStrength === 0) { + return Array(count).fill(0); + } + + const center = (count - 1) / 2; + const sign = direction === "down" ? 1 : -1; + + return Array.from({ length: count }, (_, i) => { + const dist = Math.abs(i - center); + const maxDist = center || 1; + const normalized = 1 - (dist / maxDist); // центр = 1, края = 0 + return sign * arcStrength * Math.pow(normalized, 1.4); + }); +} + +function getRowTopLift(profile, count) { + const arc = Number(profile?.arc || 0); + const yOffset = Number(profile?.yOffset || 0); + + if (count === 5 && arc > 0) { + // у пятёрки самая верхняя точка — центр + return Math.max(0, arc - yOffset); + } + + if (count > 1 && arc > 0) { + // для обычной дуги максимум подъёма в центре + return Math.max(0, arc - yOffset); + } + + return Math.max(0, -yOffset); +} + + + +function buildFormationCoords(formation) { + const parts = String(formation).split("-").map(Number).filter(Number.isFinite); + const coords = []; + + coords.push(BASE_GEOMETRY.goalkeeper); + + if (!parts.length) { + return coords; + } + const rowCount = parts.length; + const firstRowY = 25; + let rowGap = 23; + + if (formation === "4-1-4-1") { + rowGap = 16; + } + + let previousBottomY = firstRowY - rowGap; + + parts.forEach((count, idx) => { + const profile = BASE_GEOMETRY.rowProfiles[count] || { + minX: 10, + maxX: 90, + arc: 0 + }; + + const topLift = getRowTopLift(profile, count); + + // стартуем новую линию так, чтобы её верхняя точка была ниже предыдущей линии + gap + const yCenter = previousBottomY + rowGap + topLift; + + const rowCoords = distributeLine(count, yCenter, profile); + coords.push(...rowCoords); + + previousBottomY = Math.max(...rowCoords.map(p => p.y)); + }); + + return coords; +} + +function generateFormationCoordsFromLayout(preset) { + const layout = FORMATION_ASSIGN_LAYOUTS[preset]; + if (!layout) return []; + + return buildFormationCoords(preset); +} + + +function generateFormationCoordsFromLayout2(preset) { + const layout = FORMATION_ASSIGN_LAYOUTS[preset]; + if (!layout || !layout.length) return []; + + const coords = []; + + const gk = FORMATION_COORDS["4-4-2"]?.[0] || { x: 50, y: 10 }; + coords.push({ x: gk.x, y: gk.y }); + + const fieldRows = layout.slice(1); + const rowCount = fieldRows.length; + + const yStart = 35; + const yEnd = 100; + + fieldRows.forEach((row, rowIndex) => { + const count = row.length; + + const baseY = + rowCount === 1 + ? yEnd + : yStart + ((yEnd - yStart) * rowIndex / (rowCount - 1)); + + const xSlots = getDistributedXSlots(count); + + let arcStrength = count >= 4 ? 10 : 0; + + // Для 3-5-2 средняя линия из 5 должна быть ровно посередине + if (preset === "3-5-2" && rowIndex === 1 && count === 5) { + arcStrength = 20; + } + + const arcOffsets = getArcYOffsetSlots(count, arcStrength); + + xSlots.forEach((x, i) => { + coords.push({ + x, + y: baseY + arcOffsets[i] + }); + }); + }); + + return coords; +} + +const FORMATION_ASSIGN_LAYOUTS = { + "4-4-2": [ + ["GK"], + ["LB", "LCB", "RCB", "RB"], + ["LM", "LCM", "RCM", "RM"], + ["LS", "RS"] + ], + "4-3-3": [ + ["GK"], + ["LB", "LCB", "RCB", "RB"], + ["LCM", "CM", "RCM"], + ["LW", "ST", "RW"] + ], + "4-2-3-1": [ + ["GK"], + ["LB", "LCB", "RCB", "RB"], + ["LDM", "RDM"], + ["LAM", "CAM", "RAM"], + ["ST"] + ], + "3-5-2": [ + ["GK"], + ["LCB", "CB", "RCB"], + ["LM", "LCM", "CM", "RCM", "RM"], + ["LS", "RS"] + ], + "5-3-2": [ + ["GK"], + ["LWB", "LCB", "CB", "RCB", "RWB"], + ["LCM", "CM", "RCM"], + ["LS", "RS"] + ], + "4-1-4-1": [ + ["GK"], + ["LB", "LCB", "RCB", "RB"], + ["CDM"], + ["LM", "LCM", "RCM", "RM"], + ["ST"] + ], + + "3-4-3": [ + ["GK"], + ["LCB", "CB", "RCB"], + ["LM", "LCM", "RCM", "RM"], + ["LW", "ST", "RW"] + ], + + "4-5-1": [ + ["GK"], + ["LB", "LCB", "RCB", "RB"], + ["LM", "LCM", "CM", "RCM", "RM"], + ["ST"] + ], + + "5-4-1": [ + ["GK"], + ["LWB", "LCB", "CB", "RCB", "RWB"], + ["LM", "LCM", "RCM", "RM"], + ["ST"] + ], + + "4-3-1-2": [ + ["GK"], + ["LB", "LCB", "RCB", "RB"], + ["LCM", "CM", "RCM"], + ["CAM"], + ["LS", "RS"] + ], + + "3-6-1": [ + ["GK"], + ["LCB", "CB", "RCB"], + ["LM", "LCM", "CM", "RCM", "RM", "CAM"], + ["ST"] + ] +}; + +let formationAssignState = { + side: null, + preset: null +}; + +function getFormationAssignSquad(side) { + return side === "home" + ? (window.MATCH_DATA.homeStarting || []) + : (window.MATCH_DATA.awayStarting || []); +} + +function getFormationAssignTeamName(side) { + return side === "home" + ? (window.MATCH_DATA.teamNames?.home || "") + : (window.MATCH_DATA.teamNames?.away || ""); +} + +function getFormationAssignPhoto(player) { + return player.photo || player.player_photo || player.image || player.avatar || ""; +} + +function getFormationAssignPlayerName(player) { + return `${player.last_name || player.player_name || ""} ${player.first_name || ""}`.trim(); +} + +function getExistingAssignedNumber(side, rowIndex, colIndex) { + const list = Array.isArray(formationState[side]) ? formationState[side] : []; + const item = list.find(p => Number(p.row_index) === rowIndex && Number(p.col_index) === colIndex); + return item ? String(item.number || "") : ""; +} + +function openFormationAssignModal(side) { + const presetSelect = document.getElementById(`${side}-preset`); + if (!presetSelect) return; + + const preset = presetSelect.value; + const layout = FORMATION_ASSIGN_LAYOUTS[preset]; + if (!layout) return; + + formationAssignState.side = side; + formationAssignState.preset = preset; + + const subtitle = document.getElementById("formationAssignSubtitle"); + if (subtitle) { + subtitle.textContent = `${getFormationAssignTeamName(side)} — схема ${preset}`; + } + + const squad = getFormationAssignSquad(side); + const wrap = document.getElementById("formationAssignTableWrap"); + if (!wrap) return; + + let html = ` + + + + + + + + + + + `; + + layout.forEach((row, rowIndex) => { + row.forEach((pos, colIndex) => { + const existingNumber = getExistingAssignedNumber(side, rowIndex, colIndex); + + html += ` + + + + + + + `; + }); + }); + + html += `
ЛинияПозиция№ игрокаДоступные номера
${rowIndex + 1}${pos} + + ${squad.map(p => p.number).filter(Boolean).join(", ")}
`; + wrap.innerHTML = html; + + document.getElementById("formationAssignModal")?.classList.add("show"); +} + +function closeFormationAssignModal() { + document.getElementById("formationAssignModal")?.classList.remove("show"); +} + +function confirmFormationAssign() { + const side = formationAssignState.side; + const preset = formationAssignState.preset; + + if (!side || !preset) return; + + const layout = FORMATION_ASSIGN_LAYOUTS[preset]; + const squad = getFormationAssignSquad(side); + const inputs = document.querySelectorAll("#formationAssignTableWrap .formation-number-input"); + + const usedNumbers = new Set(); + const players = []; + + for (const input of inputs) { + const rawNumber = String(input.value || "").trim(); + if (!rawNumber) continue; + + if (usedNumbers.has(rawNumber)) { + alert(`Номер ${rawNumber} указан дважды.`); + return; + } + usedNumbers.add(rawNumber); + + const player = squad.find(p => String(p.number || "").trim() === rawNumber); + if (!player) { + alert(`Игрок с номером ${rawNumber} не найден в стартовом составе.`); + return; + } + + players.push({ + player_id: player.player_id || player.id || player.number || "", + player_name: getFormationAssignPlayerName(player), + last_name: player.last_name || player.player_name || "", + first_name: player.first_name || "", + number: player.number || "", + position: player.position || "", + is_captain: !!player.is_captain, + photo: getFormationAssignPhoto(player), + x: 50, + y: 50, + row_index: Number(input.dataset.row), + col_index: Number(input.dataset.col), + slot_position: input.dataset.pos || "" + }); + } + + const expectedCount = layout.flat().length; + if (players.length !== expectedCount) { + alert(`Нужно заполнить все позиции. Сейчас заполнено ${players.length} из ${expectedCount}.`); + return; + } + + const rowCount = layout.length; + + const topPadding = 18; + const bottomPadding = 14; + const usableHeight = 100 - topPadding - bottomPadding; + + const coords = FORMATION_COORDS[preset] || generateFormationCoordsFromLayout(preset); + + players.forEach((item, index) => { + if (coords[index]) { + item.x = coords[index].x; + item.y = coords[index].y; + } + }); + + formationState[side] = players; + syncFormationCaptainsInState(side); + renderFormation(side); + closeFormationAssignModal(); + + renderFormation(side); + closeFormationAssignModal(); +} + +async function applyPreset(side) { + openFormationAssignModal(side); +} + +const squadEditorState = { + home: null, + away: null +}; + +function normalizePlayer(item = {}) { + return { + player_id: item.player_id ?? item.id ?? item.number ?? "", + player_name: item.player_name || `${item.last_name || ""} ${item.first_name || ""}`.trim(), + last_name: item.last_name || item.player_name || "", + first_name: item.first_name || "", + number: item.number || "", + position: item.position || "", + is_captain: !!item.is_captain + }; +} + +function normalizeCoach(item = {}) { + return { + coach_id: item.coach_id ?? item.id ?? item.coach_name ?? "", + coach_name: item.coach_name || item.name || "", + role: item.role || "" + }; +} + +function cloneSideState(side) { + return { + starting: (side === "home" ? (window.MATCH_DATA.homeStarting || []) : (window.MATCH_DATA.awayStarting || [])) + .map(normalizePlayer), + bench: (side === "home" ? (window.MATCH_DATA.homeBench || []) : (window.MATCH_DATA.awayBench || [])) + .map(normalizePlayer), + coaches: (side === "home" ? (window.MATCH_DATA.homeCoaches || []) : (window.MATCH_DATA.awayCoaches || [])) + .map(normalizeCoach), + squadPool: (side === "home" ? (window.MATCH_DATA.homeSquadPool || []) : (window.MATCH_DATA.awaySquadPool || [])) + .map(normalizePlayer), + coachPool: (side === "home" ? (window.MATCH_DATA.homeCoachPool || []) : (window.MATCH_DATA.awayCoachPool || [])) + .map(normalizeCoach) + }; +} + +async function openSquadEditor() { + if (!editModeEnabled) return; + + const ok = await loadSquadEditorData(); + if (!ok) { + alert("Не удалось загрузить игроков и тренеров из базы."); + return; + } + + squadEditorState.home = cloneSideState("home"); + squadEditorState.away = cloneSideState("away"); + + renderSquadEditorSide("home"); + renderSquadEditorSide("away"); + + document.getElementById("squadEditorModal")?.classList.add("show"); +} + + +function closeSquadEditor() { + document.getElementById("squadEditorModal")?.classList.remove("show"); +} + +function getSideEditorState(side) { + return side === "home" ? squadEditorState.home : squadEditorState.away; +} + +function getSideContainerId(side) { + return side === "home" ? "squadEditorHome" : "squadEditorAway"; +} + +function getAvailableSquadPool(side) { + const state = getSideEditorState(side); + if (!state) return []; + + const used = new Set( + [...state.starting, ...state.bench].map(p => String(p.player_id)) + ); + + return state.squadPool.filter(p => !used.has(String(p.player_id))); +} + +function buildPlayerOptionText(p) { + const fullName = `${p.last_name || p.player_name || ""} ${p.first_name || ""}`.trim(); + const num = p.number ? `#${p.number}` : "без №"; + const pos = p.position ? ` • ${p.position}` : ""; + return `${num} • ${fullName}${pos}`; +} + +function renderSquadEditorSide(side) { + const state = getSideEditorState(side); + const mount = document.getElementById(getSideContainerId(side)); + if (!state || !mount) return; + + const availablePlayers = getAvailableSquadPool(side); + const coachOptions = state.coachPool; + + mount.innerHTML = ` +
+
Добавить игрока
+
+ + +
+ + +
+ +
+ В списке показываются только игроки, которых ещё нет в текущем составе. +
+
+
+ +
+
Основной состав
+
+
+ ${state.starting.length ? state.starting.map(player => renderSquadEditorPlayerRow(side, "starting", player)).join("") : `
Пока пусто.
`} +
+
+
+ +
+
Запасные
+
+
+ ${state.bench.length ? state.bench.map(player => renderSquadEditorPlayerRow(side, "bench", player)).join("") : `
Пока пусто.
`} +
+
+
+ +
+
Тренеры
+
+ ${renderCoachEditorSection(side, coachOptions)} +
+
+ `; +} + +function renderSquadEditorPlayerRow(side, role, player) { + const fullName = `${player.last_name || player.player_name || ""} ${player.first_name || ""}`.trim(); + const captainClass = player.is_captain ? "active" : ""; + const captainText = player.is_captain ? "Капитан" : "Сделать капитаном"; + + return ` +
+
+
${escapeHtml(fullName)}
+
+ #${escapeHtml(String(player.number || "—"))} + ${player.position ? ` • ${escapeHtml(player.position)}` : ""} +
+
+ +
+ + + ${role === "starting" ? ` + + ` : ` + + `} + + +
+
+ `; +} + +function renderCoachEditorSection(side, coachOptions) { + const state = getSideEditorState(side); + if (!state) return ""; + + const existingRoles = state.coaches.length ? state.coaches : [{ coach_id: "", coach_name: "", role: "Главный тренер" }]; + + return ` +
+ ${existingRoles.map((coach, index) => ` +
+
+
${escapeHtml(coach.role || "Тренер")}
+
${escapeHtml(coach.coach_name || "Не выбран")}
+
+
+ +
+
+ `).join("")} +
+ `; +} + +function addPlayerFromPool(side, targetRole) { + const state = getSideEditorState(side); + if (!state) return; + + const select = document.getElementById(`addPlayerSelect-${side}`); + const playerId = String(select?.value || "").trim(); + if (!playerId) return; + + const player = state.squadPool.find(p => String(p.player_id) === playerId); + if (!player) return; + + state[targetRole].push({ ...player }); + + renderSquadEditorSide(side); +} + +function removePlayerFromEditor(side, playerId, role) { + const state = getSideEditorState(side); + if (!state) return; + + const hasLinkedEvents = matchEvents.some(event => + String(event.side) === String(side) && + ( + String(event.player_id || "") === String(playerId) || + String(event.player_out_id || "") === String(playerId) || + String(event.player_in_id || "") === String(playerId) + ) + ); + + if (hasLinkedEvents) { + alert("Нельзя удалить игрока: по нему уже есть события матча."); + return; + } + + state[role] = state[role].filter(p => String(p.player_id) !== String(playerId)); + renderSquadEditorSide(side); +} + +function movePlayerBetweenZones(side, playerId, fromRole, toRole) { + const state = getSideEditorState(side); + if (!state) return; + + const idx = state[fromRole].findIndex(p => String(p.player_id) === String(playerId)); + if (idx === -1) return; + + const [player] = state[fromRole].splice(idx, 1); + + if (toRole === "bench") { + player.is_captain = false; + } + + state[toRole].push(player); + + const hasCaptainInStarting = state.starting.some(p => p.is_captain); + if (!hasCaptainInStarting && state.starting.length) { + state.starting[0].is_captain = true; + } + + renderSquadEditorSide(side); +} + + +function setCaptainInEditor(side, playerId, role) { + const state = getSideEditorState(side); + if (!state) return; + + if (role !== "starting") { + alert("Капитан должен быть в основном составе."); + return; + } + + state.starting.forEach(player => { + player.is_captain = String(player.player_id) === String(playerId); + }); + + state.bench.forEach(player => { + player.is_captain = false; + }); + + renderSquadEditorSide(side); +} + +function updateCoachInEditor(side, index, coachId) { + const state = getSideEditorState(side); + if (!state) return; + + const coach = state.coachPool.find(c => String(c.coach_id) === String(coachId)); + if (!coach) return; + + state.coaches[index] = { + coach_id: coach.coach_id, + coach_name: coach.coach_name, + role: coach.role || "Тренер" + }; + + renderSquadEditorSide(side); +} + +function renderLiveCoachBlocks() { + renderLiveCoachBlockForSide("home"); + renderLiveCoachBlockForSide("away"); + updateCoachCardButtons(); +} + +function renderLiveCoachBlockForSide(side) { + const selector = side === "home" ? ".panel .coaches-block" : ".panel .coaches-block-right"; + const block = Array.from(document.querySelectorAll(selector)).find(Boolean); + const source = side === "home" ? (window.MATCH_DATA.homeCoaches || []) : (window.MATCH_DATA.awayCoaches || []); + if (!block) return; + + block.innerHTML = source.map(c => ` +
+
+ ${escapeHtml(c.coach_name || "")} + ${c.role ? `${escapeHtml(c.role)}` : ""} +
+
+ + + +
+
+ `).join(""); +} + +function rerenderLineupTable(side, role) { + const rows = Array.from(document.querySelectorAll(`tr.lineup-row[data-side="${side}"][data-role="${role}"]`)); + const tbody = rows[0]?.closest("tbody"); + if (!tbody) return; + + rows.forEach(row => row.remove()); + + const source = side === "home" + ? (role === "starting" ? window.MATCH_DATA.homeStarting : window.MATCH_DATA.homeBench) + : (role === "starting" ? window.MATCH_DATA.awayStarting : window.MATCH_DATA.awayBench); + + source.forEach(playerRaw => { + const player = normalizePlayer(playerRaw); + const row = document.createElement("tr"); + row.className = "lineup-row"; + row.dataset.side = side; + row.dataset.role = role; + setRowPlayer(row, { + id: player.player_id, + name: player.player_name, + lastName: player.last_name, + firstName: player.first_name, + number: player.number, + position: player.position, + isCaptain: !!player.is_captain, + disciplineType: "" + }); + renderLineupRow(row); + tbody.appendChild(row); + }); + + sortLineupRows(side, role); +} + +function rebuildFormationFromMatchData(side) { + const starting = side === "home" + ? (window.MATCH_DATA.homeStarting || []) + : (window.MATCH_DATA.awayStarting || []); + + const prev = Array.isArray(formationState[side]) ? formationState[side] : []; + + formationState[side] = starting.slice(0, 11).map((p, idx) => { + const playerId = String(p.player_id ?? p.id ?? p.number ?? ""); + const old = prev.find(x => String(x.player_id ?? x.id ?? x.number ?? "") === playerId); + + return { + player_id: p.player_id || p.id || p.number || null, + player_name: p.player_name || `${p.last_name || ""} ${p.first_name || ""}`.trim(), + last_name: p.last_name || p.player_name || "", + first_name: p.first_name || "", + number: p.number || "", + position: p.position || "", + is_captain: !!p.is_captain, + photo: p.photo || "", + x: old?.x ?? 50, + y: old?.y ?? (12 + idx * 6) + }; + }); + + renderFormation(side); +} + + +async function applySquadEditorChanges() { + if (!squadEditorState.home || !squadEditorState.away) return; + + const payload = buildSquadSavePayload(); + const result = await saveSquadEditorChangesToServer(payload); + + if (!result.success) { + alert(`Не удалось сохранить состав: ${result.error}`); + return; + } + + console.log("SAVE PAYLOAD HOME STARTING", payload.home_starting); + window.MATCH_DATA.homeStarting = payload.home_starting.map(p => ({ ...p })); + window.MATCH_DATA.homeBench = payload.home_bench.map(p => ({ ...p })); + window.MATCH_DATA.homeCoaches = payload.home_coaches.map(c => ({ ...c })); + + window.MATCH_DATA.awayStarting = payload.away_starting.map(p => ({ ...p })); + window.MATCH_DATA.awayBench = payload.away_bench.map(p => ({ ...p })); + window.MATCH_DATA.awayCoaches = payload.away_coaches.map(c => ({ ...c })); + fallbackLineups.home = window.MATCH_DATA.homeStarting; + fallbackLineups.away = window.MATCH_DATA.awayStarting; + rebuildFormationFromMatchData("home"); + rebuildFormationFromMatchData("away"); + console.log("MATCH_DATA HOME STARTING AFTER SAVE", window.MATCH_DATA.homeStarting); + + rerenderLineupTable("home", "starting"); + rerenderLineupTable("home", "bench"); + rerenderLineupTable("away", "starting"); + rerenderLineupTable("away", "bench"); + + renderLiveCoachBlocks(); + + originalLineupState = captureCurrentLineupState(); + rebuildLineupsFromEvents(); + saveMatchState(); + closeSquadEditor(); + + alert("Состав сохранён"); +} + +async function loadSquadEditorData() { + if (!SESSION_TOKEN) return false; + + try { + const res = await fetch(`/admin/session/${SESSION_TOKEN}/squad-editor-data`, { + method: "GET", + headers: { "Accept": "application/json" } + }); + + if (!res.ok) { + console.warn("Не удалось загрузить данные редактора состава", res.status); + return false; + } + + const data = await res.json(); + + window.MATCH_DATA.homeStarting = Array.isArray(data.home_starting) ? data.home_starting : []; + window.MATCH_DATA.awayStarting = Array.isArray(data.away_starting) ? data.away_starting : []; + window.MATCH_DATA.homeBench = Array.isArray(data.home_bench) ? data.home_bench : []; + window.MATCH_DATA.awayBench = Array.isArray(data.away_bench) ? data.away_bench : []; + + window.MATCH_DATA.homeCoaches = Array.isArray(data.home_coaches) ? data.home_coaches : []; + window.MATCH_DATA.awayCoaches = Array.isArray(data.away_coaches) ? data.away_coaches : []; + + window.MATCH_DATA.homeSquadPool = Array.isArray(data.home_squad_pool) ? data.home_squad_pool : []; + window.MATCH_DATA.awaySquadPool = Array.isArray(data.away_squad_pool) ? data.away_squad_pool : []; + + window.MATCH_DATA.homeCoachPool = Array.isArray(data.home_coach_pool) ? data.home_coach_pool : []; + window.MATCH_DATA.awayCoachPool = Array.isArray(data.away_coach_pool) ? data.away_coach_pool : []; + + return true; + } catch (e) { + console.warn("Ошибка загрузки squad editor data", e); + return false; + } +} + +function getFormationPresetStorageKey(side) { + return `match_formation_preset_${MATCH_ID}_${side}`; +} + +function saveSelectedPreset(side) { + const select = document.getElementById(`${side}-preset`); + if (!select) return; + localStorage.setItem(getFormationPresetStorageKey(side), select.value); +} + +function restoreSelectedPreset(side) { + const select = document.getElementById(`${side}-preset`); + if (!select) return; + + const saved = localStorage.getItem(getFormationPresetStorageKey(side)); + if (!saved) return; + + const exists = Array.from(select.options).some(opt => opt.value === saved); + if (exists) { + select.value = saved; + } +} + +function buildSquadSavePayload() { + return { + home_starting: (squadEditorState.home?.starting || []).map(p => ({ ...p })), + home_bench: (squadEditorState.home?.bench || []).map(p => ({ ...p })), + away_starting: (squadEditorState.away?.starting || []).map(p => ({ ...p })), + away_bench: (squadEditorState.away?.bench || []).map(p => ({ ...p })), + + home_coaches: (squadEditorState.home?.coaches || []).map(c => ({ ...c })), + away_coaches: (squadEditorState.away?.coaches || []).map(c => ({ ...c })) + }; +} + +async function saveSquadEditorChangesToServer(payload) { + if (!SESSION_TOKEN) return { success: false, error: "Нет SESSION_TOKEN" }; + + try { + const res = await fetch(`/admin/session/${SESSION_TOKEN}/squad-editor-save`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + + const data = await res.json().catch(() => ({})); + + if (!res.ok || !data.success) { + return { + success: false, + error: data.error || `HTTP ${res.status}` + }; + } + + return { success: true, data }; + } catch (e) { + return { + success: false, + error: e?.message || "Ошибка сети" + }; + } +} + + +function normalizeReferee(item = {}) { + return { + referee_id: item.referee_id ?? item.id ?? "", + referee_name: (item.referee_name || item.full_name || item.name || "").trim(), + role: (item.role || "").trim() + }; +} + +let filteredRefereePool = []; + +function renderRefereesEditor() { + const wrap = document.getElementById("refereesEditorWrap"); + if (!wrap) return; + + const items = Array.isArray(refereeEditorState.items) + ? refereeEditorState.items.map(normalizeReferee) + : []; + + const searchHtml = editModeEnabled ? ` + + ` : ""; + + wrap.innerHTML = ` + + + + + + ${editModeEnabled ? "" : ""} + + + + ${items.map((r, i) => ` + + + + ${editModeEnabled ? `` : ""} + + `).join("")} + +
РольФИО
${r.role}${r.referee_name}
+ ${searchHtml} + `; +} + +function filterRefereeList(q) { + const used = new Set(refereeEditorState.items.map(r => String(r.referee_id))); + filteredRefereePool = refereeEditorState.pool.filter(r => + r.referee_name.toLowerCase().includes(q.toLowerCase()) && + !used.has(String(r.referee_id)) + ); + renderRefereeSearchList(); +} + +function renderRefereeSearchList() { + const list = document.getElementById("refereeSearchList"); + if (!list) return; + + list.innerHTML = filteredRefereePool.map(r => + `
${r.referee_name}
` + ).join(""); +} + +function selectRefereeFromSearch(id) { + selectedRefereeForAdd = refereeEditorState.pool.find(r => String(r.referee_id) === String(id)); + document.getElementById("refereeSearchInput").value = selectedRefereeForAdd.referee_name; + filteredRefereePool = []; + renderRefereeSearchList(); +} + +function addRefereeFromSelect() { + const role = document.getElementById("newRefereeRole").value; + if (!selectedRefereeForAdd || !role) return alert("Выбери судью и роль"); + + refereeEditorState.items.push({ + referee_id: selectedRefereeForAdd.referee_id, + referee_name: selectedRefereeForAdd.referee_name, + role + }); + + selectedRefereeForAdd = null; + renderRefereesEditor(); +} + +function removeRefereeRow(i) { + refereeEditorState.items.splice(i, 1); + renderRefereesEditor(); +} + +async function saveReferees() { + const payload = refereeEditorState.items + .map(normalizeReferee) + .filter(item => (item.referee_id || item.referee_name) && item.role); + + console.log("SAVE referees payload:", payload); + + if (!payload.length) { + alert("Нельзя сохранить пустой список судей"); + return; + } + + try { + const response = await fetch(`${window.location.pathname}/referees/save`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ referees: payload }) + }); + + const data = await response.json().catch(() => ({})); + + if (!response.ok || !data.success) { + alert(data.error || `Ошибка сохранения судей (HTTP ${response.status})`); + return; + } + + window.MATCH_DATA.referees = payload; + refereeEditorState.items = [...payload]; + filteredRefereePool = []; + selectedRefereeForAdd = null; + + renderRefereesEditor(); + alert("Судьи сохранены"); + window.location.reload(); + } catch (e) { + console.error("saveReferees failed", e); + alert("Ошибка сети при сохранении судей"); + } +} \ No newline at end of file diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..ace8e2b --- /dev/null +++ b/static/styles.css @@ -0,0 +1,2177 @@ +:root { + --bg: #0f1115; + --panel: #171a21; + --panel-2: #1d222b; + --border: #2b3240; + --text: #e8ecf3; + --muted: #9aa4b2; + --accent: #4f8cff; + --accent-hover: #3e78e6; + --success: #2ea043; + --danger: #dc3545; + --warning: #f2c94c; + --shadow: 0 10px 30px rgba(0, 0, 0, 0.35); + --radius: 16px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: Arial, sans-serif; +} + +.page { + min-height: 100vh; +} + +.topbar { + position: sticky; + top: 0; + z-index: 1000; + padding: 10px 0; + background: rgba(15, 17, 21, 0.96); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--border); +} + +.topbar-inner { + max-width: 1600px; + margin: 0 auto; + padding: 0 24px; +} + +.match-header { + display: grid; + grid-template-columns: minmax(220px, 1fr) auto minmax(220px, 1fr) auto; + align-items: center; + gap: 18px; + min-height: 72px; +} + +.team-box { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.team-box.right { + justify-content: flex-end; + text-align: right; +} + +.team-logo { + width: 42px; + height: 42px; +} + +.team-name { + font-size: 30px; + font-weight: 700; +} + +.match-meta { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + text-align: center; + min-width: 180px; +} + +.match-tour { + font-size: 21px; + font-weight: 700; +} + +.match-time, +.match-place { + font-size: 15px; + color: var(--muted); + line-height: 1.2; +} + +.header-actions.compact { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + flex-wrap: nowrap; +} + +.header-actions.compact form { + margin: 0; +} + +.header-icon { + width: 32px; + height: 32px; + padding: 0; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + color: var(--text); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; + line-height: 1; + cursor: pointer; +} + +.header-icon:hover { + transform: translateY(-1px); + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.18); +} + +.header-icon.danger { + background: rgba(220, 53, 69, 0.18); + border-color: rgba(220, 53, 69, 0.35); +} + +.header-icon.danger:hover { + background: rgba(220, 53, 69, 0.28); +} + +#editModeToggle.active { + background: rgba(242, 201, 76, 0.18); + border-color: rgba(242, 201, 76, 0.45); +} + +.btn { + min-height: 36px; + padding: 0 14px; + border-radius: 10px; + border: 1px solid transparent; + background: var(--panel-2); + color: var(--text); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + font-size: 13px; + font-weight: 700; + line-height: 1; + text-decoration: none; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.12s ease, color 0.15s ease; + white-space: nowrap; +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn-primary { + background: var(--accent); + color: white; +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.btn-secondary { + background: transparent; + color: var(--text); + border: 1px solid var(--border); +} + +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.04); +} + +.content { + max-width: 1600px; + margin: 0 auto; + padding: 22px 24px 32px; +} + +.tabs { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 22px; +} + +.tab-link { + display: inline-flex; + align-items: center; + min-height: 40px; + padding: 0 14px; + border-radius: 999px; + background: var(--panel); + border: 1px solid var(--border); + color: var(--text); + text-decoration: none; + font-size: 14px; + font-weight: 600; +} + +.tab-link:hover { + background: var(--panel-2); + text-decoration: none; +} + +.tab-link.active { + background: var(--accent); + border-color: var(--accent); + color: white; +} + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 18px; +} + +.game-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 440px; + gap: 20px; + align-items: start; +} + +.game-main { + display: flex; + flex-direction: column; + gap: 20px; +} + +.lineups-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +.match-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.match-toolbar-left, +.match-toolbar-right { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.timer-box { + min-width: 122px; + height: 46px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--panel-2); + border: 1px solid var(--border); + font-size: 24px; + font-weight: 800; + letter-spacing: 1px; +} + +.period-badge { + display: inline-flex; + align-items: center; + min-height: 36px; + padding: 0 12px; + border-radius: 999px; + background: rgba(79, 140, 255, 0.14); + color: var(--accent); + font-size: 13px; + font-weight: 700; +} + +.team-panel-title { + font-size: 18px; + font-weight: 700; + margin-bottom: 14px; +} + +.section-title { + margin: 18px 0 10px; + font-size: 15px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.section-title:first-child { + margin-top: 0; +} + +table { + width: 100%; + border-collapse: collapse; + overflow: hidden; + border-radius: 12px; + background: var(--panel-2); + table-layout: fixed; +} + +th, +td { + padding: 10px 12px; + border-bottom: 1px solid var(--border); + text-align: left; + font-size: 14px; + vertical-align: middle; +} + +th { + color: var(--muted); + font-weight: 600; + background: rgba(255, 255, 255, 0.02); +} + +tr:last-child td { + border-bottom: none; +} + +tr:hover td { + background: rgba(255, 255, 255, 0.03); +} + +.num-col { + width: 60px; + text-align: center; +} + +.num-cell { + text-align: center; + font-weight: 600; +} + +.col-last { + width: 36%; +} + +.col-first { + width: 22%; +} + +.actions-col { + width: 230px; + text-align: right; +} + +.player { + position: absolute; + transform: translate(-50%, -50%); +} + +.players-layer { + transform: translateX(1000px); + /* подгони */ +} + +.player-name, +.player-first { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.player-name { + font-weight: 600; +} + +.player-first { + color: var(--muted); +} + +.gk-badge, +.captain-badge { + display: inline-block; + margin-left: 6px; + padding: 2px 6px; + font-size: 10px; + border-radius: 6px; + font-weight: 700; + letter-spacing: 0.3px; +} + +.gk-badge { + background: rgba(79, 140, 255, 0.2); + color: #8ab4ff; +} + +.captain-badge { + background: rgba(255, 215, 0, 0.18); + color: #ffd54a; +} + +.row-actions { + display: flex; + flex-wrap: nowrap !important; + gap: 4px; + align-items: center; +} + +.actions-col { + white-space: nowrap; + width: 1%; +} + +.icon-btn { + width: 28px; + height: 28px; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + color: var(--text); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + font-size: 13px; + font-weight: 700; + transition: transform 0.12s ease, background 0.12s ease, border-color 0.12s ease; +} + +.icon-btn:hover { + transform: translateY(-1px); + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.18); +} + +.icon-btn.goal { + font-size: 15px; +} + +.icon-btn.yellow-card, +.icon-btn.red-card { + position: relative; + overflow: hidden; +} + +.icon-btn.yellow-card::before, +.icon-btn.red-card::before { + content: ""; + width: 12px; + height: 16px; + border-radius: 2px; + display: block; +} + +.icon-btn.yellow-card::before { + background: var(--warning); +} + +.icon-btn.red-card::before { + background: var(--danger); +} + +.icon-btn.sub { + position: relative; + font-size: 16px; +} + +.icon-btn.sub::before { + content: "↗"; + position: absolute; + left: 4px; + top: 2px; + color: #22c55e; + font-size: 10px; + font-weight: 900; +} + +.icon-btn.sub::after { + content: "↘"; + position: absolute; + right: 4px; + bottom: 2px; + color: #ef4444; + font-size: 10px; + font-weight: 900; +} + +.empty-box { + padding: 18px; + border-radius: 12px; + background: var(--panel-2); + color: var(--muted); + border: 1px dashed var(--border); +} + +.small-meta { + font-size: 13px; + color: var(--muted); + margin-top: 4px; +} + +.coaches-block { + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.coaches-block-right { + text-align: right; +} + +.coach-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.coach-line:last-child { + margin-bottom: 0; +} + +.coach-left, +.coach-right { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.coach-name { + font-weight: 600; + color: var(--text); + font-size: 13px; +} + +.coach-badge { + display: inline-block; + padding: 3px 8px; + font-size: 11px; + border-radius: 8px; + font-weight: 600; + background: rgba(255, 255, 255, 0.08); + color: var(--text); +} + +.coach-actions { + display: inline-flex; + gap: 6px; +} + +.events-panel { + position: sticky; + top: 110px; + padding: 16px; +} + +.panel-title-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 12px; +} + +.panel-title { + font-size: 18px; + font-weight: 700; + margin: 0; +} + +.events-list { + display: flex; + flex-direction: column; + gap: 10px; + max-height: calc(100vh - 220px); + overflow-y: auto; + padding-right: 4px; +} + +.event-row { + display: grid; + grid-template-columns: 1fr 56px 1fr; + gap: 10px; + align-items: stretch; +} + +.event-side { + min-height: 64px; +} + +.event-card { + border: 1px solid var(--border); + border-radius: 12px; + background: var(--panel-2); + padding: 10px 12px; + min-height: 64px; +} + +.event-card.home { + text-align: right; +} + +.event-card.away { + text-align: left; +} + +.event-time { + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: rgba(79, 140, 255, 0.12); + color: var(--accent); + font-size: 14px; + font-weight: 800; + border: 1px solid rgba(79, 140, 255, 0.18); +} + +.event-title { + font-size: 14px; + font-weight: 700; + margin-bottom: 4px; +} + +.event-sub { + font-size: 12px; + color: var(--muted); + line-height: 1.4; +} + +.event-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 26px; + height: 20px; + padding: 0 6px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: var(--text); + font-size: 11px; + font-weight: 700; + margin-bottom: 6px; +} + +@media (max-width: 1320px) { + .game-layout { + grid-template-columns: 1fr; + } + + .events-panel { + position: static; + } + + .events-list { + max-height: none; + } +} + +@media (max-width: 980px) { + .match-header { + grid-template-columns: 1fr; + } + + .team-box.right { + justify-content: flex-start; + text-align: left; + } + + .lineups-grid { + grid-template-columns: 1fr; + } +} + + +.lineups-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +.team-panel-title { + font-size: 18px; + font-weight: 700; + margin-bottom: 14px; +} + +.section-title { + margin: 18px 0 10px; + font-size: 15px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.4px; +} + +table { + width: 100%; + border-collapse: collapse; + overflow: hidden; + border-radius: 12px; + background: var(--panel-2); +} + +th, +td { + padding: 10px 12px; + border-bottom: 1px solid var(--border); + text-align: left; + font-size: 14px; +} + +th { + color: var(--muted); + font-weight: 600; + background: rgba(255, 255, 255, 0.02); +} + +tr:last-child td { + border-bottom: none; +} + +tr:hover td { + background: rgba(255, 255, 255, 0.03); +} + +.num-col { + width: 70px; +} + +.empty-box { + padding: 18px; + border-radius: 12px; + background: var(--panel-2); + color: var(--muted); + border: 1px dashed var(--border); +} + +.small-meta { + font-size: 13px; + color: var(--muted); + margin-top: 4px; +} + +.num-col { + width: 60px; + text-align: center; +} + +.num-cell { + text-align: center; + font-weight: 600; +} + +.player-name { + font-weight: 600; +} + +.player-first { + color: var(--muted); +} + +.gk-badge { + display: inline-block; + margin-left: 6px; + padding: 2px 6px; + font-size: 10px; + border-radius: 6px; + background: rgba(79, 140, 255, 0.2); + color: #8ab4ff; + font-weight: 700; + letter-spacing: 0.3px; +} + +.table-compact th, +.table-compact td { + padding: 8px 10px; +} + +.status-badge { + display: inline-block; + padding: 4px 8px; + font-size: 11px; + font-weight: 700; + border-radius: 8px; + letter-spacing: 0.3px; + white-space: nowrap; +} + +.status-scheduled { + background: rgba(154, 164, 178, 0.18); + color: #c2cad5; +} + +.status-live { + background: rgba(79, 140, 255, 0.18); + color: #8ab4ff; +} + +.status-finished { + background: rgba(46, 160, 67, 0.18); + color: #57d17a; +} + +.col-last { + width: 45%; +} + +.col-first { + width: 45%; +} + +.num-col { + width: 60px; +} + +table { + table-layout: fixed; +} + +.player-name, +.player-first { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tour-table { + table-layout: auto; +} + +.tour-date-col { + width: 1%; + white-space: nowrap; +} + +.tour-match-col { + width: 52%; +} + +.tour-meta-col { + width: 1%; + white-space: nowrap; +} + +.tour-stadium-col { + width: auto; +} + +.tour-date-cell { + white-space: nowrap; + color: var(--text); +} + +.tour-match-cell { + font-weight: 600; +} + +.tour-meta-cell { + white-space: nowrap; +} + +.tour-meta-wrap { + display: inline-flex; + align-items: center; + gap: 8px; + white-space: nowrap; +} + +.score-badge { + display: inline-block; + min-width: 48px; + text-align: center; + padding: 4px 8px; + font-size: 12px; + font-weight: 700; + border-radius: 8px; + background: rgba(255, 255, 255, 0.06); + color: var(--text); +} + +.stadium-cell { + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; +} + +.standings-table .st-pos-col { + width: 50px; + text-align: center; +} + +.standings-table .st-logo-col { + width: 54px; + text-align: center; +} + +.standings-table .st-team-col { + width: auto; +} + +.standings-table .st-stat-col, +.standings-table .st-gfga-col, +.standings-table .st-points-col { + width: 64px; + text-align: center; + white-space: nowrap; +} + +.standings-logo { + width: 28px; + height: 28px; + object-fit: contain; + display: inline-block; + vertical-align: middle; +} + +.standings-row-home td { + background: #FF00FF !important; + color: #ffffff; +} + +.standings-row-away td { + background: #6E00A5 !important; + color: #ffffff; +} + +.standings-row-home:hover td, +.standings-row-away:hover td { + background: inherit !important; + color: #ffffff; +} + +.standings-row-home .player-name, +.standings-row-away .player-name { + color: #ffffff; +} + +.standings-row-home img, +.standings-row-away img { + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.35)); +} + +.captain-badge { + display: inline-block; + margin-left: 6px; + padding: 2px 6px; + font-size: 10px; + border-radius: 6px; + background: rgba(255, 215, 0, 0.18); + color: #ffd54a; + font-weight: 700; + letter-spacing: 0.3px; +} + +.coaches-block { + margin-bottom: 10px; +} + +.coach-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; + font-size: 13px; + line-height: 1.3; + color: var(--muted); +} + +.coach-name { + font-weight: 600; + color: var(--text); +} + +.coach-left { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + flex: 1 1 auto; + min-width: 0; +} + +.coach-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + position: relative; + z-index: 5; +} + +.coach-actions .icon-btn { + position: relative; + z-index: 6; + pointer-events: auto; +} + +.coach-role { + color: var(--muted); +} + +.coaches-block-right { + text-align: right; +} + +.coaches-block { + margin-bottom: 10px; + padding-bottom: 6px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.coach-badge { + display: inline-block; + margin-left: 6px; + padding: 3px 8px; + font-size: 11px; + border-radius: 8px; + font-weight: 600; + letter-spacing: 0.2px; + white-space: nowrap; + + background: rgba(255, 255, 255, 0.08); + color: var(--text); +} + +.formation-toolbar { + display: flex; + gap: 10px; + align-items: center; + margin-bottom: 14px; + flex-wrap: wrap; +} + +.formation-select { + min-height: 40px; + padding: 0 12px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--text); +} + +.pitch { + position: relative; + width: 800px; + max-width: 100%; + margin: 0 auto; +} + +.pitch-img { + width: 100%; + display: block; +} + +.players-layer { + position: absolute; + inset: 0; + /* важно */ +} + +.wrapper { + display: flex; + justify-content: center; + align-items: center; +} + +.pitch-half { + position: relative; + width: 100%; + height: 980px; + border-radius: 10px; + overflow: hidden; + border: 2px solid rgba(255, 255, 255, 0.18); + background: #4b0b68; +} + +/* Контур поля */ +.pitch-half::before { + content: ""; + position: absolute; + inset: 14px; + border: 3px solid rgba(255, 255, 255, 0.6); + border-bottom: none; + pointer-events: none; +} + +.pitch-half::after { + content: ""; + position: absolute; + top: 14px; + left: 50%; + transform: translateX(-50%); + width: 180px; + height: 72px; + border: 3px solid rgba(255, 255, 255, 0.6); + border-top: none; + pointer-events: none; +} + +/* Центральный круг нижней части */ +.pitch-center-circle { + position: absolute; + left: 50%; + bottom: 64px; + transform: translateX(-50%); + width: 180px; + height: 180px; + border: 3px solid rgba(255, 255, 255, 0.22); + border-radius: 50%; + pointer-events: none; +} + +.pitch-center-line { + position: absolute; + left: 14px; + right: 14px; + bottom: 154px; + height: 0; + border-top: 3px solid rgba(255, 255, 255, 0.35); + pointer-events: none; +} + +/* Игрок */ +.player-node { + position: absolute; + width: 150px; + transform: translate(-50%, -50%); + z-index: 5; + user-select: none; + cursor: default; + margin-left: 0; +} + +.player-node.dragging { + cursor: grabbing; + z-index: 20; +} + +.player-photo { + width: 110px; + height: 138px; + margin: 0 auto; + display: block; + object-fit: contain; + filter: drop-shadow(0 8px 18px rgba(0, 0, 0, 0.35)); + pointer-events: none; +} + +.player-photo, +.player-silhouette { + width: 95px; + height: 120px; +} + +.player-silhouette { + position: relative; + width: 110px; + height: 138px; + margin: 0 auto; + border-radius: 18px 18px 8px 8px; + background: linear-gradient(180deg, #f2f2f2 0%, #d9d9d9 100%); + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.35); + pointer-events: none; +} + +.player-silhouette::before { + content: ""; + position: absolute; + top: 18px; + left: 50%; + transform: translateX(-50%); + width: 34px; + height: 34px; + border-radius: 50%; + background: #b8b8b8; +} + +.player-silhouette::after { + content: ""; + position: absolute; + left: 50%; + bottom: 18px; + transform: translateX(-50%); + width: 66px; + height: 62px; + border-radius: 36px 36px 12px 12px; + background: #b8b8b8; +} + +.player-label { + margin: -6px auto 0; + width: 150px; + min-height: 28px; + padding: 4px 8px; + background: #97ef1f; + color: #2a2440; + font-size: 11px; + font-weight: 700; + line-height: 1.1; + text-transform: uppercase; + text-align: center; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25); +} + +.player-label-text { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + + +.lineup-row-sub-in td { + background: rgba(46, 160, 67, 0.14) !important; +} + +.lineup-row-sub-out td { + background: rgba(220, 53, 69, 0.14) !important; +} + +.event-row-center { + grid-template-columns: 1fr 56px 1fr; +} + +.event-time-center-card { + background: transparent; + border: none; + display: flex; + align-items: center; + justify-content: center; +} + +.event-card { + position: relative; +} + +.event-card.center { + width: max-content; + min-width: 180px; + text-align: center; + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; + padding: 10px 16px; +} + +.event-delete-btn { + position: absolute; + top: 8px; + width: 18px; + height: 18px; + border: none; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: var(--muted); + cursor: pointer; + font-size: 11px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.event-card.home .event-delete-btn { + left: 8px; + right: auto; +} + +.event-card.away .event-delete-btn, +.event-card.center .event-delete-btn { + right: 8px; + left: auto; +} + +.event-delete-btn:hover { + background: rgba(220, 53, 69, 0.18); + color: #ffd7dc; +} + +.bench-pick-hint { + display: none; + margin: 6px 0 10px; + font-size: 12px; + color: var(--accent); +} + +.bench-pick-active { + background: rgba(79, 140, 255, 0.12) !important; + border-radius: 6px; +} + +.score-box { + min-width: 110px; + height: 46px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--panel-2); + border: 1px solid var(--border); + font-size: 22px; + font-weight: 800; + letter-spacing: 1px; + padding: 0 14px; +} + +.extra-time-wrap { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.extra-time-input { + width: 76px; + min-height: 38px; + padding: 0 10px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--text); + font-size: 14px; + font-weight: 600; +} + + +.lineup-row .player-name { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.lineup-row .player-first { + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.lineup-row-sub-in td { + background: rgba(46, 160, 67, 0.14) !important; +} + +.lineup-row-sub-out td { + background: rgba(220, 53, 69, 0.14) !important; +} + + +.player-full { + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + min-width: 140px; +} + +.player-name-line { + display: flex; + gap: 6px; + flex-wrap: nowrap; + overflow: hidden; +} + +.last-name { + font-weight: 700; + white-space: nowrap; +} + +.first-name { + font-weight: 400; + color: var(--muted); + white-space: nowrap; +} + +.player-badges { + display: flex; + gap: 4px; + flex-wrap: nowrap; +} + + +.row-actions button { + flex: 0 0 auto; +} + + +/* === Game tab: one beautiful player column === */ +.game-lineup-table { + width: 100%; + table-layout: fixed; +} + +.game-lineup-table .num-col { + width: 56px; + text-align: center; +} + +.game-lineup-table .player-col { + width: auto; +} + +.game-lineup-table .actions-col { + width: 150px; + text-align: right; + white-space: nowrap; +} + +.game-lineup-table th, +.game-lineup-table td { + padding: 8px 10px; + vertical-align: middle; +} + +.game-lineup-table td.player-cell { + min-width: 0; +} + +.player-inline { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex-wrap: nowrap; +} + +.player-inline-text { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 13px; + line-height: 1.15; +} + +.player-inline-text .last-name { + font-weight: 700; + color: var(--text); +} + +.player-inline-text .first-name { + font-weight: 400; + color: var(--text); + opacity: 0.92; +} + +.player-inline .badges { + display: inline-flex; + align-items: center; + gap: 4px; + flex: 0 0 auto; + white-space: nowrap; +} + +.player-inline .gk-badge, +.player-inline .captain-badge { + margin-left: 0; + vertical-align: middle; +} + +.game-lineup-table .row-actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 4px; + flex-wrap: nowrap; +} + +.game-lineup-table .icon-btn { + width: 22px; + height: 22px; + font-size: 11px; + padding: 0; + background: transparent; + border: none; + box-shadow: none; +} + +.game-lineup-table .icon-btn:hover { + background: rgba(255, 255, 255, 0.06); + border: none; +} + +.discipline-badge-yellow2, +.discipline-badge-red { + display: inline-block; + padding: 2px 6px; + font-size: 10px; + border-radius: 6px; + font-weight: 700; + letter-spacing: 0.3px; + white-space: nowrap; +} + +.discipline-badge-yellow2 { + background: rgba(242, 201, 76, 0.18); + color: #f2c94c; +} + +.discipline-badge-red { + background: rgba(220, 53, 69, 0.18); + color: #ff7b88; +} + +.lineup-row-expelled-hidden { + display: none !important; +} + + +tr.lineup-row.lineup-row-expelled-hidden { + display: none !important; +} + +tr.lineup-row[data-role="expelled"] .row-actions { + display: none !important; +} + +.edit-mode-toggle-note { + font-size: 12px; + color: var(--muted); +} + +body:not(.edit-mode-on) [data-edit-lock] { + opacity: 0.55; + cursor: not-allowed; + pointer-events: none; +} + +body:not(.edit-mode-on) .player-node { + cursor: default !important; +} + +body:not(.edit-mode-on) .pitch-half::before, +body:not(.edit-mode-on) .pitch-half::after, +body:not(.edit-mode-on) .pitch-center-line, +body:not(.edit-mode-on) .pitch-center-circle { + pointer-events: none; +} + +body.edit-mode-on .player-node { + cursor: grab !important; +} + +body.edit-mode-on .player-node.dragging { + cursor: grabbing !important; +} + +#editModeToggle.btn-primary { + box-shadow: 0 0 0 1px rgba(79, 140, 255, 0.35), 0 10px 24px rgba(79, 140, 255, 0.18); +} + +.extra-time-wrap { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.extra-time-control { + display: inline-flex; + align-items: stretch; + height: 40px; + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; + background: var(--panel-2); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02); +} + +.extra-time-input { + width: 72px; + height: 40px; + padding: 0 10px; + border: none; + outline: none; + background: transparent; + color: var(--text); + text-align: center; + font-size: 14px; + font-weight: 700; + appearance: textfield; + -moz-appearance: textfield; +} + +.extra-time-input::placeholder { + color: var(--muted); + font-weight: 600; +} + +.extra-time-input::-webkit-outer-spin-button, +.extra-time-input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.extra-time-arrows { + display: flex; + flex-direction: column; + width: 30px; + flex: 0 0 30px; + border-left: 1px solid var(--border); + background: rgba(255, 255, 255, 0.02); +} + +.extra-time-arrows button { + all: unset; + box-sizing: border-box; + width: 30px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--muted); + font-size: 9px; + line-height: 1; + user-select: none; + transition: background 0.15s ease, color 0.15s ease, transform 0.08s ease; +} + +.extra-time-arrows button:first-child { + border-bottom: 1px solid var(--border); +} + +.extra-time-arrows button:hover { + background: rgba(255, 255, 255, 0.06); + color: var(--text); +} + +.extra-time-arrows button:active { + background: rgba(79, 140, 255, 0.18); + color: var(--accent); + transform: scale(0.96); +} + +.formation-modal { + position: fixed; + inset: 0; + display: none; + z-index: 3000; +} + +.formation-modal.show { + display: block; +} + +.formation-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.55); +} + +.formation-modal-content { + position: relative; + z-index: 2; + width: min(760px, 94vw); + max-height: 88vh; + overflow: auto; + margin: 4vh auto 0; + background: #fff; + border-radius: 18px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.28); + padding: 20px; +} + +.formation-modal-header, +.formation-modal-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.formation-modal-title { + font-size: 22px; + font-weight: 700; + color: #111; +} + +.formation-modal-subtitle { + margin-top: 6px; + color: #666; + font-size: 14px; + font-weight: 600; +} + +.formation-modal-body { + margin-top: 18px; +} + +.formation-input-table { + width: 100%; + border-collapse: collapse; + background: #fff; + table-layout: auto; +} + +.formation-input-table th, +.formation-input-table td { + border: 1px solid #e1e1e1; + padding: 10px 12px; + text-align: left; + vertical-align: middle; + color: #111; +} + +.formation-input-table th { + background: #f8f8f8; + font-weight: 700; +} + +.formation-number-input { + width: 110px; + padding: 8px 10px; + border: 1px solid #bbb; + border-radius: 8px; + outline: none; + background: #fff; + color: #111; +} + +.formation-number-input:focus { + border-color: #222; +} + +.formation-available-numbers { + font-size: 12px; + color: #666; +} + +.formation-modal-footer { + margin-top: 18px; +} + + + +.squad-editor-modal-content { + width: min(1320px, 96vw); +} + +.squad-editor-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; +} + +.squad-editor-team { + background: #f7f8fb; + border: 1px solid #e6e8ef; + border-radius: 14px; + padding: 14px; +} + +.squad-editor-team-title { + font-size: 18px; + font-weight: 800; + color: #111; + margin-bottom: 12px; +} + +.squad-editor-section { + margin-bottom: 16px; +} + +.squad-editor-section:last-child { + margin-bottom: 0; +} + +.squad-editor-section-title { + font-size: 13px; + font-weight: 800; + color: #555; + text-transform: uppercase; + letter-spacing: 0.4px; + margin-bottom: 8px; +} + +.squad-editor-box { + background: #fff; + border: 1px solid #e1e4ea; + border-radius: 12px; + padding: 10px; +} + +.squad-editor-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.squad-editor-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 8px 10px; + border: 1px solid #eceef3; + border-radius: 10px; + background: #fff; +} + +.squad-editor-player { + min-width: 0; +} + +.squad-editor-player-name { + font-size: 14px; + font-weight: 700; + color: #111; +} + +.squad-editor-player-meta { + font-size: 12px; + color: #666; + margin-top: 2px; +} + +.squad-editor-actions { + display: inline-flex; + gap: 6px; + flex-wrap: wrap; +} + +.squad-editor-btn { + min-height: 30px; + padding: 0 10px; + border: 1px solid #d7dbe5; + border-radius: 8px; + background: #fff; + color: #111; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.squad-editor-btn:hover { + background: #f2f4f8; +} + +.squad-editor-btn.primary { + background: #4f8cff; + border-color: #4f8cff; + color: #fff; +} + +.squad-editor-btn.primary:hover { + background: #3e78e6; +} + +.squad-editor-btn.danger { + background: #fff5f6; + border-color: #f0c7cc; + color: #b42318; +} + +.squad-editor-btn.active { + background: #fff7db; + border-color: #f2c94c; + color: #6b5500; +} + +.squad-editor-select { + width: 100%; + min-height: 38px; + border: 1px solid #d7dbe5; + border-radius: 10px; + background: #fff; + color: #111; + padding: 0 10px; + margin-bottom: 8px; +} + +.squad-editor-hint { + font-size: 12px; + color: #777; + margin-top: 6px; +} + +@media (max-width: 1100px) { + .squad-editor-grid { + grid-template-columns: 1fr; + } +} + +[data-edit-only] { + display: none !important; +} + +body.edit-mode-on [data-edit-only] { + display: inline-flex !important; +} + + +.referee-edit-controls { + display: none; + margin-top: 12px; + gap: 10px; +} + +body.edit-mode-on .referee-edit-controls { + display: flex !important; +} + +body:not(.edit-mode-on) .referee-search { + display: none !important; +} + + +.referee-search { + margin-top: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.referee-search input { + width: 100%; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--text); +} + +.referee-search-list { + max-height: 180px; + overflow-y: auto; + border: 1px solid #2c3440; + border-radius: 8px; + background: #11161c; +} + +.referee-search-item { + padding: 8px 10px; + cursor: pointer; +} + +.referee-search-item:hover { + background: #1f2933; +} + +/* ===== Судьи: селект роли ===== */ +.referee-search select, +#newRefereeRole { + min-height: 40px; + width: 240px; + padding: 0 12px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--text); + font-size: 14px; + font-weight: 600; + outline: none; + box-shadow: none; +} + +.referee-search select:focus, +#newRefereeRole:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(79, 140, 255, 0.18); +} + +/* ===== Судьи: кнопка удаления ===== */ +.referee-delete-btn { + width: 30px; + height: 30px; + border: 1px solid rgba(220, 53, 69, 0.35); + border-radius: 8px; + background: rgba(220, 53, 69, 0.12); + color: #ff9aa5; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 14px; + font-weight: 700; + line-height: 1; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.12s ease, color 0.15s ease; +} + +.referee-delete-btn:hover { + background: rgba(220, 53, 69, 0.22); + border-color: rgba(220, 53, 69, 0.55); + color: #ffd7dc; + transform: translateY(-1px); +} + +.referee-delete-btn:active { + transform: translateY(0); +} + +.event-edit-btn { + position: absolute; + top: 8px; + width: 18px; + height: 18px; + border: none; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: var(--muted); + cursor: pointer; + font-size: 11px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.event-edit-btn:hover { + background: rgba(79, 140, 255, 0.18); + color: #cfe0ff; +} + +.event-card-actions { + position: absolute; + top: 6px; + display: inline-flex; + gap: 4px; + align-items: center; + z-index: 20; + pointer-events: auto; +} + +.event-card.home .event-card-actions { + left: 6px; + right: auto; +} + +.event-card.away .event-card-actions, +.event-card.center .event-card-actions { + right: 6px; + left: auto; +} + +.event-delete-btn, +.event-edit-btn { + position: static !important; + z-index: 21; + pointer-events: auto; +} + +.formation-modal.open { + display: block; +} + +.goal-editor-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.goal-editor-player-btn { + width: 100%; + min-height: 44px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--text); + text-align: left; + font-size: 14px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 10px; +} + +.goal-editor-player-btn:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(79, 140, 255, 0.4); +} + +.goal-editor-player-number { + min-width: 32px; + height: 26px; + padding: 0 8px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.08); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + color: var(--text); +} + +.goal-editor-player-name { + flex: 1 1 auto; + min-width: 0; +} + +#goalEditorModal { + z-index: 4000; +} + +.event-card-clickable { + cursor: pointer; + transition: transform 0.12s ease, background 0.12s ease, border-color 0.12s ease; +} + +.event-card-clickable:hover { + transform: translateY(-1px); + background: rgba(255, 255, 255, 0.06); + border-color: rgba(79, 140, 255, 0.35); +} + +/* ===== Модалка: кнопки ===== */ + +.modal-close-btn { + width: 36px; + height: 36px; + border-radius: 10px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.04); + color: var(--muted); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 16px; + cursor: pointer; + transition: all 0.15s ease; +} + +.modal-close-btn:hover { + background: rgba(220, 53, 69, 0.18); + color: #ffd7dc; + border-color: rgba(220, 53, 69, 0.45); + transform: translateY(-1px); +} + +/* Отмена */ +.btn-cancel { + background: transparent; + border: 1px solid var(--border); + color: var(--muted); +} + +.btn-cancel:hover { + background: rgba(255, 255, 255, 0.06); + color: var(--text); +} + +/* Сохранить / Применить */ +.btn-confirm { + background: var(--accent); + color: #fff; + border: 1px solid var(--accent); + box-shadow: 0 6px 16px rgba(79, 140, 255, 0.25); +} + +.btn-confirm:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); + transform: translateY(-1px); +} \ No newline at end of file diff --git a/templates/admin_db_coach_edit.html b/templates/admin_db_coach_edit.html new file mode 100644 index 0000000..932564c --- /dev/null +++ b/templates/admin_db_coach_edit.html @@ -0,0 +1,154 @@ + + + + + + Редактирование тренера + + + + +
+
+
Редактирование тренера
+
ID: {{ coach.id }}
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + К списку + Разделы +
+
+ +
+
+ + + \ No newline at end of file diff --git a/templates/admin_db_coaches.html b/templates/admin_db_coaches.html new file mode 100644 index 0000000..5cd8c92 --- /dev/null +++ b/templates/admin_db_coaches.html @@ -0,0 +1,178 @@ + + + + + + Тренеры + + + + +
+
+
+
Тренеры
+
+ + + Назад +
+
+ + {% if coaches %} + + + {% for c in coaches %} + + + + + + + + + {% endfor %} + +
{{ c.id }}{{ c.full_name }}{{ c.team_name or "—" }}{{ c.role or "—" }}{{ c.external_id }} + Редактировать +
+ {% else %} +
Тренеры не найдены.
+ {% endif %} +
+
+ + + \ No newline at end of file diff --git a/templates/admin_db_index.html b/templates/admin_db_index.html new file mode 100644 index 0000000..5e6777c --- /dev/null +++ b/templates/admin_db_index.html @@ -0,0 +1,92 @@ + + + + + База + + + +
+
+
Редактирование базы
+ + +
+
+ + \ No newline at end of file diff --git a/templates/admin_db_player_edit.html b/templates/admin_db_player_edit.html new file mode 100644 index 0000000..87e641e --- /dev/null +++ b/templates/admin_db_player_edit.html @@ -0,0 +1,226 @@ + + + + + + Редактирование игрока + + + + +
+
+
Редактирование игрока
+
ID: {{ player.id }}
+ +
+ {% set positions = [ + "", + "Вратарь", + "Защитник", + "Полузащитник", + "Нападающий", + ] %} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + {% if player.photo %} +
+ {{ player.full_name }} +
+ {% endif %} +
+ +
+ + +
+
+ +
+ + К списку + Разделы +
+
+ +
+
+ + + \ No newline at end of file diff --git a/templates/admin_db_players.html b/templates/admin_db_players.html new file mode 100644 index 0000000..cf9e166 --- /dev/null +++ b/templates/admin_db_players.html @@ -0,0 +1,186 @@ + + + + + Игроки + + + +
+
+
+
Игроки
+ +
+ + + Назад +
+
+ + {% if players %} + + + + + + + + + + + + + + {% for p in players %} + + + + + + + + + + {% endfor %} + +
IDФИОИмяФамилияExternal IDАмплуа
{{ p.id }}{{ p.full_name }}{{ p.first_name }}{{ p.last_name }}{{ p.external_id }}{{ p.position }} + Редактировать +
+ {% else %} +
Игроки не найдены.
+ {% endif %} +
+
+ + \ No newline at end of file diff --git a/templates/admin_db_referee_edit.html b/templates/admin_db_referee_edit.html new file mode 100644 index 0000000..b12e350 --- /dev/null +++ b/templates/admin_db_referee_edit.html @@ -0,0 +1,55 @@ + + + + + Редактирование судьи + + + +
+
+
Редактирование судьи
+
ID: {{ referee.id }}
+ +
+
+
+ + +
+ +
+ + +
+
+ +
+ + К списку + Разделы +
+
+
+
+ + \ No newline at end of file diff --git a/templates/admin_db_referees.html b/templates/admin_db_referees.html new file mode 100644 index 0000000..3f6eb75 --- /dev/null +++ b/templates/admin_db_referees.html @@ -0,0 +1,72 @@ + + + + + Судьи + + + +
+
+
+
Судьи
+
+ + + Назад +
+
+ + {% if referees %} + + + + + + + + + + + {% for r in referees %} + + + + + + + {% endfor %} + +
IDФИОExternal ID
{{ r.id }}{{ r.full_name }}{{ r.external_id }} + Редактировать +
+ {% else %} +
Судьи не найдены.
+ {% endif %} +
+
+ + \ No newline at end of file diff --git a/templates/admin_db_stadium_edit.html b/templates/admin_db_stadium_edit.html new file mode 100644 index 0000000..7922ee8 --- /dev/null +++ b/templates/admin_db_stadium_edit.html @@ -0,0 +1,163 @@ + + + + + + Редактирование стадиона + + + + +
+
+
Редактирование стадиона
+
ID: {{ stadium.id }}
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + К списку + Разделы +
+
+
+
+ + + \ No newline at end of file diff --git a/templates/admin_db_stadiums.html b/templates/admin_db_stadiums.html new file mode 100644 index 0000000..5cda762 --- /dev/null +++ b/templates/admin_db_stadiums.html @@ -0,0 +1,78 @@ + + + + + Стадионы + + + +
+
+
+
Стадионы
+
+ + + Назад +
+
+ + {% if stadiums %} + + + + + + + + + + + + + + {% for s in stadiums %} + + + + + + + + + + {% endfor %} + +
IDНазваниеНазвание GFXГородАдресExternal ID
{{ s.id }}{{ s.name }}{{ s.stadium_gfx }}{{ s.city }}{{ s.address }}{{ s.external_id }} + Редактировать +
+ {% else %} +
Стадионы не найдены.
+ {% endif %} +
+
+ + \ No newline at end of file diff --git a/templates/admin_db_team_edit.html b/templates/admin_db_team_edit.html new file mode 100644 index 0000000..0ee9622 --- /dev/null +++ b/templates/admin_db_team_edit.html @@ -0,0 +1,103 @@ + + + + + Редактирование команды + + + +
+
+
Редактирование команды
+
ID: {{ team.id }}
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ {% if team.logo_path %} + {{ team.name }} + {% else %} + Логотип не задан. + {% endif %} +
+
+
+ +
+ + К списку + Разделы +
+
+
+
+ + \ No newline at end of file diff --git a/templates/admin_db_teams.html b/templates/admin_db_teams.html new file mode 100644 index 0000000..56e459c --- /dev/null +++ b/templates/admin_db_teams.html @@ -0,0 +1,93 @@ + + + + + Команды + + + +
+
+
+
Команды
+
+ + + Назад +
+
+ + {% if teams %} + + + + + + + + + + + + + + + {% for t in teams %} + + + + + + + + + + + {% endfor %} + +
IDНазваниеПолное названиеГород3 буквыЛоготипExternal ID
{{ t.id }}{{ t.name }}{{ t.full_name or "—" }}{{ t.city or "—" }}{{ t.short_name_3 or "—" }} + {% if t.logo_path %} + {{ t.name }} + {% else %} + — + {% endif %} + {{ t.external_id }} + Редактировать +
+ {% else %} +
Команды не найдены.
+ {% endif %} +
+
+ + \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..4582cfb --- /dev/null +++ b/templates/login.html @@ -0,0 +1,110 @@ + + + + + WFL Admin Login + + + +
+

WFL Admin

+
Вход в административный интерфейс
+ + {% if message %} +
{{ message }}
+ {% endif %} + + {% if error %} +
{{ error }}
+ {% endif %} + +
+ + + + + + + +
+ +
Сессия автоматически завершится после 2 часов без активности.
+
+ + diff --git a/templates/match_workspace copy.html b/templates/match_workspace copy.html new file mode 100644 index 0000000..c477688 --- /dev/null +++ b/templates/match_workspace copy.html @@ -0,0 +1,2908 @@ + + + + + + Рабочее окно матча + + + + +
+
+
+
+
+ {% if session[15] %} + + {% endif %} +
+
{{ session[14] }}
+
+
+ +
+
{{ session[10] or "Тур не указан" }}
+
{{ session[9] or "Время не указано" }}
+
{{ session[12] or "Стадион не указан" }}
+
+ +
+
+
{{ session[17] }}
+
+ {% if session[18] %} + + {% endif %} +
+ +
+
+ +
+ +
+ +
+
+
+
+
+ +
+ + + {% if tab == 'game' %} +
+
+
+
+
+ + + + +
+ +
+ Матч не начат +
00:00
+
0 : 0
+
+ + +
+
+
+
+ +
+
+ {% if coaches.home %} +
+ {% for c in coaches.home %} +
+
+ {{ c.coach_name }} + {% if c.role %} + {{ c.role }} + {% endif %} +
+
+ + +
+
+ {% endfor %} +
+ {% endif %} + +
Основной состав
+ {% if lineups.home_starting %} + + + + + + + + + + + {% for p in lineups.home_starting %} + + + + + + {% endfor %} + +
#ИгрокДействия
{{ p.number }} +
+
+ {{ p.last_name or p.player_name }} + {% if p.first_name %} {{ p.first_name }}{% endif %} +
+ + {% if p.position and p.position|lower in ['вр.', 'вр', 'вратарь', 'gk', 'goalkeeper'] %} + ВР + {% endif %} + {% if p.is_captain %} + К + {% endif %} + +
+
+
+ + + + + + +
+
+ + {% else %} +
Основной состав пока не загружен.
+ {% endif %} + +
Запасные
+
Выбери игрока из запасных для завершения замены
+ {% if lineups.home_bench %} + + + + + + + + + + + {% for p in lineups.home_bench %} + + + + + + {% endfor %} + +
#ИгрокДействия
{{ p.number }} +
+
+ {{ p.last_name or p.player_name }} + {% if p.first_name %} {{ p.first_name }}{% endif %} +
+ + {% if p.position and p.position|lower in ['вр.', 'вр', 'вратарь', 'gk', 'goalkeeper'] %} + ВР + {% endif %} + {% if p.is_captain %} + К + {% endif %} + +
+
+
+ +
+
+ + {% else %} +
Запасные пока не загружены.
+ {% endif %} +
+ +
+ {% if coaches.away %} +
+ {% for c in coaches.away %} +
+
+ {{ c.coach_name }} + {% if c.role %} + {{ c.role }} + {% endif %} +
+
+ + +
+
+ {% endfor %} +
+ {% endif %} + +
Основной состав
+ {% if lineups.away_starting %} + + + + + + + + + + + {% for p in lineups.away_starting %} + + + + + + {% endfor %} + +
#ИгрокДействия
{{ p.number }} +
+
+ {{ p.last_name or p.player_name }} + {% if p.first_name %} {{ p.first_name }}{% endif %} +
+ + {% if p.position and p.position|lower in ['вр.', 'вр', 'вратарь', 'gk', 'goalkeeper'] %} + ВР + {% endif %} + {% if p.is_captain %} + К + {% endif %} + +
+
+
+ + + + + + +
+
+ + {% else %} +
Основной состав пока не загружен.
+ {% endif %} + +
Запасные
+
Выбери игрока из запасных для завершения замены
+ {% if lineups.away_bench %} + + + + + + + + + + + {% for p in lineups.away_bench %} + + + + + + {% endfor %} + +
#ИгрокДействия
{{ p.number }} +
+
+ {{ p.last_name or p.player_name }} + {% if p.first_name %} {{ p.first_name }}{% endif %} +
+ + {% if p.position and p.position|lower in ['вр.', 'вр', 'вратарь', 'gk', 'goalkeeper'] %} + ВР + {% endif %} + {% if p.is_captain %} + К + {% endif %} + +
+
+
+ +
+
+ + {% else %} +
Запасные пока не загружены.
+ {% endif %} +
+
+
+ +
+
+
+
События матча
+
Слева хозяева, справа гости. Время идёт сверху вниз по общей шкале.
+
+ +
+ +
+
Событий пока нет.
+
+
+
+ {% elif tab == 'referees' %} +
+ +
Судейская бригада
+ + {% if referees %} + + + + + + + + + {% for r in referees %} + + + + + {% endfor %} + +
РольФИО
{{ r.role or "—" }}{{ r.referee_name }}
+ {% else %} +
Судьи не найдены.
+ {% endif %} + +
+ {% elif tab == 'tour_schedule' %} +
+
Расписание тура
+ + {% set month_names = { + 1: 'января', 2: 'февраля', 3: 'марта', 4: 'апреля', + 5: 'мая', 6: 'июня', 7: 'июля', 8: 'августа', + 9: 'сентября', 10: 'октября', 11: 'ноября', 12: 'декабря' + } %} + + {% if tour_schedule %} + + + + + + + + + + + {% for m in tour_schedule %} + + + + + + + + + + {% endfor %} + +
ДатаМатчСчёт / статусСтадион
+ {% if m.match_date %} + {{ m.match_date.day }} {{ month_names[m.match_date.month] }} {{ m.match_date.year }} + {% if m.match_date.hour != 0 or m.match_date.minute != 0 %} + {{ "%02d:%02d"|format(m.match_date.hour, m.match_date.minute) }} + {% endif %} + {% else %} + — + {% endif %} + + {{ m.home_team_name }} — {{ m.away_team_name }} + {% if m.match_external_id == session[8] %} + ТЕКУЩИЙ + {% endif %} + +
+ {% if m.status == 'finished' and m.home_score is not none and m.away_score is not + none %} + {{ m.home_score }} : {{ m.away_score }} + {% endif %} + + {% if m.status == 'finished' %} + Завершён + {% elif m.status == 'live' %} + Идёт + {% elif m.status == 'scheduled' %} + Не начался + {% else %} + {{ m.status or "—" }} + {% endif %} +
+
+ {{ m.stadium_name or "—" }} +
+ {% else %} +
Расписание тура не найдено.
+ {% endif %} +
+ {% elif tab == 'standings' %} +
+
Турнирная таблица
+ + {% if standings %} + + + + + + + + + + + + + + + + + {% for row in standings %} + + + + + + + + + + + + + + + + {% endfor %} + +
#КомандаИВНПЗМПМО
{{ row.position }} + {% if row.team_logo %} + + {% endif %} + + {{ row.team_name }} + {{ row.played }}{{ row.wins }}{{ row.draws }}{{ row.losses }}{{ row.goals_for }}{{ row.goals_against }}{{ row.points }}
+ {% else %} +
Турнирная таблица пока не загружена.
+ {% endif %} +
+ {% elif tab == 'formations' %} +
+
+
Расстановка — {{ session[14] }}
+
+ + + +
+
+
+
+
+
+ +
+
Расстановка — {{ session[17] }}
+ +
+ + + +
+ +
+
+
+
+
+
+ {% else %} +
+
Эта вкладка пока в разработке.
+
+ {% endif %} +
+
+ + + + + + diff --git a/templates/match_workspace.html b/templates/match_workspace.html new file mode 100644 index 0000000..5be2d42 --- /dev/null +++ b/templates/match_workspace.html @@ -0,0 +1,1180 @@ + + + + + + + + Рабочее окно матча + + + + +
+
+
+
+
+ {% if session[15] %} + + {% endif %} +
+
{{ session[14] }}
+
+
+ +
+
{{ session[10] or "Тур не указан" }}
+
+ {{ session[9] or "Время не указано" }} +
+
+ {{ session[12] or "Стадион не указан" }} +
+
+ +
+
+
{{ session[17] }}
+
+ {% if session[18] %} + + {% endif %} +
+
+
+ +
+ +
+ +
+ + + {% if current_user and current_user.get("role") == "admin" %} + + 🗄️ + + {% endif %} + +
+ +
+
+
+
+
+ +
+
+ Сессия истекла. Выполняется переход на страницу входа… +
+ + + {% if tab == 'game' %} +
+
+
+
+
+ + + + +
+ +
+ Матч не начат +
00:00
+
0 : 0
+ +
+
+ +
+ + +
+
+ + +
+
+
+
+ +
+
+ {% if coaches.home %} +
+ {% for c in coaches.home %} +
+
+ {{ c.coach_name }} + {% if c.role %} + {{ c.role }} + {% endif %} +
+
+ + + +
+
+ {% endfor %} +
+ {% endif %} + +
Основной состав
+ {% if lineups.home_starting %} + + + + + + + + + + {% for p in lineups.home_starting %} + + + + + + {% endfor %} + +
#ИгрокДействия
{{ p.number }} +
+
+ {{ p.last_name or p.player_name }} + {% if p.first_name %} + {{ p.first_name }}{% endif %} +
+ + {% if p.position and p.position|lower in ['вр.', + 'вр', 'вратарь', 'gk', 'goalkeeper'] %} + ВР + {% endif %} {% if p.is_captain %} + К + {% endif %} + +
+
+
+ + + + + + +
+
+ {% else %} +
Основной состав пока не загружен.
+ {% endif %} + +
Запасные
+
+ Выбери игрока из запасных для завершения замены +
+ {% if lineups.home_bench %} + + + + + + + + + + {% for p in lineups.home_bench %} + + + + + + {% endfor %} + +
#ИгрокДействия
{{ p.number }} +
+
+ {{ p.last_name or p.player_name }} + {% if p.first_name %} + {{ p.first_name }}{% endif %} +
+ + {% if p.position and p.position|lower in ['вр.', + 'вр', 'вратарь', 'gk', 'goalkeeper'] %} + ВР + {% endif %} {% if p.is_captain %} + К + {% endif %} + +
+
+
+ +
+
+ {% else %} +
Запасные пока не загружены.
+ {% endif %} +
+ +
+ {% if coaches.away %} +
+ {% for c in coaches.away %} +
+
+ {{ c.coach_name }} + {% if c.role %} + {{ c.role }} + {% endif %} +
+
+ + + +
+
+ {% endfor %} +
+ {% endif %} + +
Основной состав
+ {% if lineups.away_starting %} + + + + + + + + + + {% for p in lineups.away_starting %} + + + + + + {% endfor %} + +
#ИгрокДействия
{{ p.number }} +
+
+ {{ p.last_name or p.player_name }} + {% if p.first_name %} + {{ p.first_name }}{% endif %} +
+ + {% if p.position and p.position|lower in ['вр.', + 'вр', 'вратарь', 'gk', 'goalkeeper'] %} + ВР + {% endif %} {% if p.is_captain %} + К + {% endif %} + +
+
+
+ + + + + + +
+
+ {% else %} +
Основной состав пока не загружен.
+ {% endif %} + +
Запасные
+
+ Выбери игрока из запасных для завершения замены +
+ {% if lineups.away_bench %} + + + + + + + + + + {% for p in lineups.away_bench %} + + + + + + {% endfor %} + +
#ИгрокДействия
{{ p.number }} +
+
+ {{ p.last_name or p.player_name }} + {% if p.first_name %} + {{ p.first_name }}{% endif %} +
+ + {% if p.position and p.position|lower in ['вр.', + 'вр', 'вратарь', 'gk', 'goalkeeper'] %} + ВР + {% endif %} {% if p.is_captain %} + К + {% endif %} + +
+
+
+ +
+
+ {% else %} +
Запасные пока не загружены.
+ {% endif %} +
+
+
+ +
+
+
+
События матча
+
+ Слева хозяева, справа гости. Время идёт сверху вниз по общей + шкале. +
+
+ +
+ +
+
Событий пока нет.
+
+
+
+ + {% elif tab == 'formations' %} +
+
+
Расстановка — {{ session[14] }}
+ +
+ + + +
+ +
+
+
+
+
+ +
+
Расстановка — {{ session[17] }}
+ +
+ + + +
+ +
+
+
+
+
+
+ + + {% elif tab == 'referees' %} +
+
Судейская бригада
+ + +
+ + +
+ + + +
+
+ + {% elif tab == 'tour_schedule' %} +
+
Расписание тура
+ + {% set month_names = { 1: 'января', 2: 'февраля', 3: 'марта', 4: + 'апреля', 5: 'мая', 6: 'июня', 7: 'июля', 8: 'августа', 9: 'сентября', + 10: 'октября', 11: 'ноября', 12: 'декабря' } %} {% if tour_schedule %} + + + + + + + + + + + {% for m in tour_schedule %} + + + + + + + + + + {% endfor %} + +
ДатаМатчСчёт / статусСтадион
+ {% if m.match_date %} + {{ m.match_date.day }} {{ month_names[m.match_date.month] }} + {{ m.match_date.year }} + {% if m.match_date.hour != 0 or m.match_date.minute != 0 %} + {{ "%02d:%02d"|format(m.match_date.hour, m.match_date.minute) }} + {% endif %} {% else %} — {% endif %} + + {{ m.home_team_name }} — {{ m.away_team_name }} + {% if m.match_external_id == session[8] %} + ТЕКУЩИЙ + {% endif %} + +
+ {% if m.status == 'finished' and m.home_score is not none + and m.away_score is not none %} + {{ m.home_score }} : {{ m.away_score }} + {% endif %} {% if m.status == 'finished' %} + Завершён + {% elif m.status == 'live' %} + Идёт + {% elif m.status == 'scheduled' %} + Не начался + {% else %} + {{ m.status or "—" }} + {% endif %} +
+
+ {{ m.stadium_name or "—" }} +
+ {% else %} +
Расписание тура не найдено.
+ {% endif %} +
+ + {% elif tab == 'standings' %} +
+
Турнирная таблица
+ + {% if standings %} + + + + + + + + + + + + + + + + {% for s in standings %} + + + + + + + + + + + + {% endfor %} + +
#КомандаИВНПМО
{{ s.position }} + {% if s.team_logo %} + + {% endif %} + {{ s.team_name }}{{ s.played }}{{ s.wins }}{{ s.draws }}{{ s.losses }} + {{ s.goals_for }}-{{ s.goals_against }} + {{ s.points }}
+ {% else %} +
Турнирная таблица пока не загружена.
+ {% endif %} +
+ + {% else %} +
+
Эта вкладка пока в разработке.
+
+ {% endif %} +
+
+ + +
+
+ +
+
+
+
Заполнение расстановки
+
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+ +
+
+
+
Редактирование гола
+
+
+ + +
+ +
+
+
+
+
+ +
+
+ +
+
+
+
Редактирование состава
+
+ Добавление, удаление игроков, капитаны и тренеры +
+
+ +
+ +
+
+
+
{{ session[14] }}
+
+
+ +
+
{{ session[17] }}
+
+
+
+
+ + +
+
+ + + + + diff --git a/templates/matches.html b/templates/matches.html new file mode 100644 index 0000000..937ab57 --- /dev/null +++ b/templates/matches.html @@ -0,0 +1,554 @@ + + + + + + + + Выбор матча + + + +
+ + +
+ +
+
+ +
+ + Только сегодня +
+
+ +
+ + +
+ + +
+ +
+ {% if matches %} + + + + + + + + + + + + + + + {% for row in matches %} + {% set status = (row[3] or 'scheduled') %} + + + + + + + + + + {% endfor %} + +
IDДатаМатчТурСезонСтатусОператорДействие
{{ row[0] }} +
{{ row[2] or "" }}
+
+ {{ row[6] }} — {{ row[7] }} + {{ row[4] or "" }}{{ row[5] or "" }} + + {{ status }} + + +
+ + +
+
+ {% else %} +
Матчи не найдены.
+ {% endif %} +
+
+ + + \ No newline at end of file diff --git a/vmix/__init__.py b/vmix/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vmix/__pycache__/__init__.cpython-312.pyc b/vmix/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5c20ab1550ffe25ebcf799d4513773d4bde9bd15 GIT binary patch literal 125 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 str: + """ + Меняет URL вида: + http://127.0.0.1:8000/vmix/session/OLDTOKEN/home-lineup + на: + https://hostname.tvstart.ru/vmix/session/NEWTOKEN/home-lineup + """ + old_url = (old_url or "").strip() + parsed = urlparse(old_url) + path = parsed.path or "" + + match = re.match(r"^/vmix/session/[^/]+/(.+)$", path) + if match: + endpoint = match.group(1) + return f"{new_base_url}/vmix/session/{session_token}/{endpoint}" + + # fallback: если структура другая, просто меняем host + return re.sub(r"https?://[^/]+", new_base_url, old_url) + + +def change_vmix_datasource_urls( + xml_data, + new_base_url: str, + session_token: str, + match_id: str | int | None = None, +) -> bytes: + if isinstance(xml_data, dict): + candidate = None + + for key in ("content", "data", "body", "text", "file", "bio"): + if key in xml_data and xml_data[key] is not None: + candidate = xml_data[key] + break + + if candidate is None: + raise TypeError( + f"Unsupported xml_data dict structure. Keys: {list(xml_data.keys())}" + ) + + if isinstance(candidate, (bytes, bytearray)): + raw_bytes = bytes(candidate) + elif isinstance(candidate, str): + raw_bytes = candidate.encode("utf-8") + elif isinstance(candidate, io.IOBase) or hasattr(candidate, "read"): + raw_bytes = candidate.read() + try: + candidate.seek(0) + except Exception: + pass + else: + raise TypeError( + f"Unsupported xml_data payload inside dict: {type(candidate)}; " + f"available keys: {list(xml_data.keys())}" + ) + + elif isinstance(xml_data, (bytes, bytearray)): + raw_bytes = bytes(xml_data) + + elif isinstance(xml_data, str): + raw_bytes = xml_data.encode("utf-8") + + elif isinstance(xml_data, io.IOBase) or hasattr(xml_data, "read"): + raw_bytes = xml_data.read() + try: + xml_data.seek(0) + except Exception: + pass + + else: + raise TypeError(f"Unsupported xml_data type: {type(xml_data)}") + + text = raw_bytes.decode("utf-8", errors="replace") + root = ET.fromstring(text) + + for url_tag in root.findall( + ".//datasource[@friendlyName='JSON']//instance//state/xml/url" + ): + old_url = (url_tag.text or "").strip() + url_tag.text = rebuild_vmix_url(old_url, new_base_url, session_token) + + dynamic_settings = root.find(".//DynamicSettings") + if dynamic_settings is None: + dynamic_settings = ET.SubElement(root, "DynamicSettings") + + dynamic = dynamic_settings.find("Dynamic") + if dynamic is None: + dynamic = ET.SubElement(dynamic_settings, "Dynamic") + + + def get_or_create_dynamic_value(index: int): + values = dynamic.findall("DynamicValue") + + # гарантируем нужное количество + while len(values) <= index: + ET.SubElement(dynamic, "DynamicInput") + ET.SubElement(dynamic, "DynamicValue") + values = dynamic.findall("DynamicValue") + + return values[index] + + + # value1 = session + get_or_create_dynamic_value(0).text = str(session_token) + + # value2 = match_id + get_or_create_dynamic_value(1).text = "" if match_id is None else str(match_id) + + + return ET.tostring(root, encoding="utf-8", method="xml") + + + +def build_vmix_project_bytes(session_token: str, match_id: str | int | None = None) -> bytes: + vmix_bio = nasio.load_bio( + user=SYNO_USERNAME, + password=SYNO_PASSWORD, + nas_ip=SYNO_URL, + nas_port="443", + path=SYNO_PATH_VMIX, + ) + + edited_vmix = change_vmix_datasource_urls( + vmix_bio, + FQDN, + session_token, + match_id, + ) + + if isinstance(edited_vmix, str): + edited_vmix = edited_vmix.encode("utf-8") + + return edited_vmix + + +def build_vmix_filename(session_row) -> str: + home = session_row[14].replace("«", "").replace("»", "") + away = session_row[17].replace("«", "").replace("»", "") + tour = session_row[10] + date_str = session_row[9].strftime("%d%m%Y") + filename = f"ЖФЛ_{home} VS {away}_{tour}_{date_str}.vmix" + return filename \ No newline at end of file diff --git a/wfl.sql b/wfl.sql new file mode 100644 index 0000000..dabdbfc --- /dev/null +++ b/wfl.sql @@ -0,0 +1,84 @@ +-- ========================= +-- TEAMS +-- ========================= +CREATE TABLE teams ( + id BIGSERIAL PRIMARY KEY, + external_id VARCHAR(100) UNIQUE, + name VARCHAR(255) NOT NULL, + short_name VARCHAR(100), + logo_url TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_teams_external_id ON teams(external_id); + + +-- ========================= +-- PLAYERS +-- ========================= +CREATE TABLE players ( + id BIGSERIAL PRIMARY KEY, + external_id VARCHAR(100) UNIQUE, + team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + full_name VARCHAR(255) NOT NULL, + first_name VARCHAR(100), + last_name VARCHAR(100), + number VARCHAR(20), + position VARCHAR(50), + birth_date DATE, + height_cm INTEGER, + weight_kg INTEGER, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_players_team_id ON players(team_id); +CREATE INDEX idx_players_external_id ON players(external_id); + + +-- ========================= +-- MATCHES (расписание) +-- ========================= +CREATE TABLE matches ( + id BIGSERIAL PRIMARY KEY, + external_id VARCHAR(100) UNIQUE, + home_team_id BIGINT NOT NULL REFERENCES teams(id), + away_team_id BIGINT NOT NULL REFERENCES teams(id), + match_date TIMESTAMP, + status VARCHAR(50) NOT NULL DEFAULT 'scheduled', + home_score INTEGER, + away_score INTEGER, + tour VARCHAR(100), + season VARCHAR(50), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_matches_external_id ON matches(external_id); +CREATE INDEX idx_matches_season ON matches(season); +CREATE INDEX idx_matches_date ON matches(match_date); + + +-- ========================= +-- STANDINGS (таблица турнира) +-- ========================= +CREATE TABLE standings ( + id BIGSERIAL PRIMARY KEY, + team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + season VARCHAR(50) NOT NULL, + played INTEGER NOT NULL DEFAULT 0, + wins INTEGER NOT NULL DEFAULT 0, + losses INTEGER NOT NULL DEFAULT 0, + draws INTEGER NOT NULL DEFAULT 0, + points_for INTEGER NOT NULL DEFAULT 0, + points_against INTEGER NOT NULL DEFAULT 0, + points INTEGER NOT NULL DEFAULT 0, + position INTEGER, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(team_id, season) +); + +CREATE INDEX idx_standings_season ON standings(season);