идентификаторы удалений, заготовки
This commit is contained in:
@@ -3906,12 +3906,9 @@ def create_hockey_agent_router(
|
|||||||
async def mapping_data_catalog(payload: MappingDataContextPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
|
async def mapping_data_catalog(payload: MappingDataContextPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
|
||||||
return hub.mapping_data.data_catalog(user, payload.context)
|
return hub.mapping_data.data_catalog(user, payload.context)
|
||||||
|
|
||||||
@router.post("/api/hockey/context/{key}")
|
# BUILD86: static context routes MUST be registered before /{key}.
|
||||||
async def hockey_context_set(key: str, payload: ContextValuePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
|
# Starlette matches routes in registration order; the old order treated the
|
||||||
result = hub.mapping_data.set_context_value(key, payload.value, user, payload.context)
|
# literal word "batch" (and "resolve") as a context variable key.
|
||||||
result["mapping_apply"] = await hub.apply_mapping_for_user(user, reason=f"context_changed:{key}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
@router.post("/api/hockey/context/batch")
|
@router.post("/api/hockey/context/batch")
|
||||||
async def hockey_context_batch(payload: ContextValuesPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
|
async def hockey_context_batch(payload: ContextValuesPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
|
||||||
if len(payload.values) > 32:
|
if len(payload.values) > 32:
|
||||||
@@ -3934,6 +3931,12 @@ def create_hockey_agent_router(
|
|||||||
context, variables = hub.mapping_data.resolve_context(user, payload.context)
|
context, variables = hub.mapping_data.resolve_context(user, payload.context)
|
||||||
return {"context": context, "variables": variables}
|
return {"context": context, "variables": variables}
|
||||||
|
|
||||||
|
@router.post("/api/hockey/context/{key}")
|
||||||
|
async def hockey_context_set(key: str, payload: ContextValuePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
|
||||||
|
result = hub.mapping_data.set_context_value(key, payload.value, user, payload.context)
|
||||||
|
result["mapping_apply"] = await hub.apply_mapping_for_user(user, reason=f"context_changed:{key}")
|
||||||
|
return result
|
||||||
|
|
||||||
@router.websocket("/ws/hockey-agent")
|
@router.websocket("/ws/hockey-agent")
|
||||||
async def agent_socket(websocket: WebSocket) -> None:
|
async def agent_socket(websocket: WebSocket) -> None:
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
|||||||
@@ -687,9 +687,39 @@ class MappingDataService:
|
|||||||
context, _ = self.resolve_context(user, supplied)
|
context, _ = self.resolve_context(user, supplied)
|
||||||
with self.database.session() as session:
|
with self.database.session() as session:
|
||||||
variable = session.scalar(select(MappingContextVariable).where(MappingContextVariable.key == key))
|
variable = session.scalar(select(MappingContextVariable).where(MappingContextVariable.key == key))
|
||||||
if variable is None: raise HTTPException(status_code=404, detail="Переменная не найдена")
|
if variable is None:
|
||||||
|
# BUILD86: be defensive with long-lived production databases.
|
||||||
|
# Penalty selection keys were added over several builds; if a row
|
||||||
|
# is missing for any reason, restore the known default contract
|
||||||
|
# immediately instead of breaking an operator click with 404.
|
||||||
|
default_index = next((i for i, item in enumerate(DEFAULT_CONTEXT_VARIABLES) if item.get("key") == key), -1)
|
||||||
|
default_item = DEFAULT_CONTEXT_VARIABLES[default_index] if default_index >= 0 else None
|
||||||
|
if default_item is not None:
|
||||||
|
now = _utcnow()
|
||||||
|
variable = MappingContextVariable(
|
||||||
|
key=default_item["key"],
|
||||||
|
label=default_item["label"],
|
||||||
|
category=default_item.get("category", "Контекст"),
|
||||||
|
description=default_item.get("description", ""),
|
||||||
|
value_type=default_item.get("value_type", "id"),
|
||||||
|
entity_type=default_item.get("entity_type", ""),
|
||||||
|
scope=default_item.get("scope", "match"),
|
||||||
|
source_type=default_item.get("source_type", "manual"),
|
||||||
|
default_value=default_item.get("default_value", ""),
|
||||||
|
is_system=default_item.get("source_type") == "system",
|
||||||
|
enabled=True,
|
||||||
|
sort_order=default_index,
|
||||||
|
created_by="system",
|
||||||
|
updated_by="system",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(variable)
|
||||||
|
session.flush()
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Переменная не найдена: {key}")
|
||||||
if variable.source_type == "system" or variable.is_system:
|
if variable.source_type == "system" or variable.is_system:
|
||||||
raise HTTPException(status_code=409, detail="Системная переменная задаётся программой")
|
raise HTTPException(status_code=409, detail=f"Системная переменная задаётся программой: {key}")
|
||||||
scope_key = self._scope_key(variable, user, context)
|
scope_key = self._scope_key(variable, user, context)
|
||||||
row = session.scalar(select(MappingContextValue).where(and_(MappingContextValue.variable_id == variable.id, MappingContextValue.scope_key == scope_key)))
|
row = session.scalar(select(MappingContextValue).where(and_(MappingContextValue.variable_id == variable.id, MappingContextValue.scope_key == scope_key)))
|
||||||
if row is None:
|
if row is None:
|
||||||
|
|||||||
84
tests/test_build86_penalty_context_prepared_titles.py
Normal file
84
tests/test_build86_penalty_context_prepared_titles.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from hockey_data.auth_bridge import HockeyUser
|
||||||
|
from hockey_data.mapping_context import MappingDataService
|
||||||
|
from tests.support import LocalTestDatabase
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
APP = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepared_titles_is_forced_to_last_runtime_tab_and_uses_hockey_api_directly():
|
||||||
|
assert 'state.config.tabs = state.config.tabs.filter((tab) => tab?.id !== "prepared_titles")' in APP
|
||||||
|
assert 'state.config.tabs.push({ id: "prepared_titles", label: "Заготовки" });' in APP
|
||||||
|
# BUILD84 accidentally routed these through boot.api (/api/ui-builder/runtime), producing 404 Not Found.
|
||||||
|
prepared_slice = APP[APP.index("async function hockeyLoadPreparedMappingSources"):APP.index("function renderHockeyPreparedTitlesWorkspace")]
|
||||||
|
assert 'api(`/api/hockey/prepared-titles' not in prepared_slice
|
||||||
|
assert 'api("/api/hockey/prepared-titles' not in prepared_slice
|
||||||
|
assert 'hockeyGameControlRequest(`/prepared-titles' in prepared_slice
|
||||||
|
assert 'hockeyGameControlRequest(`/agents/vmix-inventory' in prepared_slice
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepared_title_browser_keeps_title_inputs_visible_even_without_field_inventory():
|
||||||
|
start = APP.index("function hockeyPreparedInventoryInputs")
|
||||||
|
end = APP.index("function hockeyPreparedSourceIdentity", start)
|
||||||
|
block = APP[start:end]
|
||||||
|
assert 'fields.length > 0' in block
|
||||||
|
assert '(GT|XAML|TITLE)' in block
|
||||||
|
|
||||||
|
|
||||||
|
def test_penalty_side_context_is_match_shared_between_operator_and_admin(tmp_path):
|
||||||
|
db = LocalTestDatabase(tmp_path / "ctx.sqlite3")
|
||||||
|
db.create_all()
|
||||||
|
service = MappingDataService(db)
|
||||||
|
operator = HockeyUser(id="op", login="operator", display_name="Operator")
|
||||||
|
admin = HockeyUser(id="admin", login="admin", display_name="Admin")
|
||||||
|
|
||||||
|
service.set_context_value("selected_home_penalty_id", "pen-home-17", operator, {"game_id": "game-1"})
|
||||||
|
service.set_context_value("selected_away_penalty_id", "pen-away-9", operator, {"game_id": "game-1"})
|
||||||
|
context, variables = service.resolve_context(admin, {"game_id": "game-1"})
|
||||||
|
values = {item["key"]: item.get("value", "") for item in variables}
|
||||||
|
|
||||||
|
assert context["selected_home_penalty_id"] == "pen-home-17"
|
||||||
|
assert context["selected_away_penalty_id"] == "pen-away-9"
|
||||||
|
assert values["selected_home_penalty_id"] == "pen-home-17"
|
||||||
|
assert values["selected_away_penalty_id"] == "pen-away-9"
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_batch_route_precedes_dynamic_context_key_route(tmp_path):
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from hockey_data.agent_bridge import VmixAgentHub, create_hockey_agent_router
|
||||||
|
|
||||||
|
db = LocalTestDatabase(tmp_path / "batch.sqlite3")
|
||||||
|
db.create_all()
|
||||||
|
hub = VmixAgentHub(db) # type: ignore[arg-type]
|
||||||
|
user = HockeyUser(id="op", login="operator", display_name="Operator")
|
||||||
|
|
||||||
|
async def auth():
|
||||||
|
return user
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(create_hockey_agent_router(hub, auth_dependency=auth))
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post(
|
||||||
|
"/api/hockey/context/batch",
|
||||||
|
json={
|
||||||
|
"values": {
|
||||||
|
"selected_home_penalty_id": "pen-home-17",
|
||||||
|
"selected_home_penalty_player_id": "777",
|
||||||
|
"selected_home_penalty_player_db_id": "42",
|
||||||
|
"selected_home_penalty_team_penalty": "0",
|
||||||
|
},
|
||||||
|
"context": {"game_id": "game-1"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["ok"] is True
|
||||||
|
assert {item["key"] for item in payload["items"]} == {
|
||||||
|
"selected_home_penalty_id",
|
||||||
|
"selected_home_penalty_player_id",
|
||||||
|
"selected_home_penalty_player_db_id",
|
||||||
|
"selected_home_penalty_team_penalty",
|
||||||
|
}
|
||||||
@@ -2561,13 +2561,11 @@ function startCustomTooltips() {
|
|||||||
state.config.tabs = (Array.isArray(state.config.tabs) ? state.config.tabs : []).filter((tab) => tab?.id !== "prematch");
|
state.config.tabs = (Array.isArray(state.config.tabs) ? state.config.tabs : []).filter((tab) => tab?.id !== "prematch");
|
||||||
state.config.components = components.filter((component) => component?.type !== "hockey_prematch_panel" && component?.action_id !== "hockey_prematch_panel");
|
state.config.components = components.filter((component) => component?.type !== "hockey_prematch_panel" && component?.action_id !== "hockey_prematch_panel");
|
||||||
if (!state.config.tabs.length) state.config.tabs = [{ id: "main", label: "Игра" }];
|
if (!state.config.tabs.length) state.config.tabs = [{ id: "main", label: "Игра" }];
|
||||||
// BUILD84: "Заготовки" is a built-in runtime workspace rather than a canvas
|
// BUILD86: "Заготовки" is always the final top-level runtime tab.
|
||||||
// component. Keep it near the game tab and recreate it if an older config
|
// Reorder it on every config normalisation as older published configs may
|
||||||
// does not contain it yet.
|
// already contain the tab near "Игра" from BUILD84/85.
|
||||||
if (!state.config.tabs.some((tab) => tab?.id === "prepared_titles")) {
|
state.config.tabs = state.config.tabs.filter((tab) => tab?.id !== "prepared_titles");
|
||||||
const mainIndex = Math.max(0, state.config.tabs.findIndex((tab) => tab?.id === "main"));
|
state.config.tabs.push({ id: "prepared_titles", label: "Заготовки" });
|
||||||
state.config.tabs.splice(mainIndex + 1, 0, { id: "prepared_titles", label: "Заготовки" });
|
|
||||||
}
|
|
||||||
if (state.activeTab === "prematch") state.activeTab = state.config.tabs.find((tab) => tab.id === "main")?.id || state.config.tabs[0].id;
|
if (state.activeTab === "prematch") state.activeTab = state.config.tabs.find((tab) => tab.id === "main")?.id || state.config.tabs[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8934,7 +8932,15 @@ function hockeyPreparedNumber(value, fallback = 999999) {
|
|||||||
function hockeyPreparedInventoryInputs() {
|
function hockeyPreparedInventoryInputs() {
|
||||||
const inventory = state.preparedTitleInventory?.inventory || {};
|
const inventory = state.preparedTitleInventory?.inventory || {};
|
||||||
return (Array.isArray(inventory.inputs) ? inventory.inputs : [])
|
return (Array.isArray(inventory.inputs) ? inventory.inputs : [])
|
||||||
.filter((item) => item && typeof item === "object" && Array.isArray(item.fields) && item.fields.length)
|
.filter((item) => {
|
||||||
|
if (!item || typeof item !== "object") return false;
|
||||||
|
const fields = Array.isArray(item.fields) ? item.fields : [];
|
||||||
|
const type = String(item.type || "").trim();
|
||||||
|
// BUILD86: keep title Inputs visible even when an older Agent inventory
|
||||||
|
// did not yet include their child fields. This makes the left-hand title
|
||||||
|
// browser useful instead of showing an empty list.
|
||||||
|
return fields.length > 0 || /(?:^|\b)(GT|XAML|TITLE)(?:$|\b)/i.test(type);
|
||||||
|
})
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => hockeyPreparedNumber(a.number) - hockeyPreparedNumber(b.number) || String(a.title || "").localeCompare(String(b.title || ""), "ru", { numeric: true, sensitivity: "base" }));
|
.sort((a, b) => hockeyPreparedNumber(a.number) - hockeyPreparedNumber(b.number) || String(a.title || "").localeCompare(String(b.title || ""), "ru", { numeric: true, sensitivity: "base" }));
|
||||||
}
|
}
|
||||||
@@ -8978,6 +8984,8 @@ function hockeyPreparedWorkspaceKey() {
|
|||||||
return [currentRuntimeVmixDeviceId(), hockeyTimerSelectedGameId()].join("|");
|
return [currentRuntimeVmixDeviceId(), hockeyTimerSelectedGameId()].join("|");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BUILD86: direct backend endpoint family: /api/hockey/prepared-titles
|
||||||
|
// hockeyGameControlRequest() adds the /api/hockey prefix exactly once.
|
||||||
async function hockeyLoadPreparedMappingSources(panelId = state.preparedTitlePanelId, { render = false } = {}) {
|
async function hockeyLoadPreparedMappingSources(panelId = state.preparedTitlePanelId, { render = false } = {}) {
|
||||||
const clean = String(panelId || "").trim();
|
const clean = String(panelId || "").trim();
|
||||||
if (!clean) {
|
if (!clean) {
|
||||||
@@ -8990,7 +8998,7 @@ async function hockeyLoadPreparedMappingSources(panelId = state.preparedTitlePan
|
|||||||
const deviceId = currentRuntimeVmixDeviceId();
|
const deviceId = currentRuntimeVmixDeviceId();
|
||||||
if (deviceId) params.set("device_id", deviceId);
|
if (deviceId) params.set("device_id", deviceId);
|
||||||
params.set("panel_id", clean);
|
params.set("panel_id", clean);
|
||||||
const payload = await api(`/api/hockey/prepared-titles/mapping-sources?${params.toString()}`);
|
const payload = await hockeyGameControlRequest(`/prepared-titles/mapping-sources?${params.toString()}`);
|
||||||
state.preparedTitleMappingSources = Array.isArray(payload?.items) ? payload.items : [];
|
state.preparedTitleMappingSources = Array.isArray(payload?.items) ? payload.items : [];
|
||||||
if (!state.preparedTitleSourceKey && state.preparedTitleMappingSources.length === 1) {
|
if (!state.preparedTitleSourceKey && state.preparedTitleMappingSources.length === 1) {
|
||||||
const source = state.preparedTitleMappingSources[0];
|
const source = state.preparedTitleMappingSources[0];
|
||||||
@@ -9030,10 +9038,26 @@ async function hockeyLoadPreparedTitlesWorkspace({ force = false, render = true
|
|||||||
const listParams = new URLSearchParams();
|
const listParams = new URLSearchParams();
|
||||||
if (deviceId) listParams.set("device_id", deviceId);
|
if (deviceId) listParams.set("device_id", deviceId);
|
||||||
if (gameId) listParams.set("game_id", gameId);
|
if (gameId) listParams.set("game_id", gameId);
|
||||||
const [inventoryPayload, preparedPayload] = await Promise.all([
|
let inventoryPayload = null;
|
||||||
api(`/api/hockey/agents/vmix-inventory${invParams.toString() ? `?${invParams}` : ""}`),
|
try {
|
||||||
api(`/api/hockey/prepared-titles${listParams.toString() ? `?${listParams}` : ""}`),
|
inventoryPayload = await hockeyGameControlRequest(`/agents/vmix-inventory${invParams.toString() ? `?${invParams}` : ""}`);
|
||||||
]);
|
} catch (inventoryError) {
|
||||||
|
// A browser can retain an obsolete locally-selected Agent id. Retry the
|
||||||
|
// active account Agent before giving up, so the title browser still opens.
|
||||||
|
if (deviceId) {
|
||||||
|
inventoryPayload = await hockeyGameControlRequest("/agents/vmix-inventory");
|
||||||
|
} else {
|
||||||
|
throw inventoryError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let preparedPayload = { items: [] };
|
||||||
|
try {
|
||||||
|
preparedPayload = await hockeyGameControlRequest(`/prepared-titles${listParams.toString() ? `?${listParams}` : ""}`);
|
||||||
|
} catch (preparedListError) {
|
||||||
|
console.error("Prepared titles saved-list error", preparedListError);
|
||||||
|
// Saved-list failure must not hide the source title inventory/editor.
|
||||||
|
preparedPayload = { items: [] };
|
||||||
|
}
|
||||||
state.preparedTitleInventory = inventoryPayload || { device_id: "", device_name: "", inventory: { inputs: [] } };
|
state.preparedTitleInventory = inventoryPayload || { device_id: "", device_name: "", inventory: { inputs: [] } };
|
||||||
state.preparedTitles = Array.isArray(preparedPayload?.items) ? preparedPayload.items : [];
|
state.preparedTitles = Array.isArray(preparedPayload?.items) ? preparedPayload.items : [];
|
||||||
state.preparedTitlesLoadedKey = key;
|
state.preparedTitlesLoadedKey = key;
|
||||||
@@ -9088,7 +9112,7 @@ async function hockeyApplyPreparedMappingSnapshot() {
|
|||||||
if (input.number) params.set("input_number", input.number);
|
if (input.number) params.set("input_number", input.number);
|
||||||
if (input.title) params.set("input_title", input.title);
|
if (input.title) params.set("input_title", input.title);
|
||||||
if (state.preparedTitlePanelId) params.set("panel_id", state.preparedTitlePanelId);
|
if (state.preparedTitlePanelId) params.set("panel_id", state.preparedTitlePanelId);
|
||||||
const payload = await api(`/api/hockey/prepared-titles/mapping-snapshot?${params.toString()}`);
|
const payload = await hockeyGameControlRequest(`/prepared-titles/mapping-snapshot?${params.toString()}`);
|
||||||
const fields = Array.isArray(payload?.fields) ? payload.fields : [];
|
const fields = Array.isArray(payload?.fields) ? payload.fields : [];
|
||||||
if (!fields.length) {
|
if (!fields.length) {
|
||||||
toast(state.preparedTitlePanelId ? "Для этого блока и Input нет Mapping-связей" : "Для этого Input нет Mapping-связей", true);
|
toast(state.preparedTitlePanelId ? "Для этого блока и Input нет Mapping-связей" : "Для этого Input нет Mapping-связей", true);
|
||||||
@@ -9130,7 +9154,7 @@ async function hockeyCreatePreparedTitle() {
|
|||||||
state.preparedTitlesLoading = true;
|
state.preparedTitlesLoading = true;
|
||||||
renderRuntime();
|
renderRuntime();
|
||||||
try {
|
try {
|
||||||
const payload = await api("/api/hockey/prepared-titles", {
|
const payload = await hockeyGameControlRequest("/prepared-titles", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -9163,7 +9187,7 @@ async function hockeyCreatePreparedTitle() {
|
|||||||
|
|
||||||
async function hockeyPreviewPreparedTitle(id) {
|
async function hockeyPreviewPreparedTitle(id) {
|
||||||
try {
|
try {
|
||||||
await api(`/api/hockey/prepared-titles/${encodeURIComponent(id)}/preview`, {
|
await hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}/preview`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ device_id: currentRuntimeVmixDeviceId(), session_token: currentRuntimeHockeySessionToken() }),
|
body: JSON.stringify({ device_id: currentRuntimeVmixDeviceId(), session_token: currentRuntimeHockeySessionToken() }),
|
||||||
@@ -9179,7 +9203,7 @@ async function hockeyPreviewPreparedTitle(id) {
|
|||||||
async function hockeyDeletePreparedTitle(id) {
|
async function hockeyDeletePreparedTitle(id) {
|
||||||
if (!confirm("Убрать заготовку из списка? Сам vMix Input останется в проекте.")) return false;
|
if (!confirm("Убрать заготовку из списка? Сам vMix Input останется в проекте.")) return false;
|
||||||
try {
|
try {
|
||||||
await api(`/api/hockey/prepared-titles/${encodeURIComponent(id)}`, { method: "DELETE" });
|
await hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||||
state.preparedTitles = state.preparedTitles.filter((item) => String(item.id) !== String(id));
|
state.preparedTitles = state.preparedTitles.filter((item) => String(item.id) !== String(id));
|
||||||
renderRuntime();
|
renderRuntime();
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
Reference in New Issue
Block a user