can_csv_import.rb 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  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 = data[:try].to_s == 'true'
  41. delete = data[:delete].to_s == 'true'
  42. begin
  43. data[:string] = File.read(data[:file]) if data[:file].present?
  44. rescue Errno::ENOENT
  45. raise Exceptions::UnprocessableEntity, "No such file '#{data[:file]}'"
  46. rescue => e
  47. raise Exceptions::UnprocessableEntity, "Unable to read file '#{data[:file]}': #{e.inspect}"
  48. end
  49. header, *rows = ::CSV.parse(data[:string], data[:parse_params])
  50. header&.each { |column| column.try(:strip!) }
  51. header&.each { |column| column.try(:downcase!) }
  52. begin
  53. raise "Delete is not possible for #{self}." if delete && !csv_delete_possible
  54. raise "Unable to parse empty file/string for #{self}." if data[:string].blank?
  55. raise "Unable to parse file/string without header for #{self}." if header.blank?
  56. raise "No records found in file/string for #{self}." if rows.first.blank?
  57. raise "No lookup column like #{lookup_keys.map(&:to_s).join(',')} for #{self} found." if (header & lookup_keys.map(&:to_s)).none?
  58. rescue => e
  59. return {
  60. try: try,
  61. result: 'failed',
  62. errors: [e.message],
  63. }
  64. end
  65. # get payload based on csv
  66. payload = []
  67. rows.each do |row|
  68. if row.first(2).any?(&:present?)
  69. payload.push(
  70. header.zip(row).to_h
  71. .compact.transform_values(&:strip)
  72. .except(nil).transform_keys(&:to_sym)
  73. .except(*csv_attributes_ignored)
  74. .merge(data[:fixed_params] || {})
  75. )
  76. else
  77. header.zip(row).to_h
  78. .compact.transform_values(&:strip)
  79. .except(nil).transform_keys(&:to_sym)
  80. .each { |col, val| payload.last[col] = [*payload.last[col], val] }
  81. end
  82. end
  83. stats = {
  84. created: 0,
  85. updated: 0,
  86. deleted: (count if delete),
  87. }.compact
  88. # delete
  89. destroy_all if delete && !try
  90. # create or update records
  91. records = []
  92. errors = []
  93. transaction do
  94. payload.each.with_index do |attributes, i|
  95. record = (lookup_keys & attributes.keys).lazy.map do |lookup_key|
  96. params = attributes.slice(lookup_key)
  97. params.transform_values!(&:downcase) if lookup_key.in?(%i[email login])
  98. lookup(params)
  99. end.detect(&:present?)
  100. if record&.in?(records)
  101. errors.push "Line #{i.next}: duplicate record found."
  102. next
  103. end
  104. if !record && attributes[:id].present?
  105. errors.push "Line #{i.next}: unknown #{self} with id '#{attributes[:id]}'."
  106. next
  107. end
  108. if record&.id&.in?(csv_object_ids_ignored)
  109. errors.push "Line #{i.next}: unable to update #{self} with id '#{attributes[:id]}'."
  110. next
  111. end
  112. begin
  113. clean_params = association_name_to_id_convert(attributes)
  114. rescue => e
  115. errors.push "Line #{i.next}: #{e.message}"
  116. next
  117. end
  118. # create object
  119. Transaction.execute(disable_notification: true, reset_user_id: true, bulk: true) do
  120. UserInfo.current_user_id = clean_params[:updated_by_id] || clean_params[:created_by_id]
  121. if !record || delete == true
  122. stats[:created] += 1
  123. begin
  124. csv_verify_attributes(clean_params)
  125. record = new(param_cleanup(clean_params).reverse_merge(created_by_id: 1, updated_by_id: 1))
  126. record.associations_from_param(attributes)
  127. record.save!
  128. rescue => e
  129. errors.push "Line #{i.next}: Unable to create record - #{e.message}"
  130. next
  131. end
  132. else
  133. stats[:updated] += 1
  134. begin
  135. csv_verify_attributes(clean_params)
  136. clean_params = param_cleanup(clean_params).reverse_merge(updated_by_id: 1)
  137. record.with_lock do
  138. record.associations_from_param(attributes)
  139. record.assign_attributes(clean_params)
  140. record.save! if record.changed?
  141. end
  142. rescue => e
  143. errors.push "Line #{i.next}: Unable to update record - #{e.message}"
  144. next
  145. end
  146. end
  147. end
  148. records.push record if record
  149. end
  150. ensure
  151. raise ActiveRecord::Rollback if try || errors.any?
  152. end
  153. {
  154. stats: stats,
  155. records: records,
  156. errors: errors,
  157. try: try,
  158. result: errors.empty? ? 'success' : 'failed',
  159. }
  160. end
  161. =begin
  162. verify if attributes are valid, will raise an ArgumentError with "unknown attribute '#{key}' for #{new.class}."
  163. Model.csv_verify_attributes({'attribute': 'some value'})
  164. =end
  165. def csv_verify_attributes(clean_params)
  166. all_clean_attributes = {}
  167. new.attributes.each_key do |attribute|
  168. all_clean_attributes[attribute.to_sym] = true
  169. end
  170. reflect_on_all_associations.map do |assoc|
  171. all_clean_attributes[assoc.name.to_sym] = true
  172. ref = if assoc.name.to_s.end_with?('_id')
  173. "#{assoc.name}_id"
  174. else
  175. "#{assoc.name.to_s.chop}_ids"
  176. end
  177. all_clean_attributes[ref.to_sym] = true
  178. end
  179. clean_params.each_key do |key|
  180. next if all_clean_attributes.key?(key.to_sym)
  181. raise ArgumentError, "unknown attribute '#{key}' for #{new.class}."
  182. end
  183. true
  184. end
  185. =begin
  186. csv_string = Model.csv_example(
  187. col_sep: ',',
  188. )
  189. returns
  190. csv_string
  191. =end
  192. def csv_example(params = {})
  193. header = []
  194. records = where.not(id: csv_object_ids_ignored).offset(1).limit(23).to_a
  195. if records.count < 20
  196. record_ids = records.pluck(:id).concat(csv_object_ids_ignored)
  197. local_records = where.not(id: record_ids).limit(20 - records.count)
  198. records = records.concat(local_records)
  199. end
  200. records_attributes_with_association_names = []
  201. records.each do |record|
  202. record_attributes_with_association_names = record.attributes_with_association_names
  203. records_attributes_with_association_names.push record_attributes_with_association_names
  204. record_attributes_with_association_names.each do |key, value|
  205. next if value.class == ActiveSupport::HashWithIndifferentAccess
  206. next if value.class == Hash
  207. next if csv_attributes_ignored&.include?(key.to_sym)
  208. next if key.end_with?('_id')
  209. next if key.end_with?('_ids')
  210. next if key == 'created_by'
  211. next if key == 'updated_by'
  212. next if key == 'created_at'
  213. next if key == 'updated_at'
  214. next if header.include?(key)
  215. header.push key
  216. end
  217. end
  218. rows = []
  219. records_attributes_with_association_names.each do |record|
  220. row = []
  221. rows_to_add = []
  222. position = -1
  223. header.each do |key|
  224. position += 1
  225. if record[key].class == ActiveSupport::TimeWithZone
  226. row.push record[key].iso8601
  227. next
  228. end
  229. if record[key].class == Array
  230. entry_count = -2
  231. record[key].each do |entry|
  232. entry_count += 1
  233. next if entry_count == -1
  234. if !rows_to_add[entry_count]
  235. rows_to_add[entry_count] = Array.new(header.count + 1) { '' }
  236. end
  237. rows_to_add[entry_count][position] = entry
  238. end
  239. record[key] = record[key][0]
  240. end
  241. row.push record[key]
  242. end
  243. rows.push row
  244. next if rows_to_add.count.zero?
  245. rows_to_add.each do |item|
  246. rows.push item
  247. end
  248. rows_to_add = []
  249. end
  250. ::CSV.generate(params) do |csv|
  251. csv << header
  252. rows.each do |row|
  253. csv << row
  254. end
  255. end
  256. end
  257. =begin
  258. serve method to ignore model based on id
  259. class Model < ApplicationModel
  260. include CanCsvImport
  261. csv_object_ids_ignored(1, 2, 3)
  262. end
  263. =end
  264. def csv_object_ids_ignored(*object_ids)
  265. return @csv_object_ids_ignored || [] if object_ids.empty?
  266. @csv_object_ids_ignored = object_ids
  267. end
  268. =begin
  269. serve method to ignore model attributes
  270. class Model < ApplicationModel
  271. include CanCsvImport
  272. csv_attributes_ignored :password,
  273. :image_source,
  274. :login_failed,
  275. :source,
  276. :image_source,
  277. :image,
  278. :authorizations,
  279. :organizations
  280. end
  281. =end
  282. def csv_attributes_ignored(*attributes)
  283. return @csv_attributes_ignored || [] if attributes.empty?
  284. @csv_attributes_ignored = attributes
  285. end
  286. =begin
  287. serve method to define if delete option is possible or not
  288. class Model < ApplicationModel
  289. include CanCsvImport
  290. csv_delete_possible true
  291. end
  292. =end
  293. def csv_delete_possible(*value)
  294. return @csv_delete_possible if value.empty?
  295. @csv_delete_possible = value.first
  296. end
  297. end
  298. end