activr 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +20 -0
- data/README.md +764 -0
- data/Rakefile +50 -0
- data/lib/activr.rb +206 -0
- data/lib/activr/activity.rb +446 -0
- data/lib/activr/async.rb +92 -0
- data/lib/activr/async/resque.rb +58 -0
- data/lib/activr/configuration.rb +40 -0
- data/lib/activr/dispatcher.rb +70 -0
- data/lib/activr/entity.rb +103 -0
- data/lib/activr/entity/model_mixin.rb +117 -0
- data/lib/activr/rails_ctx.rb +37 -0
- data/lib/activr/railtie.rb +73 -0
- data/lib/activr/railties/activr.rake +14 -0
- data/lib/activr/registry.rb +268 -0
- data/lib/activr/storage.rb +404 -0
- data/lib/activr/storage/mongo_driver.rb +645 -0
- data/lib/activr/timeline.rb +441 -0
- data/lib/activr/timeline/entry.rb +165 -0
- data/lib/activr/timeline/route.rb +161 -0
- data/lib/activr/utils.rb +74 -0
- data/lib/activr/version.rb +6 -0
- data/lib/generators/activr/activity_generator.rb +91 -0
- data/lib/generators/activr/templates/activity.rb +28 -0
- data/lib/generators/activr/templates/timeline.rb +42 -0
- data/lib/generators/activr/timeline_generator.rb +24 -0
- metadata +197 -0
data/lib/activr/async.rb
ADDED
@@ -0,0 +1,92 @@
|
|
1
|
+
module Activr
|
2
|
+
|
3
|
+
#
|
4
|
+
# Async hooks module
|
5
|
+
#
|
6
|
+
# The async hooks module permits to plug any job system to run some part if Activr code asynchronously.
|
7
|
+
#
|
8
|
+
# Possible hooks are:
|
9
|
+
# - :route_activity - An activity must me routed by the Dispatcher
|
10
|
+
# - :timeline_handle - An activity must be handled by a timeline
|
11
|
+
#
|
12
|
+
# A hook class:
|
13
|
+
# - must implement a `#enqueue` method, used to enqueue the async job
|
14
|
+
# - must call `Activr::Async.<hook_name>` method in the async job
|
15
|
+
#
|
16
|
+
# Hook classes to use are specified thanks to the `config.async` hash.
|
17
|
+
#
|
18
|
+
# When Resque is detected inside a Rails application then defaults hooks are provided out of the box (see the {Activr::Async::Resque} module).
|
19
|
+
#
|
20
|
+
# @example The default :route_activity hook handler when Resque is detected in a Rails application:
|
21
|
+
#
|
22
|
+
# # config
|
23
|
+
# Activr.configure do |config|
|
24
|
+
# config.async[:route_activity] ||= Activr::Async::Resque::RouteActivity
|
25
|
+
# end
|
26
|
+
#
|
27
|
+
# class Activr::Async::Resque::RouteActivity
|
28
|
+
# @queue = 'activr_route_activity'
|
29
|
+
#
|
30
|
+
# class << self
|
31
|
+
# def enqueue(activity)
|
32
|
+
# ::Resque.enqueue(self, activity.to_hash)
|
33
|
+
# end
|
34
|
+
#
|
35
|
+
# def perform(activity_hash)
|
36
|
+
# # unserialize argument
|
37
|
+
# activity_hash = Activr::Activity.unserialize_hash(activity_hash)
|
38
|
+
# activity = Activr::Activity.from_hash(activity_hash)
|
39
|
+
#
|
40
|
+
# # call hook
|
41
|
+
# Activr::Async.route_activity(activity)
|
42
|
+
# end
|
43
|
+
# end # class << self
|
44
|
+
# end # class RouteActivity
|
45
|
+
#
|
46
|
+
module Async
|
47
|
+
|
48
|
+
autoload :Resque, 'activr/async/resque'
|
49
|
+
|
50
|
+
class << self
|
51
|
+
# Run hook
|
52
|
+
#
|
53
|
+
# If an async class is defined for that hook name then it is used to process
|
54
|
+
# the hook asynchronously, else the hooked code is executed immediately.
|
55
|
+
#
|
56
|
+
# @param name [Symbol] Hook name to run
|
57
|
+
# @param args [Array] Hook parameters
|
58
|
+
def hook(name, *args)
|
59
|
+
if Activr.config.async[name] && (ENV['ACTIVR_FORCE_SYNC'] != 'true')
|
60
|
+
# async
|
61
|
+
Activr.config.async[name].enqueue(*args)
|
62
|
+
else
|
63
|
+
# sync
|
64
|
+
self.__send__(name, *args)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
|
69
|
+
#
|
70
|
+
# Hooks
|
71
|
+
#
|
72
|
+
|
73
|
+
# Hook: route an activity
|
74
|
+
#
|
75
|
+
# @param activity [Activity] Activity to route
|
76
|
+
def route_activity(activity)
|
77
|
+
Activr.dispatcher.route(activity)
|
78
|
+
end
|
79
|
+
|
80
|
+
# Hook: timeline handles an activity thanks to given route
|
81
|
+
#
|
82
|
+
# @param timeline [Timeline] Timeline that handles the activity
|
83
|
+
# @param activity [Activity] Activity to handle
|
84
|
+
# @param route [Timeline::Route] The route causing that activity handling
|
85
|
+
def timeline_handle(timeline, activity, route)
|
86
|
+
timeline.handle_activity(activity, route)
|
87
|
+
end
|
88
|
+
end # class << self
|
89
|
+
|
90
|
+
end # module Async
|
91
|
+
|
92
|
+
end # module Activr
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'resque'
|
2
|
+
|
3
|
+
#
|
4
|
+
# The defaults hook classes when Resque is detected inside a Rails application
|
5
|
+
#
|
6
|
+
module Activr::Async::Resque
|
7
|
+
|
8
|
+
# Class to handle :route_activity hook thanks to a Resque job
|
9
|
+
class RouteActivity
|
10
|
+
@queue = 'activr_route_activity'
|
11
|
+
|
12
|
+
class << self
|
13
|
+
# Enqueue job
|
14
|
+
def enqueue(activity)
|
15
|
+
::Resque.enqueue(self, activity.to_hash)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Perform job
|
19
|
+
def perform(activity_hash)
|
20
|
+
# unserialize argument
|
21
|
+
activity_hash = Activr::Activity.unserialize_hash(activity_hash)
|
22
|
+
activity = Activr::Activity.from_hash(activity_hash)
|
23
|
+
|
24
|
+
# call hook
|
25
|
+
Activr::Async.route_activity(activity)
|
26
|
+
end
|
27
|
+
end # class << self
|
28
|
+
end # class RouteActivity
|
29
|
+
|
30
|
+
# Class to handle :timeline_handle hook thanks to a Resque job
|
31
|
+
class TimelineHandle
|
32
|
+
@queue = 'activr_timeline_handle'
|
33
|
+
|
34
|
+
class << self
|
35
|
+
# Enqueue job
|
36
|
+
def enqueue(timeline, activity, route)
|
37
|
+
::Resque.enqueue(self, timeline.kind, timeline.recipient_id, activity.to_hash, route.kind)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Perform job
|
41
|
+
def perform(timeline_kind, recipient_id, activity_hash, route_kind)
|
42
|
+
# unserialize arguments
|
43
|
+
recipient_id = Activr.storage.unserialize_id_if_necessary(recipient_id)
|
44
|
+
activity_hash = Activr::Activity.unserialize_hash(activity_hash)
|
45
|
+
|
46
|
+
timeline_klass = Activr.registry.class_for_timeline(timeline_kind)
|
47
|
+
|
48
|
+
timeline = timeline_klass.new(recipient_id)
|
49
|
+
activity = Activr::Activity.from_hash(activity_hash)
|
50
|
+
route = timeline_klass.route_for_kind(route_kind)
|
51
|
+
|
52
|
+
# call hook
|
53
|
+
Activr::Async.timeline_handle(timeline, activity, route)
|
54
|
+
end
|
55
|
+
end # class << self
|
56
|
+
end # class TimelineHandle
|
57
|
+
|
58
|
+
end # module Activr::Async::Resque
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module Activr
|
2
|
+
|
3
|
+
#
|
4
|
+
# That module gives configuration behaviour to includer
|
5
|
+
#
|
6
|
+
# @see ActiveSupport::Configurable
|
7
|
+
#
|
8
|
+
module Configuration
|
9
|
+
|
10
|
+
extend ActiveSupport::Concern
|
11
|
+
|
12
|
+
include ActiveSupport::Configurable
|
13
|
+
|
14
|
+
included do
|
15
|
+
# default config
|
16
|
+
config.app_path = Dir.pwd
|
17
|
+
config.skip_dup_period = nil
|
18
|
+
|
19
|
+
config.mongodb = {
|
20
|
+
:uri => 'mongodb://127.0.0.1/activr',
|
21
|
+
:col_prefix => nil,
|
22
|
+
:activities_col => nil,
|
23
|
+
:timelines_col => nil,
|
24
|
+
}
|
25
|
+
|
26
|
+
config.async = { }
|
27
|
+
|
28
|
+
# compiles reader methods so we don't have to go through method_missing
|
29
|
+
config.compile_methods!
|
30
|
+
|
31
|
+
# fetch config from fwissr
|
32
|
+
config.app_path = Fwissr['/activr/app_path'] unless Fwissr['/activr/app_path'].blank?
|
33
|
+
config.skip_dup_period = Fwissr['/activr/skip_dup_period'] unless Fwissr['/activr/skip_dup_period'].blank?
|
34
|
+
config.mongodb.merge!(Fwissr['/activr/mongodb'].symbolize_keys) unless Fwissr['/activr/mongodb'].blank?
|
35
|
+
config.async.merge!(Fwissr['/activr/async'].symbolize_keys) unless Fwissr['/activr/async'].blank?
|
36
|
+
end
|
37
|
+
|
38
|
+
end # module Configuration
|
39
|
+
|
40
|
+
end # module Activr
|
@@ -0,0 +1,70 @@
|
|
1
|
+
module Activr
|
2
|
+
|
3
|
+
#
|
4
|
+
# The dispatcher is the component that is in charge of routing activities to timelines.
|
5
|
+
#
|
6
|
+
# The dispatcher singleton is accessible with {Activr.dispatcher}
|
7
|
+
#
|
8
|
+
class Dispatcher
|
9
|
+
|
10
|
+
# Route an activity
|
11
|
+
#
|
12
|
+
# @param activity [Activity] Activity to route
|
13
|
+
# @return [Integer] The number of resolved recipients that will handle activity
|
14
|
+
def route(activity)
|
15
|
+
raise "Activity must be stored before routing: #{activity.inspect}" if !activity.stored?
|
16
|
+
|
17
|
+
result = 0
|
18
|
+
|
19
|
+
activity.run_callbacks(:route) do
|
20
|
+
# iterate on all timelines
|
21
|
+
Activr.registry.timelines.values.each do |timeline_class|
|
22
|
+
# check if timeline refuses that activity
|
23
|
+
next unless timeline_class.should_route_activity?(activity)
|
24
|
+
|
25
|
+
# store activity in timelines
|
26
|
+
self.recipients_for_timeline(timeline_class, activity).each do |recipient, route|
|
27
|
+
result += 1
|
28
|
+
|
29
|
+
timeline = timeline_class.new(recipient)
|
30
|
+
if timeline.should_handle_activity?(activity, route)
|
31
|
+
Activr::Async.hook(:timeline_handle, timeline, activity, route)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
result
|
38
|
+
end
|
39
|
+
|
40
|
+
# Find recipients for given activity in given timeline
|
41
|
+
#
|
42
|
+
# @api private
|
43
|
+
#
|
44
|
+
# @param timeline_class [Class] Timeline class
|
45
|
+
# @param activity [Activity] Activity instance
|
46
|
+
# @return [Hash{Object=>Timeline::Route}] Recipients with corresponding Routes
|
47
|
+
def recipients_for_timeline(timeline_class, activity)
|
48
|
+
result = { }
|
49
|
+
|
50
|
+
routes = timeline_class.routes_for_activity(activity.class)
|
51
|
+
routes.each do |route|
|
52
|
+
route.resolve(activity).each do |recipient|
|
53
|
+
recipient_id = timeline_class.recipient_id(recipient)
|
54
|
+
|
55
|
+
# keep only one route per recipient
|
56
|
+
if result[recipient_id].nil?
|
57
|
+
result[recipient_id] = { :rcpt => recipient, :route => route }
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
result.inject({ }) do |memo, (recipient_id, infos)|
|
63
|
+
memo[infos[:rcpt]] = infos[:route]
|
64
|
+
memo
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
end # class Dispatcher
|
69
|
+
|
70
|
+
end # module Activr
|
@@ -0,0 +1,103 @@
|
|
1
|
+
module Activr
|
2
|
+
|
3
|
+
#
|
4
|
+
# An Entity represents one of your application model involved in activities
|
5
|
+
#
|
6
|
+
class Entity
|
7
|
+
|
8
|
+
autoload :ModelMixin, 'activr/entity/model_mixin'
|
9
|
+
|
10
|
+
# @return [Symbol] entity name
|
11
|
+
attr_reader :name
|
12
|
+
|
13
|
+
# @return [Hash] entity options
|
14
|
+
attr_reader :options
|
15
|
+
|
16
|
+
# @return [Activity] activity owning that entity
|
17
|
+
attr_reader :activity
|
18
|
+
|
19
|
+
# @return [Class] entity model class
|
20
|
+
attr_reader :model_class
|
21
|
+
|
22
|
+
# @return [Objecy] entity model id
|
23
|
+
attr_reader :model_id
|
24
|
+
|
25
|
+
|
26
|
+
# @param name [String] Entity name
|
27
|
+
# @param value [Object] Model instance or model id
|
28
|
+
# @param options [Hash] Options hash
|
29
|
+
# @option (see Activity.entity)
|
30
|
+
# @option options [Activity] :activity Entity belongs to that activity
|
31
|
+
def initialize(name, value, options = { })
|
32
|
+
@name = name
|
33
|
+
@options = options.dup
|
34
|
+
|
35
|
+
@activity = @options.delete(:activity)
|
36
|
+
@model_class = @options.delete(:class)
|
37
|
+
|
38
|
+
if Activr.storage.valid_id?(value)
|
39
|
+
@model_id = value
|
40
|
+
|
41
|
+
raise "Missing :class option for #{name} / #{value}: #{options.inspect}" if @model_class.nil?
|
42
|
+
raise "Model class MUST implement #find method" unless @model_class.respond_to?(:find)
|
43
|
+
else
|
44
|
+
@model = value
|
45
|
+
|
46
|
+
if (@model_class && (@model_class != @model.class))
|
47
|
+
raise "Model class mismatch: #{@model_class} != #{@model.class}"
|
48
|
+
end
|
49
|
+
|
50
|
+
@model_class ||= @model.class
|
51
|
+
@model_id = @model.id
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Get model instance
|
56
|
+
#
|
57
|
+
# @return [Object] Model instance
|
58
|
+
def model
|
59
|
+
@model ||= self.model_class.find(self.model_id)
|
60
|
+
end
|
61
|
+
|
62
|
+
# Humanize entity
|
63
|
+
#
|
64
|
+
# @param options [hash] Options
|
65
|
+
# @option options [true,false] :html Generate HTML ?
|
66
|
+
# @return [String] Humanized sentence
|
67
|
+
def humanize(options = { })
|
68
|
+
result = nil
|
69
|
+
htmlized = false
|
70
|
+
|
71
|
+
humanize_meth = @options[:humanize]
|
72
|
+
if humanize_meth.nil? && (self.model.respond_to?(:humanize))
|
73
|
+
humanize_meth = :humanize
|
74
|
+
end
|
75
|
+
|
76
|
+
if humanize_meth
|
77
|
+
case self.model.method(humanize_meth).arity
|
78
|
+
when 1
|
79
|
+
result = self.model.__send__(humanize_meth, options)
|
80
|
+
htmlized = true
|
81
|
+
else
|
82
|
+
result = self.model.__send__(humanize_meth)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
if result.nil? && @options[:default]
|
87
|
+
result = @options[:default]
|
88
|
+
end
|
89
|
+
|
90
|
+
if !result.nil? && options[:html] && !htmlized && Activr::RailsCtx.view_context
|
91
|
+
# let Rails sanitize and htmlize the entity
|
92
|
+
result = Activr::RailsCtx.view_context.sanitize(result)
|
93
|
+
result = Activr::RailsCtx.view_context.link_to(result, self.model)
|
94
|
+
end
|
95
|
+
|
96
|
+
result ||= ""
|
97
|
+
|
98
|
+
result
|
99
|
+
end
|
100
|
+
|
101
|
+
end # class Entity
|
102
|
+
|
103
|
+
end # module Activr
|
@@ -0,0 +1,117 @@
|
|
1
|
+
#
|
2
|
+
# Including that module in your model class adds these methods: {#activities}, {#activities_count} and
|
3
|
+
# {#delete_activities!}.
|
4
|
+
#
|
5
|
+
# If you plan to call either {#activities} or {#activities_count} methods then set the `:feed_index => true`
|
6
|
+
# entity setting to ensure that an index is correctly setup when running the `rake activr:create_indexes` task.
|
7
|
+
#
|
8
|
+
# If you plan to call {#delete_activities!} method then you should set the `:deletable => true` entity setting
|
9
|
+
# to ensure that a deletion index is correctly setup when running the `rake activr:create_indexes` task.
|
10
|
+
#
|
11
|
+
# @example Model:
|
12
|
+
# class User
|
13
|
+
#
|
14
|
+
# # inject sugar methods
|
15
|
+
# include Activr::Entity::ModelMixin
|
16
|
+
#
|
17
|
+
# activr_entity :feed_index => true, :deletable => true
|
18
|
+
#
|
19
|
+
# include Mongoid::Document
|
20
|
+
#
|
21
|
+
# field :_id, :type => String
|
22
|
+
# field :first_name, :type => String
|
23
|
+
# field :last_name, :type => String
|
24
|
+
#
|
25
|
+
# def fullname
|
26
|
+
# "#{self.first_name} #{self.last_name}"
|
27
|
+
# end
|
28
|
+
#
|
29
|
+
# after_destroy :delete_activities!
|
30
|
+
#
|
31
|
+
# end
|
32
|
+
#
|
33
|
+
# @example Usage:
|
34
|
+
# user = User.find('john')
|
35
|
+
#
|
36
|
+
# puts "#{user.fullname} has #{user.activities_count} activites. Here are the 10 most recent:"
|
37
|
+
#
|
38
|
+
# user.activities(10).each do |activity|
|
39
|
+
# puts activity.humanize
|
40
|
+
# end
|
41
|
+
#
|
42
|
+
module Activr::Entity::ModelMixin
|
43
|
+
|
44
|
+
extend ActiveSupport::Concern
|
45
|
+
|
46
|
+
included do
|
47
|
+
# Entity settings
|
48
|
+
class_attribute :activr_entity_settings, :instance_writer => false
|
49
|
+
self.activr_entity_settings = { :feed_index => false, :deletable => false, :name => nil }
|
50
|
+
|
51
|
+
# Register model
|
52
|
+
Activr.registry.add_model(self)
|
53
|
+
end
|
54
|
+
|
55
|
+
# Class methods for the {ModelMixin} mixin
|
56
|
+
module ClassMethods
|
57
|
+
|
58
|
+
# Get entity name to use for activity feed queries
|
59
|
+
#
|
60
|
+
# @api private
|
61
|
+
#
|
62
|
+
# @return [Symbol]
|
63
|
+
def activr_entity_feed_actual_name
|
64
|
+
self.activr_entity_settings[:name] || Activr::Utils.kind_for_class(self).to_sym
|
65
|
+
end
|
66
|
+
|
67
|
+
|
68
|
+
#
|
69
|
+
# Class interface
|
70
|
+
#
|
71
|
+
|
72
|
+
# Set a custom entity name to use in entity activity feed queries
|
73
|
+
#
|
74
|
+
# @note By default, the entity name is inferred from the model class name
|
75
|
+
# @todo Add documentation in README for that
|
76
|
+
#
|
77
|
+
# @param settings [Hash] Entity settings
|
78
|
+
# @option settings [Boolean] :deletable Entity is deletable ? (default: `false`)
|
79
|
+
# @option settings [String] :name Custom entity name to use in entity activity feed queries (default is inferred from model class name)
|
80
|
+
# @option settings [Boolean] :feed_index Ensure entity activity feed index ? (default: `false`)
|
81
|
+
def activr_entity(settings)
|
82
|
+
self.activr_entity_settings = self.activr_entity_settings.merge(settings)
|
83
|
+
end
|
84
|
+
|
85
|
+
end # module ClassMethods
|
86
|
+
|
87
|
+
# sugar
|
88
|
+
def activr_entity_feed_actual_name
|
89
|
+
self.class.activr_entity_feed_actual_name
|
90
|
+
end
|
91
|
+
|
92
|
+
# Fetch activities
|
93
|
+
#
|
94
|
+
# @param limit [Integer] Max number of activities to fetch
|
95
|
+
# @param options (see Storage#find_activities)
|
96
|
+
# @return [Array<Activity>] A list of activities
|
97
|
+
def activities(limit, options = { })
|
98
|
+
Activr.activities(limit, options.merge(self.activr_entity_feed_actual_name => self.id))
|
99
|
+
end
|
100
|
+
|
101
|
+
# Get total number of activities
|
102
|
+
#
|
103
|
+
# @return [Integer] The total number of activities
|
104
|
+
def activities_count
|
105
|
+
Activr.activities_count(self.activr_entity_feed_actual_name => self.id)
|
106
|
+
end
|
107
|
+
|
108
|
+
# Delete all activities and timeline entries that reference that entity
|
109
|
+
def delete_activities!
|
110
|
+
# delete activities
|
111
|
+
Activr.storage.delete_activities_for_entity_model(self)
|
112
|
+
|
113
|
+
# delete timeline entries
|
114
|
+
Activr.storage.delete_timeline_entries_for_entity_model(self)
|
115
|
+
end
|
116
|
+
|
117
|
+
end # module Activr::Entity::ModelMixin
|