pigeons 0.0.1pre

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.
@@ -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
+