ecoportal-api-v2 1.1.8 → 2.0.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/.markdownlint.json +4 -0
  3. data/.rubocop.yml +1 -1
  4. data/.ruby-version +1 -0
  5. data/CHANGELOG.md +501 -374
  6. data/ecoportal-api-v2.gemspec +13 -12
  7. data/lib/ecoportal/api/common/concerns/benchmarkable.rb +47 -34
  8. data/lib/ecoportal/api/common/concerns/threadable.rb +41 -0
  9. data/lib/ecoportal/api/common/concerns.rb +1 -0
  10. data/lib/ecoportal/api/common/content/array_model.rb +6 -3
  11. data/lib/ecoportal/api/common/content/class_helpers.rb +12 -8
  12. data/lib/ecoportal/api/common/content/collection_model.rb +7 -7
  13. data/lib/ecoportal/api/common/content/wrapped_response.rb +11 -11
  14. data/lib/ecoportal/api/v2/page/component/reference_field.rb +17 -13
  15. data/lib/ecoportal/api/v2/page/component.rb +7 -4
  16. data/lib/ecoportal/api/v2/page/force.rb +1 -1
  17. data/lib/ecoportal/api/v2/page/stages.rb +5 -6
  18. data/lib/ecoportal/api/v2/page.rb +26 -22
  19. data/lib/ecoportal/api/v2/pages/page_stage/task.rb +63 -0
  20. data/lib/ecoportal/api/v2/pages/page_stage/tasks.rb +69 -0
  21. data/lib/ecoportal/api/v2/pages/page_stage.rb +30 -22
  22. data/lib/ecoportal/api/v2/pages/stages.rb +3 -4
  23. data/lib/ecoportal/api/v2/pages.rb +24 -14
  24. data/lib/ecoportal/api/v2/people.rb +2 -3
  25. data/lib/ecoportal/api/v2/registers.rb +28 -13
  26. data/lib/ecoportal/api/v2/s3/data.rb +27 -0
  27. data/lib/ecoportal/api/v2/s3/files/batch_upload.rb +110 -0
  28. data/lib/ecoportal/api/v2/s3/files/poll.rb +82 -0
  29. data/lib/ecoportal/api/v2/s3/files/poll_status.rb +52 -0
  30. data/lib/ecoportal/api/v2/s3/files.rb +132 -0
  31. data/lib/ecoportal/api/v2/s3/upload.rb +154 -0
  32. data/lib/ecoportal/api/v2/s3.rb +66 -0
  33. data/lib/ecoportal/api/v2.rb +10 -3
  34. data/lib/ecoportal/api/v2_version.rb +1 -1
  35. metadata +55 -54
@@ -3,7 +3,7 @@ module Ecoportal
3
3
  class V2
4
4
  class Page < Common::Content::DoubleModel
5
5
  ALLOWED_KEYS = %w[
6
- id patch_ver name template_id
6
+ id external_id patch_ver name template_id
7
7
  base_tags tags
8
8
  time_zone created_at updated_at
9
9
  components sections stages
@@ -11,9 +11,11 @@ module Ecoportal
11
11
  state task_priority
12
12
  votes_enabled upvotes downvotes
13
13
  forces force_errors subtags
14
+ tasks
14
15
  ].freeze
15
16
 
16
17
  passkey :id
18
+ passthrough :external_id
17
19
  passforced :patch_ver, default: 1
18
20
  passthrough :name, :template_id
19
21
  passarray :base_tags, :tags, order_matters: false
@@ -41,12 +43,13 @@ module Ecoportal
41
43
 
42
44
  def as_update
43
45
  super.tap do |hash|
44
- if hash
45
- hash["data"].select! do |key, value|
46
- ALLOWED_KEYS.include?(key)
47
- end
48
- return nil if (hash["data"].keys - ["patch_ver"]).empty?
46
+ next unless hash
47
+
48
+ hash["data"].select! do |key, _value|
49
+ ALLOWED_KEYS.include?(key)
49
50
  end
51
+
52
+ return nil if (hash["data"].keys - ["patch_ver"]).empty?
50
53
  end
51
54
  end
52
55
 
@@ -57,32 +60,33 @@ module Ecoportal
57
60
  # @return [String] with feedback, if for this page instance, there are any of:
58
61
  # 1. components multi-section (fields belonging to more than one section)
59
62
  def validate
60
- msg = ""
61
- if (multi = components.multi_section).length.positive?
62
- msg += "There are fields attached to more than one section:\n • "
63
- msg += multi.map do |fld|
64
- fld.label
65
- end.join("\n • ") + "\n"
66
- end
63
+ multi = components.multi_section
64
+ return true unless multi.length.positive?
67
65
 
68
- msg.empty?? true : msg
66
+ msg = ""
67
+ msg << "There are fields attached to more than one section:"
68
+ msg << "\n • "
69
+ msg << multi.map(&:label).join("\n • ")
70
+ msg << "\n"
71
+ msg
69
72
  end
70
73
 
71
74
  private
72
75
 
73
76
  def _doc_bug_fix(hash)
74
- hash.tap do |hash|
77
+ hash.tap do
75
78
  _fix_doc(hash["stages"], "flow_node_ids", "section_ids") if hash.key?("stages")
76
- if hash.key?("sections")
77
- _fix_doc(hash["sections"], "membrane_ids", "component_ids")
78
- _fix_doc(hash["sections"], "left_membrane_ids", "left_component_ids")
79
- _fix_doc(hash["sections"], "right_membrane_ids", "right_component_ids")
80
- end
79
+
80
+ next unless hash.key?("sections")
81
+
82
+ _fix_doc(hash["sections"], "membrane_ids", "component_ids")
83
+ _fix_doc(hash["sections"], "left_membrane_ids", "left_component_ids")
84
+ _fix_doc(hash["sections"], "right_membrane_ids", "right_component_ids")
81
85
  end
82
86
  end
83
87
 
84
88
  def _fix_doc(value, source, dest)
85
- value.tap do |value|
89
+ value.tap do
86
90
  case value
87
91
  when Array
88
92
  value.each {|v| _fix_doc(v, source, dest)}
@@ -91,7 +95,7 @@ module Ecoportal
91
95
  value[dest] = value[source]
92
96
  value.delete(source)
93
97
  end
94
- else
98
+ else # rubocop:disable Style/EmptyElse
95
99
  # Do nothing!
96
100
  end
97
101
  end
@@ -0,0 +1,63 @@
1
+ module Ecoportal
2
+ module API
3
+ class V2
4
+ class Pages
5
+ class PageStage
6
+ class Task < Common::Content::DoubleModel
7
+ passkey :id
8
+ passforced :patch_ver, default: 0
9
+
10
+ passthrough :name
11
+ passthrough :user_lookups, :user_ids
12
+ passthrough :strategies, :scheduled_callbacks
13
+ passboolean :historic
14
+
15
+ passboolean :complete, :completable
16
+ passdate :due, :complete_at
17
+ passthrough :complete_by_id, :completed_by_name
18
+
19
+ passthrough :last_strategy
20
+ passboolean :is_retry, :rejected
21
+ passthrough :retry_reason
22
+ passthrough :rejected_by, :rejected_by_name, :rejected_notes
23
+ passdate :rejected_at
24
+
25
+ passboolean :escalated
26
+ passdate :escalated_at
27
+
28
+ passboolean :submitted
29
+ passthrough :type
30
+ passthrough :view_type
31
+
32
+ def ooze
33
+ self._parent.ooze
34
+ end
35
+
36
+ def fill_in?
37
+ type == 'complete_page'
38
+ end
39
+
40
+ def review?
41
+ type == 'review_page'
42
+ end
43
+
44
+ def complete!
45
+ return mark_as_submit if fill_in?
46
+ return mark_as_sign_off if review?
47
+ end
48
+
49
+ private
50
+
51
+ def mark_as_submit
52
+ doc['submitted'] = true
53
+ end
54
+
55
+ def mark_as_sign_off
56
+ doc['sign_off'] = true
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,69 @@
1
+ module Ecoportal
2
+ module API
3
+ class V2
4
+ class Pages
5
+ class PageStage
6
+ class Tasks < Common::Content::CollectionModel
7
+ class_resolver :task_class, "Ecoportal::API::V2::Pages::PageStage::Task"
8
+
9
+ self.klass = :task_class
10
+
11
+ def ooze
12
+ self._parent.ooze
13
+ end
14
+
15
+ # @return [Ecoportal::API::V2::Pages::PageStage::Task]
16
+ def get_by_id(id)
17
+ self.find do |task|
18
+ task.id == id
19
+ end
20
+ end
21
+
22
+ # @return [Array<Ecoportal::API::V2::Pages::PageStage::Task>]
23
+ def get_by_type(type)
24
+ select do |task|
25
+ task.type == type
26
+ end
27
+ end
28
+
29
+ # @return [Array<Ecoportal::API::V2::Pages::PageStage::Task>]
30
+ def open(&block)
31
+ reject(&:complete).tap do |res|
32
+ res.each(&block)
33
+ end
34
+ end
35
+
36
+ # @return [Array<Ecoportal::API::V2::Pages::PageStage::Task>]
37
+ def complete(&block)
38
+ select(&:complete).tap do |res|
39
+ res.each(&block)
40
+ end
41
+ end
42
+
43
+ # @return [Array<Ecoportal::API::V2::Pages::PageStage::Task>]
44
+ def fill_in(active: :unused, &block)
45
+ target = self
46
+ target = open if active == true
47
+ target = complete if active == false
48
+
49
+ target.select(&:fill_in?).tap do |res|
50
+ res.each(&block)
51
+ end
52
+ end
53
+
54
+ # @return [Array<Ecoportal::API::V2::Pages::PageStage::Task>]
55
+ def review(active: :unused, &block)
56
+ target = self
57
+ target = open if active == true
58
+ target = complete if active == false
59
+
60
+ target.select(&:review?).tap do |res|
61
+ res.each(&block)
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -8,6 +8,9 @@ module Ecoportal
8
8
  passthrough :task_priority, :state, :status
9
9
  passthrough :votes_enabled, :upvotes, :downvotes
10
10
 
11
+ class_resolver :tasks_class, "Ecoportal::API::V2::Pages::PageStage::Tasks"
12
+ embeds_many :tasks, enum_class: :tasks_class
13
+
11
14
  #embeds_many :permits, klass: "Ecoportal::API::V2::Page::Permit"
12
15
  passarray :force_errors, :subtags, order_matters: false
13
16
 
@@ -18,11 +21,10 @@ module Ecoportal
18
21
 
19
22
  # @return [String] unique id
20
23
  def uid
21
- if mould_counter? && counter = mould_counter
22
- counter.render
23
- else
24
- id
25
- end
24
+ return id unless mould_counter?
25
+ return id unless (counter = mould_counter)
26
+
27
+ counter.render
26
28
  end
27
29
 
28
30
  # @return [String] `id` of the stage we got the data of.
@@ -32,46 +34,52 @@ module Ecoportal
32
34
 
33
35
  # @return [Ecoportal::API::V2::Page::Stage]
34
36
  def current_stage
35
- if stage_id = current_stage_id
36
- stages[stage_id]
37
- end
37
+ return false unless (stage_id = current_stage_id)
38
+
39
+ stages[stage_id]
38
40
  end
39
41
 
40
42
  # @return [String] with feedback, if for this page instance, there are any of:
41
43
  # 1. orphaned components (fields not belonging to any section)
42
44
  # 2. orphaned sections (sections not belonging to any stage)
43
- def validate
45
+ def validate # rubocop:disable Metrics/AbcSize
44
46
  msg = super
45
47
  msg = "" unless msg.is_a?(String)
46
48
 
47
49
  orphans = components.unattached.select {|comp| comp.global_binding.to_s.strip.empty?}
48
- if orphans.length > 0
49
- msg += "There are fields not attached to any sections:\n • "
50
- msg += orphans.map do |fld|
51
- fld.label
52
- end.join("\n • ") + "\n"
50
+ if orphans.length.positive?
51
+ msg << "There are fields not attached to any sections:"
52
+ msg << "\n • "
53
+ msg << orphans.map(&:label).join("\n • ")
54
+ msg << "\n"
53
55
  end
54
56
 
55
- if (orphans = sections.unattached).length > 0
56
- msg += "There are sections not attached to any stage:\n • "
57
- msg += orphans.map do |sec|
57
+ if (orphans = sections.unattached).length.positive?
58
+ msg << "There are sections not attached to any stage:"
59
+ msg << "\n • "
60
+ msg << orphans.map do |sec|
58
61
  "'#{sec.heading}' (#{sec.id})"
59
- end.join("\n • ") + "\n"
62
+ end.join("\n • ")
63
+ msg << "\n"
60
64
  end
65
+
61
66
  msg.empty?? true : msg
62
67
  end
63
68
 
64
69
  def mark_as_submit
65
- doc["submitted"] = true
66
- doc["type"] = "complete_page"
70
+ tasks.fill_in(active: true).each(&:complete!)
67
71
  end
72
+ alias_method :submit!, :mark_as_submit
68
73
 
69
74
  def mark_as_sign_off
70
- doc["sign_off"] = true
71
- doc["type"] = "review_page"
75
+ tasks.review(active: true).each(&:complete!)
72
76
  end
77
+ alias_method :sign_off!, :mark_as_sign_off
73
78
  end
74
79
  end
75
80
  end
76
81
  end
77
82
  end
83
+
84
+ require 'ecoportal/api/v2/pages/page_stage/task'
85
+ require 'ecoportal/api/v2/pages/page_stage/tasks'
@@ -40,12 +40,11 @@ module Ecoportal
40
40
  # @param stage_id [String] the `id` of the target **stage**.
41
41
  # @return [Response] an object with the api response.
42
42
  def update(doc, id: nil, stage_id:)
43
- body = get_body(doc)
44
- id = id || get_id(doc)
45
- path = "/pages/#{CGI.escape(id)}/stages/#{CGI.escape(stage_id)}/"
43
+ body = get_body(doc)
44
+ id ||= get_id(doc)
45
+ path = "/pages/#{CGI.escape(id)}/stages/#{CGI.escape(stage_id)}/"
46
46
  client.patch(path, data: body)
47
47
  end
48
-
49
48
  end
50
49
  end
51
50
  end
@@ -1,16 +1,17 @@
1
1
  module Ecoportal
2
2
  module API
3
3
  class V2
4
- # @attr_reader client [Common::Client] a `Common::Client` object that holds the configuration of the api connection.
4
+ # @attr_reader client [Common::Client] a `Common::Client` object that
5
+ # holds the configuration of the api connection.
5
6
  class Pages
6
- STAGE_REX = /stages\/(?<sid>.*)/
7
+ STAGE_REX = /stages\/(?<sid>.*)/.freeze
7
8
  extend Common::BaseClass
8
9
  include Common::Content::DocHelpers
9
10
 
10
11
  class_resolver :stages_class, "Ecoportal::API::V2::Pages::Stages"
11
- class_resolver :page_class, "Ecoportal::API::V2::Page"
12
- class_resolver :page_stage_class, "Ecoportal::API::V2::Pages::PageStage"
13
- class_resolver :create_page_response_class, "Ecoportal::API::V2::Pages::PageCreateResponse"
12
+ class_resolver :page_class, "Ecoportal::API::V2::Page"
13
+ class_resolver :page_stage_class, "Ecoportal::API::V2::Pages::PageStage"
14
+ class_resolver :create_page_response_class, "Ecoportal::API::V2::Pages::PageCreateResponse"
14
15
 
15
16
  attr_reader :client
16
17
 
@@ -35,16 +36,19 @@ module Ecoportal
35
36
  # @return [Ecoportal::API::V2::Page, Ecoportal::API::V2::Pages::PageStage] the target page.
36
37
  def get(id, stage_id: nil)
37
38
  return stages.get(id: id, stage_id: stage_id) if stage_id
39
+
38
40
  id = get_id(id)
39
41
  response = client.get("/pages/#{CGI.escape(id)}")
40
42
  wrapped = Common::Content::WrappedResponse.new(response, page_class)
41
43
 
42
44
  return wrapped.result if wrapped.success?
43
- if (response.status == 302) && (url = response.body["data"])
44
- if stage_id = url_to_stage_id(url)
45
- return stages.get(id: id, stage_id: stage_id)
46
- end
47
- end
45
+
46
+ url = nil
47
+ url = response.body["data"] if response.status == 302
48
+ stage_id = url_to_stage_id(url) unless url.nil?
49
+
50
+ return stages.get(id: id, stage_id: stage_id) if stage_id
51
+
48
52
  raise "Could not get page #{id} - Error #{response.status}: #{response.body}"
49
53
  end
50
54
 
@@ -53,10 +57,17 @@ module Ecoportal
53
57
  # @param doc [Hash, Page] data that at least contains an `id` (internal or external) of the target page.
54
58
  # @return [Ecoportal::API::Common::Response] an object with the api response.
55
59
  def update(doc)
60
+ if doc.is_a?(Ecoportal::API::V2::Pages::PageStage)
61
+ stage_id = doc.current_stage_id
62
+ return stages.update(doc, stage_id: stage_id)
63
+ end
64
+
56
65
  body = get_body(doc) # , level: "page"
57
66
  # Launch only if there are changes
58
67
  raise "Missing page object" unless body && body["page"]
59
- id = get_id(doc)
68
+
69
+ id = get_id(doc)
70
+
60
71
  client.patch("/pages/#{CGI.escape(id)}", data: body)
61
72
  end
62
73
 
@@ -81,9 +92,9 @@ module Ecoportal
81
92
  body = get_body(doc).tap do |hash|
82
93
  unless hash["page"]
83
94
  hash["page"] = {
84
- "id" => "111111111111111111111111",
95
+ "id" => "111111111111111111111111",
85
96
  "operation" => "changed",
86
- "data" => {"patch_ver" => 0}
97
+ "data" => {"patch_ver" => 0}
87
98
  }
88
99
  end
89
100
  end
@@ -98,7 +109,6 @@ module Ecoportal
98
109
  def url_to_stage_id(url)
99
110
  (matches = url.match(STAGE_REX)) && matches[:sid]
100
111
  end
101
-
102
112
  end
103
113
  end
104
114
  end
@@ -1,9 +1,9 @@
1
1
  module Ecoportal
2
2
  module API
3
3
  class V2
4
- # @attr_reader client [Common::Client] a `Common::Client` object that holds the configuration of the api connection.
4
+ # @attr_reader client [Common::Client] a `Common::Client` object that
5
+ # holds the configuration of the api connection.
5
6
  class People < API::Internal::People
6
-
7
7
  def batch
8
8
  unavailable_method!(__method__)
9
9
  end
@@ -24,7 +24,6 @@ module Ecoportal
24
24
  def unavailable_method!(str)
25
25
  raise "Unavailable method '#{str}' for api '#{VERSION}'"
26
26
  end
27
-
28
27
  end
29
28
  end
30
29
  end
@@ -2,26 +2,29 @@ require 'base64'
2
2
  module Ecoportal
3
3
  module API
4
4
  class V2
5
- # @attr_reader client [Common::Client] a `Common::Client` object that holds the configuration of the api connection.
5
+ # @attr_reader client [Common::Client] a `Common::Client` object that
6
+ # holds the configuration of the api connection.
6
7
  class Registers
7
8
  extend Common::BaseClass
8
9
  include Enumerable
9
10
  include Common::Content::DocHelpers
10
11
 
11
- class_resolver :register_class, "Ecoportal::API::V2::Registers::Register"
12
+ class_resolver :register_class, "Ecoportal::API::V2::Registers::Register"
12
13
  class_resolver :register_search_result_class, "Ecoportal::API::V2::Registers::PageResult"
13
14
  class_resolver :register_search_results, "Ecoportal::API::V2::Registers::SearchResults"
14
15
 
15
16
  attr_reader :client
16
17
 
17
- # @param client [Common::Client] a `Common::Client` object that holds the configuration of the api connection.
18
+ # @param client [Common::Client] a `Common::Client` object that
19
+ # holds the configuration of the api connection.
18
20
  # @return [Registers] an instance object ready to make registers api requests.
19
21
  def initialize(client)
20
22
  @client = client
21
23
  end
22
24
 
23
25
  def each(params: {}, &block)
24
- return to_enum(:each) unless block
26
+ return to_enum(:each, params: params) unless block
27
+
25
28
  get.each(&block)
26
29
  end
27
30
 
@@ -29,7 +32,11 @@ module Ecoportal
29
32
  # @return [Enumerable<Register>] an `Enumerable` with all schemas already wrapped as `Register` objects.
30
33
  def get
31
34
  response = client.get("/templates")
32
- Common::Content::WrappedResponse.new(response, register_class, key: "registers")
35
+ Common::Content::WrappedResponse.new(
36
+ response,
37
+ register_class,
38
+ key: "registers"
39
+ )
33
40
  end
34
41
 
35
42
  # Gets all the oozes/pages of `register_id` matching the `options`
@@ -41,18 +48,21 @@ module Ecoportal
41
48
  # @yield [result] something to do with search page-result.
42
49
  # @yieldparam result [Ecoportal::V2::Registers::PageResult] a page result.
43
50
  # @return [Ecoportal::API::V2::Registers, Ecoportal::API::V2::Registers::SearchResults]
44
- def search(register_id, options = {})
51
+ def search(register_id, options = {}) # rubocop:disable Metrics/AbcSize
45
52
  only_first = options.delete(:only_first)
46
53
  options = build_options(options)
47
54
 
48
55
  if only_first
49
56
  response = client.get("/registers/#{register_id}/search", params: options)
50
57
  raise "Request failed - Status #{response.status}: #{response.body}" unless response.success?
58
+
51
59
  return register_search_results.new(response.body["data"])
52
60
  end
53
61
 
54
62
  cursor_id = nil
55
- results = 0; total = nil
63
+ results = 0
64
+ total = nil
65
+
56
66
  loop do
57
67
  options.update(cursor_id: cursor_id) if cursor_id
58
68
  response = client.get("/registers/#{register_id}/search", params: options)
@@ -61,12 +71,14 @@ module Ecoportal
61
71
  data = response.body["data"]
62
72
  total ||= data["total"]
63
73
  if total != data["total"]
64
- msg = "Change of total in search results. Probably due to changes that affect the filter (register: #{register_id}):"
74
+ msg = "Change of total in search results. "
75
+ msg << "Probably due to changes that affect the filter"
76
+ msg << "(register: #{register_id}):"
65
77
  print_search_status(msg, total, results, cursor_id, data, options)
66
78
  #total = data["total"]
67
79
  end
68
80
 
69
- unless total == 0
81
+ unless total&.zero?
70
82
  results += data["results"].length
71
83
  print_progress(results, total)
72
84
  end
@@ -77,12 +89,15 @@ module Ecoportal
77
89
  end
78
90
 
79
91
  break if total <= results
92
+
80
93
  unless data["cursor_id"]
81
- msg = "Possible error... finishing search for lack of cursor_id in response:"
94
+ msg = "Possible error... finishing search for lack of cursor_id in response:"
82
95
  print_search_status(msg, total, results, cursor_id, data, options)
83
96
  end
97
+
84
98
  break unless (cursor_id = data["cursor_id"])
85
99
  end
100
+
86
101
  self
87
102
  end
88
103
 
@@ -96,14 +111,14 @@ module Ecoportal
96
111
  options.each do |key, value|
97
112
  if key == :filters && value.any?
98
113
  ret[key] = {filters: value}.to_json
99
- else
100
- ret[key] = value if key
114
+ elsif key
115
+ ret[key] = value
101
116
  end
102
117
  end
103
118
  end
104
119
  end
105
120
 
106
- def print_search_status(msg, total, results, cursor_id, data, options)
121
+ def print_search_status(msg, total, results, cursor_id, data, options) # rubocop:disable Metrics/ParameterLists
107
122
  msg += "\n"
108
123
  msg += " • Original total: #{total}\n"
109
124
  msg += " • Current total: #{data&.dig("total")}\n"
@@ -0,0 +1,27 @@
1
+ module Ecoportal
2
+ module API
3
+ class V2
4
+ class S3
5
+ class Data < Common::Content::DoubleModel
6
+ passthrough :s3_endpoint, :AWSAccessKeyId
7
+ passthrough :policy, :signature
8
+ passthrough :user_tmpdir
9
+
10
+ def x_amz_server_side_encryption
11
+ doc['x-amz-server-side-encryption']
12
+ end
13
+
14
+ def [](key)
15
+ doc[key.to_s]
16
+ end
17
+
18
+ def user_id
19
+ return unless user_tmpdir
20
+
21
+ user_tmpdir.split('uploads/').last
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end