refactor, add db search
This commit is contained in:
@@ -1,14 +1,18 @@
|
||||
# <GRACE_MODULE id="migration_script" name="migration_script.py">
|
||||
# @SEMANTICS: migration, cli, superset, ui, logging, error-recovery, batch-delete
|
||||
# @PURPOSE: Предоставляет интерактивный CLI для миграции дашбордов Superset между окружениями с возможностью восстановления после ошибок.
|
||||
# @DEPENDS_ON: superset_tool.client -> Для взаимодействия с API Superset.
|
||||
# @DEPENDS_ON: superset_tool.utils -> Для инициализации клиентов, работы с файлами, UI и логирования.
|
||||
# [DEF:migration_script:Module]
|
||||
#
|
||||
# @SEMANTICS: migration, cli, superset, ui, logging, error-recovery, batch-delete
|
||||
# @PURPOSE: Предоставляет интерактивный CLI для миграции дашбордов Superset между окружениями с возможностью восстановления после ошибок.
|
||||
# @LAYER: App
|
||||
# @RELATION: DEPENDS_ON -> superset_tool.client
|
||||
# @RELATION: DEPENDS_ON -> superset_tool.utils
|
||||
# @PUBLIC_API: Migration
|
||||
|
||||
# <IMPORTS>
|
||||
# [SECTION: IMPORTS]
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import zipfile
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple, Dict
|
||||
from superset_tool.client import SupersetClient
|
||||
@@ -16,22 +20,20 @@ from superset_tool.utils.init_clients import setup_clients
|
||||
from superset_tool.utils.fileio import create_temp_file, update_yamls, create_dashboard_export
|
||||
from superset_tool.utils.whiptail_fallback import menu, checklist, yesno, msgbox, inputbox, gauge
|
||||
from superset_tool.utils.logger import SupersetLogger
|
||||
# </IMPORTS>
|
||||
# [/SECTION]
|
||||
|
||||
# --- Начало кода модуля ---
|
||||
|
||||
# <ANCHOR id="Migration" type="Class">
|
||||
# @PURPOSE: Инкапсулирует логику интерактивной миграции дашбордов с возможностью «удалить‑и‑перезаписать» при ошибке импорта.
|
||||
# @RELATION: CREATES_INSTANCE_OF -> SupersetLogger
|
||||
# @RELATION: USES -> SupersetClient
|
||||
# [DEF:Migration:Class]
|
||||
# @PURPOSE: Инкапсулирует логику интерактивной миграции дашбордов с возможностью «удалить‑и‑перезаписать» при ошибке импорта.
|
||||
# @RELATION: CREATES_INSTANCE_OF -> SupersetLogger
|
||||
# @RELATION: USES -> SupersetClient
|
||||
class Migration:
|
||||
"""
|
||||
Интерактивный процесс миграции дашбордов.
|
||||
"""
|
||||
# [DEF:Migration.__init__:Function]
|
||||
# @PURPOSE: Инициализирует сервис миграции, настраивает логгер и начальные состояния.
|
||||
# @POST: `self.logger` готов к использованию; `enable_delete_on_failure` = `False`.
|
||||
def __init__(self) -> None:
|
||||
# <ANCHOR id="Migration.__init__" type="Function">
|
||||
# @PURPOSE: Инициализирует сервис миграции, настраивает логгер и начальные состояния.
|
||||
# @POST: `self.logger` готов к использованию; `enable_delete_on_failure` = `False`.
|
||||
default_log_dir = Path.cwd() / "logs"
|
||||
self.logger = SupersetLogger(
|
||||
name="migration_script",
|
||||
@@ -46,17 +48,17 @@ class Migration:
|
||||
self.db_config_replacement: Optional[dict] = None
|
||||
self._failed_imports: List[dict] = []
|
||||
assert self.logger is not None, "Logger must be instantiated."
|
||||
# </ANCHOR id="Migration.__init__">
|
||||
# [/DEF:Migration.__init__]
|
||||
|
||||
# <ANCHOR id="Migration.run" type="Function">
|
||||
# @PURPOSE: Точка входа – последовательный запуск всех шагов миграции.
|
||||
# @PRE: Логгер готов.
|
||||
# @POST: Скрипт завершён, пользователю выведено сообщение.
|
||||
# @RELATION: CALLS -> self.ask_delete_on_failure
|
||||
# @RELATION: CALLS -> self.select_environments
|
||||
# @RELATION: CALLS -> self.select_dashboards
|
||||
# @RELATION: CALLS -> self.confirm_db_config_replacement
|
||||
# @RELATION: CALLS -> self.execute_migration
|
||||
# [DEF:Migration.run:Function]
|
||||
# @PURPOSE: Точка входа – последовательный запуск всех шагов миграции.
|
||||
# @PRE: Логгер готов.
|
||||
# @POST: Скрипт завершён, пользователю выведено сообщение.
|
||||
# @RELATION: CALLS -> self.ask_delete_on_failure
|
||||
# @RELATION: CALLS -> self.select_environments
|
||||
# @RELATION: CALLS -> self.select_dashboards
|
||||
# @RELATION: CALLS -> self.confirm_db_config_replacement
|
||||
# @RELATION: CALLS -> self.execute_migration
|
||||
def run(self) -> None:
|
||||
self.logger.info("[run][Entry] Запуск скрипта миграции.")
|
||||
self.ask_delete_on_failure()
|
||||
@@ -65,12 +67,12 @@ class Migration:
|
||||
self.confirm_db_config_replacement()
|
||||
self.execute_migration()
|
||||
self.logger.info("[run][Exit] Скрипт миграции завершён.")
|
||||
# </ANCHOR id="Migration.run">
|
||||
# [/DEF:Migration.run]
|
||||
|
||||
# <ANCHOR id="Migration.ask_delete_on_failure" type="Function">
|
||||
# @PURPOSE: Запрашивает у пользователя, следует ли удалять дашборд при ошибке импорта.
|
||||
# @POST: `self.enable_delete_on_failure` установлен.
|
||||
# @RELATION: CALLS -> yesno
|
||||
# [DEF:Migration.ask_delete_on_failure:Function]
|
||||
# @PURPOSE: Запрашивает у пользователя, следует ли удалять дашборд при ошибке импорта.
|
||||
# @POST: `self.enable_delete_on_failure` установлен.
|
||||
# @RELATION: CALLS -> yesno
|
||||
def ask_delete_on_failure(self) -> None:
|
||||
self.enable_delete_on_failure = yesno(
|
||||
"Поведение при ошибке импорта",
|
||||
@@ -80,14 +82,14 @@ class Migration:
|
||||
"[ask_delete_on_failure][State] Delete-on-failure = %s",
|
||||
self.enable_delete_on_failure,
|
||||
)
|
||||
# </ANCHOR id="Migration.ask_delete_on_failure">
|
||||
# [/DEF:Migration.ask_delete_on_failure]
|
||||
|
||||
# <ANCHOR id="Migration.select_environments" type="Function">
|
||||
# @PURPOSE: Позволяет пользователю выбрать исходное и целевое окружения Superset.
|
||||
# @PRE: `setup_clients` успешно инициализирует все клиенты.
|
||||
# @POST: `self.from_c` и `self.to_c` установлены.
|
||||
# @RELATION: CALLS -> setup_clients
|
||||
# @RELATION: CALLS -> menu
|
||||
# [DEF:Migration.select_environments:Function]
|
||||
# @PURPOSE: Позволяет пользователю выбрать исходное и целевое окружения Superset.
|
||||
# @PRE: `setup_clients` успешно инициализирует все клиенты.
|
||||
# @POST: `self.from_c` и `self.to_c` установлены.
|
||||
# @RELATION: CALLS -> setup_clients
|
||||
# @RELATION: CALLS -> menu
|
||||
def select_environments(self) -> None:
|
||||
self.logger.info("[select_environments][Entry] Шаг 1/5: Выбор окружений.")
|
||||
try:
|
||||
@@ -119,14 +121,14 @@ class Migration:
|
||||
self.to_c = all_clients[to_env_name]
|
||||
self.logger.info("[select_environments][State] to = %s", to_env_name)
|
||||
self.logger.info("[select_environments][Exit] Шаг 1 завершён.")
|
||||
# </ANCHOR id="Migration.select_environments">
|
||||
# [/DEF:Migration.select_environments]
|
||||
|
||||
# <ANCHOR id="Migration.select_dashboards" type="Function">
|
||||
# @PURPOSE: Позволяет пользователю выбрать набор дашбордов для миграции.
|
||||
# @PRE: `self.from_c` инициализирован.
|
||||
# @POST: `self.dashboards_to_migrate` заполнен.
|
||||
# @RELATION: CALLS -> self.from_c.get_dashboards
|
||||
# @RELATION: CALLS -> checklist
|
||||
# [DEF:Migration.select_dashboards:Function]
|
||||
# @PURPOSE: Позволяет пользователю выбрать набор дашбордов для миграции.
|
||||
# @PRE: `self.from_c` инициализирован.
|
||||
# @POST: `self.dashboards_to_migrate` заполнен.
|
||||
# @RELATION: CALLS -> self.from_c.get_dashboards
|
||||
# @RELATION: CALLS -> checklist
|
||||
def select_dashboards(self) -> None:
|
||||
self.logger.info("[select_dashboards][Entry] Шаг 2/5: Выбор дашбордов.")
|
||||
try:
|
||||
@@ -135,11 +137,20 @@ class Migration:
|
||||
self.logger.warning("[select_dashboards][State] No dashboards.")
|
||||
msgbox("Информация", "В исходном окружении нет дашбордов.")
|
||||
return
|
||||
|
||||
options = [("ALL", "Все дашборды")] + [
|
||||
(str(d["id"]), d["dashboard_title"]) for d in all_dashboards
|
||||
|
||||
rc, regex = inputbox("Поиск", "Введите регулярное выражение для поиска дашбордов:")
|
||||
if rc != 0:
|
||||
return
|
||||
# Ensure regex is a string and perform case‑insensitive search
|
||||
regex_str = str(regex)
|
||||
filtered_dashboards = [
|
||||
d for d in all_dashboards if re.search(regex_str, d["dashboard_title"], re.IGNORECASE)
|
||||
]
|
||||
|
||||
|
||||
options = [("ALL", "Все дашборды")] + [
|
||||
(str(d["id"]), d["dashboard_title"]) for d in filtered_dashboards
|
||||
]
|
||||
|
||||
rc, selected = checklist(
|
||||
title="Выбор дашбордов",
|
||||
prompt="Отметьте нужные дашборды (введите номера):",
|
||||
@@ -147,14 +158,14 @@ class Migration:
|
||||
)
|
||||
if rc != 0:
|
||||
return
|
||||
|
||||
|
||||
if "ALL" in selected:
|
||||
self.dashboards_to_migrate = list(all_dashboards)
|
||||
self.dashboards_to_migrate = filtered_dashboards
|
||||
else:
|
||||
self.dashboards_to_migrate = [
|
||||
d for d in all_dashboards if str(d["id"]) in selected
|
||||
d for d in filtered_dashboards if str(d["id"]) in selected
|
||||
]
|
||||
|
||||
|
||||
self.logger.info(
|
||||
"[select_dashboards][State] Выбрано %d дашбордов.",
|
||||
len(self.dashboards_to_migrate),
|
||||
@@ -163,32 +174,106 @@ class Migration:
|
||||
self.logger.error("[select_dashboards][Failure] %s", e, exc_info=True)
|
||||
msgbox("Ошибка", "Не удалось получить список дашбордов.")
|
||||
self.logger.info("[select_dashboards][Exit] Шаг 2 завершён.")
|
||||
# </ANCHOR id="Migration.select_dashboards">
|
||||
# [/DEF:Migration.select_dashboards]
|
||||
|
||||
# <ANCHOR id="Migration.confirm_db_config_replacement" type="Function">
|
||||
# @PURPOSE: Запрашивает у пользователя, требуется ли заменить имена БД в YAML-файлах.
|
||||
# @POST: `self.db_config_replacement` либо `None`, либо заполнен.
|
||||
# @RELATION: CALLS -> yesno
|
||||
# @RELATION: CALLS -> inputbox
|
||||
# [DEF:Migration.confirm_db_config_replacement:Function]
|
||||
# @PURPOSE: Запрашивает у пользователя, требуется ли заменить имена БД в YAML-файлах.
|
||||
# @POST: `self.db_config_replacement` либо `None`, либо заполнен.
|
||||
# @RELATION: CALLS -> yesno
|
||||
# @RELATION: CALLS -> self._select_databases
|
||||
def confirm_db_config_replacement(self) -> None:
|
||||
if yesno("Замена БД", "Заменить конфигурацию БД в YAML‑файлах?"):
|
||||
rc, old_name = inputbox("Замена БД", "Старое имя БД (например, db_dev):")
|
||||
if rc != 0: return
|
||||
rc, new_name = inputbox("Замена БД", "Новое имя БД (например, db_prod):")
|
||||
if rc != 0: return
|
||||
|
||||
self.db_config_replacement = { "old": {"database_name": old_name}, "new": {"database_name": new_name} }
|
||||
old_db, new_db = self._select_databases()
|
||||
if not old_db or not new_db:
|
||||
self.logger.info("[confirm_db_config_replacement][State] Selection cancelled.")
|
||||
return
|
||||
|
||||
self.db_config_replacement = { "old": {"database_name": old_db["database_name"]}, "new": {"database_name": new_db["database_name"]} }
|
||||
self.logger.info("[confirm_db_config_replacement][State] Replacement set: %s", self.db_config_replacement)
|
||||
else:
|
||||
self.logger.info("[confirm_db_config_replacement][State] Skipped.")
|
||||
# </ANCHOR id="Migration.confirm_db_config_replacement">
|
||||
# [/DEF:Migration.confirm_db_config_replacement]
|
||||
|
||||
# <ANCHOR id="Migration._batch_delete_by_ids" type="Function">
|
||||
# @PURPOSE: Удаляет набор дашбордов по их ID единым запросом.
|
||||
# @PRE: `ids` – непустой список целых чисел.
|
||||
# @POST: Все указанные дашборды удалены (если они существовали).
|
||||
# @PARAM: ids: List[int] - Список ID дашбордов для удаления.
|
||||
# @RELATION: CALLS -> self.to_c.network.request
|
||||
# [DEF:Migration._select_databases:Function]
|
||||
# @PURPOSE: Позволяет пользователю выбрать исходную и целевую БД через API.
|
||||
# @POST: Возвращает кортеж (старая БД, новая БД) или (None, None) при отмене.
|
||||
# @RELATION: CALLS -> self.from_c.get_databases
|
||||
# @RELATION: CALLS -> self.to_c.get_databases
|
||||
# @RELATION: CALLS -> self.from_c.get_database
|
||||
# @RELATION: CALLS -> self.to_c.get_database
|
||||
# @RELATION: CALLS -> menu
|
||||
def _select_databases(self) -> tuple:
|
||||
self.logger.info("[_select_databases][Entry] Selecting databases from both environments.")
|
||||
|
||||
# Получаем список БД из обоих окружений
|
||||
try:
|
||||
_, from_dbs = self.from_c.get_databases()
|
||||
_, to_dbs = self.to_c.get_databases()
|
||||
except Exception as e:
|
||||
self.logger.error("[_select_databases][Failure] Failed to fetch databases: %s", e)
|
||||
msgbox("Ошибка", "Не удалось получить список баз данных.")
|
||||
return None, None
|
||||
|
||||
# Формируем список для выбора
|
||||
# По Swagger документации, в ответе API поле называется "database_name"
|
||||
from_choices = []
|
||||
for db in from_dbs:
|
||||
db_name = db.get("database_name", "Без имени")
|
||||
from_choices.append((str(db["id"]), db_name))
|
||||
|
||||
to_choices = []
|
||||
for db in to_dbs:
|
||||
db_name = db.get("database_name", "Без имени")
|
||||
to_choices.append((str(db["id"]), db_name))
|
||||
|
||||
# Показываем список БД для исходного окружения
|
||||
rc, from_sel = menu(
|
||||
title="Выбор исходной БД",
|
||||
prompt="Выберите исходную БД:",
|
||||
choices=[f"{name} (ID: {id})" for id, name in from_choices]
|
||||
)
|
||||
if rc != 0:
|
||||
return None, None
|
||||
|
||||
# Определяем выбранную БД
|
||||
from_db_id = from_choices[[choice[1] for choice in from_choices].index(from_sel.split(" (ID: ")[0])]
|
||||
# Получаем полную информацию о выбранной БД из исходного окружения
|
||||
try:
|
||||
from_db = self.from_c.get_database(int(from_db_id))
|
||||
except Exception as e:
|
||||
self.logger.error("[_select_databases][Failure] Failed to fetch database details: %s", e)
|
||||
msgbox("Ошибка", "Не удалось получить информацию о выбранной базе данных.")
|
||||
return None, None
|
||||
|
||||
# Показываем список БД для целевого окружения
|
||||
rc, to_sel = menu(
|
||||
title="Выбор целевой БД",
|
||||
prompt="Выберите целевую БД:",
|
||||
choices=[f"{name} (ID: {id})" for id, name in to_choices]
|
||||
)
|
||||
if rc != 0:
|
||||
return None, None
|
||||
|
||||
# Определяем выбранную БД
|
||||
to_db_id = to_choices[[choice[1] for choice in to_choices].index(to_sel.split(" (ID: ")[0])]
|
||||
# Получаем полную информацию о выбранной БД из целевого окружения
|
||||
try:
|
||||
to_db = self.to_c.get_database(int(to_db_id))
|
||||
except Exception as e:
|
||||
self.logger.error("[_select_databases][Failure] Failed to fetch database details: %s", e)
|
||||
msgbox("Ошибка", "Не удалось получить информацию о выбранной базе данных.")
|
||||
return None, None
|
||||
|
||||
self.logger.info("[_select_databases][Exit] Selected databases: %s -> %s", from_db.get("database_name", "Без имени"), to_db.get("database_name", "Без имени"))
|
||||
return from_db, to_db
|
||||
# [/DEF:Migration._select_databases]
|
||||
|
||||
# [DEF:Migration._batch_delete_by_ids:Function]
|
||||
# @PURPOSE: Удаляет набор дашбордов по их ID единым запросом.
|
||||
# @PRE: `ids` – непустой список целых чисел.
|
||||
# @POST: Все указанные дашборды удалены (если они существовали).
|
||||
# @RELATION: CALLS -> self.to_c.network.request
|
||||
# @PARAM: ids (List[int]) - Список ID дашбордов для удаления.
|
||||
def _batch_delete_by_ids(self, ids: List[int]) -> None:
|
||||
if not ids:
|
||||
self.logger.debug("[_batch_delete_by_ids][Skip] Empty ID list – nothing to delete.")
|
||||
@@ -202,18 +287,18 @@ class Migration:
|
||||
self.logger.warning("[_batch_delete_by_ids][Warning] Unexpected delete response: %s", response)
|
||||
else:
|
||||
self.logger.info("[_batch_delete_by_ids][Success] Delete request completed.")
|
||||
# </ANCHOR id="Migration._batch_delete_by_ids">
|
||||
# [/DEF:Migration._batch_delete_by_ids]
|
||||
|
||||
# <ANCHOR id="Migration.execute_migration" type="Function">
|
||||
# @PURPOSE: Выполняет экспорт-импорт дашбордов, обрабатывает ошибки и, при необходимости, выполняет процедуру восстановления.
|
||||
# @PRE: `self.dashboards_to_migrate` не пуст; `self.from_c` и `self.to_c` инициализированы.
|
||||
# @POST: Успешные дашборды импортированы; неудачные - восстановлены или залогированы.
|
||||
# @RELATION: CALLS -> self.from_c.export_dashboard
|
||||
# @RELATION: CALLS -> create_temp_file
|
||||
# @RELATION: CALLS -> update_yamls
|
||||
# @RELATION: CALLS -> create_dashboard_export
|
||||
# @RELATION: CALLS -> self.to_c.import_dashboard
|
||||
# @RELATION: CALLS -> self._batch_delete_by_ids
|
||||
# [DEF:Migration.execute_migration:Function]
|
||||
# @PURPOSE: Выполняет экспорт-импорт дашбордов, обрабатывает ошибки и, при необходимости, выполняет процедуру восстановления.
|
||||
# @PRE: `self.dashboards_to_migrate` не пуст; `self.from_c` и `self.to_c` инициализированы.
|
||||
# @POST: Успешные дашборды импортированы; неудачные - восстановлены или залогированы.
|
||||
# @RELATION: CALLS -> self.from_c.export_dashboard
|
||||
# @RELATION: CALLS -> create_temp_file
|
||||
# @RELATION: CALLS -> update_yamls
|
||||
# @RELATION: CALLS -> create_dashboard_export
|
||||
# @RELATION: CALLS -> self.to_c.import_dashboard
|
||||
# @RELATION: CALLS -> self._batch_delete_by_ids
|
||||
def execute_migration(self) -> None:
|
||||
if not self.dashboards_to_migrate:
|
||||
self.logger.warning("[execute_migration][Skip] No dashboards to migrate.")
|
||||
@@ -269,13 +354,11 @@ class Migration:
|
||||
|
||||
self.logger.info("[execute_migration][Exit] Migration finished.")
|
||||
msgbox("Информация", "Миграция завершена!")
|
||||
# </ANCHOR id="Migration.execute_migration">
|
||||
# [/DEF:Migration.execute_migration]
|
||||
|
||||
# </ANCHOR id="Migration">
|
||||
|
||||
# --- Конец кода модуля ---
|
||||
# [/DEF:Migration]
|
||||
|
||||
if __name__ == "__main__":
|
||||
Migration().run()
|
||||
|
||||
# </GRACE_MODULE id="migration_script">
|
||||
# [/DEF:migration_script]
|
||||
|
||||
Reference in New Issue
Block a user