activr 1.0.0

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 ADDED
@@ -0,0 +1,50 @@
1
+ require 'rake'
2
+
3
+ $:.unshift File.expand_path(File.dirname(__FILE__) + "/lib")
4
+ require 'activr'
5
+
6
+ task :default => :list
7
+ task :list do
8
+ system 'rake -T'
9
+ end
10
+
11
+ ##############################################################################
12
+ # SYNTAX CHECKING
13
+ ##############################################################################
14
+ desc 'Check code syntax'
15
+ task :check_syntax do
16
+ `find . -name "*.rb" |xargs -n1 ruby -c |grep -v "Syntax OK"`
17
+ puts "* Done"
18
+ end
19
+
20
+ ##############################################################################
21
+ # Stats
22
+ ##############################################################################
23
+ desc 'Show some stats about the code'
24
+ task :stats do
25
+ line_count = proc do |path|
26
+ Dir[path].collect { |f| File.open(f).readlines.reject { |l| l =~ /(^\s*(\#|\/\*))|^\s*$/ }.size }.inject(0){ |sum,n| sum += n }
27
+ end
28
+ comment_count = proc do |path|
29
+ Dir[path].collect { |f| File.open(f).readlines.select { |l| l =~ /^\s*\#/ }.size }.inject(0) { |sum,n| sum += n }
30
+ end
31
+ lib = line_count['lib/**/*.rb']
32
+ comment = comment_count['lib/**/*.rb']
33
+ ext = line_count['ext/**/*.{c,h}']
34
+ spec = line_count['spec/**/*.rb']
35
+
36
+ comment_ratio = '%1.2f' % (comment.to_f / lib.to_f)
37
+ spec_ratio = '%1.2f' % (spec.to_f / lib.to_f)
38
+
39
+ puts '/======================\\'
40
+ puts '| Part LOC |'
41
+ puts '|======================|'
42
+ puts "| lib #{lib.to_s.ljust(5)}|"
43
+ puts "| lib comments #{comment.to_s.ljust(5)}|"
44
+ puts "| ext #{ext.to_s.ljust(5)}|"
45
+ puts "| spec #{spec.to_s.ljust(5)}|"
46
+ puts '| ratios: |'
47
+ puts "| lib/comment #{comment_ratio.to_s.ljust(5)}|"
48
+ puts "| lib/spec #{spec_ratio.to_s.ljust(5)}|"
49
+ puts '\======================/'
50
+ end
data/lib/activr.rb ADDED
@@ -0,0 +1,206 @@
1
+ require 'rubygems'
2
+
3
+ require 'mustache'
4
+ require 'fwissr'
5
+
6
+ require 'logger'
7
+
8
+ # active support
9
+ require 'active_support/callbacks'
10
+ require 'active_support/core_ext/class'
11
+ require 'active_support/core_ext/string/inflections'
12
+ require 'active_support/core_ext/object/blank'
13
+ require 'active_support/concern'
14
+ require 'active_support/configurable'
15
+
16
+ # active model
17
+ require 'active_model/callbacks'
18
+
19
+ # activr
20
+ require 'activr/version'
21
+ require 'activr/utils'
22
+ require 'activr/configuration'
23
+ require 'activr/storage'
24
+ require 'activr/registry'
25
+ require 'activr/entity'
26
+ require 'activr/activity'
27
+ require 'activr/timeline'
28
+ require 'activr/dispatcher'
29
+ require 'activr/async'
30
+ require 'activr/rails_ctx'
31
+ require 'activr/railtie' if defined?(::Rails)
32
+
33
+
34
+ #
35
+ # Manage activity feeds
36
+ #
37
+ module Activr
38
+
39
+ # Access configuration with `Activr.config`
40
+ include Activr::Configuration
41
+
42
+ class << self
43
+
44
+ attr_writer :logger
45
+
46
+
47
+ # Configuration sugar
48
+ #
49
+ # @example
50
+ # Activr.configure do |config|
51
+ # config.app_path = File.join(File.dirname(__FILE__), "app")
52
+ # config.mongodb[:uri] = "mongodb://#{rspec_mongo_host}:#{rspec_mongo_port}/#{rspec_mongo_db}"
53
+ # end
54
+ #
55
+ # @yield [Configuration] Configuration singleton
56
+ def configure
57
+ yield self.config
58
+ end
59
+
60
+ # Setup registry
61
+ def setup
62
+ self.registry.setup
63
+ end
64
+
65
+ # @return [Logger] A logger instance
66
+ def logger
67
+ @logger ||= begin
68
+ result = Logger.new(STDOUT)
69
+ result.formatter = proc do |severity, datetime, progname, msg|
70
+ "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} [activr] #{msg}\n"
71
+ end
72
+ result
73
+ end
74
+ end
75
+
76
+ # Path to activities classes directory
77
+ #
78
+ # @return [String] Directory path
79
+ def activities_path
80
+ File.join(Activr.config.app_path, "activities")
81
+ end
82
+
83
+ # Path to timelines classes directory
84
+ #
85
+ # @return [String] Directory path
86
+ def timelines_path
87
+ File.join(Activr.config.app_path, "timelines")
88
+ end
89
+
90
+ # {Registry} singleton
91
+ #
92
+ # @return [Registry] {Registry} instance
93
+ def registry
94
+ @registy ||= Activr::Registry.new
95
+ end
96
+
97
+ # {Storage} singleton
98
+ #
99
+ # @return [Storage] {Storage} instance
100
+ def storage
101
+ @storage ||= Activr::Storage.new
102
+ end
103
+
104
+ # {Dispatcher} singleton
105
+ #
106
+ # @return [Dispatcher] {Dispatcher} instance
107
+ def dispatcher
108
+ @dispatcher ||= Activr::Dispatcher.new
109
+ end
110
+
111
+ # Dispatch an activity
112
+ #
113
+ # @param activity [Activity] Activity instance to dispatch
114
+ # @param options [Hash] Options hash
115
+ # @option options [Integer] :skip_dup_period Activity is skipped if a duplicate one is found in that period of time, in seconds (default: `Activr.config.skip_dup_period`)
116
+ # @return [Activity] The activity
117
+ def dispatch!(activity, options = { })
118
+ # default options
119
+ options = {
120
+ :skip_dup_period => Activr.config.skip_dup_period,
121
+ }.merge(options)
122
+
123
+ # check for duplicates
124
+ skip_it = options[:skip_dup_period] && (options[:skip_dup_period] > 0) &&
125
+ (Activr.storage.count_duplicate_activities(activity, Time.now - options[:skip_dup_period]) > 0)
126
+
127
+ if !skip_it
128
+ if !activity.stored?
129
+ # store activity in main collection
130
+ activity.store!
131
+ end
132
+
133
+ # check if storing failed
134
+ if activity.stored?
135
+ Activr::Async.hook(:route_activity, activity)
136
+ end
137
+ end
138
+
139
+ activity
140
+ end
141
+
142
+ # Normalize query options
143
+ #
144
+ # @api private
145
+ #
146
+ # @param options [Hash] Options to normalize
147
+ # @return [Hash] Normalized options
148
+ def normalize_query_options(options)
149
+ result = { }
150
+
151
+ options.each do |key, value|
152
+ key = key.to_sym
153
+
154
+ if Activr.registry.entities_names.include?(key)
155
+ # extract entities from options
156
+ result[:entities] ||= { }
157
+ result[:entities][key] = value
158
+ else
159
+ result[key] = value
160
+ end
161
+ end
162
+
163
+ result
164
+ end
165
+
166
+ # (see Storage#find_activities)
167
+ def activities(limit, options = { })
168
+ options = self.normalize_query_options(options)
169
+
170
+ Activr.storage.find_activities(limit, options)
171
+ end
172
+
173
+ # (see Storage#count_activities)
174
+ def activities_count(options = { })
175
+ options = self.normalize_query_options(options)
176
+
177
+ Activr.storage.count_activities(options)
178
+ end
179
+
180
+ # Get a timeline instance
181
+ #
182
+ # @param timeline_class [Class] Timeline class
183
+ # @param recipient [String|Object] Recipient instance or recipient id
184
+ # @return [Timeline] Timeline instance
185
+ def timeline(timeline_class, recipient)
186
+ timeline_class.new(recipient)
187
+ end
188
+
189
+ # Render a sentence
190
+ #
191
+ # @param text [String] Sentence to render
192
+ # @param bindings [Hash] Sentence bindings
193
+ # @return [String] Rendered sentence
194
+ def sentence(text, bindings = { })
195
+ # render
196
+ result = Activr::Utils.render_mustache(text, bindings)
197
+
198
+ # strip whitespaces
199
+ result.strip!
200
+
201
+ result
202
+ end
203
+
204
+ end # class << self
205
+
206
+ end # module Activr
@@ -0,0 +1,446 @@
1
+ module Activr
2
+
3
+ #
4
+ # An activity is an event that is (most of the time) performed by a user in your application.
5
+ #
6
+ # When defining an activity you specify allowed entities and a humanization template.
7
+ #
8
+ # When instanciated, an activity contains:
9
+ # - Concrete `entities` instances
10
+ # - A timestamp (the `at` field)
11
+ # - User-defined `meta` data
12
+ #
13
+ # By default, entities are mandatory and the exception {MissingEntityError} is raised when trying to store an activity
14
+ # with a missing entity.
15
+ #
16
+ # When an activity is stored in database, its `_id` field is filled.
17
+ #
18
+ # Model callbacks:
19
+ # - `before_store`, `around_store` and `after_store` are called when activity is stored in database
20
+ # - `before_route`, `around_route` and `after_route` are called when activity is routed by the dispatcher
21
+ #
22
+ # @example
23
+ # class AddPictureActivity < Activr::Activity
24
+ #
25
+ # entity :actor, :class => User, :humanize => :fullname
26
+ # entity :picture, :humanize => :title
27
+ # entity :album, :humanize => :name
28
+ #
29
+ # humanize "{{{actor}}} added picture {{{picture}}} to the album {{{album}}}"
30
+ #
31
+ # before_store :set_bar_meta
32
+ #
33
+ # def set_bar_meta
34
+ # self[:bar] = 'baz'
35
+ # true
36
+ # end
37
+ #
38
+ # end
39
+ #
40
+ # activity = AddPictureActivity.new(:actor => user, :picture => picture, :album => album, :foo => 'bar')
41
+ #
42
+ # activity.humanize
43
+ # # => John WILLIAMS added picture My Face to the album My Selfies
44
+ #
45
+ # activity[:foo]
46
+ # => 'bar'
47
+ #
48
+ # activity.store!
49
+ #
50
+ # activity._id
51
+ # => BSON::ObjectId('529cca3d61796d296e020000')
52
+ #
53
+ # activity[:bar]
54
+ # => 'baz'
55
+ #
56
+ class Activity
57
+
58
+ extend ActiveModel::Callbacks
59
+
60
+ # Callbacks when an activity is stored, and routed to timelines
61
+ define_model_callbacks :store, :route
62
+
63
+
64
+ # Exception: a mandatory entity is missing
65
+ class MissingEntityError < StandardError; end
66
+
67
+
68
+ # Allowed entities
69
+ class_attribute :allowed_entities, :instance_writer => false
70
+ self.allowed_entities = { }
71
+
72
+ # Humanization template
73
+ class_attribute :humanize_tpl, :instance_writer => false
74
+ self.humanize_tpl = nil
75
+
76
+
77
+ class << self
78
+
79
+ # Get activity class kind
80
+ #
81
+ # @example
82
+ # AddPictureActivity.kind
83
+ # => 'add_picture'
84
+ #
85
+ # @note Kind is inferred from class name, unless `#set_kind` method is used to force a custom value
86
+ #
87
+ # @return [String] Activity kind
88
+ def kind
89
+ @kind ||= @forced_kind || Activr::Utils.kind_for_class(self, 'activity')
90
+ end
91
+
92
+ # Instanciate an activity from a hash
93
+ #
94
+ # @note Correct activity subclass is resolved thanks to `kind` field
95
+ #
96
+ # @param data_hash [Hash] Activity fields
97
+ # @return [Activity] Subclass instance
98
+ def from_hash(data_hash)
99
+ activity_kind = data_hash['kind'] || data_hash[:kind]
100
+ raise "No kind found in activity hash: #{data_hash.inspect}" unless activity_kind
101
+
102
+ klass = Activr.registry.class_for_activity(activity_kind)
103
+ klass.new(data_hash)
104
+ end
105
+
106
+ # Unserialize an activity hash
107
+ #
108
+ # That method fixes issues remaining after an activity hash has been unserialized partially:
109
+ #
110
+ # - the `at` field is converted from String to Time
111
+ # - the `_id` field is converted from `{ '$oid' => [String] }` format to correct `ObjectId` class (`BSON::ObjectId` or `Moped::BSON::ObjectId`)
112
+ #
113
+ # @param data_hash [Hash] Activity fields
114
+ # @return [Hash] Unserialized activity fields
115
+ def unserialize_hash(data_hash)
116
+ result = { }
117
+
118
+ data_hash.each do |key, val|
119
+ result[key] = if Activr.storage.serialized_id?(val)
120
+ Activr.storage.unserialize_id(val)
121
+ elsif (key == 'at') && val.is_a?(String)
122
+ Time.parse(val)
123
+ else
124
+ val
125
+ end
126
+ end
127
+
128
+ result
129
+ end
130
+
131
+
132
+ #
133
+ # Class interface
134
+ #
135
+
136
+ # Define an allowed entity for that activity class
137
+ #
138
+ # @example That method creates several instance methods, for example with `entity :album`:
139
+ #
140
+ # # Get the entity model instance
141
+ # def album
142
+ # # ...
143
+ # end
144
+ #
145
+ # # Set the entity model instance
146
+ # def album=(value)
147
+ # # ...
148
+ # end
149
+ #
150
+ # # Get the entity id
151
+ # def album_id
152
+ # # ...
153
+ # end
154
+ #
155
+ # # Get the Activr::Entity instance
156
+ # def album_entity
157
+ # # ...
158
+ # end
159
+ #
160
+ # @note By convention the entity that correspond to a user performing an action should be named `:actor`
161
+ #
162
+ # @param name [String,Symbol] Entity name
163
+ # @param options [Hash] Entity options
164
+ # @option options [Class] :class Entity model class
165
+ # @option options [Symbol] :humanize A method name to call on entity model instance to humanize it
166
+ # @option options [String] :default Default humanization value
167
+ # @option options [true, false] :optional Is it an optional entity ?
168
+ def entity(name, options = { })
169
+ name = name.to_sym
170
+ raise "Entity already defined: #{name}" unless self.allowed_entities[name].nil?
171
+
172
+ if options[:class].nil?
173
+ options = options.dup
174
+ options[:class] = name.to_s.camelize.constantize
175
+ end
176
+
177
+ # NOTE: always use a setter on a class_attribute (cf. http://apidock.com/rails/Class/class_attribute)
178
+ self.allowed_entities = self.allowed_entities.merge(name => options)
179
+
180
+ # create entity methods
181
+ class_eval <<-EOS, __FILE__, __LINE__
182
+ # eg: actor
183
+ def #{name}
184
+ @entities[:#{name}] && @entities[:#{name}].model
185
+ end
186
+
187
+ # eg: actor = ...
188
+ def #{name}=(value)
189
+ @entities.delete(:#{name})
190
+
191
+ if (value != nil)
192
+ @entities[:#{name}] = Activr::Entity.new(:#{name}, value, self.allowed_entities[:#{name}])
193
+ end
194
+ end
195
+
196
+ # eg: actor_id
197
+ def #{name}_id
198
+ @entities[:#{name}] && @entities[:#{name}].model_id
199
+ end
200
+
201
+ # eg: actor_entity
202
+ def #{name}_entity
203
+ @entities[:#{name}]
204
+ end
205
+ EOS
206
+
207
+ if (name == :actor) && options[:class] &&
208
+ (options[:class] < Activr::Entity::ModelMixin) &&
209
+ options[:class].activr_entity_settings[:name].nil?
210
+ # sugar so that we don't have to explicitly call `activr_entity` on model class
211
+ options[:class].activr_entity_settings = options[:class].activr_entity_settings.merge(:name => :actor)
212
+ end
213
+
214
+ # register used entity
215
+ Activr.registry.add_entity(name, options, self)
216
+ end
217
+
218
+ # Define a humanization template for that activity class
219
+ #
220
+ # @param tpl [String] Mustache template
221
+ def humanize(tpl)
222
+ raise "Humanize already defined: #{self.humanize_tpl}" unless self.humanize_tpl.blank?
223
+
224
+ self.humanize_tpl = tpl
225
+ end
226
+
227
+ # Set activity kind
228
+ #
229
+ # @note Default kind is inferred from class name
230
+ #
231
+ # @param forced_kind [String] Activity kind
232
+ def set_kind(forced_kind)
233
+ @forced_kind = forced_kind.to_s
234
+ end
235
+
236
+ end # class << self
237
+
238
+ # @return [Object] activity id
239
+ attr_accessor :_id
240
+
241
+ # @return [Time] activity timestamp
242
+ attr_accessor :at
243
+
244
+ # @return [Hash{Symbol=>Entity}] activity entities
245
+ attr_reader :entities
246
+
247
+ # @return [Hash{Symbol=>Object}] activity meta hash (symbolized)
248
+ attr_reader :meta
249
+
250
+ # @param data_hash [Hash] Activity fields
251
+ def initialize(data_hash = { })
252
+ @_id = nil
253
+ @at = nil
254
+
255
+ @entities = { }
256
+ @meta = { }
257
+
258
+ data_hash.each do |data_name, data_value|
259
+ data_name = data_name.to_sym
260
+
261
+ if (self.allowed_entities[data_name] != nil)
262
+ # entity
263
+ @entities[data_name] = Activr::Entity.new(data_name, data_value, self.allowed_entities[data_name].merge(:activity => self))
264
+ elsif (data_name == :_id)
265
+ # activity id
266
+ @_id = data_value
267
+ elsif (data_name == :at)
268
+ # timestamp
269
+ raise "Wrong :at class: #{data_value.inspect}" unless data_value.is_a?(Time)
270
+ @at = data_value
271
+ elsif self.respond_to?("#{data_name}=")
272
+ # ivar
273
+ self.send("#{data_name}=", data_value)
274
+ elsif (data_name == :kind)
275
+ # ignore it
276
+ elsif (data_name == :meta)
277
+ # meta
278
+ @meta.merge!(data_value.symbolize_keys)
279
+ else
280
+ # sugar for meta data
281
+ self[data_name] = data_value
282
+ end
283
+ end
284
+
285
+ # default timestamp
286
+ @at ||= Time.now.utc
287
+ end
288
+
289
+ # Get a meta
290
+ #
291
+ # @example
292
+ # activity[:foo]
293
+ # # => 'bar'
294
+ #
295
+ # @param key [Symbol] Meta name
296
+ # @return [Object] Meta value
297
+ def [](key)
298
+ @meta[key.to_sym]
299
+ end
300
+
301
+ # Set a meta
302
+ #
303
+ # @example
304
+ # activity[:foo] = 'bar'
305
+ #
306
+ # @param key [Symbol] Meta name
307
+ # @param value [Object] Meta value
308
+ def []=(key, value)
309
+ @meta[key.to_sym] = value
310
+ end
311
+
312
+ # Serialize activity to a hash
313
+ #
314
+ # @note All keys are stringified (ie. there is no Symbol)
315
+ #
316
+ # @return [Hash] Activity hash
317
+ def to_hash
318
+ result = { }
319
+
320
+ # id
321
+ result['_id'] = @_id if @_id
322
+
323
+ # timestamp
324
+ result['at'] = @at
325
+
326
+ # kind
327
+ result['kind'] = kind.to_s
328
+
329
+ # entities
330
+ @entities.each do |entity_name, entity|
331
+ result[entity_name.to_s] = entity.model_id
332
+ end
333
+
334
+ # meta
335
+ result['meta'] = @meta.stringify_keys unless @meta.blank?
336
+
337
+ result
338
+ end
339
+
340
+ # Activity kind
341
+ #
342
+ # @example
343
+ # AddPictureActivity.new(...).kind
344
+ # => 'add_picture'
345
+ #
346
+ # @note Kind is inferred from Class name
347
+ #
348
+ # @return [String] Activity kind
349
+ def kind
350
+ self.class.kind
351
+ end
352
+
353
+ # Bindings for humanization sentence
354
+ #
355
+ # For each entity, returned hash contains:
356
+ # :<entity_name> => <entity humanization>
357
+ # :<entity_name>_model => <entity model instance>
358
+ #
359
+ # All `meta` are merged in returned hash too.
360
+ #
361
+ # @param options [Hash] Humanization options
362
+ # @return [Hash] Humanization bindings
363
+ def humanization_bindings(options = { })
364
+ result = { }
365
+
366
+ @entities.each do |entity_name, entity|
367
+ result[entity_name] = entity.humanize(options.merge(:activity => self))
368
+ result["#{entity_name}_model".to_sym] = entity.model
369
+ end
370
+
371
+ result.merge(@meta)
372
+ end
373
+
374
+ # Humanize that activity
375
+ #
376
+ # @param options [Hash] Options hash
377
+ # @option options [true, false] :html Output HTML (default: `false`)
378
+ # @return [String] Humanized activity
379
+ def humanize(options = { })
380
+ raise "No humanize_tpl defined" if self.humanize_tpl.blank?
381
+
382
+ Activr.sentence(self.humanize_tpl, self.humanization_bindings(options))
383
+ end
384
+
385
+ # Check if activity is valid
386
+ #
387
+ # @raise [MissingEntityError] if a mandatory entity is missing
388
+ # @api private
389
+ def check!
390
+ # check mandatory entities
391
+ self.allowed_entities.each do |entity_name, entity_options|
392
+ if !entity_options[:optional] && @entities[entity_name].blank?
393
+ raise Activr::Activity::MissingEntityError, "Missing '#{entity_name}' entity in this '#{self.kind}' activity: #{self.inspect}"
394
+ end
395
+ end
396
+ end
397
+
398
+ # Check if activity is stored in database
399
+ #
400
+ # @return [true, false]
401
+ def stored?
402
+ !@_id.nil?
403
+ end
404
+
405
+ # Store activity in database
406
+ #
407
+ # @raise [MissingEntityError] if a mandatory entity is missing
408
+ #
409
+ # @note SIDE EFFECT: The `_id` field is set
410
+ def store!
411
+ run_callbacks(:store) do
412
+ # check validity
413
+ self.check!
414
+
415
+ # store
416
+ @_id = Activr.storage.insert_activity(self)
417
+ end
418
+ end
419
+
420
+ # Sugar so that we can try to fetch an entity defined for another activity (yes, I hate myself for that...)
421
+ #
422
+ # @api private
423
+ def method_missing(sym, *args, &blk)
424
+ # match: actor_entity | actor_id | actor
425
+ match_data = sym.to_s.match(/(.+)_(entity|id)$/)
426
+ entity_name = match_data ? match_data[1].to_sym : sym
427
+
428
+ if Activr.registry.entities_names.include?(entity_name)
429
+ # ok, don't worry...
430
+ # define an instance method so that future calls on that method do not rely on method_missing
431
+ self.instance_eval <<-RUBY
432
+ def #{sym}(*args, &blk)
433
+ nil
434
+ end
435
+ RUBY
436
+
437
+ self.__send__(sym, *args, &blk)
438
+ else
439
+ # super Michel !
440
+ super
441
+ end
442
+ end
443
+
444
+ end # class Activity
445
+
446
+ end # module Activr