Compare commits
5 Commits
60ec71c73d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c759f34f0f | |||
| b29fc06a4f | |||
| adea3a8bd6 | |||
| 2c2d9b9a7c | |||
| e17c287d65 |
242
app.py
242
app.py
@@ -35,7 +35,13 @@ from parsers.parser_players import run_parser_players
|
|||||||
from parsers.parser_schedule import run_parser_schedule
|
from parsers.parser_schedule import run_parser_schedule
|
||||||
from parsers.parser_standings import run_parser_standings
|
from parsers.parser_standings import run_parser_standings
|
||||||
from parsers.parser_teams import run_parser_teams
|
from parsers.parser_teams import run_parser_teams
|
||||||
from parsers.parser_sources import build_empty_photo_path, build_channel_logo_path, build_empty_channel_logo_path, list_parser_sources, get_default_source_key, get_parser_source
|
from parsers.parser_sources import (
|
||||||
|
build_empty_photo_path,
|
||||||
|
build_schedule_channel_logo_path,
|
||||||
|
list_parser_sources,
|
||||||
|
get_default_source_key,
|
||||||
|
get_parser_source,
|
||||||
|
)
|
||||||
from services.project_settings_service import build_project_settings_context, save_project_settings
|
from services.project_settings_service import build_project_settings_context, save_project_settings
|
||||||
from repositories.project_settings_repository import ensure_project_settings_tables
|
from repositories.project_settings_repository import ensure_project_settings_tables
|
||||||
|
|
||||||
@@ -69,6 +75,7 @@ from repositories.player_repository import (
|
|||||||
update_player_admin,
|
update_player_admin,
|
||||||
update_player_photo_enabled,
|
update_player_photo_enabled,
|
||||||
ensure_player_photo_enabled_column,
|
ensure_player_photo_enabled_column,
|
||||||
|
create_player_admin,
|
||||||
)
|
)
|
||||||
|
|
||||||
from repositories.referee_repository import (
|
from repositories.referee_repository import (
|
||||||
@@ -76,6 +83,7 @@ from repositories.referee_repository import (
|
|||||||
get_referee_by_id,
|
get_referee_by_id,
|
||||||
update_referee_admin,
|
update_referee_admin,
|
||||||
get_all_referees,
|
get_all_referees,
|
||||||
|
create_referee_admin,
|
||||||
# replace_match_referees,
|
# replace_match_referees,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,6 +93,7 @@ from repositories.coach_repository import (
|
|||||||
search_coaches_for_admin,
|
search_coaches_for_admin,
|
||||||
get_coach_by_id,
|
get_coach_by_id,
|
||||||
update_coach_admin,
|
update_coach_admin,
|
||||||
|
create_coach_admin,
|
||||||
)
|
)
|
||||||
|
|
||||||
from repositories.stadium_repository import (
|
from repositories.stadium_repository import (
|
||||||
@@ -97,6 +106,7 @@ from repositories.team_repository import (
|
|||||||
search_teams_for_admin,
|
search_teams_for_admin,
|
||||||
get_team_by_id,
|
get_team_by_id,
|
||||||
update_team_admin,
|
update_team_admin,
|
||||||
|
list_teams_for_admin_select,
|
||||||
)
|
)
|
||||||
|
|
||||||
from repositories.match_event_repository import (
|
from repositories.match_event_repository import (
|
||||||
@@ -826,26 +836,10 @@ def load_match_data(request: Request, session_token: str):
|
|||||||
match_external_id = session_row[8]
|
match_external_id = session_row[8]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from db import get_connection
|
# Очистка и замена матчевых данных выполняется внутри sync_match_page().
|
||||||
|
# Важно не удалять ручной состав здесь заранее: сервис сначала должен
|
||||||
# Сначала очищаем ручные данные матча,
|
# увидеть ранее выбранного капитана и, если сайт забыл его указать,
|
||||||
# чтобы парсер потом записал свежие составы и тренеров
|
# сохранить этот выбор при повторной загрузке.
|
||||||
conn = get_connection()
|
|
||||||
try:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(
|
|
||||||
"DELETE FROM match_lineup_players WHERE match_id = %s",
|
|
||||||
(match_id,),
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"DELETE FROM match_coaches WHERE match_id = %s",
|
|
||||||
(match_id,),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# Потом загружаем свежие данные с сайта
|
|
||||||
run_parser_game(str(match_external_id))
|
run_parser_game(str(match_external_id))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -888,6 +882,188 @@ def close_session(session_token: str):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_create_optional_int(value, field_title: str, minimum: int = 0) -> int | None:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
number = int(text)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"Поле «{field_title}» должно быть целым числом.") from exc
|
||||||
|
if number < minimum:
|
||||||
|
raise ValueError(f"Поле «{field_title}» не может быть меньше {minimum}.")
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_create_required_int(value, field_title: str, minimum: int = 0) -> int:
|
||||||
|
number = _admin_create_optional_int(value, field_title, minimum)
|
||||||
|
if number is None:
|
||||||
|
raise ValueError(f"Заполните поле «{field_title}».")
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def _render_admin_entity_create(
|
||||||
|
request: Request,
|
||||||
|
entity_type: str = "player",
|
||||||
|
error: str | None = None,
|
||||||
|
created_id: int | None = None,
|
||||||
|
form_values: dict | None = None,
|
||||||
|
status_code: int = 200,
|
||||||
|
):
|
||||||
|
allowed_entities = {"player", "coach", "referee"}
|
||||||
|
entity_type = entity_type if entity_type in allowed_entities else "player"
|
||||||
|
|
||||||
|
try:
|
||||||
|
teams = list_teams_for_admin_select()
|
||||||
|
except Exception:
|
||||||
|
teams = []
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
name="admin_db_create.html",
|
||||||
|
request=request,
|
||||||
|
context={
|
||||||
|
"entity_type": entity_type,
|
||||||
|
"teams": teams,
|
||||||
|
"error": error,
|
||||||
|
"created_id": created_id,
|
||||||
|
"form_values": form_values or {},
|
||||||
|
},
|
||||||
|
status_code=status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/db/create", response_class=HTMLResponse)
|
||||||
|
def admin_db_create_page(
|
||||||
|
request: Request,
|
||||||
|
entity: str = Query(default="player"),
|
||||||
|
created_id: int | None = Query(default=None),
|
||||||
|
):
|
||||||
|
denied = require_role(request, {"admin"})
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
return _render_admin_entity_create(
|
||||||
|
request=request,
|
||||||
|
entity_type=entity,
|
||||||
|
created_id=created_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/db/create", response_class=HTMLResponse)
|
||||||
|
async def admin_db_create_submit(request: Request):
|
||||||
|
denied = require_role(request, {"admin"})
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
values = {key: str(value) for key, value in form.items()}
|
||||||
|
entity_type = values.get("entity_type", "player").strip().lower()
|
||||||
|
if entity_type not in {"player", "coach", "referee"}:
|
||||||
|
entity_type = "player"
|
||||||
|
|
||||||
|
try:
|
||||||
|
full_name = values.get("full_name", "").strip()
|
||||||
|
if not full_name:
|
||||||
|
raise ValueError("Заполните поле «Полное имя / ФИО».")
|
||||||
|
|
||||||
|
external_id = values.get("external_id", "").strip()
|
||||||
|
first_name = values.get("first_name", "").strip()
|
||||||
|
last_name = values.get("last_name", "").strip()
|
||||||
|
is_active = "is_active" in form
|
||||||
|
|
||||||
|
if entity_type == "player":
|
||||||
|
team_id = _admin_create_required_int(values.get("team_id"), "Команда", 1)
|
||||||
|
position = values.get("position", "").strip()
|
||||||
|
if not position:
|
||||||
|
raise ValueError("Выберите или укажите амплуа игрока.")
|
||||||
|
|
||||||
|
created_id = create_player_admin(
|
||||||
|
team_id=team_id,
|
||||||
|
full_name=full_name,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
external_id=external_id,
|
||||||
|
number=values.get("number", ""),
|
||||||
|
position=position,
|
||||||
|
birth_date=values.get("birth_date", ""),
|
||||||
|
photo=values.get("photo", ""),
|
||||||
|
video=values.get("video", ""),
|
||||||
|
height_cm=_admin_create_optional_int(values.get("height_cm"), "Рост", 1),
|
||||||
|
weight_kg=_admin_create_optional_int(values.get("weight_kg"), "Вес", 1),
|
||||||
|
games=_admin_create_optional_int(values.get("games"), "Игры", 0) or 0,
|
||||||
|
goals=_admin_create_optional_int(values.get("goals"), "Голы", 0) or 0,
|
||||||
|
penaltys=_admin_create_optional_int(values.get("penaltys"), "Голы с пенальти", 0) or 0,
|
||||||
|
assists=_admin_create_optional_int(values.get("assists"), "Передачи", 0) or 0,
|
||||||
|
yellows=_admin_create_optional_int(values.get("yellows"), "Жёлтые карточки", 0) or 0,
|
||||||
|
reds=_admin_create_optional_int(values.get("reds"), "Красные карточки", 0) or 0,
|
||||||
|
is_active=is_active,
|
||||||
|
photo_enabled="photo_enabled" in form,
|
||||||
|
)
|
||||||
|
redirect_url = f"/admin/db/create?entity=player&created_id={created_id}"
|
||||||
|
|
||||||
|
elif entity_type == "coach":
|
||||||
|
team_id = _admin_create_required_int(values.get("team_id"), "Команда", 1)
|
||||||
|
role = values.get("role", "").strip()
|
||||||
|
if not role:
|
||||||
|
raise ValueError("Заполните должность / амплуа тренера.")
|
||||||
|
|
||||||
|
created_id = create_coach_admin(
|
||||||
|
team_id=team_id,
|
||||||
|
full_name=full_name,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
external_id=external_id,
|
||||||
|
birth_date=values.get("birth_date", ""),
|
||||||
|
role=role,
|
||||||
|
is_active=is_active,
|
||||||
|
)
|
||||||
|
redirect_url = f"/admin/db/create?entity=coach&created_id={created_id}"
|
||||||
|
|
||||||
|
else:
|
||||||
|
created_id = create_referee_admin(
|
||||||
|
full_name=full_name,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
middle_name=values.get("middle_name", ""),
|
||||||
|
city=values.get("city", ""),
|
||||||
|
external_id=external_id,
|
||||||
|
is_active=is_active,
|
||||||
|
)
|
||||||
|
redirect_url = f"/admin/db/create?entity=referee&created_id={created_id}"
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
request,
|
||||||
|
action="admin_db_entity_created",
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=created_id,
|
||||||
|
details={"full_name": full_name, "external_id": external_id},
|
||||||
|
)
|
||||||
|
return RedirectResponse(url=redirect_url, status_code=303)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
return _render_admin_entity_create(
|
||||||
|
request=request,
|
||||||
|
entity_type=entity_type,
|
||||||
|
error=str(exc),
|
||||||
|
form_values=values,
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
traceback.print_exc()
|
||||||
|
error_text = "Не удалось создать запись."
|
||||||
|
if "duplicate key" in str(exc).lower() or "unique constraint" in str(exc).lower():
|
||||||
|
error_text += " Запись с таким External ID уже существует."
|
||||||
|
else:
|
||||||
|
error_text += f" Ошибка базы данных: {str(exc)}"
|
||||||
|
return _render_admin_entity_create(
|
||||||
|
request=request,
|
||||||
|
entity_type=entity_type,
|
||||||
|
error=error_text,
|
||||||
|
form_values=values,
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/db/players", response_class=HTMLResponse)
|
@app.get("/admin/db/players", response_class=HTMLResponse)
|
||||||
def admin_db_players(
|
def admin_db_players(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -1519,6 +1695,22 @@ def api_save_squad_editor_data(
|
|||||||
home_team_id = session_row[13]
|
home_team_id = session_row[13]
|
||||||
away_team_id = session_row[17]
|
away_team_id = session_row[17]
|
||||||
|
|
||||||
|
missing_captains = []
|
||||||
|
if payload.home_starting and not any(player.is_captain for player in payload.home_starting):
|
||||||
|
missing_captains.append(str(session_row[14] or "Домашняя команда"))
|
||||||
|
if payload.away_starting and not any(player.is_captain for player in payload.away_starting):
|
||||||
|
missing_captains.append(str(session_row[18] or "Гостевая команда"))
|
||||||
|
|
||||||
|
if missing_captains:
|
||||||
|
teams_text = ", ".join(missing_captains)
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": f"Не выбран капитан: {teams_text}. Назначьте капитана из основного состава.",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
save_match_lineup_for_editor(
|
save_match_lineup_for_editor(
|
||||||
match_id=match_id,
|
match_id=match_id,
|
||||||
home_team_id=home_team_id,
|
home_team_id=home_team_id,
|
||||||
@@ -1850,9 +2042,11 @@ def vmix_schedule(session_token: str):
|
|||||||
"#FFFFFF00" if row[2] is None and row[3] is None else "#FFFFFF"
|
"#FFFFFF00" if row[2] is None and row[3] is None else "#FFFFFF"
|
||||||
),
|
),
|
||||||
"channel": (
|
"channel": (
|
||||||
build_channel_logo_path(row[8] if len(row) > 8 else None, row[7])
|
build_schedule_channel_logo_path(
|
||||||
if str(row[7] or "").strip()
|
row[8] if len(row) > 8 else None,
|
||||||
else build_empty_channel_logo_path(row[8] if len(row) > 8 else None)
|
row[7],
|
||||||
|
has_score=(row[2] is not None or row[3] is not None),
|
||||||
|
)
|
||||||
),
|
),
|
||||||
"channel_value": row[7] or "",
|
"channel_value": row[7] or "",
|
||||||
"source_key": row[8] if len(row) > 8 else "",
|
"source_key": row[8] if len(row) > 8 else "",
|
||||||
|
|||||||
@@ -21,12 +21,37 @@ def extract_player_id_from_href(href: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def detect_captain(item) -> bool:
|
def detect_captain(item) -> bool:
|
||||||
|
"""Определяет капитана по текстовой метке или отдельному HTML-маркеру сайта.
|
||||||
|
|
||||||
|
На странице протокола капитан может быть обозначен как ``(К)`` / ``(C)``,
|
||||||
|
а в некоторых версиях вёрстки — отдельным элементом/иконкой с captain в
|
||||||
|
class, title, aria-label или data-атрибуте. Если сайт вообще не указал
|
||||||
|
капитана, функция корректно возвращает False.
|
||||||
|
"""
|
||||||
if not item:
|
if not item:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
text = item.get_text(" ", strip=True).lower()
|
text = item.get_text(" ", strip=True).lower().replace("ё", "е")
|
||||||
|
if any(marker in text for marker in ("(к)", "(c)", "капитан", "captain")):
|
||||||
|
return True
|
||||||
|
|
||||||
return any(x in text for x in ["(к)", "(c)"])
|
# Поддержка отдельной иконки/элемента капитана, если буква не входит
|
||||||
|
# в видимый текст строки игрока. Не привязываемся к одной версии вёрстки.
|
||||||
|
for node in [item, *item.find_all(True)]:
|
||||||
|
classes = " ".join(node.get("class", [])).lower()
|
||||||
|
attrs_text = " ".join(
|
||||||
|
str(node.get(attr) or "")
|
||||||
|
for attr in ("title", "aria-label", "data-title", "data-role", "data-captain")
|
||||||
|
).lower().replace("ё", "е")
|
||||||
|
|
||||||
|
if "captain" in classes or "капитан" in classes:
|
||||||
|
return True
|
||||||
|
if "captain" in attrs_text or "капитан" in attrs_text:
|
||||||
|
return True
|
||||||
|
if str(node.get("data-captain") or "").strip().lower() in {"1", "true", "yes"}:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
def parse_starting_teams(soup: BeautifulSoup) -> tuple[list[dict], list[dict]]:
|
def parse_starting_teams(soup: BeautifulSoup) -> tuple[list[dict], list[dict]]:
|
||||||
home_starting = []
|
home_starting = []
|
||||||
|
|||||||
@@ -270,3 +270,22 @@ def build_channel_logo_path(source_key: str | None, filename: str | None) -> str
|
|||||||
|
|
||||||
def build_empty_channel_logo_path(source_key: str | None = None) -> str:
|
def build_empty_channel_logo_path(source_key: str | None = None) -> str:
|
||||||
return build_channel_logo_path(source_key, "EMPTY.png")
|
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)
|
||||||
|
|||||||
@@ -360,3 +360,58 @@ def update_coach_admin(
|
|||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def create_coach_admin(
|
||||||
|
team_id: int,
|
||||||
|
full_name: str,
|
||||||
|
first_name: str = "",
|
||||||
|
last_name: str = "",
|
||||||
|
external_id: str = "",
|
||||||
|
birth_date: str = "",
|
||||||
|
role: str = "",
|
||||||
|
is_active: bool = True,
|
||||||
|
) -> int:
|
||||||
|
"""Создаёт тренера вручную из административного раздела."""
|
||||||
|
query = """
|
||||||
|
INSERT INTO coaches (
|
||||||
|
external_id,
|
||||||
|
team_id,
|
||||||
|
player,
|
||||||
|
lastname,
|
||||||
|
name,
|
||||||
|
born,
|
||||||
|
amplua,
|
||||||
|
is_active,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
NULLIF(%s, ''), %s, %s, %s, %s, NULLIF(%s, ''), %s, %s, NOW(), NOW()
|
||||||
|
)
|
||||||
|
RETURNING id;
|
||||||
|
"""
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
query,
|
||||||
|
(
|
||||||
|
external_id.strip(),
|
||||||
|
int(team_id),
|
||||||
|
full_name.strip(),
|
||||||
|
last_name.strip(),
|
||||||
|
first_name.strip(),
|
||||||
|
birth_date.strip(),
|
||||||
|
role.strip(),
|
||||||
|
bool(is_active),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return int(row[0])
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|||||||
@@ -471,6 +471,52 @@ def save_match_lineup_for_editor(
|
|||||||
insert_players("away", "starting", away_starting)
|
insert_players("away", "starting", away_starting)
|
||||||
insert_players("away", "bench", away_bench)
|
insert_players("away", "bench", away_bench)
|
||||||
|
|
||||||
|
# Синхронизируем капитана с уже сохранённой расстановкой.
|
||||||
|
# /home-formations и /away-formations читают is_captain из
|
||||||
|
# match_formations, поэтому ручная смена капитана в редакторе
|
||||||
|
# состава должна сразу попадать и туда без пересохранения
|
||||||
|
# вкладки «Расстановки».
|
||||||
|
def sync_formation_captain(side: str, team_id: int) -> None:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE match_formations mf
|
||||||
|
SET
|
||||||
|
is_captain = COALESCE((
|
||||||
|
SELECT mlp.is_captain
|
||||||
|
FROM match_lineup_players mlp
|
||||||
|
WHERE mlp.match_id = mf.match_id
|
||||||
|
AND mlp.side = %s
|
||||||
|
AND mlp.role = 'starting'
|
||||||
|
AND (
|
||||||
|
(
|
||||||
|
mf.player_id IS NOT NULL
|
||||||
|
AND mlp.player_id = mf.player_id
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
COALESCE(NULLIF(TRIM(mf.number), ''), '') <> ''
|
||||||
|
AND COALESCE(mlp.number::text, '') = COALESCE(mf.number, '')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN mf.player_id IS NOT NULL
|
||||||
|
AND mlp.player_id = mf.player_id
|
||||||
|
THEN 0
|
||||||
|
ELSE 1
|
||||||
|
END,
|
||||||
|
mlp.sort_order
|
||||||
|
LIMIT 1
|
||||||
|
), FALSE),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE mf.match_id = %s
|
||||||
|
AND mf.team_id = %s
|
||||||
|
""",
|
||||||
|
(side, match_id, team_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
sync_formation_captain("home", home_team_id)
|
||||||
|
sync_formation_captain("away", away_team_id)
|
||||||
|
|
||||||
insert_coaches("home", home_coaches)
|
insert_coaches("home", home_coaches)
|
||||||
insert_coaches("away", away_coaches)
|
insert_coaches("away", away_coaches)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from db import get_connection
|
from db import get_connection
|
||||||
from parsers.parser_sources import build_logo_path
|
from parsers.parser_sources import build_logo_variant_path
|
||||||
from repositories.match_repository import resolve_match_source_key
|
from repositories.match_repository import resolve_match_source_key
|
||||||
|
|
||||||
|
|
||||||
@@ -92,8 +92,12 @@ def get_match_session_by_token(session_token: str):
|
|||||||
source_key = resolve_match_source_key(row[1], row[21] if len(row) > 21 else None)
|
source_key = resolve_match_source_key(row[1], row[21] if len(row) > 21 else None)
|
||||||
if len(row) > 21:
|
if len(row) > 21:
|
||||||
row[21] = source_key
|
row[21] = source_key
|
||||||
row[16] = build_logo_path(source_key, row[16])
|
# Эти пути используются рабочей страницей при отправке команд
|
||||||
row[20] = build_logo_path(source_key, row[20])
|
# 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)
|
return tuple(row)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -544,3 +544,101 @@ def update_player_photo_enabled(player_id: int, photo_enabled: bool = False) ->
|
|||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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()
|
||||||
|
|||||||
@@ -266,3 +266,55 @@ def replace_match_referees(match_id: int, rows: list[dict]) -> None:
|
|||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def create_referee_admin(
|
||||||
|
full_name: str,
|
||||||
|
first_name: str = "",
|
||||||
|
last_name: str = "",
|
||||||
|
middle_name: str = "",
|
||||||
|
city: str = "",
|
||||||
|
external_id: str = "",
|
||||||
|
is_active: bool = True,
|
||||||
|
) -> int:
|
||||||
|
"""Создаёт судью вручную из административного раздела."""
|
||||||
|
query = """
|
||||||
|
INSERT INTO referees (
|
||||||
|
external_id,
|
||||||
|
full_name,
|
||||||
|
lastname,
|
||||||
|
name,
|
||||||
|
middle_name,
|
||||||
|
city,
|
||||||
|
is_active,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
NULLIF(%s, ''), %s, %s, %s, %s, NULLIF(%s, ''), %s, NOW(), NOW()
|
||||||
|
)
|
||||||
|
RETURNING id;
|
||||||
|
"""
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
query,
|
||||||
|
(
|
||||||
|
external_id.strip(),
|
||||||
|
full_name.strip(),
|
||||||
|
last_name.strip(),
|
||||||
|
first_name.strip(),
|
||||||
|
middle_name.strip(),
|
||||||
|
city.strip(),
|
||||||
|
bool(is_active),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return int(row[0])
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|||||||
@@ -239,3 +239,29 @@ def update_team_admin(
|
|||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def list_teams_for_admin_select() -> list[dict]:
|
||||||
|
"""Возвращает полный компактный список команд для выпадающих списков админки."""
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name, short_name_3, city
|
||||||
|
FROM teams
|
||||||
|
ORDER BY name ASC, id ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row[0],
|
||||||
|
"name": row[1] or "",
|
||||||
|
"short_name_3": row[2] or "",
|
||||||
|
"city": row[3] or "",
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ from repositories.coach_repository import (
|
|||||||
create_coach_from_lineup,
|
create_coach_from_lineup,
|
||||||
)
|
)
|
||||||
from repositories.referee_repository import get_referee_id_by_name, upsert_referee
|
from repositories.referee_repository import get_referee_id_by_name, upsert_referee
|
||||||
from repositories.match_lineup_repository import replace_match_lineups, save_match_lineup_for_editor
|
from repositories.match_lineup_repository import (
|
||||||
|
replace_match_lineups,
|
||||||
|
get_match_lineup_for_editor,
|
||||||
|
)
|
||||||
from repositories.match_coach_repository import replace_match_coaches
|
from repositories.match_coach_repository import replace_match_coaches
|
||||||
from repositories.match_referee_repository import replace_match_referees
|
from repositories.match_referee_repository import replace_match_referees
|
||||||
|
|
||||||
@@ -35,6 +38,31 @@ def sync_match_page(
|
|||||||
raise ValueError(f"Match not found by external_id: {match_external_id}")
|
raise ValueError(f"Match not found by external_id: {match_external_id}")
|
||||||
|
|
||||||
match_id, _, home_team_id, away_team_id = match_row
|
match_id, _, home_team_id, away_team_id = match_row
|
||||||
|
|
||||||
|
# Сохраняем текущий выбор капитана ДО очистки данных матча. Это важно
|
||||||
|
# при повторной загрузке с сайта: на сайте капитана иногда не указывают,
|
||||||
|
# и в таком случае нельзя молча терять уже сделанный оператором выбор.
|
||||||
|
previous_lineup = get_match_lineup_for_editor(
|
||||||
|
match_id=match_id,
|
||||||
|
home_team_id=home_team_id,
|
||||||
|
away_team_id=away_team_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def previous_captain(side: str) -> dict | None:
|
||||||
|
rows = previous_lineup.get(f"{side}_starting", []) or []
|
||||||
|
for player in rows:
|
||||||
|
if bool(player.get("is_captain")):
|
||||||
|
return {
|
||||||
|
"player_id": player.get("player_id"),
|
||||||
|
"number": str(player.get("number") or "").strip(),
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
previous_captains = {
|
||||||
|
"home": previous_captain("home"),
|
||||||
|
"away": previous_captain("away"),
|
||||||
|
}
|
||||||
|
|
||||||
clear_match_squad_data(match_id)
|
clear_match_squad_data(match_id)
|
||||||
|
|
||||||
created_players_count = 0
|
created_players_count = 0
|
||||||
@@ -117,7 +145,10 @@ def sync_match_page(
|
|||||||
"number": player.get("number"),
|
"number": player.get("number"),
|
||||||
"position": player.get("position"),
|
"position": player.get("position"),
|
||||||
"position_full": player_position,
|
"position_full": player_position,
|
||||||
"is_captain": bool(player.get("is_captain")),
|
# Капитаном может быть только игрок основного состава.
|
||||||
|
# Если сайт по ошибке пометил игрока запаса, не переносим
|
||||||
|
# такой флаг в матчевые данные.
|
||||||
|
"is_captain": bool(player.get("is_captain")) and lineup_type == "starting",
|
||||||
"lineup_type": lineup_type,
|
"lineup_type": lineup_type,
|
||||||
"source": "parser",
|
"source": "parser",
|
||||||
}
|
}
|
||||||
@@ -128,6 +159,72 @@ def sync_match_page(
|
|||||||
append_players(home_bench, home_team_id, "bench")
|
append_players(home_bench, home_team_id, "bench")
|
||||||
append_players(away_bench, away_team_id, "bench")
|
append_players(away_bench, away_team_id, "bench")
|
||||||
|
|
||||||
|
def restore_previous_captain_if_missing(side: str, team_id: int) -> bool:
|
||||||
|
"""Возвращает старого капитана только когда сайт не указал нового.
|
||||||
|
|
||||||
|
Восстановление разрешено исключительно для игрока, который всё ещё
|
||||||
|
присутствует в основном составе. Если прежний капитан отсутствует или
|
||||||
|
ушёл в запас, капитан остаётся не выбран — веб-интерфейс покажет
|
||||||
|
обязательное предупреждение оператору.
|
||||||
|
"""
|
||||||
|
starting_rows = [
|
||||||
|
row for row in lineup_rows
|
||||||
|
if row.get("team_id") == team_id and row.get("lineup_type") == "starting"
|
||||||
|
]
|
||||||
|
|
||||||
|
imported_captains = [row for row in starting_rows if bool(row.get("is_captain"))]
|
||||||
|
|
||||||
|
# Ровно один капитан с сайта — корректные данные, они имеют приоритет.
|
||||||
|
if len(imported_captains) == 1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Если сайт по ошибке передал больше одного капитана, не выбираем
|
||||||
|
# случайного игрока. Сбрасываем конфликт и используем тот же безопасный
|
||||||
|
# fallback, что и при полностью отсутствующем капитане.
|
||||||
|
if len(imported_captains) > 1:
|
||||||
|
for row in imported_captains:
|
||||||
|
row["is_captain"] = False
|
||||||
|
|
||||||
|
old_captain = previous_captains.get(side)
|
||||||
|
if not old_captain:
|
||||||
|
return False
|
||||||
|
|
||||||
|
old_player_id = old_captain.get("player_id")
|
||||||
|
old_number = str(old_captain.get("number") or "").strip()
|
||||||
|
|
||||||
|
# Сначала точное совпадение по player_id.
|
||||||
|
if old_player_id is not None:
|
||||||
|
for row in starting_rows:
|
||||||
|
if row.get("player_id") is not None and str(row.get("player_id")) == str(old_player_id):
|
||||||
|
row["is_captain"] = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Fallback для старых/неполных данных — по игровому номеру.
|
||||||
|
if old_number:
|
||||||
|
matches = [
|
||||||
|
row for row in starting_rows
|
||||||
|
if str(row.get("number") or "").strip() == old_number
|
||||||
|
]
|
||||||
|
if len(matches) == 1:
|
||||||
|
matches[0]["is_captain"] = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
restored_home_captain = restore_previous_captain_if_missing("home", home_team_id)
|
||||||
|
restored_away_captain = restore_previous_captain_if_missing("away", away_team_id)
|
||||||
|
|
||||||
|
if restored_home_captain or restored_away_captain:
|
||||||
|
restored = []
|
||||||
|
if restored_home_captain:
|
||||||
|
restored.append("home")
|
||||||
|
if restored_away_captain:
|
||||||
|
restored.append("away")
|
||||||
|
print(
|
||||||
|
f"[parser_game] website captain missing for match={match_external_id}; "
|
||||||
|
f"preserved previous captain for: {', '.join(restored)}"
|
||||||
|
)
|
||||||
|
|
||||||
coach_rows = []
|
coach_rows = []
|
||||||
|
|
||||||
def append_coaches(coaches: list[dict], team_id: int, side: str) -> None:
|
def append_coaches(coaches: list[dict], team_id: int, side: str) -> None:
|
||||||
|
|||||||
109
static/script.js
109
static/script.js
@@ -2580,6 +2580,13 @@ function makeDraggable(el) {
|
|||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
setEditMode(false);
|
setEditMode(false);
|
||||||
renderRefereesEditor();
|
renderRefereesEditor();
|
||||||
|
|
||||||
|
if (isGameTabActive()) {
|
||||||
|
const missingCaptainSides = getMissingCaptainSidesFromMatchData();
|
||||||
|
if (missingCaptainSides.length) {
|
||||||
|
window.setTimeout(() => showCaptainRequiredWarning(missingCaptainSides), 150);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -3354,6 +3361,69 @@ function cloneSideState(side) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let suppressNextCaptainWarning = false;
|
||||||
|
|
||||||
|
function sideNeedsCaptain(state) {
|
||||||
|
const starting = Array.isArray(state?.starting) ? state.starting : [];
|
||||||
|
return starting.length > 0 && !starting.some(player => !!player.is_captain);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMissingCaptainSidesFromEditor() {
|
||||||
|
const missing = [];
|
||||||
|
if (sideNeedsCaptain(squadEditorState.home)) missing.push("home");
|
||||||
|
if (sideNeedsCaptain(squadEditorState.away)) missing.push("away");
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMissingCaptainSidesFromMatchData() {
|
||||||
|
const missing = [];
|
||||||
|
const homeState = { starting: window.MATCH_DATA?.homeStarting || [] };
|
||||||
|
const awayState = { starting: window.MATCH_DATA?.awayStarting || [] };
|
||||||
|
if (sideNeedsCaptain(homeState)) missing.push("home");
|
||||||
|
if (sideNeedsCaptain(awayState)) missing.push("away");
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCaptainWarningTeamNames(sides) {
|
||||||
|
return sides.map(side => {
|
||||||
|
const fallback = side === "home" ? "Домашняя команда" : "Гостевая команда";
|
||||||
|
return String(window.MATCH_DATA?.teamNames?.[side] || fallback).trim() || fallback;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCaptainRequiredWarning(sides) {
|
||||||
|
const uniqueSides = Array.from(new Set((sides || []).filter(side => side === "home" || side === "away")));
|
||||||
|
if (!uniqueSides.length) {
|
||||||
|
closeCaptainRequiredWarning();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.getElementById("captainRequiredModal");
|
||||||
|
const text = document.getElementById("captainRequiredText");
|
||||||
|
if (!modal || !text) return;
|
||||||
|
|
||||||
|
const names = getCaptainWarningTeamNames(uniqueSides);
|
||||||
|
if (names.length === 1) {
|
||||||
|
text.textContent = `У команды «${names[0]}» не выбран капитан. Обязательно назначьте капитана из основного состава перед сохранением.`;
|
||||||
|
} else {
|
||||||
|
text.textContent = `У команд «${names[0]}» и «${names[1]}» не выбраны капитаны. Обязательно назначьте капитана каждой команды из основного состава перед сохранением.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.classList.add("show");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCaptainRequiredWarning() {
|
||||||
|
document.getElementById("captainRequiredModal")?.classList.remove("show");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCaptainEditorFromWarning() {
|
||||||
|
closeCaptainRequiredWarning();
|
||||||
|
if (!editModeEnabled) {
|
||||||
|
suppressNextCaptainWarning = true;
|
||||||
|
setEditMode(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openSquadEditor() {
|
async function openSquadEditor() {
|
||||||
if (!editModeEnabled) return;
|
if (!editModeEnabled) return;
|
||||||
|
|
||||||
@@ -3370,6 +3440,13 @@ async function openSquadEditor() {
|
|||||||
renderSquadEditorSide("away");
|
renderSquadEditorSide("away");
|
||||||
|
|
||||||
document.getElementById("squadEditorModal")?.classList.add("show");
|
document.getElementById("squadEditorModal")?.classList.add("show");
|
||||||
|
|
||||||
|
const missingCaptainSides = getMissingCaptainSidesFromEditor();
|
||||||
|
if (suppressNextCaptainWarning) {
|
||||||
|
suppressNextCaptainWarning = false;
|
||||||
|
} else if (missingCaptainSides.length) {
|
||||||
|
showCaptainRequiredWarning(missingCaptainSides);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -3410,8 +3487,12 @@ function renderSquadEditorSide(side) {
|
|||||||
|
|
||||||
const availablePlayers = getAvailableSquadPool(side);
|
const availablePlayers = getAvailableSquadPool(side);
|
||||||
const coachOptions = state.coachPool;
|
const coachOptions = state.coachPool;
|
||||||
|
const captainWarning = sideNeedsCaptain(state)
|
||||||
|
? `<div class="squad-editor-captain-warning">⚠ Капитан не выбран. Назначьте капитана из основного состава.</div>`
|
||||||
|
: "";
|
||||||
|
|
||||||
mount.innerHTML = `
|
mount.innerHTML = `
|
||||||
|
${captainWarning}
|
||||||
<div class="squad-editor-section">
|
<div class="squad-editor-section">
|
||||||
<div class="squad-editor-section-title">Добавить игрока</div>
|
<div class="squad-editor-section-title">Добавить игрока</div>
|
||||||
<div class="squad-editor-box">
|
<div class="squad-editor-box">
|
||||||
@@ -3560,6 +3641,9 @@ function removePlayerFromEditor(side, playerId, role) {
|
|||||||
const state = getSideEditorState(side);
|
const state = getSideEditorState(side);
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
|
||||||
|
const removedPlayer = state[role]?.find(p => String(p.player_id) === String(playerId));
|
||||||
|
const removedCaptain = role === "starting" && !!removedPlayer?.is_captain;
|
||||||
|
|
||||||
const hasLinkedEvents = matchEvents.some(event =>
|
const hasLinkedEvents = matchEvents.some(event =>
|
||||||
String(event.side) === String(side) &&
|
String(event.side) === String(side) &&
|
||||||
(
|
(
|
||||||
@@ -3576,6 +3660,10 @@ function removePlayerFromEditor(side, playerId, role) {
|
|||||||
|
|
||||||
state[role] = state[role].filter(p => String(p.player_id) !== String(playerId));
|
state[role] = state[role].filter(p => String(p.player_id) !== String(playerId));
|
||||||
renderSquadEditorSide(side);
|
renderSquadEditorSide(side);
|
||||||
|
|
||||||
|
if (removedCaptain && sideNeedsCaptain(state)) {
|
||||||
|
showCaptainRequiredWarning([side]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function movePlayerBetweenZones(side, playerId, fromRole, toRole) {
|
function movePlayerBetweenZones(side, playerId, fromRole, toRole) {
|
||||||
@@ -3586,19 +3674,18 @@ function movePlayerBetweenZones(side, playerId, fromRole, toRole) {
|
|||||||
if (idx === -1) return;
|
if (idx === -1) return;
|
||||||
|
|
||||||
const [player] = state[fromRole].splice(idx, 1);
|
const [player] = state[fromRole].splice(idx, 1);
|
||||||
|
const movedCaptainOutOfStarting = fromRole === "starting" && toRole === "bench" && !!player.is_captain;
|
||||||
|
|
||||||
if (toRole === "bench") {
|
if (toRole === "bench") {
|
||||||
player.is_captain = false;
|
player.is_captain = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
state[toRole].push(player);
|
state[toRole].push(player);
|
||||||
|
|
||||||
const hasCaptainInStarting = state.starting.some(p => p.is_captain);
|
|
||||||
if (!hasCaptainInStarting && state.starting.length) {
|
|
||||||
state.starting[0].is_captain = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderSquadEditorSide(side);
|
renderSquadEditorSide(side);
|
||||||
|
|
||||||
|
if (movedCaptainOutOfStarting && sideNeedsCaptain(state)) {
|
||||||
|
showCaptainRequiredWarning([side]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -3620,6 +3707,10 @@ function setCaptainInEditor(side, playerId, role) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
renderSquadEditorSide(side);
|
renderSquadEditorSide(side);
|
||||||
|
|
||||||
|
if (!getMissingCaptainSidesFromEditor().length) {
|
||||||
|
closeCaptainRequiredWarning();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCoachInEditor(side, index, coachId) {
|
function updateCoachInEditor(side, index, coachId) {
|
||||||
@@ -3757,6 +3848,12 @@ function rebuildFormationFromMatchData(side) {
|
|||||||
async function applySquadEditorChanges() {
|
async function applySquadEditorChanges() {
|
||||||
if (!squadEditorState.home || !squadEditorState.away) return;
|
if (!squadEditorState.home || !squadEditorState.away) return;
|
||||||
|
|
||||||
|
const missingCaptainSides = getMissingCaptainSidesFromEditor();
|
||||||
|
if (missingCaptainSides.length) {
|
||||||
|
showCaptainRequiredWarning(missingCaptainSides);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = buildSquadSavePayload();
|
const payload = buildSquadSavePayload();
|
||||||
const result = await saveSquadEditorChangesToServer(payload);
|
const result = await saveSquadEditorChangesToServer(payload);
|
||||||
|
|
||||||
|
|||||||
@@ -1897,6 +1897,63 @@ body.edit-mode-on .player-node.dragging {
|
|||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.squad-editor-captain-warning {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #f1b9b9;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff1f1;
|
||||||
|
color: #9f1d1d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
#captainRequiredModal {
|
||||||
|
z-index: 3400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captain-required-modal-content {
|
||||||
|
width: min(560px, 92vw);
|
||||||
|
margin-top: 16vh;
|
||||||
|
padding: 24px;
|
||||||
|
overflow: visible;
|
||||||
|
border: 1px solid #f0c7c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captain-required-icon {
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff0f0;
|
||||||
|
color: #c62828;
|
||||||
|
border: 2px solid #ef9a9a;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captain-required-copy {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captain-required-text {
|
||||||
|
margin-top: 10px;
|
||||||
|
color: #4d4d4d;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captain-required-actions {
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.squad-editor-grid {
|
.squad-editor-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -149,6 +149,9 @@
|
|||||||
<form method="get" action="/admin/db/coaches" class="search-form">
|
<form method="get" action="/admin/db/coaches" class="search-form">
|
||||||
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по ФИО или external_id">
|
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по ФИО или external_id">
|
||||||
<button type="submit" class="btn btn-primary">Найти</button>
|
<button type="submit" class="btn btn-primary">Найти</button>
|
||||||
|
{% if request.state.current_user and request.state.current_user.role == "admin" %}
|
||||||
|
<a href="/admin/db/create?entity=coach" class="btn btn-primary">➕ Создать тренера</a>
|
||||||
|
{% endif %}
|
||||||
<a href="/admin/db" class="btn btn-secondary">Назад</a>
|
<a href="/admin/db" class="btn btn-secondary">Назад</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
459
templates/admin_db_create.html
Normal file
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>
|
||||||
@@ -520,6 +520,9 @@
|
|||||||
<div class="title">Редактирование базы</div>
|
<div class="title">Редактирование базы</div>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
|
{% if current_user and current_user.role == "admin" %}
|
||||||
|
<a class="tab-link" href="/admin/db/create?entity=player">➕ Создать запись</a>
|
||||||
|
{% endif %}
|
||||||
<a class="tab-link" href="/admin/db/players">Игроки</a>
|
<a class="tab-link" href="/admin/db/players">Игроки</a>
|
||||||
<a class="tab-link" href="/admin/db/referees">Судьи</a>
|
<a class="tab-link" href="/admin/db/referees">Судьи</a>
|
||||||
<a class="tab-link" href="/admin/db/teams">Команды</a>
|
<a class="tab-link" href="/admin/db/teams">Команды</a>
|
||||||
|
|||||||
@@ -190,6 +190,9 @@
|
|||||||
<input type="hidden" name="direction" value="{{ direction }}">
|
<input type="hidden" name="direction" value="{{ direction }}">
|
||||||
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по ФИО, external_id или команде">
|
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по ФИО, external_id или команде">
|
||||||
<button type="submit" class="btn btn-primary">Найти</button>
|
<button type="submit" class="btn btn-primary">Найти</button>
|
||||||
|
{% if request.state.current_user and request.state.current_user.role == "admin" %}
|
||||||
|
<a href="/admin/db/create?entity=player" class="btn btn-primary">➕ Создать игрока</a>
|
||||||
|
{% endif %}
|
||||||
<a href="/admin/db" class="btn btn-secondary">Назад</a>
|
<a href="/admin/db" class="btn btn-secondary">Назад</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,6 +37,9 @@
|
|||||||
<form method="get" action="/admin/db/referees" class="search-form">
|
<form method="get" action="/admin/db/referees" class="search-form">
|
||||||
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по ФИО или external_id">
|
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по ФИО или external_id">
|
||||||
<button type="submit" class="btn btn-primary">Найти</button>
|
<button type="submit" class="btn btn-primary">Найти</button>
|
||||||
|
{% if request.state.current_user and request.state.current_user.role == "admin" %}
|
||||||
|
<a href="/admin/db/create?entity=referee" class="btn btn-primary">➕ Создать судью</a>
|
||||||
|
{% endif %}
|
||||||
<a href="/admin/db" class="btn btn-secondary">Назад</a>
|
<a href="/admin/db" class="btn btn-secondary">Назад</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1387,6 +1387,27 @@ data-role="{{ p.position or '' }}"
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="formation-modal captain-required-modal" id="captainRequiredModal">
|
||||||
|
<div class="formation-modal-backdrop" onclick="closeCaptainRequiredWarning()"></div>
|
||||||
|
|
||||||
|
<div class="formation-modal-content captain-required-modal-content">
|
||||||
|
<div class="captain-required-icon">!</div>
|
||||||
|
<div class="captain-required-copy">
|
||||||
|
<div class="formation-modal-title">Не выбран капитан</div>
|
||||||
|
<div class="captain-required-text" id="captainRequiredText"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="formation-modal-footer captain-required-actions">
|
||||||
|
<button type="button" class="btn btn-cancel" onclick="closeCaptainRequiredWarning()">
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-confirm" onclick="openCaptainEditorFromWarning()">
|
||||||
|
Выбрать капитана
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="formation-modal" id="squadEditorModal">
|
<div class="formation-modal" id="squadEditorModal">
|
||||||
<div class="formation-modal-backdrop" onclick="closeSquadEditor()"></div>
|
<div class="formation-modal-backdrop" onclick="closeSquadEditor()"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from synology_drive_api.drive import SynologyDrive
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
@@ -247,13 +248,15 @@ def build_vmix_project_bytes(
|
|||||||
f"nas={normalized_syno_url}:443 env={env_path}"
|
f"nas={normalized_syno_url}:443 env={env_path}"
|
||||||
)
|
)
|
||||||
|
|
||||||
vmix_bio = nasio.load_bio(
|
with SynologyDrive(
|
||||||
user=syno_username,
|
username=syno_username,
|
||||||
password=syno_password,
|
password=syno_password,
|
||||||
nas_ip=normalized_syno_url,
|
nas_domain=normalized_syno_url,
|
||||||
nas_port="443",
|
port=443,
|
||||||
path=vmix_preset_path,
|
https=True,
|
||||||
)
|
dsm_version="7",
|
||||||
|
) as nas:
|
||||||
|
vmix_bio = nas.download_file(vmix_preset_path)
|
||||||
|
|
||||||
edited_vmix = change_vmix_datasource_urls(
|
edited_vmix = change_vmix_datasource_urls(
|
||||||
vmix_bio,
|
vmix_bio,
|
||||||
|
|||||||
Reference in New Issue
Block a user