Files
WFL/app.py

3072 lines
101 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from fastapi import (
FastAPI,
Form,
Request,
Query,
HTTPException,
WebSocket,
WebSocketDisconnect,
)
from datetime import datetime
from fastapi.responses import (
HTMLResponse,
RedirectResponse,
JSONResponse,
RedirectResponse,
StreamingResponse,
PlainTextResponse,
)
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from typing import List, Dict
from pathlib import Path
import io
import asyncio
import time
from urllib.parse import quote, urlencode
import traceback
import contextlib
import os
import html
import re
from parsers.parser_game import run_parser_game
from parsers.parser_players import run_parser_players
from parsers.parser_schedule import run_parser_schedule
from parsers.parser_standings import run_parser_standings
from parsers.parser_teams import run_parser_teams
from parsers.parser_sources import (
build_empty_photo_path,
build_schedule_channel_logo_path,
list_parser_sources,
get_default_source_key,
get_parser_source,
)
from services.project_settings_service import build_project_settings_context, save_project_settings
from repositories.project_settings_repository import ensure_project_settings_tables
from db import get_connection
from repositories.match_formation_repository import (
get_match_formations,
replace_match_formations,
apply_formation_preset_to_players,
)
from parsers.parser_game import run_parser_game
from repositories.match_session_repository import (
create_match_session,
get_match_session_by_token,
deactivate_match_session,
update_match_session_vmix_path,
get_match_by_id,
list_matches_for_admin,
list_available_tours,
)
from repositories.match_view_repository import get_match_lineups_grouped
from vmix.vmix_service import build_vmix_project_bytes, build_vmix_filename
from repositories.match_referee_repository import get_match_referees
from repositories.match_repository import get_tour_schedule_by_match_id, ensure_match_source_key_column, resolve_match_source_key
from repositories.standings_repository import get_standings_by_match_id
from repositories.match_coach_repository import get_match_coaches_grouped
from repositories.player_repository import (
search_players_for_admin,
get_player_by_id,
update_player_admin,
update_player_photo_enabled,
ensure_player_photo_enabled_column,
create_player_admin,
)
from repositories.referee_repository import (
search_referees_for_admin,
get_referee_by_id,
update_referee_admin,
get_all_referees,
create_referee_admin,
# replace_match_referees,
)
from repositories.match_referee_repository import replace_match_referees
from repositories.coach_repository import (
search_coaches_for_admin,
get_coach_by_id,
update_coach_admin,
create_coach_admin,
)
from repositories.stadium_repository import (
search_stadiums_for_admin,
get_stadium_by_id,
update_stadium_admin,
)
from repositories.team_repository import (
search_teams_for_admin,
get_team_by_id,
update_team_admin,
list_teams_for_admin_select,
)
from repositories.match_event_repository import (
create_event,
get_events,
delete_event,
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,
hash_password,
verify_password,
get_current_user_from_request,
build_not_authenticated_response,
revoke_auth_session_by_request,
)
from repositories.audit_log_repository import create_audit_log
from repositories.team_squad_repository import get_team_players_for_match_editor
from repositories.team_coach_repository import get_team_coaches_for_match_editor
from repositories.match_lineup_repository import (
get_match_lineup_for_editor,
save_match_lineup_for_editor,
)
from services.vmix_json_service import (
EMPTY_PHOTO_PATH,
build_generated_player_photo_path,
build_lineup_json,
get_vmix_match_info_by_token,
get_vmix_standings,
get_vmix_schedule,
get_vmix_team_formations,
get_vmix_scoreboard_info,
get_vmix_players_goal,
resolve_player_photo_for_json,
)
from scheduler import run_scheduler
BASE_DIR = Path(__file__).resolve().parent
app = FastAPI(
docs_url=None, # ❌ отключает /docs
redoc_url=None, # ❌ отключает /redoc
openapi_url=None, # ❌ отключает /openapi.json
)
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
PLAYER_ADMIN_SORT_COLUMNS = [
"id",
"full_name",
"first_name",
"last_name",
"external_id",
"position",
"team_name",
"photo",
]
def build_player_admin_sort_links(q: str, current_sort: str, current_direction: str) -> dict:
links = {}
current_sort = current_sort if current_sort in PLAYER_ADMIN_SORT_COLUMNS else "id"
current_direction = "asc" if current_direction == "asc" else "desc"
for column in PLAYER_ADMIN_SORT_COLUMNS:
next_direction = (
"desc"
if current_sort == column and current_direction == "asc"
else "asc"
)
indicator = ""
if current_sort == column:
indicator = "" if current_direction == "asc" else ""
links[column] = {
"url": f"/admin/db/players?{urlencode({'q': q or '', 'sort': column, 'direction': next_direction})}",
"indicator": indicator,
}
return links
@app.on_event("startup")
def start_scheduler():
import threading
try:
ensure_project_settings_tables()
ensure_player_photo_enabled_column()
ensure_match_source_key_column()
ensure_match_penalty_tables()
except Exception:
traceback.print_exc()
thread = threading.Thread(
target=run_scheduler, kwargs={"with_signal_handlers": False}, daemon=True
)
thread.start()
class PublishVmixCommandPayload(BaseModel):
match_id: int
session_token: str | None = None
commands: List[str] = Field(default_factory=list)
target_client_id: str | None = None
target_group: str | None = None
target_login: str | None = None
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
session_token: str | None = None
operator_name: str | None = None
group_name: str | None = None
class VmixConnectionManager:
def __init__(self):
self.connections: Dict[str, dict] = {}
self.lock = asyncio.Lock()
async def connect(
self,
websocket: WebSocket,
client_id: str,
match_id: int | None = None,
session_token: str | None = None,
operator_name: str | None = None,
group_name: str | None = None,
):
await websocket.accept()
async with self.lock:
old = self.connections.get(client_id)
if old:
try:
await old["websocket"].close(code=4000)
except Exception:
pass
self.connections[client_id] = {
"websocket": websocket,
"client_id": client_id,
"match_id": match_id,
"session_token": session_token,
"operator_name": operator_name,
"group_name": group_name,
"connected_at": time.time(),
"last_seen": time.time(),
}
async def disconnect(self, client_id: str):
async with self.lock:
self.connections.pop(client_id, None)
async def heartbeat(self, client_id: str):
async with self.lock:
if client_id in self.connections:
self.connections[client_id]["last_seen"] = time.time()
async def update_meta(
self,
client_id: str,
match_id: int | None = None,
session_token: str | None = None,
operator_name: str | None = None,
group_name: str | None = None,
):
async with self.lock:
conn = self.connections.get(client_id)
if not conn:
return
conn["last_seen"] = time.time()
if match_id is not None:
conn["match_id"] = match_id
if session_token is not None:
conn["session_token"] = session_token
if operator_name is not None:
conn["operator_name"] = operator_name
if group_name is not None:
conn["group_name"] = group_name
async def list_clients(self):
async with self.lock:
return [
{
"client_id": c["client_id"],
"match_id": c["match_id"],
"session_token": c["session_token"],
"operator_name": c["operator_name"],
"group_name": c["group_name"],
"connected_at": c["connected_at"],
"last_seen": c["last_seen"],
}
for c in self.connections.values()
]
async def send_to_client(self, client_id: str, message: dict) -> bool:
async with self.lock:
conn = self.connections.get(client_id)
if not conn:
return False
ws = conn["websocket"]
try:
await ws.send_json(message)
await self.heartbeat(client_id)
return True
except Exception:
await self.disconnect(client_id)
return False
async def send_to_match(
self,
match_id: int,
message: dict,
target_group: str | None = None,
target_login: str | None = None,
):
async with self.lock:
items = list(self.connections.values())
matched = []
for conn in items:
if conn["match_id"] != match_id:
continue
if target_group and conn.get("group_name") != target_group:
continue
if target_login and conn.get("operator_name") != target_login:
continue
matched.append(conn["client_id"])
results = []
for client_id in matched:
ok = await self.send_to_client(client_id, message)
results.append({"client_id": client_id, "ok": ok})
return results
vmix_ws_manager = VmixConnectionManager()
def get_current_user(request: Request):
return getattr(request.state, "current_user", None)
def require_role(request: Request, allowed_roles: set[str]):
user = get_current_user(request)
if not user or user["role"] not in allowed_roles:
if request.headers.get(
"x-requested-with"
) == "XMLHttpRequest" or "application/json" in request.headers.get(
"accept", ""
):
return JSONResponse({"error": "forbidden"}, status_code=403)
return RedirectResponse("/admin/matches", status_code=303)
return None
@app.get("/")
def root(request: Request):
user = get_current_user_from_request(request)
if user:
return RedirectResponse(url="/admin/matches")
return RedirectResponse(url="/login")
def static_version(filename: str) -> str:
path = Path(__file__).parent / "static" / filename
try:
return str(int(os.path.getmtime(path)))
except OSError:
return "1"
def log_action(
request: Request,
action: str,
entity_type=None,
entity_id=None,
match_id=None,
session_token=None,
details=None,
):
user = getattr(request.state, "current_user", None) or {}
create_audit_log(
user_id=user.get("id") or user.get("user_id"),
username=user.get("username"),
role=user.get("role"),
action=action,
entity_type=entity_type,
entity_id=str(entity_id) if entity_id is not None else None,
match_id=match_id,
session_token=session_token,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
details=details,
)
@app.middleware("http")
async def admin_auth_middleware(request: Request, call_next):
path = request.url.path
if path.startswith("/admin"):
user = get_current_user_from_request(request)
if not user:
return build_not_authenticated_response(request)
current_user = get_current_user_from_request(request)
request.state.current_user = {
"id": current_user.get("user_id") or current_user.get("id"),
"username": current_user.get("username"),
"role": current_user.get("role", "operator"),
}
response = await call_next(request)
if path.startswith("/vmix/session/"):
response.headers["Cache-Control"] = "no-store, no-cache, max-age=0, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
@app.get("/login", response_class=HTMLResponse)
def login_page(request: Request, reason: str | None = Query(default=None)):
if get_current_user_from_request(request):
return RedirectResponse(url="/admin/matches", status_code=303)
reason_map = {
"expired": "Сессия истекла. Войдите заново.",
"idle": "Сессия завершена из-за 4 часов бездействия.",
"logged_out": "Вы вышли из системы.",
"invalid": "Нужно войти в систему.",
}
return templates.TemplateResponse(
name="login.html",
request=request,
context={
"error": "",
"message": reason_map.get(reason or "", ""),
},
)
@app.post("/login", response_class=HTMLResponse)
def login_submit(
request: Request,
username: str = Form(...),
password: str = Form(...),
):
user = get_user_by_username(username.strip())
if not user or not verify_password(password, user[2]) or not user[3]:
return templates.TemplateResponse(
request=request,
name="login.html",
context={
"error": "Неверный логин или пароль",
"message": "",
},
status_code=401,
)
token = create_auth_session(
user_id=user[0],
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
response = RedirectResponse(url="/admin/matches", status_code=303)
response.set_cookie(
key="auth_token",
value=token,
httponly=True,
secure=True,
samesite="lax",
max_age=4 * 60 * 60,
)
return response
@app.post("/logout")
def logout(request: Request):
revoke_auth_session_by_request(request)
response = RedirectResponse(url="/login?reason=logged_out", status_code=303)
response.delete_cookie("auth_token", path="/")
return response
class ClockStatePayload(BaseModel):
currentPeriod: str | None = None
matchClockSeconds: int = 0
timerRunning: bool = False
pausedAccumulatedSeconds: int | None = None
sourceClientId: str | None = None
# Простое общее состояние часов для всех браузеров.
# localStorage остаётся резервом на стороне клиента.
MATCH_CLOCK_STATES: Dict[int, dict] = {}
def build_clock_state_payload(match_id: int) -> dict:
state = MATCH_CLOCK_STATES.get(match_id)
if not state:
return {
"exists": False,
"currentPeriod": None,
"matchClockSeconds": 0,
"timerRunning": False,
"serverSavedAt": None,
"sourceClientId": None,
}
seconds = int(state.get("matchClockSeconds") or 0)
timer_running = bool(state.get("timerRunning"))
saved_at = float(state.get("serverSavedAt") or time.time())
if timer_running:
seconds += max(0, int(time.time() - saved_at))
return {
"exists": True,
"currentPeriod": state.get("currentPeriod"),
"matchClockSeconds": max(0, seconds),
"timerRunning": timer_running,
"serverSavedAt": saved_at,
"sourceClientId": state.get("sourceClientId"),
}
class MatchEventPayload(BaseModel):
side: str
type: str
player_name: str = ""
minute: int | None = None
seconds: int = 0
meta: str | None = None
player_id: int | None = None
player_out_id: int | None = None
player_in_id: int | None = None
class FormationApplyPayload(BaseModel):
side: str
preset: str
class FormationPlayerPayload(BaseModel):
player_id: int | None = None
player_name: str = ""
number: str = ""
position: str = ""
is_captain: bool = False
photo_enabled: bool = False
x: float
y: float
class FormationSavePayload(BaseModel):
side: str
players: list[FormationPlayerPayload]
class SquadEditorPlayerPayload(BaseModel):
player_id: int | None = None
player_name: str = ""
last_name: str = ""
first_name: str = ""
number: str = ""
position: str = ""
is_captain: bool = False
photo_enabled: bool = False
class SquadEditorCoachPayload(BaseModel):
coach_id: int | None = None
coach_name: str = ""
role: str = ""
class SquadEditorSavePayload(BaseModel):
home_starting: list[SquadEditorPlayerPayload] = Field(default_factory=list)
home_bench: list[SquadEditorPlayerPayload] = Field(default_factory=list)
away_starting: list[SquadEditorPlayerPayload] = Field(default_factory=list)
away_bench: list[SquadEditorPlayerPayload] = Field(default_factory=list)
home_coaches: list[SquadEditorCoachPayload] = Field(default_factory=list)
away_coaches: list[SquadEditorCoachPayload] = Field(default_factory=list)
@app.get("/admin/matches", response_class=HTMLResponse)
def admin_matches(
request: Request,
today_only: bool = Query(default=False),
tour: str | None = Query(default=None),
hide_finished: str | None = Query(default=None),
):
# По умолчанию завершённые матчи скрываем.
# Но если оператор выбрал конкретный тур, показываем все матчи тура,
# поэтому чекбокс «Завершенные матчи» автоматически снимается.
if hide_finished is None:
hide_finished_bool = False if tour else True
else:
hide_finished_bool = str(hide_finished).lower() == "true"
matches = list_matches_for_admin(
today_only=today_only,
tour=tour,
hide_finished=hide_finished_bool,
)
tours = list_available_tours()
return templates.TemplateResponse(
name="matches.html",
request=request,
context={
"matches": matches,
"today_only": today_only,
"hide_finished": hide_finished_bool,
"selected_tour": tour or "",
"tours": tours,
"current_user": getattr(request.state, "current_user", None),
},
)
@app.get("/admin/matches/{match_id}/select")
def select_match(
request: Request, match_id: int, operator_name: str = Query(default="")
):
match_row = get_match_by_id(match_id)
if not match_row:
return JSONResponse({"error": "match_not_found"}, status_code=404)
session_row = create_match_session(
match_id=match_id,
operator_name=None,
vmix_project_path=None,
)
session_token = session_row[3]
log_action(
request,
action="match_selected",
entity_type="match",
entity_id=match_id,
match_id=match_id,
session_token=session_token,
details={
"operator_name": None,
},
)
return JSONResponse(
{
"success": True,
"session_token": session_token,
"session_url": f"/admin/session/{session_token}",
}
)
@app.get(
"/admin/session/{session_token}/download-vmix-page", response_class=HTMLResponse
)
def download_vmix_project_page(request: Request, session_token: str):
session_row = get_match_session_by_token(session_token)
if not session_row:
return RedirectResponse(url="/admin/matches", status_code=303)
return templates.TemplateResponse(
name="download_vmix.html",
request=request,
context={
"session": session_row,
"session_token": session_token,
"download_url": f"/admin/session/{session_token}/download-vmix",
"back_url": f"/admin/session/{session_token}?tab=game",
},
)
@app.get("/admin/session/{session_token}/download-vmix")
def download_vmix_project(request: Request, 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)
current_user = getattr(request.state, "current_user", None) or {}
operator_login = current_user.get("username") or None
try:
source_key = resolve_match_source_key(session_row[1], session_row[21] if len(session_row) > 21 else None)
vmix_bytes = build_vmix_project_bytes(
session_token=session_token,
match_id=session_row[1],
operator_login=operator_login,
source_key=source_key,
)
filename = build_vmix_filename(session_row)
return StreamingResponse(
io.BytesIO(vmix_bytes),
media_type="application/octet-stream",
headers={
"Content-Disposition": (
f"attachment; filename*=UTF-8''{quote(filename)}"
)
},
)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/admin/session/{session_token}", response_class=HTMLResponse)
def session_workspace(
request: Request, session_token: str, tab: str = Query(default="game")
):
session_row = get_match_session_by_token(session_token)
if not session_row:
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)
referee_pool = get_all_referees()
tour_schedule = get_tour_schedule_by_match_id(match_id)
standings = get_standings_by_match_id(match_id)
coaches = get_match_coaches_grouped(
match_id=match_id,
home_team_id=home_team_id,
away_team_id=away_team_id,
)
lineups = get_match_lineup_for_editor(
match_id=match_id,
home_team_id=home_team_id,
away_team_id=away_team_id,
)
home_formations = get_match_formations(match_id, home_team_id)
away_formations = get_match_formations(match_id, away_team_id)
return templates.TemplateResponse(
name="match_workspace.html",
request=request,
context={
"session": session_row,
"static_version": static_version,
"tab": tab,
"lineups": lineups,
"referees": referees,
"referee_pool": referee_pool,
"tour_schedule": tour_schedule,
"standings": standings,
"coaches": coaches,
"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,
},
)
@app.post("/admin/session/{session_token}/load-match-data")
def load_match_data(request: Request, session_token: str):
session_row = get_match_session_by_token(session_token)
if not session_row:
if request.headers.get("x-requested-with") == "XMLHttpRequest":
return JSONResponse(
{
"success": False,
"error": "Сессия не найдена",
"redirect_url": "/admin/matches",
},
status_code=404,
)
return RedirectResponse(url="/admin/matches", status_code=303)
match_id = session_row[1]
match_external_id = session_row[8]
try:
# Очистка и замена матчевых данных выполняется внутри sync_match_page().
# Важно не удалять ручной состав здесь заранее: сервис сначала должен
# увидеть ранее выбранного капитана и, если сайт забыл его указать,
# сохранить этот выбор при повторной загрузке.
run_parser_game(str(match_external_id))
except Exception as e:
error_text = str(e) or "Ошибка загрузки данных с сайта"
if request.headers.get("x-requested-with") == "XMLHttpRequest":
return JSONResponse(
{
"success": False,
"error": error_text,
"redirect_url": f"/admin/session/{session_token}?tab=game",
},
status_code=500,
)
return RedirectResponse(
url=f"/admin/session/{session_token}?tab=game",
status_code=303,
)
if request.headers.get("x-requested-with") == "XMLHttpRequest":
return JSONResponse(
{
"success": True,
"message": "Данные успешно загружены",
"redirect_url": f"/admin/session/{session_token}?tab=game",
}
)
return RedirectResponse(
url=f"/admin/session/{session_token}?tab=game",
status_code=303,
)
@app.post("/admin/session/{session_token}/close")
def close_session(session_token: str):
deactivate_match_session(session_token)
return RedirectResponse(url="/admin/matches", status_code=303)
def _admin_create_optional_int(value, field_title: str, minimum: int = 0) -> int | None:
text = str(value or "").strip()
if not text:
return None
try:
number = int(text)
except ValueError as exc:
raise ValueError(f"Поле «{field_title}» должно быть целым числом.") from exc
if number < minimum:
raise ValueError(f"Поле «{field_title}» не может быть меньше {minimum}.")
return number
def _admin_create_required_int(value, field_title: str, minimum: int = 0) -> int:
number = _admin_create_optional_int(value, field_title, minimum)
if number is None:
raise ValueError(f"Заполните поле «{field_title}».")
return number
def _render_admin_entity_create(
request: Request,
entity_type: str = "player",
error: str | None = None,
created_id: int | None = None,
form_values: dict | None = None,
status_code: int = 200,
):
allowed_entities = {"player", "coach", "referee"}
entity_type = entity_type if entity_type in allowed_entities else "player"
try:
teams = list_teams_for_admin_select()
except Exception:
teams = []
return templates.TemplateResponse(
name="admin_db_create.html",
request=request,
context={
"entity_type": entity_type,
"teams": teams,
"error": error,
"created_id": created_id,
"form_values": form_values or {},
},
status_code=status_code,
)
@app.get("/admin/db/create", response_class=HTMLResponse)
def admin_db_create_page(
request: Request,
entity: str = Query(default="player"),
created_id: int | None = Query(default=None),
):
denied = require_role(request, {"admin"})
if denied:
return denied
return _render_admin_entity_create(
request=request,
entity_type=entity,
created_id=created_id,
)
@app.post("/admin/db/create", response_class=HTMLResponse)
async def admin_db_create_submit(request: Request):
denied = require_role(request, {"admin"})
if denied:
return denied
form = await request.form()
values = {key: str(value) for key, value in form.items()}
entity_type = values.get("entity_type", "player").strip().lower()
if entity_type not in {"player", "coach", "referee"}:
entity_type = "player"
try:
full_name = values.get("full_name", "").strip()
if not full_name:
raise ValueError("Заполните поле «Полное имя / ФИО».")
external_id = values.get("external_id", "").strip()
first_name = values.get("first_name", "").strip()
last_name = values.get("last_name", "").strip()
is_active = "is_active" in form
if entity_type == "player":
team_id = _admin_create_required_int(values.get("team_id"), "Команда", 1)
position = values.get("position", "").strip()
if not position:
raise ValueError("Выберите или укажите амплуа игрока.")
created_id = create_player_admin(
team_id=team_id,
full_name=full_name,
first_name=first_name,
last_name=last_name,
external_id=external_id,
number=values.get("number", ""),
position=position,
birth_date=values.get("birth_date", ""),
photo=values.get("photo", ""),
video=values.get("video", ""),
height_cm=_admin_create_optional_int(values.get("height_cm"), "Рост", 1),
weight_kg=_admin_create_optional_int(values.get("weight_kg"), "Вес", 1),
games=_admin_create_optional_int(values.get("games"), "Игры", 0) or 0,
goals=_admin_create_optional_int(values.get("goals"), "Голы", 0) or 0,
penaltys=_admin_create_optional_int(values.get("penaltys"), "Голы с пенальти", 0) or 0,
assists=_admin_create_optional_int(values.get("assists"), "Передачи", 0) or 0,
yellows=_admin_create_optional_int(values.get("yellows"), "Жёлтые карточки", 0) or 0,
reds=_admin_create_optional_int(values.get("reds"), "Красные карточки", 0) or 0,
is_active=is_active,
photo_enabled="photo_enabled" in form,
)
redirect_url = f"/admin/db/create?entity=player&created_id={created_id}"
elif entity_type == "coach":
team_id = _admin_create_required_int(values.get("team_id"), "Команда", 1)
role = values.get("role", "").strip()
if not role:
raise ValueError("Заполните должность / амплуа тренера.")
created_id = create_coach_admin(
team_id=team_id,
full_name=full_name,
first_name=first_name,
last_name=last_name,
external_id=external_id,
birth_date=values.get("birth_date", ""),
role=role,
is_active=is_active,
)
redirect_url = f"/admin/db/create?entity=coach&created_id={created_id}"
else:
created_id = create_referee_admin(
full_name=full_name,
first_name=first_name,
last_name=last_name,
middle_name=values.get("middle_name", ""),
city=values.get("city", ""),
external_id=external_id,
is_active=is_active,
)
redirect_url = f"/admin/db/create?entity=referee&created_id={created_id}"
log_action(
request,
action="admin_db_entity_created",
entity_type=entity_type,
entity_id=created_id,
details={"full_name": full_name, "external_id": external_id},
)
return RedirectResponse(url=redirect_url, status_code=303)
except ValueError as exc:
return _render_admin_entity_create(
request=request,
entity_type=entity_type,
error=str(exc),
form_values=values,
status_code=400,
)
except Exception as exc:
traceback.print_exc()
error_text = "Не удалось создать запись."
if "duplicate key" in str(exc).lower() or "unique constraint" in str(exc).lower():
error_text += " Запись с таким External ID уже существует."
else:
error_text += f" Ошибка базы данных: {str(exc)}"
return _render_admin_entity_create(
request=request,
entity_type=entity_type,
error=error_text,
form_values=values,
status_code=500,
)
@app.get("/admin/db/players", response_class=HTMLResponse)
def admin_db_players(
request: Request,
q: str = Query(default=""),
sort: str = Query(default="id"),
direction: str = Query(default="desc"),
):
# denied = require_role(request, {"admin"})
# if denied:
# return denied
allowed_sort = set(PLAYER_ADMIN_SORT_COLUMNS)
if sort not in allowed_sort:
sort = "id"
direction = "asc" if direction == "asc" else "desc"
players = search_players_for_admin(q, sort_by=sort, sort_dir=direction)
return templates.TemplateResponse(
name="admin_db_players.html",
request=request,
context={
"q": q,
"sort": sort,
"direction": direction,
"sort_links": build_player_admin_sort_links(q, sort, direction),
"players": players,
},
)
@app.post("/admin/db/players/{player_id}/photo-enabled")
def admin_db_player_photo_enabled_submit(
player_id: int,
q: str = Form(default=""),
sort: str = Form(default="id"),
direction: str = Form(default="desc"),
photo_enabled: bool = Form(default=False),
):
allowed_sort = set(PLAYER_ADMIN_SORT_COLUMNS)
if sort not in allowed_sort:
sort = "id"
direction = "asc" if direction == "asc" else "desc"
update_player_photo_enabled(player_id=player_id, photo_enabled=photo_enabled)
return RedirectResponse(
url=f"/admin/db/players?{urlencode({'q': q or '', 'sort': sort, 'direction': direction})}",
status_code=303,
)
@app.get("/admin/db/players/{player_id}/edit", response_class=HTMLResponse)
def admin_db_player_edit(request: Request, player_id: int):
player = get_player_by_id(player_id)
if not player:
return RedirectResponse(url="/admin/db/players", status_code=303)
return templates.TemplateResponse(
name="admin_db_player_edit.html",
request=request,
context={
"player": player,
},
)
@app.post("/admin/db/players/{player_id}/edit")
def admin_db_player_edit_submit(
player_id: int,
full_name: str = Form(default=""),
first_name: str = Form(default=""),
last_name: str = Form(default=""),
external_id: str = Form(default=""),
position: str = Form(default=""),
birth_date: str = Form(default=""),
photo: str = Form(default=""),
video: str = Form(default=""),
photo_enabled: bool = Form(default=False),
):
update_player_admin(
player_id=player_id,
full_name=full_name,
first_name=first_name,
last_name=last_name,
external_id=external_id,
position=position,
birth_date=birth_date,
photo=photo,
video=video,
photo_enabled=photo_enabled,
)
return RedirectResponse(
url=f"/admin/db/players/{player_id}/edit",
status_code=303,
)
@app.get("/admin/db/referees", response_class=HTMLResponse)
def admin_db_referees(request: Request, q: str = Query(default="")):
referees = search_referees_for_admin(q)
return templates.TemplateResponse(
name="admin_db_referees.html",
request=request,
context={
"q": q,
"referees": referees,
},
)
@app.get("/admin/db/referees/{referee_id}/edit", response_class=HTMLResponse)
def admin_db_referee_edit(request: Request, referee_id: int):
referee = get_referee_by_id(referee_id)
if not referee:
return RedirectResponse(url="/admin/db/referees", status_code=303)
return templates.TemplateResponse(
name="admin_db_referee_edit.html",
request=request,
context={
"referee": referee,
},
)
@app.post("/admin/db/referees/{referee_id}/edit")
def admin_db_referee_edit_submit(
referee_id: int,
full_name: str = Form(default=""),
external_id: str = Form(default=""),
):
update_referee_admin(
referee_id=referee_id,
full_name=full_name,
external_id=external_id,
)
return RedirectResponse(
url=f"/admin/db/referees/{referee_id}/edit",
status_code=303,
)
@app.get("/admin/db/coaches", response_class=HTMLResponse)
def admin_db_coaches(request: Request, q: str = Query(default="")):
coaches = search_coaches_for_admin(q)
return templates.TemplateResponse(
name="admin_db_coaches.html",
request=request,
context={
"q": q,
"coaches": coaches,
},
)
@app.get("/admin/db/coaches/{coach_id}/edit", response_class=HTMLResponse)
def admin_db_coach_edit(request: Request, coach_id: int):
coach = get_coach_by_id(coach_id)
if not coach:
return RedirectResponse(url="/admin/db/coaches", status_code=303)
return templates.TemplateResponse(
name="admin_db_coach_edit.html",
request=request,
context={
"coach": coach,
},
)
@app.post("/admin/db/coaches/{coach_id}/edit")
def admin_db_coach_edit_submit(
coach_id: int,
full_name: str = Form(default=""),
external_id: str = Form(default=""),
role: str = Form(default=""),
):
update_coach_admin(
coach_id=coach_id,
full_name=full_name,
external_id=external_id,
role=role,
)
return RedirectResponse(
url=f"/admin/db/coaches/{coach_id}/edit",
status_code=303,
)
@app.get("/admin/db/stadiums", response_class=HTMLResponse)
def admin_db_stadiums(request: Request, q: str = Query(default="")):
stadiums = search_stadiums_for_admin(q)
return templates.TemplateResponse(
name="admin_db_stadiums.html",
request=request,
context={
"q": q,
"stadiums": stadiums,
},
)
@app.get("/admin/db/stadiums/{stadium_id}/edit", response_class=HTMLResponse)
def admin_db_stadium_edit(request: Request, stadium_id: int):
stadium = get_stadium_by_id(stadium_id)
if not stadium:
return RedirectResponse(url="/admin/db/stadiums", status_code=303)
return templates.TemplateResponse(
name="admin_db_stadium_edit.html",
request=request,
context={
"stadium": stadium,
},
)
@app.post("/admin/db/stadiums/{stadium_id}/edit")
def admin_db_stadium_edit_submit(
stadium_id: int,
stadium_gfx: str = Form(default=""),
city: str = Form(default=""),
address: str = Form(default=""),
external_id: str = Form(default=""),
):
update_stadium_admin(
stadium_id=stadium_id,
stadium_gfx=stadium_gfx,
city=city,
address=address,
external_id=external_id,
)
return RedirectResponse(
url=f"/admin/db/stadiums/{stadium_id}/edit",
status_code=303,
)
@app.get("/admin/db/teams", response_class=HTMLResponse)
def admin_db_teams(request: Request, q: str = Query(default="")):
teams = search_teams_for_admin(q)
return templates.TemplateResponse(
name="admin_db_teams.html",
request=request,
context={
"q": q,
"teams": teams,
},
)
@app.get("/admin/db/teams/{team_id}/edit", response_class=HTMLResponse)
def admin_db_team_edit(request: Request, team_id: int):
team = get_team_by_id(team_id)
if not team:
return RedirectResponse(url="/admin/db/teams", status_code=303)
return templates.TemplateResponse(
name="admin_db_team_edit.html",
request=request,
context={
"team": team,
},
)
@app.post("/admin/db/teams/{team_id}/edit")
def admin_db_team_edit_submit(
team_id: int,
name: str = Form(default=""),
full_name: str = Form(default=""),
short_name_3: str = Form(default=""),
city: str = Form(default=""),
logo_path: str = Form(default=""),
external_id: str = Form(default=""),
):
update_team_admin(
team_id=team_id,
name=name,
full_name=full_name,
short_name_3=short_name_3,
city=city,
logo_path=logo_path,
external_id=external_id,
)
return RedirectResponse(
url=f"/admin/db/teams/{team_id}/edit",
status_code=303,
)
@app.post("/admin/session/{session_token}/formations/apply")
def apply_formation(session_token: str, payload: FormationApplyPayload):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
match_id = session_row[1]
home_team_id = session_row[13]
away_team_id = session_row[17]
lineups = get_match_lineup_for_editor(
match_id=match_id,
home_team_id=home_team_id,
away_team_id=away_team_id,
)
if payload.side == "home":
team_id = home_team_id
players = lineups["home_starting"]
elif payload.side == "away":
team_id = away_team_id
players = lineups["away_starting"]
else:
return JSONResponse({"error": "invalid_side"}, status_code=400)
applied = apply_formation_preset_to_players(players, payload.preset)
replace_match_formations(match_id, team_id, applied)
return {"players": applied}
@app.post("/admin/session/{session_token}/formations/save")
def save_formation(session_token: str, payload: FormationSavePayload):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
match_id = session_row[1]
home_team_id = session_row[13]
away_team_id = session_row[17]
if payload.side == "home":
team_id = home_team_id
elif payload.side == "away":
team_id = away_team_id
else:
return JSONResponse({"error": "invalid_side"}, status_code=400)
players = [
{
"player_id": p.player_id,
"player_name": p.player_name,
"number": p.number,
"position": p.position,
"is_captain": p.is_captain,
"x": p.x,
"y": p.y,
}
for p in payload.players
]
replace_match_formations(match_id, team_id, players)
return {"success": True}
@app.get("/admin/session/{session_token}/clock-state")
def api_get_clock_state(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)
match_id = session_row[1]
return build_clock_state_payload(match_id)
@app.post("/admin/session/{session_token}/clock-state")
def api_save_clock_state(session_token: str, payload: ClockStatePayload):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
match_id = session_row[1]
seconds = max(0, int(payload.matchClockSeconds or 0))
MATCH_CLOCK_STATES[match_id] = {
"currentPeriod": payload.currentPeriod,
"matchClockSeconds": seconds,
"timerRunning": bool(payload.timerRunning),
"pausedAccumulatedSeconds": (
int(payload.pausedAccumulatedSeconds)
if payload.pausedAccumulatedSeconds is not None
else seconds
),
"serverSavedAt": time.time(),
"sourceClientId": payload.sourceClientId,
}
return build_clock_state_payload(match_id)
@app.get("/admin/session/{session_token}/events")
def api_get_events(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)
match_id = session_row[1]
return get_events(match_id)
@app.post("/admin/session/{session_token}/event")
def api_add_event(session_token: str, payload: MatchEventPayload):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
match_id = session_row[1]
event_id = create_event(
match_id=match_id,
side=payload.side,
type_=payload.type,
player_name=payload.player_name,
minute=payload.minute,
seconds=payload.seconds,
meta=payload.meta,
player_id=payload.player_id,
player_out_id=payload.player_out_id,
player_in_id=payload.player_in_id,
)
return {"id": event_id}
@app.put("/admin/session/{session_token}/event/{event_id}")
def api_update_event(session_token: str, event_id: int, payload: MatchEventPayload):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
update_event(
event_id=event_id,
side=payload.side,
type_=payload.type,
player_name=payload.player_name,
minute=payload.minute,
seconds=payload.seconds,
meta=payload.meta,
player_id=payload.player_id,
player_out_id=payload.player_out_id,
player_in_id=payload.player_in_id,
)
return {"ok": True}
@app.delete("/admin/session/{session_token}/events")
def api_clear_events(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)
match_id = session_row[1]
clear_events(match_id)
# Если события очищены в одном браузере, сбрасываем общий серверный таймер тоже.
# Иначе другой браузер с запущенным таймером может снова подтянуть старое время.
MATCH_CLOCK_STATES[match_id] = {
"currentPeriod": None,
"matchClockSeconds": 0,
"timerRunning": False,
"pausedAccumulatedSeconds": 0,
"serverSavedAt": time.time(),
"sourceClientId": "events_clear",
}
return {"ok": True}
@app.delete("/admin/session/{session_token}/event/{event_id}")
def api_delete_event(session_token: str, event_id: int):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
delete_event(event_id)
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)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
match_id = session_row[1]
home_team_id = session_row[13]
away_team_id = session_row[17]
current_lineup = get_match_lineup_for_editor(
match_id=match_id,
home_team_id=home_team_id,
away_team_id=away_team_id,
)
home_squad_pool = get_team_players_for_match_editor(
home_team_id,
match_id=match_id,
home_team_id=home_team_id,
away_team_id=away_team_id,
)
away_squad_pool = get_team_players_for_match_editor(
away_team_id,
match_id=match_id,
home_team_id=home_team_id,
away_team_id=away_team_id,
)
home_coach_pool = get_team_coaches_for_match_editor(
home_team_id,
match_id=match_id,
home_team_id=home_team_id,
away_team_id=away_team_id,
)
away_coach_pool = get_team_coaches_for_match_editor(
away_team_id,
match_id=match_id,
home_team_id=home_team_id,
away_team_id=away_team_id,
)
return {
"home_starting": current_lineup.get("home_starting", []),
"home_bench": current_lineup.get("home_bench", []),
"away_starting": current_lineup.get("away_starting", []),
"away_bench": current_lineup.get("away_bench", []),
"home_coaches": current_lineup.get("home_coaches", []),
"away_coaches": current_lineup.get("away_coaches", []),
"home_squad_pool": home_squad_pool,
"away_squad_pool": away_squad_pool,
"home_coach_pool": home_coach_pool,
"away_coach_pool": away_coach_pool,
}
@app.post("/admin/session/{session_token}/squad-editor-save")
def api_save_squad_editor_data(
session_token: str,
payload: SquadEditorSavePayload,
):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
match_id = session_row[1]
home_team_id = session_row[13]
away_team_id = session_row[17]
missing_captains = []
if payload.home_starting and not any(player.is_captain for player in payload.home_starting):
missing_captains.append(str(session_row[14] or "Домашняя команда"))
if payload.away_starting and not any(player.is_captain for player in payload.away_starting):
missing_captains.append(str(session_row[18] or "Гостевая команда"))
if missing_captains:
teams_text = ", ".join(missing_captains)
return JSONResponse(
{
"success": False,
"error": f"Не выбран капитан: {teams_text}. Назначьте капитана из основного состава.",
},
status_code=400,
)
save_match_lineup_for_editor(
match_id=match_id,
home_team_id=home_team_id,
away_team_id=away_team_id,
home_starting=[p.model_dump() for p in payload.home_starting],
home_bench=[p.model_dump() for p in payload.home_bench],
away_starting=[p.model_dump() for p in payload.away_starting],
away_bench=[p.model_dump() for p in payload.away_bench],
home_coaches=[c.model_dump() for c in payload.home_coaches],
away_coaches=[c.model_dump() for c in payload.away_coaches],
)
return {"success": True}
@app.post("/admin/session/{session_token}/referees/save")
async def save_session_referees(request: Request, session_token: str):
session_row = get_match_session_by_token(session_token)
if not session_row:
return {"success": False, "error": "Сессия не найдена"}
match_id = session_row[1]
payload = await request.json()
referees = payload.get("referees", [])
replace_match_referees(match_id, referees)
return {"success": True}
EMPTY_PLAYER = {
"last_name": " ",
"first_name": " ",
"full_name": " ",
"full_name_K": " ",
"number": " ",
"position": " ",
"pos": " ",
"number_lastname_amp_K": " ",
"number_fullname": " ",
"photo": EMPTY_PHOTO_PATH,
}
def build_empty_player(source_key=None):
item = EMPTY_PLAYER.copy()
item["photo"] = build_empty_photo_path(source_key) or EMPTY_PHOTO_PATH
return item
def get_roster_data(session_token: str, name: str, count_player: int):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
match_id = session_row[1]
home_team_id = session_row[13]
home_team_name = session_row[14].replace("«", "").replace("»", "")
away_team_id = session_row[17]
away_team_name = session_row[18].replace("«", "").replace("»", "")
source_key = resolve_match_source_key(match_id, session_row[21] if len(session_row) > 21 else None)
# print(session_row)
data = build_lineup_json(
match_id,
home_team_id,
away_team_id,
name,
home_team_name,
away_team_name,
source_key=source_key,
)
players = data.get("players", [])
players.extend([build_empty_player(source_key) for _ in range(count_player - len(players))])
data["players"] = players[:count_player]
return data
@app.get("/vmix/session/{session_token}/home-lineup")
def vmix_home_lineup(session_token: str):
return get_roster_data(session_token, "home_starting", 11)
@app.get("/vmix/session/{session_token}/away-lineup")
def vmix_away_lineup(session_token: str):
return get_roster_data(session_token, "away_starting", 11)
@app.get("/vmix/session/{session_token}/home-bench")
def vmix_home_bench(session_token: str):
return get_roster_data(session_token, "home_bench", 12)
@app.get("/vmix/session/{session_token}/away-bench")
def vmix_away_bench(session_token: str):
return get_roster_data(session_token, "away_bench", 12)
MONTHS_RU = [
"", # чтобы месяц = 1 → январь
"января",
"февраля",
"марта",
"апреля",
"мая",
"июня",
"июля",
"августа",
"сентября",
"октября",
"ноября",
"декабря",
]
def format_date_ru(value) -> str:
if isinstance(value, datetime):
dt = value
else:
dt = datetime.fromisoformat(value)
return f"{dt.day} {MONTHS_RU[dt.month]}"
@app.get("/vmix/session/{session_token}/info")
def vmix_info(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)
row = get_vmix_match_info_by_token(session_token)
if not row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
return [
{
"date_and_stadium": f'{format_date_ru(row[1])} | {row[12] or ""}',
"tour": (row[13].replace(" ", "") if row[-1] != "RUSSIAN_CUP" else row[13]) or "",
"tour_ID": (row[13].replace(" ", "") if row[-1] != "RUSSIAN_CUP" else row[13]) or "",
"home_team": {
"full_name": row[6] or "",
"short_name": row[7] or "",
"logo": row[14].replace("Teams Logos", 'Teams Logos\\HOME') or "",
"logo_1": row[20] or "",
"logo_2": row[22] or "",
"city": f'{row[6] or ""} {row[24] or ""}',
},
"away_team": {
"full_name": row[10] or "",
"short_name": row[11] or "",
"logo": row[15].replace("Teams Logos", 'Teams Logos\\AWAY') or "",
"logo_1": row[21] or "",
"logo_2": row[23] or "",
"city": f'{row[10] or ""} {row[25] or ""}',
},
"referees1": row[16] or "",
"referees2": row[17] or "",
"referees3": row[18] or "",
"referees4": row[19] or "",
"coach1": row[26] or "",
"coach2": row[28] or "",
"amplua1": row[27] or "",
"amplua2": row[29] or "",
}
]
@app.get("/vmix/session/{session_token}/standings")
def vmix_standings(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)
home_team_id = session_row[13]
away_team_id = session_row[17]
rows = get_vmix_standings(session_row)
return [
{
"position": row[0],
"team_name": row[1],
"logo": row[2],
"played": row[3],
"wins": row[4],
"losses": row[5],
"draws": row[6],
"score": row[7],
"points": row[8],
"color": (
("#FF00FF" if row[-1] != "RUSSIAN_CUP" else "#50FFFF")
if row[9] == home_team_id
else "#7300FF" if row[9] == away_team_id else "#FFFFFF00"
),
}
for row in rows
]
class MatchChannelPayload(BaseModel):
match_external_id: 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")
def update_schedule_channel(session_token: str, payload: MatchChannelPayload):
session_row = get_match_session_by_token(session_token)
if not session_row:
return JSONResponse({"error": "session_not_found"}, status_code=404)
allowed = {"", "match", "match_premier", "match_and_premier"}
if payload.channel not in allowed:
return JSONResponse({"error": "invalid_channel"}, status_code=400)
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE matches
SET channel = %s
WHERE external_id = %s
""",
(payload.channel, payload.match_external_id),
)
conn.commit()
return {"success": True}
except Exception as e:
conn.rollback()
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")
def vmix_schedule(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)
rows = get_vmix_schedule(session_row)
return [
{
"logo1": row[0],
"logo2": row[1],
"score": (
f"{row[2]} - {row[3]}"
if row[2] is not None and row[3] is not None
else " "
),
"date": format_date_ru(row[4]),
"time": (
row[4].strftime("%H:%M") if row[2] is None and row[3] is None else " "
),
"status": "ИДЁТ" if row[5] not in ["scheduled", "finished"] else " ",
"color": (
"#37F193" if row[5] not in ["scheduled", "finished"] else "#FFFFFF00"
),
"mask_time": (
"#FFFFFF00" if row[2] is not None and row[3] is not None else "#FFFFFF"
),
"mask_score": (
"#FFFFFF00" if row[2] is None and row[3] is None else "#FFFFFF"
),
"channel": (
build_schedule_channel_logo_path(
row[8] if len(row) > 8 else None,
row[7],
has_score=(row[2] is not None or row[3] is not None),
)
),
"channel_value": row[7] or "",
"source_key": row[8] if len(row) > 8 else "",
}
for row in rows
]
EMPTY_PLAYER_FORMATION = {
"number": " ",
"number_lastname_amp_K": " ",
"position": " ",
"photo": EMPTY_PHOTO_PATH,
}
def build_empty_player_formation(source_key=None):
item = EMPTY_PLAYER_FORMATION.copy()
item["photo"] = build_empty_photo_path(source_key) or EMPTY_PHOTO_PATH
return item
def build_vmix_formation_response(rows, team_id, team_name, source_key=None):
result = []
for p in rows:
suffix = []
lastname = p[0] or ""
is_captain = p[1]
number = p[2]
position = p[3] or ""
if "вратарь" in position.lower():
suffix.append("ВР")
if is_captain:
suffix.append("К")
suffix_str = f' ({", ".join(suffix)})' if suffix else ""
result.append(
{
"number_lastname_amp_K": f"{number} {lastname}{suffix_str}".strip(),
"number": number,
"position": position,
"photo": resolve_player_photo_for_json(
{
"player_id": p[7] if len(p) > 7 else None,
"team_id": p[8] if len(p) > 8 else team_id,
"player_name": p[9] if len(p) > 9 else "",
"last_name": lastname,
"first_name": p[4] if len(p) > 4 else "",
"number": number,
"position": position,
"photo": p[5] if len(p) > 5 else "",
"photo_enabled": p[6] if len(p) > 6 else False,
},
generated_photo=build_generated_player_photo_path(
team_name=team_name,
last_name=lastname,
first_name=p[4] if len(p) > 4 else "",
source_key=source_key,
),
fallback_team_id=team_id,
source_key=source_key,
),
}
)
result.extend([build_empty_player_formation(source_key) for _ in range(11 - len(result))])
return result
@app.get("/vmix/session/{session_token}/home-formations")
def vmix_home_formations(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)
home_team_id = session_row[13]
home_team = session_row[14].replace("«", "").replace("»", "")
source_key = resolve_match_source_key(session_row[1], session_row[21] if len(session_row) > 21 else None)
rows = get_vmix_team_formations(session_row, home_team_id)
return build_vmix_formation_response(rows, home_team_id, home_team, source_key=source_key)
@app.get("/vmix/session/{session_token}/away-formations")
def vmix_away_formations(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)
away_team_id = session_row[17]
away_team = session_row[18].replace("«", "").replace("»", "")
source_key = resolve_match_source_key(session_row[1], session_row[21] if len(session_row) > 21 else None)
rows = get_vmix_team_formations(session_row, away_team_id)
return build_vmix_formation_response(rows, away_team_id, away_team, source_key=source_key)
@app.get("/vmix/session/{session_token}/scoreboard")
def vmix_scoreboard(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)
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],
"red_home_4": row[5],
"red_away_1": row[6],
"red_away_2": row[7],
"red_away_3": row[8],
"red_away_4": row[9],
}
]
EMPTY_GOALS = {
"player_home": " ",
"player_away": " ",
}
@app.get("/vmix/session/{session_token}/match-events")
def vmix_match_events(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)
match_id = session_row[1]
events = get_vmix_players_goal(match_id)
new_events = [
{
"player_home": event[0],
"player_away": event[1],
}
for event in events
]
new_events.extend([EMPTY_GOALS.copy() for _ in range(12 - len(new_events))])
return new_events
def _build_goal_authors_text(match_id: int) -> str:
"""Возвращает авторов голов в 2 строки для текстового Data Source vMix.
1-я строка — голы первой команды, 2-я строка — голы второй команды.
Авторы внутри строки разделяются символом |.
"""
events = get_vmix_players_goal(match_id)
home_authors = []
away_authors = []
for home_goal, away_goal in events:
home_goal = (home_goal or "").strip()
away_goal = (away_goal or "").strip()
if home_goal:
home_authors.append(home_goal)
if away_goal:
away_authors.append(away_goal)
return "|".join(home_authors) + "\n" + "|".join(away_authors)
@app.get("/vmix/session/{session_token}/goal-authors.txt", response_class=PlainTextResponse)
def vmix_goal_authors_txt(session_token: str):
session_row = get_match_session_by_token(session_token)
if not session_row:
return PlainTextResponse("session_not_found", status_code=404)
text = _build_goal_authors_text(session_row[1])
return PlainTextResponse(
text,
media_type="text/plain; charset=utf-8",
headers={
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache",
},
)
@app.get("/vmix/session/{session_token}/goal-authors", response_class=PlainTextResponse)
def vmix_goal_authors_text(session_token: str):
return vmix_goal_authors_txt(session_token)
@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,
client_id: str = Query(...),
match_id: int | None = Query(default=None),
session_token: str | None = Query(default=None),
operator_name: str | None = Query(default=None),
group_name: str | None = Query(default=None),
):
await vmix_ws_manager.connect(
websocket=websocket,
client_id=client_id,
match_id=match_id,
session_token=session_token,
operator_name=operator_name,
group_name=group_name,
)
try:
await websocket.send_json(
{
"type": "connected",
"client_id": client_id,
"match_id": match_id,
"session_token": session_token,
"operator_name": operator_name,
}
)
while True:
msg = await websocket.receive_json()
msg_type = str(msg.get("type") or "").strip()
if msg_type == "ping":
await vmix_ws_manager.heartbeat(client_id)
await websocket.send_json({"type": "pong"})
continue
if msg_type == "register":
await vmix_ws_manager.update_meta(
client_id=client_id,
match_id=msg.get("match_id"),
session_token=msg.get("session_token"),
operator_name=msg.get("operator_name"),
group_name=msg.get("group_name"),
)
await websocket.send_json(
{"type": "registered", "client_id": client_id}
)
continue
if msg_type == "vmix_result":
# тут можно логировать delivery/result при желании
await vmix_ws_manager.heartbeat(client_id)
continue
except WebSocketDisconnect:
await vmix_ws_manager.disconnect(client_id)
except Exception:
await vmix_ws_manager.disconnect(client_id)
try:
await websocket.close()
except Exception:
pass
@app.post("/api/vmix/publish-command")
async def publish_vmix_command(payload: PublishVmixCommandPayload, request: Request):
commands = [str(x).strip() for x in (payload.commands or []) if str(x).strip()]
if not commands:
raise HTTPException(status_code=400, detail="commands is empty")
message = {
"type": "vmix_command",
"match_id": payload.match_id,
"commands": commands,
"target_login": payload.target_login,
"meta": payload.meta or {},
"sent_at": time.time(),
}
if payload.target_client_id:
ok = await vmix_ws_manager.send_to_client(payload.target_client_id, message)
results = [{"client_id": payload.target_client_id, "ok": ok}]
else:
results = await vmix_ws_manager.send_to_match(
match_id=payload.match_id,
message=message,
target_group=payload.target_group,
target_login=payload.target_login,
)
log_action(
request,
action="vmix_publish_command",
entity_type="match",
entity_id=payload.match_id,
match_id=payload.match_id,
session_token=payload.session_token,
details={
"target_client_id": payload.target_client_id,
"target_group": payload.target_group,
"target_login": payload.target_login,
"commands_count": len(commands),
"results": results,
},
)
return {
"success": True,
"delivered_to": results,
"commands_count": len(commands),
}
@app.get("/api/vmix/clients")
async def list_vmix_clients():
clients = await vmix_ws_manager.list_clients()
return {"items": clients}
INFO_TEMPLATE_PATH = BASE_DIR / "templates" / "info.html"
def _clean_update_text(value: str | None) -> str:
return (value or "").strip()
def _escape_text(value: str | None) -> str:
return html.escape(_clean_update_text(value), quote=True)
def _split_lines(value: str | None) -> list[str]:
return [line.strip() for line in (value or "").splitlines() if line.strip()]
def _format_update_date(value: str | None) -> str:
value = _clean_update_text(value)
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
try:
return datetime.strptime(value, "%Y-%m-%d").strftime("%d.%m.%Y")
except ValueError:
return value
return value
def _inline_update_markup(value: str | None) -> str:
"""Безопасное мини-форматирование для текста обновлений.
Поддерживаем в админке:
- `F5` или `1` — выделенная клавиша/команда;
- **важный текст** — зелёное выделение.
Остальной текст обязательно экранируется, чтобы не сломать HTML.
"""
source = _clean_update_text(value)
if not source:
return ""
token_re = re.compile(r"(`[^`]+`|\*\*.+?\*\*)")
result: list[str] = []
last_pos = 0
for match in token_re.finditer(source):
result.append(html.escape(source[last_pos:match.start()], quote=True))
token = match.group(0)
if token.startswith("`") and token.endswith("`"):
inner = token[1:-1].strip()
if inner:
result.append(f'<kbd class="update-key">{html.escape(inner, quote=True)}</kbd>')
elif token.startswith("**") and token.endswith("**"):
inner = token[2:-2].strip()
if inner:
result.append(f'<span class="update-highlight">{html.escape(inner, quote=True)}</span>')
last_pos = match.end()
result.append(html.escape(source[last_pos:], quote=True))
return "".join(result)
def _flush_update_list(buffer: list[str], list_type: str) -> str:
if not buffer:
return ""
tag = "ol" if list_type == "numbered" else "ul"
items = "\n".join(f" <li>{item}</li>" for item in buffer)
return (
f' <{tag} class="update-pretty-list update-pretty-list--{list_type}">\n'
f"{items}\n"
f" </{tag}>"
)
def _rich_update_text_html(value: str | None) -> str:
"""Текст обновления с красивыми списками и мини-выделениями.
Поддерживаемые форматы в textarea:
1. Первый пункт -> красивый нумерованный список
- Первый пункт -> красивый маркированный список
`F5` -> выделенная клавиша/команда
**важно** -> выделенное слово/фраза
"""
raw_lines = [line.strip() for line in (value or "").splitlines()]
lines = [line for line in raw_lines if line]
if not lines:
return "<p>Описание не заполнено.</p>"
html_parts: list[str] = []
list_buffer: list[str] = []
current_list_type = ""
def close_list() -> None:
nonlocal list_buffer, current_list_type
if list_buffer:
html_parts.append(_flush_update_list(list_buffer, current_list_type))
list_buffer = []
current_list_type = ""
for line in lines:
numbered_match = re.match(r"^\d+[\.)]\s+(.+)$", line)
bullet_match = re.match(r"^[-*•]\s+(.+)$", line)
if numbered_match:
if current_list_type and current_list_type != "numbered":
close_list()
current_list_type = "numbered"
list_buffer.append(_inline_update_markup(numbered_match.group(1)))
continue
if bullet_match:
if current_list_type and current_list_type != "bullet":
close_list()
current_list_type = "bullet"
list_buffer.append(_inline_update_markup(bullet_match.group(1)))
continue
close_list()
html_parts.append(f"<p>{_inline_update_markup(line)}</p>")
close_list()
return "\n".join(html_parts)
def _paragraphs_html(value: str | None) -> str:
# Оставлено как совместимый алиас: теперь описание умеет списки, клавиши и выделения.
return _rich_update_text_html(value)
def _normalize_update_image_path(value: str | None) -> str:
path = _clean_update_text(value)
if not path:
return ""
# Удобный вариант для админки: можно ввести только имя файла,
# например "no photo.png" — оно превратится в /static/docs/no photo.png.
if path.startswith("static/"):
path = "/" + path
elif path.startswith("docs/"):
path = "/static/" + path
elif not path.startswith(("/static/", "http://", "https://")):
path = "/static/docs/" + path.lstrip("/")
return path
def _image_html(value: str | None, alt: str | None = None) -> str:
path = _normalize_update_image_path(value)
if not path:
return ""
return f'\n <img src="{html.escape(path, quote=True)}" alt="{_escape_text(alt or "Скриншот обновления")}">'
def _build_update_block(
version: str,
date: str,
operator_actions: str,
item_badges: list[str],
item_titles: list[str],
item_texts: list[str],
item_images: list[str] | None = None,
) -> str:
badge_meta = {
"new": ("new", "Новое"),
"fix": ("fix", "Исправлено"),
"improve": ("improve", "Улучшено"),
"important": ("important", "Важно"),
}
version_html = _escape_text(version)
date_html = _escape_text(date)
actions = _split_lines(operator_actions)
if actions:
actions_html = "\n".join(f"<li>{_inline_update_markup(action)}</li>" for action in actions)
else:
actions_html = "<li>Ознакомиться с обновлением перед работой с матчем.</li>"
item_images = item_images or []
update_items: list[str] = []
max_len = max(len(item_titles), len(item_texts), len(item_badges), len(item_images), 1)
for index in range(max_len):
title = _clean_update_text(item_titles[index] if index < len(item_titles) else "")
text = _clean_update_text(item_texts[index] if index < len(item_texts) else "")
badge = _clean_update_text(item_badges[index] if index < len(item_badges) else "new")
image = _clean_update_text(item_images[index] if index < len(item_images) else "")
if not title and not text:
continue
badge_class, badge_label = badge_meta.get(badge, badge_meta["new"])
item_title_html = _escape_text(title or "Без названия")
item_text_html = _paragraphs_html(text) + _image_html(image, title or "Скриншот обновления")
update_items.append(
f''' <div class="update-item">
<span class="update-badge {badge_class}">{badge_label}</span>
<div class="update-content">
<div class="update-title">{item_title_html}</div>
<div class="update-text">
{item_text_html}
</div>
</div>
</div>'''
)
if not update_items:
update_items.append(
''' <div class="update-item">
<span class="update-badge new">Новое</span>
<div class="update-content">
<div class="update-title">Обновление системы</div>
<div class="update-text">
<p>Описание не заполнено.</p>
</div>
</div>
</div>'''
)
items_html = "\n".join(update_items)
return f'''
<details class="update-details" open>
<summary>
<article class="update-card latest">
<div class="update-header">
<div>
<div class="update-version-big update-date">Версия {version_html}</div>
<div class="update-version">{date_html}</div>
</div>
<div class="update-toggle"></div>
</div>
<div class="update-actions">
<div class="update-actions-title">Что сделать оператору</div>
<ul>
{actions_html}
</ul>
</div>
<div class="update-items">
{items_html}
</div>
</article>
</summary>
</details>
'''.rstrip()
def _update_latest_summary(html_text: str, version: str, date: str, item_titles: list[str]) -> str:
titles = [title.strip() for title in item_titles if title and title.strip()]
if not titles:
titles = ["Добавлено новое обновление системы."]
list_items = "\n".join(f" <li>{_escape_text(title)}</li>" for title in titles[:5])
summary_html = f''' <div class="latest-update-box">
<div class="latest-update-title">Последние изменения: версия {_escape_text(version)} от {_escape_text(date)}</div>
<ul class="latest-update-list">
{list_items}
</ul>
</div>'''
pattern = re.compile(r' <div class="latest-update-box">.*? </div>', re.S)
return pattern.sub(summary_html, html_text, count=1)
def add_update_to_info_template(
version: str,
date: str,
operator_actions: str,
item_badges: list[str],
item_titles: list[str],
item_texts: list[str],
item_images: list[str] | None = None,
) -> None:
version = _clean_update_text(version)
date = _format_update_date(date)
if not version:
raise ValueError("Укажите номер версии.")
if not date:
raise ValueError("Укажите дату обновления.")
html_text = INFO_TEMPLATE_PATH.read_text(encoding="utf-8")
insert_marker = ' <div class="updates-timeline">'
if insert_marker not in html_text:
raise RuntimeError("Не найден блок updates-timeline в templates/info.html.")
# У старых карточек убираем отметку latest, чтобы зелёная лента NEW была только у свежего обновления.
html_text = html_text.replace('update-card latest', 'update-card')
new_update_html = _build_update_block(
version=version,
date=date,
operator_actions=operator_actions,
item_badges=item_badges,
item_titles=item_titles,
item_texts=item_texts,
item_images=item_images,
)
html_text = html_text.replace(
insert_marker,
insert_marker + "\n" + new_update_html,
1,
)
html_text = _update_latest_summary(html_text, version, date, item_titles)
INFO_TEMPLATE_PATH.write_text(html_text, encoding="utf-8")
@app.get("/info", response_class=HTMLResponse)
def info_page(request: Request):
return templates.TemplateResponse(name="info.html", request=request, context={})
def render_admin_db_index(
request: Request,
parser_output: str | None = None,
parser_name: str | None = None,
parser_success: bool | None = None,
update_output: str | None = None,
update_success: bool | None = None,
settings_output: str | None = None,
settings_success: bool | None = None,
):
try:
parser_sources = list_parser_sources()
default_parser_source_key = get_default_source_key()
except Exception:
parser_sources = [
{
"key": "SUPERLEAGUE",
"title": "Суперлига 2026",
"logo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Teams Logos",
"photo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo",
},
{
"key": "RUSSIAN_CUP",
"title": "Кубок России 2026",
"logo_base_path": r"D:\Графика\ФУТБОЛ\Кубок России 2026\Teams Logos",
"photo_base_path": r"D:\Графика\ФУТБОЛ\Кубок России 2026\Photo",
},
]
default_parser_source_key = "SUPERLEAGUE"
try:
project_settings = build_project_settings_context()
except Exception:
project_settings = {"storage_title": "База данных", "groups": [], "editable_keys": []}
return templates.TemplateResponse(
name="admin_db_index.html",
request=request,
context={
"current_user": getattr(request.state, "current_user", None),
"parser_sources": parser_sources,
"default_parser_source_key": default_parser_source_key,
"parser_output": parser_output,
"parser_name": parser_name,
"parser_success": parser_success,
"update_output": update_output,
"update_success": update_success,
"settings_output": settings_output,
"settings_success": settings_success,
"project_settings": project_settings,
},
)
@app.get("/admin/db", response_class=HTMLResponse)
def admin_db_index(request: Request):
return render_admin_db_index(request)
@app.post("/admin/db/run-parser", response_class=HTMLResponse)
def admin_db_run_parser(
request: Request,
parser_name: str | None = Form(default=None),
parser_names: List[str] = Form(default=[]),
parser_source: str | None = Form(default=None),
):
current_user = getattr(request.state, "current_user", None)
if not current_user or current_user.get("role") != "admin":
return RedirectResponse(url="/login", status_code=303)
parser_map = {
"teams": ("Команды", run_parser_teams),
"players": ("Игроки", run_parser_players),
"schedule": ("Расписание", run_parser_schedule),
"standings": ("Турнирка", run_parser_standings),
}
selected_parser_names = list(parser_names or [])
if not selected_parser_names and parser_name:
selected_parser_names = [parser_name]
selected_parser_names = [name for name in selected_parser_names if name in parser_map]
if not selected_parser_names:
return render_admin_db_index(
request,
parser_output="Выберите хотя бы один тип данных для парсинга.",
parser_name="Импорт данных",
parser_success=False,
)
try:
source = get_parser_source(parser_source)
except Exception as exc:
return render_admin_db_index(
request,
parser_output=str(exc),
parser_name="Импорт данных",
parser_success=False,
)
titles = [parser_map[name][0] for name in selected_parser_names]
title = f"{source['title']}: {', '.join(titles)}"
buffer = io.StringIO()
success = True
with contextlib.redirect_stdout(buffer), contextlib.redirect_stderr(buffer):
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Источник: {source['title']}")
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Запуск: {', '.join(titles)}")
for selected_name in selected_parser_names:
item_title, parser_func = parser_map[selected_name]
print()
print(f"===== {item_title} =====")
try:
parser_func(source_key=source["key"])
print(
f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {item_title}: успешно"
)
except Exception:
success = False
print(
f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {item_title}: ошибка"
)
print(traceback.format_exc())
if success:
print()
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Импорт завершён успешно")
else:
print()
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Импорт завершён с ошибками")
output = buffer.getvalue().strip() or "Парсер не вернул сообщений."
return render_admin_db_index(
request,
parser_output=output,
parser_name=title,
parser_success=success,
)
@app.post("/admin/db/project-settings", response_class=HTMLResponse)
async def admin_db_save_project_settings(request: Request):
current_user = getattr(request.state, "current_user", None)
if not current_user or current_user.get("role") != "admin":
return RedirectResponse(url="/login", status_code=303)
try:
form = await request.form()
form_values = {key: str(value) for key, value in form.items()}
updated_keys = save_project_settings(form_values)
output = [
"Настройки проекта сохранены в базе данных.",
f"Сохранено полей: {len(updated_keys)}",
]
if updated_keys:
output.append("Поля:")
output.extend([f"- {key}" for key in updated_keys])
output.append("")
output.append(".env больше не используется для ссылок, сезонов, путей логотипов и фотографий.")
return render_admin_db_index(
request,
settings_output="\n".join(output),
settings_success=True,
)
except Exception:
return render_admin_db_index(
request,
settings_output="Ошибка при сохранении настроек проекта:\n" + traceback.format_exc(),
settings_success=False,
)
@app.post("/admin/db/add-update", response_class=HTMLResponse)
def admin_db_add_update(
request: Request,
version: str = Form(...),
date: str = Form(...),
operator_actions: str = Form(default=""),
item_badge: list[str] = Form(default=[]),
item_title: list[str] = Form(default=[]),
item_text: list[str] = Form(default=[]),
item_image: list[str] = Form(default=[]),
):
current_user = getattr(request.state, "current_user", None)
if not current_user or current_user.get("role") != "admin":
return RedirectResponse(url="/login", status_code=303)
try:
add_update_to_info_template(
version=version,
date=date,
operator_actions=operator_actions,
item_badges=item_badge,
item_titles=item_title,
item_texts=item_text,
item_images=item_image,
)
return render_admin_db_index(
request,
update_output=f"Обновление версии {version.strip()} добавлено в templates/info.html и будет отображаться сверху на странице /info.",
update_success=True,
)
except Exception:
return render_admin_db_index(
request,
update_output="Ошибка при добавлении обновления:\n" + traceback.format_exc(),
update_success=False,
)
@app.post("/admin/db/create-account", response_class=HTMLResponse)
def admin_db_create_account(
request: Request,
username: str = Form(...),
password: str = Form(...),
role: str = Form(default="operator"),
):
current_user = getattr(request.state, "current_user", None)
if not current_user or current_user.get("role") != "admin":
return RedirectResponse(url="/login", status_code=303)
username = username.strip()
role = (role or "operator").strip().lower()
if not username:
return render_admin_db_index(
request, parser_output="Логин обязателен.", parser_success=False
)
if not password:
return render_admin_db_index(
request, parser_output="Пароль обязателен.", parser_success=False
)
if len(password) < 6:
return render_admin_db_index(
request,
parser_output="Пароль должен быть не короче 6 символов.",
parser_success=False,
)
if role not in {"admin", "operator"}:
return render_admin_db_index(
request,
parser_output="Роль должна быть admin или operator.",
parser_success=False,
)
password_hash = hash_password(password)
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO admin_users (username, password_hash, role, is_active)
VALUES (%s, %s, %s, TRUE)
ON CONFLICT (username) DO NOTHING
RETURNING id;
""",
(username, password_hash, role),
)
row = cur.fetchone()
conn.commit()
if row:
return render_admin_db_index(
request,
parser_output=f"Пользователь создан: {username} (id={row[0]})\nРоль: {role}",
parser_success=True,
)
return render_admin_db_index(
request,
parser_output=f"Пользователь '{username}' уже существует.",
parser_success=False,
)
except Exception:
conn.rollback()
return render_admin_db_index(
request,
parser_output="Ошибка при создании аккаунта:\n" + traceback.format_exc(),
parser_success=False,
)
finally:
conn.close()