Одни из самых интересных задач в работе администратора приложений, на мой взгляд, это осуществление миграции данных при переходе на новую систему. Сегодня я хочу поделится собственным опытом переноса данных с не очень известной helpdesk системы VisionFlow в более известную систему ServiceNow.

Что хотел заказчик

  • Перенести все данные из VisionFlow в ServiceNow с сохранением даты регистрации / закрытия тикетов

  • Перенести всю историю переписки по каждому тикету (достаточно было объединить все комментарии в один тред, но мы пошли чуть дальше)

  • Перенести все прикреплённые к тикетам файлы

Что мы имели

  • Серверную версию Helpdesk системы VisionFlow развёрнутую на виртуальной линукс машине с БД MySQL для хранения данных.

  • ServiceNow инстанс, с подготовленной заранее таблицей для заказчика.

    На данном этапе были обговорены все нюансы, такие как:

  • Статусная модель

  • Требуемые поля

  • Логика автоматического назначения тикетов на исполнителя

  • Данные требующие переноса

Перенос данных

ServiceNow позволяет использовать excel файлы в качестве ресурса для импорта данных. Не буду подробно расписывать процесс импорта данных в систему (процесс неплохо описан в документации к продукту), но в общих чертах он выглядит так:

Импорт данных

Transform map позволяет нам задать ключевое поле, по которому система будет понимать, что запись с данными параметрами уже присутствует в таблице и требуется только обновление полей

Так как было решено использовать xlsx файл в качестве источника данных, файл требовалось подготовить. Требуемые данные VisionFlow хранит в разных таблицах своей базы данных. Для получения данных был написан запрос к БД:

Запрос данных по тикетам в VisionFlow
SELECT
	projectissue.projectIssueId,
	projectissue.ticketId as 'Number',
    reporter.email as 'Reporter',
    projectissue.name as 'Short Description',
    projectissue.Description as 'Description',
    projectissue.companycustomfield15 as 'Product',
    projectissue.companycustomfield13 as 'Document',
    issuestatus.name as 'Status',
    assignee.name as 'Assignee',
    ADDTIME(projectissue.CreateDate, '-01:00') as 'Created',
    ADDTIME(projectissue.completionDate, '-01:00') as 'Closed',
    issuehistory.EventText as 'Comment',
    author.name as 'commentAuthor'
FROM
	projectissue
INNER JOIN issuestatus
    ON projectissue.IssueStatusId = issuestatus.IssueStatusId
INNER JOIN systemuser assignee
	ON projectissue.ResponsibleSystemUserId = assignee.SystemUserId
INNER JOIN systemuser reporter
	ON projectissue.CreatedBySystemUserId = reporter.SystemUserId
INNER JOIN issuehistory ON
	issuehistory.ProjectIssueId = projectissue.ProjectIssueId
INNER JOIN systemuser author 
	ON issuehistory.SystemUserId = author.SystemUserId
WHERE
	projectissue.ProjectId = 54 AND (issuehistory.IssueEventTypeId = 5 OR issuehistory.IssueEventTypeId = 10 OR issuehistory.IssueEventTypeId = 2)
    #projectissue.ProjectId = 54
ORDER BY projectissue.TicketId ASC, issuehistory.EventDate ASC

Выполнение запроса позволило нам получить данные по всем тикетам из определённого проекта, включая историю комментариев по каждому отдельному элементу, с сохранением авторства и даты добавления. Вторым шагом данные были выгружены в JSON и залиты в Excel документ. После загрузки документа в ServiceNow в качестве Data Source была проведена обработка записей и создание / обновление тикетов в системе.

Результат: В системе ServiceNow зарегистрированы все записи из VisionFlow, включая дату открытия и закрытия тикетов, комментариев (и их авторов) с соблюдением исходного порядка и всех ключевых полей. Т.к. таблица на момент переноса была пуста, проблем с подменой даты создания тикетов не возникло (ничего такого, чтобы могло повлиять на нумерацию).

Перенос вложений

Если первая часть миграции (требующая переноса исключительно текстовой информации) не потребовала больших усилий, то над переносом вложений пришлось попотеть. Сначала требовалось выяснить, а как VisionFlow, собственно, эти аттачи хранит.

Выполняем запрос к БД для получения всех данных по вложениям переносимого проекта:

Запрос к БД VisionFlow
SELECT 
	document.documentId,
	document.name,
    document.FullPath,
	SUBSTRING_INDEX(SUBSTRING_INDEX(document.FullPath, '/', -2), '/', 1) as 'projectIssueId',
    projectissue.ticketId as 'Number'
 FROM 
	visionflow.document
 INNER JOIN projectissue
 ON projectissue.ProjectIssueId = SUBSTRING_INDEX(SUBSTRING_INDEX(document.FullPath, '/', -2), '/', 1)
 WHERE 
	document.FullPath like '%/54/issuedocuments/%'
ORDER BY projectissueid

Данный запрос позволил нам получить информацию о том, как и где VisionFlow хранит вложения. К нашему счастью, оказалось, что VF создаёт отдельную папку для каждого проекта, в которой создаёт набор папок для тикетов, в которых вложения присутствуют. Папки имеют в качестве названия issueId, позволяющее однозначно идентифицировать принадлежность к тикету. Собственно, запрос выше позволят нам получить наименование папки, в которой лежит вложение и TicketId (его мы использовали для переноса данных в ServiceNow).

Выгрузив архив с вложениями, встал вопрос о сопоставлении файлов с тикетами в ServiceNow и их проливке в систему. Т.к. я недавно начал изучать Python, я подумал, что решение данной задачи будет неплохой практикой в языке.

Для добавления вложений в ServiceNow было решено использовать API attachments. Для этого на стороне SN был создан endpoint для получения временного токена с доступами к нужной таблице.

ServiceNow предоставляет code samples для их API. По документации мы видим, что нам потребуются следующие параметры для нашего запроса:

file_name (Required) - имя добавляемого файла

table_name (Required) - имя таблицы, в которой запись хранится

table_sys_id (Required) - ID записи, в которую требуется добавить вложение

Content-Type (Header) - mime type передаваемого контента

Как мы видим, вложение имеет связку с sys_id записи, к которой он принадлежит ( как и в VisionFlow). Следовательно, нам достаточно переименовать папки, которые мы загрузили из VisionFlow в sys_id записей, к которым мы будем их крепить. Для этого был выгружен список sys_id + ticketId из ServiceNow + список issueId + ticketId из VisionFlow. С помощью VLOOKUP функции Excel списки были сопоставлены и создан новый список с полями:

  • old_folder_name

  • ticket_id

  • new_folder_name

На Python был написан скрипт для переименования папок и удаления тех, в которых не было найдено файлов (прогрессбар в данном случае был добавлен только для тренировки):

Переименование папок
import pandas as pd, os 
from tqdm import tqdm

def renameFolders():
    df = pd.read_csv('/Downloads/folder_rename.csv')
    pbar = tqdm(total=len(df))

    for _ , row in df.iterrows():
        old_name = row['old_folder_name']
        new_name = row['new_folder_name']
        try:
            os.rename(f'/Downloads/home/tomcat/vflowdocs/54/issuedocuments/{old_name}', f'/Downloads/home/tomcat/vflowdocs/54/issuedocuments/{new_name}')
            pbar.update(1)
        except:
            pbar.update(1)

def removeEmptyFolders():
    folder_list = os.listdir('/Downloads/home/tomcat/vflowdocs/54/issuedocuments/')
    for folder in folder_list:
        path = f'/Downloads/home/tomcat/vflowdocs/54/issuedocuments/{folder}'
        try:
            os.rmdir(path)
        except:
            if len(os.path.basename(path)) < 6 and os.path.basename(path) != 'nan':
                print(f'ServiceNow SysId not found for item: {os.path.basename(path)}')

renameFolders()
removeEmptyFolders()

Не буду расписывать скрипт для заливки вложений, опишу лишь несколько моментов:

  • В скрипт добавлена проверка размера вложений, для того, чтобы отсеять всё то, что имеет вес менее 3000 kb (различные иконки, картинки из подписей и другой мусор) def getSize()

  • Добавлен метод для удаления дубликатов аттачей. В VisionFlow каждое повторно пересылаемое вложение создавало новый файл документа def removeDuplicates()

  • Добавлена обработка файлов с mime типом None. По какой-то причине mimetypes не возвращает типы для формата *msg, *txt, *eml

  • Реализован финальный лог по операциями на основе ответов от сервера

  • Ну и последнее (но мне, как любителю всё смотреть визуально, не менее важное) - прогрессбар для отслеживания процесса загрузки

Финальный скрипт
import os, glob, filetype, requests, mimetypes
from tqdm import tqdm
import pandas as pd

def number_of_files():
    files_number = 0

    folder_list = os.listdir('/Downloads/home/tomcat/vflowdocs/54/issuedocuments/')
    for folder in folder_list:
       files_number += len(os.listdir(f'/Downloads/home/tomcat/vflowdocs/54/issuedocuments/{folder}/'))
    return files_number

#Progress Bar
pbar = tqdm(total=1297)
log_messages_status = []
log_messages_filepath = []
log_messages_filename = []
log_messages_target = []

def uploadAllFiles(folder_name):
    #Variables
    entire_list = glob.glob(f'/Downloads/home/tomcat/vflowdocs/54/issuedocuments/{folder_name}/*')
    my_list_updated = []

    #Get Files Size
    def getSize(fileobject):
        fileobject.seek(0,2)
        size = fileobject.tell()
        return size

    #Upload Files
    def uploadFunc(filename, sys_id, path_to_file, content_type):

        url = f'https://instance.service-now.com/api/now/attachment/file?file_name={filename}&table_name=table_name&table_sys_id={sys_id}'
        payload=open(path_to_file, 'rb').read()
        headers = {
            'Accept': 'application/json',
            'Authorization': 'Bearer ',
            'Content-Type': content_type,
            }
            
        response = requests.request("POST", url, headers=headers, data=payload)
        if response.status_code == 201:
            #print(f'Success: {filename} was uploaded to the incident with sys_id {sys_id}')
            pbar.update(1)
            log_messages_status.append('Success')
            log_messages_filename.append(filename)
            log_messages_filepath.append(path_to_file)
            log_messages_target.append(sys_id)
        else:
            pbar.update(1)
            #print(f'Error: {filename} was not uploaded to the incident with sys_id {sys_id}')
            log_messages_status.append('Error')
            log_messages_filename.append(filename)
            log_messages_filepath.append(path_to_file)
            log_messages_target.append(sys_id)

    #Remove Duplicates
    def removeDuplicatesByName(list_of_elements):
        list_of_elements.sort()
        if len(list_of_elements) > 1:
            for item in list_of_elements:
                item_to_compare = item.split('.')[0]
                for element in list_of_elements:
                    if item_to_compare in element:
                        entire_list.remove(element)
                    else:
                        pass
            return list_of_elements
        else:
            return list_of_elements

    my_list = removeDuplicatesByName(entire_list)

    for item in my_list:
        file_size = open(item, 'rb')
        if getSize(file_size) > 3000:
            my_list_updated.append(item)
        else:
            pass

    for attach in my_list_updated:
        kind = filetype.guess_mime(attach)

        if kind != None:
            uploadFunc(os.path.basename(attach), os.path.dirname(attach).split('/')[-1], attach, kind)
        elif kind == None and attach.split('.')[-1] == 'txt':
            uploadFunc(os.path.basename(attach), os.path.dirname(attach).split('/')[-1], attach, 'text/plain')
        else:
            uploadFunc(os.path.basename(attach), os.path.dirname(attach).split('/')[-1], attach, 'application/octet-stream')
        

def getFolders():
    folder_list = os.listdir('/Downloads/home/tomcat/vflowdocs/54/issuedocuments/')
    for folder in folder_list:
        if folder != '.DS_Store':
            uploadAllFiles(folder)
            

getFolders()
data_to_write = pd.DataFrame({
    'status': log_messages_status,
    'file_name' : log_messages_filename,
    'file_path' : log_messages_filepath,
    'target' : log_messages_target
})
data_to_write.to_csv('/Downloads/results_log.csv')

Заключение

У нас было 2 пакетика….©. У нас было 6000 тысяч записей к переносу (не так много, старая система работала не долго), 2000 вложений и немного времени. Процесс подготовки занял у меня около 14 часов (изучение, попытки и т.д.) неспешной работы, а процесс переноса занимает около 30 минут суммарно.

Конечно, можно было бы многое улучшить, полностью автоматизировать процесс (начиная с выгрузки данных, заканчивая их проливкой), но, к сожалению, данная задача одноразовая. Было интересно попробовать Python для реализации проекта, и могу сказать, что с такой задачей он помог справится на ура.

В конечном счёте, основная задача переезда - сделать это максимально незаметно для заказчика, что и было сделано с моей стороны.