proposal 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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