can_csv_import.rb 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. # Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
  2. require 'csv'
  3. module CanCsvImport
  4. extend ActiveSupport::Concern
  5. # methods defined here are going to extend the class, not the instance of it
  6. class_methods do
  7. =begin
  8. result = Model.csv_import(
  9. string: csv_string,
  10. parse_params: {
  11. col_sep: ',',
  12. },
  13. try: true,
  14. delete: false,
  15. )
  16. result = Model.csv_import(
  17. file: '/file/location/of/file.csv',
  18. parse_params: {
  19. col_sep: ',',
  20. },
  21. try: true,
  22. delete: false,
  23. )
  24. result = TextModule.csv_import(
  25. file: '/Users/me/Downloads/Textbausteine_final.csv',
  26. parse_params: {
  27. col_sep: ',',
  28. },
  29. try: false,
  30. delete: false,
  31. )
  32. returns
  33. {
  34. records: [record1, ...]
  35. try: true, # true|false
  36. success: true, # true|false
  37. }
  38. =end
  39. def csv_import(data)
  40. try = true
  41. if data[:try] != 'true' && data[:try] != true
  42. try = false
  43. end
  44. delete = false
  45. if data[:delete] == true || data[:delete] == 'true'
  46. delete = true
  47. end
  48. errors = []
  49. if delete == true && @csv_delete_possible != true
  50. errors.push "Delete is not possible for #{new.class}."
  51. result = {
  52. errors: errors,
  53. try: try,
  54. result: 'failed',
  55. }
  56. return result
  57. end
  58. if data[:file].present?
  59. raise Exceptions::UnprocessableEntity, "No such file '#{data[:file]}'" if !File.exist?(data[:file])
  60. begin
  61. file = File.open(data[:file], 'r:UTF-8')
  62. data[:string] = file.read
  63. rescue => e
  64. raise Exceptions::UnprocessableEntity, "Unable to read file '#{data[:file]}': #{e.inspect}"
  65. end
  66. end
  67. if data[:string].blank?
  68. errors.push "Unable to parse empty file/string for #{new.class}."
  69. result = {
  70. errors: errors,
  71. try: try,
  72. result: 'failed',
  73. }
  74. return result
  75. end
  76. rows = ::CSV.parse(data[:string], data[:parse_params])
  77. header = rows.shift
  78. if header.blank?
  79. errors.push "Unable to parse file/string without header for #{new.class}."
  80. result = {
  81. errors: errors,
  82. try: try,
  83. result: 'failed',
  84. }
  85. return result
  86. end
  87. header.each do |item|
  88. if item.respond_to?(:strip!)
  89. item.strip!
  90. end
  91. next if !item.respond_to?(:downcase!)
  92. item.downcase!
  93. end
  94. if rows[0].blank?
  95. errors.push "No records found in file/string for #{new.class}."
  96. result = {
  97. errors: errors,
  98. try: try,
  99. result: 'failed',
  100. }
  101. return result
  102. end
  103. # check if min one lookup key exists
  104. if header.count == (header - lookup_keys.map(&:to_s)).count
  105. errors.push "No lookup column like #{lookup_keys.map(&:to_s).join(',')} for #{new.class} found."
  106. result = {
  107. errors: errors,
  108. try: try,
  109. result: 'failed',
  110. }
  111. return result
  112. end
  113. # get payload based on csv
  114. payload = []
  115. rows.each do |row|
  116. if row[0].blank? && row[1].blank?
  117. payload_last = payload.last
  118. row.each_with_index do |item, count|
  119. next if item.blank?
  120. next if header[count].nil?
  121. if payload_last[header[count].to_sym].class != Array
  122. payload_last[header[count].to_sym] = [payload_last[header[count].to_sym]]
  123. end
  124. payload_last[header[count].to_sym].push item.strip
  125. end
  126. next
  127. end
  128. attributes = {}
  129. row.each_with_index do |item, count|
  130. next if !item
  131. next if header[count].blank?
  132. next if @csv_attributes_ignored&.include?(header[count].to_sym)
  133. attributes[header[count].to_sym] = if item.respond_to?(:strip)
  134. item.strip
  135. else
  136. item
  137. end
  138. end
  139. data[:fixed_params]&.each do |key, value|
  140. attributes[key] = value
  141. end
  142. payload.push attributes
  143. end
  144. stats = {
  145. created: 0,
  146. updated: 0,
  147. }
  148. # delete
  149. if delete == true
  150. stats[:deleted] = self.count
  151. if try == false
  152. destroy_all
  153. end
  154. end
  155. # create or update records
  156. csv_object_ids_ignored = @csv_object_ids_ignored || []
  157. records = []
  158. line_count = 0
  159. payload.each do |attributes|
  160. line_count += 1
  161. record = nil
  162. lookup_keys.each do |lookup_by|
  163. next if attributes[lookup_by].blank?
  164. params = {}
  165. params[lookup_by] = if %i[email login].include?(lookup_by)
  166. attributes[lookup_by].downcase
  167. else
  168. attributes[lookup_by]
  169. end
  170. record = lookup(params)
  171. break if record
  172. end
  173. if attributes[:id].present? && !record
  174. errors.push "Line #{line_count}: unknown record with id '#{attributes[:id]}' for #{new.class}."
  175. next
  176. end
  177. if record && csv_object_ids_ignored.include?(record.id)
  178. errors.push "Line #{line_count}: unable to update record with id '#{attributes[:id]}' for #{new.class}."
  179. next
  180. end
  181. begin
  182. clean_params = association_name_to_id_convert(attributes)
  183. rescue => e
  184. errors.push "Line #{line_count}: #{e.message}"
  185. next
  186. end
  187. # create object
  188. Transaction.execute(disable_notification: true, reset_user_id: true, bulk: true) do
  189. UserInfo.current_user_id = clean_params[:updated_by_id] || clean_params[:created_by_id]
  190. if !record || delete == true
  191. stats[:created] += 1
  192. begin
  193. csv_verify_attributes(clean_params)
  194. clean_params = param_cleanup(clean_params)
  195. if !UserInfo.current_user_id
  196. clean_params[:created_by_id] = 1
  197. clean_params[:updated_by_id] = 1
  198. end
  199. record = new(clean_params)
  200. next if try == true
  201. record.associations_from_param(attributes)
  202. record.save!
  203. rescue => e
  204. errors.push "Line #{line_count}: Unable to create record - #{e.message}"
  205. next
  206. end
  207. else
  208. stats[:updated] += 1
  209. next if try == true
  210. begin
  211. csv_verify_attributes(clean_params)
  212. clean_params = param_cleanup(clean_params)
  213. if !UserInfo.current_user_id
  214. clean_params[:updated_by_id] = 1
  215. end
  216. record.with_lock do
  217. record.associations_from_param(attributes)
  218. clean_params.each do |key, value|
  219. record[key] = value
  220. end
  221. next if !record.changed?
  222. record.save!
  223. end
  224. rescue => e
  225. errors.push "Line #{line_count}: Unable to update record - #{e.message}"
  226. next
  227. end
  228. end
  229. end
  230. records.push record
  231. end
  232. result = 'success'
  233. if errors.present?
  234. result = 'failed'
  235. end
  236. {
  237. stats: stats,
  238. records: records,
  239. errors: errors,
  240. try: try,
  241. result: result,
  242. }
  243. end
  244. =begin
  245. verify if attributes are valid, will raise an ArgumentError with "unknown attribute '#{key}' for #{new.class}."
  246. Model.csv_verify_attributes({'attribute': 'some value'})
  247. =end
  248. def csv_verify_attributes(clean_params)
  249. all_clean_attributes = {}
  250. new.attributes.each_key do |attribute|
  251. all_clean_attributes[attribute.to_sym] = true
  252. end
  253. reflect_on_all_associations.map do |assoc|
  254. all_clean_attributes[assoc.name.to_sym] = true
  255. ref = if assoc.name.to_s.end_with?('_id')
  256. "#{assoc.name}_id"
  257. else
  258. "#{assoc.name.to_s.chop}_ids"
  259. end
  260. all_clean_attributes[ref.to_sym] = true
  261. end
  262. clean_params.each_key do |key|
  263. next if all_clean_attributes.key?(key.to_sym)
  264. raise ArgumentError, "unknown attribute '#{key}' for #{new.class}."
  265. end
  266. true
  267. end
  268. =begin
  269. csv_string = Model.csv_example(
  270. col_sep: ',',
  271. )
  272. returns
  273. csv_string
  274. =end
  275. def csv_example(params = {})
  276. header = []
  277. csv_object_ids_ignored = @csv_object_ids_ignored || []
  278. records = where.not(id: csv_object_ids_ignored).offset(1).limit(23).to_a
  279. if records.count < 20
  280. record_ids = records.pluck(:id).concat(csv_object_ids_ignored)
  281. local_records = where.not(id: record_ids).limit(20 - records.count)
  282. records = records.concat(local_records)
  283. end
  284. records_attributes_with_association_names = []
  285. records.each do |record|
  286. record_attributes_with_association_names = record.attributes_with_association_names
  287. records_attributes_with_association_names.push record_attributes_with_association_names
  288. record_attributes_with_association_names.each do |key, value|
  289. next if value.class == ActiveSupport::HashWithIndifferentAccess
  290. next if value.class == Hash
  291. next if @csv_attributes_ignored&.include?(key.to_sym)
  292. next if key.match?(/_id$/)
  293. next if key.match?(/_ids$/)
  294. next if key == 'created_by'
  295. next if key == 'updated_by'
  296. next if key == 'created_at'
  297. next if key == 'updated_at'
  298. next if header.include?(key)
  299. header.push key
  300. end
  301. end
  302. rows = []
  303. records_attributes_with_association_names.each do |record|
  304. row = []
  305. rows_to_add = []
  306. position = -1
  307. header.each do |key|
  308. position += 1
  309. if record[key].class == ActiveSupport::TimeWithZone
  310. row.push record[key].iso8601
  311. next
  312. end
  313. if record[key].class == Array
  314. entry_count = -2
  315. record[key].each do |entry|
  316. entry_count += 1
  317. next if entry_count == -1
  318. if !rows_to_add[entry_count]
  319. rows_to_add[entry_count] = Array.new(header.count + 1) { '' }
  320. end
  321. rows_to_add[entry_count][position] = entry
  322. end
  323. record[key] = record[key][0]
  324. end
  325. row.push record[key]
  326. end
  327. rows.push row
  328. next if rows_to_add.count.zero?
  329. rows_to_add.each do |item|
  330. rows.push item
  331. end
  332. rows_to_add = []
  333. end
  334. ::CSV.generate(params) do |csv|
  335. csv << header
  336. rows.each do |row|
  337. csv << row
  338. end
  339. end
  340. end
  341. =begin
  342. serve method to ignore model based on id
  343. class Model < ApplicationModel
  344. include CanCsvImport
  345. csv_object_ids_ignored(1, 2, 3)
  346. end
  347. =end
  348. def csv_object_ids_ignored(*object_ids)
  349. @csv_object_ids_ignored = object_ids
  350. end
  351. =begin
  352. serve method to ignore model attributes
  353. class Model < ApplicationModel
  354. include CanCsvImport
  355. csv_attributes_ignored :password,
  356. :image_source,
  357. :login_failed,
  358. :source,
  359. :image_source,
  360. :image,
  361. :authorizations,
  362. :organizations
  363. end
  364. =end
  365. def csv_attributes_ignored(*attributes)
  366. @csv_attributes_ignored = attributes
  367. end
  368. =begin
  369. serve method to define if delete option is possible or not
  370. class Model < ApplicationModel
  371. include CanCsvImport
  372. csv_delete_possible true
  373. end
  374. =end
  375. def csv_delete_possible(value)
  376. @csv_delete_possible = value
  377. end
  378. end
  379. end