diff --git a/app.py b/app.py index 6c7827c..f35b3c2 100644 --- a/app.py +++ b/app.py @@ -29,7 +29,7 @@ from ui_builder import install_ui_builder from khl_site.khl_data_center import APP as khl_site_app BASE_DIR = Path(__file__).resolve().parent -BUILD_VERSION = "2026.08.27.4" +BUILD_VERSION = "2026.09.10.1" # compatibility: BUILD_VERSION = "2026.08.26.1" # compatibility: BUILD_VERSION = "2026.08.25.1" # compatibility: BUILD_VERSION = "2026.08.24.15" diff --git a/hockey_data/database.py b/hockey_data/database.py index 3130ef6..ec7e932 100644 --- a/hockey_data/database.py +++ b/hockey_data/database.py @@ -161,9 +161,25 @@ class HockeyDatabase: migrated = self._migrate_operator_session_user_id() Base.metadata.create_all(self.engine) repaired = migrated + self._repair_game_table() + + # BUILD129: preserve the behaviour of the legacy primary team + # colour when the explicit enable flag is introduced. Existing + # rows that already had color_hex become enabled exactly once, + # only on the migration that creates color_enabled. + team_repaired = self._repair_additive_table(Team) + repaired.extend(team_repaired) + if f"{Team.__tablename__}.color_enabled" in team_repaired: + with self.engine.begin() as connection: + connection.execute( + text( + "UPDATE \"hockey_teams\" SET \"color_enabled\" = TRUE " + "WHERE COALESCE(TRIM(\"color_hex\"), '') <> ''" + ) + ) + + for model in ( OperatorSession, - Team, TeamLeague, Country, PenaltyType, diff --git a/hockey_data/mapping_context.py b/hockey_data/mapping_context.py index 5c9520c..18f8c38 100644 --- a/hockey_data/mapping_context.py +++ b/hockey_data/mapping_context.py @@ -16,6 +16,7 @@ from .service import HockeyDataService from .models import ( Game, GameControlState, + Team, Tournament, MappingContextValue, MappingContextVariable, @@ -855,6 +856,12 @@ class MappingDataService: control = session.scalar( select(GameControlState).where(GameControlState.game_external_id == game_id) ) + home_team = session.scalar( + select(Team).where(Team.external_id == str(game.home_team_external_id or "")) + ) if game.home_team_external_id else None + away_team = session.scalar( + select(Team).where(Team.external_id == str(game.away_team_external_id or "")) + ) if game.away_team_external_id else None current_period = str(getattr(control, "current_period", "1") or "1") stage = stage_key( str(getattr(tournament, "season_part", "") or ""), @@ -878,6 +885,47 @@ class MappingDataService: language=language, ) result: list[dict[str, Any]] = [] + + # BUILD129: operator-managed team colours. Enabled is deliberately a + # separate value from the HEX string so a colour may remain configured + # while being disabled for the current graphics logic. + for side, team, side_label in (("home", home_team, "Хозяева"), ("away", away_team, "Гости")): + color_specs = ( + (1, "color_hex", "color_enabled"), + (2, "color_2_hex", "color_2_enabled"), + (3, "color_3_hex", "color_3_enabled"), + ) + for index, color_field, enabled_field in color_specs: + raw_color = str(getattr(team, color_field, "") or "") if team is not None else "" + enabled = bool(getattr(team, enabled_field, False)) if team is not None else False + result.append({ + "key": f"team.{side}.color{index}", + "label": f"Цвет {index}", + "category": f"Команды · Цвета · {side_label}", + "value": raw_color if enabled else "", + "kind": "color", + "source_code": "live_control", + "description": f"Цвет {index} команды. Пусто, если цвет выключен в справочнике команд.", + }) + result.append({ + "key": f"team.{side}.color{index}_enabled", + "label": f"Цвет {index} включён (1/0)", + "category": f"Команды · Цвета · {side_label}", + "value": "1" if enabled else "0", + "kind": "text", + "source_code": "live_control", + "description": f"1 — цвет {index} включён; 0 — отключён оператором.", + }) + result.append({ + "key": f"team.{side}.color{index}_raw", + "label": f"Цвет {index} · сохранённый HEX", + "category": f"Команды · Цвета · {side_label}", + "value": raw_color, + "kind": "color", + "source_code": "live_control", + "description": f"Сохранённый HEX цвета {index}, независимо от флага включения.", + }) + score_control = HockeyDataService._score_control_payload(game, control) score_fields = ( ("home", "Счёт HOME · LIVE", score_control["home"], "Оперативный счёт HOME. После первого ручного +/- становится главным до синхронизации."), diff --git a/hockey_data/models.py b/hockey_data/models.py index 8837a43..805cfdc 100644 --- a/hockey_data/models.py +++ b/hockey_data/models.py @@ -254,7 +254,14 @@ class Team(Base): short_name_en: Mapped[str] = mapped_column(String(255), nullable=False, default="") city_ru: Mapped[str] = mapped_column(String(255), nullable=False, default="") city_en: Mapped[str] = mapped_column(String(255), nullable=False, default="") + # BUILD129: three operator-editable team colours. The legacy ``color_hex`` + # column remains colour #1 for backwards compatibility with existing SQL. color_hex: Mapped[str] = mapped_column(String(9), nullable=False, default="") + color_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + color_2_hex: Mapped[str] = mapped_column(String(9), nullable=False, default="") + color_2_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + color_3_hex: Mapped[str] = mapped_column(String(9), nullable=False, default="") + color_3_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) logo_url: Mapped[str] = mapped_column(Text, nullable=False, default="") raw_payload: Mapped[str] = mapped_column(Text, nullable=False, default="") synced_at: Mapped[datetime] = mapped_column( diff --git a/hockey_data/router.py b/hockey_data/router.py index 52edc87..804eb39 100644 --- a/hockey_data/router.py +++ b/hockey_data/router.py @@ -166,6 +166,11 @@ class TeamDirectoryPayload(BaseModel): city_ru: str = Field(default="", max_length=255) city_en: str = Field(default="", max_length=255) color_hex: str = Field(default="", max_length=9) + color_enabled: bool = False + color_2_hex: str = Field(default="", max_length=9) + color_2_enabled: bool = False + color_3_hex: str = Field(default="", max_length=9) + color_3_enabled: bool = False logo_url: str = Field(default="", max_length=2000) active: bool = True diff --git a/hockey_data/service.py b/hockey_data/service.py index 63365d5..fdafe6f 100644 --- a/hockey_data/service.py +++ b/hockey_data/service.py @@ -609,6 +609,16 @@ class HockeyDataService: "city_ru": team.city_ru, "city_en": team.city_en, "color_hex": team.color_hex, + "color_enabled": bool(team.color_enabled), + "color_2_hex": team.color_2_hex, + "color_2_enabled": bool(team.color_2_enabled), + "color_3_hex": team.color_3_hex, + "color_3_enabled": bool(team.color_3_enabled), + "colors": [ + {"index": 1, "hex": team.color_hex, "enabled": bool(team.color_enabled)}, + {"index": 2, "hex": team.color_2_hex, "enabled": bool(team.color_2_enabled)}, + {"index": 3, "hex": team.color_3_hex, "enabled": bool(team.color_3_enabled)}, + ], "logo_url": team.logo_url, "source": membership.source, "active": bool(membership.active), @@ -756,8 +766,17 @@ class HockeyDataService: if field in payload and payload[field] is not None: setattr(team, field, str(payload[field]).strip()) - if "color_hex" in payload and payload["color_hex"] is not None: - team.color_hex = normalise_team_color_hex(payload["color_hex"]) + color_fields = ( + ("color_hex", "color_enabled"), + ("color_2_hex", "color_2_enabled"), + ("color_3_hex", "color_3_enabled"), + ) + for color_field, enabled_field in color_fields: + if color_field in payload and payload[color_field] is not None: + setattr(team, color_field, normalise_team_color_hex(payload[color_field])) + if enabled_field in payload: + color_value = str(getattr(team, color_field, "") or "").strip() + setattr(team, enabled_field, bool(payload.get(enabled_field)) and bool(color_value)) if not (team.name_ru or team.name_en): raise ValueError("Укажите название команды на русском или английском") diff --git a/hockey_data/static/admin-directories.css b/hockey_data/static/admin-directories.css index 2a64dfa..93b3dfd 100644 --- a/hockey_data/static/admin-directories.css +++ b/hockey_data/static/admin-directories.css @@ -1745,3 +1745,25 @@ .hockey-map-sql-table-ref span{font-size:8px;color:#7890a8;white-space:nowrap} .hockey-map-sql-table-ref code{grid-column:1/-1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:8px Consolas,monospace;color:#738da6} @media(max-width:1050px){.hockey-map-sql-table-list{grid-template-columns:1fr}} + +/* BUILD129 — three optional team colours with explicit enable flags */ +.hockey-team-color-field-head{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;margin-bottom:3px} +.hockey-team-color-field-head>span{color:#c8d7e6;font-size:10px;font-weight:900} +.hockey-team-color-field-head>small{max-width:520px;text-align:right;line-height:1.35} +.hockey-team-colors-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px} +.hockey-team-color-item{min-width:0;padding:8px;border:1px solid #2b4057;border-radius:10px;background:#0b1725;opacity:.72;transition:border-color .15s ease,opacity .15s ease,background .15s ease} +.hockey-team-color-item.is-enabled{border-color:#347662;background:#0d201f;opacity:1} +.hockey-team-color-enabled{display:grid!important;grid-template-columns:auto minmax(0,1fr) auto!important;align-items:center!important;gap:6px!important;margin:0 0 6px!important} +.hockey-team-color-enabled input{width:15px!important;height:15px!important;min-height:0!important;margin:0;accent-color:#48dfbd} +.hockey-team-color-enabled>span{color:#d8e5f0;font-size:9px;font-weight:900} +.hockey-team-color-enabled>small{overflow:hidden;color:#71869a;font-size:7px!important;text-overflow:ellipsis;white-space:nowrap} +.hockey-team-color-item.is-enabled .hockey-team-color-enabled>small{color:#67d9bd} +.hockey-team-colors-cell{display:grid;gap:3px;min-width:118px} +.hockey-team-color-chip{display:grid;grid-template-columns:14px 15px minmax(0,1fr);align-items:center;gap:5px;min-height:20px;color:#7f94a8} +.hockey-team-color-chip>b{display:grid;place-items:center;width:14px;height:14px;border-radius:50%;background:#142437;color:#7890a8;font-size:7px} +.hockey-team-color-chip>i{width:14px;height:14px;border:1px solid rgba(255,255,255,.45);border-radius:4px;background:var(--team-color,#fff)} +.hockey-team-color-chip>code{overflow:hidden;color:#8ea3b7;font-size:8px;text-overflow:ellipsis;white-space:nowrap} +.hockey-team-color-chip.is-disabled{opacity:.38} +.hockey-team-color-chip.is-enabled>b{background:#183a32;color:#71e2c7} +.hockey-team-color-chip.is-enabled>code{color:#bed2e2} +@media(max-width:1180px){.hockey-team-colors-grid{grid-template-columns:1fr}.hockey-team-color-field-head{align-items:flex-start;flex-direction:column}.hockey-team-color-field-head>small{text-align:left}} diff --git a/hockey_data/static/admin-directories.js b/hockey_data/static/admin-directories.js index 09bdc4a..9e7d559 100644 --- a/hockey_data/static/admin-directories.js +++ b/hockey_data/static/admin-directories.js @@ -80,6 +80,43 @@ return color || "#FFFFFF"; }; + const teamColorFieldName = (index) => index === 1 ? "color_hex" : `color_${index}_hex`; + const teamColorEnabledName = (index) => index === 1 ? "color_enabled" : `color_${index}_enabled`; + + const teamColorEditorMarkup = (editing, index) => { + const field = teamColorFieldName(index); + const enabledField = teamColorEnabledName(index); + const fieldNameAttr = index === 1 ? 'name="color_hex"' : `name="color_${index}_hex"`; + const enabledNameAttr = index === 1 ? 'name="color_enabled"' : `name="color_${index}_enabled"`; + const value = String(editing?.[field] || ""); + const enabled = editing ? Boolean(editing?.[enabledField]) : false; + return ` +
+ +
+ + + +
+
+ `; + }; + + const teamColorsCellMarkup = (item) => [1, 2, 3].map((index) => { + const field = teamColorFieldName(index); + const enabledField = teamColorEnabledName(index); + const value = normalizeTeamColorHex(item?.[field]); + const enabled = Boolean(item?.[enabledField]) && Boolean(value); + return `${index}${escapeHtml(value || "—")}`; + }).join(""); + const countryFlagMarkup = (item) => { const code = String(item?.iso2 || item?.country_code || "").trim().toLowerCase().replace(/[^a-z]/g, "").slice(0, 2); const url = String(item?.flag_url || (code.length === 2 ? `/hockey-assets/flags/${code}.svg` : "")); @@ -341,7 +378,7 @@ ${escapeHtml(item.name_ru || "—")}${escapeHtml(item.short_name_ru || "")} ${escapeHtml(item.name_en || "—")}${escapeHtml(item.short_name_en || "")} ${escapeHtml(item.city_ru || item.city_en || "—")} - ${item.color_hex ? `${escapeHtml(item.color_hex)}` : "—"} +
${teamColorsCellMarkup(item)}
${escapeHtml(item.source === "manual" ? "изменено" : "Stat2TV")} @@ -361,7 +398,7 @@
- + ${rows}
ID APIРусскийEnglishГородЦветИсточник
ID APIРусскийEnglishГородЦветаИсточник
@@ -381,18 +418,15 @@
- +
+ Цвета команды + HEX можно сохранить заранее, а галочкой отдельно включать/выключать цвет в данных Mapping. +
+
+ ${teamColorEditorMarkup(editing, 1)} + ${teamColorEditorMarkup(editing, 2)} + ${teamColorEditorMarkup(editing, 3)} +
@@ -3088,57 +3122,82 @@ setStatus(""); renderTeams(); }); - const colorText = modal.querySelector("[data-team-color-text]"); - const colorPicker = modal.querySelector("[data-team-color-picker]"); - const colorPreview = modal.querySelector("[data-team-color-preview]"); - const syncTeamColorUi = (rawValue, { commit = false } = {}) => { - const normalized = normalizeTeamColorHex(rawValue); - if (normalized) { - if (colorText && (commit || colorText.value !== rawValue)) colorText.value = normalized; - if (colorPicker) colorPicker.value = normalized; - if (colorPreview) colorPreview.style.setProperty("--team-color", normalized); - return normalized; - } - if (commit && colorText && String(rawValue || "").trim()) { - setStatus("Цвет команды должен быть в формате #RRGGBB", true); - } - if (!String(rawValue || "").trim()) { - if (colorText && commit) colorText.value = ""; - if (colorPicker) colorPicker.value = "#FFFFFF"; - if (colorPreview) colorPreview.style.setProperty("--team-color", "#FFFFFF"); - } - return ""; + const bindTeamColor = (index) => { + const item = modal.querySelector(`[data-team-color-item="${index}"]`); + const colorText = modal.querySelector(`[data-team-color-text="${index}"]`); + const colorPicker = modal.querySelector(`[data-team-color-picker="${index}"]`); + const colorPreview = modal.querySelector(`[data-team-color-preview="${index}"]`); + const colorEnabled = modal.querySelector(`[data-team-color-enabled="${index}"]`); + const enabledCaption = item?.querySelector(".hockey-team-color-enabled small"); + const updateEnabledUi = () => { + const enabled = Boolean(colorEnabled?.checked); + item?.classList.toggle("is-enabled", enabled); + if (enabledCaption) enabledCaption.textContent = enabled ? "используется" : "выключен"; + }; + const syncTeamColorUi = (rawValue, { commit = false } = {}) => { + const normalized = normalizeTeamColorHex(rawValue); + if (normalized) { + if (colorText && (commit || colorText.value !== rawValue)) colorText.value = normalized; + if (colorPicker) colorPicker.value = normalized; + if (colorPreview) colorPreview.style.setProperty("--team-color", normalized); + return normalized; + } + if (commit && colorText && String(rawValue || "").trim()) { + setStatus(`Цвет ${index} команды должен быть в формате #RRGGBB`, true); + } + if (!String(rawValue || "").trim()) { + if (colorText && commit) colorText.value = ""; + if (colorPicker) colorPicker.value = "#FFFFFF"; + if (colorPreview) colorPreview.style.setProperty("--team-color", "#FFFFFF"); + } + return ""; + }; + modal.querySelector(`[data-team-color-open="${index}"]`)?.addEventListener("click", () => colorPicker?.click()); + colorPicker?.addEventListener("input", () => { + if (colorText) colorText.value = String(colorPicker.value || "").toUpperCase(); + if (colorEnabled) colorEnabled.checked = true; + updateEnabledUi(); + syncTeamColorUi(colorPicker.value); + }); + colorText?.addEventListener("input", () => { + const raw = String(colorText.value || ""); + const compact = raw.replace(/\s+/g, "").toUpperCase(); + if (raw !== compact) colorText.value = compact; + const normalized = normalizeTeamColorHex(compact); + if (normalized) syncTeamColorUi(normalized); + }); + colorText?.addEventListener("blur", () => syncTeamColorUi(colorText.value, { commit: true })); + colorEnabled?.addEventListener("change", updateEnabledUi); + modal.querySelector(`[data-team-color-clear="${index}"]`)?.addEventListener("click", () => { + if (colorText) colorText.value = ""; + if (colorEnabled) colorEnabled.checked = false; + updateEnabledUi(); + syncTeamColorUi("", { commit: true }); + colorText?.focus(); + }); + updateEnabledUi(); }; - modal.querySelector("[data-team-color-open]")?.addEventListener("click", () => colorPicker?.click()); - colorPicker?.addEventListener("input", () => { - if (colorText) colorText.value = String(colorPicker.value || "").toUpperCase(); - syncTeamColorUi(colorPicker.value); - }); - colorText?.addEventListener("input", () => { - const raw = String(colorText.value || ""); - const compact = raw.replace(/\s+/g, "").toUpperCase(); - if (raw !== compact) colorText.value = compact; - const normalized = normalizeTeamColorHex(compact); - if (normalized) syncTeamColorUi(normalized); - }); - colorText?.addEventListener("blur", () => syncTeamColorUi(colorText.value, { commit: true })); - modal.querySelector("[data-team-color-clear]")?.addEventListener("click", () => { - if (colorText) colorText.value = ""; - syncTeamColorUi("", { commit: true }); - colorText?.focus(); - }); + [1, 2, 3].forEach(bindTeamColor); modal.querySelector("[data-team-form]")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const values = new FormData(form); - const rawTeamColor = String(values.get("color_hex") || "").trim(); - const normalizedTeamColor = normalizeTeamColorHex(rawTeamColor); - if (rawTeamColor && !normalizedTeamColor) { - setStatus("Цвет команды должен быть в формате #RRGGBB", true); - colorText?.focus(); - return; + const normalizedColors = {}; + for (const index of [1, 2, 3]) { + const field = teamColorFieldName(index); + const enabledField = teamColorEnabledName(index); + const raw = String(values.get(field) || "").trim(); + const normalized = normalizeTeamColorHex(raw); + if (raw && !normalized) { + setStatus(`Цвет ${index} команды должен быть в формате #RRGGBB`, true); + modal.querySelector(`[data-team-color-text="${index}"]`)?.focus(); + return; + } + normalizedColors[field] = normalized; + normalizedColors[enabledField] = values.has(enabledField) && Boolean(normalized); } + const normalizedTeamColor = normalizedColors.color_hex; const payload = { external_id: values.get("external_id") || state.editingTeam?.external_id || "", league_key: values.get("league_key") || "", @@ -3150,6 +3209,11 @@ city_ru: values.get("city_ru") || "", city_en: values.get("city_en") || "", color_hex: normalizedTeamColor, + color_enabled: normalizedColors.color_enabled, + color_2_hex: normalizedColors.color_2_hex, + color_2_enabled: normalizedColors.color_2_enabled, + color_3_hex: normalizedColors.color_3_hex, + color_3_enabled: normalizedColors.color_3_enabled, logo_url: values.get("logo_url") || "", active: values.has("active"), };