diff --git a/app.py b/app.py index a091190..8558427 100644 --- a/app.py +++ b/app.py @@ -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) @@ -1648,6 +1760,62 @@ def update_schedule_channel(session_token: str, payload: MatchChannelPayload): return JSONResponse({"error": str(e)}, status_code=500) finally: 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") @@ -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" diff --git a/repositories/coach_repository.py b/repositories/coach_repository.py index df7d04f..402be54 100644 --- a/repositories/coach_repository.py +++ b/repositories/coach_repository.py @@ -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: diff --git a/repositories/match_penalty_repository.py b/repositories/match_penalty_repository.py new file mode 100644 index 0000000..0a0aa10 --- /dev/null +++ b/repositories/match_penalty_repository.py @@ -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() diff --git a/repositories/match_repository.py b/repositories/match_repository.py index 08dcefa..b68bf5b 100644 --- a/repositories/match_repository.py +++ b/repositories/match_repository.py @@ -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() diff --git a/repositories/player_repository.py b/repositories/player_repository.py index 4df58ca..47329f3 100644 --- a/repositories/player_repository.py +++ b/repositories/player_repository.py @@ -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", diff --git a/services/game_service.py b/services/game_service.py index 11a2c97..b11f2ca 100644 --- a/services/game_service.py +++ b/services/game_service.py @@ -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"]) + 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) - 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) - - - 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": "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( + { + "match_id": match_id, + "team_id": team_id, + "player_id": player_id, + "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": lineup_type, + "source": "parser", + } ) - 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 + def append_coaches(coaches: list[dict], team_id: int, side: str) -> None: + for coach in coaches: + coach_id = resolve_coach(coach, team_id) - if coach.get("coach_external_id"): - coach_id = get_coach_id_by_external_id(coach["coach_external_id"]) + coach_rows.append( + { + "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: - coach_id = get_coach_id_by_name_and_team(coach["coach_name"], home_team_id) - - 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", - } - ) + 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 diff --git a/sql/007_match_penalties.sql b/sql/007_match_penalties.sql new file mode 100644 index 0000000..132e38c --- /dev/null +++ b/sql/007_match_penalties.sql @@ -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) +); diff --git a/static/script.js b/static/script.js index 03891dd..6a95578 100644 --- a/static/script.js +++ b/static/script.js @@ -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 ` + + ${row.number} + ${renderPenaltyCell("home", row.number, row.home)} + ${renderPenaltyCell("away", row.number, row.away)} + + `; + }).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 ` +
+
${escapeHtml(mark)}
+
+ + + +
+
+ `; +} + +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 = `Не удалось загрузить серию пенальти.`; + } +} + +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(); + } +}); diff --git a/static/styles.css b/static/styles.css index f468abe..59479ec 100644 --- a/static/styles.css +++ b/static/styles.css @@ -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; +} diff --git a/templates/admin_db_index.html b/templates/admin_db_index.html index b7cab79..b354f9a 100644 --- a/templates/admin_db_index.html +++ b/templates/admin_db_index.html @@ -542,19 +542,18 @@
Список источников хранится в базе данных и редактируется в настройках проекта.
Путь к логотипам:
Путь к фотографиям:
-
Путь к логотипам каналов:
@@ -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) => { diff --git a/templates/match_workspace.html b/templates/match_workspace.html index 6d41c5c..02acbb4 100644 --- a/templates/match_workspace.html +++ b/templates/match_workspace.html @@ -245,6 +245,15 @@ Расписание тура +{% if is_russian_cup %} + + Пенальти + +{% else %} Турнирная таблица +{% endif %}
{% if tab == 'game' %} @@ -969,11 +979,15 @@ data-role="{{ p.position or '' }}" Счёт / статус Стадион Канал + Правка {% for m in tour_schedule %} - + {% 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 %} + {% if m.match_date %} {{ m.match_date.day }} {{ month_names[m.match_date.month] }} @@ -991,27 +1005,61 @@ data-role="{{ p.position or '' }}" -
- {% if m.status == 'finished' and m.home_score is not none - and m.away_score is not none %} - {{ m.home_score }} : {{ m.away_score }} - {% endif %} {% if m.status == 'finished' %} +
+ {% if m.home_score is not none and m.away_score is not none %} + {{ m.home_score }} : {{ m.away_score }} + {% endif %} + {% if m.status == 'finished' %} Завершён {% elif m.status == 'live' %} - Идёт + Live {% elif m.status == 'scheduled' %} - Не начался + Не начался {% else %} - {{ m.status or "—" }} + {{ m.status or "—" }} {% endif %}
+ + @@ -1046,7 +1094,22 @@ data-role="{{ p.position or '' }}" - + + + {% endfor %} @@ -1108,6 +1171,64 @@ data-role="{{ p.position or '' }}" {% endif %}
+ {% elif tab == 'penalties' and is_russian_cup %} +
+
+
+
Серия пенальти
+
Быстро отмечай удары для Кубка России. В vMix всегда уходят 5 строк: 1–5, затем 6–10 снова в строки 1–5.
+
+
+ + + +
+
+ +
+
+
{{ session[14] }}
+
0
+
Следующий удар: 1
+
+ + +
+
+ +
+
{{ session[18] }}
+
0
+
Следующий удар: 1
+
+ + +
+
+
+ +
+ + + + + + + + + + + + + +
Серия{{ session[14] }}{{ session[18] }}
Загрузка серии пенальти…
+
+ + +
+ {% else %}
Эта вкладка пока в разработке.
@@ -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 }},