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.color_hex)}` : "—"}