commit 8d80fadb56d4e8598b7d8f6b8958d7a82f5c02fc Author: Юрий Черненко Date: Mon Apr 20 11:56:11 2026 +0300 first commit 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 0000000..6bc10a5 Binary files /dev/null and b/parsers/__pycache__/__init__.cpython-312.pyc differ diff --git a/parsers/__pycache__/parser_game.cpython-312.pyc b/parsers/__pycache__/parser_game.cpython-312.pyc new file mode 100644 index 0000000..e332709 Binary files /dev/null and b/parsers/__pycache__/parser_game.cpython-312.pyc differ diff --git a/parsers/__pycache__/parser_players.cpython-312.pyc b/parsers/__pycache__/parser_players.cpython-312.pyc new file mode 100644 index 0000000..f0302a6 Binary files /dev/null and b/parsers/__pycache__/parser_players.cpython-312.pyc differ diff --git a/parsers/__pycache__/parser_schedule.cpython-312.pyc b/parsers/__pycache__/parser_schedule.cpython-312.pyc new file mode 100644 index 0000000..40fe923 Binary files /dev/null and b/parsers/__pycache__/parser_schedule.cpython-312.pyc differ diff --git a/parsers/__pycache__/parser_standings.cpython-312.pyc b/parsers/__pycache__/parser_standings.cpython-312.pyc new file mode 100644 index 0000000..dfdf845 Binary files /dev/null and b/parsers/__pycache__/parser_standings.cpython-312.pyc differ diff --git a/parsers/__pycache__/parser_teams.cpython-312.pyc b/parsers/__pycache__/parser_teams.cpython-312.pyc new file mode 100644 index 0000000..3241afa Binary files /dev/null and b/parsers/__pycache__/parser_teams.cpython-312.pyc differ 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 0000000..f6d1eb5 Binary files /dev/null and b/repositories/__pycache__/__init__.cpython-312.pyc differ diff --git a/repositories/__pycache__/audit_log_repository.cpython-312.pyc b/repositories/__pycache__/audit_log_repository.cpython-312.pyc new file mode 100644 index 0000000..a801118 Binary files /dev/null and b/repositories/__pycache__/audit_log_repository.cpython-312.pyc differ diff --git a/repositories/__pycache__/auth_repository.cpython-312.pyc b/repositories/__pycache__/auth_repository.cpython-312.pyc new file mode 100644 index 0000000..ea6f94f Binary files /dev/null and b/repositories/__pycache__/auth_repository.cpython-312.pyc differ diff --git a/repositories/__pycache__/coach_repository.cpython-312.pyc b/repositories/__pycache__/coach_repository.cpython-312.pyc new file mode 100644 index 0000000..0af5d4d Binary files /dev/null and b/repositories/__pycache__/coach_repository.cpython-312.pyc differ diff --git a/repositories/__pycache__/match_clock_repository.cpython-312.pyc b/repositories/__pycache__/match_clock_repository.cpython-312.pyc new file mode 100644 index 0000000..b68a34b Binary files /dev/null and b/repositories/__pycache__/match_clock_repository.cpython-312.pyc differ 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 0000000..93c4280 Binary files /dev/null and b/repositories/__pycache__/match_clock_repository.cpython-313.pyc differ 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 0000000..262e840 Binary files /dev/null and b/repositories/__pycache__/match_coach_repository.cpython-312.pyc differ diff --git a/repositories/__pycache__/match_coach_repository.cpython-313.pyc b/repositories/__pycache__/match_coach_repository.cpython-313.pyc new file mode 100644 index 0000000..055b064 Binary files /dev/null and b/repositories/__pycache__/match_coach_repository.cpython-313.pyc differ diff --git a/repositories/__pycache__/match_event_repository.cpython-312.pyc b/repositories/__pycache__/match_event_repository.cpython-312.pyc new file mode 100644 index 0000000..9eff907 Binary files /dev/null and b/repositories/__pycache__/match_event_repository.cpython-312.pyc differ 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 0000000..14277c3 Binary files /dev/null and b/repositories/__pycache__/match_event_repository.cpython-313.pyc differ 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 0000000..4bc9039 Binary files /dev/null and b/repositories/__pycache__/match_formation_repository.cpython-312.pyc differ diff --git a/repositories/__pycache__/match_lineup_repository.cpython-312.pyc b/repositories/__pycache__/match_lineup_repository.cpython-312.pyc new file mode 100644 index 0000000..435a944 Binary files /dev/null and b/repositories/__pycache__/match_lineup_repository.cpython-312.pyc differ 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 0000000..0f66466 Binary files /dev/null and b/repositories/__pycache__/match_referee_repository.cpython-312.pyc differ diff --git a/repositories/__pycache__/match_repository.cpython-312.pyc b/repositories/__pycache__/match_repository.cpython-312.pyc new file mode 100644 index 0000000..70c044e Binary files /dev/null and b/repositories/__pycache__/match_repository.cpython-312.pyc differ diff --git a/repositories/__pycache__/match_session_repository.cpython-312.pyc b/repositories/__pycache__/match_session_repository.cpython-312.pyc new file mode 100644 index 0000000..39fba5b Binary files /dev/null and b/repositories/__pycache__/match_session_repository.cpython-312.pyc differ diff --git a/repositories/__pycache__/match_view_repository.cpython-312.pyc b/repositories/__pycache__/match_view_repository.cpython-312.pyc new file mode 100644 index 0000000..b39bd53 Binary files /dev/null and b/repositories/__pycache__/match_view_repository.cpython-312.pyc differ diff --git a/repositories/__pycache__/player_repository.cpython-312.pyc b/repositories/__pycache__/player_repository.cpython-312.pyc new file mode 100644 index 0000000..a950128 Binary files /dev/null and b/repositories/__pycache__/player_repository.cpython-312.pyc differ diff --git a/repositories/__pycache__/referee_repository.cpython-312.pyc b/repositories/__pycache__/referee_repository.cpython-312.pyc new file mode 100644 index 0000000..e3ca804 Binary files /dev/null and b/repositories/__pycache__/referee_repository.cpython-312.pyc differ diff --git a/repositories/__pycache__/stadium_repository.cpython-312.pyc b/repositories/__pycache__/stadium_repository.cpython-312.pyc new file mode 100644 index 0000000..b204e90 Binary files /dev/null and b/repositories/__pycache__/stadium_repository.cpython-312.pyc differ diff --git a/repositories/__pycache__/standings_repository.cpython-312.pyc b/repositories/__pycache__/standings_repository.cpython-312.pyc new file mode 100644 index 0000000..3f32c51 Binary files /dev/null and b/repositories/__pycache__/standings_repository.cpython-312.pyc differ 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 0000000..953fb78 Binary files /dev/null and b/repositories/__pycache__/team_coach_repository.cpython-312.pyc differ diff --git a/repositories/__pycache__/team_repository.cpython-312.pyc b/repositories/__pycache__/team_repository.cpython-312.pyc new file mode 100644 index 0000000..2775798 Binary files /dev/null and b/repositories/__pycache__/team_repository.cpython-312.pyc differ 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 0000000..561414f Binary files /dev/null and b/repositories/__pycache__/team_squad_repository.cpython-312.pyc differ diff --git a/repositories/audit_log_repository.py b/repositories/audit_log_repository.py new file mode 100644 index 0000000..1ecd624 --- /dev/null +++ b/repositories/audit_log_repository.py @@ -0,0 +1,45 @@ +# repositories/audit_log_repository.py +import json +from db import get_connection + +def create_audit_log( + user_id=None, + username=None, + role=None, + action="", + entity_type=None, + entity_id=None, + match_id=None, + session_token=None, + ip_address=None, + user_agent=None, + details=None, +): + conn = get_connection() + try: + with conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO audit_logs ( + user_id, username, role, action, entity_type, entity_id, + match_id, session_token, ip_address, user_agent, details + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb) + """, + ( + user_id, + username, + role, + action, + entity_type, + entity_id, + match_id, + session_token, + ip_address, + user_agent, + json.dumps(details or {}, ensure_ascii=False), + ), + ) + finally: + conn.close() \ No newline at end of file diff --git a/repositories/auth_repository.py b/repositories/auth_repository.py new file mode 100644 index 0000000..0bdcc53 --- /dev/null +++ b/repositories/auth_repository.py @@ -0,0 +1,159 @@ +from db import get_connection + + +def get_user_by_username(username: str): + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, username, password_hash, is_active, created_at + FROM admin_users + WHERE username = %s + LIMIT 1 + """, + (username,), + ) + return cur.fetchone() + finally: + conn.close() + + +def get_user_by_id(user_id: int): + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, username, password_hash, is_active, created_at + FROM admin_users + WHERE id = %s + LIMIT 1 + """, + (user_id,), + ) + return cur.fetchone() + finally: + conn.close() + + +def create_auth_session_record( + user_id: int, + session_token: str, + expires_at, + ip_address: str | None = None, + user_agent: str | None = None, +): + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO auth_sessions ( + user_id, + session_token, + last_activity_at, + expires_at, + ip_address, + user_agent + ) + VALUES (%s, %s, NOW(), %s, %s, %s) + RETURNING id + """, + (user_id, session_token, expires_at, ip_address, user_agent), + ) + row = cur.fetchone() + conn.commit() + return row[0] if row else None + finally: + conn.close() + + +def get_auth_session_by_token(session_token: str): + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + s.id, + s.user_id, + s.session_token, + s.created_at, + s.last_activity_at, + s.expires_at, + s.revoked_at, + s.ip_address, + s.user_agent, + u.username, + u.is_active, + u.role + FROM auth_sessions s + JOIN admin_users u ON u.id = s.user_id + WHERE s.session_token = %s + LIMIT 1 + """, + (session_token,), + ) + return cur.fetchone() + finally: + conn.close() + + +def touch_auth_session_if_needed(session_token: str, expires_at, throttle_seconds: int = 60): + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE auth_sessions + SET last_activity_at = NOW(), + expires_at = %s + WHERE session_token = %s + AND revoked_at IS NULL + AND last_activity_at < NOW() - (%s || ' seconds')::interval + """, + (expires_at, session_token, str(throttle_seconds)), + ) + conn.commit() + return cur.rowcount + finally: + conn.close() + + +def revoke_auth_session(session_token: str): + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE auth_sessions + SET revoked_at = NOW() + WHERE session_token = %s + AND revoked_at IS NULL + """, + (session_token,), + ) + conn.commit() + return cur.rowcount + finally: + conn.close() + + +def revoke_all_user_sessions(user_id: int): + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE auth_sessions + SET revoked_at = NOW() + WHERE user_id = %s + AND revoked_at IS NULL + """, + (user_id,), + ) + conn.commit() + return cur.rowcount + finally: + conn.close() diff --git a/repositories/coach_repository.py b/repositories/coach_repository.py new file mode 100644 index 0000000..df7d04f --- /dev/null +++ b/repositories/coach_repository.py @@ -0,0 +1,286 @@ +from db import get_connection +from repositories.team_repository import get_team_id_by_external_id + + +def upsert_coach( + external_id: str, + team_external_id: str, + player: str, + lastname: str = "", + name: str = "", + born: str = "", + amplua: str = "", + 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 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 0000000..1a9135d Binary files /dev/null and b/scripts/__pycache__/create_admin.cpython-312.pyc differ diff --git a/scripts/__pycache__/create_admin.cpython-313.pyc b/scripts/__pycache__/create_admin.cpython-313.pyc new file mode 100644 index 0000000..a5a91c3 Binary files /dev/null and b/scripts/__pycache__/create_admin.cpython-313.pyc differ diff --git a/scripts/create_admin.py b/scripts/create_admin.py new file mode 100644 index 0000000..6a397e3 --- /dev/null +++ b/scripts/create_admin.py @@ -0,0 +1,59 @@ +import os +import sys +from getpass import getpass + +sys.path.append(os.path.dirname(os.path.dirname(__file__))) + +import psycopg2 +from services.auth_service import hash_password + + +def get_connection(): + return psycopg2.connect( + host=os.getenv("DB_HOST", "localhost"), + port=os.getenv("DB_PORT", 5432), + dbname=os.getenv("DB_NAME", "wfl_db"), + user=os.getenv("DB_USER", "postgres"), + password=os.getenv("DB_PASSWORD", "159753"), + ) + + +def main(): + username = input("Username: ").strip() + password = getpass("Password: ").strip() + + if not username: + print("Username is required") + return + + if not password: + print("Password is required") + return + + password_hash = hash_password(password) + + conn = get_connection() + try: + with conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO admin_users (username, password_hash, is_active) + VALUES (%s, %s, TRUE) + ON CONFLICT (username) DO NOTHING + RETURNING id; + """, + (username, password_hash), + ) + row = cur.fetchone() + + if row: + print(f"User created: {username} (id={row[0]})") + else: + print(f"User '{username}' already exists") + finally: + conn.close() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/__pycache__/__init__.cpython-312.pyc b/services/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..c298545 Binary files /dev/null and b/services/__pycache__/__init__.cpython-312.pyc differ diff --git a/services/__pycache__/auth_service.cpython-312.pyc b/services/__pycache__/auth_service.cpython-312.pyc new file mode 100644 index 0000000..6d1532a Binary files /dev/null and b/services/__pycache__/auth_service.cpython-312.pyc differ diff --git a/services/__pycache__/game_service.cpython-312.pyc b/services/__pycache__/game_service.cpython-312.pyc new file mode 100644 index 0000000..4ab551a Binary files /dev/null and b/services/__pycache__/game_service.cpython-312.pyc differ diff --git a/services/__pycache__/players_service.cpython-312.pyc b/services/__pycache__/players_service.cpython-312.pyc new file mode 100644 index 0000000..20e8345 Binary files /dev/null and b/services/__pycache__/players_service.cpython-312.pyc differ diff --git a/services/__pycache__/schedule_service.cpython-312.pyc b/services/__pycache__/schedule_service.cpython-312.pyc new file mode 100644 index 0000000..f2ffa8f Binary files /dev/null and b/services/__pycache__/schedule_service.cpython-312.pyc differ diff --git a/services/__pycache__/standings_service.cpython-312.pyc b/services/__pycache__/standings_service.cpython-312.pyc new file mode 100644 index 0000000..5e78b71 Binary files /dev/null and b/services/__pycache__/standings_service.cpython-312.pyc differ diff --git a/services/__pycache__/teams_service.cpython-312.pyc b/services/__pycache__/teams_service.cpython-312.pyc new file mode 100644 index 0000000..63628fe Binary files /dev/null and b/services/__pycache__/teams_service.cpython-312.pyc differ 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 0000000..28ef24b Binary files /dev/null and b/services/__pycache__/vmix_json_service.cpython-312.pyc differ diff --git a/services/auth_service.py b/services/auth_service.py new file mode 100644 index 0000000..c4b3978 --- /dev/null +++ b/services/auth_service.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import secrets +from datetime import datetime, timedelta, timezone + +from fastapi import Request +from fastapi.responses import JSONResponse, RedirectResponse + +from repositories.auth_repository import ( + create_auth_session_record, + get_auth_session_by_token, + revoke_auth_session, +) + +IDLE_TIMEOUT_SECONDS = 2 * 60 * 60 +SESSION_TOUCH_THROTTLE_SECONDS = 60 +PBKDF2_ITERATIONS = 260_000 + + +def hash_password(password: str) -> 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 0000000..5c20ab1 Binary files /dev/null and b/vmix/__pycache__/__init__.cpython-312.pyc differ diff --git a/vmix/__pycache__/vmix_service.cpython-312.pyc b/vmix/__pycache__/vmix_service.cpython-312.pyc new file mode 100644 index 0000000..cd5e4e2 Binary files /dev/null and b/vmix/__pycache__/vmix_service.cpython-312.pyc differ diff --git a/vmix/vmix_service.py b/vmix/vmix_service.py new file mode 100644 index 0000000..bfdc862 --- /dev/null +++ b/vmix/vmix_service.py @@ -0,0 +1,169 @@ +import nasio +import re +import xml.etree.ElementTree as ET +import io +import platform +import os +from urllib.parse import urlparse +from dotenv import load_dotenv + +load_dotenv() + +SYNO_URL = os.getenv("SYNO_URL") +SYNO_USERNAME = os.getenv("SYNO_USERNAME") +SYNO_PASSWORD = os.getenv("SYNO_PASSWORD") +SYNO_PATH_VMIX = os.getenv("SYNO_PATH_VMIX") + + +def get_fqdn(): + system_name = platform.system() + if system_name == "Linux": + hostname = platform.node().lower() + return f"https://{hostname}.tvstart.ru" + return "http://127.0.0.1:8000" + + +FQDN = get_fqdn() + + +def rebuild_vmix_url(old_url: str, new_base_url: str, session_token: str) -> 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);