PageRenderTime 51ms CodeModel.GetById 22ms RepoModel.GetById 0ms app.codeStats 0ms

/lib/tender_import/zendesk_api_import.rb

https://github.com/yoyolvturner/tender_import_scripts
Ruby | 312 lines | 241 code | 39 blank | 32 comment | 10 complexity | 085d5e74a65dee7ad557df5f861b1c92 MD5 | raw file
  1. require 'yajl'
  2. require 'faraday'
  3. require 'trollop'
  4. require 'fileutils'
  5. require 'logger'
  6. # Produce a Tender import archive from a ZenDesk site using the ZenDesk API.
  7. class TenderImport::ZendeskApiImport
  8. class Error < StandardError; end
  9. class ResponseJSON < Faraday::Response::Middleware
  10. def parse(body)
  11. Yajl::Parser.parse(body)
  12. end
  13. end
  14. module Log # {{{
  15. attr_reader :logger
  16. def log string
  17. logger.info "#{to_s}: #{string}"
  18. end
  19. def debug string
  20. logger.debug "#{to_s}: #{string}"
  21. end
  22. end # }}}
  23. class Client # {{{
  24. include Log
  25. attr_reader :opts, :conn, :subdomain
  26. # If no options are provided they will be obtained from the command-line.
  27. #
  28. # The options are subdomain, email and password.
  29. #
  30. # There is also an optional logger option, for use with Ruby/Rails
  31. def initialize(options = nil)
  32. @opts = options || command_line_options
  33. @subdomain = opts[:subdomain]
  34. @logger = opts[:logger] || Logger.new(STDOUT).tap {|l| l.level = Logger::INFO}
  35. @conn = Faraday::Connection.new("http://#{subdomain}.zendesk.com") do |b|
  36. b.adapter :net_http
  37. b.use ResponseJSON
  38. end
  39. conn.basic_auth(opts[:email], opts[:password])
  40. end
  41. def to_s
  42. "#{self.class.name} (#{subdomain})"
  43. end
  44. # API helpers # {{{
  45. def user user_id
  46. fetch_resource("users/#{user_id}.json")
  47. end
  48. def users
  49. fetch_paginated_resources("users.json?page=%d")
  50. end
  51. def forums
  52. fetch_resource("forums.json")
  53. end
  54. def entries forum_id
  55. fetch_paginated_resources("forums/#{forum_id}/entries.json?page=%d")
  56. end
  57. def posts entry_id
  58. fetch_paginated_resources("entries/#{entry_id}/posts.json?page=%d", 'posts')
  59. end
  60. def open_tickets
  61. fetch_paginated_resources("search.json?query=type:ticket+status:open+status:pending+status:new&page=%d")
  62. end
  63. # }}}
  64. protected # {{{
  65. # Fetch every page of a given resource. Must provide a sprintf format string
  66. # with a single integer for the page specification.
  67. #
  68. # Example: "users.json?page=%d"
  69. #
  70. # In some cases the desired data is not in the top level of the payload. In
  71. # that case specify resource_key to pull the data from that key.
  72. def fetch_resource resource_url, resource_key = nil
  73. debug "fetching #{resource_url}"
  74. loop do
  75. response = conn.get(resource_url)
  76. if response.success?
  77. return resource_key ? response.body[resource_key] : response.body
  78. elsif response.status == 503
  79. log "got a 503 (API throttle), waiting 30 seconds..."
  80. sleep 30
  81. else
  82. raise Error, "failed to get resource #{resource_format}: #{response.inspect}"
  83. end
  84. end
  85. end
  86. # Fetch every page of a given resource. Must provide a sprintf format string
  87. # with a single integer for the page specification.
  88. #
  89. # Example: "users.json?page=%d"
  90. #
  91. # In some cases the desired data is not in the top level of the payload. In
  92. # that case specify resource_key to pull the data from that key.
  93. def fetch_paginated_resources resource_format, resource_key = nil
  94. resources = []
  95. page = 1
  96. loop do
  97. resource = fetch_resource(resource_format % page, resource_key)
  98. break if resource.empty?
  99. page += 1
  100. resources += resource
  101. end
  102. resources
  103. end
  104. def command_line_options
  105. options = Trollop::options do
  106. banner <<-EOM
  107. Usage:
  108. #{$0} -e <email> -p <password> -s <subdomain>
  109. Prerequisites:
  110. # Ruby gems (should already be installed)
  111. gem install faraday
  112. gem install trollop
  113. gem install yajl-ruby
  114. # Python tools (must be in your PATH)
  115. html2text.py: http://www.aaronsw.com/2002/html2text/
  116. Options:
  117. EOM
  118. opt :email, "user email address", :type => String
  119. opt :password, "user password", :type => String
  120. opt :subdomain, "subdomain", :type => String
  121. end
  122. [:email, :password, :subdomain ].each do |option|
  123. Trollop::die option, "is required" if options[option].nil?
  124. end
  125. return options
  126. end
  127. # }}}
  128. end # }}}
  129. class Exporter # {{{
  130. attr_reader :logger, :client
  131. include Log
  132. include FileUtils
  133. def initialize client
  134. @client = client
  135. @author_email = {}
  136. @processed_users = {}
  137. @logger = client.logger
  138. @archive = TenderImport::Archive.new(client.subdomain)
  139. if `which html2text.py`.empty?
  140. raise Error, 'missing prerequisite: html2text.py is not in your PATH'
  141. end
  142. end
  143. def to_s
  144. "#{self.class.name} (#{client.subdomain})"
  145. end
  146. def stats
  147. @archive.stats
  148. end
  149. def report
  150. @archive.report
  151. end
  152. def export_users # {{{
  153. log 'exporting users'
  154. client.users.each do |user|
  155. @author_email[user['id'].to_s] = user['email']
  156. log "exporting user #{user['email']}"
  157. @archive.add_user \
  158. :name => user['name'],
  159. :email => user['email'],
  160. :created_at => user['created_at'],
  161. :updated_at => user['updated_at'],
  162. :state => (user['roles'].to_i == 0 ? 'user' : 'support')
  163. end
  164. end # }}}
  165. def export_categories # {{{
  166. log 'exporting categories'
  167. client.forums.each do |forum|
  168. log "exporting category #{forum['name']}"
  169. category = @archive.add_category \
  170. :name => forum['name'],
  171. :summary => forum['description']
  172. export_discussions(forum['id'], category)
  173. end
  174. end # }}}
  175. def export_tickets # {{{
  176. log "exporting open tickets"
  177. tickets = client.open_tickets
  178. if tickets.size > 0
  179. # create category for tickets
  180. log "creating ticket category"
  181. category = @archive.add_category \
  182. :name => 'Tickets',
  183. :summary => 'Imported from ZenDesk.'
  184. # export tickets into new category
  185. tickets.each do |ticket|
  186. comments = ticket['comments'].map do |post|
  187. {
  188. :body => post['value'],
  189. :author_email => author_email(post['author_id']),
  190. :created_at => post['created_at'],
  191. :updated_at => post['updated_at'],
  192. }
  193. end
  194. log "exporting ticket #{ticket['nice_id']}"
  195. @archive.add_discussion category,
  196. :title => ticket['subject'],
  197. :state => ticket['is_locked'] ? 'resolved' : 'open',
  198. :private => !ticket['is_public'],
  199. :author_email => author_email(ticket['submitter_id']),
  200. :created_at => ticket['created_at'],
  201. :updated_at => ticket['updated_at'],
  202. :comments => comments
  203. end
  204. end
  205. end # }}}
  206. def export_discussions forum_id, category # {{{
  207. client.entries(forum_id).each do |entry|
  208. comments = client.posts(entry['id']).map do |post|
  209. dump_body post, post['body']
  210. {
  211. :body => load_body(entry),
  212. :author_email => author_email(post['user_id']),
  213. :created_at => post['created_at'],
  214. :updated_at => post['updated_at'],
  215. }
  216. end
  217. dump_body entry, entry['body']
  218. log "exporting discussion #{entry['title']}"
  219. @archive.add_discussion category,
  220. :title => entry['title'],
  221. :author_email => author_email(entry['submitter_id']),
  222. :comments => [{
  223. :body => load_body(entry),
  224. :author_email => author_email(entry['submitter_id']),
  225. :created_at => entry['created_at'],
  226. :updated_at => entry['updated_at'],
  227. }] + comments
  228. rm "tmp/#{entry['id']}_body.html"
  229. end
  230. end # }}}
  231. def create_archive # {{{
  232. export_file = @archive.write_archive
  233. log "created #{export_file}"
  234. return export_file
  235. end # }}}
  236. protected
  237. def author_email user_id
  238. # the cache should be populated during export_users but we'll attempt
  239. # to fetch unrecognized ids just in case
  240. @author_email[user_id.to_s] ||= (client.user(user_id)['email'] rescue nil)
  241. end
  242. def dump_body entry, body
  243. File.open(File.join("tmp", "#{entry['id']}_body.html"), "w") do |file|
  244. file.write(body)
  245. end
  246. end
  247. def load_body entry
  248. `html2text.py /$PWD/tmp/#{entry['id']}_body.html`
  249. end
  250. end # }}}
  251. # Produce a complete import archive either from API or command line options.
  252. def self.run options=nil
  253. begin
  254. client = Client.new options
  255. exporter = Exporter.new client
  256. exporter.export_users
  257. exporter.export_categories
  258. exporter.export_tickets
  259. exporter.create_archive
  260. rescue Error => e
  261. puts "FAILED WITH AN ERROR"
  262. puts e.to_s
  263. exit 1
  264. ensure
  265. if exporter
  266. puts "RESULTS"
  267. puts exporter.stats.inspect
  268. puts exporter.report.join("\n")
  269. end
  270. end
  271. end
  272. end
  273. # vi:foldmethod=marker