gamifier 1.0.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. data/.gitignore +17 -0
  2. data/Gemfile +4 -0
  3. data/LICENSE +22 -0
  4. data/README.md +60 -0
  5. data/Rakefile +2 -0
  6. data/examples/dev.rb +186 -0
  7. data/examples/preprod.rb +218 -0
  8. data/gamifier.gemspec +24 -0
  9. data/lib/gamifier/collection.rb +26 -0
  10. data/lib/gamifier/dsl/network.rb +33 -0
  11. data/lib/gamifier/dsl/site.rb +134 -0
  12. data/lib/gamifier/dsl.rb +24 -0
  13. data/lib/gamifier/engine.rb +84 -0
  14. data/lib/gamifier/errors.rb +6 -0
  15. data/lib/gamifier/model.rb +212 -0
  16. data/lib/gamifier/models/activity.rb +8 -0
  17. data/lib/gamifier/models/activity_definition.rb +6 -0
  18. data/lib/gamifier/models/group.rb +6 -0
  19. data/lib/gamifier/models/player.rb +20 -0
  20. data/lib/gamifier/models/reward.rb +6 -0
  21. data/lib/gamifier/models/reward_definition.rb +14 -0
  22. data/lib/gamifier/models/site.rb +6 -0
  23. data/lib/gamifier/models/track.rb +6 -0
  24. data/lib/gamifier/models/unit.rb +16 -0
  25. data/lib/gamifier/models/user.rb +6 -0
  26. data/lib/gamifier/version.rb +3 -0
  27. data/lib/gamifier.rb +76 -0
  28. data/spec/integration/dsl_integration_spec.rb +76 -0
  29. data/spec/integration/player_integration_spec.rb +41 -0
  30. data/spec/integration/user_integration_spec.rb +19 -0
  31. data/spec/spec_helper.rb +7 -0
  32. data/spec/spec_integration_helper.rb +51 -0
  33. data/spec/unit/collection_spec.rb +21 -0
  34. data/spec/unit/dsl/network_spec.rb +34 -0
  35. data/spec/unit/dsl/site_spec.rb +54 -0
  36. data/spec/unit/dsl_spec.rb +9 -0
  37. data/spec/unit/engine_spec.rb +135 -0
  38. data/spec/unit/gamifier_spec.rb +60 -0
  39. data/spec/unit/model_spec.rb +182 -0
  40. data/spec/unit/models/player_spec.rb +40 -0
  41. metadata +179 -0
@@ -0,0 +1,134 @@
1
+ module Gamifier
2
+ module DSL
3
+ class Site
4
+ attr_reader :behaviors
5
+ attr_reader :units
6
+ attr_reader :rewards
7
+ attr_reader :missions
8
+ attr_reader :tracks
9
+ attr_reader :source
10
+
11
+ def initialize(*args)
12
+ @source = ::Gamifier::Site.new(*args)
13
+ @behaviors = []
14
+ @units = []
15
+ @rewards = []
16
+ @missions = []
17
+ @tracks = []
18
+ end
19
+
20
+ def engine
21
+ source.engine
22
+ end
23
+
24
+ def behavior(name, &block)
25
+ new_behavior = engine.activity_definitions.build(:name => name)
26
+ behaviors.push new_behavior
27
+
28
+ DSL.eval_with_context(new_behavior, &block)
29
+ end
30
+
31
+ def reward(name, &block)
32
+ new_reward = engine.reward_definitions.build(:name => name)
33
+ rewards.push new_reward
34
+
35
+ DSL.eval_with_context(new_reward, &block)
36
+ end
37
+
38
+ def unit(name, &block)
39
+ new_unit = engine.units.build(:name => name)
40
+ units.push new_unit
41
+
42
+ DSL.eval_with_context(new_unit, &block)
43
+ end
44
+
45
+ def mission(name, &block)
46
+ new_mission = engine.groups.build(:name => name)
47
+ missions.push new_mission
48
+
49
+ DSL.eval_with_context(new_mission, &block)
50
+ end
51
+
52
+ def track(name, &block)
53
+ new_track = engine.tracks.build(:label => name)
54
+ tracks.push new_track
55
+
56
+ DSL.eval_with_context(new_track, &block)
57
+ end
58
+
59
+ def save
60
+ if super
61
+ save_units!
62
+ save_behaviors!
63
+ save_rewards!
64
+ save_missions!
65
+ save_tracks!
66
+ else
67
+ raise Error, "Can't save #{self}"
68
+ end
69
+ end
70
+
71
+ def save_units!
72
+ units.each do |unit|
73
+ Gamifier.logger.info "Saving units..."
74
+ unit.site = self.url
75
+ if gunit = engine.send(unit.class.path).find_by_name(unit.name, :site => unit.site)
76
+ unit._id = gunit._id
77
+ # FIXME: once Badgeville returns the unit _id in the
78
+ # response, remove the next line:
79
+ next
80
+ end
81
+ unit.save || raise(Error, "Can't save #{unit}")
82
+ end
83
+ end
84
+
85
+ def save_behaviors!
86
+ behaviors.each do |behavior|
87
+ Gamifier.logger.info "Saving behaviors..."
88
+ behavior.site_id = self._id
89
+ if gbehavior = engine.send(behavior.class.path).find_by_name(behavior.name, :site => self.url)
90
+ behavior._id = gbehavior._id
91
+ end
92
+ behavior.save || raise(Error, "Can't save #{behavior}")
93
+ end
94
+ end
95
+
96
+ def save_rewards!
97
+ rewards.each do |reward|
98
+ Gamifier.logger.info "Saving rewards..."
99
+ reward.site_id = self._id
100
+ if greward = engine.send(reward.class.path).find_by_name(reward.name, :site => self.url)
101
+ reward._id = greward._id
102
+ end
103
+ reward.save || raise(Error, "Can't save #{reward}")
104
+ end
105
+ end
106
+
107
+ def save_missions!
108
+ missions.each do |mission|
109
+ Gamifier.logger.info "Saving missions..."
110
+ mission.site_id = self._id
111
+ if gmission = engine.send(mission.class.path).find_by_name(mission.name, :site => self.url)
112
+ mission._id = gmission._id
113
+ end
114
+ mission.save || raise(Error, "Can't save #{mission}")
115
+ end
116
+ end
117
+
118
+ def save_tracks!
119
+ tracks.each do |track|
120
+ Gamifier.logger.info "Saving tracks..."
121
+ track.site_id = self._id
122
+ if gtrack = engine.send(track.class.path).find_by_label(track.label, :site => self.url)
123
+ track._id = gtrack._id
124
+ end
125
+ track.save || raise(Error, "Can't save #{track}")
126
+ end
127
+ end
128
+
129
+ def method_missing(*args, &block)
130
+ source.send(*args, &block)
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,24 @@
1
+ require 'gamifier/dsl/network'
2
+ require 'gamifier/dsl/site'
3
+
4
+ module Gamifier
5
+ module DSL
6
+ class << self
7
+ def eval_with_context(new_context, &block)
8
+ new_context.extend DSL
9
+ if block
10
+ new_context.instance_eval(&block)
11
+ end
12
+ new_context
13
+ end
14
+ end
15
+
16
+ def set(key, *args, &block)
17
+ if block
18
+ self.send("#{key}=".to_sym, block)
19
+ else
20
+ self.send("#{key}=".to_sym, *args)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,84 @@
1
+ require 'faraday'
2
+ require 'gamifier/collection'
3
+ require 'json'
4
+ require 'rack/utils'
5
+
6
+ module Gamifier
7
+ class Engine
8
+ MODELS = %w{Activity ActivityDefinition Group Player Reward RewardDefinition Site Track Unit User}
9
+
10
+ attr_accessor :connection
11
+ attr_writer :config
12
+
13
+ def initialize(opts = {})
14
+ opts.each{|k,v| config[k.to_sym] = v}
15
+ raise ArgumentError, "Please configure a :uri and :key first." unless ok?
16
+ @connection = Faraday::Connection.new(:url => uri_to) do |builder|
17
+ builder.use Faraday::Adapter::NetHttp # make http requests with Net::HTTP
18
+ end
19
+ end
20
+
21
+ def config
22
+ @config ||= Gamifier.config.dup
23
+ end
24
+
25
+ def ok?
26
+ !config.values_at(:key, :uri).any?{|k| k.nil? || k.empty?}
27
+ end
28
+
29
+ # Sends an HTTP request corresponding to +method+, on +path+, with
30
+ # any options given in +opts+:
31
+ # +query+:: a hash of query parameters to pass with the request
32
+ # +head+:: a hash of HTTP headers to pass with the request
33
+ # +body+:: the body to send with the request. If you pass a Ruby
34
+ # object, it will be automatically converted into
35
+ # x-www-form-urlencoded by default.
36
+ def transmit(method, path, opts = {})
37
+ res = connection.send(method) do |req|
38
+ req.url [path, "json"].join("."), (opts[:query] || {})
39
+ (opts[:head] || {}).each do |k,v|
40
+ req.headers[k] = v
41
+ end
42
+ req.headers['Content-Type'] ||= 'application/x-www-form-urlencoded'
43
+ req.body = self.class.encode_www_form(opts[:body]) unless opts[:body].nil?
44
+ Gamifier.logger.debug "#{method.to_s.upcase} #{req.inspect}"
45
+ end
46
+ Gamifier.logger.debug "#{res.inspect}"
47
+ body = JSON.parse(res.body) rescue res.body
48
+ case res.status
49
+ when 204
50
+ true
51
+ when 200...300
52
+ body
53
+ when 404
54
+ nil
55
+ when 422
56
+ # Badgeville returns 422 when an entry already exists or is not valid
57
+ [:get, :head].include?(method) ? nil : false
58
+ when 400...500
59
+ raise HTTPClientError, body
60
+ when 500...600
61
+ raise HTTPServerError, body
62
+ else
63
+ raise HTTPError, body
64
+ end
65
+ end
66
+
67
+ def uri_to(path = "")
68
+ URI.join(File.join(::Gamifier.config[:uri], ::Gamifier.config[:key], ""), path)
69
+ end
70
+
71
+ MODELS.each do |model|
72
+ underscored = Gamifier.underscore(model)
73
+ require "gamifier/models/#{underscored}"
74
+ klass = Gamifier.const_get(model)
75
+ define_method(klass.path.to_sym) do
76
+ Collection.new(self, klass)
77
+ end
78
+ end
79
+
80
+ def self.encode_www_form(enum)
81
+ Rack::Utils.build_nested_query(enum)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,6 @@
1
+ module Gamifier
2
+ class Error < StandardError; end
3
+ class HTTPError < Error; end
4
+ class HTTPServerError < HTTPError; end
5
+ class HTTPClientError < HTTPError; end
6
+ end
@@ -0,0 +1,212 @@
1
+ require 'ostruct'
2
+
3
+ module Gamifier
4
+ class Model < OpenStruct
5
+ attr_writer :engine
6
+
7
+ # The engine to use when creating or updating records. Default to
8
+ # the global Gamifier engine if not set via <tt>#engine=</tt>
9
+ # method.
10
+ def engine
11
+ @engine ||= Gamifier.engine
12
+ end
13
+
14
+ def attributes; @table.dup; end
15
+
16
+ def save
17
+ new? ? create : update
18
+ end
19
+
20
+ # Allow to update only the specific attributes given in the +opts+ Hash.
21
+ def update_attributes(opts = {})
22
+ raise Error, "Can't update an object if it has not been saved yet" if new?
23
+ update(opts)
24
+ end
25
+
26
+ def payload_for_submission(opts = {})
27
+ attr_to_keep = opts.empty? ? attributes : opts
28
+
29
+ attr_to_keep = attr_to_keep.reject{|k,v|
30
+ self.class.special_attributes.include?(k.to_sym) || (
31
+ !self.class.mutable_attributes.empty? && !self.class.mutable_attributes.include?(k.to_sym)
32
+ )
33
+ }
34
+
35
+ # Nested hashes are dumped as JSON payload.
36
+ attr_to_keep.each do |k,v|
37
+ attr_to_keep[k] = encode(k.to_sym,v) || case v
38
+ when Hash
39
+ JSON.dump(v)
40
+ # Lazyloaded attributes
41
+ when Proc
42
+ v.call
43
+ else
44
+ v.to_s
45
+ end
46
+ end
47
+
48
+ h = { self.class.container => attr_to_keep }
49
+
50
+ self.class.special_attributes.each do |key|
51
+ h[key] = send(key)
52
+ end
53
+
54
+ h
55
+ end
56
+
57
+ # Overwrite in descendants to specifically encode a key/value pair.
58
+ def encode(key, value)
59
+ return nil
60
+ end
61
+
62
+ def _id
63
+ super || attributes[:id]
64
+ end
65
+
66
+ def destroy
67
+ return true if new?
68
+ engine.transmit :delete, path
69
+ end
70
+
71
+ def replace_if_successful(response)
72
+ if response.kind_of?(Hash)
73
+ response.each{|k,v| send("#{k}=".to_sym, v)}
74
+ self
75
+ else
76
+ response
77
+ end
78
+ end
79
+
80
+ # Returns true if the current object does not exist on the server.
81
+ def new?
82
+ self._id.nil?
83
+ end
84
+
85
+ def path
86
+ [self.class.path, self._id || "undefined"].join("/")
87
+ end
88
+
89
+ # To be included as instance methods into the Collection class used
90
+ # to instantiate and find objects. Add more in descendants if you
91
+ # want to add more methods. See Gamifier::Player for an example.
92
+ module FinderMethods
93
+ # Fetch all the records according to the given query parameters.
94
+ # If a block is given, it will automatically lazy-load all the
95
+ # pages and yield each entry, until all the items have been
96
+ # loaded, or until the user returns from the block.
97
+ def all(params = {}, &block)
98
+ params[:page] ||= 1
99
+ params[:per_page] ||= 50
100
+ res = engine.transmit(:get, path, :query => params)
101
+ if res.kind_of?(Hash)
102
+ entries = res['data'].map{|h| build(h)}
103
+ if block
104
+ # Lazy load all the pages
105
+ entries.each{|entry| yield(entry)}
106
+ params[:page] += 1
107
+ all(params, &block) unless res['paging'].empty? || res['data'].empty?
108
+ end
109
+ entries
110
+ else
111
+ res
112
+ end
113
+ end
114
+
115
+ def find_by(key, value, params = {})
116
+ all(params) do |entry|
117
+ return entry if entry.respond_to?(key) && entry.send(key) == value.to_s
118
+ end
119
+ return nil
120
+ end
121
+
122
+ def find_by_name(name, params = {})
123
+ find_by(:name, name, params)
124
+ end
125
+
126
+ def find_by_label(label, params = {})
127
+ find_by(:label, label, params)
128
+ end
129
+
130
+ def find(key, params = {})
131
+ res = engine.transmit(:get, [path, key].join("/"), :query => params)
132
+ select_first_entry_if_any(res)
133
+ end
134
+
135
+ def find_or_create(key, params = {})
136
+ find(key, params) || build(params).save
137
+ end
138
+
139
+ protected
140
+ # Selects the first entry out of a data array, or out of a single
141
+ # data entry.
142
+ def select_first_entry_if_any(response)
143
+ if response.kind_of?(Hash) && response.has_key?('data')
144
+ entry = [response['data']].flatten[0]
145
+ return nil if entry.nil?
146
+ build(entry)
147
+ else
148
+ response
149
+ end
150
+ end
151
+ end
152
+
153
+ module ClassMethods
154
+ def reset!
155
+ @path = nil
156
+ end
157
+
158
+ # Sets or retrieve the path to use to access the resource on the
159
+ # remote server. By default the path will be the pluralized
160
+ # container name (naive pluralization: container+s).
161
+ def path(value = nil)
162
+ if value.nil?
163
+ @path ||= container+"s"
164
+ else
165
+ @path = value
166
+ end
167
+ end
168
+
169
+ # Declare attributes that must be submitted outside the default
170
+ # container.
171
+ def special_attributes *values
172
+ store_or_get("@special_attributes", values)
173
+ end
174
+
175
+ # Define the attributes that will be sent when creating or
176
+ # updating a model.
177
+ def mutable_attributes *values
178
+ store_or_get("@mutable_attributes", values)
179
+ end
180
+
181
+ def store_or_get(variable, values)
182
+ if values.empty?
183
+ v = instance_variable_get(variable)
184
+ v ||= []
185
+ else
186
+ instance_variable_set(variable, values.map{|v| v.to_sym})
187
+ end
188
+ end
189
+ # Returns the container to use when submitting data. E.g. when
190
+ # submitting user attributes as user[email]=xxx, the container is
191
+ # 'user'. Basically, this is the underscored class name.
192
+ def container
193
+ Gamifier.underscore(name)
194
+ end
195
+ end
196
+
197
+ extend ClassMethods
198
+
199
+ protected
200
+
201
+ def create
202
+ res = engine.transmit :post, self.class.path, :body => payload_for_submission
203
+ replace_if_successful(res)
204
+ end
205
+
206
+ def update(opts = {})
207
+ res = engine.transmit :put, path, :body => payload_for_submission(opts)
208
+ replace_if_successful(res)
209
+ end
210
+ end
211
+
212
+ end
@@ -0,0 +1,8 @@
1
+ require 'gamifier/model'
2
+
3
+ module Gamifier
4
+ class Activity < Model
5
+ path "activities"
6
+ special_attributes :player_id, :site, :email
7
+ end
8
+ end
@@ -0,0 +1,6 @@
1
+ require 'gamifier/model'
2
+
3
+ module Gamifier
4
+ class ActivityDefinition < Model
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ require 'gamifier/model'
2
+
3
+ module Gamifier
4
+ class Group < Model
5
+ end
6
+ end
@@ -0,0 +1,20 @@
1
+ require 'gamifier/model'
2
+
3
+ module Gamifier
4
+ class Player < Model
5
+
6
+ special_attributes :email, :site
7
+ mutable_attributes :first_name, :last_name, :nickname, :display_name, :picture_url, :admin
8
+
9
+ module FinderMethods
10
+ def find_by_site_and_email(site, email, params = {})
11
+ res = engine.transmit(:get, path, :query => params.merge({:site => site, :email => email}))
12
+ select_first_entry_if_any(res)
13
+ end
14
+ end
15
+
16
+ def credit(verb, metadata = {})
17
+ engine.activities.build(metadata.merge({:player_id => _id, :verb => verb})).save
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,6 @@
1
+ require 'gamifier/model'
2
+
3
+ module Gamifier
4
+ class Reward < Model
5
+ end
6
+ end
@@ -0,0 +1,14 @@
1
+ require 'gamifier/model'
2
+
3
+ module Gamifier
4
+ class RewardDefinition < Model
5
+ def encode(key, value)
6
+ case key
7
+ when :components
8
+ JSON.dump(value)
9
+ else
10
+ super(key,value)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,6 @@
1
+ require 'gamifier/model'
2
+
3
+ module Gamifier
4
+ class Site < Model
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ require 'gamifier/model'
2
+
3
+ module Gamifier
4
+ class Track < Model
5
+ end
6
+ end
@@ -0,0 +1,16 @@
1
+ require 'gamifier/model'
2
+
3
+ module Gamifier
4
+ class Unit < Model
5
+ special_attributes :site, :type
6
+ mutable_attributes :name, :label, :abbreviation
7
+
8
+ def initialize(*args)
9
+ super
10
+ end
11
+
12
+ def type
13
+ "point"
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,6 @@
1
+ require 'gamifier/model'
2
+
3
+ module Gamifier
4
+ class User < Model
5
+ end
6
+ end
@@ -0,0 +1,3 @@
1
+ module Gamifier
2
+ VERSION = "1.0.3" unless defined?(Gamifier::VERSION)
3
+ end
data/lib/gamifier.rb ADDED
@@ -0,0 +1,76 @@
1
+ require 'logger'
2
+ require "gamifier/version"
3
+ require 'gamifier/errors'
4
+
5
+ module Gamifier
6
+ class << self
7
+ attr_writer :logger
8
+
9
+ # Sets a configuration option. The keys will be symbolized.
10
+ def set(key, opts = nil)
11
+ if opts.nil?
12
+ key.each do |k,v|
13
+ config[k.to_sym] = v
14
+ end
15
+ else
16
+ config[key.to_sym] = opts
17
+ end
18
+ end
19
+
20
+ # Returns the configuration Hash.
21
+ def config
22
+ @config ||= {}
23
+ end
24
+
25
+ # Returns the global Engine. If you need multiple engine with
26
+ # different API keys and/or URIs, you should create a new engine
27
+ # directly with Gamifier::Engine.new
28
+ def engine(&block)
29
+ @engine ||= Engine.new
30
+ block.call(@engine) if block
31
+ @engine
32
+ end
33
+
34
+ # Creates and returns a new DSL::Network object. The given block
35
+ # will be evaluated in the context of the network object.
36
+ #
37
+ # Usage:
38
+ #
39
+ # network = Gamifier.dsl do
40
+ # site 'my-site' do
41
+ # set :url, 'my-site.com'
42
+ # ...
43
+ # end
44
+ # end
45
+ def dsl(&block)
46
+ network = DSL::Network.new
47
+ network.instance_eval(&block)
48
+ network
49
+ end
50
+
51
+ def logger
52
+ @logger ||= begin
53
+ l = Logger.new(STDERR)
54
+ l.level = Logger::WARN
55
+ l
56
+ end
57
+ end
58
+
59
+ def reset!
60
+ @engine = nil
61
+ config.clear
62
+ end
63
+
64
+ def underscore(camel_cased_word)
65
+ camel_cased_word.to_s.gsub(/::/, '/').
66
+ gsub("Gamifier/", '').
67
+ gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
68
+ gsub(/([a-z\d])([A-Z])/,'\1_\2').
69
+ tr("-", "_").
70
+ downcase
71
+ end
72
+ end
73
+ end
74
+
75
+ require 'gamifier/engine'
76
+ require 'gamifier/dsl'