маппинг
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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("Укажите название команды на русском или английском")
|
||||||
|
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
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 `
|
||||||
|
<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}">
|
<button type="button" class="hockey-mapping-profile ${profile?.id === item.id ? "is-active" : ""}" data-mapping-profile="${item.id}">
|
||||||
<strong>${escapeHtml(item.name)}</strong>
|
<strong>${escapeHtml(item.name)}</strong>
|
||||||
<small>v${Number(item.version || 1)} · ${Number(item.field_count || 0)} связей</small>
|
<small>v${Number(item.version || 1)} · ${Number(item.field_count || 0)} связей${item.created_by ? ` · ${escapeHtml(item.created_by)}` : ""}</small>
|
||||||
</button>`).join("") : `<div class="hockey-directory-empty">Mapping-профилей ещё нет.</div>`;
|
</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>
|
</div>
|
||||||
<button type="button" data-map-export>Экспорт JSON</button>
|
<div><button type="button" class="is-accent" data-map-save>Сохранить конфиг</button></div>
|
||||||
<button type="button" class="danger" data-map-delete>Удалить профиль</button>
|
|
||||||
</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>`;
|
</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>
|
||||||
|
<h3>Конфиги Mapping</h3>
|
||||||
|
<div class="hockey-map-config-hint">Выберите любой готовый конфиг — свой или чужой — и нажмите «Применить к моему Agent». Он будет использован для текущего открытого матча.</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>
|
<form class="hockey-mapping-create" data-mapping-create>
|
||||||
<h3>Новый Mapping</h3>
|
|
||||||
<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>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="name" required placeholder="KHL_MAIN_2026"></label>
|
||||||
<label>Описание<input name="description" placeholder="Основной графический пакет"></label>
|
<label>Описание<input name="description" placeholder="Основной графический пакет"></label>
|
||||||
<button type="submit" class="is-accent" ${usableDevices.length ? "" : "disabled"}>Создать из vMix</button>
|
<button type="submit" class="is-accent" ${usableDevices.length ? "" : "disabled"}>Создать конфиг</button>
|
||||||
</form>
|
</form>
|
||||||
<form class="hockey-mapping-create hockey-mapping-import" data-mapping-import>
|
</details>
|
||||||
<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>
|
|
||||||
</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"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
33
tests/test_build73_team_color.py
Normal file
33
tests/test_build73_team_color.py
Normal 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
|
||||||
110
tests/test_build74_mapping_config_picker.py
Normal file
110
tests/test_build74_mapping_config_picker.py
Normal 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
|
||||||
Reference in New Issue
Block a user