can_csv_import.rb 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  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. BulkImportInfo.enable
  189. Transaction.execute(disable_notification: true, reset_user_id: true) do
  190. UserInfo.current_user_id = clean_params[:updated_by_id] || clean_params[:created_by_id]
  191. if !record || delete == true
  192. stats[:created] += 1
  193. begin
  194. csv_verify_attributes(clean_params)
  195. clean_params = param_cleanup(clean_params)
  196. if !UserInfo.current_user_id
  197. clean_params[:created_by_id] = 1
  198. clean_params[:updated_by_id] = 1
  199. end
  200. record = new(clean_params)
  201. next if try == true
  202. record.associations_from_param(attributes)
  203. record.save!
  204. rescue => e
  205. errors.push "Line #{line_count}: Unable to create record - #{e.message}"
  206. next
  207. end
  208. else
  209. stats[:updated] += 1
  210. next if try == true
  211. begin
  212. csv_verify_attributes(clean_params)
  213. clean_params = param_cleanup(clean_params)
  214. if !UserInfo.current_user_id
  215. clean_params[:updated_by_id] = 1
  216. end
  217. record.with_lock do
  218. record.associations_from_param(attributes)
  219. clean_params.each do |key, value|
  220. record[key] = value
  221. end
  222. next if !record.changed?
  223. record.save!
  224. end
  225. rescue => e
  226. errors.push "Line #{line_count}: Unable to update record - #{e.message}"
  227. next
  228. end
  229. end
  230. end
  231. BulkImportInfo.disable
  232. records.push record
  233. end
  234. result = 'success'
  235. if errors.present?
  236. result = 'failed'
  237. end
  238. {
  239. stats: stats,
  240. records: records,
  241. errors: errors,
  242. try: try,
  243. result: result,
  244. }
  245. end
  246. =begin
  247. verify if attributes are valid, will raise an ArgumentError with "unknown attribute '#{key}' for #{new.class}."
  248. Model.csv_verify_attributes({'attribute': 'some value'})
  249. =end
  250. def csv_verify_attributes(clean_params)
  251. all_clean_attributes = {}
  252. new.attributes.each_key do |attribute|
  253. all_clean_attributes[attribute.to_sym] = true
  254. end
  255. reflect_on_all_associations.map do |assoc|
  256. all_clean_attributes[assoc.name.to_sym] = true
  257. ref = if assoc.name.to_s.end_with?('_id')
  258. "#{assoc.name}_id"
  259. else
  260. "#{assoc.name.to_s.chop}_ids"
  261. end
  262. all_clean_attributes[ref.to_sym] = true
  263. end
  264. clean_params.each_key do |key|
  265. next if all_clean_attributes.key?(key.to_sym)
  266. raise ArgumentError, "unknown attribute '#{key}' for #{new.class}."
  267. end
  268. true
  269. end
  270. =begin
  271. csv_string = Model.csv_example(
  272. col_sep: ',',
  273. )
  274. returns
  275. csv_string
  276. =end
  277. def csv_example(params = {})
  278. header = []
  279. csv_object_ids_ignored = @csv_object_ids_ignored || []
  280. records = where.not(id: csv_object_ids_ignored).offset(1).limit(23).to_a
  281. if records.count < 20
  282. record_ids = records.pluck(:id).concat(csv_object_ids_ignored)
  283. local_records = where.not(id: record_ids).limit(20 - records.count)
  284. records = records.concat(local_records)
  285. end
  286. records_attributes_with_association_names = []
  287. records.each do |record|
  288. record_attributes_with_association_names = record.attributes_with_association_names
  289. records_attributes_with_association_names.push record_attributes_with_association_names
  290. record_attributes_with_association_names.each do |key, value|
  291. next if value.class == ActiveSupport::HashWithIndifferentAccess
  292. next if value.class == Hash
  293. next if @csv_attributes_ignored&.include?(key.to_sym)
  294. next if key.match?(/_id$/)
  295. next if key.match?(/_ids$/)
  296. next if key == 'created_by'
  297. next if key == 'updated_by'
  298. next if key == 'created_at'
  299. next if key == 'updated_at'
  300. next if header.include?(key)
  301. header.push key
  302. end
  303. end
  304. rows = []
  305. records_attributes_with_association_names.each do |record|
  306. row = []
  307. rows_to_add = []
  308. position = -1
  309. header.each do |key|
  310. position += 1
  311. if record[key].class == ActiveSupport::TimeWithZone
  312. row.push record[key].iso8601
  313. next
  314. end
  315. if record[key].class == Array
  316. entry_count = -2
  317. record[key].each do |entry|
  318. entry_count += 1
  319. next if entry_count == -1
  320. if !rows_to_add[entry_count]
  321. rows_to_add[entry_count] = Array.new(header.count + 1) { '' }
  322. end
  323. rows_to_add[entry_count][position] = entry
  324. end
  325. record[key] = record[key][0]
  326. end
  327. row.push record[key]
  328. end
  329. rows.push row
  330. next if rows_to_add.count.zero?
  331. rows_to_add.each do |item|
  332. rows.push item
  333. end
  334. rows_to_add = []
  335. end
  336. ::CSV.generate(params) do |csv|
  337. csv << header
  338. rows.each do |row|
  339. csv << row
  340. end
  341. end
  342. end
  343. =begin
  344. serve methode to ignore model based on id
  345. class Model < ApplicationModel
  346. include CanCsvImport
  347. csv_object_ids_ignored(1, 2, 3)
  348. end
  349. =end
  350. def csv_object_ids_ignored(*object_ids)
  351. @csv_object_ids_ignored = object_ids
  352. end
  353. =begin
  354. serve methode to ignore model attributes
  355. class Model < ApplicationModel
  356. include CanCsvImport
  357. csv_attributes_ignored :password,
  358. :image_source,
  359. :login_failed,
  360. :source,
  361. :image_source,
  362. :image,
  363. :authorizations,
  364. :organizations
  365. end
  366. =end
  367. def csv_attributes_ignored(*attributes)
  368. @csv_attributes_ignored = attributes
  369. end
  370. =begin
  371. serve methode to define if delete option is possible or not
  372. class Model < ApplicationModel
  373. include CanCsvImport
  374. csv_delete_possible true
  375. end
  376. =end
  377. def csv_delete_possible(value)
  378. @csv_delete_possible = value
  379. end
  380. end
  381. end