diff --git a/fs2zammad b/fs2zammad new file mode 100755 index 0000000..a914be3 --- /dev/null +++ b/fs2zammad @@ -0,0 +1,220 @@ +#!/usr/bin/env python + +import base64 +import json +import os +import pickle +import sys + +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 + +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 or "EmailAddress" not in userdata: + 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: + last, first = realname.split(", ", 1) + elif " " in realname: + 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 = list.pop(result) + 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) + +### 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) + +zammad = get_zammad_session() + +os.makedirs("users", exist_ok=True) +os.makedirs("tickets", exist_ok=True) +os.makedirs("attachments", exist_ok=True) + +ticket_ids = os.listdir("tickets/") +print(f"Found {len(ticket_ids)} tickets on filesystem.") + +for id in ticket_ids: + 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_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 = get_zammad_session(creator).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 = get_zammad_session(creator).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: + continue + + # 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 = [] + for a, title in entry["Attachments"]: + with open(f"attachments/{id}/{a}", "rb") as handle: + data = pickle.load(handle) + + if data["Filename"] in ("", "signature.asc"): + continue + files.append( + { + "filename": data["Filename"], + "data": base64.b64encode(data["Content"]).decode("utf-8"), + "mime-type": data["ContentType"], + } + ) + + # Comment/note. + entry_creator = entry["Creator"] + with open(f"users/{entry_creator}", "rb") as handle: + rt_entry_creator = pickle.load(handle) + + zammad_creator = maybe_create_zammad_user(rt_entry_creator, zammad) + zammad_entry_template = { + "ticket_id": zammad_ticket["id"], + "body": entry["Content"], + "internal": entry["Type"] == "Comment", + "attachments": files, + } + try: + TicketArticle(get_zammad_session(zammad_creator)).create(zammad_entry_template) + except: + print(f"FIXME: Failed to add comment to ticket RT#{id}/Zammad#{zammad_ticket["id"]}: user permission issue?") diff --git a/rt2fs b/rt2fs index 486b94a..c841c0d 100755 --- a/rt2fs +++ b/rt2fs @@ -18,8 +18,6 @@ TEMPLATE = """{ "rt_pass": "", "rt_start": 1, "rt_end": 1000, -"usermap": {}, -"userdata": {} } """ diff --git a/rt2zammad.py b/rt2zammad.py deleted file mode 100755 index 424b05d..0000000 --- a/rt2zammad.py +++ /dev/null @@ -1,275 +0,0 @@ -#!/usr/bin/env python -""" -Quick and dirty attempt to migrate issues from Request Tracker to Zammad. -""" - -import base64 -import json -import os -import pickle -import sys - -from rt import Rt -from zammad_py import ZammadAPI -from zammad_py.api import Resource, TagList, TicketArticle - - -class Tag(Resource): - - path_attribute = "tags" - - def add(self, obj, id, item): - response = self._connection.session.post( - self.url + "/add", - data={ - "object": obj, - "o_id": id, - "item": item, - }, - ) - return self._raise_or_return_json(response) - - -TEMPLATE = """{ -"zammad_host": "", -"zammad_user": "", -"zammad_password": "", -"zammad_secure": true, -"rt_url": "", -"rt_user": "", -"rt_pass": "", -"rt_start": 1, -"rt_end": 1000, -"usermap": {}, -"userdata": {} -} -""" - -COMMENT_TEMPLATE = """ -Ticket imported from Request Tracker - -URL: https://oldsupport.weblate.org/Ticket/Display.html?id={numerical_id} -Created: {Created} -Resolved: {Resolved} -""" - - -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) - - -ZAMMADS = {} - - -def get_zammad(user=None): - if user not in ZAMMADS: - kwargs = {} - if user: - kwargs["on_behalf_of"] = user - ZAMMADS[user] = ZammadAPI( - host=config["zammad_host"], - username=config["zammad_user"], - password=config["zammad_password"], - is_secure=config["zammad_secure"], - **kwargs, - ) - return ZAMMADS[user] - - -target = get_zammad() -target.user.me() - -source = Rt(config["rt_url"], config["rt_user"], config["rt_pass"]) -if not source.login(): - print("Failed to login to RT!") - sys.exit(2) - -if os.path.exists("rt2zammad.cache"): - # Load RT from cache - with open("rt2zammad.cache", "rb") as handle: - data = pickle.load(handle) - users = data["users"] - queues = data["queues"] - tickets = data["tickets"] - attachments = data["attachments"] - -else: - # Load RT from remote - users = {} - attachments = {} - tickets = [] - queues = set() - - def ensure_user(username): - if username not in users: - users[username] = source.get_user(username) - - for i in range(config["rt_start"], config["rt_end"]): - print(f"Loading ticket {i}") - ticket = source.get_ticket(i) - if ticket is None: - break - ticket["original_id"] = str(i) - queues.add(ticket["Queue"]) - ensure_user(ticket["Creator"]) - ensure_user(ticket["Owner"]) - - if ticket["original_id"] != ticket["numerical_id"]: - # Merged ticket - history = [] - else: - history = source.get_history(i) - for item in history: - for a, title in item["Attachments"]: - attachments[a] = source.get_attachment(i, a) - ensure_user(item["Creator"]) - tickets.append({"ticket": ticket, "history": history}) - with open("rt2zammad.cache", "wb") as handle: - data = pickle.dump( - { - "users": users, - "queues": queues, - "tickets": tickets, - "attachments": attachments, - }, - handle, - ) - -# Create tags -tag_list = TagList(target) -ticket_article = TicketArticle(target) -tag_obj = Tag(target) -tags = {tag["name"] for tag in tag_list.all()} -for queue in queues: - queue = queue.lower().split()[0] - if queue not in tags: - tag_list.create({"name": queue}) - -STATUSMAP = {"new": 1, "open": 2, "resolved": 4, "rejected": 4, "deleted": 4} - -USERMAP = {} - -for user in target.user.all(): - USERMAP[user["email"].lower()] = user - - -def get_user(userdata, attr="login", default=None): - email = userdata["EmailAddress"] - lemail = email.lower() - if lemail in config["usermap"]: - email = lemail = config["usermap"][lemail] - # Search existing users - if lemail not in USERMAP: - for user in target.user.search({"query": email}): - USERMAP[user["email"].lower()] = user - # Create new one - if lemail not in USERMAP: - kwargs = {"email": email} - kwargs.update(config["userdata"]) - if "RealName" in userdata: - realname = userdata["RealName"] - if ", " in realname: - last, first = realname.split(", ", 1) - elif " " in realname: - first, last = realname.split(None, 1) - else: - last = realname - first = "" - kwargs["lastname"] = last - kwargs["firstname"] = first - user = target.user.create(kwargs) - USERMAP[user["email"].lower()] = user - - if default is None: - return USERMAP[lemail][attr] - return USERMAP[lemail].get(attr, default) - - -# Create tickets -for ticket in tickets: - label = "RT-{}".format(ticket["ticket"]["original_id"]) - creator = get_user(users[ticket["ticket"]["Creator"]]) - - print(f"Importing {label} ({creator})") - - create_args = { - "title": ticket["ticket"]["Subject"], - "group": "Users", - "customer": creator, - "note": "RT-import:{}".format(ticket["ticket"]["original_id"]), - "article": { - "subject": ticket["ticket"]["Subject"], - }, - } - - merged = False - if ticket["ticket"]["original_id"] != ticket["ticket"]["numerical_id"]: - # Merged ticket - merged = True - create_args["state_id"] = 4 - create_args["article"]["body"] = "RT ticket merged into {}".format( - ticket["ticket"]["numerical_id"] - ) - new = get_zammad(creator).ticket.create(create_args) - else: - create_args["state_id"] = STATUSMAP[ticket["ticket"]["Status"]] - create_args["article"]["body"] = ticket["history"][0]["Content"] - new = get_zammad(creator).ticket.create(create_args) - - print(f"Created ticket {new['id']}") - - if ticket["ticket"]["Owner"] and ticket["ticket"]["Owner"] != "Nobody": - get_zammad().ticket.update( - new["id"], {"owner_id": get_user(users[ticket["ticket"]["Owner"]], "id")} - ) - - if merged: - # Do not add comments to merged ticket - continue - - tag_obj.add("Ticket", new["id"], ticket["ticket"]["Queue"].lower().split()[0]) - ticket_article.create( - { - "ticket_id": new["id"], - "body": COMMENT_TEMPLATE.format(**ticket["ticket"]), - "internal": True, - } - ) - - for item in ticket["history"]: - if item["Type"] not in ("Correspond", "Comment"): - continue - files = [] - for a, title in item["Attachments"]: - data = attachments[a] - if data["Filename"] in ("", "signature.asc"): - continue - files.append( - { - "filename": data["Filename"], - "data": base64.b64encode(data["Content"]).decode("utf-8"), - "mime-type": data["ContentType"], - } - ) - creator_id = get_user(users[item["Creator"]], "id") - chown = creator_id != new["customer_id"] and "Agent" not in get_user( - users[item["Creator"]], "roles", [] - ) - if chown: - target.ticket.update(new["id"], {"customer_id": creator_id}) - TicketArticle(get_zammad(get_user(users[item["Creator"]]))).create( - { - "ticket_id": new["id"], - "body": item["Content"], - "internal": item["Type"] == "Comment", - "attachments": files, - } - ) - if chown: - target.ticket.update(new["id"], {"customer_id": new["customer_id"]})