182 build - свои плюсы (ссылка) + триггер для выделенного удаления
This commit is contained in:
@@ -288,6 +288,7 @@ class HockeySettingsStore:
|
||||
self.local_settings_dir = self.settings_dir / "local"
|
||||
self.local_settings_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.timer_rules_file = self.local_settings_dir / "hockey_timers.json"
|
||||
self.custom_pluses_file = self.local_settings_dir / "hockey_custom_pluses.json"
|
||||
self.secret_file = self.settings_dir / "stat2tv_credentials.local.json"
|
||||
self._lock = RLock()
|
||||
with self._lock, _timer_settings_lock(self.local_settings_dir):
|
||||
@@ -351,6 +352,24 @@ class HockeySettingsStore:
|
||||
raise ValueError("Некорректный адрес Stat2TV")
|
||||
return value.rstrip("/") + "/"
|
||||
|
||||
def custom_pluses_link(self) -> str | None:
|
||||
"""None means the legacy match-specific link has not been migrated yet."""
|
||||
with self._lock:
|
||||
if not self.custom_pluses_file.exists():
|
||||
return None
|
||||
return str(self._read_json(self.custom_pluses_file, strict=True).get("link_url") or "")
|
||||
|
||||
def save_custom_pluses_link(self, link_url: str, *, only_if_missing: bool = False) -> str:
|
||||
value = str(link_url or "").strip()
|
||||
if len(value) > 2000:
|
||||
raise ValueError("Ссылка не должна быть длиннее 2000 символов")
|
||||
with self._lock, _timer_settings_lock(self.local_settings_dir):
|
||||
existing = self.custom_pluses_link()
|
||||
if only_if_missing and existing is not None:
|
||||
return existing
|
||||
_atomic_settings_write(self.custom_pluses_file, {"link_url": value})
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _language(value: Any, fallback: str) -> str:
|
||||
value = str(value or "").lower().strip()
|
||||
|
||||
@@ -29,6 +29,15 @@ from .models import (
|
||||
_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_]*)")
|
||||
# A team penalty (or a draft without a player) has no player identifiers.
|
||||
# Bind these optional IDs as SQL NULL so LEFT JOIN / COALESCE / NULLIF can
|
||||
# still return the penalty's own text instead of keeping the previous title.
|
||||
_NULLABLE_PENALTY_PLAYER_PARAMS = frozenset(
|
||||
f"{prefix}_player{suffix}"
|
||||
for prefix in ("selected_penalty", "selected_home_penalty", "selected_away_penalty",
|
||||
"active_home_penalty", "active_away_penalty")
|
||||
for suffix in ("_id", "_db_id")
|
||||
)
|
||||
_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|"
|
||||
@@ -686,6 +695,9 @@ class MappingDataService:
|
||||
for key in ("selected_player_id", "selected_player_db_id"):
|
||||
if key in supplied and supplied[key] is not None:
|
||||
context[key] = str(supplied[key])
|
||||
for key in _NULLABLE_PENALTY_PLAYER_PARAMS:
|
||||
if key in supplied:
|
||||
context[key] = str(supplied[key]) if supplied[key] is not None else ""
|
||||
context["account_id"] = str(user.id)
|
||||
context.setdefault("ui_language", self._current_language(supplied))
|
||||
context.update({f"title_button_{key}": "" for key in TITLE_BUTTON_FIELDS})
|
||||
@@ -841,7 +853,11 @@ class MappingDataService:
|
||||
for variable in variables:
|
||||
if variable.source_type == "system" or variable.key in context:
|
||||
continue
|
||||
value = stored.get((variable.id, self._scope_key(variable, user, context)), "")
|
||||
scope_key = (variable.id, self._scope_key(variable, user, context))
|
||||
value = stored.get(scope_key, "")
|
||||
if variable.key in _NULLABLE_PENALTY_PLAYER_PARAMS and scope_key in stored:
|
||||
context[variable.key] = value
|
||||
continue
|
||||
if value or variable.default_value:
|
||||
context[variable.key] = value or variable.default_value
|
||||
payload = []
|
||||
@@ -914,15 +930,17 @@ class MappingDataService:
|
||||
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)
|
||||
# An intentionally blank button caption is valid SQL input: it clears
|
||||
# the previous title's text. Identifiers still require a selected entity.
|
||||
# Blank captions clear old title text. Only penalty-player identifiers
|
||||
# are nullable: other required IDs and unknown variables still skip SQL.
|
||||
def blank_caption(name: str) -> bool:
|
||||
return name == "title_button_text" or (name.startswith("player_select_") and name.endswith("_preview_button_text"))
|
||||
missing = sorted(name for name in params if name not in context or
|
||||
(not context.get(name) and not blank_caption(name)))
|
||||
missing = sorted(name for name in params if name not in _NULLABLE_PENALTY_PLAYER_PARAMS and
|
||||
(name not in context or (not context.get(name) and not blank_caption(name))))
|
||||
if missing:
|
||||
return {"ok": True, "skipped": True, "missing": missing, "columns": [], "rows": [], "row_count": 0}
|
||||
bound = {name: context.get(name, "") for name in params}
|
||||
bound = {name: (None if name in _NULLABLE_PENALTY_PLAYER_PARAMS and
|
||||
not str(context.get(name) or "").strip() else context.get(name, ""))
|
||||
for name in params}
|
||||
try:
|
||||
with self.database.engine.connect() as connection:
|
||||
transaction = connection.begin()
|
||||
|
||||
@@ -8122,10 +8122,14 @@ class HockeyDataService:
|
||||
.where(CustomPlusRow.game_external_id == game.external_id)
|
||||
.order_by(CustomPlusRow.side, CustomPlusRow.sort_order, CustomPlusRow.id)
|
||||
))
|
||||
control = session.scalar(select(GameControlState).where(
|
||||
GameControlState.game_external_id == game.external_id
|
||||
))
|
||||
link_url = str(control.custom_pluses_link_url or "") if control else ""
|
||||
link_url = self.settings.custom_pluses_link()
|
||||
if link_url is None:
|
||||
# Preserve the most recently edited nonempty link from BUILD180.
|
||||
# An explicitly cleared global link is never imported again.
|
||||
legacy_link = session.scalar(select(GameControlState.custom_pluses_link_url).where(
|
||||
GameControlState.custom_pluses_link_url != ""
|
||||
).order_by(GameControlState.updated_at.desc(), GameControlState.id.desc()).limit(1)) or ""
|
||||
link_url = self.settings.save_custom_pluses_link(legacy_link, only_if_missing=True)
|
||||
game_payload = self._serialize_game(
|
||||
game, language=language, fallback_language=fallback_language
|
||||
)
|
||||
@@ -8136,6 +8140,7 @@ class HockeyDataService:
|
||||
return {
|
||||
"game_external_id": str(game_external_id),
|
||||
"link_url": link_url,
|
||||
"link_scope": "global",
|
||||
"home": grouped["home"],
|
||||
"away": grouped["away"],
|
||||
"teams": {
|
||||
@@ -8219,13 +8224,10 @@ class HockeyDataService:
|
||||
row.updated_by = str(updated_by or "")[:128]
|
||||
row.updated_at = now
|
||||
|
||||
# Omitted by older clients: preserve the existing header link.
|
||||
if link_url is not None:
|
||||
control = self._get_or_create_game_control(session, game.external_id)
|
||||
control.custom_pluses_link_url = link_url
|
||||
control.updated_by = str(updated_by or "")[:128]
|
||||
control.updated_at = now
|
||||
|
||||
# Notes stay match-specific; the header URL is shared by all matches.
|
||||
# Omitted on ordinary row edits to avoid replacing a newer global URL.
|
||||
if link_url is not None:
|
||||
self.settings.save_custom_pluses_link(link_url)
|
||||
return self.custom_pluses(game_external_id, language=language)
|
||||
|
||||
def set_game_period(
|
||||
|
||||
Reference in New Issue
Block a user