betterplace-bi 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.semaphore/semaphore.yml +26 -0
  4. data/.tool-versions +2 -0
  5. data/.utilsrc +26 -0
  6. data/Gemfile +5 -0
  7. data/README.md +76 -0
  8. data/Rakefile +37 -0
  9. data/VERSION +1 -0
  10. data/betterplace-bi.gemspec +39 -0
  11. data/lib/betterplace-bi.rb +1 -0
  12. data/lib/bi/ab_test_helper.rb +81 -0
  13. data/lib/bi/api.rb +115 -0
  14. data/lib/bi/commands/base.rb +11 -0
  15. data/lib/bi/commands/collector.rb +32 -0
  16. data/lib/bi/commands/connection.rb +25 -0
  17. data/lib/bi/commands/delete.rb +33 -0
  18. data/lib/bi/commands/serializer.rb +40 -0
  19. data/lib/bi/commands/update.rb +29 -0
  20. data/lib/bi/commands.rb +10 -0
  21. data/lib/bi/commands_job.rb +24 -0
  22. data/lib/bi/event.rb +52 -0
  23. data/lib/bi/planning_value_parser.rb +100 -0
  24. data/lib/bi/planning_value_validations.rb +15 -0
  25. data/lib/bi/railtie.rb +13 -0
  26. data/lib/bi/request_analyzer.rb +64 -0
  27. data/lib/bi/session_id.rb +11 -0
  28. data/lib/bi/shared_value.rb +102 -0
  29. data/lib/bi/tracking.rb +28 -0
  30. data/lib/bi/type_generator.rb +79 -0
  31. data/lib/bi/update_error.rb +4 -0
  32. data/lib/bi/updater.rb +61 -0
  33. data/lib/bi/version.rb +8 -0
  34. data/lib/bi.rb +27 -0
  35. data/lib/tasks/bime.rake +55 -0
  36. data/spec/bi/ab_test_helper_spec.rb +145 -0
  37. data/spec/bi/commands/collector_spec.rb +26 -0
  38. data/spec/bi/commands/connection_spec.rb +15 -0
  39. data/spec/bi/commands_job_spec.rb +24 -0
  40. data/spec/bi/commands_spec.rb +46 -0
  41. data/spec/bi/event_spec.rb +77 -0
  42. data/spec/bi/planning_value_parser_spec.rb +94 -0
  43. data/spec/bi/request_analyzer_spec.rb +75 -0
  44. data/spec/bi/shared_value_spec.rb +47 -0
  45. data/spec/bi/tracking_spec.rb +37 -0
  46. data/spec/bi/type_generator_spec.rb +44 -0
  47. data/spec/bi/updater_spec.rb +61 -0
  48. data/spec/bime_dir/.keep +0 -0
  49. data/spec/config/bi.yml +18 -0
  50. data/spec/spec_helper.rb +31 -0
  51. data/spec/support/models.rb +106 -0
  52. metadata +331 -0
data/lib/bi/event.rb ADDED
@@ -0,0 +1,52 @@
1
+ module BI
2
+ class Event
3
+ def self.write(**args)
4
+ new.write(**args)
5
+ end
6
+
7
+ def event_class
8
+ cc.bi.event_class.constantize
9
+ end
10
+
11
+ # The parameter +channel+ is the channel that selects an ab test from the
12
+ # configuration.
13
+ # The paramter +name+ is the name of the event, e. g. show_form.
14
+ # The parameter session is either a session object or a session id string.
15
+ # In the former case the session object will be loaded if hasn't been
16
+ # loaded yet to generate the unique session id we need..
17
+ # The remaining parameters +**data+ are passed through to the created event
18
+ # as tracking parameters.
19
+ def write(
20
+ channel:, name:, version:, session:,
21
+ **data
22
+ )
23
+ event_class < BI::Tracking or
24
+ raise TypeError, 'event class has to mixin BI::Tracking'
25
+
26
+ if session.respond_to?(:loaded?)
27
+ session.loaded? or session.send(:load!)
28
+ session = BI::SessionID.session_id(session)
29
+ elsif session.respond_to?(:to_str)
30
+ session = session.to_str
31
+ else
32
+ session = nil
33
+ end
34
+
35
+ created_event = event_class.create!(
36
+ tracking: {
37
+ channel: channel,
38
+ name: name,
39
+ version: version,
40
+ session: session,
41
+ **data.transform_keys { |k| k.to_s.sub(/\Atracking_/, '').to_sym },
42
+ }
43
+ )
44
+ Log.info "Stored a #{self.class} for #{name}",
45
+ meta: { module: 'bi', event: created_event.attributes }
46
+ created_event
47
+ rescue => e
48
+ Log.error(e, notify: true, meta: { module: 'bi' })
49
+ raise e
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,100 @@
1
+ require 'csv'
2
+ require 'set'
3
+
4
+ module BI
5
+ class PlanningValueParser
6
+ class ParserError < StandardError
7
+ attr_accessor :row
8
+
9
+ attr_accessor :line
10
+ end
11
+
12
+ def self.parse(csv_text, class_name:)
13
+ begin
14
+ model_class = Object.const_get(class_name)
15
+ rescue NameError
16
+ raise ParserError, "unknown class name #{class_name.inspect}"
17
+ end
18
+ csv_text = csv_text.gsub(/\r\n?/, "\n")
19
+ errors = {}
20
+ values = []
21
+ table = CSV.parse(csv_text, headers: true, row_sep: ?\n, col_sep: ?;)
22
+ table.each_with_index do |row, i|
23
+ values << Check.new(row, line: i + 2, class_name: class_name).value
24
+ rescue ParserError => e
25
+ errors[e.line] = e.message
26
+ end
27
+ new(values, errors: errors, model_class: model_class)
28
+ end
29
+
30
+ def initialize(values, model_class:, errors: [])
31
+ @model_class = model_class
32
+ @values = values
33
+ @errors = errors
34
+ end
35
+
36
+ attr_reader :model_class
37
+
38
+ def import
39
+ model_class.where(data_version: data_versions).
40
+ destroy_all
41
+ each { |pv| pv.save! }
42
+ self
43
+ end
44
+
45
+ include Enumerable
46
+ def each(&block)
47
+ @values.each(&block)
48
+ end
49
+
50
+ def data_versions
51
+ @values.each_with_object(Set[]) { |v, s| s << v.data_version }.to_a
52
+ end
53
+
54
+ def valid?
55
+ @errors.blank? && all?(&:valid?)
56
+ end
57
+
58
+ def errors
59
+ if @errors.present?
60
+ @errors
61
+ else
62
+ @values.each_with_index.each_with_object({}) do |(v, i), errors|
63
+ v.valid?
64
+ e = v.errors.messages.full? or next
65
+ errors[i + 2] = e.transform_values { |value| value.join(' & ') }.
66
+ map { |a| "%s: %s" % a }.join(' | ')
67
+ end
68
+ end
69
+ end
70
+
71
+ VALID_HEADERS = [
72
+ "amount_in_cents",
73
+ "data_type",
74
+ "data_version",
75
+ "product_tracking",
76
+ "yearmonth",
77
+ ].sort
78
+
79
+ class Check
80
+ def initialize(row, line:, class_name:)
81
+ parser_error = ParserError.new.tap { |pe|
82
+ pe.row = row
83
+ pe.line = line
84
+ }
85
+ if row.size != VALID_HEADERS.size
86
+ raise parser_error,
87
+ "#values has to match #headers = #{VALID_HEADERS.size}"
88
+ end
89
+ if row.headers.sort != VALID_HEADERS
90
+ raise parser_error, "headers need to be #{VALID_HEADERS.inspect}"
91
+ end
92
+ @value = Object.const_get(class_name).new(row.to_h)
93
+ end
94
+
95
+ attr_reader :value
96
+
97
+ delegate :valid?, to: :@value
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,15 @@
1
+ module BI
2
+ module PlanningValueValidations
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ validates :product_tracking, presence: true
7
+ validates :amount_in_cents, presence: true,
8
+ numericality: { only_integer: true }
9
+ validates :yearmonth, presence: true,
10
+ format: { with: /\A[0-9]{4}-(1[0-2]|0[1-9])\z/ }
11
+ validates :data_type, presence: true
12
+ validates :data_version, presence: true
13
+ end
14
+ end
15
+ end
data/lib/bi/railtie.rb ADDED
@@ -0,0 +1,13 @@
1
+ module BI
2
+ if defined?(Rails::Railtie)
3
+ class Railtie < Rails::Railtie
4
+ initializer 'bi.configure_rails_initialization' do |app|
5
+ app.config.active_job.custom_serializers << BI::Commands::Serializer
6
+ end
7
+
8
+ rake_tasks do
9
+ load 'tasks/bime.rake'
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,64 @@
1
+ module BI
2
+ class RequestAnalyzer
3
+ def initialize(request)
4
+ @request = request
5
+ end
6
+
7
+ attr_reader :request
8
+
9
+ def user_agent
10
+ request.user_agent
11
+ end
12
+
13
+ def ignore?
14
+ preview? || robot?
15
+ end
16
+
17
+ def preview?
18
+ request.headers['x-purpose'] == 'preview'
19
+ end
20
+
21
+ def robot?
22
+ robot_regexp.match?(user_agent)
23
+ end
24
+
25
+ def mobile?
26
+ mobile_regexp.match?(user_agent)
27
+ end
28
+
29
+ def device_type
30
+ case
31
+ when mobile?
32
+ :mobile
33
+ when robot?
34
+ :bot
35
+ else
36
+ :desktop
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def user_agent_configuration
43
+ cc.bi.requests.user_agents?
44
+ end
45
+
46
+ memoize function:
47
+ def robot_regexp
48
+ combine_patterns(user_agent_configuration&.robot?)
49
+ end
50
+
51
+ memoize function:
52
+ def mobile_regexp
53
+ combine_patterns(user_agent_configuration&.mobile?)
54
+ end
55
+
56
+ def combine_patterns(patterns)
57
+ if patterns
58
+ /\b(?:#{patterns.to_h.keys * ?|})\b|\A\W*\z/i
59
+ else
60
+ /\p{^any}/
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,11 @@
1
+ module BI
2
+ module SessionID
3
+ def self.session_id(session)
4
+ if session.respond_to?(:id)
5
+ session.id.inspect[/\A"(\h*)"\z/i, 1].to_s
6
+ else
7
+ ''
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,102 @@
1
+ require 'active_support/time'
2
+
3
+ module BI
4
+ module SharedValue
5
+ extend Tins::Concern
6
+
7
+ included do
8
+ annotate :field
9
+
10
+ include GlobalID::Identification
11
+ end
12
+
13
+ module ClassMethods
14
+ def find(id)
15
+ new(update_class.find(id.ask_and_send_or_self(:id)))
16
+ end
17
+
18
+ implement def update_class() end, :subclass
19
+
20
+ GormTimestamp = Struct.new(:type, :gorm)
21
+
22
+ memoize function:
23
+ def bp_timestamp
24
+ Hash.new do |_, k|
25
+ raise KeyError, "illegal Attribute name #{k.inspect}"
26
+ end.merge(
27
+ Local: GormTimestamp.new('*time.Time', 'gorm:"type:timestamp;index"'),
28
+ YearLocal: GormTimestamp.new('*int', 'gorm:"type:int;index"'),
29
+ MonthLocal: GormTimestamp.new('*int', 'gorm:"type:int;index"'),
30
+ DayLocal: GormTimestamp.new('*int', 'gorm:"type:int;index"'),
31
+ HourLocal: GormTimestamp.new('*int', 'gorm:"type:int;index"'),
32
+ CWLocal: GormTimestamp.new('*int', 'gorm:"type:int;index"'),
33
+ DateLocal: GormTimestamp.new('*string', 'gorm:"type:varchar;index"'),
34
+ YearMonthLocal: GormTimestamp.new('*string', 'gorm:"type:varchar;index"'),
35
+ YearCWLocal: GormTimestamp.new('*string', 'gorm:"type:varchar;index"'),
36
+ )
37
+ end
38
+
39
+ def url(method: 'POST', id: nil)
40
+ if ep = cc.bi.endpoints[name]
41
+ if u = ep[method]
42
+ id and return u = u.sub(':id', id)
43
+ u
44
+ end
45
+ end
46
+ end
47
+
48
+ def clear
49
+ command = BI::Commands::Delete.new(
50
+ url: cc.bi.clear.sub(':id', update_class.name)
51
+ )
52
+ BI::Commands::Connection.connect_to_server do |connection|
53
+ command.perform_via(connection)
54
+ end
55
+ self
56
+ end
57
+ end
58
+
59
+ def initialize(object)
60
+ if object_uuid = object.ask_and_send(:to_str)
61
+ object = update_class.find(object_uuid)
62
+ end
63
+ @object = object
64
+ end
65
+
66
+ private
67
+
68
+ def iso_timestamp(time)
69
+ if time
70
+ time.in_time_zone('Europe/Berlin').strftime('%FT%T.%6NZ') # with precision 6 sub seconds
71
+ end
72
+ end
73
+
74
+ public
75
+
76
+ def update_class
77
+ self.class.update_class
78
+ end
79
+
80
+ def url(**args)
81
+ self.class.url(**args)
82
+ end
83
+
84
+ def to_s
85
+ "#<#{self.class} type=#{update_class} id=#{@object.id}>"
86
+ end
87
+
88
+ def attributes
89
+ field_annotations.each_with_object({}) do |(name, opts), result|
90
+ result[name] = __send__(name)
91
+ end
92
+ end
93
+
94
+ def as_json(*)
95
+ attributes
96
+ end
97
+
98
+ def to_json(*a)
99
+ as_json.to_json(*a)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,28 @@
1
+ module BI
2
+ module Tracking
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ serialize :tracking, coder: JSON
7
+ end
8
+
9
+ def tracking_hash
10
+ tracking.to_h.symbolize_keys
11
+ end
12
+
13
+ def respond_to_missing?(name, include_private = false)
14
+ name =~ /\Atracking_(.+=|.*[^?!])\z/ or super
15
+ end
16
+
17
+ def method_missing(name, *args, &block)
18
+ case name
19
+ when /\Atracking_(.+)=\z/
20
+ tracking[$1] = args.first
21
+ when /\Atracking_(.*[^?!])\z/
22
+ tracking[$1]
23
+ else
24
+ super
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,79 @@
1
+ module BI
2
+ class TypeGenerator
3
+ def initialize(output_dir: cc.bi.bime_dir)
4
+ @output_dir = Pathname.new(output_dir)
5
+ @value_classes = cc.bi.endpoints.attribute_names.
6
+ map(&:to_s).map(&:constantize)
7
+ end
8
+
9
+ def timestamp_value(attribute_name, attribute_suffix, db_value)
10
+ %{ #{attribute_name + attribute_suffix.to_s} #{db_value.type} `json:"-" #{db_value.gorm}`}
11
+ end
12
+
13
+ def gofmt
14
+ command = `which gofmt`.chomp
15
+ return if command.empty?
16
+ command
17
+ end
18
+
19
+ def generate
20
+ STDOUT.puts "Generating GO types in directory #{@output_dir}"
21
+ @value_classes.each do |vc|
22
+ c = vc.name.demodulize
23
+ output_filename = @output_dir.join(vc.update_class.name.underscore + ".go")
24
+ temp_io(name: 'go-type', content: -> src {
25
+ code = ''
26
+ STDOUT.print "Generating type code for #{c} …"
27
+ imports = Set[]
28
+ vc.field_annotations.each do |name, opts|
29
+ attribute_name = name.to_s.camelize
30
+ db = opts.fetch(:db)
31
+ case type = opts.fetch(:type)
32
+ when '*time.Time'
33
+ imports << 'time'
34
+ code << %{ #{attribute_name} #{type} `json:"#{name}" gorm:"-" timestamp:"split"`\n}
35
+ db.each do |attribute_suffix, db_value|
36
+ code << timestamp_value(attribute_name, attribute_suffix, db_value) << "\n"
37
+ end
38
+ next
39
+ when 'pq.StringArray'
40
+ imports << 'github.com/lib/pq'
41
+ end
42
+ code << %{ #{attribute_name} #{type} `json:"#{name}" #{db}`\n}
43
+ rescue KeyError, NoMethodError => e
44
+ raise "Error for #{name} #{e.class}: #{e}"
45
+ end
46
+ code << "}\n"
47
+ code = preamble(vc.update_class, imports: imports) + code
48
+ src.write code
49
+ STDOUT.puts "done."
50
+ }) do |src|
51
+ STDOUT.print "Outputting type code to #{output_filename} …"
52
+ File.open(output_filename, ?w) do |out|
53
+ if g = gofmt
54
+ out.puts `#{g} #{src.path}`
55
+ else
56
+ out.puts IO.read(src.path)
57
+ end
58
+ end
59
+ STDOUT.puts "done."
60
+ end
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def preamble(type_name, imports:)
67
+ result = "package bime\n\n"
68
+ if i = imports.full?(:to_a)
69
+ result << <<~end
70
+ import (
71
+ #{i.map { |x| " #{x.dump}" }.join(?\n)}
72
+ )
73
+
74
+ end
75
+ end
76
+ result << "type #{type_name} struct {\n"
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,4 @@
1
+ module BI
2
+ class UpdateError < StandardError
3
+ end
4
+ end
data/lib/bi/updater.rb ADDED
@@ -0,0 +1,61 @@
1
+ module BI
2
+ module Updater
3
+ extend Tins::Concern
4
+
5
+ included do
6
+ before_save :schedule_bi_update_job_on_change, if: :changed?
7
+
8
+ before_destroy :schedule_bi_update_job_on_change
9
+
10
+ after_commit :schedule_bi_update_job
11
+
12
+ thread_local :change_detected, false
13
+ end
14
+
15
+ class << self
16
+ thread_local :bi_commands
17
+ end
18
+
19
+ def schedule_bi_update_job_on_change
20
+ self.change_detected = true
21
+ end
22
+
23
+ def bi_commands
24
+ BI::Updater.bi_commands ||= BI::Commands::Collector.new
25
+ yield BI::Updater.bi_commands
26
+ if BI::Updater.bi_commands.schedule_job?(self)
27
+ BI::Updater.bi_commands.schedule_job
28
+ BI::Updater.bi_commands = nil
29
+ end
30
+ end
31
+
32
+ def bi_update(really = true)
33
+ if cc.bi.update
34
+ if really
35
+ self.change_detected = false # Set to false as early as possible
36
+ bi_commands do |commands|
37
+ value_type = "BI::%sValue" % self.class.name
38
+ begin
39
+ value_class = value_type.constantize
40
+ rescue NameError => e
41
+ BI.error(e, notify: true, meta: { module: 'bi' })
42
+ else
43
+ BI::Updater.bi_commands.add(self, value_class)
44
+ end
45
+ ask_and_send(:trigger_dependent_updates)
46
+ end
47
+ end
48
+ else
49
+ Log.info "Would send a BI Command for #{self.class}##{id} if not disabled"
50
+ end
51
+ end
52
+
53
+ def schedule_bi_update_job(default_change_detected = false)
54
+ self.change_detected ||= default_change_detected
55
+ bi_update(change_detected)
56
+ true
57
+ ensure
58
+ self.change_detected = false
59
+ end
60
+ end
61
+ end
data/lib/bi/version.rb ADDED
@@ -0,0 +1,8 @@
1
+ module BI
2
+ # BI version
3
+ VERSION = '0.7.0'
4
+ VERSION_ARRAY = VERSION.split('.').map(&:to_i) # :nodoc:
5
+ VERSION_MAJOR = VERSION_ARRAY[0] # :nodoc:
6
+ VERSION_MINOR = VERSION_ARRAY[1] # :nodoc:
7
+ VERSION_BUILD = VERSION_ARRAY[2] # :nodoc:
8
+ end
data/lib/bi.rb ADDED
@@ -0,0 +1,27 @@
1
+ require 'complex_config/rude'
2
+ require 'tins/xt'
3
+ require 'globalid'
4
+ require 'excon'
5
+ require 'logger'
6
+ require 'betterlog'
7
+
8
+ module BI
9
+ end
10
+
11
+ require 'bi/version'
12
+ require 'bi/railtie'
13
+ require 'bi/shared_value'
14
+ require 'bi/type_generator'
15
+ require 'bi/update_error'
16
+ require 'bi/updater'
17
+ require 'bi/commands'
18
+ require 'bi/commands_job' if defined?(ActiveJob::Base)
19
+
20
+ require 'bi/planning_value_validations'
21
+ require 'bi/planning_value_parser'
22
+ require 'bi/session_id'
23
+ require 'bi/event'
24
+ require 'bi/request_analyzer'
25
+ require 'bi/tracking'
26
+ require 'bi/ab_test_helper'
27
+ require 'bi/api'
@@ -0,0 +1,55 @@
1
+ namespace :bime do
2
+ desc 'Generate golang type definitions'
3
+ task :types => :environment do
4
+ BI::TypeGenerator.new.generate
5
+ end
6
+
7
+ silence = proc { |&b| ActiveJob::Base.logger.silence(&b) }
8
+
9
+ desc 'Update all BI values'
10
+ task :update => :environment do |task, args|
11
+ cc.bi.update or raise "BI Update was disabled in cc.bi.update!"
12
+
13
+ args = args.to_a
14
+
15
+ if args.empty?
16
+ args.concat(cc.bi.endpoints.attribute_names.map { |n|
17
+ n.to_s.demodulize.sub(/Value\z/, '').underscore
18
+ })
19
+ end
20
+
21
+ models = args.map { |a| a.camelize.constantize }
22
+
23
+ silence.() {
24
+ models.each do |model|
25
+ model.find_each.with_infobar(label: model.name) do |o|
26
+ infobar << o.bi_update
27
+ end
28
+ infobar.finish
29
+ infobar.newline
30
+ end
31
+ }
32
+ end
33
+
34
+ desc 'Clear all BI values'
35
+ task :clear => :environment do |task, args|
36
+ cc.bi.update or raise "BI Update was disabled in cc.bi.update!"
37
+
38
+ args = args.to_a
39
+ if args.empty?
40
+ args.concat(cc.bi.endpoints.attribute_names.map { |n|
41
+ n.to_s.demodulize.sub(/Value\z/, '').underscore
42
+ })
43
+ end
44
+
45
+ args.each.with_infobar(label: 'BI Values') do |v|
46
+ infobar << "BI::#{(v + '_value').camelize}".constantize.clear
47
+ end
48
+
49
+ infobar.finish
50
+ infobar.newline
51
+ end
52
+
53
+ desc 'Reset all BI values'
54
+ task :reset => %i[ clear update ]
55
+ end