import_zammad_history.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. #!/usr/bin/env python3
  2. import json
  3. import os
  4. import re
  5. import sqlite3
  6. import time
  7. from html import unescape
  8. from pathlib import Path
  9. from urllib.parse import urljoin
  10. import requests
  11. BASE = Path(".")
  12. ENV = BASE / ".env"
  13. DB = BASE / "data/zammad_history.sqlite3"
  14. PER_PAGE = 100
  15. def load_env():
  16. if not ENV.exists():
  17. raise SystemExit(".env fehlt")
  18. for line in ENV.read_text().splitlines():
  19. line = line.strip()
  20. if not line or line.startswith("#") or "=" not in line:
  21. continue
  22. key, value = line.split("=", 1)
  23. value = value.strip().strip('"').strip("'")
  24. os.environ.setdefault(key, value)
  25. def clean_html(value):
  26. if not value:
  27. return ""
  28. value = re.sub(
  29. r"<(script|style).*?</\1>",
  30. " ",
  31. value,
  32. flags=re.I | re.S,
  33. )
  34. value = re.sub(r"<br\s*/?>", "\n", value, flags=re.I)
  35. value = re.sub(r"</p\s*>", "\n", value, flags=re.I)
  36. value = re.sub(r"</div\s*>", "\n", value, flags=re.I)
  37. value = re.sub(r"<[^>]+>", " ", value)
  38. value = unescape(value)
  39. value = re.sub(r"[ \t]+", " ", value)
  40. value = re.sub(r"\n\s*\n+", "\n\n", value)
  41. return value.strip()
  42. def api(session, url, params=None):
  43. for attempt in range(5):
  44. response = session.get(
  45. url,
  46. params=params,
  47. timeout=60,
  48. )
  49. if response.status_code == 429:
  50. wait = int(
  51. response.headers.get(
  52. "Retry-After",
  53. "5",
  54. )
  55. )
  56. print(f"Rate limit – warte {wait}s")
  57. time.sleep(wait)
  58. continue
  59. response.raise_for_status()
  60. return response.json()
  61. raise RuntimeError(f"API nicht erreichbar: {url}")
  62. def init_db(con):
  63. con.executescript("""
  64. CREATE TABLE IF NOT EXISTS tickets (
  65. id INTEGER PRIMARY KEY,
  66. number TEXT,
  67. title TEXT,
  68. group_name TEXT,
  69. state TEXT,
  70. state_id INTEGER,
  71. priority TEXT,
  72. priority_id INTEGER,
  73. customer_id INTEGER,
  74. customer_email TEXT,
  75. owner_id INTEGER,
  76. organization_id INTEGER,
  77. created_at TEXT,
  78. updated_at TEXT,
  79. close_at TEXT,
  80. tags_json TEXT,
  81. custom_fields_json TEXT,
  82. raw_json TEXT,
  83. imported_at TEXT DEFAULT CURRENT_TIMESTAMP
  84. );
  85. CREATE TABLE IF NOT EXISTS articles (
  86. id INTEGER PRIMARY KEY,
  87. ticket_id INTEGER NOT NULL,
  88. type TEXT,
  89. sender TEXT,
  90. sender_id INTEGER,
  91. from_address TEXT,
  92. to_address TEXT,
  93. subject TEXT,
  94. internal INTEGER,
  95. created_at TEXT,
  96. body_html TEXT,
  97. body_text TEXT,
  98. content_type TEXT,
  99. attachments_json TEXT,
  100. raw_json TEXT,
  101. imported_at TEXT DEFAULT CURRENT_TIMESTAMP
  102. );
  103. CREATE INDEX IF NOT EXISTS idx_articles_ticket
  104. ON articles(ticket_id);
  105. CREATE INDEX IF NOT EXISTS idx_tickets_updated
  106. ON tickets(updated_at);
  107. """)
  108. def main():
  109. load_env()
  110. base_url = os.environ["ZAMMAD_URL"].rstrip("/")
  111. user = os.environ["ZAMMAD_USER"]
  112. password = os.environ["ZAMMAD_PASSWORD"]
  113. session = requests.Session()
  114. session.auth = (user, password)
  115. session.headers.update({
  116. "Accept": "application/json",
  117. "Content-Type": "application/json",
  118. "User-Agent": "Schmid-Telefonie-Taxonomy-Importer/1.0",
  119. })
  120. DB.parent.mkdir(parents=True, exist_ok=True)
  121. con = sqlite3.connect(DB)
  122. init_db(con)
  123. print("=" * 72)
  124. print("ZAMMAD HISTORIENIMPORT")
  125. print("=" * 72)
  126. print(f"Server: {base_url}")
  127. print(f"Ziel: {DB}")
  128. print()
  129. page = 1
  130. total_tickets = 0
  131. total_articles = 0
  132. while True:
  133. tickets = api(
  134. session,
  135. f"{base_url}/api/v1/tickets",
  136. {
  137. "page": page,
  138. "per_page": PER_PAGE,
  139. "order_by": "id",
  140. "order_direction": "asc",
  141. },
  142. )
  143. if not tickets:
  144. break
  145. print(
  146. f"Seite {page}: "
  147. f"{len(tickets)} Tickets"
  148. )
  149. for ticket in tickets:
  150. ticket_id = ticket["id"]
  151. con.execute("""
  152. INSERT OR REPLACE INTO tickets (
  153. id,
  154. number,
  155. title,
  156. group_name,
  157. state,
  158. state_id,
  159. priority,
  160. priority_id,
  161. customer_id,
  162. customer_email,
  163. owner_id,
  164. organization_id,
  165. created_at,
  166. updated_at,
  167. close_at,
  168. tags_json,
  169. custom_fields_json,
  170. raw_json
  171. )
  172. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  173. """, (
  174. ticket_id,
  175. ticket.get("number"),
  176. ticket.get("title"),
  177. ticket.get("group"),
  178. ticket.get("state"),
  179. ticket.get("state_id"),
  180. ticket.get("priority"),
  181. ticket.get("priority_id"),
  182. ticket.get("customer_id"),
  183. ticket.get("customer"),
  184. ticket.get("owner_id"),
  185. ticket.get("organization_id"),
  186. ticket.get("created_at"),
  187. ticket.get("updated_at"),
  188. ticket.get("close_at"),
  189. json.dumps(
  190. ticket.get("tags", []),
  191. ensure_ascii=False,
  192. ),
  193. json.dumps(
  194. ticket.get("preferences", {}),
  195. ensure_ascii=False,
  196. ),
  197. json.dumps(
  198. ticket,
  199. ensure_ascii=False,
  200. ),
  201. ))
  202. articles = api(
  203. session,
  204. f"{base_url}/api/v1/ticket_articles/by_ticket/{ticket_id}",
  205. )
  206. for article in articles:
  207. body = article.get("body") or ""
  208. con.execute("""
  209. INSERT OR REPLACE INTO articles (
  210. id,
  211. ticket_id,
  212. type,
  213. sender,
  214. sender_id,
  215. from_address,
  216. to_address,
  217. subject,
  218. internal,
  219. created_at,
  220. body_html,
  221. body_text,
  222. content_type,
  223. attachments_json,
  224. raw_json
  225. )
  226. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  227. """, (
  228. article["id"],
  229. ticket_id,
  230. article.get("type"),
  231. article.get("sender"),
  232. article.get("sender_id"),
  233. article.get("from"),
  234. article.get("to"),
  235. article.get("subject"),
  236. int(bool(article.get("internal"))),
  237. article.get("created_at"),
  238. body,
  239. clean_html(body),
  240. article.get("content_type"),
  241. json.dumps(
  242. article.get("attachments", []),
  243. ensure_ascii=False,
  244. ),
  245. json.dumps(
  246. article,
  247. ensure_ascii=False,
  248. ),
  249. ))
  250. total_articles += 1
  251. total_tickets += 1
  252. if total_tickets % 100 == 0:
  253. con.commit()
  254. print(
  255. f" {total_tickets} Tickets / "
  256. f"{total_articles} Artikel"
  257. )
  258. con.commit()
  259. if len(tickets) < PER_PAGE:
  260. break
  261. page += 1
  262. con.commit()
  263. ticket_count = con.execute(
  264. "SELECT COUNT(*) FROM tickets"
  265. ).fetchone()[0]
  266. article_count = con.execute(
  267. "SELECT COUNT(*) FROM articles"
  268. ).fetchone()[0]
  269. con.close()
  270. print()
  271. print("=" * 72)
  272. print("IMPORT FERTIG")
  273. print("=" * 72)
  274. print(f"Tickets: {ticket_count}")
  275. print(f"Artikel: {article_count}")
  276. print(f"DB: {DB}")
  277. if __name__ == "__main__":
  278. main()