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