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.
- 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
|