proposal 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/Rakefile +38 -0
- data/app/models/proposal/token.rb +184 -0
- data/app/validators/proposal/arguments_validator.rb +36 -0
- data/app/validators/proposal/email_validator.rb +11 -0
- data/db/migrate/20121026005348_create_proposal_tokens.rb +33 -0
- data/lib/proposal.rb +5 -0
- data/lib/proposal/engine.rb +101 -0
- data/lib/proposal/version.rb +3 -0
- data/lib/tasks/proposal_tasks.rake +4 -0
- data/test/dummy/Rakefile +7 -0
- data/test/dummy/app/models/project.rb +3 -0
- data/test/dummy/app/models/user.rb +5 -0
- data/test/dummy/config.ru +4 -0
- data/test/dummy/config/application.rb +59 -0
- data/test/dummy/config/boot.rb +10 -0
- data/test/dummy/config/database.yml +25 -0
- data/test/dummy/config/environment.rb +5 -0
- data/test/dummy/config/environments/development.rb +37 -0
- data/test/dummy/config/environments/production.rb +67 -0
- data/test/dummy/config/environments/test.rb +37 -0
- data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
- data/test/dummy/config/initializers/inflections.rb +15 -0
- data/test/dummy/config/initializers/mime_types.rb +5 -0
- data/test/dummy/config/initializers/secret_token.rb +7 -0
- data/test/dummy/config/initializers/session_store.rb +8 -0
- data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
- data/test/dummy/config/locales/en.yml +5 -0
- data/test/dummy/config/routes.rb +58 -0
- data/test/dummy/db/migrate/20121026035505_create_users.rb +9 -0
- data/test/dummy/db/migrate/20121031041439_create_projects.rb +8 -0
- data/test/dummy/db/proposal.sqlite3 +0 -0
- data/test/dummy/log/development.log +3 -0
- data/test/dummy/log/test.log +31285 -0
- data/test/dummy/script/rails +6 -0
- data/test/proposal_test.rb +227 -0
- data/test/test_helper.rb +18 -0
- 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,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,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
|
data/test/dummy/Rakefile
ADDED
@@ -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
|