betterplace-bi 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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