netzke-basepack 0.7.4 → 0.7.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (74) hide show
  1. data/.travis.yml +11 -0
  2. data/CHANGELOG.rdoc +10 -0
  3. data/README.md +36 -2
  4. data/Rakefile +1 -3
  5. data/config/ci/before-travis.sh +28 -0
  6. data/lib/netzke/active_record.rb +10 -8
  7. data/lib/netzke/active_record/attributes.rb +28 -17
  8. data/lib/netzke/active_record/relation_extensions.rb +3 -1
  9. data/lib/netzke/basepack.rb +10 -2
  10. data/lib/netzke/basepack/action_column.rb +6 -8
  11. data/lib/netzke/basepack/data_accessor.rb +11 -174
  12. data/lib/netzke/basepack/data_adapters/abstract_adapter.rb +164 -0
  13. data/lib/netzke/basepack/data_adapters/active_record_adapter.rb +279 -0
  14. data/lib/netzke/basepack/data_adapters/data_mapper_adapter.rb +264 -0
  15. data/lib/netzke/basepack/data_adapters/sequel_adapter.rb +260 -0
  16. data/lib/netzke/basepack/form_panel.rb +3 -3
  17. data/lib/netzke/basepack/form_panel/fields.rb +6 -10
  18. data/lib/netzke/basepack/form_panel/javascripts/form_panel.js +1 -0
  19. data/lib/netzke/basepack/form_panel/services.rb +15 -16
  20. data/lib/netzke/basepack/grid_panel.rb +16 -10
  21. data/lib/netzke/basepack/grid_panel/columns.rb +6 -7
  22. data/lib/netzke/basepack/grid_panel/javascripts/event_handling.js +29 -27
  23. data/lib/netzke/basepack/grid_panel/services.rb +13 -90
  24. data/lib/netzke/basepack/paging_form_panel.rb +3 -3
  25. data/lib/netzke/basepack/query_builder.rb +2 -0
  26. data/lib/netzke/basepack/query_builder/javascripts/query_builder.js +29 -19
  27. data/lib/netzke/basepack/search_panel.rb +6 -3
  28. data/lib/netzke/basepack/search_panel/javascripts/search_panel.js +2 -1
  29. data/lib/netzke/basepack/search_window.rb +2 -1
  30. data/lib/netzke/basepack/version.rb +1 -1
  31. data/lib/netzke/data_mapper.rb +18 -0
  32. data/lib/netzke/data_mapper/attributes.rb +273 -0
  33. data/lib/netzke/data_mapper/combobox_options.rb +11 -0
  34. data/lib/netzke/data_mapper/relation_extensions.rb +38 -0
  35. data/lib/netzke/sequel.rb +18 -0
  36. data/lib/netzke/sequel/attributes.rb +274 -0
  37. data/lib/netzke/sequel/combobox_options.rb +10 -0
  38. data/lib/netzke/sequel/relation_extensions.rb +40 -0
  39. data/netzke-basepack.gemspec +24 -13
  40. data/test/basepack_test_app/Gemfile +33 -8
  41. data/test/basepack_test_app/Gemfile.lock +98 -79
  42. data/test/basepack_test_app/Guardfile +46 -0
  43. data/test/basepack_test_app/app/components/book_grid_with_persistence.rb +3 -0
  44. data/test/basepack_test_app/app/components/extras/book_presentation.rb +10 -3
  45. data/test/basepack_test_app/app/models/address.rb +27 -1
  46. data/test/basepack_test_app/app/models/author.rb +28 -0
  47. data/test/basepack_test_app/app/models/book.rb +43 -0
  48. data/test/basepack_test_app/app/models/book_with_custom_primary_key.rb +22 -0
  49. data/test/basepack_test_app/app/models/role.rb +21 -0
  50. data/test/basepack_test_app/app/models/user.rb +24 -0
  51. data/test/basepack_test_app/config/database.yml.sample +11 -10
  52. data/test/basepack_test_app/config/database.yml.travis +15 -0
  53. data/test/basepack_test_app/config/initializers/data_mapper_logging.rb +3 -0
  54. data/test/basepack_test_app/config/initializers/sequel.rb +26 -0
  55. data/test/basepack_test_app/db/schema.rb +0 -3
  56. data/test/basepack_test_app/features/grid_panel.feature +28 -8
  57. data/test/basepack_test_app/features/grid_sorting.feature +6 -6
  58. data/test/basepack_test_app/features/paging_form_panel.feature +13 -13
  59. data/test/basepack_test_app/features/search_in_grid.feature +31 -31
  60. data/test/basepack_test_app/features/step_definitions/generic_steps.rb +3 -1
  61. data/test/basepack_test_app/features/support/env.rb +17 -4
  62. data/test/basepack_test_app/lib/tasks/travis.rake +7 -0
  63. data/test/basepack_test_app/spec/components/form_panel_spec.rb +2 -2
  64. data/test/basepack_test_app/spec/data_adapter/adapter_spec.rb +68 -0
  65. data/test/basepack_test_app/spec/{active_record → data_adapter}/attributes_spec.rb +12 -4
  66. data/test/basepack_test_app/spec/data_adapter/relation_extensions_spec.rb +125 -0
  67. data/test/basepack_test_app/spec/spec_helper.rb +9 -0
  68. data/test/unit/active_record_basepack_test.rb +1 -1
  69. data/test/unit/grid_panel_test.rb +1 -1
  70. metadata +26 -31
  71. data/app/models/netzke_field_list.rb +0 -261
  72. data/app/models/netzke_model_attr_list.rb +0 -21
  73. data/app/models/netzke_persistent_array_auto_model.rb +0 -57
  74. data/test/basepack_test_app/spec/active_record/relation_extensions_spec.rb +0 -44
@@ -129,7 +129,7 @@ module Netzke
129
129
 
130
130
  if columns_from_config
131
131
  # automatically add a column that reflects the primary key (unless specified in the config)
132
- columns_from_config.insert(0, {:name => data_class.primary_key}) unless columns_from_config.any?{ |c| c[:name] == data_class.primary_key }
132
+ columns_from_config.insert(0, {:name => data_class.primary_key.to_s}) unless columns_from_config.any?{ |c| c[:name] == data_class.primary_key }
133
133
 
134
134
  # reverse-merge each column hash from config with each column hash from exposed_attributes
135
135
  # (columns from config have higher priority)
@@ -225,11 +225,10 @@ module Netzke
225
225
 
226
226
  # Detects an association column and sets up the proper editor.
227
227
  def set_default_association_editor(c)
228
- assoc, assoc_method = assoc_and_assoc_method_for_attr(c)
228
+ assoc, assoc_method = c[:name].split('__')
229
229
  return unless assoc
230
230
 
231
- assoc_column = assoc.klass.columns_hash[assoc_method]
232
- assoc_method_type = assoc_column.try(:type)
231
+ assoc_method_type = data_adapter.get_assoc_property_type assoc, assoc_method
233
232
 
234
233
  # if association column is boolean, display a checkbox (or alike), otherwise - a combobox (or alike)
235
234
  if c[:nested_attribute]
@@ -361,12 +360,12 @@ module Netzke
361
360
 
362
361
  def columns_default_values
363
362
  columns.inject({}) do |r,c|
364
- assoc, assoc_method = assoc_and_assoc_method_for_attr(c)
363
+ assoc_name, assoc_method = c[:name].split '__'
365
364
  if c[:default_value].nil?
366
365
  r
367
366
  else
368
- if assoc
369
- r.merge(assoc.options[:foreign_key] || assoc.name.to_s.foreign_key => c[:default_value])
367
+ if assoc_method
368
+ r.merge(data_adapter.foreign_key_for(assoc_name) || data_adapter.foreign_key_for(assoc_name) => c[:default_value])
370
369
  else
371
370
  r.merge(c[:name] => c[:default_value])
372
371
  end
@@ -37,38 +37,40 @@
37
37
  },
38
38
 
39
39
  onApply: function(){
40
- var newRecords = [],
41
- updatedRecords = [],
42
- store = this.getStore();
43
-
44
- Ext.each(store.getUpdatedRecords().concat(store.getNewRecords()),
45
- function(r) {
46
- if (r.isNew) {
47
- newRecords.push(r.data); // HACK: r.data seems private
48
- } else {
49
- updatedRecords.push(Ext.apply(r.getChanges(), {id:r.getId()}));
50
- }
51
- },
52
- this);
40
+ if (this.fireEvent('apply')) {
41
+ var newRecords = [],
42
+ updatedRecords = [],
43
+ store = this.getStore();
53
44
 
54
- if (newRecords.length > 0 || updatedRecords.length > 0) {
55
- var params = {};
45
+ Ext.each(store.getUpdatedRecords().concat(store.getNewRecords()),
46
+ function(r) {
47
+ if (r.isNew) {
48
+ newRecords.push(r.data); // HACK: r.data seems private
49
+ } else {
50
+ updatedRecords.push(Ext.apply(r.getChanges(), {id:r.getId()}));
51
+ }
52
+ },
53
+ this);
56
54
 
57
- if (newRecords.length > 0) {
58
- params.created_records = Ext.encode(newRecords);
59
- }
55
+ if (newRecords.length > 0 || updatedRecords.length > 0) {
56
+ var params = {};
60
57
 
61
- if (updatedRecords.length > 0) {
62
- params.updated_records = Ext.encode(updatedRecords);
63
- }
58
+ if (newRecords.length > 0) {
59
+ params.created_records = Ext.encode(newRecords);
60
+ }
64
61
 
65
- if (this.getStore().getProxy().extraParams !== {}) {
66
- params.base_params = Ext.encode(this.getStore().getProxy().extraParams);
67
- }
62
+ if (updatedRecords.length > 0) {
63
+ params.updated_records = Ext.encode(updatedRecords);
64
+ }
68
65
 
69
- this.postData(params);
70
- }
66
+ if (this.getStore().getProxy().extraParams !== {}) {
67
+ params.base_params = Ext.encode(this.getStore().getProxy().extraParams);
68
+ }
71
69
 
70
+ this.postData(params);
71
+ }
72
+ }
73
+ this.fireEvent('afterApply', this);
72
74
  },
73
75
 
74
76
  // Handlers for tools
@@ -174,4 +176,4 @@
174
176
  this.searchWindow.destroy();
175
177
  }
176
178
  }
177
- }
179
+ }
@@ -32,7 +32,7 @@ module Netzke
32
32
  endpoint :delete_data do |params|
33
33
  if !config[:prohibit_delete]
34
34
  record_ids = ActiveSupport::JSON.decode(params[:records])
35
- data_class.destroy(record_ids)
35
+ data_adapter.destroy(record_ids)
36
36
  on_data_changed
37
37
  {:netzke_feedback => I18n.t('netzke.basepack.grid_panel.deleted_n_records', :n => record_ids.size), :load_store_data => get_data}
38
38
  else
@@ -83,16 +83,7 @@ module Netzke
83
83
  end
84
84
 
85
85
  endpoint :move_rows do |params|
86
- if defined?(ActsAsList) && data_class.ancestors.include?(ActsAsList::InstanceMethods)
87
- ids = JSON.parse(params[:ids]).reverse
88
- ids.each_with_index do |id, i|
89
- r = data_class.find(id)
90
- r.insert_at(params[:new_index].to_i + i + 1)
91
- end
92
- on_data_changed
93
- else
94
- raise RuntimeError, "Data class should 'acts_as_list' to support moving rows"
95
- end
86
+ data_adapter.move_records(params)
96
87
  {}
97
88
  end
98
89
 
@@ -155,12 +146,8 @@ module Netzke
155
146
  if !config[:prohibit_read]
156
147
  {}.tap do |res|
157
148
  records = get_records(params)
158
- res[:data] = records.map{|r| r.to_array(columns(:with_meta => true))}
159
- res[:total] = records.total_entries if config[:enable_pagination]
160
-
161
- # provide association values for all records at once
162
- # assoc_values = get_association_values(records, columns)
163
- # res[:set_association_values] = assoc_values.literalize_keys if assoc_values.present?
149
+ res[:data] = records.map{|r| r.netzke_array(columns(:with_meta => true))}
150
+ res[:total] = count_records(params) if config[:enable_pagination]
164
151
  end
165
152
  else
166
153
  flash :error => "You don't have permissions to read data"
@@ -170,68 +157,18 @@ module Netzke
170
157
 
171
158
  protected
172
159
 
173
- # Returns all values for association columns, per column, per associated record id, e.g.:
174
- # {
175
- # :author__first_name => {1 => "Vladimir", 2 => "Herman"},
176
- # :author__last_name => {1 => "Nabokov", 2 => "Hesse"}
177
- # }
178
- # This is used to display the association by the specified method instead by the foreign key
179
- # def get_association_values(records, columns)
180
- # columns.select{ |c| c[:name].index("__") }.each.inject({}) do |r,c|
181
- # column_values = {}
182
- # records.each{ |r| column_values[r.value_for_attribute(c)] = r.value_for_attribute(c, true) }
183
- # r.merge(c[:name] => column_values)
184
- # end
185
- # end
186
-
187
160
  # Returns an array of records.
188
161
  def get_records(params)
162
+ params[:limit] = config[:rows_per_page] if config[:enable_pagination]
163
+ params[:scope] = config[:scope] # note, params[:scope] becomes ActiveSupport::HashWithIndifferentAccess
189
164
 
190
- # Restore params from component_session if requested
191
- if params[:with_last_params]
192
- params = component_session[:last_params]
193
- else
194
- # remember the last params
195
- component_session[:last_params] = params
196
- end
197
-
198
- # build initial relation based on passed params
199
- relation = get_relation(params)
165
+ data_adapter.get_records(params, columns)
166
+ end
200
167
 
201
- # addressing the n+1 query problem
202
- columns.each do |c|
203
- assoc, method = c[:name].split('__')
204
- relation = relation.includes(assoc.to_sym) if method
205
- end
168
+ def count_records(params)
169
+ params[:scope] = config[:scope] # note, params[:scope] becomes ActiveSupport::HashWithIndifferentAccess
206
170
 
207
- # apply sorting if needed
208
- if params[:sort] && sort_params = params[:sort].first
209
- assoc, method = sort_params["property"].split('__')
210
- dir = sort_params["direction"].downcase
211
-
212
- # if a sorting scope is set, call the scope with the given direction
213
- column = columns.detect { |c| c[:name] == sort_params["property"] }
214
- if column.has_key?(:sorting_scope)
215
- relation = relation.send(column[:sorting_scope].to_sym, dir.to_sym)
216
- ::Rails.logger.debug "!!! relation: #{relation.inspect}\n"
217
- else
218
- relation = if method.nil?
219
- relation.order("#{assoc} #{dir}")
220
- else
221
- assoc = data_class.reflect_on_association(assoc.to_sym)
222
- relation.joins(assoc.name).order("#{assoc.klass.table_name}.#{method} #{dir}")
223
- end
224
- end
225
- end
226
-
227
- # apply pagination if needed
228
- if config[:enable_pagination]
229
- per_page = config[:rows_per_page]
230
- page = params[:limit] ? params[:start].to_i/params[:limit].to_i + 1 : 1
231
- relation.paginate(:per_page => per_page, :page => page)
232
- else
233
- relation.all
234
- end
171
+ data_adapter.count_records(params, columns)
235
172
  end
236
173
 
237
174
  # Override this method to react on each operation that caused changing of data
@@ -258,7 +195,7 @@ module Netzke
258
195
  modified_records = 0
259
196
  data.each do |record_hash|
260
197
  id = record_hash.delete('id')
261
- record = operation == :create ? data_class.new : data_class.find(id)
198
+ record = operation == :create ? data_adapter.new_record : data_adapter.find_record(id)
262
199
  success = true
263
200
 
264
201
  # merge with strong default attirbutes
@@ -268,21 +205,8 @@ module Netzke
268
205
  record.set_value_for_attribute(columns_hash[k.to_sym].nil? ? {:name => k} : columns_hash[k.to_sym], v)
269
206
  end
270
207
 
271
- # process all attirubutes for this record
272
- #record_hash.each_pair do |k,v|
273
- #begin
274
- #record.send("#{k}=",v)
275
- #rescue ArgumentError => exc
276
- #flash :error => exc.message
277
- #success = false
278
- #break
279
- #end
280
- #end
281
-
282
208
  # try to save
283
- # modified_records += 1 if success && record.save
284
- mod_records[id] = record.to_array(columns(:with_meta => true)) if success && record.save
285
- # mod_record_ids << id if success && record.save
209
+ mod_records[id] = record.netzke_array(columns(:with_meta => true)) if success && record.save
286
210
 
287
211
  # flash eventual errors
288
212
  if !record.errors.empty?
@@ -292,7 +216,6 @@ module Netzke
292
216
  end
293
217
  end
294
218
  end
295
- # flash :notice => "#{operation.to_s.capitalize}d #{modified_records} record(s)"
296
219
  else
297
220
  success = false
298
221
  flash :error => "You don't have permissions to #{operation} data"
@@ -23,7 +23,7 @@ module Netzke
23
23
 
24
24
  # override
25
25
  def record
26
- @record ||= get_relation.first
26
+ @record ||= data_adapter.first
27
27
  end
28
28
 
29
29
  # Pass total records amount and the first record to the JS constructor
@@ -34,7 +34,7 @@ module Netzke
34
34
  end
35
35
 
36
36
  endpoint :get_data do |params|
37
- @record = get_relation(params).offset(params[:start].to_i).limit(1).first
37
+ @record = data_adapter.get_records(params).first
38
38
  record_hash = @record && js_record_data
39
39
  {:records => record_hash && [record_hash] || [], :total => total_records(params)}
40
40
  end
@@ -64,7 +64,7 @@ module Netzke
64
64
  protected
65
65
 
66
66
  def total_records(params = {})
67
- @total_records ||= get_relation(params).count
67
+ @total_records ||= data_adapter.count_records(params, [])
68
68
  end
69
69
 
70
70
  end
@@ -13,6 +13,7 @@ module Netzke
13
13
  {
14
14
  :class_name => "Netzke::Basepack::SearchPanel",
15
15
  :model => config[:model],
16
+ :fields => config[:fields],
16
17
  :preset_query => config[:query],
17
18
  :auto_scroll => config[:auto_scroll]
18
19
  }
@@ -63,6 +64,7 @@ module Netzke
63
64
  s[:bbar] = (config[:bbar] || []) + [:clear_all.action, :reset.action, "->",
64
65
  I18n.t('netzke.basepack.query_builder.presets'),
65
66
  {
67
+ :itemId => "presetsCombo",
66
68
  :xtype => "combo",
67
69
  :triggerAction => "all",
68
70
  :value => super[:load_last_preset] && last_preset.try(:fetch, "name"),
@@ -2,18 +2,20 @@
2
2
  initComponent: function() {
3
3
  this.callParent();
4
4
 
5
+ this.presetsCombo = this.getDockedItems()[1].getComponent('presetsCombo');
6
+
5
7
  this.add({title: "+"});
6
8
 
7
9
  this.on('beforetabchange', function(c, newTab, curentTab){
8
10
  if (newTab.title === '+') {
9
- this.addTab(true);
11
+ this.addTab(true, true);
10
12
  return false;
11
13
  } else {
12
14
  if (this.maxTabHeight) newTab.setHeight(this.maxTabHeight);
13
15
  }
14
16
  }, this);
15
17
 
16
- this.addTab(true);
18
+ this.addTab(true, false);
17
19
 
18
20
  this.addEvents('conditionsupdate');
19
21
 
@@ -24,22 +26,31 @@
24
26
 
25
27
  if (query.length !== 0) {
26
28
  Ext.each(query, function(f, i){
27
- if (this.items.getCount() < i + 2) { this.addTab(); }
28
- this.items.get(i).buildFormFromQuery(query[i]);
29
+ var closable = i === 0 ? false : true;
30
+ if (this.items.getCount() < i + 2) { this.addTab(false, closable); }
31
+ this.items.get(i).items.first().buildFormFromQuery(query[i]);
29
32
  }, this);
30
33
  }
31
34
 
32
35
  this.doLayout();
33
36
  },
34
37
 
35
- addTab: function(activate){
38
+ addTab: function(activate, closable){
36
39
  var newTabConfig = Ext.apply({}, this.netzkeComponents.searchPanel);
37
40
  newTabConfig.id = Ext.id(); // We need a unique ID every time
38
- newTabConfig.title = "OR";
39
- newTabConfig.closable = true;
40
- var newTab = Ext.createByAlias(newTabConfig.alias, newTabConfig);
41
-
42
- this.insert(this.items.getCount() - 1, newTab);
41
+ newTabConfig.preventHeader = true;
42
+ newTabConfig.border = false;
43
+
44
+ var newTab = this.insert(
45
+ this.items.getCount() - 1,
46
+ {
47
+ title: "OR",
48
+ closable: closable,
49
+ items: [
50
+ Ext.createByAlias(newTabConfig.alias, newTabConfig)
51
+ ]
52
+ }
53
+ );
43
54
 
44
55
  if (activate) {
45
56
  this.suspendEvents();
@@ -51,8 +62,8 @@
51
62
  getQuery: function(all) {
52
63
  var query = [];
53
64
  this.eachTab(function(i) {
54
- var q = i.getQuery();
55
- if (q.length > 0) query.push(i.getQuery(all));
65
+ var q = i.items.first().getQuery();
66
+ if (q.length > 0) query.push(i.items.first().getQuery(all));
56
67
  });
57
68
  return query;
58
69
  },
@@ -66,8 +77,8 @@
66
77
  },
67
78
 
68
79
  eachTab: function(fn, scope) {
69
- this.items.each(function(f, i) {
70
- if (this.items.last() !== f) {
80
+ this.items.each(function(f) {
81
+ if (f.title !== "+") {
71
82
  fn.call(scope || f, f);
72
83
  }
73
84
  }, this);
@@ -75,7 +86,7 @@
75
86
 
76
87
  onClearAll: function() {
77
88
  this.removeAllTabs(true);
78
- this.items.first().onClearAll();
89
+ this.getActiveTab().items.first().onClearAll();
79
90
  },
80
91
 
81
92
  onReset: function() {
@@ -94,7 +105,7 @@
94
105
  var existingPresetIndex = presetsComboStore.find('field2', searchName);
95
106
  if (existingPresetIndex !== -1) {
96
107
  // overwriting
97
- Ext.Msg.confirm(this.i18n.overwriteConfirmTitle, String.format(this.i18n.overwriteConfirm, searchName), function(btn, text){
108
+ Ext.Msg.confirm(this.i18n.overwriteConfirmTitle, this.i18n.overwriteConfirm, function(btn, text){
98
109
  if (btn == 'yes') {
99
110
  var r = presetsComboStore.getAt(existingPresetIndex);
100
111
  r.set('field1', this.getQuery(true));
@@ -104,8 +115,7 @@
104
115
  }, this);
105
116
  } else {
106
117
  this.doSavePreset(searchName);
107
- var r = new presetsComboStore.recordType({field1: this.getQuery(true), field2: searchName});
108
- presetsComboStore.add(r);
118
+ presetsComboStore.add({field1: this.getQuery(true), field2: searchName});
109
119
  }
110
120
  }
111
121
  },
@@ -120,7 +130,7 @@
120
130
  onDeletePreset: function(){
121
131
  var searchName = this.presetsCombo.getRawValue();
122
132
  if (searchName !== "") {
123
- Ext.Msg.confirm(this.i18n.deleteConfirmTitle, String.format(this.i18n.overwriteConfirm, searchName), function(btn, text){
133
+ Ext.Msg.confirm(this.i18n.deleteConfirmTitle, this.i18n.overwriteConfirm, function(btn, text){
124
134
  if (btn == 'yes') {
125
135
  this.removePresetFromList(searchName);
126
136
  this.deletePreset({
@@ -4,6 +4,8 @@ module Netzke
4
4
  # +load_last_preset+ - on load, tries to load the latest saved preset
5
5
  class SearchPanel < Base
6
6
 
7
+ include Netzke::Basepack::DataAccessor
8
+
7
9
  js_base_class "Ext.form.FormPanel"
8
10
 
9
11
  js_properties(
@@ -56,14 +58,15 @@ module Netzke
56
58
  def js_config
57
59
  super.merge(
58
60
  :attrs => attributes,
59
- :attrs_hash => data_class.column_names.inject({}){ |hsh,c| hsh.merge(c => data_class.columns_hash[c].type) },
61
+ :attrs_hash => data_class.column_names.inject({}){ |hsh,c|
62
+ hsh.merge(c => data_adapter.get_property_type(data_class.columns_hash[c])) },
60
63
  :preset_query => (config[:load_last_preset] ? last_preset.try(:fetch, "query") : config[:query]) || []
61
64
  )
62
65
  end
63
66
 
64
67
  def attributes
65
- data_class.column_names.map do |name|
66
- [name, data_class.human_attribute_name(name)]
68
+ config[:fields].map do |field|
69
+ [field[:name], field[:field_label]]
67
70
  end
68
71
  end
69
72