openproject-service_packs 1.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 (67) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +19 -0
  3. data/README.md +4 -0
  4. data/app/assets/javascripts/assigns.js +20 -0
  5. data/app/assets/javascripts/service_packs.js +17 -0
  6. data/app/assets/stylesheets/assigns.css +9 -0
  7. data/app/assets/stylesheets/service_packs.css +132 -0
  8. data/app/controllers/assigns_controller.rb +157 -0
  9. data/app/controllers/service_packs_controller.rb +219 -0
  10. data/app/helpers/service_pack_presenter.rb +39 -0
  11. data/app/helpers/service_pack_report.rb +65 -0
  12. data/app/helpers/service_packs_notification.rb +13 -0
  13. data/app/helpers/sp_assignment_manager.rb +25 -0
  14. data/app/mailers/application_mailer.rb +5 -0
  15. data/app/mailers/service_packs_mailer.rb +43 -0
  16. data/app/models/application_record.rb +3 -0
  17. data/app/models/assign.rb +13 -0
  18. data/app/models/mapping_rate.rb +14 -0
  19. data/app/models/service_pack.rb +180 -0
  20. data/app/models/service_pack_entry.rb +6 -0
  21. data/app/views/assigns/already_assigned.html.erb +14 -0
  22. data/app/views/assigns/not_assigned_yet.html.erb +19 -0
  23. data/app/views/assigns/unassignable.html.erb +4 -0
  24. data/app/views/layouts/mailer.html.erb +13 -0
  25. data/app/views/layouts/mailer.text.erb +1 -0
  26. data/app/views/service_packs/_active_assignments.html.erb +14 -0
  27. data/app/views/service_packs/_form.html.erb +80 -0
  28. data/app/views/service_packs/_rates_input.html.erb +15 -0
  29. data/app/views/service_packs/edit.html.erb +75 -0
  30. data/app/views/service_packs/index.html.erb +33 -0
  31. data/app/views/service_packs/new.html.erb +6 -0
  32. data/app/views/service_packs/show.html.erb +48 -0
  33. data/app/views/service_packs_mailer/expired_email.html.erb +1 -0
  34. data/app/views/service_packs_mailer/expired_email.text.erb +1 -0
  35. data/app/views/service_packs_mailer/notify_under_threshold1.html.erb +2 -0
  36. data/app/views/service_packs_mailer/notify_under_threshold1.text.erb +1 -0
  37. data/app/views/service_packs_mailer/notify_under_threshold2.html.erb +2 -0
  38. data/app/views/service_packs_mailer/notify_under_threshold2.text.erb +1 -0
  39. data/app/views/service_packs_mailer/used_up_email.html.erb +14 -0
  40. data/app/views/service_packs_mailer/used_up_email.text.erb +1 -0
  41. data/app/workers/expired_sp_worker.rb +11 -0
  42. data/app/workers/used_up_service_pack_job.rb +13 -0
  43. data/config/application.rb +4 -0
  44. data/config/routes.rb +13 -0
  45. data/config/schedule.example.rb +27 -0
  46. data/config/schedule.rb +28 -0
  47. data/config/sidekiq.yml +4 -0
  48. data/db/migrate/20190108031704_create_assigns.rb +11 -0
  49. data/db/migrate/20190108031712_create_service_packs.rb +14 -0
  50. data/db/migrate/20190108111243_create_mapping_rates.rb +11 -0
  51. data/db/migrate/20190113111300_rename_assign.rb +6 -0
  52. data/db/migrate/20190116085528_create_service_pack_entries.rb +9 -0
  53. data/db/migrate/20190121072000_final_assigns_table.rb +5 -0
  54. data/db/migrate/20190123150023_add_service_pack_ref_to_service_pack_entries.rb +5 -0
  55. data/db/migrate/20190123205130_feature_a_done.rb +16 -0
  56. data/db/migrate/20190301070654_change_units_of_service_pack_entries.rb +5 -0
  57. data/db/migrate/20190301071911_change_total_and_remained_units_of_service_pack.rb +6 -0
  58. data/lib/open_project/service_packs.rb +5 -0
  59. data/lib/open_project/service_packs/engine.rb +50 -0
  60. data/lib/open_project/service_packs/patches.rb +4 -0
  61. data/lib/open_project/service_packs/patches/enumeration_patch.rb +37 -0
  62. data/lib/open_project/service_packs/patches/project_patch.rb +14 -0
  63. data/lib/open_project/service_packs/patches/time_entry_activity_patch.rb +16 -0
  64. data/lib/open_project/service_packs/patches/time_entry_patch.rb +89 -0
  65. data/lib/open_project/service_packs/version.rb +5 -0
  66. data/lib/openproject-service_packs.rb +1 -0
  67. metadata +121 -0
@@ -0,0 +1,39 @@
1
+ class ServicePackPresenter
2
+ attr_reader :service_pack
3
+ def initialize(service_pack)
4
+ # we don't take NIL as an option
5
+ if service_pack&.id
6
+ @service_pack = service_pack
7
+ else
8
+ raise 'This is NIL cannot print'
9
+ end
10
+ end
11
+ def json_full_header
12
+ # not recommended in production
13
+ @service_pack.to_json
14
+ end
15
+ def hash_lite_header
16
+ @service_pack.as_json(except: [:id, :threshold1, :threshold2, :updated_at, :created_at])
17
+ end
18
+ def json_lite_header
19
+ hash_lite_header.to_json
20
+ end
21
+ def hash_rate_only
22
+ # ActiveRecord join returns an array!
23
+ q = <<-SQL
24
+ SELECT name, units_per_hour AS upt
25
+ FROM mapping_rates t1
26
+ INNER JOIN #{TimeEntryActivity.table_name} t2
27
+ ON t1.activity_id = t2.id
28
+ WHERE t1.service_pack_id = #{@service_pack.id}
29
+ SQL
30
+ ActiveRecord::Base.connection.exec_query(q).to_hash
31
+ end
32
+ def json_rate_only
33
+ hash_rate_only.to_json
34
+ end
35
+ def json_export(sym=:header)
36
+ err = { :error => 422, :name => "Unsupported format"}
37
+ sym == :header ? json_lite_header : (sym == :rate ? json_rate_only : err.to_json)
38
+ end
39
+ end
@@ -0,0 +1,65 @@
1
+ class ServicePackReport
2
+ attr_reader :service_pack
3
+
4
+ def initialize(service_pack)
5
+ if service_pack&.id
6
+ @service_pack = service_pack
7
+ else
8
+ raise -'This service pack is NIL, cannot report'
9
+ end
10
+ end
11
+
12
+ def query(project = nil)
13
+ sql = <<-SQL
14
+ SELECT t2.spent_on, concat(t4.firstname, ' ', t4.lastname) AS user_name, t3.name AS activity_name,
15
+ t5.id AS work_package_id, t7.name AS type_name, t5.subject AS subject, t2.comments AS comment,
16
+ t1.units AS units, t2.hours AS hours, #{project.nil? ? 't6.name' : "'#{project.name}'"} AS project_name
17
+ FROM service_pack_entries t1
18
+ INNER JOIN #{TimeEntry.table_name} t2
19
+ ON t1.time_entry_id = t2.id
20
+ INNER JOIN #{TimeEntryActivity.table_name} t3
21
+ ON t2.activity_id = t3.id
22
+ INNER JOIN users t4
23
+ ON t2.user_id = t4.id
24
+ #{project.nil? ? 'INNER JOIN projects t6 ON t2.project_id = t6.id' : ''}
25
+ LEFT JOIN #{WorkPackage.table_name} t5
26
+ ON t2.work_package_id = t5.id
27
+ LEFT JOIN types t7
28
+ ON t5.type_id = t7.id
29
+ WHERE service_pack_id = #{@service_pack.id}
30
+ #{project.nil? ? '' : "AND t2.project_id = #{project.id}"}
31
+ ORDER BY spent_on DESC
32
+ SQL
33
+ @entries = ActiveRecord::Base.connection.exec_query(sql).to_hash
34
+ end
35
+
36
+ def csv_extractor
37
+ raise -'Query not run yet' unless @entries
38
+ decimal_separator = I18n.t(:general_csv_decimal_separator)
39
+ export = CSV.generate(col_sep: ';') { |csv|
40
+ headers = [-'Date', -'User', -'Activity', -'Project', -'Work Package', -'Hours', -'Type', -'Subject', -'Units', -'Comments']
41
+ # headers += custom_fields.map(&:name) # not supported
42
+ csv << headers
43
+ @entries.each do |entry|
44
+ fields = [entry[-'spent_on'],
45
+ entry[-'user_name'],
46
+ entry[-'activity_name'],
47
+ entry[-'project_name'],
48
+ entry[-'work_package_id'],
49
+ entry[-'hours'].round(2).to_s.gsub(-'.', decimal_separator),
50
+ entry[-'type_name'],
51
+ entry[-'subject'],
52
+ entry[-'units'].round(0),
53
+ entry[-'comment']
54
+ ]
55
+ # fields += custom_fields.map { |f| show_value(entry.custom_value_for(f)) }
56
+ csv << fields
57
+ end
58
+ }
59
+ end
60
+
61
+ def call(project=nil)
62
+ self.query(project)
63
+ self.csv_extractor
64
+ end
65
+ end
@@ -0,0 +1,13 @@
1
+ module ServicePacksNotification
2
+
3
+ def self.notify_under_threshold1
4
+ # https://blog.arkency.com/2013/12/rails4-preloading/
5
+ service_packs = ServicePack.where('remained_units <= total_units / 100.0 * threshold1').preload(:consuming_projects)
6
+ ServicePack.find_each do |sp|
7
+ sp.consuming_projects.find_each do |project|
8
+ users = User.allowed(:see_assigned_service_packs, project)
9
+ users.each do |user| ServicePacksMailer.notify_under_threshold1(user, sp).deliver_later end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,25 @@
1
+ module SPAssignmentManager
2
+ # Implementation is subject to change.
3
+ def assign_to(service_pack, project)
4
+ # binding.pry
5
+ ActiveRecord::Base.transaction do
6
+ # one query only
7
+ project.assigns.update_all(assigned: false)
8
+ @assignment = service_pack.assigns.find_by(project_id: project.id) || project.assigns.new
9
+ @assignment.assigned = true
10
+ @assignment.assign_date = Date.today
11
+ @assignment.unassign_date = service_pack.expired_date
12
+ @assignment.service_pack_id = service_pack.id if @assignment.new_record?
13
+ @assignment.save!
14
+ end
15
+ end
16
+ def _unassign(project)
17
+ project.assigns.find_by(assigned: true)&.terminate # ruby >= 2.3.0 "safe navigation operator"
18
+ end
19
+ def unassigned?(project)
20
+ !assigned?(project)
21
+ end
22
+ def assigned?(project)
23
+ project.assigns.find_by(assigned: true)
24
+ end
25
+ end
@@ -0,0 +1,5 @@
1
+ class ApplicationMailer < ActionMailer::Base
2
+ default from: 'from@example.com'
3
+ layout 'mailer'
4
+ end
5
+
@@ -0,0 +1,43 @@
1
+ class ServicePacksMailer < ApplicationMailer
2
+ def expired_email(user, service_pack)
3
+ @user = user
4
+ @sp = service_pack
5
+ # binding.pry
6
+
7
+ mail to: @user.mail, subject: "The service pack #{@sp.name} has expired" do |format|
8
+ format.text
9
+ format.html
10
+ end
11
+ end
12
+
13
+ def notify_under_threshold1(user, service_pack)
14
+ @user = user
15
+ @sp = service_pack
16
+ # binding.pry
17
+ mail to: @user.mail, subject: "The service pack #{@sp.name} is running out" do |format|
18
+ format.text
19
+ format.html
20
+ end
21
+ end
22
+
23
+ def notify_under_threshold2(user, service_pack)
24
+ @user = user
25
+ @sp = service_pack
26
+ # binding.pry
27
+ mail to: @user.mail, subject: "The service pack #{@sp.name} is running out" do |format|
28
+ format.text
29
+ format.html
30
+ end
31
+ end
32
+
33
+ def used_up_email(user, service_pack)
34
+ @user = user
35
+ @sp = service_pack
36
+
37
+ mail to: @user.mail, subject: "The service pack #{@sp.name} ran out of units" do |format|
38
+ format.text
39
+ format.html
40
+ end
41
+
42
+ end
43
+ end
@@ -0,0 +1,3 @@
1
+ class ApplicationRecord < ActiveRecord::Base
2
+ self.abstract_class = true
3
+ end
@@ -0,0 +1,13 @@
1
+ class Assign < ApplicationRecord
2
+ belongs_to :service_pack
3
+ belongs_to :project
4
+ scope :active, ->{where("assigned = ? and unassign_date > ?", true, Date.today)}
5
+ def terminate
6
+ self.assigned = false
7
+ self.unassign_date = Date.today
8
+ self.save!
9
+ end
10
+ def overdue?
11
+ service_pack.unavailable?
12
+ end
13
+ end
@@ -0,0 +1,14 @@
1
+ class MappingRate < ApplicationRecord
2
+ # DO NOT ADD :inverse_of TO THIS CLASS - it breaks the SP#edit and #new
3
+ belongs_to :activity, class_name: 'TimeEntryActivity', foreign_key: 'activity_id'
4
+ belongs_to :service_pack
5
+ # validates_uniqueness_of :activity, scope: :service_pack
6
+
7
+ validates_numericality_of :units_per_hour, greater_than_or_equal_to: 0
8
+ validate :only_define_rates_on_shared_activity
9
+
10
+ private
11
+ def only_define_rates_on_shared_activity
12
+ errors.add(:activity, 'invalid') if !activity.shared?
13
+ end
14
+ end
@@ -0,0 +1,180 @@
1
+ # freeze_literal_string: true
2
+ class ServicePack < ApplicationRecord
3
+ before_create :default_remained_units
4
+ after_save :revoke_all_assignments, if: :expired? # should be time-based only.
5
+ after_save :knock_out, if: :used_up?, on: :consumption
6
+
7
+ has_many :assigns, dependent: :destroy
8
+ has_many :active_assignments, -> {where('assigned = ? and unassign_date >= ?', true, Date.today)}, class_name: 'Assign'
9
+ has_many :projects, through: :assigns
10
+ has_many :consuming_projects, through: :active_assignments, source: :project
11
+ has_many :mapping_rates, inverse_of: :service_pack, dependent: :delete_all
12
+ has_many :time_entry_activities, through: :mapping_rates, source: :activity
13
+ has_many :service_pack_entries, inverse_of: :service_pack, dependent: :delete_all
14
+ # :source is the name of association on the "going out" side of the joining table
15
+ # (the "going in" side is taken by this association)
16
+ # example: User has many :pets, Dog is a :pets and has many :breeds. Breeds have ...
17
+ # Rails will look for :dog_breeds by default! (e.g. User.pets.dog_breeds)
18
+ # sauce: https://stackoverflow.com/a/4632472
19
+
20
+ accepts_nested_attributes_for :mapping_rates, allow_destroy: true, reject_if: ->(attributes) {attributes['units_per_hour'].blank?}
21
+
22
+ validates_presence_of :name, :threshold1, :threshold2, :expired_date, :started_date, :total_units
23
+
24
+ validates_uniqueness_of :name, on: :create # SP name never changes
25
+ # https://rubular.com/r/CCtRDRq9jDuMmb
26
+
27
+ validates_format_of :name, with: /\A[^_`~^*\\+=\{\}\|\\;"'<>.\/]+\Z/, message: 'has invalid character(s)'
28
+
29
+ validates_numericality_of :total_units, greater_than: 0
30
+ validates_numericality_of :threshold1, :threshold2, only_integer: true, greater_than: 0
31
+
32
+ validate :threshold2_is_greater_than_threshold1
33
+ validate :end_after_start
34
+ validate :must_not_expire_in_the_past
35
+ validate :threshold1_is_greater_than_total_units
36
+ validate :threshold2_is_greater_than_total_units
37
+
38
+ scope :assignments, -> {joins(:assigns).where(assigned: true)}
39
+ scope :availables, -> {where('remained_units > 0 and expired_date >= ?', Date.today)}
40
+ scope :notifiable, ->(thresno) {where("remained_units <= threshold#{thresno}")}
41
+
42
+ def default_remained_units
43
+ self.remained_units = total_units
44
+ end
45
+
46
+ def revoke_all_assignments
47
+ assignments.where(assigned: true).update_all(assigned: false, unassign_date: Date.today)
48
+ end
49
+
50
+ ### CHECKERS ###
51
+ def expired?
52
+ true if Time.now > expired_date
53
+ end
54
+
55
+ def used_up?
56
+ true if remained_units <= 0
57
+ end
58
+
59
+ def unavailable? # available SP might not be assignable
60
+ used_up? && expired?
61
+ end
62
+
63
+ def available?
64
+ !unavailable?
65
+ end
66
+
67
+ def is_notify?
68
+ # so what is with the two thresholds!?
69
+ dates_to_notify = (expired_date - Date.today).to_i
70
+ dates_to_notify.between?(1, 2)
71
+ end
72
+
73
+ def assigned?
74
+ assigns.where(assigned: true).exists?
75
+ end
76
+
77
+ # def total_unit_updatable?(new_value, old_value = total_units)
78
+ # # old_value=total_units
79
+ # if new_value > old_value
80
+ # true
81
+ # elsif new_value == old_value
82
+ # true
83
+ # elsif new_value < old_value
84
+ # unit_subtract_number = old_value - new_value
85
+ # !(remained_units < unit_subtract_number)
86
+ # end
87
+ # end
88
+
89
+ ### END CHECKERS ###
90
+
91
+ # FOR TESTING ONLY
92
+ def expired_notification # send to the first user in the first record in the DB
93
+ if expired?
94
+ user = User.first
95
+ ServicePacksMailer.expired_email(user, self).deliver_later
96
+ end
97
+ end
98
+
99
+ def cron_send_specific
100
+ # modify the User param
101
+ ServicePacksMailer.expired_email(User.last, ServicePack.first).deliver_later
102
+ end
103
+
104
+ # def self.cron_send_default
105
+ # # modify the User param
106
+ # ServicePack.find_each do |sp|
107
+ # ExpiredSpMailer.expired_email(User.last, sp).deliver_now
108
+ # end
109
+ # end
110
+ # END TESTING ONLY
111
+
112
+ def assignments
113
+ assigns.where(assigned: true)
114
+ end
115
+
116
+ def grant(units)
117
+ self.total_units += units
118
+ self.remained_units += units
119
+ self
120
+ end
121
+
122
+ ### START CRON JOBS ###
123
+ # modify User param first
124
+ # deliver_later doesn't work
125
+
126
+ def self.check_expired_sp
127
+ ServicePack.find_each do |sp|
128
+ ServicePacksMailer.expired_email(User.last, sp).deliver_now if sp.expired?
129
+ end
130
+ end
131
+
132
+ # will be replaced
133
+ # # notify immediately at entries
134
+ # def self.check_used_up
135
+ # ServicePack.find_each do |sp|
136
+ # ServicePacksMailer.used_up_email(User.last, sp).deliver_now if sp.used_up?
137
+ # end
138
+ # end
139
+
140
+ def self.check_threshold1
141
+ ServicePack.notifiable(1).find_each do |sp|
142
+ ServicePacksMailer.notify_under_threshold1(User.last, sp).deliver_now
143
+ end
144
+ end
145
+
146
+ def self.check_threshold2
147
+ ServicePack.notifiable(2).find_each do |sp|
148
+ ServicePacksMailer.notify_under_threshold2(User.last, sp).deliver_now
149
+ end
150
+ end
151
+
152
+ ### END CRON JOBS ###
153
+
154
+ private
155
+
156
+ def threshold1_is_greater_than_total_units
157
+ @errors.add(:threshold1, 'must be smaller than total units') if threshold1 >= total_units
158
+ end
159
+
160
+ def threshold2_is_greater_than_total_units
161
+ @errors.add(:threshold2, 'must be smaller than total units') if threshold2 >= total_units
162
+ end
163
+
164
+ def threshold2_is_greater_than_threshold1
165
+ @errors.add(:threshold2, 'must be less than threshold 1') if threshold2 >= threshold1
166
+ end
167
+
168
+ def end_after_start
169
+ @errors.add(:expired_date, 'must be after start date') if expired_date < started_date
170
+ end
171
+
172
+ def must_not_expire_in_the_past
173
+ @errors.add(:expired_date, 'must not be in the past') if expired_date < Date.today
174
+ end
175
+
176
+ def knock_out
177
+ revoke_all_assignments
178
+ Delayed::Job.enqueue UsedUpServicePackJob.new(self)
179
+ end
180
+ end
@@ -0,0 +1,6 @@
1
+ class ServicePackEntry < ApplicationRecord
2
+ belongs_to :time_entry
3
+ # dependent is for THIS association.
4
+ belongs_to :service_pack, inverse_of: :service_pack_entries
5
+ end
6
+
@@ -0,0 +1,14 @@
1
+ <%= stylesheet_link_tag 'assigns', plugin: :openproject_service_packs %>
2
+
3
+ <div id="sp-display">
4
+ <h2>Service Pack <%= @service_pack.name %> already assigned to <%= @project.name %></h2><hr/>
5
+ <strong>Assignment Date</strong>: <%= @assignment.assign_date %>
6
+ <strong>Valid Until</strong>: <%= @assignment.unassign_date %>
7
+ <strong>Remained:</strong> <%= @service_pack.remained_units %>/<%= @service_pack.total_units %>
8
+ </div>
9
+ <br/>
10
+ <% if @can_unassign ||= User.current.allowed_to?(:unassign_service_packs, @project) %>
11
+ <%= link_to -'Unassign', assigns_unassign_path(@project), method: :post, class: -'button norm', data: { confirm: -'Are you sure?' }%>
12
+ <% end %>
13
+ <hr/>
14
+ <p class='other-formats'>Report available in: <%= link_to -'CSV', {action: :report, format: :csv} %></p>
@@ -0,0 +1,19 @@
1
+ <%= javascript_include_tag 'assigns.js', plugin: :openproject_service_packs %>
2
+
3
+ <div id="sp-assign">
4
+ <h2>No Service Pack is assigned to this project.</h2>
5
+ <br/>
6
+
7
+ <%= form_with model: @assignment, url: assigns_assign_path(@project), local: true, method: :post do |f| %>
8
+ Choose a Service Pack to apply:
9
+ <%= f.select :service_pack_id, {},{},{id: "select-sp"} do %>
10
+ <option selected>--Pick one Service Pack--</option>
11
+ <% @assignables.each do |assignable| %>
12
+ <%= content_tag(:option, assignable.name, value: assignable.id, data: {start: assignable.started_date.to_s, end: assignable.expired_date.to_s, cap: assignable.total_units, rem: assignable.remained_units}) %>
13
+ <% end %>
14
+ <% end %>
15
+ <br/>
16
+ <%= f.submit -'Assign a Service Pack', class: -'button -alt-highlight', id: 'sp-assign-button' %>
17
+ <% end %>
18
+ </div>
19
+ <div id="sp-content"></div>