bulid 63
This commit is contained in:
966
hockey_data/mapping_context.py
Normal file
966
hockey_data/mapping_context.py
Normal file
@@ -0,0 +1,966 @@
|
||||
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": "Игрок выбранного удаления", "category": "Выделенные", "entity_type": "player", "scope": "match", "source_type": "selection"},
|
||||
{"key": "selected_penalty_team_id", "label": "Команда выбранного удаления", "category": "Выделенные", "entity_type": "team", "scope": "match", "source_type": "selection"},
|
||||
{"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)
|
||||
for raw_key, raw_value in runtime_values.items():
|
||||
sql_key = re.sub(r"[^A-Za-z0-9_]+", "_", str(raw_key)).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))
|
||||
runtime_variable_payloads.append({
|
||||
"key": sql_key,
|
||||
"label": str(raw_key),
|
||||
"category": "Нижняя панель",
|
||||
"description": f"Runtime-параметр нижней панели: {raw_key}",
|
||||
"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 {}
|
||||
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": "Выбранное значение параметризованной кнопки нижней панели.",
|
||||
})
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user