1. добавил в админку сортировку игроков и Фото (если галочка стоит, то путь к фото генерируется автоматически)
2. фото обновляются прям в json
This commit is contained in:
121
app.py
121
app.py
@@ -23,7 +23,7 @@ from pathlib import Path
|
||||
import io
|
||||
import asyncio
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import quote, urlencode
|
||||
import traceback
|
||||
import contextlib
|
||||
import os
|
||||
@@ -60,6 +60,8 @@ 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 (
|
||||
@@ -117,6 +119,8 @@ from repositories.match_lineup_repository import (
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -124,6 +128,7 @@ from services.vmix_json_service import (
|
||||
get_vmix_team_formations,
|
||||
get_vmix_scoreboard_info,
|
||||
get_vmix_players_goal,
|
||||
resolve_player_photo_for_json,
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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_player_photo_enabled_column()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
thread = threading.Thread(
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -803,23 +854,57 @@ def admin_db_index(request: Request):
|
||||
|
||||
|
||||
@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"})
|
||||
# if 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(
|
||||
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)
|
||||
@@ -846,6 +931,7 @@ def admin_db_player_edit_submit(
|
||||
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,
|
||||
@@ -857,6 +943,7 @@ def admin_db_player_edit_submit(
|
||||
birth_date=birth_date,
|
||||
photo=photo,
|
||||
video=video,
|
||||
photo_enabled=photo_enabled,
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
@@ -1358,7 +1445,7 @@ EMPTY_PLAYER = {
|
||||
"pos": " ",
|
||||
"number_lastname_amp_K": " ",
|
||||
"number_fullname": " ",
|
||||
"photo": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo\EMPTY.png",
|
||||
"photo": EMPTY_PHOTO_PATH,
|
||||
}
|
||||
|
||||
|
||||
@@ -1586,7 +1673,7 @@ EMPTY_PLAYER_FORMATION = {
|
||||
"number": " ",
|
||||
"number_lastname_amp_K": " ",
|
||||
"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": number,
|
||||
"position": position,
|
||||
"photo": (
|
||||
r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo"
|
||||
+ "\\"
|
||||
+ team_name
|
||||
+ "\\"
|
||||
+ (p[0] + " " + p[4]).strip()
|
||||
+ ".png"
|
||||
"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_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,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -230,9 +230,12 @@ def get_match_lineup_for_vmix(
|
||||
COALESCE(mlp.number::text, '') AS number,
|
||||
COALESCE(p.position, '') AS position,
|
||||
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
|
||||
JOIN players p
|
||||
LEFT JOIN players p
|
||||
ON p.id = mlp.player_id
|
||||
WHERE mlp.match_id = %s
|
||||
ORDER BY
|
||||
@@ -248,7 +251,7 @@ def get_match_lineup_for_vmix(
|
||||
COALESCE(p.first_name, ''),
|
||||
p.id
|
||||
""",
|
||||
(match_id,),
|
||||
(home_team_id, away_team_id, match_id),
|
||||
)
|
||||
player_rows = cur.fetchall()
|
||||
|
||||
@@ -293,6 +296,9 @@ def get_match_lineup_for_vmix(
|
||||
"position": row[8] or "",
|
||||
"is_captain": bool(row[9]),
|
||||
"pos": row[10] or "",
|
||||
"photo": row[11] or "",
|
||||
"photo_enabled": bool(row[12]),
|
||||
"team_id": row[13],
|
||||
}
|
||||
|
||||
side = row[0]
|
||||
|
||||
@@ -13,7 +13,9 @@ def get_match_lineups_grouped(match_id: int, home_team_id: int, away_team_id: in
|
||||
ml.is_captain,
|
||||
p.last_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
|
||||
LEFT JOIN players p ON p.id = ml.player_id
|
||||
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,
|
||||
first_name,
|
||||
pos,
|
||||
photo,
|
||||
photo_enabled,
|
||||
) = row
|
||||
|
||||
item = {
|
||||
"player_id": player_id,
|
||||
"team_id": team_id,
|
||||
"number": number or "",
|
||||
"last_name": last_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 "",
|
||||
"is_captain": bool(is_captain),
|
||||
"pos": pos,
|
||||
"photo": photo or "",
|
||||
"photo_enabled": bool(photo_enabled),
|
||||
}
|
||||
|
||||
if team_id == home_team_id and lineup_type == "starting":
|
||||
|
||||
@@ -222,50 +222,91 @@ def get_player_id_by_external_id(external_id: str) -> int | None:
|
||||
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()
|
||||
try:
|
||||
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():
|
||||
pattern = f"%{q.strip()}%"
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
p.id,
|
||||
p.full_name,
|
||||
p.first_name,
|
||||
p.last_name,
|
||||
p.external_id,
|
||||
p.position
|
||||
FROM players p
|
||||
where_sql = """
|
||||
WHERE
|
||||
p.full_name ILIKE %s
|
||||
OR p.first_name ILIKE %s
|
||||
OR p.last_name ILIKE %s
|
||||
OR COALESCE(p.external_id, '') ILIKE %s
|
||||
ORDER BY p.full_name ASC, p.id ASC
|
||||
LIMIT 200
|
||||
""",
|
||||
(pattern, pattern, pattern, pattern),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
p.id,
|
||||
p.full_name,
|
||||
p.first_name,
|
||||
p.last_name,
|
||||
p.external_id,
|
||||
p.position
|
||||
FROM players p
|
||||
ORDER BY p.id DESC
|
||||
LIMIT 200
|
||||
"""
|
||||
)
|
||||
OR COALESCE(t.name, '') ILIKE %s
|
||||
"""
|
||||
params.extend([pattern, pattern, pattern, pattern, pattern])
|
||||
|
||||
params.append(limit)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT
|
||||
p.id,
|
||||
p.full_name,
|
||||
p.first_name,
|
||||
p.last_name,
|
||||
p.external_id,
|
||||
p.position,
|
||||
COALESCE(t.name, '') AS team_name,
|
||||
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
|
||||
FROM players p
|
||||
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()
|
||||
|
||||
@@ -277,6 +318,8 @@ def search_players_for_admin(q: str = "") -> list[dict]:
|
||||
"last_name": row[3] or "",
|
||||
"external_id": row[4] or "",
|
||||
"position": row[5] or "",
|
||||
"team_name": row[6] or "",
|
||||
"photo_enabled": bool(row[7]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
@@ -299,8 +342,11 @@ def get_player_by_id(player_id: int) -> dict | None:
|
||||
p.position,
|
||||
p.born,
|
||||
p.photo,
|
||||
p.video
|
||||
p.video,
|
||||
COALESCE(t.name, '') AS team_name,
|
||||
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
|
||||
FROM players p
|
||||
LEFT JOIN teams t ON t.id = p.team_id
|
||||
WHERE p.id = %s
|
||||
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]),
|
||||
"photo": row[7] or "",
|
||||
"video": row[8] or "",
|
||||
"team_name": row[9] or "",
|
||||
"photo_enabled": bool(row[10]),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -336,6 +384,7 @@ def update_player_admin(
|
||||
birth_date: str = "",
|
||||
photo: str = "",
|
||||
video: str = "",
|
||||
photo_enabled: bool = False,
|
||||
) -> None:
|
||||
conn = get_connection()
|
||||
try:
|
||||
@@ -351,7 +400,9 @@ def update_player_admin(
|
||||
position = %s,
|
||||
born = NULLIF(%s, '')::date,
|
||||
photo = NULLIF(%s, ''),
|
||||
video = NULLIF(%s, '')
|
||||
video = NULLIF(%s, ''),
|
||||
photo_enabled = %s,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
@@ -363,6 +414,7 @@ def update_player_admin(
|
||||
birth_date.strip(),
|
||||
photo.strip(),
|
||||
video.strip(),
|
||||
bool(photo_enabled),
|
||||
player_id,
|
||||
),
|
||||
)
|
||||
@@ -372,3 +424,25 @@ def update_player_admin(
|
||||
raise
|
||||
finally:
|
||||
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()
|
||||
|
||||
@@ -3,6 +3,232 @@ from db import get_connection
|
||||
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):
|
||||
lineups = get_match_lineup_for_vmix(
|
||||
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 = []
|
||||
|
||||
fallback_team_id = home_team_id if str(name).startswith("home") else away_team_id
|
||||
|
||||
for p in players:
|
||||
suffix = []
|
||||
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 ""),
|
||||
"pos": p.get("pos", ""),
|
||||
"position": p.get("position", ""),
|
||||
"photo": (
|
||||
r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo"
|
||||
+ "\\"
|
||||
+ (team_a_name if "home" in name else team_b_name)
|
||||
+ "\\"
|
||||
+ (p.get("last_name", "")
|
||||
+ " "
|
||||
+ p.get("first_name", "")).strip()
|
||||
+ ".png"
|
||||
),
|
||||
}
|
||||
"photo": resolve_player_photo_for_json(
|
||||
p,
|
||||
generated_photo=build_generated_player_photo_path(
|
||||
team_name=team_a_name if "home" in name else team_b_name,
|
||||
last_name=p.get("last_name", ""),
|
||||
first_name=p.get("first_name", ""),
|
||||
),
|
||||
fallback_team_id=fallback_team_id,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
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):
|
||||
query = """
|
||||
SELECT
|
||||
p.last_name,
|
||||
COALESCE(NULLIF(p.last_name, ''), NULLIF(mf.player_name, ''), '') AS last_name,
|
||||
mf.is_captain,
|
||||
p.number,
|
||||
p.position,
|
||||
p.first_name
|
||||
COALESCE(NULLIF(mf.number, ''), p.number::text, '') AS number,
|
||||
COALESCE(NULLIF(mf.position, ''), p.position, '') AS position,
|
||||
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
|
||||
LEFT JOIN players p ON p.id = mf.player_id
|
||||
WHERE mf.match_id = %s and mf.team_id = %s
|
||||
|
||||
2
sql/002_players_photo_enabled.sql
Normal file
2
sql/002_players_photo_enabled.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE players
|
||||
ADD COLUMN IF NOT EXISTS photo_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -143,6 +143,31 @@
|
||||
object-fit: contain;
|
||||
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>
|
||||
</head>
|
||||
|
||||
@@ -182,6 +207,11 @@
|
||||
<input type="text" name="external_id" value="{{ player.external_id }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Название команды</label>
|
||||
<input type="text" value="{{ player.team_name }}" readonly>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Амплуа</label>
|
||||
<select name="position">
|
||||
@@ -201,6 +231,11 @@
|
||||
<div class="form-group full">
|
||||
<label>Фото игрока</label>
|
||||
<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 %}
|
||||
<div class="media-preview-box">
|
||||
<img src="{{ player.photo }}" alt="{{ player.full_name }}" class="media-preview-image">
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1400px;
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
@@ -62,7 +62,7 @@
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
min-width: 280px;
|
||||
min-width: 320px;
|
||||
min-height: 42px;
|
||||
padding: 0 12px;
|
||||
border-radius: 10px;
|
||||
@@ -94,11 +94,16 @@
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--panel-2);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -107,6 +112,7 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th {
|
||||
@@ -118,10 +124,47 @@
|
||||
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 {
|
||||
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 {
|
||||
width: 140px;
|
||||
text-align: right;
|
||||
@@ -143,45 +186,68 @@
|
||||
<div class="title">Игроки</div>
|
||||
|
||||
<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>
|
||||
<a href="/admin/db" class="btn btn-secondary">Назад</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if players %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="id-col">ID</th>
|
||||
<th>ФИО</th>
|
||||
<th>Имя</th>
|
||||
<th>Фамилия</th>
|
||||
<th>External ID</th>
|
||||
<th>Амплуа</th>
|
||||
<th class="action-col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in players %}
|
||||
<tr>
|
||||
<td>{{ p.id }}</td>
|
||||
<td>{{ p.full_name }}</td>
|
||||
<td>{{ p.first_name }}</td>
|
||||
<td>{{ p.last_name }}</td>
|
||||
<td>{{ p.external_id }}</td>
|
||||
<td>{{ p.position }}</td>
|
||||
<td class="action-col">
|
||||
<a href="/admin/db/players/{{ p.id }}/edit" class="btn btn-secondary">Редактировать</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<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><a class="sort-link" href="{{ sort_links.full_name.url }}">ФИО <span class="sort-indicator">{{ sort_links.full_name.indicator }}</span></a></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><a class="sort-link" href="{{ sort_links.last_name.url }}">Фамилия <span class="sort-indicator">{{ sort_links.last_name.indicator }}</span></a></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><a class="sort-link" href="{{ sort_links.position.url }}">Амплуа <span class="sort-indicator">{{ sort_links.position.indicator }}</span></a></th>
|
||||
<th><a class="sort-link" href="{{ sort_links.team_name.url }}">Название команды <span class="sort-indicator">{{ sort_links.team_name.indicator }}</span></a></th>
|
||||
<th class="photo-col"><a class="sort-link" href="{{ sort_links.photo.url }}">Фото <span class="sort-indicator">{{ sort_links.photo.indicator }}</span></a></th>
|
||||
<th class="action-col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in players %}
|
||||
<tr>
|
||||
<td>{{ p.id }}</td>
|
||||
<td>{{ p.full_name }}</td>
|
||||
<td>{{ p.first_name }}</td>
|
||||
<td>{{ p.last_name }}</td>
|
||||
<td>{{ p.external_id }}</td>
|
||||
<td>{{ p.position }}</td>
|
||||
<td>{{ p.team_name }}</td>
|
||||
<td class="photo-col">
|
||||
<form method="post" action="/admin/db/players/{{ p.id }}/photo-enabled" class="photo-toggle-form">
|
||||
<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 %}
|
||||
<div class="empty-box">Игроки не найдены.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user