pigeons 0.0.1pre

Sign up to get free protection for your applications and to get access to all the features.
data/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # Pigeons
2
+
3
+ TODO: Write a gem description
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'pigeons'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install pigeons
18
+
19
+ ## Usage
20
+
21
+ TODO: Write usage instructions here
22
+
23
+ ## Contributing
24
+
25
+ 1. Fork it
26
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
27
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
28
+ 4. Push to the branch (`git push origin my-new-feature`)
29
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+
3
+ require 'rake/testtask'
4
+
5
+ Rake::TestTask.new do |t|
6
+ t.libs << 'test'
7
+ end
8
+
9
+ desc "Run tests"
10
+ task :default => :test
@@ -0,0 +1,42 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/active_record'
3
+
4
+ module Pigeons
5
+
6
+ class InstallGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+
9
+ source_root File.expand_path('../templates', __FILE__)
10
+
11
+ # desc "Some description of my generator here"
12
+
13
+ # Commandline options can be defined here using Thor-like options:
14
+ # class_option :my_opt, :type => :boolean, :default => false, :desc => "My Option"
15
+
16
+ # I can later access that option using:
17
+ # options[:my_opt]
18
+
19
+ # Generator Code. Remember this is just suped-up Thor so methods are executed in order
20
+ def generate_pigeons_json
21
+ copy_file "pigeons.json", "config/pigeons.json"
22
+ end
23
+
24
+ def generate_pigeon_letter
25
+ copy_file "pigeon_letter.rb", "app/models/pigeon_letter.rb"
26
+ end
27
+
28
+ def generate_pigeon_letter_migration
29
+ migration_template "pigeon_letter_migration.rb", "db/migrate/create_pigeon_letters"
30
+ end
31
+
32
+ def generate_pigeon_mailer
33
+ copy_file "pigeon_mailer.rb", "app/mailers/pigeon_mailer.rb"
34
+ end
35
+
36
+ def self.next_migration_number(dirname)
37
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
38
+ end
39
+
40
+ end
41
+
42
+ end
@@ -0,0 +1,30 @@
1
+ # Represents a letter sent by Pigeons
2
+ #
3
+ # Attributes
4
+ # letter_type:: Type of letter being sent
5
+ # cargo_id:: Associated object id (e.g. a <user_id>) *polymorphic
6
+ # cargo_type:: Assoication type (e.g. User) *polymorphic
7
+ # sent_at:: Time letter was sent
8
+ # flight:: What flight was this sent on?
9
+ class PigeonLetter < ActiveRecord::Base
10
+
11
+ ### Associations
12
+ belongs_to :cargo, polymorphic: true
13
+
14
+ ### Attributes
15
+ attr_accessible :letter_type, :cargo, :cargo_id, :cargo_type, :flight
16
+
17
+ ### Validations
18
+ validates_presence_of :letter_type
19
+ validates_presence_of :cargo_id
20
+ validates_presence_of :cargo_type
21
+ validates_presence_of :flight
22
+
23
+ ### Member Functions
24
+
25
+ def send!
26
+ PigeonMailer.send(letter_type, cargo, flight).deliver
27
+ self.update_attribute(:sent_at, Time.now)
28
+ end
29
+
30
+ end
@@ -0,0 +1,16 @@
1
+ class CreatePigeonLetters < ActiveRecord::Migration
2
+ def change
3
+ create_table :pigeon_letters do |t|
4
+ t.string :letter_type
5
+ t.string :flight
6
+ t.string :cargo_type
7
+ t.integer :cargo_id
8
+ t.datetime :sent_at
9
+
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :pigeon_letters, [ :cargo_id, :cargo_type ], :name => "index_pigeon_letters_cargo_id_cargo_type"
14
+ add_index :pigeon_letters, [ :letter_type ], :name => "index_pigeon_letters_letter_type"
15
+ end
16
+ end
@@ -0,0 +1,8 @@
1
+ class PigeonMailer < ActionMailer::Base
2
+
3
+ # All mailers here should take a cargo (who you're sending to), and a flight (what flight this is in)
4
+ def welcome(cargo, flight)
5
+
6
+ end
7
+
8
+ end
@@ -0,0 +1,5 @@
1
+ {
2
+ "flights": {
3
+ "baseline": []
4
+ }
5
+ }
@@ -0,0 +1,98 @@
1
+ module Pigeons
2
+
3
+ module Checks
4
+
5
+ # We're going to try to factor all major queries into individual functions
6
+ def self.no_recent_letter_check(args)
7
+ scope = args[:scope]
8
+ pigeon_arel = args[:pigeon]
9
+ source_arel = args[:source]
10
+
11
+ scope.where(
12
+ PigeonLetter.where(
13
+ pigeon_arel[:cargo_id].eq(source_arel[:id]).and(
14
+ pigeon_arel[:cargo_type].eq(source_arel.name.classify)
15
+ ).and(
16
+ pigeon_arel[:created_at].gt(Pigeons::Settings.cooldown.ago)
17
+ )
18
+ ).exists.not
19
+ )
20
+ end
21
+
22
+ def self.non_recurring_check(args)
23
+ scope = args[:scope]
24
+ pigeon_arel = args[:pigeon]
25
+ source_arel = args[:source]
26
+ letter = args[:letter]
27
+
28
+ scope.where(
29
+ PigeonLetter.where(
30
+ pigeon_arel[:cargo_id].eq(source_arel[:id]).and(
31
+ pigeon_arel[:cargo_type].eq(source_arel.name.classify)
32
+ ).and(
33
+ pigeon_arel[:created_at].not_eq(nil)
34
+ ).and(
35
+ pigeon_arel[:letter_type].eq(letter)
36
+ )
37
+ ).exists.not
38
+ )
39
+ end
40
+
41
+ def self.recurring_check(args)
42
+ scope = args[:scope]
43
+ pigeon_arel = args[:pigeon]
44
+ source_arel = args[:source]
45
+ letter = args[:letter]
46
+ relative_time = args[:relative_time]
47
+
48
+ scope.where(
49
+ PigeonLetter.where(
50
+ pigeon_arel[:cargo_id].eq(source_arel[:id]).and(
51
+ pigeon_arel[:cargo_type].eq(source_arel.name.classify)
52
+ ).and(
53
+ pigeon_arel[:created_at].gt(relative_time)
54
+ ).and(
55
+ pigeon_arel[:letter_type].eq(letter)
56
+ )
57
+ ).exists.not
58
+ )
59
+ end
60
+
61
+ def self.after_letter_check(args)
62
+ scope = args[:scope]
63
+ pigeon_arel = args[:pigeon]
64
+ source_arel = args[:source]
65
+ letters = args[:letters]
66
+ relative_action = args[:relative_action]
67
+ relative_time = args[:relative_time]
68
+
69
+ # TODO: We really need to sit down and think about what this check means
70
+ # Right now, it's going to mean
71
+ # All letters of letters type must have been made after relative_time ago
72
+ # And there must exist a letter of some such type
73
+ # As in, "we need the existence of a letter of type X or Y, and no letter of type X or Y may have been sent in the last .. days"
74
+
75
+ scope.where(
76
+ PigeonLetter.where(
77
+ pigeon_arel[:cargo_id].eq(source_arel[:id]).and(
78
+ pigeon_arel[:cargo_type].eq(source_arel.name.classify)
79
+ ).and(
80
+ pigeon_arel[:letter_type].in(letters)
81
+ )
82
+ ).exists
83
+ ).where(
84
+ PigeonLetter.where(
85
+ pigeon_arel[:cargo_id].eq(source_arel[:id]).and(
86
+ pigeon_arel[:cargo_type].eq(source_arel.name.classify)
87
+ ).and(
88
+ pigeon_arel[:letter_type].in(letters)
89
+ ).and(
90
+ relative_action == :after ?
91
+ pigeon_arel[:created_at].gt(relative_time) : nil # TODO: nil?
92
+ )
93
+ ).exists.not
94
+ )
95
+ end
96
+
97
+ end
98
+ end
@@ -0,0 +1,35 @@
1
+
2
+ module Pigeons
3
+
4
+ module Elements
5
+
6
+ # This is the beast of our regex base parsing library
7
+ # He is going to parse all discernable elements from the string
8
+ # And send it back as elements
9
+ def self.parse_elements(letter)
10
+ article = /a(?:[n]?|ll)/ix
11
+ base_element = /(#{article}\s+)? (?<base_element>[\w\s]+?)/ix
12
+ conditionals = /(who((\'ve)|(\s+ have))?\s+) (?<conditionals>[\w\s]+)/ix
13
+ letter_type = /(#{article}\s+)? (?<letter>[\w\s]+) \s+ letter/ix
14
+ time_metric = /( (?<time_metric_coefficient>\d+) \s+)? (?<time_metric_unit>time|second|minute|hour|day|week|fortnight|month|year)(s?)/ix
15
+ time_qualifier = /(?<relative>after) \s+ (?<time_item>[^.!$]+)/ix
16
+ time_clause = /((?<recurring>every)\s+)? (?<time_metric>#{time_metric})? (\s*#{time_qualifier})?/ix
17
+
18
+ grammar = /^\s* # Allow whitespace
19
+ ((?<joiner>and|then)\s+)? # Joiner is and or then
20
+ (#{base_element} \s+)? # Base element as ActiveRecord type or base block
21
+ (#{conditionals} \s+)? # Who ... condition block
22
+ get(s)? \s+ # get
23
+ #{letter_type} # a ... letter
24
+ (\s+ #{time_clause})? # after letter or event block
25
+ \s*[.!]? # optional punctuation
26
+ \s* # allow whitespace
27
+ $/ix
28
+
29
+ # Pigeons::Logger.debug [ 'Grammar', grammar ]
30
+ grammar.match(letter)
31
+ end
32
+
33
+ end
34
+
35
+ end
@@ -0,0 +1,8 @@
1
+ module Pigeons
2
+
3
+ module PigeonError
4
+ class PigeonConfigError < StandardError; end
5
+ class PigeonFlightConfigError < PigeonConfigError; end
6
+ end
7
+
8
+ end
@@ -0,0 +1,34 @@
1
+ module Pigeons
2
+
3
+ # Extensions will tell Pigeon how to parse flights for app-specific context
4
+ class Extension
5
+ # base /regexp/ { return scope }
6
+
7
+ protected
8
+
9
+ def self.base(matcher, &clause)
10
+ matcher = Regexp.new(matcher, "i") if matcher.is_a?(String)
11
+ raise ArgumentError, "First argument must be a string or regular expression" unless matcher.is_a?(Regexp)
12
+ raise ArgumentError, "Must include block which returns scope" unless block_given?
13
+
14
+ Pigeons.add_base(matcher, clause)
15
+ end
16
+
17
+ def self.condition(matcher, &clause)
18
+ matcher = Regexp.new(matcher, "i") if matcher.is_a?(String)
19
+ raise ArgumentError, "First argument must be a string or regular expression" unless matcher.is_a?(Regexp)
20
+ raise ArgumentError, "Must include block which returns scope" unless block_given?
21
+
22
+ Pigeons.add_conditional(matcher, clause)
23
+ end
24
+
25
+ def self.event(matcher, &clause)
26
+ matcher = Regep.new(matcher, "i") if matcher.is_a?(String)
27
+ raise ArgumentError, "First argument must be a string or regular expression" unless matcher.is_a?(Regexp)
28
+ raise ArgumentError, "Must include block which returns scope" unless block_given?
29
+
30
+ Pigeons.add_event(matcher, clause)
31
+ end
32
+
33
+ end
34
+ end
@@ -0,0 +1,15 @@
1
+ module Pigeons
2
+ class Logger
3
+
4
+ def self.log(*args)
5
+ Rails.logger.info(*args) if defined?(Rails)
6
+ p *args
7
+ end
8
+
9
+ def self.debug(*args)
10
+ Rails.logger.debug(*args) if defined?(Rails)
11
+ p *args
12
+ end
13
+
14
+ end
15
+ end
@@ -0,0 +1,156 @@
1
+
2
+ # Pigeon controls the Carrier Pigeon system
3
+ # Note, we're going to try to make this not require Rails - but it may require some aspects of ActiveSupport, etc.
4
+
5
+ module Pigeons
6
+
7
+ class Settings
8
+ # Initialize class attributes
9
+ class_attribute :pigeon_config_file
10
+ class_attribute :cooldown # Limit time between sending entity a second letter
11
+ class_attribute :bases, :conditionals, :events
12
+
13
+ # Set Defaults
14
+ self.pigeon_config_file = nil
15
+ self.cooldown = 2.days
16
+
17
+ # Initialize
18
+ self.bases = []
19
+ self.conditionals = []
20
+ self.events = []
21
+ end
22
+
23
+ # returns will be { flight_name: [ { letter: letter_a, query: sql query, count: result count, entity: type of entity }, { ... } ] } ...
24
+ def self.assemble(options={})
25
+ opts = {
26
+ debug: false,
27
+ deep_debug: false, # include flight results in debug output
28
+ send: true
29
+ }.with_indifferent_access.merge(options)
30
+
31
+ '''Assembles all base entities deserving a post in this flight'''
32
+
33
+ # First, we're going to check that PigeonMailer and PigeonLetter exist (and are as we'd expect)
34
+ require 'pigeon_letter' unless defined?(PigeonLetter)
35
+ require 'pigeon_mailer' unless defined?(PigeonMailer)
36
+
37
+ # We'll set a natural default location if Rails is defined
38
+ Settings.pigeon_config_file ||= "#{Rails.root}/config/pigeons.json" if defined?(Rails)
39
+
40
+ raise PigeonError::PigeonConfigError, "Must create PigeonMailer mailer (TODO: instructions)" unless defined?(PigeonMailer) && ( PigeonMailer < ActionMailer::Base )
41
+ raise PigeonError::PigeonConfigError, "Must create PigeonLetter model (TODO: instructions)" unless defined?(PigeonLetter) && ( PigeonLetter < ActiveRecord::Base )
42
+
43
+ # Next, we're going to pull the configuration JSON file
44
+ raise PigeonError::PigeonConfigError, "Must set pigeon_config_file" if Settings.pigeon_config_file.blank?
45
+ raise PigeonError::PigeonConfigError, "Missing pigeon configuration file: #{Settings.pigeon_config_file}" if !File.exists?(Settings.pigeon_config_file)
46
+
47
+ pigeon_config = begin
48
+ JSON(File.read(Settings.pigeon_config_file)) # TODO: Caching?
49
+ rescue JSON::ParserError => e
50
+ raise PigeonError::PigeonConfigError, "Error parsing pigeon configuration file: #{e.inspect}"
51
+ end
52
+
53
+ # Flights are the "cohorts" defined what and how to send letters to our entities (e.g. users)
54
+ flights = Hash[pigeon_config['flights'].sort]
55
+ raise PigeonError::PigeonConfigError, "Configuration must include flights object" if flights.nil?
56
+
57
+ results = {}
58
+
59
+ # Note, order here is important (for mod i), and thus, we've sorted flights alphabeticaly to maintain consistency
60
+ flights.each_with_index do |(flight, flight_info), flight_id|
61
+ Pigeons::Logger.log [ 'Pigeons::', 'Flight::', flight ] if opts[:debug]
62
+
63
+ results[flight] = []
64
+
65
+ # We'll allow flight: [ ... ] instead of flight: { letters: [ ... ] } for shorthand if other options are not needed
66
+ flight_info = { 'letters' => flight_info } if flight_info.is_a?(Array)
67
+
68
+ base_scope = default_base_scope = if flight_info['base'] # Note, this will usually be nil, but it can be set specifically
69
+ Scope.get_base_scope(flight_info['base'])
70
+ end
71
+
72
+ raise PigeonError::PigeonFlightConfigError, "Letters must be present in flight" if flight_info['letters'].empty?
73
+
74
+ previous_letters = []
75
+
76
+ flight_info['letters'].each_with_index do |letter_info, i|
77
+
78
+ Pigeons::Logger.debug [ 'Pigeons::', "Flight #{flight}", "Letter Info", letter_info ] if opts[:debug]
79
+ # This is going to parse the letter info and make sure it makes sense
80
+ elements = Elements.parse_elements(letter_info)
81
+
82
+ # Let's see if we were able to parse letter info
83
+ raise PigeonError::PigeonFlightConfigError, "Failed to parse letter info for flight #{flight}: \"#{letter_info}\". Should be like \"someone\" gets a \"type a\" letter \"sometime\" after \"some event\"" if elements.nil?
84
+
85
+ # Otherwise, convert capture groups to a nice hash
86
+ elements = Hash[elements.names.zip(elements.captures)].with_indifferent_access
87
+
88
+ # args such as default base scope? maybe we should change to an arguments hash
89
+ # note: for running conditionals, we could verify they skip the previous scopes, but this is going to
90
+ # end up a complicated query. we're going to rely on letter-existence skipping instead
91
+ scope_res = Scope.get_scope(elements: elements, previous_base_scope: base_scope, previous_letters: previous_letters, flights: flights, flight: flight, flight_id: flight_id, letter_info: letter_info, opts: opts)
92
+
93
+ scope = scope_res[:scope]
94
+ base_scope = scope_res[:base_scope]
95
+ previous_letters = scope_res[:previous_letters]
96
+ letter = scope_res[:letter]
97
+ entity = scope_res[:entity]
98
+
99
+ Pigeons::Logger.debug [ 'Pigeons::', "Flight #{flight} Letter", letter_info ] if opts[:debug]
100
+ Pigeons::Logger.debug [ 'Pigeons::', "Flight #{flight} Scope", scope.explain ] if opts[:debug]
101
+ Pigeons::Logger.debug [ 'Pigeons::', "Flight #{flight} Count", scope.count ] if opts[:debug]
102
+ Pigeons::Logger.debug [ 'Pigeons::', "Flight #{flight} Results", scope ] if opts[:deep_debug]
103
+
104
+ results[flight].push(letter: letter, query: scope.to_sql, count: scope.count, entity: entity, scope: scope)
105
+
106
+ # TODO: We could break this out?
107
+ self.send_letter(scope, letter, flight) if opts[:send]
108
+ end
109
+ end
110
+
111
+ results
112
+ end
113
+
114
+ # Add base will add a match clause to our checks in parsing
115
+ # scope_block is a proc that will return a scope used in further flight tests
116
+ def self.add_base(matcher, clause)
117
+ Settings.bases.push(matcher: matcher, clause: clause)
118
+ end
119
+
120
+ # Add a conditional block
121
+ # If a condition matches this conditional block, it will append its where clause
122
+ def self.add_conditional(matcher, clause)
123
+ Settings.conditionals.push(matcher: matcher, clause: clause)
124
+ end
125
+
126
+ def self.add_event(matcher, clause)
127
+ Settings.events.push(matcher: matcher, clause: clause)
128
+ end
129
+
130
+ public # TODO: should these change to private?
131
+
132
+ # This is responsible for the actual send letter aspect
133
+ # It will create PigeonLetters and call delivery
134
+ # to PigeonMailer
135
+ def self.send_letter(scope, letter_type, flight)
136
+ # The question we face here is, do we create letters one-by-one, or do we create the letters in bulk
137
+ # possibly updating sent_at after sending
138
+
139
+ # Also, how do we send letters?
140
+ # Is that asynchronous?
141
+
142
+ # Let's just do this quick and dirty and come back to this
143
+ scope.find_each do |cargo|
144
+ letter = PigeonLetter.create!(cargo: cargo, letter_type: letter_type, flight: flight)
145
+
146
+ # Send the letter -- note, we may want to also send info about flights for tracking
147
+ begin
148
+ letter.send!
149
+ rescue => e # We're going to rescue here to save ourselves the humiliation of failing.
150
+ # It would be nice to log this at the PigeonLetter (database) or return level
151
+ p [ 'Pigeons::SendLetter', 'Failed to send letter', e ]
152
+ end
153
+ end
154
+ end
155
+
156
+ end
@@ -0,0 +1,8 @@
1
+
2
+ if defined?(Rails)
3
+ class PigeonTask < Rails::Railtie
4
+ rake_tasks do
5
+ Dir[File.join(File.dirname(__FILE__),'../../tasks/*.rake')].each { |f| load f }
6
+ end
7
+ end
8
+ end