From e049a302d3e58ef807e04cd4b4b856449f327fa5 Mon Sep 17 00:00:00 2001 From: y9938 Date: Sat, 18 Oct 2025 00:52:08 +0300 Subject: [PATCH] Initial commit --- .env.example | 15 ++ .gitignore | 9 + LICENSE | 18 ++ README.md | 41 ++++ assets/full_migration.png | Bin 0 -> 12155 bytes assets/help.png | Bin 0 -> 32042 bytes assets/interactive.png | Bin 0 -> 44582 bytes lib/__init__.py | 0 lib/base_migrator.py | 78 ++++++++ lib/interactive.py | 90 +++++++++ lib/kanboard_api.py | 46 +++++ lib/projects.py | 408 ++++++++++++++++++++++++++++++++++++++ lib/tags.py | 85 ++++++++ lib/tasks-word.py | 227 +++++++++++++++++++++ lib/tasks.py | 274 +++++++++++++++++++++++++ lib/users.py | 79 ++++++++ main.py | 167 ++++++++++++++++ requirements.txt | 4 + 18 files changed, 1541 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 assets/full_migration.png create mode 100644 assets/help.png create mode 100644 assets/interactive.png create mode 100644 lib/__init__.py create mode 100644 lib/base_migrator.py create mode 100644 lib/interactive.py create mode 100755 lib/kanboard_api.py create mode 100644 lib/projects.py create mode 100644 lib/tags.py create mode 100644 lib/tasks-word.py create mode 100644 lib/tasks.py create mode 100644 lib/users.py create mode 100755 main.py create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bc23fa0 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Source Server +SOURCE_URL="https://example.com/jsonrpc.php" +SOURCE_USER="jsonrpc" +SOURCE_TOKEN="SECRET" + +# Target Server +TARGET_URL="https://example.com/jsonrpc.php" +TARGET_USER="jsonrpc" +TARGET_TOKEN="SECRET" + +# Optional +TEMP_USER_PASSWORD= +# Если не задать TEMP_USER_PASSWORD, который всем пользователям поставит +# одинаковый пароль, то автоматически пароль будет равен username +# например user62 пароль: user62 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a7a0acd --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +**/__pycache__/** +venv/ +.venv/ +.env + +user_mapping.json +project_mapping.json +tag_mapping.json +column_mapping.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e9d9de0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2025 y9938 + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f4bf018 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Kanboard API + +> Взаимодействие с API, миграция с одного instance на другой. + +> Для вас миграция может быть не идеальная. + +> По документации https://docs.kanboard.org/v1/api/ + +Код затрагивает работу с API Reference по документации: + +- Column API Procedures +- Project API Procedures +- Project File API Procedures +- Project Metadata API Procedures +- Project Permission API Procedures +- Subtask API Procedures +- Tags API Procedures +- Task API Procedures +- Task File API Procedures +- Task Metadata API Procedures +- User API Procedures + +## Начальные шаги + +```bash +cp .env.example .env +chmod +x main.py +``` +Можно запустить main.py и другим способом без `uv`. + +--- + +### Демонстрация + +![help](assets/help.png) + +![run full migration](assets/full_migration.png) +- Без указания COMMAND выполняется полная миграция для users, projects, tags и tasks + +![interactive mode](assets/interactive.png) + diff --git a/assets/full_migration.png b/assets/full_migration.png new file mode 100644 index 0000000000000000000000000000000000000000..5b53ee3f0a6b1518c41a6f537e7aab35249d076d GIT binary patch literal 12155 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

GY#Pnscl9T5s#1x*@Uz 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 literal 0 HcmV?d00001 diff --git a/assets/interactive.png b/assets/interactive.png new file mode 100644 index 0000000000000000000000000000000000000000..c90a8f99b56585e90289938462d355f4013e2a8a GIT binary patch literal 44582 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

J0{3xlx!lwc2?NGN7H|& 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 Dict[int, int]: + """Загружает маппинг из JSON файла.""" + if os.path.exists(filename): + with open(filename, "r", encoding="utf-8") as f: + data = json.load(f) + return {int(k): int(v) for k, v in data.items()} + return {} + + def _load_nested_mapping(self, filename: str) -> Dict[Any, Any]: + """Загрузка вложенного маппинга из JSON-файла (для колонок).""" + if os.path.exists(filename): + with open(filename, 'r') as f: + data = json.load(f) + + # Для вложенного маппинга колонок: {project_id: {src_col_id: target_col_id}} + result = {} + for project_id_str, column_map in data.items(): + project_id = int(project_id_str) + result[project_id] = {} + for src_col_str, target_col_id in column_map.items(): + result[project_id][int(src_col_str)] = int(target_col_id) + return result + return {} + + def _save_mapping(self, filename: str, mapping: Dict[int, int]): + """Сохраняет маппинг в JSON файл.""" + with open(filename, "w", encoding="utf-8") as f: + json.dump(mapping, f, ensure_ascii=False, indent=2) + + def _save_nested_mapping(self, filename: str, mapping: Dict[Any, Any]): + """Сохранение вложенного маппинга в JSON-файл.""" + # Преобразуем в формат, который можно сериализовать в JSON + serializable_mapping = {} + for project_id, column_map in mapping.items(): + serializable_mapping[str(project_id)] = {str(k): v for k, v in column_map.items()} + + with open(filename, 'w') as f: + json.dump(serializable_mapping, f, indent=2) + + def _delete_mapping_file(self, filename: str) -> bool: + """ + Удаляет файл маппинга, если он существует. + + Args: + filename: Путь к файлу маппинга. + Returns: + bool: True, если файл удален или не существует, False в случае ошибки. + """ + try: + if filename and os.path.exists(filename): + os.remove(filename) + print(f"Файл маппинга {filename} удалён") + return True + else: + print("Файл маппинга не найден или не задан") + return True + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при удалении файла маппинга: {e}") + return False + + + @abstractmethod + def migrate(self): + """Абстрактный метод для миграции данных.""" + pass + + @abstractmethod + def delete_all(self): + """Абстрактный метод для удаления всех данных.""" + pass diff --git a/lib/interactive.py b/lib/interactive.py new file mode 100644 index 0000000..d3eb81b --- /dev/null +++ b/lib/interactive.py @@ -0,0 +1,90 @@ +import json +from lib.kanboard_api import KanboardAPI + + +class KanboardInteractive: + """Позволяет выполнять произвольные JSON-RPC запросы через KanboardAPI.""" + + def __init__(self, api: KanboardAPI): + self.api = api + + def parse_simple_syntax(self, line): + """Парсит упрощенный синтаксис: метод param1=value1 param2=value2""" + parts = line.strip().split() + if not parts: + return None, None + + method = parts[0] + params = {} + + for part in parts[1:]: + # Парсим key=value + if '=' in part: + key, value = part.split('=', 1) + # Пробуем преобразовать значение в число, если возможно + try: + value = int(value) + except ValueError: + try: + value = float(value) + except ValueError: + # Оставляем как строку + pass + params[key] = value + else: + # Если нет '=', считаем позиционным параметром с числовым ключом + try: + value = int(part) + except ValueError: + value = part + params[str(len(params))] = value + + return method, params + + def convert_to_array_params(self, params): + """Конвертирует словарь параметров в массив, если ключи числовые""" + if all(key.isdigit() for key in params.keys()): + # Сортируем по числовым ключам и возвращаем массив значений + return [params[str(i)] for i in range(len(params))] + return params + + def run(self): + print("Введите метод Kanboard API и параметры.") + print("Форматы:") + print(' JSON: {"method": "getProjectById", "params": {"project_id": 1}}') + print(' Простой: getProjectUsers project_id=32') + print(' Позиционный: getProjectUserRole 32 8') + print("Для выхода введите 'exit'") + + while True: + line = input("Kanboard> ").strip() + if line.lower() in ("exit", "quit"): + break + if not line: + continue + + # Определяем формат ввода + if line.startswith('{'): + # JSON формат + try: + data = json.loads(line) + method = data.get("method") + params = data.get("params", {}) + except json.JSONDecodeError as e: + print(f"Ошибка JSON: {e}") + continue + else: + # Упрощенный формат + method, params = self.parse_simple_syntax(line) + if not method: + print("Не указан метод.") + continue + # Для методов, которые ожидают массив параметров, конвертируем + params = self.convert_to_array_params(params) + + try: + result = self.api.call(method, params) + print(json.dumps(result, ensure_ascii=False, indent=2)) + except Exception as e: + print(f"Ошибка API: {e}") + diff --git a/lib/kanboard_api.py b/lib/kanboard_api.py new file mode 100755 index 0000000..0b0a12c --- /dev/null +++ b/lib/kanboard_api.py @@ -0,0 +1,46 @@ +import json +import requests +from typing import Any, Dict, Tuple + +class KanboardAPI: + """Базовый класс для работы с Kanboard через JSON-RPC.""" + + def __init__(self, url: str, user: str, token: str): + self.url: str = url + self.auth: Tuple[str, str] = (user, token) + self.request_id: int = 0 + + def call(self, method: str, params: Any = None) -> Any: + """Выполнить JSON-RPC запрос с кодировкой UTF-8.""" + self.request_id += 1 + payload: Dict[str, Any] = { + "jsonrpc": "2.0", + "method": method, + "id": self.request_id, + "params": params or {} + } + + try: + json_data: bytes = json.dumps(payload, ensure_ascii=False).encode('utf-8') + except Exception as e: + raise ValueError(f"Ошибка кодирования JSON: {e}") + + headers = {'Content-Type': 'application/json; charset=utf-8'} + + try: + response = requests.post(self.url, data=json_data, auth=self.auth, headers=headers) + response.raise_for_status() + response.encoding = 'utf-8' + except requests.exceptions.RequestException as e: + raise ConnectionError(f"Сетевая ошибка при вызове API {method}: {e}") + + try: + result = response.json() + except json.JSONDecodeError as e: + raise ValueError(f"Ошибка декодирования JSON: {e}. Ответ: {response.text[:100]}...") + + if 'error' in result: + raise Exception(f"API Error: {result['error']}") + + return result.get('result') + diff --git a/lib/projects.py b/lib/projects.py new file mode 100644 index 0000000..3789a29 --- /dev/null +++ b/lib/projects.py @@ -0,0 +1,408 @@ +from typing import Any, Dict, Optional, List +from lib.kanboard_api import KanboardAPI +from lib.base_migrator import BaseMigrator + + +class ProjectsMigrator(BaseMigrator): + """Миграция и удаление проектов в Kanboard.""" + + def __init__( + self, + source_api: KanboardAPI, + target_api: KanboardAPI, + project_mapping_file: str = "project_mapping.json", + user_mapping_file: str = "user_mapping.json", + column_mapping_file: str = "column_mapping.json" + ): + self.source_api = source_api + self.target_api = target_api + self.project_mapping_file = project_mapping_file + self.user_mapping_file = user_mapping_file + self.column_mapping_file = column_mapping_file + self.project_mapping = self._load_mapping(self.project_mapping_file) + self.user_mapping = self._load_mapping(self.user_mapping_file) + self.column_mapping = self._load_nested_mapping(self.column_mapping_file) + + # === Helpers === + def _create_or_find_project(self, src_proj: Dict[str, Any]) -> Optional[int]: + name = src_proj.get("name") or "" + identifier = src_proj.get("identifier") or "" + description = src_proj.get("description") or "" + owner_id = src_proj.get("owner_id") + email = src_proj.get("email") + + target_proj = None + found = False + if identifier: + try: + target_proj = self.target_api.call("getProjectByIdentifier", {"identifier": identifier}) + except Exception: + target_proj = None + if not target_proj and name: + try: + target_proj = self.target_api.call("getProjectByName", {"name": name}) + except Exception: + target_proj = None + if target_proj: + try: + found = True + return int(target_proj["id"]), found + except Exception: + return None, found + + create_params: Dict[str, Any] = {"name": name} + if description is not None: + create_params["description"] = description + if identifier: + create_params["identifier"] = identifier + if email: + create_params["email"] = email + + if owner_id: + try: + mapped_owner = self.user_mapping.get(int(owner_id)) + if mapped_owner: + create_params["owner_id"] = mapped_owner + except Exception: + pass + + try: + new_id = self.target_api.call("createProject", create_params) + if new_id: + return int(new_id), False + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при createProject '{name}': {e}") + return None, found + + + def _migrate_columns(self, src_project_id: int, target_project_id: int): + """Миграция колонок - создаем такие же как в source и сохраняем маппинг.""" + try: + src_columns = self.source_api.call("getColumns", [src_project_id]) or [] + except Exception as e: + print(f"[{type(e).__name__}] Не удалось получить колонки {src_project_id}: {e}") + return + + try: + target_columns = self.target_api.call("getColumns", [target_project_id]) or [] + except Exception as e: + print(f"[{type(e).__name__}] Не удалось получить колонки target-проекта {target_project_id}: {e}") + target_columns = [] + + # Инициализируем маппинг колонок для этого проекта + if src_project_id not in self.column_mapping: + self.column_mapping[src_project_id] = {} + + project_column_mapping = self.column_mapping[src_project_id] + created_columns: List[int] = [] + existing_titles = {col.get("title", "").lower(): col for col in target_columns} + + for src_col in sorted(src_columns, key=lambda c: int(c.get("position", 0))): + src_col_id = int(src_col.get("id")) + title = src_col.get("title", "") + task_limit = None + try: + tl = int(src_col.get("task_limit", 0)) + if tl > 0: + task_limit = tl + except Exception: + pass + description = src_col.get("description") + + # Проверяем, есть ли колонка с таким названием + if title.lower() in existing_titles: + # Колонка уже существует, находим ее ID и сохраняем маппинг + target_col = existing_titles[title.lower()] + target_col_id = int(target_col.get("id")) + project_column_mapping[src_col_id] = target_col_id + created_columns.append(target_col_id) + print(f"Колонка '{title}' уже существует ({src_col_id} -> {target_col_id})") + continue + + # Создаем новую колонку + params = {"project_id": target_project_id, "title": title} + if task_limit is not None: + params["task_limit"] = task_limit + if description: + params["description"] = description + + try: + new_col_id = self.target_api.call("addColumn", params) + if new_col_id: + new_col_id_int = int(new_col_id) + created_columns.append(new_col_id_int) + # Сохраняем маппинг новой колонки + project_column_mapping[src_col_id] = new_col_id_int + print(f"Добавлена колонка '{title}' ({src_col_id} -> {new_col_id_int})") + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при addColumn '{title}': {e}") + + # Обновляем позиции колонок + for position, col_id in enumerate(created_columns, start=1): + try: + ok = self.target_api.call("changeColumnPosition", [target_project_id, col_id, position]) + if not ok: + print(f"Не удалось установить позицию {position} для колонки {col_id}") + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при changeColumnPosition {col_id}: {e}") + + # Сохраняем маппинг колонок после обработки всех колонок проекта + self._save_nested_mapping(self.column_mapping_file, self.column_mapping) + + def _migrate_columns(self, src_project_id: int, target_project_id: int): + """Миграция колонок - создаем такие же как в source и сохраняем маппинг.""" + try: + src_columns = self.source_api.call("getColumns", [src_project_id]) or [] + except Exception as e: + print(f"[{type(e).__name__}] Не удалось получить колонки {src_project_id}: {e}") + return + + try: + target_columns = self.target_api.call("getColumns", [target_project_id]) or [] + except Exception as e: + print(f"[{type(e).__name__}] Не удалось получить колонки target-проекта {target_project_id}: {e}") + target_columns = [] + + # Инициализируем маппинг колонок для этого проекта + if str(src_project_id) not in self.column_mapping: + self.column_mapping[str(src_project_id)] = {} + + project_column_mapping = self.column_mapping[str(src_project_id)] + created_columns: List[int] = [] + existing_titles = {col.get("title", "").lower(): col for col in target_columns} + + for src_col in sorted(src_columns, key=lambda c: int(c.get("position", 0))): + src_col_id = src_col.get("id") + title = src_col.get("title", "") + task_limit = None + try: + tl = int(src_col.get("task_limit", 0)) + if tl > 0: + task_limit = tl + except Exception: + pass + description = src_col.get("description") + + # Проверяем, есть ли колонка с таким названием + if title.lower() in existing_titles: + # Колонка уже существует, находим ее ID и сохраняем маппинг + target_col = existing_titles[title.lower()] + target_col_id = target_col.get("id") + project_column_mapping[str(src_col_id)] = target_col_id + created_columns.append(int(target_col_id)) + print(f"Колонка '{title}' уже существует ({src_col_id} -> {target_col_id})") + continue + + # Создаем новую колонку + params = {"project_id": target_project_id, "title": title} + if task_limit is not None: + params["task_limit"] = task_limit + if description: + params["description"] = description + + try: + new_col_id = self.target_api.call("addColumn", params) + if new_col_id: + new_col_id_int = int(new_col_id) + created_columns.append(new_col_id_int) + # Сохраняем маппинг новой колонки + project_column_mapping[str(src_col_id)] = new_col_id_int + print(f"Добавлена колонка '{title}' ({src_col_id} -> {new_col_id_int})") + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при addColumn '{title}': {e}") + + # Обновляем позиции колонок + for position, col_id in enumerate(created_columns, start=1): + try: + ok = self.target_api.call("changeColumnPosition", [target_project_id, col_id, position]) + if not ok: + print(f"Не удалось установить позицию {position} для колонки {col_id}") + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при changeColumnPosition {col_id}: {e}") + + # Сохраняем маппинг колонок после обработки всех колонок проекта + self._save_mapping(self.column_mapping_file, self.column_mapping) + def _migrate_project_users(self, src_project_id: int, target_project_id: int): + try: + src_members = self.source_api.call("getProjectUsers", [src_project_id]) or {} + except Exception as e: + print(f"[{type(e).__name__}] Не удалось получить пользователей проекта {src_project_id}: {e}") + src_members = {} + + for src_uid_str, username in list(src_members.items()): + try: + src_uid = int(src_uid_str) + except Exception: + continue + try: + role = self.source_api.call("getProjectUserRole", [src_project_id, src_uid]) + except Exception: + role = None + + target_uid = self.user_mapping.get(src_uid) + if not target_uid: + print(f" Пропускаем пользователя '{username}' ({src_uid}) — нет в user_mapping") + continue + + try: + ok = self.target_api.call( + "addProjectUser", + [target_project_id, target_uid, role] if role else [target_project_id, target_uid] + ) + if ok: + print(f" Добавлен пользователь '{username}' -> target_id {target_uid} роль='{role}'") + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при addProjectUser '{username}': {e}") + + def _migrate_project_files(self, src_project_id: int, target_project_id: int): + """Миграция файлов проекта.""" + try: + files = self.source_api.call("getAllProjectFiles", {"project_id": src_project_id}) or [] + except Exception as e: + print(f"[{type(e).__name__}] Не удалось получить файлы проекта {src_project_id}: {e}") + return + + for f in files: + file_id = int(f.get("id")) + filename = f.get("name") + try: + data_b64 = self.source_api.call("downloadProjectFile", [src_project_id, file_id]) + if not data_b64: + print(f" Пропущен файл '{filename}' — пустой контент") + continue + new_id = self.target_api.call("createProjectFile", [target_project_id, filename, data_b64]) + if new_id: + print(f" Файл '{filename}' перенесён ({file_id} -> {new_id})") + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при переносе файла '{filename}': {e}") + + def _apply_project_settings(self, src_project_id: int, target_project_id: int): + try: + src_info = self.source_api.call("getProjectById", {"project_id": src_project_id}) or {} + except Exception as e: + print(f"[{type(e).__name__}] Не удалось получить info проекта {src_project_id}: {e}") + return + + try: + if str(src_info.get("is_active", "1")) == "1": + self.target_api.call("enableProject", [target_project_id]) + else: + self.target_api.call("disableProject", [target_project_id]) + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при установке is_active {target_project_id}: {e}") + + try: + if str(src_info.get("is_public", "0")) == "1": + self.target_api.call("enableProjectPublicAccess", [target_project_id]) + else: + self.target_api.call("disableProjectPublicAccess", [target_project_id]) + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при установке public access {target_project_id}: {e}") + + updatable = {} + for fld in ( + "start_date", "end_date", "priority_default", + "priority_start", "priority_end", "email", + "identifier", "description", "name" + ): + if fld in src_info and src_info.get(fld) is not None: + updatable[fld] = src_info.get(fld) + + if updatable: + updatable["project_id"] = target_project_id + try: + ok = self.target_api.call("updateProject", updatable) + if not ok: + print(f" Не удалось применить дополнительные настройки для проекта {target_project_id}") + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при updateProject {target_project_id}: {e}") + + def _delete_project_files(self, project_id: int): + """Удаление всех файлов проекта перед удалением.""" + try: + files = self.target_api.call("getAllProjectFiles", {"project_id": project_id}) or [] + if files: + self.target_api.call("removeAllProjectFiles", {"project_id": project_id}) + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при удалении файлов проекта {project_id}: {e}") + + # === Public API === + def migrate(self): + print("Начало миграции проектов...") + try: + source_projects = self.source_api.call("getAllProjects") or [] + except Exception as e: + print(f"[{type(e).__name__}] Не удалось получить список проектов: {e}") + source_projects = [] + + for proj in source_projects: + try: + src_id = int(proj.get("id")) + except Exception: + continue + target_id, found = self._create_or_find_project(proj) + if not target_id: + print(f"Не удалось создать или найти проект '{proj.get('name')}' ({src_id})") + continue + + self.project_mapping[src_id] = target_id + if found: + print(f"Проект '{proj.get('name')}' уже существует ({src_id} -> {target_id})") + else: + print(f"Проект '{proj.get('name')}' создан ({src_id} -> {target_id})") + + self._migrate_columns(src_id, target_id) + self._migrate_project_users(src_id, target_id) + self._migrate_project_files(src_id, target_id) + self._apply_project_settings(src_id, target_id) + + self._save_mapping(self.project_mapping_file, self.project_mapping) + self._save_mapping(self.column_mapping_file, self.column_mapping) + print(f"Миграция проектов завершена.") + print(f"Маппинг проектов сохранён в {self.project_mapping_file}") + print(f"Маппинг колонок сохранён в {self.column_mapping_file}") + + def delete_all(self, exclude_project_ids: Optional[List[int]] = None, **kwargs): + """ + Удаление всех проектов. + + Args: + exclude_project_ids: Список ID проектов, которые нужно исключить из удаления + """ + exclude_project_ids = set(exclude_project_ids or []) + print("Начало удаления проектов...") + try: + all_projects = self.target_api.call("getAllProjects") or [] + except Exception as e: + print(f"[{type(e).__name__}] Не удалось получить проекты target: {e}") + return + + for p in all_projects: + try: + pid = int(p.get("id")) + except Exception: + continue + if pid in exclude_project_ids: + continue + try: + self._delete_project_files(pid) + ok = self.target_api.call("removeProject", {"project_id": pid}) + if ok: + print(f"Проект '{p.get('name')}' (ID {pid}) удалён") + else: + print(f"Проект '{p.get('name')}' (ID {pid}) не удалён") + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при удалении проекта {pid}: {e}") + + if hasattr(self, 'project_mapping_file'): + self._delete_mapping_file(self.project_mapping_file) + if hasattr(self, 'column_mapping_file'): + self._delete_mapping_file(self.column_mapping_file) + + if hasattr(self, 'project_mapping_file'): + self._delete_mapping_file(self.project_mapping_file) + if hasattr(self, 'project_mapping'): + self.project_mapping.clear() + + print("Удаление проектов завершено.") + diff --git a/lib/tags.py b/lib/tags.py new file mode 100644 index 0000000..31a1684 --- /dev/null +++ b/lib/tags.py @@ -0,0 +1,85 @@ +from lib.kanboard_api import KanboardAPI +from lib.base_migrator import BaseMigrator + + +class TagsMigrator(BaseMigrator): + """Миграция и полное удаление тегов в Kanboard.""" + + def __init__( + self, + source_api: KanboardAPI, + target_api: KanboardAPI, + tag_mapping_file: str = "tag_mapping.json", + project_mapping_file: str = "project_mapping.json" + ): + self.source_api = source_api + self.target_api = target_api + self.tag_mapping_file = tag_mapping_file + self.project_mapping_file = project_mapping_file + self.tag_mapping = self._load_mapping(self.tag_mapping_file) + self.project_mapping = self._load_mapping(self.project_mapping_file) + + # === Миграция === + def migrate(self): + print("Начало миграции тегов...") + source_tags = self.source_api.call("getAllTags") or [] + + for tag in source_tags: + tag_id = int(tag["id"]) + tag_name = tag["name"] + project_id = int(tag["project_id"]) + + # Определяем target_project_id + if project_id == 0: + target_project_id = 0 # глобальный тег + else: + target_project_id = self.project_mapping.get(project_id) + if not target_project_id: + print(f"Проект исходного тега {project_id} не найден на целевом сервере, пропускаем тег '{tag_name}'") + continue + + # Проверяем, есть ли такой тег уже на целевом экземпляре + existing_tags = self.target_api.call("getTagsByProject", [target_project_id]) or [] + if any(t["name"] == tag_name for t in existing_tags): + continue + + try: + new_id = self.target_api.call("createTag", [target_project_id, tag_name]) + if new_id: + self.tag_mapping[tag_id] = new_id + location = "глобальный" if target_project_id == 0 else f"проект {target_project_id}" + print(f"Перенесен тег '{tag_name}' ({tag_id} -> {new_id}) в {location}") + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при переносе '{tag_name}': {e}") + + self._save_mapping(self.tag_mapping_file, self.tag_mapping) + print("Миграция тегов завершена.") + print(f"Маппинг тегов сохранён в {self.tag_mapping_file}") + + # === Полное удаление всех тегов === + def delete_all(self, **kwargs): + print("Начало удаления всех тегов...") + all_tags = self.target_api.call("getAllTags") or [] + if not all_tags: + print("Теги не найдены.") + return + + for tag in all_tags: + tag_id = int(tag["id"]) + tag_name = tag["name"] + try: + ok = self.target_api.call("removeTag", [tag_id]) + if ok: + print(f"Тег '{tag_name}' (ID {tag_id}) удалён") + else: + print(f"Тег '{tag_name}' (ID {tag_id}) не удалён") + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при удалении '{tag_name}': {e}") + + if hasattr(self, 'tag_mapping_file'): + self._delete_mapping_file(self.tag_mapping_file) + if hasattr(self, 'tag_mapping'): + self.tag_mapping.clear() + + print("Удаление тегов завершено.") + diff --git a/lib/tasks-word.py b/lib/tasks-word.py new file mode 100644 index 0000000..5f15cf5 --- /dev/null +++ b/lib/tasks-word.py @@ -0,0 +1,227 @@ +from typing import Optional +from datetime import datetime + +from lib.kanboard_api import KanboardAPI +from lib.base_migrator import BaseMigrator + +class TasksMigrator(BaseMigrator): + """Миграция задач в Kanboard.""" + + def __init__( + self, + source_api: KanboardAPI, + target_api: KanboardAPI, + project_mapping_file: str = "project_mapping.json", + user_mapping_file: str = "user_mapping.json", + tag_mapping_file: str = "tag_mapping.json", + column_mapping_file: str = "column_mapping.json" + ): + self.source_api = source_api + self.target_api = target_api + self.project_mapping_file = project_mapping_file + self.user_mapping_file = user_mapping_file + self.tag_mapping_file = tag_mapping_file + self.column_mapping_file = column_mapping_file + self.project_mapping = self._load_mapping(self.project_mapping_file) + self.user_mapping = self._load_mapping(self.user_mapping_file) + self.tag_mapping = self._load_mapping(self.tag_mapping_file) + self.column_mapping = self._load_nested_mapping(self.column_mapping_file) + + def migrate(self): + """Миграция всех задач из исходных проектов в целевые.""" + print("Начало миграции задач...") + + for source_project_id, target_project_id in self.project_mapping.items(): + print(f"Миграция задач из проекта {source_project_id} -> {target_project_id}") + + # Получаем задачи с учетом статуса (1 = активные, 0 = закрытые) + source_tasks = self.source_api.call("getAllTasks", [int(source_project_id), 1]) or [] + + for task in source_tasks: + self._migrate_task(task, int(source_project_id), int(target_project_id)) + + print("Миграция задач завершена.") + + def _migrate_task(self, task, source_project_id: int, target_project_id: int): + """Миграция одной задачи.""" + task_id = int(task["id"]) + task_title = task["title"] + + # Получаем маппинг колонки + source_column_id = task.get("column_id") + target_column_id = None + + if source_column_id: + target_column_id = self.get_column_mapping(source_project_id, source_column_id) + if not target_column_id: + print(f" Предупреждение: не найден маппинг для колонки {source_column_id} проекта {source_project_id}") + + params = { + "project_id": target_project_id, + "title": task_title, + "description": task.get("description", ""), + "color_id": task.get("color_id", "yellow"), + "owner_id": self._safe_map_id(task.get("owner_id"), self.user_mapping), + "creator_id": self._safe_map_id(task.get("creator_id"), self.user_mapping), + "score": int(task.get("score", 0)), + "priority": int(task.get("priority", 0)), + "reference": task.get("reference", ""), + "time_estimated": int(task.get("time_estimated", 0)), + "time_spent": int(task.get("time_spent", 0)), + } + + # Добавляем column_id если нашли маппинг + if target_column_id: + params["column_id"] = target_column_id + + # Обрабатываем даты + if task.get("date_due") and task["date_due"] != "0": + params["date_due"] = self._timestamp_to_kanboard_date(task["date_due"]) + + if task.get("date_started") and task["date_started"] != "0": + params["date_started"] = self._timestamp_to_kanboard_date(task["date_started"]) + + # Обрабатываем теги задачи + task_tags = self._get_task_tags(task_id) + if task_tags: + params["tags"] = task_tags + + try: + new_task_id = self.target_api.call("createTask", params) + if new_task_id: + column_info = f" -> колонка {target_column_id}" if target_column_id else "" + print(f" Перенесена задача '{task_title}' ({task_id} -> {new_task_id}{column_info})") + + # Мигрируем файлы задачи после создания задачи + self._migrate_task_files(task_id, new_task_id, target_project_id) + + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при переносе задачи '{task_title}': {e}") + + def _migrate_task_files(self, source_task_id: int, target_task_id: int, target_project_id: int): + """Миграция файлов задачи.""" + try: + files = self.source_api.call("getAllTaskFiles", {"task_id": source_task_id}) or [] + except Exception as e: + print(f"[{type(e).__name__}] Не удалось получить файлы задачи {source_task_id}: {e}") + return + + if not files: + return + + print(f" Миграция файлов задачи ({len(files)} файлов)...") + + for file_info in files: + file_id = int(file_info.get("id")) + filename = file_info.get("name") + + try: + # Скачиваем файл из исходной системы + data_b64 = self.source_api.call("downloadTaskFile", [file_id]) + if not data_b64: + print(f" Пропущен файл '{filename}' — пустой контент") + continue + + # Загружаем файл в целевую систему + new_file_id = self.target_api.call( + "createTaskFile", + [target_project_id, target_task_id, filename, data_b64] + ) + + if new_file_id: + print(f" Файл '{filename}' перенесён ({file_id} -> {new_file_id})") + else: + print(f" Не удалось перенести файл '{filename}'") + + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при переносе файла '{filename}': {e}") + + def get_column_mapping(self, src_project_id: int, src_column_id: int) -> Optional[int]: + """ + Получить ID целевой колонки по ID исходной колонки и проекта. + + Args: + src_project_id: ID исходного проекта + src_column_id: ID исходной колонки + + Returns: + ID целевой колонки или None если маппинг не найден + """ + project_mapping = self.column_mapping.get(src_project_id, {}) + return project_mapping.get(src_column_id) + + def _safe_map_id(self, source_id, mapping_dict): + """Безопасный маппинг ID с преобразованием типов.""" + if not source_id or source_id in ("0", 0): + return 0 + + try: + source_id_int = int(source_id) + mapped_id = mapping_dict.get(source_id_int) + + if mapped_id is not None: + return mapped_id + except (ValueError, TypeError): + pass + + return 0 + + def _get_task_tags(self, task_id): + """Получение тегов задачи - используем оригинальные названия тегов.""" + source_tags = self.source_api.call("getTaskTags", [task_id]) or {} + + if not source_tags: + return [] + + # Просто возвращаем названия тегов из source + # Kanboard автоматически создаст теги с такими названиями или свяжет с существующими + return list(source_tags.values()) + + def _timestamp_to_kanboard_date(self, timestamp): + """Конвертация timestamp в формат даты Kanboard.""" + try: + dt = datetime.fromtimestamp(int(timestamp)) + return dt.strftime("%Y-%m-%d %H:%M") + except (ValueError, TypeError): + return "" + + def delete_all(self, **kwargs): + """Удаление всех задач из целевых проектов.""" + print("Начало удаления всех задач...") + + for target_project_id in self.project_mapping.values(): + print(f"Удаление задач из проекта {target_project_id}") + + tasks = self.target_api.call("getAllTasks", [int(target_project_id), 1]) or [] + + for task in tasks: + task_id = int(task["id"]) + task_title = task["title"] + + try: + # Сначала удаляем все файлы задачи + self._delete_task_files(task_id) + + # Затем удаляем саму задачу + ok = self.target_api.call("removeTask", [task_id]) + if ok: + print(f" Задача '{task_title}' (ID {task_id}) удалена") + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при удалении '{task_title}': {e}") + + print("Удаление задач завершено.") + + def _delete_task_files(self, task_id: int): + """Удаление всех файлов задачи.""" + try: + files = self.target_api.call("getAllTaskFiles", {"task_id": task_id}) or [] + if files: + # Используем removeAllTaskFiles для удаления всех файлов сразу + ok = self.target_api.call("removeAllTaskFiles", {"task_id": task_id}) + if ok: + print(f" Удалены все файлы задачи {task_id} ({len(files)} файлов)") + else: + print(f" Не удалось удалить файлы задачи {task_id}") + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при удалении файлов задачи {task_id}: {e}") + diff --git a/lib/tasks.py b/lib/tasks.py new file mode 100644 index 0000000..335dace --- /dev/null +++ b/lib/tasks.py @@ -0,0 +1,274 @@ +from typing import Optional +from datetime import datetime + +from lib.kanboard_api import KanboardAPI +from lib.base_migrator import BaseMigrator + +class TasksMigrator(BaseMigrator): + """Миграция задач в Kanboard.""" + + def __init__( + self, + source_api: KanboardAPI, + target_api: KanboardAPI, + project_mapping_file: str = "project_mapping.json", + user_mapping_file: str = "user_mapping.json", + tag_mapping_file: str = "tag_mapping.json", + column_mapping_file: str = "column_mapping.json" + ): + self.source_api = source_api + self.target_api = target_api + self.project_mapping_file = project_mapping_file + self.user_mapping_file = user_mapping_file + self.tag_mapping_file = tag_mapping_file + self.column_mapping_file = column_mapping_file + self.project_mapping = self._load_mapping(self.project_mapping_file) + self.user_mapping = self._load_mapping(self.user_mapping_file) + self.tag_mapping = self._load_mapping(self.tag_mapping_file) + self.column_mapping = self._load_nested_mapping(self.column_mapping_file) + + def migrate(self): + """Миграция всех задач из исходных проектов в целевые.""" + print("Начало миграции задач...") + + for source_project_id, target_project_id in self.project_mapping.items(): + print(f"Миграция задач из проекта {source_project_id} -> {target_project_id}") + + # Получаем задачи с учетом статуса (1 = активные, 0 = закрытые) + source_tasks = self.source_api.call("getAllTasks", [int(source_project_id), 1]) or [] + + for task in source_tasks: + self._migrate_task(task, int(source_project_id), int(target_project_id)) + + print("Миграция задач завершена.") + + def _migrate_task(self, task, source_project_id: int, target_project_id: int): + """Миграция одной задачи.""" + task_id = int(task["id"]) + task_title = task["title"] + + # Получаем маппинг колонки + source_column_id = task.get("column_id") + target_column_id = None + + if source_column_id: + target_column_id = self.get_column_mapping(source_project_id, source_column_id) + if not target_column_id: + print(f" Предупреждение: не найден маппинг для колонки {source_column_id} проекта {source_project_id}") + + params = { + "project_id": target_project_id, + "title": task_title, + "description": task.get("description", ""), + "color_id": task.get("color_id", "yellow"), + "owner_id": self._safe_map_id(task.get("owner_id"), self.user_mapping), + "creator_id": self._safe_map_id(task.get("creator_id"), self.user_mapping), + "score": int(task.get("score", 0)), + "priority": int(task.get("priority", 0)), + "reference": task.get("reference", ""), + "time_estimated": int(task.get("time_estimated", 0)), + "time_spent": int(task.get("time_spent", 0)), + } + + # Добавляем column_id если нашли маппинг + if target_column_id: + params["column_id"] = target_column_id + + # Обрабатываем даты + if task.get("date_due") and task["date_due"] != "0": + params["date_due"] = self._timestamp_to_kanboard_date(task["date_due"]) + + if task.get("date_started") and task["date_started"] != "0": + params["date_started"] = self._timestamp_to_kanboard_date(task["date_started"]) + + # Обрабатываем теги задачи + task_tags = self._get_task_tags(task_id) + if task_tags: + params["tags"] = task_tags + + try: + new_task_id = self.target_api.call("createTask", params) + if new_task_id: + column_info = f" -> колонка {target_column_id}" if target_column_id else "" + print(f" Перенесена задача '{task_title}' ({task_id} -> {new_task_id}{column_info})") + + # Мигрируем дополнительные данные задачи + self._migrate_task_metadata(task_id, new_task_id) + self._migrate_subtasks(task_id, new_task_id) + self._migrate_task_files(task_id, new_task_id, target_project_id) + + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при переносе задачи '{task_title}': {e}") + + def _migrate_task_metadata(self, source_task_id: int, target_task_id: int): + """Миграция метаданных задачи.""" + try: + metadata = self.source_api.call("getTaskMetadata", [source_task_id]) or [] + except Exception as e: + print(f"[{type(e).__name__}] Не удалось получить метаданные задачи {source_task_id}: {e}") + return + + if not metadata: + return + + # Метаданные возвращаются как список словарей, берем первый + if isinstance(metadata, list) and len(metadata) > 0: + metadata_dict = metadata[0] + else: + metadata_dict = metadata + + if not isinstance(metadata_dict, dict) or not metadata_dict: + return + + try: + ok = self.target_api.call("saveTaskMetadata", [target_task_id, metadata_dict]) + if ok: + print(f" Метаданные задачи перенесены ({len(metadata_dict)} ключей)") + else: + print(f" Не удалось сохранить метаданные задачи {target_task_id}") + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при сохранении метаданных задачи {target_task_id}: {e}") + + def _migrate_subtasks(self, source_task_id: int, target_task_id: int): + """Миграция подзадач.""" + try: + subtasks = self.source_api.call("getAllSubtasks", {"task_id": source_task_id}) or [] + except Exception as e: + print(f"[{type(e).__name__}] Не удалось получить подзадачи {source_task_id}: {e}") + return + + if not subtasks: + return + + print(f" Миграция подзадач ({len(subtasks)} подзадач)...") + + for subtask in subtasks: + params = { + "task_id": target_task_id, + "title": subtask.get("title", ""), + "user_id": self._safe_map_id(subtask.get("user_id"), self.user_mapping), + "time_estimated": int(subtask.get("time_estimated", 0)), + "time_spent": int(subtask.get("time_spent", 0)), + "status": int(subtask.get("status", 0)), + } + + try: + new_subtask_id = self.target_api.call("createSubtask", params) + if new_subtask_id: + print(f" Подзадача '{subtask.get('title')}' перенесена ({subtask.get('id')} -> {new_subtask_id})") + else: + print(f" Не удалось создать подзадачу '{subtask.get('title')}'") + + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при переносе подзадачи: {e}") + + def _migrate_task_files(self, source_task_id: int, target_task_id: int, target_project_id: int): + """Миграция файлов задачи.""" + try: + files = self.source_api.call("getAllTaskFiles", {"task_id": source_task_id}) or [] + except Exception as e: + print(f"[{type(e).__name__}] Не удалось получить файлы задачи {source_task_id}: {e}") + return + + if not files: + return + + print(f" Миграция файлов задачи ({len(files)} файлов)...") + + for file_info in files: + file_id = int(file_info.get("id")) + filename = file_info.get("name") + + try: + # Скачиваем файл из исходной системы + data_b64 = self.source_api.call("downloadTaskFile", [file_id]) + if not data_b64: + print(f" Пропущен файл '{filename}' — пустой контент") + continue + + # Загружаем файл в целевую систему + new_file_id = self.target_api.call( + "createTaskFile", + [target_project_id, target_task_id, filename, data_b64] + ) + + if new_file_id: + print(f" Файл '{filename}' перенесён ({file_id} -> {new_file_id})") + else: + print(f" Не удалось перенести файл '{filename}'") + + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при переносе файла '{filename}': {e}") + + def get_column_mapping(self, src_project_id: int, src_column_id: int) -> Optional[int]: + """ + Получить ID целевой колонки по ID исходной колонки и проекта. + + Args: + src_project_id: ID исходного проекта + src_column_id: ID исходной колонки + + Returns: + ID целевой колонки или None если маппинг не найден + """ + project_mapping = self.column_mapping.get(src_project_id, {}) + return project_mapping.get(src_column_id) + + def _safe_map_id(self, source_id, mapping_dict): + """Безопасный маппинг ID с преобразованием типов.""" + if not source_id or source_id in ("0", 0): + return 0 + + try: + source_id_int = int(source_id) + mapped_id = mapping_dict.get(source_id_int) + + if mapped_id is not None: + return mapped_id + except (ValueError, TypeError): + pass + + return 0 + + def _get_task_tags(self, task_id): + """Получение тегов задачи - используем оригинальные названия тегов.""" + source_tags = self.source_api.call("getTaskTags", [task_id]) or {} + + if not source_tags: + return [] + + # Просто возвращаем названия тегов из source + # Kanboard автоматически создаст теги с такими названиями или свяжет с существующими + return list(source_tags.values()) + + def _timestamp_to_kanboard_date(self, timestamp): + """Конвертация timestamp в формат даты Kanboard.""" + try: + dt = datetime.fromtimestamp(int(timestamp)) + return dt.strftime("%Y-%m-%d %H:%M") + except (ValueError, TypeError): + return "" + + def delete_all(self, **kwargs): + """Удаление всех задач из целевых проектов.""" + print("Начало удаления всех задач...") + + for target_project_id in self.project_mapping.values(): + print(f"Удаление задач из проекта {target_project_id}") + + tasks = self.target_api.call("getAllTasks", [int(target_project_id), 1]) or [] + + for task in tasks: + task_id = int(task["id"]) + task_title = task["title"] + + try: + # Затем удаляем саму задачу + ok = self.target_api.call("removeTask", [task_id]) + if ok: + print(f" Задача '{task_title}' (ID {task_id}) удалена") + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при удалении '{task_title}': {e}") + + print("Удаление задач завершено.") + diff --git a/lib/users.py b/lib/users.py new file mode 100644 index 0000000..b0b9df8 --- /dev/null +++ b/lib/users.py @@ -0,0 +1,79 @@ +from lib.kanboard_api import KanboardAPI +from lib.base_migrator import BaseMigrator +import os + + +class UsersMigrator(BaseMigrator): + """Миграция и удаление пользователей в Kanboard.""" + + def __init__(self, source_api: KanboardAPI, target_api: KanboardAPI, user_mapping_file: str = "user_mapping.json"): + self.source_api = source_api + self.target_api = target_api + self.user_mapping_file = user_mapping_file + self.user_mapping = self._load_mapping(self.user_mapping_file) + + # === Миграция === + def migrate(self): + print("Начало миграции пользователей...") + source_users = self.source_api.call("getAllUsers") or [] + target_users = self.target_api.call("getAllUsers") or [] + existing_usernames = {u["username"] for u in target_users} + temp_password = os.getenv("TEMP_USER_PASSWORD") + + for user in source_users: + source_id = int(user["id"]) + username = user["username"] + name = user.get("name") or "" + email = user.get("email") or "" + role = user.get("role") or "app-user" + + if username in existing_usernames: + print(f"Пользователь '{username}' уже существует, пропускаем") + continue + + password = temp_password if temp_password else username + + try: + new_id = self.target_api.call("createUser", { + "username": username, + "password": password, + "name": name, + "email": email, + "role": role + }) + if new_id: + self.user_mapping[source_id] = new_id + print(f"Пользователь '{username}' ({source_id} -> {new_id}) создан") + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при переносе '{username}': {e}") + + self._save_mapping(self.user_mapping_file, self.user_mapping) + print(f"Миграция пользователей завершена. Маппинг сохранён в {self.user_mapping_file}") + + # === Удаление === + def delete_all(self, exclude_admin: bool = True, **kwargs): + print("Начало удаления пользователей...") + all_users = self.target_api.call("getAllUsers") or [] + + for user in all_users: + user_id = int(user["id"]) + username = user["username"] + if exclude_admin and username == "admin": + continue + + try: + ok = self.target_api.call("removeUser", {"user_id": user_id}) + if ok: + print(f"Пользователь '{username}' (ID {user_id}) удалён") + else: + print(f"Пользователь '{username}' (ID {user_id}) не удалён") + except Exception as e: + print(f"[{type(e).__name__}] Ошибка при удалении '{username}': {e}") + + if hasattr(self, 'user_mapping_file'): + self._delete_mapping_file(self.user_mapping_file) + if hasattr(self, 'user_mapping'): + self.user_mapping.clear() + + print("Удаление пользователей завершено.") + diff --git a/main.py b/main.py new file mode 100755 index 0000000..f9e9b54 --- /dev/null +++ b/main.py @@ -0,0 +1,167 @@ +#!/usr/bin/env -S uv run --with-requirements requirements.txt + +import os +import sys +import click +from dotenv import load_dotenv + +from lib.kanboard_api import KanboardAPI +from lib.interactive import KanboardInteractive +from lib.users import UsersMigrator +from lib.projects import ProjectsMigrator +from lib.tags import TagsMigrator +from lib.tasks import TasksMigrator + +load_dotenv() + +MAPPING_FILES = { + "user": "user_mapping.json", + "project": "project_mapping.json", + "column": "column_mapping.json", + "tag": "tag_mapping.json", +} + +SCOPES_ORDER = ["users", "projects", "tags", "tasks"] + +def get_env_var(name: str) -> str: + value = os.environ.get(name) + if not value: + raise EnvironmentError(f"Не найдена переменная окружения: {name}") + return value + +def setup_apis(): + try: + source = KanboardAPI( + url=get_env_var("SOURCE_URL"), + user=get_env_var("SOURCE_USER"), + token=get_env_var("SOURCE_TOKEN") + ) + target = KanboardAPI( + url=get_env_var("TARGET_URL"), + user=get_env_var("TARGET_USER"), + token=get_env_var("TARGET_TOKEN") + ) + return source, target + except EnvironmentError as e: + print(e) + sys.exit(1) + +def create_migrator(scope_name, source_api, target_api): + """Создание одного мигратора для указанной области.""" + if scope_name == "users": + return UsersMigrator(source_api, target_api, MAPPING_FILES["user"]) + elif scope_name == "projects": + return ProjectsMigrator(source_api, target_api, MAPPING_FILES["project"], MAPPING_FILES["user"], MAPPING_FILES["column"]) + elif scope_name == "tags": + return TagsMigrator(source_api, target_api, MAPPING_FILES["tag"], MAPPING_FILES["project"]) + elif scope_name == "tasks": + return TasksMigrator(source_api, target_api, MAPPING_FILES["project"], MAPPING_FILES["user"], MAPPING_FILES["tag"], MAPPING_FILES["column"]) + else: + raise ValueError(f"Неизвестный scope: {scope_name}") + +def create_plan(scope, reverse_order=False): + """ + Создание плана выполнения на основе области. + + Args: + scope: Область выполнения (None для полного выполнения) + reverse_order: Если True, используется обратный порядок для полного выполнения + + Returns: + Список областей для выполнения + """ + if scope is None: + # Полное выполнение + order = list(reversed(SCOPES_ORDER)) if reverse_order else SCOPES_ORDER + return order + else: + # Выполнение только указанной области + return [scope] + +# +# Основные операции +# + +def run_interactive(source, target, instance): + if instance not in ["source", "target"]: + print(f"Неизвестный экземпляр: '{instance}'. Должен быть 'source' или 'target'") + sys.exit(1) + api = source if instance == "source" else target + interactive = KanboardInteractive(api) + interactive.run() + +def run_migrate(source_api, target_api, scope): + """Запуск миграции с созданием миграторов по мере выполнения.""" + + if scope and scope not in SCOPES_ORDER: + click.echo(f"Неизвестный scope: '{scope}'. Доступные: {', '.join(SCOPES_ORDER)}", err=True) + sys.exit(1) + + migration_plan = create_plan(scope, reverse_order=False) + + for scope_name in migration_plan: + # Создаем мигратор непосредственно перед выполнением + migrator = create_migrator(scope_name, source_api, target_api) + try: + click.echo(f"=== Выполнение миграции: {scope_name} ===") + migrator.migrate() + click.echo(f"=== Миграция {scope_name} завершена ===\n") + except Exception as e: + click.echo(f"Ошибка при миграции {scope_name}: {e}", err=True) + sys.exit(1) + +def run_delete(source_api, target_api, scope): + """Удаление данных из целевого экземпляра с созданием миграторов по мере выполнения.""" + + if scope and scope not in SCOPES_ORDER: + click.echo(f"Неизвестный scope: '{scope}'. Доступные: {', '.join(SCOPES_ORDER)}", err=True) + sys.exit(1) + + deletion_plan = create_plan(scope, reverse_order=True) + + for scope_name in deletion_plan: + # Создаем мигратор непосредственно перед выполнением + deleter = create_migrator(scope_name, source_api, target_api) + try: + click.echo(f"=== Выполнение удаления: {scope_name} ===") + deleter.delete_all() + click.echo(f"=== Удаление {scope_name} завершено ===\n") + except Exception as e: + click.echo(f"Ошибка при удалении {scope_name}: {e}", err=True) + sys.exit(1) + +@click.group() +def cli(): + """Скрипт для миграции Kanboard. Используйте 'command --help' для деталей.""" + +@cli.command() +@click.argument('instance', + type=click.Choice(['source', 'target'])) +def interactive(instance): + """Интерактивный режим: укажите source или target.""" + source, target = setup_apis() + run_interactive(source, target, instance) + +@cli.command() +@click.option('--scope', + type=click.Choice(SCOPES_ORDER), + default=None, + help=f"Scope: {', '.join(SCOPES_ORDER)} (пример: --scope projects)") +def migrate(scope): + """Миграция данных: укажите --scope или используйте для полного режима.""" + source, target = setup_apis() + run_migrate(source, target, scope) + +@cli.command() +@click.option('--scope', + type=click.Choice(SCOPES_ORDER), + default=None, + help=f"Scope: {', '.join(SCOPES_ORDER)} (пример: --scope tags)") +def delete(scope): + """Удаление данных: укажите --scope или используйте для полного режима.""" + source, target = setup_apis() + run_delete(source, target, scope) + +if __name__ == '__main__': + cli() + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bab0c60 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +dotenv +requests +click +