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 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,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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;
|
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">
|
||||||
|
|||||||
@@ -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,45 +186,68 @@
|
|||||||
<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 %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
3
wfl.sql
3
wfl.sql
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user