Compare commits

..

92 Commits

Author SHA1 Message Date
c759f34f0f обновил еще капитонов 2026-08-10 10:49:32 +03:00
b29fc06a4f поправил капитанов на расстановки 2026-08-10 10:30:08 +03:00
adea3a8bd6 добавил возможность администраторам создавать игроков, тренеров и судей вручную 2026-08-04 10:49:03 +03:00
2c2d9b9a7c исправлено передача синих логотипов для Зенит/Динамо и логотипы каналов не должны отображаться, когда у матча уже есть счет 2026-08-04 10:20:54 +03:00
e17c287d65 update 6 2026-07-29 11:52:22 +03:00
60ec71c73d update 5 2026-07-29 11:03:27 +03:00
77adc31a1a апдейт 4 2026-07-29 11:03:12 +03:00
8e38505f69 update 3.0 2026-07-29 10:29:35 +03:00
504cf77312 апдейт 2.0 2026-07-29 10:27:19 +03:00
00f8533236 обновил ссылки на tigra 2026-07-29 10:22:42 +03:00
87cd044c3f переименовал название картинки 2026-07-24 10:20:25 +03:00
6fe4eca0ba добавил картинку для info 2026-07-24 10:09:43 +03:00
c646691142 поправлена отдача данных в vMix с помощью agent.exe 2026-07-21 11:07:23 +03:00
2b3c882d25 замена url для текстового датасорса 2026-07-04 10:15:55 +03:00
c724c7e965 обновление 2 2026-07-03 14:59:23 +03:00
d689b853dc обновил инфо для Кубка России 2026-07-03 14:58:40 +03:00
3ec7f57d46 загрузил картинку по пенальти для Info 2026-07-03 14:50:25 +03:00
aeafdc9887 новый файл для скрипта создал 2026-07-02 17:42:48 +03:00
a215ba526b удилил архив 2026-07-02 17:00:31 +03:00
e7b215af5e все остальные апдейты на Кубок России 2026-07-02 17:00:15 +03:00
03a68ee8ca логотипы каналов, тест 2 2026-07-02 14:47:37 +03:00
e32fdcb74e поправил логотипы каналов для Кубка России 2026-07-02 14:09:29 +03:00
5b056550ed добавил экранирование \ для логотипов команд 2026-07-02 13:37:41 +03:00
1303b3b562 поправил вывод названия тура для кубка россии 2026-07-02 13:37:13 +03:00
61861085f8 поправил логотипы для заявочного титра, тест 1 2026-07-02 13:06:30 +03:00
e98d014243 подгрузка данных для кубка россии. тест 2 2026-07-02 12:56:24 +03:00
57907fd86b обновление для Кубка России
переделаны все парсеры на ссылки из базы
2026-07-02 12:46:29 +03:00
b0fe3ad5a4 Обновил добавление обновлений 2026-06-22 18:25:04 +03:00
24c10ea5ca обновил Обновления 2026-06-22 18:20:25 +03:00
425d1f981d поправка 2 2026-06-22 18:15:06 +03:00
3867579508 поправил инфо на сегодняший день 2026-06-22 18:12:56 +03:00
4580ff1d3e добавил при выборе тура отключение галочки Завершенные матчи 2026-06-22 18:04:54 +03:00
55cb965d9f 1. обновил инструкцию
2. добавил в админку Добавление обновлений, чтобы не через код
2026-06-22 17:58:20 +03:00
3e6f767a12 добавил в веб-интерфейс отображение рядом с номером игра фото, если фотографии у игрока нет 2026-06-22 15:52:47 +03:00
f63061a06d 1. добавил в админку сортировку игроков и Фото (если галочка стоит, то путь к фото генерируется автоматически)
2. фото обновляются прям в json
2026-06-22 15:31:07 +03:00
cd370cf04f условие канала, фикс 1 2026-06-18 11:26:44 +03:00
e314aa0ad2 поправил условие для отображение каналов 2026-06-18 11:22:57 +03:00
9df79f77df обновил инфо 4 2026-05-29 11:58:04 +03:00
a9d580795d обновил инфо 3 2026-05-29 11:55:27 +03:00
d34776a2d6 обновил инфо 2 2026-05-29 11:55:01 +03:00
59df51f6be обновил информацию 2026-05-29 11:41:14 +03:00
91eee7c08c дополнил инфо 2026-05-25 15:22:27 +03:00
81556cfd43 поправил инфо 2026-05-25 15:15:52 +03:00
96b257cff1 1. добавлена кнопка ? (переход на info)
2. добавлен пункт Обновления в Инфо
2026-05-25 15:13:14 +03:00
2de22915cf добавил кнопку инфо 2026-05-25 14:44:03 +03:00
d8127dccaf добавил защиту для отчистки событий матча, css стиль темный 2026-05-25 14:38:37 +03:00
4336ab19df добавил защиту для отчистки событий матча, тест 1 2026-05-25 14:36:36 +03:00
495121a8b7 синхронизация событий со всеми браузерами, тест 1 2026-05-25 14:32:27 +03:00
998774bf1c синхронизация текущего времени матча во всех браузерах, тест 1 2026-05-25 14:22:47 +03:00
64cd3891ea изменение текущего времени, тест 4 2026-05-25 14:13:06 +03:00
3ff7c95018 изменение текущего времени, тест 3 2026-05-25 14:08:43 +03:00
9db889ca4b изменение текущего времени, тест 2 2026-05-25 14:02:32 +03:00
508d209377 изменение текущего времени, тест 1 2026-05-25 13:49:40 +03:00
3738538d23 возможно нашел место, где неправильно формируется отправка данных в vMix (Фамилия Имя) 2026-05-25 12:55:24 +03:00
21e37a1097 мини защита для парсинга игры 2026-05-25 12:36:18 +03:00
801936518a мини защита от сброса данных в базе данных, если данных нет 2026-05-25 12:34:29 +03:00
18bac0d12d тест5 2026-05-25 12:26:42 +03:00
6c449ef7df тест4 2026-05-25 12:22:02 +03:00
23ecbc0f57 вроде как откатился на ошибки парсеров без веб интерфейс 2026-05-25 12:13:31 +03:00
3ff5d42045 Revert "каждый гол пишется отдельной строкой"
This reverts commit 50c4755b38.
2026-05-25 12:06:29 +03:00
28d7d01fdf тест 3 2026-05-25 11:42:51 +03:00
57f783e70d описание недоступности сайта, тест 2 2026-05-25 11:34:08 +03:00
9bbafe7c35 описание недоступности сайта 2026-05-25 11:28:41 +03:00
50c4755b38 каждый гол пишется отдельной строкой 2026-05-25 11:22:15 +03:00
04b1b8796e убрал точку из амплуа тренеров 2026-05-25 10:56:50 +03:00
2df9c97608 фикс5 2026-05-16 15:03:56 +03:00
685fcdabef фикс4 2026-05-16 13:52:53 +03:00
c970a9d755 фикс3 2026-05-16 13:16:08 +03:00
5d00fdeaf5 фикс2 2026-05-16 13:14:18 +03:00
214ab84fc6 фикс 1 2026-05-16 12:40:14 +03:00
2e3aba7c5a поправил ссесию на 4 часа 2026-05-15 15:36:16 +03:00
121095882c поправил вывод доп времени в голах на титр нижний счет 2026-05-15 15:09:28 +03:00
e671859f71 поправил отображение каналов на расписании тура, когда матч live, каналы не пишутся 2026-05-15 15:02:20 +03:00
79c679f35d тест 9 2026-05-15 14:35:11 +03:00
4e6806c553 тест 8 2026-05-15 14:28:14 +03:00
a8947556a5 тест 7 2026-05-15 14:23:17 +03:00
d8a31442c9 тест 6 2026-05-15 14:19:26 +03:00
761f46a932 в json выводиться канал 2026-05-15 14:08:08 +03:00
5110c402e8 логотипы команд в расписании тура 2026-05-15 14:05:05 +03:00
eda35ade7e добавил + в доп время 2026-05-15 13:55:52 +03:00
e3b612433c добавил отправку в vMix дополнительного времени 2026-05-15 13:51:07 +03:00
d7f13dcfe2 тест 5 2026-05-15 13:42:19 +03:00
007939a05e тест 4 2026-05-15 13:29:38 +03:00
48f752d512 тест 3 2026-05-15 13:23:21 +03:00
13b859a9fa поправил сортировку событий в отображении на веб странице 2026-05-15 13:15:20 +03:00
4f8e2f9b92 тест2 2026-05-15 13:10:41 +03:00
7f07f982d8 редактирование времени события. тест 2026-05-15 12:58:19 +03:00
a74aab3acd поправил ошибку для css и js 2026-05-15 12:52:52 +03:00
cfb746ebec автообновлении версии js и css 2026-05-15 12:50:18 +03:00
69e40984f2 убрал старые функции и сделал автообновление версий js и css 2026-05-15 12:45:16 +03:00
636a9a5e59 убрал дублированные функции 2026-05-15 12:22:05 +03:00
4f1dc64d5d поправил сброс инпутов на расстановках (убрал дублирование команд) 2026-05-15 12:00:03 +03:00
57 changed files with 10009 additions and 960 deletions

View File

@@ -13,8 +13,8 @@ import requests
import websockets import websockets
VMIX_API = "http://127.0.0.1:8088/api" VMIX_API = "http://127.0.0.1:8088/api"
WS_BASE = "wss://wfl.tvstart.ru/ws/vmix-client" # WS_BASE = "wss://wfl.tvstart.ru/ws/vmix-client"
# WS_BASE = "ws://127.0.0.1:8000/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]}" CLIENT_ID = f"vmix-{socket.gethostname().lower()}-{uuid.uuid4().hex[:6]}"
POLL_INTERVAL = 3 POLL_INTERVAL = 3

1497
app.py

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,9 @@ import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from services.game_service import sync_match_page from services.game_service import sync_match_page
from source_config import DATA_MATCH_BASE_URL, match_url
BASE_MATCH_URL = "https://wfl.rfs.ru/match/" BASE_MATCH_URL = DATA_MATCH_BASE_URL
def fetch_html(url: str) -> str: def fetch_html(url: str) -> str:
@@ -20,12 +21,37 @@ def extract_player_id_from_href(href: str) -> str:
def detect_captain(item) -> bool: def detect_captain(item) -> bool:
"""Определяет капитана по текстовой метке или отдельному HTML-маркеру сайта.
На странице протокола капитан может быть обозначен как ``(К)`` / ``(C)``,
а в некоторых версиях вёрстки — отдельным элементом/иконкой с captain в
class, title, aria-label или data-атрибуте. Если сайт вообще не указал
капитана, функция корректно возвращает False.
"""
if not item: if not item:
return False return False
text = item.get_text(" ", strip=True).lower() text = item.get_text(" ", strip=True).lower().replace("ё", "е")
if any(marker in text for marker in ("(к)", "(c)", "капитан", "captain")):
return True
return any(x in text for x in ["(к)", "(c)"]) # Поддержка отдельной иконки/элемента капитана, если буква не входит
# в видимый текст строки игрока. Не привязываемся к одной версии вёрстки.
for node in [item, *item.find_all(True)]:
classes = " ".join(node.get("class", [])).lower()
attrs_text = " ".join(
str(node.get(attr) or "")
for attr in ("title", "aria-label", "data-title", "data-role", "data-captain")
).lower().replace("ё", "е")
if "captain" in classes or "капитан" in classes:
return True
if "captain" in attrs_text or "капитан" in attrs_text:
return True
if str(node.get("data-captain") or "").strip().lower() in {"1", "true", "yes"}:
return True
return False
def parse_starting_teams(soup: BeautifulSoup) -> tuple[list[dict], list[dict]]: def parse_starting_teams(soup: BeautifulSoup) -> tuple[list[dict], list[dict]]:
home_starting = [] home_starting = []
@@ -197,30 +223,31 @@ def parse_game_page(html: str) -> dict:
def run_parser_game(match_external_id: str) -> None: def run_parser_game(match_external_id: str) -> None:
url = f"{BASE_MATCH_URL}{str(match_external_id).strip()}" url = match_url(match_external_id)
html = fetch_html(url) html = fetch_html(url)
data = parse_game_page(html) data = parse_game_page(html)
if data:
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"],
)
sync_match_page( print(
match_external_id=str(match_external_id).strip(), f"[parser_game] match={match_external_id} "
home_starting=data["home_starting"], f"home_starting={len(data['home_starting'])} "
away_starting=data["away_starting"], f"away_starting={len(data['away_starting'])} "
home_bench=data["home_bench"], f"home_bench={len(data['home_bench'])} "
away_bench=data["away_bench"], f"away_bench={len(data['away_bench'])} "
home_coaches=data["home_coaches"], f"home_coaches={len(data['home_coaches'])} "
away_coaches=data["away_coaches"], f"away_coaches={len(data['away_coaches'])} "
referees=data["referees"], f"referees={len(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'])}"
)

View File

@@ -3,10 +3,9 @@ from bs4 import BeautifulSoup
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from services.players_service import sync_team_roster from services.players_service import sync_team_roster
from parsers.parser_sources import get_parser_source, source_absolute_url
URL_TEAMS = "https://wfl.rfs.ru/tournament/1061879/teams"
AMPLUA_FULL = { AMPLUA_FULL = {
"Пз.": "Полузащитник", "Пз.": "Полузащитник",
"Вр.": "Вратарь", "Вр.": "Вратарь",
@@ -23,19 +22,26 @@ def fetch_html(url: str) -> str:
return r.text return r.text
def get_links(html: str) -> list[dict]: def get_links(html: str, source: dict) -> list[dict]:
soup = BeautifulSoup(html, "html.parser") soup = BeautifulSoup(html, "html.parser")
links: list[dict] = [] links: list[dict] = []
items = soup.find("ul", class_="teams__list").find_all("li") teams_list = soup.find("ul", class_="teams__list")
for i in items: if not teams_list:
href = i.find("a", class_="teams__link").get("href") return links
team_external_id = href.split("team_id=")[-1].strip()
items = teams_list.find_all("li")
for item in items:
link_el = item.find("a", class_="teams__link")
href = link_el.get("href") if link_el else ""
if not href:
continue
team_external_id = href.split("team_id=")[-1].strip() if "team_id=" in href else ""
links.append( links.append(
{ {
"team_external_id": team_external_id, "team_external_id": team_external_id,
"url": "https://wfl.rfs.ru" + href, "url": source_absolute_url(source, href),
} }
) )
@@ -92,13 +98,14 @@ def parse_team(html: str) -> dict:
full_player = name_p.get_text(strip=True) if name_p else "" full_player = name_p.get_text(strip=True) if name_p else ""
parts = full_player.split() parts = full_player.split()
pos_short = pos_td.get_text(strip=True) if pos_td else ""
players.append( players.append(
{ {
"player_id": player_id or "", "player_id": player_id or "",
"number": number_td.get_text(strip=True) if number_td else "", "number": number_td.get_text(strip=True) if number_td else "",
"pos": pos_td.get_text(strip=True) if pos_td else "", "pos": pos_short,
"amplua": AMPLUA_FULL[pos_td.get_text(strip=True) if pos_td else ""], "amplua": AMPLUA_FULL.get(pos_short, pos_short),
"player": full_player, "player": full_player,
"lastname": parts[0] if len(parts) >= 1 else "", "lastname": parts[0] if len(parts) >= 1 else "",
"name": parts[-1] if len(parts) >= 2 else "", "name": parts[-1] if len(parts) >= 2 else "",
@@ -133,7 +140,7 @@ def parse_team(html: str) -> dict:
"lastname": last_name, "lastname": last_name,
"player": f"{last_name} {first_name}".strip(), "player": f"{last_name} {first_name}".strip(),
"born": born.get_text(strip=True).replace(",", "") if born else "", "born": born.get_text(strip=True).replace(",", "") if born else "",
"amplua": amplua.get_text(strip=True) if amplua else "", "amplua": amplua.get_text(strip=True).replace(".", "") if amplua else "",
} }
) )
@@ -144,9 +151,13 @@ def parse_team(html: str) -> dict:
} }
def run_parser_players() -> None: def run_parser_players(source_key: str | None = None) -> None:
html = fetch_html(URL_TEAMS) source = get_parser_source(source_key)
links = get_links(html) print(f"[parser_players] Источник: {source['title']}")
print(f"[parser_players] URL: {source['teams_url']}")
html = fetch_html(source["teams_url"])
links = get_links(html, source)
with ThreadPoolExecutor(max_workers=8) as pool: with ThreadPoolExecutor(max_workers=8) as pool:
futures = {pool.submit(fetch_html, item["url"]): item for item in links} futures = {pool.submit(fetch_html, item["url"]): item for item in links}
@@ -169,6 +180,9 @@ def run_parser_players() -> None:
except Exception as e: except Exception as e:
print(f"[parser_players] error team={item['team_external_id']}: {e}") print(f"[parser_players] error team={item['team_external_id']}: {e}")
if not links:
print("[parser_players] Команды для парсинга игроков не найдены")
if __name__ == "__main__": if __name__ == "__main__":
run_parser_players() run_parser_players()

View File

@@ -5,6 +5,7 @@ from zoneinfo import ZoneInfo
from services.schedule_service import sync_matches from services.schedule_service import sync_matches
from repositories.team_repository import get_team_external_id_by_name, get_team_id_by_external_id from repositories.team_repository import get_team_external_id_by_name, get_team_id_by_external_id
from parsers.parser_sources import get_parser_source
MONTHS_RU = { MONTHS_RU = {
@@ -24,11 +25,6 @@ MONTHS_RU = {
TZ = ZoneInfo("Europe/Moscow") 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: def parse_russian_date(date_str: str, year: int | None = None) -> datetime:
@@ -76,7 +72,7 @@ def safe_int(value: str | None) -> int | None:
return int(value) if value.isdigit() else None return int(value) if value.isdigit() else None
def parse_schedule(html: str) -> list[dict]: def parse_schedule(html: str, season: str, source_key: str | None = None) -> list[dict]:
soup = BeautifulSoup(html, "html.parser") soup = BeautifulSoup(html, "html.parser")
matches_data: list[dict] = [] matches_data: list[dict] = []
@@ -151,10 +147,11 @@ def parse_schedule(html: str) -> list[dict]:
"home_score": home_score, "home_score": home_score,
"away_score": away_score, "away_score": away_score,
"tour": tour, "tour": tour,
"season": SEASON, "season": season,
"place": place, "place": place,
"date_raw": time_site, "date_raw": time_site,
"score_add": score_add, "score_add": score_add,
"source_key": source_key,
} }
) )
@@ -162,11 +159,18 @@ def parse_schedule(html: str) -> list[dict]:
return matches_data return matches_data
def run_parser_schedule() -> None: def run_parser_schedule(source_key: str | None = None) -> None:
html = fetch_html(URL_SCHEDULE) source = get_parser_source(source_key)
matches_data = parse_schedule(html) print(f"[parser_schedule] Источник: {source['title']}")
sync_matches(matches_data) print(f"[parser_schedule] URL: {source['schedule_url']}")
print(f"[parser_schedule] Matches synced: {len(matches_data)}")
html = fetch_html(source["schedule_url"])
matches_data = parse_schedule(html, source["season"], source["key"])
if matches_data:
sync_matches(matches_data)
print(f"[parser_schedule] Matches synced: {len(matches_data)}")
else:
print("[parser_schedule] Матчи не найдены")
if __name__ == "__main__": if __name__ == "__main__":

291
parsers/parser_sources.py Normal file
View File

@@ -0,0 +1,291 @@
from __future__ import annotations
from urllib.parse import urljoin
from repositories.project_settings_repository import (
DEFAULT_APP_SETTINGS,
DEFAULT_PARSER_SOURCES,
ensure_project_settings_tables,
get_app_setting,
get_parser_source_from_db,
list_parser_sources_from_db,
)
def _fallback_sources() -> list[dict]:
return [dict(item) for item in DEFAULT_PARSER_SOURCES]
def _fallback_sources_dict() -> dict[str, dict]:
return {item["key"]: dict(item) for item in DEFAULT_PARSER_SOURCES}
def _fallback_default_source_key() -> str:
return DEFAULT_APP_SETTINGS["default_parser_source_key"]
def _fallback_base_url() -> str:
return DEFAULT_APP_SETTINGS["rfs_base_url"].rstrip("/")
def _get_base_url() -> str:
try:
ensure_project_settings_tables()
return get_app_setting("rfs_base_url", _fallback_base_url()).rstrip("/")
except Exception:
return _fallback_base_url()
RFS_BASE_URL = _get_base_url()
def get_rfs_base_url() -> str:
return _get_base_url()
def get_default_source_key() -> str:
try:
ensure_project_settings_tables()
value = get_app_setting("default_parser_source_key", _fallback_default_source_key())
value = (value or _fallback_default_source_key()).upper().strip()
known_keys = {source["key"] for source in list_parser_sources()}
return value if value in known_keys else _fallback_default_source_key()
except Exception:
return _fallback_default_source_key()
def get_parser_sources() -> dict[str, dict]:
try:
ensure_project_settings_tables()
sources = list_parser_sources_from_db(active_only=True)
if not sources:
return _fallback_sources_dict()
return {source["key"]: source for source in sources}
except Exception:
return _fallback_sources_dict()
def list_parser_sources() -> list[dict]:
sources = get_parser_sources()
return list(sources.values())
def get_parser_source(source_key: str | None = None) -> dict:
"""Возвращает настройки источника из БД. Если БД недоступна, используется fallback."""
source_key = (source_key or get_default_source_key() or "SUPERLEAGUE").upper().strip()
try:
ensure_project_settings_tables()
source = get_parser_source_from_db(source_key)
if source:
return source
except Exception:
pass
fallback = _fallback_sources_dict()
if source_key in fallback:
return fallback[source_key]
raise ValueError(f"Неизвестный источник парсинга: {source_key}")
def source_absolute_url(source: dict, path_or_url: str) -> str:
value = (path_or_url or "").strip()
if value.startswith(("http://", "https://")):
return value
base_url = (source.get("base_url") or get_rfs_base_url()).rstrip("/")
return urljoin(base_url + "/", value.lstrip("/"))
def source_match_url(source: dict, match_external_id: str | int) -> str:
match_base_url = (source.get("match_base_url") or f"{get_rfs_base_url()}/match/").rstrip("/") + "/"
return f"{match_base_url}{str(match_external_id).strip()}"
def extract_logo_filename(value: str | None) -> str:
"""Возвращает только имя файла логотипа из старого полного пути или нового значения."""
value = str(value or "").strip().strip('"').strip("'")
if not value:
return ""
normalized = value.replace("/", "\\")
filename = normalized.split("\\")[-1].strip()
return filename
def ensure_logo_extension(filename: str) -> str:
filename = extract_logo_filename(filename)
if not filename:
return ""
if "." not in filename.rsplit("\\", 1)[-1]:
return f"{filename}.png"
return filename
def get_logo_base_path(source_key: str | None = None) -> str:
source = get_parser_source(source_key)
return str(source.get("logo_base_path") or "").rstrip("\\/")
def build_logo_path(source_key: str | None, filename: str | None) -> str:
"""Собирает полный путь к логотипу для vMix по источнику турнира."""
logo_file = ensure_logo_extension(filename)
if not logo_file:
return ""
base_path = get_logo_base_path(source_key)
if not base_path:
return logo_file
return f"{base_path}\\{logo_file}"
def build_logo_variant_path(
source_key: str | None,
filename: str | None,
variant: str | None = None,
) -> str:
"""
Собирает путь к варианту логотипа.
variant="white" -> Динамоелый / Зенит_Белый
variant="blue" -> Динамо_Синий / Зенит_Синий
Остальные команды остаются без изменения.
"""
logo_file = ensure_logo_extension(filename)
if not logo_file:
return ""
suffix = ""
if variant == "white":
suffix = "елый"
elif variant == "blue":
suffix = "_Синий"
if suffix:
lower = logo_file.lower()
if "динамо" in lower or "зенит" in lower:
if "." in logo_file:
stem, ext = logo_file.rsplit(".", 1)
ext = "." + ext
else:
stem, ext = logo_file, ".png"
for old_suffix in ("елый", "_Синий"):
if stem.endswith(old_suffix):
stem = stem[: -len(old_suffix)]
logo_file = f"{stem}{suffix}{ext}"
return build_logo_path(source_key, logo_file)
def extract_photo_filename(value: str | None) -> str:
"""Возвращает имя файла/относительный путь фото из старого полного пути или нового значения."""
value = str(value or "").strip().strip('"').strip("'")
if not value:
return ""
normalized = value.replace("/", "\\")
lower = normalized.lower()
# Для старых полных путей пытаемся сохранить относительную часть от папки Photo.
marker = "\\photo\\"
if marker in lower:
idx = lower.rfind(marker)
return normalized[idx + len(marker):].strip("\\")
parts = [part for part in normalized.split("\\") if part]
if len(parts) >= 2 and ":" in parts[0]:
return parts[-1].strip()
return normalized.strip("\\")
def ensure_photo_extension(filename: str) -> str:
filename = extract_photo_filename(filename)
if not filename:
return ""
if "." not in filename.rsplit("\\", 1)[-1]:
return f"{filename}.png"
return filename
def get_photo_base_path(source_key: str | None = None) -> str:
source = get_parser_source(source_key)
return str(source.get("photo_base_path") or "").rstrip("\\/")
def build_photo_path(source_key: str | None, filename: str | None) -> str:
"""Собирает полный путь к фото игрока для vMix по источнику турнира."""
photo_file = ensure_photo_extension(filename)
if not photo_file:
return ""
base_path = get_photo_base_path(source_key)
if not base_path:
return photo_file
return f"{base_path}\\{photo_file}"
def build_empty_photo_path(source_key: str | None = None) -> str:
"""Путь к пустой картинке фото для выбранного источника."""
return build_photo_path(source_key, "EMPTY.png")
def extract_channel_logo_filename(value: str | None) -> str:
"""Возвращает имя файла логотипа телеканала из старого полного пути или нового значения."""
value = str(value or "").strip().strip('"').strip("'")
if not value:
return ""
normalized = value.replace("/", "\\")
filename = normalized.split("\\")[-1].strip()
return filename
def ensure_channel_logo_extension(filename: str) -> str:
filename = extract_channel_logo_filename(filename)
if not filename:
return ""
if "." not in filename.rsplit("\\", 1)[-1]:
return f"{filename}.png"
return filename
def get_channel_logo_base_path(source_key: str | None = None) -> str:
source = get_parser_source(source_key)
return str(source.get("channel_logo_base_path") or "").rstrip("\\/")
def build_channel_logo_path(source_key: str | None, filename: str | None) -> str:
"""Собирает полный путь к логотипу телеканала по источнику турнира."""
logo_file = ensure_channel_logo_extension(filename)
if not logo_file:
return ""
base_path = get_channel_logo_base_path(source_key)
if not base_path:
return logo_file
return f"{base_path}\\{logo_file}"
def build_empty_channel_logo_path(source_key: str | None = None) -> str:
return build_channel_logo_path(source_key, "EMPTY.png")
def build_schedule_channel_logo_path(
source_key: str | None,
channel: str | None,
has_score: bool,
) -> str:
"""Возвращает логотип канала для расписания vMix.
После появления счёта логотип канала должен быть скрыт, даже если канал
остался отмечен в расписании.
"""
if has_score:
return build_empty_channel_logo_path(source_key)
if str(channel or "").strip():
return build_channel_logo_path(source_key, channel)
return build_empty_channel_logo_path(source_key)

View File

@@ -2,9 +2,8 @@ import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from services.standings_service import sync_standings from services.standings_service import sync_standings
from parsers.parser_sources import get_parser_source
URL_STANDINGS = "https://wfl.rfs.ru/tournament/1061879/tables"
SEASON = "2025/2026"
def fetch_html(url: str) -> str: def fetch_html(url: str) -> str:
@@ -65,16 +64,22 @@ def parse_standings(html: str) -> list[dict]:
return standings return standings
def run_parser_standings() -> None: def run_parser_standings(source_key: str | None = None) -> None:
html = fetch_html(URL_STANDINGS) source = get_parser_source(source_key)
print(f"[parser_standings] Источник: {source['title']}")
print(f"[parser_standings] URL: {source['standings_url']}")
html = fetch_html(source["standings_url"])
standings_rows = parse_standings(html) standings_rows = parse_standings(html)
if standings_rows:
sync_standings(
season=source["season"],
standings_rows=standings_rows,
)
sync_standings( print(f"[parser_standings] Synced rows: {len(standings_rows)}")
season=SEASON, else:
standings_rows=standings_rows, print("[parser_standings] Строки турнирной таблицы не найдены")
)
print(f"[parser_standings] Synced rows: {len(standings_rows)}")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -3,9 +3,7 @@ from bs4 import BeautifulSoup
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from services.teams_service import sync_teams from services.teams_service import sync_teams
from parsers.parser_sources import get_parser_source, source_absolute_url
TEAMS_URL = "https://wfl.rfs.ru/tournament/1061879/teams"
def fetch_html(url: str) -> str: def fetch_html(url: str) -> str:
@@ -15,45 +13,51 @@ def fetch_html(url: str) -> str:
return response.text return response.text
def get_links(html) -> list: def get_links(html: str, source: dict) -> list[str]:
soup = BeautifulSoup(html, "html.parser") soup = BeautifulSoup(html, "html.parser")
links: list[dict] = [] links: list[str] = []
items = soup.find("ul", class_="teams__list").find_all("li")
for i in items: teams_list = soup.find("ul", class_="teams__list")
links.append( if not teams_list:
"https://wfl.rfs.ru/team/" return links
+ i.find("a", class_="teams__link").get("href").split("team_id=")[-1]
) items = teams_list.find_all("li")
for item in items:
link_el = item.find("a", class_="teams__link")
href = link_el.get("href") if link_el else ""
if not href:
continue
if "team_id=" in href:
team_external_id = href.split("team_id=")[-1].strip()
links.append(source_absolute_url(source, "/team/" + team_external_id))
else:
links.append(source_absolute_url(source, href))
return links return links
def get_url_teams() -> list[dict]: def get_url_teams(source_key: str | None = None) -> list[dict]:
html = fetch_html(TEAMS_URL) source = get_parser_source(source_key)
links = get_links(html) html = fetch_html(source["teams_url"])
links = get_links(html, source)
teams_data: list[dict] = [] teams_data: list[dict] = []
with ThreadPoolExecutor() as pool: with ThreadPoolExecutor() as pool:
responses = [ responses = [pool.submit(fetch_html, link) for link in links]
pool.submit(
fetch_html,
link,
)
for link in links
]
for result in responses: for result in responses:
try: try:
html = result.result() html = result.result()
team_data = parse_teams_html(html) team_data = parse_teams_html(html)
teams_data.append(team_data) teams_data.append(team_data)
except Exception as e: except Exception as e:
print(f"Error fetching team data: {e}") print(f"[parser_teams] Error fetching team data: {e}")
return teams_data return teams_data
def parse_teams_html(html: str) -> dict: def parse_teams_html(html: str) -> dict:
soup = BeautifulSoup(html, "html.parser") soup = BeautifulSoup(html, "html.parser")
teams_data: dict = {}
name = soup.find("a", class_="team-promo__team-name").text.strip() name = soup.find("a", class_="team-promo__team-name").text.strip()
external_id = soup.find("a", class_="team-promo__logo").get("href").split("/")[-1] external_id = soup.find("a", class_="team-promo__logo").get("href").split("/")[-1]
stat_info = soup.find("ul", class_="stats-info").find_all( stat_info = soup.find("ul", class_="stats-info").find_all(
@@ -65,7 +69,7 @@ def parse_teams_html(html: str) -> dict:
goals = stat_info[2].text.strip() goals = stat_info[2].text.strip()
tournaments = stat_info[3].text.strip() tournaments = stat_info[3].text.strip()
teams_data = { return {
"external_id": str(external_id), "external_id": str(external_id),
"name": name, "name": name,
"logo_url": logo_url, "logo_url": logo_url,
@@ -75,13 +79,18 @@ def parse_teams_html(html: str) -> dict:
"tournaments": tournaments, "tournaments": tournaments,
} }
return teams_data
def run_parser_teams(source_key: str | None = None) -> None:
source = get_parser_source(source_key)
print(f"[parser_teams] Источник: {source['title']}")
print(f"[parser_teams] URL: {source['teams_url']}")
def run_parser_teams() -> None: teams_data = get_url_teams(source_key)
teams_data = get_url_teams() if teams_data:
sync_teams(teams_data) sync_teams(teams_data)
print(f"Teams synced: {len(teams_data)}") print(f"[parser_teams] Teams synced: {len(teams_data)}")
else:
print("[parser_teams] Команды не найдены")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -122,6 +122,82 @@ def get_coach_id_by_external_id(external_id: str) -> int | None:
def _split_coach_name_for_autocreate(full_name: str) -> tuple[str, str]:
parts = [p.strip() for p in str(full_name or "").replace("\xa0", " ").split() if p.strip()]
if not parts:
return "", ""
if len(parts) == 1:
return "", parts[0]
first_name = parts[0]
last_name = " ".join(parts[1:])
return first_name, last_name
def create_coach_from_lineup(
team_id: int,
external_id: str = "",
full_name: str = "",
role: str = "",
) -> int | None:
"""Создаёт минимальную карточку тренера из протокола матча."""
full_name = str(full_name or "").strip()
if not full_name:
return None
first_name, last_name = _split_coach_name_for_autocreate(full_name)
external_id = str(external_id or "").strip()
role = str(role or "").strip()
query = """
INSERT INTO coaches (
external_id,
team_id,
player,
lastname,
name,
amplua,
is_active,
created_at,
updated_at
)
VALUES (NULLIF(%s, ''), %s, %s, %s, %s, %s, TRUE, NOW(), NOW())
ON CONFLICT (external_id)
DO UPDATE SET
team_id = EXCLUDED.team_id,
player = COALESCE(NULLIF(EXCLUDED.player, ''), coaches.player),
lastname = COALESCE(NULLIF(EXCLUDED.lastname, ''), coaches.lastname),
name = COALESCE(NULLIF(EXCLUDED.name, ''), coaches.name),
amplua = COALESCE(NULLIF(EXCLUDED.amplua, ''), coaches.amplua),
is_active = TRUE,
updated_at = NOW()
RETURNING id;
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
query,
(
external_id,
team_id,
full_name,
last_name,
first_name,
role,
),
)
row = cur.fetchone()
conn.commit()
return row[0] if row else None
except Exception:
conn.rollback()
raise
finally:
conn.close()
def search_coaches_for_admin(q: str = "") -> list[dict]: def search_coaches_for_admin(q: str = "") -> list[dict]:
conn = get_connection() conn = get_connection()
try: try:
@@ -283,4 +359,59 @@ def update_coach_admin(
conn.rollback() conn.rollback()
raise raise
finally: finally:
conn.close() conn.close()
def create_coach_admin(
team_id: int,
full_name: str,
first_name: str = "",
last_name: str = "",
external_id: str = "",
birth_date: str = "",
role: str = "",
is_active: bool = True,
) -> int:
"""Создаёт тренера вручную из административного раздела."""
query = """
INSERT INTO coaches (
external_id,
team_id,
player,
lastname,
name,
born,
amplua,
is_active,
created_at,
updated_at
)
VALUES (
NULLIF(%s, ''), %s, %s, %s, %s, NULLIF(%s, ''), %s, %s, NOW(), NOW()
)
RETURNING id;
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
query,
(
external_id.strip(),
int(team_id),
full_name.strip(),
last_name.strip(),
first_name.strip(),
birth_date.strip(),
role.strip(),
bool(is_active),
),
)
row = cur.fetchone()
conn.commit()
return int(row[0])
except Exception:
conn.rollback()
raise
finally:
conn.close()

View File

@@ -59,6 +59,7 @@ def _player_to_editor_dict(p: dict) -> dict:
"number": str(p.get("number", "") or ""), "number": str(p.get("number", "") or ""),
"position": p.get("position", "") or "", "position": p.get("position", "") or "",
"is_captain": bool(p.get("is_captain")), "is_captain": bool(p.get("is_captain")),
"photo_enabled": bool(p.get("photo_enabled")),
} }
@@ -90,7 +91,8 @@ def get_match_lineup_for_editor(
COALESCE(p.first_name, '') AS first_name, COALESCE(p.first_name, '') AS first_name,
COALESCE(mlp.number::text, '') AS number, COALESCE(mlp.number::text, '') AS number,
COALESCE(p.position, '') AS position, COALESCE(p.position, '') AS position,
COALESCE(mlp.is_captain, FALSE) AS is_captain COALESCE(mlp.is_captain, FALSE) AS is_captain,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM match_lineup_players mlp FROM match_lineup_players mlp
JOIN players p JOIN players p
ON p.id = mlp.player_id ON p.id = mlp.player_id
@@ -141,6 +143,7 @@ def get_match_lineup_for_editor(
"number": row[7] or "", "number": row[7] or "",
"position": row[8] or "", "position": row[8] or "",
"is_captain": bool(row[9]), "is_captain": bool(row[9]),
"photo_enabled": bool(row[10]),
} }
side = row[0] side = row[0]
@@ -230,10 +233,26 @@ def get_match_lineup_for_vmix(
COALESCE(mlp.number::text, '') AS number, COALESCE(mlp.number::text, '') AS number,
COALESCE(p.position, '') AS position, COALESCE(p.position, '') AS position,
COALESCE(mlp.is_captain, FALSE) AS is_captain, COALESCE(mlp.is_captain, FALSE) AS is_captain,
COALESCE(p.position, '') AS pos COALESCE(p.position, '') AS pos,
p.photo,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM match_lineup_players mlp FROM match_lineup_players mlp
JOIN players p LEFT JOIN LATERAL (
ON p.id = mlp.player_id SELECT p.*
FROM players p
WHERE p.team_id = CASE WHEN mlp.side = 'home' THEN %s ELSE %s END
AND (
p.id = mlp.player_id
OR (
COALESCE(p.number::text, '') <> ''
AND COALESCE(p.number::text, '') = COALESCE(mlp.number::text, '')
)
)
ORDER BY
CASE WHEN p.id = mlp.player_id THEN 0 ELSE 1 END,
p.id
LIMIT 1
) p ON TRUE
WHERE mlp.match_id = %s WHERE mlp.match_id = %s
ORDER BY ORDER BY
CASE CASE
@@ -248,7 +267,7 @@ def get_match_lineup_for_vmix(
COALESCE(p.first_name, ''), COALESCE(p.first_name, ''),
p.id p.id
""", """,
(match_id,), (home_team_id, away_team_id, match_id),
) )
player_rows = cur.fetchall() player_rows = cur.fetchall()
@@ -293,6 +312,9 @@ def get_match_lineup_for_vmix(
"position": row[8] or "", "position": row[8] or "",
"is_captain": bool(row[9]), "is_captain": bool(row[9]),
"pos": row[10] or "", "pos": row[10] or "",
"photo": row[11] or "",
"photo_enabled": bool(row[12]),
"team_id": home_team_id if row[0] == "home" else away_team_id,
} }
side = row[0] side = row[0]
@@ -449,6 +471,52 @@ def save_match_lineup_for_editor(
insert_players("away", "starting", away_starting) insert_players("away", "starting", away_starting)
insert_players("away", "bench", away_bench) insert_players("away", "bench", away_bench)
# Синхронизируем капитана с уже сохранённой расстановкой.
# /home-formations и /away-formations читают is_captain из
# match_formations, поэтому ручная смена капитана в редакторе
# состава должна сразу попадать и туда без пересохранения
# вкладки «Расстановки».
def sync_formation_captain(side: str, team_id: int) -> None:
cur.execute(
"""
UPDATE match_formations mf
SET
is_captain = COALESCE((
SELECT mlp.is_captain
FROM match_lineup_players mlp
WHERE mlp.match_id = mf.match_id
AND mlp.side = %s
AND mlp.role = 'starting'
AND (
(
mf.player_id IS NOT NULL
AND mlp.player_id = mf.player_id
)
OR (
COALESCE(NULLIF(TRIM(mf.number), ''), '') <> ''
AND COALESCE(mlp.number::text, '') = COALESCE(mf.number, '')
)
)
ORDER BY
CASE
WHEN mf.player_id IS NOT NULL
AND mlp.player_id = mf.player_id
THEN 0
ELSE 1
END,
mlp.sort_order
LIMIT 1
), FALSE),
updated_at = NOW()
WHERE mf.match_id = %s
AND mf.team_id = %s
""",
(side, match_id, team_id),
)
sync_formation_captain("home", home_team_id)
sync_formation_captain("away", away_team_id)
insert_coaches("home", home_coaches) insert_coaches("home", home_coaches)
insert_coaches("away", away_coaches) insert_coaches("away", away_coaches)

View File

@@ -0,0 +1,295 @@
from db import get_connection
VALID_SIDES = {"home", "away"}
VALID_RESULTS = {"scored", "missed"}
def ensure_match_penalty_tables() -> None:
"""Создает таблицы для серии пенальти."""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS match_penalty_settings (
match_id INTEGER PRIMARY KEY REFERENCES matches(id) ON DELETE CASCADE,
max_rounds INTEGER NOT NULL DEFAULT 5,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS match_penalties (
id SERIAL PRIMARY KEY,
match_id INTEGER NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
side VARCHAR(10) NOT NULL CHECK (side IN ('home', 'away')),
shot_number INTEGER NOT NULL CHECK (shot_number > 0),
result VARCHAR(20) NOT NULL CHECK (result IN ('scored', 'missed')),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE (match_id, side, shot_number)
);
"""
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _normalize_side(side: str) -> str:
side = str(side or "").strip().lower()
if side not in VALID_SIDES:
raise ValueError("Некорректная сторона пенальти")
return side
def _normalize_result(result: str) -> str:
result = str(result or "").strip().lower()
if result not in VALID_RESULTS:
raise ValueError("Некорректный результат пенальти")
return result
def _ensure_penalty_rounds(cur, match_id: int, max_rounds: int) -> None:
safe_rounds = max(5, int(max_rounds or 5))
cur.execute(
"""
INSERT INTO match_penalty_settings (match_id, max_rounds, created_at, updated_at)
VALUES (%s, %s, NOW(), NOW())
ON CONFLICT (match_id)
DO UPDATE SET
max_rounds = GREATEST(match_penalty_settings.max_rounds, EXCLUDED.max_rounds),
updated_at = NOW();
""",
(match_id, safe_rounds),
)
def get_penalty_state(match_id: int, home_team_name: str = "", away_team_name: str = "") -> dict:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT COALESCE(max_rounds, 5)
FROM match_penalty_settings
WHERE match_id = %s;
""",
(match_id,),
)
settings_row = cur.fetchone()
cur.execute(
"""
SELECT side, shot_number, result
FROM match_penalties
WHERE match_id = %s
ORDER BY shot_number, side;
""",
(match_id,),
)
rows = cur.fetchall()
finally:
conn.close()
max_rounds = int(settings_row[0]) if settings_row else 5
for _, shot_number, _ in rows:
max_rounds = max(max_rounds, int(shot_number or 0), 5)
shots_map = {
(str(side), int(shot_number)): str(result)
for side, shot_number, result in rows
}
rounds = []
totals = {"home": 0, "away": 0}
completed = {"home": 0, "away": 0}
for number in range(1, max_rounds + 1):
row = {"number": number}
for side in ("home", "away"):
result = shots_map.get((side, number), "")
if result:
completed[side] += 1
if result == "scored":
totals[side] += 1
row[side] = result
rounds.append(row)
shots = [
{"side": str(side), "shot_number": int(shot_number), "result": str(result)}
for side, shot_number, result in rows
]
return {
"match_id": match_id,
"home_team": home_team_name or "Хозяева",
"away_team": away_team_name or "Гости",
"max_rounds": max_rounds,
"totals": totals,
"completed": completed,
"rounds": rounds,
"shots": shots,
}
def set_penalty_shot(match_id: int, side: str, shot_number: int, result: str) -> None:
side = _normalize_side(side)
result = _normalize_result(result)
safe_shot_number = max(1, int(shot_number or 1))
conn = get_connection()
try:
with conn.cursor() as cur:
_ensure_penalty_rounds(cur, match_id, safe_shot_number)
cur.execute(
"""
INSERT INTO match_penalties (match_id, side, shot_number, result, created_at, updated_at)
VALUES (%s, %s, %s, %s, NOW(), NOW())
ON CONFLICT (match_id, side, shot_number)
DO UPDATE SET
result = EXCLUDED.result,
updated_at = NOW();
""",
(match_id, side, safe_shot_number, result),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def delete_penalty_shot(match_id: int, side: str, shot_number: int) -> None:
side = _normalize_side(side)
safe_shot_number = max(1, int(shot_number or 1))
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
DELETE FROM match_penalties
WHERE match_id = %s AND side = %s AND shot_number = %s;
""",
(match_id, side, safe_shot_number),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def add_penalty_round(match_id: int) -> int:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO match_penalty_settings (match_id, max_rounds, created_at, updated_at)
VALUES (%s, 6, NOW(), NOW())
ON CONFLICT (match_id)
DO UPDATE SET
max_rounds = GREATEST(match_penalty_settings.max_rounds + 1, 6),
updated_at = NOW()
RETURNING max_rounds;
""",
(match_id,),
)
max_rounds = int(cur.fetchone()[0])
conn.commit()
return max_rounds
except Exception:
conn.rollback()
raise
finally:
conn.close()
def delete_last_penalty_round(match_id: int) -> int:
"""Удаляет последнюю добавленную строку серии пенальти и ее данные.
Первые 5 строк считаются базовыми и не удаляются. Если добавлена 6-я,
7-я и т.д. строка, удаляется самая последняя строка вместе с ударами
обеих команд в этой строке.
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT COALESCE(max_rounds, 5)
FROM match_penalty_settings
WHERE match_id = %s;
""",
(match_id,),
)
settings_row = cur.fetchone()
settings_max = int(settings_row[0]) if settings_row else 5
cur.execute(
"""
SELECT COALESCE(MAX(shot_number), 0)
FROM match_penalties
WHERE match_id = %s;
""",
(match_id,),
)
shots_max = int(cur.fetchone()[0] or 0)
current_max = max(5, settings_max, shots_max)
if current_max <= 5:
_ensure_penalty_rounds(cur, match_id, 5)
conn.commit()
return 5
new_max = max(5, current_max - 1)
cur.execute(
"""
DELETE FROM match_penalties
WHERE match_id = %s AND shot_number = %s;
""",
(match_id, current_max),
)
cur.execute(
"""
INSERT INTO match_penalty_settings (match_id, max_rounds, created_at, updated_at)
VALUES (%s, %s, NOW(), NOW())
ON CONFLICT (match_id)
DO UPDATE SET
max_rounds = EXCLUDED.max_rounds,
updated_at = NOW();
""",
(match_id, new_max),
)
conn.commit()
return new_max
except Exception:
conn.rollback()
raise
finally:
conn.close()
def clear_penalties(match_id: int) -> None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM match_penalties WHERE match_id = %s;", (match_id,))
cur.execute("DELETE FROM match_penalty_settings WHERE match_id = %s;", (match_id,))
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()

View File

@@ -3,8 +3,142 @@ from numpy import place
from db import get_connection from db import get_connection
from repositories.team_repository import get_team_id_by_external_id from repositories.team_repository import get_team_id_by_external_id
from repositories.stadium_repository import get_or_create_stadium from repositories.stadium_repository import get_or_create_stadium
from parsers.parser_sources import get_default_source_key, list_parser_sources
def ensure_match_source_key_column() -> None:
"""Добавляет источник турнира для матчей, чтобы vMix выбирал правильную папку логотипов."""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
ALTER TABLE matches
ADD COLUMN IF NOT EXISTS source_key VARCHAR(50);
"""
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _normalize_source_key(value: str | None) -> str:
return str(value or "").upper().strip()
def infer_match_source_key(
explicit_source_key: str | None = None,
season: str | None = None,
tour: str | None = None,
) -> str:
"""Определяет источник матча, даже если старые строки matches.source_key ещё пустые.
Приоритет:
1. matches.source_key;
2. точное совпадение season с parser_sources.season;
3. кубковые слова в tour;
4. обычный дефолт из настроек проекта.
"""
explicit = _normalize_source_key(explicit_source_key)
if explicit:
return explicit
season_value = str(season or "").strip()
tour_value = str(tour or "").lower().strip()
try:
sources = list_parser_sources()
except Exception:
sources = []
if season_value and sources:
matches = [
_normalize_source_key(source.get("key"))
for source in sources
if str(source.get("season") or "").strip() == season_value
]
matches = [key for key in matches if key]
if len(matches) == 1:
return matches[0]
cup_markers = (
"кубок",
"финал",
"полуфинал",
"четвертьфинал",
"1/2",
"1/4",
"1/8",
"1/16",
"групп",
"этап",
"стад",
)
if any(marker in tour_value for marker in cup_markers):
known_keys = {_normalize_source_key(source.get("key")) for source in sources}
if "RUSSIAN_CUP" in known_keys:
return "RUSSIAN_CUP"
try:
return _normalize_source_key(get_default_source_key()) or "SUPERLEAGUE"
except Exception:
return "SUPERLEAGUE"
def resolve_match_source_key(match_id: int | str | None, fallback: str | None = None) -> str:
"""Возвращает source_key для матча и, если возможно, записывает его в matches.
Это нужно для старых матчей Кубка России, которые могли быть загружены до появления
колонки source_key. Без этого vMix скачивает пресет и собирает пути как для Суперлиги.
"""
fallback_key = _normalize_source_key(fallback)
if not match_id:
return fallback_key or infer_match_source_key(fallback_key)
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT source_key, season, tour
FROM matches
WHERE id = %s
LIMIT 1;
""",
(match_id,),
)
row = cur.fetchone()
if not row:
return fallback_key or infer_match_source_key(fallback_key)
explicit_key, season, tour = row
source_key = infer_match_source_key(explicit_key or fallback_key, season, tour)
if source_key and not _normalize_source_key(explicit_key):
cur.execute(
"""
UPDATE matches
SET source_key = %s, updated_at = NOW()
WHERE id = %s
AND (source_key IS NULL OR TRIM(source_key) = '');
""",
(source_key, match_id),
)
conn.commit()
return source_key
except Exception:
conn.rollback()
return fallback_key or infer_match_source_key(fallback_key)
finally:
conn.close()
def upsert_match( def upsert_match(
external_id: str, external_id: str,
@@ -20,6 +154,7 @@ def upsert_match(
stadium_id: int | None = None, stadium_id: int | None = None,
date_raw: str | None = None, date_raw: str | None = None,
score_add: str | None = None, score_add: str | None = None,
source_key: str | None = None,
) -> None: ) -> None:
query = """ query = """
INSERT INTO matches ( INSERT INTO matches (
@@ -36,10 +171,11 @@ INSERT INTO matches (
stadium_id, stadium_id,
date_raw, date_raw,
score_add, score_add,
source_key,
created_at, created_at,
updated_at updated_at
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
ON CONFLICT (external_id) ON CONFLICT (external_id)
DO UPDATE SET DO UPDATE SET
home_team_id = EXCLUDED.home_team_id, home_team_id = EXCLUDED.home_team_id,
@@ -54,6 +190,7 @@ DO UPDATE SET
stadium_id = EXCLUDED.stadium_id, stadium_id = EXCLUDED.stadium_id,
date_raw = EXCLUDED.date_raw, date_raw = EXCLUDED.date_raw,
score_add = EXCLUDED.score_add, score_add = EXCLUDED.score_add,
source_key = COALESCE(EXCLUDED.source_key, matches.source_key),
updated_at = NOW(); updated_at = NOW();
""" """
@@ -76,6 +213,7 @@ DO UPDATE SET
stadium_id, stadium_id,
date_raw, date_raw,
score_add, score_add,
source_key,
), ),
) )
conn.commit() conn.commit()
@@ -100,6 +238,7 @@ def upsert_match_by_team_external_ids(
stadium_id: int | None = None, stadium_id: int | None = None,
date_raw: str | None = None, date_raw: str | None = None,
score_add: str | None = None, score_add: str | None = None,
source_key: str | None = None,
) -> None: ) -> None:
home_team_id = get_team_id_by_external_id(home_team_external_id) 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) away_team_id = get_team_id_by_external_id(away_team_external_id)
@@ -125,6 +264,7 @@ def upsert_match_by_team_external_ids(
stadium_id=stadium_id, stadium_id=stadium_id,
date_raw=date_raw, date_raw=date_raw,
score_add=score_add, score_add=score_add,
source_key=source_key,
) )
@@ -238,7 +378,8 @@ def get_tour_schedule_by_match_id(match_id: int) -> list[dict]:
m.home_score, m.home_score,
m.away_score, m.away_score,
ht.name AS home_team_name, ht.name AS home_team_name,
at.name AS away_team_name at.name AS away_team_name,
COALESCE(m.channel, '') AS channel
FROM matches m FROM matches m
LEFT JOIN teams ht ON ht.id = m.home_team_id LEFT JOIN teams ht ON ht.id = m.home_team_id
LEFT JOIN teams at ON at.id = m.away_team_id LEFT JOIN teams at ON at.id = m.away_team_id
@@ -262,6 +403,7 @@ def get_tour_schedule_by_match_id(match_id: int) -> list[dict]:
"away_score": r[6], "away_score": r[6],
"home_team_name": r[7] or "", "home_team_name": r[7] or "",
"away_team_name": r[8] or "", "away_team_name": r[8] or "",
"channel": r[9] or "",
} }
for r in rows for r in rows
] ]

View File

@@ -1,6 +1,8 @@
import secrets import secrets
from db import get_connection from db import get_connection
from parsers.parser_sources import build_logo_variant_path
from repositories.match_repository import resolve_match_source_key
def create_match_session( def create_match_session(
@@ -66,7 +68,8 @@ def get_match_session_by_token(session_token: str):
at.id AS away_team_id, at.id AS away_team_id,
at.name AS away_team_name, at.name AS away_team_name,
at.logo_url AS away_team_logo, at.logo_url AS away_team_logo,
at.logo_path AS away_team_logo_path at.logo_path AS away_team_logo_path,
m.source_key AS source_key
FROM match_sessions ms FROM match_sessions ms
JOIN matches m ON m.id = ms.match_id JOIN matches m ON m.id = ms.match_id
@@ -80,7 +83,22 @@ def get_match_session_by_token(session_token: str):
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(query, (session_token,)) cur.execute(query, (session_token,))
return cur.fetchone() row = cur.fetchone()
if not row:
return None
row = list(row)
source_key = resolve_match_source_key(row[1], row[21] if len(row) > 21 else None)
if len(row) > 21:
row[21] = source_key
# Эти пути используются рабочей страницей при отправке команд
# SetImage через agent.exe для нейтральных титров. Для «Динамо» и
# «Зенита» в титрах нужен синий вариант логотипа; остальные команды
# остаются без изменений.
row[16] = build_logo_variant_path(source_key, row[16], "blue")
row[20] = build_logo_variant_path(source_key, row[20], "blue")
return tuple(row)
finally: finally:
conn.close() conn.close()

View File

@@ -13,7 +13,9 @@ def get_match_lineups_grouped(match_id: int, home_team_id: int, away_team_id: in
ml.is_captain, ml.is_captain,
p.last_name, p.last_name,
p.first_name, p.first_name,
p.position as pos p.position as pos,
p.photo,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM match_lineups ml FROM match_lineups ml
LEFT JOIN players p ON p.id = ml.player_id LEFT JOIN players p ON p.id = ml.player_id
WHERE ml.match_id = %s; WHERE ml.match_id = %s;
@@ -46,10 +48,13 @@ def get_match_lineups_grouped(match_id: int, home_team_id: int, away_team_id: in
last_name, last_name,
first_name, first_name,
pos, pos,
photo,
photo_enabled,
) = row ) = row
item = { item = {
"player_id": player_id, "player_id": player_id,
"team_id": team_id,
"number": number or "", "number": number or "",
"last_name": last_name or "", "last_name": last_name or "",
"first_name": first_name or "", "first_name": first_name or "",
@@ -57,6 +62,8 @@ def get_match_lineups_grouped(match_id: int, home_team_id: int, away_team_id: in
"position": position or "", "position": position or "",
"is_captain": bool(is_captain), "is_captain": bool(is_captain),
"pos": pos, "pos": pos,
"photo": photo or "",
"photo_enabled": bool(photo_enabled),
} }
if team_id == home_team_id and lineup_type == "starting": if team_id == home_team_id and lineup_type == "starting":

View File

@@ -222,50 +222,189 @@ def get_player_id_by_external_id(external_id: str) -> int | None:
conn.close() conn.close()
from db import get_connection
def _split_full_name_for_autocreate(full_name: str) -> tuple[str, str]:
"""Аккуратно делит имя с сайта на first_name / last_name для новых записей."""
parts = [p.strip() for p in str(full_name or "").replace("\xa0", " ").split() if p.strip()]
if not parts:
return "", ""
if len(parts) == 1:
return "", parts[0]
# На сайте чаще приходит "Имя Фамилия". Полное имя всё равно сохраняем отдельно,
# поэтому даже при другом порядке данные можно быстро поправить в справочнике.
first_name = parts[0]
last_name = " ".join(parts[1:])
return first_name, last_name
def search_players_for_admin(q: str = "") -> list[dict]: def create_player_from_lineup(
team_id: int,
external_id: str = "",
full_name: str = "",
number: str = "",
position: str = "",
) -> tuple[int | None, str | None]:
"""Создаёт минимальную карточку игрока из протокола матча и возвращает (id, position).
Используется при загрузке составов с сайта, когда игрока ещё нет в справочнике.
"""
full_name = str(full_name or "").strip()
if not full_name:
return None, None
first_name, last_name = _split_full_name_for_autocreate(full_name)
external_id = str(external_id or "").strip()
number = str(number or "").strip()
position = str(position or "").strip()
query = """
INSERT INTO players (
external_id,
team_id,
full_name,
first_name,
last_name,
number,
position,
pos,
amplua,
is_active,
photo_enabled,
created_at,
updated_at
)
VALUES (
NULLIF(%s, ''), %s, %s, %s, %s, %s, %s, %s, %s, TRUE, FALSE,
NOW(), NOW()
)
ON CONFLICT (external_id)
DO UPDATE SET
team_id = EXCLUDED.team_id,
full_name = COALESCE(NULLIF(EXCLUDED.full_name, ''), players.full_name),
first_name = COALESCE(NULLIF(EXCLUDED.first_name, ''), players.first_name),
last_name = COALESCE(NULLIF(EXCLUDED.last_name, ''), players.last_name),
number = COALESCE(NULLIF(EXCLUDED.number, ''), players.number),
position = COALESCE(NULLIF(EXCLUDED.position, ''), players.position),
pos = COALESCE(NULLIF(EXCLUDED.pos, ''), players.pos),
amplua = COALESCE(NULLIF(EXCLUDED.amplua, ''), players.amplua),
is_active = TRUE,
updated_at = NOW()
RETURNING id, position;
"""
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(
query,
(
external_id,
team_id,
full_name,
first_name,
last_name,
number,
position,
position,
position,
),
)
row = cur.fetchone()
conn.commit()
return row if row else (None, None)
except Exception:
conn.rollback()
raise
finally:
conn.close()
PLAYER_ADMIN_SORT_COLUMNS = {
"id": "p.id",
"full_name": "p.full_name",
"first_name": "p.first_name",
"last_name": "p.last_name",
"external_id": "p.external_id",
"position": "p.position",
"team_name": "t.name",
"photo": "p.photo_enabled",
}
def ensure_player_photo_enabled_column() -> None:
"""Добавляет флаг использования фото игрока, если база ещё не обновлена."""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
ALTER TABLE players
ADD COLUMN IF NOT EXISTS photo_enabled BOOLEAN NOT NULL DEFAULT FALSE;
"""
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _normalize_player_sort(sort_by: str = "id", sort_dir: str = "desc") -> tuple[str, str, str, str]:
sort_by = (sort_by or "id").strip()
if sort_by not in PLAYER_ADMIN_SORT_COLUMNS:
sort_by = "id"
sort_dir = (sort_dir or "desc").strip().lower()
if sort_dir not in {"asc", "desc"}:
sort_dir = "desc"
return sort_by, sort_dir, PLAYER_ADMIN_SORT_COLUMNS[sort_by], sort_dir.upper()
def search_players_for_admin(q: str = "", sort_by: str = "id", sort_dir: str = "desc") -> list[dict]:
sort_by, sort_dir, order_column, order_direction = _normalize_player_sort(sort_by, sort_dir)
limit = 200
conn = get_connection()
try:
with conn.cursor() as cur:
params: list = []
where_sql = ""
if q.strip(): if q.strip():
pattern = f"%{q.strip()}%" pattern = f"%{q.strip()}%"
cur.execute( where_sql = """
"""
SELECT
p.id,
p.full_name,
p.first_name,
p.last_name,
p.external_id,
p.position
FROM players p
WHERE WHERE
p.full_name ILIKE %s p.full_name ILIKE %s
OR p.first_name ILIKE %s OR p.first_name ILIKE %s
OR p.last_name ILIKE %s OR p.last_name ILIKE %s
OR COALESCE(p.external_id, '') ILIKE %s OR COALESCE(p.external_id, '') ILIKE %s
ORDER BY p.full_name ASC, p.id ASC OR COALESCE(t.name, '') ILIKE %s
LIMIT 200 """
""", params.extend([pattern, pattern, pattern, pattern, pattern])
(pattern, pattern, pattern, pattern),
) params.append(limit)
else: cur.execute(
cur.execute( f"""
""" SELECT
SELECT p.id,
p.id, p.full_name,
p.full_name, p.first_name,
p.first_name, p.last_name,
p.last_name, p.external_id,
p.external_id, p.position,
p.position COALESCE(t.name, '') AS team_name,
FROM players p COALESCE(p.photo_enabled, FALSE) AS photo_enabled
ORDER BY p.id DESC FROM players p
LIMIT 200 LEFT JOIN teams t ON t.id = p.team_id
""" {where_sql}
) ORDER BY {order_column} {order_direction} NULLS LAST, p.id ASC
LIMIT %s
""",
tuple(params),
)
rows = cur.fetchall() rows = cur.fetchall()
@@ -277,6 +416,8 @@ def search_players_for_admin(q: str = "") -> list[dict]:
"last_name": row[3] or "", "last_name": row[3] or "",
"external_id": row[4] or "", "external_id": row[4] or "",
"position": row[5] or "", "position": row[5] or "",
"team_name": row[6] or "",
"photo_enabled": bool(row[7]),
} }
for row in rows for row in rows
] ]
@@ -299,8 +440,11 @@ def get_player_by_id(player_id: int) -> dict | None:
p.position, p.position,
p.born, p.born,
p.photo, p.photo,
p.video p.video,
COALESCE(t.name, '') AS team_name,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM players p FROM players p
LEFT JOIN teams t ON t.id = p.team_id
WHERE p.id = %s WHERE p.id = %s
LIMIT 1 LIMIT 1
""", """,
@@ -321,6 +465,8 @@ def get_player_by_id(player_id: int) -> dict | None:
"birth_date": normalize_birth_date_for_input(row[6]), "birth_date": normalize_birth_date_for_input(row[6]),
"photo": row[7] or "", "photo": row[7] or "",
"video": row[8] or "", "video": row[8] or "",
"team_name": row[9] or "",
"photo_enabled": bool(row[10]),
} }
finally: finally:
conn.close() conn.close()
@@ -336,6 +482,7 @@ def update_player_admin(
birth_date: str = "", birth_date: str = "",
photo: str = "", photo: str = "",
video: str = "", video: str = "",
photo_enabled: bool = False,
) -> None: ) -> None:
conn = get_connection() conn = get_connection()
try: try:
@@ -351,7 +498,9 @@ def update_player_admin(
position = %s, position = %s,
born = NULLIF(%s, '')::date, born = NULLIF(%s, '')::date,
photo = NULLIF(%s, ''), photo = NULLIF(%s, ''),
video = NULLIF(%s, '') video = NULLIF(%s, ''),
photo_enabled = %s,
updated_at = NOW()
WHERE id = %s WHERE id = %s
""", """,
( (
@@ -363,6 +512,7 @@ def update_player_admin(
birth_date.strip(), birth_date.strip(),
photo.strip(), photo.strip(),
video.strip(), video.strip(),
bool(photo_enabled),
player_id, player_id,
), ),
) )
@@ -372,3 +522,123 @@ def update_player_admin(
raise raise
finally: finally:
conn.close() conn.close()
def update_player_photo_enabled(player_id: int, photo_enabled: bool = False) -> None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE players
SET
photo_enabled = %s,
updated_at = NOW()
WHERE id = %s
""",
(bool(photo_enabled), player_id),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def create_player_admin(
team_id: int,
full_name: str,
first_name: str = "",
last_name: str = "",
external_id: str = "",
number: str = "",
position: str = "",
birth_date: str = "",
photo: str = "",
video: str = "",
height_cm: int | None = None,
weight_kg: int | None = None,
games: int = 0,
goals: int = 0,
penaltys: int = 0,
assists: int = 0,
yellows: int = 0,
reds: int = 0,
is_active: bool = True,
photo_enabled: bool = False,
) -> int:
"""Создаёт игрока вручную из административного раздела."""
query = """
INSERT INTO players (
external_id,
team_id,
full_name,
first_name,
last_name,
number,
position,
pos,
amplua,
born,
photo,
video,
height_cm,
weight_kg,
games,
goals,
penaltys,
assists,
yellows,
reds,
is_active,
photo_enabled,
created_at,
updated_at
)
VALUES (
NULLIF(%s, ''), %s, %s, %s, %s, NULLIF(%s, ''), %s, %s, %s,
NULLIF(%s, '')::date, NULLIF(%s, ''), NULLIF(%s, ''), %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()
)
RETURNING id;
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
query,
(
external_id.strip(),
int(team_id),
full_name.strip(),
first_name.strip(),
last_name.strip(),
number.strip(),
position.strip(),
position.strip(),
position.strip(),
birth_date.strip(),
photo.strip(),
video.strip(),
height_cm,
weight_kg,
int(games),
int(goals),
int(penaltys),
int(assists),
int(yellows),
int(reds),
bool(is_active),
bool(photo_enabled),
),
)
row = cur.fetchone()
conn.commit()
return int(row[0])
except Exception:
conn.rollback()
raise
finally:
conn.close()

View File

@@ -0,0 +1,349 @@
from __future__ import annotations
from typing import Any
from db import get_connection
DEFAULT_APP_SETTINGS = {
"default_parser_source_key": "SUPERLEAGUE",
"rfs_base_url": "https://wfl.rfs.ru",
}
DEFAULT_PARSER_SOURCES = [
{
"key": "SUPERLEAGUE",
"title": "Суперлига 2026",
"tournament_id": "1061879",
"round_id": "1117550",
"season": "2025/2026",
"calendar_type": "tours",
"logo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Teams Logos",
"photo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo",
"channel_logo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Лого каналов",
"teams_url": "https://wfl.rfs.ru/tournament/1061879/teams",
"schedule_url": "https://wfl.rfs.ru/tournament/1061879/calendar?round_id=1117550&type=tours",
"standings_url": "https://wfl.rfs.ru/tournament/1061879/tables",
"match_base_url": "https://wfl.rfs.ru/match/",
"base_url": "https://wfl.rfs.ru",
"sort_order": 10,
"is_active": True,
},
{
"key": "RUSSIAN_CUP",
"title": "Кубок России 2026",
"tournament_id": "1064908",
"round_id": "1125159",
"season": "2026",
"calendar_type": "stages",
"logo_base_path": r"D:\Графика\ФУТБОЛ\Кубок России 2026\Teams Logos",
"photo_base_path": r"D:\Графика\ФУТБОЛ\Кубок России 2026\Photo",
"channel_logo_base_path": r"D:\Графика\ФУТБОЛ\ЖФЛ Кубок России 2026\Лого каналов",
"teams_url": "https://wfl.rfs.ru/tournament/1064908/teams",
"schedule_url": "https://wfl.rfs.ru/tournament/1064908/calendar?round_id=1125159&type=stages",
"standings_url": "https://wfl.rfs.ru/tournament/1064908/tables",
"match_base_url": "https://wfl.rfs.ru/match/",
"base_url": "https://wfl.rfs.ru",
"sort_order": 20,
"is_active": True,
},
]
SOURCE_SELECT_SQL = """
SELECT key, title, tournament_id, round_id, season, calendar_type,
logo_base_path, photo_base_path, channel_logo_base_path,
teams_url, schedule_url, standings_url,
match_base_url, base_url, sort_order, is_active
FROM parser_sources
"""
def _row_to_source(row: tuple) -> dict[str, Any]:
return {
"key": row[0] or "",
"title": row[1] or "",
"tournament_id": row[2] or "",
"round_id": row[3] or "",
"season": row[4] or "",
"calendar_type": row[5] or "tours",
"logo_base_path": row[6] or "",
"photo_base_path": row[7] or "",
"channel_logo_base_path": row[8] or "",
"teams_url": row[9] or "",
"schedule_url": row[10] or "",
"standings_url": row[11] or "",
"match_base_url": row[12] or "",
"base_url": row[13] or "https://wfl.rfs.ru",
"sort_order": row[14] or 0,
"is_active": bool(row[15]),
}
def ensure_project_settings_tables() -> None:
"""Создаёт таблицы настроек проекта и добавляет стандартные источники, если их ещё нет."""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS app_settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS parser_sources (
key VARCHAR(50) PRIMARY KEY,
title VARCHAR(255) NOT NULL,
tournament_id VARCHAR(100),
round_id VARCHAR(100),
season VARCHAR(50),
calendar_type VARCHAR(50) NOT NULL DEFAULT 'tours',
logo_base_path TEXT,
photo_base_path TEXT,
channel_logo_base_path TEXT,
teams_url TEXT,
schedule_url TEXT,
standings_url TEXT,
match_base_url TEXT,
base_url TEXT NOT NULL DEFAULT 'https://wfl.rfs.ru',
sort_order INTEGER NOT NULL DEFAULT 100,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
"""
)
cur.execute("ALTER TABLE parser_sources ADD COLUMN IF NOT EXISTS photo_base_path TEXT;")
cur.execute("ALTER TABLE parser_sources ADD COLUMN IF NOT EXISTS channel_logo_base_path TEXT;")
for key, value in DEFAULT_APP_SETTINGS.items():
cur.execute(
"""
INSERT INTO app_settings (key, value, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (key) DO NOTHING;
""",
(key, value),
)
for source in DEFAULT_PARSER_SOURCES:
cur.execute(
"""
INSERT INTO parser_sources (
key, title, tournament_id, round_id, season, calendar_type,
logo_base_path, photo_base_path, channel_logo_base_path,
teams_url, schedule_url, standings_url, match_base_url, base_url,
sort_order, is_active, created_at, updated_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
ON CONFLICT (key) DO NOTHING;
""",
(
source["key"],
source["title"],
source["tournament_id"],
source["round_id"],
source["season"],
source["calendar_type"],
source["logo_base_path"],
source["photo_base_path"],
source["channel_logo_base_path"],
source["teams_url"],
source["schedule_url"],
source["standings_url"],
source["match_base_url"],
source["base_url"],
source["sort_order"],
source["is_active"],
),
)
for source in DEFAULT_PARSER_SOURCES:
cur.execute(
"""
UPDATE parser_sources
SET photo_base_path = %s, updated_at = NOW()
WHERE key = %s
AND (photo_base_path IS NULL OR TRIM(photo_base_path) = '');
""",
(source.get("photo_base_path") or "", source["key"]),
)
cur.execute(
"""
UPDATE parser_sources
SET channel_logo_base_path = %s, updated_at = NOW()
WHERE key = %s
AND (channel_logo_base_path IS NULL OR TRIM(channel_logo_base_path) = '');
""",
(source.get("channel_logo_base_path") or "", source["key"]),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def get_app_settings() -> dict[str, str]:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("SELECT key, value FROM app_settings;")
rows = cur.fetchall()
settings = dict(DEFAULT_APP_SETTINGS)
settings.update({row[0]: row[1] or "" for row in rows})
return settings
finally:
conn.close()
def get_app_setting(key: str, default: str = "") -> str:
return get_app_settings().get(key, default)
def update_app_setting(key: str, value: str) -> None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO app_settings (key, value, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (key)
DO UPDATE SET value = EXCLUDED.value, updated_at = NOW();
""",
(key, value or ""),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def list_parser_sources_from_db(active_only: bool = True) -> list[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor() as cur:
if active_only:
cur.execute(SOURCE_SELECT_SQL + " WHERE is_active = TRUE ORDER BY sort_order ASC, title ASC;")
else:
cur.execute(SOURCE_SELECT_SQL + " ORDER BY sort_order ASC, title ASC;")
rows = cur.fetchall()
return [_row_to_source(row) for row in rows]
finally:
conn.close()
def get_parser_source_from_db(source_key: str) -> dict[str, Any] | None:
source_key = (source_key or "").upper().strip()
if not source_key:
return None
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(SOURCE_SELECT_SQL + " WHERE key = %s LIMIT 1;", (source_key,))
row = cur.fetchone()
return _row_to_source(row) if row else None
finally:
conn.close()
def update_parser_source(source_key: str, values: dict[str, str]) -> None:
source_key = (source_key or "").upper().strip()
if not source_key:
raise ValueError("Не указан ключ источника")
current = get_parser_source_from_db(source_key)
if not current:
current = next((item for item in DEFAULT_PARSER_SOURCES if item["key"] == source_key), None)
if not current:
current = {
"key": source_key,
"title": source_key,
"tournament_id": "",
"round_id": "",
"season": "",
"calendar_type": "tours",
"logo_base_path": "",
"photo_base_path": "",
"channel_logo_base_path": "",
"teams_url": "",
"schedule_url": "",
"standings_url": "",
"match_base_url": "",
"base_url": DEFAULT_APP_SETTINGS["rfs_base_url"],
"sort_order": 100,
"is_active": True,
}
merged = {**current, **{key: value for key, value in values.items() if value is not None}}
merged["key"] = source_key
merged["calendar_type"] = merged.get("calendar_type") or "tours"
merged["base_url"] = merged.get("base_url") or DEFAULT_APP_SETTINGS["rfs_base_url"]
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO parser_sources (
key, title, tournament_id, round_id, season, calendar_type,
logo_base_path, photo_base_path, channel_logo_base_path,
teams_url, schedule_url, standings_url,
match_base_url, base_url, sort_order, is_active, created_at, updated_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
ON CONFLICT (key)
DO UPDATE SET
title = EXCLUDED.title,
tournament_id = EXCLUDED.tournament_id,
round_id = EXCLUDED.round_id,
season = EXCLUDED.season,
calendar_type = EXCLUDED.calendar_type,
logo_base_path = EXCLUDED.logo_base_path,
photo_base_path = EXCLUDED.photo_base_path,
channel_logo_base_path = EXCLUDED.channel_logo_base_path,
teams_url = EXCLUDED.teams_url,
schedule_url = EXCLUDED.schedule_url,
standings_url = EXCLUDED.standings_url,
match_base_url = EXCLUDED.match_base_url,
base_url = EXCLUDED.base_url,
sort_order = EXCLUDED.sort_order,
is_active = EXCLUDED.is_active,
updated_at = NOW();
""",
(
merged.get("key", source_key),
merged.get("title") or source_key,
merged.get("tournament_id") or "",
merged.get("round_id") or "",
merged.get("season") or "",
merged.get("calendar_type") or "tours",
merged.get("logo_base_path") or "",
merged.get("photo_base_path") or "",
merged.get("channel_logo_base_path") or "",
merged.get("teams_url") or "",
merged.get("schedule_url") or "",
merged.get("standings_url") or "",
merged.get("match_base_url") or "",
merged.get("base_url") or DEFAULT_APP_SETTINGS["rfs_base_url"],
int(merged.get("sort_order") or 100),
bool(merged.get("is_active", True)),
),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()

View File

@@ -265,4 +265,56 @@ def replace_match_referees(match_id: int, rows: list[dict]) -> None:
conn.rollback() conn.rollback()
raise raise
finally: finally:
conn.close() conn.close()
def create_referee_admin(
full_name: str,
first_name: str = "",
last_name: str = "",
middle_name: str = "",
city: str = "",
external_id: str = "",
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 (
NULLIF(%s, ''), %s, %s, %s, %s, NULLIF(%s, ''), %s, NOW(), NOW()
)
RETURNING id;
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
query,
(
external_id.strip(),
full_name.strip(),
last_name.strip(),
first_name.strip(),
middle_name.strip(),
city.strip(),
bool(is_active),
),
)
row = cur.fetchone()
conn.commit()
return int(row[0])
except Exception:
conn.rollback()
raise
finally:
conn.close()

View File

@@ -1,4 +1,5 @@
from db import get_connection from db import get_connection
from parsers.parser_sources import extract_logo_filename
def upsert_team( def upsert_team(
@@ -153,7 +154,7 @@ def search_teams_for_admin(q: str = "") -> list[dict]:
"full_name": row[2] or "", "full_name": row[2] or "",
"short_name_3": row[3] or "", "short_name_3": row[3] or "",
"city": row[4] or "", "city": row[4] or "",
"logo_path": row[5] or "", "logo_path": extract_logo_filename(row[5]),
"external_id": row[6] or "", "external_id": row[6] or "",
} }
for row in rows for row in rows
@@ -192,7 +193,7 @@ def get_team_by_id(team_id: int) -> dict | None:
"full_name": row[2] or "", "full_name": row[2] or "",
"short_name_3": row[3] or "", "short_name_3": row[3] or "",
"city": row[4] or "", "city": row[4] or "",
"logo_path": row[5] or "", "logo_path": extract_logo_filename(row[5]),
"external_id": row[6] or "", "external_id": row[6] or "",
} }
finally: finally:
@@ -227,7 +228,7 @@ def update_team_admin(
full_name.strip(), full_name.strip(),
short_name_3.strip().upper(), short_name_3.strip().upper(),
city.strip(), city.strip(),
logo_path.strip(), extract_logo_filename(logo_path),
external_id.strip(), external_id.strip(),
team_id, team_id,
), ),
@@ -237,4 +238,30 @@ def update_team_admin(
conn.rollback() conn.rollback()
raise raise
finally: finally:
conn.close() conn.close()
def list_teams_for_admin_select() -> list[dict]:
"""Возвращает полный компактный список команд для выпадающих списков админки."""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, name, short_name_3, city
FROM teams
ORDER BY name ASC, id ASC
"""
)
rows = cur.fetchall()
return [
{
"id": row[0],
"name": row[1] or "",
"short_name_3": row[2] or "",
"city": row[3] or "",
}
for row in rows
]
finally:
conn.close()

View File

@@ -22,7 +22,8 @@ def get_team_players_for_match_editor(
COALESCE(p.first_name, '') AS first_name, COALESCE(p.first_name, '') AS first_name,
COALESCE(p.number::text, '') AS number, COALESCE(p.number::text, '') AS number,
COALESCE(p.position, '') AS position, COALESCE(p.position, '') AS position,
FALSE AS is_captain FALSE AS is_captain,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM players p FROM players p
WHERE p.team_id = %s WHERE p.team_id = %s
ORDER BY ORDER BY
@@ -51,6 +52,7 @@ def get_team_players_for_match_editor(
"number": row[4] or "", "number": row[4] or "",
"position": row[5] or "", "position": row[5] or "",
"is_captain": bool(row[6]), "is_captain": bool(row[6]),
"photo_enabled": bool(row[7]),
} }
for row in rows for row in rows
] ]

View File

@@ -12,6 +12,8 @@ from parsers.parser_standings import run_parser_standings
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from source_config import match_url
TZ = ZoneInfo("Europe/Moscow") TZ = ZoneInfo("Europe/Moscow")
@@ -202,7 +204,7 @@ def parse_score(value: str) -> int | None:
def fetch_match_live_data(match_id: int) -> dict: def fetch_match_live_data(match_id: int) -> dict:
html = fetch_html(f"https://wfl.rfs.ru/match/{match_id}") html = fetch_html(match_url(match_id))
soup = BeautifulSoup(html, "html.parser") soup = BeautifulSoup(html, "html.parser")
score_box = soup.find("div", class_="score__container") score_box = soup.find("div", class_="score__container")
@@ -217,9 +219,10 @@ def fetch_match_live_data(match_id: int) -> dict:
live = soup.find("section", class_=lambda c: c and "game--live" in c) live = soup.find("section", class_=lambda c: c and "game--live" in c)
if live: if live:
return {"status": "live", "home_score": home, "away_score": away} return {"status": "live", "home_score": home, "away_score": away}
elif not live and home and away: # else:
if home and away:
return {"status": "finished", "home_score": home, "away_score": away} return {"status": "finished", "home_score": home, "away_score": away}
return {"status": "scheduled", "home_score": home, "away_score": away} return {"status": "scheduled", "home_score": home, "away_score": away}
# ========================================================= # =========================================================

View File

@@ -15,7 +15,7 @@ from repositories.auth_repository import (
revoke_auth_session, revoke_auth_session,
) )
IDLE_TIMEOUT_SECONDS = 999_999 IDLE_TIMEOUT_SECONDS = 4 * 60 * 60
SESSION_TOUCH_THROTTLE_SECONDS = 60 SESSION_TOUCH_THROTTLE_SECONDS = 60
PBKDF2_ITERATIONS = 260_000 PBKDF2_ITERATIONS = 260_000

View File

@@ -0,0 +1,148 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
import os
import shutil
from datetime import datetime
from dotenv import dotenv_values, load_dotenv, set_key
from parsers import parser_sources
PROJECT_ROOT = Path(__file__).resolve().parent.parent
ENV_PATH = PROJECT_ROOT / ".env"
def _field(key: str, label: str, value: str = "", *, field_type: str = "text", options: list[dict] | None = None, help_text: str = "", wide: bool = False) -> dict[str, Any]:
return {
"key": key,
"label": label,
"value": value or "",
"type": field_type,
"options": options or [],
"help": help_text,
"wide": wide,
}
def _source_field_key(source_key: str, field: str) -> str:
return f"{source_key}_{field}"
def get_env_file_path() -> Path:
return ENV_PATH
def _dotenv_values() -> dict[str, str]:
if not ENV_PATH.exists():
return {}
raw = dotenv_values(ENV_PATH)
return {key: str(value or "") for key, value in raw.items() if key}
def build_env_settings_context() -> dict[str, Any]:
"""Данные для формы редактирования безопасных ключей .env."""
env_values = _dotenv_values()
sources = parser_sources.list_parser_sources()
source_options = [
{"value": source["key"], "label": source.get("title") or source["key"]}
for source in sources
]
groups: list[dict[str, Any]] = [
{
"title": "Общие настройки источников",
"subtitle": "Эти значения влияют на выбор активного турнира по умолчанию и базовый сайт RFS.",
"fields": [
_field(
"DATA_EVENT_CODE",
"Активный источник по умолчанию",
parser_sources.get_default_source_key(),
field_type="select",
options=source_options,
help_text="Используется, если парсер запускается без выбора источника.",
),
_field(
"RFS_BASE_URL",
"Базовый URL RFS/WFL",
parser_sources.RFS_BASE_URL,
help_text="Обычно https://wfl.rfs.ru",
wide=True,
),
],
}
]
for source in sources:
source_key = source["key"]
title = source.get("title") or source_key
groups.append(
{
"title": title,
"subtitle": f"Ключ источника: {source_key}",
"fields": [
_field(_source_field_key(source_key, "EVENT_NAME"), "Название в интерфейсе", source.get("title", "")),
_field(_source_field_key(source_key, "SEASON"), "Сезон", source.get("season", "")),
_field(_source_field_key(source_key, "TOURNAMENT_ID"), "Tournament ID", source.get("tournament_id", "")),
_field(_source_field_key(source_key, "ROUND_ID"), "Round ID", source.get("round_id", "")),
_field(
_source_field_key(source_key, "CALENDAR_TYPE"),
"Тип календаря",
source.get("calendar_type", "tours"),
field_type="select",
options=[
{"value": "tours", "label": "tours — туры"},
{"value": "stages", "label": "stages — стадии"},
],
),
_field(_source_field_key(source_key, "LOGO_BASE_PATH"), "Папка логотипов", source.get("logo_base_path", ""), wide=True),
_field(_source_field_key(source_key, "TEAMS_URL"), "Ссылка на команды", source.get("teams_url", ""), wide=True),
_field(_source_field_key(source_key, "SCHEDULE_URL"), "Ссылка на расписание", source.get("schedule_url", ""), wide=True),
_field(_source_field_key(source_key, "STANDINGS_URL"), "Ссылка на турнирку", source.get("standings_url", ""), wide=True),
_field(_source_field_key(source_key, "MATCH_BASE_URL"), "Базовая ссылка матча", source.get("match_base_url", ""), wide=True),
],
}
)
editable_keys = [field["key"] for group in groups for field in group["fields"]]
return {
"env_path": str(ENV_PATH),
"groups": groups,
"editable_keys": editable_keys,
"raw_values": env_values,
}
def get_editable_env_keys() -> set[str]:
context = build_env_settings_context()
return set(context["editable_keys"])
def save_env_settings(form_values: dict[str, str]) -> list[str]:
"""Сохраняет только разрешённые ключи в .env и обновляет os.environ."""
editable_keys = get_editable_env_keys()
updates: list[str] = []
ENV_PATH.parent.mkdir(parents=True, exist_ok=True)
if not ENV_PATH.exists():
ENV_PATH.write_text("", encoding="utf-8")
backup_path = ENV_PATH.with_suffix(".env.bak")
try:
shutil.copy2(ENV_PATH, backup_path)
except Exception:
# Бэкап полезен, но не должен ломать сохранение настроек.
pass
for key in sorted(editable_keys):
if key not in form_values:
continue
value = str(form_values.get(key) or "").strip()
set_key(str(ENV_PATH), key, value, quote_mode="always")
os.environ[key] = value
updates.append(key)
load_dotenv(ENV_PATH, override=True)
return updates

View File

@@ -7,13 +7,18 @@ from repositories.match_repository import (
from repositories.player_repository import ( from repositories.player_repository import (
get_player_id_by_name_and_team, get_player_id_by_name_and_team,
get_player_id_by_external_id, get_player_id_by_external_id,
create_player_from_lineup,
) )
from repositories.coach_repository import ( from repositories.coach_repository import (
get_coach_id_by_name_and_team, get_coach_id_by_name_and_team,
get_coach_id_by_external_id, get_coach_id_by_external_id,
create_coach_from_lineup,
) )
from repositories.referee_repository import get_referee_id_by_name, upsert_referee 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_lineup_repository import (
replace_match_lineups,
get_match_lineup_for_editor,
)
from repositories.match_coach_repository import replace_match_coaches from repositories.match_coach_repository import replace_match_coaches
from repositories.match_referee_repository import replace_match_referees from repositories.match_referee_repository import replace_match_referees
@@ -33,160 +38,213 @@ def sync_match_page(
raise ValueError(f"Match not found by external_id: {match_external_id}") raise ValueError(f"Match not found by external_id: {match_external_id}")
match_id, _, home_team_id, away_team_id = match_row match_id, _, home_team_id, away_team_id = match_row
# Сохраняем текущий выбор капитана ДО очистки данных матча. Это важно
# при повторной загрузке с сайта: на сайте капитана иногда не указывают,
# и в таком случае нельзя молча терять уже сделанный оператором выбор.
previous_lineup = get_match_lineup_for_editor(
match_id=match_id,
home_team_id=home_team_id,
away_team_id=away_team_id,
)
def previous_captain(side: str) -> dict | None:
rows = previous_lineup.get(f"{side}_starting", []) or []
for player in rows:
if bool(player.get("is_captain")):
return {
"player_id": player.get("player_id"),
"number": str(player.get("number") or "").strip(),
}
return None
previous_captains = {
"home": previous_captain("home"),
"away": previous_captain("away"),
}
clear_match_squad_data(match_id) clear_match_squad_data(match_id)
created_players_count = 0
created_coaches_count = 0
def resolve_player(player: dict, team_id: int) -> tuple[int | None, str | None]:
"""Находит игрока из протокола или создаёт его в справочнике автоматически."""
nonlocal created_players_count
player_name = str(player.get("player_name") or "").strip()
player_external_id = str(player.get("player_external_id") or "").strip()
number = str(player.get("number") or "").strip()
position = str(player.get("position") or "").strip()
player_id = None
player_position = None
if player_external_id:
player_row = get_player_id_by_external_id(player_external_id)
if player_row:
player_id, player_position = player_row
if player_id is None and player_name:
player_id = get_player_id_by_name_and_team(player_name, team_id)
if player_id is None and player_name:
player_row = create_player_from_lineup(
team_id=team_id,
external_id=player_external_id,
full_name=player_name,
number=number,
position=position,
)
if player_row:
player_id, player_position = player_row
created_players_count += 1
return player_id, player_position or position
def resolve_coach(coach: dict, team_id: int) -> int | None:
"""Находит тренера из протокола или создаёт его в справочнике автоматически."""
nonlocal created_coaches_count
coach_name = str(coach.get("coach_name") or "").strip()
coach_external_id = str(coach.get("coach_external_id") or "").strip()
role = str(coach.get("role") or "").strip()
coach_id = None
if coach_external_id:
coach_id = get_coach_id_by_external_id(coach_external_id)
if coach_id is None and coach_name:
coach_id = get_coach_id_by_name_and_team(coach_name, team_id)
if coach_id is None and coach_name:
coach_id = create_coach_from_lineup(
team_id=team_id,
external_id=coach_external_id,
full_name=coach_name,
role=role,
)
if coach_id:
created_coaches_count += 1
return coach_id
lineup_rows = [] lineup_rows = []
for player in home_starting:
player_id = None
player_position = None
if player.get("player_external_id"): def append_players(players: list[dict], team_id: int, lineup_type: str) -> None:
player_id, player_position = get_player_id_by_external_id(player["player_external_id"]) for player in players:
player_id, player_position = resolve_player(player, team_id)
if player_id is None: lineup_rows.append(
player_id = get_player_id_by_name_and_team( {
player["player_name"], home_team_id "match_id": match_id,
) "team_id": team_id,
print(player_id, player_position) "player_id": player_id,
"player_name": player.get("player_name") or "",
"number": player.get("number"),
lineup_rows.append( "position": player.get("position"),
{ "position_full": player_position,
"match_id": match_id, # Капитаном может быть только игрок основного состава.
"team_id": home_team_id, # Если сайт по ошибке пометил игрока запаса, не переносим
"player_id": player_id, # такой флаг в матчевые данные.
"player_name": player["player_name"], "is_captain": bool(player.get("is_captain")) and lineup_type == "starting",
"number": player.get("number"), "lineup_type": lineup_type,
"position": player.get("position"), "source": "parser",
"position_full": player_position, }
"is_captain": bool(player.get("is_captain")),
"lineup_type": "starting",
"source": "parser",
}
)
for player in away_starting:
player_id = None
player_position = None
if player.get("player_external_id"):
player_id, player_position = 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( append_players(home_starting, home_team_id, "starting")
{ append_players(away_starting, away_team_id, "starting")
"match_id": match_id, append_players(home_bench, home_team_id, "bench")
"team_id": away_team_id, append_players(away_bench, away_team_id, "bench")
"player_id": player_id,
"player_name": player["player_name"],
"number": player.get("number"),
"position": player.get("position"),
"position_full": player_position,
"is_captain": bool(player.get("is_captain")),
"lineup_type": "starting",
"source": "parser",
}
)
for player in home_bench: def restore_previous_captain_if_missing(side: str, team_id: int) -> bool:
player_id = None """Возвращает старого капитана только когда сайт не указал нового.
player_position = None
if player.get("player_external_id"): Восстановление разрешено исключительно для игрока, который всё ещё
player_id, player_position = get_player_id_by_external_id(player["player_external_id"]) присутствует в основном составе. Если прежний капитан отсутствует или
ушёл в запас, капитан остаётся не выбран — веб-интерфейс покажет
обязательное предупреждение оператору.
"""
starting_rows = [
row for row in lineup_rows
if row.get("team_id") == team_id and row.get("lineup_type") == "starting"
]
if player_id is None: imported_captains = [row for row in starting_rows if bool(row.get("is_captain"))]
player_id = get_player_id_by_name_and_team(
player["player_name"], home_team_id
)
lineup_rows.append( # Ровно один капитан с сайта — корректные данные, они имеют приоритет.
{ if len(imported_captains) == 1:
"match_id": match_id, return False
"team_id": home_team_id,
"player_id": player_id,
"player_name": player["player_name"],
"number": player.get("number"),
"position": player.get("position"),
"position_full": player_position,
"is_captain": bool(player.get("is_captain")),
"lineup_type": "bench",
"source": "parser",
}
)
for player in away_bench: # Если сайт по ошибке передал больше одного капитана, не выбираем
player_id = None # случайного игрока. Сбрасываем конфликт и используем тот же безопасный
player_position = None # fallback, что и при полностью отсутствующем капитане.
if len(imported_captains) > 1:
for row in imported_captains:
row["is_captain"] = False
if player.get("player_external_id"): old_captain = previous_captains.get(side)
player_id, player_position = get_player_id_by_external_id(player["player_external_id"]) if not old_captain:
return False
if player_id is None: old_player_id = old_captain.get("player_id")
player_id = get_player_id_by_name_and_team( old_number = str(old_captain.get("number") or "").strip()
player["player_name"], away_team_id
)
lineup_rows.append( # Сначала точное совпадение по player_id.
{ if old_player_id is not None:
"match_id": match_id, for row in starting_rows:
"team_id": away_team_id, if row.get("player_id") is not None and str(row.get("player_id")) == str(old_player_id):
"player_id": player_id, row["is_captain"] = True
"player_name": player["player_name"], return True
"number": player.get("number"),
"position": player.get("position"), # Fallback для старых/неполных данных — по игровому номеру.
"position_full": player_position, if old_number:
"is_captain": bool(player.get("is_captain")), matches = [
"lineup_type": "bench", row for row in starting_rows
"source": "parser", if str(row.get("number") or "").strip() == old_number
} ]
if len(matches) == 1:
matches[0]["is_captain"] = True
return True
return False
restored_home_captain = restore_previous_captain_if_missing("home", home_team_id)
restored_away_captain = restore_previous_captain_if_missing("away", away_team_id)
if restored_home_captain or restored_away_captain:
restored = []
if restored_home_captain:
restored.append("home")
if restored_away_captain:
restored.append("away")
print(
f"[parser_game] website captain missing for match={match_external_id}; "
f"preserved previous captain for: {', '.join(restored)}"
) )
coach_rows = [] coach_rows = []
for coach in home_coaches: def append_coaches(coaches: list[dict], team_id: int, side: str) -> None:
coach_id = None for coach in coaches:
coach_id = resolve_coach(coach, team_id)
if coach.get("coach_external_id"): coach_rows.append(
coach_id = get_coach_id_by_external_id(coach["coach_external_id"]) {
"match_id": match_id,
"team_id": team_id,
"side": side,
"coach_id": coach_id,
"coach_name": coach.get("coach_name") or "",
"role": coach.get("role"),
"source": "parser",
}
)
if coach_id is None: append_coaches(home_coaches, home_team_id, "home")
coach_id = get_coach_id_by_name_and_team(coach["coach_name"], home_team_id) append_coaches(away_coaches, away_team_id, "away")
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 = [] referee_rows = []
@@ -209,13 +267,15 @@ def sync_match_page(
try: try:
replace_match_lineups(match_id, lineup_rows) 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_coaches(match_id, coach_rows)
replace_match_referees(match_id, referee_rows) replace_match_referees(match_id, referee_rows)
mark_match_parsed(match_external_id) mark_match_parsed(match_external_id)
if created_players_count or created_coaches_count:
print(
f"[parser_game] auto-created for match={match_external_id}: "
f"players={created_players_count}, coaches={created_coaches_count}"
)
except Exception as e: except Exception as e:
mark_match_parse_error(match_external_id, str(e)) mark_match_parse_error(match_external_id, str(e))
raise raise

View File

@@ -0,0 +1,144 @@
from __future__ import annotations
from typing import Any
from parsers import parser_sources
from repositories.project_settings_repository import update_app_setting, update_parser_source
def _field(
key: str,
label: str,
value: str = "",
*,
field_type: str = "text",
options: list[dict] | None = None,
help_text: str = "",
wide: bool = False,
) -> dict[str, Any]:
return {
"key": key,
"label": label,
"value": value or "",
"type": field_type,
"options": options or [],
"help": help_text,
"wide": wide,
}
def _source_field_key(source_key: str, field: str) -> str:
return f"{source_key}_{field}"
def build_project_settings_context() -> dict[str, Any]:
"""Данные для формы редактирования настроек проекта из БД."""
sources = parser_sources.list_parser_sources()
source_options = [
{"value": source["key"], "label": source.get("title") or source["key"]}
for source in sources
]
groups: list[dict[str, Any]] = [
{
"title": "Общие настройки проекта",
"subtitle": "Эти значения хранятся в базе данных, а не в .env.",
"fields": [
_field(
"DATA_EVENT_CODE",
"Активный источник по умолчанию",
parser_sources.get_default_source_key(),
field_type="select",
options=source_options,
help_text="Используется, если парсер запускается без выбора источника.",
),
_field(
"RFS_BASE_URL",
"Базовый URL RFS/WFL",
parser_sources.get_rfs_base_url(),
help_text="Обычно https://wfl.rfs.ru",
wide=True,
),
],
}
]
for source in sources:
source_key = source["key"]
title = source.get("title") or source_key
groups.append(
{
"title": title,
"subtitle": f"Ключ источника: {source_key}",
"fields": [
_field(_source_field_key(source_key, "EVENT_NAME"), "Название в интерфейсе", source.get("title", "")),
_field(_source_field_key(source_key, "SEASON"), "Сезон", source.get("season", "")),
_field(_source_field_key(source_key, "TOURNAMENT_ID"), "Tournament ID", source.get("tournament_id", "")),
_field(_source_field_key(source_key, "ROUND_ID"), "Round ID", source.get("round_id", "")),
_field(
_source_field_key(source_key, "CALENDAR_TYPE"),
"Тип календаря",
source.get("calendar_type", "tours"),
field_type="select",
options=[
{"value": "tours", "label": "tours — туры"},
{"value": "stages", "label": "stages — стадии"},
],
),
_field(_source_field_key(source_key, "LOGO_BASE_PATH"), "Папка логотипов", source.get("logo_base_path", ""), wide=True),
_field(_source_field_key(source_key, "PHOTO_BASE_PATH"), "Папка фотографий", source.get("photo_base_path", ""), wide=True),
_field(_source_field_key(source_key, "CHANNEL_LOGO_BASE_PATH"), "Папка логотипов каналов", source.get("channel_logo_base_path", ""), wide=True),
_field(_source_field_key(source_key, "TEAMS_URL"), "Ссылка на команды", source.get("teams_url", ""), wide=True),
_field(_source_field_key(source_key, "SCHEDULE_URL"), "Ссылка на расписание", source.get("schedule_url", ""), wide=True),
_field(_source_field_key(source_key, "STANDINGS_URL"), "Ссылка на турнирку", source.get("standings_url", ""), wide=True),
_field(_source_field_key(source_key, "MATCH_BASE_URL"), "Базовая ссылка матча", source.get("match_base_url", ""), wide=True),
],
}
)
editable_keys = [field["key"] for group in groups for field in group["fields"]]
return {
"storage_title": "База данных",
"groups": groups,
"editable_keys": editable_keys,
}
def save_project_settings(form_values: dict[str, str]) -> list[str]:
"""Сохраняет настройки проекта в БД."""
updated: list[str] = []
default_source_key = str(form_values.get("DATA_EVENT_CODE") or "SUPERLEAGUE").upper().strip()
rfs_base_url = str(form_values.get("RFS_BASE_URL") or "https://wfl.rfs.ru").strip().rstrip("/")
update_app_setting("default_parser_source_key", default_source_key)
update_app_setting("rfs_base_url", rfs_base_url)
updated.extend(["DATA_EVENT_CODE", "RFS_BASE_URL"])
sources = parser_sources.list_parser_sources()
field_map = {
"EVENT_NAME": "title",
"SEASON": "season",
"TOURNAMENT_ID": "tournament_id",
"ROUND_ID": "round_id",
"CALENDAR_TYPE": "calendar_type",
"LOGO_BASE_PATH": "logo_base_path",
"PHOTO_BASE_PATH": "photo_base_path",
"CHANNEL_LOGO_BASE_PATH": "channel_logo_base_path",
"TEAMS_URL": "teams_url",
"SCHEDULE_URL": "schedule_url",
"STANDINGS_URL": "standings_url",
"MATCH_BASE_URL": "match_base_url",
}
for source in sources:
source_key = source["key"]
values: dict[str, str] = {"base_url": rfs_base_url}
for form_field, db_field in field_map.items():
full_key = _source_field_key(source_key, form_field)
if full_key in form_values:
values[db_field] = str(form_values.get(full_key) or "").strip()
updated.append(full_key)
update_parser_source(source_key, values)
return updated

View File

@@ -16,4 +16,5 @@ def sync_matches(matches_data: list[dict]) -> None:
place=match.get("place"), place=match.get("place"),
date_raw=match.get("date_raw"), date_raw=match.get("date_raw"),
score_add=match.get("score_add"), score_add=match.get("score_add"),
) source_key=match.get("source_key"),
)

View File

@@ -1,9 +1,268 @@
# services/vmix_json_service.py # services/vmix_json_service.py
from db import get_connection from db import get_connection
from repositories.match_lineup_repository import get_match_lineup_for_vmix from repositories.match_lineup_repository import get_match_lineup_for_vmix
from repositories.match_repository import resolve_match_source_key
from parsers.parser_sources import build_empty_photo_path, build_logo_path, build_logo_variant_path, build_photo_path
def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, team_b_name): DEFAULT_PHOTO_BASE_PATH = r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo"
EMPTY_PHOTO_PATH = DEFAULT_PHOTO_BASE_PATH + r"\EMPTY.png"
def _session_source_key(session_row) -> str | None:
try:
match_id = session_row[1]
except Exception:
match_id = None
try:
value = session_row[21]
except Exception:
value = None
return resolve_match_source_key(match_id, value)
def build_generated_player_photo_path(
team_name: str,
last_name: str,
first_name: str,
source_key: str | None = None,
) -> str:
relative_file = (
str(team_name or "").strip()
+ "\\"
+ (str(last_name or "") + " " + str(first_name or "")).strip()
+ ".png"
)
return build_photo_path(source_key, relative_file)
def resolve_player_photo(
photo_enabled: bool,
generated_photo: str,
source_key: str | None = None,
) -> str:
if photo_enabled:
return generated_photo
return build_empty_photo_path(source_key) or EMPTY_PHOTO_PATH
def _normalize_text(value) -> str:
return " ".join(str(value or "").strip().lower().split())
def _player_name_variants(player: dict) -> set[str]:
first_name = _normalize_text(player.get("first_name"))
last_name = _normalize_text(player.get("last_name"))
player_name = _normalize_text(player.get("player_name"))
full_name = _normalize_text(player.get("full_name"))
variants = {player_name, full_name}
if first_name or last_name:
variants.add(f"{last_name} {first_name}".strip())
variants.add(f"{first_name} {last_name}".strip())
return {v for v in variants if v}
def _candidate_name_variants(row: tuple) -> set[str]:
_, full_name, last_name, first_name, *_ = row
full_name = _normalize_text(full_name)
last_name = _normalize_text(last_name)
first_name = _normalize_text(first_name)
variants = {full_name}
if first_name or last_name:
variants.add(f"{last_name} {first_name}".strip())
variants.add(f"{first_name} {last_name}".strip())
return {v for v in variants if v}
def _find_player_photo_state(
*,
player_id=None,
team_id=None,
player_name: str = "",
first_name: str = "",
last_name: str = "",
full_name: str = "",
number: str = "",
) -> tuple[str | None, bool] | None:
"""
Берёт актуальное состояние галочки photo_enabled прямо из players.
Если в составе нет корректного player_id, пробует найти игрока по команде, номеру и имени.
Это важно для старых/загруженных с сайта составов, где player_id мог быть пустым.
Значение p.photo здесь не используется для JSON-пути: путь генерируется по старой схеме.
"""
try:
player_id_int = int(player_id) if str(player_id or "").strip().isdigit() else None
except Exception:
player_id_int = None
try:
team_id_int = int(team_id) if str(team_id or "").strip().isdigit() else None
except Exception:
team_id_int = None
target_number = str(number or "").strip()
target_player = {
"player_name": player_name,
"first_name": first_name,
"last_name": last_name,
"full_name": full_name,
}
target_names = _player_name_variants(target_player)
where_parts = []
params = []
if player_id_int is not None:
where_parts.append("p.id = %s")
params.append(player_id_int)
if team_id_int is not None:
team_conditions = []
team_params = []
if target_number:
team_conditions.append("COALESCE(p.number::text, '') = %s")
team_params.append(target_number)
for name in sorted(target_names):
like = f"%{name}%"
team_conditions.append(
"""
(
LOWER(COALESCE(p.full_name, '')) = %s
OR LOWER(TRIM(COALESCE(p.last_name, '') || ' ' || COALESCE(p.first_name, ''))) = %s
OR LOWER(TRIM(COALESCE(p.first_name, '') || ' ' || COALESCE(p.last_name, ''))) = %s
OR LOWER(COALESCE(p.full_name, '')) LIKE %s
)
"""
)
team_params.extend([name, name, name, like])
if team_conditions:
where_parts.append(f"(p.team_id = %s AND ({' OR '.join(team_conditions)}))")
params.append(team_id_int)
params.extend(team_params)
if not where_parts:
return None
query = f"""
SELECT
p.id,
COALESCE(p.full_name, ''),
COALESCE(p.last_name, ''),
COALESCE(p.first_name, ''),
COALESCE(p.number::text, ''),
p.team_id,
p.photo,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM players p
WHERE {' OR '.join(where_parts)}
LIMIT 50
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(query, tuple(params))
rows = cur.fetchall()
finally:
conn.close()
best_row = None
best_score = -1
for row in rows:
row_id, _, _, _, row_number, row_team_id, *_ = row
row_names = _candidate_name_variants(row)
id_match = player_id_int is not None and int(row_id) == player_id_int
team_match = team_id_int is not None and int(row_team_id) == team_id_int
number_match = bool(target_number) and str(row_number or "").strip() == target_number
name_match = bool(target_names.intersection(row_names))
partial_name_match = False
if target_names and row_names:
partial_name_match = any(
target in candidate or candidate in target
for target in target_names
for candidate in row_names
if len(target) >= 4 and len(candidate) >= 4
)
# Если ID выглядит как номер игрока и случайно совпал с чужим players.id,
# не считаем его надёжным без совпадения команды/имени/номера.
trusted_id_match = id_match and (
(team_id_int is None or team_match)
and (not target_number and not target_names or number_match or name_match or partial_name_match)
)
score = 0
if trusted_id_match:
score += 100
if team_match:
score += 40
if number_match:
score += 30
if name_match:
score += 35
elif partial_name_match:
score += 15
# Для поиска без надёжного ID нужно хотя бы совпадение команды и номера/имени.
if not trusted_id_match and not (team_match and (number_match or name_match or partial_name_match)):
continue
if score > best_score:
best_score = score
best_row = row
if not best_row:
return None
return best_row[6], bool(best_row[7])
def resolve_player_photo_for_json(
player: dict,
generated_photo: str,
fallback_team_id=None,
source_key: str | None = None,
) -> str:
state = _find_player_photo_state(
player_id=player.get("player_id") or player.get("id"),
team_id=player.get("team_id") or fallback_team_id,
player_name=player.get("player_name") or "",
first_name=player.get("first_name") or "",
last_name=player.get("last_name") or "",
full_name=player.get("full_name") or "",
number=player.get("number") or player.get("player_number") or "",
)
if state is not None:
photo_value, photo_enabled = state
resolved_photo = build_photo_path(source_key, photo_value) if photo_value else generated_photo
return resolve_player_photo(
photo_enabled=photo_enabled,
generated_photo=resolved_photo,
source_key=source_key,
)
photo_value = player.get("photo") or ""
resolved_photo = build_photo_path(source_key, photo_value) if photo_value else generated_photo
return resolve_player_photo(
photo_enabled=player.get("photo_enabled"),
generated_photo=resolved_photo,
source_key=source_key,
)
def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, team_b_name, source_key=None):
lineups = get_match_lineup_for_vmix( lineups = get_match_lineup_for_vmix(
match_id=match_id, match_id=match_id,
home_team_id=home_team_id, home_team_id=home_team_id,
@@ -14,6 +273,8 @@ def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, t
result = [] result = []
fallback_team_id = home_team_id if str(name).startswith("home") else away_team_id
for p in players: for p in players:
suffix = [] suffix = []
if "вратарь" in (p.get("pos") or "").lower(): if "вратарь" in (p.get("pos") or "").lower():
@@ -40,17 +301,18 @@ def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, t
+ (f" {', '.join(suffix)}" if suffix else ""), + (f" {', '.join(suffix)}" if suffix else ""),
"pos": p.get("pos", ""), "pos": p.get("pos", ""),
"position": p.get("position", ""), "position": p.get("position", ""),
"photo": ( "photo": resolve_player_photo_for_json(
r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo" p,
+ "\\" generated_photo=build_generated_player_photo_path(
+ (team_a_name if "home" in name else team_b_name) team_name=team_a_name if "home" in name else team_b_name,
+ "\\" last_name=p.get("last_name", ""),
+ (p.get("last_name", "") first_name=p.get("first_name", ""),
+ " " source_key=source_key,
+ p.get("first_name", "")).strip() ),
+ ".png" fallback_team_id=fallback_team_id,
), source_key=source_key,
} ),
}
) )
return {"players": result} return {"players": result}
@@ -78,43 +340,16 @@ def get_vmix_match_info_by_token(session_token: str):
m.tour, m.tour,
ht.logo_path AS home_logo, ht.logo_path AS home_logo,
REPLACE(at.logo_path, 'HOME', 'AWAY') AS away_logo, at.logo_path AS away_logo,
ref1.referee_name AS referee1, ref1.referee_name AS referee1,
ref2.referee_name AS referee2, ref2.referee_name AS referee2,
ref3.referee_name AS referee3, ref3.referee_name AS referee3,
ref4.referee_name AS referee4, ref4.referee_name AS referee4,
CASE ht.logo_path AS home_logo1,
WHEN ht.name ILIKE '%%динамо%%' at.logo_path AS away_logo1,
THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Динамо', 'Динамоелый') ht.logo_path AS home_logo2,
WHEN ht.name ILIKE '%%зенит%%' at.logo_path AS away_logo2,
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, ht.city AS home_city,
at.city AS away_city, at.city AS away_city,
@@ -123,7 +358,8 @@ def get_vmix_match_info_by_token(session_token: str):
c1.amplua AS coach_amplua1, c1.amplua AS coach_amplua1,
TRIM(COALESCE(c2.name, '') || ' ' || COALESCE(c2.lastname, '')) AS coach_name2, TRIM(COALESCE(c2.name, '') || ' ' || COALESCE(c2.lastname, '')) AS coach_name2,
c2.amplua AS coach_amplua2 c2.amplua AS coach_amplua2,
m.source_key AS source_key
FROM match_sessions ms FROM match_sessions ms
JOIN matches m ON m.id = ms.match_id JOIN matches m ON m.id = ms.match_id
@@ -151,23 +387,35 @@ def get_vmix_match_info_by_token(session_token: str):
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(query, (session_token,)) cur.execute(query, (session_token,))
return cur.fetchone() row = cur.fetchone()
if not row:
return None
row = list(row)
source_key = resolve_match_source_key(row[0], row[30] if len(row) > 30 else None)
if len(row) > 30:
row[30] = source_key
row[14] = build_logo_path(source_key, row[14])
row[15] = build_logo_path(source_key, row[15])
row[20] = build_logo_variant_path(source_key, row[20], "white")
row[21] = build_logo_variant_path(source_key, row[21], "white")
row[22] = build_logo_variant_path(source_key, row[22], "blue")
row[23] = build_logo_variant_path(source_key, row[23], "blue")
return tuple(row)
finally: finally:
conn.close() conn.close()
def get_vmix_standings(session_token: str): def get_vmix_standings(session_row):
source_key = _session_source_key(session_row)
season = session_row[11] if len(session_row) > 11 else None
query = """ query = """
SELECT SELECT
s.position, s.position,
t.full_name, COALESCE(t.full_name, t.name) AS team_name,
CASE t.logo_path AS logo,
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.played,
s.wins, s.wins,
s.losses, s.losses,
@@ -177,52 +425,66 @@ def get_vmix_standings(session_token: str):
s.team_id s.team_id
FROM standings s FROM standings s
LEFT JOIN teams t ON s.team_id = t.id LEFT JOIN teams t ON s.team_id = t.id
WHERE (%s IS NULL OR s.season = %s)
ORDER BY s.position ORDER BY s.position
""" """
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(query, (session_token,)) cur.execute(query, (season, season))
return cur.fetchall() rows = cur.fetchall()
result = []
for row in rows:
row = list(row)
row[2] = build_logo_variant_path(source_key, row[2], "white")
result.append(tuple(row))
return result
finally: finally:
conn.close() conn.close()
def get_vmix_schedule(session_token: str): def get_vmix_schedule(session_row):
source_key = _session_source_key(session_row)
tour = session_row[10] if len(session_row) > 10 else None
season = session_row[11] if len(session_row) > 11 else None
source_filter = source_key or "SUPERLEAGUE"
query = """ query = """
SELECT SELECT
CASE t1.logo_path AS logo1,
WHEN t1.full_name ILIKE '%%динамо%%' t2.logo_path AS logo2,
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.home_score,
m.away_score, m.away_score,
m.match_date, m.match_date,
m.status, m.status,
m.id m.id,
COALESCE(m.channel, '') AS channel,
COALESCE(NULLIF(m.source_key, ''), %s) AS source_key
FROM matches m FROM matches m
LEFT JOIN teams t1 ON m.home_team_id = t1.id LEFT JOIN teams t1 ON m.home_team_id = t1.id
LEFT JOIN teams t2 ON m.away_team_id = t2.id LEFT JOIN teams t2 ON m.away_team_id = t2.id
WHERE m.tour = %s WHERE m.tour = %s
AND (%s IS NULL OR m.season = %s)
AND COALESCE(NULLIF(m.source_key, ''), %s) = %s
ORDER BY m.match_date, m.id ORDER BY m.match_date, m.id
""" """
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(query, (session_token[10],)) cur.execute(query, (source_filter, tour, season, season, source_filter, source_filter))
return cur.fetchall() rows = cur.fetchall()
result = []
for row in rows:
row = list(row)
row_source_key = row[8] if len(row) > 8 else source_key
row[0] = build_logo_variant_path(row_source_key, row[0], "white")
row[1] = build_logo_variant_path(row_source_key, row[1], "white")
result.append(tuple(row))
return result
finally: finally:
conn.close() conn.close()
@@ -230,13 +492,59 @@ def get_vmix_schedule(session_token: str):
def get_vmix_team_formations(session_token: str, team_id: int): def get_vmix_team_formations(session_token: str, team_id: int):
query = """ query = """
SELECT SELECT
p.last_name, COALESCE(NULLIF(p.last_name, ''), NULLIF(mf.player_name, ''), '') AS last_name,
mf.is_captain, mf.is_captain,
p.number, COALESCE(NULLIF(mf.number, ''), p.number::text, '') AS number,
p.position, COALESCE(NULLIF(mf.position, ''), p.position, '') AS position,
p.first_name COALESCE(p.first_name, '') AS first_name,
p.photo,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled,
mf.player_id,
mf.team_id,
COALESCE(mf.player_name, '') AS player_name
FROM match_formations mf FROM match_formations mf
LEFT JOIN players p ON p.id = mf.player_id LEFT JOIN LATERAL (
SELECT p.*
FROM players p
WHERE p.team_id = mf.team_id
AND (
p.id = mf.player_id
OR (
COALESCE(p.number::text, '') <> ''
AND COALESCE(p.number::text, '') = COALESCE(mf.number, '')
)
OR (
COALESCE(mf.player_name, '') <> ''
AND (
LOWER(COALESCE(p.full_name, '')) = LOWER(TRIM(mf.player_name))
OR LOWER(TRIM(COALESCE(p.last_name, '') || ' ' || COALESCE(p.first_name, ''))) = LOWER(TRIM(mf.player_name))
OR LOWER(TRIM(COALESCE(p.first_name, '') || ' ' || COALESCE(p.last_name, ''))) = LOWER(TRIM(mf.player_name))
)
)
)
ORDER BY
CASE
WHEN p.id = mf.player_id
AND (
COALESCE(mf.number, '') = ''
OR COALESCE(p.number::text, '') = COALESCE(mf.number, '')
OR LOWER(COALESCE(p.full_name, '')) = LOWER(TRIM(COALESCE(mf.player_name, '')))
OR LOWER(TRIM(COALESCE(p.last_name, '') || ' ' || COALESCE(p.first_name, ''))) = LOWER(TRIM(COALESCE(mf.player_name, '')))
OR LOWER(TRIM(COALESCE(p.first_name, '') || ' ' || COALESCE(p.last_name, ''))) = LOWER(TRIM(COALESCE(mf.player_name, '')))
)
THEN 0
WHEN COALESCE(p.number::text, '') = COALESCE(mf.number, '')
AND COALESCE(mf.player_name, '') <> ''
THEN 1
WHEN COALESCE(mf.player_name, '') <> ''
THEN 2
WHEN COALESCE(p.number::text, '') = COALESCE(mf.number, '')
THEN 3
ELSE 4
END,
p.id
LIMIT 1
) p ON TRUE
WHERE mf.match_id = %s and mf.team_id = %s WHERE mf.match_id = %s and mf.team_id = %s
ORDER BY mf.id ORDER BY mf.id
""" """
@@ -332,7 +640,10 @@ WHERE m.match_id = %s;
conn.close() conn.close()
def get_vmix_players_goal(match_id: int): def get_vmix_players_goal2(match_id: int):
"""запрос для получения игроков, забивших голы, с указанием минут и типа гола (основной, пенальти, автогол)
Иванов 23', 25' (дубли пишуться вместе)
"""
query = """ query = """
WITH goal_events AS ( WITH goal_events AS (
SELECT SELECT
@@ -372,10 +683,10 @@ grouped_players AS (
( (
CASE CASE
WHEN period_no = 1 AND seconds > 2700 WHEN period_no = 1 AND seconds > 2700
THEN '45''' || '+ ' || ((seconds - 2700) / 60)::int::text THEN '45''' || '+' || ((seconds - 2700) / 60)::int::text
WHEN period_no = 2 AND seconds > 5400 WHEN period_no = 2 AND seconds > 5400
THEN '90''' || '+ ' || ((seconds - 5400) / 60)::int::text THEN '90''' || '+' || ((seconds - 5400) / 60)::int::text
WHEN period_no = 2 WHEN period_no = 2
THEN (46 + ((seconds - 2700) / 60)::int)::text || '''' THEN (46 + ((seconds - 2700) / 60)::int)::text || ''''
@@ -422,3 +733,95 @@ ORDER BY COALESCE(h.rn, a.rn);
return cur.fetchall() return cur.fetchall()
finally: finally:
conn.close() 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')
),
goal_rows AS (
SELECT
side,
last_name,
seconds,
id,
last_name || ' ' ||
(
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 AS player_goal
FROM goal_events
),
home_rows AS (
SELECT
ROW_NUMBER() OVER (ORDER BY seconds, id, last_name) AS rn,
player_goal AS player_name1
FROM goal_rows
WHERE side = 'home'
),
away_rows AS (
SELECT
ROW_NUMBER() OVER (ORDER BY seconds, id, last_name) AS rn,
player_goal AS player_name2
FROM goal_rows
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()

32
source_config.py Normal file
View File

@@ -0,0 +1,32 @@
from parsers.parser_sources import (
RFS_BASE_URL,
get_default_source_key,
get_parser_source,
source_absolute_url,
source_match_url,
)
_default_source = get_parser_source()
# Старые константы оставлены для совместимости с уже существующим кодом.
DATA_EVENT_CODE = get_default_source_key()
DATA_EVENT_NAME = _default_source["title"]
DATA_TOURNAMENT_ID = _default_source["tournament_id"]
DATA_ROUND_ID = _default_source["round_id"]
DATA_SEASON = _default_source["season"]
DATA_TEAMS_URL = _default_source["teams_url"]
DATA_SCHEDULE_URL = _default_source["schedule_url"]
DATA_STANDINGS_URL = _default_source["standings_url"]
DATA_MATCH_BASE_URL = _default_source["match_base_url"]
DATA_LOGO_BASE_PATH = _default_source.get("logo_base_path", "")
DATA_PHOTO_BASE_PATH = _default_source.get("photo_base_path", "")
def absolute_url(path_or_url: str) -> str:
"""Возвращает абсолютную ссылку для href с сайта WFL."""
return source_absolute_url(_default_source, path_or_url)
def match_url(match_external_id: str | int) -> str:
return source_match_url(_default_source, match_external_id)

View File

@@ -0,0 +1,2 @@
ALTER TABLE players
ADD COLUMN IF NOT EXISTS photo_enabled BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,4 @@
ALTER TABLE matches
ADD COLUMN IF NOT EXISTS source_key VARCHAR(50);
CREATE INDEX IF NOT EXISTS idx_matches_source_key ON matches(source_key);

View File

@@ -0,0 +1,79 @@
-- Настройки проекта и источники парсинга теперь хранятся в БД, а не в .env.
CREATE TABLE IF NOT EXISTS app_settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS parser_sources (
key VARCHAR(50) PRIMARY KEY,
title VARCHAR(255) NOT NULL,
tournament_id VARCHAR(100),
round_id VARCHAR(100),
season VARCHAR(50),
calendar_type VARCHAR(50) NOT NULL DEFAULT 'tours',
logo_base_path TEXT,
photo_base_path TEXT,
teams_url TEXT,
schedule_url TEXT,
standings_url TEXT,
match_base_url TEXT,
base_url TEXT NOT NULL DEFAULT 'https://wfl.rfs.ru',
sort_order INTEGER NOT NULL DEFAULT 100,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
INSERT INTO app_settings (key, value, updated_at)
VALUES
('default_parser_source_key', 'SUPERLEAGUE', NOW()),
('rfs_base_url', 'https://wfl.rfs.ru', NOW())
ON CONFLICT (key) DO NOTHING;
INSERT INTO parser_sources (
key, title, tournament_id, round_id, season, calendar_type, logo_base_path, photo_base_path,
teams_url, schedule_url, standings_url, match_base_url, base_url,
sort_order, is_active, created_at, updated_at
)
VALUES
(
'SUPERLEAGUE',
'Суперлига 2026',
'1061879',
'1117550',
'2025/2026',
'tours',
'D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Teams Logos',
'D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo',
'https://wfl.rfs.ru/tournament/1061879/teams',
'https://wfl.rfs.ru/tournament/1061879/calendar?round_id=1117550&type=tours',
'https://wfl.rfs.ru/tournament/1061879/tables',
'https://wfl.rfs.ru/match/',
'https://wfl.rfs.ru',
10,
TRUE,
NOW(),
NOW()
),
(
'RUSSIAN_CUP',
'Кубок России 2026',
'1064908',
'1125159',
'2026',
'stages',
'D:\Графика\ФУТБОЛ\Кубок России 2026\Teams Logos',
'D:\Графика\ФУТБОЛ\Кубок России 2026\Photo',
'https://wfl.rfs.ru/tournament/1064908/teams',
'https://wfl.rfs.ru/tournament/1064908/calendar?round_id=1125159&type=stages',
'https://wfl.rfs.ru/tournament/1064908/tables',
'https://wfl.rfs.ru/match/',
'https://wfl.rfs.ru',
20,
TRUE,
NOW(),
NOW()
)
ON CONFLICT (key) DO NOTHING;

View File

@@ -0,0 +1,14 @@
-- Папка фотографий для каждого источника турнира.
ALTER TABLE parser_sources
ADD COLUMN IF NOT EXISTS photo_base_path TEXT;
UPDATE parser_sources
SET photo_base_path = 'D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo'
WHERE key = 'SUPERLEAGUE'
AND (photo_base_path IS NULL OR TRIM(photo_base_path) = '');
UPDATE parser_sources
SET photo_base_path = 'D:\Графика\ФУТБОЛ\Кубок России 2026\Photo'
WHERE key = 'RUSSIAN_CUP'
AND (photo_base_path IS NULL OR TRIM(photo_base_path) = '');

View File

@@ -0,0 +1,12 @@
ALTER TABLE parser_sources
ADD COLUMN IF NOT EXISTS channel_logo_base_path TEXT;
UPDATE parser_sources
SET channel_logo_base_path = 'D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Лого каналов'
WHERE key = 'SUPERLEAGUE'
AND (channel_logo_base_path IS NULL OR TRIM(channel_logo_base_path) = '');
UPDATE parser_sources
SET channel_logo_base_path = 'D:\Графика\ФУТБОЛ\ЖФЛ Кубок России 2026\Лого каналов'
WHERE key = 'RUSSIAN_CUP'
AND (channel_logo_base_path IS NULL OR TRIM(channel_logo_base_path) = '');

View File

@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS match_penalty_settings (
match_id INTEGER PRIMARY KEY REFERENCES matches(id) ON DELETE CASCADE,
max_rounds INTEGER NOT NULL DEFAULT 5,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS match_penalties (
id SERIAL PRIMARY KEY,
match_id INTEGER NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
side VARCHAR(10) NOT NULL CHECK (side IN ('home', 'away')),
shot_number INTEGER NOT NULL CHECK (shot_number > 0),
result VARCHAR(20) NOT NULL CHECK (result IN ('scored', 'missed')),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE (match_id, side, shot_number)
);

BIN
static/docs/L3 scores.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

BIN
static/docs/Schedule.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

BIN
static/docs/SuperCup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

BIN
static/docs/no photo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

BIN
static/docs/penalty.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -856,7 +856,8 @@ table {
} }
.tour-meta-col { .tour-meta-col {
width: 1%; width: 300px;
min-width: 300px;
white-space: nowrap; white-space: nowrap;
} }
@@ -1896,6 +1897,63 @@ body.edit-mode-on .player-node.dragging {
margin-top: 6px; margin-top: 6px;
} }
.squad-editor-captain-warning {
margin-bottom: 12px;
padding: 10px 12px;
border: 1px solid #f1b9b9;
border-radius: 10px;
background: #fff1f1;
color: #9f1d1d;
font-size: 13px;
font-weight: 800;
line-height: 1.35;
}
#captainRequiredModal {
z-index: 3400;
}
.captain-required-modal-content {
width: min(560px, 92vw);
margin-top: 16vh;
padding: 24px;
overflow: visible;
border: 1px solid #f0c7c7;
}
.captain-required-icon {
width: 54px;
height: 54px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 14px;
border-radius: 50%;
background: #fff0f0;
color: #c62828;
border: 2px solid #ef9a9a;
font-size: 30px;
font-weight: 900;
}
.captain-required-copy {
text-align: center;
}
.captain-required-text {
margin-top: 10px;
color: #4d4d4d;
font-size: 15px;
font-weight: 650;
line-height: 1.5;
}
.captain-required-actions {
justify-content: center;
margin-top: 22px;
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.squad-editor-grid { .squad-editor-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -2293,3 +2351,554 @@ body:not(.edit-mode-on) .referee-search {
border-radius: 6px; border-radius: 6px;
} }
.event-time-editable {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 72px;
}
.event-time-label {
font-weight: 600;
}
.event-time-edit-btn {
position: absolute;
top: -6px;
left: -6px;
width: 20px;
height: 20px;
border: 1px solid rgba(255,255,255,0.14);
border-radius: 6px;
background: rgba(20,20,20,0.92);
color: #d0d0d0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
cursor: pointer;
opacity: 0.72;
transition:
background 0.18s ease,
border-color 0.18s ease,
transform 0.18s ease,
opacity 0.18s ease;
}
.event-time-edit-btn:hover {
background: #2d5cff;
border-color: #5f84ff;
color: #fff;
opacity: 1;
transform: scale(1.08);
}
.event-time-edit-btn:active {
transform: scale(0.96);
}
.event-time-edit-btn:focus-visible {
outline: 2px solid rgba(95,132,255,0.45);
outline-offset: 2px;
}
/* ===== Редактирование текущего времени ===== */
.timer-box-clickable {
cursor: pointer;
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.timer-box-clickable:hover {
transform: translateY(-1px);
border-color: rgba(79, 140, 255, 0.55);
box-shadow: 0 0 0 3px rgba(79, 140, 255, 0.12);
}
#clockEditorModal {
z-index: 5000;
}
#clockEditorModal .formation-modal-backdrop {
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(4px);
}
.clock-editor-modal-content {
width: min(460px, 94vw);
margin: 12vh auto 0;
padding: 22px;
background:
linear-gradient(180deg, rgba(31, 36, 46, 0.98), rgba(18, 21, 27, 0.98));
border: 1px solid rgba(255, 255, 255, 0.10);
border-radius: 18px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.55);
color: var(--text);
}
.clock-editor-modal-content .formation-modal-title {
color: var(--text);
}
.clock-editor-modal-content .formation-modal-subtitle {
color: var(--muted);
}
.clock-editor-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.clock-editor-label {
font-size: 13px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.clock-editor-input {
width: 100%;
height: 74px;
background: #0b0d12;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 16px;
color: #ffffff;
font-size: 40px;
font-weight: 800;
letter-spacing: 2px;
text-align: center;
outline: none;
font-family: Arial, sans-serif;
}
.clock-editor-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 4px rgba(79, 140, 255, 0.18);
}
.clock-editor-note {
font-size: 13px;
color: var(--muted);
}
#clockEditorModal .formation-modal-footer {
margin-top: 20px;
}
#clockEditorModal .btn-primary {
box-shadow: 0 10px 24px rgba(79, 140, 255, 0.25);
}
.clear-match-modal-content {
width: min(420px, 92vw);
background:
linear-gradient(
180deg,
rgba(31, 36, 46, 0.98),
rgba(18, 21, 27, 0.98)
) !important;
border: 1px solid rgba(255,255,255,0.08);
color: var(--text);
box-shadow: 0 24px 80px rgba(0,0,0,.55);
}
#clearMatchModal .formation-modal-backdrop {
background: rgba(0,0,0,.72);
backdrop-filter: blur(4px);
}
#clearMatchModal .formation-modal-title {
color: #fff;
}
#clearMatchModal .formation-modal-subtitle {
color: var(--muted);
}
.clear-match-warning {
padding: 18px;
border-radius: 14px;
background: rgba(220, 53, 69, 0.12);
border: 1px solid rgba(220, 53, 69, 0.28);
color: #ffb8bf;
font-size: 15px;
line-height: 1.5;
}
.btn-danger {
background: #dc3545;
color: white;
border: none;
}
.btn-danger:hover {
background: #e14b5b;
}
.num-with-photo-status {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-width: 44px;
}
.no-photo-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 12px;
line-height: 1;
opacity: 0.65;
filter: grayscale(1);
cursor: help;
}
.player-number-value {
display: inline-block;
min-width: 16px;
text-align: center;
}
/* Пенальти Кубка России */
.penalty-panel {
display: flex;
flex-direction: column;
gap: 18px;
}
.penalty-header,
.penalty-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.penalty-header-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
}
.penalty-subtitle,
.penalty-help,
.penalty-next-label {
color: rgba(255, 255, 255, 0.62);
font-size: 13px;
margin-top: 5px;
}
.penalty-scoreboard {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.penalty-team-card {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 18px;
padding: 18px;
background: rgba(255, 255, 255, 0.045);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.penalty-team-card.home {
border-color: rgba(79, 140, 255, 0.28);
}
.penalty-team-card.away {
border-color: rgba(255, 120, 120, 0.25);
}
.penalty-team-name {
color: #fff;
font-size: 16px;
font-weight: 800;
min-height: 22px;
}
.penalty-team-score {
margin-top: 8px;
color: #fff;
font-size: 54px;
line-height: 1;
font-weight: 900;
letter-spacing: -0.04em;
}
.penalty-fast-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 14px;
}
.penalty-fast-btn,
.penalty-mini-btn {
border: 0;
cursor: pointer;
color: #fff;
font-weight: 800;
transition: transform 0.12s ease, opacity 0.12s ease, box-shadow 0.12s ease;
}
.penalty-fast-btn:hover,
.penalty-mini-btn:hover {
transform: translateY(-1px);
opacity: 0.95;
}
.penalty-fast-btn {
min-height: 54px;
border-radius: 14px;
font-size: 18px;
}
.penalty-fast-btn.scored,
.penalty-mini-btn.scored {
background: linear-gradient(135deg, #0e9f6e, #12b981);
box-shadow: 0 10px 28px rgba(18, 185, 129, 0.16);
}
.penalty-fast-btn.missed,
.penalty-mini-btn.missed {
background: linear-gradient(135deg, #b91c1c, #ef4444);
box-shadow: 0 10px 28px rgba(239, 68, 68, 0.16);
}
.penalty-table-wrap {
overflow-x: auto;
}
.penalty-table {
width: 100%;
border-collapse: separate;
border-spacing: 0 8px;
}
.penalty-table th {
color: rgba(255, 255, 255, 0.58);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0 10px 6px;
}
.penalty-table td {
padding: 0 10px;
vertical-align: middle;
}
.penalty-round-number {
width: 70px;
color: rgba(255, 255, 255, 0.72);
text-align: center;
font-size: 18px;
font-weight: 900;
}
.penalty-cell {
display: grid;
grid-template-columns: 52px 1fr;
align-items: center;
gap: 10px;
min-height: 58px;
padding: 8px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.045);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.penalty-cell.scored {
border-color: rgba(18, 185, 129, 0.38);
background: rgba(18, 185, 129, 0.08);
}
.penalty-cell.missed {
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.08);
}
.penalty-mark {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.22);
color: #fff;
font-size: 30px;
line-height: 1;
font-weight: 900;
}
.penalty-cell.scored .penalty-mark {
color: #34d399;
}
.penalty-cell.missed .penalty-mark {
color: #f87171;
}
.penalty-cell-actions {
display: grid;
grid-template-columns: 1fr 1fr minmax(74px, 0.8fr);
gap: 8px;
}
.penalty-mini-btn {
min-height: 38px;
border-radius: 10px;
font-size: 13px;
}
.penalty-mini-btn.active {
outline: 2px solid rgba(255, 255, 255, 0.75);
outline-offset: 2px;
}
.penalty-mini-btn.clear {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.75);
}
.penalty-mini-btn.clear:disabled {
cursor: default;
opacity: 0.35;
transform: none;
}
@media (max-width: 900px) {
.penalty-scoreboard {
grid-template-columns: 1fr;
}
.penalty-header,
.penalty-footer,
.penalty-header-actions {
align-items: stretch;
flex-direction: column;
}
}
/* Расписание тура: компактная ручная правка счёта и статуса */
.tour-actions-col,
.tour-actions-cell {
width: 156px;
text-align: right;
white-space: nowrap;
}
.tour-meta-cell {
min-width: 300px;
}
.tour-score-edit {
min-width: 300px;
}
.tour-edit-line {
display: inline-flex;
align-items: center;
gap: 10px;
flex-wrap: nowrap;
white-space: nowrap;
}
.tour-score-inputs {
display: inline-grid;
grid-template-columns: 52px auto 52px;
gap: 6px;
align-items: center;
width: max-content;
flex: 0 0 auto;
}
.tour-score-input {
width: 52px;
height: 32px;
padding: 4px 6px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
text-align: center;
font-weight: 800;
font-size: 13px;
}
.tour-score-input:disabled,
.tour-status-select:disabled {
opacity: 0.55;
}
.tour-score-separator {
font-weight: 900;
color: rgba(255, 255, 255, 0.78);
}
.tour-status-select {
height: 32px;
width: 128px;
max-width: 128px;
padding: 4px 28px 4px 10px;
flex: 0 0 128px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-size: 12px;
font-weight: 800;
}
.tour-status-select option {
color: #111827;
}
.tour-edit-btn {
min-width: 38px;
min-height: 32px;
padding: 6px 9px;
}
.tour-save-actions {
display: inline-flex;
justify-content: flex-end;
gap: 5px;
flex-wrap: nowrap;
}
.tour-save-btn,
.tour-cancel-btn {
min-height: 32px;
padding: 6px 9px;
font-size: 12px;
}
/* Расписание тура: редактирование включается общим карандашом в шапке */
.tour-edit-only {
display: none;
}
body.edit-mode-on .tour-edit-only {
display: table-cell;
}

View File

@@ -149,6 +149,9 @@
<form method="get" action="/admin/db/coaches" class="search-form"> <form method="get" action="/admin/db/coaches" class="search-form">
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по ФИО или external_id"> <input type="text" name="q" value="{{ q }}" placeholder="Поиск по ФИО или external_id">
<button type="submit" class="btn btn-primary">Найти</button> <button type="submit" class="btn btn-primary">Найти</button>
{% if request.state.current_user and request.state.current_user.role == "admin" %}
<a href="/admin/db/create?entity=coach" class="btn btn-primary"> Создать тренера</a>
{% endif %}
<a href="/admin/db" class="btn btn-secondary">Назад</a> <a href="/admin/db" class="btn btn-secondary">Назад</a>
</form> </form>
</div> </div>

View File

@@ -0,0 +1,459 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Создание записи</title>
<link rel="icon" href="/static/smith.ico" type="image/x-icon">
<style>
:root {
--bg: #0f1115;
--panel: #171a21;
--panel-2: #1d222b;
--border: #2b3240;
--text: #e8ecf3;
--muted: #9aa4b2;
--accent: #4f8cff;
--accent-hover: #3e78e6;
--success: #44cf88;
--danger: #ff6b78;
--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: 1180px;
margin: 0 auto;
padding: 24px;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 22px;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.title {
font-size: 26px;
font-weight: 700;
margin-bottom: 6px;
}
.subtitle {
color: var(--muted);
line-height: 1.45;
max-width: 760px;
}
.entity-tabs {
display: grid;
grid-template-columns: repeat(3, minmax(150px, 1fr));
gap: 10px;
margin-bottom: 22px;
}
.entity-tab {
min-height: 52px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
background: var(--panel-2);
border-radius: 12px;
color: var(--text);
text-decoration: none;
font-weight: 700;
}
.entity-tab.active {
border-color: var(--accent);
background: rgba(79, 140, 255, .14);
color: #cfe0ff;
}
.notice {
padding: 14px 16px;
border-radius: 12px;
margin-bottom: 18px;
line-height: 1.45;
}
.notice.error {
border: 1px solid rgba(255, 107, 120, .5);
background: rgba(255, 107, 120, .1);
color: #ffd2d6;
}
.notice.success {
border: 1px solid rgba(68, 207, 136, .5);
background: rgba(68, 207, 136, .1);
color: #caffdf;
}
.notice a { color: inherit; font-weight: 700; }
.section {
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid var(--border);
}
.section:first-of-type {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
.section-title {
font-size: 17px;
font-weight: 700;
margin-bottom: 14px;
}
.section-hint {
color: var(--muted);
font-size: 13px;
margin-top: -7px;
margin-bottom: 14px;
line-height: 1.45;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 15px;
}
.form-grid.stats {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
}
.form-group.full { grid-column: 1 / -1; }
label {
color: var(--muted);
font-size: 13px;
font-weight: 600;
}
.required::after {
content: " *";
color: var(--danger);
}
input[type="text"], input[type="date"], input[type="number"], select {
width: 100%;
min-height: 43px;
padding: 0 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--text);
outline: none;
}
input:focus, select:focus { border-color: var(--accent); }
.checkbox-grid {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.checkbox-card {
min-height: 44px;
display: inline-flex;
align-items: center;
gap: 9px;
padding: 0 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel-2);
color: var(--text);
}
.checkbox-card input {
width: 18px;
height: 18px;
accent-color: var(--accent);
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 24px;
}
.btn {
min-height: 43px;
padding: 0 15px;
border-radius: 10px;
border: 0;
font-weight: 700;
text-decoration: none;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-secondary { background: transparent; color: var(--text); border: 1px solid var(--border); }
.empty-teams {
padding: 14px;
border: 1px dashed var(--danger);
border-radius: 10px;
color: #ffd2d6;
background: rgba(255, 107, 120, .08);
}
@media (max-width: 760px) {
.page { padding: 12px; }
.panel { padding: 16px; }
.entity-tabs, .form-grid, .form-grid.stats { grid-template-columns: 1fr; }
.form-group.full { grid-column: auto; }
}
</style>
</head>
<body>
<div class="page">
<div class="panel">
<div class="header">
<div>
<div class="title">Создание записи</div>
<div class="subtitle">Выберите тип человека. Форма покажет только те поля, которые используются для этой роли в базе WFL.</div>
</div>
<a href="/admin/db" class="btn btn-secondary">Назад к разделам</a>
</div>
<div class="entity-tabs">
<a href="/admin/db/create?entity=player" class="entity-tab {% if entity_type == 'player' %}active{% endif %}">⚽ Игрок</a>
<a href="/admin/db/create?entity=coach" class="entity-tab {% if entity_type == 'coach' %}active{% endif %}">📋 Тренер</a>
<a href="/admin/db/create?entity=referee" class="entity-tab {% if entity_type == 'referee' %}active{% endif %}">🟨 Судья</a>
</div>
{% if error %}
<div class="notice error">{{ error }}</div>
{% endif %}
{% if created_id %}
<div class="notice success">
Запись успешно создана. ID: <b>{{ created_id }}</b>.
{% if entity_type == 'player' %}
<a href="/admin/db/players/{{ created_id }}/edit">Открыть карточку игрока</a>
{% elif entity_type == 'coach' %}
<a href="/admin/db/coaches/{{ created_id }}/edit">Открыть карточку тренера</a>
{% else %}
<a href="/admin/db/referees/{{ created_id }}/edit">Открыть карточку судьи</a>
{% endif %}
</div>
{% endif %}
<form method="post" action="/admin/db/create">
<input type="hidden" name="entity_type" value="{{ entity_type }}">
{% if entity_type == 'player' %}
<div class="section">
<div class="section-title">Основные данные игрока</div>
<div class="form-grid">
<div class="form-group">
<label class="required">Команда</label>
{% if teams %}
<select name="team_id" required>
<option value="">Выберите команду</option>
{% for team in teams %}
<option value="{{ team.id }}" {% if form_values.get('team_id') == team.id|string %}selected{% endif %}>
{{ team.name }}{% if team.city %} — {{ team.city }}{% endif %}
</option>
{% endfor %}
</select>
{% else %}
<div class="empty-teams">В базе нет команд. Сначала добавьте или загрузите команды.</div>
{% endif %}
</div>
<div class="form-group">
<label>External ID</label>
<input type="text" name="external_id" value="{{ form_values.get('external_id', '') }}" placeholder="ID игрока на сайте РФС">
</div>
<div class="form-group full">
<label class="required">Полное имя / ФИО</label>
<input type="text" name="full_name" value="{{ form_values.get('full_name', '') }}" required placeholder="Фамилия Имя">
</div>
<div class="form-group">
<label>Имя</label>
<input type="text" name="first_name" value="{{ form_values.get('first_name', '') }}">
</div>
<div class="form-group">
<label>Фамилия</label>
<input type="text" name="last_name" value="{{ form_values.get('last_name', '') }}">
</div>
<div class="form-group">
<label>Игровой номер</label>
<input type="text" name="number" value="{{ form_values.get('number', '') }}" placeholder="Например, 10">
</div>
<div class="form-group">
<label class="required">Амплуа</label>
<input type="text" name="position" list="playerPositions" value="{{ form_values.get('position', '') }}" required placeholder="Выберите или введите">
<datalist id="playerPositions">
<option value="Вратарь">
<option value="Защитник">
<option value="Полузащитник">
<option value="Нападающий">
</datalist>
</div>
<div class="form-group">
<label>Дата рождения</label>
<input type="date" name="birth_date" value="{{ form_values.get('birth_date', '') }}">
</div>
<div class="form-group">
<label>Рост, см</label>
<input type="number" name="height_cm" min="1" value="{{ form_values.get('height_cm', '') }}">
</div>
<div class="form-group">
<label>Вес, кг</label>
<input type="number" name="weight_kg" min="1" value="{{ form_values.get('weight_kg', '') }}">
</div>
</div>
</div>
<div class="section">
<div class="section-title">Медиа</div>
<div class="section-hint">Можно указать имя файла или полный путь — формат остаётся таким же, как в существующих карточках.</div>
<div class="form-grid">
<div class="form-group">
<label>Фото</label>
<input type="text" name="photo" value="{{ form_values.get('photo', '') }}" placeholder="Путь или имя файла фотографии">
</div>
<div class="form-group">
<label>Видео</label>
<input type="text" name="video" value="{{ form_values.get('video', '') }}" placeholder="Путь или имя видео">
</div>
</div>
</div>
<div class="section">
<div class="section-title">Статистика</div>
<div class="section-hint">Для нового игрока можно оставить нули. При следующем парсинге статистика обновится по External ID.</div>
<div class="form-grid stats">
<div class="form-group"><label>Игры</label><input type="number" name="games" min="0" value="{{ form_values.get('games', '0') }}"></div>
<div class="form-group"><label>Голы</label><input type="number" name="goals" min="0" value="{{ form_values.get('goals', '0') }}"></div>
<div class="form-group"><label>Голы с пенальти</label><input type="number" name="penaltys" min="0" value="{{ form_values.get('penaltys', '0') }}"></div>
<div class="form-group"><label>Передачи</label><input type="number" name="assists" min="0" value="{{ form_values.get('assists', '0') }}"></div>
<div class="form-group"><label>Жёлтые карточки</label><input type="number" name="yellows" min="0" value="{{ form_values.get('yellows', '0') }}"></div>
<div class="form-group"><label>Красные карточки</label><input type="number" name="reds" min="0" value="{{ form_values.get('reds', '0') }}"></div>
</div>
</div>
<div class="section">
<div class="checkbox-grid">
<label class="checkbox-card"><input type="checkbox" name="is_active" {% if not form_values or form_values.get('is_active') %}checked{% endif %}> Активный игрок</label>
<label class="checkbox-card"><input type="checkbox" name="photo_enabled" {% if form_values.get('photo_enabled') %}checked{% endif %}> Использовать фотографию в титрах</label>
</div>
</div>
{% elif entity_type == 'coach' %}
<div class="section">
<div class="section-title">Данные тренера</div>
<div class="form-grid">
<div class="form-group">
<label class="required">Команда</label>
{% if teams %}
<select name="team_id" required>
<option value="">Выберите команду</option>
{% for team in teams %}
<option value="{{ team.id }}" {% if form_values.get('team_id') == team.id|string %}selected{% endif %}>
{{ team.name }}{% if team.city %} — {{ team.city }}{% endif %}
</option>
{% endfor %}
</select>
{% else %}
<div class="empty-teams">В базе нет команд. Сначала добавьте или загрузите команды.</div>
{% endif %}
</div>
<div class="form-group">
<label>External ID</label>
<input type="text" name="external_id" value="{{ form_values.get('external_id', '') }}" placeholder="ID тренера на сайте РФС">
</div>
<div class="form-group full">
<label class="required">Полное имя / ФИО</label>
<input type="text" name="full_name" value="{{ form_values.get('full_name', '') }}" required placeholder="Фамилия Имя">
</div>
<div class="form-group">
<label>Имя</label>
<input type="text" name="first_name" value="{{ form_values.get('first_name', '') }}">
</div>
<div class="form-group">
<label>Фамилия</label>
<input type="text" name="last_name" value="{{ form_values.get('last_name', '') }}">
</div>
<div class="form-group">
<label class="required">Должность / амплуа</label>
<input type="text" name="role" list="coachRoles" value="{{ form_values.get('role', '') }}" required placeholder="Например, главный тренер">
<datalist id="coachRoles">
<option value="Главный тренер">
<option value="Старший тренер">
<option value="Тренер">
<option value="Тренер вратарей">
<option value="Тренер по физической подготовке">
</datalist>
</div>
<div class="form-group">
<label>Дата рождения</label>
<input type="date" name="birth_date" value="{{ form_values.get('birth_date', '') }}">
</div>
</div>
</div>
<div class="section">
<div class="checkbox-grid">
<label class="checkbox-card"><input type="checkbox" name="is_active" {% if not form_values or form_values.get('is_active') %}checked{% endif %}> Активный тренер</label>
</div>
</div>
{% else %}
<div class="section">
<div class="section-title">Данные судьи</div>
<div class="section-hint">Конкретная роль — главный судья, помощник, резервный — назначается отдельно в карточке матча.</div>
<div class="form-grid">
<div class="form-group full">
<label class="required">Полное имя / ФИО</label>
<input type="text" name="full_name" value="{{ form_values.get('full_name', '') }}" required placeholder="Фамилия Имя Отчество">
</div>
<div class="form-group">
<label>Фамилия</label>
<input type="text" name="last_name" value="{{ form_values.get('last_name', '') }}">
</div>
<div class="form-group">
<label>Имя</label>
<input type="text" name="first_name" value="{{ form_values.get('first_name', '') }}">
</div>
<div class="form-group">
<label>Отчество</label>
<input type="text" name="middle_name" value="{{ form_values.get('middle_name', '') }}">
</div>
<div class="form-group">
<label>Город</label>
<input type="text" name="city" value="{{ form_values.get('city', '') }}">
</div>
<div class="form-group full">
<label>External ID</label>
<input type="text" name="external_id" value="{{ form_values.get('external_id', '') }}" placeholder="ID судьи во внешнем источнике">
</div>
</div>
</div>
<div class="section">
<div class="checkbox-grid">
<label class="checkbox-card"><input type="checkbox" name="is_active" {% if not form_values or form_values.get('is_active') %}checked{% endif %}> Активный судья</label>
</div>
</div>
{% endif %}
<div class="actions">
<button type="submit" class="btn btn-primary" {% if entity_type in ['player', 'coach'] and not teams %}disabled{% endif %}>Создать запись</button>
{% if entity_type == 'player' %}
<a href="/admin/db/players" class="btn btn-secondary">Список игроков</a>
{% elif entity_type == 'coach' %}
<a href="/admin/db/coaches" class="btn btn-secondary">Список тренеров</a>
{% else %}
<a href="/admin/db/referees" class="btn btn-secondary">Список судей</a>
{% endif %}
</div>
</form>
</div>
</div>
</body>
</html>

View File

@@ -107,6 +107,82 @@
margin: 0; margin: 0;
} }
.parser-import-card {
width: 100%;
padding: 18px;
border: 1px solid rgba(0, 255, 136, 0.18);
border-radius: 16px;
background: rgba(255, 255, 255, 0.025);
}
.parser-import-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 6px;
}
.parser-import-subtitle {
color: var(--muted);
font-size: 13px;
line-height: 1.45;
margin-bottom: 16px;
}
.parser-import-grid {
display: grid;
grid-template-columns: minmax(260px, 360px) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.parser-checkboxes {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 10px;
}
.parser-checkbox {
display: flex;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 0 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: #0f141d;
color: var(--text);
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.parser-checkbox input {
accent-color: var(--accent);
}
.parser-submit-row {
margin-top: 16px;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.parser-mode-note {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
@media (max-width: 900px) {
.parser-import-grid,
.parser-checkboxes {
grid-template-columns: 1fr;
}
}
.log-block { .log-block {
margin-top: 20px; margin-top: 20px;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -150,7 +226,9 @@
pointer-events: none; pointer-events: none;
} }
.account-modal { .account-modal,
.update-modal,
.settings-modal {
position: fixed; position: fixed;
inset: 0; inset: 0;
display: none; display: none;
@@ -161,11 +239,15 @@
padding: 20px; padding: 20px;
} }
.account-modal.active { .account-modal.active,
.update-modal.active,
.settings-modal.active {
display: flex; display: flex;
} }
.account-modal-card { .account-modal-card,
.update-modal-card,
.settings-modal-card {
width: min(100%, 460px); width: min(100%, 460px);
background: rgba(12, 17, 26, 0.98); background: rgba(12, 17, 26, 0.98);
border: 1px solid rgba(0, 255, 136, 0.28); border: 1px solid rgba(0, 255, 136, 0.28);
@@ -174,13 +256,24 @@
padding: 24px; padding: 24px;
} }
.account-modal-title { .update-modal-card,
.settings-modal-card {
width: min(100%, 760px);
max-height: 92vh;
overflow-y: auto;
}
.account-modal-title,
.update-modal-title,
.settings-modal-title {
font-size: 22px; font-size: 22px;
font-weight: 700; font-weight: 700;
margin-bottom: 8px; margin-bottom: 8px;
} }
.account-modal-subtitle { .account-modal-subtitle,
.update-modal-subtitle,
.settings-modal-subtitle {
color: var(--muted); color: var(--muted);
margin-bottom: 18px; margin-bottom: 18px;
line-height: 1.5; line-height: 1.5;
@@ -191,6 +284,46 @@
gap: 14px; gap: 14px;
} }
.env-groups {
display: grid;
gap: 16px;
}
.env-group {
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px;
background: rgba(255, 255, 255, 0.025);
}
.env-group-title {
font-size: 16px;
font-weight: 700;
margin-bottom: 4px;
}
.env-group-subtitle {
color: var(--muted);
font-size: 12px;
margin-bottom: 12px;
}
.env-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.env-field-wide {
grid-column: 1 / -1;
}
@media (max-width: 720px) {
.env-grid {
grid-template-columns: 1fr;
}
}
.field-label { .field-label {
display: block; display: block;
font-size: 13px; font-size: 13px;
@@ -200,7 +333,8 @@
} }
.field-input, .field-input,
.field-select { .field-select,
.field-textarea {
width: 100%; width: 100%;
min-height: 44px; min-height: 44px;
border-radius: 12px; border-radius: 12px;
@@ -212,12 +346,117 @@
outline: none; outline: none;
} }
.field-textarea {
min-height: 96px;
padding: 12px 14px;
resize: vertical;
line-height: 1.45;
font-family: Arial, sans-serif;
}
.field-input:focus, .field-input:focus,
.field-select:focus { .field-select:focus,
.field-textarea:focus {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(0, 255, 136, 0.12); box-shadow: 0 0 0 3px rgba(0, 255, 136, 0.12);
} }
.update-form-grid {
display: grid;
grid-template-columns: 1fr 180px;
gap: 14px;
}
.update-items-editor {
display: grid;
gap: 14px;
}
.update-item-form {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 12px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 14px;
background: rgba(255, 255, 255, 0.025);
}
.update-item-text,
.update-item-image {
grid-column: 1 / -1;
}
.helper-text {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.format-guide {
margin-top: 8px;
padding: 10px 12px;
border: 1px solid rgba(0, 255, 136, 0.18);
border-radius: 12px;
background: rgba(0, 255, 136, 0.045);
color: var(--muted);
font-size: 12px;
line-height: 1.55;
}
.format-guide code {
color: #00ff88;
background: rgba(0, 255, 136, 0.10);
border: 1px solid rgba(0, 255, 136, 0.22);
border-radius: 7px;
padding: 1px 6px;
}
.format-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.format-btn {
min-height: 30px;
padding: 0 10px;
border: 1px solid rgba(0, 255, 136, 0.28);
border-radius: 999px;
color: #9cf3c8;
background: rgba(0, 255, 136, 0.055);
cursor: pointer;
font-size: 12px;
}
.format-btn:hover {
color: #00ff88;
background: rgba(0, 255, 136, 0.12);
}
.add-row-btn {
min-height: 38px;
padding: 0 14px;
border-radius: 999px;
border: 1px dashed rgba(0, 255, 136, 0.45);
background: rgba(0, 255, 136, 0.06);
color: var(--accent);
font-weight: 700;
cursor: pointer;
}
.add-row-btn:hover {
background: rgba(0, 255, 136, 0.12);
}
@media (max-width: 720px) {
.update-form-grid,
.update-item-form {
grid-template-columns: 1fr;
}
}
.modal-actions { .modal-actions {
display: flex; display: flex;
gap: 12px; gap: 12px;
@@ -281,6 +520,9 @@
<div class="title">Редактирование базы</div> <div class="title">Редактирование базы</div>
<div class="tabs"> <div class="tabs">
{% if current_user and current_user.role == "admin" %}
<a class="tab-link" href="/admin/db/create?entity=player"> Создать запись</a>
{% endif %}
<a class="tab-link" href="/admin/db/players">Игроки</a> <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/referees">Судьи</a>
<a class="tab-link" href="/admin/db/teams">Команды</a> <a class="tab-link" href="/admin/db/teams">Команды</a>
@@ -290,24 +532,67 @@
</div> </div>
<div class="parser-actions"> <div class="parser-actions">
<form method="post" action="/admin/db/run-parser" class="action-form" data-parser-title="Игроки" data-parser-status="Сбор и сохранение базы игроков..."> <form method="post" action="/admin/db/run-parser" class="action-form parser-import-card" data-parser-title="Выбранные данные" data-parser-status="Загрузка выбранных данных из выбранного турнира...">
<input type="hidden" name="parser_name" value="players"> <div class="parser-import-title">Импорт данных</div>
<button type="submit" class="action-btn">🗄️ Заграбить игроков</button> <div class="parser-import-subtitle">
</form> Выберите источник турнира и отметьте, какие данные нужно заграбить. Существующие команды, игроки и матчи будут обновлены через UPSERT, без полной очистки таблиц.
</div>
<form method="post" action="/admin/db/run-parser" class="action-form" data-parser-title="Расписание" data-parser-status="Загрузка расписания матчей с сайта..."> <div class="parser-import-grid">
<input type="hidden" name="parser_name" value="schedule"> <div>
<button type="submit" class="action-btn">🗄️ Заграбить расписание</button> <label class="field-label" for="parserSource">Источник / турнир</label>
</form> {% set sources = parser_sources.values() if parser_sources is mapping else parser_sources|default([]) %}
<select class="field-select" id="parserSource" name="parser_source" required>
{% if sources %}
{% for source in sources %}
<option value="{{ source.key }}" data-logo-base-path="{{ source.logo_base_path or '' }}" data-photo-base-path="{{ source.photo_base_path or '' }}" {% if source.key == default_parser_source_key %}selected{% endif %}>
{{ source.title }}
</option>
{% endfor %}
{% else %}
<option value="SUPERLEAGUE" data-logo-base-path="D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Teams Logos" data-photo-base-path="D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo" selected>Суперлига 2026</option>
<option value="RUSSIAN_CUP" data-logo-base-path="D:\Графика\ФУТБОЛ\Кубок России 2026\Teams Logos" data-photo-base-path="D:\Графика\ФУТБОЛ\Кубок России 2026\Photo">Кубок России 2026</option>
{% endif %}
</select>
<div class="helper-text">Список источников хранится в <b>базе данных</b> и редактируется в настройках проекта.</div>
<div class="helper-text">Путь к логотипам: <b id="selectedLogoBasePath"></b></div>
<div class="helper-text">Путь к фотографиям: <b id="selectedPhotoBasePath"></b></div>
</div>
<form method="post" action="/admin/db/run-parser" class="action-form" data-parser-title="Турнирка" data-parser-status="Обновление турнирной таблицы..."> <div>
<input type="hidden" name="parser_name" value="standings"> <label class="field-label">Что парсить</label>
<button type="submit" class="action-btn">🗄️ Заграбить турнирку</button> <div class="parser-checkboxes">
<label class="parser-checkbox">
<input type="checkbox" name="parser_names" value="teams" checked>
<span>Команды</span>
</label>
<label class="parser-checkbox">
<input type="checkbox" name="parser_names" value="players" checked>
<span>Игроки</span>
</label>
<label class="parser-checkbox">
<input type="checkbox" name="parser_names" value="schedule" checked>
<span>Расписание</span>
</label>
<label class="parser-checkbox">
<input type="checkbox" name="parser_names" value="standings">
<span>Турнирка</span>
</label>
</div>
</div>
</div>
<div class="parser-submit-row">
<button type="submit" class="action-btn">🗄️ Заграбить выбранное</button>
<div class="parser-mode-note">Безопасный режим: добавляет новые записи и обновляет существующие. Турнирка по-прежнему пересобирается только за выбранный сезон.</div>
</div>
</form> </form>
</div> </div>
<div class="account-actions"> <div class="account-actions">
<button type="button" class="account-toggle-btn" id="openCreateAccountBtn"> Создать аккаунт</button> <button type="button" class="account-toggle-btn" id="openCreateAccountBtn"> Создать аккаунт</button>
<button type="button" class="account-toggle-btn" id="openSettingsBtn">⚙️ Настройки проекта</button>
<button type="button" class="account-toggle-btn" id="openAddUpdateBtn">📝 Добавить обновление</button>
</div> </div>
{% if account_output %} {% if account_output %}
@@ -339,6 +624,36 @@
<pre class="log-output">{{ parser_output }}</pre> <pre class="log-output">{{ parser_output }}</pre>
</div> </div>
{% endif %} {% endif %}
{% if settings_output %}
<div class="log-block {% if settings_success %}success{% else %}error{% endif %}">
<div class="log-title">Сохранение настроек проекта</div>
<div class="log-status">
Статус:
{% if settings_success %}
успешно
{% else %}
ошибка
{% endif %}
</div>
<pre class="log-output">{{ settings_output }}</pre>
</div>
{% endif %}
{% if update_output %}
<div class="log-block {% if update_success %}success{% else %}error{% endif %}">
<div class="log-title">Добавление обновления</div>
<div class="log-status">
Статус:
{% if update_success %}
успешно
{% else %}
ошибка
{% endif %}
</div>
<pre class="log-output">{{ update_output }}</pre>
</div>
{% endif %}
</div> </div>
</div> </div>
@@ -376,6 +691,151 @@
</div> </div>
</div> </div>
<div id="settingsModal" class="settings-modal" aria-hidden="true">
<div class="settings-modal-card">
<div class="settings-modal-title">Настройки проекта</div>
<div class="settings-modal-subtitle">
Здесь редактируются источники парсинга: ссылки, сезон, тип календаря, папки логотипов и фотографий. Эти данные хранятся в базе данных.
<br>.env оставляем только для NAS и подключения к базе.
</div>
<form method="post" action="/admin/db/project-settings" id="settingsForm">
{% if project_settings and project_settings.groups %}
<div class="env-groups">
{% for group in project_settings.groups %}
<div class="env-group">
<div class="env-group-title">{{ group.title }}</div>
{% if group.subtitle %}
<div class="env-group-subtitle">{{ group.subtitle }}</div>
{% endif %}
<div class="env-grid">
{% for field in group.fields %}
<div class="{% if field.wide %}env-field-wide{% endif %}">
<label class="field-label" for="env_{{ field.key }}">{{ field.label }}</label>
{% if field.type == 'select' %}
<select class="field-select" id="env_{{ field.key }}" name="{{ field.key }}">
{% for option in field.options %}
<option value="{{ option.value }}" {% if option.value == field.value %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
{% else %}
<input class="field-input" id="env_{{ field.key }}" type="text" name="{{ field.key }}" value="{{ field.value }}">
{% endif %}
{% if field.help %}
<div class="helper-text">{{ field.help }}</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="helper-text">Не удалось загрузить список настроек проекта из базы данных.</div>
{% endif %}
<div class="modal-actions">
<button type="button" class="cancel-btn" id="closeSettingsBtn">Отмена</button>
<button type="submit" class="submit-btn">Сохранить настройки</button>
</div>
</form>
</div>
</div>
<div id="updateModal" class="update-modal" aria-hidden="true">
<div class="update-modal-card">
<div class="update-modal-title">Добавить обновление</div>
<div class="update-modal-subtitle">
Заполните версию, дату, действия для оператора и пункты изменений. При необходимости добавьте путь к картинке. После сохранения новая версия появится сверху на странице /info.
</div>
<form method="post" action="/admin/db/add-update" id="addUpdateForm">
<div class="form-grid">
<div class="update-form-grid">
<div>
<label class="field-label" for="updateVersion">Версия</label>
<input class="field-input" id="updateVersion" type="text" name="version" placeholder="1.4" required>
</div>
<div>
<label class="field-label" for="updateDate">Дата</label>
<input class="field-input" id="updateDate" type="date" name="date" required>
</div>
</div>
<div>
<label class="field-label" for="operatorActions">Что сделать оператору</label>
<textarea class="field-textarea" id="operatorActions" name="operator_actions" placeholder="Каждое действие с новой строки">Скачать новый проект vMix перед работой с матчем.
Полностью заменить все файлы проекта.</textarea>
<div class="helper-text">Каждая строка станет отдельным пунктом списка. Можно выделять клавиши и важные слова.</div>
<div class="format-guide">
Формат: <code>`F5`</code> — клавиша/команда, <code>**важно**</code> — выделенное слово.
</div>
<div class="format-toolbar" data-format-for="operatorActions">
<button type="button" class="format-btn" data-snippet="`F5`">⌨️ Клавиша</button>
<button type="button" class="format-btn" data-snippet="**важно**">✨ Выделить слово</button>
</div>
</div>
<div>
<label class="field-label">Пункты обновления</label>
<div class="update-items-editor" id="updateItemsEditor">
<div class="update-item-form">
<div>
<label class="field-label">Тип</label>
<select class="field-select" name="item_badge">
<option value="new">Новое</option>
<option value="fix">Исправлено</option>
<option value="improve">Улучшено</option>
<option value="important">Важно</option>
</select>
</div>
<div>
<label class="field-label">Заголовок</label>
<input class="field-input" type="text" name="item_title" placeholder="Например: Верхний счёт" required>
</div>
<div class="update-item-text">
<label class="field-label">Описание</label>
<textarea class="field-textarea" name="item_text" placeholder="Пример:
1. Нажмите `1`, чтобы уменьшить таймер на секунду.
2. Нажмите `2`, чтобы увеличить таймер на секунду.
Можно выделить **важный текст**." required></textarea>
<div class="format-guide">
Красивый список: <code>1. Первый пункт</code> или <code>- Первый пункт</code><br>
Клавиши/команды: <code>`F5`</code>, <code>`1`</code>. Выделение слов: <code>**важно**</code>.
</div>
<div class="format-toolbar">
<button type="button" class="format-btn" data-snippet="1. Первый пункт&#10;2. Второй пункт">🔢 Список</button>
<button type="button" class="format-btn" data-snippet="- Первый пункт&#10;- Второй пункт">• Маркеры</button>
<button type="button" class="format-btn" data-snippet="`F5`">⌨️ Клавиша</button>
<button type="button" class="format-btn" data-snippet="**важно**">✨ Выделить слово</button>
</div>
</div>
<div class="update-item-image">
<label class="field-label">Картинка / скриншот</label>
<input class="field-input" type="text" name="item_image" placeholder="Например: /static/docs/no photo.png или просто no photo.png">
<div class="helper-text">Картинку нужно положить в static/docs. Если указать только имя файла, система сама подставит /static/docs/.</div>
</div>
</div>
</div>
<button type="button" class="add-row-btn" id="addUpdateItemBtn"> Добавить ещё пункт</button>
</div>
</div>
<div class="modal-actions">
<button type="button" class="cancel-btn" id="closeAddUpdateBtn">Отмена</button>
<button type="submit" class="submit-btn">Сохранить обновление</button>
</div>
</form>
</div>
</div>
<div id="matrixLoader" class="matrix-loader" aria-hidden="true"> <div id="matrixLoader" class="matrix-loader" aria-hidden="true">
<canvas id="matrixCanvas" class="matrix-canvas"></canvas> <canvas id="matrixCanvas" class="matrix-canvas"></canvas>
<div class="matrix-content"> <div class="matrix-content">
@@ -391,9 +851,21 @@
const canvas = document.getElementById("matrixCanvas"); const canvas = document.getElementById("matrixCanvas");
const ctx = canvas ? canvas.getContext("2d") : null; const ctx = canvas ? canvas.getContext("2d") : null;
const parserForms = document.querySelectorAll(".action-form"); const parserForms = document.querySelectorAll(".action-form");
const parserSourceSelect = document.getElementById("parserSource");
const selectedLogoBasePath = document.getElementById("selectedLogoBasePath");
const selectedPhotoBasePath = document.getElementById("selectedPhotoBasePath");
const openCreateAccountBtn = document.getElementById("openCreateAccountBtn"); const openCreateAccountBtn = document.getElementById("openCreateAccountBtn");
const closeCreateAccountBtn = document.getElementById("closeCreateAccountBtn"); const closeCreateAccountBtn = document.getElementById("closeCreateAccountBtn");
const accountModal = document.getElementById("accountModal"); const accountModal = document.getElementById("accountModal");
const openSettingsBtn = document.getElementById("openSettingsBtn");
const closeSettingsBtn = document.getElementById("closeSettingsBtn");
const settingsModal = document.getElementById("settingsModal");
const openAddUpdateBtn = document.getElementById("openAddUpdateBtn");
const closeAddUpdateBtn = document.getElementById("closeAddUpdateBtn");
const updateModal = document.getElementById("updateModal");
const updateDate = document.getElementById("updateDate");
const addUpdateItemBtn = document.getElementById("addUpdateItemBtn");
const updateItemsEditor = document.getElementById("updateItemsEditor");
let drops = []; let drops = [];
let fontSize = 18; let fontSize = 18;
let columns = 0; let columns = 0;
@@ -463,6 +935,94 @@
accountModal.setAttribute("aria-hidden", "true"); accountModal.setAttribute("aria-hidden", "true");
} }
function openSettingsModal() {
if (!settingsModal) return;
settingsModal.classList.add("active");
settingsModal.setAttribute("aria-hidden", "false");
}
function closeSettingsModal() {
if (!settingsModal) return;
settingsModal.classList.remove("active");
settingsModal.setAttribute("aria-hidden", "true");
}
function openUpdateModal() {
if (!updateModal) return;
if (updateDate && !updateDate.value) {
updateDate.value = new Date().toISOString().slice(0, 10);
}
updateModal.classList.add("active");
updateModal.setAttribute("aria-hidden", "false");
}
function closeUpdateModal() {
if (!updateModal) return;
updateModal.classList.remove("active");
updateModal.setAttribute("aria-hidden", "true");
}
function addUpdateItem() {
if (!updateItemsEditor) return;
const firstItem = updateItemsEditor.querySelector(".update-item-form");
if (!firstItem) return;
const clone = firstItem.cloneNode(true);
clone.querySelectorAll("input, textarea").forEach((field) => {
field.value = "";
});
clone.querySelectorAll("select").forEach((field) => {
field.selectedIndex = 0;
});
updateItemsEditor.appendChild(clone);
}
function insertSnippet(textarea, snippet) {
if (!textarea || !snippet) return;
const start = textarea.selectionStart ?? textarea.value.length;
const end = textarea.selectionEnd ?? textarea.value.length;
const before = textarea.value.slice(0, start);
const after = textarea.value.slice(end);
const spacerBefore = before && !before.endsWith("\n") ? "\n" : "";
const spacerAfter = after && !snippet.endsWith("\n") ? "\n" : "";
const insertion = spacerBefore + snippet + spacerAfter;
textarea.value = before + insertion + after;
const cursorPosition = before.length + insertion.length;
textarea.focus();
textarea.setSelectionRange(cursorPosition, cursorPosition);
}
function updateSelectedSourcePaths() {
if (!parserSourceSelect) return;
const option = parserSourceSelect.options[parserSourceSelect.selectedIndex];
if (selectedLogoBasePath) {
selectedLogoBasePath.textContent = option?.dataset?.logoBasePath || "—";
}
if (selectedPhotoBasePath) {
selectedPhotoBasePath.textContent = option?.dataset?.photoBasePath || "—";
}
}
document.addEventListener("click", (event) => {
const button = event.target.closest(".format-btn");
if (!button) return;
const snippet = button.dataset.snippet || "";
const explicitTargetId = button.closest(".format-toolbar")?.dataset.formatFor;
let textarea = explicitTargetId ? document.getElementById(explicitTargetId) : null;
if (!textarea) {
textarea = button.closest(".update-item-text")?.querySelector("textarea");
}
insertSnippet(textarea, snippet);
});
updateSelectedSourcePaths();
parserSourceSelect?.addEventListener("change", updateSelectedSourcePaths);
parserForms.forEach((form) => { parserForms.forEach((form) => {
form.addEventListener("submit", () => { form.addEventListener("submit", () => {
const parserTitle = form.dataset.parserTitle || "Запуск парсера"; const parserTitle = form.dataset.parserTitle || "Запуск парсера";
@@ -492,12 +1052,55 @@
}); });
} }
if (openSettingsBtn) {
openSettingsBtn.addEventListener("click", openSettingsModal);
}
if (closeSettingsBtn) {
closeSettingsBtn.addEventListener("click", closeSettingsModal);
}
if (settingsModal) {
settingsModal.addEventListener("click", (event) => {
if (event.target === settingsModal) {
closeSettingsModal();
}
});
}
if (openAddUpdateBtn) {
openAddUpdateBtn.addEventListener("click", openUpdateModal);
}
if (closeAddUpdateBtn) {
closeAddUpdateBtn.addEventListener("click", closeUpdateModal);
}
if (updateModal) {
updateModal.addEventListener("click", (event) => {
if (event.target === updateModal) {
closeUpdateModal();
}
});
}
if (addUpdateItemBtn) {
addUpdateItemBtn.addEventListener("click", addUpdateItem);
}
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
if (event.key === "Escape") { if (event.key === "Escape") {
closeAccountModal(); closeAccountModal();
closeSettingsModal();
closeUpdateModal();
} }
}); });
const queryParams = new URLSearchParams(window.location.search);
if (queryParams.get("open_update") === "1") {
openUpdateModal();
}
resizeCanvas(); resizeCanvas();
window.addEventListener("resize", resizeCanvas); window.addEventListener("resize", resizeCanvas);
</script> </script>

View File

@@ -143,6 +143,31 @@
object-fit: contain; object-fit: contain;
display: block; display: block;
} }
input[readonly] {
color: var(--muted);
cursor: default;
}
.checkbox-row {
min-height: 42px;
display: flex;
align-items: center;
gap: 10px;
color: var(--text);
}
.checkbox-row input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--accent);
}
.hint {
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
</style> </style>
</head> </head>
@@ -182,6 +207,11 @@
<input type="text" name="external_id" value="{{ player.external_id }}"> <input type="text" name="external_id" value="{{ player.external_id }}">
</div> </div>
<div class="form-group">
<label>Название команды</label>
<input type="text" value="{{ player.team_name }}" readonly>
</div>
<div class="form-group"> <div class="form-group">
<label>Амплуа</label> <label>Амплуа</label>
<select name="position"> <select name="position">
@@ -199,8 +229,13 @@
</div> </div>
<div class="form-group full"> <div class="form-group full">
<label>Фото игрока</label> <label>Файл фото игрока</label>
<input type="text" name="photo" value="{{ player.photo }}"> <input type="text" name="photo" value="{{ player.photo }}" placeholder="Например: Динамо\Иванов Иван.png или Иванов Иван.png">
<label class="checkbox-row">
<input type="checkbox" name="photo_enabled" value="true" {% if player.photo_enabled %}checked{% endif %}>
Использовать фото в JSON/vMix
</label>
<div class="hint">Если поле пустое, путь генерируется автоматически: папка фотографий источника + Команда\Фамилия Имя.png. Если поле заполнено, можно указать имя файла или относительный путь. Если галочка выключена — отдается EMPTY.png из папки выбранного источника.</div>
{% if player.photo %} {% if player.photo %}
<div class="media-preview-box"> <div class="media-preview-box">
<img src="{{ player.photo }}" alt="{{ player.full_name }}" class="media-preview-image"> <img src="{{ player.photo }}" alt="{{ player.full_name }}" class="media-preview-image">

View File

@@ -28,7 +28,7 @@
} }
.page { .page {
max-width: 1400px; max-width: 1500px;
margin: 0 auto; margin: 0 auto;
padding: 24px; padding: 24px;
} }
@@ -62,7 +62,7 @@
} }
input[type="text"] { input[type="text"] {
min-width: 280px; min-width: 320px;
min-height: 42px; min-height: 42px;
padding: 0 12px; padding: 0 12px;
border-radius: 10px; border-radius: 10px;
@@ -94,11 +94,16 @@
border: 1px solid var(--border); border: 1px solid var(--border);
} }
.table-wrap {
width: 100%;
overflow-x: auto;
border-radius: 12px;
}
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
background: var(--panel-2); background: var(--panel-2);
border-radius: 12px;
overflow: hidden; overflow: hidden;
} }
@@ -107,6 +112,7 @@
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
text-align: left; text-align: left;
font-size: 14px; font-size: 14px;
white-space: nowrap;
} }
th { th {
@@ -118,10 +124,47 @@
border-bottom: none; border-bottom: none;
} }
.sort-link {
color: var(--muted);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 6px;
}
.sort-link:hover {
color: var(--text);
}
.sort-indicator {
color: var(--accent);
min-width: 12px;
display: inline-block;
}
.id-col { .id-col {
width: 70px; width: 70px;
} }
.photo-col {
width: 80px;
text-align: center;
}
.photo-check {
width: 18px;
height: 18px;
accent-color: var(--accent);
cursor: pointer;
}
.photo-toggle-form {
margin: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.action-col { .action-col {
width: 140px; width: 140px;
text-align: right; text-align: right;
@@ -143,45 +186,71 @@
<div class="title">Игроки</div> <div class="title">Игроки</div>
<form method="get" action="/admin/db/players" class="search-form"> <form method="get" action="/admin/db/players" class="search-form">
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по ФИО или external_id"> <input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="direction" value="{{ direction }}">
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по ФИО, external_id или команде">
<button type="submit" class="btn btn-primary">Найти</button> <button type="submit" class="btn btn-primary">Найти</button>
{% if request.state.current_user and request.state.current_user.role == "admin" %}
<a href="/admin/db/create?entity=player" class="btn btn-primary"> Создать игрока</a>
{% endif %}
<a href="/admin/db" class="btn btn-secondary">Назад</a> <a href="/admin/db" class="btn btn-secondary">Назад</a>
</form> </form>
</div> </div>
{% if players %} {% if players %}
<table> <div class="table-wrap">
<thead> <table>
<tr> <thead>
<th class="id-col">ID</th> <tr>
<th>ФИО</th> <th class="id-col"><a class="sort-link" href="{{ sort_links.id.url }}">ID <span class="sort-indicator">{{ sort_links.id.indicator }}</span></a></th>
<th>Имя</th> <th><a class="sort-link" href="{{ sort_links.full_name.url }}">ФИО <span class="sort-indicator">{{ sort_links.full_name.indicator }}</span></a></th>
<th>Фамилия</th> <th><a class="sort-link" href="{{ sort_links.first_name.url }}">Имя <span class="sort-indicator">{{ sort_links.first_name.indicator }}</span></a></th>
<th>External ID</th> <th><a class="sort-link" href="{{ sort_links.last_name.url }}">Фамилия <span class="sort-indicator">{{ sort_links.last_name.indicator }}</span></a></th>
<th>Амплуа</th> <th><a class="sort-link" href="{{ sort_links.external_id.url }}">External ID <span class="sort-indicator">{{ sort_links.external_id.indicator }}</span></a></th>
<th class="action-col"></th> <th><a class="sort-link" href="{{ sort_links.position.url }}">Амплуа <span class="sort-indicator">{{ sort_links.position.indicator }}</span></a></th>
</tr> <th><a class="sort-link" href="{{ sort_links.team_name.url }}">Название команды <span class="sort-indicator">{{ sort_links.team_name.indicator }}</span></a></th>
</thead> <th class="photo-col"><a class="sort-link" href="{{ sort_links.photo.url }}">Фото <span class="sort-indicator">{{ sort_links.photo.indicator }}</span></a></th>
<tbody> <th class="action-col"></th>
{% for p in players %} </tr>
<tr> </thead>
<td>{{ p.id }}</td> <tbody>
<td>{{ p.full_name }}</td> {% for p in players %}
<td>{{ p.first_name }}</td> <tr>
<td>{{ p.last_name }}</td> <td>{{ p.id }}</td>
<td>{{ p.external_id }}</td> <td>{{ p.full_name }}</td>
<td>{{ p.position }}</td> <td>{{ p.first_name }}</td>
<td class="action-col"> <td>{{ p.last_name }}</td>
<a href="/admin/db/players/{{ p.id }}/edit" class="btn btn-secondary">Редактировать</a> <td>{{ p.external_id }}</td>
</td> <td>{{ p.position }}</td>
</tr> <td>{{ p.team_name }}</td>
{% endfor %} <td class="photo-col">
</tbody> <form method="post" action="/admin/db/players/{{ p.id }}/photo-enabled" class="photo-toggle-form">
</table> <input type="hidden" name="q" value="{{ q }}">
<input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="direction" value="{{ direction }}">
<input
type="checkbox"
name="photo_enabled"
value="true"
class="photo-check"
title="Использовать фото игрока в JSON/vMix"
onchange="this.form.submit()"
{% if p.photo_enabled %}checked{% endif %}
>
</form>
</td>
<td class="action-col">
<a href="/admin/db/players/{{ p.id }}/edit" class="btn btn-secondary">Редактировать</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %} {% else %}
<div class="empty-box">Игроки не найдены.</div> <div class="empty-box">Игроки не найдены.</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -37,6 +37,9 @@
<form method="get" action="/admin/db/referees" class="search-form"> <form method="get" action="/admin/db/referees" class="search-form">
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по ФИО или external_id"> <input type="text" name="q" value="{{ q }}" placeholder="Поиск по ФИО или external_id">
<button type="submit" class="btn btn-primary">Найти</button> <button type="submit" class="btn btn-primary">Найти</button>
{% if request.state.current_user and request.state.current_user.role == "admin" %}
<a href="/admin/db/create?entity=referee" class="btn btn-primary"> Создать судью</a>
{% endif %}
<a href="/admin/db" class="btn btn-secondary">Назад</a> <a href="/admin/db" class="btn btn-secondary">Назад</a>
</form> </form>
</div> </div>

View File

@@ -27,19 +27,6 @@
background: var(--panel-2); background: var(--panel-2);
color: var(--text); 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; } .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 { 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-primary { background: var(--accent); color: white; }
@@ -80,14 +67,10 @@
</div> </div>
<div class="form-group full"> <div class="form-group full">
<label>Путь к логотипу</label> <label>Файл логотипа</label>
<input type="text" name="logo_path" value="{{ team.logo_path }}"> <input type="text" name="logo_path" value="{{ team.logo_path }}" placeholder="Например: Динамо_Синий.png">
<div class="logo-box"> <div class="small-meta">
{% if team.logo_path %} В команде хранится только имя файла. Полный путь подставляется автоматически по источнику выбранного матча.
<img src="{{ team.logo_path }}" alt="{{ team.name }}" class="logo-preview">
{% else %}
Логотип не задан.
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -26,13 +26,6 @@
tr:last-child td { border-bottom: none; } tr:last-child td { border-bottom: none; }
.id-col { width: 70px; } .id-col { width: 70px; }
.action-col { width: 140px; text-align: right; } .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); } .empty-box { padding: 18px; border-radius: 12px; background: var(--panel-2); color: var(--muted); border: 1px dashed var(--border); }
</style> </style>
</head> </head>
@@ -57,7 +50,7 @@
<th>Полное название</th> <th>Полное название</th>
<th>Город</th> <th>Город</th>
<th>3 буквы</th> <th>3 буквы</th>
<th>Логотип</th> <th>Файл логотипа</th>
<th>External ID</th> <th>External ID</th>
<th class="action-col"></th> <th class="action-col"></th>
</tr> </tr>
@@ -70,13 +63,7 @@
<td>{{ t.full_name or "—" }}</td> <td>{{ t.full_name or "—" }}</td>
<td>{{ t.city or "—" }}</td> <td>{{ t.city or "—" }}</td>
<td>{{ t.short_name_3 or "—" }}</td> <td>{{ t.short_name_3 or "—" }}</td>
<td> <td>{{ t.logo_path or "—" }}</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>{{ t.external_id }}</td>
<td class="action-col"> <td class="action-col">
<a href="/admin/db/teams/{{ t.id }}/edit" class="btn btn-secondary">Редактировать</a> <a href="/admin/db/teams/{{ t.id }}/edit" class="btn btn-secondary">Редактировать</a>

File diff suppressed because it is too large Load Diff

1024
templates/info_updated.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,9 @@
<meta http-equiv="Expires" content="0" /> <meta http-equiv="Expires" content="0" />
<title>ЖФЛ Управление vMix</title> <title>ЖФЛ Управление vMix</title>
<link rel="icon" href="/static/smith.ico" type="image/x-icon"> <link rel="icon" href="/static/smith.ico" type="image/x-icon">
<link rel="stylesheet" href="/static/styles.css?v=20260421_4"> <link rel="stylesheet" href="/static/styles.css?v={{ static_version('styles.css') }}">
<style> <style>
.player-link-btn, .player-link-btn,
.coach-link-btn { .coach-link-btn {
@@ -66,6 +67,9 @@
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
letter-spacing: 0.02em; letter-spacing: 0.02em;
box-shadow: 0 0 10px rgba(123, 97, 255, 0.28); box-shadow: 0 0 10px rgba(123, 97, 255, 0.28);
object-fit: contain; /* чтобы не растягивалась */
display: block;
} }
.vmix-btn { .vmix-btn {
display: inline-flex; display: inline-flex;
@@ -84,12 +88,6 @@
background: #222; background: #222;
} }
.vmix-icon {
width: 1em; /* подгоняешь под стиль кнопок */
height: 1em;
object-fit: contain; /* чтобы не растягивалась */
display: block;
}
</style> </style>
@@ -152,6 +150,15 @@
<a href="/admin/session/{{ session[3] }}/download-vmix-page" class="vmix-btn"> <a href="/admin/session/{{ session[3] }}/download-vmix-page" class="vmix-btn">
<img src="/static/images/vmix_icon.png" class="vmix-icon" alt="vMix"> <img src="/static/images/vmix_icon.png" class="vmix-icon" alt="vMix">
<a
href="/info"
target="_blank"
class="icon-btn header-icon"
title="Информация"
style="text-decoration: none;"
>
?
</a>
</a> </a>
<form <form
method="post" method="post"
@@ -238,6 +245,15 @@
Расписание тура Расписание тура
</a> </a>
{% if is_russian_cup %}
<a
class="tab-link {% if tab == 'penalties' %}active{% endif %}"
href="/admin/session/{{ session[3] }}?tab=penalties"
data-vmix-title="ПЕНАЛЬТИ"
>
Пенальти
</a>
{% else %}
<a <a
class="tab-link {% if tab == 'standings' %}active{% endif %}" class="tab-link {% if tab == 'standings' %}active{% endif %}"
href="/admin/session/{{ session[3] }}?tab=standings" href="/admin/session/{{ session[3] }}?tab=standings"
@@ -245,6 +261,7 @@
> >
Турнирная таблица Турнирная таблица
</a> </a>
{% endif %}
</div> </div>
{% if tab == 'game' %} {% if tab == 'game' %}
@@ -291,7 +308,7 @@
<span class="period-badge" id="periodBadge" <span class="period-badge" id="periodBadge"
>Матч не начат</span >Матч не начат</span
> >
<div class="timer-box" id="matchTimer">00:00</div> <div class="timer-box timer-box-clickable" id="matchTimer" onclick="openClockEditor()" title="Нажмите, чтобы изменить время">00:00</div>
<div class="score-box" id="matchScore">0 : 0</div> <div class="score-box" id="matchScore">0 : 0</div>
<div class="extra-time-wrap"> <div class="extra-time-wrap">
@@ -395,13 +412,22 @@
data-player-number="{{ p.number or '' }}" data-player-number="{{ p.number or '' }}"
data-player-position="{{ p.position or '' }}" data-player-position="{{ p.position or '' }}"
data-player-captain="{{ 1 if p.is_captain else 0 }}" data-player-captain="{{ 1 if p.is_captain else 0 }}"
data-player-photo-enabled="{{ 1 if p.photo_enabled else 0 }}"
> >
<td class="num-cell">{{ p.number }}</td> <td class="num-cell">
<span class="num-with-photo-status">
{% if not p.photo_enabled %}
<span class="no-photo-icon" title="Фото выключено: в JSON будет подставлена EMPTY.png" aria-label="Фото выключено">📷</span>
{% endif %}
<span class="player-number-value">{{ p.number }}</span>
</span>
</td>
<td class="player-cell" title="Показать титр игрока"> <td class="player-cell" title="Показать титр игрока">
<div <div
class="player-inline vmix-person-trigger player-link" class="player-inline vmix-person-trigger player-link"
data-vmix-person-type="player" data-vmix-person-type="player"
data-side="home" data-side="home"
data-name="{{ (((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '') }}"
data-first-name="{{ p.first_name or '' }}" data-first-name="{{ p.first_name or '' }}"
data-last-name="{{ p.last_name or '' }}" data-last-name="{{ p.last_name or '' }}"
data-role="{{ p.position or '' }}" data-role="{{ p.position or '' }}"
@@ -427,7 +453,7 @@ data-role="{{ p.position or '' }}"
type="button" type="button"
class="icon-btn goal" class="icon-btn goal"
title="Гол" title="Гол"
onclick="addPlayerEvent('home', 'goal', {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or '') ~ ' ' ~ (p.first_name or '')) | trim | tojson }})" onclick="addPlayerEvent('home', 'goal', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }})"
> >
</button> </button>
@@ -435,7 +461,7 @@ data-role="{{ p.position or '' }}"
type="button" type="button"
class="icon-btn" class="icon-btn"
title="Автогол" title="Автогол"
onclick="addPlayerEvent('home', 'own_goal', {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or '') ~ ' ' ~ (p.first_name or '')) | trim | tojson }})" onclick="addPlayerEvent('home', 'own_goal', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }})"
> >
АГ АГ
</button> </button>
@@ -443,7 +469,7 @@ data-role="{{ p.position or '' }}"
type="button" type="button"
class="icon-btn" class="icon-btn"
title="Пенальти" title="Пенальти"
onclick="addPlayerEvent('home', 'penalty', {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or '') ~ ' ' ~ (p.first_name or '')) | trim | tojson }})" onclick="addPlayerEvent('home', 'penalty', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }})"
> >
П П
</button> </button>
@@ -451,7 +477,7 @@ data-role="{{ p.position or '' }}"
type="button" type="button"
class="icon-btn sub" class="icon-btn sub"
title="Замена" title="Замена"
onclick="beginSubstitution('home', {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or '') ~ ' ' ~ (p.first_name or '')) | trim | tojson }}, this)" onclick="beginSubstitution('home', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }}, this)"
> >
</button> </button>
@@ -459,13 +485,13 @@ data-role="{{ p.position or '' }}"
type="button" type="button"
class="icon-btn yellow-card" class="icon-btn yellow-card"
title="Жёлтая карточка" title="Жёлтая карточка"
onclick="addPlayerEvent('home', 'yellow', {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or '') ~ ' ' ~ (p.first_name or '')) | trim | tojson }})" onclick="addPlayerEvent('home', 'yellow', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }})"
></button> ></button>
<button <button
type="button" type="button"
class="icon-btn red-card" class="icon-btn red-card"
title="Красная карточка" title="Красная карточка"
onclick="addPlayerEvent('home', 'red', {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or '') ~ ' ' ~ (p.first_name or '')) | trim | tojson }})" onclick="addPlayerEvent('home', 'red', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }})"
></button> ></button>
</div> </div>
</td> </td>
@@ -503,13 +529,22 @@ data-role="{{ p.position or '' }}"
data-player-number="{{ p.number or '' }}" data-player-number="{{ p.number or '' }}"
data-player-position="{{ p.position or '' }}" data-player-position="{{ p.position or '' }}"
data-player-captain="{{ 1 if p.is_captain else 0 }}" data-player-captain="{{ 1 if p.is_captain else 0 }}"
data-player-photo-enabled="{{ 1 if p.photo_enabled else 0 }}"
> >
<td class="num-cell">{{ p.number }}</td> <td class="num-cell">
<span class="num-with-photo-status">
{% if not p.photo_enabled %}
<span class="no-photo-icon" title="Фото выключено: в JSON будет подставлена EMPTY.png" aria-label="Фото выключено">📷</span>
{% endif %}
<span class="player-number-value">{{ p.number }}</span>
</span>
</td>
<td class="player-cell" title="Показать титр игрока"> <td class="player-cell" title="Показать титр игрока">
<div <div
class="player-inline vmix-person-trigger player-link" class="player-inline vmix-person-trigger player-link"
data-vmix-person-type="player" data-vmix-person-type="player"
data-side="home" data-side="home"
data-name="{{ (((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '') }}"
data-first-name="{{ p.first_name or '' }}" data-first-name="{{ p.first_name or '' }}"
data-last-name="{{ p.last_name or '' }}" data-last-name="{{ p.last_name or '' }}"
data-role="{{ p.position or '' }}" data-role="{{ p.position or '' }}"
@@ -535,7 +570,7 @@ data-role="{{ p.position or '' }}"
type="button" type="button"
class="icon-btn sub" class="icon-btn sub"
title="Замена" title="Замена"
onclick="selectBenchPlayer('home', {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or '') ~ ' ' ~ (p.first_name or '')) | trim | tojson }}, this)" onclick="selectBenchPlayer('home', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }}, this)"
> >
</button> </button>
@@ -621,13 +656,22 @@ data-role="{{ p.position or '' }}"
data-player-number="{{ p.number or '' }}" data-player-number="{{ p.number or '' }}"
data-player-position="{{ p.position or '' }}" data-player-position="{{ p.position or '' }}"
data-player-captain="{{ 1 if p.is_captain else 0 }}" data-player-captain="{{ 1 if p.is_captain else 0 }}"
data-player-photo-enabled="{{ 1 if p.photo_enabled else 0 }}"
> >
<td class="num-cell">{{ p.number }}</td> <td class="num-cell">
<span class="num-with-photo-status">
{% if not p.photo_enabled %}
<span class="no-photo-icon" title="Фото выключено: в JSON будет подставлена EMPTY.png" aria-label="Фото выключено">📷</span>
{% endif %}
<span class="player-number-value">{{ p.number }}</span>
</span>
</td>
<td class="player-cell" title="Показать титр игрока"> <td class="player-cell" title="Показать титр игрока">
<div <div
class="player-inline vmix-person-trigger player-link" class="player-inline vmix-person-trigger player-link"
data-vmix-person-type="player" data-vmix-person-type="player"
data-side="away" data-side="away"
data-name="{{ (((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '') }}"
data-first-name="{{ p.first_name or '' }}" data-first-name="{{ p.first_name or '' }}"
data-last-name="{{ p.last_name or '' }}" data-last-name="{{ p.last_name or '' }}"
data-role="{{ p.position or '' }}" data-role="{{ p.position or '' }}"
@@ -653,7 +697,7 @@ data-role="{{ p.position or '' }}"
type="button" type="button"
class="icon-btn goal" class="icon-btn goal"
title="Гол" title="Гол"
onclick="addPlayerEvent('away', 'goal', {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or '') ~ ' ' ~ (p.first_name or '')) | trim | tojson }})" onclick="addPlayerEvent('away', 'goal', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }})"
> >
</button> </button>
@@ -661,7 +705,7 @@ data-role="{{ p.position or '' }}"
type="button" type="button"
class="icon-btn" class="icon-btn"
title="Автогол" title="Автогол"
onclick="addPlayerEvent('away', 'own_goal', {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or '') ~ ' ' ~ (p.first_name or '')) | trim | tojson }})" onclick="addPlayerEvent('away', 'own_goal', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }})"
> >
АГ АГ
</button> </button>
@@ -669,7 +713,7 @@ data-role="{{ p.position or '' }}"
type="button" type="button"
class="icon-btn" class="icon-btn"
title="Пенальти" title="Пенальти"
onclick="addPlayerEvent('away', 'penalty', {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or '') ~ ' ' ~ (p.first_name or '')) | trim | tojson }})" onclick="addPlayerEvent('away', 'penalty', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }})"
> >
П П
</button> </button>
@@ -677,7 +721,7 @@ data-role="{{ p.position or '' }}"
type="button" type="button"
class="icon-btn sub" class="icon-btn sub"
title="Замена" title="Замена"
onclick="beginSubstitution('away', {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or '') ~ ' ' ~ (p.first_name or '')) | trim | tojson }}, this)" onclick="beginSubstitution('away', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }}, this)"
> >
</button> </button>
@@ -685,13 +729,13 @@ data-role="{{ p.position or '' }}"
type="button" type="button"
class="icon-btn yellow-card" class="icon-btn yellow-card"
title="Жёлтая карточка" title="Жёлтая карточка"
onclick="addPlayerEvent('away', 'yellow', {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or '') ~ ' ' ~ (p.first_name or '')) | trim | tojson }})" onclick="addPlayerEvent('away', 'yellow', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }})"
></button> ></button>
<button <button
type="button" type="button"
class="icon-btn red-card" class="icon-btn red-card"
title="Красная карточка" title="Красная карточка"
onclick="addPlayerEvent('away', 'red', {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or '') ~ ' ' ~ (p.first_name or '')) | trim | tojson }})" onclick="addPlayerEvent('away', 'red', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }})"
></button> ></button>
</div> </div>
</td> </td>
@@ -729,13 +773,22 @@ data-role="{{ p.position or '' }}"
data-player-number="{{ p.number or '' }}" data-player-number="{{ p.number or '' }}"
data-player-position="{{ p.position or '' }}" data-player-position="{{ p.position or '' }}"
data-player-captain="{{ 1 if p.is_captain else 0 }}" data-player-captain="{{ 1 if p.is_captain else 0 }}"
data-player-photo-enabled="{{ 1 if p.photo_enabled else 0 }}"
> >
<td class="num-cell">{{ p.number }}</td> <td class="num-cell">
<span class="num-with-photo-status">
{% if not p.photo_enabled %}
<span class="no-photo-icon" title="Фото выключено: в JSON будет подставлена EMPTY.png" aria-label="Фото выключено">📷</span>
{% endif %}
<span class="player-number-value">{{ p.number }}</span>
</span>
</td>
<td class="player-cell" title="Показать титр игрока"> <td class="player-cell" title="Показать титр игрока">
<div <div
class="player-inline vmix-person-trigger player-link" class="player-inline vmix-person-trigger player-link"
data-vmix-person-type="player" data-vmix-person-type="player"
data-side="away" data-side="away"
data-name="{{ (((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '') }}"
data-first-name="{{ p.first_name or '' }}" data-first-name="{{ p.first_name or '' }}"
data-last-name="{{ p.last_name or '' }}" data-last-name="{{ p.last_name or '' }}"
data-role="{{ p.position or '' }}" data-role="{{ p.position or '' }}"
@@ -761,7 +814,7 @@ data-role="{{ p.position or '' }}"
type="button" type="button"
class="icon-btn sub" class="icon-btn sub"
title="Замена" title="Замена"
onclick="selectBenchPlayer('away', {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or '') ~ ' ' ~ (p.first_name or '')) | trim | tojson }}, this)" onclick="selectBenchPlayer('away', {{ p.player_id | default(p.number) | tojson }}, {{ ((((p.first_name or '') ~ ' ' ~ (p.last_name or '')) | trim) or (p.player_name or '')) | tojson }}, this)"
> >
</button> </button>
@@ -790,7 +843,7 @@ data-role="{{ p.position or '' }}"
<button <button
type="button" type="button"
class="btn btn-secondary" class="btn btn-secondary"
onclick="clearMatchEvents()" onclick="openClearMatchModal()"
> >
Очистить Очистить
</button> </button>
@@ -929,11 +982,16 @@ data-role="{{ p.position or '' }}"
<th class="tour-match-col">Матч</th> <th class="tour-match-col">Матч</th>
<th class="tour-meta-col">Счёт / статус</th> <th class="tour-meta-col">Счёт / статус</th>
<th class="tour-stadium-col">Стадион</th> <th class="tour-stadium-col">Стадион</th>
<th class="tour-channel-col">Канал</th>
<th class="tour-actions-col tour-edit-only">Правка</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for m in tour_schedule %} {% for m in tour_schedule %}
<tr> {% set row_key = m.match_external_id %}
{% set home_score_value = '' if m.home_score is none else m.home_score %}
{% set away_score_value = '' if m.away_score is none else m.away_score %}
<tr class="tour-schedule-row" data-schedule-row="{{ row_key }}">
<td class="tour-date-cell"> <td class="tour-date-cell">
{% if m.match_date %} {% if m.match_date %}
{{ m.match_date.day }} {{ month_names[m.match_date.month] }} {{ m.match_date.day }} {{ month_names[m.match_date.month] }}
@@ -951,32 +1009,111 @@ data-role="{{ p.position or '' }}"
</td> </td>
<td class="tour-meta-cell"> <td class="tour-meta-cell">
<div class="tour-meta-wrap"> <div class="tour-meta-wrap tour-meta-display" data-schedule-display="{{ row_key }}">
{% if m.status == 'finished' and m.home_score is not none {% if m.home_score is not none and m.away_score is not none %}
and m.away_score is not none %} <span class="score-badge">{{ m.home_score }} : {{ m.away_score }}</span>
<span class="score-badge" {% endif %}
>{{ m.home_score }} : {{ m.away_score }}</span {% if m.status == 'finished' %}
>
{% endif %} {% if m.status == 'finished' %}
<span class="status-badge status-finished">Завершён</span> <span class="status-badge status-finished">Завершён</span>
{% elif m.status == 'live' %} {% elif m.status == 'live' %}
<span class="status-badge status-live">Идёт</span> <span class="status-badge status-live">Live</span>
{% elif m.status == 'scheduled' %} {% elif m.status == 'scheduled' %}
<span class="status-badge status-scheduled" <span class="status-badge status-scheduled">Не начался</span>
>Не начался</span
>
{% else %} {% else %}
<span <span class="status-badge status-scheduled">{{ m.status or "—" }}</span>
class="status-badge status-scheduled"
>{{ m.status or "—" }}</span
>
{% endif %} {% endif %}
</div> </div>
<div class="tour-score-edit" data-schedule-edit="{{ row_key }}" hidden>
<div class="tour-edit-line">
<div class="tour-score-inputs" aria-label="Счёт">
<input
type="number"
min="0"
class="tour-score-input"
data-match-id="{{ row_key }}"
data-score-side="home"
value="{{ home_score_value }}"
data-initial-value="{{ home_score_value }}"
placeholder="—"
disabled
>
<span class="tour-score-separator">:</span>
<input
type="number"
min="0"
class="tour-score-input"
data-match-id="{{ row_key }}"
data-score-side="away"
value="{{ away_score_value }}"
data-initial-value="{{ away_score_value }}"
placeholder="—"
disabled
>
</div>
<select
class="tour-status-select"
data-match-id="{{ row_key }}"
data-initial-status="{{ m.status or 'scheduled' }}"
disabled
>
<option value="scheduled" {% if m.status == 'scheduled' or not m.status %}selected{% endif %}>Не начался</option>
<option value="live" {% if m.status == 'live' %}selected{% endif %}>Live</option>
<option value="finished" {% if m.status == 'finished' %}selected{% endif %}>Завершён</option>
</select>
</div>
</div>
</td> </td>
<td class="stadium-cell"> <td class="stadium-cell">
{{ m.stadium_name or "—" }} {{ m.stadium_name or "—" }}
</td> </td>
<td class="tour-channel-cell">
{% set channel = m.channel or "" %}
<label class="channel-check">
<input
type="checkbox"
class="tour-channel-checkbox"
data-channel="match"
data-match-id="{{ m.match_external_id }}"
onchange="saveTourChannel('{{ m.match_external_id }}')"
{% if channel in ['match', 'match_and_premier'] %}checked{% endif %}
>
Матч
</label>
<label class="channel-check">
<input
type="checkbox"
class="tour-channel-checkbox"
data-channel="match_premier"
data-match-id="{{ m.match_external_id }}"
onchange="saveTourChannel('{{ m.match_external_id }}')"
{% if channel in ['match_premier', 'match_and_premier'] %}checked{% endif %}
>
Матч Премьер
</label>
</td>
<td class="tour-actions-cell tour-edit-only">
<div class="tour-save-actions" data-schedule-save-actions="{{ row_key }}" hidden>
<button
type="button"
class="btn btn-primary tour-save-btn"
onclick="saveTourScheduleMatch('{{ row_key }}')"
title="Сохранить счёт и статус"
></button>
<button
type="button"
class="btn btn-secondary tour-cancel-btn"
onclick="cancelTourScheduleEdit('{{ row_key }}')"
title="Отмена"
>×</button>
</div>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -1038,6 +1175,64 @@ data-role="{{ p.position or '' }}"
{% endif %} {% endif %}
</div> </div>
{% elif tab == 'penalties' and is_russian_cup %}
<div class="panel penalty-panel" id="penaltyPanel">
<div class="penalty-header">
<div>
<div class="team-panel-title">Серия пенальти</div>
<div class="penalty-subtitle">Быстро отмечай удары для Кубка России. В vMix всегда уходят 5 строк: 15, затем 610 снова в строки 15.</div>
</div>
<div class="penalty-header-actions">
<button type="button" class="btn btn-secondary" onclick="addPenaltyRound()">+ Добавить серию</button>
<button type="button" class="btn btn-secondary" onclick="deleteLastPenaltyRound()">Удалить последнюю серию</button>
<button type="button" class="btn btn-danger" onclick="clearPenaltyShootout()">Очистить</button>
</div>
</div>
<div class="penalty-scoreboard">
<div class="penalty-team-card home">
<div class="penalty-team-name">{{ session[14] }}</div>
<div class="penalty-team-score" id="penaltyHomeScore">0</div>
<div class="penalty-next-label" id="penaltyHomeNext">Следующий удар: 1</div>
<div class="penalty-fast-actions">
<button type="button" class="penalty-fast-btn scored" onclick="setNextPenaltyShot('home', 'scored')">Забил</button>
<button type="button" class="penalty-fast-btn missed" onclick="setNextPenaltyShot('home', 'missed')">Не забил</button>
</div>
</div>
<div class="penalty-team-card away">
<div class="penalty-team-name">{{ session[18] }}</div>
<div class="penalty-team-score" id="penaltyAwayScore">0</div>
<div class="penalty-next-label" id="penaltyAwayNext">Следующий удар: 1</div>
<div class="penalty-fast-actions">
<button type="button" class="penalty-fast-btn scored" onclick="setNextPenaltyShot('away', 'scored')">Забил</button>
<button type="button" class="penalty-fast-btn missed" onclick="setNextPenaltyShot('away', 'missed')">Не забил</button>
</div>
</div>
</div>
<div class="penalty-table-wrap">
<table class="penalty-table">
<thead>
<tr>
<th>Серия</th>
<th>{{ session[14] }}</th>
<th>{{ session[18] }}</th>
</tr>
</thead>
<tbody id="penaltyRoundsBody">
<tr>
<td colspan="3" class="empty-box">Загрузка серии пенальти…</td>
</tr>
</tbody>
</table>
</div>
<div class="penalty-footer">
<div class="penalty-help">Любой удар можно исправить прямо в таблице: нажми “Забил”, “Не забил” или “Очистить” в нужной ячейке. После 5 ударов продолжай 6-й, 7-й и дальше — для vMix они будут записываться в первые 5 строк нового блока.</div>
</div>
</div>
{% else %} {% else %}
<div class="panel"> <div class="panel">
<div class="empty-box">Эта вкладка пока в разработке.</div> <div class="empty-box">Эта вкладка пока в разработке.</div>
@@ -1062,6 +1257,8 @@ data-role="{{ p.position or '' }}"
}, },
sessionToken: {{ session[3] | tojson }}, sessionToken: {{ session[3] | tojson }},
matchId: {{ session[1] | tojson }}, matchId: {{ session[1] | tojson }},
sourceKey: {{ source_key | tojson }},
isRussianCup: {{ is_russian_cup | tojson }},
homeFormations: {{ home_formations | tojson }}, homeFormations: {{ home_formations | tojson }},
awayFormations: {{ away_formations | tojson }}, awayFormations: {{ away_formations | tojson }},
@@ -1085,6 +1282,38 @@ data-role="{{ p.position or '' }}"
awayCoachPool: [] awayCoachPool: []
}; };
</script> </script>
<div class="formation-modal" id="clockEditorModal">
<div class="formation-modal-backdrop" onclick="closeClockEditor()"></div>
<div class="formation-modal-content clock-editor-modal-content">
<div class="formation-modal-header">
<div>
<div class="formation-modal-title">Редактировать время</div>
<div class="formation-modal-subtitle" id="clockEditorHint"></div>
</div>
<button type="button" class="modal-close-btn" onclick="closeClockEditor()">×</button>
</div>
<div class="formation-modal-body clock-editor-body">
<label class="clock-editor-label" for="clockEditorInput">Текущее матчевое время</label>
<input
type="text"
class="clock-editor-input"
id="clockEditorInput"
placeholder="12:30"
inputmode="numeric"
onkeydown="if (event.key === 'Enter') applyClockEditor(); if (event.key === 'Escape') closeClockEditor();"
/>
<div class="clock-editor-note">Можно ввести 12:30, 57:20 или 01:02:03.</div>
</div>
<div class="formation-modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeClockEditor()">Отмена</button>
<button type="button" class="btn btn-primary" onclick="applyClockEditor()">Применить в интерфейсе и vMix</button>
</div>
</div>
</div>
<div class="formation-modal" id="formationAssignModal"> <div class="formation-modal" id="formationAssignModal">
<div <div
class="formation-modal-backdrop" class="formation-modal-backdrop"
@@ -1158,6 +1387,27 @@ data-role="{{ p.position or '' }}"
</div> </div>
</div> </div>
<div class="formation-modal captain-required-modal" id="captainRequiredModal">
<div class="formation-modal-backdrop" onclick="closeCaptainRequiredWarning()"></div>
<div class="formation-modal-content captain-required-modal-content">
<div class="captain-required-icon">!</div>
<div class="captain-required-copy">
<div class="formation-modal-title">Не выбран капитан</div>
<div class="captain-required-text" id="captainRequiredText"></div>
</div>
<div class="formation-modal-footer captain-required-actions">
<button type="button" class="btn btn-cancel" onclick="closeCaptainRequiredWarning()">
Закрыть
</button>
<button type="button" class="btn btn-confirm" onclick="openCaptainEditorFromWarning()">
Выбрать капитана
</button>
</div>
</div>
</div>
<div class="formation-modal" id="squadEditorModal"> <div class="formation-modal" id="squadEditorModal">
<div class="formation-modal-backdrop" onclick="closeSquadEditor()"></div> <div class="formation-modal-backdrop" onclick="closeSquadEditor()"></div>
@@ -1402,7 +1652,7 @@ data-role="{{ p.position or '' }}"
</script> </script>
<script src="/static/script.js?v=20260421_4"></script> <script src="/static/script.js?v={{ static_version('script.js') }}"></script>
<div id="matrixLoader" class="matrix-loader" aria-hidden="true"> <div id="matrixLoader" class="matrix-loader" aria-hidden="true">
<canvas id="matrixCanvas" class="matrix-canvas"></canvas> <canvas id="matrixCanvas" class="matrix-canvas"></canvas>
@@ -1555,5 +1805,57 @@ data-role="{{ p.position or '' }}"
}); });
})(); })();
</script> </script>
<div class="formation-modal" id="clearMatchModal">
<div
class="formation-modal-backdrop"
onclick="closeClearMatchModal()"
></div>
<div class="formation-modal-content clear-match-modal-content">
<div class="formation-modal-header">
<div>
<div class="formation-modal-title">
Очистить матч
</div>
<div class="formation-modal-subtitle">
Это действие удалит все события, счет и время матча
</div>
</div>
<button
type="button"
class="modal-close-btn"
onclick="closeClearMatchModal()"
>
×
</button>
</div>
<div class="formation-modal-body">
<div class="clear-match-warning">
Вы точно уверены, что хотите очистить данные матча?
</div>
</div>
<div class="formation-modal-footer">
<button
type="button"
class="btn btn-secondary"
onclick="closeClearMatchModal()"
>
Отмена
</button>
<button
type="button"
class="btn btn-danger"
onclick="confirmClearMatch()"
>
Очистить
</button>
</div>
</div>
</div>
</body> </body>
</html> </html>

View File

@@ -408,7 +408,7 @@
<select <select
name="tour" name="tour"
id="tour" id="tour"
onchange="document.getElementById('filters-form').submit();" onchange="submitFiltersAfterTourChange();"
> >
<option value="">Все туры</option> <option value="">Все туры</option>
{% for t in tours %} {% for t in tours %}
@@ -424,6 +424,7 @@
<input <input
type="checkbox" type="checkbox"
name="hide_finished" name="hide_finished"
id="hideFinishedCheckbox"
value="true" value="true"
{% if hide_finished %}checked{% endif %} {% if hide_finished %}checked{% endif %}
onchange="document.getElementById('filters-form').submit();" onchange="document.getElementById('filters-form').submit();"
@@ -486,6 +487,23 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<script>
function submitFiltersAfterTourChange() {
const form = document.getElementById("filters-form");
const tourSelect = document.getElementById("tour");
const hideFinishedCheckbox = document.getElementById("hideFinishedCheckbox");
if (tourSelect && hideFinishedCheckbox) {
// Выбран конкретный тур — показываем в нём все матчи, включая завершённые.
// Выбраны «Все туры» — снова скрываем завершённые матчи.
hideFinishedCheckbox.checked = !tourSelect.value;
}
if (form) {
form.submit();
}
}
</script>
<div id="matrixLoader" class="matrix-loader" aria-hidden="true"> <div id="matrixLoader" class="matrix-loader" aria-hidden="true">
<canvas id="matrixCanvas" class="matrix-canvas"></canvas> <canvas id="matrixCanvas" class="matrix-canvas"></canvas>
<div class="matrix-content"> <div class="matrix-content">

BIN
vmix.zip

Binary file not shown.

View File

@@ -4,15 +4,73 @@ import xml.etree.ElementTree as ET
import io import io
import platform import platform
import os import os
from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
from dotenv import load_dotenv from dotenv import load_dotenv
from synology_drive_api.drive import SynologyDrive
load_dotenv() PROJECT_ROOT = Path(__file__).resolve().parent.parent
SYNO_URL = os.getenv("SYNO_URL")
SYNO_USERNAME = os.getenv("SYNO_USERNAME") def get_env_path() -> Path:
SYNO_PASSWORD = os.getenv("SYNO_PASSWORD") """Возвращает тот же .env, который использует systemd на сервере.
SYNO_PATH_VMIX = os.getenv("SYNO_PATH_VMIX")
На production deploy.sh подключает /mnt/wfl/.env через EnvironmentFile.
Для локальной разработки остаётся <project>/.env. Путь можно явно
переопределить переменной WFL_ENV_FILE.
"""
configured = str(os.getenv("WFL_ENV_FILE") or "").strip()
if configured:
return Path(configured)
production_env = Path("/mnt/wfl/.env")
if production_env.exists():
return production_env
return PROJECT_ROOT / ".env"
def _refresh_env() -> None:
# ВАЖНО: не используем load_dotenv() без пути. Иначе /root/WFL/.env
# может перезаписать значения из systemd EnvironmentFile=/mnt/wfl/.env.
env_path = get_env_path()
if env_path.exists():
load_dotenv(env_path, override=True)
_refresh_env()
def get_syno_settings() -> tuple[str | None, str | None, str | None]:
_refresh_env()
return (
os.getenv("SYNO_URL"),
os.getenv("SYNO_USERNAME"),
os.getenv("SYNO_PASSWORD"),
)
def normalize_source_key(source_key: str | None) -> str:
return str(source_key or "").upper().strip()
def get_vmix_preset_path(source_key: str | None = None) -> str | None:
"""Выбирает NAS-путь к vMix-пресету по источнику матча.
SUPERLEAGUE -> SYNO_PATH_VMIX
RUSSIAN_CUP -> SYNO_PATH_VMIX_2, если он заполнен
Значения читаются из .env прямо перед скачиванием.
"""
_refresh_env()
key = normalize_source_key(source_key)
primary_path = os.getenv("SYNO_PATH_VMIX")
cup_path = os.getenv("SYNO_PATH_VMIX_2")
if key == "RUSSIAN_CUP" and cup_path:
return cup_path
return primary_path
def get_fqdn(): def get_fqdn():
@@ -42,6 +100,15 @@ def rebuild_vmix_url(old_url: str, new_base_url: str, session_token: str) -> str
endpoint = match.group(1) endpoint = match.group(1)
return f"{new_base_url}/vmix/session/{session_token}/{endpoint}" return f"{new_base_url}/vmix/session/{session_token}/{endpoint}"
# Старые пресеты могли хранить ссылки без session-токена:
# /vmix/home-lineup, /vmix/info.json и т.п.
old_style_match = re.match(r"^/vmix/(?!session/)(.+)$", path)
if old_style_match:
endpoint = old_style_match.group(1).strip("/")
if endpoint.endswith(".json"):
endpoint = endpoint[:-5]
return f"{new_base_url}/vmix/session/{session_token}/{endpoint}"
# fallback: если структура другая, просто меняем host # fallback: если структура другая, просто меняем host
return re.sub(r"https?://[^/]+", new_base_url, old_url) return re.sub(r"https?://[^/]+", new_base_url, old_url)
@@ -52,6 +119,7 @@ def change_vmix_datasource_urls(
session_token: str, session_token: str,
match_id: str | int | None = None, match_id: str | int | None = None,
operator_login: str | None = None, operator_login: str | None = None,
source_key: str | None = None,
) -> bytes: ) -> bytes:
if isinstance(xml_data, dict): if isinstance(xml_data, dict):
candidate = None candidate = None
@@ -100,10 +168,11 @@ def change_vmix_datasource_urls(
text = raw_bytes.decode("utf-8", errors="replace") text = raw_bytes.decode("utf-8", errors="replace")
root = ET.fromstring(text) root = ET.fromstring(text)
url_tags = root.findall(
for url_tag in root.findall(
".//datasource[@friendlyName='JSON']//instance//state/xml/url" ".//datasource[@friendlyName='JSON']//instance//state/xml/url"
): ) + root.findall(".//datasource[@friendlyName='Text']//instance//state/xml/url")
for url_tag in url_tags:
old_url = (url_tag.text or "").strip() old_url = (url_tag.text or "").strip()
url_tag.text = rebuild_vmix_url(old_url, new_base_url, session_token) url_tag.text = rebuild_vmix_url(old_url, new_base_url, session_token)
@@ -115,7 +184,6 @@ def change_vmix_datasource_urls(
if dynamic is None: if dynamic is None:
dynamic = ET.SubElement(dynamic_settings, "Dynamic") dynamic = ET.SubElement(dynamic_settings, "Dynamic")
def get_or_create_dynamic_value(index: int): def get_or_create_dynamic_value(index: int):
values = dynamic.findall("DynamicValue") values = dynamic.findall("DynamicValue")
@@ -127,39 +195,76 @@ def change_vmix_datasource_urls(
return values[index] return values[index]
# value1 = session # value1 = session
get_or_create_dynamic_value(0).text = str(session_token) get_or_create_dynamic_value(0).text = str(session_token)
# value2 = match_id # value2 = match_id
get_or_create_dynamic_value(1).text = "" if match_id is None else str(match_id) get_or_create_dynamic_value(1).text = "" if match_id is None else str(match_id)
# value3 = source_key (SUPERLEAGUE / RUSSIAN_CUP)
get_or_create_dynamic_value(2).text = (
"" if not source_key else normalize_source_key(source_key)
)
# value4 = operator_login # value4 = operator_login
get_or_create_dynamic_value(3).text = "" if not operator_login else str(operator_login) get_or_create_dynamic_value(3).text = (
"" if not operator_login else str(operator_login)
)
return ET.tostring(root, encoding="utf-8", method="xml") return ET.tostring(root, encoding="utf-8", method="xml")
def build_vmix_project_bytes( def build_vmix_project_bytes(
session_token: str, session_token: str,
match_id: str | int | None = None, match_id: str | int | None = None,
operator_login: str | None = None, operator_login: str | None = None,
source_key: str | None = None,
) -> bytes: ) -> bytes:
vmix_bio = nasio.load_bio( source_key = normalize_source_key(source_key)
user=SYNO_USERNAME, vmix_preset_path = get_vmix_preset_path(source_key)
password=SYNO_PASSWORD, if not vmix_preset_path:
nas_ip=SYNO_URL, raise RuntimeError(
nas_port="443", "Не задан путь к vMix-пресету: SYNO_PATH_VMIX или SYNO_PATH_VMIX_2"
path=SYNO_PATH_VMIX, )
syno_url, syno_username, syno_password = get_syno_settings()
env_path = get_env_path()
if not syno_url:
raise RuntimeError(f"Не задан SYNO_URL (env: {env_path})")
if not syno_username:
raise RuntimeError(f"Не задан SYNO_USERNAME (env: {env_path})")
if not syno_password:
raise RuntimeError(f"Не задан SYNO_PASSWORD (env: {env_path})")
# nasio ожидает имя хоста/IP отдельно от порта, поэтому убираем случайно
# добавленную схему https:// и завершающий slash.
normalized_syno_url = str(syno_url).strip().rstrip("/")
if normalized_syno_url.startswith(("http://", "https://")):
normalized_syno_url = urlparse(normalized_syno_url).hostname or normalized_syno_url
print(
f"[vmix] source_key={source_key or '-'} preset_path={vmix_preset_path} "
f"nas={normalized_syno_url}:443 env={env_path}"
) )
with SynologyDrive(
username=syno_username,
password=syno_password,
nas_domain=normalized_syno_url,
port=443,
https=True,
dsm_version="7",
) as nas:
vmix_bio = nas.download_file(vmix_preset_path)
edited_vmix = change_vmix_datasource_urls( edited_vmix = change_vmix_datasource_urls(
vmix_bio, vmix_bio,
FQDN, FQDN,
session_token, session_token,
match_id, match_id,
operator_login, operator_login,
source_key,
) )
if isinstance(edited_vmix, str): if isinstance(edited_vmix, str):
@@ -174,4 +279,4 @@ def build_vmix_filename(session_row) -> str:
tour = session_row[10] tour = session_row[10]
date_str = session_row[9].strftime("%d%m%Y") date_str = session_row[9].strftime("%d%m%Y")
filename = f"ЖФЛ_{home} VS {away}_{tour}_{date_str}.vmix" filename = f"ЖФЛ_{home} VS {away}_{tour}_{date_str}.vmix"
return filename return filename

86
wfl.sql
View File

@@ -27,6 +27,9 @@ CREATE TABLE players (
number VARCHAR(20), number VARCHAR(20),
position VARCHAR(50), position VARCHAR(50),
birth_date DATE, birth_date DATE,
photo TEXT,
photo_enabled BOOLEAN NOT NULL DEFAULT FALSE,
video TEXT,
height_cm INTEGER, height_cm INTEGER,
weight_kg INTEGER, weight_kg INTEGER,
is_active BOOLEAN NOT NULL DEFAULT TRUE, is_active BOOLEAN NOT NULL DEFAULT TRUE,
@@ -52,12 +55,14 @@ CREATE TABLE matches (
away_score INTEGER, away_score INTEGER,
tour VARCHAR(100), tour VARCHAR(100),
season VARCHAR(50), season VARCHAR(50),
source_key VARCHAR(50),
created_at TIMESTAMP NOT NULL DEFAULT NOW(), created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW() updated_at TIMESTAMP NOT NULL DEFAULT NOW()
); );
CREATE INDEX idx_matches_external_id ON matches(external_id); CREATE INDEX idx_matches_external_id ON matches(external_id);
CREATE INDEX idx_matches_season ON matches(season); CREATE INDEX idx_matches_season ON matches(season);
CREATE INDEX idx_matches_source_key ON matches(source_key);
CREATE INDEX idx_matches_date ON matches(match_date); CREATE INDEX idx_matches_date ON matches(match_date);
@@ -82,3 +87,84 @@ CREATE TABLE standings (
); );
CREATE INDEX idx_standings_season ON standings(season); CREATE INDEX idx_standings_season ON standings(season);
-- =========================
-- PROJECT SETTINGS / PARSER SOURCES
-- =========================
CREATE TABLE IF NOT EXISTS app_settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS parser_sources (
key VARCHAR(50) PRIMARY KEY,
title VARCHAR(255) NOT NULL,
tournament_id VARCHAR(100),
round_id VARCHAR(100),
season VARCHAR(50),
calendar_type VARCHAR(50) NOT NULL DEFAULT 'tours',
logo_base_path TEXT,
photo_base_path TEXT,
teams_url TEXT,
schedule_url TEXT,
standings_url TEXT,
match_base_url TEXT,
base_url TEXT NOT NULL DEFAULT 'https://wfl.rfs.ru',
sort_order INTEGER NOT NULL DEFAULT 100,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
INSERT INTO app_settings (key, value, updated_at)
VALUES
('default_parser_source_key', 'SUPERLEAGUE', NOW()),
('rfs_base_url', 'https://wfl.rfs.ru', NOW())
ON CONFLICT (key) DO NOTHING;
INSERT INTO parser_sources (
key, title, tournament_id, round_id, season, calendar_type, logo_base_path, photo_base_path,
teams_url, schedule_url, standings_url, match_base_url, base_url,
sort_order, is_active, created_at, updated_at
)
VALUES
(
'SUPERLEAGUE',
'Суперлига 2026',
'1061879',
'1117550',
'2025/2026',
'tours',
'D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Teams Logos',
'D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo',
'https://wfl.rfs.ru/tournament/1061879/teams',
'https://wfl.rfs.ru/tournament/1061879/calendar?round_id=1117550&type=tours',
'https://wfl.rfs.ru/tournament/1061879/tables',
'https://wfl.rfs.ru/match/',
'https://wfl.rfs.ru',
10,
TRUE,
NOW(),
NOW()
),
(
'RUSSIAN_CUP',
'Кубок России 2026',
'1064908',
'1125159',
'2026',
'stages',
'D:\Графика\ФУТБОЛ\Кубок России 2026\Teams Logos',
'D:\Графика\ФУТБОЛ\Кубок России 2026\Photo',
'https://wfl.rfs.ru/tournament/1064908/teams',
'https://wfl.rfs.ru/tournament/1064908/calendar?round_id=1125159&type=stages',
'https://wfl.rfs.ru/tournament/1064908/tables',
'https://wfl.rfs.ru/match/',
'https://wfl.rfs.ru',
20,
TRUE,
NOW(),
NOW()
)
ON CONFLICT (key) DO NOTHING;