├── README.md ├── app ├── controllers │ └── board_controller.rb ├── helpers │ ├── board_helper.rb │ ├── board_queries_helper.rb │ └── workload_management_settings_helper.rb ├── models │ ├── board_issue_query.rb │ └── issue_patch.rb └── views │ ├── board │ └── index.html.erb │ ├── queries │ └── _filter_query_form.html.erb │ └── settings │ └── _required_statuses_settings.html.erb ├── assets └── javascripts │ └── board.js ├── config ├── locales │ └── en.yml └── routes.rb ├── init.rb └── lib └── tasks └── clear_time_for_today_values.rake /README.md: -------------------------------------------------------------------------------- 1 | # Daily workload management Redmine plugin 2 | 3 | Plugin provides functionality for managing daily workload. 4 | 5 | ## Installation 6 | 7 | 1. Clone this plugin to your Redmine plugins directory: 8 | 9 | ```bash 10 | user@user:/path/to/redmine/plugins$ git clone https://github.com/DefaultValue/redmine-daily-workload-management.git daily_workload_management 11 | ``` 12 | 13 | 2. Restart Redmine to check plugin availability and configure its options. 14 | 15 | 3. Create custom issue field for 'time for today' (Administration > Custom fields > New custom field > Issues) 16 | 17 | 4. Create issue status which will be used as 'today' status ( Administration > Issue statuses > New status ). 18 | Go to Workflow page (Administration > Workflow) and set usage of created 'today' status 19 | 20 | 5. Go to plugin configuration page (Administration > Plugins > Daily Workload Management plugin > Configure) and set required options: 21 | 22 | - choose custom issue field which will be used as 'time for today' field (field created on step 3) 23 | - choose issue status for which 'time for today' field is required. 24 | 25 | 6. Set up cron task for clearing 'Time for today' field value before business day starts (for instance: at 04:11 AM): 26 | ``` 27 | 11 4 * * * path/to/redmine/bin/rake clear_time_for_today_values 28 | ``` 29 | 30 | ## Usage 31 | 32 | 1. Use 'time for today' issue field for setting planned issue time for current day. 33 | 34 | 2. Use 'Workload Management' page for managing 'time for today' and/or taking issue to work in current day. 35 | 36 | 3. Define user groups permissions for getting access to 'Workload Management' page (Administration > Roles and permissions > Permissions report > View board). 37 | As these permissions are defined in the context of projects - user should be a member of the project (Administration > Projects). 38 | -------------------------------------------------------------------------------- /app/controllers/board_controller.rb: -------------------------------------------------------------------------------- 1 | class BoardController < ApplicationController 2 | default_search_scope :issues 3 | 4 | before_filter :find_projects, :authorize, :only => :index 5 | 6 | include BoardQueriesHelper 7 | include BoardsHelper 8 | include BoardHelper 9 | 10 | helper :journals 11 | helper :projects 12 | helper :custom_fields 13 | helper :issue_relations 14 | helper :watchers 15 | helper :attachments 16 | helper :queries 17 | helper :repositories 18 | helper :timelog 19 | 20 | IN_PROGRESS_STATUS_CODE = 1 21 | RESOLVED_STATUS_CODE = 2 22 | 23 | def find_projects 24 | # @project variable must be set before calling the authorize filter 25 | @projects = User.current.projects.to_a 26 | end 27 | 28 | def index 29 | retrieve_query 30 | 31 | if @query.valid? 32 | respond_to do |format| 33 | format.html { 34 | @issue_count = @query.issue_count 35 | @issue_pages = Paginator.new @issue_count, per_page_option, params['page'] 36 | @issues = @query.issues(:offset => @issue_pages.offset, :limit => @issue_pages.per_page) 37 | 38 | render :layout => !request.xhr? 39 | } 40 | end 41 | else 42 | respond_to do |format| 43 | format.html { render :layout => !request.xhr? } 44 | format.any(:atom, :csv, :pdf) { head 422 } 45 | format.api { render_validation_errors(@query) } 46 | end 47 | end 48 | rescue ActiveRecord::RecordNotFound 49 | render_404 50 | end 51 | 52 | def will_do_today 53 | @issue = Issue.find(params[:id]) 54 | issue_status_name = settings_today_time_status_name 55 | issue_status = IssueStatus.find_by_name(issue_status_name) 56 | 57 | if issue_status 58 | @issue.init_journal(User.current) 59 | 60 | @issue.status = issue_status 61 | @issue.assigned_to = User.current 62 | @issue.custom_field_values.each do |field| 63 | if field.custom_field.name == settings_today_time_field_name 64 | field.value = params[:time] 65 | end 66 | end 67 | 68 | BoardHelper.setHandleBoardUpdate(true) 69 | 70 | @issue.save(:validate => true) 71 | 72 | errors = @issue.errors.full_messages 73 | is_successful = errors.empty? 74 | else 75 | errors = [l(:notification_no_status_for_action)] 76 | is_successful = false 77 | end 78 | 79 | render :json => { 80 | :success => is_successful, 81 | :errors => errors, 82 | :info => l(:notification_issue_updated), 83 | :status => issue_status_name, 84 | :assignee => User.current.name 85 | } 86 | end 87 | 88 | def update_status 89 | @issue = Issue.find(params[:id]) 90 | status_code = params[:status].to_f 91 | issue_status_name = status_code == IN_PROGRESS_STATUS_CODE ? settings_in_progress_status_name : settings_resolved_status_name 92 | issue_status = IssueStatus.find_by_name(issue_status_name) 93 | 94 | if issue_status 95 | @issue.init_journal(User.current) 96 | 97 | @issue.status = issue_status 98 | @issue.assigned_to = User.current 99 | 100 | @issue.save(:validate => true) 101 | 102 | errors = @issue.errors.full_messages 103 | is_successful = errors.empty? 104 | else 105 | errors = [l(:notification_no_status_for_action)] 106 | is_successful = false 107 | end 108 | 109 | render :json => { 110 | :success => is_successful, 111 | :errors => errors, 112 | :info => l(:notification_issue_updated), 113 | :status => issue_status_name, 114 | :assignee => User.current.name 115 | } 116 | end 117 | 118 | def update_time_for_today 119 | @issue = Issue.find(params[:id]) 120 | is_changed = false 121 | errors = [] 122 | is_successful = true 123 | @issue.init_journal(User.current) 124 | 125 | @issue.custom_field_values.each do |field| 126 | if field.custom_field.name == settings_today_time_field_name 127 | field.value = params[:time] 128 | is_changed = field.value_was != field.value && field.value.to_f != 0 129 | end 130 | end 131 | 132 | if is_changed 133 | BoardHelper.setHandleBoardUpdate(true) 134 | 135 | @issue.save(:validate => true) 136 | 137 | errors = @issue.errors.full_messages 138 | is_successful = errors.empty? 139 | end 140 | 141 | render :json => { 142 | :success => is_successful, 143 | :is_changed => is_changed, 144 | :errors => errors, 145 | :info => l(:notification_time_updated), 146 | } 147 | end 148 | 149 | def time_for_today_statistic 150 | get_user_total_today_time 151 | 152 | render :json => { 153 | :time_total => @time_total, 154 | :time_pipeline => @time_pipeline 155 | } 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /app/helpers/board_helper.rb: -------------------------------------------------------------------------------- 1 | module BoardHelper 2 | 3 | # Bad solution (done as workaround) 4 | @@handling_board_update = false 5 | def self.setHandleBoardUpdate(x) 6 | @@handling_board_update = x 7 | end 8 | 9 | def self.getHandleBoardUpdate 10 | @@handling_board_update 11 | end 12 | 13 | def get_total_for_today(issue) 14 | real = '' 15 | suggested = 0 16 | issue.custom_field_values.each do |field| 17 | if field.custom_field.name == settings_today_time_field_name 18 | if field.value.nil? || field.value.to_f == 0.0 19 | suggested = issue.estimated_hours.to_f - issue.spent_hours.to_f 20 | suggested = suggested < 0 ? 0 : suggested 21 | else 22 | real = suggested = field.value.to_f 23 | end 24 | end 25 | end 26 | 27 | @real_time = real 28 | @suggested_time = suggested.round(2) 29 | end 30 | 31 | def settings_today_time_field_name 32 | (Setting.plugin_daily_workload_management && Setting.plugin_daily_workload_management.include?('field')) ? Setting.plugin_daily_workload_management['field'] : '' 33 | end 34 | 35 | def settings_today_time_status_name 36 | (Setting.plugin_daily_workload_management && Setting.plugin_daily_workload_management.include?('status')) ? Setting.plugin_daily_workload_management['status'] : '' 37 | end 38 | 39 | def settings_resolved_status_name 40 | (Setting.plugin_daily_workload_management && Setting.plugin_daily_workload_management.include?('settings_resolved_status')) ? Setting.plugin_daily_workload_management['settings_resolved_status'] : '' 41 | end 42 | 43 | def settings_in_progress_status_name 44 | (Setting.plugin_daily_workload_management && Setting.plugin_daily_workload_management.include?('settings_in_progress_status')) ? Setting.plugin_daily_workload_management['settings_in_progress_status'] : '' 45 | end 46 | 47 | def get_user_total_today_time 48 | today_status = IssueStatus.find_by_name(settings_today_time_status_name) 49 | total_for_today = 0 50 | 51 | if today_status.nil? 52 | return total_for_today 53 | end 54 | 55 | current_user = User.current 56 | if current_user.nil? 57 | return total_for_today 58 | end 59 | 60 | total_for_today = ActiveRecord::Base.connection.execute(" 61 | SELECT 62 | SUM(custom_values.value) AS h_total, 63 | SUM(CASE WHEN p_statuses.id IS NOT NULL THEN custom_values.value ELSE 0 END) AS h_pipeline 64 | FROM issues 65 | INNER JOIN custom_values ON issues.id = custom_values.customized_id 66 | LEFT JOIN issue_statuses AS p_statuses ON issues.status_id = p_statuses.id AND 67 | p_statuses.name = #{ActiveRecord::Base.sanitize(settings_today_time_status_name)} 68 | WHERE issues.assigned_to_id = #{current_user.id} 69 | AND custom_values.custom_field_id = #{IssueCustomField.find_by_name(settings_today_time_field_name).id}; 70 | ").to_a[0] 71 | 72 | time_tracked = ActiveRecord::Base.connection.execute(" 73 | SELECT 74 | SUM(hours) AS total_tracked 75 | FROM time_entries 76 | WHERE time_entries.user_id = #{current_user.id} 77 | AND time_entries.spent_on = '#{DateTime.now.strftime('%Y-%m-%d')}'; 78 | ").to_a[0] 79 | 80 | @time_total = (total_for_today[0].nil? ? 0 : total_for_today[0]).round(2) 81 | @time_pipeline = (total_for_today[1].nil? ? 0 : total_for_today[1]).round(2) 82 | @time_tracked = (time_tracked[0].nil? ? 0 : time_tracked[0]).round(2) 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /app/helpers/board_queries_helper.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Redmine - project management software 4 | # Copyright (C) 2006-2017 Jean-Philippe Lang 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 8 | # as published by the Free Software Foundation; either version 2 9 | # of the License, or (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program; if not, write to the Free Software 18 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 19 | 20 | module BoardQueriesHelper 21 | include ApplicationHelper 22 | 23 | def filters_options_for_select(query) 24 | ungrouped = [] 25 | grouped = {} 26 | query.available_filters.map do |field, field_options| 27 | if field_options[:type] == :relation 28 | group = :label_relations 29 | elsif field_options[:type] == :tree 30 | group = query.is_a?(BoardIssueQuery) ? :label_relations : nil 31 | elsif field =~ /^cf_\d+\./ 32 | group = (field_options[:through] || field_options[:field]).try(:name) 33 | elsif field =~ /^(.+)\./ 34 | # association filters 35 | group = "field_#{$1}".to_sym 36 | elsif %w(member_of_group assigned_to_role).include?(field) 37 | group = :field_assigned_to 38 | elsif field_options[:type] == :date_past || field_options[:type] == :date 39 | group = :label_date 40 | end 41 | if group 42 | (grouped[group] ||= []) << [field_options[:name], field] 43 | else 44 | ungrouped << [field_options[:name], field] 45 | end 46 | end 47 | # Don't group dates if there's only one (eg. time entries filters) 48 | if grouped[:label_date].try(:size) == 1 49 | ungrouped << grouped.delete(:label_date).first 50 | end 51 | s = options_for_select([[]] + ungrouped) 52 | if grouped.present? 53 | localized_grouped = grouped.map {|k,v| [k.is_a?(Symbol) ? l(k) : k.to_s, v]} 54 | s << grouped_options_for_select(localized_grouped) 55 | end 56 | s 57 | end 58 | 59 | def query_filters_hidden_tags(query) 60 | tags = ''.html_safe 61 | query.filters.each do |field, options| 62 | tags << hidden_field_tag("f[]", field, :id => nil) 63 | tags << hidden_field_tag("op[#{field}]", options[:operator], :id => nil) 64 | options[:values].each do |value| 65 | tags << hidden_field_tag("v[#{field}][]", value, :id => nil) 66 | end 67 | end 68 | tags 69 | end 70 | 71 | def query_columns_hidden_tags(query) 72 | tags = ''.html_safe 73 | query.columns.each do |column| 74 | tags << hidden_field_tag("c[]", column.name, :id => nil) 75 | end 76 | tags 77 | end 78 | 79 | def query_hidden_tags(query) 80 | query_filters_hidden_tags(query) + query_columns_hidden_tags(query) 81 | end 82 | 83 | def group_by_column_select_tag(query) 84 | options = [[]] + query.groupable_columns.collect {|c| [c.caption, c.name.to_s]} 85 | select_tag('group_by', options_for_select(options, @query.group_by)) 86 | end 87 | 88 | def available_block_columns_tags(query) 89 | tags = ''.html_safe 90 | query.available_block_columns.each do |column| 91 | tags << content_tag('label', check_box_tag('c[]', column.name.to_s, query.has_column?(column), :id => nil) + " #{column.caption}", :class => 'inline') 92 | end 93 | tags 94 | end 95 | 96 | def available_totalable_columns_tags(query) 97 | tags = ''.html_safe 98 | query.available_totalable_columns.each do |column| 99 | tags << content_tag('label', check_box_tag('t[]', column.name.to_s, query.totalable_columns.include?(column), :id => nil) + " #{column.caption}", :class => 'inline') 100 | end 101 | tags << hidden_field_tag('t[]', '') 102 | tags 103 | end 104 | 105 | def query_available_inline_columns_options(query) 106 | (query.available_inline_columns - query.columns).reject(&:frozen?).collect {|column| [column.caption, column.name]} 107 | end 108 | 109 | def query_selected_inline_columns_options(query) 110 | (query.inline_columns & query.available_inline_columns).reject(&:frozen?).collect {|column| [column.caption, column.name]} 111 | end 112 | 113 | def render_query_columns_selection(query, options={}) 114 | tag_name = (options[:name] || 'c') + '[]' 115 | render :partial => 'queries/columns', :locals => {:query => query, :tag_name => tag_name} 116 | end 117 | 118 | def grouped_query_results(items, query, &block) 119 | result_count_by_group = query.result_count_by_group 120 | previous_group, first = false, true 121 | totals_by_group = query.totalable_columns.inject({}) do |h, column| 122 | h[column] = query.total_by_group_for(column) 123 | h 124 | end 125 | items.each do |item| 126 | group_name = group_count = nil 127 | if query.grouped? 128 | group = query.group_by_column.value(item) 129 | if first || group != previous_group 130 | if group.blank? && group != false 131 | group_name = "(#{l(:label_blank_value)})" 132 | else 133 | group_name = format_object(group) 134 | end 135 | group_name ||= "" 136 | group_count = result_count_by_group ? result_count_by_group[group] : nil 137 | group_totals = totals_by_group.map {|column, t| total_tag(column, t[group] || 0)}.join(" ").html_safe 138 | end 139 | end 140 | yield item, group_name, group_count, group_totals 141 | previous_group, first = group, false 142 | end 143 | end 144 | 145 | def render_query_totals(query) 146 | return unless query.totalable_columns.present? 147 | totals = query.totalable_columns.map do |column| 148 | total_tag(column, query.total_for(column)) 149 | end 150 | content_tag('p', totals.join(" ").html_safe, :class => "query-totals") 151 | end 152 | 153 | def total_tag(column, value) 154 | label = content_tag('span', "#{column.caption}:") 155 | value = if [:hours, :spent_hours, :total_spent_hours, :estimated_hours].include? column.name 156 | format_hours(value) 157 | else 158 | format_object(value) 159 | end 160 | value = content_tag('span', value, :class => 'value') 161 | content_tag('span', label + " " + value, :class => "total-for-#{column.name.to_s.dasherize}") 162 | end 163 | 164 | def column_header(query, column, options={}) 165 | if column.sortable? 166 | css, order = nil, column.default_order 167 | if column.name.to_s == query.sort_criteria.first_key 168 | if query.sort_criteria.first_asc? 169 | css = 'sort asc' 170 | order = 'desc' 171 | else 172 | css = 'sort desc' 173 | order = 'asc' 174 | end 175 | end 176 | param_key = options[:sort_param] || :sort 177 | sort_param = { param_key => query.sort_criteria.add(column.name, order).to_param } 178 | while sort_param.keys.first.to_s =~ /^(.+)\[(.+)\]$/ 179 | sort_param = {$1 => {$2 => sort_param.values.first}} 180 | end 181 | link_options = { 182 | :title => l(:label_sort_by, "\"#{column.caption}\""), 183 | :class => css 184 | } 185 | if options[:sort_link_options] 186 | link_options.merge! options[:sort_link_options] 187 | end 188 | content = link_to(column.caption, 189 | {:params => request.query_parameters.deep_merge(sort_param)}, 190 | link_options 191 | ) 192 | else 193 | content = column.caption 194 | end 195 | content_tag('th', content) 196 | end 197 | 198 | def column_content(column, item) 199 | value = column.value_object(item) 200 | if value.is_a?(Array) 201 | values = value.collect {|v| column_value(column, item, v)}.compact 202 | safe_join(values, ', ') 203 | else 204 | column_value(column, item, value) 205 | end 206 | end 207 | 208 | def column_value(column, item, value) 209 | case column.name 210 | when :id 211 | link_to value, issue_path(item) 212 | when :subject 213 | link_to value, issue_path(item) 214 | when :parent 215 | value ? (value.visible? ? link_to_issue(value, :subject => false) : "##{value.id}") : '' 216 | when :description 217 | item.description? ? content_tag('div', textilizable(item, :description), :class => "wiki") : '' 218 | when :last_notes 219 | item.last_notes.present? ? content_tag('div', textilizable(item, :last_notes), :class => "wiki") : '' 220 | when :done_ratio 221 | progress_bar(value) 222 | when :relations 223 | content_tag('span', 224 | value.to_s(item) {|other| link_to_issue(other, :subject => false, :tracker => false)}.html_safe, 225 | :class => value.css_classes_for(item)) 226 | when :hours, :estimated_hours 227 | format_hours(value) 228 | when :spent_hours 229 | link_to_if(value > 0, format_hours(value), project_time_entries_path(item.project, :issue_id => "#{item.id}")) 230 | when :total_spent_hours 231 | link_to_if(value > 0, format_hours(value), project_time_entries_path(item.project, :issue_id => "~#{item.id}")) 232 | when :attachments 233 | value.to_a.map {|a| format_object(a)}.join(" ").html_safe 234 | else 235 | format_object(value) 236 | end 237 | end 238 | 239 | def csv_content(column, item) 240 | value = column.value_object(item) 241 | if value.is_a?(Array) 242 | value.collect {|v| csv_value(column, item, v)}.compact.join(', ') 243 | else 244 | csv_value(column, item, value) 245 | end 246 | end 247 | 248 | def csv_value(column, object, value) 249 | case column.name 250 | when :attachments 251 | value.to_a.map {|a| a.filename}.join("\n") 252 | else 253 | format_object(value, false) do |value| 254 | case value.class.name 255 | when 'Float' 256 | sprintf("%.2f", value).gsub('.', l(:general_csv_decimal_separator)) 257 | when 'IssueRelation' 258 | value.to_s(object) 259 | when 'Issue' 260 | if object.is_a?(TimeEntry) 261 | "#{value.tracker} ##{value.id}: #{value.subject}" 262 | else 263 | value.id 264 | end 265 | else 266 | value 267 | end 268 | end 269 | end 270 | end 271 | 272 | def query_to_csv(items, query, options={}) 273 | columns = query.columns 274 | 275 | Redmine::Export::CSV.generate do |csv| 276 | # csv header fields 277 | csv << columns.map {|c| c.caption.to_s} 278 | # csv lines 279 | items.each do |item| 280 | csv << columns.map {|c| csv_content(c, item)} 281 | end 282 | end 283 | end 284 | 285 | # Retrieve query from session or build a new query 286 | def retrieve_query(klass=BoardIssueQuery, use_session=true) 287 | session_key = klass.name.underscore.to_sym 288 | 289 | 290 | prepared_ids_values = [] 291 | IssueStatus.where(:is_closed => 1).ids.each do |id| 292 | prepared_ids_values.push("#{id}") 293 | end 294 | 295 | if params[:query_id].present? 296 | cond = "project_id IS NULL" 297 | cond << " OR project_id = #{@project.id}" if @project 298 | @query = klass.where(cond).find(params[:query_id]) 299 | raise ::Unauthorized unless @query.visible? 300 | @query.project = @project 301 | session[session_key] = {:id => @query.id, :project_id => @query.project_id} if use_session 302 | elsif api_request? || params[:set_filter] || !use_session || session[session_key].nil? || session[session_key][:project_id] != (@project ? @project.id : nil) 303 | # Give it a name, required to be valid 304 | @query = klass.new(:name => "_", :project => @project) 305 | @query.build_from_params(params) 306 | 307 | 308 | if session[session_key].nil? 309 | 310 | default_filters = { 311 | "status_id" => { 312 | :operator => "!", 313 | :values => prepared_ids_values 314 | }, 315 | "assigned_to_id" => { 316 | :operator => "=", 317 | :values => ["me"] 318 | } 319 | } 320 | 321 | default_sorters = [['priority', 'desc'], ['id', 'desc']] 322 | 323 | 324 | @query.filters = default_filters 325 | @query.sort_criteria = default_sorters 326 | end 327 | 328 | session[session_key] = {:project_id => @query.project_id, :filters => @query.filters, :group_by => @query.group_by, :column_names => @query.column_names, :totalable_names => @query.totalable_names, :sort => @query.sort_criteria.to_a} if use_session 329 | else 330 | # retrieve from session 331 | @query = nil 332 | @query = klass.find_by_id(session[session_key][:id]) if session[session_key][:id] 333 | @query ||= klass.new(:name => "_", :filters => session[session_key][:filters], :group_by => session[session_key][:group_by], :column_names => session[session_key][:column_names], :totalable_names => session[session_key][:totalable_names], :sort_criteria => session[session_key][:sort]) 334 | @query.project = @project 335 | end 336 | if params[:sort].present? 337 | @query.sort_criteria = params[:sort] 338 | if use_session 339 | session[session_key] ||= {} 340 | session[session_key][:sort] = @query.sort_criteria.to_a 341 | end 342 | end 343 | @query 344 | end 345 | 346 | def retrieve_query_from_session(klass=BoardIssueQuery) 347 | session_key = klass.name.underscore.to_sym 348 | session_data = session[session_key] 349 | 350 | if session_data 351 | if session_data[:id] 352 | @query = BoardIssueQuery.find_by_id(session_data[:id]) 353 | return unless @query 354 | else 355 | @query = BoardIssueQuery.new(:name => "_", :filters => session_data[:filters], :group_by => session_data[:group_by], :column_names => session_data[:column_names], :totalable_names => session_data[:totalable_names], :sort_criteria => session[session_key][:sort]) 356 | end 357 | if session_data.has_key?(:project_id) 358 | @query.project_id = session_data[:project_id] 359 | else 360 | @query.project = @project 361 | end 362 | @query 363 | end 364 | end 365 | 366 | # Returns the query definition as hidden field tags 367 | def query_as_hidden_field_tags(query) 368 | tags = hidden_field_tag("set_filter", "1", :id => nil) 369 | 370 | if query.filters.present? 371 | query.filters.each do |field, filter| 372 | tags << hidden_field_tag("f[]", field, :id => nil) 373 | tags << hidden_field_tag("op[#{field}]", filter[:operator], :id => nil) 374 | filter[:values].each do |value| 375 | tags << hidden_field_tag("v[#{field}][]", value, :id => nil) 376 | end 377 | end 378 | else 379 | tags << hidden_field_tag("f[]", "", :id => nil) 380 | end 381 | query.columns.each do |column| 382 | tags << hidden_field_tag("c[]", column.name, :id => nil) 383 | end 384 | if query.totalable_names.present? 385 | query.totalable_names.each do |name| 386 | tags << hidden_field_tag("t[]", name, :id => nil) 387 | end 388 | end 389 | if query.group_by.present? 390 | tags << hidden_field_tag("group_by", query.group_by, :id => nil) 391 | end 392 | if query.sort_criteria.present? 393 | tags << hidden_field_tag("sort", query.sort_criteria.to_param, :id => nil) 394 | end 395 | 396 | tags 397 | end 398 | 399 | def query_hidden_sort_tag(query) 400 | hidden_field_tag("sort", query.sort_criteria.to_param, :id => nil) 401 | end 402 | 403 | # Returns the queries that are rendered in the sidebar 404 | def sidebar_queries(klass, project) 405 | klass.visible.global_or_on_project(@project).sorted.to_a 406 | end 407 | 408 | # Renders a group of queries 409 | def query_links(title, queries) 410 | return '' if queries.empty? 411 | # links to #index on issues/show 412 | url_params = controller_name == 'issues' ? {:controller => 'issues', :action => 'index', :project_id => @project} : {} 413 | 414 | content_tag('h3', title) + "\n" + 415 | content_tag('ul', 416 | queries.collect {|query| 417 | css = 'query' 418 | css << ' selected' if query == @query 419 | content_tag('li', link_to(query.name, url_params.merge(:query_id => query), :class => css)) 420 | }.join("\n").html_safe, 421 | :class => 'queries' 422 | ) + "\n" 423 | end 424 | 425 | # Renders the list of queries for the sidebar 426 | def render_sidebar_queries(klass, project) 427 | queries = sidebar_queries(klass, project) 428 | 429 | out = ''.html_safe 430 | out << query_links(l(:label_my_queries), queries.select(&:is_private?)) 431 | out << query_links(l(:label_query_plural), queries.reject(&:is_private?)) 432 | out 433 | end 434 | end 435 | -------------------------------------------------------------------------------- /app/helpers/workload_management_settings_helper.rb: -------------------------------------------------------------------------------- 1 | module WorkloadManagementSettingsHelper 2 | def time_for_today_required_statuses 3 | allStatuses = IssueStatus.all.sorted 4 | end 5 | 6 | def issue_custom_fields 7 | allIssueCustomFields = IssueCustomField.all.sorted 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/models/board_issue_query.rb: -------------------------------------------------------------------------------- 1 | class BoardIssueQuery < Query 2 | 3 | self.queried_class = Issue 4 | self.view_permission = :view_issues 5 | 6 | self.available_columns = [ 7 | QueryColumn.new(:id, :sortable => "#{Issue.table_name}.id", :default_order => 'desc', :caption => '#', :frozen => true), 8 | QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true), 9 | QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true), 10 | QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true), 11 | QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"), 12 | QueryColumn.new(:author, :sortable => lambda {User.fields_for_order_statement("authors")}, :groupable => true), 13 | QueryColumn.new(:assigned_to, :sortable => lambda {User.fields_for_order_statement}, :groupable => true), 14 | QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours", :totalable => true), 15 | ] 16 | 17 | def initialize(attributes=nil, *args) 18 | super attributes 19 | self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} } 20 | end 21 | 22 | def columns 23 | available_columns 24 | end 25 | 26 | def draw_relations 27 | r = options[:draw_relations] 28 | r.nil? || r == '1' 29 | end 30 | 31 | def draw_relations=(arg) 32 | options[:draw_relations] = (arg == '0' ? '0' : nil) 33 | end 34 | 35 | def draw_progress_line 36 | r = options[:draw_progress_line] 37 | r == '1' 38 | end 39 | 40 | def draw_progress_line=(arg) 41 | options[:draw_progress_line] = (arg == '1' ? '1' : nil) 42 | end 43 | 44 | def build_from_params(params) 45 | super 46 | self.draw_relations = params[:draw_relations] || (params[:query] && params[:query][:draw_relations]) 47 | self.draw_progress_line = params[:draw_progress_line] || (params[:query] && params[:query][:draw_progress_line]) 48 | self 49 | end 50 | 51 | def initialize_available_filters 52 | add_available_filter "status_id", 53 | :type => :list_status, :values => lambda { issue_statuses_values } 54 | 55 | add_available_filter("project_id", 56 | :type => :list, :values => lambda { project_values } 57 | ) if project.nil? 58 | 59 | add_available_filter "tracker_id", 60 | :type => :list, :values => trackers.collect{|s| [s.name, s.id.to_s] } 61 | 62 | add_available_filter "priority_id", 63 | :type => :list, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] } 64 | 65 | add_available_filter("author_id", 66 | :type => :list, :values => lambda { author_values } 67 | ) 68 | 69 | add_available_filter("assigned_to_id", 70 | :type => :list_optional, :values => lambda { assigned_to_values } 71 | ) 72 | 73 | add_available_filter("member_of_group", 74 | :type => :list_optional, :values => lambda { Group.givable.visible.collect {|g| [g.name, g.id.to_s] } } 75 | ) 76 | 77 | add_available_filter("assigned_to_role", 78 | :type => :list_optional, :values => lambda { Role.givable.collect {|r| [r.name, r.id.to_s] } } 79 | ) 80 | 81 | add_available_filter "fixed_version_id", 82 | :type => :list_optional, :values => lambda { fixed_version_values } 83 | 84 | add_available_filter "fixed_version.due_date", 85 | :type => :date, 86 | :name => l(:label_attribute_of_fixed_version, :name => l(:field_effective_date)) 87 | 88 | add_available_filter "fixed_version.status", 89 | :type => :list, 90 | :name => l(:label_attribute_of_fixed_version, :name => l(:field_status)), 91 | :values => Version::VERSION_STATUSES.map{|s| [l("version_status_#{s}"), s] } 92 | 93 | add_available_filter "category_id", 94 | :type => :list_optional, 95 | :values => lambda { project.issue_categories.collect{|s| [s.name, s.id.to_s] } } if project 96 | 97 | add_available_filter "subject", :type => :text 98 | add_available_filter "description", :type => :text 99 | add_available_filter "created_on", :type => :date_past 100 | add_available_filter "updated_on", :type => :date_past 101 | add_available_filter "closed_on", :type => :date_past 102 | add_available_filter "start_date", :type => :date 103 | add_available_filter "due_date", :type => :date 104 | add_available_filter "estimated_hours", :type => :float 105 | add_available_filter "done_ratio", :type => :integer 106 | 107 | if User.current.allowed_to?(:set_issues_private, nil, :global => true) || 108 | User.current.allowed_to?(:set_own_issues_private, nil, :global => true) 109 | add_available_filter "is_private", 110 | :type => :list, 111 | :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]] 112 | end 113 | 114 | add_available_filter "attachment", 115 | :type => :text, :name => l(:label_attachment) 116 | 117 | if User.current.logged? 118 | add_available_filter "watcher_id", 119 | :type => :list, :values => [["<< #{l(:label_me)} >>", "me"]] 120 | end 121 | 122 | add_available_filter("updated_by", 123 | :type => :list, :values => lambda { author_values } 124 | ) 125 | 126 | add_available_filter("last_updated_by", 127 | :type => :list, :values => lambda { author_values } 128 | ) 129 | 130 | if project && !project.leaf? 131 | add_available_filter "subproject_id", 132 | :type => :list_subprojects, 133 | :values => lambda { subproject_values } 134 | end 135 | 136 | add_custom_fields_filters(issue_custom_fields) 137 | add_associations_custom_fields_filters :project, :author, :assigned_to, :fixed_version 138 | 139 | IssueRelation::TYPES.each do |relation_type, options| 140 | add_available_filter relation_type, :type => :relation, :label => options[:name], :values => lambda {all_projects_values} 141 | end 142 | add_available_filter "parent_id", :type => :tree, :label => :field_parent_issue 143 | add_available_filter "child_id", :type => :tree, :label => :label_subtask_plural 144 | 145 | add_available_filter "issue_id", :type => :integer, :label => :label_issue 146 | 147 | Tracker.disabled_core_fields(trackers).each {|field| 148 | delete_available_filter field 149 | } 150 | end 151 | 152 | def available_columns 153 | return @available_columns if @available_columns 154 | @available_columns = self.class.available_columns.dup 155 | @available_columns += issue_custom_fields.visible.collect {|cf| QueryCustomFieldColumn.new(cf) } 156 | 157 | if User.current.allowed_to?(:view_time_entries, project, :global => true) 158 | # insert the columns after total_estimated_hours or at the end 159 | index = @available_columns.find_index {|column| column.name == :total_estimated_hours} 160 | index = (index ? index + 1 : -1) 161 | 162 | subselect = "SELECT SUM(hours) FROM #{TimeEntry.table_name}" + 163 | " JOIN #{Project.table_name} ON #{Project.table_name}.id = #{TimeEntry.table_name}.project_id" + 164 | " WHERE (#{TimeEntry.visible_condition(User.current)}) AND #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id" 165 | 166 | @available_columns.insert index, QueryColumn.new(:spent_hours, 167 | :sortable => "COALESCE((#{subselect}), 0)", 168 | :default_order => 'desc', 169 | :caption => :label_spent_time, 170 | :totalable => true 171 | ) 172 | 173 | subselect = "SELECT SUM(hours) FROM #{TimeEntry.table_name}" + 174 | " JOIN #{Project.table_name} ON #{Project.table_name}.id = #{TimeEntry.table_name}.project_id" + 175 | " JOIN #{Issue.table_name} subtasks ON subtasks.id = #{TimeEntry.table_name}.issue_id" + 176 | " WHERE (#{TimeEntry.visible_condition(User.current)})" + 177 | " AND subtasks.root_id = #{Issue.table_name}.root_id AND subtasks.lft >= #{Issue.table_name}.lft AND subtasks.rgt <= #{Issue.table_name}.rgt" 178 | 179 | @available_columns.insert index+1, QueryColumn.new(:total_spent_hours, 180 | :sortable => "COALESCE((#{subselect}), 0)", 181 | :default_order => 'desc', 182 | :caption => :label_total_spent_time 183 | ) 184 | end 185 | 186 | if User.current.allowed_to?(:set_issues_private, nil, :global => true) || 187 | User.current.allowed_to?(:set_own_issues_private, nil, :global => true) 188 | @available_columns << QueryColumn.new(:is_private, :sortable => "#{Issue.table_name}.is_private", :groupable => true) 189 | end 190 | 191 | disabled_fields = Tracker.disabled_core_fields(trackers).map {|field| field.sub(/_id$/, '')} 192 | @available_columns.reject! {|column| 193 | disabled_fields.include?(column.name.to_s) 194 | } 195 | 196 | @available_columns 197 | end 198 | 199 | def default_columns_names 200 | @default_columns_names ||= begin 201 | default_columns = Setting.issue_list_default_columns.map(&:to_sym) 202 | 203 | project.present? ? default_columns : [:project] | default_columns 204 | end 205 | end 206 | 207 | def default_totalable_names 208 | Setting.issue_list_default_totals.map(&:to_sym) 209 | end 210 | 211 | def default_sort_criteria 212 | [['id', 'desc']] 213 | end 214 | 215 | def base_scope 216 | Issue.visible.joins(:status, :project).where(statement) 217 | end 218 | 219 | # Returns the issue count 220 | def issue_count 221 | base_scope.count 222 | rescue ::ActiveRecord::StatementInvalid => e 223 | raise StatementInvalid.new(e.message) 224 | end 225 | 226 | # Returns sum of all the issue's estimated_hours 227 | def total_for_estimated_hours(scope) 228 | map_total(scope.sum(:estimated_hours)) {|t| t.to_f.round(2)} 229 | end 230 | 231 | # Returns sum of all the issue's time entries hours 232 | def total_for_spent_hours(scope) 233 | total = scope.joins(:time_entries). 234 | where(TimeEntry.visible_condition(User.current)). 235 | sum("#{TimeEntry.table_name}.hours") 236 | 237 | map_total(total) {|t| t.to_f.round(2)} 238 | end 239 | 240 | # Returns the issues 241 | # Valid options are :order, :offset, :limit, :include, :conditions 242 | def issues(options={}) 243 | order_option = [group_by_sort_order, (options[:order] || sort_clause)].flatten.reject(&:blank?) 244 | 245 | scope = Issue.visible. 246 | joins(:status, :project). 247 | preload(:priority). 248 | where(statement). 249 | includes(([:status, :project] + (options[:include] || [])).uniq). 250 | where(options[:conditions]). 251 | order(order_option). 252 | joins(joins_for_order_statement(order_option.join(','))). 253 | limit(options[:limit]). 254 | offset(options[:offset]) 255 | 256 | scope = scope.preload([:tracker, :author, :assigned_to, :fixed_version, :category, :attachments] & columns.map(&:name)) 257 | if has_custom_field_column? 258 | scope = scope.preload(:custom_values) 259 | end 260 | 261 | issues = scope.to_a 262 | 263 | if has_column?(:spent_hours) 264 | Issue.load_visible_spent_hours(issues) 265 | end 266 | if has_column?(:total_spent_hours) 267 | Issue.load_visible_total_spent_hours(issues) 268 | end 269 | if has_column?(:last_updated_by) 270 | Issue.load_visible_last_updated_by(issues) 271 | end 272 | if has_column?(:relations) 273 | Issue.load_visible_relations(issues) 274 | end 275 | if has_column?(:last_notes) 276 | Issue.load_visible_last_notes(issues) 277 | end 278 | issues 279 | rescue ::ActiveRecord::StatementInvalid => e 280 | raise StatementInvalid.new(e.message) 281 | end 282 | 283 | # Returns the issues ids 284 | def issue_ids(options={}) 285 | order_option = [group_by_sort_order, (options[:order] || sort_clause)].flatten.reject(&:blank?) 286 | 287 | Issue.visible. 288 | joins(:status, :project). 289 | where(statement). 290 | includes(([:status, :project] + (options[:include] || [])).uniq). 291 | references(([:status, :project] + (options[:include] || [])).uniq). 292 | where(options[:conditions]). 293 | order(order_option). 294 | joins(joins_for_order_statement(order_option.join(','))). 295 | limit(options[:limit]). 296 | offset(options[:offset]). 297 | pluck(:id) 298 | rescue ::ActiveRecord::StatementInvalid => e 299 | raise StatementInvalid.new(e.message) 300 | end 301 | 302 | # Returns the journals 303 | # Valid options are :order, :offset, :limit 304 | def journals(options={}) 305 | Journal.visible. 306 | joins(:issue => [:project, :status]). 307 | where(statement). 308 | order(options[:order]). 309 | limit(options[:limit]). 310 | offset(options[:offset]). 311 | preload(:details, :user, {:issue => [:project, :author, :tracker, :status]}). 312 | to_a 313 | rescue ::ActiveRecord::StatementInvalid => e 314 | raise StatementInvalid.new(e.message) 315 | end 316 | 317 | # Returns the versions 318 | # Valid options are :conditions 319 | def versions(options={}) 320 | Version.visible. 321 | where(project_statement). 322 | where(options[:conditions]). 323 | includes(:project). 324 | references(:project). 325 | to_a 326 | rescue ::ActiveRecord::StatementInvalid => e 327 | raise StatementInvalid.new(e.message) 328 | end 329 | 330 | def sql_for_updated_by_field(field, operator, value) 331 | neg = (operator == '!' ? 'NOT' : '') 332 | subquery = "SELECT 1 FROM #{Journal.table_name}" + 333 | " WHERE #{Journal.table_name}.journalized_type='Issue' AND #{Journal.table_name}.journalized_id=#{Issue.table_name}.id" + 334 | " AND (#{sql_for_field field, '=', value, Journal.table_name, 'user_id'})" + 335 | " AND (#{Journal.visible_notes_condition(User.current, :skip_pre_condition => true)})" 336 | 337 | "#{neg} EXISTS (#{subquery})" 338 | end 339 | 340 | def sql_for_last_updated_by_field(field, operator, value) 341 | neg = (operator == '!' ? 'NOT' : '') 342 | subquery = "SELECT 1 FROM #{Journal.table_name} sj" + 343 | " WHERE sj.journalized_type='Issue' AND sj.journalized_id=#{Issue.table_name}.id AND (#{sql_for_field field, '=', value, 'sj', 'user_id'})" + 344 | " AND sj.id = (SELECT MAX(#{Journal.table_name}.id) FROM #{Journal.table_name}" + 345 | " WHERE #{Journal.table_name}.journalized_type='Issue' AND #{Journal.table_name}.journalized_id=#{Issue.table_name}.id" + 346 | " AND (#{Journal.visible_notes_condition(User.current, :skip_pre_condition => true)}))" 347 | 348 | "#{neg} EXISTS (#{subquery})" 349 | end 350 | 351 | def sql_for_watcher_id_field(field, operator, value) 352 | db_table = Watcher.table_name 353 | "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND " + 354 | sql_for_field(field, '=', value, db_table, 'user_id') + ')' 355 | end 356 | 357 | def sql_for_member_of_group_field(field, operator, value) 358 | if operator == '*' # Any group 359 | groups = Group.givable 360 | operator = '=' # Override the operator since we want to find by assigned_to 361 | elsif operator == "!*" 362 | groups = Group.givable 363 | operator = '!' # Override the operator since we want to find by assigned_to 364 | else 365 | groups = Group.where(:id => value).to_a 366 | end 367 | groups ||= [] 368 | 369 | members_of_groups = groups.inject([]) {|user_ids, group| 370 | user_ids + group.user_ids + [group.id] 371 | }.uniq.compact.sort.collect(&:to_s) 372 | 373 | '(' + sql_for_field("assigned_to_id", operator, members_of_groups, Issue.table_name, "assigned_to_id", false) + ')' 374 | end 375 | 376 | def sql_for_assigned_to_role_field(field, operator, value) 377 | case operator 378 | when "*", "!*" # Member / Not member 379 | sw = operator == "!*" ? 'NOT' : '' 380 | nl = operator == "!*" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : '' 381 | "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}" + 382 | " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id))" 383 | when "=", "!" 384 | role_cond = value.any? ? 385 | "#{MemberRole.table_name}.role_id IN (" + value.collect{|val| "'#{self.class.connection.quote_string(val)}'"}.join(",") + ")" : 386 | "1=0" 387 | 388 | sw = operator == "!" ? 'NOT' : '' 389 | nl = operator == "!" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : '' 390 | "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}, #{MemberRole.table_name}" + 391 | " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id AND #{Member.table_name}.id = #{MemberRole.table_name}.member_id AND #{role_cond}))" 392 | end 393 | end 394 | 395 | def sql_for_fixed_version_status_field(field, operator, value) 396 | where = sql_for_field(field, operator, value, Version.table_name, "status") 397 | version_ids = versions(:conditions => [where]).map(&:id) 398 | 399 | nl = operator == "!" ? "#{Issue.table_name}.fixed_version_id IS NULL OR" : '' 400 | "(#{nl} #{sql_for_field("fixed_version_id", "=", version_ids, Issue.table_name, "fixed_version_id")})" 401 | end 402 | 403 | def sql_for_fixed_version_due_date_field(field, operator, value) 404 | where = sql_for_field(field, operator, value, Version.table_name, "effective_date") 405 | version_ids = versions(:conditions => [where]).map(&:id) 406 | 407 | nl = operator == "!*" ? "#{Issue.table_name}.fixed_version_id IS NULL OR" : '' 408 | "(#{nl} #{sql_for_field("fixed_version_id", "=", version_ids, Issue.table_name, "fixed_version_id")})" 409 | end 410 | 411 | def sql_for_is_private_field(field, operator, value) 412 | op = (operator == "=" ? 'IN' : 'NOT IN') 413 | va = value.map {|v| v == '0' ? self.class.connection.quoted_false : self.class.connection.quoted_true}.uniq.join(',') 414 | 415 | "#{Issue.table_name}.is_private #{op} (#{va})" 416 | end 417 | 418 | def sql_for_attachment_field(field, operator, value) 419 | case operator 420 | when "*", "!*" 421 | e = (operator == "*" ? "EXISTS" : "NOT EXISTS") 422 | "#{e} (SELECT 1 FROM #{Attachment.table_name} a WHERE a.container_type = 'Issue' AND a.container_id = #{Issue.table_name}.id)" 423 | when "~", "!~" 424 | c = sql_contains("a.filename", value.first) 425 | e = (operator == "~" ? "EXISTS" : "NOT EXISTS") 426 | "#{e} (SELECT 1 FROM #{Attachment.table_name} a WHERE a.container_type = 'Issue' AND a.container_id = #{Issue.table_name}.id AND #{c})" 427 | end 428 | end 429 | 430 | def sql_for_parent_id_field(field, operator, value) 431 | case operator 432 | when "=" 433 | "#{Issue.table_name}.parent_id = #{value.first.to_i}" 434 | when "~" 435 | root_id, lft, rgt = Issue.where(:id => value.first.to_i).pluck(:root_id, :lft, :rgt).first 436 | if root_id && lft && rgt 437 | "#{Issue.table_name}.root_id = #{root_id} AND #{Issue.table_name}.lft > #{lft} AND #{Issue.table_name}.rgt < #{rgt}" 438 | else 439 | "1=0" 440 | end 441 | when "!*" 442 | "#{Issue.table_name}.parent_id IS NULL" 443 | when "*" 444 | "#{Issue.table_name}.parent_id IS NOT NULL" 445 | end 446 | end 447 | 448 | def sql_for_child_id_field(field, operator, value) 449 | case operator 450 | when "=" 451 | parent_id = Issue.where(:id => value.first.to_i).pluck(:parent_id).first 452 | if parent_id 453 | "#{Issue.table_name}.id = #{parent_id}" 454 | else 455 | "1=0" 456 | end 457 | when "~" 458 | root_id, lft, rgt = Issue.where(:id => value.first.to_i).pluck(:root_id, :lft, :rgt).first 459 | if root_id && lft && rgt 460 | "#{Issue.table_name}.root_id = #{root_id} AND #{Issue.table_name}.lft < #{lft} AND #{Issue.table_name}.rgt > #{rgt}" 461 | else 462 | "1=0" 463 | end 464 | when "!*" 465 | "#{Issue.table_name}.rgt - #{Issue.table_name}.lft = 1" 466 | when "*" 467 | "#{Issue.table_name}.rgt - #{Issue.table_name}.lft > 1" 468 | end 469 | end 470 | 471 | def sql_for_updated_on_field(field, operator, value) 472 | case operator 473 | when "!*" 474 | "#{Issue.table_name}.updated_on = #{Issue.table_name}.created_on" 475 | when "*" 476 | "#{Issue.table_name}.updated_on > #{Issue.table_name}.created_on" 477 | else 478 | sql_for_field("updated_on", operator, value, Issue.table_name, "updated_on") 479 | end 480 | end 481 | 482 | def sql_for_issue_id_field(field, operator, value) 483 | if operator == "=" 484 | # accepts a comma separated list of ids 485 | ids = value.first.to_s.scan(/\d+/).map(&:to_i) 486 | if ids.present? 487 | "#{Issue.table_name}.id IN (#{ids.join(",")})" 488 | else 489 | "1=0" 490 | end 491 | else 492 | sql_for_field("id", operator, value, Issue.table_name, "id") 493 | end 494 | end 495 | 496 | def sql_for_relations(field, operator, value, options={}) 497 | relation_options = IssueRelation::TYPES[field] 498 | return relation_options unless relation_options 499 | 500 | relation_type = field 501 | join_column, target_join_column = "issue_from_id", "issue_to_id" 502 | if relation_options[:reverse] || options[:reverse] 503 | relation_type = relation_options[:reverse] || relation_type 504 | join_column, target_join_column = target_join_column, join_column 505 | end 506 | 507 | sql = case operator 508 | when "*", "!*" 509 | op = (operator == "*" ? 'IN' : 'NOT IN') 510 | "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{self.class.connection.quote_string(relation_type)}')" 511 | when "=", "!" 512 | op = (operator == "=" ? 'IN' : 'NOT IN') 513 | "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{self.class.connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = #{value.first.to_i})" 514 | when "=p", "=!p", "!p" 515 | op = (operator == "!p" ? 'NOT IN' : 'IN') 516 | comp = (operator == "=!p" ? '<>' : '=') 517 | "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name}, #{Issue.table_name} relissues WHERE #{IssueRelation.table_name}.relation_type = '#{self.class.connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = relissues.id AND relissues.project_id #{comp} #{value.first.to_i})" 518 | when "*o", "!o" 519 | op = (operator == "!o" ? 'NOT IN' : 'IN') 520 | "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name}, #{Issue.table_name} relissues WHERE #{IssueRelation.table_name}.relation_type = '#{self.class.connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = relissues.id AND relissues.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{self.class.connection.quoted_false}))" 521 | end 522 | 523 | if relation_options[:sym] == field && !options[:reverse] 524 | sqls = [sql, sql_for_relations(field, operator, value, :reverse => true)] 525 | sql = sqls.join(["!", "!*", "!p", '!o'].include?(operator) ? " AND " : " OR ") 526 | end 527 | "(#{sql})" 528 | end 529 | 530 | def find_assigned_to_id_filter_values(values) 531 | Principal.visible.where(:id => values).map {|p| [p.name, p.id.to_s]} 532 | end 533 | alias :find_author_id_filter_values :find_assigned_to_id_filter_values 534 | 535 | IssueRelation::TYPES.keys.each do |relation_type| 536 | alias_method "sql_for_#{relation_type}_field".to_sym, :sql_for_relations 537 | end 538 | 539 | def joins_for_order_statement(order_options) 540 | joins = [super] 541 | 542 | if order_options 543 | if order_options.include?('authors') 544 | joins << "LEFT OUTER JOIN #{User.table_name} authors ON authors.id = #{queried_table_name}.author_id" 545 | end 546 | if order_options.include?('users') 547 | joins << "LEFT OUTER JOIN #{User.table_name} ON #{User.table_name}.id = #{queried_table_name}.assigned_to_id" 548 | end 549 | if order_options.include?('last_journal_user') 550 | joins << "LEFT OUTER JOIN #{Journal.table_name} ON #{Journal.table_name}.id = (SELECT MAX(#{Journal.table_name}.id) FROM #{Journal.table_name}" + 551 | " WHERE #{Journal.table_name}.journalized_type='Issue' AND #{Journal.table_name}.journalized_id=#{Issue.table_name}.id AND #{Journal.visible_notes_condition(User.current, :skip_pre_condition => true)})" + 552 | " LEFT OUTER JOIN #{User.table_name} last_journal_user ON last_journal_user.id = #{Journal.table_name}.user_id"; 553 | end 554 | if order_options.include?('versions') 555 | joins << "LEFT OUTER JOIN #{Version.table_name} ON #{Version.table_name}.id = #{queried_table_name}.fixed_version_id" 556 | end 557 | if order_options.include?('issue_categories') 558 | joins << "LEFT OUTER JOIN #{IssueCategory.table_name} ON #{IssueCategory.table_name}.id = #{queried_table_name}.category_id" 559 | end 560 | if order_options.include?('trackers') 561 | joins << "LEFT OUTER JOIN #{Tracker.table_name} ON #{Tracker.table_name}.id = #{queried_table_name}.tracker_id" 562 | end 563 | if order_options.include?('enumerations') 564 | joins << "LEFT OUTER JOIN #{IssuePriority.table_name} ON #{IssuePriority.table_name}.id = #{queried_table_name}.priority_id" 565 | end 566 | end 567 | 568 | joins.any? ? joins.join(' ') : nil 569 | end 570 | end 571 | -------------------------------------------------------------------------------- /app/models/issue_patch.rb: -------------------------------------------------------------------------------- 1 | module IssuePatch 2 | 3 | include BoardHelper 4 | 5 | def self.included(base) 6 | base.send(:include, ExtraMethods) 7 | 8 | base.class_eval do 9 | alias_method_chain :validate_issue, :total_for_today 10 | end 11 | end 12 | 13 | module ExtraMethods 14 | def validate_issue_with_total_for_today 15 | if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date 16 | errors.add :due_date, :greater_than_start_date 17 | end 18 | 19 | if start_date && start_date_changed? && soonest_start && start_date < soonest_start 20 | errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start) 21 | end 22 | 23 | if fixed_version 24 | if !assignable_versions.include?(fixed_version) 25 | errors.add :fixed_version_id, :inclusion 26 | elsif reopening? && fixed_version.closed? 27 | errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version) 28 | end 29 | end 30 | 31 | # Checks that the issue can not be added/moved to a disabled tracker 32 | if project && (tracker_id_changed? || project_id_changed?) 33 | if tracker && !project.trackers.include?(tracker) 34 | errors.add :tracker_id, :inclusion 35 | end 36 | end 37 | 38 | if assigned_to_id_changed? && assigned_to_id.present? 39 | unless assignable_users.include?(assigned_to) 40 | errors.add :assigned_to_id, :invalid 41 | end 42 | end 43 | 44 | # Checks parent issue assignment 45 | if @invalid_parent_issue_id.present? 46 | errors.add :parent_issue_id, :invalid 47 | elsif @parent_issue 48 | if !valid_parent_project?(@parent_issue) 49 | errors.add :parent_issue_id, :invalid 50 | elsif (@parent_issue != parent) && ( 51 | self.would_reschedule?(@parent_issue) || 52 | @parent_issue.self_and_ancestors.any? {|a| a.relations_from.any? {|r| r.relation_type == IssueRelation::TYPE_PRECEDES && r.issue_to.would_reschedule?(self)}} 53 | ) 54 | errors.add :parent_issue_id, :invalid 55 | elsif !closed? && @parent_issue.closed? 56 | # cannot attach an open issue to a closed parent 57 | errors.add :base, :open_issue_with_closed_parent 58 | elsif !new_record? 59 | # moving an existing issue 60 | if move_possible?(@parent_issue) 61 | # move accepted 62 | else 63 | errors.add :parent_issue_id, :invalid 64 | end 65 | end 66 | end 67 | 68 | if BoardHelper.getHandleBoardUpdate 69 | BoardHelper.setHandleBoardUpdate(false) 70 | self.validate_total_for_today 71 | end 72 | 73 | end 74 | 75 | # Validates total for today field 76 | def validate_total_for_today 77 | issue_status = IssueStatus.find(self.status_id) 78 | issue_status_name = issue_status.name 79 | 80 | self.custom_field_values.each do |custom_field_value| 81 | custom_field_name = custom_field_value.custom_field.name 82 | if custom_field_name == settings_today_time_field_name && issue_status_name == settings_today_time_status_name 83 | estimated_hours = self.estimated_hours.to_f 84 | spent_hours = self.spent_hours 85 | left_hours = (estimated_hours - spent_hours).round(2) 86 | custom_field_value = custom_field_value.value.to_f 87 | if estimated_hours != 0 && left_hours < custom_field_value 88 | errors.add custom_field_name, l(:notification_should_be_greater, :left_hours => left_hours.to_s) 89 | end 90 | end 91 | end 92 | end 93 | end 94 | end 95 | 96 | Issue.send(:include, IssuePatch) 97 | -------------------------------------------------------------------------------- /app/views/board/index.html.erb: -------------------------------------------------------------------------------- 1 | <% query_options = nil unless defined?(query_options) %> 2 | <% query_options ||= {} %> 3 | 4 |

<%= l(:label_workload_management) %>

5 | <% get_user_total_today_time %> 6 |

7 | Time for today: 8 | <%= l(:label_your_time_for_today) %>: <%= @time_total %> 9 | | <%= l(:label_your_pipiline_for_today) %>: <%= @time_pipeline %> 10 | | <%= l(:label_your_tracked_time) %>: <%= @time_tracked %> 11 | <% html_title l(:label_workload_management) %> 12 |

13 | 14 |
15 | 16 |
17 | 18 |
19 | 23 |
24 | 25 | <%= form_tag('/board', :method => :get, :id => 'query_form') do %> 26 | <%= render :partial => 'queries/filter_query_form' %> 27 | <% end %> 28 | 29 | <%= render_query_totals(@query) %> 30 | 31 |
32 | 33 | 34 | 35 | 36 | <% @query.inline_columns.each do |column| %> 37 | <% if column.caption == 'Project' %><%= column_header(@query, column, query_options) %><% end %> 38 | <% end %> 39 | <% @query.inline_columns.each do |column| %> 40 | <% if column.caption == 'Status' %><%= column_header(@query, column, query_options) %><% end %> 41 | <% end %> 42 | <% @query.inline_columns.each do |column| %> 43 | <% if column.caption == 'Priority' %><%= column_header(@query, column, query_options) %><% end %> 44 | <% end %> 45 | 46 | 47 | 48 | 49 | <% @query.inline_columns.each do |column| %> 50 | <% if column.caption == settings_today_time_field_name %><%= column_header(@query, column, query_options) %><% end %> 51 | <% end %> 52 | 53 | 54 | 55 | 56 | <% unless @issues.nil? %> 57 | <% @issues.each do |issue| %> 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 72 | 77 | 78 | <% end %> 79 | <% end %> 80 | 81 |
#SubjectAssigneeEstimatedSpent hoursActions
<%= issue.id %><%= issue.project %><%= issue.status %><%= issue.priority %><%= issue.subject %><%= issue.assigned_to %><% unless issue.estimated_hours.nil? || issue.estimated_hours == 0 %><%= issue.estimated_hours.round(2) %><% end %><% unless issue.spent_hours.nil? || issue.spent_hours == 0 %><%= issue.spent_hours.round(2) %><% end %> 68 | <% get_total_for_today(issue) %> 69 | <%= @real_time %> 70 | 71 | 73 | <%= l(:label_will_do_today) %> 74 | <%= l(:label_mark_in_progress) %> 75 | <%= l(:label_mark_resolved) %> 76 |
82 |
83 | 84 | <% unless @issues.nil? %> 85 | <%= pagination_links_full @issue_pages, @issue_count %> 86 | <% end %> 87 | 88 | 89 | <%= javascript_include_tag 'board', :plugin => 'daily_workload_management' %> 90 | 91 | 92 | 136 | -------------------------------------------------------------------------------- /app/views/queries/_filter_query_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= hidden_field_tag 'set_filter', '1' %> 2 | <%= hidden_field_tag 'type', @query.type, :disabled => true, :id => 'query_type' %> 3 | <%= query_hidden_sort_tag(@query) %> 4 | 5 |
6 |
7 |
"> 8 | <%= l(:label_filter_plural) %> 9 |
"> 10 | <%= render :partial => 'queries/filters', :locals => {:query => @query} %> 11 |
12 |
13 | 14 |
15 | 16 |

17 | <%= link_to_function l(:button_apply), '$("#query_form").submit()', :class => 'icon icon-checked' %> 18 | <%= link_to l(:button_clear), { :set_filter => 1, :sort => '', :project_id => @project }, :class => 'icon icon-reload' %> 19 |

20 |
21 | 22 | <%= error_messages_for @query %> 23 | -------------------------------------------------------------------------------- /app/views/settings/_required_statuses_settings.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | <% issue_custom_fields.each do |field| %> 5 | 13 | <% end %> 14 |

15 |
16 |
17 |

18 | 19 | <% time_for_today_required_statuses.each do |status| %> 20 | 28 | <% end %> 29 |

30 |
31 |
32 |

33 | 34 | <% time_for_today_required_statuses.each do |status| %> 35 | 43 | <% end %> 44 |

45 |
46 |
47 |

48 | 49 | <% time_for_today_required_statuses.each do |status| %> 50 | 58 | <% end %> 59 |

60 |
61 | -------------------------------------------------------------------------------- /assets/javascripts/board.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | (function ($) { 5 | 6 | /** --------- Edit time for today --------- */ 7 | $(".time_for_today").on("click", function (e) { 8 | e.preventDefault(); 9 | e.stopPropagation(); 10 | 11 | $(".workload-management-flash-notice").hide(); 12 | 13 | $(this).find(".time_for_today_input").show().focus(); 14 | $(this).find(".time_for_today_value").hide(); 15 | }); 16 | 17 | var $timeForTodayInput = $(".time_for_today_input"); 18 | $timeForTodayInput.on( "focusout", function (e) { 19 | e.preventDefault(); 20 | e.stopPropagation(); 21 | 22 | $(".workload-management-flash-notice").hide(); 23 | $(".time_for_today_input").hide(); 24 | $(".time_for_today_value").show(); 25 | }); 26 | 27 | $timeForTodayInput.on( "keydown", function (e) { 28 | if (e.which === 13 || e.keyCode === 13) { 29 | var $this = $(this); 30 | var inputValue = $this.val(); 31 | updateTimeForToday.apply(this, [$this.attr("data-issue-id"), inputValue]); 32 | 33 | $(this).blur(); 34 | } 35 | }); 36 | 37 | /** --------- Mark task as will do today --------- */ 38 | $(".will-do-today").on("click", function (e) { 39 | e.preventDefault(); 40 | e.stopPropagation(); 41 | 42 | $(".workload-management-errors").hide(); 43 | $(".workload-management-flash-notice").hide(); 44 | 45 | var workTodayButton = $(this); 46 | var $issue = workTodayButton.closest(".issue"); 47 | var issueId = workTodayButton.attr("data-issue-id"); 48 | var url = "board/issue/" + issueId + "/will-do-today"; 49 | 50 | var time_value = $issue.find(".time_for_today_value").html(); 51 | time_value = time_value ? time_value : $issue.find(".time_for_today_input").val(); 52 | 53 | $.ajax({ 54 | "type": "POST", 55 | "url": url, 56 | "dataType": "json", 57 | "data": {time: time_value}, 58 | "success": function (response) { 59 | 60 | if (response.success && response.success === true) { 61 | $issue.find(".status").html(response.status); 62 | $issue.find(".assignee").html(response.assignee); 63 | $(".workload-management-flash-notice").show().html(response.info); 64 | $issue.find(".time_for_today_value").html(time_value); 65 | 66 | refreshTimeForTodayStatistic(); 67 | } else { 68 | renderErrors(response.errors); 69 | } 70 | setTimeout(hideFlash, 5000); 71 | } 72 | }); 73 | }); 74 | 75 | /** --------- Mark task as in progress --------- */ 76 | $(".mark-in-progress").on("click", function (e) { 77 | e.preventDefault(); 78 | e.stopPropagation(); 79 | 80 | $(".workload-management-errors").hide(); 81 | $(".workload-management-flash-notice").hide(); 82 | 83 | var workTodayButton = $(this); 84 | var $issue = workTodayButton.closest(".issue"); 85 | var issueId = workTodayButton.attr("data-issue-id"); 86 | var url = "board/issue/" + issueId + "/mark-in-progress"; 87 | 88 | $.ajax({ 89 | "type": "POST", 90 | "url": url, 91 | "dataType": "json", 92 | "data": {status: 1}, 93 | "success": function (response) { 94 | handleStatusChangeResponse(response, $issue); 95 | } 96 | }); 97 | }); 98 | 99 | /** --------- Mark task as resolved --------- */ 100 | $(".mark-resolved").on("click", function (e) { 101 | e.preventDefault(); 102 | e.stopPropagation(); 103 | 104 | $(".workload-management-errors").hide(); 105 | $(".workload-management-flash-notice").hide(); 106 | 107 | var workTodayButton = $(this); 108 | var $issue = workTodayButton.closest(".issue"); 109 | var issueId = workTodayButton.attr("data-issue-id"); 110 | var url = "board/issue/" + issueId + "/mark-resolved"; 111 | 112 | $.ajax({ 113 | "type": "POST", 114 | "url": url, 115 | "dataType": "json", 116 | "data": {status: 2}, 117 | "success": function (response) { 118 | handleStatusChangeResponse(response, $issue); 119 | } 120 | }); 121 | }); 122 | 123 | function handleStatusChangeResponse(response, $issue) { 124 | if (response.success && response.success === true) { 125 | $issue.find(".status").html(response.status); 126 | $issue.find(".assignee").html(response.assignee); 127 | $(".workload-management-flash-notice").show().html(response.info); 128 | refreshTimeForTodayStatistic(); 129 | } else { 130 | renderErrors(response.errors); 131 | } 132 | setTimeout(hideFlash, 5000); 133 | } 134 | 135 | function updateTimeForToday(issueId, timeForToday) { 136 | var $input = $(this); 137 | var url = "board/issue/" + issueId + "/update-time-for-today"; 138 | 139 | $.ajax({ 140 | "type": "POST", 141 | "url": url, 142 | "dataType": "json", 143 | "data": {time: timeForToday}, 144 | "success": function (response) { 145 | if (response.success && response.success === true) { 146 | if (response.is_changed) { 147 | $(".workload-management-flash-notice").show().html(response.info); 148 | $input.siblings(".time_for_today_value").html($input.val()); 149 | refreshTimeForTodayStatistic(); 150 | } 151 | } else { 152 | renderErrors(response.errors); 153 | } 154 | setTimeout(hideFlash, 5000); 155 | } 156 | }); 157 | } 158 | 159 | function refreshTimeForTodayStatistic() { 160 | $.ajax({ 161 | "type": "GET", 162 | "url": "board/time-for-today-statistic", 163 | "dataType": "json", 164 | "success": function (response) { 165 | if (response) { 166 | $(".today_time_total").html(response.time_total); 167 | $(".today_time_pipeline").html(response.time_pipeline); 168 | } 169 | } 170 | }); 171 | } 172 | 173 | function renderErrors(errors) { 174 | for (var i = 0; i < errors.length; i++) { 175 | var li = "
  • " + errors[i] + "
  • "; 176 | $(".workload-management-errors").show().find("ul").append(li); 177 | } 178 | } 179 | 180 | function hideFlash() { 181 | $(".workload-management-errors").hide(); 182 | $(".workload-management-flash-notice").hide(); 183 | $(".workload-management-errors").find("ul").html(''); 184 | } 185 | })(jQuery); 186 | })(); 187 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | top_menu_workload_management: Workload Management 3 | label_workload_management: Workload Management 4 | label_your_time_for_today: Total 5 | label_your_pipiline_for_today: In Pipeline 6 | label_will_do_today: Will do today 7 | label_your_tracked_time: Tracked 8 | permission_view_board: View Workload Management page 9 | notification_issue_updated: Issue successfully updated 10 | notification_time_updated: "'Time for today' successfully updated" 11 | notification_no_status_for_action: "Status for this action is not configured in plugin settings" 12 | notification_should_be_greater: "should be less than left time (left time: %{left_hours})" 13 | settings_select_custom_field: "Custom field for specifying your today's work time ('Time for today')" 14 | settings_select_status: Issue status for 'time for today' field 15 | settings_resolved_status: Issue status to mark task as 'Resolved' 16 | settings_in_progress_status: Issue status to mark task as 'In progress' 17 | label_mark_in_progress: In progress 18 | label_mark_resolved: Resolved 19 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | get 'board', :to => 'board#index', :as => 'workload_management_board' 2 | get 'board/time-for-today-statistic', :to => 'board#time_for_today_statistic', :as => 'time_for_today_statistic' 3 | 4 | post 'board/issue/:id/will-do-today', :to => 'board#will_do_today' 5 | post 'board/issue/:id/update-time-for-today', :to => 'board#update_time_for_today' 6 | 7 | post 'board/issue/:id/mark-in-progress', :to => 'board#update_status' 8 | post 'board/issue/:id/mark-resolved', :to => 'board#update_status' 9 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | require_dependency '../../plugins/daily_workload_management/app/helpers/board_helper' 2 | require_dependency '../../plugins/daily_workload_management/app/models/issue_patch' 3 | 4 | Redmine::Plugin.register :daily_workload_management do 5 | name 'Daily Workload Management plugin' 6 | author 'Default Value' 7 | description 'Plugin provides functionality for managing daily workload' 8 | version '1.0' 9 | author_url 'http://default-value.com/' 10 | 11 | menu :top_menu, :board, { :controller => 'board', :action => 'index' }, :caption => :top_menu_workload_management 12 | 13 | settings \ 14 | :partial => 'settings/required_statuses_settings' 15 | 16 | permission :view_board, :board => :index 17 | 18 | end 19 | 20 | ActionDispatch::Reloader.to_prepare do 21 | SettingsHelper.send :include, WorkloadManagementSettingsHelper 22 | end 23 | -------------------------------------------------------------------------------- /lib/tasks/clear_time_for_today_values.rake: -------------------------------------------------------------------------------- 1 | desc "Clear 'time for today' issue field values" 2 | task :clear_time_for_today_values => :environment do 3 | include BoardHelper 4 | 5 | custom_field = CustomField.find_by name: BoardHelper::settings_today_time_field_name 6 | CustomValue.where(custom_field: custom_field).destroy_all 7 | end 8 | --------------------------------------------------------------------------------