все остальные апдейты на Кубок России

This commit is contained in:
2026-07-02 17:00:15 +03:00
parent 03a68ee8ca
commit e7b215af5e
11 changed files with 1644 additions and 180 deletions

261
app.py
View File

@@ -105,6 +105,15 @@ from repositories.match_event_repository import (
clear_events,
update_event,
)
from repositories.match_penalty_repository import (
ensure_match_penalty_tables,
get_penalty_state,
set_penalty_shot,
delete_penalty_shot,
add_penalty_round,
delete_last_penalty_round,
clear_penalties,
)
from repositories.auth_repository import get_user_by_username
from services.auth_service import (
create_auth_session,
@@ -193,6 +202,7 @@ def start_scheduler():
ensure_project_settings_tables()
ensure_player_photo_enabled_column()
ensure_match_source_key_column()
ensure_match_penalty_tables()
except Exception:
traceback.print_exc()
@@ -212,6 +222,12 @@ class PublishVmixCommandPayload(BaseModel):
meta: dict | None = None
class PenaltyShotPayload(BaseModel):
side: str
shot_number: int = Field(..., ge=1)
result: str
class RegisterVmixClientPayload(BaseModel):
client_id: str
match_id: int | None = None
@@ -739,6 +755,13 @@ def session_workspace(
return RedirectResponse(url="/admin/matches", status_code=303)
match_id = session_row[1]
source_key = resolve_match_source_key(match_id, session_row[21] if len(session_row) > 21 else None)
is_russian_cup = str(source_key or "").upper() == "RUSSIAN_CUP"
if is_russian_cup and tab == "standings":
tab = "penalties"
if (not is_russian_cup) and tab == "penalties":
tab = "game"
home_team_id = session_row[13]
away_team_id = session_row[17]
referees = get_match_referees(match_id)
@@ -776,6 +799,8 @@ def session_workspace(
"home_formations": home_formations,
"away_formations": away_formations,
"current_user": getattr(request.state, "current_user", None),
"source_key": source_key,
"is_russian_cup": is_russian_cup,
"auth_idle_timeout_seconds": 7200,
},
)
@@ -1343,6 +1368,85 @@ def api_delete_event(session_token: str, event_id: int):
return {"ok": True}
@app.get("/admin/session/{session_token}/penalties")
def api_get_penalties(session_token: str):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
return get_penalty_state(
match_id=session_row[1],
home_team_name=session_row[14],
away_team_name=session_row[18],
)
@app.post("/admin/session/{session_token}/penalties/shot")
def api_set_penalty_shot(session_token: str, payload: PenaltyShotPayload):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
result = str(payload.result or "").strip().lower()
try:
if result in {"clear", "empty", "delete", ""}:
delete_penalty_shot(session_row[1], payload.side, payload.shot_number)
else:
set_penalty_shot(session_row[1], payload.side, payload.shot_number, result)
except ValueError as e:
return JSONResponse({"error": str(e)}, status_code=400)
return get_penalty_state(
match_id=session_row[1],
home_team_name=session_row[14],
away_team_name=session_row[18],
)
@app.post("/admin/session/{session_token}/penalties/round")
def api_add_penalty_round(session_token: str):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
add_penalty_round(session_row[1])
return get_penalty_state(
match_id=session_row[1],
home_team_name=session_row[14],
away_team_name=session_row[18],
)
@app.delete("/admin/session/{session_token}/penalties/round")
def api_delete_last_penalty_round(session_token: str):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
delete_last_penalty_round(session_row[1])
return get_penalty_state(
match_id=session_row[1],
home_team_name=session_row[14],
away_team_name=session_row[18],
)
@app.delete("/admin/session/{session_token}/penalties")
def api_clear_penalties(session_token: str):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
clear_penalties(session_row[1])
return get_penalty_state(
match_id=session_row[1],
home_team_name=session_row[14],
away_team_name=session_row[18],
)
@app.get("/admin/session/{session_token}/squad-editor-data")
def api_get_squad_editor_data(session_token: str):
session_row = get_match_session_by_token(session_token)
@@ -1620,6 +1724,14 @@ class MatchChannelPayload(BaseModel):
channel: str = ""
class MatchSchedulePayload(BaseModel):
match_external_id: str
home_score: int | None = None
away_score: int | None = None
live: bool = False
status: str | None = None
@app.post("/admin/session/{session_token}/schedule/channel")
def update_schedule_channel(session_token: str, payload: MatchChannelPayload):
session_row = get_match_session_by_token(session_token)
@@ -1650,6 +1762,62 @@ def update_schedule_channel(session_token: str, payload: MatchChannelPayload):
conn.close()
@app.post("/admin/session/{session_token}/schedule/match")
def update_schedule_match(session_token: str, payload: MatchSchedulePayload):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
home_score = payload.home_score
away_score = payload.away_score
if (home_score is None) != (away_score is None):
return JSONResponse(
{"error": "score_pair_required", "message": "Нужно заполнить оба значения счёта или очистить оба."},
status_code=400,
)
if home_score is not None and (home_score < 0 or away_score < 0):
return JSONResponse({"error": "invalid_score"}, status_code=400)
allowed_statuses = {"scheduled", "live", "finished"}
if payload.status is not None:
status = str(payload.status or "scheduled").strip().lower()
if status not in allowed_statuses:
return JSONResponse({"error": "invalid_status"}, status_code=400)
elif payload.live:
status = "live"
elif home_score is not None and away_score is not None:
status = "finished"
else:
status = "scheduled"
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE matches
SET home_score = %s,
away_score = %s,
status = %s,
updated_at = NOW()
WHERE external_id = %s
""",
(home_score, away_score, status, payload.match_external_id),
)
if cur.rowcount == 0:
conn.rollback()
return JSONResponse({"error": "match_not_found"}, status_code=404)
conn.commit()
return {"success": True, "status": status, "home_score": home_score, "away_score": away_score}
except Exception as e:
conn.rollback()
return JSONResponse({"error": str(e)}, status_code=500)
finally:
conn.close()
@app.get("/vmix/session/{session_token}/schedule")
def vmix_schedule(session_token: str):
session_row = get_match_session_by_token(session_token)
@@ -1792,10 +1960,19 @@ def vmix_scoreboard(session_token: str):
row = get_vmix_scoreboard_info(session_row)
if not row:
return JSONResponse({"error": "data_not_found"}, status_code=404)
penalty_state = get_penalty_state(
match_id=session_row[1],
home_team_name=session_row[14],
away_team_name=session_row[18],
)
return [
{
"home_score": row[0],
"away_score": row[1],
"home_penalty_score": penalty_state["totals"]["home"],
"away_penalty_score": penalty_state["totals"]["away"],
"red_home_1": row[2],
"red_home_2": row[3],
"red_home_3": row[4],
@@ -1834,6 +2011,88 @@ def vmix_match_events(session_token: str):
return new_events
@app.get("/vmix/session/{session_token}/penalties")
def vmix_penalties(session_token: str):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
state = get_penalty_state(
match_id=session_row[1],
home_team_name=session_row[14],
away_team_name=session_row[18],
)
def mark(value: str) -> str:
if value == "scored":
return ""
if value == "missed":
return "×"
return " "
def color(value: str) -> str:
if value == "scored":
return "#00FF00"
if value == "missed":
return "#FF0000"
return "#FFFFFF00"
rounds = state.get("rounds") or []
rows = []
# vMix-титр рассчитан на 5 строк. В базе удары хранятся как 1-5, 6-10,
# 11-15 и дальше, но в data source всегда отдаем только текущий блок из 5.
# Например: удар 6 попадает в строку 1, удар 7 — в строку 2 и т.д.
shot_numbers = []
for shot in state.get("shots") or []:
try:
shot_numbers.append(int(shot.get("shot_number") or 0))
except Exception:
continue
max_shot_number = max(shot_numbers) if shot_numbers else 1
configured_max_rounds = max(5, int(state.get("max_rounds") or 5))
# Важный момент для vMix:
# если оператор добавил новую серию/новый удар после 5, то max_rounds становится 6,
# даже если сам 6-й удар еще не заполнен. Поэтому активный блок считаем не только
# по последнему заполненному удару, но и по max_rounds. Это сразу очищает 5 строк
# data source в vMix перед первым ударом новой серии: 6-10, 11-15 и т.д.
active_shot_number = max(max_shot_number, configured_max_rounds, 1)
visible_start = ((active_shot_number - 1) // 5) * 5 + 1
visible_end = visible_start + 4
visible_group = ((visible_start - 1) // 5) + 1
by_number = {int(r.get("number") or 0): r for r in rounds}
display_row = 1
for shot_number in range(visible_start, visible_end + 1):
round_row = by_number.get(shot_number, {"number": shot_number, "home": "", "away": ""})
home_value = round_row.get("home") or ""
away_value = round_row.get("away") or ""
rows.append({
# Строка для vMix: всегда 1-5.
"round": display_row,
# Реальный номер удара в базе: 1-5, 6-10, 11-15...
"shot_number": shot_number,
"penalty_group": visible_group,
"home_penalty": mark(home_value),
"away_penalty": mark(away_value),
"home_penalty_color": color(home_value),
"away_penalty_color": color(away_value),
"home_penalty_result": home_value,
"away_penalty_result": away_value,
"home_penalty_score": state["totals"]["home"],
"away_penalty_score": state["totals"]["away"],
"home_team": state.get("home_team") or "",
"away_team": state.get("away_team") or "",
})
display_row += 1
return rows
@app.websocket("/ws/vmix-client")
async def ws_vmix_client(
websocket: WebSocket,
@@ -2300,14 +2559,12 @@ def render_admin_db_index(
"title": "Суперлига 2026",
"logo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Teams Logos",
"photo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo",
"channel_logo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Лого каналов",
},
{
"key": "RUSSIAN_CUP",
"title": "Кубок России 2026",
"logo_base_path": r"D:\Графика\ФУТБОЛ\Кубок России 2026\Teams Logos",
"photo_base_path": r"D:\Графика\ФУТБОЛ\Кубок России 2026\Photo",
"channel_logo_base_path": r"D:\Графика\ФУТБОЛ\ЖФЛ Кубок России 2026\Лого каналов",
},
]
default_parser_source_key = "SUPERLEAGUE"

View File

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

View File

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

View File

@@ -355,7 +355,7 @@ def get_tour_schedule_by_match_id(match_id: int) -> list[dict]:
with conn.cursor() as cur:
cur.execute(
"""
SELECT m.tour, m.season, m.source_key
SELECT m.tour
FROM matches m
WHERE m.id = %s
""",
@@ -366,8 +366,6 @@ def get_tour_schedule_by_match_id(match_id: int) -> list[dict]:
return []
tour = row[0]
season = row[1]
source_key = resolve_match_source_key(match_id, row[2] if len(row) > 2 else None)
cur.execute(
"""
@@ -386,13 +384,11 @@ def get_tour_schedule_by_match_id(match_id: int) -> list[dict]:
LEFT JOIN teams ht ON ht.id = m.home_team_id
LEFT JOIN teams at ON at.id = m.away_team_id
WHERE m.tour = %s
AND (%s IS NULL OR m.season = %s)
AND COALESCE(NULLIF(m.source_key, ''), %s) = %s
ORDER BY
m.match_date NULLS LAST,
m.id
""",
(tour, season, season, source_key, source_key),
(tour,),
)
rows = cur.fetchall()

View File

@@ -222,6 +222,104 @@ def get_player_id_by_external_id(external_id: str) -> int | None:
conn.close()
def _split_full_name_for_autocreate(full_name: str) -> tuple[str, str]:
"""Аккуратно делит имя с сайта на first_name / last_name для новых записей."""
parts = [p.strip() for p in str(full_name or "").replace("\xa0", " ").split() if p.strip()]
if not parts:
return "", ""
if len(parts) == 1:
return "", parts[0]
# На сайте чаще приходит "Имя Фамилия". Полное имя всё равно сохраняем отдельно,
# поэтому даже при другом порядке данные можно быстро поправить в справочнике.
first_name = parts[0]
last_name = " ".join(parts[1:])
return first_name, last_name
def create_player_from_lineup(
team_id: int,
external_id: str = "",
full_name: str = "",
number: str = "",
position: str = "",
) -> tuple[int | None, str | None]:
"""Создаёт минимальную карточку игрока из протокола матча и возвращает (id, position).
Используется при загрузке составов с сайта, когда игрока ещё нет в справочнике.
"""
full_name = str(full_name or "").strip()
if not full_name:
return None, None
first_name, last_name = _split_full_name_for_autocreate(full_name)
external_id = str(external_id or "").strip()
number = str(number or "").strip()
position = str(position or "").strip()
query = """
INSERT INTO players (
external_id,
team_id,
full_name,
first_name,
last_name,
number,
position,
pos,
amplua,
is_active,
photo_enabled,
created_at,
updated_at
)
VALUES (
NULLIF(%s, ''), %s, %s, %s, %s, %s, %s, %s, %s, TRUE, FALSE,
NOW(), NOW()
)
ON CONFLICT (external_id)
DO UPDATE SET
team_id = EXCLUDED.team_id,
full_name = COALESCE(NULLIF(EXCLUDED.full_name, ''), players.full_name),
first_name = COALESCE(NULLIF(EXCLUDED.first_name, ''), players.first_name),
last_name = COALESCE(NULLIF(EXCLUDED.last_name, ''), players.last_name),
number = COALESCE(NULLIF(EXCLUDED.number, ''), players.number),
position = COALESCE(NULLIF(EXCLUDED.position, ''), players.position),
pos = COALESCE(NULLIF(EXCLUDED.pos, ''), players.pos),
amplua = COALESCE(NULLIF(EXCLUDED.amplua, ''), players.amplua),
is_active = TRUE,
updated_at = NOW()
RETURNING id, position;
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
query,
(
external_id,
team_id,
full_name,
first_name,
last_name,
number,
position,
position,
position,
),
)
row = cur.fetchone()
conn.commit()
return row if row else (None, None)
except Exception:
conn.rollback()
raise
finally:
conn.close()
PLAYER_ADMIN_SORT_COLUMNS = {
"id": "p.id",
"full_name": "p.full_name",

View File

@@ -7,10 +7,12 @@ from repositories.match_repository import (
from repositories.player_repository import (
get_player_id_by_name_and_team,
get_player_id_by_external_id,
create_player_from_lineup,
)
from repositories.coach_repository import (
get_coach_id_by_name_and_team,
get_coach_id_by_external_id,
create_coach_from_lineup,
)
from repositories.referee_repository import get_referee_id_by_name, upsert_referee
from repositories.match_lineup_repository import replace_match_lineups, save_match_lineup_for_editor
@@ -35,158 +37,117 @@ def sync_match_page(
match_id, _, home_team_id, away_team_id = match_row
clear_match_squad_data(match_id)
created_players_count = 0
created_coaches_count = 0
def resolve_player(player: dict, team_id: int) -> tuple[int | None, str | None]:
"""Находит игрока из протокола или создаёт его в справочнике автоматически."""
nonlocal created_players_count
player_name = str(player.get("player_name") or "").strip()
player_external_id = str(player.get("player_external_id") or "").strip()
number = str(player.get("number") or "").strip()
position = str(player.get("position") or "").strip()
player_id = None
player_position = None
if player_external_id:
player_row = get_player_id_by_external_id(player_external_id)
if player_row:
player_id, player_position = player_row
if player_id is None and player_name:
player_id = get_player_id_by_name_and_team(player_name, team_id)
if player_id is None and player_name:
player_row = create_player_from_lineup(
team_id=team_id,
external_id=player_external_id,
full_name=player_name,
number=number,
position=position,
)
if player_row:
player_id, player_position = player_row
created_players_count += 1
return player_id, player_position or position
def resolve_coach(coach: dict, team_id: int) -> int | None:
"""Находит тренера из протокола или создаёт его в справочнике автоматически."""
nonlocal created_coaches_count
coach_name = str(coach.get("coach_name") or "").strip()
coach_external_id = str(coach.get("coach_external_id") or "").strip()
role = str(coach.get("role") or "").strip()
coach_id = None
if coach_external_id:
coach_id = get_coach_id_by_external_id(coach_external_id)
if coach_id is None and coach_name:
coach_id = get_coach_id_by_name_and_team(coach_name, team_id)
if coach_id is None and coach_name:
coach_id = create_coach_from_lineup(
team_id=team_id,
external_id=coach_external_id,
full_name=coach_name,
role=role,
)
if coach_id:
created_coaches_count += 1
return coach_id
lineup_rows = []
for player in home_starting:
player_id = None
player_position = None
if player.get("player_external_id"):
player_id, player_position = get_player_id_by_external_id(player["player_external_id"])
if player_id is None:
player_id = get_player_id_by_name_and_team(
player["player_name"], home_team_id
)
print(player_id, player_position)
def append_players(players: list[dict], team_id: int, lineup_type: str) -> None:
for player in players:
player_id, player_position = resolve_player(player, team_id)
lineup_rows.append(
{
"match_id": match_id,
"team_id": home_team_id,
"team_id": team_id,
"player_id": player_id,
"player_name": player["player_name"],
"player_name": player.get("player_name") or "",
"number": player.get("number"),
"position": player.get("position"),
"position_full": player_position,
"is_captain": bool(player.get("is_captain")),
"lineup_type": "starting",
"lineup_type": lineup_type,
"source": "parser",
}
)
for player in away_starting:
player_id = None
player_position = None
if player.get("player_external_id"):
player_id, player_position = get_player_id_by_external_id(player["player_external_id"])
if player_id is None:
player_id = get_player_id_by_name_and_team(
player["player_name"], away_team_id
)
lineup_rows.append(
{
"match_id": match_id,
"team_id": away_team_id,
"player_id": player_id,
"player_name": player["player_name"],
"number": player.get("number"),
"position": player.get("position"),
"position_full": player_position,
"is_captain": bool(player.get("is_captain")),
"lineup_type": "starting",
"source": "parser",
}
)
for player in home_bench:
player_id = None
player_position = None
if player.get("player_external_id"):
player_id, player_position = get_player_id_by_external_id(player["player_external_id"])
if player_id is None:
player_id = get_player_id_by_name_and_team(
player["player_name"], home_team_id
)
lineup_rows.append(
{
"match_id": match_id,
"team_id": home_team_id,
"player_id": player_id,
"player_name": player["player_name"],
"number": player.get("number"),
"position": player.get("position"),
"position_full": player_position,
"is_captain": bool(player.get("is_captain")),
"lineup_type": "bench",
"source": "parser",
}
)
for player in away_bench:
player_id = None
player_position = None
if player.get("player_external_id"):
player_id, player_position = get_player_id_by_external_id(player["player_external_id"])
if player_id is None:
player_id = get_player_id_by_name_and_team(
player["player_name"], away_team_id
)
lineup_rows.append(
{
"match_id": match_id,
"team_id": away_team_id,
"player_id": player_id,
"player_name": player["player_name"],
"number": player.get("number"),
"position": player.get("position"),
"position_full": player_position,
"is_captain": bool(player.get("is_captain")),
"lineup_type": "bench",
"source": "parser",
}
)
append_players(home_starting, home_team_id, "starting")
append_players(away_starting, away_team_id, "starting")
append_players(home_bench, home_team_id, "bench")
append_players(away_bench, away_team_id, "bench")
coach_rows = []
for coach in home_coaches:
coach_id = None
if coach.get("coach_external_id"):
coach_id = get_coach_id_by_external_id(coach["coach_external_id"])
if coach_id is None:
coach_id = get_coach_id_by_name_and_team(coach["coach_name"], home_team_id)
def append_coaches(coaches: list[dict], team_id: int, side: str) -> None:
for coach in coaches:
coach_id = resolve_coach(coach, team_id)
coach_rows.append(
{
"match_id": match_id,
"team_id": home_team_id,
"team_id": team_id,
"side": side,
"coach_id": coach_id,
"coach_name": coach["coach_name"],
"coach_name": coach.get("coach_name") or "",
"role": coach.get("role"),
"source": "parser",
}
)
for coach in away_coaches:
coach_id = None
if coach.get("coach_external_id"):
coach_id = get_coach_id_by_external_id(coach["coach_external_id"])
if coach_id is None:
coach_id = get_coach_id_by_name_and_team(coach["coach_name"], away_team_id)
coach_rows.append(
{
"match_id": match_id,
"team_id": away_team_id,
"coach_id": coach_id,
"coach_name": coach["coach_name"],
"role": coach.get("role"),
"source": "parser",
}
)
append_coaches(home_coaches, home_team_id, "home")
append_coaches(away_coaches, away_team_id, "away")
referee_rows = []
@@ -209,13 +170,15 @@ def sync_match_page(
try:
replace_match_lineups(match_id, lineup_rows)
for row in coach_rows:
row["side"] = "home" if row["team_id"] == home_team_id else "away"
replace_match_coaches(match_id, coach_rows)
replace_match_referees(match_id, referee_rows)
mark_match_parsed(match_external_id)
if created_players_count or created_coaches_count:
print(
f"[parser_game] auto-created for match={match_external_id}: "
f"players={created_players_count}, coaches={created_coaches_count}"
)
except Exception as e:
mark_match_parse_error(match_external_id, str(e))
raise

View File

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

View File

@@ -1069,6 +1069,8 @@ function setEditMode(enabled) {
el.disabled = !editModeEnabled;
}
});
setTourScheduleGlobalEditMode(editModeEnabled);
renderRefereesEditor();
if (editModeEnabled && isGameTabActive()) {
@@ -1099,6 +1101,8 @@ function updateEditModeUI() {
el.disabled = !editModeEnabled;
}
});
setTourScheduleGlobalEditMode(editModeEnabled);
}
function stepExtraTime(step) {
@@ -4064,6 +4068,113 @@ function buildChannelValue(matchId) {
return "";
}
function resetTourScheduleRowEditValues(matchId) {
document.querySelectorAll(`.tour-score-input[data-match-id="${matchId}"]`).forEach((input) => {
input.value = input.dataset.initialValue || "";
});
const statusSelect = document.querySelector(`.tour-status-select[data-match-id="${matchId}"]`);
if (statusSelect) {
statusSelect.value = statusSelect.dataset.initialStatus || "scheduled";
}
}
function setTourScheduleRowEditMode(matchId, enabled, resetValues = false) {
const display = document.querySelector(`[data-schedule-display="${matchId}"]`);
const edit = document.querySelector(`[data-schedule-edit="${matchId}"]`);
const saveActions = document.querySelector(`[data-schedule-save-actions="${matchId}"]`);
if (resetValues) {
resetTourScheduleRowEditValues(matchId);
}
if (display) display.hidden = enabled;
if (edit) edit.hidden = !enabled;
if (saveActions) saveActions.hidden = !enabled;
document
.querySelectorAll(`.tour-score-input[data-match-id="${matchId}"], .tour-status-select[data-match-id="${matchId}"]`)
.forEach((el) => {
el.disabled = !enabled;
});
}
function setTourScheduleGlobalEditMode(enabled) {
document.querySelectorAll("[data-schedule-row]").forEach((row) => {
const matchId = row.dataset.scheduleRow;
if (!matchId) return;
setTourScheduleRowEditMode(matchId, enabled, !enabled);
});
}
function enableTourScheduleEdit(matchId) {
setTourScheduleRowEditMode(matchId, true);
}
function cancelTourScheduleEdit(matchId) {
resetTourScheduleRowEditValues(matchId);
setTourScheduleRowEditMode(matchId, editModeEnabled);
}
function readTourScoreValue(matchId, side) {
const input = document.querySelector(`.tour-score-input[data-match-id="${matchId}"][data-score-side="${side}"]`);
if (!input) return null;
const value = String(input.value || "").trim();
if (value === "") return null;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error("invalid_score");
}
return parsed;
}
async function saveTourScheduleMatch(matchExternalId) {
let homeScore;
let awayScore;
try {
homeScore = readTourScoreValue(matchExternalId, "home");
awayScore = readTourScoreValue(matchExternalId, "away");
} catch (err) {
alert("Счёт должен быть целым числом 0 или больше");
return;
}
if ((homeScore === null) !== (awayScore === null)) {
alert("Заполни оба значения счёта или очисти оба поля");
return;
}
const status = document.querySelector(`.tour-status-select[data-match-id="${matchExternalId}"]`)?.value || "scheduled";
const res = await fetch(`/admin/session/${MATCH_DATA.sessionToken}/schedule/match`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
match_external_id: String(matchExternalId),
home_score: homeScore,
away_score: awayScore,
status
})
});
if (!res.ok) {
let message = "Не удалось сохранить счёт / статус";
try {
const data = await res.json();
message = data.message || data.error || message;
} catch (_) {}
alert(message);
return;
}
window.location.reload();
}
function openClearMatchModal() {
const modal = document.getElementById("clearMatchModal");
if (modal) modal.classList.add("show");
@@ -4078,3 +4189,206 @@ function confirmClearMatch() {
closeClearMatchModal();
clearMatchEvents();
}
// -------------------- Пенальти Кубка России --------------------
let penaltyState = null;
let penaltyRequestInFlight = false;
function penaltyResultMark(result) {
if (result === "scored") return "●";
if (result === "missed") return "×";
return "";
}
function penaltyResultTitle(result) {
if (result === "scored") return "Забил";
if (result === "missed") return "Не забил";
return "Не выбран";
}
function normalizePenaltyRounds(state) {
const maxRounds = Math.max(5, Number(state?.max_rounds || 5));
const inputRounds = Array.isArray(state?.rounds) ? state.rounds : [];
const byNumber = new Map(inputRounds.map(row => [Number(row.number), row]));
const rounds = [];
for (let number = 1; number <= maxRounds; number += 1) {
const row = byNumber.get(number) || { number, home: "", away: "" };
rounds.push({
number,
home: row.home || "",
away: row.away || ""
});
}
return rounds;
}
function renderPenaltyShootout(state) {
penaltyState = state || penaltyState || { max_rounds: 5, rounds: [], totals: { home: 0, away: 0 } };
penaltyState.rounds = normalizePenaltyRounds(penaltyState);
const homeScore = document.getElementById("penaltyHomeScore");
const awayScore = document.getElementById("penaltyAwayScore");
const homeNext = document.getElementById("penaltyHomeNext");
const awayNext = document.getElementById("penaltyAwayNext");
const body = document.getElementById("penaltyRoundsBody");
if (homeScore) homeScore.textContent = penaltyState?.totals?.home ?? 0;
if (awayScore) awayScore.textContent = penaltyState?.totals?.away ?? 0;
if (homeNext) homeNext.textContent = `Следующий удар: ${getNextPenaltyShotNumber("home")}`;
if (awayNext) awayNext.textContent = `Следующий удар: ${getNextPenaltyShotNumber("away")}`;
if (!body) return;
body.innerHTML = penaltyState.rounds.map(row => {
return `
<tr>
<td class="penalty-round-number">${row.number}</td>
<td>${renderPenaltyCell("home", row.number, row.home)}</td>
<td>${renderPenaltyCell("away", row.number, row.away)}</td>
</tr>
`;
}).join("");
}
function renderPenaltyCell(side, shotNumber, result) {
const safeSide = side === "away" ? "away" : "home";
const currentClass = result ? ` ${result}` : " empty";
const scoredActive = result === "scored" ? " active" : "";
const missedActive = result === "missed" ? " active" : "";
const clearDisabled = result ? "" : " disabled";
const mark = penaltyResultMark(result) || "—";
return `
<div class="penalty-cell${currentClass}">
<div class="penalty-mark" title="${escapeHtml(penaltyResultTitle(result))}">${escapeHtml(mark)}</div>
<div class="penalty-cell-actions">
<button type="button" class="penalty-mini-btn scored${scoredActive}" onclick="setPenaltyShot('${safeSide}', ${shotNumber}, 'scored')">Забил</button>
<button type="button" class="penalty-mini-btn missed${missedActive}" onclick="setPenaltyShot('${safeSide}', ${shotNumber}, 'missed')">Не забил</button>
<button type="button" class="penalty-mini-btn clear" onclick="setPenaltyShot('${safeSide}', ${shotNumber}, 'clear')"${clearDisabled}>Очистить</button>
</div>
</div>
`;
}
async function loadPenaltyShootout() {
if (!SESSION_TOKEN || !MATCH_DATA.isRussianCup) return;
const body = document.getElementById("penaltyRoundsBody");
if (!body) return;
try {
const res = await fetch(`/admin/session/${SESSION_TOKEN}/penalties`, { cache: "no-store" });
if (!res.ok) throw new Error("penalty_load_failed");
const data = await res.json();
renderPenaltyShootout(data);
} catch (err) {
console.error("Не удалось загрузить пенальти", err);
body.innerHTML = `<tr><td colspan="3" class="empty-box">Не удалось загрузить серию пенальти.</td></tr>`;
}
}
async function setPenaltyShot(side, shotNumber, result) {
if (!SESSION_TOKEN || penaltyRequestInFlight) return;
penaltyRequestInFlight = true;
try {
const res = await fetch(`/admin/session/${SESSION_TOKEN}/penalties/shot`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ side, shot_number: shotNumber, result })
});
if (!res.ok) throw new Error("penalty_save_failed");
const data = await res.json();
renderPenaltyShootout(data);
} catch (err) {
console.error("Не удалось сохранить пенальти", err);
alert("Не удалось сохранить пенальти");
} finally {
penaltyRequestInFlight = false;
}
}
function getNextPenaltyShotNumber(side) {
const safeSide = side === "away" ? "away" : "home";
const rounds = normalizePenaltyRounds(penaltyState || { max_rounds: 5, rounds: [] });
const emptyRow = rounds.find(row => !row[safeSide]);
if (emptyRow) return emptyRow.number;
return rounds.length + 1;
}
async function setNextPenaltyShot(side, result) {
const nextNumber = getNextPenaltyShotNumber(side);
const currentMax = Math.max(5, Number(penaltyState?.max_rounds || 5));
if (nextNumber > currentMax) {
await addPenaltyRound(false);
}
await setPenaltyShot(side, nextNumber, result);
}
async function addPenaltyRound(showAlert = true) {
if (!SESSION_TOKEN || penaltyRequestInFlight) return;
penaltyRequestInFlight = true;
try {
const res = await fetch(`/admin/session/${SESSION_TOKEN}/penalties/round`, { method: "POST" });
if (!res.ok) throw new Error("penalty_round_failed");
const data = await res.json();
renderPenaltyShootout(data);
} catch (err) {
console.error("Не удалось добавить серию пенальти", err);
if (showAlert) alert("Не удалось добавить серию пенальти");
} finally {
penaltyRequestInFlight = false;
}
}
async function deleteLastPenaltyRound() {
if (!SESSION_TOKEN || penaltyRequestInFlight) return;
const currentMax = Math.max(5, Number(penaltyState?.max_rounds || 5));
if (currentMax <= 5) {
alert("Первые 5 серий нельзя удалить. Можно только очистить данные.");
return;
}
if (!confirm(`Удалить последнюю серию №${currentMax}? Данные ударов этой серии тоже будут удалены.`)) return;
penaltyRequestInFlight = true;
try {
const res = await fetch(`/admin/session/${SESSION_TOKEN}/penalties/round`, { method: "DELETE" });
if (!res.ok) throw new Error("penalty_delete_round_failed");
const data = await res.json();
renderPenaltyShootout(data);
} catch (err) {
console.error("Не удалось удалить последнюю серию пенальти", err);
alert("Не удалось удалить последнюю серию пенальти");
} finally {
penaltyRequestInFlight = false;
}
}
async function clearPenaltyShootout() {
if (!SESSION_TOKEN || penaltyRequestInFlight) return;
if (!confirm("Очистить всю серию пенальти для этого матча?")) return;
penaltyRequestInFlight = true;
try {
const res = await fetch(`/admin/session/${SESSION_TOKEN}/penalties`, { method: "DELETE" });
if (!res.ok) throw new Error("penalty_clear_failed");
const data = await res.json();
renderPenaltyShootout(data);
} catch (err) {
console.error("Не удалось очистить пенальти", err);
alert("Не удалось очистить пенальти");
} finally {
penaltyRequestInFlight = false;
}
}
document.addEventListener("DOMContentLoaded", () => {
if (document.getElementById("penaltyPanel")) {
loadPenaltyShootout();
}
});

View File

@@ -856,7 +856,8 @@ table {
}
.tour-meta-col {
width: 1%;
width: 300px;
min-width: 300px;
white-space: nowrap;
}
@@ -2515,3 +2516,332 @@ body:not(.edit-mode-on) .referee-search {
min-width: 16px;
text-align: center;
}
/* Пенальти Кубка России */
.penalty-panel {
display: flex;
flex-direction: column;
gap: 18px;
}
.penalty-header,
.penalty-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.penalty-header-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
}
.penalty-subtitle,
.penalty-help,
.penalty-next-label {
color: rgba(255, 255, 255, 0.62);
font-size: 13px;
margin-top: 5px;
}
.penalty-scoreboard {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.penalty-team-card {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 18px;
padding: 18px;
background: rgba(255, 255, 255, 0.045);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.penalty-team-card.home {
border-color: rgba(79, 140, 255, 0.28);
}
.penalty-team-card.away {
border-color: rgba(255, 120, 120, 0.25);
}
.penalty-team-name {
color: #fff;
font-size: 16px;
font-weight: 800;
min-height: 22px;
}
.penalty-team-score {
margin-top: 8px;
color: #fff;
font-size: 54px;
line-height: 1;
font-weight: 900;
letter-spacing: -0.04em;
}
.penalty-fast-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 14px;
}
.penalty-fast-btn,
.penalty-mini-btn {
border: 0;
cursor: pointer;
color: #fff;
font-weight: 800;
transition: transform 0.12s ease, opacity 0.12s ease, box-shadow 0.12s ease;
}
.penalty-fast-btn:hover,
.penalty-mini-btn:hover {
transform: translateY(-1px);
opacity: 0.95;
}
.penalty-fast-btn {
min-height: 54px;
border-radius: 14px;
font-size: 18px;
}
.penalty-fast-btn.scored,
.penalty-mini-btn.scored {
background: linear-gradient(135deg, #0e9f6e, #12b981);
box-shadow: 0 10px 28px rgba(18, 185, 129, 0.16);
}
.penalty-fast-btn.missed,
.penalty-mini-btn.missed {
background: linear-gradient(135deg, #b91c1c, #ef4444);
box-shadow: 0 10px 28px rgba(239, 68, 68, 0.16);
}
.penalty-table-wrap {
overflow-x: auto;
}
.penalty-table {
width: 100%;
border-collapse: separate;
border-spacing: 0 8px;
}
.penalty-table th {
color: rgba(255, 255, 255, 0.58);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0 10px 6px;
}
.penalty-table td {
padding: 0 10px;
vertical-align: middle;
}
.penalty-round-number {
width: 70px;
color: rgba(255, 255, 255, 0.72);
text-align: center;
font-size: 18px;
font-weight: 900;
}
.penalty-cell {
display: grid;
grid-template-columns: 52px 1fr;
align-items: center;
gap: 10px;
min-height: 58px;
padding: 8px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.045);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.penalty-cell.scored {
border-color: rgba(18, 185, 129, 0.38);
background: rgba(18, 185, 129, 0.08);
}
.penalty-cell.missed {
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.08);
}
.penalty-mark {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.22);
color: #fff;
font-size: 30px;
line-height: 1;
font-weight: 900;
}
.penalty-cell.scored .penalty-mark {
color: #34d399;
}
.penalty-cell.missed .penalty-mark {
color: #f87171;
}
.penalty-cell-actions {
display: grid;
grid-template-columns: 1fr 1fr minmax(74px, 0.8fr);
gap: 8px;
}
.penalty-mini-btn {
min-height: 38px;
border-radius: 10px;
font-size: 13px;
}
.penalty-mini-btn.active {
outline: 2px solid rgba(255, 255, 255, 0.75);
outline-offset: 2px;
}
.penalty-mini-btn.clear {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.75);
}
.penalty-mini-btn.clear:disabled {
cursor: default;
opacity: 0.35;
transform: none;
}
@media (max-width: 900px) {
.penalty-scoreboard {
grid-template-columns: 1fr;
}
.penalty-header,
.penalty-footer,
.penalty-header-actions {
align-items: stretch;
flex-direction: column;
}
}
/* Расписание тура: компактная ручная правка счёта и статуса */
.tour-actions-col,
.tour-actions-cell {
width: 156px;
text-align: right;
white-space: nowrap;
}
.tour-meta-cell {
min-width: 300px;
}
.tour-score-edit {
min-width: 300px;
}
.tour-edit-line {
display: inline-flex;
align-items: center;
gap: 10px;
flex-wrap: nowrap;
white-space: nowrap;
}
.tour-score-inputs {
display: inline-grid;
grid-template-columns: 52px auto 52px;
gap: 6px;
align-items: center;
width: max-content;
flex: 0 0 auto;
}
.tour-score-input {
width: 52px;
height: 32px;
padding: 4px 6px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
text-align: center;
font-weight: 800;
font-size: 13px;
}
.tour-score-input:disabled,
.tour-status-select:disabled {
opacity: 0.55;
}
.tour-score-separator {
font-weight: 900;
color: rgba(255, 255, 255, 0.78);
}
.tour-status-select {
height: 32px;
width: 128px;
max-width: 128px;
padding: 4px 28px 4px 10px;
flex: 0 0 128px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-size: 12px;
font-weight: 800;
}
.tour-status-select option {
color: #111827;
}
.tour-edit-btn {
min-width: 38px;
min-height: 32px;
padding: 6px 9px;
}
.tour-save-actions {
display: inline-flex;
justify-content: flex-end;
gap: 5px;
flex-wrap: nowrap;
}
.tour-save-btn,
.tour-cancel-btn {
min-height: 32px;
padding: 6px 9px;
font-size: 12px;
}
/* Расписание тура: редактирование включается общим карандашом в шапке */
.tour-edit-only {
display: none;
}
body.edit-mode-on .tour-edit-only {
display: table-cell;
}

View File

@@ -542,19 +542,18 @@
<select class="field-select" id="parserSource" name="parser_source" required>
{% if sources %}
{% for source in sources %}
<option value="{{ source.key }}" data-logo-base-path="{{ source.logo_base_path or '' }}" data-photo-base-path="{{ source.photo_base_path or '' }}" data-channel-logo-base-path="{{ source.channel_logo_base_path or '' }}" {% if source.key == default_parser_source_key %}selected{% endif %}>
<option value="{{ source.key }}" data-logo-base-path="{{ source.logo_base_path or '' }}" data-photo-base-path="{{ source.photo_base_path or '' }}" {% if source.key == default_parser_source_key %}selected{% endif %}>
{{ source.title }}
</option>
{% endfor %}
{% else %}
<option value="SUPERLEAGUE" data-logo-base-path="D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Teams Logos" data-photo-base-path="D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo" data-channel-logo-base-path="D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Лого каналов" selected>Суперлига 2026</option>
<option value="RUSSIAN_CUP" data-logo-base-path="D:\Графика\ФУТБОЛ\Кубок России 2026\Teams Logos" data-photo-base-path="D:\Графика\ФУТБОЛ\Кубок России 2026\Photo" data-channel-logo-base-path="D:\Графика\ФУТБОЛ\ЖФЛ Кубок России 2026\Лого каналов">Кубок России 2026</option>
<option value="SUPERLEAGUE" data-logo-base-path="D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Teams Logos" data-photo-base-path="D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo" selected>Суперлига 2026</option>
<option value="RUSSIAN_CUP" data-logo-base-path="D:\Графика\ФУТБОЛ\Кубок России 2026\Teams Logos" data-photo-base-path="D:\Графика\ФУТБОЛ\Кубок России 2026\Photo">Кубок России 2026</option>
{% endif %}
</select>
<div class="helper-text">Список источников хранится в <b>базе данных</b> и редактируется в настройках проекта.</div>
<div class="helper-text">Путь к логотипам: <b id="selectedLogoBasePath"></b></div>
<div class="helper-text">Путь к фотографиям: <b id="selectedPhotoBasePath"></b></div>
<div class="helper-text">Путь к логотипам каналов: <b id="selectedChannelLogoBasePath"></b></div>
</div>
<div>
@@ -852,7 +851,6 @@
const parserSourceSelect = document.getElementById("parserSource");
const selectedLogoBasePath = document.getElementById("selectedLogoBasePath");
const selectedPhotoBasePath = document.getElementById("selectedPhotoBasePath");
const selectedChannelLogoBasePath = document.getElementById("selectedChannelLogoBasePath");
const openCreateAccountBtn = document.getElementById("openCreateAccountBtn");
const closeCreateAccountBtn = document.getElementById("closeCreateAccountBtn");
const accountModal = document.getElementById("accountModal");
@@ -1002,9 +1000,6 @@
if (selectedPhotoBasePath) {
selectedPhotoBasePath.textContent = option?.dataset?.photoBasePath || "—";
}
if (selectedChannelLogoBasePath) {
selectedChannelLogoBasePath.textContent = option?.dataset?.channelLogoBasePath || "—";
}
}
document.addEventListener("click", (event) => {

View File

@@ -245,6 +245,15 @@
Расписание тура
</a>
{% if is_russian_cup %}
<a
class="tab-link {% if tab == 'penalties' %}active{% endif %}"
href="/admin/session/{{ session[3] }}?tab=penalties"
data-vmix-title="ПЕНАЛЬТИ"
>
Пенальти
</a>
{% else %}
<a
class="tab-link {% if tab == 'standings' %}active{% endif %}"
href="/admin/session/{{ session[3] }}?tab=standings"
@@ -252,6 +261,7 @@
>
Турнирная таблица
</a>
{% endif %}
</div>
{% if tab == 'game' %}
@@ -969,11 +979,15 @@ data-role="{{ p.position or '' }}"
<th class="tour-meta-col">Счёт / статус</th>
<th class="tour-stadium-col">Стадион</th>
<th class="tour-channel-col">Канал</th>
<th class="tour-actions-col tour-edit-only">Правка</th>
</tr>
</thead>
<tbody>
{% for m in tour_schedule %}
<tr>
{% set row_key = m.match_external_id %}
{% set home_score_value = '' if m.home_score is none else m.home_score %}
{% set away_score_value = '' if m.away_score is none else m.away_score %}
<tr class="tour-schedule-row" data-schedule-row="{{ row_key }}">
<td class="tour-date-cell">
{% if m.match_date %}
{{ m.match_date.day }} {{ month_names[m.match_date.month] }}
@@ -991,27 +1005,61 @@ data-role="{{ p.position or '' }}"
</td>
<td class="tour-meta-cell">
<div class="tour-meta-wrap">
{% if m.status == 'finished' and m.home_score is not none
and m.away_score is not none %}
<span class="score-badge"
>{{ m.home_score }} : {{ m.away_score }}</span
>
{% endif %} {% if m.status == 'finished' %}
<div class="tour-meta-wrap tour-meta-display" data-schedule-display="{{ row_key }}">
{% if m.home_score is not none and m.away_score is not none %}
<span class="score-badge">{{ m.home_score }} : {{ m.away_score }}</span>
{% endif %}
{% if m.status == 'finished' %}
<span class="status-badge status-finished">Завершён</span>
{% elif m.status == 'live' %}
<span class="status-badge status-live">Идёт</span>
<span class="status-badge status-live">Live</span>
{% elif m.status == 'scheduled' %}
<span class="status-badge status-scheduled"
>Не начался</span
>
<span class="status-badge status-scheduled">Не начался</span>
{% else %}
<span
class="status-badge status-scheduled"
>{{ m.status or "—" }}</span
>
<span class="status-badge status-scheduled">{{ m.status or "—" }}</span>
{% endif %}
</div>
<div class="tour-score-edit" data-schedule-edit="{{ row_key }}" hidden>
<div class="tour-edit-line">
<div class="tour-score-inputs" aria-label="Счёт">
<input
type="number"
min="0"
class="tour-score-input"
data-match-id="{{ row_key }}"
data-score-side="home"
value="{{ home_score_value }}"
data-initial-value="{{ home_score_value }}"
placeholder="—"
disabled
>
<span class="tour-score-separator">:</span>
<input
type="number"
min="0"
class="tour-score-input"
data-match-id="{{ row_key }}"
data-score-side="away"
value="{{ away_score_value }}"
data-initial-value="{{ away_score_value }}"
placeholder="—"
disabled
>
</div>
<select
class="tour-status-select"
data-match-id="{{ row_key }}"
data-initial-status="{{ m.status or 'scheduled' }}"
disabled
>
<option value="scheduled" {% if m.status == 'scheduled' or not m.status %}selected{% endif %}>Не начался</option>
<option value="live" {% if m.status == 'live' %}selected{% endif %}>Live</option>
<option value="finished" {% if m.status == 'finished' %}selected{% endif %}>Завершён</option>
</select>
</div>
</div>
</td>
<td class="stadium-cell">
@@ -1046,7 +1094,22 @@ data-role="{{ p.position or '' }}"
</label>
</td>
<td class="tour-actions-cell tour-edit-only">
<div class="tour-save-actions" data-schedule-save-actions="{{ row_key }}" hidden>
<button
type="button"
class="btn btn-primary tour-save-btn"
onclick="saveTourScheduleMatch('{{ row_key }}')"
title="Сохранить счёт и статус"
></button>
<button
type="button"
class="btn btn-secondary tour-cancel-btn"
onclick="cancelTourScheduleEdit('{{ row_key }}')"
title="Отмена"
>×</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
@@ -1108,6 +1171,64 @@ data-role="{{ p.position or '' }}"
{% endif %}
</div>
{% elif tab == 'penalties' and is_russian_cup %}
<div class="panel penalty-panel" id="penaltyPanel">
<div class="penalty-header">
<div>
<div class="team-panel-title">Серия пенальти</div>
<div class="penalty-subtitle">Быстро отмечай удары для Кубка России. В vMix всегда уходят 5 строк: 15, затем 610 снова в строки 15.</div>
</div>
<div class="penalty-header-actions">
<button type="button" class="btn btn-secondary" onclick="addPenaltyRound()">+ Добавить серию</button>
<button type="button" class="btn btn-secondary" onclick="deleteLastPenaltyRound()">Удалить последнюю серию</button>
<button type="button" class="btn btn-danger" onclick="clearPenaltyShootout()">Очистить</button>
</div>
</div>
<div class="penalty-scoreboard">
<div class="penalty-team-card home">
<div class="penalty-team-name">{{ session[14] }}</div>
<div class="penalty-team-score" id="penaltyHomeScore">0</div>
<div class="penalty-next-label" id="penaltyHomeNext">Следующий удар: 1</div>
<div class="penalty-fast-actions">
<button type="button" class="penalty-fast-btn scored" onclick="setNextPenaltyShot('home', 'scored')">Забил</button>
<button type="button" class="penalty-fast-btn missed" onclick="setNextPenaltyShot('home', 'missed')">Не забил</button>
</div>
</div>
<div class="penalty-team-card away">
<div class="penalty-team-name">{{ session[18] }}</div>
<div class="penalty-team-score" id="penaltyAwayScore">0</div>
<div class="penalty-next-label" id="penaltyAwayNext">Следующий удар: 1</div>
<div class="penalty-fast-actions">
<button type="button" class="penalty-fast-btn scored" onclick="setNextPenaltyShot('away', 'scored')">Забил</button>
<button type="button" class="penalty-fast-btn missed" onclick="setNextPenaltyShot('away', 'missed')">Не забил</button>
</div>
</div>
</div>
<div class="penalty-table-wrap">
<table class="penalty-table">
<thead>
<tr>
<th>Серия</th>
<th>{{ session[14] }}</th>
<th>{{ session[18] }}</th>
</tr>
</thead>
<tbody id="penaltyRoundsBody">
<tr>
<td colspan="3" class="empty-box">Загрузка серии пенальти…</td>
</tr>
</tbody>
</table>
</div>
<div class="penalty-footer">
<div class="penalty-help">Любой удар можно исправить прямо в таблице: нажми “Забил”, “Не забил” или “Очистить” в нужной ячейке. После 5 ударов продолжай 6-й, 7-й и дальше — для vMix они будут записываться в первые 5 строк нового блока.</div>
</div>
</div>
{% else %}
<div class="panel">
<div class="empty-box">Эта вкладка пока в разработке.</div>
@@ -1132,6 +1253,8 @@ data-role="{{ p.position or '' }}"
},
sessionToken: {{ session[3] | tojson }},
matchId: {{ session[1] | tojson }},
sourceKey: {{ source_key | tojson }},
isRussianCup: {{ is_russian_cup | tojson }},
homeFormations: {{ home_formations | tojson }},
awayFormations: {{ away_formations | tojson }},