segmentor 0.0.1
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.
- checksums.yaml +7 -0
- data/.editorconfig +10 -0
- data/.gitignore +6 -0
- data/.rubocop.yml +10 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +219 -0
- data/LICENSE +201 -0
- data/README.md +0 -0
- data/Rakefile +4 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/config.ru +9 -0
- data/lib/generators/segmentor/segmentor.rb +27 -0
- data/lib/generators/segmentor/templates/create_segmentor_tables.rb +59 -0
- data/lib/segmentor/email_notifier_context.rb +8 -0
- data/lib/segmentor/errors/evaluation_error.rb +9 -0
- data/lib/segmentor/errors/notification_error.rb +16 -0
- data/lib/segmentor/errors/session_error.rb +12 -0
- data/lib/segmentor/notifier_context.rb +14 -0
- data/lib/segmentor/receipt.rb +35 -0
- data/lib/segmentor/segment.rb +151 -0
- data/lib/segmentor/session.rb +97 -0
- data/lib/segmentor/source.rb +19 -0
- data/lib/segmentor/sources/ruby_source.rb +26 -0
- data/lib/segmentor/target.rb +66 -0
- data/lib/segmentor/version.rb +5 -0
- data/lib/segmentor.rb +11 -0
- data/segmentor.gemspec +45 -0
- metadata +310 -0
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Segmentor
|
4
|
+
# Receipt is generated when a notification is sent to a target.
|
5
|
+
class Receipt < ActiveRecord::Base
|
6
|
+
self.table_name = :segmentor_receipts
|
7
|
+
store :metadata, accessors: %i[target_id session_id], coder: JSON
|
8
|
+
|
9
|
+
belongs_to :segment, class_name: '::Segmentor::Segment'
|
10
|
+
belongs_to :user
|
11
|
+
|
12
|
+
validates :sent_at, presence: true
|
13
|
+
|
14
|
+
def self.record_receipt(target, rendered_value = nil)
|
15
|
+
receipt = ::Segmentor::Receipt.new
|
16
|
+
receipt.segment = target.segment
|
17
|
+
receipt.user_id = target.user_id
|
18
|
+
receipt.target_id = target.id
|
19
|
+
receipt.session_id = target.session_id
|
20
|
+
receipt.rendered_value = rendered_value
|
21
|
+
receipt.sent_at = Time.now.utc
|
22
|
+
receipt.save!
|
23
|
+
|
24
|
+
receipt
|
25
|
+
end
|
26
|
+
|
27
|
+
def next
|
28
|
+
segment.receipts.where('id > ?', id).first
|
29
|
+
end
|
30
|
+
|
31
|
+
def prev
|
32
|
+
segment.receipts.where('id < ?', id).last
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,151 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_record'
|
4
|
+
|
5
|
+
module Segmentor
|
6
|
+
# Segment is a slice of data, based on a condition.
|
7
|
+
class Segment < ActiveRecord::Base
|
8
|
+
self.table_name = :segmentor_segments
|
9
|
+
|
10
|
+
NO_REPEAT = 0
|
11
|
+
|
12
|
+
has_many :receipts, class_name: '::Segmentor::Receipt', dependent: :destroy
|
13
|
+
has_many :sessions, class_name: '::Segmentor::Session', dependent: :destroy
|
14
|
+
has_one :source, class_name: '::Segmentor::Source', inverse_of: :segment, dependent: :destroy
|
15
|
+
has_one :notifier_context, class_name: '::Segmentor::NotifierContext', inverse_of: :segment, dependent: :destroy
|
16
|
+
|
17
|
+
accepts_nested_attributes_for :source
|
18
|
+
accepts_nested_attributes_for :notifier_context
|
19
|
+
|
20
|
+
class_attribute :_after_session_change
|
21
|
+
|
22
|
+
def self.after_session_change(method_name)
|
23
|
+
self._after_session_change = method_name
|
24
|
+
end
|
25
|
+
|
26
|
+
# repeat_frequency is in days, should be between 0 and 365. 0 means no repeat
|
27
|
+
validates :repeat_frequency, numericality: { only_integer: true,
|
28
|
+
greater_than_or_equal_to: 0,
|
29
|
+
less_than_or_equal_to: 365 }
|
30
|
+
|
31
|
+
validates :name, length: { minimum: 3, maximum: 255 }, presence: true
|
32
|
+
validates :description, length: { maximum: 255 }
|
33
|
+
|
34
|
+
# generate all the targets for this segment
|
35
|
+
# it then returns the session
|
36
|
+
def generate!
|
37
|
+
# start a new session
|
38
|
+
|
39
|
+
session = start_session
|
40
|
+
begin
|
41
|
+
# get the data from the source
|
42
|
+
returned_targets = source.execute
|
43
|
+
# replace the data in the segment in a transaction
|
44
|
+
returned_targets.each do |target|
|
45
|
+
target.session = session
|
46
|
+
target.save!
|
47
|
+
end
|
48
|
+
|
49
|
+
# mark the session as draft
|
50
|
+
end_session(session)
|
51
|
+
|
52
|
+
activate(session) if auto_activate
|
53
|
+
|
54
|
+
session
|
55
|
+
rescue ::Segmentor::Errors::EvaluationError => e
|
56
|
+
# mark the session as failed with a reason. don't raise
|
57
|
+
session.mark_as_failed!(e.message)
|
58
|
+
session
|
59
|
+
rescue StandardError
|
60
|
+
# delete the session and raise
|
61
|
+
session.destroy
|
62
|
+
raise
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# notify! calls the given block for each target that can be notified
|
67
|
+
# if the block raises a Segmentor::Errors::NotificationError, the target is skipped
|
68
|
+
# and no receipt is issued but the rest of the targets are still notified
|
69
|
+
# as most notification systems do not support transactions, it is important
|
70
|
+
# to ensure only ::Segmentor::Errors::NotificationError is raised unless you are
|
71
|
+
# sure you want to rollback all notification receipts
|
72
|
+
def notify!(&block)
|
73
|
+
# get the active targets from the segment
|
74
|
+
# for each target, check if its receipt is within segment repeat frequency
|
75
|
+
# if not, send the notification and generate a receipt
|
76
|
+
# if so, do nothing
|
77
|
+
raise ::Segmentor::Errors::NoSessionError, 'no active session' unless active_session
|
78
|
+
|
79
|
+
::ActiveRecord::Base.transaction do
|
80
|
+
active_session.targets.each do |target|
|
81
|
+
next unless target.can_be_notified?
|
82
|
+
|
83
|
+
# send the notification
|
84
|
+
# generate a receipt
|
85
|
+
result = block.call(target, notifier_context) if block_given?
|
86
|
+
target.issue_receipt!(result)
|
87
|
+
# catch ::Segmentor::Errors::NotificationError and do nothing
|
88
|
+
rescue ::Segmentor::Errors::NotificationError
|
89
|
+
# do nothing. so we only skip issue_receipt! but continue to the rest of the targets
|
90
|
+
# any other error will be raised and rollback the transaction
|
91
|
+
end
|
92
|
+
active_session.touch
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# resets the last session of this segment to be in progress again
|
97
|
+
def reset!
|
98
|
+
latest_session.mark_as_draft!
|
99
|
+
end
|
100
|
+
|
101
|
+
# turns a session from draft into active and marks
|
102
|
+
# the current active session as archived.
|
103
|
+
# if no current active session is found. the latest session is marked as active
|
104
|
+
def activate(session)
|
105
|
+
::ActiveRecord::Base.transaction do
|
106
|
+
# mark the current active session as archived
|
107
|
+
as = active_session
|
108
|
+
if as
|
109
|
+
as.status = ::Segmentor::Session::ARCHIVED
|
110
|
+
as.save!
|
111
|
+
end
|
112
|
+
|
113
|
+
# mark the new session as active
|
114
|
+
session.status = ::Segmentor::Session::ACTIVE
|
115
|
+
session.save!
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def active_session
|
120
|
+
sessions.active.first
|
121
|
+
end
|
122
|
+
|
123
|
+
def active_session!
|
124
|
+
as = active_session
|
125
|
+
raise ::Segmentor::Errors::NoSessionError, 'no active session' unless as
|
126
|
+
|
127
|
+
as
|
128
|
+
end
|
129
|
+
|
130
|
+
def latest_session
|
131
|
+
# pick the latest session that's not in progress or archived
|
132
|
+
sessions.where
|
133
|
+
.not(status: [::Segmentor::Session::ARCHIVED, ::Segmentor::Session::IN_PROGRESS])
|
134
|
+
.order(created_at: :desc)
|
135
|
+
.first
|
136
|
+
end
|
137
|
+
|
138
|
+
private
|
139
|
+
|
140
|
+
def start_session
|
141
|
+
session = ::Segmentor::Session.new(segment: self)
|
142
|
+
session.save!
|
143
|
+
|
144
|
+
session
|
145
|
+
end
|
146
|
+
|
147
|
+
def end_session(session)
|
148
|
+
session.mark_as_draft!
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Segmentor
|
4
|
+
# Session is a single segmentation session. It can be
|
5
|
+
# draft, in progress, or active.
|
6
|
+
# at any point, there is only 1 active session per segment is allowed
|
7
|
+
# while draft and in progress, there can be multiple sessions
|
8
|
+
# in progress is when a source is still generating targets and they
|
9
|
+
# are being saved.
|
10
|
+
# draft is when a session has the targets, but they are not the ones
|
11
|
+
# used for notification. This can be used to test a new source code for example.
|
12
|
+
class Session < ActiveRecord::Base
|
13
|
+
self.table_name = :segmentor_sessions
|
14
|
+
|
15
|
+
IN_PROGRESS = 0 # default
|
16
|
+
DRAFT = 1
|
17
|
+
ACTIVE = 2
|
18
|
+
ARCHIVED = 3
|
19
|
+
FAILED = 4
|
20
|
+
|
21
|
+
belongs_to :segment, class_name: '::Segmentor::Segment'
|
22
|
+
has_many :targets, class_name: '::Segmentor::Target', dependent: :destroy
|
23
|
+
|
24
|
+
validates :status, presence: true
|
25
|
+
validates :status, inclusion: { in: [IN_PROGRESS, DRAFT, ACTIVE, ARCHIVED, FAILED] }
|
26
|
+
validates :segment_id, presence: true
|
27
|
+
validate :only_one_active_session_per_segment
|
28
|
+
validates :reason, presence: true, if: :failed?
|
29
|
+
|
30
|
+
scope :active, -> { where(status: ACTIVE) }
|
31
|
+
scope :latest, -> { order(created_at: :desc).limit(1).first }
|
32
|
+
|
33
|
+
before_create :generate_session_id
|
34
|
+
after_commit :call_segment_callbacks
|
35
|
+
|
36
|
+
def mark_as_active!
|
37
|
+
# only draft sessions can be made active
|
38
|
+
raise ::Segmentor::Errors::SessionStateError, 'only draft sessions can be made active' unless status == DRAFT
|
39
|
+
|
40
|
+
self.status = ACTIVE
|
41
|
+
save!
|
42
|
+
end
|
43
|
+
|
44
|
+
def mark_as_draft!
|
45
|
+
# archived and failed sessions cannot be turned into draft
|
46
|
+
if status == ARCHIVED || status == FAILED
|
47
|
+
raise ::Segmentor::Errors::SessionStateError, 'failed and archived sessions cannot be turned into draft'
|
48
|
+
end
|
49
|
+
|
50
|
+
self.status = DRAFT
|
51
|
+
self.reason = nil
|
52
|
+
save!
|
53
|
+
end
|
54
|
+
|
55
|
+
def mark_as_archived!
|
56
|
+
# only active and failed sessions can be archived
|
57
|
+
unless status == ACTIVE || status == FAILED
|
58
|
+
raise ::Segmentor::Errors::SessionStateError, 'only active and failed sessions can be archived'
|
59
|
+
end
|
60
|
+
|
61
|
+
self.status = ARCHIVED
|
62
|
+
save!
|
63
|
+
end
|
64
|
+
|
65
|
+
def mark_as_failed!(reason)
|
66
|
+
# archived sessions cannot be marked as failed
|
67
|
+
raise ::Segmentor::Errors::SessionStateError, 'archived sessions cannot be marked as failed' if status == ARCHIVED
|
68
|
+
|
69
|
+
self.status = FAILED
|
70
|
+
self.reason = reason
|
71
|
+
save!
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def only_one_active_session_per_segment
|
77
|
+
# only one active session per segment is allowed
|
78
|
+
if status == ACTIVE && ::Segmentor::Session.where(status: ACTIVE, segment_id: segment_id).count.positive?
|
79
|
+
raise ::Segmentor::Errors::SessionStateError, 'only one active session per segment is allowed'
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def generate_session_id
|
84
|
+
# generate a session id
|
85
|
+
self.session_id ||= SecureRandom.uuid
|
86
|
+
end
|
87
|
+
|
88
|
+
def failed?
|
89
|
+
status == FAILED
|
90
|
+
end
|
91
|
+
|
92
|
+
def call_segment_callbacks
|
93
|
+
segment.send(segment.class._after_session_change, self) if segment.class._after_session_change.present?
|
94
|
+
end
|
95
|
+
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Segmentor
|
4
|
+
# Source runs the code and returns an array of ::Segmentor::Target
|
5
|
+
# you can dervie from this for different types of code sources (ie Ruby, JS, etc)
|
6
|
+
class Source < ActiveRecord::Base
|
7
|
+
self.table_name = 'segmentor_sources'
|
8
|
+
|
9
|
+
belongs_to :segment, class_name: '::Segmentor::Segment', foreign_key: :segment_id
|
10
|
+
|
11
|
+
validates :code, presence: true
|
12
|
+
validates :type, presence: true
|
13
|
+
validates :segment, presence: true
|
14
|
+
|
15
|
+
def execute
|
16
|
+
raise NotImplementedError, 'execute must be implemented in a subclass'
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Segmentor
|
2
|
+
module Sources
|
3
|
+
# RubySource is a source that evaluates ruby code to generate a list of targets
|
4
|
+
# DO NOT USE THIS SOURCE IN UNSECURE ENVIRONMENTS
|
5
|
+
class RubySource < ::Segmentor::Source
|
6
|
+
def execute
|
7
|
+
# evaluate the code
|
8
|
+
# return the result
|
9
|
+
begin
|
10
|
+
targets = eval(code)
|
11
|
+
rescue StandardError => e
|
12
|
+
raise ::Segmentor::Errors::EvaluationError, e.message
|
13
|
+
end
|
14
|
+
|
15
|
+
targets = targets.is_a?(Array) ? targets : [targets]
|
16
|
+
|
17
|
+
# all targets should be a ::Segment::Target
|
18
|
+
if targets.any? { |target| !target.is_a?(::Segmentor::Target) }
|
19
|
+
raise ::Segmentor::Errors::EvaluationError, 'all returned items should be ::Segment::Target'
|
20
|
+
end
|
21
|
+
|
22
|
+
targets
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Segmentor
|
4
|
+
# Target is single piece of data, returned by a
|
5
|
+
class Target < ActiveRecord::Base
|
6
|
+
self.table_name = :segmentor_targets
|
7
|
+
|
8
|
+
belongs_to :session, class_name: '::Segmentor::Session'
|
9
|
+
belongs_to :segment, class_name: '::Segmentor::Segment'
|
10
|
+
belongs_to :user
|
11
|
+
|
12
|
+
validates :user_id, presence: true
|
13
|
+
validates :user_id, uniqueness: { scope: :session_id }
|
14
|
+
validates :segment_id, presence: true
|
15
|
+
validates :session_id, presence: true
|
16
|
+
|
17
|
+
before_validation :set_segment_if_nil
|
18
|
+
|
19
|
+
def self.new_with_user(user)
|
20
|
+
::Segmentor::Target.new(user_id: user.id, payload: {})
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.new_with_user_and_payload(user, payload)
|
24
|
+
::Segmentor::Target.new(user_id: user.id, payload: payload)
|
25
|
+
end
|
26
|
+
|
27
|
+
def issue_receipt!(rendered_value = nil)
|
28
|
+
::Segmentor::Receipt.record_receipt(self, rendered_value)
|
29
|
+
end
|
30
|
+
|
31
|
+
def most_recent_receipt
|
32
|
+
segment.receipts.where(user_id: user_id).order(:sent_at).last
|
33
|
+
end
|
34
|
+
|
35
|
+
def get_binding
|
36
|
+
binding
|
37
|
+
end
|
38
|
+
|
39
|
+
def preview(&block)
|
40
|
+
raise ArgumentError, 'block required' unless block_given?
|
41
|
+
|
42
|
+
block.call(self, segment.notifier_context)
|
43
|
+
end
|
44
|
+
|
45
|
+
def can_be_notified?
|
46
|
+
most_recent_receipt = segment.receipts.where(user_id: user_id).order(:sent_at).last
|
47
|
+
|
48
|
+
# notification can be sent if there is a receipt for this target that
|
49
|
+
# is older than the repeat frequency of its segment
|
50
|
+
return true if most_recent_receipt.nil?
|
51
|
+
|
52
|
+
# we have a receipt. notify only if:
|
53
|
+
# segment repeat frequency is not NO_REPEAT
|
54
|
+
# receipt is older than segment repeat frequency
|
55
|
+
segment.repeat_frequency != ::Segmentor::Segment::NO_REPEAT &&
|
56
|
+
most_recent_receipt.sent_at < segment.repeat_frequency.days.ago
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def set_segment_if_nil
|
62
|
+
self.segment_id ||= session.segment_id
|
63
|
+
end
|
64
|
+
|
65
|
+
end
|
66
|
+
end
|
data/lib/segmentor.rb
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/all'
|
4
|
+
require 'rails/all'
|
5
|
+
|
6
|
+
Dir["#{File.dirname(__FILE__)}/segmentor/**/*.rb"].each { |f| load(f) }
|
7
|
+
load "#{File.dirname(__FILE__)}/generators/segmentor/segmentor.rb"
|
8
|
+
|
9
|
+
# Segmentor module
|
10
|
+
module Segmentor
|
11
|
+
end
|
data/segmentor.gemspec
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
lib = File.expand_path('lib', __dir__)
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
5
|
+
require 'segmentor/version'
|
6
|
+
|
7
|
+
Gem::Specification.new do |spec|
|
8
|
+
spec.name = 'segmentor'
|
9
|
+
spec.version = Segmentor::VERSION
|
10
|
+
spec.authors = ['Khash Sajadi']
|
11
|
+
spec.email = ['khash@cloud66.com']
|
12
|
+
spec.license = 'Apache-2.0'
|
13
|
+
spec.homepage = 'https://github.com/cloud66-oss/segmentor'
|
14
|
+
spec.required_ruby_version = '>= 3.0.0'
|
15
|
+
|
16
|
+
spec.summary = 'Segmentor creates slices from your data that can be used for notifications'
|
17
|
+
|
18
|
+
# Specify which files should be added to the gem when it is released.
|
19
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
20
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
21
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
22
|
+
end
|
23
|
+
spec.bindir = 'exe'
|
24
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
25
|
+
spec.require_paths = ['lib']
|
26
|
+
|
27
|
+
spec.add_dependency 'actionpack', '~> 6.1'
|
28
|
+
spec.add_dependency 'activerecord', '~> 6.1'
|
29
|
+
spec.add_dependency 'activesupport', '~> 6.1'
|
30
|
+
|
31
|
+
spec.add_development_dependency 'bootsnap', '~> 1.9'
|
32
|
+
spec.add_development_dependency 'bundler', '~> 2.0'
|
33
|
+
spec.add_development_dependency 'factory_bot', '~> 6.2'
|
34
|
+
spec.add_development_dependency 'generator_spec', '~> 0.9'
|
35
|
+
spec.add_development_dependency 'parser', '3.0.0.0'
|
36
|
+
spec.add_development_dependency 'pry', '~> 0.13.1'
|
37
|
+
spec.add_development_dependency 'rails', '~> 6.1'
|
38
|
+
spec.add_development_dependency 'rake', '~> 13.0'
|
39
|
+
spec.add_development_dependency 'rspec', '~> 3.10'
|
40
|
+
spec.add_development_dependency 'rspec-rails', '~> 5.0'
|
41
|
+
spec.add_development_dependency 'rubocop', '~> 1.4'
|
42
|
+
spec.add_development_dependency 'rubocop-performance', '~> 1.9'
|
43
|
+
spec.add_development_dependency 'rufo', '~> 0.12'
|
44
|
+
spec.add_development_dependency 'sqlite3', '~> 1.4'
|
45
|
+
end
|