diff --git a/app.py b/app.py index 718d42f..f2fd0e2 100644 --- a/app.py +++ b/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, ), } ) diff --git a/repositories/match_lineup_repository.py b/repositories/match_lineup_repository.py index cedaee9..caa5d7e 100644 --- a/repositories/match_lineup_repository.py +++ b/repositories/match_lineup_repository.py @@ -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] diff --git a/repositories/match_view_repository.py b/repositories/match_view_repository.py index 23f160c..4cecd4d 100644 --- a/repositories/match_view_repository.py +++ b/repositories/match_view_repository.py @@ -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": diff --git a/repositories/player_repository.py b/repositories/player_repository.py index 6c0059f..4df58ca 100644 --- a/repositories/player_repository.py +++ b/repositories/player_repository.py @@ -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() diff --git a/services/vmix_json_service.py b/services/vmix_json_service.py index 889df7c..c9bede6 100644 --- a/services/vmix_json_service.py +++ b/services/vmix_json_service.py @@ -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 diff --git a/sql/002_players_photo_enabled.sql b/sql/002_players_photo_enabled.sql new file mode 100644 index 0000000..ed61a1d --- /dev/null +++ b/sql/002_players_photo_enabled.sql @@ -0,0 +1,2 @@ +ALTER TABLE players +ADD COLUMN IF NOT EXISTS photo_enabled BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/templates/admin_db_player_edit.html b/templates/admin_db_player_edit.html index 82d38eb..c64255b 100644 --- a/templates/admin_db_player_edit.html +++ b/templates/admin_db_player_edit.html @@ -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; + } @@ -182,6 +207,11 @@ +