все остальные апдейты на Кубок России
This commit is contained in:
261
app.py
261
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"
|
||||
|
||||
Reference in New Issue
Block a user