From 14067f082c32ee8cba84f39be348f68f50a263ec 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: Wed, 19 Aug 2026 17:44:24 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hockey_data/agent_bridge.py | 230 +++++++++++++++++- hockey_data/database.py | 4 + hockey_data/models.py | 1 + hockey_data/router.py | 1 + hockey_data/service.py | 17 ++ hockey_data/static/admin-directories.css | 106 ++++++++ hockey_data/static/admin-directories.js | 162 +++++++++--- .../test_build72_portable_mapping_transfer.py | 8 +- tests/test_build73_team_color.py | 33 +++ tests/test_build74_mapping_config_picker.py | 110 +++++++++ 10 files changed, 632 insertions(+), 40 deletions(-) create mode 100644 tests/test_build73_team_color.py create mode 100644 tests/test_build74_mapping_config_picker.py diff --git a/hockey_data/agent_bridge.py b/hockey_data/agent_bridge.py index e87ce66..46b81fb 100644 --- a/hockey_data/agent_bridge.py +++ b/hockey_data/agent_bridge.py @@ -12,7 +12,7 @@ from typing import Any, Callable from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect, status 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 .database import HockeyDatabase @@ -1879,6 +1879,20 @@ class VmixAgentHub: ] 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]: with self.database.session() as session: 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, "field_count": row.project_field_count, "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} @@ -2182,6 +2196,198 @@ class VmixAgentHub: session.flush() 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]: with self.database.session() as session: profile = session.get(VmixMappingProfile, profile_id) @@ -2290,7 +2496,11 @@ class VmixAgentHub: async def list_mapping_profiles(self) -> dict[str, Any]: 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] for item, row in zip(items, rows): 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: raise HTTPException(status_code=404, detail="Mapping-профиль не найден") 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))): session.delete(field) session.delete(profile) @@ -2606,6 +2821,11 @@ class MappingFieldsReplacePayload(BaseModel): 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): device_id: str = Field(min_length=6, max_length=128) 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]: 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) async def mapping_profile_export(profile_id: int) -> dict[str, Any]: return await hub.export_mapping_profile(profile_id) diff --git a/hockey_data/database.py b/hockey_data/database.py index 3f66e60..3130ef6 100644 --- a/hockey_data/database.py +++ b/hockey_data/database.py @@ -34,6 +34,8 @@ from .models import ( TournamentStandingRow, TournamentStatisticResource, TournamentStatisticRow, + Team, + TeamLeague, TeamSeasonStatistic, UserPreference, VmixAssignment, @@ -161,6 +163,8 @@ class HockeyDatabase: repaired = migrated + self._repair_game_table() for model in ( OperatorSession, + Team, + TeamLeague, Country, PenaltyType, Player, diff --git a/hockey_data/models.py b/hockey_data/models.py index c260b0c..ecd14d0 100644 --- a/hockey_data/models.py +++ b/hockey_data/models.py @@ -254,6 +254,7 @@ class Team(Base): short_name_en: 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="") + color_hex: Mapped[str] = mapped_column(String(9), nullable=False, default="") logo_url: Mapped[str] = mapped_column(Text, nullable=False, default="") raw_payload: Mapped[str] = mapped_column(Text, nullable=False, default="") synced_at: Mapped[datetime] = mapped_column( diff --git a/hockey_data/router.py b/hockey_data/router.py index 47594ac..6336033 100644 --- a/hockey_data/router.py +++ b/hockey_data/router.py @@ -141,6 +141,7 @@ class TeamDirectoryPayload(BaseModel): short_name_en: str = Field(default="", max_length=255) city_ru: 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) active: bool = True diff --git a/hockey_data/service.py b/hockey_data/service.py index 4b85992..91753e4 100644 --- a/hockey_data/service.py +++ b/hockey_data/service.py @@ -88,6 +88,18 @@ from .tournament_statistics_parser import parse_tournament_statistics_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], ...] = ( ("TRIP", "Подножка", "Tripping", "2"), ("HOOK", "Задержка клюшкой", "Hooking", "2"), @@ -596,6 +608,7 @@ class HockeyDataService: "short_name_en": team.short_name_en, "city_ru": team.city_ru, "city_en": team.city_en, + "color_hex": team.color_hex, "logo_url": team.logo_url, "source": membership.source, "active": bool(membership.active), @@ -742,6 +755,10 @@ class HockeyDataService: ): if field in payload and payload[field] is not None: 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): raise ValueError("Укажите название команды на русском или английском") diff --git a/hockey_data/static/admin-directories.css b/hockey_data/static/admin-directories.css index 4e7b3ba..843ee14 100644 --- a/hockey_data/static/admin-directories.css +++ b/hockey_data/static/admin-directories.css @@ -1599,3 +1599,109 @@ .hockey-mapping-import small{display:block;color:#6f879d;font-size:8px;line-height:1.35} .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%}} + +/* 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}} diff --git a/hockey_data/static/admin-directories.js b/hockey_data/static/admin-directories.js index b689d68..3a02913 100644 --- a/hockey_data/static/admin-directories.js +++ b/hockey_data/static/admin-directories.js @@ -63,6 +63,18 @@ .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 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` : "")); @@ -301,11 +313,12 @@ ${escapeHtml(item.name_ru || "—")}${escapeHtml(item.short_name_ru || "")} ${escapeHtml(item.name_en || "—")}${escapeHtml(item.short_name_en || "")} ${escapeHtml(item.city_ru || item.city_en || "—")} + ${item.color_hex ? `${escapeHtml(item.color_hex)}` : "—"} ${escapeHtml(item.source === "manual" ? "изменено" : "Stat2TV")} `).join("") - : `Для этой лиги команды ещё не загружены. Нажмите «Обновить из API».`; + : `Для этой лиги команды ещё не загружены. Нажмите «Обновить из API».`; const content = `
@@ -320,7 +333,7 @@
- + ${rows}
ID APIРусскийEnglishГородИсточник
ID APIРусскийEnglishГородЦветИсточник
@@ -339,6 +352,20 @@
+
+ +
${editing ? `` : ""} @@ -2127,11 +2154,19 @@ ${item.mapping ? `Mapping: ${escapeHtml(item.mapping.name)} · v${Number(item.mapping.version || 1)}` : (item.project_fingerprint ? "Mapping не назначен" : "Ожидание структуры vMix")} `).join("") : `
Agent пока не передал структуру ни одного vMix.
`; - const profileButtons = profiles.length ? profiles.map((item) => ` - `).join("") : `
Mapping-профилей ещё нет.
`; + const selectedDeviceId = String(localStorage.getItem("hockey.vmix.selected_device") || "").trim(); + const selectedDevice = devices.find((item) => String(item.device_id || "") === selectedDeviceId) || null; + const profileButtons = profiles.length ? profiles.map((item) => { + const isUsed = Boolean(selectedDevice?.mapping && Number(selectedDevice.mapping.source_profile_id || selectedDevice.mapping.id || 0) === Number(item.id)); + return ` +
+ + +
`; + }).join("") : `
Mapping-конфигов ещё нет.
`; const loadErrors = Object.entries(state.mappingLoadErrors || {}); const mappingDiagnostics = loadErrors.length ? `
Часть Mapping API недоступна${loadErrors.map(([key, value]) => `${escapeHtml(key)}${escapeHtml(value)}`).join("")}
` : ""; @@ -2173,14 +2208,9 @@
-
- - -
- - +
-
+
`; } @@ -2189,24 +2219,18 @@
${statusMarkup()}${mappingDiagnostics}${editor}
`); @@ -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-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.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 () => { try { state.mappingActiveProfile = await request(`/api/hockey/admin/vmix-mapping/profiles/${button.dataset.mappingProfile}`); @@ -2756,7 +2806,7 @@ if (currentScroll) state.mappingVmixScrollTop = currentScroll.scrollTop; const id = state.mappingActiveProfile.id; 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() }) }); await loadMapping(id); setStatus(`Mapping сохранён · версия ${result.version}.`); } catch (error) { setStatus(error.message, true); } @@ -2885,10 +2935,57 @@ setStatus(""); 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) => { event.preventDefault(); const form = event.currentTarget; 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 = { external_id: values.get("external_id") || state.editingTeam?.external_id || "", league_key: values.get("league_key") || "", @@ -2899,6 +2996,7 @@ short_name_en: values.get("short_name_en") || "", city_ru: values.get("city_ru") || "", city_en: values.get("city_en") || "", + color_hex: normalizedTeamColor, logo_url: values.get("logo_url") || "", active: values.has("active"), }; diff --git a/tests/test_build72_portable_mapping_transfer.py b/tests/test_build72_portable_mapping_transfer.py index dcb6528..2bf0c09 100644 --- a/tests/test_build72_portable_mapping_transfer.py +++ b/tests/test_build72_portable_mapping_transfer.py @@ -132,14 +132,12 @@ def test_copy_profile_creates_target_specific_mapping_and_keeps_source(tmp_path: assert profiles[1].project_fingerprint == "b" * 64 -def test_admin_ui_has_export_import_and_copy_to_agent_controls() -> None: - assert "data-map-export" in JS - assert "data-map-copy-to-device" in JS - assert "data-mapping-import" in JS +def test_portable_transfer_backend_remains_available_for_compatibility() -> None: + # Build 74 intentionally simplified the operator UI, but the Build 72 API stays + # available so existing integrations/exported tools do not break. assert "/copy-to-device" in JS assert "/api/hockey/admin/vmix-mapping/import" in JS assert "hockey-mapping.json" in JS - assert "Input ищется по key → названию → номеру" in JS 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/import"' in BRIDGE diff --git a/tests/test_build73_team_color.py b/tests/test_build73_team_color.py new file mode 100644 index 0000000..165f42b --- /dev/null +++ b/tests/test_build73_team_color.py @@ -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 diff --git a/tests/test_build74_mapping_config_picker.py b/tests/test_build74_mapping_config_picker.py new file mode 100644 index 0000000..957061e --- /dev/null +++ b/tests/test_build74_mapping_config_picker.py @@ -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