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,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 527fadb4ab0d898c0b42fab884ceb22b11244060c7599fed1d212fcd67c095d4
4
+ data.tar.gz: d19e0f5302fe898a109df7c6537e970ab2d6f431eeeca4d3cec1f0f6fd929e00
5
+ SHA512:
6
+ metadata.gz: 403086129a426fd15ad56843e4a9f74adde12177b7da7149599831cfe0abb5474ccfa2885560701979b739dbf069ca0f75971ccfd4aa4249a4417c048fa1cc67
7
+ data.tar.gz: 1358d495404e2159a6577012ebebcec5845ac2e5dab1b3de889798382ef56e4b118ea61ff32d2d27d649ba047070b2e895a343990902749bbac94f3af7816790
@@ -0,0 +1,19 @@
1
+ <!---- copyright
2
+ OpenProject Plugins Plugin
3
+
4
+ Copyright (C) 2013 - 2014 the OpenProject Foundation (OPF)
5
+
6
+ This program is free software; you can redistribute it and/or
7
+ modify it under the terms of the GNU General Public License version 3.
8
+
9
+ You should have received a copy of the GNU General Public License
10
+ along with this program; if not, write to the Free Software
11
+ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
12
+
13
+ See doc/COPYRIGHT.md for more details.
14
+
15
+ ++-->
16
+
17
+ # Changelog
18
+
19
+ * `#<ticket number>` Create plugin
@@ -0,0 +1,4 @@
1
+ OpenProject Service Packs Plugin
2
+ ================================
3
+ ## A R&D project
4
+ A plugin tracks its units through logging time work packages basing on definable rates of Time Entry Activities.
@@ -0,0 +1,20 @@
1
+ //use <% javascript_include_tag %>
2
+ function loadServicePack() {
3
+ if (this.selectedIndex == 0) {
4
+ document.querySelector("#sp-content").innerHTML = "Please select a Service Pack";
5
+ document.querySelector("#sp-assign-button").disabled = true;
6
+ return;
7
+ }
8
+ var comp = this.options[this.selectedIndex];
9
+ var str = "Activation Date: " + comp.dataset.start + "<br/>";
10
+ str += "Expiration Date: " + comp.dataset.end + "<br/>";
11
+ str += "Capacity: " + comp.dataset.cap + "<br/>";
12
+ str += "Remained: " + comp.dataset.rem + "<br/>";
13
+ // click for more
14
+ document.querySelector("#sp-content").innerHTML = str;
15
+ document.querySelector("#sp-assign-button").disabled = false;
16
+ }
17
+ document.addEventListener("DOMContentLoaded", function(event) {
18
+ document.querySelector("#select-sp").addEventListener("change", loadServicePack);
19
+ document.querySelector("#sp-assign-button").disabled = true;
20
+ })
@@ -0,0 +1,17 @@
1
+ document.addEventListener("DOMContentLoaded", function(event) {
2
+ document.getElementById("view-stat").addEventListener("click", function(){
3
+ var xhr = new XMLHttpRequest();
4
+ // replace the link with statistics link
5
+ xhr.open('GET', 'http://localhost:3000/service_packs/1.json');
6
+ xhr.setRequestHeader('Content-Type', 'application/json');
7
+ xhr.onload = function() {
8
+ if (xhr.status === 200) {
9
+ console.log(JSON.parse(xhr.responseText))
10
+ }
11
+ else {
12
+ alert('Request failed. Returned status of ' + xhr.status);
13
+ }
14
+ };
15
+ xhr.send();
16
+ });
17
+ })
@@ -0,0 +1,9 @@
1
+ a.button.norm {
2
+ background-color: #1a67a3 !important;
3
+ color: #ffffff !important;
4
+ margin-bottom: 0;
5
+ }
6
+ #sp-display h2 {
7
+ padding-bottom: 0px;
8
+ font-size: 1.25em;
9
+ }
@@ -0,0 +1,132 @@
1
+ label, input {
2
+ margin-bottom: 0 !important;
3
+ max-width: 500px !important;
4
+ }
5
+
6
+ input.rates {
7
+ max-width: 465px !important;
8
+ }
9
+
10
+ table {
11
+ font-family: arial, sans-serif;
12
+ border-collapse: collapse;
13
+ width: 100%;
14
+ }
15
+
16
+ td, th {
17
+ border: 1px solid #dddddd;
18
+ text-align: left;
19
+ padding: 8px;
20
+ }
21
+
22
+ .destroy {
23
+ background-color: #fc0000 !important;
24
+ color: #ffeeee !important;
25
+ }
26
+
27
+ a.blocked {
28
+ opacity: 0.3;
29
+ margin-right: 8px;
30
+ cursor: default !important;
31
+ margin-left: -5px;
32
+ }
33
+
34
+ #warnbox {
35
+ font-size: 0.75em;
36
+ text-align: center;
37
+ position: relative;
38
+ bottom: 7px;
39
+ right: 2px;
40
+ }
41
+
42
+ hr {
43
+ margin-top: 12px;
44
+ }
45
+
46
+ .button.-highlight.-edit {
47
+ margin-bottom: 0;
48
+ }
49
+
50
+ .button.-alt-highlight.-new {
51
+ margin-bottom: 0;
52
+ }
53
+
54
+ .button-container {
55
+ margin-bottom: 10px;
56
+ }
57
+
58
+ #rates-input {
59
+ padding-left: 30px;
60
+ padding-bottom: 20px;
61
+ overflow-x: auto;
62
+ }
63
+
64
+ #table-rates-input {
65
+ width: 40%;
66
+ }
67
+
68
+ table#table-rates-input td {
69
+ border: 0px;
70
+ }
71
+
72
+ table#table-rates-input th {
73
+ border: 0px;
74
+ }
75
+
76
+ #error-explanation {
77
+ background-color: rgba(255, 255, 0, 0.41);
78
+ }
79
+
80
+ #error-explanation h2 {
81
+ color: #fc0000;
82
+ }
83
+
84
+
85
+ #detailed-sp {
86
+ position: relative;
87
+ right: 15px;
88
+ }
89
+
90
+ #block-left {
91
+ float: left;
92
+ width: 47%;
93
+ height: auto;
94
+ }
95
+
96
+ #block-right {
97
+ float: right;
98
+ width: 47%;
99
+ height: auto;
100
+ }
101
+
102
+ @media screen and (max-width: 860px) {
103
+ #block-left, #block-right {
104
+ width: 95%;
105
+ float: none;
106
+ height: auto;
107
+ }
108
+ }
109
+
110
+ .sp {
111
+ border: 1.5px solid;
112
+ border-color: #EAEAEA;
113
+ min-height: 200px;
114
+ margin: 0 20px 20px 20px;
115
+ padding: 20px 0 20px 20px;
116
+ box-sizing: border-box;
117
+ }
118
+
119
+ .sp.description {
120
+ float: left;
121
+ width: 100%;
122
+ line-height: 1.8em;
123
+ }
124
+
125
+ .sp.rates, .sp.assigned {
126
+ width: 100%;
127
+ height: auto;
128
+ }
129
+
130
+ .clearfix {
131
+ clear: both;
132
+ }
@@ -0,0 +1,157 @@
1
+ class AssignsController < ApplicationController
2
+ #layout 'admin'
3
+ before_action :find_project_by_project_id
4
+ include SPAssignmentManager
5
+
6
+ def assign
7
+ # binding.pry
8
+ return head 403 unless @can_assign = User.current.allowed_to?(:assign_service_packs, @project)
9
+
10
+ if assigned?(@project)
11
+ flash.now[:alert] = "You must unassign first!"
12
+ render_400 and return
13
+ end
14
+ @service_pack = ServicePack.find_by(id: params[:assign][:service_pack_id])
15
+ if @service_pack.nil?
16
+ flash.now[:alert] = "Service Pack not found"
17
+ render_404 and return
18
+ end
19
+ if @service_pack.available?
20
+ # binding.pry
21
+ assign_to(@service_pack, @project)
22
+ flash.now[:notice] = "Service Pack '#{@service_pack.name}' successfully assigned to project '#{@project.name}'"
23
+ render 'already_assigned' and return
24
+ else
25
+ # already assigned for another project
26
+ # constraint need
27
+ flash.now[:alert] = "Service Pack '#{@service_pack.name}' has been already assigned"
28
+ render_400 and return
29
+ end
30
+ flash.now[:alert] = 'Service Pack cannot be assigned'
31
+ redirect_to action: :show
32
+ end
33
+
34
+ def unassign
35
+ return head 403 unless @can_unassign = User.current.allowed_to?(:unassign_service_packs, @project)
36
+
37
+ if unassigned?(@project)
38
+ flash[:alert] = 'No Service Pack is assigned to this project'
39
+ render_404 and return
40
+ end
41
+ _unassign(@project)
42
+ flash[:notice] = 'Unassigned a Service Pack from this project'
43
+ redirect_to action: :show and return
44
+ end
45
+
46
+ def show
47
+ # This will lock even admins out if the module is not activated.
48
+ return head 403 unless
49
+ User.current.allowed_to?(:see_assigned_service_packs, @project) ||
50
+ (@can_assign = User.current.allowed_to?(:assign_service_packs, @project)) ||
51
+ (@can_unassign = User.current.allowed_to?(:unassign_service_packs, @project))
52
+ # binding.pry
53
+ if @assignment = @project.assigns.find_by(assigned: true)
54
+ if @assignment.service_pack.unavailable?
55
+ @assignment.terminate
56
+ @assignment = nil # signifying no assignments are in effect
57
+ # as the single one is terminated.
58
+ end
59
+ end
60
+ # binding.pry
61
+ if @assignment.nil?
62
+ if @can_assign ||= User.current.allowed_to?(:assign_service_packs, @project)
63
+ @assignables = ServicePack.availables
64
+ if @assignables.exists?
65
+ @assignment = Assign.new
66
+ render -'not_assigned_yet' and return
67
+ end
68
+ end
69
+ render -'unassignable'
70
+ # binding.pry
71
+ else
72
+ @service_pack = @assignment.service_pack
73
+ render -'already_assigned'
74
+ end
75
+ end
76
+
77
+ def select_to_transfer
78
+ return head 403 unless @can_assign = User.current.allowed_to?(:assign_service_packs, @project)
79
+ end
80
+
81
+ def report
82
+ return head 403 unless User.current.allowed_to?(:see_assigned_service_packs, @project)
83
+ if assignment = assigned?(@project)
84
+ render csv: ServicePackReport.new(assignment.service_pack).call(@project), filename: "ServicePackReport_#{@project.name.gsub(/\s+/, -'_')}.csv"
85
+ else
86
+ render_404
87
+ end
88
+ end
89
+
90
+ # =======================================================
91
+ # :Docs
92
+ # * Limit: Serving JSON only. Intended to restrict to :show.
93
+ # * Purpose:
94
+ # Return a table with consumed units by a Project grouped by service
95
+ # pack, then activities and sorted from large to small.
96
+ # * Expected Inputs:
97
+ # [project_id]: Sharing the same route with the resourceful default.
98
+ # Put in the link. Mandatory.
99
+ # [start_period]: Beginning of the counting period. As a date. Optional.
100
+ # [end_period]: Ending of the counting period. As a date. Optional.
101
+ # start_period MUST NOT be later than end_period.
102
+ # Both or none of [start_period, end_period] can be present.
103
+ # * Expected Outputs
104
+ # Top class: None
105
+ # Content: Array of object having [name, act_name, consumed]
106
+ # - Name: Name of Service Pack
107
+ # - act_name: Name of activity
108
+ # - consumed: How many units are consumed (in given period)
109
+ # Status: 200
110
+ # * When raising error
111
+ # HTTP 400: Malformed request.
112
+ # =======================================================
113
+
114
+ def statistics
115
+ return head 403 unless
116
+ User.current.allowed_to?(:see_assigned_service_packs, @project) ||
117
+ User.current.allowed_to?(:assign_service_packs, @project) ||
118
+ User.current.allowed_to?(:unassign_service_packs, @project)
119
+
120
+ start_day = params[:start_period]&.to_date # ruby >= 2.3.0
121
+ end_day = params[:end_period]&.to_date
122
+ if start_day.nil? ^ end_day.nil?
123
+ render json: { error: 'GET OUT!'}, status: 400 and return
124
+ end
125
+
126
+ # Notice: Change max(t4.name) to ANY_VALUE(t4.name) on production builds.
127
+ # MySQL specific >= 5.7.5
128
+ # https://dev.mysql.com/doc/refman/5.7/en/group-by-handling.html
129
+
130
+ get_parent_id = <<-SQL
131
+ SELECT id, name, COALESCE(parent_id, id) AS pid
132
+ FROM #{TimeEntryActivity.table_name}
133
+ WHERE type = 'TimeEntryActivity'
134
+ SQL
135
+ body_query = <<-SQL
136
+ SELECT t1.service_pack_id AS spid, max(t4.name) AS name, t3.pid AS pid,
137
+ max(t3.name) AS act_name, sum(t1.units) AS consumed
138
+ FROM #{ServicePackEntry.table_name} t1
139
+ INNER JOIN #{TimeEntry.table_name} t2
140
+ ON t1.time_entry_id = t2.id
141
+ INNER JOIN (#{get_parent_id}) t3
142
+ ON t2.activity_id = t3.id
143
+ INNER JOIN #{ServicePack.table_name} t4
144
+ ON t1.service_pack_id = t4.id
145
+ SQL
146
+ group_clause = <<-SQL
147
+ GROUP BY t1.service_pack_id, t3.pid
148
+ ORDER BY consumed DESC
149
+ SQL
150
+ where_clause = "WHERE t2.project_id = ?"
151
+ where_clause << (start_day.nil? ? '' : ' AND t1.created_at BETWEEN ? AND ?')
152
+ query = body_query + where_clause + group_clause
153
+ par = start_day.nil? ? [query, params[@project.id]] : [query, params[@project.id], start_day, end_day]
154
+ sql = ActiveRecord::Base.send(:sanitize_sql_array, par)
155
+ render json: ActiveRecord::Base.connection.exec_query(sql).to_hash, status: 200
156
+ end
157
+ end
@@ -0,0 +1,219 @@
1
+ class ServicePacksController < ApplicationController
2
+ # only allow admin
3
+ before_action :require_admin
4
+
5
+ # Specifying Layouts for Controllers, looking at OPENPROJECT_ROOT/app/views/layouts/admin
6
+ layout 'admin'
7
+
8
+ def index
9
+ @service_packs = ServicePack.all
10
+ # for demo
11
+ #ServicePacksMailer.notify_under_threshold1(User.first,@service_packs.first).deliver_now
12
+ end
13
+
14
+ def new
15
+ @service_pack = ServicePack.new
16
+ # TimeEntryActivity.shared.count.times {@service_pack.mapping_rates.build}
17
+ @sh = TimeEntryActivity.shared
18
+ @c = TimeEntryActivity.shared.count
19
+ end
20
+
21
+ def show
22
+ @service_pack = ServicePack.find(params[:id])
23
+ # controller chooses not to get the thresholds.
24
+ # assume the service pack exists.
25
+ # TODO: make a separate action JSON only.
26
+ respond_to do |format|
27
+ format.json {
28
+ # the function already converted this to json
29
+ render plain: ServicePackPresenter.new(@service_pack).json_export(:rate), status: 200
30
+ }
31
+ format.html {
32
+ # http://www.chrisrolle.com/en/blog/benchmark-preload-vs-eager_load
33
+ @rates = @service_pack.mapping_rates.preload(:activity)
34
+ @assignments = @service_pack.assignments.preload(:project)
35
+ }
36
+ format.csv {
37
+ render csv: ServicePackReport.new(@service_pack).call, filename: "service_pack_#{@service_pack.name}.csv"
38
+ }
39
+ end
40
+ end
41
+
42
+ # The string with the minus sign in front is a shorthand for <string>.freeze
43
+ # reducing server processing time (and testing time) by 30%!
44
+ # Freezing a string literal will stop it from being created anew over and over.
45
+ # All literal strings will be frozen in Ruby 3 by default, which is a good idea.
46
+
47
+ def create
48
+ mapping_rate_attribute = params[:service_pack][:mapping_rates_attributes]
49
+ # binding.pry
50
+ activity_id = []
51
+ mapping_rate_attribute.each {|_index, hash_value| activity_id.push(hash_value[:activity_id])}
52
+
53
+ if activity_id.uniq.length == activity_id.length
54
+ @service_pack = ServicePack.new(service_pack_params)
55
+ # render plain: 'not duplicated'
56
+ if @service_pack.save
57
+ flash[:notice] = -'Service Pack creation successful.'
58
+ redirect_to action: :show, id: @service_pack.id and return
59
+ else
60
+ flash.now[:error] = -'Service Pack creation failed.'
61
+ end
62
+ else
63
+ # render plain: 'duplicated'
64
+ flash.now[:error] = -'Only one rate can be defined to one activity.'
65
+ end
66
+ # the only successful path has returned 10 lines ago.
67
+ @sh = TimeEntryActivity.shared
68
+ @c = TimeEntryActivity.shared.count
69
+ render 'new'
70
+ end
71
+
72
+ def edit
73
+ @sp = ServicePack.find_by(id: params[:id])
74
+ if @sp.nil?
75
+ flash[:error] = -"Service Pack not found"
76
+ redirect_to action: :index and return
77
+ end
78
+ # @activity = @sp.time_entry_activities.build
79
+ end
80
+
81
+ def update
82
+ @sp = ServicePack.find_by(id: params[:id])
83
+ if @sp.nil?
84
+ flash[:error] = -"Service Pack not found"
85
+ redirect_to action: :index and return
86
+ end
87
+
88
+ mapping_rate_attribute = params[:service_pack][:mapping_rates_attributes]
89
+ activity_id = []
90
+ mapping_rate_attribute.each {|_index, hash_value| activity_id.push(hash_value[:activity_id])}
91
+
92
+ if activity_id.uniq.length == activity_id.length
93
+ # No duplication
94
+ add_units
95
+ @sp.assign_attributes(service_pack_edit_params)
96
+ # binding.pry
97
+ if @sp.save
98
+ flash[:notice] = -'Service Pack update successful.'
99
+ redirect_to @sp
100
+ else
101
+ flash.now[:error] = -'Service Pack update failed.'
102
+ render -'edit'
103
+ end
104
+ else
105
+ # Duplication
106
+ flash.now[:error] = -'Only one rate can be defined to one activity.'
107
+ render -'edit'
108
+ end
109
+ end
110
+
111
+ def destroy
112
+ @sp = ServicePack.find_by(id: params[:id])
113
+ if @sp.nil?
114
+ flash[:error] = -"Service Pack not found"
115
+ redirect_to action: :index and return
116
+ end
117
+ if @sp.assigned?
118
+ flash.now[:error] = "Please unassign this SP from all projects before proceeding!"
119
+ redirect_to @sp
120
+ end
121
+ @sp.destroy!
122
+
123
+ redirect_to service_packs_path
124
+ end
125
+
126
+ # for breadcrumb code
127
+ def show_local_breadcrumb
128
+ true
129
+ end
130
+
131
+ def default_breadcrumb
132
+ action_name == 'index' ? -'Service Packs' : ActionController::Base.helpers.link_to(-'Service Packs', service_packs_path)
133
+ end
134
+
135
+ # =======================================================
136
+ # :Docs
137
+ # * Limit: Serving JSON only. Must be Admin to access.
138
+ # * Purpose:
139
+ # Return a table with consumed units for a Service Pack grouped by activities and sorted
140
+ # from large to small.
141
+ # * Expected Inputs:
142
+ # [service_pack_id]: Sharing the same route with the resourceful default.
143
+ # Put in the link. Mandatory.
144
+ # [start_period]: Beginning of the counting period. As a date. Optional.
145
+ # [end_period]: Ending of the counting period. As a date. Optional.
146
+ # start_period MUST NOT be later than end_period.
147
+ # Both or none of [start_period, end_period] can be present.
148
+ # * Expected Outputs
149
+ # Top class: None
150
+ # Content: Array of object having [name, consumed]
151
+ # - consumed: How many units are consumed (in given period)
152
+ # - act_name: Name of activity
153
+ # Status: 200
154
+ # * When raising error
155
+ # HTTP 404: SP not found
156
+ # HTTP 400: Malformed request.
157
+ # =======================================================
158
+
159
+ def statistics
160
+ start_day = params[:start_period]&.to_date # ruby >= 2.3.0
161
+ end_day = params[:end_period]&.to_date
162
+ if start_day.nil? ^ end_day.nil?
163
+ render json: {error: 'GET OUT!'}, status: 400 and return
164
+ end
165
+ if !ServicePack.find_by(id: params[:service_pack_id])
166
+ render json: {error: 'NOT FOUND'}, status: 404 and return
167
+ end
168
+
169
+ # Notice: Change max(t3.name) to ANY_VALUE(t3.name) on production builds.
170
+ # MySQL specific >= 5.7.5
171
+ # https://dev.mysql.com/doc/refman/5.7/en/group-by-handling.html
172
+
173
+ get_parent_id = <<-SQL
174
+ SELECT id, name, COALESCE(parent_id, id) AS pid
175
+ FROM #{TimeEntryActivity.table_name}
176
+ WHERE type = 'TimeEntryActivity'
177
+ SQL
178
+ body_query = <<-SQL
179
+ SELECT t3.pid AS act_id, max(t3.name) AS act_name, sum(t1.units) AS consumed
180
+ FROM #{ServicePackEntry.table_name} t1
181
+ INNER JOIN #{TimeEntry.table_name} t2
182
+ ON t1.time_entry_id = t2.id
183
+ INNER JOIN (#{get_parent_id}) t3
184
+ ON t2.activity_id = t3.id
185
+ SQL
186
+ group_clause = <<-SQL
187
+ GROUP BY t3.pid
188
+ ORDER BY consumed DESC
189
+ SQL
190
+ where_clause = "WHERE t1.service_pack_id = ?"
191
+ where_clause << (start_day.nil? ? '' : ' AND t1.created_at BETWEEN ? AND ?')
192
+ query = body_query + where_clause + group_clause
193
+ # binding.pry
194
+ par = start_day.nil? ? [query, params[:service_pack_id]] : [query, params[:service_pack_id], start_day, end_day]
195
+ sql = ActiveRecord::Base.send(:sanitize_sql_array, par)
196
+ render json: ActiveRecord::Base.connection.exec_query(sql).to_hash, status: 200
197
+ end
198
+
199
+ private
200
+
201
+ def service_pack_params
202
+ params.require(:service_pack).permit(:name, :total_units, :started_date, :expired_date, :threshold1, :threshold2,
203
+ mapping_rates_attributes: [:id, :activity_id, :service_pack_id, :units_per_hour, :_destroy])
204
+ end
205
+
206
+ def service_pack_edit_params
207
+ params.require(:service_pack).permit(:total_units, :threshold1, :threshold2,
208
+ mapping_rates_attributes: [:id, :activity_id, :service_pack_id, :units_per_hour, :_destroy])
209
+ end
210
+
211
+ def add_units
212
+ return unless params[:service_pack][:total_units]
213
+ if (t = params[:service_pack][:total_units].to_f) <= 0.0
214
+ @sp.errors.add(:total_units, 'is invalid') and return
215
+ end
216
+ @sp.grant(t - @sp.total_units) unless t == @sp.total_units
217
+ end
218
+
219
+ end