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