forest_liana 7.0.0.beta.4 → 7.0.0.beta.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/forest_liana/actions_controller.rb +2 -2
  3. data/app/controllers/forest_liana/associations_controller.rb +2 -2
  4. data/app/controllers/forest_liana/resources_controller.rb +15 -6
  5. data/app/controllers/forest_liana/scopes_controller.rb +20 -0
  6. data/app/controllers/forest_liana/smart_actions_controller.rb +39 -3
  7. data/app/controllers/forest_liana/stats_controller.rb +5 -5
  8. data/app/services/forest_liana/filters_parser.rb +8 -4
  9. data/app/services/forest_liana/has_many_dissociator.rb +2 -2
  10. data/app/services/forest_liana/has_many_getter.rb +2 -2
  11. data/app/services/forest_liana/leaderboard_stat_getter.rb +20 -14
  12. data/app/services/forest_liana/line_stat_getter.rb +4 -2
  13. data/app/services/forest_liana/permissions_checker.rb +3 -4
  14. data/app/services/forest_liana/permissions_getter.rb +2 -2
  15. data/app/services/forest_liana/pie_stat_getter.rb +6 -3
  16. data/app/services/forest_liana/resource_getter.rb +6 -3
  17. data/app/services/forest_liana/resource_updater.rb +5 -2
  18. data/app/services/forest_liana/resources_getter.rb +6 -5
  19. data/app/services/forest_liana/scope_manager.rb +102 -0
  20. data/app/services/forest_liana/search_query_builder.rb +6 -3
  21. data/app/services/forest_liana/stat_getter.rb +2 -1
  22. data/app/services/forest_liana/value_stat_getter.rb +4 -2
  23. data/config/routes.rb +3 -0
  24. data/lib/forest_liana/version.rb +1 -1
  25. data/spec/dummy/app/controllers/forest/islands_controller.rb +5 -0
  26. data/spec/dummy/config/routes.rb +4 -0
  27. data/spec/dummy/lib/forest_liana/collections/island.rb +7 -0
  28. data/spec/requests/actions_controller_spec.rb +100 -12
  29. data/spec/requests/resources_spec.rb +2 -0
  30. data/spec/services/forest_liana/filters_parser_spec.rb +1 -1
  31. data/spec/services/forest_liana/has_many_getter_spec.rb +116 -0
  32. data/spec/services/forest_liana/line_stat_getter_spec.rb +14 -6
  33. data/spec/services/forest_liana/permissions_checker_acl_disabled_spec.rb +1 -3
  34. data/spec/services/forest_liana/pie_stat_getter_spec.rb +114 -0
  35. data/spec/services/forest_liana/resource_updater_spec.rb +116 -0
  36. data/spec/services/forest_liana/resources_getter_spec.rb +68 -1
  37. data/spec/services/forest_liana/scope_manager_spec.rb +232 -0
  38. data/spec/services/forest_liana/value_stat_getter_spec.rb +96 -0
  39. metadata +18 -13
  40. data/app/services/forest_liana/scope_validator.rb +0 -98
  41. data/test/services/forest_liana/has_many_getter_test.rb +0 -75
  42. data/test/services/forest_liana/pie_stat_getter_test.rb +0 -29
  43. data/test/services/forest_liana/resource_updater_test.rb +0 -86
  44. data/test/services/forest_liana/scope_validator_test.rb +0 -185
  45. data/test/services/forest_liana/value_stat_getter_test.rb +0 -71
@@ -3,15 +3,18 @@ module ForestLiana
3
3
  attr_accessor :record
4
4
  attr_accessor :errors
5
5
 
6
- def initialize(resource, params)
6
+ def initialize(resource, params, forest_user)
7
7
  @resource = resource
8
8
  @params = params
9
9
  @errors = nil
10
+ @user = forest_user
10
11
  end
11
12
 
12
13
  def perform
13
14
  begin
14
- @record = @resource.find(@params[:id])
15
+ collection_name = ForestLiana.name_for(@resource)
16
+ scoped_records = ForestLiana::ScopeManager.apply_scopes_on_records(@resource, @user, collection_name, @params[:timezone])
17
+ @record = scoped_records.find(@params[:id])
15
18
 
16
19
  if has_strong_parameter
17
20
  @record.update(resource_params)
@@ -4,7 +4,7 @@ module ForestLiana
4
4
  attr_reader :includes
5
5
  attr_reader :records_count
6
6
 
7
- def initialize(resource, params)
7
+ def initialize(resource, params, forest_user)
8
8
  @resource = resource
9
9
  @params = params
10
10
  @count_needs_includes = false
@@ -14,12 +14,13 @@ module ForestLiana
14
14
  @field_names_requested = field_names_requested
15
15
  get_segment
16
16
  compute_includes
17
- @search_query_builder = SearchQueryBuilder.new(@params, @includes, @collection)
17
+ @user = forest_user
18
+ @search_query_builder = SearchQueryBuilder.new(@params, @includes, @collection, forest_user)
18
19
 
19
20
  prepare_query
20
21
  end
21
22
 
22
- def self.get_ids_from_request(params)
23
+ def self.get_ids_from_request(params, user)
23
24
  attributes = params.dig('data', 'attributes')
24
25
  has_body_attributes = attributes != nil
25
26
  is_select_all_records_query = has_body_attributes && attributes[:all_records] == true
@@ -45,11 +46,11 @@ module ForestLiana
45
46
  collection: parent_collection_name,
46
47
  id: attributes[:parent_collection_id],
47
48
  association_name: attributes[:parent_association_name],
48
- }))
49
+ }), user)
49
50
  else
50
51
  collection_name = attributes[:collection_name]
51
52
  model = ForestLiana::SchemaUtils.find_model_from_collection_name(collection_name)
52
- resources_getter = ForestLiana::ResourcesGetter.new(model, attributes)
53
+ resources_getter = ForestLiana::ResourcesGetter.new(model, attributes, user)
53
54
  end
54
55
 
55
56
  # NOTICE: build IDs list.
@@ -0,0 +1,102 @@
1
+ module ForestLiana
2
+ class ScopeManager
3
+ @@scopes_cache = Hash.new
4
+ # 5 minutes exipration cache
5
+ @@scope_cache_expiration_delta = 300
6
+
7
+ def self.apply_scopes_on_records(records, forest_user, collection_name, timezone)
8
+ scope_filters = get_scope_for_user(forest_user, collection_name, as_string: true)
9
+
10
+ return records if scope_filters.blank?
11
+
12
+ FiltersParser.new(scope_filters, records, timezone).apply_filters
13
+ end
14
+
15
+ def self.append_scope_for_user(existing_filter, user, collection_name)
16
+ scope_filter = get_scope_for_user(user, collection_name, as_string: true)
17
+ filters = [existing_filter, scope_filter].compact
18
+
19
+ case filters.length
20
+ when 0
21
+ nil
22
+ when 1
23
+ filters[0]
24
+ else
25
+ "{\"aggregator\":\"and\",\"conditions\":[#{existing_filter},#{scope_filter}]}"
26
+ end
27
+ end
28
+
29
+ def self.get_scope_for_user(user, collection_name, as_string: false)
30
+ raise 'Missing required rendering_id' unless user['rendering_id']
31
+ raise 'Missing required collection_name' unless collection_name
32
+
33
+ collection_scope = get_collection_scope(user['rendering_id'], collection_name)
34
+
35
+ return nil unless collection_scope
36
+
37
+ filters = format_dynamic_values(user['id'], collection_scope)
38
+
39
+ as_string && filters ? JSON.generate(filters) : filters
40
+ end
41
+
42
+ def self.get_collection_scope(rendering_id, collection_name)
43
+ if !@@scopes_cache[rendering_id]
44
+ # when scope cache is unset wait for the refresh
45
+ refresh_scopes_cache(rendering_id)
46
+ elsif has_cache_expired?(rendering_id)
47
+ # when cache expired refresh the scopes without waiting for it
48
+ Thread.new { refresh_scopes_cache(rendering_id) }
49
+ end
50
+
51
+ @@scopes_cache[rendering_id][:scopes][collection_name]
52
+ end
53
+
54
+ def self.has_cache_expired?(rendering_id)
55
+ rendering_scopes = @@scopes_cache[rendering_id]
56
+ return true unless rendering_scopes
57
+
58
+ second_since_last_fetch = Time.now - rendering_scopes[:fetched_at]
59
+ second_since_last_fetch >= @@scope_cache_expiration_delta
60
+ end
61
+
62
+ def self.refresh_scopes_cache(rendering_id)
63
+ scopes = fetch_scopes(rendering_id)
64
+ @@scopes_cache[rendering_id] = {
65
+ :fetched_at => Time.now,
66
+ :scopes => scopes
67
+ }
68
+ end
69
+
70
+ def self.fetch_scopes(rendering_id)
71
+ query_parameters = { 'renderingId' => rendering_id }
72
+ response = ForestLiana::ForestApiRequester.get('/liana/scopes', query: query_parameters)
73
+
74
+ if response.is_a?(Net::HTTPOK)
75
+ JSON.parse(response.body)
76
+ else
77
+ raise 'Unable to fetch scopes'
78
+ end
79
+ end
80
+
81
+ def self.format_dynamic_values(user_id, collection_scope)
82
+ filter = collection_scope.dig('scope', 'filter')
83
+ return nil unless filter
84
+
85
+ dynamic_scopes_values = collection_scope.dig('scope', 'dynamicScopesValues')
86
+
87
+ # Only goes one level deep as required for now
88
+ filter['conditions'].map do |condition|
89
+ value = condition['value']
90
+ if value.is_a?(String) && value.start_with?('$currentUser')
91
+ condition['value'] = dynamic_scopes_values.dig('users', user_id, value)
92
+ end
93
+ end
94
+
95
+ filter
96
+ end
97
+
98
+ def self.invalidate_scope_cache(rendering_id)
99
+ @@scopes_cache.delete(rendering_id)
100
+ end
101
+ end
102
+ end
@@ -4,12 +4,13 @@ module ForestLiana
4
4
 
5
5
  attr_reader :fields_searched
6
6
 
7
- def initialize(params, includes, collection)
7
+ def initialize(params, includes, collection, user)
8
8
  @params = params
9
9
  @includes = includes
10
10
  @collection = collection
11
11
  @fields_searched = []
12
12
  @search = @params[:search]
13
+ @user = user
13
14
  end
14
15
 
15
16
  def perform(resource)
@@ -18,8 +19,10 @@ module ForestLiana
18
19
  ForestLiana::QueryHelper.get_tables_associated_to_relations_name(@resource)
19
20
  @records = search_param
20
21
 
21
- unless @params[:filters].blank?
22
- @records = FiltersParser.new(@params[:filters], @records, @params[:timezone]).apply_filters
22
+ filters = ForestLiana::ScopeManager.append_scope_for_user(@params[:filters], @user, @collection.name)
23
+
24
+ unless filters.blank?
25
+ @records = FiltersParser.new(filters, @records, @params[:timezone]).apply_filters
23
26
  end
24
27
 
25
28
  if @search
@@ -2,9 +2,10 @@ module ForestLiana
2
2
  class StatGetter < BaseGetter
3
3
  attr_accessor :record
4
4
 
5
- def initialize(resource, params)
5
+ def initialize(resource, params, forest_user)
6
6
  @resource = resource
7
7
  @params = params
8
+ @user = forest_user
8
9
  compute_includes()
9
10
  end
10
11
  end
@@ -6,8 +6,10 @@ module ForestLiana
6
6
  return if @params[:aggregate].blank?
7
7
  resource = get_resource().eager_load(@includes)
8
8
 
9
- unless @params[:filters].blank?
10
- filter_parser = FiltersParser.new(@params[:filters], resource, @params[:timezone])
9
+ filters = ForestLiana::ScopeManager.append_scope_for_user(@params[:filters], @user, @resource.name)
10
+
11
+ unless filters.blank?
12
+ filter_parser = FiltersParser.new(filters, resource, @params[:timezone])
11
13
  resource = filter_parser.apply_filters
12
14
  raw_previous_interval = filter_parser.get_previous_interval_condition
13
15
 
data/config/routes.rb CHANGED
@@ -20,6 +20,9 @@ ForestLiana::Engine.routes.draw do
20
20
  post '/stats/:collection' => 'stats#get'
21
21
  post '/stats' => 'stats#get_with_live_query'
22
22
 
23
+ # Scopes
24
+ post '/scope-cache-invalidation' => 'scopes#invalidate_scope_cache'
25
+
23
26
  # Stripe Integration
24
27
  get '(*collection)_stripe_payments' => 'stripe#payments'
25
28
  get ':collection/:id/stripe_payments' => 'stripe#payments'
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "7.0.0.beta.4"
2
+ VERSION = "7.0.0.beta.5"
3
3
  end
@@ -0,0 +1,5 @@
1
+ class Forest::IslandsController < ForestLiana::SmartActionsController
2
+ def test
3
+ render json: { success: 'You are OK.' }
4
+ end
5
+ end
@@ -1,3 +1,7 @@
1
1
  Rails.application.routes.draw do
2
+ namespace :forest do
3
+ post '/actions/test' => 'islands#test'
4
+ end
5
+
2
6
  mount ForestLiana::Engine => "/forest"
3
7
  end
@@ -0,0 +1,7 @@
1
+ class Forest::Island
2
+ include ForestLiana::Collection
3
+
4
+ collection :Island
5
+
6
+ action 'Test'
7
+ end
@@ -1,16 +1,42 @@
1
1
  require 'rails_helper'
2
2
 
3
3
  describe 'Requesting Actions routes', :type => :request do
4
+ let(:rendering_id) { 13 }
5
+ let(:scope_filters) { nil }
6
+
4
7
  before(:each) do
5
8
  allow(ForestLiana::IpWhitelist).to receive(:is_ip_whitelist_retrieved) { true }
6
9
  allow(ForestLiana::IpWhitelist).to receive(:is_ip_valid) { true }
7
- Island.create(name: 'Corsica')
10
+ Island.create(id: 1, name: 'Corsica')
11
+
12
+ ForestLiana::ScopeManager.invalidate_scope_cache(rendering_id)
13
+ allow(ForestLiana::ScopeManager).to receive(:get_scope_for_user).and_return(scope_filters)
8
14
  end
9
15
 
10
16
  after(:each) do
11
17
  Island.destroy_all
12
18
  end
13
-
19
+
20
+ let(:token) {
21
+ JWT.encode({
22
+ id: 38,
23
+ email: 'michael.kelso@that70.show',
24
+ first_name: 'Michael',
25
+ last_name: 'Kelso',
26
+ team: 'Operations',
27
+ rendering_id: rendering_id,
28
+ exp: Time.now.to_i + 2.weeks.to_i
29
+ }, ForestLiana.auth_secret, 'HS256')
30
+ }
31
+
32
+ let(:headers) {
33
+ {
34
+ 'Accept' => 'application/json',
35
+ 'Content-Type' => 'application/json',
36
+ 'Authorization' => "Bearer #{token}"
37
+ }
38
+ }
39
+
14
40
  describe 'hooks' do
15
41
  foo = {
16
42
  field: 'foo',
@@ -126,25 +152,25 @@ describe 'Requesting Actions routes', :type => :request do
126
152
  }
127
153
 
128
154
  it 'should respond 200' do
129
- post '/forest/actions/my_action/hooks/load', params: JSON.dump(params), headers: { 'CONTENT_TYPE' => 'application/json' }
155
+ post '/forest/actions/my_action/hooks/load', params: JSON.dump(params), headers: headers
130
156
  expect(response.status).to eq(200)
131
157
  expect(JSON.parse(response.body)).to eq({'fields' => [foo.merge({:value => nil}).stringify_keys]})
132
158
  end
133
159
 
134
160
  it 'should respond 500 with bad params' do
135
- post '/forest/actions/my_action/hooks/load', params: {}
161
+ post '/forest/actions/my_action/hooks/load', params: {}, headers: headers
136
162
  expect(response.status).to eq(500)
137
163
  expect(JSON.parse(response.body)).to eq({'error' => 'Error in smart action load hook: cannot retrieve action from collection'})
138
164
  end
139
165
 
140
166
  it 'should respond 500 with bad hook result type' do
141
- post '/forest/actions/fail_action/hooks/load', params: JSON.dump(params), headers: { 'CONTENT_TYPE' => 'application/json' }
167
+ post '/forest/actions/fail_action/hooks/load', params: JSON.dump(params), headers: headers
142
168
  expect(response.status).to eq(500)
143
169
  expect(JSON.parse(response.body)).to eq({'error' => 'Error in smart action load hook: hook must return an array of fields'})
144
170
  end
145
171
 
146
172
  it 'should respond 500 with bad hook result data structure' do
147
- post '/forest/actions/cheat_action/hooks/load', params: JSON.dump(params), headers: { 'CONTENT_TYPE' => 'application/json' }
173
+ post '/forest/actions/cheat_action/hooks/load', params: JSON.dump(params), headers: headers
148
174
  expect(response.status).to eq(500)
149
175
  expect(JSON.parse(response.body)).to eq({'error' => 'Error in smart action load hook: hook must return an array of fields'})
150
176
  end
@@ -164,7 +190,7 @@ describe 'Requesting Actions routes', :type => :request do
164
190
  }
165
191
 
166
192
  it 'should respond 200' do
167
- post '/forest/actions/my_action/hooks/change', params: JSON.dump(params), headers: { 'CONTENT_TYPE' => 'application/json' }
193
+ post '/forest/actions/my_action/hooks/change', params: JSON.dump(params), headers: headers
168
194
  expect(response.status).to eq(200)
169
195
  expected = updated_foo.clone.merge({:value => 'baz'})
170
196
  expected[:widgetEdit] = nil
@@ -173,13 +199,13 @@ describe 'Requesting Actions routes', :type => :request do
173
199
  end
174
200
 
175
201
  it 'should respond 500 with bad params' do
176
- post '/forest/actions/my_action/hooks/change', params: JSON.dump({ data: { attributes: { collection_name: 'Island' }}}), headers: { 'CONTENT_TYPE' => 'application/json' }
202
+ post '/forest/actions/my_action/hooks/change', params: JSON.dump({ data: { attributes: { collection_name: 'Island' }}}), headers: headers
177
203
  expect(response.status).to eq(500)
178
204
  expect(JSON.parse(response.body)).to eq({'error' => 'Error in smart action change hook: fields params is mandatory'})
179
205
  end
180
206
 
181
207
  it 'should respond 500 with bad hook result type' do
182
- post '/forest/actions/fail_action/hooks/change', params: JSON.dump(params), headers: { 'CONTENT_TYPE' => 'application/json' }
208
+ post '/forest/actions/fail_action/hooks/change', params: JSON.dump(params), headers: headers
183
209
  expect(response.status).to eq(500)
184
210
  expect(JSON.parse(response.body)).to eq({'error' => 'Error in smart action load hook: hook must return an array of fields'})
185
211
  end
@@ -196,7 +222,7 @@ describe 'Requesting Actions routes', :type => :request do
196
222
  }
197
223
  }
198
224
  }
199
- post '/forest/actions/enums_action/hooks/change', params: JSON.dump(p), headers: { 'CONTENT_TYPE' => 'application/json' }
225
+ post '/forest/actions/enums_action/hooks/change', params: JSON.dump(p), headers: headers
200
226
  expect(response.status).to eq(200)
201
227
 
202
228
  expected_enum = updated_enum.clone.merge({ :enums => %w[c d e], :value => nil, :widgetEdit => nil})
@@ -219,7 +245,7 @@ describe 'Requesting Actions routes', :type => :request do
219
245
  }
220
246
  }
221
247
  }
222
- post '/forest/actions/multiple_enums_action/hooks/change', params: JSON.dump(p), headers: { 'CONTENT_TYPE' => 'application/json' }
248
+ post '/forest/actions/multiple_enums_action/hooks/change', params: JSON.dump(p), headers: headers
223
249
  expect(response.status).to eq(200)
224
250
 
225
251
  expected_multiple_enum = updated_multiple_enum.clone.merge({ :enums => %w[c d z], :widgetEdit => nil, :value => %w[c]})
@@ -243,7 +269,7 @@ describe 'Requesting Actions routes', :type => :request do
243
269
  }
244
270
  }
245
271
 
246
- post '/forest/actions/multiple_enums_action/hooks/change', params: JSON.dump(p), headers: { 'CONTENT_TYPE' => 'application/json' }
272
+ post '/forest/actions/multiple_enums_action/hooks/change', params: JSON.dump(p), headers: headers
247
273
  expect(response.status).to eq(200)
248
274
 
249
275
  expected_multiple_enum = wrongly_updated_multiple_enum.clone.merge({ :enums => %w[c d z], :widgetEdit => nil, :value => nil })
@@ -255,4 +281,66 @@ describe 'Requesting Actions routes', :type => :request do
255
281
  end
256
282
  end
257
283
  end
284
+
285
+ describe 'calling the action' do
286
+ before(:each) do
287
+ allow_any_instance_of(ForestLiana::PermissionsChecker).to receive(:is_authorized?) { true }
288
+ end
289
+
290
+ let(:all_records) { false }
291
+ let(:params) {
292
+ {
293
+ data: {
294
+ attributes: {
295
+ collection_name: 'Island',
296
+ ids: ['1'],
297
+ all_records: all_records,
298
+ smart_action_id: 'Island-Test'
299
+ },
300
+ type: 'custom-action-requests'
301
+ },
302
+ timezone: 'Europe/Paris'
303
+ }
304
+ }
305
+
306
+ describe 'without scopes' do
307
+ it 'should respond 200 and perform the action' do
308
+ post '/forest/actions/test', params: JSON.dump(params), headers: headers
309
+ expect(response.status).to eq(200)
310
+ expect(JSON.parse(response.body)).to eq({'success' => 'You are OK.'})
311
+ end
312
+ end
313
+
314
+ describe 'with scopes' do
315
+ describe 'when record is in scope' do
316
+ let(:scope_filters) { JSON.generate({ field: 'name', operator: 'equal', value: 'Corsica' }) }
317
+
318
+ it 'should respond 200 and perform the action' do
319
+ post '/forest/actions/test', params: JSON.dump(params), headers: headers
320
+ expect(response.status).to eq(200)
321
+ expect(JSON.parse(response.body)).to eq({'success' => 'You are OK.'})
322
+ end
323
+ end
324
+
325
+ describe 'when record is out of scope' do
326
+ let(:scope_filters) { JSON.generate({ field: 'name', operator: 'equal', value: 'Ré' }) }
327
+
328
+ it 'should respond 400 and NOT perform the action' do
329
+ post '/forest/actions/test', params: JSON.dump(params), headers: headers
330
+ expect(response.status).to eq(400)
331
+ expect(JSON.parse(response.body)).to eq({ 'error' => 'Smart Action: target record not found' })
332
+ end
333
+
334
+ describe 'and all_records are targeted' do
335
+ let(:all_records) { true }
336
+
337
+ it 'should respond 200 and perform the action' do
338
+ post '/forest/actions/test', params: JSON.dump(params), headers: headers
339
+ expect(response.status).to eq(200)
340
+ expect(JSON.parse(response.body)).to eq({'success' => 'You are OK.'})
341
+ end
342
+ end
343
+ end
344
+ end
345
+ end
258
346
  end