1. добавил в админку сортировку игроков и Фото (если галочка стоит, то путь к фото генерируется автоматически)

2. фото обновляются прям в json
This commit is contained in:
2026-06-22 15:31:07 +03:00
parent cd370cf04f
commit f63061a06d
10 changed files with 620 additions and 98 deletions

121
app.py
View File

@@ -23,7 +23,7 @@ from pathlib import Path
import io import io
import asyncio import asyncio
import time import time
from urllib.parse import quote from urllib.parse import quote, urlencode
import traceback import traceback
import contextlib import contextlib
import os import os
@@ -60,6 +60,8 @@ from repositories.player_repository import (
search_players_for_admin, search_players_for_admin,
get_player_by_id, get_player_by_id,
update_player_admin, update_player_admin,
update_player_photo_enabled,
ensure_player_photo_enabled_column,
) )
from repositories.referee_repository import ( from repositories.referee_repository import (
@@ -117,6 +119,8 @@ from repositories.match_lineup_repository import (
) )
from services.vmix_json_service import ( from services.vmix_json_service import (
EMPTY_PHOTO_PATH,
build_generated_player_photo_path,
build_lineup_json, build_lineup_json,
get_vmix_match_info_by_token, get_vmix_match_info_by_token,
get_vmix_standings, get_vmix_standings,
@@ -124,6 +128,7 @@ from services.vmix_json_service import (
get_vmix_team_formations, get_vmix_team_formations,
get_vmix_scoreboard_info, get_vmix_scoreboard_info,
get_vmix_players_goal, get_vmix_players_goal,
resolve_player_photo_for_json,
) )
from scheduler import run_scheduler from scheduler import run_scheduler
@@ -139,10 +144,50 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") 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") @app.on_event("startup")
def start_scheduler(): def start_scheduler():
import threading import threading
try:
ensure_player_photo_enabled_column()
except Exception:
traceback.print_exc()
thread = threading.Thread( thread = threading.Thread(
target=run_scheduler, kwargs={"with_signal_handlers": False}, daemon=True target=run_scheduler, kwargs={"with_signal_handlers": False}, daemon=True
) )
@@ -370,6 +415,12 @@ async def admin_auth_middleware(request: Request, call_next):
} }
response = await call_next(request) 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 return response
@@ -803,23 +854,57 @@ def admin_db_index(request: Request):
@app.get("/admin/db/players", response_class=HTMLResponse) @app.get("/admin/db/players", response_class=HTMLResponse)
def admin_db_players(request: Request, q: str = Query(default="")): 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"}) # denied = require_role(request, {"admin"})
# if denied: # if denied:
# return denied # return denied
players = search_players_for_admin(q) 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( return templates.TemplateResponse(
name="admin_db_players.html", name="admin_db_players.html",
request=request, request=request,
context={ context={
"q": q, "q": q,
"sort": sort,
"direction": direction,
"sort_links": build_player_admin_sort_links(q, sort, direction),
"players": players, "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) @app.get("/admin/db/players/{player_id}/edit", response_class=HTMLResponse)
def admin_db_player_edit(request: Request, player_id: int): def admin_db_player_edit(request: Request, player_id: int):
player = get_player_by_id(player_id) player = get_player_by_id(player_id)
@@ -846,6 +931,7 @@ def admin_db_player_edit_submit(
birth_date: str = Form(default=""), birth_date: str = Form(default=""),
photo: str = Form(default=""), photo: str = Form(default=""),
video: str = Form(default=""), video: str = Form(default=""),
photo_enabled: bool = Form(default=False),
): ):
update_player_admin( update_player_admin(
player_id=player_id, player_id=player_id,
@@ -857,6 +943,7 @@ def admin_db_player_edit_submit(
birth_date=birth_date, birth_date=birth_date,
photo=photo, photo=photo,
video=video, video=video,
photo_enabled=photo_enabled,
) )
return RedirectResponse( return RedirectResponse(
@@ -1358,7 +1445,7 @@ EMPTY_PLAYER = {
"pos": " ", "pos": " ",
"number_lastname_amp_K": " ", "number_lastname_amp_K": " ",
"number_fullname": " ", "number_fullname": " ",
"photo": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo\EMPTY.png", "photo": EMPTY_PHOTO_PATH,
} }
@@ -1586,7 +1673,7 @@ EMPTY_PLAYER_FORMATION = {
"number": " ", "number": " ",
"number_lastname_amp_K": " ", "number_lastname_amp_K": " ",
"position": " ", "position": " ",
"photo": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo\EMPTY.png", "photo": EMPTY_PHOTO_PATH,
} }
@@ -1613,13 +1700,23 @@ def build_vmix_formation_response(rows, team_id, team_name):
"number_lastname_amp_K": f"{number} {lastname}{suffix_str}".strip(), "number_lastname_amp_K": f"{number} {lastname}{suffix_str}".strip(),
"number": number, "number": number,
"position": position, "position": position,
"photo": ( "photo": resolve_player_photo_for_json(
r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo" {
+ "\\" "player_id": p[7] if len(p) > 7 else None,
+ team_name "team_id": p[8] if len(p) > 8 else team_id,
+ "\\" "player_name": p[9] if len(p) > 9 else "",
+ (p[0] + " " + p[4]).strip() "last_name": lastname,
+ ".png" "first_name": p[4] if len(p) > 4 else "",
"number": number,
"position": position,
"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 "",
),
fallback_team_id=team_id,
), ),
} }
) )

View File

@@ -230,9 +230,12 @@ def get_match_lineup_for_vmix(
COALESCE(mlp.number::text, '') AS number, COALESCE(mlp.number::text, '') AS number,
COALESCE(p.position, '') AS position, COALESCE(p.position, '') AS position,
COALESCE(mlp.is_captain, FALSE) AS is_captain, COALESCE(mlp.is_captain, FALSE) AS is_captain,
COALESCE(p.position, '') AS pos COALESCE(p.position, '') AS pos,
p.photo,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled,
CASE WHEN mlp.side = 'home' THEN %s ELSE %s END AS team_id
FROM match_lineup_players mlp FROM match_lineup_players mlp
JOIN players p LEFT JOIN players p
ON p.id = mlp.player_id ON p.id = mlp.player_id
WHERE mlp.match_id = %s WHERE mlp.match_id = %s
ORDER BY ORDER BY
@@ -248,7 +251,7 @@ def get_match_lineup_for_vmix(
COALESCE(p.first_name, ''), COALESCE(p.first_name, ''),
p.id p.id
""", """,
(match_id,), (home_team_id, away_team_id, match_id),
) )
player_rows = cur.fetchall() player_rows = cur.fetchall()
@@ -293,6 +296,9 @@ def get_match_lineup_for_vmix(
"position": row[8] or "", "position": row[8] or "",
"is_captain": bool(row[9]), "is_captain": bool(row[9]),
"pos": row[10] or "", "pos": row[10] or "",
"photo": row[11] or "",
"photo_enabled": bool(row[12]),
"team_id": row[13],
} }
side = row[0] side = row[0]

View File

@@ -13,7 +13,9 @@ def get_match_lineups_grouped(match_id: int, home_team_id: int, away_team_id: in
ml.is_captain, ml.is_captain,
p.last_name, p.last_name,
p.first_name, p.first_name,
p.position as pos p.position as pos,
p.photo,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM match_lineups ml FROM match_lineups ml
LEFT JOIN players p ON p.id = ml.player_id LEFT JOIN players p ON p.id = ml.player_id
WHERE ml.match_id = %s; WHERE ml.match_id = %s;
@@ -46,10 +48,13 @@ def get_match_lineups_grouped(match_id: int, home_team_id: int, away_team_id: in
last_name, last_name,
first_name, first_name,
pos, pos,
photo,
photo_enabled,
) = row ) = row
item = { item = {
"player_id": player_id, "player_id": player_id,
"team_id": team_id,
"number": number or "", "number": number or "",
"last_name": last_name or "", "last_name": last_name or "",
"first_name": first_name or "", "first_name": first_name or "",
@@ -57,6 +62,8 @@ def get_match_lineups_grouped(match_id: int, home_team_id: int, away_team_id: in
"position": position or "", "position": position or "",
"is_captain": bool(is_captain), "is_captain": bool(is_captain),
"pos": pos, "pos": pos,
"photo": photo or "",
"photo_enabled": bool(photo_enabled),
} }
if team_id == home_team_id and lineup_type == "starting": if team_id == home_team_id and lineup_type == "starting":

View File

@@ -222,50 +222,91 @@ def get_player_id_by_external_id(external_id: str) -> int | None:
conn.close() conn.close()
from db import get_connection PLAYER_ADMIN_SORT_COLUMNS = {
"id": "p.id",
"full_name": "p.full_name",
"first_name": "p.first_name",
"last_name": "p.last_name",
"external_id": "p.external_id",
"position": "p.position",
"team_name": "t.name",
"photo": "p.photo_enabled",
}
def search_players_for_admin(q: str = "") -> list[dict]: def ensure_player_photo_enabled_column() -> None:
"""Добавляет флаг использования фото игрока, если база ещё не обновлена."""
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(
"""
ALTER TABLE players
ADD COLUMN IF NOT EXISTS photo_enabled BOOLEAN NOT NULL DEFAULT FALSE;
"""
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _normalize_player_sort(sort_by: str = "id", sort_dir: str = "desc") -> tuple[str, str, str, str]:
sort_by = (sort_by or "id").strip()
if sort_by not in PLAYER_ADMIN_SORT_COLUMNS:
sort_by = "id"
sort_dir = (sort_dir or "desc").strip().lower()
if sort_dir not in {"asc", "desc"}:
sort_dir = "desc"
return sort_by, sort_dir, PLAYER_ADMIN_SORT_COLUMNS[sort_by], sort_dir.upper()
def search_players_for_admin(q: str = "", sort_by: str = "id", sort_dir: str = "desc") -> list[dict]:
sort_by, sort_dir, order_column, order_direction = _normalize_player_sort(sort_by, sort_dir)
limit = 200
conn = get_connection()
try:
with conn.cursor() as cur:
params: list = []
where_sql = ""
if q.strip(): if q.strip():
pattern = f"%{q.strip()}%" pattern = f"%{q.strip()}%"
cur.execute( where_sql = """
"""
SELECT
p.id,
p.full_name,
p.first_name,
p.last_name,
p.external_id,
p.position
FROM players p
WHERE WHERE
p.full_name ILIKE %s p.full_name ILIKE %s
OR p.first_name ILIKE %s OR p.first_name ILIKE %s
OR p.last_name ILIKE %s OR p.last_name ILIKE %s
OR COALESCE(p.external_id, '') ILIKE %s OR COALESCE(p.external_id, '') ILIKE %s
ORDER BY p.full_name ASC, p.id ASC OR COALESCE(t.name, '') ILIKE %s
LIMIT 200 """
""", params.extend([pattern, pattern, pattern, pattern, pattern])
(pattern, pattern, pattern, pattern),
) params.append(limit)
else: cur.execute(
cur.execute( f"""
""" SELECT
SELECT p.id,
p.id, p.full_name,
p.full_name, p.first_name,
p.first_name, p.last_name,
p.last_name, p.external_id,
p.external_id, p.position,
p.position COALESCE(t.name, '') AS team_name,
FROM players p COALESCE(p.photo_enabled, FALSE) AS photo_enabled
ORDER BY p.id DESC FROM players p
LIMIT 200 LEFT JOIN teams t ON t.id = p.team_id
""" {where_sql}
) ORDER BY {order_column} {order_direction} NULLS LAST, p.id ASC
LIMIT %s
""",
tuple(params),
)
rows = cur.fetchall() rows = cur.fetchall()
@@ -277,6 +318,8 @@ def search_players_for_admin(q: str = "") -> list[dict]:
"last_name": row[3] or "", "last_name": row[3] or "",
"external_id": row[4] or "", "external_id": row[4] or "",
"position": row[5] or "", "position": row[5] or "",
"team_name": row[6] or "",
"photo_enabled": bool(row[7]),
} }
for row in rows for row in rows
] ]
@@ -299,8 +342,11 @@ def get_player_by_id(player_id: int) -> dict | None:
p.position, p.position,
p.born, p.born,
p.photo, p.photo,
p.video p.video,
COALESCE(t.name, '') AS team_name,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM players p FROM players p
LEFT JOIN teams t ON t.id = p.team_id
WHERE p.id = %s WHERE p.id = %s
LIMIT 1 LIMIT 1
""", """,
@@ -321,6 +367,8 @@ def get_player_by_id(player_id: int) -> dict | None:
"birth_date": normalize_birth_date_for_input(row[6]), "birth_date": normalize_birth_date_for_input(row[6]),
"photo": row[7] or "", "photo": row[7] or "",
"video": row[8] or "", "video": row[8] or "",
"team_name": row[9] or "",
"photo_enabled": bool(row[10]),
} }
finally: finally:
conn.close() conn.close()
@@ -336,6 +384,7 @@ def update_player_admin(
birth_date: str = "", birth_date: str = "",
photo: str = "", photo: str = "",
video: str = "", video: str = "",
photo_enabled: bool = False,
) -> None: ) -> None:
conn = get_connection() conn = get_connection()
try: try:
@@ -351,7 +400,9 @@ def update_player_admin(
position = %s, position = %s,
born = NULLIF(%s, '')::date, born = NULLIF(%s, '')::date,
photo = NULLIF(%s, ''), photo = NULLIF(%s, ''),
video = NULLIF(%s, '') video = NULLIF(%s, ''),
photo_enabled = %s,
updated_at = NOW()
WHERE id = %s WHERE id = %s
""", """,
( (
@@ -363,6 +414,7 @@ def update_player_admin(
birth_date.strip(), birth_date.strip(),
photo.strip(), photo.strip(),
video.strip(), video.strip(),
bool(photo_enabled),
player_id, player_id,
), ),
) )
@@ -372,3 +424,25 @@ def update_player_admin(
raise raise
finally: finally:
conn.close() conn.close()
def update_player_photo_enabled(player_id: int, photo_enabled: bool = False) -> None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE players
SET
photo_enabled = %s,
updated_at = NOW()
WHERE id = %s
""",
(bool(photo_enabled), player_id),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()

View File

@@ -3,6 +3,232 @@ from db import get_connection
from repositories.match_lineup_repository import get_match_lineup_for_vmix from repositories.match_lineup_repository import get_match_lineup_for_vmix
PHOTO_BASE_PATH = r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo"
EMPTY_PHOTO_PATH = PHOTO_BASE_PATH + r"\EMPTY.png"
def build_generated_player_photo_path(team_name: str, last_name: str, first_name: str) -> str:
return (
PHOTO_BASE_PATH
+ "\\"
+ str(team_name or "")
+ "\\"
+ (str(last_name or "") + " " + str(first_name or "")).strip()
+ ".png"
)
def resolve_player_photo(photo_enabled: bool, generated_photo: str) -> str:
if photo_enabled:
return generated_photo
return EMPTY_PHOTO_PATH
def _normalize_text(value) -> str:
return " ".join(str(value or "").strip().lower().split())
def _player_name_variants(player: dict) -> set[str]:
first_name = _normalize_text(player.get("first_name"))
last_name = _normalize_text(player.get("last_name"))
player_name = _normalize_text(player.get("player_name"))
full_name = _normalize_text(player.get("full_name"))
variants = {player_name, full_name}
if first_name or last_name:
variants.add(f"{last_name} {first_name}".strip())
variants.add(f"{first_name} {last_name}".strip())
return {v for v in variants if v}
def _candidate_name_variants(row: tuple) -> set[str]:
_, full_name, last_name, first_name, *_ = row
full_name = _normalize_text(full_name)
last_name = _normalize_text(last_name)
first_name = _normalize_text(first_name)
variants = {full_name}
if first_name or last_name:
variants.add(f"{last_name} {first_name}".strip())
variants.add(f"{first_name} {last_name}".strip())
return {v for v in variants if v}
def _find_player_photo_state(
*,
player_id=None,
team_id=None,
player_name: str = "",
first_name: str = "",
last_name: str = "",
full_name: str = "",
number: str = "",
) -> tuple[str | None, bool] | None:
"""
Берёт актуальное состояние галочки photo_enabled прямо из players.
Если в составе нет корректного player_id, пробует найти игрока по команде, номеру и имени.
Это важно для старых/загруженных с сайта составов, где player_id мог быть пустым.
Значение p.photo здесь не используется для JSON-пути: путь генерируется по старой схеме.
"""
try:
player_id_int = int(player_id) if str(player_id or "").strip().isdigit() else None
except Exception:
player_id_int = None
try:
team_id_int = int(team_id) if str(team_id or "").strip().isdigit() else None
except Exception:
team_id_int = None
target_number = str(number or "").strip()
target_player = {
"player_name": player_name,
"first_name": first_name,
"last_name": last_name,
"full_name": full_name,
}
target_names = _player_name_variants(target_player)
where_parts = []
params = []
if player_id_int is not None:
where_parts.append("p.id = %s")
params.append(player_id_int)
if team_id_int is not None:
team_conditions = []
team_params = []
if target_number:
team_conditions.append("COALESCE(p.number::text, '') = %s")
team_params.append(target_number)
for name in sorted(target_names):
like = f"%{name}%"
team_conditions.append(
"""
(
LOWER(COALESCE(p.full_name, '')) = %s
OR LOWER(TRIM(COALESCE(p.last_name, '') || ' ' || COALESCE(p.first_name, ''))) = %s
OR LOWER(TRIM(COALESCE(p.first_name, '') || ' ' || COALESCE(p.last_name, ''))) = %s
OR LOWER(COALESCE(p.full_name, '')) LIKE %s
)
"""
)
team_params.extend([name, name, name, like])
if team_conditions:
where_parts.append(f"(p.team_id = %s AND ({' OR '.join(team_conditions)}))")
params.append(team_id_int)
params.extend(team_params)
if not where_parts:
return None
query = f"""
SELECT
p.id,
COALESCE(p.full_name, ''),
COALESCE(p.last_name, ''),
COALESCE(p.first_name, ''),
COALESCE(p.number::text, ''),
p.team_id,
p.photo,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM players p
WHERE {' OR '.join(where_parts)}
LIMIT 50
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(query, tuple(params))
rows = cur.fetchall()
finally:
conn.close()
best_row = None
best_score = -1
for row in rows:
row_id, _, _, _, row_number, row_team_id, *_ = row
row_names = _candidate_name_variants(row)
id_match = player_id_int is not None and int(row_id) == player_id_int
team_match = team_id_int is not None and int(row_team_id) == team_id_int
number_match = bool(target_number) and str(row_number or "").strip() == target_number
name_match = bool(target_names.intersection(row_names))
partial_name_match = False
if target_names and row_names:
partial_name_match = any(
target in candidate or candidate in target
for target in target_names
for candidate in row_names
if len(target) >= 4 and len(candidate) >= 4
)
# Если ID выглядит как номер игрока и случайно совпал с чужим players.id,
# не считаем его надёжным без совпадения команды/имени/номера.
trusted_id_match = id_match and (
(team_id_int is None or team_match)
and (not target_number and not target_names or number_match or name_match or partial_name_match)
)
score = 0
if trusted_id_match:
score += 100
if team_match:
score += 40
if number_match:
score += 30
if name_match:
score += 35
elif partial_name_match:
score += 15
# Для поиска без надёжного ID нужно хотя бы совпадение команды и номера/имени.
if not trusted_id_match and not (team_match and (number_match or name_match or partial_name_match)):
continue
if score > best_score:
best_score = score
best_row = row
if not best_row:
return None
return best_row[6], bool(best_row[7])
def resolve_player_photo_for_json(
player: dict,
generated_photo: str,
fallback_team_id=None,
) -> str:
state = _find_player_photo_state(
player_id=player.get("player_id") or player.get("id"),
team_id=player.get("team_id") or fallback_team_id,
player_name=player.get("player_name") or "",
first_name=player.get("first_name") or "",
last_name=player.get("last_name") or "",
full_name=player.get("full_name") or "",
number=player.get("number") or player.get("player_number") or "",
)
if state is not None:
_photo, photo_enabled = state
return resolve_player_photo(photo_enabled=photo_enabled, generated_photo=generated_photo)
return resolve_player_photo(
photo_enabled=player.get("photo_enabled"),
generated_photo=generated_photo,
)
def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, team_b_name): def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, team_b_name):
lineups = get_match_lineup_for_vmix( lineups = get_match_lineup_for_vmix(
match_id=match_id, match_id=match_id,
@@ -14,6 +240,8 @@ def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, t
result = [] result = []
fallback_team_id = home_team_id if str(name).startswith("home") else away_team_id
for p in players: for p in players:
suffix = [] suffix = []
if "вратарь" in (p.get("pos") or "").lower(): if "вратарь" in (p.get("pos") or "").lower():
@@ -40,17 +268,16 @@ def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, t
+ (f" {', '.join(suffix)}" if suffix else ""), + (f" {', '.join(suffix)}" if suffix else ""),
"pos": p.get("pos", ""), "pos": p.get("pos", ""),
"position": p.get("position", ""), "position": p.get("position", ""),
"photo": ( "photo": resolve_player_photo_for_json(
r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo" p,
+ "\\" generated_photo=build_generated_player_photo_path(
+ (team_a_name if "home" in name else team_b_name) team_name=team_a_name if "home" in name else team_b_name,
+ "\\" last_name=p.get("last_name", ""),
+ (p.get("last_name", "") first_name=p.get("first_name", ""),
+ " " ),
+ p.get("first_name", "")).strip() fallback_team_id=fallback_team_id,
+ ".png"
), ),
} }
) )
return {"players": result} return {"players": result}
@@ -231,11 +458,16 @@ def get_vmix_schedule(session_token: str):
def get_vmix_team_formations(session_token: str, team_id: int): def get_vmix_team_formations(session_token: str, team_id: int):
query = """ query = """
SELECT SELECT
p.last_name, COALESCE(NULLIF(p.last_name, ''), NULLIF(mf.player_name, ''), '') AS last_name,
mf.is_captain, mf.is_captain,
p.number, COALESCE(NULLIF(mf.number, ''), p.number::text, '') AS number,
p.position, COALESCE(NULLIF(mf.position, ''), p.position, '') AS position,
p.first_name COALESCE(p.first_name, '') AS first_name,
p.photo,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled,
mf.player_id,
mf.team_id,
COALESCE(mf.player_name, '') AS player_name
FROM match_formations mf FROM match_formations mf
LEFT JOIN players p ON p.id = mf.player_id LEFT JOIN players p ON p.id = mf.player_id
WHERE mf.match_id = %s and mf.team_id = %s WHERE mf.match_id = %s and mf.team_id = %s

View File

@@ -0,0 +1,2 @@
ALTER TABLE players
ADD COLUMN IF NOT EXISTS photo_enabled BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -143,6 +143,31 @@
object-fit: contain; object-fit: contain;
display: block; display: block;
} }
input[readonly] {
color: var(--muted);
cursor: default;
}
.checkbox-row {
min-height: 42px;
display: flex;
align-items: center;
gap: 10px;
color: var(--text);
}
.checkbox-row input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--accent);
}
.hint {
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
</style> </style>
</head> </head>
@@ -182,6 +207,11 @@
<input type="text" name="external_id" value="{{ player.external_id }}"> <input type="text" name="external_id" value="{{ player.external_id }}">
</div> </div>
<div class="form-group">
<label>Название команды</label>
<input type="text" value="{{ player.team_name }}" readonly>
</div>
<div class="form-group"> <div class="form-group">
<label>Амплуа</label> <label>Амплуа</label>
<select name="position"> <select name="position">
@@ -201,6 +231,11 @@
<div class="form-group full"> <div class="form-group full">
<label>Фото игрока</label> <label>Фото игрока</label>
<input type="text" name="photo" value="{{ player.photo }}"> <input type="text" name="photo" value="{{ player.photo }}">
<label class="checkbox-row">
<input type="checkbox" name="photo_enabled" value="true" {% if player.photo_enabled %}checked{% endif %}>
Использовать фото в JSON/vMix
</label>
<div class="hint">Если галочка включена, путь к фото в JSON генерируется по старой схеме: Photo\Команда\Фамилия Имя.png. Если выключена — отдается EMPTY.png.</div>
{% if player.photo %} {% if player.photo %}
<div class="media-preview-box"> <div class="media-preview-box">
<img src="{{ player.photo }}" alt="{{ player.full_name }}" class="media-preview-image"> <img src="{{ player.photo }}" alt="{{ player.full_name }}" class="media-preview-image">

View File

@@ -28,7 +28,7 @@
} }
.page { .page {
max-width: 1400px; max-width: 1500px;
margin: 0 auto; margin: 0 auto;
padding: 24px; padding: 24px;
} }
@@ -62,7 +62,7 @@
} }
input[type="text"] { input[type="text"] {
min-width: 280px; min-width: 320px;
min-height: 42px; min-height: 42px;
padding: 0 12px; padding: 0 12px;
border-radius: 10px; border-radius: 10px;
@@ -94,11 +94,16 @@
border: 1px solid var(--border); border: 1px solid var(--border);
} }
.table-wrap {
width: 100%;
overflow-x: auto;
border-radius: 12px;
}
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
background: var(--panel-2); background: var(--panel-2);
border-radius: 12px;
overflow: hidden; overflow: hidden;
} }
@@ -107,6 +112,7 @@
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
text-align: left; text-align: left;
font-size: 14px; font-size: 14px;
white-space: nowrap;
} }
th { th {
@@ -118,10 +124,47 @@
border-bottom: none; border-bottom: none;
} }
.sort-link {
color: var(--muted);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 6px;
}
.sort-link:hover {
color: var(--text);
}
.sort-indicator {
color: var(--accent);
min-width: 12px;
display: inline-block;
}
.id-col { .id-col {
width: 70px; width: 70px;
} }
.photo-col {
width: 80px;
text-align: center;
}
.photo-check {
width: 18px;
height: 18px;
accent-color: var(--accent);
cursor: pointer;
}
.photo-toggle-form {
margin: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.action-col { .action-col {
width: 140px; width: 140px;
text-align: right; text-align: right;
@@ -143,41 +186,64 @@
<div class="title">Игроки</div> <div class="title">Игроки</div>
<form method="get" action="/admin/db/players" class="search-form"> <form method="get" action="/admin/db/players" class="search-form">
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по ФИО или external_id"> <input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="direction" value="{{ direction }}">
<input type="text" name="q" value="{{ q }}" placeholder="Поиск по ФИО, external_id или команде">
<button type="submit" class="btn btn-primary">Найти</button> <button type="submit" class="btn btn-primary">Найти</button>
<a href="/admin/db" class="btn btn-secondary">Назад</a> <a href="/admin/db" class="btn btn-secondary">Назад</a>
</form> </form>
</div> </div>
{% if players %} {% if players %}
<table> <div class="table-wrap">
<thead> <table>
<tr> <thead>
<th class="id-col">ID</th> <tr>
<th>ФИО</th> <th class="id-col"><a class="sort-link" href="{{ sort_links.id.url }}">ID <span class="sort-indicator">{{ sort_links.id.indicator }}</span></a></th>
<th>Имя</th> <th><a class="sort-link" href="{{ sort_links.full_name.url }}">ФИО <span class="sort-indicator">{{ sort_links.full_name.indicator }}</span></a></th>
<th>Фамилия</th> <th><a class="sort-link" href="{{ sort_links.first_name.url }}">Имя <span class="sort-indicator">{{ sort_links.first_name.indicator }}</span></a></th>
<th>External ID</th> <th><a class="sort-link" href="{{ sort_links.last_name.url }}">Фамилия <span class="sort-indicator">{{ sort_links.last_name.indicator }}</span></a></th>
<th>Амплуа</th> <th><a class="sort-link" href="{{ sort_links.external_id.url }}">External ID <span class="sort-indicator">{{ sort_links.external_id.indicator }}</span></a></th>
<th class="action-col"></th> <th><a class="sort-link" href="{{ sort_links.position.url }}">Амплуа <span class="sort-indicator">{{ sort_links.position.indicator }}</span></a></th>
</tr> <th><a class="sort-link" href="{{ sort_links.team_name.url }}">Название команды <span class="sort-indicator">{{ sort_links.team_name.indicator }}</span></a></th>
</thead> <th class="photo-col"><a class="sort-link" href="{{ sort_links.photo.url }}">Фото <span class="sort-indicator">{{ sort_links.photo.indicator }}</span></a></th>
<tbody> <th class="action-col"></th>
{% for p in players %} </tr>
<tr> </thead>
<td>{{ p.id }}</td> <tbody>
<td>{{ p.full_name }}</td> {% for p in players %}
<td>{{ p.first_name }}</td> <tr>
<td>{{ p.last_name }}</td> <td>{{ p.id }}</td>
<td>{{ p.external_id }}</td> <td>{{ p.full_name }}</td>
<td>{{ p.position }}</td> <td>{{ p.first_name }}</td>
<td class="action-col"> <td>{{ p.last_name }}</td>
<a href="/admin/db/players/{{ p.id }}/edit" class="btn btn-secondary">Редактировать</a> <td>{{ p.external_id }}</td>
</td> <td>{{ p.position }}</td>
</tr> <td>{{ p.team_name }}</td>
{% endfor %} <td class="photo-col">
</tbody> <form method="post" action="/admin/db/players/{{ p.id }}/photo-enabled" class="photo-toggle-form">
</table> <input type="hidden" name="q" value="{{ q }}">
<input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="direction" value="{{ direction }}">
<input
type="checkbox"
name="photo_enabled"
value="true"
class="photo-check"
title="Использовать фото игрока в JSON/vMix"
onchange="this.form.submit()"
{% if p.photo_enabled %}checked{% endif %}
>
</form>
</td>
<td class="action-col">
<a href="/admin/db/players/{{ p.id }}/edit" class="btn btn-secondary">Редактировать</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %} {% else %}
<div class="empty-box">Игроки не найдены.</div> <div class="empty-box">Игроки не найдены.</div>
{% endif %} {% endif %}

BIN
vmix.zip

Binary file not shown.

View File

@@ -27,6 +27,9 @@ CREATE TABLE players (
number VARCHAR(20), number VARCHAR(20),
position VARCHAR(50), position VARCHAR(50),
birth_date DATE, birth_date DATE,
photo TEXT,
photo_enabled BOOLEAN NOT NULL DEFAULT FALSE,
video TEXT,
height_cm INTEGER, height_cm INTEGER,
weight_kg INTEGER, weight_kg INTEGER,
is_active BOOLEAN NOT NULL DEFAULT TRUE, is_active BOOLEAN NOT NULL DEFAULT TRUE,