first commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/__pycache__
|
||||||
|
*.env
|
||||||
45
README_AUTH.md
Normal file
45
README_AUTH.md
Normal file
@@ -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`.
|
||||||
165
agent.py
Normal file
165
agent.py
Normal file
@@ -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())
|
||||||
37
db.py
Normal file
37
db.py
Normal file
@@ -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()
|
||||||
96
main.py
Normal file
96
main.py
Normal file
@@ -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()
|
||||||
0
parsers/__init__.py
Normal file
0
parsers/__init__.py
Normal file
BIN
parsers/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
parsers/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
parsers/__pycache__/parser_game.cpython-312.pyc
Normal file
BIN
parsers/__pycache__/parser_game.cpython-312.pyc
Normal file
Binary file not shown.
BIN
parsers/__pycache__/parser_players.cpython-312.pyc
Normal file
BIN
parsers/__pycache__/parser_players.cpython-312.pyc
Normal file
Binary file not shown.
BIN
parsers/__pycache__/parser_schedule.cpython-312.pyc
Normal file
BIN
parsers/__pycache__/parser_schedule.cpython-312.pyc
Normal file
Binary file not shown.
BIN
parsers/__pycache__/parser_standings.cpython-312.pyc
Normal file
BIN
parsers/__pycache__/parser_standings.cpython-312.pyc
Normal file
Binary file not shown.
BIN
parsers/__pycache__/parser_teams.cpython-312.pyc
Normal file
BIN
parsers/__pycache__/parser_teams.cpython-312.pyc
Normal file
Binary file not shown.
226
parsers/parser_game.py
Normal file
226
parsers/parser_game.py
Normal file
@@ -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'])}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
174
parsers/parser_players.py
Normal file
174
parsers/parser_players.py
Normal file
@@ -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()
|
||||||
173
parsers/parser_schedule.py
Normal file
173
parsers/parser_schedule.py
Normal file
@@ -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()
|
||||||
81
parsers/parser_standings.py
Normal file
81
parsers/parser_standings.py
Normal file
@@ -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()
|
||||||
88
parsers/parser_teams.py
Normal file
88
parsers/parser_teams.py
Normal file
@@ -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()
|
||||||
0
repositories/__init__.py
Normal file
0
repositories/__init__.py
Normal file
BIN
repositories/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
repositories/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
repositories/__pycache__/audit_log_repository.cpython-312.pyc
Normal file
BIN
repositories/__pycache__/audit_log_repository.cpython-312.pyc
Normal file
Binary file not shown.
BIN
repositories/__pycache__/auth_repository.cpython-312.pyc
Normal file
BIN
repositories/__pycache__/auth_repository.cpython-312.pyc
Normal file
Binary file not shown.
BIN
repositories/__pycache__/coach_repository.cpython-312.pyc
Normal file
BIN
repositories/__pycache__/coach_repository.cpython-312.pyc
Normal file
Binary file not shown.
BIN
repositories/__pycache__/match_clock_repository.cpython-312.pyc
Normal file
BIN
repositories/__pycache__/match_clock_repository.cpython-312.pyc
Normal file
Binary file not shown.
BIN
repositories/__pycache__/match_clock_repository.cpython-313.pyc
Normal file
BIN
repositories/__pycache__/match_clock_repository.cpython-313.pyc
Normal file
Binary file not shown.
BIN
repositories/__pycache__/match_coach_repository.cpython-312.pyc
Normal file
BIN
repositories/__pycache__/match_coach_repository.cpython-312.pyc
Normal file
Binary file not shown.
BIN
repositories/__pycache__/match_coach_repository.cpython-313.pyc
Normal file
BIN
repositories/__pycache__/match_coach_repository.cpython-313.pyc
Normal file
Binary file not shown.
BIN
repositories/__pycache__/match_event_repository.cpython-312.pyc
Normal file
BIN
repositories/__pycache__/match_event_repository.cpython-312.pyc
Normal file
Binary file not shown.
BIN
repositories/__pycache__/match_event_repository.cpython-313.pyc
Normal file
BIN
repositories/__pycache__/match_event_repository.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
repositories/__pycache__/match_lineup_repository.cpython-312.pyc
Normal file
BIN
repositories/__pycache__/match_lineup_repository.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
repositories/__pycache__/match_repository.cpython-312.pyc
Normal file
BIN
repositories/__pycache__/match_repository.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
repositories/__pycache__/match_view_repository.cpython-312.pyc
Normal file
BIN
repositories/__pycache__/match_view_repository.cpython-312.pyc
Normal file
Binary file not shown.
BIN
repositories/__pycache__/player_repository.cpython-312.pyc
Normal file
BIN
repositories/__pycache__/player_repository.cpython-312.pyc
Normal file
Binary file not shown.
BIN
repositories/__pycache__/referee_repository.cpython-312.pyc
Normal file
BIN
repositories/__pycache__/referee_repository.cpython-312.pyc
Normal file
Binary file not shown.
BIN
repositories/__pycache__/stadium_repository.cpython-312.pyc
Normal file
BIN
repositories/__pycache__/stadium_repository.cpython-312.pyc
Normal file
Binary file not shown.
BIN
repositories/__pycache__/standings_repository.cpython-312.pyc
Normal file
BIN
repositories/__pycache__/standings_repository.cpython-312.pyc
Normal file
Binary file not shown.
BIN
repositories/__pycache__/team_coach_repository.cpython-312.pyc
Normal file
BIN
repositories/__pycache__/team_coach_repository.cpython-312.pyc
Normal file
Binary file not shown.
BIN
repositories/__pycache__/team_repository.cpython-312.pyc
Normal file
BIN
repositories/__pycache__/team_repository.cpython-312.pyc
Normal file
Binary file not shown.
BIN
repositories/__pycache__/team_squad_repository.cpython-312.pyc
Normal file
BIN
repositories/__pycache__/team_squad_repository.cpython-312.pyc
Normal file
Binary file not shown.
45
repositories/audit_log_repository.py
Normal file
45
repositories/audit_log_repository.py
Normal file
@@ -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()
|
||||||
159
repositories/auth_repository.py
Normal file
159
repositories/auth_repository.py
Normal file
@@ -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()
|
||||||
286
repositories/coach_repository.py
Normal file
286
repositories/coach_repository.py
Normal file
@@ -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()
|
||||||
178
repositories/match_clock_repository.py
Normal file
178
repositories/match_clock_repository.py
Normal file
@@ -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)
|
||||||
155
repositories/match_coach_repository.py
Normal file
155
repositories/match_coach_repository.py
Normal file
@@ -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()
|
||||||
184
repositories/match_event_repository.py
Normal file
184
repositories/match_event_repository.py
Normal file
@@ -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()
|
||||||
369
repositories/match_formation_repository.py
Normal file
369
repositories/match_formation_repository.py
Normal file
@@ -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
|
||||||
460
repositories/match_lineup_repository.py
Normal file
460
repositories/match_lineup_repository.py
Normal file
@@ -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()
|
||||||
130
repositories/match_referee_repository.py
Normal file
130
repositories/match_referee_repository.py
Normal file
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
269
repositories/match_repository.py
Normal file
269
repositories/match_repository.py
Normal file
@@ -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()
|
||||||
209
repositories/match_session_repository.py
Normal file
209
repositories/match_session_repository.py
Normal file
@@ -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()
|
||||||
96
repositories/match_view_repository.py
Normal file
96
repositories/match_view_repository.py
Normal file
@@ -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
|
||||||
373
repositories/player_repository.py
Normal file
373
repositories/player_repository.py
Normal file
@@ -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()
|
||||||
268
repositories/referee_repository.py
Normal file
268
repositories/referee_repository.py
Normal file
@@ -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()
|
||||||
291
repositories/stadium_repository.py
Normal file
291
repositories/stadium_repository.py
Normal file
@@ -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()
|
||||||
153
repositories/standings_repository.py
Normal file
153
repositories/standings_repository.py
Normal file
@@ -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()
|
||||||
50
repositories/team_coach_repository.py
Normal file
50
repositories/team_coach_repository.py
Normal file
@@ -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()
|
||||||
240
repositories/team_repository.py
Normal file
240
repositories/team_repository.py
Normal file
@@ -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()
|
||||||
58
repositories/team_squad_repository.py
Normal file
58
repositories/team_squad_repository.py
Normal file
@@ -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()
|
||||||
BIN
scripts/__pycache__/create_admin.cpython-312.pyc
Normal file
BIN
scripts/__pycache__/create_admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
scripts/__pycache__/create_admin.cpython-313.pyc
Normal file
BIN
scripts/__pycache__/create_admin.cpython-313.pyc
Normal file
Binary file not shown.
59
scripts/create_admin.py
Normal file
59
scripts/create_admin.py
Normal file
@@ -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()
|
||||||
0
services/__init__.py
Normal file
0
services/__init__.py
Normal file
BIN
services/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
services/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
services/__pycache__/auth_service.cpython-312.pyc
Normal file
BIN
services/__pycache__/auth_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
services/__pycache__/game_service.cpython-312.pyc
Normal file
BIN
services/__pycache__/game_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
services/__pycache__/players_service.cpython-312.pyc
Normal file
BIN
services/__pycache__/players_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
services/__pycache__/schedule_service.cpython-312.pyc
Normal file
BIN
services/__pycache__/schedule_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
services/__pycache__/standings_service.cpython-312.pyc
Normal file
BIN
services/__pycache__/standings_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
services/__pycache__/teams_service.cpython-312.pyc
Normal file
BIN
services/__pycache__/teams_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
services/__pycache__/vmix_json_service.cpython-312.pyc
Normal file
BIN
services/__pycache__/vmix_json_service.cpython-312.pyc
Normal file
Binary file not shown.
155
services/auth_service.py
Normal file
155
services/auth_service.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
211
services/game_service.py
Normal file
211
services/game_service.py
Normal file
@@ -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
|
||||||
46
services/players_service.py
Normal file
46
services/players_service.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
19
services/schedule_service.py
Normal file
19
services/schedule_service.py
Normal file
@@ -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"),
|
||||||
|
)
|
||||||
8
services/standings_service.py
Normal file
8
services/standings_service.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
14
services/teams_service.py
Normal file
14
services/teams_service.py
Normal file
@@ -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),
|
||||||
|
)
|
||||||
424
services/vmix_json_service.py
Normal file
424
services/vmix_json_service.py
Normal file
@@ -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()
|
||||||
23
sql/001_auth.sql
Normal file
23
sql/001_auth.sql
Normal file
@@ -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);
|
||||||
3579
static/script.js
Normal file
3579
static/script.js
Normal file
File diff suppressed because it is too large
Load Diff
2177
static/styles.css
Normal file
2177
static/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
154
templates/admin_db_coach_edit.html
Normal file
154
templates/admin_db_coach_edit.html
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Редактирование тренера</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115;
|
||||||
|
--panel: #171a21;
|
||||||
|
--panel-2: #1d222b;
|
||||||
|
--border: #2b3240;
|
||||||
|
--text: #e8ecf3;
|
||||||
|
--muted: #9aa4b2;
|
||||||
|
--accent: #4f8cff;
|
||||||
|
--shadow: 0 10px 30px rgba(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 {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-meta {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="title">Редактирование тренера</div>
|
||||||
|
<div class="small-meta">ID: {{ coach.id }}</div>
|
||||||
|
<form method="post" action="/admin/db/coaches/{{ coach.id }}/edit">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>ФИО</label>
|
||||||
|
<input type="text" name="full_name" value="{{ coach.full_name }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>External ID</label>
|
||||||
|
<input type="text" name="external_id" value="{{ coach.external_id }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group full">
|
||||||
|
<label>Амплуа</label>
|
||||||
|
<input type="text" name="role" value="{{ coach.role }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group full">
|
||||||
|
<label>Команда</label>
|
||||||
|
<input type="text" value="{{ coach.team_name or 'Не привязан' }}" disabled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||||
|
<a href="/admin/db/coaches" class="btn btn-secondary">К списку</a>
|
||||||
|
<a href="/admin/db" class="btn btn-secondary">Разделы</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
178
templates/admin_db_coaches.html
Normal file
178
templates/admin_db_coaches.html
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Тренеры</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115;
|
||||||
|
--panel: #171a21;
|
||||||
|
--panel-2: #1d222b;
|
||||||
|
--border: #2b3240;
|
||||||
|
--text: #e8ecf3;
|
||||||
|
--muted: #9aa4b2;
|
||||||
|
--accent: #4f8cff;
|
||||||
|
--shadow: 0 10px 30px rgba(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 {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
min-width: 280px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.id-col {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-col {
|
||||||
|
width: 140px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-box {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="title">Тренеры</div>
|
||||||
|
<form method="get" action="/admin/db/coaches" class="search-form">
|
||||||
|
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по ФИО или external_id">
|
||||||
|
<button type="submit" class="btn btn-primary">Найти</button>
|
||||||
|
<a href="/admin/db" class="btn btn-secondary">Назад</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if coaches %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
{% for c in coaches %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ c.id }}</td>
|
||||||
|
<td>{{ c.full_name }}</td>
|
||||||
|
<td>{{ c.team_name or "—" }}</td>
|
||||||
|
<td>{{ c.role or "—" }}</td>
|
||||||
|
<td>{{ c.external_id }}</td>
|
||||||
|
<td class="action-col">
|
||||||
|
<a href="/admin/db/coaches/{{ c.id }}/edit" class="btn btn-secondary">Редактировать</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-box">Тренеры не найдены.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
92
templates/admin_db_index.html
Normal file
92
templates/admin_db_index.html
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>База</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115;
|
||||||
|
--panel: #171a21;
|
||||||
|
--panel-2: #1d222b;
|
||||||
|
--border: #2b3240;
|
||||||
|
--text: #e8ecf3;
|
||||||
|
--muted: #9aa4b2;
|
||||||
|
--accent: #4f8cff;
|
||||||
|
--accent-hover: #3e78e6;
|
||||||
|
--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 {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-link:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="title">Редактирование базы</div>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<a class="tab-link" href="/admin/db/players">Игроки</a>
|
||||||
|
|
||||||
|
<a class="tab-link" href="/admin/db/referees">Судьи</a>
|
||||||
|
<a class="tab-link" href="/admin/db/teams">Команды</a>
|
||||||
|
<a class="tab-link" href="/admin/db/coaches">Тренеры</a>
|
||||||
|
<a class="tab-link" href="/admin/db/stadiums">Стадионы</a>
|
||||||
|
<a class="tab-link" href="/admin/matches">Назад к матчам</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
226
templates/admin_db_player_edit.html
Normal file
226
templates/admin_db_player_edit.html
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Редактирование игрока</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115;
|
||||||
|
--panel: #171a21;
|
||||||
|
--panel-2: #1d222b;
|
||||||
|
--border: #2b3240;
|
||||||
|
--text: #e8ecf3;
|
||||||
|
--muted: #9aa4b2;
|
||||||
|
--accent: #4f8cff;
|
||||||
|
--accent-hover: #3e78e6;
|
||||||
|
--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 {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group.full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="date"] {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-meta {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
input[type="text"],
|
||||||
|
input[type="date"] {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-preview-box {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-preview-image {
|
||||||
|
max-width: 180px;
|
||||||
|
max-height: 180px;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="title">Редактирование игрока</div>
|
||||||
|
<div class="small-meta">ID: {{ player.id }}</div>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/db/players/{{ player.id }}/edit">
|
||||||
|
{% set positions = [
|
||||||
|
"",
|
||||||
|
"Вратарь",
|
||||||
|
"Защитник",
|
||||||
|
"Полузащитник",
|
||||||
|
"Нападающий",
|
||||||
|
] %}
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group full">
|
||||||
|
<label>Полное имя</label>
|
||||||
|
<input type="text" name="full_name" value="{{ player.full_name }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Имя</label>
|
||||||
|
<input type="text" name="first_name" value="{{ player.first_name }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Фамилия</label>
|
||||||
|
<input type="text" name="last_name" value="{{ player.last_name }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>External ID</label>
|
||||||
|
<input type="text" name="external_id" value="{{ player.external_id }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Амплуа</label>
|
||||||
|
<select name="position">
|
||||||
|
{% for pos in positions %}
|
||||||
|
<option value="{{ pos }}" {% if player.position==pos %}selected{% endif %}>
|
||||||
|
{{ pos if pos else "Не выбрано" }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Дата рождения</label>
|
||||||
|
<input type="date" name="birth_date" value="{{ player.birth_date or '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group full">
|
||||||
|
<label>Фото игрока</label>
|
||||||
|
<input type="text" name="photo" value="{{ player.photo }}">
|
||||||
|
{% if player.photo %}
|
||||||
|
<div class="media-preview-box">
|
||||||
|
<img src="{{ player.photo }}" alt="{{ player.full_name }}" class="media-preview-image">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group full">
|
||||||
|
<label>Видео игрока</label>
|
||||||
|
<input type="text" name="video" value="{{ player.video }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||||
|
<a href="/admin/db/players" class="btn btn-secondary">К списку</a>
|
||||||
|
<a href="/admin/db" class="btn btn-secondary">Разделы</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
186
templates/admin_db_players.html
Normal file
186
templates/admin_db_players.html
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Игроки</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115;
|
||||||
|
--panel: #171a21;
|
||||||
|
--panel-2: #1d222b;
|
||||||
|
--border: #2b3240;
|
||||||
|
--text: #e8ecf3;
|
||||||
|
--muted: #9aa4b2;
|
||||||
|
--accent: #4f8cff;
|
||||||
|
--accent-hover: #3e78e6;
|
||||||
|
--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 {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
min-width: 280px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.id-col {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-col {
|
||||||
|
width: 140px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-box {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="title">Игроки</div>
|
||||||
|
|
||||||
|
<form method="get" action="/admin/db/players" class="search-form">
|
||||||
|
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по ФИО или external_id">
|
||||||
|
<button type="submit" class="btn btn-primary">Найти</button>
|
||||||
|
<a href="/admin/db" class="btn btn-secondary">Назад</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if players %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="id-col">ID</th>
|
||||||
|
<th>ФИО</th>
|
||||||
|
<th>Имя</th>
|
||||||
|
<th>Фамилия</th>
|
||||||
|
<th>External ID</th>
|
||||||
|
<th>Амплуа</th>
|
||||||
|
<th class="action-col"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in players %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ p.id }}</td>
|
||||||
|
<td>{{ p.full_name }}</td>
|
||||||
|
<td>{{ p.first_name }}</td>
|
||||||
|
<td>{{ p.last_name }}</td>
|
||||||
|
<td>{{ p.external_id }}</td>
|
||||||
|
<td>{{ p.position }}</td>
|
||||||
|
<td class="action-col">
|
||||||
|
<a href="/admin/db/players/{{ p.id }}/edit" class="btn btn-secondary">Редактировать</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-box">Игроки не найдены.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
55
templates/admin_db_referee_edit.html
Normal file
55
templates/admin_db_referee_edit.html
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Редактирование судьи</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115; --panel: #171a21; --panel-2: #1d222b; --border: #2b3240;
|
||||||
|
--text: #e8ecf3; --muted: #9aa4b2; --accent: #4f8cff; --shadow: 0 10px 30px rgba(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 { max-width: 900px; margin: 0 auto; padding: 24px; }
|
||||||
|
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px; }
|
||||||
|
.title { font-size: 24px; font-weight: 700; margin-bottom: 18px; }
|
||||||
|
.small-meta { margin-bottom: 16px; color: var(--muted); font-size: 13px; }
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr; gap: 16px; }
|
||||||
|
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
label { color: var(--muted); font-size: 13px; font-weight: 600; }
|
||||||
|
input[type="text"] { min-height: 42px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--border); background: var(--panel-2); color: var(--text); }
|
||||||
|
.actions { margin-top: 20px; display: flex; gap: 10px; }
|
||||||
|
.btn { min-height: 42px; padding: 0 14px; border: none; border-radius: 10px; cursor: pointer; font-weight: 600; text-decoration: none; display: inline-flex; align-items: center; }
|
||||||
|
.btn-primary { background: var(--accent); color: white; }
|
||||||
|
.btn-secondary { background: transparent; color: var(--text); border: 1px solid var(--border); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="title">Редактирование судьи</div>
|
||||||
|
<div class="small-meta">ID: {{ referee.id }}</div>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/db/referees/{{ referee.id }}/edit">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>ФИО</label>
|
||||||
|
<input type="text" name="full_name" value="{{ referee.full_name }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>External ID</label>
|
||||||
|
<input type="text" name="external_id" value="{{ referee.external_id }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||||
|
<a href="/admin/db/referees" class="btn btn-secondary">К списку</a>
|
||||||
|
<a href="/admin/db" class="btn btn-secondary">Разделы</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
72
templates/admin_db_referees.html
Normal file
72
templates/admin_db_referees.html
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Судьи</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115; --panel: #171a21; --panel-2: #1d222b; --border: #2b3240;
|
||||||
|
--text: #e8ecf3; --muted: #9aa4b2; --accent: #4f8cff; --shadow: 0 10px 30px rgba(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 { max-width: 1400px; margin: 0 auto; padding: 24px; }
|
||||||
|
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px; }
|
||||||
|
.topbar { display: flex; gap: 12px; justify-content: space-between; align-items: center; margin-bottom: 18px; flex-wrap: wrap; }
|
||||||
|
.title { font-size: 24px; font-weight: 700; }
|
||||||
|
.search-form { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||||
|
input[type="text"] { min-width: 280px; min-height: 42px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--border); background: var(--panel-2); color: var(--text); }
|
||||||
|
.btn { min-height: 42px; padding: 0 14px; border: none; border-radius: 10px; cursor: pointer; font-weight: 600; text-decoration: none; display: inline-flex; align-items: center; }
|
||||||
|
.btn-primary { background: var(--accent); color: white; }
|
||||||
|
.btn-secondary { background: transparent; color: var(--text); border: 1px solid var(--border); }
|
||||||
|
table { width: 100%; border-collapse: collapse; background: var(--panel-2); border-radius: 12px; overflow: hidden; }
|
||||||
|
th, td { padding: 10px 12px; border-bottom: 1px solid var(--border); text-align: left; font-size: 14px; }
|
||||||
|
th { color: var(--muted); font-weight: 600; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
.id-col { width: 70px; }
|
||||||
|
.action-col { width: 140px; text-align: right; }
|
||||||
|
.empty-box { padding: 18px; border-radius: 12px; background: var(--panel-2); color: var(--muted); border: 1px dashed var(--border); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="title">Судьи</div>
|
||||||
|
<form method="get" action="/admin/db/referees" class="search-form">
|
||||||
|
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по ФИО или external_id">
|
||||||
|
<button type="submit" class="btn btn-primary">Найти</button>
|
||||||
|
<a href="/admin/db" class="btn btn-secondary">Назад</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if referees %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="id-col">ID</th>
|
||||||
|
<th>ФИО</th>
|
||||||
|
<th>External ID</th>
|
||||||
|
<th class="action-col"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in referees %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.id }}</td>
|
||||||
|
<td>{{ r.full_name }}</td>
|
||||||
|
<td>{{ r.external_id }}</td>
|
||||||
|
<td class="action-col">
|
||||||
|
<a href="/admin/db/referees/{{ r.id }}/edit" class="btn btn-secondary">Редактировать</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-box">Судьи не найдены.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
163
templates/admin_db_stadium_edit.html
Normal file
163
templates/admin_db_stadium_edit.html
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Редактирование стадиона</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115;
|
||||||
|
--panel: #171a21;
|
||||||
|
--panel-2: #1d222b;
|
||||||
|
--border: #2b3240;
|
||||||
|
--text: #e8ecf3;
|
||||||
|
--muted: #9aa4b2;
|
||||||
|
--accent: #4f8cff;
|
||||||
|
--shadow: 0 10px 30px rgba(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 {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-meta {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group.full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="title">Редактирование стадиона</div>
|
||||||
|
<div class="small-meta">ID: {{ stadium.id }}</div>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/db/stadiums/{{ stadium.id }}/edit">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group full">
|
||||||
|
<label>Старое название стадиона</label>
|
||||||
|
<input type="text" value="{{ stadium.name }}" readonly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group full">
|
||||||
|
<label>Название для графики</label>
|
||||||
|
<input type="text" name="stadium_gfx" value="{{ stadium.stadium_gfx }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Город</label>
|
||||||
|
<input type="text" name="city" value="{{ stadium.city }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>External ID</label>
|
||||||
|
<input type="text" name="external_id" value="{{ stadium.external_id }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group full">
|
||||||
|
<label>Адрес</label>
|
||||||
|
<input type="text" name="address" value="{{ stadium.address }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||||
|
<a href="/admin/db/stadiums" class="btn btn-secondary">К списку</a>
|
||||||
|
<a href="/admin/db" class="btn btn-secondary">Разделы</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
78
templates/admin_db_stadiums.html
Normal file
78
templates/admin_db_stadiums.html
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Стадионы</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115; --panel: #171a21; --panel-2: #1d222b; --border: #2b3240;
|
||||||
|
--text: #e8ecf3; --muted: #9aa4b2; --accent: #4f8cff; --shadow: 0 10px 30px rgba(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 { max-width: 1400px; margin: 0 auto; padding: 24px; }
|
||||||
|
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px; }
|
||||||
|
.topbar { display: flex; gap: 12px; justify-content: space-between; align-items: center; margin-bottom: 18px; flex-wrap: wrap; }
|
||||||
|
.title { font-size: 24px; font-weight: 700; }
|
||||||
|
.search-form { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||||
|
input[type="text"] { min-width: 280px; min-height: 42px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--border); background: var(--panel-2); color: var(--text); }
|
||||||
|
.btn { min-height: 42px; padding: 0 14px; border: none; border-radius: 10px; cursor: pointer; font-weight: 600; text-decoration: none; display: inline-flex; align-items: center; }
|
||||||
|
.btn-primary { background: var(--accent); color: white; }
|
||||||
|
.btn-secondary { background: transparent; color: var(--text); border: 1px solid var(--border); }
|
||||||
|
table { width: 100%; border-collapse: collapse; background: var(--panel-2); border-radius: 12px; overflow: hidden; }
|
||||||
|
th, td { padding: 10px 12px; border-bottom: 1px solid var(--border); text-align: left; font-size: 14px; }
|
||||||
|
th { color: var(--muted); font-weight: 600; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
.id-col { width: 70px; }
|
||||||
|
.action-col { width: 140px; text-align: right; }
|
||||||
|
.empty-box { padding: 18px; border-radius: 12px; background: var(--panel-2); color: var(--muted); border: 1px dashed var(--border); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="title">Стадионы</div>
|
||||||
|
<form method="get" action="/admin/db/stadiums" class="search-form">
|
||||||
|
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по названию, городу, адресу, external_id">
|
||||||
|
<button type="submit" class="btn btn-primary">Найти</button>
|
||||||
|
<a href="/admin/db" class="btn btn-secondary">Назад</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if stadiums %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="id-col">ID</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Название GFX</th>
|
||||||
|
<th>Город</th>
|
||||||
|
<th>Адрес</th>
|
||||||
|
<th>External ID</th>
|
||||||
|
<th class="action-col"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in stadiums %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ s.id }}</td>
|
||||||
|
<td>{{ s.name }}</td>
|
||||||
|
<td>{{ s.stadium_gfx }}</td>
|
||||||
|
<td>{{ s.city }}</td>
|
||||||
|
<td>{{ s.address }}</td>
|
||||||
|
<td>{{ s.external_id }}</td>
|
||||||
|
<td class="action-col">
|
||||||
|
<a href="/admin/db/stadiums/{{ s.id }}/edit" class="btn btn-secondary">Редактировать</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-box">Стадионы не найдены.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
103
templates/admin_db_team_edit.html
Normal file
103
templates/admin_db_team_edit.html
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Редактирование команды</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115; --panel: #171a21; --panel-2: #1d222b; --border: #2b3240;
|
||||||
|
--text: #e8ecf3; --muted: #9aa4b2; --accent: #4f8cff; --shadow: 0 10px 30px rgba(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 { max-width: 1000px; margin: 0 auto; padding: 24px; }
|
||||||
|
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px; }
|
||||||
|
.title { font-size: 24px; font-weight: 700; margin-bottom: 18px; }
|
||||||
|
.small-meta { margin-bottom: 16px; color: var(--muted); font-size: 13px; }
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
|
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.form-group.full { grid-column: 1 / -1; }
|
||||||
|
label { color: var(--muted); font-size: 13px; font-weight: 600; }
|
||||||
|
input[type="text"] {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.logo-box {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
.logo-preview {
|
||||||
|
max-width: 120px;
|
||||||
|
max-height: 120px;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.actions { margin-top: 20px; display: flex; gap: 10px; }
|
||||||
|
.btn { min-height: 42px; padding: 0 14px; border: none; border-radius: 10px; cursor: pointer; font-weight: 600; text-decoration: none; display: inline-flex; align-items: center; }
|
||||||
|
.btn-primary { background: var(--accent); color: white; }
|
||||||
|
.btn-secondary { background: transparent; color: var(--text); border: 1px solid var(--border); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="title">Редактирование команды</div>
|
||||||
|
<div class="small-meta">ID: {{ team.id }}</div>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/db/teams/{{ team.id }}/edit">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Название</label>
|
||||||
|
<input type="text" name="name" value="{{ team.name }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>External ID</label>
|
||||||
|
<input type="text" name="external_id" value="{{ team.external_id }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group full">
|
||||||
|
<label>Полное название</label>
|
||||||
|
<input type="text" name="full_name" value="{{ team.full_name }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Город</label>
|
||||||
|
<input type="text" name="city" value="{{ team.city }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Трёхбуквенное название</label>
|
||||||
|
<input type="text" name="short_name_3" value="{{ team.short_name_3 }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group full">
|
||||||
|
<label>Путь к логотипу</label>
|
||||||
|
<input type="text" name="logo_path" value="{{ team.logo_path }}">
|
||||||
|
<div class="logo-box">
|
||||||
|
{% if team.logo_path %}
|
||||||
|
<img src="{{ team.logo_path }}" alt="{{ team.name }}" class="logo-preview">
|
||||||
|
{% else %}
|
||||||
|
Логотип не задан.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||||
|
<a href="/admin/db/teams" class="btn btn-secondary">К списку</a>
|
||||||
|
<a href="/admin/db" class="btn btn-secondary">Разделы</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
93
templates/admin_db_teams.html
Normal file
93
templates/admin_db_teams.html
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Команды</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115; --panel: #171a21; --panel-2: #1d222b; --border: #2b3240;
|
||||||
|
--text: #e8ecf3; --muted: #9aa4b2; --accent: #4f8cff; --shadow: 0 10px 30px rgba(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 { max-width: 1500px; margin: 0 auto; padding: 24px; }
|
||||||
|
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px; }
|
||||||
|
.topbar { display: flex; gap: 12px; justify-content: space-between; align-items: center; margin-bottom: 18px; flex-wrap: wrap; }
|
||||||
|
.title { font-size: 24px; font-weight: 700; }
|
||||||
|
.search-form { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||||
|
input[type="text"] { min-width: 280px; min-height: 42px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--border); background: var(--panel-2); color: var(--text); }
|
||||||
|
.btn { min-height: 42px; padding: 0 14px; border: none; border-radius: 10px; cursor: pointer; font-weight: 600; text-decoration: none; display: inline-flex; align-items: center; }
|
||||||
|
.btn-primary { background: var(--accent); color: white; }
|
||||||
|
.btn-secondary { background: transparent; color: var(--text); border: 1px solid var(--border); }
|
||||||
|
table { width: 100%; border-collapse: collapse; background: var(--panel-2); border-radius: 12px; overflow: hidden; }
|
||||||
|
th, td { padding: 10px 12px; border-bottom: 1px solid var(--border); text-align: left; font-size: 14px; }
|
||||||
|
th { color: var(--muted); font-weight: 600; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
.id-col { width: 70px; }
|
||||||
|
.action-col { width: 140px; text-align: right; }
|
||||||
|
.logo-preview {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
object-fit: contain;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.empty-box { padding: 18px; border-radius: 12px; background: var(--panel-2); color: var(--muted); border: 1px dashed var(--border); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="title">Команды</div>
|
||||||
|
<form method="get" action="/admin/db/teams" class="search-form">
|
||||||
|
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по названию, short, external_id">
|
||||||
|
<button type="submit" class="btn btn-primary">Найти</button>
|
||||||
|
<a href="/admin/db" class="btn btn-secondary">Назад</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if teams %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="id-col">ID</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Полное название</th>
|
||||||
|
<th>Город</th>
|
||||||
|
<th>3 буквы</th>
|
||||||
|
<th>Логотип</th>
|
||||||
|
<th>External ID</th>
|
||||||
|
<th class="action-col"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in teams %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ t.id }}</td>
|
||||||
|
<td>{{ t.name }}</td>
|
||||||
|
<td>{{ t.full_name or "—" }}</td>
|
||||||
|
<td>{{ t.city or "—" }}</td>
|
||||||
|
<td>{{ t.short_name_3 or "—" }}</td>
|
||||||
|
<td>
|
||||||
|
{% if t.logo_path %}
|
||||||
|
<img src="{{ t.logo_path }}" alt="{{ t.name }}" class="logo-preview">
|
||||||
|
{% else %}
|
||||||
|
—
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ t.external_id }}</td>
|
||||||
|
<td class="action-col">
|
||||||
|
<a href="/admin/db/teams/{{ t.id }}/edit" class="btn btn-secondary">Редактировать</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-box">Команды не найдены.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
110
templates/login.html
Normal file
110
templates/login.html
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>WFL Admin Login</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115;
|
||||||
|
--panel: #171a21;
|
||||||
|
--panel-2: #1d222b;
|
||||||
|
--border: #2b3240;
|
||||||
|
--text: #e8ecf3;
|
||||||
|
--muted: #9aa4b2;
|
||||||
|
--accent: #4f8cff;
|
||||||
|
--accent-hover: #3e78e6;
|
||||||
|
--danger-bg: rgba(220, 53, 69, 0.14);
|
||||||
|
--danger-border: rgba(220, 53, 69, 0.45);
|
||||||
|
--info-bg: rgba(79, 140, 255, 0.14);
|
||||||
|
--info-border: rgba(79, 140, 255, 0.4);
|
||||||
|
--shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||||
|
--radius: 16px;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
h1 { margin: 0 0 8px; font-size: 28px; }
|
||||||
|
.subtitle { color: var(--muted); margin-bottom: 22px; }
|
||||||
|
.alert {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.alert-error { background: var(--danger-bg); border-color: var(--danger-border); }
|
||||||
|
.alert-info { background: var(--info-bg); border-color: var(--info-border); }
|
||||||
|
label { display: block; font-size: 14px; margin-bottom: 8px; }
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(79, 140, 255, 0.15);
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 46px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover { background: var(--accent-hover); }
|
||||||
|
.hint { color: var(--muted); font-size: 13px; margin-top: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>WFL Admin</h1>
|
||||||
|
<div class="subtitle">Вход в административный интерфейс</div>
|
||||||
|
|
||||||
|
{% if message %}
|
||||||
|
<div class="alert alert-info">{{ message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<label for="username">Логин</label>
|
||||||
|
<input id="username" name="username" type="text" autocomplete="username" required>
|
||||||
|
|
||||||
|
<label for="password">Пароль</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||||
|
|
||||||
|
<button type="submit">Войти</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="hint">Сессия автоматически завершится после 2 часов без активности.</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2908
templates/match_workspace copy.html
Normal file
2908
templates/match_workspace copy.html
Normal file
File diff suppressed because it is too large
Load Diff
1180
templates/match_workspace.html
Normal file
1180
templates/match_workspace.html
Normal file
File diff suppressed because it is too large
Load Diff
554
templates/matches.html
Normal file
554
templates/matches.html
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0">
|
||||||
|
<meta http-equiv="Pragma" content="no-cache">
|
||||||
|
<meta http-equiv="Expires" content="0">
|
||||||
|
<title>Выбор матча</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115;
|
||||||
|
--panel: #171a21;
|
||||||
|
--panel-2: #1d222b;
|
||||||
|
--border: #2b3240;
|
||||||
|
--text: #e8ecf3;
|
||||||
|
--muted: #9aa4b2;
|
||||||
|
--accent: #4f8cff;
|
||||||
|
--accent-hover: #3e78e6;
|
||||||
|
--success-bg: rgba(46, 160, 67, 0.16);
|
||||||
|
--success-border: rgba(46, 160, 67, 0.4);
|
||||||
|
--live-bg: rgba(245, 158, 11, 0.16);
|
||||||
|
--live-border: rgba(245, 158, 11, 0.4);
|
||||||
|
--scheduled-bg: transparent;
|
||||||
|
--input-bg: #11151c;
|
||||||
|
--shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||||
|
--radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 28px;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: end;
|
||||||
|
padding: 18px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
transform: scale(1.15);
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
input[type="text"] {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus,
|
||||||
|
input[type="text"]:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(79, 140, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #8ab4ff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
padding: 14px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th:first-child {
|
||||||
|
border-top-left-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th:last-child {
|
||||||
|
border-top-right-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody td {
|
||||||
|
padding: 14px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr.finished {
|
||||||
|
background: var(--success-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr.finished:hover {
|
||||||
|
background: rgba(46, 160, 67, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr.live {
|
||||||
|
background: var(--live-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr.live:hover {
|
||||||
|
background: rgba(245, 158, 11, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-cell {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-finished {
|
||||||
|
background: var(--success-bg);
|
||||||
|
border-color: var(--success-border);
|
||||||
|
color: #8ee29b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-live {
|
||||||
|
background: var(--live-bg);
|
||||||
|
border-color: var(--live-border);
|
||||||
|
color: #ffd27a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-scheduled {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
color: #c8d1dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-input {
|
||||||
|
width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, transform 0.05s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 28px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.id-chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.page-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: rgba(255,255,255,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-banner {
|
||||||
|
display: none;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 193, 7, 0.35);
|
||||||
|
background: rgba(255, 193, 7, 0.08);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>Выбор матча</h1>
|
||||||
|
<div class="subtitle">Рабочая панель оператора трансляции</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-actions">
|
||||||
|
<form method="post" action="/logout" class="logout-form">
|
||||||
|
<button type="submit" class="btn btn-secondary">Выйти</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="sessionStatusBanner" class="session-banner"></div>
|
||||||
|
|
||||||
|
<form method="get" action="/admin/matches" class="filters panel" id="filters-form">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Фильтр по дате</label>
|
||||||
|
<div class="check-line">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="today_only"
|
||||||
|
value="true"
|
||||||
|
{% if today_only %}checked{% endif %}
|
||||||
|
onchange="document.getElementById('filters-form').submit();"
|
||||||
|
>
|
||||||
|
<span>Только сегодня</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="tour">Тур</label>
|
||||||
|
<select
|
||||||
|
name="tour"
|
||||||
|
id="tour"
|
||||||
|
onchange="document.getElementById('filters-form').submit();"
|
||||||
|
>
|
||||||
|
<option value="">Все туры</option>
|
||||||
|
{% for t in tours %}
|
||||||
|
<option value="{{ t }}" {% if selected_tour == t %}selected{% endif %}>{{ t }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label> </label>
|
||||||
|
<div class="check-line">
|
||||||
|
<a href="/admin/matches">Сбросить фильтры</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="panel table-wrap">
|
||||||
|
{% if matches %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Матч</th>
|
||||||
|
<th>Тур</th>
|
||||||
|
<th>Сезон</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Оператор</th>
|
||||||
|
<th>Действие</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in matches %}
|
||||||
|
{% set status = (row[3] or 'scheduled') %}
|
||||||
|
<tr class="{{ status }}">
|
||||||
|
<td><span class="id-chip">{{ row[0] }}</span></td>
|
||||||
|
<td>
|
||||||
|
<div>{{ row[2] or "" }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="match-cell">
|
||||||
|
{{ row[6] }} — {{ row[7] }}
|
||||||
|
</td>
|
||||||
|
<td>{{ row[4] or "" }}</td>
|
||||||
|
<td>{{ row[5] or "" }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge status-{{ status }}">
|
||||||
|
{{ status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td colspan="2">
|
||||||
|
<form method="get" action="/admin/matches/{{ row[0] }}/select" class="select-form js-select-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="operator_name"
|
||||||
|
placeholder="Имя оператора"
|
||||||
|
class="operator-input"
|
||||||
|
>
|
||||||
|
<button type="submit" class="btn">Выбрать матч</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">Матчи не найдены.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
async function handleSelectSubmit(event) {
|
||||||
|
const form = event.target;
|
||||||
|
if (!form.classList.contains("js-select-form")) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const submitButton = form.querySelector('button[type="submit"]');
|
||||||
|
const operatorInput = form.querySelector('input[name="operator_name"]');
|
||||||
|
const operatorName = operatorInput ? operatorInput.value : "";
|
||||||
|
|
||||||
|
if (submitButton) {
|
||||||
|
submitButton.disabled = true;
|
||||||
|
submitButton.textContent = "Подготовка...";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(form.action, window.location.origin);
|
||||||
|
url.searchParams.set("operator_name", operatorName);
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"Accept": "application/json"
|
||||||
|
},
|
||||||
|
credentials: "same-origin"
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
throw new Error(data.error || data.vmix_error || "Не удалось выбрать матч");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.download_url) {
|
||||||
|
const downloadResponse = await fetch(data.download_url, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "same-origin"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!downloadResponse.ok) {
|
||||||
|
let errText = "Не удалось скачать vMix проект";
|
||||||
|
try {
|
||||||
|
const errJson = await downloadResponse.json();
|
||||||
|
errText = errJson.error || errText;
|
||||||
|
} catch (_) {}
|
||||||
|
throw new Error(errText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await downloadResponse.blob();
|
||||||
|
|
||||||
|
let filename = "project.vmix";
|
||||||
|
const disposition = downloadResponse.headers.get("Content-Disposition");
|
||||||
|
|
||||||
|
if (disposition) {
|
||||||
|
let match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
|
||||||
|
if (match && match[1]) {
|
||||||
|
filename = decodeURIComponent(match[1]);
|
||||||
|
} else {
|
||||||
|
match = disposition.match(/filename="([^"]+)"/i);
|
||||||
|
if (match && match[1]) {
|
||||||
|
filename = match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = blobUrl;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = data.session_url;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || "Ошибка при подготовке матча");
|
||||||
|
if (submitButton) {
|
||||||
|
submitButton.disabled = false;
|
||||||
|
submitButton.textContent = "Выбрать матч";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("submit", handleSelectSubmit);
|
||||||
|
})();
|
||||||
|
(function () {
|
||||||
|
const LOGIN_URL = "/login";
|
||||||
|
const IDLE_LIMIT_MS = 2 * 60 * 60 * 1000;
|
||||||
|
const WARNING_BEFORE_MS = 60 * 1000;
|
||||||
|
const ACTIVITY_EVENTS = ["mousemove", "mousedown", "keydown", "scroll", "touchstart", "click"];
|
||||||
|
const banner = document.getElementById("sessionStatusBanner");
|
||||||
|
let lastActivityAt = Date.now();
|
||||||
|
let warned = false;
|
||||||
|
let redirected = false;
|
||||||
|
|
||||||
|
function showBanner(message) {
|
||||||
|
if (!banner) return;
|
||||||
|
banner.textContent = message;
|
||||||
|
banner.style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
function markActivity() {
|
||||||
|
lastActivityAt = Date.now();
|
||||||
|
warned = false;
|
||||||
|
if (banner) banner.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectToLogin(reason) {
|
||||||
|
if (redirected) return;
|
||||||
|
redirected = true;
|
||||||
|
const url = new URL(LOGIN_URL, window.location.origin);
|
||||||
|
if (reason) url.searchParams.set("reason", reason);
|
||||||
|
window.location.replace(url.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
ACTIVITY_EVENTS.forEach((eventName) => {
|
||||||
|
window.addEventListener(eventName, markActivity, { passive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("submit", markActivity, true);
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const idleFor = Date.now() - lastActivityAt;
|
||||||
|
const leftMs = IDLE_LIMIT_MS - idleFor;
|
||||||
|
|
||||||
|
if (!warned && leftMs <= WARNING_BEFORE_MS && leftMs > 0) {
|
||||||
|
warned = true;
|
||||||
|
const leftMinutes = Math.ceil(leftMs / 60000);
|
||||||
|
showBanner(`До выхода из системы из-за неактивности осталось около ${leftMinutes} мин.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idleFor >= IDLE_LIMIT_MS) {
|
||||||
|
showBanner("Сессия завершена из-за 2 часов неактивности.");
|
||||||
|
redirectToLogin("idle");
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
0
vmix/__init__.py
Normal file
0
vmix/__init__.py
Normal file
BIN
vmix/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
vmix/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user