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

1471
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 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:
@@ -20,12 +21,37 @@ def extract_player_id_from_href(href: str) -> str:
def detect_captain(item) -> bool:
"""Определяет капитана по текстовой метке или отдельному HTML-маркеру сайта.
На странице протокола капитан может быть обозначен как ``(К)`` / ``(C)``,
а в некоторых версиях вёрстки — отдельным элементом/иконкой с captain в
class, title, aria-label или data-атрибуте. Если сайт вообще не указал
капитана, функция корректно возвращает False.
"""
if not item:
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]]:
home_starting = []
@@ -197,30 +223,31 @@ def parse_game_page(html: str) -> dict:
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)
data = parse_game_page(html)
sync_match_page(
match_external_id=str(match_external_id).strip(),
home_starting=data["home_starting"],
away_starting=data["away_starting"],
home_bench=data["home_bench"],
away_bench=data["away_bench"],
home_coaches=data["home_coaches"],
away_coaches=data["away_coaches"],
referees=data["referees"],
)
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"],
)
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'])}"
)
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 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 = {
"Пз.": "Полузащитник",
"Вр.": "Вратарь",
@@ -23,19 +22,26 @@ def fetch_html(url: str) -> str:
return r.text
def get_links(html: str) -> list[dict]:
def get_links(html: str, source: dict) -> list[dict]:
soup = BeautifulSoup(html, "html.parser")
links: list[dict] = []
items = soup.find("ul", class_="teams__list").find_all("li")
for i in items:
href = i.find("a", class_="teams__link").get("href")
team_external_id = href.split("team_id=")[-1].strip()
teams_list = soup.find("ul", class_="teams__list")
if not teams_list:
return links
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(
{
"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 ""
parts = full_player.split()
pos_short = pos_td.get_text(strip=True) if pos_td else ""
players.append(
{
"player_id": player_id or "",
"number": number_td.get_text(strip=True) if number_td else "",
"pos": pos_td.get_text(strip=True) if pos_td else "",
"amplua": AMPLUA_FULL[pos_td.get_text(strip=True) if pos_td else ""],
"pos": pos_short,
"amplua": AMPLUA_FULL.get(pos_short, pos_short),
"player": full_player,
"lastname": parts[0] if len(parts) >= 1 else "",
"name": parts[-1] if len(parts) >= 2 else "",
@@ -133,7 +140,7 @@ def parse_team(html: str) -> dict:
"lastname": last_name,
"player": f"{last_name} {first_name}".strip(),
"born": born.get_text(strip=True).replace(",", "") if born else "",
"amplua": amplua.get_text(strip=True) if amplua else "",
"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:
html = fetch_html(URL_TEAMS)
links = get_links(html)
def run_parser_players(source_key: str | None = None) -> None:
source = get_parser_source(source_key)
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:
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:
print(f"[parser_players] error team={item['team_external_id']}: {e}")
if not links:
print("[parser_players] Команды для парсинга игроков не найдены")
if __name__ == "__main__":
run_parser_players()

View File

@@ -5,6 +5,7 @@ from zoneinfo import ZoneInfo
from services.schedule_service import sync_matches
from repositories.team_repository import get_team_external_id_by_name, get_team_id_by_external_id
from parsers.parser_sources import get_parser_source
MONTHS_RU = {
@@ -24,11 +25,6 @@ MONTHS_RU = {
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:
@@ -76,7 +72,7 @@ def safe_int(value: str | None) -> int | 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")
matches_data: list[dict] = []
@@ -151,10 +147,11 @@ def parse_schedule(html: str) -> list[dict]:
"home_score": home_score,
"away_score": away_score,
"tour": tour,
"season": SEASON,
"season": season,
"place": place,
"date_raw": time_site,
"score_add": score_add,
"source_key": source_key,
}
)
@@ -162,11 +159,18 @@ def parse_schedule(html: str) -> list[dict]:
return matches_data
def run_parser_schedule() -> None:
html = fetch_html(URL_SCHEDULE)
matches_data = parse_schedule(html)
sync_matches(matches_data)
print(f"[parser_schedule] Matches synced: {len(matches_data)}")
def run_parser_schedule(source_key: str | None = None) -> None:
source = get_parser_source(source_key)
print(f"[parser_schedule] Источник: {source['title']}")
print(f"[parser_schedule] URL: {source['schedule_url']}")
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__":

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 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:
@@ -65,16 +64,22 @@ def parse_standings(html: str) -> list[dict]:
return standings
def run_parser_standings() -> None:
html = fetch_html(URL_STANDINGS)
def run_parser_standings(source_key: str | None = None) -> None:
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)
if standings_rows:
sync_standings(
season=source["season"],
standings_rows=standings_rows,
)
sync_standings(
season=SEASON,
standings_rows=standings_rows,
)
print(f"[parser_standings] Synced rows: {len(standings_rows)}")
print(f"[parser_standings] Synced rows: {len(standings_rows)}")
else:
print("[parser_standings] Строки турнирной таблицы не найдены")
if __name__ == "__main__":

View File

@@ -3,9 +3,7 @@ from bs4 import BeautifulSoup
from concurrent.futures import ThreadPoolExecutor
from services.teams_service import sync_teams
TEAMS_URL = "https://wfl.rfs.ru/tournament/1061879/teams"
from parsers.parser_sources import get_parser_source, source_absolute_url
def fetch_html(url: str) -> str:
@@ -15,45 +13,51 @@ def fetch_html(url: str) -> str:
return response.text
def get_links(html) -> list:
def get_links(html: str, source: dict) -> list[str]:
soup = BeautifulSoup(html, "html.parser")
links: list[dict] = []
items = soup.find("ul", class_="teams__list").find_all("li")
for i in items:
links.append(
"https://wfl.rfs.ru/team/"
+ i.find("a", class_="teams__link").get("href").split("team_id=")[-1]
)
links: list[str] = []
teams_list = soup.find("ul", class_="teams__list")
if not teams_list:
return links
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
def get_url_teams() -> list[dict]:
html = fetch_html(TEAMS_URL)
links = get_links(html)
def get_url_teams(source_key: str | None = None) -> list[dict]:
source = get_parser_source(source_key)
html = fetch_html(source["teams_url"])
links = get_links(html, source)
teams_data: list[dict] = []
with ThreadPoolExecutor() as pool:
responses = [
pool.submit(
fetch_html,
link,
)
for link in links
]
responses = [pool.submit(fetch_html, link) for link in links]
for result in responses:
try:
html = result.result()
team_data = parse_teams_html(html)
teams_data.append(team_data)
except Exception as e:
print(f"Error fetching team data: {e}")
print(f"[parser_teams] Error fetching team data: {e}")
return teams_data
def parse_teams_html(html: str) -> dict:
soup = BeautifulSoup(html, "html.parser")
teams_data: dict = {}
name = soup.find("a", class_="team-promo__team-name").text.strip()
external_id = soup.find("a", class_="team-promo__logo").get("href").split("/")[-1]
stat_info = soup.find("ul", class_="stats-info").find_all(
@@ -65,7 +69,7 @@ def parse_teams_html(html: str) -> dict:
goals = stat_info[2].text.strip()
tournaments = stat_info[3].text.strip()
teams_data = {
return {
"external_id": str(external_id),
"name": name,
"logo_url": logo_url,
@@ -75,13 +79,18 @@ def parse_teams_html(html: str) -> dict:
"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()
sync_teams(teams_data)
print(f"Teams synced: {len(teams_data)}")
teams_data = get_url_teams(source_key)
if teams_data:
sync_teams(teams_data)
print(f"[parser_teams] Teams synced: {len(teams_data)}")
else:
print("[parser_teams] Команды не найдены")
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]:
conn = get_connection()
try:
@@ -284,3 +360,58 @@ def update_coach_admin(
raise
finally:
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 ""),
"position": p.get("position", "") or "",
"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(mlp.number::text, '') AS number,
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
JOIN players p
ON p.id = mlp.player_id
@@ -141,6 +143,7 @@ def get_match_lineup_for_editor(
"number": row[7] or "",
"position": row[8] or "",
"is_captain": bool(row[9]),
"photo_enabled": bool(row[10]),
}
side = row[0]
@@ -230,10 +233,26 @@ def get_match_lineup_for_vmix(
COALESCE(mlp.number::text, '') AS number,
COALESCE(p.position, '') AS position,
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
JOIN players p
ON p.id = mlp.player_id
LEFT JOIN LATERAL (
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
ORDER BY
CASE
@@ -248,7 +267,7 @@ def get_match_lineup_for_vmix(
COALESCE(p.first_name, ''),
p.id
""",
(match_id,),
(home_team_id, away_team_id, match_id),
)
player_rows = cur.fetchall()
@@ -293,6 +312,9 @@ def get_match_lineup_for_vmix(
"position": row[8] or "",
"is_captain": bool(row[9]),
"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]
@@ -449,6 +471,52 @@ def save_match_lineup_for_editor(
insert_players("away", "starting", away_starting)
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("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 repositories.team_repository import get_team_id_by_external_id
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(
external_id: str,
@@ -20,6 +154,7 @@ def upsert_match(
stadium_id: int | None = None,
date_raw: str | None = None,
score_add: str | None = None,
source_key: str | None = None,
) -> None:
query = """
INSERT INTO matches (
@@ -36,10 +171,11 @@ INSERT INTO matches (
stadium_id,
date_raw,
score_add,
source_key,
created_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)
DO UPDATE SET
home_team_id = EXCLUDED.home_team_id,
@@ -54,6 +190,7 @@ DO UPDATE SET
stadium_id = EXCLUDED.stadium_id,
date_raw = EXCLUDED.date_raw,
score_add = EXCLUDED.score_add,
source_key = COALESCE(EXCLUDED.source_key, matches.source_key),
updated_at = NOW();
"""
@@ -76,6 +213,7 @@ DO UPDATE SET
stadium_id,
date_raw,
score_add,
source_key,
),
)
conn.commit()
@@ -100,6 +238,7 @@ def upsert_match_by_team_external_ids(
stadium_id: int | None = None,
date_raw: str | None = None,
score_add: str | None = None,
source_key: str | None = None,
) -> None:
home_team_id = get_team_id_by_external_id(home_team_external_id)
away_team_id = get_team_id_by_external_id(away_team_external_id)
@@ -125,6 +264,7 @@ def upsert_match_by_team_external_ids(
stadium_id=stadium_id,
date_raw=date_raw,
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.away_score,
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
LEFT JOIN teams ht ON ht.id = m.home_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],
"home_team_name": r[7] or "",
"away_team_name": r[8] or "",
"channel": r[9] or "",
}
for r in rows
]

View File

@@ -1,6 +1,8 @@
import secrets
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(
@@ -66,7 +68,8 @@ def get_match_session_by_token(session_token: str):
at.id AS away_team_id,
at.name AS away_team_name,
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
JOIN matches m ON m.id = ms.match_id
@@ -80,7 +83,22 @@ def get_match_session_by_token(session_token: str):
try:
with conn.cursor() as cur:
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:
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,
p.last_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
LEFT JOIN players p ON p.id = ml.player_id
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,
first_name,
pos,
photo,
photo_enabled,
) = row
item = {
"player_id": player_id,
"team_id": team_id,
"number": number or "",
"last_name": last_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 "",
"is_captain": bool(is_captain),
"pos": pos,
"photo": photo or "",
"photo_enabled": bool(photo_enabled),
}
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()
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()
try:
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():
pattern = f"%{q.strip()}%"
cur.execute(
"""
SELECT
p.id,
p.full_name,
p.first_name,
p.last_name,
p.external_id,
p.position
FROM players p
where_sql = """
WHERE
p.full_name ILIKE %s
OR p.first_name ILIKE %s
OR p.last_name ILIKE %s
OR COALESCE(p.external_id, '') ILIKE %s
ORDER BY p.full_name ASC, p.id ASC
LIMIT 200
""",
(pattern, pattern, pattern, pattern),
)
else:
cur.execute(
"""
SELECT
p.id,
p.full_name,
p.first_name,
p.last_name,
p.external_id,
p.position
FROM players p
ORDER BY p.id DESC
LIMIT 200
"""
)
OR COALESCE(t.name, '') ILIKE %s
"""
params.extend([pattern, pattern, pattern, pattern, pattern])
params.append(limit)
cur.execute(
f"""
SELECT
p.id,
p.full_name,
p.first_name,
p.last_name,
p.external_id,
p.position,
COALESCE(t.name, '') AS team_name,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM players p
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()
@@ -277,6 +416,8 @@ def search_players_for_admin(q: str = "") -> list[dict]:
"last_name": row[3] or "",
"external_id": row[4] or "",
"position": row[5] or "",
"team_name": row[6] or "",
"photo_enabled": bool(row[7]),
}
for row in rows
]
@@ -299,8 +440,11 @@ def get_player_by_id(player_id: int) -> dict | None:
p.position,
p.born,
p.photo,
p.video
p.video,
COALESCE(t.name, '') AS team_name,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM players p
LEFT JOIN teams t ON t.id = p.team_id
WHERE p.id = %s
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]),
"photo": row[7] or "",
"video": row[8] or "",
"team_name": row[9] or "",
"photo_enabled": bool(row[10]),
}
finally:
conn.close()
@@ -336,6 +482,7 @@ def update_player_admin(
birth_date: str = "",
photo: str = "",
video: str = "",
photo_enabled: bool = False,
) -> None:
conn = get_connection()
try:
@@ -351,7 +498,9 @@ def update_player_admin(
position = %s,
born = NULLIF(%s, '')::date,
photo = NULLIF(%s, ''),
video = NULLIF(%s, '')
video = NULLIF(%s, ''),
photo_enabled = %s,
updated_at = NOW()
WHERE id = %s
""",
(
@@ -363,6 +512,7 @@ def update_player_admin(
birth_date.strip(),
photo.strip(),
video.strip(),
bool(photo_enabled),
player_id,
),
)
@@ -372,3 +522,123 @@ def update_player_admin(
raise
finally:
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

@@ -266,3 +266,55 @@ def replace_match_referees(match_id: int, rows: list[dict]) -> None:
raise
finally:
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 parsers.parser_sources import extract_logo_filename
def upsert_team(
@@ -153,7 +154,7 @@ def search_teams_for_admin(q: str = "") -> list[dict]:
"full_name": row[2] or "",
"short_name_3": row[3] or "",
"city": row[4] or "",
"logo_path": row[5] or "",
"logo_path": extract_logo_filename(row[5]),
"external_id": row[6] or "",
}
for row in rows
@@ -192,7 +193,7 @@ def get_team_by_id(team_id: int) -> dict | None:
"full_name": row[2] or "",
"short_name_3": row[3] or "",
"city": row[4] or "",
"logo_path": row[5] or "",
"logo_path": extract_logo_filename(row[5]),
"external_id": row[6] or "",
}
finally:
@@ -227,7 +228,7 @@ def update_team_admin(
full_name.strip(),
short_name_3.strip().upper(),
city.strip(),
logo_path.strip(),
extract_logo_filename(logo_path),
external_id.strip(),
team_id,
),
@@ -238,3 +239,29 @@ def update_team_admin(
raise
finally:
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.number::text, '') AS number,
COALESCE(p.position, '') AS position,
FALSE AS is_captain
FALSE AS is_captain,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM players p
WHERE p.team_id = %s
ORDER BY
@@ -51,6 +52,7 @@ def get_team_players_for_match_editor(
"number": row[4] or "",
"position": row[5] or "",
"is_captain": bool(row[6]),
"photo_enabled": bool(row[7]),
}
for row in rows
]

View File

@@ -12,6 +12,8 @@ from parsers.parser_standings import run_parser_standings
import requests
from bs4 import BeautifulSoup
from source_config import match_url
TZ = ZoneInfo("Europe/Moscow")
@@ -202,7 +204,7 @@ def parse_score(value: str) -> int | None:
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")
score_box = soup.find("div", class_="score__container")
@@ -217,7 +219,8 @@ def fetch_match_live_data(match_id: int) -> dict:
live = soup.find("section", class_=lambda c: c and "game--live" in c)
if live:
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": "scheduled", "home_score": home, "away_score": away}

View File

@@ -15,7 +15,7 @@ from repositories.auth_repository import (
revoke_auth_session,
)
IDLE_TIMEOUT_SECONDS = 999_999
IDLE_TIMEOUT_SECONDS = 4 * 60 * 60
SESSION_TOUCH_THROTTLE_SECONDS = 60
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 (
get_player_id_by_name_and_team,
get_player_id_by_external_id,
create_player_from_lineup,
)
from repositories.coach_repository import (
get_coach_id_by_name_and_team,
get_coach_id_by_external_id,
create_coach_from_lineup,
)
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_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}")
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)
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 = []
for player in home_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"])
def append_players(players: list[dict], team_id: int, lineup_type: str) -> None:
for player in players:
player_id, player_position = resolve_player(player, team_id)
if player_id is None:
player_id = get_player_id_by_name_and_team(
player["player_name"], home_team_id
)
print(player_id, player_position)
lineup_rows.append(
{
"match_id": match_id,
"team_id": home_team_id,
"player_id": player_id,
"player_name": player["player_name"],
"number": player.get("number"),
"position": player.get("position"),
"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(
{
"match_id": match_id,
"team_id": team_id,
"player_id": player_id,
"player_name": player.get("player_name") or "",
"number": player.get("number"),
"position": player.get("position"),
"position_full": player_position,
# Капитаном может быть только игрок основного состава.
# Если сайт по ошибке пометил игрока запаса, не переносим
# такой флаг в матчевые данные.
"is_captain": bool(player.get("is_captain")) and lineup_type == "starting",
"lineup_type": lineup_type,
"source": "parser",
}
)
lineup_rows.append(
{
"match_id": match_id,
"team_id": away_team_id,
"player_id": player_id,
"player_name": player["player_name"],
"number": player.get("number"),
"position": player.get("position"),
"position_full": player_position,
"is_captain": bool(player.get("is_captain")),
"lineup_type": "starting",
"source": "parser",
}
)
append_players(home_starting, home_team_id, "starting")
append_players(away_starting, away_team_id, "starting")
append_players(home_bench, home_team_id, "bench")
append_players(away_bench, away_team_id, "bench")
for player in home_bench:
player_id = None
player_position = None
def restore_previous_captain_if_missing(side: str, team_id: int) -> bool:
"""Возвращает старого капитана только когда сайт не указал нового.
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:
player_id = get_player_id_by_name_and_team(
player["player_name"], home_team_id
)
imported_captains = [row for row in starting_rows if bool(row.get("is_captain"))]
lineup_rows.append(
{
"match_id": match_id,
"team_id": home_team_id,
"player_id": player_id,
"player_name": player["player_name"],
"number": player.get("number"),
"position": player.get("position"),
"position_full": player_position,
"is_captain": bool(player.get("is_captain")),
"lineup_type": "bench",
"source": "parser",
}
)
# Ровно один капитан с сайта — корректные данные, они имеют приоритет.
if len(imported_captains) == 1:
return False
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"):
player_id, player_position = get_player_id_by_external_id(player["player_external_id"])
old_captain = previous_captains.get(side)
if not old_captain:
return False
if player_id is None:
player_id = get_player_id_by_name_and_team(
player["player_name"], away_team_id
)
old_player_id = old_captain.get("player_id")
old_number = str(old_captain.get("number") or "").strip()
lineup_rows.append(
{
"match_id": match_id,
"team_id": away_team_id,
"player_id": player_id,
"player_name": player["player_name"],
"number": player.get("number"),
"position": player.get("position"),
"position_full": player_position,
"is_captain": bool(player.get("is_captain")),
"lineup_type": "bench",
"source": "parser",
}
# Сначала точное совпадение по player_id.
if old_player_id is not None:
for row in starting_rows:
if row.get("player_id") is not None and str(row.get("player_id")) == str(old_player_id):
row["is_captain"] = True
return True
# Fallback для старых/неполных данных — по игровому номеру.
if old_number:
matches = [
row for row in starting_rows
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 = []
for coach in home_coaches:
coach_id = None
def append_coaches(coaches: list[dict], team_id: int, side: str) -> None:
for coach in coaches:
coach_id = resolve_coach(coach, team_id)
if coach.get("coach_external_id"):
coach_id = get_coach_id_by_external_id(coach["coach_external_id"])
coach_rows.append(
{
"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:
coach_id = get_coach_id_by_name_and_team(coach["coach_name"], home_team_id)
coach_rows.append(
{
"match_id": match_id,
"team_id": home_team_id,
"coach_id": coach_id,
"coach_name": coach["coach_name"],
"role": coach.get("role"),
"source": "parser",
}
)
for coach in away_coaches:
coach_id = None
if coach.get("coach_external_id"):
coach_id = get_coach_id_by_external_id(coach["coach_external_id"])
if coach_id is None:
coach_id = get_coach_id_by_name_and_team(coach["coach_name"], away_team_id)
coach_rows.append(
{
"match_id": match_id,
"team_id": away_team_id,
"coach_id": coach_id,
"coach_name": coach["coach_name"],
"role": coach.get("role"),
"source": "parser",
}
)
append_coaches(home_coaches, home_team_id, "home")
append_coaches(away_coaches, away_team_id, "away")
referee_rows = []
@@ -209,13 +267,15 @@ def sync_match_page(
try:
replace_match_lineups(match_id, lineup_rows)
for row in coach_rows:
row["side"] = "home" if row["team_id"] == home_team_id else "away"
replace_match_coaches(match_id, coach_rows)
replace_match_referees(match_id, referee_rows)
mark_match_parsed(match_external_id)
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:
mark_match_parse_error(match_external_id, str(e))
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"),
date_raw=match.get("date_raw"),
score_add=match.get("score_add"),
source_key=match.get("source_key"),
)

View File

@@ -1,9 +1,268 @@
# services/vmix_json_service.py
from db import get_connection
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(
match_id=match_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 = []
fallback_team_id = home_team_id if str(name).startswith("home") else away_team_id
for p in players:
suffix = []
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 ""),
"pos": p.get("pos", ""),
"position": p.get("position", ""),
"photo": (
r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo"
+ "\\"
+ (team_a_name if "home" in name else team_b_name)
+ "\\"
+ (p.get("last_name", "")
+ " "
+ p.get("first_name", "")).strip()
+ ".png"
"photo": resolve_player_photo_for_json(
p,
generated_photo=build_generated_player_photo_path(
team_name=team_a_name if "home" in name else team_b_name,
last_name=p.get("last_name", ""),
first_name=p.get("first_name", ""),
source_key=source_key,
),
fallback_team_id=fallback_team_id,
source_key=source_key,
),
}
}
)
return {"players": result}
@@ -78,43 +340,16 @@ def get_vmix_match_info_by_token(session_token: str):
m.tour,
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,
ref2.referee_name AS referee2,
ref3.referee_name AS referee3,
ref4.referee_name AS referee4,
CASE
WHEN ht.name ILIKE '%%динамо%%'
THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Динамо', 'Динамоелый')
WHEN ht.name ILIKE '%%зенит%%'
THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый')
ELSE REPLACE(ht.logo_path, 'HOME\\', '')
END AS home_logo1,
CASE
WHEN at.name ILIKE '%%динамо%%'
THEN REPLACE(REPLACE(at.logo_path, 'HOME\\', ''), 'Динамо', 'Динамоелый')
WHEN at.name ILIKE '%%зенит%%'
THEN REPLACE(REPLACE(at.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый')
ELSE REPLACE(at.logo_path, 'HOME\\', '')
END AS away_logo1,
CASE
WHEN ht.name ILIKE '%%динамо%%'
THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Синий')
WHEN ht.name ILIKE '%%зенит%%'
THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Синий')
ELSE REPLACE(ht.logo_path, 'HOME\\', '')
END AS home_logo2,
CASE
WHEN at.name ILIKE '%%динамо%%'
THEN REPLACE(REPLACE(at.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Синий')
WHEN at.name ILIKE '%%зенит%%'
THEN REPLACE(REPLACE(at.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Синий')
ELSE REPLACE(at.logo_path, 'HOME\\', '')
END AS away_logo2,
ht.logo_path AS home_logo1,
at.logo_path AS away_logo1,
ht.logo_path AS home_logo2,
at.logo_path AS away_logo2,
ht.city AS home_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,
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
JOIN matches m ON m.id = ms.match_id
@@ -151,23 +387,35 @@ def get_vmix_match_info_by_token(session_token: str):
try:
with conn.cursor() as cur:
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:
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 = """
SELECT
s.position,
t.full_name,
CASE
WHEN t.full_name ILIKE '%%динамо%%'
THEN REPLACE(REPLACE(t.logo_path, 'HOME\\', ''), 'Динамо', 'Динамоелый')
WHEN t.full_name ILIKE '%%зенит%%'
THEN REPLACE(REPLACE(t.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый')
ELSE REPLACE(t.logo_path, 'HOME\\', '')
END AS logo,
COALESCE(t.full_name, t.name) AS team_name,
t.logo_path AS logo,
s.played,
s.wins,
s.losses,
@@ -177,52 +425,66 @@ def get_vmix_standings(session_token: str):
s.team_id
FROM standings s
LEFT JOIN teams t ON s.team_id = t.id
WHERE (%s IS NULL OR s.season = %s)
ORDER BY s.position
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(query, (session_token,))
return cur.fetchall()
cur.execute(query, (season, season))
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:
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 = """
SELECT
CASE
WHEN t1.full_name ILIKE '%%динамо%%'
THEN REPLACE(REPLACE(t1.logo_path, 'HOME\\', ''), 'Динамо', 'Динамоелый')
WHEN t1.full_name ILIKE '%%зенит%%'
THEN REPLACE(REPLACE(t1.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый')
ELSE REPLACE(t1.logo_path, 'HOME\\', '')
END AS logo1,
CASE
WHEN t2.full_name ILIKE '%%динамо%%'
THEN REPLACE(REPLACE(t2.logo_path, 'HOME\\', ''), 'Динамо', 'Динамоелый')
WHEN t2.full_name ILIKE '%%зенит%%'
THEN REPLACE(REPLACE(t2.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый')
ELSE REPLACE(t2.logo_path, 'HOME\\', '')
END AS logo2,
t1.logo_path AS logo1,
t2.logo_path AS logo2,
m.home_score,
m.away_score,
m.match_date,
m.status,
m.id
m.id,
COALESCE(m.channel, '') AS channel,
COALESCE(NULLIF(m.source_key, ''), %s) AS source_key
FROM matches m
LEFT JOIN teams t1 ON m.home_team_id = t1.id
LEFT JOIN teams t2 ON m.away_team_id = t2.id
WHERE m.tour = %s
AND (%s IS NULL OR m.season = %s)
AND COALESCE(NULLIF(m.source_key, ''), %s) = %s
ORDER BY m.match_date, m.id
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(query, (session_token[10],))
return cur.fetchall()
cur.execute(query, (source_filter, tour, season, season, source_filter, source_filter))
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:
conn.close()
@@ -230,13 +492,59 @@ def get_vmix_schedule(session_token: str):
def get_vmix_team_formations(session_token: str, team_id: int):
query = """
SELECT
p.last_name,
COALESCE(NULLIF(p.last_name, ''), NULLIF(mf.player_name, ''), '') AS last_name,
mf.is_captain,
p.number,
p.position,
p.first_name
COALESCE(NULLIF(mf.number, ''), p.number::text, '') AS number,
COALESCE(NULLIF(mf.position, ''), p.position, '') AS position,
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
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
ORDER BY mf.id
"""
@@ -332,7 +640,10 @@ WHERE m.match_id = %s;
conn.close()
def get_vmix_players_goal(match_id: int):
def get_vmix_players_goal2(match_id: int):
"""запрос для получения игроков, забивших голы, с указанием минут и типа гола (основной, пенальти, автогол)
Иванов 23', 25' (дубли пишуться вместе)
"""
query = """
WITH goal_events AS (
SELECT
@@ -372,10 +683,10 @@ grouped_players AS (
(
CASE
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
THEN '90''' || '+ ' || ((seconds - 5400) / 60)::int::text
THEN '90''' || '+' || ((seconds - 5400) / 60)::int::text
WHEN period_no = 2
THEN (46 + ((seconds - 2700) / 60)::int)::text || ''''
@@ -422,3 +733,95 @@ ORDER BY COALESCE(h.rn, a.rn);
return cur.fetchall()
finally:
conn.close()
def get_vmix_players_goal(match_id: int):
"""запрос для получения игроков, забивших голы, с указанием минут и типа гола (основной, пенальти, автогол)
Каждый гол в отдельной строке, дубли по игроку не объединяются
"""
query = """
WITH goal_events AS (
SELECT
meu.side,
COALESCE(p.last_name, meu.player_name) AS last_name,
meu.seconds,
meu.id,
meu.type,
CASE
WHEN EXISTS (
SELECT 1
FROM match_events_ui m2
WHERE m2.match_id = meu.match_id
AND m2.type = 'period_start_2h'
AND m2.seconds <= meu.seconds
) THEN 2
WHEN EXISTS (
SELECT 1
FROM match_events_ui m2
WHERE m2.match_id = meu.match_id
AND m2.type = 'period_start_1h'
AND m2.seconds <= meu.seconds
) THEN 1
ELSE NULL
END AS period_no
FROM match_events_ui meu
LEFT JOIN players p ON p.id = meu.player_id
WHERE meu.match_id = %s
AND meu.type IN ('goal', 'penalty', 'own_goal')
),
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 {
width: 1%;
width: 300px;
min-width: 300px;
white-space: nowrap;
}
@@ -1896,6 +1897,63 @@ body.edit-mode-on .player-node.dragging {
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) {
.squad-editor-grid {
grid-template-columns: 1fr;
@@ -2293,3 +2351,554 @@ body:not(.edit-mode-on) .referee-search {
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">
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по ФИО или external_id">
<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>
</form>
</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;
}
.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 {
margin-top: 20px;
border: 1px solid var(--border);
@@ -150,7 +226,9 @@
pointer-events: none;
}
.account-modal {
.account-modal,
.update-modal,
.settings-modal {
position: fixed;
inset: 0;
display: none;
@@ -161,11 +239,15 @@
padding: 20px;
}
.account-modal.active {
.account-modal.active,
.update-modal.active,
.settings-modal.active {
display: flex;
}
.account-modal-card {
.account-modal-card,
.update-modal-card,
.settings-modal-card {
width: min(100%, 460px);
background: rgba(12, 17, 26, 0.98);
border: 1px solid rgba(0, 255, 136, 0.28);
@@ -174,13 +256,24 @@
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-weight: 700;
margin-bottom: 8px;
}
.account-modal-subtitle {
.account-modal-subtitle,
.update-modal-subtitle,
.settings-modal-subtitle {
color: var(--muted);
margin-bottom: 18px;
line-height: 1.5;
@@ -191,6 +284,46 @@
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 {
display: block;
font-size: 13px;
@@ -200,7 +333,8 @@
}
.field-input,
.field-select {
.field-select,
.field-textarea {
width: 100%;
min-height: 44px;
border-radius: 12px;
@@ -212,12 +346,117 @@
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-select:focus {
.field-select:focus,
.field-textarea:focus {
border-color: var(--accent);
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 {
display: flex;
gap: 12px;
@@ -281,6 +520,9 @@
<div class="title">Редактирование базы</div>
<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/referees">Судьи</a>
<a class="tab-link" href="/admin/db/teams">Команды</a>
@@ -290,24 +532,67 @@
</div>
<div class="parser-actions">
<form method="post" action="/admin/db/run-parser" class="action-form" data-parser-title="Игроки" data-parser-status="Сбор и сохранение базы игроков...">
<input type="hidden" name="parser_name" value="players">
<button type="submit" class="action-btn">🗄️ Заграбить игроков</button>
</form>
<form method="post" action="/admin/db/run-parser" class="action-form parser-import-card" data-parser-title="Выбранные данные" data-parser-status="Загрузка выбранных данных из выбранного турнира...">
<div class="parser-import-title">Импорт данных</div>
<div class="parser-import-subtitle">
Выберите источник турнира и отметьте, какие данные нужно заграбить. Существующие команды, игроки и матчи будут обновлены через UPSERT, без полной очистки таблиц.
</div>
<form method="post" action="/admin/db/run-parser" class="action-form" data-parser-title="Расписание" data-parser-status="Загрузка расписания матчей с сайта...">
<input type="hidden" name="parser_name" value="schedule">
<button type="submit" class="action-btn">🗄️ Заграбить расписание</button>
</form>
<div class="parser-import-grid">
<div>
<label class="field-label" for="parserSource">Источник / турнир</label>
{% 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="Обновление турнирной таблицы...">
<input type="hidden" name="parser_name" value="standings">
<button type="submit" class="action-btn">🗄️ Заграбить турнирку</button>
<div>
<label class="field-label">Что парсить</label>
<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>
</div>
<div class="account-actions">
<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>
{% if account_output %}
@@ -339,6 +624,36 @@
<pre class="log-output">{{ parser_output }}</pre>
</div>
{% 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>
@@ -376,6 +691,151 @@
</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">
<canvas id="matrixCanvas" class="matrix-canvas"></canvas>
<div class="matrix-content">
@@ -391,9 +851,21 @@
const canvas = document.getElementById("matrixCanvas");
const ctx = canvas ? canvas.getContext("2d") : null;
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 closeCreateAccountBtn = document.getElementById("closeCreateAccountBtn");
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 fontSize = 18;
let columns = 0;
@@ -463,6 +935,94 @@
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) => {
form.addEventListener("submit", () => {
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) => {
if (event.key === "Escape") {
closeAccountModal();
closeSettingsModal();
closeUpdateModal();
}
});
const queryParams = new URLSearchParams(window.location.search);
if (queryParams.get("open_update") === "1") {
openUpdateModal();
}
resizeCanvas();
window.addEventListener("resize", resizeCanvas);
</script>

View File

@@ -143,6 +143,31 @@
object-fit: contain;
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>
</head>
@@ -182,6 +207,11 @@
<input type="text" name="external_id" value="{{ player.external_id }}">
</div>
<div class="form-group">
<label>Название команды</label>
<input type="text" value="{{ player.team_name }}" readonly>
</div>
<div class="form-group">
<label>Амплуа</label>
<select name="position">
@@ -199,8 +229,13 @@
</div>
<div class="form-group full">
<label>Фото игрока</label>
<input type="text" name="photo" value="{{ player.photo }}">
<label>Файл фото игрока</label>
<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 %}
<div class="media-preview-box">
<img src="{{ player.photo }}" alt="{{ player.full_name }}" class="media-preview-image">

View File

@@ -28,7 +28,7 @@
}
.page {
max-width: 1400px;
max-width: 1500px;
margin: 0 auto;
padding: 24px;
}
@@ -62,7 +62,7 @@
}
input[type="text"] {
min-width: 280px;
min-width: 320px;
min-height: 42px;
padding: 0 12px;
border-radius: 10px;
@@ -94,11 +94,16 @@
border: 1px solid var(--border);
}
.table-wrap {
width: 100%;
overflow-x: auto;
border-radius: 12px;
}
table {
width: 100%;
border-collapse: collapse;
background: var(--panel-2);
border-radius: 12px;
overflow: hidden;
}
@@ -107,6 +112,7 @@
border-bottom: 1px solid var(--border);
text-align: left;
font-size: 14px;
white-space: nowrap;
}
th {
@@ -118,10 +124,47 @@
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 {
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 {
width: 140px;
text-align: right;
@@ -143,41 +186,67 @@
<div class="title">Игроки</div>
<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>
{% 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>
</form>
</div>
{% if players %}
<table>
<thead>
<tr>
<th class="id-col">ID</th>
<th>ФИО</th>
<th>Имя</th>
<th>Фамилия</th>
<th>External ID</th>
<th>Амплуа</th>
<th class="action-col"></th>
</tr>
</thead>
<tbody>
{% for p in players %}
<tr>
<td>{{ p.id }}</td>
<td>{{ p.full_name }}</td>
<td>{{ p.first_name }}</td>
<td>{{ p.last_name }}</td>
<td>{{ p.external_id }}</td>
<td>{{ p.position }}</td>
<td class="action-col">
<a href="/admin/db/players/{{ p.id }}/edit" class="btn btn-secondary">Редактировать</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="table-wrap">
<table>
<thead>
<tr>
<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><a class="sort-link" href="{{ sort_links.full_name.url }}">ФИО <span class="sort-indicator">{{ sort_links.full_name.indicator }}</span></a></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><a class="sort-link" href="{{ sort_links.last_name.url }}">Фамилия <span class="sort-indicator">{{ sort_links.last_name.indicator }}</span></a></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><a class="sort-link" href="{{ sort_links.position.url }}">Амплуа <span class="sort-indicator">{{ sort_links.position.indicator }}</span></a></th>
<th><a class="sort-link" href="{{ sort_links.team_name.url }}">Название команды <span class="sort-indicator">{{ sort_links.team_name.indicator }}</span></a></th>
<th class="photo-col"><a class="sort-link" href="{{ sort_links.photo.url }}">Фото <span class="sort-indicator">{{ sort_links.photo.indicator }}</span></a></th>
<th class="action-col"></th>
</tr>
</thead>
<tbody>
{% for p in players %}
<tr>
<td>{{ p.id }}</td>
<td>{{ p.full_name }}</td>
<td>{{ p.first_name }}</td>
<td>{{ p.last_name }}</td>
<td>{{ p.external_id }}</td>
<td>{{ p.position }}</td>
<td>{{ p.team_name }}</td>
<td class="photo-col">
<form method="post" action="/admin/db/players/{{ p.id }}/photo-enabled" class="photo-toggle-form">
<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 %}
<div class="empty-box">Игроки не найдены.</div>
{% endif %}

View File

@@ -37,6 +37,9 @@
<form method="get" action="/admin/db/referees" class="search-form">
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по ФИО или external_id">
<button type="submit" class="btn btn-primary">Найти</button>
{% 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>
</form>
</div>

View File

@@ -27,19 +27,6 @@
background: var(--panel-2);
color: var(--text);
}
.logo-box {
margin-top: 8px;
padding: 12px;
border: 1px dashed var(--border);
border-radius: 12px;
background: var(--panel-2);
}
.logo-preview {
max-width: 120px;
max-height: 120px;
object-fit: contain;
display: block;
}
.actions { margin-top: 20px; display: flex; gap: 10px; }
.btn { min-height: 42px; padding: 0 14px; border: none; border-radius: 10px; cursor: pointer; font-weight: 600; text-decoration: none; display: inline-flex; align-items: center; }
.btn-primary { background: var(--accent); color: white; }
@@ -80,14 +67,10 @@
</div>
<div class="form-group full">
<label>Путь к логотипу</label>
<input type="text" name="logo_path" value="{{ team.logo_path }}">
<div class="logo-box">
{% if team.logo_path %}
<img src="{{ team.logo_path }}" alt="{{ team.name }}" class="logo-preview">
{% else %}
Логотип не задан.
{% endif %}
<label>Файл логотипа</label>
<input type="text" name="logo_path" value="{{ team.logo_path }}" placeholder="Например: Динамо_Синий.png">
<div class="small-meta">
В команде хранится только имя файла. Полный путь подставляется автоматически по источнику выбранного матча.
</div>
</div>
</div>

View File

@@ -26,13 +26,6 @@
tr:last-child td { border-bottom: none; }
.id-col { width: 70px; }
.action-col { width: 140px; text-align: right; }
.logo-preview {
width: 28px;
height: 28px;
object-fit: contain;
display: inline-block;
vertical-align: middle;
}
.empty-box { padding: 18px; border-radius: 12px; background: var(--panel-2); color: var(--muted); border: 1px dashed var(--border); }
</style>
</head>
@@ -57,7 +50,7 @@
<th>Полное название</th>
<th>Город</th>
<th>3 буквы</th>
<th>Логотип</th>
<th>Файл логотипа</th>
<th>External ID</th>
<th class="action-col"></th>
</tr>
@@ -70,13 +63,7 @@
<td>{{ t.full_name or "—" }}</td>
<td>{{ t.city or "—" }}</td>
<td>{{ t.short_name_3 or "—" }}</td>
<td>
{% if t.logo_path %}
<img src="{{ t.logo_path }}" alt="{{ t.name }}" class="logo-preview">
{% else %}
{% endif %}
</td>
<td>{{ t.logo_path or "—" }}</td>
<td>{{ t.external_id }}</td>
<td class="action-col">
<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,7 +10,8 @@
<meta http-equiv="Expires" content="0" />
<title>ЖФЛ Управление vMix</title>
<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>
.player-link-btn,
@@ -66,6 +67,9 @@
font-family: Arial, sans-serif;
letter-spacing: 0.02em;
box-shadow: 0 0 10px rgba(123, 97, 255, 0.28);
object-fit: contain; /* чтобы не растягивалась */
display: block;
}
.vmix-btn {
display: inline-flex;
@@ -84,12 +88,6 @@
background: #222;
}
.vmix-icon {
width: 1em; /* подгоняешь под стиль кнопок */
height: 1em;
object-fit: contain; /* чтобы не растягивалась */
display: block;
}
</style>
@@ -152,6 +150,15 @@
<a href="/admin/session/{{ session[3] }}/download-vmix-page" class="vmix-btn">
<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>
<form
method="post"
@@ -238,6 +245,15 @@
Расписание тура
</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
class="tab-link {% if tab == 'standings' %}active{% endif %}"
href="/admin/session/{{ session[3] }}?tab=standings"
@@ -245,6 +261,7 @@
>
Турнирная таблица
</a>
{% endif %}
</div>
{% if tab == 'game' %}
@@ -291,7 +308,7 @@
<span class="period-badge" id="periodBadge"
>Матч не начат</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="extra-time-wrap">
@@ -395,13 +412,22 @@
data-player-number="{{ p.number or '' }}"
data-player-position="{{ p.position or '' }}"
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="Показать титр игрока">
<div
class="player-inline vmix-person-trigger player-link"
data-vmix-person-type="player"
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-last-name="{{ p.last_name or '' }}"
data-role="{{ p.position or '' }}"
@@ -427,7 +453,7 @@ data-role="{{ p.position or '' }}"
type="button"
class="icon-btn goal"
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>
@@ -435,7 +461,7 @@ data-role="{{ p.position or '' }}"
type="button"
class="icon-btn"
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>
@@ -443,7 +469,7 @@ data-role="{{ p.position or '' }}"
type="button"
class="icon-btn"
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>
@@ -451,7 +477,7 @@ data-role="{{ p.position or '' }}"
type="button"
class="icon-btn sub"
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>
@@ -459,13 +485,13 @@ data-role="{{ p.position or '' }}"
type="button"
class="icon-btn yellow-card"
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
type="button"
class="icon-btn red-card"
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>
</div>
</td>
@@ -503,13 +529,22 @@ data-role="{{ p.position or '' }}"
data-player-number="{{ p.number or '' }}"
data-player-position="{{ p.position or '' }}"
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="Показать титр игрока">
<div
class="player-inline vmix-person-trigger player-link"
data-vmix-person-type="player"
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-last-name="{{ p.last_name or '' }}"
data-role="{{ p.position or '' }}"
@@ -535,7 +570,7 @@ data-role="{{ p.position or '' }}"
type="button"
class="icon-btn sub"
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>
@@ -621,13 +656,22 @@ data-role="{{ p.position or '' }}"
data-player-number="{{ p.number or '' }}"
data-player-position="{{ p.position or '' }}"
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="Показать титр игрока">
<div
class="player-inline vmix-person-trigger player-link"
data-vmix-person-type="player"
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-last-name="{{ p.last_name or '' }}"
data-role="{{ p.position or '' }}"
@@ -653,7 +697,7 @@ data-role="{{ p.position or '' }}"
type="button"
class="icon-btn goal"
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>
@@ -661,7 +705,7 @@ data-role="{{ p.position or '' }}"
type="button"
class="icon-btn"
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>
@@ -669,7 +713,7 @@ data-role="{{ p.position or '' }}"
type="button"
class="icon-btn"
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>
@@ -677,7 +721,7 @@ data-role="{{ p.position or '' }}"
type="button"
class="icon-btn sub"
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>
@@ -685,13 +729,13 @@ data-role="{{ p.position or '' }}"
type="button"
class="icon-btn yellow-card"
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
type="button"
class="icon-btn red-card"
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>
</div>
</td>
@@ -729,13 +773,22 @@ data-role="{{ p.position or '' }}"
data-player-number="{{ p.number or '' }}"
data-player-position="{{ p.position or '' }}"
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="Показать титр игрока">
<div
class="player-inline vmix-person-trigger player-link"
data-vmix-person-type="player"
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-last-name="{{ p.last_name or '' }}"
data-role="{{ p.position or '' }}"
@@ -761,7 +814,7 @@ data-role="{{ p.position or '' }}"
type="button"
class="icon-btn sub"
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>
@@ -790,7 +843,7 @@ data-role="{{ p.position or '' }}"
<button
type="button"
class="btn btn-secondary"
onclick="clearMatchEvents()"
onclick="openClearMatchModal()"
>
Очистить
</button>
@@ -929,11 +982,16 @@ data-role="{{ p.position or '' }}"
<th class="tour-match-col">Матч</th>
<th class="tour-meta-col">Счёт / статус</th>
<th class="tour-stadium-col">Стадион</th>
<th class="tour-channel-col">Канал</th>
<th class="tour-actions-col tour-edit-only">Правка</th>
</tr>
</thead>
<tbody>
{% 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">
{% if m.match_date %}
{{ m.match_date.day }} {{ month_names[m.match_date.month] }}
@@ -951,32 +1009,111 @@ data-role="{{ p.position or '' }}"
</td>
<td class="tour-meta-cell">
<div class="tour-meta-wrap">
{% if m.status == 'finished' and m.home_score is not none
and m.away_score is not none %}
<span class="score-badge"
>{{ m.home_score }} : {{ m.away_score }}</span
>
{% endif %} {% if m.status == 'finished' %}
<div class="tour-meta-wrap tour-meta-display" data-schedule-display="{{ row_key }}">
{% if m.home_score is not none and m.away_score is not none %}
<span class="score-badge">{{ m.home_score }} : {{ m.away_score }}</span>
{% endif %}
{% if m.status == 'finished' %}
<span class="status-badge status-finished">Завершён</span>
{% elif m.status == 'live' %}
<span class="status-badge status-live">Идёт</span>
<span class="status-badge status-live">Live</span>
{% elif m.status == 'scheduled' %}
<span class="status-badge status-scheduled"
>Не начался</span
>
<span class="status-badge status-scheduled">Не начался</span>
{% else %}
<span
class="status-badge status-scheduled"
>{{ m.status or "—" }}</span
>
<span class="status-badge status-scheduled">{{ m.status or "—" }}</span>
{% endif %}
</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 class="stadium-cell">
{{ m.stadium_name or "—" }}
</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>
{% endfor %}
</tbody>
@@ -1038,6 +1175,64 @@ data-role="{{ p.position or '' }}"
{% endif %}
</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 %}
<div class="panel">
<div class="empty-box">Эта вкладка пока в разработке.</div>
@@ -1062,6 +1257,8 @@ data-role="{{ p.position or '' }}"
},
sessionToken: {{ session[3] | tojson }},
matchId: {{ session[1] | tojson }},
sourceKey: {{ source_key | tojson }},
isRussianCup: {{ is_russian_cup | tojson }},
homeFormations: {{ home_formations | tojson }},
awayFormations: {{ away_formations | tojson }},
@@ -1085,6 +1282,38 @@ data-role="{{ p.position or '' }}"
awayCoachPool: []
};
</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-backdrop"
@@ -1158,6 +1387,27 @@ data-role="{{ p.position or '' }}"
</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-backdrop" onclick="closeSquadEditor()"></div>
@@ -1402,7 +1652,7 @@ data-role="{{ p.position or '' }}"
</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">
<canvas id="matrixCanvas" class="matrix-canvas"></canvas>
@@ -1555,5 +1805,57 @@ data-role="{{ p.position or '' }}"
});
})();
</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>
</html>

View File

@@ -408,7 +408,7 @@
<select
name="tour"
id="tour"
onchange="document.getElementById('filters-form').submit();"
onchange="submitFiltersAfterTourChange();"
>
<option value="">Все туры</option>
{% for t in tours %}
@@ -424,6 +424,7 @@
<input
type="checkbox"
name="hide_finished"
id="hideFinishedCheckbox"
value="true"
{% if hide_finished %}checked{% endif %}
onchange="document.getElementById('filters-form').submit();"
@@ -486,6 +487,23 @@
{% endif %}
</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">
<canvas id="matrixCanvas" class="matrix-canvas"></canvas>
<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 platform
import os
from pathlib import Path
from urllib.parse import urlparse
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")
SYNO_PASSWORD = os.getenv("SYNO_PASSWORD")
SYNO_PATH_VMIX = os.getenv("SYNO_PATH_VMIX")
def get_env_path() -> Path:
"""Возвращает тот же .env, который использует systemd на сервере.
На 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():
@@ -42,6 +100,15 @@ def rebuild_vmix_url(old_url: str, new_base_url: str, session_token: str) -> str
endpoint = match.group(1)
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
return re.sub(r"https?://[^/]+", new_base_url, old_url)
@@ -52,6 +119,7 @@ def change_vmix_datasource_urls(
session_token: str,
match_id: str | int | None = None,
operator_login: str | None = None,
source_key: str | None = None,
) -> bytes:
if isinstance(xml_data, dict):
candidate = None
@@ -100,10 +168,11 @@ def change_vmix_datasource_urls(
text = raw_bytes.decode("utf-8", errors="replace")
root = ET.fromstring(text)
for url_tag in root.findall(
url_tags = root.findall(
".//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()
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:
dynamic = ET.SubElement(dynamic_settings, "Dynamic")
def get_or_create_dynamic_value(index: int):
values = dynamic.findall("DynamicValue")
@@ -127,39 +195,76 @@ def change_vmix_datasource_urls(
return values[index]
# value1 = session
get_or_create_dynamic_value(0).text = str(session_token)
# value2 = match_id
get_or_create_dynamic_value(1).text = "" if match_id is None else str(match_id)
# 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
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")
def build_vmix_project_bytes(
session_token: str,
match_id: str | int | None = None,
operator_login: str | None = None,
source_key: str | None = None,
) -> bytes:
vmix_bio = nasio.load_bio(
user=SYNO_USERNAME,
password=SYNO_PASSWORD,
nas_ip=SYNO_URL,
nas_port="443",
path=SYNO_PATH_VMIX,
source_key = normalize_source_key(source_key)
vmix_preset_path = get_vmix_preset_path(source_key)
if not vmix_preset_path:
raise RuntimeError(
"Не задан путь к vMix-пресету: SYNO_PATH_VMIX или SYNO_PATH_VMIX_2"
)
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(
vmix_bio,
FQDN,
session_token,
match_id,
operator_login,
source_key,
)
if isinstance(edited_vmix, str):

86
wfl.sql
View File

@@ -27,6 +27,9 @@ CREATE TABLE players (
number VARCHAR(20),
position VARCHAR(50),
birth_date DATE,
photo TEXT,
photo_enabled BOOLEAN NOT NULL DEFAULT FALSE,
video TEXT,
height_cm INTEGER,
weight_kg INTEGER,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
@@ -52,12 +55,14 @@ CREATE TABLE matches (
away_score INTEGER,
tour VARCHAR(100),
season VARCHAR(50),
source_key VARCHAR(50),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_matches_external_id ON matches(external_id);
CREATE INDEX idx_matches_season ON matches(season);
CREATE INDEX idx_matches_source_key ON matches(source_key);
CREATE INDEX idx_matches_date ON matches(match_date);
@@ -82,3 +87,84 @@ CREATE TABLE standings (
);
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;