Commit 64d64a25 by Tấn Trần Thanh

Merge branch 'feature/WORKFLOW-export-times-entries-in-month' into 'master'

Feature/workflow export times entries in month

See merge request !16
parents 950db87a a3f94b3f
Pipeline #1624 canceled with stages
in 0 seconds
= 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.
To install and configure the Workflow Report plugin for Redmine, follow these steps:
1. Navigate to the plugin's configuration directory:
cd plugins/workflow_report/config
2. Create the `application.yml` configuration file based on the example:
- Copy the contents from `application.yml.example` into a new file named `application.yml`.
- You can use the following command to do this quickly:
cp application.yml.example application.yml
3. Add your GitHub Token to the configuration file:
- Open the `application.yml` file in your text editor.
- Locate the line that reads `github_token:` and add your GitHub token after the colon. For example:
github_token: your_github_token_here
- Note: Replace `your_github_token_here` with your actual GitHub token.
class WorkflowReportController < ApplicationController
include WorkflowReport
before_action :authorize_global
before_action :require_xhr_request, only: %i[export show_daily_report]
before_action :require_xhr_request, only: %i[export show_daily_report export_time_entry]
def index
@team_options = $workflow_report_config['teams'].map { |team| team.keys() }
@team_options = $app_report_config['teams'].map { |team| team.keys() }
end
def index_daily_report
......@@ -44,8 +44,9 @@ class WorkflowReportController < ApplicationController
def export
team = params[:team]
project_ids = $workflow_report_config['teams'].select { |hash| hash.key?(team) }[0][team]
result = Rails.cache.fetch("#{team}_#{params[:year]}_#{params[:month]}", expires_in: 1.hours) do
project_ids = find_project_ids(team)
result = Rails.cache.fetch("#{team}_#{params[:year]}_#{params[:month]}", expires_in: team == 'All-team' ? 1.hours : 1.days) do
WorkflowReport.build_report(params[:year].to_i, params[:month].to_i, project_ids)
end
......@@ -54,9 +55,31 @@ class WorkflowReportController < ApplicationController
end
end
def index_time_entry
@team_options = $app_report_config['teams'].map { |team| team.keys() }
end
def export_time_entry
team = params[:team]
project_ids = find_project_ids(team)
result = WorkflowReport.build_time_entry_report(project_ids, params[:date_from], params[:date_to])
respond_to do |format|
format.js { render 'build_table_time_entry', locals: { result: result, thead: TIME_ENTRY_HEADER } }
end
end
private
def require_xhr_request
head :unprocessable_entity unless request.xhr?
end
def find_project_ids(team)
if team == 'All-team'
$app_report_config['teams'].map(&:values).flatten.compact
else
$app_report_config['teams'].find { |hash| hash.key?(team) }[team]
end
end
end
......@@ -5,11 +5,19 @@ class WorkflowReportIssue < Issue
has_many :workflow_report_custom_values, class_name: 'WorkflowReportCustomValue', foreign_key: 'customized_id'
scope :raw_tasks_records, -> (year, month, project_ids) {
includes(:project, :workflow_report_version, :workflow_report_custom_values, :time_entries, :status, project: :enabled_modules)
includes(:time_entries, project: :enabled_modules)
.where.not(projects: { status: CLOSED_STATUS_PROJECT }, issues: { tracker_id: BUG_TRACKER_ID })
.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])
.order(:root_id)
.order(Arel.sql("FIELD(projects.id, #{project_ids.join(',')})"), :root_id)
}
scope :raw_task_in_date, -> (date_from, date_to, project_ids) {
includes(:project, :time_entries)
.where('time_entries.spent_on BETWEEN ? AND ?', date_from, date_to)
.where(projects: { id: project_ids })
.group('issues.root_id')
.order('projects.name', :root_id)
}
end
......@@ -2,3 +2,4 @@ h3 Report
ul
li = link_to 'Workflow Report', workflow_report_path
li = link_to 'Daily Report', workflow_report_daily_path
li = link_to 'Time Entry Report', workflow_report_time_entry_path
- grand_total = result.last&.compact&.sum
h2 = "Total: #{result[0]&.length}"
- if result.present?
table.h-fit[border="1"]
tr.purple
th = "Grand Total (hours): #{grand_total.round(2)}"
th = "Grand Total (days): #{(grand_total / 8).round(2)}"
table[border="1"]
tr.header_table--visible
th.green[colspan="5"] Div Report
tr.header_table--visible.none_colspan
- thead.each_with_index do |head, index|
th.yellow = head
- result[0].each_with_index do |root_id, index|
tr
- (0..(thead.length - 1)).each do |i|
td = result[i][index]
- else
h1.text-center There is no data for the 'time entry report' table.
h2 = "Total: #{result[0]&.length}"
- if result.present?
table[border="1"]
tr.header_table--visible
......@@ -9,27 +10,35 @@
th.pink[colspan="8"] Github
tr.header_table--visible.none_colspan
- thead.each_with_index do |head, index|
- if (0..7).include?(index)
- case index
- when 0..2
th[class="root_col_#{index} green"] = head
- when (3..7)
th.green = head
- elsif (8..10).include?(index)
- when (8..10)
th.red = head
- elsif (11..13).include?(index)
- when (11..13)
th.blue = head
- elsif (14..20).include?(index)
- when (14..20)
th.purple = head
- elsif (21..27).include?(index)
- when (21..27)
th.gray = head
- elsif (28..31).include?(index)
- when (28..31)
th.yellow = head
- else
th.pink = head
- result[0].each_with_index do |_root_id, index|
- 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]
- elsif i == 13
td.break-line = result[i][index]
- case i
- when 0..2
td[class="root_col_#{i}"]
p = result[i][index]
- when 13
td.notes
- notes = result[i][index]
p.break-line.note_est[class="note__est-#{root_id} #{notes.length.zero? ? 'none-over' : 'over_text'}"] = notes
- else
td = result[i][index]
- else
......
$("#data_workflow").html("<%= escape_javascript(render partial: 'table_time_entry', 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 TIME ENTRY REPORT
= form_tag workflow_report_time_entry_export_path, method: :get, remote: true, id: 'export-form' do
p
= label :date_from, 'Date from'
= date_field_tag :date_from, Date.today - 7.days
p
= label :date_from, 'Date to'
= date_field_tag :date_to, Date.today
p
= label :team, 'Team'
= select_tag :team, options_for_select(@team_options), { prompt: "Select team" }
= submit_tag 'FIND', id: 'export'
button.export-button.d-none Download .CSV
#data_workflow
......@@ -32,7 +32,11 @@ window.addEventListener('load', function () {
const aTag = document.createElement('a')
const fileName = $('#year').val() + $('#month').val() + $('#team').val()
aTag.setAttribute('href', url)
if ($('#date_from').val()) {
aTag.setAttribute('download', `${$('#date_from').val()}-${$('#date_to').val()}-${$('#team').val()}.csv`)
} else {
aTag.setAttribute('download', `${$('#year').val()}-${$('#month').val()}-${$('#team').val()}.csv`)
}
aTag.click()
}
......@@ -77,3 +81,10 @@ window.addEventListener('load', function () {
download(csvContent)
}
})
$(document).on('click', 'td.notes', function () {
if (!$(this).find('p.note_est').hasClass('none-over')) {
$(this).find('p.note_est').toggleClass('over_text')
$(this).find('p.note_est').toggleClass('w-100')
}
})
#data_workflow > table, #data_workflow > h1 {
width: 100%;
max-width: 100%;
overflow: auto;
height: 100vh;
}
......@@ -11,34 +11,24 @@
table {
display: inline-block;
table-layout: fixed;
border-collapse: collapse;
border-collapse: separate;
}
.header_table--visible {
position: sticky;
top: -1px;
top: 0;
z-index: 2;
height: 30px;
}
.none_colspan {
top: 29px;
}
.sidebar_visible {
position: sticky;
background: #dacbcb;
left: 0;
z-index: 1;
top: 32px;
}
.mt-10 {
margin-top: 10px;
}
.green {
background-color: #92ce92;
}
.red {
background-color: #e38585;
}
......@@ -88,3 +78,78 @@ p.error_link {
.break-line {
white-space: break-spaces;
}
.root_col_0 {
position: sticky;
left: 0;
z-index: 1;
padding: 5px 0;
background: #dacbcb;
}
.root_col_0 > p {
width: 78px;
text-align: center;
}
.root_col_1 {
position: sticky;
left: 80px;
z-index: 1;
padding: 5px 0;
background: #dacbcb;
}
.root_col_1 > p {
width: 120px;
text-align: center;
}
.root_col_2 {
position: sticky;
left: 202px;
z-index: 1;
padding: 5px 8px;
background: #dacbcb;
}
.green {
background: #92ce92;
}
td.notes {
position: relative;
}
.note_est {
width: 140px;
}
.over_text {
cursor: pointer;
overflow: hidden;
display: block;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.over_text::before {
cursor: pointer;
position: absolute;
right: 6px;
top: 8px;
content: '';
display: inline-block;
border: 5px solid transparent;
border-top-color: black;
vertical-align: middle;
}
.w-100 {
width: 100%;
}
.h-fit {
height: fit-content !important;
}
github_token: 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
github_token: your_github_token_here
teams:
- Kyujin:
- 9
......@@ -42,3 +42,4 @@ teams:
- 146
- APW-SUB:
- 137
- All-team:
......@@ -5,3 +5,5 @@ 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'
get 'workflow_report/time_entry', to: 'workflow_report#index_time_entry'
get 'workflow_report/time_entry/export', to: 'workflow_report#export_time_entry'
......@@ -11,22 +11,16 @@ Redmine::Plugin.register :workflow_report do
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)
configfile = File.join(File.dirname(__FILE__), 'config', 'application.yml')
$app_report_config = YAML::load_file(configfile)
project_module :workflow_report do
permission :workflow_report_view_and_export, { workflow_report: %i[index export index_daily_report show_daily_report
index_time_entry export_time_entry] }, require: :member
end
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
if: proc { User.current.allowed_to_globally?(:workflow_report_view_and_export) })
end
......@@ -2,6 +2,7 @@ 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'].freeze
TIME_ENTRY_HEADER = ['Site', 'Title', 'JP Request', 'Grand Total']
EST_DETAIL_FIRST_COL = 14
ACTUAL_TIME_DETAIL_FIRST_COL = 21
PR_CMT_COL = 34
......@@ -12,37 +13,39 @@ module WorkflowReport
COL_REQUIREMENT_TO_RELEASE = 6
PROCESS = ['1. Requirement', '2. Design', '3. Coding', '4. Testing', '5. Bug fixing', '6. Release', ''].freeze
BUGS = { testcases: 22, bugs: 23, stg_bugs: 27, prod_bugs: 28 }.freeze
QUANTITY_THREAD = 10
class << self
def build_report(year, month, project_ids)
github = Github.new oauth_token: $workflow_report_config['github_token']
github = Github.new oauth_token: $app_report_config['github_token']
error_links = []
github_links = { prs: [], issues: [] }
work_queue = Queue.new
result = TABLE_HEADER.length.times.map { [] }
raw_tasks = WorkflowReportIssue.raw_tasks_records(year, month, project_ids)
return { workflow_report: [], error_links: [] } if raw_tasks.empty?
raw_tasks.group_by(&:root_id).each do |root_id, issues|
issue_ids = issues.map(&:id)
root_issue = WorkflowReportIssue.find(root_id)
arr_issue = WorkflowReportIssue.where(root_id: root_id)
issue_ids = arr_issue.map(&:id)
root_issue = arr_issue.find(root_id)
journals = WorkflowReportJournal.find_journal_by_issue_ids(issue_ids).to_a
result[0] << root_id
result[1] << issues.first.project.name.gsub(/[^[:print:]]/, '')
result[2] << root_issue&.subject.gsub(/[^[:print:]]/, '')
result[3] << issues.first.workflow_report_version&.name
issue_created_on = issues.min_by { |i| i[:created_on] if i[:created_on].present? }
result[3] << root_issue.workflow_report_version&.name
issue_created_on = arr_issue.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 = issues.map(&:closed_on).compact
closed_on_issues = arr_issue.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 = issues.map(&:due_date).compact
issue_due_dates = arr_issue.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(root_issue.status&.name.present? ? root_issue.status&.name : '')
sum_estimated_hours = issues.map(&:estimated_hours).compact.sum
sum_estimated_hours = arr_issue.map(&:estimated_hours).compact.sum
result[8] << sum_estimated_hours.round(2)
actual_time = issues.map { |issue| issue.time_entries.sum(:hours) }.sum
actual_time = TimeEntry.where(issue_id: issue_ids).sum(:hours)
if actual_time.present?
result[9] << actual_time.round(2)
result[10] << (actual_time - sum_estimated_hours).round(2)
......@@ -61,12 +64,12 @@ module WorkflowReport
else
(11..13).each { |i| result[i] << '' }
end
issues.each do |issue|
arr_issue.each do |issue|
if issue.tracker_id == USER_STORY_TRACKER_ID
pr_links = issue.custom_values.find_by(custom_field_id: PR_FIELD_ID)&.value
jp_request = issue.custom_values.find_by(custom_field_id: JP_REQUEST_FIELD_ID)&.value
github_links[:prs].push({ row: result[0].length - 1, links: pr_links, root_id: root_id }) if pr_links.present?
github_links[:issues].push({ row: result[0].length - 1, links: jp_request, root_id: root_id }) if jp_request.present?
work_queue.push([:pr, { row: result[0].length - 1, links: pr_links, root_id: root_id }]) if pr_links.present?
work_queue.push([:issue, { row: result[0].length - 1, links: jp_request, root_id: root_id }]) if jp_request.present?
end
process = get_process(issue.subject.gsub(/[^[:print:]]/, ''))
......@@ -99,22 +102,43 @@ module WorkflowReport
end
end
threads_pr = github_links[:prs].map do |prs|
thread_pool = Array.new(QUANTITY_THREAD) do
Thread.new do
find_detail_pr(github, prs[:links], error_links, result, prs[:row], prs[:root_id])
until work_queue.empty?
type, item = work_queue.pop(true) rescue nil
if type == :pr
find_detail_pr(github, item[:links], error_links, result, item[:row], item[:root_id])
elsif type == :issue
find_detail_issue(github, result, error_links, item[:links], item[:row], item[:root_id])
end
end
end
end
thread_pool.each(&:join)
threads_issue = github_links[:issues].map do |issues|
Thread.new do
find_detail_issue(github, result, error_links, issues[:links], issues[:row], issues[:root_id])
{ workflow_report: result, error_links: error_links }
end
def build_time_entry_report(project_ids, date_from, date_to)
result = TIME_ENTRY_HEADER.length.times.map { [] }
raw_tasks = WorkflowReportIssue.raw_task_in_date(date_from, date_to, project_ids)
return [] if raw_tasks.empty?
raw_tasks.group_by(&:root_id).each do |root_id, issues|
arr_issue = WorkflowReportIssue.where(root_id: root_id)
result[0] << issues.first.project.name
result[1] << "##{root_id}: #{Issue.find(root_id)&.subject}"
result[3] << arr_issue.map { |issue| issue.time_entries.where("time_entries.spent_on BETWEEN ? AND ?", date_from, date_to).sum(:hours) }.sum.round(2)
request = ''
arr_issue.each do |issue|
jp = issue.custom_values.find_by(custom_field_id: JP_REQUEST_FIELD_ID)&.value
request = jp if jp.present?
end
threads_issue.each(&:join)
threads_pr.each(&:join)
result[2] << request
end
{ workflow_report: result, error_links: error_links }
result
end
private
......
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