2574 lines
83 KiB
Python
2574 lines
83 KiB
Python
from fastapi import (
|
||
FastAPI,
|
||
Form,
|
||
Request,
|
||
Query,
|
||
HTTPException,
|
||
WebSocket,
|
||
WebSocketDisconnect,
|
||
)
|
||
from datetime import datetime
|
||
from fastapi.responses import (
|
||
HTMLResponse,
|
||
RedirectResponse,
|
||
JSONResponse,
|
||
RedirectResponse,
|
||
StreamingResponse,
|
||
)
|
||
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, 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,
|
||
)
|
||
|
||
from repositories.referee_repository import (
|
||
search_referees_for_admin,
|
||
get_referee_by_id,
|
||
update_referee_admin,
|
||
get_all_referees,
|
||
# 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,
|
||
)
|
||
|
||
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,
|
||
)
|
||
|
||
from repositories.match_event_repository import (
|
||
create_event,
|
||
get_events,
|
||
delete_event,
|
||
clear_events,
|
||
update_event,
|
||
)
|
||
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()
|
||
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 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]
|
||
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),
|
||
"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:
|
||
from db import get_connection
|
||
|
||
# Сначала очищаем ручные данные матча,
|
||
# чтобы парсер потом записал свежие составы и тренеров
|
||
conn = get_connection()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"DELETE FROM match_lineup_players WHERE match_id = %s",
|
||
(match_id,),
|
||
)
|
||
cur.execute(
|
||
"DELETE FROM match_coaches WHERE match_id = %s",
|
||
(match_id,),
|
||
)
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
# Потом загружаем свежие данные с сайта
|
||
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)
|
||
|
||
|
||
|
||
@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}/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]
|
||
|
||
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 = ""
|
||
|
||
|
||
@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.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": (
|
||
(rf"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Лого каналов\{row[7]}.png" if row[-1] != "RUSSIAN_CUP" else rf"D:\Графика\ФУТБОЛ\ЖФЛ Кубок России 2026\Лого каналов\{row[7]}.png")
|
||
# if row[7] and row[5] == "scheduled"
|
||
if row[2] is None and row[3] is None
|
||
else (r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Лого каналов\EMPTY.png" if row[-1] != "RUSSIAN_CUP" else r"D:\Графика\ФУТБОЛ\ЖФЛ Кубок России 2026\Лого каналов\EMPTY.png")
|
||
),
|
||
}
|
||
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)
|
||
return [
|
||
{
|
||
"home_score": row[0],
|
||
"away_score": row[1],
|
||
"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
|
||
|
||
|
||
@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()
|