proposal 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.
Files changed (37) hide show
  1. data/Rakefile +38 -0
  2. data/app/models/proposal/token.rb +184 -0
  3. data/app/validators/proposal/arguments_validator.rb +36 -0
  4. data/app/validators/proposal/email_validator.rb +11 -0
  5. data/db/migrate/20121026005348_create_proposal_tokens.rb +33 -0
  6. data/lib/proposal.rb +5 -0
  7. data/lib/proposal/engine.rb +101 -0
  8. data/lib/proposal/version.rb +3 -0
  9. data/lib/tasks/proposal_tasks.rake +4 -0
  10. data/test/dummy/Rakefile +7 -0
  11. data/test/dummy/app/models/project.rb +3 -0
  12. data/test/dummy/app/models/user.rb +5 -0
  13. data/test/dummy/config.ru +4 -0
  14. data/test/dummy/config/application.rb +59 -0
  15. data/test/dummy/config/boot.rb +10 -0
  16. data/test/dummy/config/database.yml +25 -0
  17. data/test/dummy/config/environment.rb +5 -0
  18. data/test/dummy/config/environments/development.rb +37 -0
  19. data/test/dummy/config/environments/production.rb +67 -0
  20. data/test/dummy/config/environments/test.rb +37 -0
  21. data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
  22. data/test/dummy/config/initializers/inflections.rb +15 -0
  23. data/test/dummy/config/initializers/mime_types.rb +5 -0
  24. data/test/dummy/config/initializers/secret_token.rb +7 -0
  25. data/test/dummy/config/initializers/session_store.rb +8 -0
  26. data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
  27. data/test/dummy/config/locales/en.yml +5 -0
  28. data/test/dummy/config/routes.rb +58 -0
  29. data/test/dummy/db/migrate/20121026035505_create_users.rb +9 -0
  30. data/test/dummy/db/migrate/20121031041439_create_projects.rb +8 -0
  31. data/test/dummy/db/proposal.sqlite3 +0 -0
  32. data/test/dummy/log/development.log +3 -0
  33. data/test/dummy/log/test.log +31285 -0
  34. data/test/dummy/script/rails +6 -0
  35. data/test/proposal_test.rb +227 -0
  36. data/test/test_helper.rb +18 -0
  37. metadata +162 -0
data/Rakefile ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env rake
2
+ begin
3
+ require 'bundler/setup'
4
+ rescue LoadError
5
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
6
+ end
7
+ begin
8
+ require 'rdoc/task'
9
+ rescue LoadError
10
+ require 'rdoc/rdoc'
11
+ require 'rake/rdoctask'
12
+ RDoc::Task = Rake::RDocTask
13
+ end
14
+
15
+ RDoc::Task.new(:rdoc) do |rdoc|
16
+ rdoc.rdoc_dir = 'rdoc'
17
+ rdoc.title = 'Proposal'
18
+ rdoc.options << '--line-numbers'
19
+ rdoc.rdoc_files.include('README.rdoc')
20
+ rdoc.rdoc_files.include('lib/**/*.rb')
21
+ end
22
+
23
+
24
+
25
+
26
+ Bundler::GemHelper.install_tasks
27
+
28
+ require 'rake/testtask'
29
+
30
+ Rake::TestTask.new(:test) do |t|
31
+ t.libs << 'lib'
32
+ t.libs << 'test'
33
+ t.pattern = 'test/**/*_test.rb'
34
+ t.verbose = false
35
+ end
36
+
37
+
38
+ task :default => :test
@@ -0,0 +1,184 @@
1
+ module Proposal
2
+ class Token < ActiveRecord::Base
3
+
4
+ belongs_to :resource,
5
+ polymorphic: true
6
+
7
+ attr_accessible :email,
8
+ :proposable,
9
+ :proposable_type,
10
+ :expires,
11
+ :expects,
12
+ :resource,
13
+ :args
14
+
15
+ attr_writer :expects
16
+
17
+ validates_presence_of :email,
18
+ :token,
19
+ :proposable,
20
+ :proposable_type,
21
+ :expires_at
22
+
23
+ validates_with ::Proposal::ArgumentsValidator, if: -> {
24
+ expects.present?
25
+ }
26
+
27
+ validates_with ::Proposal::EmailValidator
28
+
29
+ serialize :arguments
30
+
31
+ validates :email,
32
+ uniqueness: {
33
+ scope: [
34
+ :proposable_type,
35
+ :resource_type,
36
+ :resource_id
37
+ ],
38
+ message: "already has an outstanding proposal"
39
+ }
40
+
41
+ before_validation on: :create do
42
+ self.token = SecureRandom.base64(15).tr('+/=lIO0', 'pqrsxyz')
43
+ end
44
+
45
+ before_validation on: :create do
46
+ self.expires_at = Time.now + 1.year unless self.expires_at
47
+ end
48
+
49
+ def expects
50
+ @expects || proposable.proposal_options[:expects]
51
+ end
52
+
53
+ def proposable
54
+ @proposable ||= self.proposable_type.constantize
55
+ end
56
+
57
+ def proposable= type
58
+ self.proposable_type = type.to_s
59
+ end
60
+
61
+ def recipient!
62
+ raise Proposal::RecordNotFound if recipient.nil?
63
+ recipient
64
+ end
65
+
66
+ def recipient
67
+ @recipient ||= self.proposable.where(email: self.email).first
68
+ end
69
+
70
+ def self.find_or_new options
71
+ constraints = options.slice :email, :proposable_type
72
+ resource = options[:resource]
73
+ if !resource.nil? && resource.respond_to?(:id)
74
+ constraints.merge! resource_type: resource.class.to_s,
75
+ resource_id: resource.id
76
+ end
77
+ token = where(constraints).first
78
+ token.nil? ? new(options) : token
79
+ end
80
+
81
+ def args= args_array
82
+ if args_array.first.is_a?(Hash) && args_array.size == 1
83
+ self.arguments = args_array.first
84
+ else
85
+ self.arguments = args_array
86
+ end
87
+ self
88
+ end
89
+
90
+ def args
91
+ self.arguments
92
+ end
93
+
94
+ def action
95
+ case
96
+ when persisted?
97
+ :remind
98
+ when recipient.nil?
99
+ :invite
100
+ else
101
+ :notify
102
+ end
103
+ end
104
+
105
+ def notify?
106
+ action == :notify
107
+ end
108
+
109
+ def invite?
110
+ action == :invite
111
+ end
112
+
113
+ def remind?
114
+ action == :remind
115
+ end
116
+
117
+ def accepted?
118
+ !accepted_at.nil?
119
+ end
120
+
121
+ def expired?
122
+ Time.now >= self.expires_at
123
+ end
124
+
125
+ def self.pending
126
+ where('accepted_at IS NULL')
127
+ end
128
+
129
+ def self.accepted
130
+ where('accepted_at IS NOT NULL')
131
+ end
132
+
133
+ def self.expired
134
+ where('expires_at < ?', Time.now)
135
+ end
136
+
137
+ def self.reminded
138
+ where('reminded_at IS NOT NULL')
139
+ end
140
+
141
+ def expires= expires_proc
142
+ unless expires_proc.is_a? Proc
143
+ raise ArgumentError, 'expires must be a proc'
144
+ end
145
+ self.expires_at = expires_proc.call
146
+ end
147
+
148
+ def acceptable?
149
+ errors.add :token, "has expired" if expired?
150
+ errors.add :token, "has been accepted" if accepted?
151
+ !expired? && !accepted?
152
+ end
153
+
154
+ def reminded
155
+ touch :reminded_at if remind?
156
+ remind?
157
+ end
158
+
159
+ def reminded!
160
+ raise Proposal::RemindError, 'proposal has not been made' unless remind?
161
+ reminded
162
+ end
163
+
164
+ def accept
165
+ if acceptable?
166
+ touch :accepted_at
167
+ true
168
+ else
169
+ false
170
+ end
171
+ end
172
+
173
+ def accept!
174
+ raise Proposal::ExpiredError, 'token has expired' if expired?
175
+ raise Proposal::AccepetedError, 'token has been used' if accepted?
176
+ touch :accepted_at
177
+ true
178
+ end
179
+
180
+ def to_s
181
+ token
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,36 @@
1
+ module Proposal
2
+ class ArgumentsValidator < ActiveModel::Validator
3
+
4
+ def validate_expected record, sym
5
+ unless record.arguments[sym].present?
6
+ record.errors.add :arguments, "is missing #{sym}"
7
+ end
8
+ end
9
+
10
+ def validate record
11
+ if record.expects.is_a? Proc
12
+ unless record.expects.call(record.arguments)
13
+ record.errors.add :arguments, "is invalid"
14
+ end
15
+ elsif record.arguments.is_a? Hash
16
+ case record.expects
17
+ when Symbol
18
+ validate_expected record, record.expects
19
+ when Array
20
+ record.expects.each { |sym| validate_expected record, sym }
21
+ end
22
+ else
23
+ record.errors.add :arguments, "must be a hash"
24
+ case record.expects
25
+ when Symbol
26
+ record.errors.add :arguments, "is missing #{record.expects}"
27
+ when Array
28
+ record.expects.each do |sym|
29
+ record.errors.add :arguments, "is missing #{sym}"
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ end
36
+ end
@@ -0,0 +1,11 @@
1
+ module Proposal
2
+ class EmailValidator < ActiveModel::Validator
3
+
4
+ def validate record
5
+ unless record.email =~ /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i
6
+ record.errors.add :email, "is not valid"
7
+ end
8
+ end
9
+
10
+ end
11
+ end
@@ -0,0 +1,33 @@
1
+ class CreateProposalTokens < ActiveRecord::Migration
2
+ def up
3
+ create_table :proposal_tokens do |t|
4
+ t.string :token, null: false
5
+ t.string :email, null: false
6
+ t.string :proposable_type, null: false
7
+ t.string :resource_type
8
+ t.integer :resource_id
9
+ t.text :arguments
10
+
11
+ t.datetime :accepted_at
12
+ t.datetime :reminded_at
13
+ t.datetime :expires_at, null: false
14
+ t.datetime :updated_at, null: false
15
+ t.datetime :created_at, null: false
16
+ end
17
+
18
+ add_index :proposal_tokens, :token, unique: true
19
+
20
+ execute <<-SQL
21
+ CREATE UNIQUE INDEX proposal_idx ON proposal_tokens (
22
+ email,
23
+ proposable_type,
24
+ resource_type,
25
+ resource_id
26
+ )
27
+ SQL
28
+ end
29
+
30
+ def down
31
+ drop_table :proposal_tokens
32
+ end
33
+ end
data/lib/proposal.rb ADDED
@@ -0,0 +1,5 @@
1
+ require "proposal/engine"
2
+
3
+ module Proposal
4
+
5
+ end
@@ -0,0 +1,101 @@
1
+ module Proposal
2
+
3
+ class ExpiredError < StandardError; end
4
+
5
+ class AccepetedError < StandardError; end
6
+
7
+ class RemindError < StandardError; end
8
+
9
+ class RecordNotFound < StandardError; end
10
+
11
+ # Wrapper object for the ORM. In this case it only supports ActiveRecord. In
12
+ # theory you could write an Adapter for each different ORM then use the rails
13
+ # initializer to add.
14
+ class Adapter
15
+ def initialize options
16
+ @options = options
17
+ end
18
+
19
+ def with *args
20
+ @options.merge! args: args
21
+ self
22
+ end
23
+
24
+ alias :with_args :with
25
+
26
+ # Method to return in instantiate the proposal object using an email
27
+ # address.
28
+ def to email, options = {}
29
+ Token.find_or_new @options.merge(options).merge email: email
30
+ end
31
+
32
+ # Delegates to ORM object and returns all proposal objects for given type.
33
+ def self.where options
34
+ Token.where options
35
+ end
36
+ end
37
+
38
+ module CanPropose
39
+
40
+ # Module for adding in class methods to object. For example:
41
+ #
42
+ # ==== Example
43
+ #
44
+ # User < ActiveRecord::Base
45
+ # can_propose
46
+ # end
47
+ #
48
+ module ClassMethods
49
+
50
+ # Class method for configuring default behaviour in ORM object.
51
+ #
52
+ # ==== Options
53
+ #
54
+ # * +:expires+ - A proc that returns a +DateTime+"
55
+ # * +:expects+ - Symbol or array of expected keys in arguments"
56
+ def can_propose options = {}
57
+ @proposal_options = options.merge proposable_type: self.to_s
58
+ end
59
+
60
+ # Getter for +@proposal_options+
61
+ def proposal_options
62
+ @proposal_options
63
+ end
64
+
65
+ # Class method for returning a new instance of +Adapter+
66
+ #
67
+ # Optional +resource+ argument that the ORM stores a reference to. This
68
+ # enables the email address to have multiple proposals for different
69
+ # unique resources.
70
+ def propose resource = nil
71
+ Adapter.new @proposal_options.merge resource: resource
72
+ end
73
+
74
+ # Delegate method to return all the proposals for the ORM object.
75
+ def proposals
76
+ Adapter.where proposable_type: self.to_s
77
+ end
78
+ end
79
+
80
+ module InstanceMethods
81
+ def proposals
82
+ Adapter.where resource_type: self.class.to_s, resource_id: self.id
83
+ end
84
+ end
85
+
86
+ def self.included base
87
+ base.send :extend, ClassMethods
88
+ base.send :include, InstanceMethods
89
+ end
90
+ end
91
+
92
+ class Engine < ::Rails::Engine
93
+ isolate_namespace Proposal
94
+
95
+ initializer "proposal.configure" do |app|
96
+ ActiveSupport.on_load :active_record do
97
+ include CanPropose
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,3 @@
1
+ module Proposal
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :proposal do
3
+ # # Task goes here
4
+ # end
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env rake
2
+ # Add your own tasks in files placed in lib/tasks ending in .rake,
3
+ # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
4
+
5
+ require File.expand_path('../config/application', __FILE__)
6
+
7
+ Dummy::Application.load_tasks
@@ -0,0 +1,3 @@
1
+ class Project < ActiveRecord::Base
2
+ # attr_accessible :title, :body
3
+ end
@@ -0,0 +1,5 @@
1
+ class User < ActiveRecord::Base
2
+ attr_accessible :email
3
+
4
+ can_propose expires: -> { Time.now + 1.day }
5
+ end