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