Compare commits

..

7 Commits

19 changed files with 1358 additions and 51 deletions

242
app.py
View File

@@ -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 "",

View File

@@ -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 = []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:

View File

@@ -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);

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,16 +4,41 @@ import xml.etree.ElementTree as ET
import io import io
import platform import platform
import os import os
from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
from dotenv import load_dotenv from dotenv import load_dotenv
from synology_drive_api.drive import SynologyDrive
load_dotenv(override=True) PROJECT_ROOT = Path(__file__).resolve().parent.parent
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: def _refresh_env() -> None:
# Читаем .env заново перед скачиванием, чтобы изменение SYNO_PATH_VMIX_2 # ВАЖНО: не используем load_dotenv() без пути. Иначе /root/WFL/.env
# применялось без пересборки кода. # может перезаписать значения из systemd EnvironmentFile=/mnt/wfl/.env.
load_dotenv(override=True) 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]: def get_syno_settings() -> tuple[str | None, str | None, str | None]:
@@ -203,16 +228,36 @@ def build_vmix_project_bytes(
) )
syno_url, syno_username, syno_password = get_syno_settings() syno_url, syno_username, syno_password = get_syno_settings()
print(f"[vmix] source_key={source_key or '-'} preset_path={vmix_preset_path}") env_path = get_env_path()
vmix_bio = nasio.load_bio( if not syno_url:
user=syno_username, raise RuntimeError(f"Не задан SYNO_URL (env: {env_path})")
password=syno_password, if not syno_username:
nas_ip=syno_url, raise RuntimeError(f"Не задан SYNO_USERNAME (env: {env_path})")
nas_port="443", if not syno_password:
path=vmix_preset_path, raise RuntimeError(f"Не задан SYNO_PASSWORD (env: {env_path})")
# nasio ожидает имя хоста/IP отдельно от порта, поэтому убираем случайно
# добавленную схему https:// и завершающий slash.
normalized_syno_url = str(syno_url).strip().rstrip("/")
if normalized_syno_url.startswith(("http://", "https://")):
normalized_syno_url = urlparse(normalized_syno_url).hostname or normalized_syno_url
print(
f"[vmix] source_key={source_key or '-'} preset_path={vmix_preset_path} "
f"nas={normalized_syno_url}:443 env={env_path}"
) )
with SynologyDrive(
username=syno_username,
password=syno_password,
nas_domain=normalized_syno_url,
port=443,
https=True,
dsm_version="7",
) as nas:
vmix_bio = nas.download_file(vmix_preset_path)
edited_vmix = change_vmix_datasource_urls( edited_vmix = change_vmix_datasource_urls(
vmix_bio, vmix_bio,
FQDN, FQDN,