diff --git a/app/controllers/timelog_controller.rb b/app/controllers/timelog_controller.rb index a0c694bc4..a2dcfac7d 100644 --- a/app/controllers/timelog_controller.rb +++ b/app/controllers/timelog_controller.rb @@ -36,24 +36,14 @@ class TimelogController < ApplicationController include TimelogHelper helper :custom_fields include CustomFieldsHelper + helper :queries def index - sort_init 'spent_on', 'desc' - sort_update 'spent_on' => ['spent_on', "#{TimeEntry.table_name}.created_on"], - 'user' => 'user_id', - 'activity' => 'activity_id', - 'project' => "#{Project.table_name}.name", - 'issue' => 'issue_id', - 'hours' => 'hours' + @query = TimeEntryQuery.build_from_params(params, :name => '_') + scope = time_entry_scope - retrieve_date_range - - scope = TimeEntry.visible.spent_between(@from, @to) - if @issue - scope = scope.on_issue(@issue) - elsif @project - scope = scope.on_project(@project, Setting.display_subprojects_issues?) - end + sort_init(@query.sort_criteria.empty? ? [['spent_on', 'desc']] : @query.sort_criteria) + sort_update(@query.sortable_columns) respond_to do |format| format.html { @@ -100,8 +90,10 @@ class TimelogController < ApplicationController end def report - retrieve_date_range - @report = Redmine::Helpers::TimeReport.new(@project, @issue, params[:criteria], params[:columns], @from, @to) + @query = TimeEntryQuery.build_from_params(params, :name => '_') + scope = time_entry_scope + + @report = Redmine::Helpers::TimeReport.new(@project, @issue, params[:criteria], params[:columns], scope) respond_to do |format| format.html { render :layout => !request.xhr? } @@ -291,6 +283,17 @@ private end end + # Returns the TimeEntry scope for index and report actions + def time_entry_scope + scope = TimeEntry.visible.where(@query.statement) + if @issue + scope = scope.on_issue(@issue) + elsif @project + scope = scope.on_project(@project, Setting.display_subprojects_issues?) + end + scope + end + # Retrieves the date range based on predefined ranges or specific from/to param dates def retrieve_date_range @free_period = false diff --git a/app/models/query.rb b/app/models/query.rb index 81eeb9f3b..814d11846 100644 --- a/app/models/query.rb +++ b/app/models/query.rb @@ -117,7 +117,13 @@ class Query < ActiveRecord::Base "> :label_in_the_next_days, "t+" => :label_in, "t" => :label_today, + "ld" => :label_yesterday, "w" => :label_this_week, + "lw" => :label_last_week, + "l2w" => [:label_last_n_weeks, :count => 2], + "m" => :label_this_month, + "lm" => :label_last_month, + "y" => :label_this_year, ">t-" => :label_less_than_ago, " :label_more_than_ago, "> :label_in_the_past_days, @@ -135,8 +141,8 @@ class Query < ActiveRecord::Base :list_status => [ "o", "=", "!", "c", "*" ], :list_optional => [ "=", "!", "!*", "*" ], :list_subprojects => [ "*", "!*", "=" ], - :date => [ "=", ">=", "<=", "><", "t+", ">t-", " [ "=", ">=", "<=", "><", ">t-", " [ "=", ">=", "<=", "><", "t+", ">t-", " [ "=", ">=", "<=", "><", ">t-", " [ "=", "~", "!", "!~", "!*", "*" ], :text => [ "~", "!~", "!*", "*" ], :integer => [ "=", ">=", "<=", "><", "!*", "*" ], @@ -173,6 +179,11 @@ class Query < ActiveRecord::Base self end + # Builds a new query from the given params and attributes + def self.build_from_params(params, attributes={}) + new(attributes).build_from_params(params) + end + def validate_query_filters filters.each_key do |field| if values_for(field) @@ -195,7 +206,7 @@ class Query < ActiveRecord::Base # filter requires one or more values (values_for(field) and !values_for(field).first.blank?) or # filter doesn't require any value - ["o", "c", "!*", "*", "t", "w"].include? operator_for(field) + ["o", "c", "!*", "*", "t", "ld", "w", "lw", "l2w", "m", "lm", "y"].include? operator_for(field) end if filters end @@ -218,7 +229,7 @@ class Query < ActiveRecord::Base # Returns a hash of localized labels for all filter operators def self.operators_labels - operators.inject({}) {|h, operator| h[operator.first] = l(operator.last); h} + operators.inject({}) {|h, operator| h[operator.first] = l(*operator.last); h} end # Returns a representation of the available filters for JSON serialization @@ -245,7 +256,7 @@ class Query < ActiveRecord::Base @all_projects_values = values end - def add_filter(field, operator, values) + def add_filter(field, operator, values=nil) # values must be an array return unless values.nil? || values.is_a?(Array) # check if field is defined as an available filter @@ -612,12 +623,39 @@ class Query < ActiveRecord::Base when "t" # = today sql = relative_date_clause(db_table, db_field, 0, 0) + when "ld" + # = yesterday + sql = relative_date_clause(db_table, db_field, -1, -1) when "w" # = this week first_day_of_week = l(:general_first_day_of_week).to_i day_of_week = Date.today.cwday days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week) sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6) + when "lw" + # = last week + first_day_of_week = l(:general_first_day_of_week).to_i + day_of_week = Date.today.cwday + days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week) + sql = relative_date_clause(db_table, db_field, - days_ago - 7, - days_ago - 1) + when "l2w" + # = last 2 weeks + first_day_of_week = l(:general_first_day_of_week).to_i + day_of_week = Date.today.cwday + days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week) + sql = relative_date_clause(db_table, db_field, - days_ago - 14, - days_ago - 1) + when "m" + # = this month + date = Date.today + sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month) + when "lm" + # = last month + date = Date.today.prev_month + sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month) + when "y" + # = this year + date = Date.today + sql = date_clause(db_table, db_field, date.beginning_of_year, date.end_of_year) when "~" sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'" when "!~" diff --git a/app/models/time_entry_query.rb b/app/models/time_entry_query.rb new file mode 100644 index 000000000..c2fea35fe --- /dev/null +++ b/app/models/time_entry_query.rb @@ -0,0 +1,65 @@ +# Redmine - project management software +# Copyright (C) 2006-2012 Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +class TimeEntryQuery < Query + + self.queried_class = TimeEntry + + self.available_columns = [ + QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true), + QueryColumn.new(:spent_on, :sortable => ["#{TimeEntry.table_name}.spent_on", "#{TimeEntry.table_name}.created_on"]), + QueryColumn.new(:user, :sortable => lambda {User.fields_for_order_statement}, :groupable => true), + QueryColumn.new(:activity, :sortable => "#{TimeEntryActivity.table_name}.position", :groupable => true), + QueryColumn.new(:issue, :sortable => "#{Issue.table_name}.id"), + QueryColumn.new(:comments), + QueryColumn.new(:hours, :sortable => "#{TimeEntry.table_name}.hours"), + ] + + def initialize(attributes=nil, *args) + super attributes + self.filters ||= {} + add_filter('spent_on', '*') unless filters.present? + end + + def available_filters + return @available_filters if @available_filters + @available_filters = { + "spent_on" => { :type => :date_past, :order => 0 } + } + @available_filters.each do |field, options| + options[:name] ||= l(options[:label] || "field_#{field}".gsub(/_id$/, '')) + end + @available_filters + end + + def default_columns_names + @default_columns_names ||= [:project, :spent_on, :user, :activity, :issue, :comments, :hours] + end + + # Accepts :from/:to params as shortcut filters + def build_from_params(params) + super + if params[:from].present? && params[:to].present? + add_filter('spent_on', '><', [params[:from], params[:to]]) + elsif params[:from].present? + add_filter('spent_on', '>=', [params[:from]]) + elsif params[:to].present? + add_filter('spent_on', '<=', [params[:to]]) + end + self + end +end diff --git a/app/views/timelog/_date_range.html.erb b/app/views/timelog/_date_range.html.erb index 17a34b99b..a0c180fc8 100644 --- a/app/views/timelog/_date_range.html.erb +++ b/app/views/timelog/_date_range.html.erb @@ -1,42 +1,23 @@ -
-<%= l(:label_date_range) %> -
-

-<%= label_tag "period_type_list", l(:description_date_range_list), :class => "hidden-for-sighted" %> -<%= radio_button_tag 'period_type', '1', !@free_period, :onclick => '$("#from,#to").attr("disabled", true);$("#period").removeAttr("disabled");', :id => "period_type_list"%> -<%= select_tag 'period', options_for_period_select(params[:period]), - :onchange => 'this.form.submit();', - :onfocus => '$("#period_type_1").attr("checked", true);', - :disabled => @free_period %> -

-

-<%= label_tag "period_type_interval", l(:description_date_range_interval), :class => "hidden-for-sighted" %> -<%= radio_button_tag 'period_type', '2', @free_period, :onclick => '$("#from,#to").removeAttr("disabled");$("#period").attr("disabled", true);', :id => "period_type_interval" %> -<%= l(:label_date_from_to, - :start => ((label_tag "from", l(:description_date_from), :class => "hidden-for-sighted") + - text_field_tag('from', @from, :size => 10, :disabled => !@free_period) + calendar_for('from')), - :end => ((label_tag "to", l(:description_date_to), :class => "hidden-for-sighted") + - text_field_tag('to', @to, :size => 10, :disabled => !@free_period) + calendar_for('to'))).html_safe %> -

-
+
+
"> + <%= l(:label_filter_plural) %> +
"> + <%= render :partial => 'queries/filters', :locals => {:query => @query} %> +
-

+

+ +

<%= link_to_function l(:button_apply), '$("#query_form").submit()', :class => 'icon icon-checked' %> - <%= link_to l(:button_clear), {:controller => controller_name, :action => action_name, :project_id => @project, :issue_id => @issue}, :class => 'icon icon-reload' %> + <%= link_to l(:button_clear), {:project_id => @project, :issue_id => @issue}, :class => 'icon icon-reload' %>

-<% url_params = @free_period ? { :from => @from, :to => @to } : { :period => params[:period] } %> +<% query_params = params.slice(:f, :op, :v, :sort) %>
    -
  • <%= link_to(l(:label_details), url_params.merge({:controller => 'timelog', :action => 'index', :project_id => @project, :issue_id => @issue }), +
  • <%= link_to(l(:label_details), query_params.merge({:controller => 'timelog', :action => 'index', :project_id => @project, :issue_id => @issue }), :class => (action_name == 'index' ? 'selected' : nil)) %>
  • -
  • <%= link_to(l(:label_report), url_params.merge({:controller => 'timelog', :action => 'report', :project_id => @project, :issue_id => @issue}), +
  • <%= link_to(l(:label_report), query_params.merge({:controller => 'timelog', :action => 'report', :project_id => @project, :issue_id => @issue}), :class => (action_name == 'report' ? 'selected' : nil)) %>
- -<%= javascript_tag do %> -$('#from, #to').change(function(){ - $('#period_type_interval').attr('checked', true); $('#from,#to').removeAttr('disabled'); $('#period').attr('disabled', true); -}); -<% end %> diff --git a/lib/redmine/helpers/time_report.rb b/lib/redmine/helpers/time_report.rb index 2c29f6ac8..69a77e790 100644 --- a/lib/redmine/helpers/time_report.rb +++ b/lib/redmine/helpers/time_report.rb @@ -18,9 +18,9 @@ module Redmine module Helpers class TimeReport - attr_reader :criteria, :columns, :from, :to, :hours, :total_hours, :periods + attr_reader :criteria, :columns, :hours, :total_hours, :periods - def initialize(project, issue, criteria, columns, from, to) + def initialize(project, issue, criteria, columns, time_entry_scope) @project = project @issue = issue @@ -30,8 +30,7 @@ module Redmine @criteria = @criteria[0,3] @columns = (columns && %w(year month week day).include?(columns)) ? columns : 'month' - @from = from - @to = to + @scope = time_entry_scope run end @@ -44,15 +43,9 @@ module Redmine def run unless @criteria.empty? - scope = TimeEntry.visible.spent_between(@from, @to) - if @issue - scope = scope.on_issue(@issue) - elsif @project - scope = scope.on_project(@project, Setting.display_subprojects_issues?) - end time_columns = %w(tyear tmonth tweek spent_on) @hours = [] - scope.sum(:hours, :include => :issue, :group => @criteria.collect{|criteria| @available_criteria[criteria][:sql]} + time_columns).each do |hash, hours| + @scope.sum(:hours, :include => :issue, :group => @criteria.collect{|criteria| @available_criteria[criteria][:sql]} + time_columns).each do |hash, hours| h = {'hours' => hours} (@criteria + time_columns).each_with_index do |name, i| h[name] = hash[i] @@ -73,15 +66,11 @@ module Redmine end end - if @from.nil? - min = @hours.collect {|row| row['spent_on']}.min - @from = min ? min.to_date : Date.today - end + min = @hours.collect {|row| row['spent_on']}.min + @from = min ? min.to_date : Date.today - if @to.nil? - max = @hours.collect {|row| row['spent_on']}.max - @to = max ? max.to_date : Date.today - end + max = @hours.collect {|row| row['spent_on']}.max + @to = max ? max.to_date : Date.today @total_hours = @hours.inject(0) {|s,k| s = s + k['hours'].to_f} diff --git a/public/javascripts/application.js b/public/javascripts/application.js index cf3c8fbed..35b24ad05 100644 --- a/public/javascripts/application.js +++ b/public/javascripts/application.js @@ -242,7 +242,13 @@ function toggleOperator(field) { case "!*": case "*": case "t": + case "ld": case "w": + case "lw": + case "l2w": + case "m": + case "lm": + case "y": case "o": case "c": enableValues(field, []); diff --git a/test/functional/time_entry_reports_controller_test.rb b/test/functional/time_entry_reports_controller_test.rb index 05c42d527..f876aa645 100644 --- a/test/functional/time_entry_reports_controller_test.rb +++ b/test/functional/time_entry_reports_controller_test.rb @@ -166,10 +166,9 @@ class TimeEntryReportsControllerTest < ActionController::TestCase assert_equal 'text/csv; header=present', @response.content_type lines = @response.body.chomp.split("\n") # Headers - assert_equal 'Project,Member,Activity,2007-1,2007-2,2007-3,2007-4,2007-5,2007-6,Total', - lines.first + assert_equal 'Project,Member,Activity,2007-3,2007-4,Total', lines.first # Total row - assert_equal 'Total,"","","","",154.25,8.65,"","",162.90', lines.last + assert_equal 'Total,"","",154.25,8.65,162.90', lines.last end def test_report_csv_export @@ -180,10 +179,9 @@ class TimeEntryReportsControllerTest < ActionController::TestCase assert_equal 'text/csv; header=present', @response.content_type lines = @response.body.chomp.split("\n") # Headers - assert_equal 'Project,Member,Activity,2007-1,2007-2,2007-3,2007-4,2007-5,2007-6,Total', - lines.first + assert_equal 'Project,Member,Activity,2007-3,2007-4,Total', lines.first # Total row - assert_equal 'Total,"","","","",154.25,8.65,"","",162.90', lines.last + assert_equal 'Total,"","",154.25,8.65,162.90', lines.last end def test_csv_big_5 diff --git a/test/functional/timelog_controller_test.rb b/test/functional/timelog_controller_test.rb index 040d9c647..407581e5e 100644 --- a/test/functional/timelog_controller_test.rb +++ b/test/functional/timelog_controller_test.rb @@ -430,14 +430,26 @@ class TimelogControllerTest < ActionController::TestCase assert_equal [1, 3], assigns(:entries).collect(&:project_id).uniq.sort assert_not_nil assigns(:total_hours) assert_equal "162.90", "%.2f" % assigns(:total_hours) - # display all time by default - assert_nil assigns(:from) - assert_nil assigns(:to) assert_tag :form, :attributes => {:action => "/projects/ecookbook/time_entries", :id => 'query_form'} end def test_index_at_project_level_with_date_range + get :index, :project_id => 'ecookbook', + :f => ['spent_on'], + :op => {'spent_on' => '><'}, + :v => {'spent_on' => ['2007-03-20', '2007-04-30']} + assert_response :success + assert_template 'index' + assert_not_nil assigns(:entries) + assert_equal 3, assigns(:entries).size + assert_not_nil assigns(:total_hours) + assert_equal "12.90", "%.2f" % assigns(:total_hours) + assert_tag :form, + :attributes => {:action => "/projects/ecookbook/time_entries", :id => 'query_form'} + end + + def test_index_at_project_level_with_date_range_using_from_and_to_params get :index, :project_id => 'ecookbook', :from => '2007-03-20', :to => '2007-04-30' assert_response :success assert_template 'index' @@ -445,116 +457,23 @@ class TimelogControllerTest < ActionController::TestCase assert_equal 3, assigns(:entries).size assert_not_nil assigns(:total_hours) assert_equal "12.90", "%.2f" % assigns(:total_hours) - assert_equal '2007-03-20'.to_date, assigns(:from) - assert_equal '2007-04-30'.to_date, assigns(:to) assert_tag :form, :attributes => {:action => "/projects/ecookbook/time_entries", :id => 'query_form'} end def test_index_at_project_level_with_period - get :index, :project_id => 'ecookbook', :period => '7_days' + get :index, :project_id => 'ecookbook', + :f => ['spent_on'], + :op => {'spent_on' => '>t-'}, + :v => {'spent_on' => ['7']} assert_response :success assert_template 'index' assert_not_nil assigns(:entries) assert_not_nil assigns(:total_hours) - assert_equal Date.today - 7, assigns(:from) - assert_equal Date.today, assigns(:to) assert_tag :form, :attributes => {:action => "/projects/ecookbook/time_entries", :id => 'query_form'} end - def test_index_one_day - get :index, :project_id => 'ecookbook', :from => "2007-03-23", :to => "2007-03-23" - assert_response :success - assert_template 'index' - assert_not_nil assigns(:total_hours) - assert_equal "4.25", "%.2f" % assigns(:total_hours) - assert_tag :form, - :attributes => {:action => "/projects/ecookbook/time_entries", :id => 'query_form'} - end - - def test_index_from_a_date - get :index, :project_id => 'ecookbook', :from => "2007-03-23", :to => "" - assert_equal '2007-03-23'.to_date, assigns(:from) - assert_nil assigns(:to) - end - - def test_index_to_a_date - get :index, :project_id => 'ecookbook', :from => "", :to => "2007-03-23" - assert_nil assigns(:from) - assert_equal '2007-03-23'.to_date, assigns(:to) - end - - def test_index_today - Date.stubs(:today).returns('2011-12-15'.to_date) - get :index, :period => 'today' - assert_equal '2011-12-15'.to_date, assigns(:from) - assert_equal '2011-12-15'.to_date, assigns(:to) - end - - def test_index_yesterday - Date.stubs(:today).returns('2011-12-15'.to_date) - get :index, :period => 'yesterday' - assert_equal '2011-12-14'.to_date, assigns(:from) - assert_equal '2011-12-14'.to_date, assigns(:to) - end - - def test_index_current_week - Date.stubs(:today).returns('2011-12-15'.to_date) - get :index, :period => 'current_week' - assert_equal '2011-12-12'.to_date, assigns(:from) - assert_equal '2011-12-18'.to_date, assigns(:to) - end - - def test_index_last_week - Date.stubs(:today).returns('2011-12-15'.to_date) - get :index, :period => 'last_week' - assert_equal '2011-12-05'.to_date, assigns(:from) - assert_equal '2011-12-11'.to_date, assigns(:to) - end - - def test_index_last_2_week - Date.stubs(:today).returns('2011-12-15'.to_date) - get :index, :period => 'last_2_weeks' - assert_equal '2011-11-28'.to_date, assigns(:from) - assert_equal '2011-12-11'.to_date, assigns(:to) - end - - def test_index_7_days - Date.stubs(:today).returns('2011-12-15'.to_date) - get :index, :period => '7_days' - assert_equal '2011-12-08'.to_date, assigns(:from) - assert_equal '2011-12-15'.to_date, assigns(:to) - end - - def test_index_current_month - Date.stubs(:today).returns('2011-12-15'.to_date) - get :index, :period => 'current_month' - assert_equal '2011-12-01'.to_date, assigns(:from) - assert_equal '2011-12-31'.to_date, assigns(:to) - end - - def test_index_last_month - Date.stubs(:today).returns('2011-12-15'.to_date) - get :index, :period => 'last_month' - assert_equal '2011-11-01'.to_date, assigns(:from) - assert_equal '2011-11-30'.to_date, assigns(:to) - end - - def test_index_30_days - Date.stubs(:today).returns('2011-12-15'.to_date) - get :index, :period => '30_days' - assert_equal '2011-11-15'.to_date, assigns(:from) - assert_equal '2011-12-15'.to_date, assigns(:to) - end - - def test_index_current_year - Date.stubs(:today).returns('2011-12-15'.to_date) - get :index, :period => 'current_year' - assert_equal '2011-01-01'.to_date, assigns(:from) - assert_equal '2011-12-31'.to_date, assigns(:to) - end - def test_index_at_issue_level get :index, :issue_id => 1 assert_response :success @@ -577,11 +496,18 @@ class TimelogControllerTest < ActionController::TestCase t2 = TimeEntry.create!(:user => User.find(1), :project => Project.find(1), :hours => 1, :spent_on => '2012-06-16', :created_on => '2012-06-16 20:05:00', :activity_id => 10) t3 = TimeEntry.create!(:user => User.find(1), :project => Project.find(1), :hours => 1, :spent_on => '2012-06-15', :created_on => '2012-06-16 20:10:00', :activity_id => 10) - get :index, :project_id => 1, :from => '2012-06-15', :to => '2012-06-16' + get :index, :project_id => 1, + :f => ['spent_on'], + :op => {'spent_on' => '><'}, + :v => {'spent_on' => ['2012-06-15', '2012-06-16']} assert_response :success assert_equal [t2, t1, t3], assigns(:entries) - get :index, :project_id => 1, :from => '2012-06-15', :to => '2012-06-16', :sort => 'spent_on' + get :index, :project_id => 1, + :f => ['spent_on'], + :op => {'spent_on' => '><'}, + :v => {'spent_on' => ['2012-06-15', '2012-06-16']}, + :sort => 'spent_on' assert_response :success assert_equal [t3, t1, t2], assigns(:entries) end