маппинг

This commit is contained in:
2026-08-19 17:44:24 +03:00
parent 614a61168a
commit 14067f082c
10 changed files with 632 additions and 40 deletions

View File

@@ -12,7 +12,7 @@ from typing import Any, Callable
from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect, status
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import and_, desc, select from sqlalchemy import and_, delete, desc, select
from .auth_bridge import HockeyUser from .auth_bridge import HockeyUser
from .database import HockeyDatabase from .database import HockeyDatabase
@@ -1879,6 +1879,20 @@ class VmixAgentHub:
] ]
return payload return payload
def _mapping_device_display_payload(self, session: Any, profile: VmixMappingProfile) -> dict[str, Any]:
source_id = self._runtime_mapping_source_id(profile)
if source_id:
source = session.get(VmixMappingProfile, source_id)
if source is not None:
return {
"id": source.id,
"name": source.name,
"version": source.version,
"runtime_profile_id": profile.id,
"source_profile_id": source.id,
}
return {"id": profile.id, "name": profile.name, "version": profile.version, "source_profile_id": profile.id}
async def list_mapping_devices(self) -> dict[str, Any]: async def list_mapping_devices(self) -> dict[str, Any]:
with self.database.session() as session: with self.database.session() as session:
rows = list(session.scalars(select(VmixDevice).order_by(desc(VmixDevice.last_seen_at)))) rows = list(session.scalars(select(VmixDevice).order_by(desc(VmixDevice.last_seen_at))))
@@ -1897,7 +1911,7 @@ class VmixAgentHub:
"input_count": row.project_input_count, "input_count": row.project_input_count,
"field_count": row.project_field_count, "field_count": row.project_field_count,
"scanned_at": row.project_scanned_at.isoformat() if row.project_scanned_at else "", "scanned_at": row.project_scanned_at.isoformat() if row.project_scanned_at else "",
"mapping": ({"id": profile.id, "name": profile.name, "version": profile.version} if profile else None), "mapping": (self._mapping_device_display_payload(session, profile) if profile else None),
}) })
return {"devices": items} return {"devices": items}
@@ -2182,6 +2196,198 @@ class VmixAgentHub:
session.flush() session.flush()
return profile, report return profile, report
@staticmethod
def _runtime_mapping_profile_name(source_profile_id: int, target_fingerprint: str) -> str:
"""Deterministic hidden profile used only to adapt one saved config to another vMix."""
safe_fp = re.sub(r"[^a-zA-Z0-9]+", "", str(target_fingerprint or ""))[:32] or "project"
return f"__AUTO_MAPPING__{int(source_profile_id)}__{safe_fp}"[:200]
@staticmethod
def _runtime_mapping_source_id(profile: VmixMappingProfile | None) -> int:
if profile is None:
return 0
match = re.match(r"^__AUTO_MAPPING__(\d+)__", str(profile.name or ""))
return int(match.group(1)) if match else 0
def _upsert_runtime_mapping_for_device(
self,
session: Any,
*,
source: VmixMappingProfile,
source_fields: list[dict[str, Any]],
device: VmixDevice,
user: HockeyUser,
) -> tuple[VmixMappingProfile, dict[str, Any]]:
"""Create/update a hidden rebinding of a saved config for one concrete vMix project."""
try:
inventory = json.loads(device.project_inventory_json or "{}")
except Exception:
inventory = {}
rebound, report = self._rebind_portable_mapping_fields(source_fields, inventory)
if not rebound:
raise HTTPException(status_code=409, detail="Не удалось сопоставить ни одной связи выбранного Mapping с вашим vMix")
target_fingerprint = str(device.project_fingerprint or "")[:64]
runtime_name = self._runtime_mapping_profile_name(source.id, target_fingerprint)
runtime = session.scalar(select(VmixMappingProfile).where(VmixMappingProfile.name == runtime_name))
now = _utcnow()
# Only one concrete mapping can drive a vMix project at a time.
for other in session.scalars(
select(VmixMappingProfile).where(and_(
VmixMappingProfile.project_fingerprint == target_fingerprint,
VmixMappingProfile.active.is_(True),
))
):
if runtime is not None and other.id == runtime.id:
continue
other.active = False
other.updated_by = user.login
other.updated_at = now
if runtime is None:
runtime = VmixMappingProfile(
name=runtime_name,
description=f"Техническая адаптация Mapping #{source.id}: {source.name}"[:4000],
project_fingerprint=target_fingerprint,
inventory_json=device.project_inventory_json or "{}",
version=1,
active=True,
created_by=user.login,
updated_by=user.login,
created_at=now,
updated_at=now,
)
session.add(runtime)
session.flush()
else:
runtime.description = f"Техническая адаптация Mapping #{source.id}: {source.name}"[:4000]
runtime.project_fingerprint = target_fingerprint
runtime.inventory_json = device.project_inventory_json or "{}"
runtime.version = int(runtime.version or 0) + 1
runtime.active = True
runtime.updated_by = user.login
runtime.updated_at = now
session.execute(delete(VmixMappingField).where(VmixMappingField.profile_id == runtime.id))
session.flush()
for index, item in enumerate(rebound):
session.add(VmixMappingField(
profile_id=runtime.id,
graphic=str(item.get("graphic") or "")[:100],
data_key=str(item.get("data_key") or "")[:200],
vmix_input_key=str(item.get("vmix_input_key") or "")[:128],
vmix_input_number=str(item.get("vmix_input_number") or "")[:32],
vmix_input_title=str(item.get("vmix_input_title") or "")[:300],
vmix_field=str(item.get("vmix_field") or "")[:300],
field_type=str(item.get("field_type") or "text")[:32],
rule_json=json.dumps(dict(item.get("rule") or {}), ensure_ascii=False, separators=(",", ":"))[:12000],
enabled=bool(item.get("enabled", True)),
sort_order=index,
))
session.flush()
return runtime, report
async def use_mapping_profile_for_user(self, profile_id: int, payload: Any, user: HockeyUser) -> dict[str, Any]:
"""Use any saved Mapping config on the Agent selected for the user's current match."""
requested = str(getattr(payload, "device_id", "") or "").strip()
session_token = str(getattr(payload, "session_token", "") or "").strip()
with self.database.session() as session:
source = session.get(VmixMappingProfile, profile_id)
if source is None or self._runtime_mapping_source_id(source):
raise HTTPException(status_code=404, detail="Mapping-конфиг не найден")
operator = None
if session_token:
operator = session.scalar(select(OperatorSession).where(and_(
OperatorSession.session_token == session_token,
OperatorSession.wfl_user_id == user.id,
OperatorSession.status == "active",
)))
if operator is None:
operator = session.scalar(
select(OperatorSession)
.where(and_(OperatorSession.wfl_user_id == user.id, OperatorSession.status == "active"))
.order_by(desc(OperatorSession.id))
)
if not requested and operator is not None:
requested = str(operator.vmix_device_uuid or "").strip()
if requested:
requested = self.normalise_device_id(requested)
device = session.scalar(select(VmixDevice).where(and_(
VmixDevice.device_uuid == requested,
VmixDevice.wfl_user_id == user.id,
VmixDevice.is_active_for_account.is_(True),
)))
else:
candidates = list(session.scalars(
select(VmixDevice)
.where(and_(VmixDevice.wfl_user_id == user.id, VmixDevice.is_active_for_account.is_(True)))
.order_by(desc(VmixDevice.last_seen_at))
))
device = candidates[0] if len(candidates) == 1 else None
if device is not None:
requested = str(device.device_uuid)
if device is None:
raise HTTPException(status_code=409, detail="Не удалось определить ваш Agent. Выберите Agent для текущей сессии матча.")
if not device.project_fingerprint or not str(device.project_inventory_json or "").strip():
raise HTTPException(status_code=409, detail="Ваш Agent ещё не передал структуру vMix")
if not bool(device.vmix_connected):
raise HTTPException(status_code=409, detail="На вашем Agent сейчас не подключён vMix")
# Make sure this concrete Agent is attached to the current match before sending values.
assignment = await self.assign_current_match(user, device_id=requested)
if assignment is None:
raise HTTPException(status_code=409, detail="Сначала откройте матч в основном интерфейсе")
with self.database.session() as session:
source = session.get(VmixMappingProfile, profile_id)
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == requested))
if source is None or device is None:
raise HTTPException(status_code=404, detail="Mapping или Agent больше недоступен")
source_payload = self._mapping_profile_payload(session, source, include_inventory=False, include_fields=True)
source_fields = list(source_payload.get("fields") or [])
target_fingerprint = str(device.project_fingerprint or "")
if str(source.project_fingerprint or "") == target_fingerprint:
now = _utcnow()
for other in session.scalars(select(VmixMappingProfile).where(and_(
VmixMappingProfile.project_fingerprint == target_fingerprint,
VmixMappingProfile.active.is_(True),
VmixMappingProfile.id != source.id,
))):
other.active = False
other.updated_by = user.login
other.updated_at = now
source.active = True
source.updated_by = user.login
source.updated_at = now
runtime = source
report = {
"total": len(source_fields), "mapped": len(source_fields), "skipped": 0,
"matched_by": {"key": len(source_fields), "title": 0, "number": 0},
"skipped_items": [], "reused": True,
}
else:
runtime, report = self._upsert_runtime_mapping_for_device(
session, source=source, source_fields=source_fields, device=device, user=user,
)
runtime_id = runtime.id
await self._broadcast_mapping_for_fingerprint(target_fingerprint)
applied = await self.apply_mapping_to_device(requested, reason="mapping_config_selected")
return {
"ok": True,
"device_id": requested,
"profile": {"id": source_payload.get("id"), "name": source_payload.get("name"), "version": source_payload.get("version")},
"runtime_profile_id": runtime_id,
"report": report,
"applied": applied,
}
async def export_mapping_profile(self, profile_id: int) -> dict[str, Any]: async def export_mapping_profile(self, profile_id: int) -> dict[str, Any]:
with self.database.session() as session: with self.database.session() as session:
profile = session.get(VmixMappingProfile, profile_id) profile = session.get(VmixMappingProfile, profile_id)
@@ -2290,7 +2496,11 @@ class VmixAgentHub:
async def list_mapping_profiles(self) -> dict[str, Any]: async def list_mapping_profiles(self) -> dict[str, Any]:
with self.database.session() as session: with self.database.session() as session:
rows = list(session.scalars(select(VmixMappingProfile).order_by(desc(VmixMappingProfile.updated_at), VmixMappingProfile.name))) rows = list(session.scalars(
select(VmixMappingProfile)
.where(~VmixMappingProfile.name.like("__AUTO_MAPPING__%"))
.order_by(desc(VmixMappingProfile.updated_at), VmixMappingProfile.name)
))
items = [self._mapping_profile_payload(session, row, include_inventory=False, include_fields=False) for row in rows] items = [self._mapping_profile_payload(session, row, include_inventory=False, include_fields=False) for row in rows]
for item, row in zip(items, rows): for item, row in zip(items, rows):
item["field_count"] = len(list(session.scalars(select(VmixMappingField.id).where(VmixMappingField.profile_id == row.id)))) item["field_count"] = len(list(session.scalars(select(VmixMappingField.id).where(VmixMappingField.profile_id == row.id))))
@@ -2426,6 +2636,11 @@ class VmixAgentHub:
if profile is None: if profile is None:
raise HTTPException(status_code=404, detail="Mapping-профиль не найден") raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
fingerprint = profile.project_fingerprint fingerprint = profile.project_fingerprint
runtime_prefix = f"__AUTO_MAPPING__{profile_id}__%"
runtime_profiles = list(session.scalars(select(VmixMappingProfile).where(VmixMappingProfile.name.like(runtime_prefix))))
for runtime in runtime_profiles:
session.execute(delete(VmixMappingField).where(VmixMappingField.profile_id == runtime.id))
session.delete(runtime)
for field in list(session.scalars(select(VmixMappingField).where(VmixMappingField.profile_id == profile_id))): for field in list(session.scalars(select(VmixMappingField).where(VmixMappingField.profile_id == profile_id))):
session.delete(field) session.delete(field)
session.delete(profile) session.delete(profile)
@@ -2606,6 +2821,11 @@ class MappingFieldsReplacePayload(BaseModel):
fields: list[MappingFieldPayload] = Field(default_factory=list, max_length=2000) fields: list[MappingFieldPayload] = Field(default_factory=list, max_length=2000)
class MappingProfileUsePayload(BaseModel):
device_id: str = Field(default="", max_length=128)
session_token: str = Field(default="", max_length=128)
class MappingProfileCopyPayload(BaseModel): class MappingProfileCopyPayload(BaseModel):
device_id: str = Field(min_length=6, max_length=128) device_id: str = Field(min_length=6, max_length=128)
name: str = Field(default="", max_length=200) name: str = Field(default="", max_length=200)
@@ -2840,6 +3060,10 @@ def create_hockey_agent_router(
async def mapping_profile_create(payload: MappingProfileCreatePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]: async def mapping_profile_create(payload: MappingProfileCreatePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.create_mapping_profile(payload, user) return await hub.create_mapping_profile(payload, user)
@router.post("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/use", dependencies=admin)
async def mapping_profile_use(profile_id: int, payload: MappingProfileUsePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.use_mapping_profile_for_user(profile_id, payload, user)
@router.get("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/export", dependencies=admin) @router.get("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/export", dependencies=admin)
async def mapping_profile_export(profile_id: int) -> dict[str, Any]: async def mapping_profile_export(profile_id: int) -> dict[str, Any]:
return await hub.export_mapping_profile(profile_id) return await hub.export_mapping_profile(profile_id)

View File

@@ -34,6 +34,8 @@ from .models import (
TournamentStandingRow, TournamentStandingRow,
TournamentStatisticResource, TournamentStatisticResource,
TournamentStatisticRow, TournamentStatisticRow,
Team,
TeamLeague,
TeamSeasonStatistic, TeamSeasonStatistic,
UserPreference, UserPreference,
VmixAssignment, VmixAssignment,
@@ -161,6 +163,8 @@ class HockeyDatabase:
repaired = migrated + self._repair_game_table() repaired = migrated + self._repair_game_table()
for model in ( for model in (
OperatorSession, OperatorSession,
Team,
TeamLeague,
Country, Country,
PenaltyType, PenaltyType,
Player, Player,

View File

@@ -254,6 +254,7 @@ class Team(Base):
short_name_en: Mapped[str] = mapped_column(String(255), nullable=False, default="") short_name_en: Mapped[str] = mapped_column(String(255), nullable=False, default="")
city_ru: Mapped[str] = mapped_column(String(255), nullable=False, default="") city_ru: Mapped[str] = mapped_column(String(255), nullable=False, default="")
city_en: Mapped[str] = mapped_column(String(255), nullable=False, default="") city_en: Mapped[str] = mapped_column(String(255), nullable=False, default="")
color_hex: Mapped[str] = mapped_column(String(9), nullable=False, default="")
logo_url: Mapped[str] = mapped_column(Text, nullable=False, default="") logo_url: Mapped[str] = mapped_column(Text, nullable=False, default="")
raw_payload: Mapped[str] = mapped_column(Text, nullable=False, default="") raw_payload: Mapped[str] = mapped_column(Text, nullable=False, default="")
synced_at: Mapped[datetime] = mapped_column( synced_at: Mapped[datetime] = mapped_column(

View File

@@ -141,6 +141,7 @@ class TeamDirectoryPayload(BaseModel):
short_name_en: str = Field(default="", max_length=255) short_name_en: str = Field(default="", max_length=255)
city_ru: str = Field(default="", max_length=255) city_ru: str = Field(default="", max_length=255)
city_en: str = Field(default="", max_length=255) city_en: str = Field(default="", max_length=255)
color_hex: str = Field(default="", max_length=9)
logo_url: str = Field(default="", max_length=2000) logo_url: str = Field(default="", max_length=2000)
active: bool = True active: bool = True

View File

@@ -88,6 +88,18 @@ from .tournament_statistics_parser import parse_tournament_statistics_xml
from .navigation_labels import parse_navigation_labels_xml from .navigation_labels import parse_navigation_labels_xml
def normalise_team_color_hex(value: Any) -> str:
"""Return an uppercase #RRGGBB value or an empty string for unset colours."""
raw_color = str(value or "").strip().upper()
if not raw_color:
return ""
if not raw_color.startswith("#"):
raw_color = f"#{raw_color}"
if not re.fullmatch(r"#[0-9A-F]{6}", raw_color):
raise ValueError("Цвет команды должен быть в формате #RRGGBB")
return raw_color
DEFAULT_PENALTY_TYPES: tuple[tuple[str, str, str, str], ...] = ( DEFAULT_PENALTY_TYPES: tuple[tuple[str, str, str, str], ...] = (
("TRIP", "Подножка", "Tripping", "2"), ("TRIP", "Подножка", "Tripping", "2"),
("HOOK", "Задержка клюшкой", "Hooking", "2"), ("HOOK", "Задержка клюшкой", "Hooking", "2"),
@@ -596,6 +608,7 @@ class HockeyDataService:
"short_name_en": team.short_name_en, "short_name_en": team.short_name_en,
"city_ru": team.city_ru, "city_ru": team.city_ru,
"city_en": team.city_en, "city_en": team.city_en,
"color_hex": team.color_hex,
"logo_url": team.logo_url, "logo_url": team.logo_url,
"source": membership.source, "source": membership.source,
"active": bool(membership.active), "active": bool(membership.active),
@@ -742,6 +755,10 @@ class HockeyDataService:
): ):
if field in payload and payload[field] is not None: if field in payload and payload[field] is not None:
setattr(team, field, str(payload[field]).strip()) setattr(team, field, str(payload[field]).strip())
if "color_hex" in payload and payload["color_hex"] is not None:
team.color_hex = normalise_team_color_hex(payload["color_hex"])
if not (team.name_ru or team.name_en): if not (team.name_ru or team.name_en):
raise ValueError("Укажите название команды на русском или английском") raise ValueError("Укажите название команды на русском или английском")

View File

@@ -1599,3 +1599,109 @@
.hockey-mapping-import small{display:block;color:#6f879d;font-size:8px;line-height:1.35} .hockey-mapping-import small{display:block;color:#6f879d;font-size:8px;line-height:1.35}
.hockey-mapping-import .hockey-directory-check{margin:2px 0} .hockey-mapping-import .hockey-directory-check{margin:2px 0}
@media(max-width:900px){.hockey-map-transfer-inline{grid-template-columns:1fr}.hockey-map-transfer-inline select{min-width:0;width:100%}} @media(max-width:900px){.hockey-map-transfer-inline{grid-template-columns:1fr}.hockey-map-transfer-inline select{min-width:0;width:100%}}
/* Build 73: editable team HEX colour */
.hockey-team-color-field {
display: grid;
gap: 4px;
}
.hockey-team-color-field small {
color: #627990;
font-size: 9px;
}
.hockey-team-color-control {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) 34px;
gap: 7px;
align-items: center;
}
.hockey-team-color-picker-wrap,
.hockey-team-color-clear {
position: relative;
width: 100%;
min-height: 35px;
padding: 0;
border: 1px solid #304861;
border-radius: 8px;
background: #0a1624;
cursor: pointer;
}
.hockey-team-color-picker-wrap:hover,
.hockey-team-color-clear:hover {
border-color: #48dfbd;
}
.hockey-team-color-picker-wrap > i {
display: block;
width: 24px;
height: 24px;
margin: auto;
border: 2px solid rgba(255,255,255,.82);
border-radius: 7px;
background: var(--team-color, #fff);
box-shadow: 0 0 0 1px rgba(0,0,0,.38), inset 0 0 0 1px rgba(0,0,0,.16);
}
.hockey-team-color-picker-wrap input[type="color"] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
min-height: 0;
padding: 0;
opacity: 0;
cursor: pointer;
}
.hockey-team-color-control input[data-team-color-text] {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-weight: 800;
letter-spacing: .5px;
text-transform: uppercase;
}
.hockey-team-color-clear {
color: #879bb1;
font-size: 18px;
line-height: 1;
}
.hockey-team-color-cell {
display: inline-flex;
align-items: center;
gap: 7px;
}
.hockey-team-color-cell i {
width: 18px;
height: 18px;
flex: 0 0 18px;
border: 1px solid rgba(255,255,255,.5);
border-radius: 5px;
background: var(--team-color, transparent);
box-shadow: 0 0 0 1px rgba(0,0,0,.28);
}
.hockey-team-color-cell code {
color: #b9c9da;
font-size: 9px;
}
/* Build 74: reusable Mapping configs + one-click apply to current Agent. */
.hockey-map-config-hint{font-size:11px;line-height:1.45;color:#8ea0b6;background:#0d1520;border:1px solid #2b394c;border-radius:10px;padding:9px 10px}
.hockey-mapping-profile-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;align-items:stretch;border-radius:11px}
.hockey-mapping-profile-row.is-active{box-shadow:0 0 0 1px rgba(56,189,248,.18)}
.hockey-mapping-profile-row .hockey-mapping-profile{min-width:0;width:100%}
.hockey-map-use-profile{min-width:106px;border:1px solid #2f5870;background:#102433;color:#9bdcff;border-radius:10px;padding:7px 9px;font-size:11px;font-weight:700;cursor:pointer;white-space:normal;line-height:1.25}
.hockey-map-use-profile:hover{border-color:#38bdf8;background:#123047;color:#d9f4ff}
.hockey-map-use-profile.is-used{border-color:#276749;background:#102b20;color:#8ff0b5}
.hockey-mapping-create-wrap{border:1px solid #334155;background:#101722;border-radius:12px;overflow:hidden}
.hockey-mapping-create-wrap>summary{cursor:pointer;padding:10px 12px;color:#b9c7d8;font-size:12px;font-weight:700;list-style:none}
.hockey-mapping-create-wrap>summary::-webkit-details-marker{display:none}
.hockey-mapping-create-wrap[open]>summary{border-bottom:1px solid #2e3b4e}
.hockey-mapping-create-wrap .hockey-mapping-create{border:0;border-radius:0;background:transparent}
@media(max-width:520px){.hockey-mapping-profile-row{grid-template-columns:1fr}.hockey-map-use-profile{min-height:34px}}

View File

@@ -63,6 +63,18 @@
.replaceAll('"', """) .replaceAll('"', """)
.replaceAll("'", "'"); .replaceAll("'", "'");
const normalizeTeamColorHex = (value) => {
let color = String(value ?? "").trim().toUpperCase();
if (!color) return "";
if (!color.startsWith("#")) color = `#${color}`;
return /^#[0-9A-F]{6}$/.test(color) ? color : "";
};
const teamColorPreview = (value) => {
const color = normalizeTeamColorHex(value);
return color || "#FFFFFF";
};
const countryFlagMarkup = (item) => { const countryFlagMarkup = (item) => {
const code = String(item?.iso2 || item?.country_code || "").trim().toLowerCase().replace(/[^a-z]/g, "").slice(0, 2); const code = String(item?.iso2 || item?.country_code || "").trim().toLowerCase().replace(/[^a-z]/g, "").slice(0, 2);
const url = String(item?.flag_url || (code.length === 2 ? `/hockey-assets/flags/${code}.svg` : "")); const url = String(item?.flag_url || (code.length === 2 ? `/hockey-assets/flags/${code}.svg` : ""));
@@ -301,11 +313,12 @@
<td><strong>${escapeHtml(item.name_ru || "—")}</strong><small>${escapeHtml(item.short_name_ru || "")}</small></td> <td><strong>${escapeHtml(item.name_ru || "—")}</strong><small>${escapeHtml(item.short_name_ru || "")}</small></td>
<td><strong>${escapeHtml(item.name_en || "—")}</strong><small>${escapeHtml(item.short_name_en || "")}</small></td> <td><strong>${escapeHtml(item.name_en || "—")}</strong><small>${escapeHtml(item.short_name_en || "")}</small></td>
<td>${escapeHtml(item.city_ru || item.city_en || "—")}</td> <td>${escapeHtml(item.city_ru || item.city_en || "—")}</td>
<td>${item.color_hex ? `<span class="hockey-team-color-cell"><i style="--team-color:${escapeHtml(item.color_hex)}"></i><code>${escapeHtml(item.color_hex)}</code></span>` : "—"}</td>
<td><span class="hockey-directory-source">${escapeHtml(item.source === "manual" ? "изменено" : "Stat2TV")}</span></td> <td><span class="hockey-directory-source">${escapeHtml(item.source === "manual" ? "изменено" : "Stat2TV")}</span></td>
<td><button type="button" class="hockey-directory-edit" data-edit-team="${item.id}">Изменить</button></td> <td><button type="button" class="hockey-directory-edit" data-edit-team="${item.id}">Изменить</button></td>
</tr> </tr>
`).join("") `).join("")
: `<tr><td colspan="6" class="hockey-directory-empty">Для этой лиги команды ещё не загружены. Нажмите «Обновить из API».</td></tr>`; : `<tr><td colspan="7" class="hockey-directory-empty">Для этой лиги команды ещё не загружены. Нажмите «Обновить из API».</td></tr>`;
const content = ` const content = `
<div class="hockey-directory-toolbar"> <div class="hockey-directory-toolbar">
@@ -320,7 +333,7 @@
<div class="hockey-directory-grid"> <div class="hockey-directory-grid">
<div class="hockey-directory-table-wrap"> <div class="hockey-directory-table-wrap">
<table> <table>
<thead><tr><th>ID API</th><th>Русский</th><th>English</th><th>Город</th><th>Источник</th><th></th></tr></thead> <thead><tr><th>ID API</th><th>Русский</th><th>English</th><th>Город</th><th>Цвет</th><th>Источник</th><th></th></tr></thead>
<tbody>${rows}</tbody> <tbody>${rows}</tbody>
</table> </table>
</div> </div>
@@ -339,6 +352,20 @@
<label><span>City (English)</span><input name="city_en" maxlength="255" value="${escapeHtml(editing?.city_en || "")}"></label> <label><span>City (English)</span><input name="city_en" maxlength="255" value="${escapeHtml(editing?.city_en || "")}"></label>
</div> </div>
<label><span>Логотип (URL)</span><input name="logo_url" maxlength="2000" value="${escapeHtml(editing?.logo_url || "")}"></label> <label><span>Логотип (URL)</span><input name="logo_url" maxlength="2000" value="${escapeHtml(editing?.logo_url || "")}"></label>
<div class="hockey-team-color-field">
<label>
<span>Цвет команды</span>
<div class="hockey-team-color-control">
<button type="button" class="hockey-team-color-picker-wrap" data-team-color-open title="Выбрать цвет">
<i data-team-color-preview style="--team-color:${escapeHtml(teamColorPreview(editing?.color_hex))}"></i>
<input type="color" data-team-color-picker value="${escapeHtml(teamColorPreview(editing?.color_hex))}" tabindex="-1" aria-label="Выбрать цвет команды">
</button>
<input name="color_hex" data-team-color-text maxlength="7" placeholder="#282E66" value="${escapeHtml(editing?.color_hex || "")}" autocomplete="off" spellcheck="false">
<button type="button" class="hockey-team-color-clear" data-team-color-clear title="Очистить цвет">×</button>
</div>
<small>HEX, например #282E66</small>
</label>
</div>
<label class="hockey-directory-check"><input type="checkbox" name="active" ${editing?.active === false ? "" : "checked"}><span>Активна</span></label> <label class="hockey-directory-check"><input type="checkbox" name="active" ${editing?.active === false ? "" : "checked"}><span>Активна</span></label>
<div class="hockey-directory-form-actions"> <div class="hockey-directory-form-actions">
${editing ? `<button type="button" data-cancel-team>Отмена</button>` : ""} ${editing ? `<button type="button" data-cancel-team>Отмена</button>` : ""}
@@ -2127,11 +2154,19 @@
<span>${item.mapping ? `Mapping: <b>${escapeHtml(item.mapping.name)}</b> · v${Number(item.mapping.version || 1)}` : (item.project_fingerprint ? "Mapping не назначен" : "Ожидание структуры vMix")}</span> <span>${item.mapping ? `Mapping: <b>${escapeHtml(item.mapping.name)}</b> · v${Number(item.mapping.version || 1)}` : (item.project_fingerprint ? "Mapping не назначен" : "Ожидание структуры vMix")}</span>
</article>`).join("") : `<div class="hockey-directory-empty">Agent пока не передал структуру ни одного vMix.</div>`; </article>`).join("") : `<div class="hockey-directory-empty">Agent пока не передал структуру ни одного vMix.</div>`;
const profileButtons = profiles.length ? profiles.map((item) => ` const selectedDeviceId = String(localStorage.getItem("hockey.vmix.selected_device") || "").trim();
<button type="button" class="hockey-mapping-profile ${profile?.id === item.id ? "is-active" : ""}" data-mapping-profile="${item.id}"> const selectedDevice = devices.find((item) => String(item.device_id || "") === selectedDeviceId) || null;
<strong>${escapeHtml(item.name)}</strong> const profileButtons = profiles.length ? profiles.map((item) => {
<small>v${Number(item.version || 1)} · ${Number(item.field_count || 0)} связей</small> const isUsed = Boolean(selectedDevice?.mapping && Number(selectedDevice.mapping.source_profile_id || selectedDevice.mapping.id || 0) === Number(item.id));
</button>`).join("") : `<div class="hockey-directory-empty">Mapping-профилей ещё нет.</div>`; return `
<div class="hockey-mapping-profile-row ${profile?.id === item.id ? "is-active" : ""}">
<button type="button" class="hockey-mapping-profile ${profile?.id === item.id ? "is-active" : ""}" data-mapping-profile="${item.id}">
<strong>${escapeHtml(item.name)}</strong>
<small>v${Number(item.version || 1)} · ${Number(item.field_count || 0)} связей${item.created_by ? ` · ${escapeHtml(item.created_by)}` : ""}</small>
</button>
<button type="button" class="hockey-map-use-profile ${isUsed ? "is-used" : ""}" data-map-use-profile="${item.id}" ${isUsed ? 'title="Этот конфиг уже выбран на вашем Agent"' : ""}>${isUsed ? "✓ Используется" : "Применить к моему Agent"}</button>
</div>`;
}).join("") : `<div class="hockey-directory-empty">Mapping-конфигов ещё нет.</div>`;
const loadErrors = Object.entries(state.mappingLoadErrors || {}); const loadErrors = Object.entries(state.mappingLoadErrors || {});
const mappingDiagnostics = loadErrors.length ? `<div class="hockey-map-load-errors"><strong>Часть Mapping API недоступна</strong>${loadErrors.map(([key, value]) => `<span><code>${escapeHtml(key)}</code>${escapeHtml(value)}</span>`).join("")}</div>` : ""; const mappingDiagnostics = loadErrors.length ? `<div class="hockey-map-load-errors"><strong>Часть Mapping API недоступна</strong>${loadErrors.map(([key, value]) => `<span><code>${escapeHtml(key)}</code>${escapeHtml(value)}</span>`).join("")}</div>` : "";
@@ -2173,14 +2208,9 @@
<div class="hockey-map-maintenance"> <div class="hockey-map-maintenance">
<select data-map-refresh-device><option value="">Обновить структуру из Agent…</option>${usableDevices.map((item) => `<option value="${escapeHtml(item.device_id)}">${escapeHtml(item.name || item.device_id)} · ${Number(item.input_count || 0)}/${Number(item.field_count || 0)}</option>`).join("")}</select> <select data-map-refresh-device><option value="">Обновить структуру из Agent…</option>${usableDevices.map((item) => `<option value="${escapeHtml(item.device_id)}">${escapeHtml(item.name || item.device_id)} · ${Number(item.input_count || 0)}/${Number(item.field_count || 0)}</option>`).join("")}</select>
<button type="button" data-map-refresh>Считать vMix заново</button> <button type="button" data-map-refresh>Считать vMix заново</button>
<div class="hockey-map-transfer-inline"> <button type="button" class="danger" data-map-delete>Удалить конфиг</button>
<select data-map-copy-device><option value="">Перенести на другой Agent…</option>${usableDevices.map((item) => `<option value="${escapeHtml(item.device_id)}">${escapeHtml(item.name || item.device_id)} · ${Number(item.input_count || 0)} Inputs</option>`).join("")}</select>
<button type="button" data-map-copy-to-device>Копировать на Agent</button>
</div>
<button type="button" data-map-export>Экспорт JSON</button>
<button type="button" class="danger" data-map-delete>Удалить профиль</button>
</div> </div>
<div><label class="hockey-directory-check"><input type="checkbox" data-mapping-active ${profile.active !== false ? "checked" : ""}><span>Профиль активен</span></label><button type="button" data-map-apply-now title="Применяются сохранённые на сервере связи">Применить весь Mapping</button><button type="button" class="is-accent" data-map-save>Сохранить mapping</button></div> <div><button type="button" class="is-accent" data-map-save>Сохранить конфиг</button></div>
</div>`; </div>`;
} }
@@ -2189,24 +2219,18 @@
<aside class="hockey-mapping-sidebar"> <aside class="hockey-mapping-sidebar">
<h3>vMix проекты</h3> <h3>vMix проекты</h3>
<div class="hockey-mapping-devices">${deviceRows}</div> <div class="hockey-mapping-devices">${deviceRows}</div>
<form class="hockey-mapping-create" data-mapping-create> <h3>Конфиги Mapping</h3>
<h3>Новый Mapping</h3> <div class="hockey-map-config-hint">Выберите любой готовый конфиг — свой или чужой — и нажмите «Применить к моему Agent». Он будет использован для текущего открытого матча.</div>
<label>Agent / текущий vMix<select name="device_id" required><option value="">Выберите устройство</option>${usableDevices.map((item) => `<option value="${escapeHtml(item.device_id)}">${escapeHtml(item.name || item.device_id)} · ${Number(item.input_count || 0)} Inputs</option>`).join("")}</select></label>
<label>Название<input name="name" required placeholder="KHL_MAIN_2026"></label>
<label>Описание<input name="description" placeholder="Основной графический пакет"></label>
<button type="submit" class="is-accent" ${usableDevices.length ? "" : "disabled"}>Создать из vMix</button>
</form>
<form class="hockey-mapping-create hockey-mapping-import" data-mapping-import>
<h3>Импорт готового Mapping</h3>
<label>Файл Mapping<input type="file" name="file" accept="application/json,.json" required></label>
<label>Применить к Agent<select name="device_id" required><option value="">Выберите устройство</option>${usableDevices.map((item) => `<option value="${escapeHtml(item.device_id)}">${escapeHtml(item.name || item.device_id)} · ${Number(item.input_count || 0)} Inputs</option>`).join("")}</select></label>
<label>Новое название<input name="name" placeholder="Оставить название из файла"></label>
<label class="hockey-directory-check"><input type="checkbox" name="replace_existing"><span>Заменить активный Mapping этого vMix</span></label>
<button type="submit" ${usableDevices.length ? "" : "disabled"}>Загрузить и применить</button>
<small>Input ищется по key → названию → номеру. Поле должно совпасть по имени.</small>
</form>
<h3>Профили</h3>
<div class="hockey-mapping-profiles">${profileButtons}</div> <div class="hockey-mapping-profiles">${profileButtons}</div>
<details class="hockey-mapping-create-wrap">
<summary>+ Создать новый конфиг из vMix</summary>
<form class="hockey-mapping-create" data-mapping-create>
<label>Agent / текущий vMix<select name="device_id" required><option value="">Выберите устройство</option>${usableDevices.map((item) => `<option value="${escapeHtml(item.device_id)}">${escapeHtml(item.name || item.device_id)} · ${Number(item.input_count || 0)} Inputs</option>`).join("")}</select></label>
<label>Название<input name="name" required placeholder="KHL_MAIN_2026"></label>
<label>Описание<input name="description" placeholder="Основной графический пакет"></label>
<button type="submit" class="is-accent" ${usableDevices.length ? "" : "disabled"}>Создать конфиг</button>
</form>
</details>
</aside> </aside>
<section class="hockey-mapping-workspace">${statusMarkup()}${mappingDiagnostics}${editor}</section> <section class="hockey-mapping-workspace">${statusMarkup()}${mappingDiagnostics}${editor}</section>
</div>`); </div>`);
@@ -2406,6 +2430,32 @@
modal.querySelector("[data-mapping-name]")?.addEventListener("input", (event) => { if (state.mappingActiveProfile) state.mappingActiveProfile.name = event.currentTarget.value; }); modal.querySelector("[data-mapping-name]")?.addEventListener("input", (event) => { if (state.mappingActiveProfile) state.mappingActiveProfile.name = event.currentTarget.value; });
modal.querySelector("[data-mapping-description]")?.addEventListener("input", (event) => { if (state.mappingActiveProfile) state.mappingActiveProfile.description = event.currentTarget.value; }); modal.querySelector("[data-mapping-description]")?.addEventListener("input", (event) => { if (state.mappingActiveProfile) state.mappingActiveProfile.description = event.currentTarget.value; });
modal.querySelector("[data-mapping-active]")?.addEventListener("change", (event) => { if (state.mappingActiveProfile) state.mappingActiveProfile.active = Boolean(event.currentTarget.checked); }); modal.querySelector("[data-mapping-active]")?.addEventListener("change", (event) => { if (state.mappingActiveProfile) state.mappingActiveProfile.active = Boolean(event.currentTarget.checked); });
modal.querySelectorAll("[data-map-use-profile]").forEach((button) => button.addEventListener("click", async (event) => {
event.stopPropagation();
const profileId = Number(button.dataset.mapUseProfile || 0);
if (!profileId) return;
const selected = (state.mappingProfiles?.profiles || []).find((item) => Number(item.id) === profileId);
const deviceId = String(localStorage.getItem("hockey.vmix.selected_device") || "").trim();
const sessionToken = String(selectedContext().token || "").trim();
button.disabled = true;
button.textContent = "Применяю…";
try {
const result = await request(`/api/hockey/admin/vmix-mapping/profiles/${profileId}/use`, {
method: "POST",
body: JSON.stringify({ device_id: deviceId, session_token: sessionToken }),
});
await loadMapping(profileId);
const applied = result.applied || {};
const report = result.report || {};
const suffix = Number(report.skipped || 0) ? ` · не сопоставлено ${Number(report.skipped || 0)}` : "";
const send = applied.ok === false ? ` · ${escapeHtml(applied.reason || "не удалось отправить данные")}` : ` · отправлено ${Number(applied.applied || 0)} из ${Number(applied.total || 0)}`;
setStatus(`✓ Конфиг «${selected?.name || result.profile?.name || profileId}» выбран для моего Agent${send}${suffix}`, Number(report.skipped || 0) > 0 || applied.ok === false);
} catch (error) {
setStatus(error.message || "Не удалось применить Mapping-конфиг", true);
}
renderMapping();
}));
modal.querySelectorAll("[data-mapping-profile]").forEach((button) => button.addEventListener("click", async () => { modal.querySelectorAll("[data-mapping-profile]").forEach((button) => button.addEventListener("click", async () => {
try { try {
state.mappingActiveProfile = await request(`/api/hockey/admin/vmix-mapping/profiles/${button.dataset.mappingProfile}`); state.mappingActiveProfile = await request(`/api/hockey/admin/vmix-mapping/profiles/${button.dataset.mappingProfile}`);
@@ -2756,7 +2806,7 @@
if (currentScroll) state.mappingVmixScrollTop = currentScroll.scrollTop; if (currentScroll) state.mappingVmixScrollTop = currentScroll.scrollTop;
const id = state.mappingActiveProfile.id; const id = state.mappingActiveProfile.id;
try { try {
await request(`/api/hockey/admin/vmix-mapping/profiles/${id}`, { method: "PUT", body: JSON.stringify({ name: modal.querySelector("[data-mapping-name]")?.value || state.mappingActiveProfile.name, description: modal.querySelector("[data-mapping-description]")?.value || "", active: Boolean(modal.querySelector("[data-mapping-active]")?.checked) }) }); await request(`/api/hockey/admin/vmix-mapping/profiles/${id}`, { method: "PUT", body: JSON.stringify({ name: modal.querySelector("[data-mapping-name]")?.value || state.mappingActiveProfile.name, description: modal.querySelector("[data-mapping-description]")?.value || "", active: modal.querySelector("[data-mapping-active]") ? Boolean(modal.querySelector("[data-mapping-active]")?.checked) : state.mappingActiveProfile.active !== false }) });
const result = await request(`/api/hockey/admin/vmix-mapping/profiles/${id}/fields`, { method: "PUT", body: JSON.stringify({ fields: collectMappingFields() }) }); const result = await request(`/api/hockey/admin/vmix-mapping/profiles/${id}/fields`, { method: "PUT", body: JSON.stringify({ fields: collectMappingFields() }) });
await loadMapping(id); setStatus(`Mapping сохранён · версия ${result.version}.`); await loadMapping(id); setStatus(`Mapping сохранён · версия ${result.version}.`);
} catch (error) { setStatus(error.message, true); } } catch (error) { setStatus(error.message, true); }
@@ -2885,10 +2935,57 @@
setStatus(""); setStatus("");
renderTeams(); renderTeams();
}); });
const colorText = modal.querySelector("[data-team-color-text]");
const colorPicker = modal.querySelector("[data-team-color-picker]");
const colorPreview = modal.querySelector("[data-team-color-preview]");
const syncTeamColorUi = (rawValue, { commit = false } = {}) => {
const normalized = normalizeTeamColorHex(rawValue);
if (normalized) {
if (colorText && (commit || colorText.value !== rawValue)) colorText.value = normalized;
if (colorPicker) colorPicker.value = normalized;
if (colorPreview) colorPreview.style.setProperty("--team-color", normalized);
return normalized;
}
if (commit && colorText && String(rawValue || "").trim()) {
setStatus("Цвет команды должен быть в формате #RRGGBB", true);
}
if (!String(rawValue || "").trim()) {
if (colorText && commit) colorText.value = "";
if (colorPicker) colorPicker.value = "#FFFFFF";
if (colorPreview) colorPreview.style.setProperty("--team-color", "#FFFFFF");
}
return "";
};
modal.querySelector("[data-team-color-open]")?.addEventListener("click", () => colorPicker?.click());
colorPicker?.addEventListener("input", () => {
if (colorText) colorText.value = String(colorPicker.value || "").toUpperCase();
syncTeamColorUi(colorPicker.value);
});
colorText?.addEventListener("input", () => {
const raw = String(colorText.value || "");
const compact = raw.replace(/\s+/g, "").toUpperCase();
if (raw !== compact) colorText.value = compact;
const normalized = normalizeTeamColorHex(compact);
if (normalized) syncTeamColorUi(normalized);
});
colorText?.addEventListener("blur", () => syncTeamColorUi(colorText.value, { commit: true }));
modal.querySelector("[data-team-color-clear]")?.addEventListener("click", () => {
if (colorText) colorText.value = "";
syncTeamColorUi("", { commit: true });
colorText?.focus();
});
modal.querySelector("[data-team-form]")?.addEventListener("submit", async (event) => { modal.querySelector("[data-team-form]")?.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const form = event.currentTarget; const form = event.currentTarget;
const values = new FormData(form); const values = new FormData(form);
const rawTeamColor = String(values.get("color_hex") || "").trim();
const normalizedTeamColor = normalizeTeamColorHex(rawTeamColor);
if (rawTeamColor && !normalizedTeamColor) {
setStatus("Цвет команды должен быть в формате #RRGGBB", true);
colorText?.focus();
return;
}
const payload = { const payload = {
external_id: values.get("external_id") || state.editingTeam?.external_id || "", external_id: values.get("external_id") || state.editingTeam?.external_id || "",
league_key: values.get("league_key") || "", league_key: values.get("league_key") || "",
@@ -2899,6 +2996,7 @@
short_name_en: values.get("short_name_en") || "", short_name_en: values.get("short_name_en") || "",
city_ru: values.get("city_ru") || "", city_ru: values.get("city_ru") || "",
city_en: values.get("city_en") || "", city_en: values.get("city_en") || "",
color_hex: normalizedTeamColor,
logo_url: values.get("logo_url") || "", logo_url: values.get("logo_url") || "",
active: values.has("active"), active: values.has("active"),
}; };

View File

@@ -132,14 +132,12 @@ def test_copy_profile_creates_target_specific_mapping_and_keeps_source(tmp_path:
assert profiles[1].project_fingerprint == "b" * 64 assert profiles[1].project_fingerprint == "b" * 64
def test_admin_ui_has_export_import_and_copy_to_agent_controls() -> None: def test_portable_transfer_backend_remains_available_for_compatibility() -> None:
assert "data-map-export" in JS # Build 74 intentionally simplified the operator UI, but the Build 72 API stays
assert "data-map-copy-to-device" in JS # available so existing integrations/exported tools do not break.
assert "data-mapping-import" in JS
assert "/copy-to-device" in JS assert "/copy-to-device" in JS
assert "/api/hockey/admin/vmix-mapping/import" in JS assert "/api/hockey/admin/vmix-mapping/import" in JS
assert "hockey-mapping.json" in JS assert "hockey-mapping.json" in JS
assert "Input ищется по key → названию → номеру" in JS
assert "portable vMix Mapping transfer/import" in CSS assert "portable vMix Mapping transfer/import" in CSS
assert '"/api/hockey/admin/vmix-mapping/profiles/{profile_id}/export"' in BRIDGE assert '"/api/hockey/admin/vmix-mapping/profiles/{profile_id}/export"' in BRIDGE
assert '"/api/hockey/admin/vmix-mapping/import"' in BRIDGE assert '"/api/hockey/admin/vmix-mapping/import"' in BRIDGE

View File

@@ -0,0 +1,33 @@
from pathlib import Path
import pytest
from hockey_data.models import Team
from hockey_data.router import TeamDirectoryPayload
from hockey_data.service import normalise_team_color_hex
def test_team_model_has_persistent_color_column():
assert "color_hex" in Team.__table__.columns.keys()
column = Team.__table__.columns["color_hex"]
assert column.nullable is False
def test_team_color_normalisation():
assert normalise_team_color_hex("#282e66") == "#282E66"
assert normalise_team_color_hex("e5cea8") == "#E5CEA8"
assert normalise_team_color_hex("") == ""
with pytest.raises(ValueError, match="#RRGGBB"):
normalise_team_color_hex("#12345")
def test_team_payload_carries_color():
payload = TeamDirectoryPayload(color_hex="#282E66")
assert payload.color_hex == "#282E66"
def test_admin_team_form_contains_synced_color_picker():
js = Path("hockey_data/static/admin-directories.js").read_text(encoding="utf-8")
assert 'name="color_hex"' in js
assert 'data-team-color-picker' in js
assert 'color_hex: normalizedTeamColor' in js

View File

@@ -0,0 +1,110 @@
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from sqlalchemy import select
from hockey_data.agent_bridge import VmixAgentHub
from hockey_data.auth_bridge import HockeyUser
from hockey_data.models import VmixDevice, VmixMappingField, VmixMappingProfile
from tests.support import LocalTestDatabase
ROOT = Path(__file__).resolve().parents[1]
JS = (ROOT / "hockey_data/static/admin-directories.js").read_text(encoding="utf-8")
CSS = (ROOT / "hockey_data/static/admin-directories.css").read_text(encoding="utf-8")
BRIDGE = (ROOT / "hockey_data/agent_bridge.py").read_text(encoding="utf-8")
def test_mapping_ui_is_config_list_with_one_click_my_agent_action() -> None:
assert "Конфиги Mapping" in JS
assert "Применить к моему Agent" in JS
assert "data-map-use-profile" in JS
assert "/profiles/${profileId}/use" in JS
assert "Импорт готового Mapping" not in JS
assert "Загрузить и применить" not in JS
assert "Перенести на другой Agent…" not in JS
assert "Экспорт JSON" not in JS
assert "hockey-map-use-profile" in CSS
assert "hockey-mapping-profile-row" in CSS
def test_hidden_runtime_mapping_is_reused_and_not_listed(tmp_path: Path) -> None:
database = LocalTestDatabase(tmp_path / "build74-mapping.sqlite3")
database.create_all()
hub = VmixAgentHub(database) # type: ignore[arg-type]
user = HockeyUser(id="admin-1", login="admin", display_name="Admin")
source_inventory = {
"fingerprint": "a" * 64,
"inputs": [{
"key": "source-key", "number": "3", "title": "SCORE BUG", "type": "GT",
"fields": [{"name": "HomeScore.Text", "type": "text", "index": "0"}],
}],
}
target_inventory = {
"fingerprint": "b" * 64,
"inputs": [{
"key": "target-key", "number": "18", "title": "SCORE BUG", "type": "GT",
"fields": [{"name": "HomeScore.Text", "type": "text", "index": "9"}],
}],
}
with database.session() as session:
source = VmixMappingProfile(
name="Чужой KHL Mapping", description="shared config",
project_fingerprint="a" * 64, inventory_json=json.dumps(source_inventory),
version=4, active=True, created_by="other-admin", updated_by="other-admin",
)
session.add(source)
session.flush()
source_id = source.id
session.add(VmixMappingField(
profile_id=source.id, graphic="score", data_key="game.home_score",
vmix_input_key="source-key", vmix_input_number="", vmix_input_title="SCORE BUG",
vmix_field="HomeScore.Text", field_type="text", rule_json="{}", enabled=True, sort_order=0,
))
device = VmixDevice(
device_uuid="MY-GFX-AGENT-01", device_secret_hash="x" * 64, name="My Agent",
wfl_user_id="admin-1", login_snapshot="admin", is_active_for_account=True,
project_fingerprint="b" * 64, project_inventory_json=json.dumps(target_inventory),
project_input_count=1, project_field_count=1, vmix_connected=True,
)
session.add(device)
with database.session() as session:
source = session.get(VmixMappingProfile, source_id)
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == "MY-GFX-AGENT-01"))
fields = hub._mapping_profile_payload(session, source, include_inventory=False, include_fields=True)["fields"]
runtime1, report1 = hub._upsert_runtime_mapping_for_device(
session, source=source, source_fields=fields, device=device, user=user,
)
runtime1_id = runtime1.id
assert runtime1.name.startswith(f"__AUTO_MAPPING__{source_id}__")
assert report1["mapped"] == 1
mapped = session.scalar(select(VmixMappingField).where(VmixMappingField.profile_id == runtime1.id))
assert mapped.vmix_input_key == "target-key"
with database.session() as session:
source = session.get(VmixMappingProfile, source_id)
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == "MY-GFX-AGENT-01"))
fields = hub._mapping_profile_payload(session, source, include_inventory=False, include_fields=True)["fields"]
runtime2, report2 = hub._upsert_runtime_mapping_for_device(
session, source=source, source_fields=fields, device=device, user=user,
)
assert runtime2.id == runtime1_id
assert report2["mapped"] == 1
profiles = list(session.scalars(select(VmixMappingProfile).order_by(VmixMappingProfile.id)))
assert len(profiles) == 2
listed = asyncio.run(hub.list_mapping_profiles())
assert [item["name"] for item in listed["profiles"]] == ["Чужой KHL Mapping"]
def test_backend_has_current_agent_use_endpoint_and_hidden_runtime_name() -> None:
assert '"/api/hockey/admin/vmix-mapping/profiles/{profile_id}/use"' in BRIDGE
assert "use_mapping_profile_for_user" in BRIDGE
assert "__AUTO_MAPPING__" in BRIDGE
assert "assign_current_match" in BRIDGE