backup worked

This commit is contained in:
Volobuev Andrey
2025-10-06 13:59:30 +03:00
parent 2f8aea3620
commit 8f6b44c679
7 changed files with 1144 additions and 480 deletions

View File

@@ -1,237 +1,442 @@
# -*- coding: utf-8 -*-
# CONTRACT:
# PURPOSE: Интерактивный скрипт для миграции ассетов Superset между различными окружениями.
# SPECIFICATION_LINK: mod_migration_script
# PRECONDITIONS: Наличие корректных конфигурационных файлов для подключения к Superset.
# POSTCONDITIONS: Выбранные ассеты успешно перенесены из исходного в целевое окружение.
# IMPORTS: [argparse, superset_tool.client, superset_tool.utils.init_clients, superset_tool.utils.logger, superset_tool.utils.fileio]
"""
[MODULE] Superset Migration Tool
@description: Интерактивный скрипт для миграции ассетов Superset между различными окружениями.
"""
from whiptail import Whiptail
# [MODULE_PATH] superset_tool.migration_script
# [FILE] migration_script.py
# [SEMANTICS] migration, cli, superset, ui, logging, fallback, error-recovery, non-interactive, temp-files, batch-delete
# --------------------------------------------------------------
# [IMPORTS]
# --------------------------------------------------------------
import json
import logging
import sys
import zipfile
from pathlib import Path
from typing import List, Optional, Tuple, Dict
from superset_tool.client import SupersetClient
from superset_tool.utils.init_clients import setup_clients
from superset_tool.utils.logger import SupersetLogger
from superset_tool.utils.fileio import (
save_and_unpack_dashboard,
read_dashboard_from_disk,
create_temp_file, # новый контекстный менеджер
update_yamls,
create_dashboard_export
create_dashboard_export,
)
from superset_tool.utils.whiptail_fallback import (
menu,
checklist,
yesno,
msgbox,
inputbox,
gauge,
)
# [ENTITY: Class('Migration')]
# CONTRACT:
# PURPOSE: Инкапсулирует логику и состояние процесса миграции.
# SPECIFICATION_LINK: class_migration
# ATTRIBUTES:
# - name: logger, type: SupersetLogger, description: Экземпляр логгера.
# - name: from_c, type: SupersetClient, description: Клиент для исходного окружения.
# - name: to_c, type: SupersetClient, description: Клиент для целевого окружения.
# - name: dashboards_to_migrate, type: list, description: Список дашбордов для миграции.
# - name: db_config_replacement, type: dict, description: Конфигурация для замены данных БД.
from superset_tool.utils.logger import SupersetLogger # type: ignore
# [END_IMPORTS]
# --------------------------------------------------------------
# [ENTITY: Service('Migration')]
# [RELATION: Service('Migration')] -> [DEPENDS_ON] -> [PythonModule('superset_tool.client')]
# --------------------------------------------------------------
"""
:purpose: Интерактивный процесс миграции дашбордов с возможностью
«удалить‑и‑перезаписать» при ошибке импорта.
:preconditions:
- Конфигурация Supersetклиентов доступна,
- Пользователь может взаимодействовать через консольный UI.
:postconditions:
- Выбранные дашборды импортированы в целевое окружение.
:sideeffect: Записывает журнал в каталог ``logs/`` текущего рабочего каталога.
"""
class Migration:
"""
Класс для управления процессом миграции дашбордов Superset.
:ivar SupersetLogger logger: Логгер.
:ivar bool enable_delete_on_failure: Флаг «удалять‑при‑ошибке».
:ivar SupersetClient from_c: Клиент‑источник.
:ivar SupersetClient to_c: Клиент‑назначение.
:ivar List[dict] dashboards_to_migrate: Список выбранных дашбордов.
:ivar Optional[dict] db_config_replacement: Параметры замены имён БД.
:ivar List[dict] _failed_imports: Внутренний буфер неудавшихся импортов
(ключи: slug, zip_content, dash_id).
"""
def __init__(self):
self.logger = SupersetLogger(name="migration_script")
self.from_c: SupersetClient = None
self.to_c: SupersetClient = None
self.dashboards_to_migrate = []
self.db_config_replacement = None
# END_FUNCTION___init__
# [ENTITY: Function('run')]
# CONTRACT:
# PURPOSE: Запускает основной воркфлоу миграции, координируя все шаги.
# SPECIFICATION_LINK: func_run_migration
# PRECONDITIONS: None
# POSTCONDITIONS: Процесс миграции завершен.
def run(self):
"""Запускает основной воркфлоу миграции."""
# --------------------------------------------------------------
# [ENTITY: Method('__init__')]
# --------------------------------------------------------------
"""
:purpose: Создать сервис миграции и настроить логгер.
:preconditions: None.
:postconditions: ``self.logger`` готов к использованию; ``enable_delete_on_failure`` = ``False``.
"""
def __init__(self) -> None:
default_log_dir = Path.cwd() / "logs"
self.logger = SupersetLogger(
name="migration_script",
log_dir=default_log_dir,
level=logging.INFO,
console=True,
)
self.enable_delete_on_failure = False
self.from_c: Optional[SupersetClient] = None
self.to_c: Optional[SupersetClient] = None
self.dashboards_to_migrate: List[dict] = []
self.db_config_replacement: Optional[dict] = None
self._failed_imports: List[dict] = [] # <-- буфер ошибок
assert self.logger is not None, "Logger must be instantiated."
# [END_ENTITY]
# --------------------------------------------------------------
# [ENTITY: Method('run')]
# --------------------------------------------------------------
"""
:purpose: Точка входа последовательный запуск всех шагов миграции.
:preconditions: Логгер готов.
:postconditions: Скрипт завершён, пользователю выведено сообщение.
"""
def run(self) -> None:
self.logger.info("[INFO][run][ENTER] Запуск скрипта миграции.")
self.ask_delete_on_failure()
self.select_environments()
self.select_dashboards()
self.confirm_db_config_replacement()
self.execute_migration()
self.logger.info("[INFO][run][EXIT] Скрипт миграции завершен.")
# END_FUNCTION_run
self.logger.info("[INFO][run][EXIT] Скрипт миграции завершён.")
# [END_ENTITY]
# [ENTITY: Function('select_environments')]
# CONTRACT:
# PURPOSE: Шаг 1. Обеспечивает интерактивный выбор исходного и целевого окружений.
# SPECIFICATION_LINK: func_select_environments
# PRECONDITIONS: None
# POSTCONDITIONS: Атрибуты `self.from_c` и `self.to_c` инициализированы валидными клиентами Superset.
def select_environments(self):
"""Шаг 1: Выбор окружений (источник и назначение)."""
self.logger.info("[INFO][select_environments][ENTER] Шаг 1/4: Выбор окружений.")
# --------------------------------------------------------------
# [ENTITY: Method('ask_delete_on_failure')]
# --------------------------------------------------------------
"""
:purpose: Запросить у пользователя, следует ли удалять дашборд при ошибке импорта.
:preconditions: None.
:postconditions: ``self.enable_delete_on_failure`` установлен.
"""
def ask_delete_on_failure(self) -> None:
self.enable_delete_on_failure = yesno(
"Поведение при ошибке импорта",
"Если импорт завершится ошибкой, удалить существующий дашборд и попытаться импортировать заново?",
)
self.logger.info(
"[INFO][ask_delete_on_failure] Deleteonfailure = %s",
self.enable_delete_on_failure,
)
# [END_ENTITY]
# --------------------------------------------------------------
# [ENTITY: Method('select_environments')]
# --------------------------------------------------------------
"""
:purpose: Выбрать исходное и целевое окружения Superset.
:preconditions: ``setup_clients`` успешно инициализирует все клиенты.
:postconditions: ``self.from_c`` и ``self.to_c`` установлены.
"""
def select_environments(self) -> None:
self.logger.info("[INFO][select_environments][ENTER] Шаг1/5: Выбор окружений.")
try:
all_clients = setup_clients(self.logger)
available_envs = list(all_clients.keys())
except Exception as e:
self.logger.error(f"[ERROR][select_environments][FAILURE] Ошибка при инициализации клиентов: {e}", exc_info=True)
w = Whiptail(title="Ошибка", backtitle="Superset Migration Tool")
w.msgbox("Не удалось инициализировать клиенты. Проверьте конфигурацию.")
self.logger.error("[ERROR][select_environments] %s", e, exc_info=True)
msgbox("Ошибка", "Не удалось инициализировать клиенты.")
return
w = Whiptail(title="Выбор окружения", backtitle="Superset Migration Tool")
# Select source environment
(return_code, from_env_name) = w.menu("Выберите исходное окружение:", available_envs)
if return_code == 0:
self.from_c = all_clients[from_env_name]
self.logger.info(f"[INFO][select_environments][STATE] Исходное окружение: {from_env_name}")
else:
rc, from_env_name = menu(
title="Выбор окружения",
prompt="Исходное окружение:",
choices=available_envs,
)
if rc != 0:
return
self.from_c = all_clients[from_env_name]
self.logger.info("[INFO][select_environments] from = %s", from_env_name)
# Select target environment
available_envs.remove(from_env_name)
(return_code, to_env_name) = w.menu("Выберите целевое окружение:", available_envs)
if return_code == 0:
self.to_c = all_clients[to_env_name]
self.logger.info(f"[INFO][select_environments][STATE] Целевое окружение: {to_env_name}")
else:
rc, to_env_name = menu(
title="Выбор окружения",
prompt="Целевое окружение:",
choices=available_envs,
)
if rc != 0:
return
self.logger.info("[INFO][select_environments][EXIT] Шаг 1 завершен.")
# END_FUNCTION_select_environments
# [ENTITY: Function('select_dashboards')]
# CONTRACT:
# PURPOSE: Шаг 2. Обеспечивает интерактивный выбор дашбордов для миграции.
# SPECIFICATION_LINK: func_select_dashboards
# PRECONDITIONS: `self.from_c` должен быть инициализирован.
# POSTCONDITIONS: `self.dashboards_to_migrate` содержит список выбранных дашбордов.
def select_dashboards(self):
"""Шаг 2: Выбор дашбордов для миграции."""
self.logger.info("[INFO][select_dashboards][ENTER] Шаг 2/4: Выбор дашбордов.")
self.to_c = all_clients[to_env_name]
self.logger.info("[INFO][select_environments] to = %s", to_env_name)
self.logger.info("[INFO][select_environments][EXIT] Шаг1 завершён.")
# [END_ENTITY]
# --------------------------------------------------------------
# [ENTITY: Method('select_dashboards')]
# --------------------------------------------------------------
"""
:purpose: Позволить пользователю выбрать набор дашбордов для миграции.
:preconditions: ``self.from_c`` инициализирован.
:postconditions: ``self.dashboards_to_migrate`` заполнен.
"""
def select_dashboards(self) -> None:
self.logger.info("[INFO][select_dashboards][ENTER] Шаг2/5: Выбор дашбордов.")
try:
_, all_dashboards = self.from_c.get_dashboards()
_, all_dashboards = self.from_c.get_dashboards() # type: ignore[attr-defined]
if not all_dashboards:
self.logger.warning("[WARN][select_dashboards][STATE] В исходном окружении не найдено дашбордов.")
w = Whiptail(title="Информация", backtitle="Superset Migration Tool")
w.msgbox("В исходном окружении не найдено дашбордов.")
self.logger.warning("[WARN][select_dashboards] No dashboards.")
msgbox("Информация", "В исходном окружении нет дашбордов.")
return
w = Whiptail(title="Выбор дашбордов", backtitle="Superset Migration Tool")
dashboard_options = [(str(d['id']), d['dashboard_title']) for d in all_dashboards]
(return_code, selected_ids) = w.checklist("Выберите дашборды для миграции:", dashboard_options)
options = [("ALL", "Все дашборды")] + [
(str(d["id"]), d["dashboard_title"]) for d in all_dashboards
]
if return_code == 0:
self.dashboards_to_migrate = [d for d in all_dashboards if str(d['id']) in selected_ids]
self.logger.info(f"[INFO][select_dashboards][STATE] Выбрано дашбордов: {len(self.dashboards_to_migrate)}")
rc, selected = checklist(
title="Выбор дашбордов",
prompt="Отметьте нужные дашборды (введите номера):",
options=options,
)
if rc != 0:
return
if "ALL" in selected:
self.dashboards_to_migrate = list(all_dashboards)
self.logger.info(
"[INFO][select_dashboards] Выбраны все дашборды (%d).",
len(self.dashboards_to_migrate),
)
return
self.dashboards_to_migrate = [
d for d in all_dashboards if str(d["id"]) in selected
]
self.logger.info(
"[INFO][select_dashboards] Выбрано %d дашбордов.",
len(self.dashboards_to_migrate),
)
except Exception as e:
self.logger.error(f"[ERROR][select_dashboards][FAILURE] Ошибка при получении или выборе дашбордов: {e}", exc_info=True)
w = Whiptail(title="Ошибка", backtitle="Superset Migration Tool")
w.msgbox("Произошла ошибка при работе с дашбордами.")
self.logger.info("[INFO][select_dashboards][EXIT] Шаг 2 завершен.")
# END_FUNCTION_select_dashboards
self.logger.error("[ERROR][select_dashboards] %s", e, exc_info=True)
msgbox("Ошибка", "Не удалось получить список дашбордов.")
self.logger.info("[INFO][select_dashboards][EXIT] Шаг2 завершён.")
# [END_ENTITY]
# [ENTITY: Function('confirm_db_config_replacement')]
# CONTRACT:
# PURPOSE: Шаг 3. Управляет процессом подтверждения и настройки замены конфигураций БД.
# SPECIFICATION_LINK: func_confirm_db_config_replacement
# PRECONDITIONS: `self.from_c` и `self.to_c` инициализированы.
# POSTCONDITIONS: `self.db_config_replacement` содержит конфигурацию для замены или `None`.
def confirm_db_config_replacement(self):
"""Шаг 3: Подтверждение и настройка замены конфигурации БД."""
self.logger.info("[INFO][confirm_db_config_replacement][ENTER] Шаг 3/4: Замена конфигурации БД.")
w = Whiptail(title="Замена конфигурации БД", backtitle="Superset Migration Tool")
if w.yesno("Хотите ли вы заменить конфигурации баз данных в YAML-файлах?"):
(return_code, old_db_name) = w.inputbox("Введите имя заменяемой базы данных (например, db_dev):")
if return_code != 0:
# --------------------------------------------------------------
# [ENTITY: Method('confirm_db_config_replacement')]
# --------------------------------------------------------------
"""
:purpose: Запросить у пользователя, требуется ли заменить имена БД в YAMLфайлах.
:preconditions: None.
:postconditions: ``self.db_config_replacement`` либо ``None``, либо заполнен.
"""
def confirm_db_config_replacement(self) -> None:
if yesno("Замена БД", "Заменить конфигурацию БД в YAMLфайлах?"):
rc, old_name = inputbox("Замена БД", "Старое имя БД (например, db_dev):")
if rc != 0:
return
(return_code, new_db_name) = w.inputbox("Введите новое имя базы данных (например, db_prod):")
if return_code != 0:
rc, new_name = inputbox("Замена БД", "Новое имя БД (например, db_prod):")
if rc != 0:
return
self.db_config_replacement = {"old": {"database_name": old_db_name}, "new": {"database_name": new_db_name}}
self.logger.info(f"[INFO][confirm_db_config_replacement][STATE] Установлена ручная замена: {self.db_config_replacement}")
self.db_config_replacement = {
"old": {"database_name": old_name},
"new": {"database_name": new_name},
}
self.logger.info(
"[INFO][confirm_db_config_replacement] Replacement set: %s",
self.db_config_replacement,
)
else:
self.logger.info("[INFO][confirm_db_config_replacement][STATE] Замена конфигурации БД пропущена.")
self.logger.info("[INFO][confirm_db_config_replacement] Skipped.")
# [END_ENTITY]
self.logger.info("[INFO][confirm_db_config_replacement][EXIT] Шаг 3 завершен.")
# END_FUNCTION_confirm_db_config_replacement
# [ENTITY: Function('execute_migration')]
# CONTRACT:
# PURPOSE: Шаг 4. Выполняет фактическую миграцию выбранных дашбордов.
# SPECIFICATION_LINK: func_execute_migration
# PRECONDITIONS: Все предыдущие шаги (`select_environments`, `select_dashboards`) успешно выполнены.
# POSTCONDITIONS: Выбранные дашборды перенесены в целевое окружение.
def execute_migration(self):
"""Шаг 4: Выполнение миграции и обновления конфигураций."""
self.logger.info("[INFO][execute_migration][ENTER] Шаг 4/4: Выполнение миграции.")
w = Whiptail(title="Выполнение миграции", backtitle="Superset Migration Tool")
if not self.dashboards_to_migrate:
self.logger.warning("[WARN][execute_migration][STATE] Нет дашбордов для миграции.")
w.msgbox("Нет дашбордов для миграции. Завершение.")
# --------------------------------------------------------------
# [ENTITY: Method('_batch_delete_by_ids')]
# --------------------------------------------------------------
"""
:purpose: Удалить набор дашбордов по их ID единым запросом.
:preconditions:
- ``ids`` непустой список целых чисел.
:postconditions: Все указанные дашборды удалены (если они существовали).
:sideeffect: Делает HTTPзапрос ``DELETE /dashboard/?q=[ids]``.
"""
def _batch_delete_by_ids(self, ids: List[int]) -> None:
if not ids:
self.logger.debug("[DEBUG][_batch_delete_by_ids] Empty ID list nothing to delete.")
return
total_dashboards = len(self.dashboards_to_migrate)
self.logger.info(f"[INFO][execute_migration][STATE] Начало миграции {total_dashboards} дашбордов.")
with w.gauge("Выполняется миграция...", width=60, height=10) as gauge:
for i, dashboard in enumerate(self.dashboards_to_migrate):
self.logger.info("[INFO][_batch_delete_by_ids] Deleting dashboards IDs: %s", ids)
# Формируем параметр q в виде JSONмассива, как требует Superset.
q_param = json.dumps(ids)
response = self.to_c.network.request(
method="DELETE",
endpoint="/dashboard/",
params={"q": q_param},
)
# Superset обычно отвечает 200/204; проверяем поле ``result`` при наличии.
if isinstance(response, dict) and response.get("result", True) is False:
self.logger.warning("[WARN][_batch_delete_by_ids] Unexpected delete response: %s", response)
else:
self.logger.info("[INFO][_batch_delete_by_ids] Delete request completed.")
# [END_ENTITY]
# --------------------------------------------------------------
# [ENTITY: Method('execute_migration')]
# --------------------------------------------------------------
"""
:purpose: Выполнить экспорт‑импорт выбранных дашбордов, при необходимости
обновив YAMLфайлы. При ошибке импортов сохраняем slug, а потом
удаляем проблемные дашборды **по ID**, получив их через slug.
:preconditions:
- ``self.dashboards_to_migrate`` не пуст,
- ``self.from_c`` и ``self.to_c`` инициализированы.
:postconditions:
- Все успешные дашборды импортированы,
- Неудачные дашборды, если пользователь выбрал «удалять‑при‑ошибке»,
удалены и повторно импортированы.
:sideeffect: При включённом флаге ``enable_delete_on_failure`` производится
батч‑удаление и повторный импорт.
"""
def execute_migration(self) -> None:
if not self.dashboards_to_migrate:
self.logger.warning("[WARN][execute_migration] No dashboards to migrate.")
msgbox("Информация", "Нет дашбордов для миграции.")
return
total = len(self.dashboards_to_migrate)
self.logger.info("[INFO][execute_migration] Starting migration of %d dashboards.", total)
# Передаём режим клиенту‑назначению
self.to_c.delete_before_reimport = self.enable_delete_on_failure # type: ignore[attr-defined]
# -----------------------------------------------------------------
# 1⃣ Основной проход экспорт → импорт → сбор ошибок
# -----------------------------------------------------------------
with gauge("Миграция...", width=60, height=10) as g:
for i, dash in enumerate(self.dashboards_to_migrate):
dash_id = dash["id"]
dash_slug = dash.get("slug") # slug нужен для дальнейшего поиска
title = dash["dashboard_title"]
progress = int((i / total) * 100)
g.set_text(f"Миграция: {title} ({i + 1}/{total})")
g.set_percent(progress)
try:
dashboard_id = dashboard['id']
dashboard_title = dashboard['dashboard_title']
progress = int((i / total_dashboards) * 100)
self.logger.debug(f"[DEBUG][execute_migration][PROGRESS] {progress}% - Миграция: {dashboard_title}")
gauge.set_text(f"Миграция: {dashboard_title} ({i+1}/{total_dashboards})")
gauge.set_percent(progress)
# ------------------- Экспорт -------------------
exported_content, _ = self.from_c.export_dashboard(dash_id) # type: ignore[attr-defined]
self.logger.info(f"[INFO][execute_migration][PROGRESS] Миграция дашборда: {dashboard_title} (ID: {dashboard_id})")
# ------------------- Временный ZIP -------------------
with create_temp_file(
content=exported_content,
suffix=".zip",
logger=self.logger,
) as tmp_zip_path:
self.logger.debug("[DEBUG][temp_zip] Temporary ZIP at %s", tmp_zip_path)
# 1. Экспорт
exported_content, _ = self.from_c.export_dashboard(dashboard_id)
zip_path, unpacked_path = save_and_unpack_dashboard(exported_content, f"temp_export_{dashboard_id}", unpack=True)
self.logger.info(f"[INFO][execute_migration][STATE] Дашборд экспортирован и распакован в {unpacked_path}")
# ------------------- Распаковка во временный каталог -------------------
with create_temp_file(suffix=".dir", logger=self.logger) as tmp_unpack_dir:
self.logger.debug("[DEBUG][temp_dir] Temporary unpack dir: %s", tmp_unpack_dir)
# 2. Обновление YAML, если нужно
if self.db_config_replacement:
update_yamls(db_configs=[self.db_config_replacement], path=str(unpacked_path))
self.logger.info(f"[INFO][execute_migration][STATE] YAML-файлы обновлены.")
with zipfile.ZipFile(tmp_zip_path, "r") as zip_ref:
zip_ref.extractall(tmp_unpack_dir)
self.logger.info("[INFO][execute_migration] Export unpacked to %s", tmp_unpack_dir)
# 3. Упаковка и импорт
new_zip_path = f"migrated_dashboard_{dashboard_id}.zip"
create_dashboard_export(new_zip_path, [str(unpacked_path)])
self.to_c.import_dashboard(new_zip_path)
self.logger.info(f"[INFO][execute_migration][SUCCESS] Дашборд {dashboard_title} успешно импортирован.")
# ------------------- YAMLобновление (если нужно) -------------------
if self.db_config_replacement:
update_yamls(
db_configs=[self.db_config_replacement],
path=str(tmp_unpack_dir),
)
self.logger.info("[INFO][execute_migration] YAMLfiles updated.")
except Exception as e:
self.logger.error(f"[ERROR][execute_migration][FAILURE] Ошибка при миграции дашборда {dashboard_title}: {e}", exc_info=True)
error_msg = f"Не удалось смигрировать дашборд: {dashboard_title}.\n\nОшибка: {e}"
w.msgbox(error_msg, width=60, height=15)
gauge.set_percent(100)
# ------------------- Сборка нового ZIP -------------------
with create_temp_file(suffix=".zip", logger=self.logger) as tmp_new_zip:
create_dashboard_export(
zip_path=tmp_new_zip,
source_paths=[str(tmp_unpack_dir)],
)
self.logger.info("[INFO][execute_migration] Repacked to %s", tmp_new_zip)
self.logger.info("[INFO][execute_migration][STATE] Миграция завершена.")
w.msgbox("Миграция завершена!", width=40, height=8)
self.logger.info("[INFO][execute_migration][EXIT] Шаг 4 завершен.")
# END_FUNCTION_execute_migration
# ------------------- Импорт -------------------
self.to_c.import_dashboard(
file_name=tmp_new_zip,
dash_id=dash_id,
dash_slug=dash_slug,
) # type: ignore[attr-defined]
# END_CLASS_Migration
# Если импорт прошёл без исключений фиксируем успех
self.logger.info("[INFO][execute_migration][SUCCESS] Dashboard %s imported.", title)
# [MAIN_EXECUTION_BLOCK]
except Exception as exc:
# Сохраняем данные для повторного импорта после batchудаления
self.logger.error("[ERROR][execute_migration] %s", exc, exc_info=True)
self._failed_imports.append(
{
"slug": dash_slug,
"dash_id": dash_id,
"zip_content": exported_content,
}
)
msgbox("Ошибка", f"Не удалось мигрировать дашборд {title}.\n\n{exc}")
g.set_percent(100)
# -----------------------------------------------------------------
# 2⃣ Если возникли ошибки и пользователь согласился удалять удаляем и повторяем
# -----------------------------------------------------------------
if self.enable_delete_on_failure and self._failed_imports:
self.logger.info(
"[INFO][execute_migration] %d dashboards failed. Starting recovery procedure.",
len(self._failed_imports),
)
# ------------------- Получаем список дашбордов в целевом окружении -------------------
_, target_dashboards = self.to_c.get_dashboards() # type: ignore[attr-defined]
slug_to_id: Dict[str, int] = {
d["slug"]: d["id"] for d in target_dashboards if "slug" in d and "id" in d
}
# ------------------- Формируем список IDов для удаления -------------------
ids_to_delete: List[int] = []
for fail in self._failed_imports:
slug = fail["slug"]
if slug and slug in slug_to_id:
ids_to_delete.append(slug_to_id[slug])
else:
self.logger.warning(
"[WARN][execute_migration] Unable to map slug '%s' to ID on target.",
slug,
)
# ------------------- Batchудаление -------------------
self._batch_delete_by_ids(ids_to_delete)
# ------------------- Повторный импорт только для проблемных дашбордов -------------------
for fail in self._failed_imports:
dash_slug = fail["slug"]
dash_id = fail["dash_id"]
zip_content = fail["zip_content"]
# Один раз создаём временный ZIPфайл из сохранённого содержимого
with create_temp_file(
content=zip_content,
suffix=".zip",
logger=self.logger,
) as retry_zip_path:
self.logger.debug("[DEBUG][retry_zip] Retry ZIP for slug %s at %s", dash_slug, retry_zip_path)
# Пере‑импортируем **slug** передаётся, но клиент будет использовать ID
self.to_c.import_dashboard(
file_name=retry_zip_path,
dash_id=dash_id,
dash_slug=dash_slug,
) # type: ignore[attr-defined]
self.logger.info("[INFO][execute_migration][RECOVERED] Dashboard slug '%s' reimported.", dash_slug)
# -----------------------------------------------------------------
# 3⃣ Финальная отчётность
# -----------------------------------------------------------------
self.logger.info("[INFO][execute_migration] Migration finished.")
msgbox("Информация", "Миграция завершена!")
# [END_ENTITY]
# [END_ENTITY: Service('Migration')]
# --------------------------------------------------------------
# Точка входа
# --------------------------------------------------------------
if __name__ == "__main__":
migration = Migration()
migration.run()
# END_MAIN_EXECUTION_BLOCK
# END_MODULE_migration_script
Migration().run()
# [END_FILE migration_script.py]
# --------------------------------------------------------------