поправлены удаления, и поправлено отображение в настройках SQL

This commit is contained in:
2026-08-20 13:40:20 +03:00
parent 381e630c77
commit 9422719ea6
6 changed files with 216 additions and 19 deletions

View File

@@ -3878,6 +3878,10 @@ def create_hockey_agent_router(
async def mapping_data_sources() -> dict[str, Any]:
return hub.mapping_data.list_sources()
@router.get("/api/hockey/admin/mapping-data/database-schema", dependencies=admin)
async def mapping_database_schema() -> dict[str, Any]:
return hub.mapping_data.database_schema_reference()
@router.post("/api/hockey/admin/mapping-data/sources", dependencies=admin)
async def mapping_data_source_create(payload: SqlDataSourcePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return hub.mapping_data.create_source(payload, user)

View File

@@ -6,7 +6,7 @@ from datetime import datetime
from typing import Any
from fastapi import HTTPException
from sqlalchemy import and_, delete, select, text
from sqlalchemy import and_, delete, inspect, select, text
from sqlalchemy.exc import SQLAlchemyError
from .auth_bridge import HockeyUser
@@ -229,6 +229,7 @@ class MappingDataService:
def __init__(self, database: HockeyDatabase, settings: Any | None = None) -> None:
self.database = database
self.settings = settings
self._database_schema_cache: dict[str, Any] | None = None
def _current_language(self, supplied: dict[str, Any] | None = None) -> str:
supplied = supplied or {}
@@ -383,6 +384,44 @@ class MappingDataService:
rows = list(session.scalars(select(MappingSqlDataSource).order_by(MappingSqlDataSource.sort_order, MappingSqlDataSource.id)))
return {"sources": [self._source_payload(row) for row in rows]}
def database_schema_reference(self) -> dict[str, Any]:
"""Return a compact read-only reference of hockey database tables.
This is intentionally metadata-only: table/column names and SQL types,
never row data or connection credentials. The SQL editor uses it as an
expandable cheat sheet so operators do not have to inspect PostgreSQL
manually just to remember a table name.
"""
if self._database_schema_cache is not None:
return self._database_schema_cache
try:
inspector = inspect(self.database.engine)
table_names = sorted(
name for name in inspector.get_table_names()
if str(name).startswith("hockey_")
)
tables: list[dict[str, Any]] = []
for table_name in table_names:
columns = []
for column in inspector.get_columns(table_name):
columns.append({
"name": str(column.get("name") or ""),
"type": str(column.get("type") or ""),
"nullable": bool(column.get("nullable", True)),
})
tables.append({
"name": table_name,
"column_count": len(columns),
"columns": columns,
})
payload = {"tables": tables, "table_count": len(tables)}
self._database_schema_cache = payload
return payload
except SQLAlchemyError as exc:
return {"tables": [], "table_count": 0, "error": str(exc)}
except Exception as exc:
return {"tables": [], "table_count": 0, "error": str(exc)}
def create_source(self, payload: Any, user: HockeyUser) -> dict[str, Any]:
self.ensure_defaults()
code = str(payload.code or "").strip()

View File

@@ -1726,3 +1726,22 @@
.hockey-map-sql-cell-quick:not([open]),.hockey-map-table-picker:not([open]){min-height:0!important;height:auto!important;display:block;padding:10px}
.hockey-map-sql-cell-quick:not([open])>summary,.hockey-map-table-picker:not([open])>summary{margin:0}
@media(max-width:1100px){.hockey-map-sql-cell-quick>summary{align-items:flex-start;flex-direction:column}.hockey-map-sql-cell-quick>summary small{max-width:none;text-align:left}}
/* BUILD85 — collapsed SQL parameters + database table reference */
.hockey-map-sql-reference{margin:8px 0;border:1px solid #30465d;border-radius:10px;background:#0b141e;overflow:hidden}
.hockey-map-sql-reference>summary{display:flex;align-items:center;gap:9px;padding:8px 10px;cursor:pointer;list-style:none;color:#dbe8f5;user-select:none}
.hockey-map-sql-reference>summary::-webkit-details-marker{display:none}
.hockey-map-sql-reference>summary>span{display:grid;place-items:center;flex:0 0 24px;height:24px;border:1px solid #3a6c88;border-radius:7px;background:#122639;color:#6ad3ef;font-weight:950;font:900 11px Consolas,monospace}
.hockey-map-sql-reference>summary>div{display:grid;gap:1px;min-width:0}
.hockey-map-sql-reference>summary strong{font-size:10px;color:#dce9f6}
.hockey-map-sql-reference>summary small{font-size:8px;color:#7890a8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.hockey-map-sql-reference>summary code{font:8px Consolas,monospace;color:#67dfc6}
.hockey-map-sql-reference-body{display:grid;gap:7px;padding:0 10px 10px}
.hockey-map-sql-reference .hockey-map-sql-params{margin:0;padding:0;border:0;background:transparent}
.hockey-map-sql-table-list{grid-template-columns:repeat(2,minmax(0,1fr))}
.hockey-map-sql-table-ref{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:3px 8px;min-width:0;padding:7px 8px;border:1px solid #2b4359;border-radius:8px;background:#101c29;color:#dce8f3;text-align:left;cursor:pointer}
.hockey-map-sql-table-ref:hover{border-color:#3b8ca3;background:#132535}
.hockey-map-sql-table-ref strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:10px Consolas,monospace;color:#77dbc8}
.hockey-map-sql-table-ref span{font-size:8px;color:#7890a8;white-space:nowrap}
.hockey-map-sql-table-ref code{grid-column:1/-1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:8px Consolas,monospace;color:#738da6}
@media(max-width:1050px){.hockey-map-sql-table-list{grid-template-columns:1fr}}

View File

@@ -41,6 +41,7 @@
mappingCatalog: null,
mappingContextVariables: null,
mappingSqlSources: null,
mappingDatabaseSchema: null,
mappingSelectedSqlSourceId: null,
mappingSqlPreview: null,
mappingSqlDraft: null,
@@ -1316,16 +1317,18 @@
try { return await request(url); }
catch (error) { state.mappingLoadErrors[key] = error.message || String(error); return fallback; }
};
const [devices, profiles, variables, sources] = await Promise.all([
const [devices, profiles, variables, sources, databaseSchema] = await Promise.all([
safe("devices", "/api/hockey/admin/vmix-mapping/devices", { devices: [] }),
safe("profiles", "/api/hockey/admin/vmix-mapping/profiles", { profiles: [] }),
safe("variables", "/api/hockey/admin/mapping-context/variables", { variables: [] }),
safe("sources", "/api/hockey/admin/mapping-data/sources", { sources: [] }),
safe("databaseSchema", "/api/hockey/admin/mapping-data/database-schema", { tables: [], table_count: 0 }),
]);
state.mappingDevices = devices;
state.mappingProfiles = profiles;
state.mappingContextVariables = variables;
state.mappingSqlSources = sources;
state.mappingDatabaseSchema = databaseSchema;
const id = profileId ?? state.mappingActiveProfile?.id ?? profiles.profiles?.[0]?.id ?? null;
if (id) {
try { state.mappingActiveProfile = await request(`/api/hockey/admin/vmix-mapping/profiles/${id}`); }
@@ -1334,7 +1337,7 @@
if (!state.mappingSelectedSqlSourceId && sources.sources?.length) state.mappingSelectedSqlSourceId = sources.sources[0].id;
await loadMappingCatalog();
if (state.mappingCatalog?.error) state.mappingLoadErrors.catalog = state.mappingCatalog.error;
return { devices, profiles, variables, sources };
return { devices, profiles, variables, sources, databaseSchema };
}
function mappingInventoryInputs(profile) {
@@ -2072,6 +2075,30 @@
</section>`;
}
function mappingSqlDatabaseReference() {
const payload = state.mappingDatabaseSchema || {};
const tables = Array.isArray(payload.tables) ? payload.tables : [];
const count = Number(payload.table_count || tables.length || 0);
const rows = tables.map((table) => {
const columns = Array.isArray(table.columns) ? table.columns : [];
const columnNames = columns.map((column) => String(column.name || "")).filter(Boolean);
const compactColumns = columnNames.slice(0, 12).join(", ");
const tail = columnNames.length > 12 ? ` … +${columnNames.length - 12}` : "";
return `<button type="button" class="hockey-map-sql-table-ref" data-sql-table-name="${escapeHtml(table.name || "")}" title="Вставить ${escapeHtml(table.name || "")} в SQL">
<strong>${escapeHtml(table.name || "")}</strong>
<span>${Number(table.column_count || columns.length || 0)} колонок</span>
<code>${escapeHtml(compactColumns)}${escapeHtml(tail)}</code>
</button>`;
}).join("");
const body = payload.error
? `<div class="hockey-directory-empty is-error">Не удалось получить схему БД: ${escapeHtml(payload.error)}</div>`
: rows || `<div class="hockey-directory-empty">Таблицы не найдены.</div>`;
return `<details class="hockey-map-sql-reference hockey-map-sql-schema-ref">
<summary><span>▦</span><div><strong>Справочник таблиц БД</strong><small>${count} таблиц · клик по названию вставляет его в SQL</small></div></summary>
<div class="hockey-map-sql-reference-body hockey-map-sql-table-list">${body}</div>
</details>`;
}
function mappingSqlHelp() {
const snippets = [
["Текст", "CONCAT_WS", "CONCAT_WS(' ', last_name, first_name)", "Склеить строки через разделитель"],
@@ -2144,7 +2171,11 @@
<label>Период, сек<input type="number" min="1" max="3600" step="1" data-sql-refresh-seconds value="${Math.max(1, Math.round(Number(draft.refresh_interval_ms || 1000) / 1000))}" ${draft.auto_refresh_enabled ? "" : "disabled"}></label>
<small>Сервер повторяет только этот SQL. В vMix отправляются только связанные с ним поля и только если значение реально изменилось.</small>
</div>
<div class="hockey-map-sql-params"><span>Доступные параметры — клик вставляет в SQL</span><div>${variableChips}</div></div>
<details class="hockey-map-sql-reference hockey-map-sql-params-ref">
<summary><span>:</span><div><strong>Доступные параметры</strong><small>${(state.mappingCatalog?.variables || []).length} идентификаторов · клик вставляет <code>:параметр</code> в SQL</small></div></summary>
<div class="hockey-map-sql-reference-body"><div class="hockey-map-sql-params"><div>${variableChips}</div></div></div>
</details>
${mappingSqlDatabaseReference()}
${mappingSqlHelp()}
<label class="hockey-map-sql-code"><span>SQL</span><textarea data-sql-text spellcheck="false">${escapeHtml(draft.sql_text || "")}</textarea></label>
<div class="hockey-map-sql-actions"><label class="hockey-directory-check"><input type="checkbox" data-sql-enabled ${draft.enabled !== false ? "checked" : ""}><span>Источник включён</span></label><button type="button" data-sql-preview>Проверить SQL</button><button type="button" class="is-accent" data-sql-save>${draft.id ? "Сохранить источник" : "Создать источник"}</button>${draft.id ? `<button type="button" class="danger" data-sql-delete>Удалить</button>` : ""}</div>
@@ -2366,6 +2397,18 @@
textarea.focus(); textarea.setSelectionRange(start + token.length, start + token.length);
}));
modal.querySelectorAll("[data-sql-table-name]").forEach((button) => button.addEventListener("click", () => {
const textarea = modal.querySelector("[data-sql-text]");
if (!textarea) return;
const tableName = String(button.dataset.sqlTableName || "").trim();
if (!tableName) return;
const start = textarea.selectionStart ?? textarea.value.length;
const end = textarea.selectionEnd ?? start;
textarea.value = textarea.value.slice(0, start) + tableName + textarea.value.slice(end);
textarea.focus(); textarea.setSelectionRange(start + tableName.length, start + tableName.length);
state.mappingSqlDraft = null;
}));
modal.querySelectorAll("[data-sql-snippet]").forEach((button) => button.addEventListener("click", () => {
const textarea = modal.querySelector("[data-sql-text]");
if (!textarea) return;

View File

@@ -0,0 +1,79 @@
from pathlib import Path
from hockey_data.mapping_context import MappingDataService
from hockey_data.service import HockeyDataService
from tests.support import LocalTestDatabase
ROOT = Path(__file__).resolve().parents[1]
APP_JS = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
ADMIN_JS = (ROOT / "hockey_data/static/admin-directories.js").read_text(encoding="utf-8")
AGENT = (ROOT / "hockey_data/agent_bridge.py").read_text(encoding="utf-8")
def _penalty(side: str) -> dict:
return {
"side": side,
"infraction": {"code": "TEST"},
"preset": "2",
"durationMs": 120000,
"remainingMs": 90000,
"finished": False,
}
def test_strength_engine_reports_advantage_for_4x3_and_3x4():
settings = {"strength_regulation_skaters": 5, "strength_min_skaters": 3}
home_advantage = HockeyDataService._strength_payload(
settings,
stage="regular",
current_period="2",
timer_state={"penalty_board": {"penalties": [_penalty("home"), _penalty("away"), _penalty("away")]}},
language="ru",
)
assert home_advantage["strength_label"] == "4×3"
assert home_advantage["advantage_side"] == "home"
away_advantage = HockeyDataService._strength_payload(
settings,
stage="regular",
current_period="2",
timer_state={"penalty_board": {"penalties": [_penalty("home"), _penalty("home"), _penalty("away")]}},
language="ru",
)
assert away_advantage["strength_label"] == "3×4"
assert away_advantage["advantage_side"] == "away"
def test_penalty_plate_routing_uses_strength_advantage_side():
start = APP_JS.index("function penaltyDisplayEntriesByTargetSide")
end = APP_JS.index("function hockeyPenaltySideMappingDetail", start)
block = APP_JS[start:end]
assert 'getByPath(state.data, "hockey.game_control.strength")' in block
assert 'if (advantageSide === "home")' in block
assert 'return { home: take(away), away: [], routedToAdvantage: true, advantageSide: "home" };' in block
assert 'if (advantageSide === "away")' in block
assert 'return { home: [], away: take(home), routedToAdvantage: true, advantageSide: "away" };' in block
def test_database_schema_reference_lists_real_hockey_tables(tmp_path: Path):
database = LocalTestDatabase(tmp_path / "schema.sqlite3")
database.create_all()
service = MappingDataService(database) # type: ignore[arg-type]
payload = service.database_schema_reference()
names = {row["name"] for row in payload["tables"]}
assert "hockey_players" in names
assert "hockey_games" in names
players = next(row for row in payload["tables"] if row["name"] == "hockey_players")
column_names = {column["name"] for column in players["columns"]}
assert "id" in column_names
assert "external_id" in column_names
def test_sql_parameters_and_table_reference_are_collapsed_by_default():
assert 'class="hockey-map-sql-reference hockey-map-sql-params-ref"' in ADMIN_JS
assert 'class="hockey-map-sql-reference hockey-map-sql-schema-ref"' in ADMIN_JS
assert 'Справочник таблиц БД' in ADMIN_JS
assert 'data-sql-table-name=' in ADMIN_JS
assert '<details class="hockey-map-sql-reference hockey-map-sql-params-ref">' in ADMIN_JS
assert '<details class="hockey-map-sql-reference hockey-map-sql-schema-ref">' in ADMIN_JS
assert '/api/hockey/admin/mapping-data/database-schema' in AGENT

View File

@@ -4143,6 +4143,8 @@ function startCustomTooltips() {
const home = penalties.filter((item) => item.side === "home").map(penaltyRuntimeItem).sort(bySoonest);
const away = penalties.filter((item) => item.side === "away").map(penaltyRuntimeItem).sort(bySoonest);
const flags = getByPath(state.data, "hockey.game_control.flags") || {};
// Legacy BUILD43 shortest-timer behaviour was allEntries.slice(0, 1);
// `take()` preserves that rule while routing the timer to the advantage side.
const strength = getByPath(state.data, "hockey.game_control.strength") || {};
return {
sequence: sequence ? clone(sequence) : null,
@@ -4635,32 +4637,43 @@ function startCustomTooltips() {
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
}
// BUILD83: HOME/AWAY penalty targets describe where the extra scorebug plate
// is drawn, not which bench committed the penalty. While both teams have an
// active penalty we keep the traditional one-per-side display (coincidental
// penalties). As soon as only one team still has a penalty, its timer must
// move to the OPPOSITE target because that is the team playing on the power
// play. Example: HOME 1:43 + HOME 2:00 + AWAY 1:43 -> after the coincidental
// 1:43 pair expires, the remaining HOME 2:00 is shown through the AWAY input.
// BUILD85: HOME/AWAY penalty targets describe the TEAM THAT HAS THE NUMERICAL
// ADVANTAGE, not the bench that committed the penalty. This matters not only
// for a normal 5x4/5x3 power play, but also for 4x3 and 3x4 when both benches
// still have active penalties. The authoritative advantage_side is calculated
// by the hockey strength engine. When strength is equal (4x4 / 3x3), keep the
// traditional one-per-side coincidental display. If the strength payload has
// not arrived yet, the single-penalty fallback still routes to the opposite
// target exactly as BUILD83 did.
function penaltyDisplayEntriesByTargetSide(step) {
const home = sortedPenaltyEntries("home");
const away = sortedPenaltyEntries("away");
const soonestOnly = String(step?.penalty_display_mode || "soonest") !== "all";
// Legacy BUILD43 equivalent was `allEntries.slice(0, 1)`; routing is now
// calculated for both sides together so the remaining penalty can move to
// the power-play team target without losing the shortest-time behaviour.
if (!soonestOnly) return { home, away, routedToAdvantage: false };
const strength = getByPath(state.data, "hockey.game_control.strength") || {};
const advantageSide = String(strength.advantage_side || "").trim().toLowerCase();
const take = (items) => soonestOnly ? items.slice(0, 1) : items;
if (advantageSide === "home") {
// HOME has more skaters, therefore an AWAY penalty controls the PP clock.
return { home: take(away), away: [], routedToAdvantage: true, advantageSide: "home" };
}
if (advantageSide === "away") {
// AWAY has more skaters, therefore a HOME penalty controls the PP clock.
return { home: [], away: take(home), routedToAdvantage: true, advantageSide: "away" };
}
if (home.length && away.length) {
return { home: home.slice(0, 1), away: away.slice(0, 1), routedToAdvantage: false };
return { home: take(home), away: take(away), routedToAdvantage: false, advantageSide: "" };
}
if (home.length) {
return { home: [], away: home.slice(0, 1), routedToAdvantage: true };
if (soonestOnly) return { home: [], away: home.slice(0, 1), routedToAdvantage: true };
return { home: [], away: home, routedToAdvantage: true, advantageSide: "away" };
}
if (away.length) {
return { home: away.slice(0, 1), away: [], routedToAdvantage: true };
if (soonestOnly) return { home: away.slice(0, 1), away: [], routedToAdvantage: true };
return { home: away, away: [], routedToAdvantage: true, advantageSide: "home" };
}
return { home: [], away: [], routedToAdvantage: false };
return { home: [], away: [], routedToAdvantage: false, advantageSide: "" };
}
function hockeyPenaltySideMappingDetail(item, side) {