From 9422719ea6fd6f8cb9e4b9a28e3c13a5f8cd5b0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AE=D1=80=D0=B8=D0=B9=20=D0=A7=D0=B5=D1=80=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE?= Date: Thu, 20 Aug 2026 13:40:20 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F,=20=D0=B8=20=D0=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=20=D0=BD=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=D1=85=20SQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hockey_data/agent_bridge.py | 4 + hockey_data/mapping_context.py | 41 +++++++++- hockey_data/static/admin-directories.css | 19 +++++ hockey_data/static/admin-directories.js | 49 +++++++++++- ...build85_penalty_advantage_sql_reference.py | 79 +++++++++++++++++++ ui_builder/static/app.js | 43 ++++++---- 6 files changed, 216 insertions(+), 19 deletions(-) create mode 100644 tests/test_build85_penalty_advantage_sql_reference.py diff --git a/hockey_data/agent_bridge.py b/hockey_data/agent_bridge.py index 376e75b..38de173 100644 --- a/hockey_data/agent_bridge.py +++ b/hockey_data/agent_bridge.py @@ -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) diff --git a/hockey_data/mapping_context.py b/hockey_data/mapping_context.py index e63b00a..c8cac8e 100644 --- a/hockey_data/mapping_context.py +++ b/hockey_data/mapping_context.py @@ -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() diff --git a/hockey_data/static/admin-directories.css b/hockey_data/static/admin-directories.css index 2f2cc80..2a64dfa 100644 --- a/hockey_data/static/admin-directories.css +++ b/hockey_data/static/admin-directories.css @@ -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}} diff --git a/hockey_data/static/admin-directories.js b/hockey_data/static/admin-directories.js index b8777a3..a77e427 100644 --- a/hockey_data/static/admin-directories.js +++ b/hockey_data/static/admin-directories.js @@ -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 @@ `; } + 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 ``; + }).join(""); + const body = payload.error + ? `
Не удалось получить схему БД: ${escapeHtml(payload.error)}
` + : rows || `
Таблицы не найдены.
`; + return `
+
Справочник таблиц БД${count} таблиц · клик по названию вставляет его в SQL
+
${body}
+
`; + } + function mappingSqlHelp() { const snippets = [ ["Текст", "CONCAT_WS", "CONCAT_WS(' ', last_name, first_name)", "Склеить строки через разделитель"], @@ -2144,7 +2171,11 @@ Сервер повторяет только этот SQL. В vMix отправляются только связанные с ним поля и только если значение реально изменилось. -
Доступные параметры — клик вставляет в SQL
${variableChips}
+
+ :
Доступные параметры${(state.mappingCatalog?.variables || []).length} идентификаторов · клик вставляет :параметр в SQL
+
${variableChips}
+
+ ${mappingSqlDatabaseReference()} ${mappingSqlHelp()}
${draft.id ? `` : ""}
@@ -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; diff --git a/tests/test_build85_penalty_advantage_sql_reference.py b/tests/test_build85_penalty_advantage_sql_reference.py new file mode 100644 index 0000000..42149d8 --- /dev/null +++ b/tests/test_build85_penalty_advantage_sql_reference.py @@ -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 '
' in ADMIN_JS + assert '
' in ADMIN_JS + assert '/api/hockey/admin/mapping-data/database-schema' in AGENT diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index e778911..74bd8ae 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -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) {