Commit 94a13e5c by Hoang Phuc Do

Merge branch 'feature/plugin-workflow-report' into 'master'

REDMINE: workflow report plugin

See merge request !8
parents 8636b97c 34333349
Pipeline #1591 failed with stages
in 0 seconds
...@@ -14,7 +14,7 @@ gem "protected_attributes" ...@@ -14,7 +14,7 @@ gem "protected_attributes"
gem "actionpack-xml_parser" gem "actionpack-xml_parser"
gem "roadie-rails", "~> 1.1.1" gem "roadie-rails", "~> 1.1.1"
gem "roadie", "~> 3.2.1" gem "roadie", "~> 3.2.1"
gem "mimemagic" gem 'mimemagic', '~> 0.4.3'
gem "nokogiri", (RUBY_VERSION >= "2.1" ? "~> 1.7.2" : "~> 1.6.8") gem "nokogiri", (RUBY_VERSION >= "2.1" ? "~> 1.7.2" : "~> 1.6.8")
gem "i18n", "~> 0.7.0" gem "i18n", "~> 0.7.0"
......
...@@ -40,6 +40,7 @@ GEM ...@@ -40,6 +40,7 @@ GEM
addressable (2.5.2) addressable (2.5.2)
public_suffix (>= 2.0.2, < 4.0) public_suffix (>= 2.0.2, < 4.0)
arel (6.0.4) arel (6.0.4)
ast (2.4.2)
builder (3.2.3) builder (3.2.3)
byebug (9.1.0) byebug (9.1.0)
capybara (1.1.4) capybara (1.1.4)
...@@ -56,17 +57,30 @@ GEM ...@@ -56,17 +57,30 @@ GEM
crass (1.0.3) crass (1.0.3)
css_parser (1.6.0) css_parser (1.6.0)
addressable addressable
descendants_tracker (0.0.4)
thread_safe (~> 0.3, >= 0.3.1)
docile (1.1.5) docile (1.1.5)
erubis (2.7.0) erubis (2.7.0)
faraday (0.17.6)
multipart-post (>= 1.2, < 3)
ffi (1.9.18) ffi (1.9.18)
github_api (0.19.0)
addressable (~> 2.4)
descendants_tracker (~> 0.0.4)
faraday (>= 0.8, < 2)
hashie (~> 3.5, >= 3.5.2)
oauth2 (~> 1.0)
globalid (0.4.1) globalid (0.4.1)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
hashie (3.6.0)
htmlentities (4.3.4) htmlentities (4.3.4)
httpclient (2.8.3) httpclient (2.8.3)
i18n (0.7.0) i18n (0.7.0)
jaro_winkler (1.5.4)
jquery-rails (3.1.4) jquery-rails (3.1.4)
railties (>= 3.0, < 5.0) railties (>= 3.0, < 5.0)
thor (>= 0.14, < 2.0) thor (>= 0.14, < 2.0)
jwt (2.3.0)
liquid (2.6.3) liquid (2.6.3)
loofah (2.1.1) loofah (2.1.1)
crass (~> 1.0.2) crass (~> 1.0.2)
...@@ -77,17 +91,30 @@ GEM ...@@ -77,17 +91,30 @@ GEM
mime-types (3.1) mime-types (3.1)
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2016.0521) mime-types-data (3.2016.0521)
mimemagic (0.3.2) mimemagic (0.4.3)
nokogiri (~> 1)
rake
mini_mime (1.0.0) mini_mime (1.0.0)
mini_portile2 (2.1.0) mini_portile2 (2.1.0)
minitest (5.11.1) minitest (5.11.1)
mocha (1.3.0) mocha (1.3.0)
metaclass (~> 0.0.1) metaclass (~> 0.0.1)
multi_json (1.13.0) multi_json (1.13.0)
multi_xml (0.6.0)
multipart-post (2.3.0)
mysql2 (0.4.10) mysql2 (0.4.10)
net-ldap (0.12.1) net-ldap (0.12.1)
nokogiri (1.7.2) nokogiri (1.7.2)
mini_portile2 (~> 2.1.0) mini_portile2 (~> 2.1.0)
oauth2 (1.4.11)
faraday (>= 0.17.3, < 3.0)
jwt (>= 1.0, < 3.0)
multi_json (~> 1.3)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
parallel (1.19.2)
parser (3.2.2.0)
ast (~> 2.4.1)
protected_attributes (1.1.4) protected_attributes (1.1.4)
activemodel (>= 4.0.1, < 5.0) activemodel (>= 4.0.1, < 5.0)
public_suffix (3.0.1) public_suffix (3.0.1)
...@@ -121,6 +148,7 @@ GEM ...@@ -121,6 +148,7 @@ GEM
activesupport (= 4.2.8) activesupport (= 4.2.8)
rake (>= 0.8.7) rake (>= 0.8.7)
thor (>= 0.18.1, < 2.0) thor (>= 0.18.1, < 2.0)
rainbow (3.1.1)
rake (12.3.0) rake (12.3.0)
rbpdf (1.19.3) rbpdf (1.19.3)
htmlentities htmlentities
...@@ -135,6 +163,7 @@ GEM ...@@ -135,6 +163,7 @@ GEM
actionpack (>= 4.2, < 6) actionpack (>= 4.2, < 6)
rails (>= 4.2, < 6) rails (>= 4.2, < 6)
request_store (1.0.5) request_store (1.0.5)
rexml (3.2.5)
rmagick (2.16.0) rmagick (2.16.0)
roadie (3.2.2) roadie (3.2.2)
css_parser (~> 1.4) css_parser (~> 1.4)
...@@ -142,7 +171,18 @@ GEM ...@@ -142,7 +171,18 @@ GEM
roadie-rails (1.1.1) roadie-rails (1.1.1)
railties (>= 3.0, < 5.1) railties (>= 3.0, < 5.1)
roadie (~> 3.1) roadie (~> 3.1)
rubocop (0.81.0)
jaro_winkler (~> 1.5.1)
parallel (~> 1.10)
parser (>= 2.7.0.1)
rainbow (>= 2.2.2, < 4.0)
rexml
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 2.0)
rubocop-performance (1.5.2)
rubocop (>= 0.71.0)
ruby-openid (2.3.0) ruby-openid (2.3.0)
ruby-progressbar (1.13.0)
rubyzip (1.2.1) rubyzip (1.2.1)
selenium-webdriver (2.53.4) selenium-webdriver (2.53.4)
childprocess (~> 0.5) childprocess (~> 0.5)
...@@ -153,6 +193,13 @@ GEM ...@@ -153,6 +193,13 @@ GEM
multi_json (~> 1.0) multi_json (~> 1.0)
simplecov-html (~> 0.9.0) simplecov-html (~> 0.9.0)
simplecov-html (0.9.0) simplecov-html (0.9.0)
slim (4.1.0)
temple (>= 0.7.6, < 0.9)
tilt (>= 2.0.6, < 2.1)
slim-rails (3.6.2)
actionpack (>= 3.1)
railties (>= 3.1)
slim (>= 3.0, < 6.0, != 5.0.0)
sprockets (3.7.1) sprockets (3.7.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
rack (> 1, < 3) rack (> 1, < 3)
...@@ -160,12 +207,18 @@ GEM ...@@ -160,12 +207,18 @@ GEM
actionpack (>= 4.0) actionpack (>= 4.0)
activesupport (>= 4.0) activesupport (>= 4.0)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
temple (0.8.2)
test_after_commit (0.4.2) test_after_commit (0.4.2)
activerecord (>= 3.2) activerecord (>= 3.2)
thor (0.20.0) thor (0.20.0)
thread_safe (0.3.6) thread_safe (0.3.6)
tilt (2.0.11)
tzinfo (1.2.4) tzinfo (1.2.4)
thread_safe (~> 0.1) thread_safe (~> 0.1)
unicode-display_width (1.8.0)
validate_url (1.0.15)
activemodel (>= 3.0.0)
public_suffix
websocket (1.2.5) websocket (1.2.5)
xpath (0.1.4) xpath (0.1.4)
nokogiri (~> 1.3) nokogiri (~> 1.3)
...@@ -176,19 +229,25 @@ PLATFORMS ...@@ -176,19 +229,25 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
actionpack-xml_parser actionpack-xml_parser
addressable (~> 2.5, >= 2.5.2)
byebug (~> 9.0, >= 9.0.6) byebug (~> 9.0, >= 9.0.6)
capybara capybara
coderay (~> 1.1.1) coderay (~> 1.1.1)
descendants_tracker (~> 0.0.4)
faraday (~> 0.17.6)
github_api (~> 0.19.0)
hashie (~> 3.5, >= 3.5.7)
httpclient httpclient
i18n (~> 0.7.0) i18n (~> 0.7.0)
jquery-rails (~> 3.1.4) jquery-rails (~> 3.1.4)
mime-types (~> 3.0) mime-types (~> 3.0)
mimemagic mimemagic (~> 0.4.3)
minitest minitest
mocha mocha
mysql2 (~> 0.4.6) mysql2 (~> 0.4.6)
net-ldap (~> 0.12.0) net-ldap (~> 0.12.0)
nokogiri (~> 1.7.2) nokogiri (~> 1.7.2)
oauth2 (~> 1.2)
protected_attributes protected_attributes
rack-openid rack-openid
rails (= 4.2.8) rails (= 4.2.8)
...@@ -203,11 +262,15 @@ DEPENDENCIES ...@@ -203,11 +262,15 @@ DEPENDENCIES
rmagick (>= 2.14.0) rmagick (>= 2.14.0)
roadie (~> 3.2.1) roadie (~> 3.2.1)
roadie-rails (~> 1.1.1) roadie-rails (~> 1.1.1)
rubocop
rubocop-performance
ruby-openid (~> 2.3.0) ruby-openid (~> 2.3.0)
selenium-webdriver (~> 2.53.4) selenium-webdriver (~> 2.53.4)
simplecov (~> 0.9.1) simplecov (~> 0.9.1)
slim-rails
test_after_commit (~> 0.4.2) test_after_commit (~> 0.4.2)
tzinfo-data tzinfo-data
validate_url
yard yard
BUNDLED WITH BUNDLED WITH
......
# Sample plugin controller # Sample plugin controller
class ExampleController < ApplicationController class ExampleController < ApplicationController
unloadable # unloadable
layout 'base' layout 'base'
before_action :find_project, :authorize before_action :find_project, :authorize
......
class <%= @controller_class %>Controller < ApplicationController class <%= @controller_class %>Controller < ApplicationController
unloadable # unloadable
<% actions.each do |action| -%> <% actions.each do |action| -%>
......
class <%= @model_class %> < ActiveRecord::Base class <%= @model_class %> < ActiveRecord::Base
unloadable # unloadable
end end
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
# along with redmine_agile. If not, see <http://www.gnu.org/licenses/>. # along with redmine_agile. If not, see <http://www.gnu.org/licenses/>.
class AgileBoardsController < ApplicationController class AgileBoardsController < ApplicationController
unloadable # unloadable
menu_item :agile menu_item :agile
......
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
# along with redmine_agile. If not, see <http://www.gnu.org/licenses/>. # along with redmine_agile. If not, see <http://www.gnu.org/licenses/>.
class AgileChartsController < ApplicationController class AgileChartsController < ApplicationController
unloadable # unloadable
menu_item :agile menu_item :agile
......
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
# along with redmine_agile. If not, see <http://www.gnu.org/licenses/>. # along with redmine_agile. If not, see <http://www.gnu.org/licenses/>.
class AgileJournalDetailsController < ApplicationController class AgileJournalDetailsController < ApplicationController
unloadable # unloadable
before_filter :find_issue before_filter :find_issue
......
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
# along with redmine_agile. If not, see <http://www.gnu.org/licenses/>. # along with redmine_agile. If not, see <http://www.gnu.org/licenses/>.
class AgileVersionsController < ApplicationController class AgileVersionsController < ApplicationController
unloadable # unloadable
menu_item :agile menu_item :agile
......
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
# along with redmine_agile. If not, see <http://www.gnu.org/licenses/>. # along with redmine_agile. If not, see <http://www.gnu.org/licenses/>.
class AgileChartsQuery < AgileQuery class AgileChartsQuery < AgileQuery
unloadable # unloadable
validate :validate_query_dates validate :validate_query_dates
......
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
# along with redmine_agile. If not, see <http://www.gnu.org/licenses/>. # along with redmine_agile. If not, see <http://www.gnu.org/licenses/>.
class AgileData < ActiveRecord::Base class AgileData < ActiveRecord::Base
unloadable # unloadable
belongs_to :issue belongs_to :issue
validates :story_points, :numericality => {:only_integer => true, :greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid} validates :story_points, :numericality => {:only_integer => true, :greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
end end
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
# along with redmine_agile. If not, see <http://www.gnu.org/licenses/>. # along with redmine_agile. If not, see <http://www.gnu.org/licenses/>.
class AgileQuery < Query class AgileQuery < Query
unloadable # unloadable
attr_reader :truncated attr_reader :truncated
......
# This file is a part of Redmin Agile (redmine_agile) plugin, # This file is a part of Redmin Agile (redmine_agile) plugin,
# Agile board plugin for redmine # Agile board plugin for redmine
# #
# Copyright (C) 2011-2017 RedmineUP # Copyright (C) 2011-2017 RedmineUP
# http://www.redmineup.com/ # http://www.redmineup.com/
# #
# redmine_agile is free software: you can redistribute it and/or modify # redmine_agile is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or # the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version. # (at your option) any later version.
# #
# redmine_agile is distributed in the hope that it will be useful, # redmine_agile is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of # but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details. # GNU General Public License for more details.
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with redmine_agile. If not, see <http://www.gnu.org/licenses/>. # along with redmine_agile. If not, see <http://www.gnu.org/licenses/>.
class AgileQuery < Query class AgileQuery < Query
unloadable # unloadable
VISIBILITY_PRIVATE = 0 VISIBILITY_PRIVATE = 0
VISIBILITY_ROLES = 1 VISIBILITY_ROLES = 1
......
...@@ -27,7 +27,7 @@ module RedmineAgile ...@@ -27,7 +27,7 @@ module RedmineAgile
def self.included(base) def self.included(base)
base.send(:include, InstanceMethods) base.send(:include, InstanceMethods)
base.class_eval do base.class_eval do
unloadable # unloadable
has_one :agile_data, :dependent => :destroy has_one :agile_data, :dependent => :destroy
delegate :position, :to => :agile_data, :allow_nil => true delegate :position, :to => :agile_data, :allow_nil => true
scope :sorted_by_rank, lambda {eager_load(:agile_data). scope :sorted_by_rank, lambda {eager_load(:agile_data).
......
...@@ -23,7 +23,7 @@ module RedmineAgile ...@@ -23,7 +23,7 @@ module RedmineAgile
module ProjectPatch module ProjectPatch
def self.included(base) def self.included(base)
base.class_eval do base.class_eval do
unloadable # unloadable
acts_as_colored acts_as_colored
safe_attributes 'agile_color_attributes', safe_attributes 'agile_color_attributes',
:if => lambda {|project, user| user.allowed_to?(:edit_project, project) && user.allowed_to?(:view_agile_queries, project) && RedmineAgile.use_colors?} :if => lambda {|project, user| user.allowed_to?(:edit_project, project) && user.allowed_to?(:view_agile_queries, project) && RedmineAgile.use_colors?}
......
...@@ -23,7 +23,7 @@ module RedmineAgile ...@@ -23,7 +23,7 @@ module RedmineAgile
module UserPatch module UserPatch
def self.included(base) def self.included(base)
base.class_eval do base.class_eval do
unloadable # unloadable
acts_as_colored acts_as_colored
safe_attributes 'agile_color_attributes', safe_attributes 'agile_color_attributes',
:if => lambda {|user, current_user| (current_user.admin? || (user.new_record? && current_user.anonymous? && Setting.self_registration?)) && RedmineAgile.use_colors? } :if => lambda {|user, current_user| (current_user.admin? || (user.new_record? && current_user.anonymous? && Setting.self_registration?)) && RedmineAgile.use_colors? }
......
source 'https://rubygems.org'
gem 'addressable', '~> 2.5', '>= 2.5.2'
gem 'descendants_tracker', '~> 0.0.4'
gem 'faraday', '~> 0.17.6'
gem 'hashie', '~> 3.5', '>= 3.5.7'
gem 'oauth2', '~> 1.2'
gem 'github_api', '~> 0.19.0'
GEM
specs:
addressable (2.5.2)
public_suffix (>= 2.0.2, < 4.0)
descendants_tracker (0.0.4)
thread_safe (~> 0.3, >= 0.3.1)
faraday (1.0.1)
multipart-post (>= 1.2, < 3)
github_api (0.19.0)
addressable (~> 2.4)
descendants_tracker (~> 0.0.4)
faraday (>= 0.8, < 2)
hashie (~> 3.5, >= 3.5.2)
oauth2 (~> 1.0)
hashie (3.6.0)
jwt (2.3.0)
multi_json (1.13.0)
multi_xml (0.6.0)
multipart-post (2.3.0)
oauth2 (1.4.11)
faraday (>= 0.17.3, < 3.0)
jwt (>= 1.0, < 3.0)
multi_json (~> 1.3)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
public_suffix (3.0.1)
rack (1.6.8)
thread_safe (0.3.6)
PLATFORMS
ruby
DEPENDENCIES
github_api (~> 0.19.0)
BUNDLED WITH
1.17.3
= workflow_report
This plugin is designed to produce monthly workflow reports with precise customization options. Users can select a specific month, year, and team of interest. Upon making these selections, the plugin generates a detailed report that is exclusively based on the chosen month and year.
class WorkflowReportController < ApplicationController
include WorkflowReport
before_action :authorize_global
before_action :require_xhr_request, only: %i[export show_daily_report]
def index
@team_options = $workflow_report_config['teams'].map { |team| team.keys() }
end
def index_daily_report
@team_options = Project.visible.sorted.active
end
def show_daily_report
date = params[:date].split('-')
project_id = Project.find_by(name: params[:team]).id
project_ids = Project.visible.active.where(parent_id: project_id).pluck(:id).push(project_id)
users_by_role = Project.find(project_id).users_by_role
result = []
users_by_role.each do |role, users|
users.each do |u|
result << { id: u.id, fullname: "#{u.firstname} #{u.lastname}" } unless role.name.in?(%w[Manager JP TeamLead])
end
end
report = {}
result.each do |user|
report[user[:fullname]] = WorkflowReportTimeEntries.query_time_entry(user[:id], date[0].to_i, date[1].to_i, date[2].to_i, project_ids)
end
respond_to do |format|
format.js { render 'build_report', locals: { report: report, display_hour: params[:display_hour] } }
end
end
def export
team = params[:team]
project_ids = $workflow_report_config['teams'].select { |hash| hash.key?(team) }[0][team]
result = WorkflowReport.build_report(params[:year].to_i, params[:month].to_i, project_ids)
respond_to do |format|
format.js { render 'build_table', locals: { result: result, thead: TABLE_HEADER } }
end
end
private
def require_xhr_request
head :unprocessable_entity unless request.xhr?
end
end
module WorkflowReportHelper
def build_report(entry)
done_ratio = entry.issue.done_ratio
github_issue_id = entry.issue.subject.match(/.*(#\d+).*/)[1] rescue "##{entry.issue.id}"
text = "#{entry.project.name}#{github_issue_id}: #{entry.activity.name} - #{entry.comments}"
text.concat(" (#{done_ratio}%)") if done_ratio > 0
text
end
end
class WorkflowReportIssue < Issue
TESTCASE_FIELD_ID = 22
BUGS_FIELD_ID = 23
STG_BUGS_FIELD_ID = 27
PROD_BUGS_FIELD_ID = 28
PR_FIELD_ID = 18
JP_REQUEST_FIELD_ID = 16
scope :find_root_ids, ->(year, month, project_ids) {
joins(:project, project: :enabled_modules)
.joins('LEFT OUTER JOIN time_entries ON issues.id = time_entries.issue_id')
.where.not(projects: { status: 9 })
.where(projects: { id: project_ids }, enabled_modules: { name: 'time_tracking' })
.where(['((time_entries.spent_on IS NOT NULL AND time_entries.tyear = ? AND time_entries.tmonth = ?) OR (issues.closed_on BETWEEN ? AND ?))',
year, month, DateTime.new(year, month).beginning_of_day, DateTime.new(year, month, -1).end_of_day])
.distinct.pluck(:root_id)
}
scope :find_sum_hours_records, ->(root_ids) {
select(:root_id, 'sum(time_entries.hours) as hours', 'max(IFNULL(c1.value,-1)) as testcases', 'max(IFNULL(c2.value,-1)) as bugs', 'max(IFNULL(stg.value,-1)) as stg_bugs', 'max(IFNULL(prod.value,-1)) as prod_bugs')
.joins(:time_entries)
.joins("LEFT OUTER JOIN custom_values c1 ON c1.customized_id = issues.id and c1.custom_field_id=#{TESTCASE_FIELD_ID}")
.joins("LEFT OUTER JOIN custom_values c2 ON c2.customized_id = issues.id and c2.custom_field_id=#{BUGS_FIELD_ID}")
.joins("LEFT OUTER JOIN custom_values stg ON stg.customized_id = issues.id and stg.custom_field_id=#{STG_BUGS_FIELD_ID}")
.joins("LEFT OUTER JOIN custom_values prod ON prod.customized_id = issues.id and prod.custom_field_id=#{PROD_BUGS_FIELD_ID}")
.where(issues: { root_id: root_ids })
.where.not(issues: { tracker_id: 1 })
.group(:root_id)
}
scope :raw_tasks_records, ->(root_ids) {
select(:root_id, :id, :tracker_id, :subject, :due_date, :created_on, :closed_on, :estimated_hours, 'issue_statuses.name as status', 'versions.name as target_version', 'projects.name as project', 'pr.value as pr', 'jr.value as jp_request')
.joins(:project, :status)
.joins('LEFT JOIN versions ON issues.fixed_version_id = versions.id')
.joins("LEFT OUTER JOIN custom_values jr ON jr.customized_id = issues.id and jr.custom_field_id=#{JP_REQUEST_FIELD_ID}")
.joins("LEFT OUTER JOIN custom_values pr ON pr.customized_id = issues.id and pr.custom_field_id=#{PR_FIELD_ID}")
.where(issues: { root_id: root_ids })
.where.not(issues: { tracker_id: 1 })
.order(:root_id)
}
end
class WorkflowReportJournal < Journal
scope :find_journal_by_issue_ids, ->(issue_ids) {
joins(:details)
.where(journals: { journalized_id: issue_ids }, journal_details: { prop_key: 'estimated_hours' })
.order(:journalized_id, :id)
.pluck(:journalized_id, :old_value, :value, :created_on, :notes)
}
end
class WorkflowReportTimeEntries < TimeEntry
JP_TIME_ZONE = 9
scope :query_time_entry, ->(user_id, year, month, day, project_ids) {
where("#{TimeEntry.table_name}.user_id = ? AND #{TimeEntry.table_name}.spent_on BETWEEN ? AND ?", user_id, DateTime.new(year, month, day).beginning_of_day - JP_TIME_ZONE.hours, DateTime.new(year, month, day).end_of_day - JP_TIME_ZONE.hours).
where(projects: {id: project_ids}).
joins(:activity, :project).
references(:issue => [:tracker, :status]).
includes(:issue => [:tracker, :status, :assigned_to, :priority]).
order("#{TimeEntry.table_name}.spent_on DESC, #{Project.table_name}.name ASC, #{Tracker.table_name}.position ASC, #{Issue.table_name}.id ASC")
}
end
- display_hour = ActiveRecord::Type::Boolean.new.type_cast_from_user(display_hour)
p = "Project: #{params[:team]}"
p = "*Daily Report #{params[:date]}*"
- report.each_with_index do |(username, entries), index|
- total_time = entries.reduce(0) { |acc, entry| acc + entry.hours }
.mt-10 class=("bg-danger" if total_time < 8)
span = "#{(index + 1).to_s}. #{username} #{'(' +total_time.to_s + 'h)' if display_hour}"
- entries.each do |entry|
div
span &nbsp;&nbsp;
- #{build_report(entry)}
- if display_hour
span = " (#{entry.hours} h)"
h3 Report
ul
li = link_to 'Workflow Report', workflow_report_path
li = link_to 'Daily Report', workflow_report_daily_path
- if result.present?
table[border="1"]
tr.header_table--visible
th.green[colspan="8"] Task information
th.blue[colspan="6"] Overall
th.purple[colspan="7"] Estimation detail *final version
th.gray[colspan="7"] Actual time detail
th.yellow[colspan="4"] Bugs
th.pink[colspan="8"] Github
tr.header_table--visible.none_colspan
- thead.each_with_index do |head, index|
- if (0..7).include?(index)
th.green = head
- elsif (8..10).include?(index)
th.red = head
- elsif (11..13).include?(index)
th.blue = head
- elsif (14..20).include?(index)
th.purple = head
- elsif (21..27).include?(index)
th.gray = head
- elsif (28..31).include?(index)
th.yellow = head
- else
th.pink = head
- result[0].each_with_index do |_root_id, index|
tr
- (0..(thead.length - 1)).each do |i|
- if i == 0
td.sidebar_visible = result[0][index]
- else
td = result[i][index]
- else
h1.text-center There is no data for the 'workflow report' table.
<% display_hour = display_hour || false %>
$("#daily_report").html("<%= escape_javascript(render partial: 'daily_report', locals: { report: report, display_hour: display_hour } ) %>")
$("#data_workflow").html("<%= escape_javascript(render partial: 'table_workflow_report', locals: { result: result, thead: thead } ) %>")
<% if result.present? %>
$('button.export-button').removeClass('d-none')
<% else %>
$('button.export-button').addClass('d-none')
<% end %>
= stylesheet_link_tag 'style', plugin: 'workflow_report'
= javascript_include_tag(:application, :plugin => 'workflow_report')
= content_for :sidebar do
= render 'side_content'
fieldset.box.tabular
legend
| EXPORT WORKFLOW
= form_tag workflow_report_export_path, method: :get, remote: true, id: 'export-form' do
p
= label :month, 'Month'
= select_tag :month,options_for_select(1..12, Time.now.month), { prompt: "Select month" }
p
= label :year, 'Year'
= select_tag :year,options_for_select((Time.now.year - 5)..(Time.now.year + 1), Time.now.year), { prompt: "Select year" }
p
= label :team, 'Team'
= select_tag :team, options_for_select(@team_options), { prompt: "Select team" }
= submit_tag 'export', id: 'export'
button.export-button.d-none Download .CSV
#data_workflow
= stylesheet_link_tag 'style', plugin: 'workflow_report'
= javascript_include_tag(:application, :plugin => 'workflow_report')
= content_for :sidebar do
= render 'side_content'
fieldset.box.tabular
legend
| DAILY REPORT
= form_tag workflow_report_daily_export_path, method: :get, remote: true, id: 'export-form' do
p
= label :team, 'Team'
= select_tag :team, options_for_select(@team_options), { prompt: "Select team" }
p
= label :team, 'Date'
= date_field_tag :date, Date.current
p
= label :display_hour, 'Display hour'
= check_box_tag :display_hour, '1', true
= submit_tag 'export', id: 'export'
#daily_report
window.addEventListener('load', function () {
$('#export-form').submit(function () {
const month = $('#month').val()
const year = $('#year').val()
const team = $('#team').val()
if (month === '' || year === '' || team === '') {
alert('Please complete all the necessary fields.')
return false
}
return true
});
const download = function (data) {
const blob = new Blob([data], {type: 'text/csv'})
const url = window.URL.createObjectURL(blob)
const aTag = document.createElement('a')
const fileName = $('#year').val() + $('#month').val() + $('#team').val()
aTag.setAttribute('href', url)
aTag.setAttribute('download', `${$('#year').val()}-${$('#month').val()}-${$('#team').val()}.csv`)
aTag.click()
}
$(".export-button").on("click", function () {
exportToCSV()
})
function exportToCSV() {
const table = $("table")
const rows = table.find("tr")
let csv = []
rows.each(function (index) {
const cols = $(this).find("td, th")
let rowText = []
let currentColspan = 1
cols.each(function () {
const text_report = $(this).text()
if ($(this).attr("colspan")) {
currentColspan = parseInt($(this).attr("colspan"), 10)
}
if (currentColspan > 1) {
for (let i = 0; i < currentColspan; i++) {
i === 0 ? rowText.push(text_report) : rowText.push('')
}
} else {
text_report.includes(',') ? rowText.push(`"${text_report}"`) : rowText.push(text_report)
}
currentColspan = Math.max(1, currentColspan - 1)
});
csv.push(rowText.join(","))
});
const csvContent = csv.join("\n")
download(csvContent)
}
})
#data_workflow {
overflow: auto;
height: 100vh;
}
#daily_report {
min-height: 80vh;
}
table {
display: inline-block;
table-layout: fixed;
border-collapse: collapse;
}
.header_table--visible {
position: sticky;
top: -1px;
z-index: 2;
}
.none_colspan {
top: 29px;
}
.sidebar_visible {
position: sticky;
background: #dacbcb;
left: 0;
z-index: 1;
}
.mt-10 {
margin-top: 10px;
}
.green {
background-color: #92ce92;
}
.red {
background-color: #e38585;
}
.blue {
background-color: #b2b2f0;
}
.purple {
background-color: #f7adf7;
}
.gray {
background-color: #e6e4e4;
}
.yellow {
background-color: #eaeab0;
}
.pink {
background-color: pink;
}
button.export-button {
margin-bottom: 35px;
}
.d-none {
display: none;
}
.text-center {
text-align: center;
}
# English strings go here for Rails i18n
en:
# my_label: "My label"
# Plugin's routes
# See: http://guides.rubyonrails.org/routing.html
get 'workflow_report', to: 'workflow_report#index'
get 'workflow_report/export', to: 'workflow_report#export'
get 'workflow_report/daily', to: 'workflow_report#index_daily_report'
get 'workflow_report/daily/export', to: 'workflow_report#show_daily_report'
github_token: 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
teams:
- Kyujin:
- 9
- 142
- 88
- Sumai:
- 17
- 29
- 31
- 131
- 140
- Kuruma:
- 139
- 138
- 94
- 90
- 93
- 120
- 128
- 147
- 121
- 116
- LS:
- 134
- PS:
- 145
- 141
- 144
- 151
- Sanko:
- 91
- 149
- CORDA:
- 92
- 112
- APW-Travelist-Air:
- 133
- APW-Travelist-Hotel:
- 148
- APW-Voyager:
- 146
- APW-SUB:
- 137
require 'redmine'
Rails.configuration.to_prepare do
require_dependency 'issue'
end
Redmine::Plugin.register :workflow_report do
name 'Workflow Report plugin'
author 'Author name'
description 'This is a plugin for Redmine'
version '0.0.1'
url 'http://example.com/path/to/plugin'
author_url 'http://example.com/about'
configfile = File.join(File.dirname(__FILE__), 'config', 'settings.yml')
$workflow_report_config = YAML::load_file(configfile)
end
Redmine::MenuManager.map :top_menu do |menu|
menu.push(:workflow_report, { controller: 'workflow_report', action: 'index' },
caption: 'Workflow Report',
if: proc { User.current.allowed_to_globally?(:view_workflow_report) })
end
ActionDispatch::Reloader.to_prepare do
Redmine::AccessControl.map do |map|
map.project_module :workflow_report do |pmap|
pmap.permission(:view_workflow_report, {
workflow_report: [:index, :export],
}, read: true)
end
end
end
module WorkflowReport
TABLE_HEADER = ['root_id', 'project', 'subject', 'target_version', 'created_on ', 'closed_on ', 'due_date ', 'status ', 'Estimated_hours (*final version)', 'Actual time', 'Diff', 'Estimated_hours (*Initial version)',
'Number of estimation changes', 'Note of estimation changes', '1. Requirement', '2. Design', '3. Coding', '4. Testing', '5. Bug fixing', '6. Release', 'Others', '1. Requirement', '2. Design', '3. Coding',
'4. Testing', '5. Bug fixing', '6. Release', 'Others', 'testcases', 'vn STG bug', 'Jp STG bug', 'Production', 'Issue', 'Issue comment', 'PR comment', 'Review comment', 'Commits', 'File changed', 'Addtion', 'Deletetion']
EST_DETAIL_FIRST_COL = 14
ACTUAL_TIME_DETAIL_FIRST_COL = 21
class << self
def build_report(year, month, project_ids)
github = Github.new oauth_token: $workflow_report_config['github_token']
result = TABLE_HEADER.length.times.map { [] }
root_ids = WorkflowReportIssue.find_root_ids(year, month, project_ids)
return unless root_ids.length.positive?
sum_hours_records = WorkflowReportIssue.find_sum_hours_records(root_ids)
raw_tasks = WorkflowReportIssue.raw_tasks_records(root_ids)
raw_tasks.group_by(&:root_id).each do |root_id, record|
sum_hours_record = sum_hours_records.find { |hr| hr.root_id == root_id }
issue_ids = record.map(&:id)
journals = WorkflowReportJournal.find_journal_by_issue_ids(issue_ids).to_a
result[0] << root_id
result[1] << record.first[:project].gsub(/[^[:print:]]/, '')
result[2] << record.first[:subject].gsub(/[^[:print:]]/, '')
result[3] << record.first[:target_version]
issue_created_on = record.min_by { |i| i[:created_on] if i[:created_on].present? }
result[4].push(issue_created_on.present? ? issue_created_on[:created_on]&.strftime('%Y-%m-%d %H:%M:%S') : '')
closed_on_issues = record.map(&:closed_on).compact
issue_closed_on = closed_on_issues.max_by { |close_on| close_on }
result[5].push(issue_closed_on.present? ? issue_closed_on.strftime('%Y-%m-%d %H:%M:%S') : '')
issue_due_dates = record.map(&:due_date).compact
issue_due_date = issue_due_dates.max_by { |due_date| due_date }
result[6].push(issue_due_date.present? ? issue_due_date&.strftime('%Y-%m-%d %H:%M:%S') : '')
result[7].push(record.first[:status].present? ? record.first[:status] : '')
sum_estimated_hours = record.map { |i| i[:estimated_hours] }.compact.sum
result[8] << sum_estimated_hours
if sum_hours_record.present?
result[9] << sum_hours_record[:hours]
result[10] << (sum_hours_record[:hours] - sum_estimated_hours)
else
result[9] << ''
result[10] << ''
end
if journals.length.positive?
number_of_est_changes = journals.count { |j| !j[1].nil? } || ''
est_changes = journals.reject { |j| j[1].nil? }.select { |e| e[4].present? }
notes = est_changes.inject('') { |all, i| "#{all}#{i[4]}\n" } || ''
original_est_hours = journals.group_by(&:shift).inject(0) { |sum, i| sum + i[1][0][1].to_f unless i.nil? } || ''
result[11] << original_est_hours
result[12] << number_of_est_changes
result[13] << notes
else
result[11] << ''
result[12] << ''
result[13] << ''
end
pull_request = ''
jp_request = ''
record.each do |issue|
if issue.tracker_id == 12
pull_request = issue.pr
jp_request = issue.jp_request
end
process = get_process(issue.subject.gsub(/[^[:print:]]/, ''))
case process
when '1. Requirement'
result[EST_DETAIL_FIRST_COL].push(issue.estimated_hours.present? ? issue.estimated_hours : '')
result[ACTUAL_TIME_DETAIL_FIRST_COL].push(issue.spent_hours.positive? ? issue.spent_hours : '')
when '2. Design'
result[EST_DETAIL_FIRST_COL + 1].push(issue.estimated_hours.present? ? issue.estimated_hours : '')
result[ACTUAL_TIME_DETAIL_FIRST_COL + 1].push(issue.spent_hours.positive? ? issue.spent_hours : '')
when '3. Coding'
result[EST_DETAIL_FIRST_COL + 2].push(issue.estimated_hours.present? ? issue.estimated_hours : '')
result[ACTUAL_TIME_DETAIL_FIRST_COL + 2].push(issue.spent_hours.positive? ? issue.spent_hours : '')
when '4. Testing'
result[EST_DETAIL_FIRST_COL + 3].push(issue.estimated_hours.present? ? issue.estimated_hours : '')
result[ACTUAL_TIME_DETAIL_FIRST_COL + 3].push(issue.spent_hours.positive? ? issue.spent_hours : '')
when '5. Bug fixing'
result[EST_DETAIL_FIRST_COL + 4].push(issue.estimated_hours.present? ? issue.estimated_hours : '')
result[ACTUAL_TIME_DETAIL_FIRST_COL + 4].push(issue.spent_hours.positive? ? issue.spent_hours : '')
when '6. Release'
result[EST_DETAIL_FIRST_COL + 5].push(issue.estimated_hours.present? ? issue.estimated_hours : '')
result[ACTUAL_TIME_DETAIL_FIRST_COL + 5].push(issue.spent_hours.positive? ? issue.spent_hours : '')
else
result[EST_DETAIL_FIRST_COL + 6].push(issue.estimated_hours.present? ? issue.estimated_hours : '')
result[ACTUAL_TIME_DETAIL_FIRST_COL + 6].push(issue.spent_hours.positive? ? issue.spent_hours : '')
end
end
if sum_hours_record.nil?
# testcases
result[28] << ''
# internal bug
result[29] << ''
# stg bug
result[30] << ''
# prod bug
result[31] << ''
else
# testcases
result[28] << sum_hours_record[:testcases] if sum_hours_record[:testcases].to_i >= 0
# internal bug
result[29] << sum_hours_record[:bugs] if sum_hours_record[:bugs].to_i >= 0
# stg bug
result[30] << sum_hours_record[:stg_bugs] if sum_hours_record[:stg_bugs].to_i >= 0
# prod bug
result[31] << sum_hours_record[:prod_bugs] if sum_hours_record[:prod_bugs].to_i >= 0
end
if jp_request.present?
jp_request = jp_request.strip
result[32] << jp_request
jp_request_arr = URI(jp_request).path.split('/').reject(&:blank?)
if jp_request_arr.length == 4 && jp_request_arr[3].to_i.positive?
begin
issue_detail = github.issues.find user: jp_request_arr[0], repo: jp_request_arr[1], number: jp_request_arr[3]
result[33].push(issue_detail.success? ? issue_detail.comments : '')
rescue => e
result[33].push('')
end
end
else
result[32] << ''
result[33] << ''
end
# pr detail
next unless pull_request.present?
pr_comments = 0
pr_review_comments = 0
pr_commits = 0
pr_additions = 0
pr_deletions = 0
pr_changed_files = 0
prs = pull_request.split("\r\n").compact
prs.each do |link|
pr_link_arr = URI(link.strip).path.split('/').compact_blank
next unless pr_link_arr.length == 4
begin
pr_detail = github.pull_requests.find user: pr_link_arr[0], repo: pr_link_arr[1], number: pr_link_arr[3]
if pr_detail.success?
pr_comments += pr_detail.comments
pr_review_comments += pr_detail.review_comments
pr_commits += pr_detail.commits
pr_additions += pr_detail.additions
pr_deletions += pr_detail.deletions
pr_changed_files += pr_detail.changed_files
end
end
result[34] << pr_comments
result[35] << pr_review_comments
result[36] << pr_commits
result[37] << pr_changed_files
result[38] << pr_additions
result[39] << pr_deletions
end
end
result
end
private
def get_process(subject)
name = ''
if !subject.nil? && index = subject.strip.index(' -')
subject = subject.strip
id = subject[0, 1]
name = subject[0, index]
if id.to_i != 0
if ["3. Code review"].include? name
name = "3. Coding"
elsif ["4. Create Test case"].include? name
name = "4. Testing"
else
name
end
else
if name.include? "Requirement"
name = "1. " + name
elsif name.include? "Design"
name = "2. " + name
elsif ["Coding", "Code review"].include? name
name = "3. Coding"
elsif ["Testing", "Create Test case"].include? name
name = "4. Testing"
elsif name.include? "Bug fixing"
name = "5. " + name
elsif name.include? "Release"
name = "6. " + name
end
end
end
name.strip
end
end
end
# Load the Redmine helper
require File.expand_path(File.dirname(__FILE__) + '/../../../test/test_helper')
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment