337 lines
		
	
	
	
		
			12 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable file
		
	
	
	
	
			
		
		
	
	
			337 lines
		
	
	
	
		
			12 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable file
		
	
	
	
	
#!/usr/bin/env python
 | 
						|
 | 
						|
import base64
 | 
						|
import json
 | 
						|
import os
 | 
						|
import pickle
 | 
						|
import sys
 | 
						|
import html2text
 | 
						|
import traceback
 | 
						|
import time
 | 
						|
 | 
						|
from zammad_py import ZammadAPI
 | 
						|
from zammad_py.api import Resource, TagList, TicketArticle
 | 
						|
 | 
						|
TEMPLATE = """{
 | 
						|
"zammad_url": "",
 | 
						|
"zammad_user": "",
 | 
						|
"zammad_password": "",
 | 
						|
"rt_url": "",
 | 
						|
"rt_user": "",
 | 
						|
"rt_pass": "",
 | 
						|
"rt_start": 1,
 | 
						|
"rt_end": 1000,
 | 
						|
}
 | 
						|
"""
 | 
						|
 | 
						|
COMMENT_TEMPLATE = """
 | 
						|
Ticket imported from Request Tracker (RT-#{numerical_id})
 | 
						|
 | 
						|
URL: https://support.ungleich.ch/Ticket/Display.html?id={numerical_id}
 | 
						|
Created: {Created}
 | 
						|
Resolved: {Resolved}
 | 
						|
"""
 | 
						|
 | 
						|
STATUSMAP = {"new": 1, "open": 2, "resolved": 4, "rejected": 4, "deleted": 4}
 | 
						|
USERMAP = {}
 | 
						|
 | 
						|
ZAMMAD_SESSIONS = {}
 | 
						|
 | 
						|
### helpers ###
 | 
						|
 | 
						|
def get_zammad_session(impersonated_user=None):
 | 
						|
    if impersonated_user in ZAMMAD_SESSIONS:
 | 
						|
        return ZAMMAD_SESSIONS[impersonated_user]
 | 
						|
    else:
 | 
						|
        kwargs = {}
 | 
						|
        if impersonated_user:
 | 
						|
            kwargs["on_behalf_of"] = impersonated_user
 | 
						|
        session = ZammadAPI(
 | 
						|
            url=config["zammad_host"],
 | 
						|
            username=config["zammad_user"],
 | 
						|
            password=config["zammad_password"],
 | 
						|
            **kwargs,
 | 
						|
        )
 | 
						|
        ZAMMAD_SESSIONS[impersonated_user or config["zammad_user"]] = session
 | 
						|
 | 
						|
        return session
 | 
						|
 | 
						|
def maybe_create_zammad_user(userdata, zammad_session, attr="login", default=None):
 | 
						|
    # Map disabled users (in RT) to mock user in Zammad.
 | 
						|
    if type(userdata) is str and userdata.lower() in USERMAP:
 | 
						|
        email = userdata
 | 
						|
    elif type(userdata) is str or "EmailAddress" not in userdata:
 | 
						|
        userdata = {
 | 
						|
            'EmailAddress': 'technik@ungleich.ch',
 | 
						|
            'RealName': 'Disabled RT'
 | 
						|
        }
 | 
						|
        email = userdata["EmailAddress"]
 | 
						|
    elif "EmailAddress" in userdata:
 | 
						|
        email = userdata["EmailAddress"]
 | 
						|
    else:
 | 
						|
        raise ValueError("Invalid userdata")
 | 
						|
 | 
						|
    # We manually filter out invalid addresses.
 | 
						|
    if email == "*@*.com":
 | 
						|
        userdata = {
 | 
						|
            'EmailAddress': 'technik@ungleich.ch',
 | 
						|
            'RealName': 'Disabled RT'
 | 
						|
        }
 | 
						|
        email = userdata["EmailAddress"]
 | 
						|
 | 
						|
 | 
						|
    lowercase_email = email.lower()
 | 
						|
 | 
						|
    if lowercase_email not in USERMAP:
 | 
						|
        kwargs = {"email": email}
 | 
						|
        kwargs.update(config["userdata"])
 | 
						|
        if "RealName" in userdata:
 | 
						|
            realname = userdata["RealName"]
 | 
						|
            if ", " in realname.strip():
 | 
						|
                last, first = realname.split(", ", 1)
 | 
						|
            elif " " in realname.strip():
 | 
						|
                first, last = realname.split(None, 1)
 | 
						|
            else:
 | 
						|
                last = realname
 | 
						|
                first = ""
 | 
						|
            kwargs["lastname"] = last
 | 
						|
            kwargs["firstname"] = first
 | 
						|
        try:
 | 
						|
            user = zammad_session.user.create(kwargs)
 | 
						|
            USERMAP[lowercase_email] = user
 | 
						|
        except:
 | 
						|
            # The use probably exist already...
 | 
						|
            result = list(zammad.user.search(lowercase_email))
 | 
						|
            if len(result) >= 1:
 | 
						|
                user = next(u for u in result if u['email'] == lowercase_email)
 | 
						|
                USERMAP[lowercase_email] = user
 | 
						|
            else:
 | 
						|
                print(f"Could not create/fetch user {lowercase_email}")
 | 
						|
 | 
						|
    if default is None:
 | 
						|
        return USERMAP[lowercase_email][attr]
 | 
						|
 | 
						|
    return USERMAP[lowercase_email].get(attr, default)
 | 
						|
 | 
						|
def remove_existing_zammad_tickets_for(rt_id, zammad, retries=3):
 | 
						|
    try:
 | 
						|
        matching_zammad_tickets= zammad.ticket.search(f"title: \"[RT-{rt_id}]*\"")
 | 
						|
        if len(matching_zammad_tickets) >= 1:
 | 
						|
            print(f"Found existing ticket:")
 | 
						|
            for zt in matching_zammad_tickets:
 | 
						|
                print(f"{zt["id"]} {zt["title"]}")
 | 
						|
                print(f"Deleting Zammad ticket {zt["id"]}")
 | 
						|
                zammad.ticket.destroy(zt["id"])
 | 
						|
    except:
 | 
						|
        print(f"Failed to cleanup duplicates for RT-{rt_id} .. ({retries} retries left)")
 | 
						|
        if retries > 0:
 | 
						|
            print("Sleeping 5 seconds to give Zammad some air...")
 | 
						|
            time.sleep(5)
 | 
						|
            remove_existing_zammad_tickets_for(rt_id, zammad, retries - 1)
 | 
						|
        else:
 | 
						|
             traceback.print_exc()
 | 
						|
             raise RuntimeError
 | 
						|
 | 
						|
# def ensure_user_is_zammad_agent(user, zammad_session):
 | 
						|
#     print(f"Promoting user {user} to Agent")
 | 
						|
#     id = maybe_create_zammad_user(user, zammad_session, "id")
 | 
						|
#     roles = maybe_create_zammad_user(user, zammad_session, "roles")
 | 
						|
#     if "Agent" not in roles:
 | 
						|
#         zammad_session.user.update(
 | 
						|
#             id, {"roles": roles.append("Agent")}
 | 
						|
#         )
 | 
						|
 | 
						|
def create_zammad_ticket(id, zammad, h2t, retries=3):
 | 
						|
    try:
 | 
						|
        with open(f"tickets/{id}", "rb") as handle:
 | 
						|
            rt_ticket = pickle.load(handle)
 | 
						|
 | 
						|
        label = "RT-{}".format(rt_ticket["ticket"]["original_id"])
 | 
						|
        creator = rt_ticket["ticket"]["Creator"]
 | 
						|
        with open(f"users/{creator}", "rb") as handle:
 | 
						|
            rt_creator = pickle.load(handle)
 | 
						|
 | 
						|
        # The RT user object doesn't always contain everything (e.g. disabled users).
 | 
						|
        if "EmailAddress" in rt_creator:
 | 
						|
            creator = rt_creator["EmailAddress"]
 | 
						|
        print(f"Importing {label} ({creator})")
 | 
						|
 | 
						|
        zammad_creator = maybe_create_zammad_user(rt_creator, zammad)
 | 
						|
 | 
						|
        # Make sure that we use what we created in Zammad, independently of what we
 | 
						|
        # had in RT.
 | 
						|
        creator = zammad_creator
 | 
						|
 | 
						|
        zammad_ticket_template = {
 | 
						|
            "title": "[RT-{}] {}".format(id, rt_ticket["ticket"]["Subject"]),
 | 
						|
            "group": "Users",
 | 
						|
            "customer": creator,
 | 
						|
            "note": "RT-import:{}".format(rt_ticket["ticket"]["original_id"]),
 | 
						|
            "article": {
 | 
						|
                "subject": rt_ticket["ticket"]["Subject"],
 | 
						|
            },
 | 
						|
        }
 | 
						|
 | 
						|
        # Ticket creation.
 | 
						|
        merged = False
 | 
						|
        if rt_ticket["ticket"]["original_id"] != rt_ticket["ticket"]["numerical_id"]:
 | 
						|
            merged = True
 | 
						|
            zammad_ticket_template["state_id"] = STATUSMAP["resolved"]
 | 
						|
            zammad_ticket_template["article"]["body"] = "RT ticket merged into {}".format(
 | 
						|
                 rt_ticket["ticket"]["numerical_id"]
 | 
						|
             )
 | 
						|
            zammad_ticket = zammad.ticket.create(zammad_ticket_template)
 | 
						|
        else:
 | 
						|
            zammad_ticket_template["state_id"] = STATUSMAP[rt_ticket["ticket"]["Status"]]
 | 
						|
            body = rt_ticket["history"][0]["Content"] or 'RT Import: empty comment.'
 | 
						|
            zammad_ticket_template["article"]["body"] = body
 | 
						|
            zammad_ticket = zammad.ticket.create(zammad_ticket_template)
 | 
						|
 | 
						|
        print(f"Created Zammad ticket {zammad_ticket['id']} for {label}")
 | 
						|
 | 
						|
        if rt_ticket["ticket"]["Owner"] and rt_ticket["ticket"]["Owner"] != "Nobody":
 | 
						|
            zammad_owner_id = maybe_create_zammad_user(rt_ticket["ticket"]["Owner"], zammad, "id")
 | 
						|
            zammad.ticket.update(
 | 
						|
                zammad_ticket["id"], {"owner_id": zammad_owner_id}
 | 
						|
            )
 | 
						|
 | 
						|
        # Ignore comments for merged tickets.
 | 
						|
        if merged:
 | 
						|
            return zammad_ticket["id"]
 | 
						|
 | 
						|
        # Internal note regarding the RT-Zammad import.
 | 
						|
        TicketArticle(zammad).create(
 | 
						|
            {
 | 
						|
                "ticket_id": zammad_ticket["id"],
 | 
						|
                "body": COMMENT_TEMPLATE.format(**rt_ticket["ticket"]),
 | 
						|
                "internal": True,
 | 
						|
            }
 | 
						|
        )
 | 
						|
 | 
						|
        # Comments/notes within the ticket.
 | 
						|
        for entry in rt_ticket["history"]:
 | 
						|
            if entry["Type"] not in ("Correspond", "Comment"):
 | 
						|
                continue
 | 
						|
 | 
						|
            # Attachments.
 | 
						|
            files = []
 | 
						|
            may_contain_entry_content = None
 | 
						|
            for a, title in entry["Attachments"]:
 | 
						|
                with open(f"attachments/{id}/{a}", "rb") as handle:
 | 
						|
                    data = pickle.load(handle)
 | 
						|
 | 
						|
                if data["Filename"] == "signature.asc":
 | 
						|
                    continue
 | 
						|
 | 
						|
                file_template = {
 | 
						|
                        "filename": data["Filename"],
 | 
						|
                        "data": base64.b64encode(data["Content"]).decode("utf-8"),
 | 
						|
                        "mime-type": data["ContentType"],
 | 
						|
                    }
 | 
						|
 | 
						|
                if data["Filename"] == '':
 | 
						|
                    if may_contain_entry_content is None:
 | 
						|
                        may_contain_entry_content = file_template
 | 
						|
                else:
 | 
						|
                    files.append(file_template)
 | 
						|
            
 | 
						|
            # Comment/note.
 | 
						|
            entry_creator = entry["Creator"]
 | 
						|
            with open(f"users/{entry_creator}", "rb") as handle:
 | 
						|
                rt_entry_creator = pickle.load(handle)
 | 
						|
 | 
						|
            zammad_entry_creator = maybe_create_zammad_user(rt_entry_creator, zammad)
 | 
						|
            entry_content = entry["Content"]
 | 
						|
            if entry_content == '' and may_contain_entry_content:
 | 
						|
                file = may_contain_entry_content
 | 
						|
                entry_content = base64.b64decode(file["data"]).decode("utf-8")
 | 
						|
                if file["mime-type"]:
 | 
						|
                    entry_content = h2t.handle(entry_content)
 | 
						|
            if entry_content.strip() == '':
 | 
						|
                entry_content = "RT: Empty content."
 | 
						|
 | 
						|
            zammad_entry_template = {
 | 
						|
                    "ticket_id": zammad_ticket["id"],
 | 
						|
                    "body": entry_content,
 | 
						|
                    "internal": entry["Type"] == "Comment",
 | 
						|
                    "attachments": files,
 | 
						|
                }
 | 
						|
            entry_creator_id = maybe_create_zammad_user(zammad_entry_creator, zammad, "id")
 | 
						|
 | 
						|
            # We temporarly change the ticket's creator to create a new entry
 | 
						|
            # without giving its author 'Agent' privileges.
 | 
						|
            restore_creator_to = None
 | 
						|
            if entry_creator_id != zammad_ticket["customer_id"]:
 | 
						|
                zammad.ticket.update(zammad_ticket["id"], {"customer_id": entry_creator_id})
 | 
						|
                restore_creator_to = zammad_ticket["customer_id"]
 | 
						|
 | 
						|
            zammad_ticket_id = zammad_ticket["id"]
 | 
						|
            print(f"-> Adding a new entry/comment to ticket {zammad_ticket_id} ({zammad_entry_creator})...")
 | 
						|
            TicketArticle(get_zammad_session(zammad_entry_creator)).create(zammad_entry_template)
 | 
						|
 | 
						|
            if restore_creator_to != None:
 | 
						|
                zammad.ticket.update(zammad_ticket["id"], {"customer_id": restore_creator_to})
 | 
						|
 | 
						|
        # Returns the new Zammad ticket ID.
 | 
						|
        return zammad_ticket["id"]
 | 
						|
 | 
						|
    except KeyboardInterrupt:
 | 
						|
        print("Received keyboard interrupt. Exiting.")
 | 
						|
        sys.exit()
 | 
						|
    except:
 | 
						|
        print(f"Failed to import RT-{id} .. ({retries} retries left)")
 | 
						|
        print("Sleeping 5 seconds to give Zammad some air...")
 | 
						|
        time.sleep(5)
 | 
						|
 | 
						|
        if retries > 0:
 | 
						|
            create_zammad_ticket(id, zammad, h2t, retries - 1)
 | 
						|
        else:
 | 
						|
             traceback.print_exc()
 | 
						|
             raise RuntimeError
 | 
						|
 | 
						|
### main logic ###
 | 
						|
 | 
						|
if not os.path.exists("rt2zammad.json"):
 | 
						|
    print("Missing rt2zammad.json!")
 | 
						|
    print("Create one based on following template:")
 | 
						|
    print(TEMPLATE)
 | 
						|
    sys.exit(1)
 | 
						|
 | 
						|
with open("rt2zammad.json") as handle:
 | 
						|
    config = json.load(handle)
 | 
						|
 | 
						|
h2t = html2text.HTML2Text()
 | 
						|
zammad = get_zammad_session()
 | 
						|
 | 
						|
source_directory = "tickets/"
 | 
						|
if len(sys.argv) >= 2:
 | 
						|
    source_directory = sys.argv[1]
 | 
						|
if not os.path.isdir(source_directory):
 | 
						|
    print(f"Could not find source directory {source_directory}. Exiting.")
 | 
						|
    sys.exit(1)
 | 
						|
 | 
						|
os.makedirs("users", exist_ok=True)
 | 
						|
os.makedirs("tickets", exist_ok=True)
 | 
						|
os.makedirs("attachments", exist_ok=True)
 | 
						|
os.makedirs("failed", exist_ok=True)
 | 
						|
os.makedirs("processed", exist_ok=True)
 | 
						|
 | 
						|
ticket_ids = os.listdir(source_directory)
 | 
						|
print(f"Found {len(ticket_ids)} tickets on filesystem (source directory: {source_directory})")
 | 
						|
ticket_ids = list(map(int, ticket_ids))
 | 
						|
ticket_ids.sort()
 | 
						|
 | 
						|
for id in ticket_ids:
 | 
						|
    try:
 | 
						|
        remove_existing_zammad_tickets_for(id, zammad, 5)
 | 
						|
        zammad_ticket_id = create_zammad_ticket(id, zammad, h2t, 5)
 | 
						|
        dumpfile = f"processed/{id}"
 | 
						|
        with open(dumpfile, "w") as handle:
 | 
						|
            handle.write(f"{zammad_ticket_id}")
 | 
						|
    except SystemExit:
 | 
						|
        sys.exit()
 | 
						|
    except:
 | 
						|
        print(f"Failed to import RT#{id}")
 | 
						|
        dumpfile = f"failed/{id}"
 | 
						|
        with open(dumpfile, "wb") as handle:
 | 
						|
            traceback.print_exc(file=handle)
 |