be_taskable 0.5.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/.document +5 -0
- data/.rspec +1 -0
- data/Gemfile +15 -0
- data/Gemfile.lock +178 -0
- data/Guardfile +5 -0
- data/LICENSE.txt +20 -0
- data/Rakefile +45 -0
- data/VERSION +1 -0
- data/be_taskable.gemspec +100 -0
- data/docs/task_refresh_activity_diagram.delineato +0 -0
- data/docs/taskable_complete_flow.delineato +0 -0
- data/lib/be_taskable/task.rb +173 -0
- data/lib/be_taskable/task_assignment.rb +60 -0
- data/lib/be_taskable/task_resolver.rb +47 -0
- data/lib/be_taskable/task_runner.rb +87 -0
- data/lib/be_taskable/taskable.rb +157 -0
- data/lib/be_taskable/tasker.rb +21 -0
- data/lib/be_taskable.rb +28 -0
- data/lib/generators/be_taskable/migration_generator.rb +44 -0
- data/lib/generators/be_taskable/resolver_generator.rb +28 -0
- data/lib/generators/be_taskable/templates/active_record/migration.rb +35 -0
- data/lib/generators/be_taskable/templates/resolver.rb.tpl +67 -0
- data/readme.md +292 -0
- data/spec/be_taskable/integration/end_to_end_spec.rb +79 -0
- data/spec/be_taskable/integration/irrelevance_spec.rb +70 -0
- data/spec/be_taskable/unit/be_taskable_spec.rb +17 -0
- data/spec/be_taskable/unit/task_assignment_spec.rb +185 -0
- data/spec/be_taskable/unit/task_runner_spec.rb +200 -0
- data/spec/be_taskable/unit/task_spec.rb +490 -0
- data/spec/be_taskable/unit/taskable_spec.rb +309 -0
- data/spec/be_taskable/unit/tasker_spec.rb +24 -0
- data/spec/database.yml +19 -0
- data/spec/models.rb +38 -0
- data/spec/schema.rb +37 -0
- data/spec/spec_helper.rb +96 -0
- metadata +245 -0
@@ -0,0 +1,87 @@
|
|
1
|
+
module BeTaskable
|
2
|
+
class TaskRunner
|
3
|
+
|
4
|
+
attr_reader :task
|
5
|
+
|
6
|
+
def initialize(task)
|
7
|
+
raise ArgumentError, "Invalid task" unless task.is_a?(BeTaskable::Task)
|
8
|
+
@task = task
|
9
|
+
end
|
10
|
+
|
11
|
+
def refresh
|
12
|
+
|
13
|
+
return if task.completed?
|
14
|
+
|
15
|
+
_mark_assignments_as_unconfirmed
|
16
|
+
|
17
|
+
if _relevant?
|
18
|
+
_make_task_relevant
|
19
|
+
_update_task_label
|
20
|
+
_update_assignments
|
21
|
+
else
|
22
|
+
_make_task_irrelevant
|
23
|
+
end
|
24
|
+
|
25
|
+
_delete_unconfirmed_assignments
|
26
|
+
end
|
27
|
+
|
28
|
+
def resolver
|
29
|
+
task.resolver
|
30
|
+
end
|
31
|
+
|
32
|
+
def _mark_assignments_as_unconfirmed
|
33
|
+
_assignments.uncompleted.update_all(confirmed: false)
|
34
|
+
end
|
35
|
+
|
36
|
+
def _delete_unconfirmed_assignments
|
37
|
+
_assignments.uncompleted.unconfirmed.delete_all
|
38
|
+
end
|
39
|
+
|
40
|
+
def _update_task_label
|
41
|
+
task.update_attribute(:label, resolver.label_for_task(task))
|
42
|
+
end
|
43
|
+
|
44
|
+
def _make_task_relevant
|
45
|
+
task.make_relevant if task.can_make_relevant?
|
46
|
+
end
|
47
|
+
|
48
|
+
def _make_task_irrelevant
|
49
|
+
# assignments will be just deleted
|
50
|
+
# no need to update them
|
51
|
+
task.make_irrelevant if task.can_make_irrelevant?
|
52
|
+
end
|
53
|
+
|
54
|
+
def _update_assignments
|
55
|
+
_assignees.each do |assignee|
|
56
|
+
# find the assignment
|
57
|
+
assignment = _assignments.where(assignee_id: assignee.id).last
|
58
|
+
|
59
|
+
unless assignment
|
60
|
+
assignment = _assignments.create(assignee: assignee)
|
61
|
+
end
|
62
|
+
|
63
|
+
assignment.complete_by = resolver.due_date_for_assignment(assignment)
|
64
|
+
assignment.visible_at = resolver.visible_date_for_assignment(assignment)
|
65
|
+
assignment.label = resolver.label_for_assignment(assignment)
|
66
|
+
assignment.url = resolver.url_for_assignment(assignment)
|
67
|
+
assignment.confirmed = true
|
68
|
+
assignment.save
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def _relevant?
|
73
|
+
# if the taskable cannot be found then assume it is not relevant
|
74
|
+
return false unless task.taskable
|
75
|
+
resolver.is_task_relevant?(task)
|
76
|
+
end
|
77
|
+
|
78
|
+
def _assignees
|
79
|
+
resolver.assignees_for_task(task)
|
80
|
+
end
|
81
|
+
|
82
|
+
def _assignments
|
83
|
+
task.assignments
|
84
|
+
end
|
85
|
+
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,157 @@
|
|
1
|
+
module BeTaskable
|
2
|
+
module Taskable
|
3
|
+
|
4
|
+
def be_taskable(*actions)
|
5
|
+
include InstanceMethods
|
6
|
+
|
7
|
+
has_many :tasks, class_name: '::BeTaskable::Task', dependent: :destroy, as: :taskable
|
8
|
+
end
|
9
|
+
|
10
|
+
def _task_resolver_name_for_action(action)
|
11
|
+
self.name + action.camelize + 'TaskResolver'
|
12
|
+
end
|
13
|
+
|
14
|
+
def _task_resolver_for_action(action)
|
15
|
+
_task_resolver_name_for_action(action).constantize.new
|
16
|
+
end
|
17
|
+
|
18
|
+
module InstanceMethods
|
19
|
+
|
20
|
+
# hook for testing
|
21
|
+
# e.g. expect(instance).to be_taskable
|
22
|
+
def taskable?
|
23
|
+
true
|
24
|
+
end
|
25
|
+
|
26
|
+
# @param {String} action Name of the action
|
27
|
+
# @return {Object} Resolver instance for the given action
|
28
|
+
def task_resolver_for_action(action)
|
29
|
+
self.class._task_resolver_for_action(action)
|
30
|
+
end
|
31
|
+
|
32
|
+
# Create a task and run it
|
33
|
+
# @param {String} action Name of the action
|
34
|
+
# @return {BeTaskable::Task} A task object
|
35
|
+
def create_task_for_action(action)
|
36
|
+
raise(ActiveRecord::RecordNotSaved, "Taskable must be persisted") unless self.persisted?
|
37
|
+
|
38
|
+
task = tasks.create(action: action)
|
39
|
+
|
40
|
+
if task.persisted?
|
41
|
+
task.on_creation
|
42
|
+
task.refresh
|
43
|
+
else
|
44
|
+
raise "Couldn't create task #{task.errors.full_messages}"
|
45
|
+
end
|
46
|
+
|
47
|
+
task
|
48
|
+
end
|
49
|
+
|
50
|
+
# Finds or Create a task and run it
|
51
|
+
# @param {String} action Name of the action
|
52
|
+
# @return {BeTaskable::Task} A task object
|
53
|
+
def create_or_refresh_task_for_action(action)
|
54
|
+
# if already created use that task
|
55
|
+
task = last_current_task_for_action(action)
|
56
|
+
|
57
|
+
if !task
|
58
|
+
task = create_task_for_action(action)
|
59
|
+
else
|
60
|
+
task.refresh
|
61
|
+
end
|
62
|
+
|
63
|
+
task
|
64
|
+
end
|
65
|
+
|
66
|
+
# @param {String} action Name of the action
|
67
|
+
# @return {ActiveRecord::Relation}
|
68
|
+
def tasks_for_action(action)
|
69
|
+
tasks.where(action: action)
|
70
|
+
end
|
71
|
+
|
72
|
+
def current_tasks_for_action(action)
|
73
|
+
tasks.where(action: action).current
|
74
|
+
end
|
75
|
+
|
76
|
+
# @param {String} action Name of the action
|
77
|
+
# @return {BeTaskable::Task} Last task for the given action
|
78
|
+
def last_task_for_action(action)
|
79
|
+
tasks_for_action(action).last
|
80
|
+
end
|
81
|
+
|
82
|
+
# @return {BeTaskable::Task} Last current task for the given action
|
83
|
+
def last_current_task_for_action(action)
|
84
|
+
current_tasks_for_action(action).last
|
85
|
+
end
|
86
|
+
|
87
|
+
# @return {Array} All current assignments for this taskable
|
88
|
+
def task_assignments
|
89
|
+
tasks.map(&:assignments).flatten
|
90
|
+
end
|
91
|
+
|
92
|
+
# @return {Array} All current assignments for this action
|
93
|
+
# returns an empty array if it cannot find the task
|
94
|
+
def task_assignments_for_action(action)
|
95
|
+
tasks_for_action(action).map(&:assignments).flatten
|
96
|
+
end
|
97
|
+
|
98
|
+
|
99
|
+
# @param {String} action
|
100
|
+
# @param {Object} assignee
|
101
|
+
# @return {TaskAssignment}
|
102
|
+
# Return nil if it cannot find the task
|
103
|
+
def task_assignment_for(action, assignee)
|
104
|
+
task = last_task_for_action(action)
|
105
|
+
task.assignment_for(assignee) if task
|
106
|
+
end
|
107
|
+
|
108
|
+
def current_task_assignment_for(action, assignee)
|
109
|
+
task = last_current_task_for_action(action)
|
110
|
+
task.assignment_for(assignee) if task
|
111
|
+
end
|
112
|
+
|
113
|
+
# @param {String} action Name of the action
|
114
|
+
# Completes the last task for an action (and all the assignments)
|
115
|
+
def complete_task_for_action(action)
|
116
|
+
task = last_task_for_action(action)
|
117
|
+
return false unless task
|
118
|
+
task.complete!
|
119
|
+
end
|
120
|
+
|
121
|
+
# Completes all task for this action
|
122
|
+
# Only for uncompleted tasks
|
123
|
+
def complete_tasks_for_action(action)
|
124
|
+
ts = current_tasks_for_action(action)
|
125
|
+
|
126
|
+
return false unless ts.any?
|
127
|
+
ts.each do |task|
|
128
|
+
task.complete!
|
129
|
+
end
|
130
|
+
true
|
131
|
+
end
|
132
|
+
|
133
|
+
# @param {String} action Name of the action
|
134
|
+
# @param {Object} assignee
|
135
|
+
# Completes a task assignment
|
136
|
+
def complete_task_for(action, assignee)
|
137
|
+
task = last_task_for_action(action)
|
138
|
+
return false unless task
|
139
|
+
task.complete_by(assignee)
|
140
|
+
end
|
141
|
+
|
142
|
+
# Expire all task for this action
|
143
|
+
# Only for uncompleted tasks
|
144
|
+
def expire_tasks_for_action(action)
|
145
|
+
ts = current_tasks_for_action(action)
|
146
|
+
|
147
|
+
return false unless ts.any?
|
148
|
+
ts.each do |task|
|
149
|
+
task.expire
|
150
|
+
end
|
151
|
+
true
|
152
|
+
end
|
153
|
+
|
154
|
+
end
|
155
|
+
|
156
|
+
end
|
157
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module BeTaskable
|
2
|
+
module Tasker
|
3
|
+
|
4
|
+
def be_tasker
|
5
|
+
include InstanceMethods
|
6
|
+
|
7
|
+
has_many :task_assignments, class_name: '::BeTaskable::TaskAssignment', dependent: :destroy, foreign_key: :assignee_id
|
8
|
+
end
|
9
|
+
|
10
|
+
module InstanceMethods
|
11
|
+
|
12
|
+
# hook for testing
|
13
|
+
# e.g. expect(instance).to be_tasker
|
14
|
+
def tasker?
|
15
|
+
true
|
16
|
+
end
|
17
|
+
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
end
|
data/lib/be_taskable.rb
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
require "active_record"
|
2
|
+
require "active_record/version"
|
3
|
+
# require "action_view"
|
4
|
+
|
5
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
6
|
+
|
7
|
+
module BeTaskable
|
8
|
+
def self.table_name_prefix
|
9
|
+
'be_taskable_'
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
require "be_taskable/taskable"
|
14
|
+
require "be_taskable/tasker"
|
15
|
+
require "be_taskable/task"
|
16
|
+
require "be_taskable/task_assignment"
|
17
|
+
require "be_taskable/task_resolver"
|
18
|
+
require "be_taskable/task_runner"
|
19
|
+
|
20
|
+
if defined?(ActiveRecord::Base)
|
21
|
+
ActiveRecord::Base.extend BeTaskable::Taskable
|
22
|
+
ActiveRecord::Base.extend BeTaskable::Tasker
|
23
|
+
end
|
24
|
+
|
25
|
+
# view helpers
|
26
|
+
# if defined?(ActionView::Base)
|
27
|
+
# ActionView::Base.send :include, ActsAsTaggableOn::TagsHelper
|
28
|
+
# end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'rails/generators'
|
2
|
+
require 'rails/generators/migration'
|
3
|
+
|
4
|
+
module BeTaskable
|
5
|
+
class MigrationGenerator < Rails::Generators::Base
|
6
|
+
|
7
|
+
include Rails::Generators::Migration
|
8
|
+
|
9
|
+
desc "Generates migration for BeTaskable Models"
|
10
|
+
|
11
|
+
def self.orm
|
12
|
+
Rails::Generators.options[:rails][:orm]
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.source_root
|
16
|
+
File.join(File.dirname(__FILE__), 'templates', (orm.to_s unless orm.class.eql?(String)) )
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.orm_has_migration?
|
20
|
+
[:active_record].include? orm
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.next_migration_number(dirname)
|
24
|
+
if ActiveRecord::Base.timestamped_migrations
|
25
|
+
migration_number = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i
|
26
|
+
migration_number += 1
|
27
|
+
migration_number.to_s
|
28
|
+
else
|
29
|
+
"%.3d" % (current_migration_number(dirname) + 1)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def create_migration_file
|
34
|
+
if self.class.orm_has_migration?
|
35
|
+
migration_template 'migration.rb', "db/migrate/#{migration_name}"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def migration_name
|
40
|
+
'be_taskable_migration'
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require 'rails/generators'
|
2
|
+
|
3
|
+
module BeTaskable
|
4
|
+
class ResolverGenerator < Rails::Generators::Base
|
5
|
+
desc "Create a Task Resolver"
|
6
|
+
|
7
|
+
def self.source_root
|
8
|
+
@source_root ||= File.join(File.dirname(__FILE__), 'templates')
|
9
|
+
end
|
10
|
+
|
11
|
+
argument :taskable, type: :string
|
12
|
+
argument :action_name, type: :string
|
13
|
+
|
14
|
+
def copy_class_file
|
15
|
+
dest = "app/task_resolvers/#{taskable}_#{action_name}_task_resolver.rb"
|
16
|
+
copy_file "resolver.rb.tpl", dest
|
17
|
+
class_name = "#{taskable.camelize}#{action_name.camelize}TaskResolver"
|
18
|
+
gsub_file dest, '{{class_name}}', class_name
|
19
|
+
end
|
20
|
+
|
21
|
+
# def copy_spec_file
|
22
|
+
# dest = "spec/services/#{name}_service_spec.rb"
|
23
|
+
# copy_file "spec.rb.tpl", dest
|
24
|
+
# gsub_file dest, '{{class_name}}', "#{name.camelize}Service"
|
25
|
+
# end
|
26
|
+
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
class BeTaskableMigration < ActiveRecord::Migration
|
2
|
+
def self.up
|
3
|
+
create_table :be_taskable_tasks do |t|
|
4
|
+
t.string :action
|
5
|
+
t.references :taskable, polymorphic: true
|
6
|
+
t.string :state
|
7
|
+
t.string :label
|
8
|
+
t.datetime :completed_at
|
9
|
+
t.datetime :expired_at
|
10
|
+
t.timestamps
|
11
|
+
end
|
12
|
+
|
13
|
+
create_table :be_taskable_task_assignments do |t|
|
14
|
+
t.integer :task_id
|
15
|
+
t.references :assignee, polymorphic: true
|
16
|
+
t.string :label
|
17
|
+
t.string :url
|
18
|
+
t.boolean :confirmed
|
19
|
+
t.boolean :enacted
|
20
|
+
t.datetime :visible_at
|
21
|
+
t.datetime :complete_by
|
22
|
+
t.datetime :completed_at
|
23
|
+
t.datetime :expired_at
|
24
|
+
t.timestamps
|
25
|
+
end
|
26
|
+
|
27
|
+
add_index :be_taskable_tasks, [:taskable_id, :taskable_type]
|
28
|
+
add_index :be_taskable_task_assignments, :task_id
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.down
|
32
|
+
drop_table :be_taskable_tasks
|
33
|
+
drop_table :be_taskable_task_assignments
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
class {{class_name}} < BeTaskable::TaskResolver
|
2
|
+
|
3
|
+
def consensus?(task)
|
4
|
+
# task.any_assignment_done?
|
5
|
+
# if any assignment is completed then return true
|
6
|
+
|
7
|
+
# task.majority_of_assignments_done?
|
8
|
+
# if the majority of assignments are completed then return true
|
9
|
+
|
10
|
+
# task.all_assignments_done?
|
11
|
+
# if all assignments are completed then return true
|
12
|
+
|
13
|
+
# use task.assignments to calculate consensus manually
|
14
|
+
# false
|
15
|
+
end
|
16
|
+
|
17
|
+
def is_task_relevant?(task)
|
18
|
+
# get the taskable by calling task.taskable
|
19
|
+
# evaluate if a task is still relevant
|
20
|
+
# e.g. the taskable object is no longer valid
|
21
|
+
# if this method returns false then:
|
22
|
+
# - the task will be marked as irrelevant
|
23
|
+
# - the assignments will be deleted (except the already completed ones)
|
24
|
+
true
|
25
|
+
end
|
26
|
+
|
27
|
+
def assignees_for_task(task)
|
28
|
+
# get the taskable by calling task.taskable
|
29
|
+
[]
|
30
|
+
end
|
31
|
+
|
32
|
+
def due_date_for_assignment(assignment)
|
33
|
+
# get the taskable by calling assignment.taskable
|
34
|
+
nil
|
35
|
+
end
|
36
|
+
|
37
|
+
def visible_date_for_assignment(assignment)
|
38
|
+
# get the taskable by calling assignment.taskable
|
39
|
+
nil
|
40
|
+
end
|
41
|
+
|
42
|
+
def label_for_task(task)
|
43
|
+
# get the taskable by calling task.taskable
|
44
|
+
""
|
45
|
+
end
|
46
|
+
|
47
|
+
def label_for_assignment(assignment)
|
48
|
+
# get the taskable by calling assignment.taskable
|
49
|
+
""
|
50
|
+
end
|
51
|
+
|
52
|
+
def url_for_assignment(assignment)
|
53
|
+
# get the taskable by calling assignment.taskable
|
54
|
+
""
|
55
|
+
end
|
56
|
+
|
57
|
+
# hooks
|
58
|
+
def on_creation(task)
|
59
|
+
end
|
60
|
+
|
61
|
+
def on_completion(task)
|
62
|
+
end
|
63
|
+
|
64
|
+
def on_expiration(task)
|
65
|
+
end
|
66
|
+
|
67
|
+
end
|