first commit

This commit is contained in:
2026-04-20 11:56:11 +03:00
commit 8d80fadb56
103 changed files with 19756 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/__pycache__
*.env

45
README_AUTH.md Normal file
View 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
View 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())

1576
app.py Normal file

File diff suppressed because it is too large Load Diff

37
db.py Normal file
View 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
View 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
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

226
parsers/parser_game.py Normal file
View 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
View 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
View 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()

View 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
View 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
View File

Binary file not shown.

View 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()

View 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()

View 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()

View 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)

View 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()

View 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()

View 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

View 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()

View 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()

View 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()

View 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()

View 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

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

Binary file not shown.

Binary file not shown.

59
scripts/create_admin.py Normal file
View 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
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

155
services/auth_service.py Normal file
View 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
View 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

View 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,
)

View 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"),
)

View 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
View 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),
)

View 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
View 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

File diff suppressed because it is too large Load Diff

2177
static/styles.css Normal file

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

554
templates/matches.html Normal file
View 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>&nbsp;</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
View File

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More