pigeons 0.0.1pre

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,205 @@
1
+ module Pigeons
2
+
3
+ module Scope
4
+
5
+ def self.get_scope(args)
6
+ elements = args[:elements]
7
+ previous_base_scope = args[:previous_base_scope]
8
+ previous_letters = args[:previous_letters]
9
+ flights = args[:flights]
10
+ flight = args[:flight]
11
+ letter_info = args[:letter_info]
12
+ flight_id = args[:flight_id]
13
+ opts = args[:opts]
14
+
15
+ Pigeons::Logger.debug [ 'Pigeons::', 'Scope::GetScope', 'Elements', elements ] if opts[:debug]
16
+
17
+ # We accept a running base_element
18
+ # -- gets
19
+ # then gets
20
+ # then gets
21
+
22
+ base_scope = previous_base_scope unless previous_base_scope.nil?
23
+
24
+ if elements[:base_element] # E.g. "users..."
25
+ base_scope = get_base_scope(elements[:base_element])
26
+ previous_letters = [] unless elements[:joiner] # Unless we have a joiner, we're ignoring previous letters
27
+ elsif elements[:joiner].nil? # "then..."
28
+ # must start with a base element or a joiner
29
+ raise PigeonError::PigeonFlightConfigError, "Missing a joiner in #{flight}, start a line with an elements (e.g. user) or and/then: #{letter_info}"
30
+ end
31
+
32
+ Pigeons::Logger.debug [ 'Pigeons::', 'Scope::GetScope', 'Base Element Count', "#{base_scope.name.number(base_scope.count)}" ] if opts[:debug]
33
+
34
+ raise PigeonError::PigeonFlightConfigError, "First flight element in #{flight} must specify base (e.g. user), but didn't: #{letter_info}" if base_scope.nil?
35
+
36
+ scope = base_scope.scoped # We'll start with a fresh
37
+ source_arel = scope.arel_table # This will be used for checking PigeonLetters.. or posts
38
+ pigeon_arel = PigeonLetter.arel_table # used in queries below
39
+
40
+ # First, we'll check by flight
41
+ # Flights are essentially cohorts
42
+ # All entites belong to one based on entity_id%x=y where x is the number of flights
43
+ # and y is the order of this flight in alphabetically order starting with 0
44
+ if flights.count > 0
45
+ scope = scope.where(["id%?=?",flights.count,flight_id])
46
+ end
47
+
48
+ raise PigeonError::PigeonFlightConfigError, "Flight #{flight} must specify letter: #{letter_info}" if elements[:letter].nil?
49
+
50
+ # Convert the letter to snakecase
51
+ letter = elements[:letter].downcase.gsub(' ', '_') # "a ... letter"
52
+
53
+ # Make sure this letter is valid
54
+ raise PigeonError::PigeonFlightConfigError, "Flight #{flight} referenced letter \"#{letter}\" that doesn't exist in PigeonMailer. Try adding \"def #{letter}(#{source_arel.name.singularize})\"" unless PigeonMailer.action_methods.include?(letter.to_s) || PigeonMailer.respond_to?(letter.to_s) # We'll take action_methods or respond_to?
55
+
56
+ # Next, we need to do the "based on letters" queries
57
+
58
+ # Todo/Question: what happens when a user switches cohorts and now deserves two item types
59
+ # we need to be mighty cautious here to preclude that
60
+ # Also, may happen with milestones
61
+ # TODO: This above, last big thing besides getting these queries right
62
+ # and renaming maybe from letters to posts, or not
63
+ # also, what are we actually sending
64
+
65
+ # First, we'll base it on cooldown
66
+ # Since we don't ever want to send an entity two letters on the same day
67
+ scope = Checks.no_recent_letter_check(scope: scope, source: source_arel, pigeon: pigeon_arel)
68
+
69
+ # If we have a time_metric, we'll track a relative time
70
+ # This will be used for "every" and "after that"
71
+ # Note: we default coeffecient to 1 for the case "Every day"
72
+ relative_time = nil # base
73
+
74
+ if elements[:time_metric] # "24 hours..."
75
+ relative_time = case elements[:time_metric_unit]
76
+ when "second", "minute", "hour", "day", "week", "fortnight", "month", "year"
77
+ (elements[:time_metric_coefficient] || 1).to_i.send(elements[:time_metric_unit]).ago
78
+ when "time"
79
+ Time.now # "Every time" = since now
80
+ else
81
+ raise PigeonError::PigeonFlightConfigError, "Flight #{flight} referenced invalid time unit (e.g. days, hours, ..): #{elements[:time_metric_unit]}: #{letter_info}"
82
+ end
83
+ end
84
+
85
+ # Then, we'll base it on ourselves
86
+ # Select all items where not exists letter = self (unless recurring, then base on time)
87
+ if elements[:recurring].blank? # every
88
+ scope = Checks.non_recurring_check(scope: scope, source: source_arel, pigeon: pigeon_arel, letter: letter)
89
+ else # Recurring ("every")
90
+ scope = Checks.recurring_check(scope: scope, source: source_arel, pigeon: pigeon_arel, letter: letter, relative_time: relative_time)
91
+ end
92
+
93
+ # Next, we're going to check the previous req clause
94
+ if elements[:relative] || ( elements[:joiner] == "then" )# "after..." or begins with "then..."
95
+
96
+ # TODO: Type may also be a milestone or item metric
97
+ # Need to make this much more diverse
98
+ relative_action = nil
99
+ time_item = nil # TODO: Event
100
+
101
+ relative_time = Time.now if relative_time.nil? # If we don't have a relative time, we count it against now
102
+
103
+ # Then, we need to check our relative
104
+ if elements[:relative]
105
+ case elements[:relative]
106
+ when "after"
107
+ relative_action = :after
108
+ time_item = elements[:time_item]
109
+ else
110
+ raise PigeonError::PigeonFlightConfigError, "Flight #{flight} referenced invalid time relative (e.g. before/after): #{elements[:relative]}: #{letter_info}"
111
+ end
112
+ else # joiner - if we don't have a relative, we'll default to "after that"
113
+ case elements[:joiner]
114
+ when "then"
115
+ relative_action = :after
116
+ time_item = "that"
117
+ else
118
+ raise PigeonError::PigeonFlightConfigError, "Flight #{flight} got to illegal area of code: #{letter_info}"
119
+ end
120
+ end
121
+
122
+ # Then, we need to find the relative letter type
123
+ if time_item == "that" # Compare with last letter -- this is baked in
124
+ raise PigeonError::PigeonFlightConfigError, "Flight #{flight} referenced to previous letters (\"#{elements[:time_metric]} #{elements[:relative]}\"), but ambiguous previous lines: #{letter_info}" if previous_letters.empty?
125
+ scope = Checks.after_letter_check( scope: scope, source: source_arel, pigeon: pigeon_arel, letters: previous_letters.clone, relative_action: relative_action, relative_time: relative_time )
126
+ elsif time_item =~ /^(sign((ing )|[-]|[ ])?up)|(creat(e|ion))$/i # this is also baked in (matches: signup, signing up, sign up, sign-up, create, creation)
127
+ scope = scope.where("created_at < ?", relative_time)
128
+ else
129
+ # Let's try to match this against the events given to settings
130
+ matched_events = Settings.events.select do |event|
131
+ match = event[:matcher].match(time_item)
132
+ if match
133
+ # TODO # uhh
134
+ scope = event[:clause].call(scope, relative_time, match)
135
+ true
136
+ else
137
+ false
138
+ end
139
+ end
140
+
141
+ raise PigeonError::PigeonFlightConfigError, "Flight #{flight} has unmatched event: \"#{time_item}\". You must set an extension to match all events: #{letter_info}" if matched_events.count == 0
142
+ end
143
+
144
+ end
145
+
146
+ # Last, we'll check conditionals brought on by us
147
+ if elements[:conditionals] # "who've..."
148
+ matched_conditionals = Settings.conditionals.select do |conditional|
149
+ match = conditional[:matcher].match(elements[:conditionals])
150
+ if match
151
+ scope = conditional[:clause].call(scope, match)
152
+ true
153
+ else
154
+ false
155
+ end
156
+ end
157
+ raise PigeonError::PigeonFlightConfigError, "Flight #{flight} has unmatched conditional: \"#{elements[:conditionals]}\". You must set an extension to match all conditionals: #{letter_info}" if matched_conditionals.count == 0
158
+ end
159
+
160
+ # Set previous letters
161
+ if elements[:base_element].nil? && !elements[:joiner].nil? && !elements[:conditionals].nil?
162
+ # then who've ...
163
+ previous_letters << letter # we're going to append ourselves to previous letters to allow us to skip since we've kept a running base element, but added conditions that make us skippable
164
+ else
165
+ # otherwise, to continue on, you must have received this specific letter
166
+ previous_letters = [ letter ] # just us
167
+ end
168
+
169
+ return {
170
+ scope: scope,
171
+ base_scope: base_scope,
172
+ letter: letter,
173
+ previous_letters: previous_letters,
174
+ entity: source_arel.name }
175
+ end
176
+
177
+ # This function takes a base element like "user" and returns a scope
178
+ def self.get_base_scope(base_element)
179
+ # First, we'll check the bases defined
180
+ Settings.bases.each do |base|
181
+ match = base[:matcher].match(base_element)
182
+ if match
183
+ res = base[:clause].call(match)
184
+
185
+ if !res.is_a?(ActiveRecord::Relation)
186
+ raise PigeonError::PigeonConfigError, "Base scope must return an ActiveRecord::Relation, but got #{res.class.name} for #{base_element}"
187
+ end
188
+
189
+ return res
190
+ end
191
+ end
192
+
193
+ # Then, we'll check for a model named likeso
194
+ # TODO/Note, we may want to depluralize
195
+ klass = Object.const_get(base_element.gsub(' ','_').classify) rescue nil
196
+
197
+ if klass && ( klass < ActiveRecord::Base )
198
+ return klass.scoped
199
+ end
200
+
201
+ raise PigeonError::PigeonConfigError, "Unable to find a scope for base element: #{base_element}"
202
+ end
203
+
204
+ end
205
+ end
@@ -0,0 +1,3 @@
1
+ module Pigeons
2
+ VERSION = "0.0.1pre"
3
+ end
data/lib/pigeons.rb ADDED
@@ -0,0 +1,10 @@
1
+ require "pigeons/version"
2
+
3
+ require 'pigeons/pigeons'
4
+ require 'pigeons/logger'
5
+ require 'pigeons/elements'
6
+ require 'pigeons/scope'
7
+ require 'pigeons/checks'
8
+ require 'pigeons/errors'
9
+ require 'pigeons/extensions'
10
+ require 'pigeons/pigeons_tasks'
data/pigeons.gemspec ADDED
@@ -0,0 +1,29 @@
1
+ # -*- encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'pigeons/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = "pigeons"
8
+ gem.version = Pigeons::VERSION
9
+ gem.authors = ["Geoff Hayes"]
10
+ gem.email = ["geoff@safeshepherd.com"]
11
+ gem.description = %q{Pigeons makes it a breeze to send your users lifecycle e-mails.}
12
+ gem.summary = %q{Pigeons provides an extensible way to send our lifecycle e-mails through simple human-readable syntax}
13
+ gem.homepage = "https://github.com/hayesgm/pigeons"
14
+
15
+ gem.add_dependency('activesupport')
16
+ gem.add_dependency('activerecord')
17
+ gem.add_dependency('actionmailer')
18
+
19
+ gem.add_development_dependency('mocha')
20
+ gem.add_development_dependency('shoulda')
21
+ gem.add_development_dependency('test-unit')
22
+ gem.add_development_dependency('rake')
23
+ gem.add_development_dependency('sqlite3')
24
+
25
+ gem.files = `git ls-files`.split($/)
26
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
27
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
28
+ gem.require_paths = ["lib"]
29
+ end
@@ -0,0 +1,142 @@
1
+
2
+ namespace :pigeons do
3
+
4
+ desc "Show all posts that would be immediately sent"
5
+ task :check, [ :debug ] => :environment do |t, args|
6
+ p [ 'Pigeons::', 'Rake::Pigeons::Check', 'Initiating', args ]
7
+
8
+ flights = Pigeons.assemble(send: false, debug: args[:debug] == "true")
9
+
10
+ flights.each do |flight, letters|
11
+ puts "\n#{flight.titleize} Flight"
12
+
13
+ letters.each do |letter_info|
14
+ puts "\t#{letter_info[:letter].titleize} -> #{letter_info[:count]} #{letter_info[:entity]}"
15
+ end
16
+ end
17
+
18
+ p [ 'Pigeons::', 'Rake::Pigeons::Check', 'Completed' ]
19
+ end
20
+
21
+ desc "Run a simulation by day of Pigeon letters to be sent."
22
+ task :flight_test, [ :days, :debug, :force ] => :environment do |t, args|
23
+ unless Rails.env.staging? || Rails.env.development? || Rails.env.test? # Note, due to mocks, etc. this is not considered safe to run on any environment besides develpoment and staging
24
+ unless args[:force].blank?
25
+ puts "\n\e[0;31m ######################################################################"
26
+ puts " #\n # Are you REALLY sure you want to run Flight Test in #{Rails.env.capitalize}?"
27
+ puts " #\n # Nothing should affect the database- but this is not considered safe for production databases."
28
+ puts " #\n # Specifically, we are going to override Time.now, Time.current (with monkeypatches) and run a"
29
+ puts " #\n # actual simulation day-by-day that we will rollback (and hope doesn't actually send letters)."
30
+ puts " #\n # These assumptions are nice, but not good in a production environment."
31
+ puts " #\n # Enter y/N + enter to continue\n #"
32
+ puts " ######################################################################\e[0m\n"
33
+ proceed = STDIN.gets[0..0] rescue nil
34
+ exit unless proceed == 'y' || proceed == 'Y'
35
+ else
36
+ raise "Refusing to run Flight Test on anything but Development and Staging (try rake pigeons:flight_test[days,debug,*force])"
37
+ end
38
+ end
39
+
40
+ p [ 'Pigeons::', 'Rake::Pigeons::FlightTest', 'Initiating', args ]
41
+ days = args[:days] ? args[:days].to_i : 15 # Default to 15 days?
42
+ debug = args[:debug] || false
43
+
44
+ # Stub both Time.now and Time.current
45
+ reality = Time.now
46
+ current_reality = Time.current
47
+
48
+ results = {} # E.g. { 'aflight': [ { letter_a: 5 } ]}
49
+ benchmarks = [] # day => run_time
50
+
51
+ # TODO: I'd like to be able to do this without degrading the environment with stubs, but that's probably not going to happen
52
+ time_class = class << ::Time; self; end
53
+ pigeon_class = class << ::PigeonMailer; self; end
54
+ PigeonMailer.action_methods.each { |mailer_action| pigeon_class.send(:define_method, mailer_action) { |*args| return true } }
55
+
56
+ PigeonLetter.transaction do
57
+ begin
58
+ days.times do |day|
59
+ puts ''
60
+ puts '-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-'
61
+ puts "Pigeons:: Simulating Day ##{day+1}"
62
+ puts ''
63
+ puts 'Letters by Type [Totals]'
64
+ PigeonLetter.where("sent_at IS NOT NULL").to_a.group_by { |pl| pl.letter_type }.each { |letter_type, letters| puts "\t#{letter_type}: #{letters.count}" }
65
+ puts ''
66
+ puts ''
67
+
68
+ # Time is a social construct
69
+ # We'll adjust it as need be
70
+ time_class.send(:define_method, :now) { reality + day.days }
71
+ time_class.send(:define_method, :current) { current_reality + day.days }
72
+
73
+ start_time = Time.new.to_f
74
+ flights = Pigeons.assemble(send: true, debug: debug)
75
+ benchmarks[day] = Time.new.to_f - start_time
76
+
77
+ flights.each do |flight, letters|
78
+ results[flight] ||= {}
79
+
80
+ puts "\n#{flight.titleize} Flight"
81
+
82
+ letters.each do |letter_info|
83
+ results[flight][letter_info[:letter]] ||= []
84
+ results[flight][letter_info[:letter]][day] = ( results[flight][letter_info[:letter]][day] || 0 ) + letter_info[:count]
85
+ puts "\t#{letter_info[:letter].titleize} -> #{letter_info[:count]} #{letter_info[:entity]}"
86
+ end
87
+ end
88
+ end
89
+ rescue => e
90
+ p [ 'Pigeons::', 'Encountered error in Rake::Pigeons::FlightTest', e.inspect ]
91
+ puts e.backtrace.join("\n\t")
92
+ ensure
93
+ p [ 'Pigeons::', 'Rolling back any changes made during test flight...' ]
94
+ raise ActiveRecord::Rollback # Force a rollback-- we don't want anything to be real here. In the words of Biggie, "It was all a dream..."
95
+ end
96
+ end
97
+
98
+ p [ 'Pigeons::', 'Flight Test Results' ]
99
+ p [ 'Pigeons::' 'Raw Results', results ]
100
+ p [ 'Pigeons::' 'Runtimes', benchmarks ]
101
+
102
+ padding = proc { |num, i| " " + num.to_s + ( 1..(i.to_s.length - 1 + 2*3 - num.to_s.length) ).map { " " }.join }
103
+
104
+ results.each do |flight, day_by_day|
105
+ puts '##############################'
106
+ puts "# #{flight.capitalize} Flight #"
107
+ puts '##############################'
108
+ puts ""
109
+ puts ""
110
+
111
+ # " "
112
+ # Get the length just right
113
+ leading = ( 1..(day_by_day.map { |letter, count_by_day| letter }.sort { |a,b| a.length <=> b.length }.last.length + 2) ).map { " " }.join
114
+
115
+
116
+ puts "#{leading}| Day #{ (1..days).map { |i| "#{i} " }.join }"
117
+ day_by_day.each do |letter, count_by_day|
118
+ puts ""
119
+ puts "#{letter}#{ ( leading.length - letter.length ).times.map { " " }.join }| #{ count_by_day.each_with_index.map { |count, i| padding.call(count, i+1) }.join }"
120
+ end
121
+ end
122
+
123
+ p [ 'Pigeons::', 'Rake::Pigeons::FlightTest', 'Completed' ]
124
+ end
125
+
126
+ task :send, [] => :environment do |t, args|
127
+ p [ 'Pigeons::', 'Rake::Pigeons::Send', 'Initiating', args ]
128
+
129
+ flights = Pigeons.assemble(send: true)
130
+
131
+ flights.each do |flight, letters|
132
+ puts "\n#{flight.titleize} Flight"
133
+
134
+ letters.each do |letter_info|
135
+ puts "\t#{letter_info[:letter].titleize} -> #{letter_info[:count]} #{letter_info[:entity]}"
136
+ end
137
+ end
138
+
139
+ p [ 'Pigeons::', 'Rake::Pigeons::Send', 'Completed' ]
140
+ end
141
+
142
+ end
@@ -0,0 +1,143 @@
1
+ ENV["RAILS_ENV"] = "test"
2
+
3
+ require File.expand_path('../test_helper', __FILE__)
4
+ require 'active_support/all'
5
+ require 'active_record'
6
+ require 'action_mailer'
7
+ require 'test/unit'
8
+
9
+ # Some defines expected by pigeons
10
+ class PigeonLetter < ActiveRecord::Base; end
11
+ class PigeonMailer < ActionMailer::Base; end
12
+
13
+ require 'pigeons'
14
+ require 'mocha'
15
+ include Mocha
16
+
17
+ ::NOW = ::Time.now
18
+ ::CURRENT = ::Time.current
19
+
20
+ ::Time.stubs(now: ::NOW) # Make this static for a test
21
+ ::Time.stubs(current: ::CURRENT)
22
+
23
+ ActiveRecord::Base.establish_connection(
24
+ :adapter => 'sqlite3',
25
+ :database => ':memory:'
26
+ )
27
+
28
+ ActiveRecord::Schema.define do
29
+ self.verbose = false
30
+
31
+ create_table :dragons, :force => true do |t|
32
+ t.string :property
33
+ t.string :color
34
+ t.boolean :eaten
35
+ t.boolean :slept
36
+ t.datetime :hatched_at
37
+ t.datetime "created_at", :null => false
38
+ t.datetime "updated_at", :null => false
39
+ end
40
+
41
+ create_table :orcs, :force => true do |t|
42
+ t.string :name
43
+ t.datetime "created_at", :null => false
44
+ t.datetime "updated_at", :null => false
45
+ end
46
+
47
+ create_table :battles, :force => true do |t|
48
+ t.integer :dragon_id
49
+ t.integer :orc_id
50
+ t.boolean :is_dragon_victor
51
+ t.datetime "created_at", :null => false
52
+ t.datetime "updated_at", :null => false
53
+ end
54
+
55
+ create_table :pixies, :force => true do |t|
56
+ t.string :text
57
+ t.datetime "created_at", :null => false
58
+ t.datetime "updated_at", :null => false
59
+ end
60
+
61
+ create_table :levels, :force => true do |t|
62
+ t.integer :pixie_id
63
+ t.integer :level
64
+ t.datetime "created_at", :null => false
65
+ t.datetime "updated_at", :null => false
66
+ end
67
+
68
+ create_table :pigeon_letters, :force => true do |t|
69
+ t.string "letter_type"
70
+ t.string "cargo_type"
71
+ t.integer "cargo_id"
72
+ t.datetime "sent_at"
73
+ t.datetime "created_at", :null => false
74
+ t.datetime "updated_at", :null => false
75
+ end
76
+
77
+ end
78
+
79
+ # Clear these out in case they were used by any project settings
80
+ Pigeons::Settings.bases = []
81
+ Pigeons::Settings.conditionals = []
82
+ Pigeons::Settings.events = []
83
+
84
+ # Let's check classify works nicely
85
+ ActiveSupport::Inflector.inflections do |inflect|
86
+ inflect.irregular 'pixie', 'pixies'
87
+ end
88
+
89
+ # This is the scope that's going to be on every object by the very nature of pigeons
90
+ # Thus, we'll make helpers for them
91
+ def letter_not_exists(scope, letter_type)
92
+ pigeon_arel = PigeonLetter.arel_table
93
+ source_arel = scope.arel_table
94
+
95
+ scope.where(
96
+ PigeonLetter.where(
97
+ pigeon_arel[:cargo_id].eq(source_arel[:id]).and(
98
+ pigeon_arel[:cargo_type].eq(source_arel.name.classify)
99
+ ).and(
100
+ pigeon_arel[:created_at].not_eq(nil)
101
+ ).and(
102
+ pigeon_arel[:letter_type].eq(letter_type.to_s)
103
+ )
104
+ ).exists.not
105
+ )
106
+ end
107
+
108
+ def simple_scope(scope, count=1, id=0)
109
+ pigeon_arel = PigeonLetter.arel_table
110
+ source_arel = scope.arel_table
111
+
112
+ scope.where("id%#{count}=#{id}").where(
113
+ PigeonLetter.where(
114
+ pigeon_arel[:cargo_id].eq(source_arel[:id]).and(
115
+ pigeon_arel[:cargo_type].eq(source_arel.name.classify)
116
+ ).and(
117
+ pigeon_arel[:created_at].gt(Pigeons::Settings.cooldown.ago)
118
+ )
119
+ ).exists.not
120
+ )
121
+ end
122
+
123
+ class Dragon < ActiveRecord::Base
124
+ has_many :battles
125
+ end
126
+
127
+ class Orc < ActiveRecord::Base
128
+ has_many :battles
129
+ end
130
+
131
+ class Battle < ActiveRecord::Base
132
+ belongs_to :dragon
133
+ belongs_to :orc
134
+ end
135
+
136
+ class Pixie < ActiveRecord::Base
137
+ has_many :levels
138
+ end
139
+
140
+ class Level < ActiveRecord::Base
141
+ belongs_to :pixie
142
+ end
143
+