effective_cpd 0.0.1

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