поправлены удаления, и поправлено отображение в настройках 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]: async def mapping_data_sources() -> dict[str, Any]:
return hub.mapping_data.list_sources() 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) @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]: async def mapping_data_source_create(payload: SqlDataSourcePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return hub.mapping_data.create_source(payload, user) return hub.mapping_data.create_source(payload, user)

View File

@@ -6,7 +6,7 @@ from datetime import datetime
from typing import Any from typing import Any
from fastapi import HTTPException 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 sqlalchemy.exc import SQLAlchemyError
from .auth_bridge import HockeyUser from .auth_bridge import HockeyUser
@@ -229,6 +229,7 @@ class MappingDataService:
def __init__(self, database: HockeyDatabase, settings: Any | None = None) -> None: def __init__(self, database: HockeyDatabase, settings: Any | None = None) -> None:
self.database = database self.database = database
self.settings = settings self.settings = settings
self._database_schema_cache: dict[str, Any] | None = None
def _current_language(self, supplied: dict[str, Any] | None = None) -> str: def _current_language(self, supplied: dict[str, Any] | None = None) -> str:
supplied = supplied or {} supplied = supplied or {}
@@ -383,6 +384,44 @@ class MappingDataService:
rows = list(session.scalars(select(MappingSqlDataSource).order_by(MappingSqlDataSource.sort_order, MappingSqlDataSource.id))) rows = list(session.scalars(select(MappingSqlDataSource).order_by(MappingSqlDataSource.sort_order, MappingSqlDataSource.id)))
return {"sources": [self._source_payload(row) for row in rows]} 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]: def create_source(self, payload: Any, user: HockeyUser) -> dict[str, Any]:
self.ensure_defaults() self.ensure_defaults()
code = str(payload.code or "").strip() 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]),.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} .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}} @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, mappingCatalog: null,
mappingContextVariables: null, mappingContextVariables: null,
mappingSqlSources: null, mappingSqlSources: null,
mappingDatabaseSchema: null,
mappingSelectedSqlSourceId: null, mappingSelectedSqlSourceId: null,
mappingSqlPreview: null, mappingSqlPreview: null,
mappingSqlDraft: null, mappingSqlDraft: null,
@@ -1316,16 +1317,18 @@
try { return await request(url); } try { return await request(url); }
catch (error) { state.mappingLoadErrors[key] = error.message || String(error); return fallback; } 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("devices", "/api/hockey/admin/vmix-mapping/devices", { devices: [] }),
safe("profiles", "/api/hockey/admin/vmix-mapping/profiles", { profiles: [] }), safe("profiles", "/api/hockey/admin/vmix-mapping/profiles", { profiles: [] }),
safe("variables", "/api/hockey/admin/mapping-context/variables", { variables: [] }), safe("variables", "/api/hockey/admin/mapping-context/variables", { variables: [] }),
safe("sources", "/api/hockey/admin/mapping-data/sources", { sources: [] }), 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.mappingDevices = devices;
state.mappingProfiles = profiles; state.mappingProfiles = profiles;
state.mappingContextVariables = variables; state.mappingContextVariables = variables;
state.mappingSqlSources = sources; state.mappingSqlSources = sources;
state.mappingDatabaseSchema = databaseSchema;
const id = profileId ?? state.mappingActiveProfile?.id ?? profiles.profiles?.[0]?.id ?? null; const id = profileId ?? state.mappingActiveProfile?.id ?? profiles.profiles?.[0]?.id ?? null;
if (id) { if (id) {
try { state.mappingActiveProfile = await request(`/api/hockey/admin/vmix-mapping/profiles/${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; if (!state.mappingSelectedSqlSourceId && sources.sources?.length) state.mappingSelectedSqlSourceId = sources.sources[0].id;
await loadMappingCatalog(); await loadMappingCatalog();
if (state.mappingCatalog?.error) state.mappingLoadErrors.catalog = state.mappingCatalog.error; if (state.mappingCatalog?.error) state.mappingLoadErrors.catalog = state.mappingCatalog.error;
return { devices, profiles, variables, sources }; return { devices, profiles, variables, sources, databaseSchema };
} }
function mappingInventoryInputs(profile) { function mappingInventoryInputs(profile) {
@@ -2072,6 +2075,30 @@
</section>`; </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() { function mappingSqlHelp() {
const snippets = [ const snippets = [
["Текст", "CONCAT_WS", "CONCAT_WS(' ', last_name, first_name)", "Склеить строки через разделитель"], ["Текст", "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> <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> <small>Сервер повторяет только этот SQL. В vMix отправляются только связанные с ним поля и только если значение реально изменилось.</small>
</div> </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()} ${mappingSqlHelp()}
<label class="hockey-map-sql-code"><span>SQL</span><textarea data-sql-text spellcheck="false">${escapeHtml(draft.sql_text || "")}</textarea></label> <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> <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); 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", () => { modal.querySelectorAll("[data-sql-snippet]").forEach((button) => button.addEventListener("click", () => {
const textarea = modal.querySelector("[data-sql-text]"); const textarea = modal.querySelector("[data-sql-text]");
if (!textarea) return; 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 home = penalties.filter((item) => item.side === "home").map(penaltyRuntimeItem).sort(bySoonest);
const away = penalties.filter((item) => item.side === "away").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") || {}; 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") || {}; const strength = getByPath(state.data, "hockey.game_control.strength") || {};
return { return {
sequence: sequence ? clone(sequence) : null, sequence: sequence ? clone(sequence) : null,
@@ -4635,32 +4637,43 @@ function startCustomTooltips() {
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0)); || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
} }
// BUILD83: HOME/AWAY penalty targets describe where the extra scorebug plate // BUILD85: HOME/AWAY penalty targets describe the TEAM THAT HAS THE NUMERICAL
// is drawn, not which bench committed the penalty. While both teams have an // ADVANTAGE, not the bench that committed the penalty. This matters not only
// active penalty we keep the traditional one-per-side display (coincidental // for a normal 5x4/5x3 power play, but also for 4x3 and 3x4 when both benches
// penalties). As soon as only one team still has a penalty, its timer must // still have active penalties. The authoritative advantage_side is calculated
// move to the OPPOSITE target because that is the team playing on the power // by the hockey strength engine. When strength is equal (4x4 / 3x3), keep the
// play. Example: HOME 1:43 + HOME 2:00 + AWAY 1:43 -> after the coincidental // traditional one-per-side coincidental display. If the strength payload has
// 1:43 pair expires, the remaining HOME 2:00 is shown through the AWAY input. // not arrived yet, the single-penalty fallback still routes to the opposite
// target exactly as BUILD83 did.
function penaltyDisplayEntriesByTargetSide(step) { function penaltyDisplayEntriesByTargetSide(step) {
const home = sortedPenaltyEntries("home"); const home = sortedPenaltyEntries("home");
const away = sortedPenaltyEntries("away"); const away = sortedPenaltyEntries("away");
const soonestOnly = String(step?.penalty_display_mode || "soonest") !== "all"; const soonestOnly = String(step?.penalty_display_mode || "soonest") !== "all";
// Legacy BUILD43 equivalent was `allEntries.slice(0, 1)`; routing is now const strength = getByPath(state.data, "hockey.game_control.strength") || {};
// calculated for both sides together so the remaining penalty can move to const advantageSide = String(strength.advantage_side || "").trim().toLowerCase();
// the power-play team target without losing the shortest-time behaviour. const take = (items) => soonestOnly ? items.slice(0, 1) : items;
if (!soonestOnly) return { home, away, routedToAdvantage: false };
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) { 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) { 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) { 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) { function hockeyPenaltySideMappingDetail(item, side) {