You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

import.rb 7.9KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. # frozen_string_literal: true
  2. # Redmine - project management software
  3. # Copyright (C) 2006-2023 Jean-Philippe Lang
  4. #
  5. # This program is free software; you can redistribute it and/or
  6. # modify it under the terms of the GNU General Public License
  7. # as published by the Free Software Foundation; either version 2
  8. # of the License, or (at your option) any later version.
  9. #
  10. # This program is distributed in the hope that it will be useful,
  11. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. # GNU General Public License for more details.
  14. #
  15. # You should have received a copy of the GNU General Public License
  16. # along with this program; if not, write to the Free Software
  17. # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  18. require 'csv'
  19. class Import < ApplicationRecord
  20. has_many :items, :class_name => 'ImportItem', :dependent => :delete_all
  21. belongs_to :user
  22. serialize :settings
  23. before_destroy :remove_file
  24. validates_presence_of :filename, :user_id
  25. validates_length_of :filename, :maximum => 255
  26. DATE_FORMATS = [
  27. '%Y-%m-%d',
  28. '%d/%m/%Y',
  29. '%m/%d/%Y',
  30. '%Y/%m/%d',
  31. '%d.%m.%Y',
  32. '%d-%m-%Y'
  33. ]
  34. AUTO_MAPPABLE_FIELDS = {}
  35. def self.menu_item
  36. nil
  37. end
  38. def self.layout
  39. 'base'
  40. end
  41. def self.authorized?(user)
  42. user.admin?
  43. end
  44. def initialize(*args)
  45. super
  46. self.settings ||= {}
  47. end
  48. def file=(arg)
  49. return unless arg.present? && arg.size > 0
  50. self.filename = generate_filename
  51. Redmine::Utils.save_upload(arg, filepath)
  52. end
  53. def set_default_settings(options={})
  54. separator = lu(user, :general_csv_separator)
  55. wrapper = '"'
  56. encoding = lu(user, :general_csv_encoding)
  57. if file_exists?
  58. begin
  59. content = File.read(filepath, 256)
  60. separator = [',', ';'].max_by {|sep| content.count(sep)}
  61. wrapper = ['"', "'"].max_by {|quote_char| content.count(quote_char)}
  62. guessed_encoding = Redmine::CodesetUtil.guess_encoding(content)
  63. encoding =
  64. (guessed_encoding && (
  65. Setting::ENCODINGS.detect {|e| e.casecmp?(guessed_encoding)} ||
  66. Setting::ENCODINGS.detect {|e| Encoding.find(e) == Encoding.find(guessed_encoding)}
  67. )) || lu(user, :general_csv_encoding)
  68. rescue => e
  69. end
  70. end
  71. date_format = lu(user, "date.formats.default", :default => "foo")
  72. date_format = DATE_FORMATS.first unless DATE_FORMATS.include?(date_format)
  73. self.settings.merge!(
  74. 'separator' => separator,
  75. 'wrapper' => wrapper,
  76. 'encoding' => encoding,
  77. 'date_format' => date_format,
  78. 'notifications' => '0'
  79. )
  80. if options.key?(:project_id) && options[:project_id].present?
  81. # Do not fail if project doesn't exist
  82. begin
  83. project = Project.find(options[:project_id])
  84. self.settings.merge!('mapping' => {'project_id' => project.id})
  85. rescue; end
  86. end
  87. end
  88. def to_param
  89. filename
  90. end
  91. # Returns the full path of the file to import
  92. # It is stored in tmp/imports with a random hex as filename
  93. def filepath
  94. if filename.present? && /\A[0-9a-f]+\z/.match?(filename)
  95. File.join(Rails.root, "tmp", "imports", filename)
  96. else
  97. nil
  98. end
  99. end
  100. # Returns true if the file to import exists
  101. def file_exists?
  102. filepath.present? && File.exist?(filepath)
  103. end
  104. # Returns the headers as an array that
  105. # can be used for select options
  106. def columns_options(default=nil)
  107. i = -1
  108. headers.map {|h| [h, i+=1]}
  109. end
  110. # Parses the file to import and updates the total number of items
  111. def parse_file
  112. count = 0
  113. read_items {|row, i| count=i}
  114. update_attribute :total_items, count
  115. count
  116. end
  117. # Reads the items to import and yields the given block for each item
  118. def read_items
  119. i = 0
  120. headers = true
  121. read_rows do |row|
  122. if i == 0 && headers
  123. headers = false
  124. next
  125. end
  126. i+= 1
  127. yield row, i if block_given?
  128. end
  129. end
  130. # Returns the count first rows of the file (including headers)
  131. def first_rows(count=4)
  132. rows = []
  133. read_rows do |row|
  134. rows << row
  135. break if rows.size >= count
  136. end
  137. rows
  138. end
  139. # Returns an array of headers
  140. def headers
  141. first_rows(1).first || []
  142. end
  143. # Returns the mapping options
  144. def mapping
  145. settings['mapping'] || {}
  146. end
  147. # Adds a callback that will be called after the item at given position is imported
  148. def add_callback(position, name, *args)
  149. settings['callbacks'] ||= {}
  150. settings['callbacks'][position] ||= []
  151. settings['callbacks'][position] << [name, args]
  152. save!
  153. end
  154. # Executes the callbacks for the given object
  155. def do_callbacks(position, object)
  156. if callbacks = (settings['callbacks'] || {}).delete(position)
  157. callbacks.each do |name, args|
  158. send :"#{name}_callback", object, *args
  159. end
  160. save!
  161. end
  162. end
  163. # Imports items and returns the position of the last processed item
  164. def run(options={})
  165. max_items = options[:max_items]
  166. max_time = options[:max_time]
  167. current = 0
  168. imported = 0
  169. resume_after = items.maximum(:position) || 0
  170. interrupted = false
  171. started_on = Time.now
  172. read_items do |row, position|
  173. if (max_items && imported >= max_items) || (max_time && Time.now >= started_on + max_time)
  174. interrupted = true
  175. break
  176. end
  177. if position > resume_after
  178. item = items.build
  179. item.position = position
  180. item.unique_id = row_value(row, 'unique_id') if use_unique_id?
  181. if object = build_object(row, item)
  182. if object.save
  183. item.obj_id = object.id
  184. else
  185. item.message = object.errors.full_messages.join("\n")
  186. end
  187. end
  188. item.save!
  189. imported += 1
  190. extend_object(row, item, object) if object.persisted?
  191. do_callbacks(use_unique_id? ? item.unique_id : item.position, object)
  192. end
  193. current = position
  194. end
  195. if imported == 0 || interrupted == false
  196. if total_items.nil?
  197. update_attribute :total_items, current
  198. end
  199. update_attribute :finished, true
  200. remove_file
  201. end
  202. current
  203. end
  204. def unsaved_items
  205. items.where(:obj_id => nil)
  206. end
  207. def saved_items
  208. items.where("obj_id IS NOT NULL")
  209. end
  210. private
  211. def read_rows
  212. return unless file_exists?
  213. csv_options = {:headers => false}
  214. csv_options[:encoding] = settings['encoding'].to_s.presence || 'UTF-8'
  215. csv_options[:encoding] = 'bom|UTF-8' if csv_options[:encoding] == 'UTF-8'
  216. separator = settings['separator'].to_s
  217. csv_options[:col_sep] = separator if separator.size == 1
  218. wrapper = settings['wrapper'].to_s
  219. csv_options[:quote_char] = wrapper if wrapper.size == 1
  220. CSV.foreach(filepath, **csv_options) do |row|
  221. yield row if block_given?
  222. end
  223. end
  224. def row_value(row, key)
  225. if index = mapping[key].presence
  226. row[index.to_i].presence
  227. end
  228. end
  229. def row_date(row, key)
  230. if s = row_value(row, key)
  231. format = settings['date_format']
  232. format = DATE_FORMATS.first unless DATE_FORMATS.include?(format)
  233. Date.strptime(s, format) rescue s
  234. end
  235. end
  236. # Builds a record for the given row and returns it
  237. # To be implemented by subclasses
  238. def build_object(row, item)
  239. end
  240. # Extends object with properties, that may only be handled after it's been
  241. # persisted.
  242. def extend_object(row, item, object)
  243. end
  244. # Generates a filename used to store the import file
  245. def generate_filename
  246. Redmine::Utils.random_hex(16)
  247. end
  248. # Deletes the import file
  249. def remove_file
  250. if file_exists?
  251. begin
  252. File.delete filepath
  253. rescue => e
  254. logger.error "Unable to delete file #{filepath}: #{e.message}" if logger
  255. end
  256. end
  257. end
  258. # Returns true if value is a string that represents a true value
  259. def yes?(value)
  260. value == lu(user, :general_text_yes) || value == '1'
  261. end
  262. def use_unique_id?
  263. mapping['unique_id'].present?
  264. end
  265. end