decidim-admin 0.25.2 → 0.26.0.rc1

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of decidim-admin might be problematic. Click here for more details.

Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/app/commands/decidim/admin/create_import_example.rb +21 -0
  3. data/app/commands/decidim/admin/create_static_page.rb +9 -3
  4. data/app/commands/decidim/admin/update_organization_appearance.rb +2 -1
  5. data/app/controllers/concerns/decidim/admin/user_groups/filterable.rb +45 -0
  6. data/app/controllers/concerns/decidim/moderated_users/admin/filterable.rb +51 -0
  7. data/app/controllers/decidim/admin/imports_controller.rb +48 -10
  8. data/app/controllers/decidim/admin/moderated_users_controller.rb +2 -2
  9. data/app/controllers/decidim/admin/scopes_controller.rb +2 -1
  10. data/app/controllers/decidim/admin/user_groups_controller.rb +13 -5
  11. data/app/forms/decidim/admin/import_example_form.rb +50 -0
  12. data/app/forms/decidim/admin/import_form.rb +46 -40
  13. data/app/forms/decidim/admin/organization_appearance_form.rb +1 -0
  14. data/app/helpers/decidim/admin/imports_helper.rb +16 -6
  15. data/app/helpers/decidim/admin/resource_scope_helper.rb +9 -0
  16. data/app/helpers/decidim/admin/settings_helper.rb +13 -0
  17. data/app/packs/entrypoints/decidim_admin.js +0 -1
  18. data/app/packs/src/decidim/admin/dynamic_fields.component.js +8 -0
  19. data/app/permissions/decidim/admin/permissions.rb +1 -0
  20. data/app/views/decidim/admin/imports/_dropdown.html.erb +9 -4
  21. data/app/views/decidim/admin/imports/new.html.erb +24 -22
  22. data/app/views/decidim/admin/moderated_users/_report.html.erb +0 -1
  23. data/app/views/decidim/admin/moderated_users/index.html.erb +5 -2
  24. data/app/views/decidim/admin/moderations/_report.html.erb +0 -1
  25. data/app/views/decidim/admin/moderations/index.html.erb +1 -1
  26. data/app/views/decidim/admin/organization_appearance/form/_colors.html.erb +7 -0
  27. data/app/views/decidim/admin/participatory_space_private_users/index.html.erb +12 -4
  28. data/app/views/decidim/admin/user_groups/index.html.erb +8 -44
  29. data/app/views/layouts/decidim/admin/_callouts_full.html.erb +4 -0
  30. data/app/views/layouts/decidim/admin/_title_bar.html.erb +2 -2
  31. data/config/brakeman.ignore +26 -0
  32. data/config/locales/ar.yml +0 -7
  33. data/config/locales/ca.yml +0 -13
  34. data/config/locales/cs.yml +50 -13
  35. data/config/locales/de.yml +8 -13
  36. data/config/locales/el.yml +0 -15
  37. data/config/locales/en.yml +46 -17
  38. data/config/locales/es-MX.yml +0 -13
  39. data/config/locales/es-PY.yml +0 -13
  40. data/config/locales/es.yml +45 -16
  41. data/config/locales/eu.yml +8 -13
  42. data/config/locales/fi-plain.yml +33 -13
  43. data/config/locales/fi.yml +46 -17
  44. data/config/locales/fr-CA.yml +42 -13
  45. data/config/locales/fr.yml +48 -19
  46. data/config/locales/ga-IE.yml +1 -6
  47. data/config/locales/gl.yml +33 -13
  48. data/config/locales/hu.yml +1 -9
  49. data/config/locales/id-ID.yml +0 -7
  50. data/config/locales/is-IS.yml +0 -7
  51. data/config/locales/it.yml +6 -13
  52. data/config/locales/ja.yml +92 -67
  53. data/config/locales/lb-LU.yml +1034 -0
  54. data/config/locales/lb.yml +6 -15
  55. data/config/locales/lv.yml +0 -7
  56. data/config/locales/nl.yml +73 -13
  57. data/config/locales/no.yml +0 -7
  58. data/config/locales/pl.yml +7 -13
  59. data/config/locales/pt-BR.yml +1 -14
  60. data/config/locales/pt.yml +8 -15
  61. data/config/locales/ro-RO.yml +40 -16
  62. data/config/locales/ru.yml +0 -7
  63. data/config/locales/sk.yml +0 -7
  64. data/config/locales/sr-CS.yml +0 -6
  65. data/config/locales/sv.yml +33 -13
  66. data/config/locales/tr-TR.yml +0 -7
  67. data/config/locales/uk.yml +0 -7
  68. data/config/locales/val-ES.yml +14 -0
  69. data/config/locales/zh-CN.yml +0 -7
  70. data/lib/decidim/admin/engine.rb +5 -0
  71. data/lib/decidim/admin/form_builder.rb +8 -1
  72. data/lib/decidim/admin/import/creator.rb +32 -12
  73. data/lib/decidim/admin/import/importer.rb +32 -14
  74. data/lib/decidim/admin/import/readers/base.rb +23 -0
  75. data/lib/decidim/admin/import/readers/csv.rb +15 -0
  76. data/lib/decidim/admin/import/readers/json.rb +73 -3
  77. data/lib/decidim/admin/import/readers/xlsx.rb +25 -1
  78. data/lib/decidim/admin/import/readers.rb +7 -1
  79. data/lib/decidim/admin/import/verifier.rb +169 -0
  80. data/lib/decidim/admin/import.rb +3 -0
  81. data/lib/decidim/admin/test/filters_participatory_space_user_roles_examples.rb +165 -0
  82. data/lib/decidim/admin/test/manage_paginated_collection_examples.rb +8 -2
  83. data/lib/decidim/admin/test.rb +1 -0
  84. data/lib/decidim/admin/version.rb +1 -1
  85. metadata +19 -12
  86. data/app/packs/src/decidim/admin/import_guidance.js +0 -28
@@ -63,7 +63,14 @@ module Decidim
63
63
 
64
64
  # Calls Decidim::FormBuilder#editor with default options for admin.
65
65
  def editor(name, options = {})
66
- super(name, { toolbar: :full, lines: 25 }.merge(options))
66
+ super(
67
+ name,
68
+ {
69
+ toolbar: :full,
70
+ lines: 25,
71
+ editor_images: true
72
+ }.merge(options)
73
+ )
67
74
  end
68
75
  end
69
76
  end
@@ -11,6 +11,33 @@ module Decidim
11
11
  # in order to parse relevant fields. Every import should specify their
12
12
  # own creator or this default will be used.
13
13
  class Creator
14
+ class << self
15
+ # Retuns the resource class to be created with the provided data.
16
+ def resource_klass
17
+ raise NotImplementedError, "#{self.class.name} does not define resource class"
18
+ end
19
+
20
+ # Returns the verifier class to be used to ensure the data is valid
21
+ # for the import.
22
+ def verifier_klass
23
+ Decidim::Admin::Import::Verifier
24
+ end
25
+
26
+ def required_headers
27
+ []
28
+ end
29
+
30
+ def localize_headers(header, locales)
31
+ @localize_headers ||= begin
32
+ localize_headers = []
33
+ locales.each do |locale|
34
+ localize_headers << "#{header}/#{locale}".to_sym
35
+ end
36
+ localize_headers
37
+ end
38
+ end
39
+ end
40
+
14
41
  attr_reader :data
15
42
 
16
43
  # Initializes the creator with a resource.
@@ -18,15 +45,10 @@ module Decidim
18
45
  # data - The data hash to parse.
19
46
  # context - The context needed by the producer
20
47
  def initialize(data, context = nil)
21
- @data = data.except(:id, "id")
48
+ @data = data
22
49
  @context = context
23
50
  end
24
51
 
25
- # Retuns the resource class to be created with the provided data.
26
- def self.resource_klass
27
- raise NotImplementedError, "#{self.class.name} does not define resource class"
28
- end
29
-
30
52
  # Can be used to convert the data hash to the resource attributes in
31
53
  # case the data hash to be imported has different column names than the
32
54
  # resource object to be created of it.
@@ -50,7 +72,9 @@ module Decidim
50
72
  resource.save!
51
73
  end
52
74
 
53
- private
75
+ protected
76
+
77
+ attr_reader :context
54
78
 
55
79
  def resource
56
80
  raise NotImplementedError, "#{self.class.name} does not define resource"
@@ -65,14 +89,10 @@ module Decidim
65
89
  # Returns the hash including locale-imported_data pairs. eg. {en: "Heading", ca: "Cap", es: "Bóveda"}
66
90
  #
67
91
  def locale_hasher(field, locales)
68
- return data[field.to_sym] if data.has_key?(field.to_sym)
69
-
70
92
  hash = {}
71
93
  locales.each do |locale|
72
94
  parsed = data[:"#{field}/#{locale}"]
73
- next if parsed.nil?
74
-
75
- hash[locale] = parsed
95
+ hash[locale] = parsed unless parsed.nil?
76
96
  end
77
97
  hash
78
98
  end
@@ -10,17 +10,24 @@ module Decidim
10
10
  # You can also use the ImporterFactory class to create an Importer
11
11
  # instance.
12
12
  class Importer
13
+ delegate :errors, to: :verifier
14
+
13
15
  # Public: Initializes an Importer.
14
16
  #
15
17
  # file - A file with the data to be imported.
16
18
  # reader - A Reader to be used to read the data from the file.
17
- # creator - A Creator to be used during the import.
19
+ # creator - A Creator class to be used during the import.
18
20
  # context - A hash including component specific data.
19
21
  def initialize(file:, reader: Readers::Base, creator: Creator, context: nil)
20
22
  @file = file
21
23
  @reader = reader
22
24
  @creator = creator
23
25
  @context = context
26
+ @data_headers = []
27
+ end
28
+
29
+ def verify
30
+ verifier.valid?
24
31
  end
25
32
 
26
33
  # Import data and create resources
@@ -40,27 +47,38 @@ module Decidim
40
47
  @collection ||= collection_data.map { |item| creator.new(item, context) }
41
48
  end
42
49
 
43
- # Returns array of all resource indexes where validations fail.
44
- def invalid_lines
45
- @invalid_lines ||= check_invalid_lines(prepare)
50
+ def invalid_file?
51
+ collection.blank?
52
+ rescue Decidim::Admin::Import::InvalidFileError
53
+ true
46
54
  end
47
55
 
48
56
  private
49
57
 
50
- attr_reader :file, :reader, :creator, :context
58
+ attr_reader :file, :reader, :creator, :context, :data_headers
59
+
60
+ def verifier
61
+ # Prepare needs to be called so that data headers become available.
62
+ data = prepare
63
+ @verifier ||= creator.verifier_klass.new(
64
+ headers: data_headers.map(&:to_s),
65
+ data: data,
66
+ reader: reader,
67
+ context: context
68
+ )
69
+ end
51
70
 
52
71
  def collection_data
53
72
  return @collection_data if @collection_data
54
73
 
55
74
  @collection_data = []
56
- data_headers = []
57
75
  reader.new(file).read_rows do |rowdata, index|
58
76
  if index.zero?
59
- data_headers = rowdata.map(&:to_sym)
77
+ @data_headers = rowdata.map { |d| d.to_s.to_sym }
60
78
  else
61
79
  @collection_data << Hash[
62
80
  rowdata.each_with_index.map do |val, ind|
63
- [data_headers[ind], val]
81
+ [@data_headers[ind], val]
64
82
  end
65
83
  ]
66
84
  end
@@ -69,12 +87,12 @@ module Decidim
69
87
  @collection_data
70
88
  end
71
89
 
72
- def check_invalid_lines(imported_data)
73
- invalid_lines = []
74
- imported_data.each_with_index do |record, index|
75
- invalid_lines << index + 1 unless record.valid?
76
- end
77
- invalid_lines
90
+ def component
91
+ context[:current_component]
92
+ end
93
+
94
+ def available_locales
95
+ @available_locales ||= component.participatory_space.organization.available_locales
78
96
  end
79
97
  end
80
98
  end
@@ -7,6 +7,13 @@ module Decidim
7
7
  # Abstract class with a very naive default implementation. Each importable
8
8
  # file type should have it's own reader.
9
9
  class Base
10
+ # Defines which index of the records defines the first line of actual
11
+ # data. E.g. with spreadsheet formats, the first row contains column
12
+ # name information.
13
+ def self.first_data_index
14
+ 0
15
+ end
16
+
10
17
  def initialize(file)
11
18
  @file = file
12
19
  end
@@ -17,10 +24,26 @@ module Decidim
17
24
  # which can be later used to map the data to correct attributes.
18
25
  #
19
26
  # This needs to be implemented by the extending classes.
27
+ #
28
+ # Returns an array of the import data where the first row should
29
+ # contain the columns.
20
30
  def read_rows
21
31
  raise NotImplementedError
22
32
  end
23
33
 
34
+ # The example_file should produce an example data file for the user to
35
+ # download and take example from to produce their import files. The
36
+ # data provided for the example file generation should be the same as
37
+ # what is returned by the read_rows method.
38
+ #
39
+ # _data - An array of data to produce the file from
40
+ #
41
+ # Returns an IO stream that can be saved to a file or sent to the
42
+ # browser to produce the import file.
43
+ def example_file(_data)
44
+ raise NotImplementedError
45
+ end
46
+
24
47
  protected
25
48
 
26
49
  attr_reader :file
@@ -11,11 +11,26 @@ module Decidim
11
11
  class CSV < Base
12
12
  MIME_TYPE = "text/csv"
13
13
 
14
+ def self.first_data_index
15
+ 1
16
+ end
17
+
14
18
  def read_rows
15
19
  ::CSV.read(file, col_sep: ";").each_with_index do |row, index|
16
20
  yield row, index
17
21
  end
18
22
  end
23
+
24
+ # Returns a StringIO
25
+ def example_file(data)
26
+ csv_data = ::CSV.generate(col_sep: ";") do |csv|
27
+ data.each do |row|
28
+ csv << row
29
+ end
30
+ end
31
+
32
+ ::StringIO.new(csv_data)
33
+ end
19
34
  end
20
35
  end
21
36
  end
@@ -13,9 +13,79 @@ module Decidim
13
13
 
14
14
  def read_rows
15
15
  json_string = File.read(file)
16
- ::JSON.parse(json_string).each_with_index do |row, index|
17
- yield row.keys, index if index.zero?
18
- yield row.values, index + 1
16
+ columns = []
17
+ data = ::JSON.parse(json_string)
18
+ data.each_with_index do |row, index|
19
+ row = flat_hash(row)
20
+ if index.zero?
21
+ columns = row.keys
22
+ yield columns.map(&:to_s), index
23
+ end
24
+
25
+ values = columns.map { |c| row[c] }
26
+ last_present = values.rindex { |v| !v.nil? }
27
+ if last_present
28
+ yield values[0..last_present], index + 1
29
+ else
30
+ yield [], index + 1
31
+ end
32
+ end
33
+ rescue ::JSON::ParserError
34
+ raise Decidim::Admin::Import::InvalidFileError, "The provided JSON file is not valid"
35
+ end
36
+
37
+ # Returns a StringIO
38
+ def example_file(data)
39
+ columns = data.shift
40
+ json_data = data.map do |row|
41
+ deep_hash(
42
+ columns.each_with_index.map { |col, ind| [col, row[ind]] }.to_h
43
+ )
44
+ end
45
+
46
+ ::StringIO.new(::JSON.pretty_generate(json_data))
47
+ end
48
+
49
+ private
50
+
51
+ # Converts e.g. structure as follows:
52
+ # { title: { en: => "Foo", es: => "Bar" } }
53
+ #
54
+ # Into:
55
+ # { "title/en": "Foo", "title/es": "Bar" }
56
+ def flat_hash(data)
57
+ {}.tap do |final|
58
+ data.each do |key, value|
59
+ if value.is_a?(Hash)
60
+ flat_hash(value).each do |subkey, subvalue|
61
+ final["#{key}/#{subkey}".to_sym] = subvalue
62
+ end
63
+ else
64
+ final[key.to_sym] = value
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ # Converts e.g. structure as follows:
71
+ # { "title/en": "Foo", "title/es": "Bar" }
72
+ #
73
+ # Into:
74
+ # { title: { en: "Foo", es: "Bar" } }
75
+ def deep_hash(data)
76
+ {}.tap do |final|
77
+ data.each do |key, value|
78
+ keyparts = key.to_s.split("/")
79
+ current = final
80
+ while (keypart = keyparts.shift&.to_sym)
81
+ if keyparts.any?
82
+ current[keypart] ||= {}
83
+ current = current[keypart]
84
+ else
85
+ current[keypart] = value
86
+ end
87
+ end
88
+ end
19
89
  end
20
90
  end
21
91
  end
@@ -11,12 +11,36 @@ module Decidim
11
11
  class XLSX < Base
12
12
  MIME_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
13
13
 
14
+ def self.first_data_index
15
+ 1
16
+ end
17
+
14
18
  def read_rows
15
19
  workbook = RubyXL::Parser.parse(file)
16
20
  sheet = workbook.worksheets[0]
17
21
  sheet.each_with_index do |row, index|
18
- yield row.cells.map(&:value), index
22
+ if row
23
+ yield row.cells.map { |c| c && c.value }, index
24
+ else
25
+ yield [], index
26
+ end
27
+ end
28
+ rescue Zip::Error
29
+ raise Decidim::Admin::Import::InvalidFileError, "The provided XLSX file is not valid"
30
+ end
31
+
32
+ # Returns a StringIO
33
+ def example_file(data)
34
+ workbook = RubyXL::Workbook.new
35
+ sheet = workbook.worksheets[0]
36
+
37
+ data.each_with_index do |row, rowi|
38
+ row.each_with_index do |col, coli|
39
+ sheet.add_cell(rowi, coli, col)
40
+ end
19
41
  end
42
+
43
+ workbook.stream
20
44
  end
21
45
  end
22
46
  end
@@ -13,8 +13,8 @@ module Decidim
13
13
  # keys: are used for dynamic help text on admin form.
14
14
  # values: are used to validate the file format of imported document.
15
15
  ACCEPTED_MIME_TYPES = {
16
- json: Readers::JSON::MIME_TYPE,
17
16
  csv: Readers::CSV::MIME_TYPE,
17
+ json: Readers::JSON::MIME_TYPE,
18
18
  xlsx: Readers::XLSX::MIME_TYPE
19
19
  }.freeze
20
20
 
@@ -33,6 +33,12 @@ module Decidim
33
33
 
34
34
  nil
35
35
  end
36
+
37
+ def self.search_by_file_extension(extension)
38
+ return unless ACCEPTED_MIME_TYPES.has_key?(extension.to_sym)
39
+
40
+ search_by_mime_type(ACCEPTED_MIME_TYPES[extension.to_sym])
41
+ end
36
42
  end
37
43
  end
38
44
  end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Admin
5
+ module Import
6
+ # This is the default verifier class that verifies the import data is
7
+ # valid before starting the import process. It makes sure the data is in
8
+ # correct format, contains the correct data headers, etc.
9
+ #
10
+ # Individual importers can extend this class to customize the verification
11
+ # process.
12
+ class Verifier
13
+ include ActiveModel::Validations
14
+
15
+ validate :validate_headers
16
+ validate :validate_data, if: -> { errors.blank? }
17
+
18
+ # Public: Initializes an Importer.
19
+ #
20
+ # headers - An array of the data headers for the import.
21
+ # data - An array of the generated data records to be imported.
22
+ # reader - A Reader class that was used to read the raw data.
23
+ # context - A hash including component specific data.
24
+ def initialize(headers:, data:, reader:, context: nil)
25
+ @headers = headers
26
+ @data = data
27
+ @reader = reader
28
+ @context = context
29
+ end
30
+
31
+ protected
32
+
33
+ attr_reader :headers, :data, :reader, :context
34
+
35
+ def validate_headers
36
+ if missing_headers.any?
37
+ message = [
38
+ I18n.t(
39
+ "decidim.admin.imports.data_errors.missing_headers.message",
40
+ count: missing_headers.count,
41
+ columns: humanize_list(missing_headers)
42
+ ),
43
+ I18n.t("decidim.admin.imports.data_errors.missing_headers.detail")
44
+ ].join(" ")
45
+
46
+ errors.add(:headers, message)
47
+ end
48
+
49
+ return unless duplicate_headers.any?
50
+
51
+ message = [
52
+ I18n.t(
53
+ "decidim.admin.imports.data_errors.duplicate_headers.message",
54
+ count: duplicate_headers.count,
55
+ columns: humanize_list(duplicate_headers)
56
+ ),
57
+ I18n.t("decidim.admin.imports.data_errors.duplicate_headers.detail")
58
+ ].join(" ")
59
+
60
+ errors.add(:headers, message)
61
+ end
62
+
63
+ def validate_data
64
+ return if invalid_indexes.empty?
65
+
66
+ indexes = humanize_indexes(invalid_indexes, reader.first_data_index)
67
+ message =
68
+ if reader.first_data_index.zero?
69
+ # If the data starts from index zero we don't want to say to the
70
+ # user that there are errors on "rows". We want to refer to record
71
+ # numbers instead. This is the case e.g. with JSON data format.
72
+ [
73
+ I18n.t(
74
+ "decidim.admin.imports.data_errors.invalid_indexes.records.message",
75
+ count: invalid_indexes.count,
76
+ indexes: indexes
77
+ ),
78
+ I18n.t("decidim.admin.imports.data_errors.invalid_indexes.records.detail")
79
+ ].join(" ")
80
+ else
81
+ [
82
+ I18n.t(
83
+ "decidim.admin.imports.data_errors.invalid_indexes.lines.message",
84
+ count: invalid_indexes.count,
85
+ indexes: indexes
86
+ ),
87
+ I18n.t("decidim.admin.imports.data_errors.invalid_indexes.lines.detail")
88
+ ].join(" ")
89
+ end
90
+
91
+ errors.add(:data, message)
92
+ end
93
+
94
+ def available_locales
95
+ @available_locales ||= context[:current_organization]&.available_locales || I18n.available_locales.map(&:to_s)
96
+ end
97
+
98
+ def default_locale
99
+ @default_locale ||= context[:current_organization]&.default_locale || I18n.default_locale.to_s
100
+ end
101
+
102
+ # Individual verifier classes can extend this to provide their required
103
+ # headers.
104
+ #
105
+ # Returns an array of required headers.
106
+ def required_headers
107
+ []
108
+ end
109
+
110
+ def required_localized_headers(name)
111
+ ["#{name}/#{default_locale}"]
112
+ end
113
+
114
+ def missing_headers
115
+ @missing_headers ||= [].tap do |array|
116
+ required_headers.each do |required|
117
+ array << required unless headers.include?(required)
118
+ end
119
+ end
120
+ end
121
+
122
+ def duplicate_headers
123
+ @duplicate_headers ||= headers.select { |e| headers.count(e) > 1 }.uniq
124
+ end
125
+
126
+ # Returns array of all resource indexes where validations fail.
127
+ def invalid_indexes
128
+ @invalid_indexes ||= [].tap do |indexes|
129
+ data.each_with_index do |record, index|
130
+ indexes << index unless valid_record?(record)
131
+ end
132
+ end
133
+ end
134
+
135
+ # Validates the record and allows individual verifiers to customize the
136
+ # validation logic by overriding this method.
137
+ #
138
+ # Returns a boolean indicating whether the record to be imported is
139
+ # valid.
140
+ def valid_record?(record)
141
+ record.valid?
142
+ end
143
+
144
+ # Humanizes the index numbers so that it is understandable for humans.
145
+ # Index zero becomes one and the indexes are included in a single
146
+ # string with the last item separated with "and". For instance, for
147
+ # indexes [1, 2, 3] the message would be "1, 2 and 3".
148
+ #
149
+ # Returns a String.
150
+ def humanize_indexes(indexes, start_index)
151
+ # Humans don't start counting from zero and this message is shown
152
+ # for humans. This also takes the data start index into account.
153
+ indexes = indexes.map { |i| i + start_index + 1 }
154
+
155
+ humanize_list(indexes)
156
+ end
157
+
158
+ def humanize_list(list)
159
+ if list.count > 1
160
+ last = list.pop
161
+ "#{list.join(", ")} #{I18n.t("decidim.admin.imports.and")} #{last}"
162
+ else
163
+ list.join
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -7,6 +7,9 @@ module Decidim
7
7
  autoload :Importer, "decidim/admin/import/importer"
8
8
  autoload :Creator, "decidim/admin/import/creator"
9
9
  autoload :Readers, "decidim/admin/import/readers"
10
+ autoload :Verifier, "decidim/admin/import/verifier"
11
+
12
+ class InvalidFileError < StandardError; end
10
13
  end
11
14
  end
12
15
  end