123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340 |
- # Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
- module CanCsvImport
- extend ActiveSupport::Concern
- # methods defined here are going to extend the class, not the instance of it
- class_methods do
- =begin
- result = Model.csv_import(
- string: csv_string,
- parse_params: {
- col_sep: ',',
- },
- try: true,
- delete: false,
- )
- result = Model.csv_import(
- file: '/file/location/of/file.csv',
- parse_params: {
- col_sep: ',',
- },
- try: true,
- delete: false,
- )
- result = TextModule.csv_import(
- file: '/Users/me/Downloads/Textbausteine_final.csv',
- parse_params: {
- col_sep: ',',
- },
- try: false,
- delete: false,
- )
- returns
- {
- records: [record1, ...]
- try: true, # true|false
- success: true, # true|false
- }
- =end
- def csv_import(data)
- try = data[:try].to_s == 'true'
- delete = data[:delete].to_s == 'true'
- begin
- data[:string] = File.read(data[:file]) if data[:file].present?
- rescue Errno::ENOENT
- raise Exceptions::UnprocessableEntity, "No such file '#{data[:file]}'"
- rescue => e
- raise Exceptions::UnprocessableEntity, "Unable to read file '#{data[:file]}': #{e.inspect}"
- end
- require 'csv' # Only load it when it's really needed to save memory.
- header, *rows = ::CSV.parse(data[:string], **data[:parse_params])
- header&.each do |column|
- column.try(:strip!)
- column.try(:downcase!)
- end
- begin
- raise "Delete is not possible for #{self}." if delete && !csv_delete_possible
- raise "Unable to parse empty file/string for #{self}." if data[:string].blank?
- raise "Unable to parse file/string without header for #{self}." if header.blank?
- raise "No records found in file/string for #{self}." if rows.first.blank?
- raise "No lookup column like #{lookup_keys.map(&:to_s).join(',')} for #{self} found." if (header & lookup_keys.map(&:to_s)).none?
- rescue => e
- return {
- try: try,
- result: 'failed',
- errors: [e.message],
- }
- end
- # get payload based on csv
- payload = rows.map do |row|
- header.zip(row).to_h
- .compact.transform_values(&:strip)
- .transform_values { |value| (value.include?('~~~') ? value.split('~~~') : value) }
- .except(nil).transform_keys(&:to_sym)
- .except(*csv_attributes_ignored)
- .merge(data[:fixed_params] || {})
- end
- stats = {
- created: 0,
- updated: 0,
- deleted: (count if delete),
- }.compact
- # delete
- destroy_all if delete && !try
- # create or update records
- records = []
- errors = []
- transaction do
- payload.each.with_index do |attributes, i|
- record = (lookup_keys & attributes.keys).lazy.map do |lookup_key|
- params = attributes.slice(lookup_key)
- params.transform_values!(&:downcase) if lookup_key.in?(%i[email login])
- lookup(**params)
- end.detect(&:present?)
- if record&.in?(records)
- errors.push "Line #{i.next}: duplicate record found."
- next
- end
- if !record && attributes[:id].present?
- errors.push "Line #{i.next}: unknown #{self} with id '#{attributes[:id]}'."
- next
- end
- if record&.id&.in?(csv_object_ids_ignored)
- errors.push "Line #{i.next}: unable to update #{self} with id '#{attributes[:id]}'."
- next
- end
- begin
- clean_params = association_name_to_id_convert(attributes)
- rescue => e
- errors.push "Line #{i.next}: #{e.message}"
- next
- end
- # create object
- Transaction.execute(disable_notification: true, reset_user_id: true, bulk: true) do
- UserInfo.current_user_id = clean_params[:updated_by_id] || clean_params[:created_by_id]
- if !record || delete == true
- stats[:created] += 1
- begin
- csv_verify_attributes(clean_params)
- record = new(param_cleanup(clean_params).reverse_merge(created_by_id: 1, updated_by_id: 1))
- record.associations_from_param(attributes)
- record.save!
- rescue => e
- errors.push "Line #{i.next}: Unable to create record - #{e.message}"
- next
- end
- else
- stats[:updated] += 1
- begin
- csv_verify_attributes(clean_params)
- clean_params = param_cleanup(clean_params).reverse_merge(updated_by_id: 1)
- record.with_lock do
- record.associations_from_param(attributes)
- record.assign_attributes(clean_params)
- record.save! if record.changed?
- end
- rescue => e
- errors.push "Line #{i.next}: Unable to update record - #{e.message}"
- next
- end
- end
- end
- records.push record if record
- end
- ensure
- raise ActiveRecord::Rollback if try || errors.any?
- end
- {
- stats: stats,
- records: records,
- errors: errors,
- try: try,
- result: errors.empty? ? 'success' : 'failed',
- }
- end
- =begin
- verify if attributes are valid, will raise an ArgumentError with "unknown attribute '#{key}' for #{new.class}."
- Model.csv_verify_attributes({'attribute': 'some value'})
- =end
- def csv_verify_attributes(clean_params)
- all_clean_attributes = {}
- new.attributes.each_key do |attribute|
- all_clean_attributes[attribute.to_sym] = true
- end
- reflect_on_all_associations.map do |assoc|
- all_clean_attributes[assoc.name.to_sym] = true
- ref = if assoc.name.to_s.end_with?('_id')
- "#{assoc.name}_id"
- else
- "#{assoc.name.to_s.chop}_ids"
- end
- all_clean_attributes[ref.to_sym] = true
- end
- clean_params.each_key do |key|
- next if all_clean_attributes.key?(key.to_sym)
- raise ArgumentError, "unknown attribute '#{key}' for #{new.class}."
- end
- true
- end
- =begin
- csv_string = Model.csv_example(
- col_sep: ',',
- )
- returns
- csv_string
- =end
- def csv_example(params = {})
- header = []
- records = where.not(id: csv_object_ids_ignored).offset(1).limit(23).to_a
- if records.count < 20
- record_ids = records.pluck(:id).concat(csv_object_ids_ignored)
- local_records = where.not(id: record_ids).limit(20 - records.count)
- records.concat(local_records)
- end
- records_attributes_with_association_names = []
- records.each do |record|
- record_attributes_with_association_names = record.attributes_with_association_names
- records_attributes_with_association_names.push record_attributes_with_association_names
- end
- new.attributes_with_association_names(empty_keys: true).each do |key, value|
- next if value.instance_of?(ActiveSupport::HashWithIndifferentAccess)
- next if value.instance_of?(Hash)
- next if csv_attributes_ignored&.include?(key.to_sym)
- next if key.end_with?('_id')
- next if key.end_with?('_ids')
- next if key == 'created_by'
- next if key == 'updated_by'
- next if key == 'created_at'
- next if key == 'updated_at'
- next if header.include?(key)
- header.push key
- end
- rows = []
- records_attributes_with_association_names.each do |record|
- row = []
- header.each do |key|
- if record[key].instance_of?(ActiveSupport::TimeWithZone)
- row.push record[key].iso8601
- next
- elsif record[key].instance_of?(Array)
- row.push record[key].join('~~~')
- else
- row.push record[key]
- end
- end
- rows << row
- end
- require 'csv' # Only load it when it's really needed to save memory.
- ::CSV.generate(**params) do |csv|
- csv << header
- rows.each do |row|
- csv << row
- end
- end
- end
- =begin
- serve method to ignore model based on id
- class Model < ApplicationModel
- include CanCsvImport
- csv_object_ids_ignored(1, 2, 3)
- end
- =end
- def csv_object_ids_ignored(*object_ids)
- return @csv_object_ids_ignored || [] if object_ids.empty?
- @csv_object_ids_ignored = object_ids
- end
- =begin
- serve method to ignore model attributes
- class Model < ApplicationModel
- include CanCsvImport
- csv_attributes_ignored :password,
- :image_source,
- :login_failed,
- :source,
- :image_source,
- :image,
- :authorizations,
- :organizations
- end
- =end
- def csv_attributes_ignored(*attributes)
- return @csv_attributes_ignored || [] if attributes.empty?
- @csv_attributes_ignored = attributes
- end
- =begin
- serve method to define if delete option is possible or not
- class Model < ApplicationModel
- include CanCsvImport
- csv_delete_possible true
- end
- =end
- def csv_delete_possible(*value)
- return @csv_delete_possible if value.empty?
- @csv_delete_possible = value.first
- end
- end
- end
|