activr 1.0.0

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