can_csv_import.rb 9.5 KB

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