182 build - свои плюсы (ссылка) + триггер для выделенного удаления

This commit is contained in:
2026-10-02 13:18:15 +03:00
parent df78254487
commit edfd0e076d
7 changed files with 270 additions and 48 deletions

View File

@@ -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()

View File

@@ -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()

View File

@@ -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(