Files
hockey_new/hockey_data/mapping_context.py

1035 lines
64 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import json
import re
from datetime import datetime
from typing import Any
from fastapi import HTTPException
from sqlalchemy import and_, delete, select, text
from sqlalchemy.exc import SQLAlchemyError
from .auth_bridge import HockeyUser
from .database import HockeyDatabase
from .localization import stage_key
from .service import HockeyDataService
from .models import (
Game,
GameControlState,
Tournament,
MappingContextValue,
MappingContextVariable,
MappingSqlDataSource,
VmixDevice,
)
_KEY_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,127}$")
_SOURCE_CODE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,99}$")
_PARAM_RE = re.compile(r"(?<!:):([A-Za-z][A-Za-z0-9_]*)")
_LANG_SUFFIX_RE = re.compile(r"^(?P<base>.+)_(?P<lang>RUS|ENG)$", re.IGNORECASE)
_DANGEROUS_SQL_RE = re.compile(
r"\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|copy|call|do|execute|"
r"vacuum|analyze|reindex|refresh|lock|set|reset|listen|notify|unlisten|prepare|deallocate|discard)\b",
re.IGNORECASE,
)
_DANGEROUS_FUNCTION_RE = re.compile(
r"\b(pg_sleep|pg_read_file|pg_read_binary_file|pg_ls_dir|lo_import|lo_export|dblink|"
r"pg_terminate_backend|pg_cancel_backend)\s*\(",
re.IGNORECASE,
)
def _utcnow() -> datetime:
return datetime.utcnow()
DEFAULT_CONTEXT_VARIABLES: tuple[dict[str, Any], ...] = (
{"key": "game_id", "label": "Текущий матч", "category": "Система", "entity_type": "match", "scope": "match", "source_type": "system"},
{"key": "tournament_id", "label": "Текущий турнир", "category": "Система", "entity_type": "tournament", "scope": "match", "source_type": "system"},
{"key": "team1_id", "label": "Команда 1 / хозяева", "category": "Система", "entity_type": "team", "scope": "match", "source_type": "system"},
{"key": "team2_id", "label": "Команда 2 / гости", "category": "Система", "entity_type": "team", "scope": "match", "source_type": "system"},
{"key": "account_id", "label": "Аккаунт оператора", "category": "Система", "entity_type": "account", "scope": "account", "source_type": "system"},
{"key": "device_id", "label": "Активный Agent", "category": "Система", "entity_type": "device", "scope": "session", "source_type": "system"},
{"key": "assignment_id", "label": "Текущее назначение Agent", "category": "Система", "entity_type": "assignment", "scope": "session", "source_type": "system"},
{"key": "active_tab", "label": "Активная вкладка интерфейса", "category": "Система", "entity_type": "tab", "scope": "session", "source_type": "selection"},
{"key": "selected_team_id", "label": "Выбранная команда", "category": "Выделенные", "entity_type": "team", "scope": "match", "source_type": "selection"},
{"key": "selected_player_id", "label": "Выбранный игрок", "category": "Выделенные", "entity_type": "player", "scope": "match", "source_type": "selection"},
{"key": "selected_player_team_id", "label": "Команда выбранного игрока", "category": "Выделенные", "entity_type": "team", "scope": "match", "source_type": "selection"},
{"key": "player_team1_id", "label": "Выбранный игрок команды 1", "category": "Выделенные", "entity_type": "player", "scope": "match", "source_type": "selection"},
{"key": "player_team2_id", "label": "Выбранный игрок команды 2", "category": "Выделенные", "entity_type": "player", "scope": "match", "source_type": "selection"},
{"key": "selected_goalkeeper_id", "label": "Выбранный вратарь", "category": "Выделенные", "entity_type": "player", "scope": "match", "source_type": "selection"},
{"key": "selected_coach_id", "label": "Выбранный тренер", "category": "Выделенные", "entity_type": "coach", "scope": "match", "source_type": "selection"},
{"key": "selected_referee_id", "label": "Выбранный судья", "category": "Выделенные", "entity_type": "referee", "scope": "match", "source_type": "selection"},
{"key": "selected_goal_id", "label": "Выбранный гол", "category": "Выделенные", "entity_type": "goal", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_id", "label": "Выбранное удаление", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_player_id", "label": "Игрок выбранного удаления (external ID)", "category": "Выделенные", "entity_type": "player", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_player_db_id", "label": "Игрок выбранного удаления (DB ID)", "category": "Выделенные", "entity_type": "player", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_team_penalty", "label": "Выбранное удаление командное (1/0)", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection", "value_type": "text"},
{"key": "selected_penalty_team_id", "label": "Команда выбранного удаления", "category": "Выделенные", "entity_type": "team", "scope": "match", "source_type": "selection"},
{"key": "selected_home_penalty_id", "label": "Удаление левой команды — ID удаления", "category": "Удаления — левая команда", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
{"key": "selected_home_penalty_player_id", "label": "Удаление левой команды — игрок external ID", "category": "Удаления — левая команда", "entity_type": "player", "scope": "match", "source_type": "selection"},
{"key": "selected_home_penalty_player_db_id", "label": "Удаление левой команды — игрок DB ID", "category": "Удаления — левая команда", "entity_type": "player", "scope": "match", "source_type": "selection"},
{"key": "selected_home_penalty_team_penalty", "label": "Удаление левой команды — командное (1/0)", "category": "Удаления — левая команда", "entity_type": "penalty", "scope": "match", "source_type": "selection", "value_type": "text"},
{"key": "selected_away_penalty_id", "label": "Удаление правой команды — ID удаления", "category": "Удаления — правая команда", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
{"key": "selected_away_penalty_player_id", "label": "Удаление правой команды — игрок external ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match", "source_type": "selection"},
{"key": "selected_away_penalty_player_db_id", "label": "Удаление правой команды — игрок DB ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match", "source_type": "selection"},
{"key": "selected_away_penalty_team_penalty", "label": "Удаление правой команды — командное (1/0)", "category": "Удаления — правая команда", "entity_type": "penalty", "scope": "match", "source_type": "selection", "value_type": "text"},
{"key": "selected_penalty_infraction_id", "label": "Нарушение выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_preset_id", "label": "Длительность выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_side", "label": "Сторона выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_event_time", "label": "Время события выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_status", "label": "Статус выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_remaining_ms", "label": "Остаток выбранного удаления, мс", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_duration_ms", "label": "Длительность выбранного удаления, мс", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
{"key": "selected_event_id", "label": "Выбранное событие", "category": "Выделенные", "entity_type": "event", "scope": "match", "source_type": "selection"},
{"key": "selected_stat_player_id", "label": "Игрок для статистики", "category": "Выделенные", "entity_type": "player", "scope": "match", "source_type": "selection"},
{"key": "selected_stat_team_id", "label": "Команда для статистики", "category": "Выделенные", "entity_type": "team", "scope": "match", "source_type": "selection"},
{"key": "highlighted_game_id", "label": "Выделенный матч", "category": "Дополнительные", "entity_type": "match", "scope": "session", "source_type": "manual"},
{"key": "compare_player1_id", "label": "Игрок сравнения 1", "category": "Сравнение", "entity_type": "player", "scope": "session", "source_type": "manual"},
{"key": "compare_player2_id", "label": "Игрок сравнения 2", "category": "Сравнение", "entity_type": "player", "scope": "session", "source_type": "manual"},
{"key": "compare_team1_id", "label": "Команда сравнения 1", "category": "Сравнение", "entity_type": "team", "scope": "session", "source_type": "manual"},
{"key": "compare_team2_id", "label": "Команда сравнения 2", "category": "Сравнение", "entity_type": "team", "scope": "session", "source_type": "manual"},
)
DEFAULT_SQL_SOURCES: tuple[dict[str, Any], ...] = (
{
"code": "game",
"name": "Матч и команды",
"category": "Матч",
"description": "Основные данные текущего матча. Параметр :game_id берётся из контекста.",
"sql_text": """SELECT
g.external_id AS \"id\",
g.tournament_external_id AS \"tournament_id\",
CAST(g.game_date AS TEXT) AS \"date\",
g.start_time AS \"time\",
g.status AS \"status\",
g.arena_ru AS \"venue\",
g.home_team_external_id AS \"home.id\",
g.home_team_name_ru AS \"home.name\",
g.home_team_short_ru AS \"home.short_name\",
g.home_team_city_ru AS \"home.city\",
g.home_score AS \"home.score\",
g.away_team_external_id AS \"away.id\",
g.away_team_name_ru AS \"away.name\",
g.away_team_short_ru AS \"away.short_name\",
g.away_team_city_ru AS \"away.city\",
g.away_score AS \"away.score\"
FROM hockey_games g
WHERE g.external_id = :game_id
LIMIT 1""",
"field_metadata": {
"id": {"label": "ID матча"}, "tournament_id": {"label": "ID турнира"},
"date": {"label": "Дата"}, "time": {"label": "Время начала"}, "status": {"label": "Статус"}, "venue": {"label": "Арена / площадка"},
"home.id": {"label": "ID команды", "category": "Хозяева"}, "home.name": {"label": "Название команды", "category": "Хозяева"},
"home.short_name": {"label": "Короткое название", "category": "Хозяева"}, "home.city": {"label": "Город", "category": "Хозяева"}, "home.score": {"label": "Счёт", "category": "Хозяева", "kind": "number"}, "home.logo": {"label": "Логотип", "category": "Хозяева", "kind": "image"},
"away.id": {"label": "ID команды", "category": "Гости"}, "away.name": {"label": "Название команды", "category": "Гости"},
"away.short_name": {"label": "Короткое название", "category": "Гости"}, "away.city": {"label": "Город", "category": "Гости"}, "away.score": {"label": "Счёт", "category": "Гости", "kind": "number"}, "away.logo": {"label": "Логотип", "category": "Гости", "kind": "image"},
},
},
{
"code": "tournament",
"name": "Турнир",
"category": "Турнир",
"description": "Данные выбранного турнира.",
"sql_text": """SELECT
t.external_id AS \"id\",
t.name_ru AS \"name\",
t.common_name_ru AS \"common_name\",
t.season AS \"season\",
t.season_part AS \"stage\",
t.level AS \"level\"
FROM hockey_tournaments t
WHERE t.external_id = :tournament_id
LIMIT 1""",
"field_metadata": {"id": {"label": "ID турнира"}, "name": {"label": "Название турнира"}, "common_name": {"label": "Короткое название"}, "season": {"label": "Сезон"}, "stage": {"label": "Этап"}, "level": {"label": "Лига"}},
},
{
"code": "selected_player",
"name": "Выбранный игрок",
"category": "Выбранный игрок",
"description": "Карточка игрока по :selected_player_id. До выбора игрока источник возвращает пустой результат.",
"sql_text": """SELECT
p.external_id AS \"id\",
p.full_name_ru AS \"full_name\",
p.last_name_ru AS \"last_name\",
p.first_name_ru AS \"first_name\",
COALESCE(pt.jersey_number, '') AS \"number\",
p.position_ru AS \"position\",
p.photo_url AS \"photo\",
COALESCE(pt.club_external_id, '') AS \"team_id\",
COALESCE(pt.team_name_ru, '') AS \"team_name\"
FROM hockey_players p
LEFT JOIN hockey_player_tournaments pt
ON pt.player_id = p.id AND pt.tournament_external_id = :tournament_id
WHERE p.external_id = :selected_player_id
ORDER BY pt.active DESC
LIMIT 1""",
"field_metadata": {"id": {"label": "ID игрока"}, "full_name": {"label": "Фамилия + Имя"}, "last_name": {"label": "Фамилия"}, "first_name": {"label": "Имя"}, "number": {"label": "Номер"}, "position": {"label": "Амплуа"}, "photo": {"label": "Фото", "kind": "image"}, "team_id": {"label": "ID команды"}, "team_name": {"label": "Команда"}},
},
{
"code": "highlighted_game",
"name": "Выделенный матч",
"category": "Выделенный матч",
"description": "Любой дополнительный матч по пользовательской переменной :highlighted_game_id.",
"sql_text": """SELECT
g.external_id AS \"id\",
CAST(g.game_date AS TEXT) AS \"date\",
g.start_time AS \"time\",
g.home_team_name_ru AS \"home.name\",
g.home_score AS \"home.score\",
g.away_team_name_ru AS \"away.name\",
g.away_score AS \"away.score\"
FROM hockey_games g
WHERE g.external_id = :highlighted_game_id
LIMIT 1""",
"field_metadata": {},
},
)
class MappingDataService:
"""Dynamic context variables and read-only SQL data sources for vMix mapping."""
def __init__(self, database: HockeyDatabase, settings: Any | None = None) -> None:
self.database = database
self.settings = settings
def _current_language(self, supplied: dict[str, Any] | None = None) -> str:
supplied = supplied or {}
raw = str(supplied.get("ui_language") or supplied.get("language") or "").strip().lower()
if raw not in {"ru", "en"} and self.settings is not None:
try:
raw = str(self.settings.public().get("ui_language") or "ru").strip().lower()
except Exception:
raw = "ru"
return raw if raw in {"ru", "en"} else "ru"
def ensure_defaults(self) -> None:
now = _utcnow()
with self.database.session() as session:
existing_vars = {row.key for row in session.scalars(select(MappingContextVariable))}
for index, item in enumerate(DEFAULT_CONTEXT_VARIABLES):
if item["key"] in existing_vars:
continue
session.add(MappingContextVariable(
key=item["key"], label=item["label"], category=item.get("category", "Контекст"),
description=item.get("description", ""), value_type=item.get("value_type", "id"),
entity_type=item.get("entity_type", ""), scope=item.get("scope", "match"),
source_type=item.get("source_type", "manual"), default_value=item.get("default_value", ""),
is_system=item.get("source_type") == "system", enabled=True, sort_order=index,
created_by="system", updated_by="system", created_at=now, updated_at=now,
))
existing_sources = {row.code for row in session.scalars(select(MappingSqlDataSource))}
for index, item in enumerate(DEFAULT_SQL_SOURCES):
if item["code"] in existing_sources:
continue
session.add(MappingSqlDataSource(
code=item["code"], name=item["name"], category=item.get("category", "Данные"),
description=item.get("description", ""), sql_text=item["sql_text"],
field_metadata_json=json.dumps(item.get("field_metadata", {}), ensure_ascii=False),
enabled=True, sort_order=index, created_by="system", updated_by="system",
created_at=now, updated_at=now,
))
@staticmethod
def _variable_payload(row: MappingContextVariable) -> dict[str, Any]:
return {
"id": row.id, "key": row.key, "label": row.label, "category": row.category,
"description": row.description, "value_type": row.value_type, "entity_type": row.entity_type,
"scope": row.scope, "source_type": row.source_type, "default_value": row.default_value,
"is_system": bool(row.is_system), "enabled": bool(row.enabled), "sort_order": row.sort_order,
}
@staticmethod
def _source_payload(row: MappingSqlDataSource, *, include_sql: bool = True) -> dict[str, Any]:
try:
metadata = json.loads(row.field_metadata_json or "{}")
except Exception:
metadata = {}
result = {
"id": row.id, "code": row.code, "name": row.name, "category": row.category,
"description": row.description, "enabled": bool(row.enabled),
"auto_refresh_enabled": bool(getattr(row, "auto_refresh_enabled", False)),
"refresh_interval_ms": max(1000, int(getattr(row, "refresh_interval_ms", 1000) or 1000)),
"sort_order": row.sort_order,
"field_metadata": metadata if isinstance(metadata, dict) else {},
}
if include_sql:
result["sql_text"] = row.sql_text
return result
def list_variables(self) -> dict[str, Any]:
self.ensure_defaults()
with self.database.session() as session:
rows = list(session.scalars(select(MappingContextVariable).order_by(MappingContextVariable.sort_order, MappingContextVariable.id)))
return {"variables": [self._variable_payload(row) for row in rows]}
def create_variable(self, payload: Any, user: HockeyUser) -> dict[str, Any]:
self.ensure_defaults()
key = str(payload.key or "").strip()
if not _KEY_RE.fullmatch(key):
raise HTTPException(status_code=422, detail="Ключ: латиница/цифры/._-, начинаться должен с буквы")
if str(payload.source_type or "manual") == "system":
raise HTTPException(status_code=422, detail="Пользовательская переменная не может иметь источник SYSTEM")
now = _utcnow()
with self.database.session() as session:
if session.scalar(select(MappingContextVariable).where(MappingContextVariable.key == key)) is not None:
raise HTTPException(status_code=409, detail="Переменная с таким ключом уже существует")
row = MappingContextVariable(
key=key, label=str(payload.label or key)[:200], category=str(payload.category or "Пользовательские")[:100],
description=str(payload.description or "")[:4000], value_type=str(payload.value_type or "id")[:32],
entity_type=str(payload.entity_type or "")[:32], scope=str(payload.scope or "match")[:32],
source_type=str(payload.source_type or "manual")[:32], default_value=str(payload.default_value or "")[:4000],
is_system=False, enabled=bool(payload.enabled), sort_order=int(payload.sort_order or 1000),
created_by=user.login, updated_by=user.login, created_at=now, updated_at=now,
)
session.add(row); session.flush(); result = self._variable_payload(row)
return result
def update_variable(self, variable_id: int, payload: Any, user: HockeyUser) -> dict[str, Any]:
self.ensure_defaults()
with self.database.session() as session:
row = session.get(MappingContextVariable, variable_id)
if row is None:
raise HTTPException(status_code=404, detail="Переменная не найдена")
if row.is_system:
# System keys are part of the runtime contract; labels can be edited but the technical contract cannot.
row.label = str(payload.label or row.label)[:200]
row.category = str(payload.category or row.category)[:100]
row.description = str(payload.description or row.description)[:4000]
row.enabled = bool(payload.enabled)
else:
key = str(payload.key or row.key).strip()
if not _KEY_RE.fullmatch(key):
raise HTTPException(status_code=422, detail="Некорректный ключ переменной")
duplicate = session.scalar(select(MappingContextVariable).where(and_(MappingContextVariable.key == key, MappingContextVariable.id != row.id)))
if duplicate is not None:
raise HTTPException(status_code=409, detail="Переменная с таким ключом уже существует")
row.key = key
row.label = str(payload.label or key)[:200]
row.category = str(payload.category or "Пользовательские")[:100]
row.description = str(payload.description or "")[:4000]
row.value_type = str(payload.value_type or "id")[:32]
row.entity_type = str(payload.entity_type or "")[:32]
row.scope = str(payload.scope or "match")[:32]
row.source_type = str(payload.source_type or "manual")[:32]
row.default_value = str(payload.default_value or "")[:4000]
row.enabled = bool(payload.enabled)
row.sort_order = int(payload.sort_order or row.sort_order or 1000)
row.updated_by = user.login; row.updated_at = _utcnow(); session.flush(); result = self._variable_payload(row)
return result
def delete_variable(self, variable_id: int) -> dict[str, Any]:
self.ensure_defaults()
with self.database.session() as session:
row = session.get(MappingContextVariable, variable_id)
if row is None:
raise HTTPException(status_code=404, detail="Переменная не найдена")
if row.is_system:
raise HTTPException(status_code=409, detail="Системную переменную удалить нельзя")
session.execute(delete(MappingContextValue).where(MappingContextValue.variable_id == variable_id))
session.delete(row)
return {"ok": True, "id": variable_id}
def list_sources(self) -> dict[str, Any]:
self.ensure_defaults()
with self.database.session() as session:
rows = list(session.scalars(select(MappingSqlDataSource).order_by(MappingSqlDataSource.sort_order, MappingSqlDataSource.id)))
return {"sources": [self._source_payload(row) for row in rows]}
def create_source(self, payload: Any, user: HockeyUser) -> dict[str, Any]:
self.ensure_defaults()
code = str(payload.code or "").strip()
if not _SOURCE_CODE_RE.fullmatch(code):
raise HTTPException(status_code=422, detail="Код источника: латиница, цифры и _, начинаться должен с буквы")
sql_text = str(payload.sql_text or "").strip()
self.validate_sql(sql_text)
now = _utcnow()
with self.database.session() as session:
if session.scalar(select(MappingSqlDataSource).where(MappingSqlDataSource.code == code)) is not None:
raise HTTPException(status_code=409, detail="SQL-источник с таким кодом уже существует")
row = MappingSqlDataSource(
code=code, name=str(payload.name or code)[:200], category=str(payload.category or "Данные")[:100],
description=str(payload.description or "")[:4000], sql_text=sql_text,
field_metadata_json=json.dumps(payload.field_metadata or {}, ensure_ascii=False),
enabled=bool(payload.enabled),
auto_refresh_enabled=bool(getattr(payload, "auto_refresh_enabled", False)),
refresh_interval_ms=max(1000, min(3600000, int(getattr(payload, "refresh_interval_ms", 1000) or 1000))),
sort_order=int(payload.sort_order or 1000),
created_by=user.login, updated_by=user.login, created_at=now, updated_at=now,
)
session.add(row); session.flush(); result = self._source_payload(row)
return result
def update_source(self, source_id: int, payload: Any, user: HockeyUser) -> dict[str, Any]:
self.ensure_defaults()
sql_text = str(payload.sql_text or "").strip(); self.validate_sql(sql_text)
with self.database.session() as session:
row = session.get(MappingSqlDataSource, source_id)
if row is None: raise HTTPException(status_code=404, detail="SQL-источник не найден")
code = str(payload.code or row.code).strip()
if not _SOURCE_CODE_RE.fullmatch(code): raise HTTPException(status_code=422, detail="Некорректный код источника")
duplicate = session.scalar(select(MappingSqlDataSource).where(and_(MappingSqlDataSource.code == code, MappingSqlDataSource.id != row.id)))
if duplicate is not None: raise HTTPException(status_code=409, detail="SQL-источник с таким кодом уже существует")
row.code=code; row.name=str(payload.name or code)[:200]; row.category=str(payload.category or "Данные")[:100]
row.description=str(payload.description or "")[:4000]; row.sql_text=sql_text
row.field_metadata_json=json.dumps(payload.field_metadata or {}, ensure_ascii=False)
row.enabled=bool(payload.enabled)
row.auto_refresh_enabled=bool(getattr(payload, "auto_refresh_enabled", False))
row.refresh_interval_ms=max(1000, min(3600000, int(getattr(payload, "refresh_interval_ms", 1000) or 1000)))
row.sort_order=int(payload.sort_order or row.sort_order or 1000)
row.updated_by=user.login; row.updated_at=_utcnow(); session.flush(); result=self._source_payload(row)
return result
def delete_source(self, source_id: int) -> dict[str, Any]:
self.ensure_defaults()
with self.database.session() as session:
row=session.get(MappingSqlDataSource, source_id)
if row is None: raise HTTPException(status_code=404, detail="SQL-источник не найден")
session.delete(row)
return {"ok": True, "id": source_id}
@staticmethod
def _normalise_sql(sql_text: str) -> str:
"""Remove SQL comments outside quoted strings and allow one trailing semicolon.
The stored editor text is not changed; normalisation happens only for validation/execution.
Multiple statements remain forbidden.
"""
source = str(sql_text or "")
out: list[str] = []
i = 0
quote = ""
while i < len(source):
ch = source[i]
nxt = source[i + 1] if i + 1 < len(source) else ""
if quote:
out.append(ch)
if ch == quote:
if nxt == quote:
out.append(nxt); i += 2; continue
quote = ""
i += 1
continue
if ch in {"'", '"'}:
quote = ch; out.append(ch); i += 1; continue
if ch == "-" and nxt == "-":
i += 2
while i < len(source) and source[i] not in "\r\n": i += 1
out.append("\n")
continue
if ch == "/" and nxt == "*":
end = source.find("*/", i + 2)
if end < 0:
raise HTTPException(status_code=422, detail="Незакрытый SQL-комментарий")
out.append(" "); i = end + 2; continue
out.append(ch); i += 1
sql = "".join(out).strip()
if not sql:
raise HTTPException(status_code=422, detail="SQL пуст")
if sql.endswith(";"):
sql = sql[:-1].rstrip()
# A semicolon that remains outside comments/quotes means more than one statement.
quote = ""; i = 0
while i < len(sql):
ch = sql[i]; nxt = sql[i + 1] if i + 1 < len(sql) else ""
if quote:
if ch == quote:
if nxt == quote: i += 2; continue
quote = ""
i += 1; continue
if ch in {"'", '"'}: quote = ch
elif ch == ";":
raise HTTPException(status_code=422, detail="Разрешён только один read-only SELECT")
i += 1
return sql
@classmethod
def validate_sql(cls, sql_text: str) -> set[str]:
sql = cls._normalise_sql(sql_text)
if not re.match(r"^(select|with)\b", sql, re.IGNORECASE):
raise HTTPException(status_code=422, detail="Разрешены только SELECT / WITH ... SELECT")
if _DANGEROUS_SQL_RE.search(sql) or _DANGEROUS_FUNCTION_RE.search(sql):
raise HTTPException(status_code=422, detail="В SQL обнаружена запрещённая операция")
return cls._find_context_params(sql)
@staticmethod
def _find_context_params(sql: str) -> set[str]:
"""Find :context parameters only in SQL code, never inside quoted strings/identifiers.
This keeps PostgreSQL formats such as 'HH24:MI:SS' and casts such as
value::text from being mistaken for Mapping context variables.
"""
params: set[str] = set()
i = 0
quote = ""
while i < len(sql):
ch = sql[i]
nxt = sql[i + 1] if i + 1 < len(sql) else ""
if quote:
if ch == quote:
if nxt == quote:
i += 2
continue
quote = ""
i += 1
continue
if ch in {"'", '"'}:
quote = ch
i += 1
continue
if ch == ":":
# PostgreSQL cast operator :: is not a bind parameter.
prev = sql[i - 1] if i > 0 else ""
if prev == ":" or nxt == ":" or not (nxt.isalpha()):
i += 1
continue
j = i + 2
while j < len(sql) and (sql[j].isalnum() or sql[j] == "_"):
j += 1
params.add(sql[i + 1:j])
i = j
continue
i += 1
return params
@staticmethod
def _scope_key(variable: MappingContextVariable, user: HockeyUser, context: dict[str, str]) -> str:
scope = (variable.scope or "match").lower()
game_id = context.get("game_id", "")
token = context.get("session_token", "")
if scope == "global": return "global"
if scope == "account": return f"account:{user.id}"
if scope in {"session", "temporary"}: return f"session:{token or user.id}"
return f"match:{game_id or '-'}:account:{user.id}"
def resolve_context(self, user: HockeyUser, supplied: dict[str, Any] | None = None) -> tuple[dict[str, str], list[dict[str, Any]]]:
self.ensure_defaults()
supplied = supplied or {}
context: dict[str, str] = {str(k): str(v) for k, v in supplied.items() if v is not None and str(v) != ""}
context.setdefault("account_id", str(user.id))
context.setdefault("ui_language", self._current_language(supplied))
game_id = context.get("game_id", "")
if game_id:
with self.database.session() as session:
game = session.scalar(select(Game).where(Game.external_id == game_id))
if game is not None:
context.setdefault("tournament_id", game.tournament_external_id or "")
context.setdefault("team1_id", game.home_team_external_id or "")
context.setdefault("team2_id", game.away_team_external_id or "")
if not context.get("device_id"):
with self.database.session() as session:
device = session.scalar(select(VmixDevice).where(and_(VmixDevice.wfl_user_id == user.id, VmixDevice.is_active_for_account.is_(True))))
if device is not None:
context["device_id"] = device.device_uuid
if device.current_assignment_key: context["assignment_id"] = device.current_assignment_key
if not context.get("game_id") and device.current_match_external_id: context["game_id"] = device.current_match_external_id
# Parameterised lower-panel controls are match-scoped system context.
# SQL bind names cannot contain dots, therefore panel.stat_period.value
# becomes :panel_stat_period_value while Mapping keeps the dotted key.
runtime_variable_payloads: list[dict[str, Any]] = []
resolved_game_id = str(context.get("game_id") or "").strip()
if resolved_game_id:
with self.database.session() as session:
game = session.scalar(select(Game).where(Game.external_id == resolved_game_id))
if game is not None:
context.setdefault("tournament_id", game.tournament_external_id or "")
context.setdefault("team1_id", game.home_team_external_id or "")
context.setdefault("team2_id", game.away_team_external_id or "")
control = session.scalar(select(GameControlState).where(GameControlState.game_external_id == resolved_game_id))
if control is not None:
runtime_values = HockeyDataService._runtime_values_from_row(control)
player_panel_labels: dict[str, str] = {}
for meta_key, meta_value in runtime_values.items():
meta_match = re.match(r"^player_select\.([A-Za-z0-9_]+)\._label$", str(meta_key))
if meta_match:
player_panel_labels[meta_match.group(1)] = str(meta_value or meta_match.group(1))
for raw_key, raw_value in runtime_values.items():
raw_key_text = str(raw_key)
player_match = re.match(r"^player_select\.([A-Za-z0-9_]+)\.(.+)$", raw_key_text)
if player_match and player_match.group(2).startswith("_"):
continue
sql_key = re.sub(r"[^A-Za-z0-9_]+", "_", raw_key_text).strip("_")[:120]
if not sql_key or not re.match(r"^[A-Za-z]", sql_key):
sql_key = f"panel_{sql_key}"[:120]
context.setdefault(sql_key, str(raw_value))
if player_match:
panel_id, field_key = player_match.groups()
slot_match = re.match(r"player(\d+)_(id|db_id|team_id|side|number|name)$", field_key)
field_labels = {
"id": "external ID", "db_id": "DB ID", "team_id": "ID команды",
"side": "сторона", "number": "номер", "name": "имя",
}
label = field_key
if slot_match:
slot_no, field_name = slot_match.groups()
label = f"Игрок {slot_no}{field_labels.get(field_name, field_name)}"
category = f"Игровые выборки · {player_panel_labels.get(panel_id, panel_id)}"
description = f"Игрок, перетащенный оператором в блок {player_panel_labels.get(panel_id, panel_id)}."
else:
label = raw_key_text
category = "Нижняя панель"
description = f"Runtime-параметр нижней панели: {raw_key_text}"
runtime_variable_payloads.append({
"key": sql_key,
"label": label,
"category": category,
"description": description,
"scope": "match",
"source_type": "system",
"is_system": True,
"value": str(raw_value),
})
with self.database.session() as session:
variables = list(session.scalars(select(MappingContextVariable).where(MappingContextVariable.enabled.is_(True)).order_by(MappingContextVariable.sort_order, MappingContextVariable.id)))
for variable in variables:
if variable.source_type == "system":
continue
scope_key = self._scope_key(variable, user, context)
value = session.scalar(select(MappingContextValue).where(and_(MappingContextValue.variable_id == variable.id, MappingContextValue.scope_key == scope_key)))
if value is not None and value.value_text != "" and variable.key not in context: context[variable.key] = value.value_text
elif variable.default_value and variable.key not in context: context[variable.key] = variable.default_value
payload = []
for variable in variables:
item = self._variable_payload(variable); item["value"] = context.get(variable.key, ""); payload.append(item)
payload.extend(runtime_variable_payloads)
return context, payload
def set_context_value(self, key: str, value: Any, user: HockeyUser, supplied: dict[str, Any] | None = None) -> dict[str, Any]:
self.ensure_defaults()
context, _ = self.resolve_context(user, supplied)
with self.database.session() as session:
variable = session.scalar(select(MappingContextVariable).where(MappingContextVariable.key == key))
if variable is None: raise HTTPException(status_code=404, detail="Переменная не найдена")
if variable.source_type == "system" or variable.is_system:
raise HTTPException(status_code=409, detail="Системная переменная задаётся программой")
scope_key = self._scope_key(variable, user, context)
row = session.scalar(select(MappingContextValue).where(and_(MappingContextValue.variable_id == variable.id, MappingContextValue.scope_key == scope_key)))
if row is None:
row = MappingContextValue(variable_id=variable.id, scope_key=scope_key, wfl_user_id=user.id,
operator_session_token=context.get("session_token", ""), game_external_id=context.get("game_id", ""),
value_text=str(value if value is not None else ""), updated_by=user.login, updated_at=_utcnow())
session.add(row)
else:
row.value_text=str(value if value is not None else ""); row.updated_by=user.login; row.updated_at=_utcnow()
session.flush()
return {"ok": True, "key": key, "value": str(value if value is not None else ""), "scope_key": scope_key}
@staticmethod
def _pretty_label(column: str) -> str:
leaf = str(column).split(".")[-1].replace("_", " ").strip()
return leaf[:1].upper() + leaf[1:] if leaf else str(column)
@staticmethod
def _infer_kind(column: str, value: Any) -> str:
name = column.lower()
if any(token in name for token in ("photo", "logo", "image", "source")): return "image"
if isinstance(value, (int, float)): return "number"
return "text"
def _run_source(self, source: MappingSqlDataSource, context: dict[str, str], *, max_rows: int = 25) -> dict[str, Any]:
params = self.validate_sql(source.sql_text)
sql_text = self._normalise_sql(source.sql_text)
missing = sorted(name for name in params if not context.get(name))
if missing:
return {"ok": True, "skipped": True, "missing": missing, "columns": [], "rows": [], "row_count": 0}
bound = {name: context.get(name, "") for name in params}
try:
with self.database.engine.connect() as connection:
transaction = connection.begin()
try:
if connection.dialect.name == "postgresql":
connection.execute(text("SET TRANSACTION READ ONLY"))
connection.execute(text("SET LOCAL statement_timeout = '2000ms'"))
result = connection.execute(text(sql_text), bound)
columns = list(result.keys())
rows = [dict(row) for row in result.mappings().fetchmany(max_rows)]
finally:
transaction.rollback()
except SQLAlchemyError as error:
return {"ok": False, "error": str(error).splitlines()[0][:500], "columns": [], "rows": [], "row_count": 0}
serial_rows = []
for row in rows:
serial_rows.append({key: (value.isoformat() if hasattr(value, "isoformat") else value) for key, value in row.items()})
return {"ok": True, "skipped": False, "columns": columns, "rows": serial_rows, "row_count": len(serial_rows)}
def preview_sql(self, sql_text: str, user: HockeyUser, supplied: dict[str, Any] | None = None) -> dict[str, Any]:
self.ensure_defaults(); context, variables = self.resolve_context(user, supplied)
class _PreviewSource:
pass
source = _PreviewSource()
source.sql_text = str(sql_text or "")
result = self._run_source(source, context, max_rows=250)
return {"context": context, "variables": variables, **result}
def preview_source(self, source_id: int, user: HockeyUser, supplied: dict[str, Any] | None = None) -> dict[str, Any]:
self.ensure_defaults(); context, variables = self.resolve_context(user, supplied)
with self.database.session() as session:
source = session.get(MappingSqlDataSource, source_id)
if source is None: raise HTTPException(status_code=404, detail="SQL-источник не найден")
payload = self._source_payload(source)
result = self._run_source(source, context, max_rows=250)
return {"source": payload, "context": context, "variables": variables, **result}
@staticmethod
def _table_row_label(row: dict[str, Any], index: int) -> str:
preferred = (
"full_name", "name", "title", "team_name", "player_name", "last_name",
"id", "external_id", "number",
)
for key in preferred:
value = row.get(key)
if value not in (None, ""):
text_value = str(value)
if len(text_value) > 46:
text_value = text_value[:43] + ""
return f"Строка {index} · {text_value}"
for value in row.values():
if value not in (None, ""):
text_value = str(value)
if len(text_value) > 46:
text_value = text_value[:43] + ""
return f"Строка {index} · {text_value}"
return f"Строка {index}"
@staticmethod
def _language_pairs(columns: list[str]) -> dict[str, dict[str, str]]:
pairs: dict[str, dict[str, str]] = {}
for column in columns:
match = _LANG_SUFFIX_RE.match(str(column))
if not match:
continue
base = match.group("base")
lang = match.group("lang").upper()
canonical = base.casefold()
entry = pairs.setdefault(canonical, {"base": base})
entry[lang] = str(column)
return {key: value for key, value in pairs.items() if value.get("RUS") and value.get("ENG")}
@staticmethod
def _localized_value(row: dict[str, Any], pair: dict[str, str], language: str) -> tuple[Any, str]:
primary = pair.get("RUS" if language == "ru" else "ENG", "")
fallback = pair.get("ENG" if language == "ru" else "RUS", "")
value = row.get(primary, "") if primary else ""
if value in (None, "") and fallback:
value = row.get(fallback, "")
primary = fallback
return value, primary
def _live_control_items(self, context: dict[str, str], language: str) -> list[dict[str, Any]]:
game_id = str(context.get("game_id") or "").strip()
if not game_id:
return []
settings = self.settings.public() if self.settings is not None else {}
with self.database.session() as session:
game = session.scalar(select(Game).where(Game.external_id == game_id))
if game is None:
return []
tournament = session.scalar(
select(Tournament).where(Tournament.external_id == game.tournament_external_id)
)
control = session.scalar(
select(GameControlState).where(GameControlState.game_external_id == game_id)
)
current_period = str(getattr(control, "current_period", "1") or "1")
stage = stage_key(
str(getattr(tournament, "season_part", "") or ""),
str(getattr(tournament, "round_code", "") or ""),
) if tournament is not None else "other"
timer_state = (
HockeyDataService._timer_state_from_row(control)
if control is not None else HockeyDataService._default_game_timer_state()
)
strength = HockeyDataService._strength_payload(
settings,
stage=stage,
current_period=current_period,
timer_state=timer_state,
language=language,
)
flags = HockeyDataService._runtime_flags_from_row(control) if control is not None else {}
period_status = HockeyDataService._period_status_payload(
settings,
current_period=current_period,
language=language,
)
result: list[dict[str, Any]] = []
period_fields = (
("key", "Ключ периода", "text"),
("number", "Номер периода / овертайма", "number"),
("label", "Статус периода · основной", "text"),
("compact", "Статус периода · компактный", "text"),
("short", "Статус периода · короткий", "text"),
("long", "Статус периода · полный", "text"),
("ru", "Статус периода · RU", "text"),
("en", "Статус периода · EN", "text"),
)
for key, label, kind in period_fields:
result.append({
"key": f"period.{key}",
"label": label,
"category": "Матч · Период",
"value": period_status.get(key, ""),
"kind": kind,
"source_code": "live_control",
"description": "Настраиваемая подпись текущего периода из раздела «Таймеры и численные составы».",
})
formats = period_status.get("formats") if isinstance(period_status.get("formats"), dict) else {}
for fmt in ("compact", "short", "long"):
fmt_values = formats.get(fmt) if isinstance(formats.get(fmt), dict) else {}
for lang in ("ru", "en"):
result.append({
"key": f"period.{lang}.{fmt}",
"label": f"{fmt.upper()} · {lang.upper()}",
"category": "Матч · Период · Форматы",
"value": fmt_values.get(lang, ""),
"kind": "text",
"source_code": "live_control",
"description": "Конкретный языковой вариант статуса периода, не зависящий от языка Mapping.",
})
strength_fields = (
("state_key", "Состояние", "text"),
("state_label", "Подпись численного состава", "text"),
("strength_label", "Численный состав HOME×AWAY", "text"),
("advantage_numbers", "Состав большинства", "text"),
("home_label", "Подпись HOME", "text"),
("away_label", "Подпись AWAY", "text"),
("home_skaters", "Игроков HOME", "number"),
("away_skaters", "Игроков AWAY", "number"),
("home_penalties", "Удалений HOME", "number"),
("away_penalties", "Удалений AWAY", "number"),
("advantage_side", "Сторона большинства", "text"),
)
for key, label, kind in strength_fields:
result.append({
"key": f"strength.{key}",
"label": label,
"category": "Матч · Численный состав",
"value": strength.get(key, ""),
"kind": kind,
"source_code": "live_control",
"description": "Рассчитано из текущих удалений с подписями из настроек «Таймеры и численные составы».",
})
for key, value in flags.items():
result.append({
"key": f"match_flag.{key}",
"label": key,
"category": "Матч · Флаги оператора",
"value": "1" if value else "0",
"kind": "text",
"source_code": "live_control",
"description": "Текущее состояние операторской кнопки матча.",
})
runtime_values = HockeyDataService._runtime_values_from_row(control) if control is not None else {}
player_panel_labels: dict[str, str] = {}
for meta_key, meta_value in runtime_values.items():
meta_match = re.match(r"^player_select\.([A-Za-z0-9_]+)\._label$", str(meta_key))
if meta_match:
player_panel_labels[meta_match.group(1)] = str(meta_value or meta_match.group(1))
for key, value in runtime_values.items():
mapping_key = str(key)
if mapping_key.startswith("panel."):
result.append({
"key": mapping_key,
"label": mapping_key,
"category": "Нижняя панель · Параметры",
"value": value,
"kind": "text",
"source_code": "live_control",
"description": "Выбранное значение параметризованной кнопки нижней панели.",
})
continue
player_match = re.match(r"^player_select\.([A-Za-z0-9_]+)\.(.+)$", mapping_key)
if not player_match:
continue
panel_id, field_key = player_match.groups()
if field_key.startswith("_"):
continue
slot_match = re.match(r"player(\d+)_(id|db_id|team_id|side|number|name)$", field_key)
field_labels = {
"id": "external ID", "db_id": "DB ID", "team_id": "ID команды",
"side": "сторона", "number": "номер", "name": "имя",
}
label = field_key
if slot_match:
slot_no, field_name = slot_match.groups()
label = f"Игрок {slot_no}{field_labels.get(field_name, field_name)}"
panel_label = player_panel_labels.get(panel_id, panel_id)
result.append({
"key": mapping_key,
"label": label,
"category": f"Игровые выборки · {panel_label}",
"value": value,
"kind": "text",
"source_code": "live_control",
"description": f"Значение слота блока игроков «{panel_label}».",
})
return result
def data_catalog(
self,
user: HockeyUser,
supplied: dict[str, Any] | None = None,
*,
source_codes: set[str] | None = None,
) -> dict[str, Any]:
self.ensure_defaults()
language = self._current_language(supplied)
context, variables = self.resolve_context(user, {**(supplied or {}), "ui_language": language})
items: list[dict[str, Any]] = []
tables: list[dict[str, Any]] = []
# Context variables are usable directly as mapping values too.
items.extend(self._live_control_items(context, language))
for variable in variables:
items.append({
"key": f"context.{variable['key']}", "label": variable["label"], "category": f"Контекст · {variable['category']}",
"value": variable.get("value", ""), "kind": "text", "source_code": "context", "variable_key": variable["key"],
"description": variable.get("description", ""),
})
source_results = []
with self.database.session() as session:
sources = list(session.scalars(select(MappingSqlDataSource).where(MappingSqlDataSource.enabled.is_(True)).order_by(MappingSqlDataSource.sort_order, MappingSqlDataSource.id)))
if source_codes is not None:
wanted = {str(code) for code in source_codes}
sources = [source for source in sources if source.code in wanted]
for source in sources:
payload = self._source_payload(source)
# Mapping may address a concrete row/cell, therefore the catalog keeps a
# larger read-only window than the compact SQL preview. 250 rows covers
# rosters, standings and most hockey tables without changing the SQL.
result = self._run_source(source, context, max_rows=250)
source_results.append({"id": source.id, "code": source.code, "name": source.name, "ok": result.get("ok"), "skipped": result.get("skipped", False), "missing": result.get("missing", []), "row_count": result.get("row_count", 0), "error": result.get("error", "")})
rows = result.get("rows") or []
columns = [str(value) for value in (result.get("columns") or [])]
metadata = payload.get("field_metadata", {})
metadata = metadata if isinstance(metadata, dict) else {}
first = rows[0] if rows else {}
language_pairs = self._language_pairs(columns)
paired_raw = {name for pair in language_pairs.values() for name in (pair.get("RUS", ""), pair.get("ENG", "")) if name}
# Raw SQL columns remain available for explicit mappings, even when they
# participate in an automatic _RUS/_ENG pair.
raw_column_info: dict[str, dict[str, Any]] = {}
for column in columns:
meta = metadata.get(column, {}) if isinstance(metadata.get(column, {}), dict) else {}
value = first.get(column, "")
info = {
"key": column,
"label": str(meta.get("label") or self._pretty_label(column)),
"kind": str(meta.get("kind") or self._infer_kind(column, value)),
"description": str(meta.get("description") or ""),
"localized": False,
}
raw_column_info[column] = info
items.append({
"key": f"{source.code}.{column}", "label": info["label"],
"category": str(meta.get("category") or source.category or source.name), "value": value,
"kind": info["kind"], "source_code": source.code, "source_name": source.name,
"column": column, "row_index": 1, "description": info["description"] or source.description or "",
"language_explicit": True if column in paired_raw else False,
})
# Visible table columns collapse paired Foo_RUS/Foo_ENG columns into one
# virtual Foo column. A saved mapping points at source.Foo and is resolved
# according to the current UI language every time it is applied.
visible_columns: list[dict[str, Any]] = []
seen_pair_bases: set[str] = set()
for column in columns:
match = _LANG_SUFFIX_RE.match(column)
pair = language_pairs.get(match.group("base").casefold()) if match else None
if pair:
base = str(pair["base"])
canonical = base.casefold()
if canonical in seen_pair_bases:
continue
seen_pair_bases.add(canonical)
chosen_value, chosen_column = self._localized_value(first, pair, language)
base_meta = metadata.get(base, {}) if isinstance(metadata.get(base, {}), dict) else {}
chosen_meta = metadata.get(chosen_column, {}) if isinstance(metadata.get(chosen_column, {}), dict) else {}
meta = {**chosen_meta, **base_meta}
kind = str(meta.get("kind") or self._infer_kind(base, chosen_value))
label = str(meta.get("label") or self._pretty_label(base))
description = str(meta.get("description") or f"Автовыбор языка: {pair['RUS']} / {pair['ENG']}")
info = {
"key": base, "label": label, "kind": kind, "description": description,
"localized": True, "language": language,
"rus_column": pair["RUS"], "eng_column": pair["ENG"], "resolved_column": chosen_column,
}
visible_columns.append(info)
items.append({
"key": f"{source.code}.{base}", "label": label,
"category": str(meta.get("category") or source.category or source.name),
"value": chosen_value, "kind": kind, "source_code": source.code, "source_name": source.name,
"column": base, "row_index": 1, "description": description,
"localized": True, "language": language,
"rus_column": pair["RUS"], "eng_column": pair["ENG"], "resolved_column": chosen_column,
})
else:
visible_columns.append(raw_column_info[column])
table_rows = []
for row_index, row in enumerate(rows, start=1):
row_label = self._table_row_label(row, row_index)
# Add virtual localized values to the row payload used by the preview/search.
row_values = dict(row)
for pair in language_pairs.values():
base = str(pair["base"])
chosen_value, _ = self._localized_value(row, pair, language)
row_values[base] = chosen_value
table_rows.append({"index": row_index, "label": row_label, "values": row_values})
# Raw cell keys remain supported for explicit language selection.
for column in columns:
info = raw_column_info[column]
value = row.get(column, "")
items.append({
"key": f"{source.code}.row.{row_index}.{column}", "label": info["label"],
"category": f"{source.name} · {row_label}", "value": value, "kind": info["kind"],
"source_code": source.code, "source_name": source.name, "column": column,
"row_index": row_index, "row_label": row_label, "table_cell": True,
"description": info.get("description") or source.description or "",
"language_explicit": True if column in paired_raw else False,
})
# And expose one virtual cell for every paired language column.
for pair in language_pairs.values():
base = str(pair["base"])
chosen_value, chosen_column = self._localized_value(row, pair, language)
base_meta = metadata.get(base, {}) if isinstance(metadata.get(base, {}), dict) else {}
chosen_meta = metadata.get(chosen_column, {}) if isinstance(metadata.get(chosen_column, {}), dict) else {}
meta = {**chosen_meta, **base_meta}
kind = str(meta.get("kind") or self._infer_kind(base, chosen_value))
label = str(meta.get("label") or self._pretty_label(base))
description = str(meta.get("description") or f"Автовыбор языка: {pair['RUS']} / {pair['ENG']}")
items.append({
"key": f"{source.code}.row.{row_index}.{base}", "label": label,
"category": f"{source.name} · {row_label}", "value": chosen_value, "kind": kind,
"source_code": source.code, "source_name": source.name, "column": base,
"row_index": row_index, "row_label": row_label, "table_cell": True,
"description": description, "localized": True, "language": language,
"rus_column": pair["RUS"], "eng_column": pair["ENG"], "resolved_column": chosen_column,
})
tables.append({
"id": source.id, "code": source.code, "name": source.name,
"category": source.category, "description": source.description,
"columns": visible_columns, "raw_columns": [raw_column_info[column] for column in columns],
"rows": table_rows, "row_count": len(table_rows), "ok": result.get("ok", False),
"skipped": result.get("skipped", False), "missing": result.get("missing", []),
"error": result.get("error", ""), "language": language,
"localized_pairs": [
{"base": pair["base"], "rus": pair["RUS"], "eng": pair["ENG"]}
for pair in language_pairs.values()
],
})
game_label = context.get("game_id", "") or "Матч не выбран"
return {
"context": context, "variables": variables, "items": items, "tables": tables,
"sources": source_results, "game_label": game_label, "language": language,
}