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

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

261
app.py
View File

@@ -105,6 +105,15 @@ from repositories.match_event_repository import (
clear_events,
update_event,
)
from repositories.match_penalty_repository import (
ensure_match_penalty_tables,
get_penalty_state,
set_penalty_shot,
delete_penalty_shot,
add_penalty_round,
delete_last_penalty_round,
clear_penalties,
)
from repositories.auth_repository import get_user_by_username
from services.auth_service import (
create_auth_session,
@@ -193,6 +202,7 @@ def start_scheduler():
ensure_project_settings_tables()
ensure_player_photo_enabled_column()
ensure_match_source_key_column()
ensure_match_penalty_tables()
except Exception:
traceback.print_exc()
@@ -212,6 +222,12 @@ class PublishVmixCommandPayload(BaseModel):
meta: dict | None = None
class PenaltyShotPayload(BaseModel):
side: str
shot_number: int = Field(..., ge=1)
result: str
class RegisterVmixClientPayload(BaseModel):
client_id: str
match_id: int | None = None
@@ -739,6 +755,13 @@ def session_workspace(
return RedirectResponse(url="/admin/matches", status_code=303)
match_id = session_row[1]
source_key = resolve_match_source_key(match_id, session_row[21] if len(session_row) > 21 else None)
is_russian_cup = str(source_key or "").upper() == "RUSSIAN_CUP"
if is_russian_cup and tab == "standings":
tab = "penalties"
if (not is_russian_cup) and tab == "penalties":
tab = "game"
home_team_id = session_row[13]
away_team_id = session_row[17]
referees = get_match_referees(match_id)
@@ -776,6 +799,8 @@ def session_workspace(
"home_formations": home_formations,
"away_formations": away_formations,
"current_user": getattr(request.state, "current_user", None),
"source_key": source_key,
"is_russian_cup": is_russian_cup,
"auth_idle_timeout_seconds": 7200,
},
)
@@ -1343,6 +1368,85 @@ def api_delete_event(session_token: str, event_id: int):
return {"ok": True}
@app.get("/admin/session/{session_token}/penalties")
def api_get_penalties(session_token: str):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
return get_penalty_state(
match_id=session_row[1],
home_team_name=session_row[14],
away_team_name=session_row[18],
)
@app.post("/admin/session/{session_token}/penalties/shot")
def api_set_penalty_shot(session_token: str, payload: PenaltyShotPayload):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
result = str(payload.result or "").strip().lower()
try:
if result in {"clear", "empty", "delete", ""}:
delete_penalty_shot(session_row[1], payload.side, payload.shot_number)
else:
set_penalty_shot(session_row[1], payload.side, payload.shot_number, result)
except ValueError as e:
return JSONResponse({"error": str(e)}, status_code=400)
return get_penalty_state(
match_id=session_row[1],
home_team_name=session_row[14],
away_team_name=session_row[18],
)
@app.post("/admin/session/{session_token}/penalties/round")
def api_add_penalty_round(session_token: str):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
add_penalty_round(session_row[1])
return get_penalty_state(
match_id=session_row[1],
home_team_name=session_row[14],
away_team_name=session_row[18],
)
@app.delete("/admin/session/{session_token}/penalties/round")
def api_delete_last_penalty_round(session_token: str):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
delete_last_penalty_round(session_row[1])
return get_penalty_state(
match_id=session_row[1],
home_team_name=session_row[14],
away_team_name=session_row[18],
)
@app.delete("/admin/session/{session_token}/penalties")
def api_clear_penalties(session_token: str):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
clear_penalties(session_row[1])
return get_penalty_state(
match_id=session_row[1],
home_team_name=session_row[14],
away_team_name=session_row[18],
)
@app.get("/admin/session/{session_token}/squad-editor-data")
def api_get_squad_editor_data(session_token: str):
session_row = get_match_session_by_token(session_token)
@@ -1620,6 +1724,14 @@ class MatchChannelPayload(BaseModel):
channel: str = ""
class MatchSchedulePayload(BaseModel):
match_external_id: str
home_score: int | None = None
away_score: int | None = None
live: bool = False
status: str | None = None
@app.post("/admin/session/{session_token}/schedule/channel")
def update_schedule_channel(session_token: str, payload: MatchChannelPayload):
session_row = get_match_session_by_token(session_token)
@@ -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"