Compare commits
92 Commits
9a61796f06
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c759f34f0f | |||
| b29fc06a4f | |||
| adea3a8bd6 | |||
| 2c2d9b9a7c | |||
| e17c287d65 | |||
| 60ec71c73d | |||
| 77adc31a1a | |||
| 8e38505f69 | |||
| 504cf77312 | |||
| 00f8533236 | |||
| 87cd044c3f | |||
| 6fe4eca0ba | |||
| c646691142 | |||
| 2b3c882d25 | |||
| c724c7e965 | |||
| d689b853dc | |||
| 3ec7f57d46 | |||
| aeafdc9887 | |||
| a215ba526b | |||
| e7b215af5e | |||
| 03a68ee8ca | |||
| e32fdcb74e | |||
| 5b056550ed | |||
| 1303b3b562 | |||
| 61861085f8 | |||
| e98d014243 | |||
| 57907fd86b | |||
| b0fe3ad5a4 | |||
| 24c10ea5ca | |||
| 425d1f981d | |||
| 3867579508 | |||
| 4580ff1d3e | |||
| 55cb965d9f | |||
| 3e6f767a12 | |||
| f63061a06d | |||
| cd370cf04f | |||
| e314aa0ad2 | |||
| 9df79f77df | |||
| a9d580795d | |||
| d34776a2d6 | |||
| 59df51f6be | |||
| 91eee7c08c | |||
| 81556cfd43 | |||
| 96b257cff1 | |||
| 2de22915cf | |||
| d8127dccaf | |||
| 4336ab19df | |||
| 495121a8b7 | |||
| 998774bf1c | |||
| 64cd3891ea | |||
| 3ff7c95018 | |||
| 9db889ca4b | |||
| 508d209377 | |||
| 3738538d23 | |||
| 21e37a1097 | |||
| 801936518a | |||
| 18bac0d12d | |||
| 6c449ef7df | |||
| 23ecbc0f57 | |||
| 3ff5d42045 | |||
| 28d7d01fdf | |||
| 57f783e70d | |||
| 9bbafe7c35 | |||
| 50c4755b38 | |||
| 04b1b8796e | |||
| 2df9c97608 | |||
| 685fcdabef | |||
| c970a9d755 | |||
| 5d00fdeaf5 | |||
| 214ab84fc6 | |||
| 2e3aba7c5a | |||
| 121095882c | |||
| e671859f71 | |||
| 79c679f35d | |||
| 4e6806c553 | |||
| a8947556a5 | |||
| d8a31442c9 | |||
| 761f46a932 | |||
| 5110c402e8 | |||
| eda35ade7e | |||
| e3b612433c | |||
| d7f13dcfe2 | |||
| 007939a05e | |||
| 48f752d512 | |||
| 13b859a9fa | |||
| 4f8e2f9b92 | |||
| 7f07f982d8 | |||
| a74aab3acd | |||
| cfb746ebec | |||
| 69e40984f2 | |||
| 636a9a5e59 | |||
| 4f1dc64d5d |
4
agent.py
@@ -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
|
||||
|
||||
@@ -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,10 +223,11 @@ 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)
|
||||
|
||||
if data:
|
||||
sync_match_page(
|
||||
match_external_id=str(match_external_id).strip(),
|
||||
home_starting=data["home_starting"],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
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
@@ -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)
|
||||
@@ -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)
|
||||
standings_rows = parse_standings(html)
|
||||
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=SEASON,
|
||||
season=source["season"],
|
||||
standings_rows=standings_rows,
|
||||
)
|
||||
|
||||
print(f"[parser_standings] Synced rows: {len(standings_rows)}")
|
||||
else:
|
||||
print("[parser_standings] Строки турнирной таблицы не найдены")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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()
|
||||
teams_data = get_url_teams(source_key)
|
||||
if teams_data:
|
||||
sync_teams(teams_data)
|
||||
print(f"Teams synced: {len(teams_data)}")
|
||||
print(f"[parser_teams] Teams synced: {len(teams_data)}")
|
||||
else:
|
||||
print("[parser_teams] Команды не найдены")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
295
repositories/match_penalty_repository.py
Normal 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()
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -222,49 +222,188 @@ 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:
|
||||
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
|
||||
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()}%"
|
||||
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(
|
||||
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
|
||||
p.position,
|
||||
COALESCE(t.name, '') AS team_name,
|
||||
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
|
||||
FROM players p
|
||||
ORDER BY p.id DESC
|
||||
LIMIT 200
|
||||
"""
|
||||
LEFT JOIN teams t ON t.id = p.team_id
|
||||
{where_sql}
|
||||
ORDER BY {order_column} {order_direction} NULLS LAST, p.id ASC
|
||||
LIMIT %s
|
||||
""",
|
||||
tuple(params),
|
||||
)
|
||||
|
||||
rows = cur.fetchall()
|
||||
@@ -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()
|
||||
|
||||
349
repositories/project_settings_repository.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
148
services/env_settings_service.py
Normal 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
|
||||
@@ -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"])
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
lineup_rows.append(
|
||||
{
|
||||
"match_id": match_id,
|
||||
"team_id": home_team_id,
|
||||
"team_id": team_id,
|
||||
"player_id": player_id,
|
||||
"player_name": player["player_name"],
|
||||
"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")),
|
||||
"lineup_type": "starting",
|
||||
# Капитаном может быть только игрок основного состава.
|
||||
# Если сайт по ошибке пометил игрока запаса, не переносим
|
||||
# такой флаг в матчевые данные.
|
||||
"is_captain": bool(player.get("is_captain")) and lineup_type == "starting",
|
||||
"lineup_type": lineup_type,
|
||||
"source": "parser",
|
||||
}
|
||||
)
|
||||
|
||||
for player in away_starting:
|
||||
player_id = None
|
||||
player_position = None
|
||||
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")
|
||||
|
||||
if player.get("player_external_id"):
|
||||
player_id, player_position = get_player_id_by_external_id(player["player_external_id"])
|
||||
def restore_previous_captain_if_missing(side: str, team_id: int) -> bool:
|
||||
"""Возвращает старого капитана только когда сайт не указал нового.
|
||||
|
||||
if player_id is None:
|
||||
player_id = get_player_id_by_name_and_team(
|
||||
player["player_name"], away_team_id
|
||||
)
|
||||
Восстановление разрешено исключительно для игрока, который всё ещё
|
||||
присутствует в основном составе. Если прежний капитан отсутствует или
|
||||
ушёл в запас, капитан остаётся не выбран — веб-интерфейс покажет
|
||||
обязательное предупреждение оператору.
|
||||
"""
|
||||
starting_rows = [
|
||||
row for row in lineup_rows
|
||||
if row.get("team_id") == team_id and row.get("lineup_type") == "starting"
|
||||
]
|
||||
|
||||
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",
|
||||
}
|
||||
)
|
||||
imported_captains = [row for row in starting_rows if bool(row.get("is_captain"))]
|
||||
|
||||
for player in home_bench:
|
||||
player_id = None
|
||||
player_position = None
|
||||
# Ровно один капитан с сайта — корректные данные, они имеют приоритет.
|
||||
if len(imported_captains) == 1:
|
||||
return False
|
||||
|
||||
if player.get("player_external_id"):
|
||||
player_id, player_position = get_player_id_by_external_id(player["player_external_id"])
|
||||
# Если сайт по ошибке передал больше одного капитана, не выбираем
|
||||
# случайного игрока. Сбрасываем конфликт и используем тот же безопасный
|
||||
# fallback, что и при полностью отсутствующем капитане.
|
||||
if len(imported_captains) > 1:
|
||||
for row in imported_captains:
|
||||
row["is_captain"] = False
|
||||
|
||||
if player_id is None:
|
||||
player_id = get_player_id_by_name_and_team(
|
||||
player["player_name"], home_team_id
|
||||
)
|
||||
old_captain = previous_captains.get(side)
|
||||
if not old_captain:
|
||||
return False
|
||||
|
||||
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",
|
||||
}
|
||||
)
|
||||
old_player_id = old_captain.get("player_id")
|
||||
old_number = str(old_captain.get("number") or "").strip()
|
||||
|
||||
for player in away_bench:
|
||||
player_id = None
|
||||
player_position = None
|
||||
# Сначала точное совпадение по 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
|
||||
|
||||
if player.get("player_external_id"):
|
||||
player_id, player_position = get_player_id_by_external_id(player["player_external_id"])
|
||||
# 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
|
||||
|
||||
if player_id is None:
|
||||
player_id = get_player_id_by_name_and_team(
|
||||
player["player_name"], away_team_id
|
||||
)
|
||||
return False
|
||||
|
||||
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",
|
||||
}
|
||||
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
|
||||
|
||||
if coach.get("coach_external_id"):
|
||||
coach_id = get_coach_id_by_external_id(coach["coach_external_id"])
|
||||
|
||||
if coach_id is None:
|
||||
coach_id = get_coach_id_by_name_and_team(coach["coach_name"], home_team_id)
|
||||
def append_coaches(coaches: list[dict], team_id: int, side: str) -> None:
|
||||
for coach in coaches:
|
||||
coach_id = resolve_coach(coach, team_id)
|
||||
|
||||
coach_rows.append(
|
||||
{
|
||||
"match_id": match_id,
|
||||
"team_id": home_team_id,
|
||||
"team_id": team_id,
|
||||
"side": side,
|
||||
"coach_id": coach_id,
|
||||
"coach_name": coach["coach_name"],
|
||||
"coach_name": coach.get("coach_name") or "",
|
||||
"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
|
||||
|
||||
144
services/project_settings_service.py
Normal 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
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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,15 +301,16 @@ 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,
|
||||
),
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
2
sql/002_players_photo_enabled.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE players
|
||||
ADD COLUMN IF NOT EXISTS photo_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
4
sql/003_matches_source_key.sql
Normal 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);
|
||||
79
sql/004_project_settings.sql
Normal 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;
|
||||
14
sql/005_parser_sources_photo_base_path.sql
Normal 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) = '');
|
||||
12
sql/006_parser_sources_channel_logo_base_path.sql
Normal 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) = '');
|
||||
17
sql/007_match_penalties.sql
Normal 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
|
After Width: | Height: | Size: 73 KiB |
BIN
static/docs/Schedule Cup 4.png
Normal file
|
After Width: | Height: | Size: 190 KiB |
BIN
static/docs/Schedule.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
static/docs/SuperCup.png
Normal file
|
After Width: | Height: | Size: 272 KiB |
BIN
static/docs/no photo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
static/docs/penalty.png
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
static/docs/scorebug with title.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
1188
static/script.js
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
459
templates/admin_db_create.html
Normal 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>
|
||||
@@ -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. Первый пункт 2. Второй пункт">🔢 Список</button>
|
||||
<button type="button" class="format-btn" data-snippet="- Первый пункт - Второй пункт">• Маркеры</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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,22 +186,30 @@
|
||||
<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 %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="id-col">ID</th>
|
||||
<th>ФИО</th>
|
||||
<th>Имя</th>
|
||||
<th>Фамилия</th>
|
||||
<th>External ID</th>
|
||||
<th>Амплуа</th>
|
||||
<th class="id-col"><a class="sort-link" href="{{ sort_links.id.url }}">ID <span class="sort-indicator">{{ sort_links.id.indicator }}</span></a></th>
|
||||
<th><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>
|
||||
@@ -171,6 +222,23 @@
|
||||
<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>
|
||||
@@ -178,6 +246,7 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-box">Игроки не найдены.</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
1272
templates/info.html
1024
templates/info_updated.html
Normal 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 строк: 1–5, затем 6–10 снова в строки 1–5.</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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||