can_csv_import.rb 9.1 KB

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