все остальные апдейты на Кубок России
This commit is contained in:
261
app.py
261
app.py
@@ -105,6 +105,15 @@ from repositories.match_event_repository import (
|
|||||||
clear_events,
|
clear_events,
|
||||||
update_event,
|
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 repositories.auth_repository import get_user_by_username
|
||||||
from services.auth_service import (
|
from services.auth_service import (
|
||||||
create_auth_session,
|
create_auth_session,
|
||||||
@@ -193,6 +202,7 @@ def start_scheduler():
|
|||||||
ensure_project_settings_tables()
|
ensure_project_settings_tables()
|
||||||
ensure_player_photo_enabled_column()
|
ensure_player_photo_enabled_column()
|
||||||
ensure_match_source_key_column()
|
ensure_match_source_key_column()
|
||||||
|
ensure_match_penalty_tables()
|
||||||
except Exception:
|
except Exception:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
@@ -212,6 +222,12 @@ class PublishVmixCommandPayload(BaseModel):
|
|||||||
meta: dict | None = None
|
meta: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PenaltyShotPayload(BaseModel):
|
||||||
|
side: str
|
||||||
|
shot_number: int = Field(..., ge=1)
|
||||||
|
result: str
|
||||||
|
|
||||||
|
|
||||||
class RegisterVmixClientPayload(BaseModel):
|
class RegisterVmixClientPayload(BaseModel):
|
||||||
client_id: str
|
client_id: str
|
||||||
match_id: int | None = None
|
match_id: int | None = None
|
||||||
@@ -739,6 +755,13 @@ def session_workspace(
|
|||||||
return RedirectResponse(url="/admin/matches", status_code=303)
|
return RedirectResponse(url="/admin/matches", status_code=303)
|
||||||
|
|
||||||
match_id = session_row[1]
|
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]
|
home_team_id = session_row[13]
|
||||||
away_team_id = session_row[17]
|
away_team_id = session_row[17]
|
||||||
referees = get_match_referees(match_id)
|
referees = get_match_referees(match_id)
|
||||||
@@ -776,6 +799,8 @@ def session_workspace(
|
|||||||
"home_formations": home_formations,
|
"home_formations": home_formations,
|
||||||
"away_formations": away_formations,
|
"away_formations": away_formations,
|
||||||
"current_user": getattr(request.state, "current_user", None),
|
"current_user": getattr(request.state, "current_user", None),
|
||||||
|
"source_key": source_key,
|
||||||
|
"is_russian_cup": is_russian_cup,
|
||||||
"auth_idle_timeout_seconds": 7200,
|
"auth_idle_timeout_seconds": 7200,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1343,6 +1368,85 @@ def api_delete_event(session_token: str, event_id: int):
|
|||||||
return {"ok": True}
|
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")
|
@app.get("/admin/session/{session_token}/squad-editor-data")
|
||||||
def api_get_squad_editor_data(session_token: str):
|
def api_get_squad_editor_data(session_token: str):
|
||||||
session_row = get_match_session_by_token(session_token)
|
session_row = get_match_session_by_token(session_token)
|
||||||
@@ -1620,6 +1724,14 @@ class MatchChannelPayload(BaseModel):
|
|||||||
channel: str = ""
|
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")
|
@app.post("/admin/session/{session_token}/schedule/channel")
|
||||||
def update_schedule_channel(session_token: str, payload: MatchChannelPayload):
|
def update_schedule_channel(session_token: str, payload: MatchChannelPayload):
|
||||||
session_row = get_match_session_by_token(session_token)
|
session_row = get_match_session_by_token(session_token)
|
||||||
@@ -1650,6 +1762,62 @@ def update_schedule_channel(session_token: str, payload: MatchChannelPayload):
|
|||||||
conn.close()
|
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")
|
@app.get("/vmix/session/{session_token}/schedule")
|
||||||
def vmix_schedule(session_token: str):
|
def vmix_schedule(session_token: str):
|
||||||
session_row = get_match_session_by_token(session_token)
|
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)
|
row = get_vmix_scoreboard_info(session_row)
|
||||||
if not row:
|
if not row:
|
||||||
return JSONResponse({"error": "data_not_found"}, status_code=404)
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
"home_score": row[0],
|
"home_score": row[0],
|
||||||
"away_score": row[1],
|
"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_1": row[2],
|
||||||
"red_home_2": row[3],
|
"red_home_2": row[3],
|
||||||
"red_home_3": row[4],
|
"red_home_3": row[4],
|
||||||
@@ -1834,6 +2011,88 @@ def vmix_match_events(session_token: str):
|
|||||||
return new_events
|
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")
|
@app.websocket("/ws/vmix-client")
|
||||||
async def ws_vmix_client(
|
async def ws_vmix_client(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
@@ -2300,14 +2559,12 @@ def render_admin_db_index(
|
|||||||
"title": "Суперлига 2026",
|
"title": "Суперлига 2026",
|
||||||
"logo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Teams Logos",
|
"logo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Teams Logos",
|
||||||
"photo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo",
|
"photo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo",
|
||||||
"channel_logo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Лого каналов",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "RUSSIAN_CUP",
|
"key": "RUSSIAN_CUP",
|
||||||
"title": "Кубок России 2026",
|
"title": "Кубок России 2026",
|
||||||
"logo_base_path": r"D:\Графика\ФУТБОЛ\Кубок России 2026\Teams Logos",
|
"logo_base_path": r"D:\Графика\ФУТБОЛ\Кубок России 2026\Teams Logos",
|
||||||
"photo_base_path": r"D:\Графика\ФУТБОЛ\Кубок России 2026\Photo",
|
"photo_base_path": r"D:\Графика\ФУТБОЛ\Кубок России 2026\Photo",
|
||||||
"channel_logo_base_path": r"D:\Графика\ФУТБОЛ\ЖФЛ Кубок России 2026\Лого каналов",
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
default_parser_source_key = "SUPERLEAGUE"
|
default_parser_source_key = "SUPERLEAGUE"
|
||||||
|
|||||||
@@ -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]:
|
def search_coaches_for_admin(q: str = "") -> list[dict]:
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
|
|||||||
295
repositories/match_penalty_repository.py
Normal file
295
repositories/match_penalty_repository.py
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
from db import get_connection
|
||||||
|
|
||||||
|
|
||||||
|
VALID_SIDES = {"home", "away"}
|
||||||
|
VALID_RESULTS = {"scored", "missed"}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_match_penalty_tables() -> None:
|
||||||
|
"""Создает таблицы для серии пенальти."""
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS match_penalty_settings (
|
||||||
|
match_id INTEGER PRIMARY KEY REFERENCES matches(id) ON DELETE CASCADE,
|
||||||
|
max_rounds INTEGER NOT NULL DEFAULT 5,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS match_penalties (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
match_id INTEGER NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
|
||||||
|
side VARCHAR(10) NOT NULL CHECK (side IN ('home', 'away')),
|
||||||
|
shot_number INTEGER NOT NULL CHECK (shot_number > 0),
|
||||||
|
result VARCHAR(20) NOT NULL CHECK (result IN ('scored', 'missed')),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE (match_id, side, shot_number)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_side(side: str) -> str:
|
||||||
|
side = str(side or "").strip().lower()
|
||||||
|
if side not in VALID_SIDES:
|
||||||
|
raise ValueError("Некорректная сторона пенальти")
|
||||||
|
return side
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_result(result: str) -> str:
|
||||||
|
result = str(result or "").strip().lower()
|
||||||
|
if result not in VALID_RESULTS:
|
||||||
|
raise ValueError("Некорректный результат пенальти")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_penalty_rounds(cur, match_id: int, max_rounds: int) -> None:
|
||||||
|
safe_rounds = max(5, int(max_rounds or 5))
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO match_penalty_settings (match_id, max_rounds, created_at, updated_at)
|
||||||
|
VALUES (%s, %s, NOW(), NOW())
|
||||||
|
ON CONFLICT (match_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
max_rounds = GREATEST(match_penalty_settings.max_rounds, EXCLUDED.max_rounds),
|
||||||
|
updated_at = NOW();
|
||||||
|
""",
|
||||||
|
(match_id, safe_rounds),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_penalty_state(match_id: int, home_team_name: str = "", away_team_name: str = "") -> dict:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(max_rounds, 5)
|
||||||
|
FROM match_penalty_settings
|
||||||
|
WHERE match_id = %s;
|
||||||
|
""",
|
||||||
|
(match_id,),
|
||||||
|
)
|
||||||
|
settings_row = cur.fetchone()
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT side, shot_number, result
|
||||||
|
FROM match_penalties
|
||||||
|
WHERE match_id = %s
|
||||||
|
ORDER BY shot_number, side;
|
||||||
|
""",
|
||||||
|
(match_id,),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
max_rounds = int(settings_row[0]) if settings_row else 5
|
||||||
|
for _, shot_number, _ in rows:
|
||||||
|
max_rounds = max(max_rounds, int(shot_number or 0), 5)
|
||||||
|
|
||||||
|
shots_map = {
|
||||||
|
(str(side), int(shot_number)): str(result)
|
||||||
|
for side, shot_number, result in rows
|
||||||
|
}
|
||||||
|
|
||||||
|
rounds = []
|
||||||
|
totals = {"home": 0, "away": 0}
|
||||||
|
completed = {"home": 0, "away": 0}
|
||||||
|
|
||||||
|
for number in range(1, max_rounds + 1):
|
||||||
|
row = {"number": number}
|
||||||
|
for side in ("home", "away"):
|
||||||
|
result = shots_map.get((side, number), "")
|
||||||
|
if result:
|
||||||
|
completed[side] += 1
|
||||||
|
if result == "scored":
|
||||||
|
totals[side] += 1
|
||||||
|
row[side] = result
|
||||||
|
rounds.append(row)
|
||||||
|
|
||||||
|
shots = [
|
||||||
|
{"side": str(side), "shot_number": int(shot_number), "result": str(result)}
|
||||||
|
for side, shot_number, result in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"match_id": match_id,
|
||||||
|
"home_team": home_team_name or "Хозяева",
|
||||||
|
"away_team": away_team_name or "Гости",
|
||||||
|
"max_rounds": max_rounds,
|
||||||
|
"totals": totals,
|
||||||
|
"completed": completed,
|
||||||
|
"rounds": rounds,
|
||||||
|
"shots": shots,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_penalty_shot(match_id: int, side: str, shot_number: int, result: str) -> None:
|
||||||
|
side = _normalize_side(side)
|
||||||
|
result = _normalize_result(result)
|
||||||
|
safe_shot_number = max(1, int(shot_number or 1))
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
_ensure_penalty_rounds(cur, match_id, safe_shot_number)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO match_penalties (match_id, side, shot_number, result, created_at, updated_at)
|
||||||
|
VALUES (%s, %s, %s, %s, NOW(), NOW())
|
||||||
|
ON CONFLICT (match_id, side, shot_number)
|
||||||
|
DO UPDATE SET
|
||||||
|
result = EXCLUDED.result,
|
||||||
|
updated_at = NOW();
|
||||||
|
""",
|
||||||
|
(match_id, side, safe_shot_number, result),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_penalty_shot(match_id: int, side: str, shot_number: int) -> None:
|
||||||
|
side = _normalize_side(side)
|
||||||
|
safe_shot_number = max(1, int(shot_number or 1))
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM match_penalties
|
||||||
|
WHERE match_id = %s AND side = %s AND shot_number = %s;
|
||||||
|
""",
|
||||||
|
(match_id, side, safe_shot_number),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def add_penalty_round(match_id: int) -> int:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO match_penalty_settings (match_id, max_rounds, created_at, updated_at)
|
||||||
|
VALUES (%s, 6, NOW(), NOW())
|
||||||
|
ON CONFLICT (match_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
max_rounds = GREATEST(match_penalty_settings.max_rounds + 1, 6),
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING max_rounds;
|
||||||
|
""",
|
||||||
|
(match_id,),
|
||||||
|
)
|
||||||
|
max_rounds = int(cur.fetchone()[0])
|
||||||
|
conn.commit()
|
||||||
|
return max_rounds
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_last_penalty_round(match_id: int) -> int:
|
||||||
|
"""Удаляет последнюю добавленную строку серии пенальти и ее данные.
|
||||||
|
|
||||||
|
Первые 5 строк считаются базовыми и не удаляются. Если добавлена 6-я,
|
||||||
|
7-я и т.д. строка, удаляется самая последняя строка вместе с ударами
|
||||||
|
обеих команд в этой строке.
|
||||||
|
"""
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(max_rounds, 5)
|
||||||
|
FROM match_penalty_settings
|
||||||
|
WHERE match_id = %s;
|
||||||
|
""",
|
||||||
|
(match_id,),
|
||||||
|
)
|
||||||
|
settings_row = cur.fetchone()
|
||||||
|
settings_max = int(settings_row[0]) if settings_row else 5
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(MAX(shot_number), 0)
|
||||||
|
FROM match_penalties
|
||||||
|
WHERE match_id = %s;
|
||||||
|
""",
|
||||||
|
(match_id,),
|
||||||
|
)
|
||||||
|
shots_max = int(cur.fetchone()[0] or 0)
|
||||||
|
|
||||||
|
current_max = max(5, settings_max, shots_max)
|
||||||
|
if current_max <= 5:
|
||||||
|
_ensure_penalty_rounds(cur, match_id, 5)
|
||||||
|
conn.commit()
|
||||||
|
return 5
|
||||||
|
|
||||||
|
new_max = max(5, current_max - 1)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM match_penalties
|
||||||
|
WHERE match_id = %s AND shot_number = %s;
|
||||||
|
""",
|
||||||
|
(match_id, current_max),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO match_penalty_settings (match_id, max_rounds, created_at, updated_at)
|
||||||
|
VALUES (%s, %s, NOW(), NOW())
|
||||||
|
ON CONFLICT (match_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
max_rounds = EXCLUDED.max_rounds,
|
||||||
|
updated_at = NOW();
|
||||||
|
""",
|
||||||
|
(match_id, new_max),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return new_max
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def clear_penalties(match_id: int) -> None:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("DELETE FROM match_penalties WHERE match_id = %s;", (match_id,))
|
||||||
|
cur.execute("DELETE FROM match_penalty_settings WHERE match_id = %s;", (match_id,))
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
@@ -355,7 +355,7 @@ def get_tour_schedule_by_match_id(match_id: int) -> list[dict]:
|
|||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT m.tour, m.season, m.source_key
|
SELECT m.tour
|
||||||
FROM matches m
|
FROM matches m
|
||||||
WHERE m.id = %s
|
WHERE m.id = %s
|
||||||
""",
|
""",
|
||||||
@@ -366,8 +366,6 @@ def get_tour_schedule_by_match_id(match_id: int) -> list[dict]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
tour = row[0]
|
tour = row[0]
|
||||||
season = row[1]
|
|
||||||
source_key = resolve_match_source_key(match_id, row[2] if len(row) > 2 else None)
|
|
||||||
|
|
||||||
cur.execute(
|
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 ht ON ht.id = m.home_team_id
|
||||||
LEFT JOIN teams at ON at.id = m.away_team_id
|
LEFT JOIN teams at ON at.id = m.away_team_id
|
||||||
WHERE m.tour = %s
|
WHERE m.tour = %s
|
||||||
AND (%s IS NULL OR m.season = %s)
|
|
||||||
AND COALESCE(NULLIF(m.source_key, ''), %s) = %s
|
|
||||||
ORDER BY
|
ORDER BY
|
||||||
m.match_date NULLS LAST,
|
m.match_date NULLS LAST,
|
||||||
m.id
|
m.id
|
||||||
""",
|
""",
|
||||||
(tour, season, season, source_key, source_key),
|
(tour,),
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
|||||||
@@ -222,6 +222,104 @@ def get_player_id_by_external_id(external_id: str) -> int | None:
|
|||||||
conn.close()
|
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 = {
|
PLAYER_ADMIN_SORT_COLUMNS = {
|
||||||
"id": "p.id",
|
"id": "p.id",
|
||||||
"full_name": "p.full_name",
|
"full_name": "p.full_name",
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ from repositories.match_repository import (
|
|||||||
from repositories.player_repository import (
|
from repositories.player_repository import (
|
||||||
get_player_id_by_name_and_team,
|
get_player_id_by_name_and_team,
|
||||||
get_player_id_by_external_id,
|
get_player_id_by_external_id,
|
||||||
|
create_player_from_lineup,
|
||||||
)
|
)
|
||||||
from repositories.coach_repository import (
|
from repositories.coach_repository import (
|
||||||
get_coach_id_by_name_and_team,
|
get_coach_id_by_name_and_team,
|
||||||
get_coach_id_by_external_id,
|
get_coach_id_by_external_id,
|
||||||
|
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, save_match_lineup_for_editor
|
||||||
@@ -35,158 +37,117 @@ def sync_match_page(
|
|||||||
match_id, _, home_team_id, away_team_id = match_row
|
match_id, _, home_team_id, away_team_id = match_row
|
||||||
clear_match_squad_data(match_id)
|
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 = []
|
lineup_rows = []
|
||||||
for player in home_starting:
|
|
||||||
player_id = None
|
|
||||||
player_position = None
|
|
||||||
|
|
||||||
if player.get("player_external_id"):
|
def append_players(players: list[dict], team_id: int, lineup_type: str) -> None:
|
||||||
player_id, player_position = get_player_id_by_external_id(player["player_external_id"])
|
for player in players:
|
||||||
|
player_id, player_position = resolve_player(player, team_id)
|
||||||
|
|
||||||
if player_id is None:
|
lineup_rows.append(
|
||||||
player_id = get_player_id_by_name_and_team(
|
{
|
||||||
player["player_name"], home_team_id
|
"match_id": match_id,
|
||||||
)
|
"team_id": team_id,
|
||||||
print(player_id, player_position)
|
"player_id": player_id,
|
||||||
|
"player_name": player.get("player_name") or "",
|
||||||
|
"number": player.get("number"),
|
||||||
lineup_rows.append(
|
"position": player.get("position"),
|
||||||
{
|
"position_full": player_position,
|
||||||
"match_id": match_id,
|
"is_captain": bool(player.get("is_captain")),
|
||||||
"team_id": home_team_id,
|
"lineup_type": lineup_type,
|
||||||
"player_id": player_id,
|
"source": "parser",
|
||||||
"player_name": player["player_name"],
|
}
|
||||||
"number": player.get("number"),
|
|
||||||
"position": player.get("position"),
|
|
||||||
"position_full": player_position,
|
|
||||||
"is_captain": bool(player.get("is_captain")),
|
|
||||||
"lineup_type": "starting",
|
|
||||||
"source": "parser",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
for player in away_starting:
|
|
||||||
player_id = None
|
|
||||||
player_position = None
|
|
||||||
|
|
||||||
if player.get("player_external_id"):
|
|
||||||
player_id, player_position = get_player_id_by_external_id(player["player_external_id"])
|
|
||||||
|
|
||||||
if player_id is None:
|
|
||||||
player_id = get_player_id_by_name_and_team(
|
|
||||||
player["player_name"], away_team_id
|
|
||||||
)
|
)
|
||||||
|
|
||||||
lineup_rows.append(
|
append_players(home_starting, home_team_id, "starting")
|
||||||
{
|
append_players(away_starting, away_team_id, "starting")
|
||||||
"match_id": match_id,
|
append_players(home_bench, home_team_id, "bench")
|
||||||
"team_id": away_team_id,
|
append_players(away_bench, away_team_id, "bench")
|
||||||
"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",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
coach_rows = []
|
coach_rows = []
|
||||||
|
|
||||||
for coach in home_coaches:
|
def append_coaches(coaches: list[dict], team_id: int, side: str) -> None:
|
||||||
coach_id = None
|
for coach in coaches:
|
||||||
|
coach_id = resolve_coach(coach, team_id)
|
||||||
|
|
||||||
if coach.get("coach_external_id"):
|
coach_rows.append(
|
||||||
coach_id = get_coach_id_by_external_id(coach["coach_external_id"])
|
{
|
||||||
|
"match_id": match_id,
|
||||||
|
"team_id": team_id,
|
||||||
|
"side": side,
|
||||||
|
"coach_id": coach_id,
|
||||||
|
"coach_name": coach.get("coach_name") or "",
|
||||||
|
"role": coach.get("role"),
|
||||||
|
"source": "parser",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if coach_id is None:
|
append_coaches(home_coaches, home_team_id, "home")
|
||||||
coach_id = get_coach_id_by_name_and_team(coach["coach_name"], home_team_id)
|
append_coaches(away_coaches, away_team_id, "away")
|
||||||
|
|
||||||
coach_rows.append(
|
|
||||||
{
|
|
||||||
"match_id": match_id,
|
|
||||||
"team_id": home_team_id,
|
|
||||||
"coach_id": coach_id,
|
|
||||||
"coach_name": coach["coach_name"],
|
|
||||||
"role": coach.get("role"),
|
|
||||||
"source": "parser",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
for coach in away_coaches:
|
|
||||||
coach_id = None
|
|
||||||
|
|
||||||
if coach.get("coach_external_id"):
|
|
||||||
coach_id = get_coach_id_by_external_id(coach["coach_external_id"])
|
|
||||||
|
|
||||||
if coach_id is None:
|
|
||||||
coach_id = get_coach_id_by_name_and_team(coach["coach_name"], away_team_id)
|
|
||||||
|
|
||||||
coach_rows.append(
|
|
||||||
{
|
|
||||||
"match_id": match_id,
|
|
||||||
"team_id": away_team_id,
|
|
||||||
"coach_id": coach_id,
|
|
||||||
"coach_name": coach["coach_name"],
|
|
||||||
"role": coach.get("role"),
|
|
||||||
"source": "parser",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
referee_rows = []
|
referee_rows = []
|
||||||
|
|
||||||
@@ -209,13 +170,15 @@ def sync_match_page(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
replace_match_lineups(match_id, lineup_rows)
|
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_coaches(match_id, coach_rows)
|
||||||
|
|
||||||
|
|
||||||
replace_match_referees(match_id, referee_rows)
|
replace_match_referees(match_id, referee_rows)
|
||||||
mark_match_parsed(match_external_id)
|
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:
|
except Exception as e:
|
||||||
mark_match_parse_error(match_external_id, str(e))
|
mark_match_parse_error(match_external_id, str(e))
|
||||||
raise
|
raise
|
||||||
|
|||||||
17
sql/007_match_penalties.sql
Normal file
17
sql/007_match_penalties.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS match_penalty_settings (
|
||||||
|
match_id INTEGER PRIMARY KEY REFERENCES matches(id) ON DELETE CASCADE,
|
||||||
|
max_rounds INTEGER NOT NULL DEFAULT 5,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS match_penalties (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
match_id INTEGER NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
|
||||||
|
side VARCHAR(10) NOT NULL CHECK (side IN ('home', 'away')),
|
||||||
|
shot_number INTEGER NOT NULL CHECK (shot_number > 0),
|
||||||
|
result VARCHAR(20) NOT NULL CHECK (result IN ('scored', 'missed')),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE (match_id, side, shot_number)
|
||||||
|
);
|
||||||
314
static/script.js
314
static/script.js
@@ -1069,6 +1069,8 @@ function setEditMode(enabled) {
|
|||||||
el.disabled = !editModeEnabled;
|
el.disabled = !editModeEnabled;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setTourScheduleGlobalEditMode(editModeEnabled);
|
||||||
renderRefereesEditor();
|
renderRefereesEditor();
|
||||||
|
|
||||||
if (editModeEnabled && isGameTabActive()) {
|
if (editModeEnabled && isGameTabActive()) {
|
||||||
@@ -1099,6 +1101,8 @@ function updateEditModeUI() {
|
|||||||
el.disabled = !editModeEnabled;
|
el.disabled = !editModeEnabled;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setTourScheduleGlobalEditMode(editModeEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stepExtraTime(step) {
|
function stepExtraTime(step) {
|
||||||
@@ -4064,6 +4068,113 @@ function buildChannelValue(matchId) {
|
|||||||
return "";
|
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() {
|
function openClearMatchModal() {
|
||||||
const modal = document.getElementById("clearMatchModal");
|
const modal = document.getElementById("clearMatchModal");
|
||||||
if (modal) modal.classList.add("show");
|
if (modal) modal.classList.add("show");
|
||||||
@@ -4078,3 +4189,206 @@ function confirmClearMatch() {
|
|||||||
closeClearMatchModal();
|
closeClearMatchModal();
|
||||||
clearMatchEvents();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -856,7 +856,8 @@ table {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tour-meta-col {
|
.tour-meta-col {
|
||||||
width: 1%;
|
width: 300px;
|
||||||
|
min-width: 300px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2515,3 +2516,332 @@ body:not(.edit-mode-on) .referee-search {
|
|||||||
min-width: 16px;
|
min-width: 16px;
|
||||||
text-align: center;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -542,19 +542,18 @@
|
|||||||
<select class="field-select" id="parserSource" name="parser_source" required>
|
<select class="field-select" id="parserSource" name="parser_source" required>
|
||||||
{% if sources %}
|
{% if sources %}
|
||||||
{% for source in 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 }}
|
{{ source.title }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% 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="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" data-channel-logo-base-path="D:\Графика\ФУТБОЛ\ЖФЛ Кубок России 2026\Лого каналов">Кубок России 2026</option>
|
<option value="RUSSIAN_CUP" data-logo-base-path="D:\Графика\ФУТБОЛ\Кубок России 2026\Teams Logos" data-photo-base-path="D:\Графика\ФУТБОЛ\Кубок России 2026\Photo">Кубок России 2026</option>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</select>
|
</select>
|
||||||
<div class="helper-text">Список источников хранится в <b>базе данных</b> и редактируется в настройках проекта.</div>
|
<div class="helper-text">Список источников хранится в <b>базе данных</b> и редактируется в настройках проекта.</div>
|
||||||
<div class="helper-text">Путь к логотипам: <b id="selectedLogoBasePath">—</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="selectedPhotoBasePath">—</b></div>
|
||||||
<div class="helper-text">Путь к логотипам каналов: <b id="selectedChannelLogoBasePath">—</b></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -852,7 +851,6 @@
|
|||||||
const parserSourceSelect = document.getElementById("parserSource");
|
const parserSourceSelect = document.getElementById("parserSource");
|
||||||
const selectedLogoBasePath = document.getElementById("selectedLogoBasePath");
|
const selectedLogoBasePath = document.getElementById("selectedLogoBasePath");
|
||||||
const selectedPhotoBasePath = document.getElementById("selectedPhotoBasePath");
|
const selectedPhotoBasePath = document.getElementById("selectedPhotoBasePath");
|
||||||
const selectedChannelLogoBasePath = document.getElementById("selectedChannelLogoBasePath");
|
|
||||||
const openCreateAccountBtn = document.getElementById("openCreateAccountBtn");
|
const openCreateAccountBtn = document.getElementById("openCreateAccountBtn");
|
||||||
const closeCreateAccountBtn = document.getElementById("closeCreateAccountBtn");
|
const closeCreateAccountBtn = document.getElementById("closeCreateAccountBtn");
|
||||||
const accountModal = document.getElementById("accountModal");
|
const accountModal = document.getElementById("accountModal");
|
||||||
@@ -1002,9 +1000,6 @@
|
|||||||
if (selectedPhotoBasePath) {
|
if (selectedPhotoBasePath) {
|
||||||
selectedPhotoBasePath.textContent = option?.dataset?.photoBasePath || "—";
|
selectedPhotoBasePath.textContent = option?.dataset?.photoBasePath || "—";
|
||||||
}
|
}
|
||||||
if (selectedChannelLogoBasePath) {
|
|
||||||
selectedChannelLogoBasePath.textContent = option?.dataset?.channelLogoBasePath || "—";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("click", (event) => {
|
document.addEventListener("click", (event) => {
|
||||||
|
|||||||
@@ -245,6 +245,15 @@
|
|||||||
Расписание тура
|
Расписание тура
|
||||||
</a>
|
</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
|
<a
|
||||||
class="tab-link {% if tab == 'standings' %}active{% endif %}"
|
class="tab-link {% if tab == 'standings' %}active{% endif %}"
|
||||||
href="/admin/session/{{ session[3] }}?tab=standings"
|
href="/admin/session/{{ session[3] }}?tab=standings"
|
||||||
@@ -252,6 +261,7 @@
|
|||||||
>
|
>
|
||||||
Турнирная таблица
|
Турнирная таблица
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if tab == 'game' %}
|
{% if tab == 'game' %}
|
||||||
@@ -969,11 +979,15 @@ data-role="{{ p.position or '' }}"
|
|||||||
<th class="tour-meta-col">Счёт / статус</th>
|
<th class="tour-meta-col">Счёт / статус</th>
|
||||||
<th class="tour-stadium-col">Стадион</th>
|
<th class="tour-stadium-col">Стадион</th>
|
||||||
<th class="tour-channel-col">Канал</th>
|
<th class="tour-channel-col">Канал</th>
|
||||||
|
<th class="tour-actions-col tour-edit-only">Правка</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for m in tour_schedule %}
|
{% 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">
|
<td class="tour-date-cell">
|
||||||
{% if m.match_date %}
|
{% if m.match_date %}
|
||||||
{{ m.match_date.day }} {{ month_names[m.match_date.month] }}
|
{{ m.match_date.day }} {{ month_names[m.match_date.month] }}
|
||||||
@@ -991,27 +1005,61 @@ data-role="{{ p.position or '' }}"
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="tour-meta-cell">
|
<td class="tour-meta-cell">
|
||||||
<div class="tour-meta-wrap">
|
<div class="tour-meta-wrap tour-meta-display" data-schedule-display="{{ row_key }}">
|
||||||
{% if m.status == 'finished' and m.home_score is not none
|
{% if m.home_score is not none and m.away_score is not none %}
|
||||||
and m.away_score is not none %}
|
<span class="score-badge">{{ m.home_score }} : {{ m.away_score }}</span>
|
||||||
<span class="score-badge"
|
{% endif %}
|
||||||
>{{ m.home_score }} : {{ m.away_score }}</span
|
{% if m.status == 'finished' %}
|
||||||
>
|
|
||||||
{% endif %} {% if m.status == 'finished' %}
|
|
||||||
<span class="status-badge status-finished">Завершён</span>
|
<span class="status-badge status-finished">Завершён</span>
|
||||||
{% elif m.status == 'live' %}
|
{% elif m.status == 'live' %}
|
||||||
<span class="status-badge status-live">Идёт</span>
|
<span class="status-badge status-live">Live</span>
|
||||||
{% elif m.status == 'scheduled' %}
|
{% elif m.status == 'scheduled' %}
|
||||||
<span class="status-badge status-scheduled"
|
<span class="status-badge status-scheduled">Не начался</span>
|
||||||
>Не начался</span
|
|
||||||
>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<span
|
<span class="status-badge status-scheduled">{{ m.status or "—" }}</span>
|
||||||
class="status-badge status-scheduled"
|
|
||||||
>{{ m.status or "—" }}</span
|
|
||||||
>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<td class="stadium-cell">
|
<td class="stadium-cell">
|
||||||
@@ -1046,7 +1094,22 @@ data-role="{{ p.position or '' }}"
|
|||||||
</label>
|
</label>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -1108,6 +1171,64 @@ data-role="{{ p.position or '' }}"
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% elif tab == 'penalties' and is_russian_cup %}
|
||||||
|
<div class="panel penalty-panel" id="penaltyPanel">
|
||||||
|
<div class="penalty-header">
|
||||||
|
<div>
|
||||||
|
<div class="team-panel-title">Серия пенальти</div>
|
||||||
|
<div class="penalty-subtitle">Быстро отмечай удары для Кубка России. В vMix всегда уходят 5 строк: 1–5, затем 6–10 снова в строки 1–5.</div>
|
||||||
|
</div>
|
||||||
|
<div class="penalty-header-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="addPenaltyRound()">+ Добавить серию</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="deleteLastPenaltyRound()">Удалить последнюю серию</button>
|
||||||
|
<button type="button" class="btn btn-danger" onclick="clearPenaltyShootout()">Очистить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="penalty-scoreboard">
|
||||||
|
<div class="penalty-team-card home">
|
||||||
|
<div class="penalty-team-name">{{ session[14] }}</div>
|
||||||
|
<div class="penalty-team-score" id="penaltyHomeScore">0</div>
|
||||||
|
<div class="penalty-next-label" id="penaltyHomeNext">Следующий удар: 1</div>
|
||||||
|
<div class="penalty-fast-actions">
|
||||||
|
<button type="button" class="penalty-fast-btn scored" onclick="setNextPenaltyShot('home', 'scored')">Забил</button>
|
||||||
|
<button type="button" class="penalty-fast-btn missed" onclick="setNextPenaltyShot('home', 'missed')">Не забил</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="penalty-team-card away">
|
||||||
|
<div class="penalty-team-name">{{ session[18] }}</div>
|
||||||
|
<div class="penalty-team-score" id="penaltyAwayScore">0</div>
|
||||||
|
<div class="penalty-next-label" id="penaltyAwayNext">Следующий удар: 1</div>
|
||||||
|
<div class="penalty-fast-actions">
|
||||||
|
<button type="button" class="penalty-fast-btn scored" onclick="setNextPenaltyShot('away', 'scored')">Забил</button>
|
||||||
|
<button type="button" class="penalty-fast-btn missed" onclick="setNextPenaltyShot('away', 'missed')">Не забил</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="penalty-table-wrap">
|
||||||
|
<table class="penalty-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Серия</th>
|
||||||
|
<th>{{ session[14] }}</th>
|
||||||
|
<th>{{ session[18] }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="penaltyRoundsBody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="empty-box">Загрузка серии пенальти…</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="penalty-footer">
|
||||||
|
<div class="penalty-help">Любой удар можно исправить прямо в таблице: нажми “Забил”, “Не забил” или “Очистить” в нужной ячейке. После 5 ударов продолжай 6-й, 7-й и дальше — для vMix они будут записываться в первые 5 строк нового блока.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="empty-box">Эта вкладка пока в разработке.</div>
|
<div class="empty-box">Эта вкладка пока в разработке.</div>
|
||||||
@@ -1132,6 +1253,8 @@ data-role="{{ p.position or '' }}"
|
|||||||
},
|
},
|
||||||
sessionToken: {{ session[3] | tojson }},
|
sessionToken: {{ session[3] | tojson }},
|
||||||
matchId: {{ session[1] | tojson }},
|
matchId: {{ session[1] | tojson }},
|
||||||
|
sourceKey: {{ source_key | tojson }},
|
||||||
|
isRussianCup: {{ is_russian_cup | tojson }},
|
||||||
|
|
||||||
homeFormations: {{ home_formations | tojson }},
|
homeFormations: {{ home_formations | tojson }},
|
||||||
awayFormations: {{ away_formations | tojson }},
|
awayFormations: {{ away_formations | tojson }},
|
||||||
|
|||||||
Reference in New Issue
Block a user