can_csv_import.rb 10.0 KB

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