effective_cpd 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +117 -0
  4. data/Rakefile +18 -0
  5. data/app/assets/config/effective_cpd_manifest.js +2 -0
  6. data/app/assets/javascripts/effective_cpd.js +2 -0
  7. data/app/assets/javascripts/effective_cpd/activities.js +49 -0
  8. data/app/assets/javascripts/effective_cpd/activities_new.js +21 -0
  9. data/app/assets/stylesheets/effective_cpd.scss +1 -0
  10. data/app/assets/stylesheets/effective_cpd/_activities.scss +19 -0
  11. data/app/controllers/admin/cpd_activities_controller.rb +13 -0
  12. data/app/controllers/admin/cpd_categories_controller.rb +13 -0
  13. data/app/controllers/admin/cpd_cycles_controller.rb +13 -0
  14. data/app/controllers/admin/cpd_rules_controller.rb +13 -0
  15. data/app/controllers/admin/cpd_statements_controller.rb +13 -0
  16. data/app/controllers/effective/cpd_cycles_controller.rb +19 -0
  17. data/app/controllers/effective/cpd_statement_activities_controller.rb +27 -0
  18. data/app/controllers/effective/cpd_statements_controller.rb +71 -0
  19. data/app/datatables/admin/effective_cpd_activities_datatable.rb +31 -0
  20. data/app/datatables/admin/effective_cpd_categories_datatable.rb +24 -0
  21. data/app/datatables/admin/effective_cpd_cycles_datatable.rb +22 -0
  22. data/app/datatables/admin/effective_cpd_rules_datatable.rb +43 -0
  23. data/app/datatables/admin/effective_cpd_statements_datatable.rb +30 -0
  24. data/app/datatables/effective_cpd_datatable.rb +30 -0
  25. data/app/datatables/effective_cpd_statements_datatable.rb +23 -0
  26. data/app/helpers/effective_cpd_helper.rb +37 -0
  27. data/app/mailers/effective/cpd_mailer.rb +7 -0
  28. data/app/models/effective/cpd_activity.rb +41 -0
  29. data/app/models/effective/cpd_category.rb +35 -0
  30. data/app/models/effective/cpd_cycle.rb +113 -0
  31. data/app/models/effective/cpd_rule.rb +108 -0
  32. data/app/models/effective/cpd_scorer.rb +158 -0
  33. data/app/models/effective/cpd_statement.rb +95 -0
  34. data/app/models/effective/cpd_statement_activity.rb +78 -0
  35. data/app/views/admin/cpd_activities/_form.html.haml +20 -0
  36. data/app/views/admin/cpd_categories/_form.html.haml +21 -0
  37. data/app/views/admin/cpd_categories/_form_cpd_category.html.haml +5 -0
  38. data/app/views/admin/cpd_cycles/_form.html.haml +17 -0
  39. data/app/views/admin/cpd_cycles/_form_content.html.haml +32 -0
  40. data/app/views/admin/cpd_cycles/_form_cpd_cycle.html.haml +21 -0
  41. data/app/views/admin/cpd_cycles/_form_cpd_rules.html.haml +56 -0
  42. data/app/views/admin/cpd_statements/_form.html.haml +6 -0
  43. data/app/views/effective/cpd_statement_activities/_form.html.haml +70 -0
  44. data/app/views/effective/cpd_statements/_activities.html.haml +64 -0
  45. data/app/views/effective/cpd_statements/_activities_new.html.haml +39 -0
  46. data/app/views/effective/cpd_statements/_agreements.html.haml +6 -0
  47. data/app/views/effective/cpd_statements/_cpd_statement.html.haml +5 -0
  48. data/app/views/effective/cpd_statements/_layout.html.haml +37 -0
  49. data/app/views/effective/cpd_statements/_summary.html.haml +36 -0
  50. data/app/views/effective/cpd_statements/activities.html.haml +25 -0
  51. data/app/views/effective/cpd_statements/agreements.html.haml +14 -0
  52. data/app/views/effective/cpd_statements/complete.html.haml +13 -0
  53. data/app/views/effective/cpd_statements/start.html.haml +13 -0
  54. data/app/views/effective/cpd_statements/submit.html.haml +20 -0
  55. data/app/views/layouts/effective_cpd_mailer_layout.html.haml +7 -0
  56. data/config/effective_cpd.rb +29 -0
  57. data/config/routes.rb +28 -0
  58. data/db/migrate/01_create_effective_cpd.rb.erb +98 -0
  59. data/db/seeds.rb +472 -0
  60. data/lib/effective_cpd.rb +18 -0
  61. data/lib/effective_cpd/engine.rb +11 -0
  62. data/lib/effective_cpd/version.rb +3 -0
  63. data/lib/generators/effective_cpd/install_generator.rb +46 -0
  64. data/lib/generators/templates/effective_cpd_mailer_preview.rb +4 -0
  65. data/lib/tasks/effective_cpd_tasks.rake +6 -0
  66. metadata +233 -0
@@ -0,0 +1,108 @@
1
+ module Effective
2
+ class CpdRule < ActiveRecord::Base
3
+ belongs_to :cpd_cycle
4
+ belongs_to :ruleable, polymorphic: true # Activity or Category
5
+
6
+ log_changes(to: :cpd_cycle) if respond_to?(:log_changes)
7
+
8
+ # Only permit the words amount, amount2 and any charater 0-9 + - / * ( )
9
+ INVALID_FORMULA_CHARS = /[^0-9\+\-\/\*\(\)]/
10
+
11
+ effective_resource do
12
+ # A plaintext description of the formula
13
+ # For a Category: A maximum of 35 PDHs/year may be claimed in the Contributions to Knowledge category
14
+ # For a Activity: 15 hours of work equals 1 credit
15
+ credit_description :text
16
+
17
+ # The maximum credits per cycle a statement. Nil for no limit
18
+ max_credits_per_cycle :integer
19
+
20
+ # (amount / 15) or (30) or (amount * 2) or (amount + (amount2 * 10))
21
+ formula :string
22
+
23
+ # Maximum number of cycles can carry forward
24
+ max_cycles_can_carry_forward :integer
25
+
26
+ # Cannot be entered in this cycle
27
+ unavailable :boolean
28
+
29
+ timestamps
30
+ end
31
+
32
+ scope :deep, -> { includes(:cpd_cycle, :ruleable) }
33
+ scope :categories, -> { where(ruleable_type: 'Effective::CpdCategory') }
34
+ scope :activities, -> { where(ruleable_type: 'Effective::CpdActivity') }
35
+ scope :unavailable, -> { where(unavailable: true) }
36
+
37
+ #validates :cpd_cycle_id, uniqueness: { scope: [:ruleable_id, :ruleable_type] }
38
+ validates :credit_description, presence: true
39
+ validates :max_credits_per_cycle, numericality: { greater_than: 0, allow_nil: true }
40
+ validates :max_cycles_can_carry_forward, numericality: { greater_than: 0, allow_nil: true }
41
+
42
+ validates :formula, presence: true, if: -> { activity? }
43
+ validates :formula, absence: true, if: -> { category? }
44
+
45
+ validate(if: -> { formula.present? }) do
46
+ if formula.gsub('amount2', '').gsub('amount', '').gsub(' ', '').match(INVALID_FORMULA_CHARS).present?
47
+ self.errors.add(:formula, "may only contain amount, amount2 and 0-9 + - / * ( ) characters")
48
+ else
49
+ begin
50
+ eval_equation(amount: 0, amount2: 0)
51
+ rescue Exception => e
52
+ self.errors.add(:formula, e.message)
53
+ end
54
+ end
55
+ end
56
+
57
+ # The formula is determined by the cpd_activity's amount_label and amount2_label presence
58
+ validate(if: -> { formula.present? && activity? }) do
59
+ amount = formula.gsub('amount2', '').include?('amount')
60
+ amount2 = formula.include?('amount2')
61
+
62
+ cpd_activity = ruleable
63
+
64
+ if cpd_activity.amount_label.present? && cpd_activity.amount2_label.present?
65
+ self.errors.add(:formula, 'must include "amount"') unless amount.present?
66
+ self.errors.add(:formula, 'must include "amount2"') unless amount2.present?
67
+ elsif cpd_activity.amount_label.present?
68
+ self.errors.add(:formula, 'must include "amount"') unless amount.present?
69
+ self.errors.add(:formula, 'must not include "amount2"') if amount2.present?
70
+ elsif cpd_activity.amount2_label.present?
71
+ self.errors.add(:formula, 'must include "amount2"') unless amount2.present?
72
+ self.errors.add(:formula, 'must not include "amount"') if amount.present?
73
+ else
74
+ self.errors.add(:formula, 'must not include "amount"') if amount.present?
75
+ self.errors.add(:formula, 'must not include "amount2"') if amount2.present?
76
+ end
77
+ end
78
+
79
+ def to_s
80
+ formula.presence || 'category'
81
+ end
82
+
83
+ def activity?
84
+ ruleable.kind_of?(CpdActivity)
85
+ end
86
+
87
+ def category?
88
+ ruleable.kind_of?(CpdCategory)
89
+ end
90
+
91
+ def score(cpd_statement_activity:)
92
+ raise('cpd_cycles must match') unless cpd_statement_activity.cpd_statement.cpd_cycle_id == cpd_cycle_id
93
+ raise('cpd_activities must match') unless cpd_statement_activity.cpd_activity_id == ruleable_id
94
+
95
+ return cpd_statement_activity.carry_over if cpd_statement_activity.is_carry_over?
96
+
97
+ eval_equation(amount: cpd_statement_activity.amount, amount2: cpd_statement_activity.amount2)
98
+ end
99
+
100
+ private
101
+
102
+ def eval_equation(amount: nil, amount2: nil)
103
+ equation = formula.gsub('amount2', amount2.to_s).gsub('amount', amount.to_s)
104
+ eval(equation).to_i
105
+ end
106
+
107
+ end
108
+ end
@@ -0,0 +1,158 @@
1
+ module Effective
2
+ class CpdScorer
3
+ include EffectiveCpdHelper
4
+
5
+ def initialize(user:, from: nil)
6
+ @cycles = CpdCycle.deep.sorted.all
7
+ @statements = CpdStatement.deep.where(user: user).sorted.all
8
+
9
+ if from.present?
10
+ raise('expected from to be a CpdStatement') unless from.kind_of?(CpdStatement)
11
+ @statements = @statements.where('cpd_cycle_id >= ?', from.cpd_cycle_id)
12
+ end
13
+
14
+ end
15
+
16
+ def score!
17
+ @statements.each_with_index do |statement, index|
18
+ prev_statement = @statements[index-1] if index > 0
19
+
20
+ Array(prev_statement&.cpd_statement_activities).each do |activity|
21
+ if activity.marked_for_destruction? # Cascade this down the line
22
+ statement.cpd_statement_activities.each { |a| a.mark_for_destruction if a.original == activity }
23
+ end
24
+
25
+ if can_carry_forward?(activity, statement.cpd_cycle)
26
+ save_carry_forward_activity(activity, statement)
27
+ else
28
+ delete_carry_forward_activity(activity, statement)
29
+ end
30
+ end
31
+
32
+ # An activity was deleted from a previous statement
33
+ statement.cpd_statement_activities.each do |activity|
34
+ activity.mark_for_destruction if activity.original_id.present? && activity.original.blank?
35
+ end
36
+
37
+ score_statement(statement)
38
+ end
39
+
40
+ save!
41
+ end
42
+
43
+ protected
44
+
45
+ def save!
46
+ CpdStatement.transaction do
47
+ @statements.each { |statement| statement.save! }; true
48
+ end
49
+ end
50
+
51
+ def score_statement(statement)
52
+ cycle = statement.cpd_cycle
53
+
54
+ # Reset the current carry_forwards and messages
55
+ statement.cpd_statement_activities.each do |activity|
56
+ activity.carry_forward = 0
57
+ activity.reduced_messages.clear
58
+ end
59
+
60
+ # This scores and enforces CycleActivity.max_credits_per_cycle
61
+ statement.cpd_statement_activities.group_by(&:cpd_activity).each do |cpd_activity, activities|
62
+ rule = cycle.rule_for(cpd_activity)
63
+ max_credits_per_cycle = rule.max_credits_per_cycle || 9999999
64
+
65
+ activities.each do |activity|
66
+ next if activity.marked_for_destruction?
67
+
68
+ activity.score = rule.score(cpd_statement_activity: activity)
69
+ activity.max_score = activity.score # Hack for Category maximums below
70
+
71
+ max_credits_per_cycle -= activity.score # Counting down...
72
+
73
+ if max_credits_per_cycle < 0
74
+ activity.carry_forward = [0 - max_credits_per_cycle, activity.score].min
75
+ activity.reduced_messages["activity_#{cpd_activity.id}"] = "You have reached the maximum of #{rule.max_credits_per_cycle}/#{cpd_cycle_label} for this type of activity"
76
+ activity.score = [activity.score + max_credits_per_cycle, 0].max
77
+ end
78
+ end
79
+ end
80
+
81
+ # This enforced CycleCategory.max_credits_per_cycle
82
+ statement.cpd_statement_activities.group_by(&:cpd_category).each do |cpd_category, activities|
83
+ rule = cycle.rule_for(cpd_category)
84
+ max_credits_per_cycle = rule.max_credits_per_cycle
85
+
86
+ next if max_credits_per_cycle == nil
87
+
88
+ activities.each do |activity|
89
+ next if activity.marked_for_destruction?
90
+
91
+ max_credits_per_cycle -= activity.score # We're already scored. Counting down...
92
+
93
+ if max_credits_per_cycle < 0
94
+ activity.score = [activity.score + max_credits_per_cycle, 0].max
95
+ activity.carry_forward = activity.max_score - activity.score
96
+ activity.reduced_messages["category_#{cpd_category.id}"] = "You have reached the maximum of #{rule.max_credits_per_cycle}/#{cpd_cycle_label} for activities in the #{cpd_category} category"
97
+ end
98
+ end
99
+ end
100
+
101
+ # This enforces the max_cycles_can_carry_forward logic
102
+ # If an Activity cannot be carried forward another cycle, its carry_forward should be 0
103
+ next_cycle = @cycles[@cycles.index(cycle) + 1]
104
+
105
+ statement.cpd_statement_activities.each do |activity|
106
+ next if (activity.carry_forward == 0 || activity.marked_for_destruction?)
107
+
108
+ unless can_carry_forward?(activity, next_cycle)
109
+ activity.carry_forward = 0
110
+ activity.reduced_messages['max_cycles_can_carry_forward'] = "This activity cannot be carried forward any further"
111
+ end
112
+ end
113
+
114
+ # Finally set the score from the sum of activitiy scores
115
+ statement.score = statement.cpd_statement_activities.map { |activity| activity.marked_for_destruction? ? 0 : activity.score }.sum
116
+ end
117
+
118
+ def can_carry_forward?(activity, to_cycle = nil) # This is a StatementActivity being passed
119
+ return false if (activity.carry_forward == 0 || activity.marked_for_destruction?)
120
+
121
+ from_cycle = @cycles.find { |cycle| cycle.id == (activity.original || activity).cpd_statement.cpd_cycle_id }
122
+ max_cycles_can_carry_forward = from_cycle.rule_for(activity.cpd_activity).max_cycles_can_carry_forward
123
+
124
+ return true if max_cycles_can_carry_forward.blank?
125
+
126
+ cycles_carried = (@cycles.index(to_cycle) || @cycles.size) - @cycles.index(from_cycle)
127
+ cycles_carried <= max_cycles_can_carry_forward
128
+ end
129
+
130
+ def save_carry_forward_activity(existing, statement)
131
+ activity = statement.cpd_statement_activities.find { |a| a.original == (existing.original || existing) }
132
+ activity ||= statement.cpd_statement_activities.build()
133
+
134
+ activity.assign_attributes(
135
+ cpd_category: existing.cpd_category,
136
+ cpd_activity: existing.cpd_activity,
137
+ amount: existing.amount,
138
+ amount2: existing.amount2,
139
+ description: existing.description,
140
+ )
141
+
142
+ existing.files.each { |file| activity.files.attach(file.blob) }
143
+
144
+ activity.assign_attributes(
145
+ carry_over: existing.carry_forward,
146
+ original: existing.original || existing
147
+ )
148
+
149
+ activity
150
+ end
151
+
152
+ def delete_carry_forward_activity(existing, statement)
153
+ activity = statement.cpd_statement_activities.find { |a| a.original == (existing.original || existing) }
154
+ activity.mark_for_destruction if activity
155
+ end
156
+
157
+ end
158
+ end
@@ -0,0 +1,95 @@
1
+ module Effective
2
+ class CpdStatement < ActiveRecord::Base
3
+ attr_accessor :current_user
4
+ attr_accessor :current_step
5
+
6
+ belongs_to :cpd_cycle
7
+ belongs_to :user, polymorphic: true
8
+
9
+ has_many :cpd_statement_activities, -> { order(:id) }, inverse_of: :cpd_statement, dependent: :destroy
10
+ accepts_nested_attributes_for :cpd_statement_activities
11
+
12
+ has_many_attached :files
13
+ log_changes if respond_to?(:log_changes)
14
+
15
+ acts_as_tokened
16
+
17
+ acts_as_wizard(
18
+ start: 'Start',
19
+ activities: 'Enter Activities',
20
+ agreements: 'Sign Agreements',
21
+ submit: 'Confirm & Submit',
22
+ complete: 'Complete'
23
+ )
24
+
25
+ effective_resource do
26
+ score :integer
27
+
28
+ confirm_read :boolean
29
+ confirm_factual :boolean
30
+ confirm_readonly :boolean
31
+
32
+ completed_at :datetime, permitted: false
33
+
34
+ # Acts as tokened
35
+ token :string, permitted: false
36
+
37
+ # Acts as Wizard
38
+ wizard_steps :text, permitted: false
39
+ timestamps
40
+ end
41
+
42
+ scope :deep, -> { includes(:cpd_cycle, :user, cpd_statement_activities: [:files_attachments, :cpd_category, :original, cpd_activity: [:rich_text_body]]) }
43
+ scope :sorted, -> { order(:cpd_cycle_id) }
44
+
45
+ scope :draft, -> { where(completed_at: nil) }
46
+ scope :completed, -> { where.not(completed_at: nil) }
47
+
48
+ before_validation(if: -> { new_record? }) do
49
+ self.user ||= current_user
50
+ self.score ||= 0
51
+ end
52
+
53
+ validate(if: -> { completed? && cpd_cycle.required_score.present? }) do
54
+ min = cpd_cycle.required_score
55
+ self.errors.add(:score, "must be #{min} or greater to submit statement") if score < min
56
+ end
57
+
58
+ with_options(if: -> { current_step == :agreements }) do
59
+ validates :confirm_read, acceptance: true
60
+ validates :confirm_factual, acceptance: true
61
+ end
62
+
63
+ with_options(if: -> { current_step == :submit}) do
64
+ validates :confirm_readonly, acceptance: true
65
+ end
66
+
67
+ def to_s
68
+ (cpd_cycle || 'statement').to_s
69
+ end
70
+
71
+ # This is the review step where they click Submit Ballot
72
+ def submit!
73
+ wizard_steps[:complete] ||= Time.zone.now
74
+ self.completed_at ||= Time.zone.now
75
+
76
+ save!
77
+ end
78
+
79
+ def completed?
80
+ completed_at.present?
81
+ end
82
+
83
+ def carry_forward
84
+ cpd_statement_activities.sum { |activity| activity.carry_forward.to_i }
85
+ end
86
+
87
+ # {category_id => 20, category_id => 15}
88
+ def score_per_category
89
+ @score_per_category ||= Hash.new(0).tap do |scores|
90
+ cpd_statement_activities.each { |activity| scores[activity.cpd_category_id] += activity.score.to_i }
91
+ end
92
+ end
93
+
94
+ end
95
+ end
@@ -0,0 +1,78 @@
1
+ module Effective
2
+ class CpdStatementActivity < ActiveRecord::Base
3
+ attr_accessor :max_score
4
+
5
+ belongs_to :cpd_statement
6
+ belongs_to :cpd_category
7
+ belongs_to :cpd_activity
8
+
9
+ belongs_to :original, class_name: 'CpdStatementActivity', optional: true # If this is a Carryover, the original_statement_activity will be set.
10
+ #has_many :carried, class_name: 'CpdStatementActivity', foreign_key: 'original_id', dependent: :delete_all
11
+
12
+ has_many_attached :files
13
+
14
+ if respond_to?(:log_changes)
15
+ log_changes(to: :cpd_statement)
16
+ end
17
+
18
+ effective_resource do
19
+ amount :integer
20
+ amount2 :integer
21
+
22
+ description :text
23
+
24
+ carry_over :integer # carry_over_from_last_cycle
25
+ score :integer
26
+ carry_forward :integer # carry_forward_to_next_cycle
27
+
28
+ reduced_messages :text
29
+
30
+ timestamps
31
+ end
32
+
33
+ serialize :reduced_messages, Hash
34
+
35
+ scope :deep, -> { includes(:cpd_statement, :cpd_category, :cpd_activity, :original) }
36
+ scope :sorted, -> { order(:id) }
37
+
38
+ validates :original, presence: true, if: -> { carry_over.to_i > 0 }
39
+ validates :description, presence: true
40
+
41
+ validate(if: -> { cpd_statement.present? }) do
42
+ self.errors.add(:base, "statement has already been completed") if cpd_statement.completed?
43
+ self.errors.add(:base, "cycle is unavailable") unless cpd_statement.cpd_cycle.available?
44
+ end
45
+
46
+ def destroy
47
+ return false if cpd_statement.completed?
48
+ return false unless cpd_statement.cpd_cycle.available?
49
+ super
50
+ end
51
+
52
+ def to_s
53
+ (cpd_activity || 'activity').to_s
54
+ end
55
+
56
+ def reduced_messages
57
+ self[:reduced_messages] ||= {}
58
+ end
59
+
60
+ def is_carry_over?
61
+ original.present?
62
+ end
63
+
64
+ def original_cycle
65
+ (original || self).cpd_statement.cpd_cycle
66
+ end
67
+
68
+ # Will display as read-only on form
69
+ def locked?
70
+ is_carry_over? || cpd_statement&.completed?
71
+ end
72
+
73
+ def to_debug
74
+ "id=#{id}, score=#{score}, carry_foward=#{carry_forward}, carry_from_last=#{carry_over}, original=#{original&.id}"
75
+ end
76
+
77
+ end
78
+ end
@@ -0,0 +1,20 @@
1
+ = effective_form_with(model: [:admin, cpd_activity], engine: true) do |f|
2
+ - if inline_datatable?
3
+ = f.hidden_field :cpd_category_id
4
+ - else
5
+ = f.select :cpd_category_id, Effective::CpdCategory.sorted.all, label: 'Category'
6
+
7
+ .row
8
+ .col
9
+ = f.text_field :title, label: 'Title'
10
+ = f.rich_text_area :body, label: "Body"
11
+ .col
12
+ = f.text_field :amount_label,
13
+ hint: 'hours of work, papers written, courses attended'
14
+
15
+ = f.text_field :amount2_label,
16
+ hint: 'continuing education units'
17
+
18
+ = f.check_box :requires_upload_file
19
+
20
+ = effective_submit(f)