diff --git a/app/controllers/issues_controller.rb b/app/controllers/issues_controller.rb index 1aceefb..8aa2713 100644 --- a/app/controllers/issues_controller.rb +++ b/app/controllers/issues_controller.rb @@ -18,11 +18,13 @@ class IssuesController < ApplicationController menu_item :new_issue, :only => :new - before_filter :find_issue, :only => [:show, :edit, :reply] + before_filter :find_issue, :only => [:show, :edit, :reply, :destroy_attachment ] before_filter :find_issues, :only => [:bulk_edit, :move, :destroy] - before_filter :find_project, :only => [:new, :update_form, :preview] - before_filter :authorize, :except => [:index, :changes, :gantt, :calendar, :preview, :update_form, :context_menu] - before_filter :find_optional_project, :only => [:index, :changes, :gantt, :calendar] + before_filter :find_parent_issue, :only => [:add_subissue] + before_filter :find_optional_parent_issue, :only => [:new] + before_filter :find_project, :only => [:new, :auto_complete_for_issue_parent, :add_subissue, :update_form, :preview ] + before_filter :find_optional_project, :only => [:index, :changes, :gantt, :calendar ] + before_filter :authorize, :except => [:index, :changes, :gantt, :calendar, :preview, :update_form, :context_menu, :auto_complete_for_issue_parent ] accept_key_auth :index, :changes helper :journals @@ -41,8 +43,47 @@ class IssuesController < ApplicationController include SortHelper include IssuesHelper helper :timelog + include ActionView::Helpers::PrototypeHelper include Redmine::Export::PDF + def auto_complete_for_issue_parent + @phrase = params[:issue_parent] + @candidates = [] + + # If cross project issue relations is allowed we should get + # candidates from every project + if Setting.cross_project_issue_relations? + projects_to_search = nil + else + projects_to_search = [ @project ] + @project.active_children + end + + # Try to find issue by id. + if @phrase.match(/^#?(\d+)$/) + if Setting.cross_project_issue_relations? + issue = Issue.find_by_id( $1) + else + issue = Issue.find_by_id_and_project_id( $1, projects_to_search.collect { |i| i.id}) + end + @candidates = [ issue ] if issue + end + + # If finding by id is fail, try to find by searching in subject + # and description. + if @candidates.empty? + # extract tokens from the question + # eg. hello "bye bye" => ["hello", "bye bye"] + tokens = @phrase.scan(%r{((\s|^)"[\s\w]+"(\s|$)|\S+)}).collect {|m| m.first.gsub(%r{(^\s*"\s*|\s*"\s*$)}, '')} + # tokens must be at least 3 character long + tokens = tokens.uniq.select {|w| w.length > 2 } + like_tokens = tokens.collect {|w| "%#{w.downcase}%"} + + @candidates, count = Issue.search( like_tokens, projects_to_search, :before => true) + end + + render :inline => "<%= auto_complete_result_parent_issue( @candidates, @phrase) %>" + end + def index retrieve_query sort_init(@query.sort_criteria.empty? ? [['id', 'desc']] : @query.sort_criteria) @@ -58,11 +99,18 @@ class IssuesController < ApplicationController end @issue_count = Issue.count(:include => [:status, :project], :conditions => @query.statement) @issue_pages = Paginator.new self, @issue_count, limit, params['page'] - @issues = Issue.find :all, :order => sort_clause, - :include => [ :assigned_to, :status, :tracker, :project, :priority, :category, :fixed_version ], - :conditions => @query.statement, - :limit => limit, - :offset => @issue_pages.current.offset + @issues = Issue.find( :all, :order => sort_clause, + :include => [ :assigned_to, + :status, + :tracker, + :project, + :priority, + :category, + :fixed_version ], + :conditions => @query.statement, + :limit => limit, + :offset => @issue_pages.current.offset) + respond_to do |format| format.html { render :template => 'issues/index.rhtml', :layout => !request.xhr? } format.atom { render_feed(@issues, :title => "#{@project || Setting.app_title}: #{l(:label_issue_plural)}") } @@ -136,7 +184,8 @@ class IssuesController < ApplicationController end @issue.status = default_status @allowed_statuses = ([default_status] + default_status.find_new_statuses_allowed_to(User.current.role_for_project(@project), @issue.tracker)).uniq - + + if request.get? || request.xhr? @issue.start_date ||= Date.today else @@ -147,6 +196,7 @@ class IssuesController < ApplicationController attach_files(@issue, params[:attachments]) flash[:notice] = l(:notice_successful_create) call_hook(:controller_issues_new_after_save, { :params => params, :issue => @issue}) + @issue.move_to_child_of @parent_issue if @parent_issue redirect_to(params[:continue] ? { :action => 'new', :tracker_id => @issue.tracker } : { :action => 'show', :id => @issue }) return @@ -155,6 +205,10 @@ class IssuesController < ApplicationController @priorities = Enumeration.priorities render :layout => !request.xhr? end + + def add_subissue + redirect_to :action => 'new', :issue => { :parent_issue_id => @parent_issue.id } + end # Attributes that can be updated on workflow transition (without :edit permission) # TODO: make it configurable (at least per role) @@ -449,7 +503,7 @@ private rescue ActiveRecord::RecordNotFound render_404 end - + def find_optional_project @project = Project.find(params[:project_id]) unless params[:project_id].blank? allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true) @@ -458,6 +512,20 @@ private render_404 end + def find_parent_issue + @parent_issue = Issue.find( params[:parent_issue_id]) + rescue ActiveRecord::RecordNotFound + render_404 + end + + def find_optional_parent_issue + if params[:issue] && !params[:issue][:parent_id].blank? + @parent_issue = Issue.find( params[:issue][:parent_id]) + end + rescue ActiveRecord::RecordNotFound + render_404 + end + # Retrieve query from session or build a new query def retrieve_query if !params[:query_id].blank? @@ -481,10 +549,22 @@ private @query.add_short_filter(field, params[field]) if params[field] end end - session[:query] = {:project_id => @query.project_id, :filters => @query.filters} + if params[:view_options] and params[:view_options].is_a? Hash + params[:view_options].each_pair do |name, value| + @query.set_view_option( name, value) + end + end + session[:query] = { + :project_id => @query.project_id, + :filters => @query.filters, + :view_options => @query.view_options + } else @query = Query.find_by_id(session[:query][:id]) if session[:query][:id] - @query ||= Query.new(:name => "_", :project => @project, :filters => session[:query][:filters]) + @query ||= Query.new(:name => "_", + :project => @project, + :filters => session[:query][:filters], + :view_options => session[:query][:view_options]) @query.project = @project end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index f9c537c..f278abe 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -46,6 +46,8 @@ class ProjectsController < ApplicationController helper :repositories include RepositoriesHelper include ProjectsHelper + helper :versions + include VersionsHelper # Lists visible projects def index @@ -292,4 +294,48 @@ private @selected_tracker_ids = selectable_trackers.collect {|t| t.id.to_s } end end + + + def sort_as_tree(issues) + issues.sort!{|a,b| a.hierarchical_level <=> b.hierarchical_level} + @sorted_issues = [] + issues.each do |issue| + if @sorted_issues.empty? + @sorted_issues << issue + next + end + @time_to_stop = false #indicates when this task reaches its parent task (important because it has to stop between its parent task and the next aunt task + @sorted_issues.each do |sorted_issue| + #if same parent and smaller date, stop; if same parent, same date and smaller id, stop; after parent and before next parent, stop; + if ((sorted_issue.parent == issue.parent) && (sorted_issue.start_date > issue.start_date)) || + ((sorted_issue.parent == issue.parent) && (sorted_issue.start_date == issue.start_date) && (sorted_issue.id > issue.id)) || + (@time_to_stop && (sorted_issue.hierarchical_level < issue.hierarchical_level)) + @sorted_issues.insert(@sorted_issues.index(sorted_issue), issue) + break + end + @time_to_stop = true if sorted_issue == issue.parent + end + #if this issue's parent is the last element + @sorted_issues << issue if @time_to_stop + end + @sorted_issues + end + + #assumes that first level issues are ordered by date (sort_as_tree) + def integrate_versions_with_issues_tree(issues, versions) + versions.sort! {|x,y| x.start_date <=> y.start_date } + versions.each do |version| + issues << version if issues.empty? + issues.each do |issue| + if ((issue.is_a? Issue && issue.root?) || (issue.is_a? Version)) && version.start_date < issue.start_date + #insert version before a root task or another version whose date is immediately after this task's one + issues.insert(issues.index(issue), version) + elsif issue == issues.last + issues << version + end + end + end + issues + end + end diff --git a/app/controllers/queries_controller.rb b/app/controllers/queries_controller.rb index 8500e85..7ab2b44 100644 --- a/app/controllers/queries_controller.rb +++ b/app/controllers/queries_controller.rb @@ -30,7 +30,11 @@ class QueriesController < ApplicationController params[:fields].each do |field| @query.add_filter(field, params[:operators][field], params[:values][field]) end if params[:fields] - + + params[:view_options].each_pair do |name, value| + @query.set_view_option( name, value) + end if params[:view_options] + if request.post? && params[:confirm] && @query.save flash[:notice] = l(:notice_successful_create) redirect_to :controller => 'issues', :action => 'index', :project_id => @project, :query_id => @query @@ -45,6 +49,9 @@ class QueriesController < ApplicationController params[:fields].each do |field| @query.add_filter(field, params[:operators][field], params[:values][field]) end if params[:fields] + params[:view_options].each_pair do |name, value| + @query.set_view_option( name, value) + end if params[:view_options] @query.attributes = params[:query] @query.project = nil if params[:query_is_for_all] @query.is_public = false unless (@query.project && current_role.allowed_to?(:manage_public_queries)) || User.current.admin? diff --git a/app/controllers/versions_controller.rb b/app/controllers/versions_controller.rb index c269432..73a4b9b 100644 --- a/app/controllers/versions_controller.rb +++ b/app/controllers/versions_controller.rb @@ -20,6 +20,10 @@ class VersionsController < ApplicationController before_filter :find_project, :authorize def show + @issues = @version.fixed_issues.find(:all, + :include => [:status, :tracker], + :order => "#{Tracker.table_name}.position, #{Issue.table_name}.id") + @issues = Issue.find_with_parents( @issues.collect { |i| i.id}) end def edit diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 7eae331..845966f 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -19,6 +19,12 @@ require 'csv' module IssuesHelper include ApplicationHelper + + def issue_ancestors(issue=@issue) + ancestors = "" + return "" if issue.parent == nil + ancestors += "issue-#{issue.parent.id}-child " + issue_ancestors(issue.parent) + end def render_issue_tooltip(issue) @cached_label_start_date ||= l(:field_start_date) @@ -196,4 +202,12 @@ module IssuesHelper export.rewind export end + + def auto_complete_result_parent_issue(candidates, phrase) + return "" if candidates.empty? + candidates.map! do |c| + content_tag("li", highlight( c.to_s, phrase), :id => String( c[:id])) + end + content_tag("ul", candidates.uniq) + end end diff --git a/app/helpers/queries_helper.rb b/app/helpers/queries_helper.rb index b41e66a..a7c9d60 100644 --- a/app/helpers/queries_helper.rb +++ b/app/helpers/queries_helper.rb @@ -1,3 +1,4 @@ +# -*- coding: mule-utf-8 -*- # redMine - project management software # Copyright (C) 2006-2007 Jean-Philippe Lang # @@ -27,7 +28,7 @@ module QueriesHelper content_tag('th', column.caption) end - def column_content(column, issue) + def column_content(column, issue, query) if column.is_a?(QueryCustomFieldColumn) cv = issue.custom_values.detect {|v| v.custom_field_id == column.custom_field.id} show_value(cv) @@ -40,8 +41,7 @@ module QueriesHelper else case column.name when :subject - h((!@project.nil? && @project != issue.project) ? "#{issue.project.name} - " : '') + - link_to(h(value), :controller => 'issues', :action => 'show', :id => issue) + subject_in_tree(issue, value, query) when :done_ratio progress_bar(value, :width => '80px') when :fixed_version @@ -52,4 +52,47 @@ module QueriesHelper end end end + + def subject_in_tree(issue, value, query) + case query.view_options['show_parents'] + when Query::VIEW_OPTIONS_SHOW_PARENTS_NEVER + content_tag('div', subject_text(issue, value), :class=>'issue-subject') + else + content_tag('span', content_tag('div', subject_text(issue, value), :class=>'issue-subject'), :class=>"issue-subject-level-#{issue.hierarchical_level}") + end + end + + def subject_text(issue, value) + subject_text = link_to(h(value), :controller => 'issues', :action => 'show', :id => issue) + h((@project.nil? || @project != issue.project) ? "#{issue.project.name} - " : '') + subject_text + end + + def issue_content(issue, query, options = { }) + html = "" + html << "' + html << '' + check_box_tag( "ids[]", issue.id, false, :id => nil) + '' + html << '' + link_to( issue.id, :controller => 'issues', :action => 'show', :id => issue) + '' + query.columns.each do |column| + html << content_tag( 'td', column_content(column, issue, query), :class => column.name) + end + html << "" + html + end + + def issues_family_content( parent, issues_to_show, query) + html = "" + html << issue_content( parent, query, :unfiltered => !( issues_to_show.include? parent)) + unless parent.children.empty? + parent.children.each do |child| + if issues_to_show.include?( child) || issues_to_show.detect { |i| i.ancestors.include? child } + html << issues_family_content( child, issues_to_show, query) + end + end + end + html + end + end diff --git a/app/helpers/versions_helper.rb b/app/helpers/versions_helper.rb index 0fcc640..35edff3 100644 --- a/app/helpers/versions_helper.rb +++ b/app/helpers/versions_helper.rb @@ -44,4 +44,31 @@ module VersionsHelper def status_by_options_for_select(value) options_for_select(STATUS_BY_CRITERIAS.collect {|criteria| [l("field_#{criteria}".to_sym), criteria]}, value) end + + def render_list_of_related_issues( issues, version, current_level = 1) + issues_on_current_level = issues.select { |i| i.hierarchical_level == current_level } + issues -= issues_on_current_level + content_tag( 'ul') do + html = '' + issues_on_current_level.each do |issue| + opts_for_issue_li = { } + if !issue.fixed_version or issue.fixed_version != version + opts_for_issue_li[:class] = 'issue-unfiltered' + end + html << content_tag( 'li', opts_for_issue_li) do + opts = { } + if issue.done_ratio == 100 + opts[:style] = 'font-weight: bold' + end + link_to_issue(issue, opts) + ": " + h(issue.subject) + end + children_to_print = issues & issue.children + children_to_print += issues.select { |i| i.hierarchical_level >= current_level + 2} + unless children_to_print.empty? + html << render_list_of_related_issues( children_to_print, version, current_level + 1) + end + end + html + end + end end diff --git a/app/models/issue.rb b/app/models/issue.rb index e7360d3..f723628 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -18,19 +18,19 @@ class Issue < ActiveRecord::Base belongs_to :project belongs_to :tracker - belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id' - belongs_to :author, :class_name => 'User', :foreign_key => 'author_id' - belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id' - belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id' - belongs_to :priority, :class_name => 'Enumeration', :foreign_key => 'priority_id' - belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id' + belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id' + belongs_to :author, :class_name => 'User', :foreign_key => 'author_id' + belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id' + belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id' + belongs_to :priority, :class_name => 'Enumeration', :foreign_key => 'priority_id' + belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id' has_many :journals, :as => :journalized, :dependent => :destroy has_many :time_entries, :dependent => :delete_all has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC" has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all - has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all + has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all acts_as_attachable :after_remove => :attachment_removed acts_as_customizable @@ -46,6 +46,30 @@ class Issue < ActiveRecord::Base acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]}, :author_key => :author_id + acts_as_nested_set + + alias_method :nested_set_move_to, :move_to + + # Move the node to the left of another node (you can pass id only) + def move_to_left_of(node) + nested_set_move_to node, :left + end + + # Move the node to the left of another node (you can pass id only) + def move_to_right_of(node) + nested_set_move_to node, :right + end + + # Move the node to the child of another node (you can pass id only) + def move_to_child_of(node) + nested_set_move_to node, :child + end + + # Move the node to root nodes + def move_to_root + nested_set_move_to nil, :root + end + validates_presence_of :subject, :priority, :project, :tracker, :author, :status validates_length_of :subject, :maximum => 255 validates_inclusion_of :done_ratio, :in => 0..100 @@ -55,81 +79,11 @@ class Issue < ActiveRecord::Base :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } } named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status - - # Returns true if usr or current user is allowed to view the issue - def visible?(usr=nil) - (usr || User.current).allowed_to?(:view_issues, self.project) - end - - def after_initialize - if new_record? - # set default values for new records only - self.status ||= IssueStatus.default - self.priority ||= Enumeration.priorities.default - end - end - - # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields - def available_custom_fields - (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : [] - end - - def copy_from(arg) - issue = arg.is_a?(Issue) ? arg : Issue.find(arg) - self.attributes = issue.attributes.dup - self.custom_values = issue.custom_values.collect {|v| v.clone} - self - end - - # Moves/copies an issue to a new project and tracker - # Returns the moved/copied issue on success, false on failure - def move_to(new_project, new_tracker = nil, options = {}) - options ||= {} - issue = options[:copy] ? self.clone : self - transaction do - if new_project && issue.project_id != new_project.id - # delete issue relations - unless Setting.cross_project_issue_relations? - issue.relations_from.clear - issue.relations_to.clear - end - # issue is moved to another project - # reassign to the category with same name if any - new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name) - issue.category = new_category - issue.fixed_version = nil - issue.project = new_project - end - if new_tracker - issue.tracker = new_tracker - end - if options[:copy] - issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h} - issue.status = self.status - end - if issue.save - unless options[:copy] - # Manually update project_id on related time entries - TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id}) - end - else - Issue.connection.rollback_db_transaction - return false - end - end - return issue - end - - def priority_id=(pid) - self.priority = nil - write_attribute(:priority_id, pid) - end - - def estimated_hours=(h) - write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h) - end - + def validate + # FIXME: I do not know what actually this should do, but this + # validation does not allow me to change due_date in hook when + # fixed_version is set. if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty? errors.add :due_date, :not_a_date end @@ -141,6 +95,22 @@ class Issue < ActiveRecord::Base if start_date && soonest_start && start_date < soonest_start errors.add :start_date, :invalid end + + unless children.empty? + if IssueStatus.find_by_id( @attributes['status_id']).is_closed? && + children.detect { |i| !i.closed? } + errors.add( :status, + "Can't close parent issue " + + "while one of the children is still open.") + end + + children_max_fixed_version = children.select { |i| i.fixed_version } .max { |a,b| a.fixed_version <=> b.fixed_version } + if @attributes['fixed_version_id'] && children_max_fixed_version + if Version.find_by_id( @attributes['fixed_version_id']) < children_max_fixed_version.fixed_version + errors.add :fixed_version, "Can't set target version of parent issue lower than any of the children." + end + end + end end def validate_on_create @@ -161,7 +131,7 @@ class Issue < ActiveRecord::Base @current_journal.details << JournalDetail.new(:property => 'attr', :prop_key => c, :old_value => @issue_before_change.send(c), - :value => send(c)) unless send(c)==@issue_before_change.send(c) + :value => send(c)) unless send(c)==@issue_before_change.send(c) || (!self.leaf? && %w(status_id priority_id fixed_version_id start_date due_date done_ratio estimated_hours).include?(c)) } # custom fields changes custom_values.each {|c| @@ -174,17 +144,43 @@ class Issue < ActiveRecord::Base } @current_journal.save end + # Save the issue even if the journal is not saved (because empty) true + end def after_save # Reload is needed in order to get the right status reload - + # Update start/due dates of following issues relations_from.each(&:set_issue_to_dates) - + + if parent + # Set default status of parent if new status opened the issue. + if !status.is_closed? && parent.status.is_closed? + parent.update_attribute :status, IssueStatus.default + end + + # Set 'Target version' of parent if one was set on one of the + # children issue and parent have no 'Target version'. Do the same + # if 'Target version of the parent issue lower (by the release + # date or by the version number). + if parent.fixed_version.nil? && fixed_version or + ( parent.fixed_version && fixed_version and + parent.fixed_version.project == fixed_version.project and + parent.fixed_version < fixed_version ) + parent.update_attribute :fixed_version, fixed_version + end + end + + # If target version is set, but "Due to" date is not, set it as + # the same as the date of target version. + if due_date.nil? && fixed_version && fixed_version.due_date + self.update_attribute :due_date, fixed_version.due_date + end + # Close duplicates if the issue was closed if @issue_before_change && !@issue_before_change.closed? && self.closed? duplicates.each do |duplicate| @@ -198,6 +194,84 @@ class Issue < ActiveRecord::Base end end end + + # Returns true if usr or current user is allowed to view the issue + def visible?(usr=nil) + (usr || User.current).allowed_to?(:view_issues, self.project) + end + + def after_initialize + if new_record? + # set default values for new records only + self.status ||= IssueStatus.default + self.priority ||= Enumeration.priorities.default + end + end + + # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields + def available_custom_fields + (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : [] + end + + def copy_from(arg) + issue = arg.is_a?(Issue) ? arg : Issue.find(arg) + self.attributes = issue.attributes.dup + self.custom_values = issue.custom_values.collect {|v| v.clone} + self + end + + # Moves/copies an issue to a new project and tracker + # Returns the moved/copied issue on success, false on failure + def move_to(new_project, new_tracker = nil, options = {}) + options ||= {} + issue = if options[:copy] + Issue.new( self.attributes.reject { |k,v| k == 'parent_id' }) + else + self + end + transaction do + if new_project && issue.project_id != new_project.id + unless Setting.cross_project_issue_relations? + # delete issue relations + issue.relations_from.clear + issue.relations_to.clear + + issue.children.each(&:move_to_root) unless options[:copy] + end + # issue is moved to another project + # reassign to the category with same name if any + new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name) + issue.category = new_category + issue.fixed_version = nil + issue.project = new_project + end + if new_tracker + issue.tracker = new_tracker + end + if options[:copy] + issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h} + issue.status = self.status + end + if issue.save + unless options[:copy] + # Manually update project_id on related time entries + TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id}) + end + if new_project && issue.project_id != new_project.id && + !Setting.cross_project_issue_relations? + issue.move_to_root + end + else + Issue.connection.rollback_db_transaction + return false + end + end + return issue + end + + def estimated_hours=(h) + write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h) + end def init_journal(user, notes = "") @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes) @@ -210,6 +284,11 @@ class Issue < ActiveRecord::Base @current_journal end + def priority_id=(pid) + self.priority = nil + write_attribute(:priority_id, pid) + end + # Return true if the issue is closed, otherwise false def closed? self.status.is_closed? @@ -274,6 +353,10 @@ class Issue < ActiveRecord::Base due_date || (fixed_version ? fixed_version.effective_date : nil) end + def duration1 + (start_date && due_date) ? (due_date - start_date + 1) : 0 + end + # Returns the time scheduled for this issue. # # Example: @@ -287,6 +370,82 @@ class Issue < ActiveRecord::Base @soonest_start ||= relations_to.collect{|relation| relation.successor_soonest_start}.compact.min end + def self.visible_by(usr) + with_scope(:find => { :conditions => Project.visible_by(usr) }) do + yield + end + end + + def done_ratio + if children? + @total_planned_days ||= 0 + @total_actual_days ||= 0 + children.each do |child| # from every subtask get the total number of days and the number of days already "worked" + planned_days = child.duration1 + actual_days = child.done_ratio ? (planned_days * child.done_ratio / 100).floor : 0 + @total_planned_days += planned_days + @total_actual_days += actual_days + end + @total_done_ratio = @total_planned_days != 0 ? (@total_actual_days * 100 / @total_planned_days).floor : 0 + else + read_attribute(:done_ratio) + end + end + + def estimated_hours + if children? + is_set = false + children.each do |child| + if child.estimated_hours + if is_set + @est_hours += child.estimated_hours + else + @est_hours = child.estimated_hours + is_set = true + end + end + end + @est_hours + else + read_attribute(:estimated_hours) + end + end + + def due_date + if children? + children_date = children.find_all { |i| i.due_date } + unless children_date.empty? + children_date.sort { |a,b| a.due_date <=> b.due_date} .max + else + read_attribute(:due_date) + end + else + read_attribute(:due_date) + end + end + + def children? + children != [] + end + + #First level tasks have hierarchical level = 1 and so on + def hierarchical_level(issue=self) + 1 + level + end + + # FIXME: remove this method. + def self.find_with_parents( *args) + issues = find( *args) + return [] if issues.empty? + issues.each do |i| + while not i.root? + issues += [ i.parent ] + i = i.parent + end + end + issues.uniq + end + def to_s "#{tracker} ##{id}: #{subject}" end diff --git a/app/models/issue_relation.rb b/app/models/issue_relation.rb index d26292c..2d429dd 100644 --- a/app/models/issue_relation.rb +++ b/app/models/issue_relation.rb @@ -17,24 +17,24 @@ class IssueRelation < ActiveRecord::Base belongs_to :issue_from, :class_name => 'Issue', :foreign_key => 'issue_from_id' - belongs_to :issue_to, :class_name => 'Issue', :foreign_key => 'issue_to_id' + belongs_to :issue_to, :class_name => 'Issue', :foreign_key => 'issue_to_id' TYPE_RELATES = "relates" TYPE_DUPLICATES = "duplicates" TYPE_BLOCKS = "blocks" TYPE_PRECEDES = "precedes" - TYPES = { TYPE_RELATES => { :name => :label_relates_to, :sym_name => :label_relates_to, :order => 1 }, - TYPE_DUPLICATES => { :name => :label_duplicates, :sym_name => :label_duplicated_by, :order => 2 }, - TYPE_BLOCKS => { :name => :label_blocks, :sym_name => :label_blocked_by, :order => 3 }, - TYPE_PRECEDES => { :name => :label_precedes, :sym_name => :label_follows, :order => 4 }, + TYPES = { TYPE_RELATES => { :name => :label_relates_to, :sym_name => :label_relates_to, :order => 1 }, + TYPE_DUPLICATES => { :name => :label_duplicates, :sym_name => :label_duplicated_by, :order => 2 }, + TYPE_BLOCKS => { :name => :label_blocks, :sym_name => :label_blocked_by, :order => 3 }, + TYPE_PRECEDES => { :name => :label_precedes, :sym_name => :label_follows, :order => 4 }, }.freeze - validates_presence_of :issue_from, :issue_to, :relation_type - validates_inclusion_of :relation_type, :in => TYPES.keys - validates_numericality_of :delay, :allow_nil => true - validates_uniqueness_of :issue_to_id, :scope => :issue_from_id - + validates_presence_of :issue_from, :issue_to, :relation_type + validates_inclusion_of :relation_type, :in => TYPES.keys + validates_numericality_of :delay, :allow_nil => true + validates_uniqueness_of :issue_to_id, :scope => :issue_from_id + attr_protected :issue_from_id, :issue_to_id def validate @@ -59,9 +59,10 @@ class IssueRelation < ActiveRecord::Base else self.delay = nil end + set_issue_to_dates end - + def set_issue_to_dates soonest_start = self.successor_soonest_start if soonest_start && (!issue_to.start_date || issue_to.start_date < soonest_start) diff --git a/app/models/query.rb b/app/models/query.rb index 41ce17f..6cc5b30 100644 --- a/app/models/query.rb +++ b/app/models/query.rb @@ -52,11 +52,26 @@ class QueryCustomFieldColumn < QueryColumn end end +class ViewOption + attr_accessor :name, :available_values + include Redmine::I18n + + def initialize( name, available_values) + self.name = name + self.available_values = available_values + end + + def caption + l("label_view_option_#{name}") + end +end + class Query < ActiveRecord::Base belongs_to :project belongs_to :user serialize :filters serialize :column_names + serialize :view_options serialize :sort_criteria, Array attr_protected :project_id, :user_id @@ -115,10 +130,22 @@ class Query < ActiveRecord::Base QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'), ] cattr_reader :available_columns + + VIEW_OPTIONS_SHOW_PARENTS_NEVER = 'do_not_show' + VIEW_OPTIONS_SHOW_PARENTS_ALWAYS = 'show_always' + VIEW_OPTIONS_SHOW_PARENTS_ORGANIZE_BY_PARENT = 'organize_by_parent' + + @@available_view_options = [ + ViewOption.new( 'show_parents', [ [ l(:label_view_option_parents_do_not_show), VIEW_OPTIONS_SHOW_PARENTS_NEVER ], + [ l(:label_view_option_parents_show_always), VIEW_OPTIONS_SHOW_PARENTS_ALWAYS ], + [ l(:label_view_option_parents_show_and_group), VIEW_OPTIONS_SHOW_PARENTS_ORGANIZE_BY_PARENT ] ]) + ] + cattr_reader :available_view_options def initialize(attributes = nil) super attributes self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} } + self.view_options ||= { 'show_parents' => 'do_not_show' } end def after_initialize @@ -353,7 +380,19 @@ class Query < ActiveRecord::Base (filters_clauses << project_statement).join(' AND ') end - + + def set_view_option( option, value) + self.view_options[option] = value + end + + def values_for_view_option( option) + @@available_view_options.find { |vo| vo.name == option }.available_values + end + + def caption_for_view_option( option) + @@available_view_options.find { |vo| vo.name == option }.caption + end + private # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+ diff --git a/app/models/version.rb b/app/models/version.rb index 13d33e2..fb0592a 100644 --- a/app/models/version.rb +++ b/app/models/version.rb @@ -27,6 +27,8 @@ class Version < ActiveRecord::Base validates_length_of :name, :maximum => 60 validates_format_of :effective_date, :with => /^\d{4}-\d{2}-\d{2}$/, :message => :not_a_date, :allow_nil => true + include Comparable + def start_date effective_date end diff --git a/app/views/issues/_edit.rhtml b/app/views/issues/_edit.rhtml index 413f217..2aa0e58 100644 --- a/app/views/issues/_edit.rhtml +++ b/app/views/issues/_edit.rhtml @@ -15,7 +15,7 @@ <%= render :partial => (@edit_allowed ? 'form' : 'form_update'), :locals => {:f => f} %> <% end %> - <% if authorize_for('timelog', 'edit') %> + <% if authorize_for('timelog', 'edit') && @issue.leaf? %>
<%= l(:button_log_time) %> <% fields_for :time_entry, @time_entry, { :builder => TabularFormBuilder, :lang => current_language} do |time_entry| %>
diff --git a/app/views/issues/_form.rhtml b/app/views/issues/_form.rhtml index fac2d6a..869c804 100644 --- a/app/views/issues/_form.rhtml +++ b/app/views/issues/_form.rhtml @@ -1,3 +1,11 @@ + + <% if @issue.new_record? %>

<%= f.select :tracker_id, @project.trackers.collect {|t| [t.name, t.id]}, :required => true %>

<%= observe_field :issue_tracker_id, :url => { :action => :new }, @@ -17,13 +25,14 @@
-<% if @issue.new_record? || @allowed_statuses.any? %> +<% if (@issue.new_record? || @allowed_statuses.any?) %>

<%= f.select :status_id, (@allowed_statuses.collect {|p| [p.name, p.id]}), :required => true %>

<% else %>

<%= @issue.status.name %>

<% end %>

<%= f.select :priority_id, (@priorities.collect {|p| [p.name, p.id]}), :required => true %>

+

<%= f.select :assigned_to_id, (@issue.assignable_users.collect {|m| [m.name, m.id]}), :include_blank => true %>

<% unless @project.issue_categories.empty? %>

<%= f.select :category_id, (@project.issue_categories.collect {|c| [c.name, c.id]}), :include_blank => true %> @@ -38,10 +47,30 @@

+<% if @issue.new_record? || @issue.leaf? %>

<%= f.text_field :start_date, :size => 10 %><%= calendar_for('issue_start_date') %>

<%= f.text_field :due_date, :size => 10 %><%= calendar_for('issue_due_date') %>

<%= f.text_field :estimated_hours, :size => 3 %> <%= l(:field_hours) %>

<%= f.select :done_ratio, ((0..10).to_a.collect {|r| ["#{r*10} %", r*10] }) %>

+<% else %> +

<%= format_date(@issue.start_date) %>

+

<%= format_date(@issue.due_date) %>

+

<%= "#{@issue.done_ratio}%" %>

+<% end %> +<%= content_tag( :input, {}, + :id => :parent_issue_id, + :type => :hidden, + :name => 'parent_issue_id', :value => @parent_issue ? @parent_issue.id : "") %> +

+<% if authorize_for( 'issues', 'add_subissue') %> + <%= text_field_with_auto_complete( :issue, :parent, + { :name => 'issue_parent', :value => @parent_issue || "" }, + :url => { :action => 'auto_complete_for_issue_parent', :project_id => @project}, + :after_update_element => 'setParentIssueValue') %> +<% else %> + <%= @parent_issue || "-" %> +<% end %> +

diff --git a/app/views/issues/_form_update.rhtml b/app/views/issues/_form_update.rhtml index 3f17a03..6dad157 100644 --- a/app/views/issues/_form_update.rhtml +++ b/app/views/issues/_form_update.rhtml @@ -4,7 +4,11 @@

<%= f.select :assigned_to_id, (@issue.assignable_users.collect {|m| [m.name, m.id]}), :include_blank => true %>

+<% if @issue.leaf? %>

<%= f.select :done_ratio, ((0..10).to_a.collect {|r| ["#{r*10} %", r*10] }) %>

+<% else %> +

<%= "#{@issue.done_ratio}%" %>

+<% end %> <%= content_tag('p', f.select(:fixed_version_id, (@project.versions.sort.collect {|v| [v.name, v.id]}), { :include_blank => true })) unless @project.versions.empty? %> diff --git a/app/views/issues/_list.rhtml b/app/views/issues/_list.rhtml index 9326760..7884bce 100644 --- a/app/views/issues/_list.rhtml +++ b/app/views/issues/_list.rhtml @@ -1,22 +1,40 @@ -<% form_tag({}) do -%> - +<% form_tag({}) do -%> +
- <%= sort_header_tag('id', :caption => '#', :default_order => 'desc') %> + <%= sort_header_tag('id', :caption => '#', :default_order => 'desc') %> <% query.columns.each do |column| %> <%= column_header(column) %> <% end %> - - + + + <% if query.view_options['show_parents'] == Query::VIEW_OPTIONS_SHOW_PARENTS_NEVER -%> <% issues.each do |issue| -%> - - - - <% query.columns.each do |column| %><%= content_tag 'td', column_content(column, issue), :class => column.name %><% end %> - + <%= issue_content( issue, query) %> <% end -%> - -
<%= link_to image_tag('toggle_check.png'), {}, :onclick => 'toggleIssuesSelection(Element.up(this, "form")); return false;', - :title => "#{l(:button_check_all)}/#{l(:button_uncheck_all)}" %> + :title => "#{l(:button_check_all)}/#{l(:button_uncheck_all)}" %>
<%= check_box_tag("ids[]", issue.id, false, :id => nil) %><%= link_to issue.id, :controller => 'issues', :action => 'show', :id => issue %>
+ <% elsif query.view_options['show_parents'] == Query::VIEW_OPTIONS_SHOW_PARENTS_ALWAYS -%> + <% issues.each do |issue| -%> + <% issue.ancestors.reverse.each do |parent_issue| -%> + <%= issue_content( parent_issue, query, :unfiltered => true) %> + <% end -%> + <%= issue_content( issue, query) %> + <% end -%> + <% elsif query.view_options['show_parents'] == Query::VIEW_OPTIONS_SHOW_PARENTS_ORGANIZE_BY_PARENT -%> + <% parents_on_first_lvl = [] + issues.each do |i| + if i.parent + first_parent = i.root + else + first_parent = i + end + parents_on_first_lvl += [ first_parent ] unless parents_on_first_lvl.include?( first_parent) + end -%> + <% parents_on_first_lvl.each do |parent| -%> + <%= issues_family_content( parent, issues, query) %> + <% end -%> + <% end -%> + + <% end -%> diff --git a/app/views/issues/context_menu.rhtml b/app/views/issues/context_menu.rhtml index ae9a1af..ff7660f 100644 --- a/app/views/issues/context_menu.rhtml +++ b/app/views/issues/context_menu.rhtml @@ -17,7 +17,6 @@
  • <%= context_menu_link l(:button_edit), {:controller => 'issues', :action => 'bulk_edit', :ids => @issues.collect(&:id)}, :class => 'icon-edit', :disabled => !@can[:edit] %>
  • <% end %> -
  • <%= l(:field_priority) %>
      @@ -66,6 +65,7 @@
  • <% end -%> + <% if @issue && @issue.leaf? %>
  • <%= l(:field_done_ratio) %>
      @@ -75,7 +75,7 @@ <% end -%>
  • - + <% end %> <% if !@issue.nil? %>
  • <%= context_menu_link l(:button_copy), {:controller => 'issues', :action => 'new', :project_id => @project, :copy_from => @issue}, :class => 'icon-copy', :disabled => !@can[:copy] %>
  • diff --git a/app/views/issues/index.rhtml b/app/views/issues/index.rhtml index 7c381d8..e855c28 100644 --- a/app/views/issues/index.rhtml +++ b/app/views/issues/index.rhtml @@ -4,8 +4,12 @@ <% form_tag({ :controller => 'queries', :action => 'new' }, :id => 'query_form') do %> <%= hidden_field_tag('project_id', @project.to_param) if @project %> +
    <%= l(:label_view) %> + <%= render :partial => 'queries/view_options', :locals => {:query => @query } %> +
    <%= l(:label_filter_plural) %> - <%= render :partial => 'queries/filters', :locals => {:query => @query} %> + <%= render :partial => 'queries/filters', :locals => {:query => @query} %> +

    <%= link_to_remote l(:button_apply), { :url => { :set_filter => 1 }, @@ -23,7 +27,6 @@ <%= link_to l(:button_save), {}, :onclick => "$('query_form').submit(); return false;", :class => 'icon icon-save' %> <% end %>

    -
    <% end %> <% else %>
    diff --git a/app/views/issues/show.rhtml b/app/views/issues/show.rhtml index ed14fe3..bd892c3 100644 --- a/app/views/issues/show.rhtml +++ b/app/views/issues/show.rhtml @@ -1,4 +1,8 @@
    +<%= link_to_if_authorized(l(:button_add_subissue), + { :controller => 'issues', :action => 'add_subissue', + :project_id => @project.id, :issue => { :parent_id => @issue.id }}, + :class => 'icon icon-add') %> <%= link_to_if_authorized(l(:button_update), {:controller => 'issues', :action => 'edit', :id => @issue }, :onclick => 'showAndScrollTo("update", "notes"); return false;', :class => 'icon icon-edit', :accesskey => accesskey(:edit)) %> <%= link_to_if_authorized l(:button_log_time), {:controller => 'timelog', :action => 'edit', :issue_id => @issue}, :class => 'icon icon-time-add' %> <%= watcher_tag(@issue, User.current) %> @@ -51,8 +55,8 @@ if (n > 1) n = 0 %> - <%end -end %> + <% end %> +<% end %> <%= call_hook(:view_issues_show_details_bottom, :issue => @issue) %> diff --git a/app/views/projects/roadmap.rhtml b/app/views/projects/roadmap.rhtml index bcba13a..7a5e2eb 100644 --- a/app/views/projects/roadmap.rhtml +++ b/app/views/projects/roadmap.rhtml @@ -10,19 +10,19 @@ <%= render :partial => 'versions/overview', :locals => {:version => version} %> <%= render(:partial => "wiki/content", :locals => {:content => version.wiki_page.content}) if version.wiki_page %> - <% issues = version.fixed_issues.find(:all, - :include => [:status, :tracker], - :conditions => ["tracker_id in (#{@selected_tracker_ids.join(',')})"], - :order => "#{Tracker.table_name}.position, #{Issue.table_name}.id") unless @selected_tracker_ids.empty? + <% + unless @selected_tracker_ids.empty? + issues = version.fixed_issues.find(:all, + :include => [:status, :tracker], + :conditions => ["tracker_id in (#{@selected_tracker_ids.join(',')})"], + :order => "#{Tracker.table_name}.position, #{Issue.table_name}.id") + issues = Issue.find_with_parents( issues.collect { |i| i.id }) + end issues ||= [] %> <% if issues.size > 0 %> <% end %> <%= call_hook :view_projects_roadmap_version_bottom, :version => version %> diff --git a/app/views/queries/_form.rhtml b/app/views/queries/_form.rhtml index 7c227a9..23e68fa 100644 --- a/app/views/queries/_form.rhtml +++ b/app/views/queries/_form.rhtml @@ -21,6 +21,10 @@ :onclick => 'if (this.checked) {Element.hide("columns")} else {Element.show("columns")}' %>

    +
    <%= l(:label_view) %> + <%= render :partial => 'queries/view_options', :locals => {:query => query } %> +
    +
    <%= l(:label_filter_plural) %> <%= render :partial => 'queries/filters', :locals => {:query => query}%>
    diff --git a/app/views/queries/_view_options.rhtml b/app/views/queries/_view_options.rhtml new file mode 100644 index 0000000..dea7db8 --- /dev/null +++ b/app/views/queries/_view_options.rhtml @@ -0,0 +1,6 @@ +<% query.view_options.each_key do |voption| -%> + <%= query.caption_for_view_option( voption) %>: + <%= select_tag( "view_options[#{voption}]", + options_for_select( query.values_for_view_option( voption), + query.view_options[voption])) %> +<% end %> diff --git a/app/views/versions/show.rhtml b/app/views/versions/show.rhtml index ef53751..d721b7c 100644 --- a/app/views/versions/show.rhtml +++ b/app/views/versions/show.rhtml @@ -31,16 +31,9 @@ <%= render :partial => 'versions/overview', :locals => {:version => @version} %> <%= render(:partial => "wiki/content", :locals => {:content => @version.wiki_page.content}) if @version.wiki_page %> -<% issues = @version.fixed_issues.find(:all, - :include => [:status, :tracker], - :order => "#{Tracker.table_name}.position, #{Issue.table_name}.id") %> -<% if issues.size > 0 %> +<% if @issues.size > 0 %> <% end %>
    diff --git a/config/locales/en.yml b/config/locales/en.yml index b60329f..8f69dc5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -136,7 +136,8 @@ en: error_scm_command_failed: "An error occurred when trying to access the repository: {{value}}" error_scm_annotate: "The entry does not exist or can not be annotated." error_issue_not_found_in_project: 'The issue was not found or does not belong to this project' - + error_issue_can_have_only_one_parent: allows only one relation (a task can have only one parent). + warning_attachments_not_saved: "{{count}} file(s) could not be saved." mail_subject_lost_password: "Your {{value}} password" @@ -242,6 +243,10 @@ en: field_watcher: Watcher field_identity_url: OpenID URL field_content: Content + field_calendar_firstday: First day of week + field_parent: Subproject of + field_parent_issue: Child of + field_parent_title: Parent page setting_app_title: Application title setting_app_subtitle: Application subtitle @@ -664,6 +669,12 @@ en: label_issue_watchers: Watchers label_example: Example label_display: Display + label_children: parent of + label_parents: child of + label_view_option_parents_do_not_show: Never + label_view_option_parents_show_always: Always + label_view_option_parents_show_and_group: Organize by parent + label_view_option_show_parents: Show parents label_sort: Sort label_ascending: Ascending label_descending: Descending @@ -708,7 +719,8 @@ en: button_update: Update button_configure: Configure button_quote: Quote - + button_add_subissue: Add sub-issue + status_active: active status_registered: registered status_locked: locked diff --git a/db/migrate/20090115162651_add_queries_view_options.rb b/db/migrate/20090115162651_add_queries_view_options.rb new file mode 100644 index 0000000..cfd0377 --- /dev/null +++ b/db/migrate/20090115162651_add_queries_view_options.rb @@ -0,0 +1,9 @@ +class AddQueriesViewOptions < ActiveRecord::Migration + def self.up + add_column :queries, :view_options, :text + end + + def self.down + remove_column :queries, :view_options + end +end diff --git a/db/migrate/20090121172432_add_default_value_of_view_option_queries.rb b/db/migrate/20090121172432_add_default_value_of_view_option_queries.rb new file mode 100644 index 0000000..8c40200 --- /dev/null +++ b/db/migrate/20090121172432_add_default_value_of_view_option_queries.rb @@ -0,0 +1,11 @@ +class AddDefaultValueOfViewOptionQueries < ActiveRecord::Migration + def self.up + Query.find(:all).each do |q| + q.view_options ||= { 'show_parents' => 'do_not_show' } + q.save! + end + end + + def self.down + end +end diff --git a/db/migrate/20090406213813_add_issues_nested_sets.rb b/db/migrate/20090406213813_add_issues_nested_sets.rb new file mode 100644 index 0000000..3e86392 --- /dev/null +++ b/db/migrate/20090406213813_add_issues_nested_sets.rb @@ -0,0 +1,36 @@ +class AddIssuesNestedSets < ActiveRecord::Migration + # IssueRelation::TYPE_PARENTS was deleted + TYPE_PARENTS = "parents" + + def self.up + add_column :issues, :parent_id, :integer, :default => nil + add_column :issues, :lft, :integer + add_column :issues, :rgt, :integer + Issue.reset_column_information + + say_with_time "fixing invalid issues" do + Issue.find( :all).each do |issue| + if !issue.valid? && issue.errors.on( :due_date) + issue.due_date = issue.start_date + issue.save! + end + end + end + + say_with_time "rebuilding left & right indexes" do + Issue.rebuild! + end + + say_with_time( + "converting subissues for using parent_id instead of IssueRelation") do + IssueRelation.find_all_by_relation_type( TYPE_PARENTS).each do |rel| + rel.issue_from.move_to_child_of rel.issue_to.id + rel.delete + end + end + end + + def self.down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/lang/en.yml b/lang/en.yml new file mode 100644 index 0000000..973a75c --- /dev/null +++ b/lang/en.yml @@ -0,0 +1,720 @@ +_gloc_rule_default: '|n| n==1 ? "" : "_plural" ' + +actionview_datehelper_select_day_prefix: +actionview_datehelper_select_month_names: January,February,March,April,May,June,July,August,September,October,November,December +actionview_datehelper_select_month_names_abbr: Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec +actionview_datehelper_select_month_prefix: +actionview_datehelper_select_year_prefix: +actionview_datehelper_time_in_words_day: 1 day +actionview_datehelper_time_in_words_day_plural: %d days +actionview_datehelper_time_in_words_hour_about: about an hour +actionview_datehelper_time_in_words_hour_about_plural: about %d hours +actionview_datehelper_time_in_words_hour_about_single: about an hour +actionview_datehelper_time_in_words_minute: 1 minute +actionview_datehelper_time_in_words_minute_half: half a minute +actionview_datehelper_time_in_words_minute_less_than: less than a minute +actionview_datehelper_time_in_words_minute_plural: %d minutes +actionview_datehelper_time_in_words_minute_single: 1 minute +actionview_datehelper_time_in_words_second_less_than: less than a second +actionview_datehelper_time_in_words_second_less_than_plural: less than %d seconds +actionview_instancetag_blank_option: Please select + +activerecord_error_inclusion: is not included in the list +activerecord_error_exclusion: is reserved +activerecord_error_invalid: is invalid +activerecord_error_confirmation: doesn't match confirmation +activerecord_error_accepted: must be accepted +activerecord_error_empty: can't be empty +activerecord_error_blank: can't be blank +activerecord_error_too_long: is too long +activerecord_error_too_short: is too short +activerecord_error_wrong_length: is the wrong length +activerecord_error_taken: has already been taken +activerecord_error_not_a_number: is not a number +activerecord_error_not_a_date: is not a valid date +activerecord_error_greater_than_start_date: must be greater than start date +activerecord_error_not_same_project: doesn't belong to the same project +activerecord_error_circular_dependency: This relation would create a circular dependency + +general_fmt_age: %d yr +general_fmt_age_plural: %d yrs +general_fmt_date: %%m/%%d/%%Y +general_fmt_datetime: %%m/%%d/%%Y %%I:%%M %%p +general_fmt_datetime_short: %%b %%d, %%I:%%M %%p +general_fmt_time: %%I:%%M %%p +general_text_No: 'No' +general_text_Yes: 'Yes' +general_text_no: 'no' +general_text_yes: 'yes' +general_lang_name: 'English' +general_csv_separator: ',' +general_csv_decimal_separator: '.' +general_csv_encoding: ISO-8859-1 +general_pdf_encoding: ISO-8859-1 +general_day_names: Monday,Tuesday,Wednesday,Thursday,Friday,Saturday,Sunday +general_first_day_of_week: '7' + +notice_account_updated: Account was successfully updated. +notice_account_invalid_creditentials: Invalid user or password +notice_account_password_updated: Password was successfully updated. +notice_account_wrong_password: Wrong password +notice_account_register_done: Account was successfully created. To activate your account, click on the link that was emailed to you. +notice_account_unknown_email: Unknown user. +notice_can_t_change_password: This account uses an external authentication source. Impossible to change the password. +notice_account_lost_email_sent: An email with instructions to choose a new password has been sent to you. +notice_account_activated: Your account has been activated. You can now log in. +notice_successful_create: Successful creation. +notice_successful_update: Successful update. +notice_successful_delete: Successful deletion. +notice_successful_connection: Successful connection. +notice_file_not_found: The page you were trying to access doesn't exist or has been removed. +notice_locking_conflict: Data has been updated by another user. +notice_not_authorized: You are not authorized to access this page. +notice_email_sent: An email was sent to %s +notice_email_error: An error occurred while sending mail (%s) +notice_feeds_access_key_reseted: Your RSS access key was reset. +notice_failed_to_save_issues: "Failed to save %d issue(s) on %d selected: %s." +notice_no_issue_selected: "No issue is selected! Please, check the issues you want to edit." +notice_account_pending: "Your account was created and is now pending administrator approval." +notice_default_data_loaded: Default configuration successfully loaded. +notice_unable_delete_version: Unable to delete version. + +error_can_t_load_default_data: "Default configuration could not be loaded: %s" +error_scm_not_found: "The entry or revision was not found in the repository." +error_scm_command_failed: "An error occurred when trying to access the repository: %s" +error_scm_annotate: "The entry does not exist or can not be annotated." +error_issue_not_found_in_project: 'The issue was not found or does not belong to this project' +error_issue_can_have_only_one_parent: allows only one relation (a task can have only one parent). + +warning_attachments_not_saved: "%d file(s) could not be saved." + +mail_subject_lost_password: Your %s password +mail_body_lost_password: 'To change your password, click on the following link:' +mail_subject_register: Your %s account activation +mail_body_register: 'To activate your account, click on the following link:' +mail_body_account_information_external: You can use your "%s" account to log in. +mail_body_account_information: Your account information +mail_subject_account_activation_request: %s account activation request +mail_body_account_activation_request: 'A new user (%s) has registered. The account is pending your approval:' +mail_subject_reminder: "%d issue(s) due in the next days" +mail_body_reminder: "%d issue(s) that are assigned to you are due in the next %d days:" + +gui_validation_error: 1 error +gui_validation_error_plural: %d errors + +field_name: Name +field_description: Description +field_summary: Summary +field_is_required: Required +field_firstname: Firstname +field_lastname: Lastname +field_mail: Email +field_filename: File +field_filesize: Size +field_downloads: Downloads +field_author: Author +field_created_on: Created +field_updated_on: Updated +field_field_format: Format +field_is_for_all: For all projects +field_possible_values: Possible values +field_regexp: Regular expression +field_min_length: Minimum length +field_max_length: Maximum length +field_value: Value +field_category: Category +field_title: Title +field_project: Project +field_issue: Issue +field_status: Status +field_notes: Notes +field_is_closed: Issue closed +field_is_default: Default value +field_tracker: Tracker +field_subject: Subject +field_due_date: Due date +field_assigned_to: Assigned to +field_priority: Priority +field_fixed_version: Target version +field_user: User +field_role: Role +field_homepage: Homepage +field_is_public: Public +field_parent: Subproject of +field_is_in_chlog: Issues displayed in changelog +field_is_in_roadmap: Issues displayed in roadmap +field_login: Login +field_mail_notification: Email notifications +field_admin: Administrator +field_last_login_on: Last connection +field_language: Language +field_effective_date: Date +field_password: Password +field_new_password: New password +field_password_confirmation: Confirmation +field_version: Version +field_type: Type +field_host: Host +field_port: Port +field_account: Account +field_base_dn: Base DN +field_attr_login: Login attribute +field_attr_firstname: Firstname attribute +field_attr_lastname: Lastname attribute +field_attr_mail: Email attribute +field_onthefly: On-the-fly user creation +field_start_date: Start +field_done_ratio: %% Done +field_auth_source: Authentication mode +field_hide_mail: Hide my email address +field_comments: Comment +field_url: URL +field_start_page: Start page +field_subproject: Subproject +field_hours: Hours +field_activity: Activity +field_spent_on: Date +field_identifier: Identifier +field_is_filter: Used as a filter +field_issue_to_id: Related issue +field_delay: Delay +field_assignable: Issues can be assigned to this role +field_redirect_existing_links: Redirect existing links +field_estimated_hours: Estimated time +field_column_names: Columns +field_time_zone: Time zone +field_searchable: Searchable +field_default_value: Default value +field_comments_sorting: Display comments +field_parent_title: Parent page +field_editable: Editable +field_calendar_firstday: First day of week +field_parent: Subproject of +field_parent_issue: Child of +field_parent_title: Parent page + +setting_app_title: Application title +setting_app_subtitle: Application subtitle +setting_welcome_text: Welcome text +setting_default_language: Default language +setting_login_required: Authentication required +setting_self_registration: Self-registration +setting_attachment_max_size: Attachment max. size +setting_issues_export_limit: Issues export limit +setting_mail_from: Emission email address +setting_bcc_recipients: Blind carbon copy recipients (bcc) +setting_plain_text_mail: Plain text mail (no HTML) +setting_host_name: Host name and path +setting_text_formatting: Text formatting +setting_wiki_compression: Wiki history compression +setting_feeds_limit: Feed content limit +setting_default_projects_public: New projects are public by default +setting_autofetch_changesets: Autofetch commits +setting_sys_api_enabled: Enable WS for repository management +setting_commit_ref_keywords: Referencing keywords +setting_commit_fix_keywords: Fixing keywords +setting_autologin: Autologin +setting_date_format: Date format +setting_time_format: Time format +setting_cross_project_issue_relations: Allow cross-project issue relations +setting_issue_list_default_columns: Default columns displayed on the issue list +setting_repositories_encodings: Repositories encodings +setting_commit_logs_encoding: Commit messages encoding +setting_emails_footer: Emails footer +setting_protocol: Protocol +setting_per_page_options: Objects per page options +setting_user_format: Users display format +setting_activity_days_default: Days displayed on project activity +setting_display_subprojects_issues: Display subprojects issues on main projects by default +setting_enabled_scm: Enabled SCM +setting_mail_handler_api_enabled: Enable WS for incoming emails +setting_mail_handler_api_key: API key +setting_sequential_project_identifiers: Generate sequential project identifiers +setting_gravatar_enabled: Use Gravatar user icons +setting_diff_max_lines_displayed: Max number of diff lines displayed +setting_repository_log_display_limit: Maximum number of revisions displayed on file log + +permission_edit_project: Edit project +permission_select_project_modules: Select project modules +permission_manage_members: Manage members +permission_manage_versions: Manage versions +permission_manage_categories: Manage issue categories +permission_add_issues: Add issues +permission_edit_issues: Edit issues +permission_manage_issue_relations: Manage issue relations +permission_add_issue_notes: Add notes +permission_edit_issue_notes: Edit notes +permission_edit_own_issue_notes: Edit own notes +permission_move_issues: Move issues +permission_delete_issues: Delete issues +permission_manage_public_queries: Manage public queries +permission_save_queries: Save queries +permission_view_gantt: View gantt chart +permission_view_calendar: View calender +permission_view_issue_watchers: View watchers list +permission_add_issue_watchers: Add watchers +permission_log_time: Log spent time +permission_view_time_entries: View spent time +permission_edit_time_entries: Edit time logs +permission_edit_own_time_entries: Edit own time logs +permission_manage_news: Manage news +permission_comment_news: Comment news +permission_manage_documents: Manage documents +permission_view_documents: View documents +permission_manage_files: Manage files +permission_view_files: View files +permission_manage_wiki: Manage wiki +permission_rename_wiki_pages: Rename wiki pages +permission_delete_wiki_pages: Delete wiki pages +permission_view_wiki_pages: View wiki +permission_view_wiki_edits: View wiki history +permission_edit_wiki_pages: Edit wiki pages +permission_delete_wiki_pages_attachments: Delete attachments +permission_protect_wiki_pages: Protect wiki pages +permission_manage_repository: Manage repository +permission_browse_repository: Browse repository +permission_view_changesets: View changesets +permission_commit_access: Commit access +permission_manage_boards: Manage boards +permission_view_messages: View messages +permission_add_messages: Post messages +permission_edit_messages: Edit messages +permission_edit_own_messages: Edit own messages +permission_delete_messages: Delete messages +permission_delete_own_messages: Delete own messages + +project_module_issue_tracking: Issue tracking +project_module_time_tracking: Time tracking +project_module_news: News +project_module_documents: Documents +project_module_files: Files +project_module_wiki: Wiki +project_module_repository: Repository +project_module_boards: Boards + +label_user: User +label_user_plural: Users +label_user_new: New user +label_project: Project +label_project_new: New project +label_project_plural: Projects +label_project_all: All Projects +label_project_latest: Latest projects +label_issue: Issue +label_issue_new: New issue +label_issue_plural: Issues +label_issue_view_all: View all issues +label_issues_by: Issues by %s +label_issue_added: Issue added +label_issue_updated: Issue updated +label_document: Document +label_document_new: New document +label_document_plural: Documents +label_document_added: Document added +label_role: Role +label_role_plural: Roles +label_role_new: New role +label_role_and_permissions: Roles and permissions +label_member: Member +label_member_new: New member +label_member_plural: Members +label_tracker: Tracker +label_tracker_plural: Trackers +label_tracker_new: New tracker +label_workflow: Workflow +label_issue_status: Issue status +label_issue_status_plural: Issue statuses +label_issue_status_new: New status +label_issue_category: Issue category +label_issue_category_plural: Issue categories +label_issue_category_new: New category +label_custom_field: Custom field +label_custom_field_plural: Custom fields +label_custom_field_new: New custom field +label_enumerations: Enumerations +label_enumeration_new: New value +label_information: Information +label_information_plural: Information +label_please_login: Please log in +label_register: Register +label_password_lost: Lost password +label_home: Home +label_my_page: My page +label_my_account: My account +label_my_projects: My projects +label_administration: Administration +label_login: Sign in +label_logout: Sign out +label_help: Help +label_reported_issues: Reported issues +label_assigned_to_me_issues: Issues assigned to me +label_last_login: Last connection +label_last_updates: Last updated +label_last_updates_plural: %d last updated +label_registered_on: Registered on +label_activity: Activity +label_overall_activity: Overall activity +label_user_activity: "%s's activity" +label_new: New +label_logged_as: Logged in as +label_environment: Environment +label_authentication: Authentication +label_auth_source: Authentication mode +label_auth_source_new: New authentication mode +label_auth_source_plural: Authentication modes +label_subproject_plural: Subprojects +label_and_its_subprojects: %s and its subprojects +label_min_max_length: Min - Max length +label_list: List +label_date: Date +label_integer: Integer +label_float: Float +label_boolean: Boolean +label_string: Text +label_text: Long text +label_attribute: Attribute +label_attribute_plural: Attributes +label_download: %d Download +label_download_plural: %d Downloads +label_no_data: No data to display +label_change_status: Change status +label_history: History +label_attachment: File +label_attachment_new: New file +label_attachment_delete: Delete file +label_attachment_plural: Files +label_file_added: File added +label_report: Report +label_report_plural: Reports +label_news: News +label_news_new: Add news +label_news_plural: News +label_news_latest: Latest news +label_news_view_all: View all news +label_news_added: News added +label_change_log: Change log +label_settings: Settings +label_overview: Overview +label_version: Version +label_version_new: New version +label_version_plural: Versions +label_confirmation: Confirmation +label_export_to: 'Also available in:' +label_read: Read... +label_public_projects: Public projects +label_open_issues: open +label_open_issues_plural: open +label_closed_issues: closed +label_closed_issues_plural: closed +label_total: Total +label_permissions: Permissions +label_current_status: Current status +label_new_statuses_allowed: New statuses allowed +label_all: all +label_none: none +label_nobody: nobody +label_next: Next +label_previous: Previous +label_used_by: Used by +label_details: Details +label_add_note: Add a note +label_per_page: Per page +label_calendar: Calendar +label_months_from: months from +label_gantt: Gantt +label_internal: Internal +label_last_changes: last %d changes +label_change_view_all: View all changes +label_personalize_page: Personalize this page +label_comment: Comment +label_comment_plural: Comments +label_comment_add: Add a comment +label_comment_added: Comment added +label_comment_delete: Delete comments +label_query: Custom query +label_query_plural: Custom queries +label_query_new: New query +label_filter_add: Add filter +label_filter_plural: Filters +label_equals: is +label_not_equals: is not +label_in_less_than: in less than +label_in_more_than: in more than +label_in: in +label_today: today +label_all_time: all time +label_yesterday: yesterday +label_this_week: this week +label_last_week: last week +label_last_n_days: last %d days +label_this_month: this month +label_last_month: last month +label_this_year: this year +label_date_range: Date range +label_less_than_ago: less than days ago +label_more_than_ago: more than days ago +label_ago: days ago +label_contains: contains +label_not_contains: doesn't contain +label_day_plural: days +label_repository: Repository +label_repository_plural: Repositories +label_browse: Browse +label_modification: %d change +label_modification_plural: %d changes +label_revision: Revision +label_revision_plural: Revisions +label_associated_revisions: Associated revisions +label_added: added +label_modified: modified +label_copied: copied +label_renamed: renamed +label_deleted: deleted +label_latest_revision: Latest revision +label_latest_revision_plural: Latest revisions +label_view_revisions: View revisions +label_max_size: Maximum size +label_on: 'on' +label_sort_highest: Move to top +label_sort_higher: Move up +label_sort_lower: Move down +label_sort_lowest: Move to bottom +label_roadmap: Roadmap +label_roadmap_due_in: Due in %s +label_roadmap_overdue: %s late +label_roadmap_no_issues: No issues for this version +label_search: Search +label_result_plural: Results +label_all_words: All words +label_wiki: Wiki +label_wiki_edit: Wiki edit +label_wiki_edit_plural: Wiki edits +label_wiki_page: Wiki page +label_wiki_page_plural: Wiki pages +label_index_by_title: Index by title +label_index_by_date: Index by date +label_current_version: Current version +label_preview: Preview +label_feed_plural: Feeds +label_changes_details: Details of all changes +label_issue_tracking: Issue tracking +label_spent_time: Spent time +label_f_hour: %.2f hour +label_f_hour_plural: %.2f hours +label_time_tracking: Time tracking +label_change_plural: Changes +label_statistics: Statistics +label_commits_per_month: Commits per month +label_commits_per_author: Commits per author +label_view_diff: View differences +label_diff_inline: inline +label_diff_side_by_side: side by side +label_options: Options +label_copy_workflow_from: Copy workflow from +label_permissions_report: Permissions report +label_watched_issues: Watched issues +label_related_issues: Related issues +label_applied_status: Applied status +label_loading: Loading... +label_relation_new: New relation +label_relation_delete: Delete relation +label_relates_to: related to +label_duplicates: duplicates +label_duplicated_by: duplicated by +label_blocks: blocks +label_blocked_by: blocked by +label_precedes: precedes +label_follows: follows +label_end_to_start: end to start +label_end_to_end: end to end +label_start_to_start: start to start +label_start_to_end: start to end +label_stay_logged_in: Stay logged in +label_disabled: disabled +label_show_completed_versions: Show completed versions +label_me: me +label_board: Forum +label_board_new: New forum +label_board_plural: Forums +label_topic_plural: Topics +label_message_plural: Messages +label_message_last: Last message +label_message_new: New message +label_message_posted: Message added +label_reply_plural: Replies +label_send_information: Send account information to the user +label_year: Year +label_month: Month +label_week: Week +label_date_from: From +label_date_to: To +label_language_based: Based on user's language +label_sort_by: Sort by %s +label_send_test_email: Send a test email +label_feeds_access_key_created_on: RSS access key created %s ago +label_module_plural: Modules +label_added_time_by: Added by %s %s ago +label_updated_time_by: Updated by %s %s ago +label_updated_time: Updated %s ago +label_jump_to_a_project: Jump to a project... +label_file_plural: Files +label_changeset_plural: Changesets +label_default_columns: Default columns +label_no_change_option: (No change) +label_bulk_edit_selected_issues: Bulk edit selected issues +label_theme: Theme +label_default: Default +label_search_titles_only: Search titles only +label_user_mail_option_all: "For any event on all my projects" +label_user_mail_option_selected: "For any event on the selected projects only..." +label_user_mail_option_none: "Only for things I watch or I'm involved in" +label_user_mail_no_self_notified: "I don't want to be notified of changes that I make myself" +label_registration_activation_by_email: account activation by email +label_registration_manual_activation: manual account activation +label_registration_automatic_activation: automatic account activation +label_display_per_page: 'Per page: %s' +label_age: Age +label_change_properties: Change properties +label_general: General +label_more: More +label_scm: SCM +label_plugins: Plugins +label_ldap_authentication: LDAP authentication +label_downloads_abbr: D/L +label_optional_description: Optional description +label_add_another_file: Add another file +label_preferences: Preferences +label_chronological_order: In chronological order +label_reverse_chronological_order: In reverse chronological order +label_planning: Planning +label_incoming_emails: Incoming emails +label_generate_key: Generate a key +label_issue_watchers: Watchers +label_example: Example +label_display: Display +label_children: parent of +label_parents: child of +label_view_option_parents_do_not_show: Never +label_view_option_parents_show_always: Always +label_view_option_parents_show_and_group: Organize by parent +label_view_option_show_parents: Show parents + +button_login: Login +button_submit: Submit +button_save: Save +button_check_all: Check all +button_uncheck_all: Uncheck all +button_delete: Delete +button_create: Create +button_create_and_continue: Create and continue +button_test: Test +button_edit: Edit +button_add: Add +button_change: Change +button_apply: Apply +button_clear: Clear +button_lock: Lock +button_unlock: Unlock +button_download: Download +button_list: List +button_view: View +button_move: Move +button_back: Back +button_cancel: Cancel +button_activate: Activate +button_sort: Sort +button_log_time: Log time +button_rollback: Rollback to this version +button_watch: Watch +button_unwatch: Unwatch +button_reply: Reply +button_archive: Archive +button_unarchive: Unarchive +button_reset: Reset +button_rename: Rename +button_change_password: Change password +button_copy: Copy +button_annotate: Annotate +button_update: Update +button_configure: Configure +button_quote: Quote +button_add_subissue: Add sub-issue + +status_active: active +status_registered: registered +status_locked: locked + +text_select_mail_notifications: Select actions for which email notifications should be sent. +text_regexp_info: eg. ^[A-Z0-9]+$ +text_min_max_length_info: 0 means no restriction +text_project_destroy_confirmation: Are you sure you want to delete this project and related data ? +text_subprojects_destroy_warning: 'Its subproject(s): %s will be also deleted.' +text_workflow_edit: Select a role and a tracker to edit the workflow +text_are_you_sure: Are you sure ? +text_journal_changed: changed from %s to %s +text_journal_set_to: set to %s +text_journal_deleted: deleted +text_tip_task_begin_day: task beginning this day +text_tip_task_end_day: task ending this day +text_tip_task_begin_end_day: task beginning and ending this day +text_project_identifier_info: 'Only lower case letters (a-z), numbers and dashes are allowed.
    Once saved, the identifier can not be changed.' +text_caracters_maximum: %d characters maximum. +text_caracters_minimum: Must be at least %d characters long. +text_length_between: Length between %d and %d characters. +text_tracker_no_workflow: No workflow defined for this tracker +text_unallowed_characters: Unallowed characters +text_comma_separated: Multiple values allowed (comma separated). +text_issues_ref_in_commit_messages: Referencing and fixing issues in commit messages +text_issue_added: Issue %s has been reported by %s. +text_issue_updated: Issue %s has been updated by %s. +text_wiki_destroy_confirmation: Are you sure you want to delete this wiki and all its content ? +text_issue_category_destroy_question: Some issues (%d) are assigned to this category. What do you want to do ? +text_issue_category_destroy_assignments: Remove category assignments +text_issue_category_reassign_to: Reassign issues to this category +text_user_mail_option: "For unselected projects, you will only receive notifications about things you watch or you're involved in (eg. issues you're the author or assignee)." +text_no_configuration_data: "Roles, trackers, issue statuses and workflow have not been configured yet.\nIt is highly recommended to load the default configuration. You will be able to modify it once loaded." +text_load_default_configuration: Load the default configuration +text_status_changed_by_changeset: Applied in changeset %s. +text_issues_destroy_confirmation: 'Are you sure you want to delete the selected issue(s) ?' +text_select_project_modules: 'Select modules to enable for this project:' +text_default_administrator_account_changed: Default administrator account changed +text_file_repository_writable: Attachments directory writable +text_plugin_assets_writable: Plugin assets directory writable +text_rmagick_available: RMagick available (optional) +text_destroy_time_entries_question: %.02f hours were reported on the issues you are about to delete. What do you want to do ? +text_destroy_time_entries: Delete reported hours +text_assign_time_entries_to_project: Assign reported hours to the project +text_reassign_time_entries: 'Reassign reported hours to this issue:' +text_user_wrote: '%s wrote:' +text_enumeration_destroy_question: '%d objects are assigned to this value.' +text_enumeration_category_reassign_to: 'Reassign them to this value:' +text_email_delivery_not_configured: "Email delivery is not configured, and notifications are disabled.\nConfigure your SMTP server in config/email.yml and restart the application to enable them." +text_repository_usernames_mapping: "Select or update the Redmine user mapped to each username found in the repository log.\nUsers with the same Redmine and repository username or email are automatically mapped." +text_diff_truncated: '... This diff was truncated because it exceeds the maximum size that can be displayed.' +text_custom_field_possible_values_info: 'One line for each value' + +default_role_manager: Manager +default_role_developper: Developer +default_role_reporter: Reporter +default_tracker_bug: Bug +default_tracker_feature: Feature +default_tracker_support: Support +default_issue_status_new: New +default_issue_status_assigned: Assigned +default_issue_status_resolved: Resolved +default_issue_status_feedback: Feedback +default_issue_status_closed: Closed +default_issue_status_rejected: Rejected +default_doc_category_user: User documentation +default_doc_category_tech: Technical documentation +default_priority_low: Low +default_priority_normal: Normal +default_priority_high: High +default_priority_urgent: Urgent +default_priority_immediate: Immediate +default_activity_design: Design +default_activity_development: Development + +enumeration_issue_priorities: Issue priorities +enumeration_doc_categories: Document categories +enumeration_activities: Activities (time tracking) diff --git a/lib/redmine.rb b/lib/redmine.rb index 5ac32b2..4134f46 100644 --- a/lib/redmine.rb +++ b/lib/redmine.rb @@ -35,8 +35,8 @@ Redmine::AccessControl.map do |map| :queries => :index, :reports => :issue_report}, :public => true map.permission :add_issues, {:issues => :new} - map.permission :edit_issues, {:issues => [:edit, :reply, :bulk_edit]} - map.permission :manage_issue_relations, {:issue_relations => [:new, :destroy]} + map.permission :edit_issues, {:issues => [:edit, :reply, :bulk_edit, :update_subject]} + map.permission :manage_issue_relations, {:issue_relations => [:new, :destroy], :issues => :add_subissue} map.permission :add_issue_notes, {:issues => [:edit, :reply]} map.permission :edit_issue_notes, {:journals => :edit}, :require => :loggedin map.permission :edit_own_issue_notes, {:journals => :edit}, :require => :loggedin diff --git a/lib/redmine/version.rb b/lib/redmine/version.rb index f51d174..41bdd68 100644 --- a/lib/redmine/version.rb +++ b/lib/redmine/version.rb @@ -23,19 +23,30 @@ module Redmine if entries.match(%r{^\d+}) revision = $1.to_i if entries.match(%r{^\d+\s+dir\s+(\d+)\s}) else - xml = REXML::Document.new(entries) - revision = xml.elements['wc-entries'].elements[1].attributes['revision'].to_i - end - rescue - # Could not find the current revision - end - end - revision + xml = REXML::Document.new(entries) + revision = xml.elements['wc-entries'].elements[1].attributes['revision'].to_i + end + rescue + # Could not find the current revision + end + end + revision + end + + def self.warecorp_revision + begin + tag = %x{ git name-rev --tags `git log -1 --pretty=format:'%H'` }.split[1] + tag = ( tag.match %r{tags/(.*)})[1] + rescue + tag = "undefined" + end + tag end REVISION = self.revision + WARECORP_REVISION = self.warecorp_revision ARRAY = [MAJOR, MINOR, TINY, BRANCH, REVISION].compact - STRING = ARRAY.join('.') + STRING = ARRAY.join('.') + " / #{WARECORP_REVISION}" def self.to_a; ARRAY end def self.to_s; STRING end diff --git a/public/stylesheets/application.css b/public/stylesheets/application.css index a8d8736..a3e7c9d 100644 --- a/public/stylesheets/application.css +++ b/public/stylesheets/application.css @@ -757,3 +757,59 @@ h2 img { vertical-align:middle; } #content { width: 99%; margin: 0; padding: 0; border: 0; background: #fff; overflow: visible !important;} #wiki_add_attachment { display:none; } } + +/***** Subtasks *****/ +/*blocks composed of icon (+, - or none), project identification (if there is one) and link to the task*/ +.issue-subject-level-1, .issue-subject-level-1 .issue-subject { + margin-left: 0em; +} + +.issue-subject-level-2, .issue-subject-level-2 .issue-subject { + margin-left: 1em; +} + +.issue-subject-level-3, .issue-subject-level-3 .issue-subject { + margin-left: 2em; +} + +.issue-subject-level-4, .issue-subject-level-4 .issue-subject { + margin-left: 3em; +} + +.issue-subject-level-5, .issue-subject-level-5 .issue-subject { + margin-left: 4em; +} + +.issue-subject-level-2, .issue-subject-level-2 .issue-subject, .issue-subject-level-3, .issue-subject-level-3 .issue-subject, .issue-subject-level-4, .issue-subject-level-4 .issue-subject, .issue-subject-level-5, .issue-subject-level-5 .issue-subject { + background-image: url(../images/corner-dots.gif); + background-repeat: no-repeat; + background-position: -3px center; +} + +/* Used to show issues which is not pass by filter, but should by + shown as parent for other issue. */ +.issue-unfiltered { + opacity: 0.5; + filter: alpha(opacity=50); +} + +.expanded-issue { + background-image: url(contract.png); + background-repeat: no-repeat; +} + +.contracted-issue { + background-image: url(expand.png); + background-repeat: no-repeat; +} + +.expand-icon, .contract-icon{ +/* position: absolute; */ + vertical-align: middle; +} + +/*text after the icon, which needs to be indented so that all its lines stay completely after the icon*/ +.issue-subject{ + padding-left: 1em; +} + diff --git a/test/fixtures/issues.yml b/test/fixtures/issues.yml index 921ba40..35fa713 100644 --- a/test/fixtures/issues.yml +++ b/test/fixtures/issues.yml @@ -15,6 +15,8 @@ issues_001: status_id: 1 start_date: <%= 1.day.ago.to_date.to_s(:db) %> due_date: <%= 10.day.from_now.to_date.to_s(:db) %> + lft: "13" + rgt: "14" issues_002: created_on: 2006-07-19 21:04:21 +02:00 project_id: 1 @@ -31,6 +33,8 @@ issues_002: status_id: 2 start_date: <%= 2.day.ago.to_date.to_s(:db) %> due_date: + lft: "17" + rgt: "18" issues_003: created_on: 2006-07-19 21:07:27 +02:00 project_id: 1 @@ -47,6 +51,8 @@ issues_003: status_id: 1 start_date: <%= 1.day.from_now.to_date.to_s(:db) %> due_date: <%= 40.day.ago.to_date.to_s(:db) %> + lft: "15" + rgt: "16" issues_004: created_on: <%= 5.days.ago.to_date.to_s(:db) %> project_id: 2 @@ -61,6 +67,8 @@ issues_004: assigned_to_id: author_id: 2 status_id: 1 + lft: "19" + rgt: "20" issues_005: created_on: <%= 5.days.ago.to_date.to_s(:db) %> project_id: 3 @@ -75,6 +83,8 @@ issues_005: assigned_to_id: author_id: 2 status_id: 1 + lft: "21" + rgt: "22" issues_006: created_on: <%= 1.minute.ago.to_date.to_s(:db) %> project_id: 5 @@ -91,6 +101,8 @@ issues_006: status_id: 1 start_date: <%= Date.today.to_s(:db) %> due_date: <%= 1.days.from_now.to_date.to_s(:db) %> + lft: "23" + rgt: "24" issues_007: created_on: <%= 10.days.ago.to_date.to_s(:db) %> project_id: 1 @@ -108,6 +120,8 @@ issues_007: start_date: <%= 10.days.ago.to_s(:db) %> due_date: <%= Date.today.to_s(:db) %> lock_version: 0 + lft: "25" + rgt: "26" issues_008: created_on: <%= 10.days.ago.to_date.to_s(:db) %> project_id: 1 @@ -125,4 +139,127 @@ issues_008: start_date: due_date: lock_version: 0 - \ No newline at end of file + lft: "27" + rgt: "28" +issues_root: + created_on: <%= 10.days.ago.to_date.to_s(:db) %> + project_id: 1 + updated_on: <%= 10.days.ago.to_date.to_s(:db) %> + priority_id: 3 + subject: root + id: 9 + fixed_version_id: + category_id: + description: This is root of subissues. + tracker_id: 1 + assigned_to_id: + author_id: 2 + status_id: 1 + start_date: + due_date: + lock_version: 0 + lft: "1" + rgt: "12" + parent_id: + +issues_child001: + created_on: <%= 10.days.ago.to_date.to_s(:db) %> + project_id: 1 + updated_on: <%= 10.days.ago.to_date.to_s(:db) %> + priority_id: 3 + subject: child001 + id: 10 + fixed_version_id: + category_id: + description: This is child001 of root. + tracker_id: 1 + assigned_to_id: + author_id: 2 + status_id: 1 + start_date: + due_date: + lock_version: 0 + parent_id: 9 + lft: "2" + rgt: "7" +issues_child002: + created_on: <%= 10.days.ago.to_date.to_s(:db) %> + project_id: 1 + updated_on: <%= 10.days.ago.to_date.to_s(:db) %> + priority_id: 3 + subject: child002 + id: 11 + fixed_version_id: + category_id: + description: This is child002 of root. + tracker_id: 1 + assigned_to_id: + author_id: 2 + status_id: 1 + start_date: + due_date: + lock_version: 0 + parent_id: 9 + lft: "8" + rgt: "11" +issues_subchild001: + created_on: <%= 10.days.ago.to_date.to_s(:db) %> + project_id: 1 + updated_on: <%= 10.days.ago.to_date.to_s(:db) %> + priority_id: 3 + subject: subchild001 + id: 12 + fixed_version_id: + category_id: + description: This is subchild001 of child001. + tracker_id: 1 + assigned_to_id: + author_id: 2 + status_id: 1 + start_date: + due_date: + lock_version: 0 + parent_id: 10 + lft: "3" + rgt: "4" +issues_subchild002: + created_on: <%= 10.days.ago.to_date.to_s(:db) %> + project_id: 1 + updated_on: <%= 10.days.ago.to_date.to_s(:db) %> + priority_id: 3 + subject: subchild002 + id: 13 + fixed_version_id: + category_id: + description: This is subchild002 of child001. + tracker_id: 1 + assigned_to_id: + author_id: 2 + status_id: 1 + start_date: + due_date: + lock_version: 0 + parent_id: 10 + lft: "5" + rgt: "6" +issues_subchild003: + created_on: <%= 10.days.ago.to_date.to_s(:db) %> + project_id: 1 + updated_on: <%= 10.days.ago.to_date.to_s(:db) %> + priority_id: 3 + subject: subchild003 + id: 14 + fixed_version_id: + category_id: + description: This is subchild003 of child002. + tracker_id: 1 + assigned_to_id: + author_id: 2 + status_id: 1 + start_date: + due_date: + lock_version: 0 + parent_id: 11 + lft: "9" + rgt: "10" + diff --git a/test/fixtures/queries.yml b/test/fixtures/queries.yml index a274ce3..6405b4c 100644 --- a/test/fixtures/queries.yml +++ b/test/fixtures/queries.yml @@ -19,6 +19,10 @@ queries_001: - "125" :operator: "=" + view_options: | + --- + show_parents: "do_not_show" + user_id: 1 column_names: queries_002: @@ -37,6 +41,10 @@ queries_002: - "1" :operator: o + view_options: | + --- + show_parents: "do_not_show" + user_id: 3 column_names: queries_003: @@ -51,6 +59,10 @@ queries_003: - "3" :operator: "=" + view_options: | + --- + show_parents: "do_not_show" + user_id: 3 column_names: queries_004: @@ -65,6 +77,10 @@ queries_004: - "3" :operator: "=" + view_options: | + --- + show_parents: "do_not_show" + user_id: 2 column_names: queries_005: @@ -86,4 +102,7 @@ queries_005: - desc - - tracker - asc - \ No newline at end of file + view_options: | + --- + show_parents: "do_not_show" + diff --git a/test/fixtures/versions.yml b/test/fixtures/versions.yml index 62c5e6f..1b8060b 100644 --- a/test/fixtures/versions.yml +++ b/test/fixtures/versions.yml @@ -23,4 +23,12 @@ versions_003: id: 3 description: Future version effective_date: +onlinestore_1_0: + created_on: 2006-07-19 21:00:33 +02:00 + name: "1.0" + project_id: 2 + updated_on: 2006-07-19 21:00:33 +02:00 + id: 4 + description: Future version + effective_date: \ No newline at end of file diff --git a/test/functional/issues_controller_test.rb b/test/functional/issues_controller_test.rb index 0df66ab..581c3c9 100644 --- a/test/functional/issues_controller_test.rb +++ b/test/functional/issues_controller_test.rb @@ -22,10 +22,12 @@ require 'issues_controller' class IssuesController; def rescue_action(e) raise e end; end class IssuesControllerTest < Test::Unit::TestCase + fixtures :projects, :users, :roles, :members, + :queries, :issues, :issue_statuses, :versions, @@ -1035,4 +1037,64 @@ class IssuesControllerTest < Test::Unit::TestCase assert_equal 2, TimeEntry.find(1).issue_id assert_equal 2, TimeEntry.find(2).issue_id end + + def test_new_child_issue + child_issue_subject = 'This is the test_new child issue' + parent_issue = Issue.find(1) + @request.session[:user_id] = 2 + post :new, :project_id => 1, + :issue => {:tracker_id => 3, + :subject => child_issue_subject, + :description => 'This is the description', + :priority_id => 5, + :estimated_hours => '', + :parent_id => 1, + :custom_field_values => {'2' => 'Value for field 2'} } + + child = Issue.find_by_subject( child_issue_subject) + assert_redirected_to "issues/#{child.id}" + assert child.parent == parent_issue + end + + def test_add_subissue_should_redirect_to_action_new + @request.session[:user_id] = 2 + get( :add_subissue, :project_id => 1, + :issue => { + :tracker_id => 3, + :subject => "test_add_subissue", + :description => "test_add_subissue", + :priority_id => 5, + :estimated_hours => '' }, + :parent_issue_id => 1) + assert_redirected_to :action => "new" + end + + def test_add_subissue_with_invalid_parent_id_should_render_404 + @request.session[:user_id] = 2 + get( :add_subissue, :project_id => 1, + :issue => { + :tracker_id => 3, + :subject => "test_add_subissue", + :description => "test_add_subissue", + :priority_id => 5, + :estimated_hours => ''}, + :parent_issue_id => 'invalid_id') + assert_template 'common/404', :status => 404 + end + + def test_index_view_option_always_show_parents + @request.session[:user_id] = 2 + get( :index, :project_id => 1, + :view_options => { :show_parents => "show_always"}) + assert_response :success + assert_tag( :tag => 'span', + :attributes => { :class => 'issue-subject-level-3'}, + :content => /subchild001/) + assert_tag( :tag => 'span', + :attributes => { :class => 'issue-subject-level-2'}, + :content => /child001/) + assert_tag( :tag => 'span', + :attributes => { :class => 'issue-subject-level-1'}, + :content => /root/) + end end diff --git a/test/unit/issue_test.rb b/test/unit/issue_test.rb index ae3fa5b..3b96e25 100644 --- a/test/unit/issue_test.rb +++ b/test/unit/issue_test.rb @@ -22,7 +22,7 @@ class IssueTest < Test::Unit::TestCase :trackers, :projects_trackers, :issue_statuses, :issue_categories, :enumerations, - :issues, + :issues, :versions, :custom_fields, :custom_fields_projects, :custom_fields_trackers, :custom_values, :time_entries @@ -233,7 +233,157 @@ class IssueTest < Test::Unit::TestCase assert_nil Issue.find_by_id(1) assert_nil TimeEntry.find_by_issue_id(1) end + + def test_should_update_target_version_of_parent_issue + create_family_of_issues 'Target version of parent updates test' + + version_0_1 = Version.find( versions( :versions_001).id) + version_1_0 = Version.find( versions( :versions_002).id) + + # set target version for child + @issue2.fixed_version = version_0_1 + assert @issue2.save + assert @issue1.reload.fixed_version == @issue2.fixed_version + + # set target version for child of child larger than target version + # of child. so, target version of parents should be updated. + @issue3.fixed_version = version_1_0 + assert @issue3.save + assert @issue1.reload.fixed_version == @issue3.fixed_version + end + + def test_should_not_allowed_close_parent_issue_while_one_of_children_open + create_family_of_issues 'Closing parent issue when some children is open test.' + + closed_status = issue_statuses( :issue_statuses_005) + @issue3.status = closed_status + assert @issue3.save + + assert_raise ActiveRecord::RecordInvalid do + @issue1.reload.status = closed_status + @issue1.save! + end + + end + + def test_should_change_status_of_parent_when_some_children_is_open + create_family_of_issues 'Changing status of parent from closed to open when some of children is open.' + + open_status = issue_statuses( :issue_statuses_001) + closed_status = issue_statuses( :issue_statuses_005) + + @issue3.status = closed_status + @issue2.status = closed_status + @issue1.status = closed_status + assert @issue3.save + assert @issue2.save + assert @issue1.save + assert @issue1.reload.closed? + assert @issue2.reload.closed? + assert @issue3.reload.closed? + + # set status of children to open status. this should update status + # of parent and set it to open state. + @issue2.status = open_status + assert @issue2.save + assert !@issue2.reload.closed? + assert !@issue1.reload.closed? + end + + def test_should_update_targetversion_of_parent_if_children_have_bigger_targetversion + create_family_of_issues 'Update target version of parent if children have bigger target version.' + + # set parent version to 1. + @issue1.fixed_version = versions( :versions_001) + assert @issue1.save + assert @issue1.reload.fixed_version == versions( :versions_001) + + # set children to version higher that parent. + @issue2.fixed_version = versions( :versions_002) + assert @issue2.save + assert @issue1.reload.fixed_version == versions( :versions_002) + end + + def test_should_set_target_version_of_parent_if_children_have_a_target_version + create_family_of_issues 'Update target version of parent if children have a target version.' + + @issue2.fixed_version = versions( :versions_001) + assert @issue2.save + assert @issue2.reload.fixed_version == versions( :versions_001) + assert @issue1.reload.fixed_version == @issue2.fixed_version + end + + def test_should_not_allow_to_set_targetversion_of_parent_lower_than_any_of_the_children + create_family_of_issues 'Not allowing to set target version of parent lower than any of the children.' + + [ @issue1, @issue2, @issue3 ].each do |issue| + issue.update_attribute :fixed_version, versions( :versions_002) + end + + assert_raise ActiveRecord::RecordInvalid do + @issue1.fixed_version = versions( :versions_001) + @issue1.save! + end + end + + def test_should_not_set_target_version_of_parent_if_child_on_another_project + create_family_of_issues 'Should not set target version of parnet if child on another project.' + + @issue2.fixed_version = versions( :versions_001) + assert @issue2.save + assert @issue1.reload.fixed_version == versions( :versions_001) + + online_store = projects( :projects_002) + assert @issue2.move_to( online_store) + @issue2.reload.fixed_version = versions( :onlinestore_1_0) + assert @issue2.save + assert @issue1.reload.fixed_version == versions( :versions_001) + assert @issue2.reload.fixed_version == versions( :onlinestore_1_0) + end + + def test_should_update_due_to_date_if_target_version_is_set_but_due_to_is_not + @issue = Issue.new( :project_id => 1, :tracker_id => 1, + :author_id => 1, :status_id => 1, + :priority => Enumeration.priorities.first, + :subject => 'issue for test hook which set due_to when sets target version.', + :description => 'issue for test hook which set due_to when sets target version.') + + assert @issue.save! + assert @issue.reload.due_date == nil + @issue.fixed_version = versions( :versions_001) + assert @issue.save! + assert @issue.reload.due_date == @issue.reload.fixed_version.due_date + end + private + + def create_family_of_issues( subject) + # Create 3 issues + @issue1 = Issue.new( :project_id => 1, :tracker_id => 1, + :author_id => 1, :status_id => 1, + :priority => Enumeration.priorities.first, + :subject => subject, + :description => subject) + assert @issue1.save + @issue2 = @issue1.clone + assert @issue2.save + @issue3 = @issue1.clone + assert @issue3.save + + # 2 is a child of 1 + @issue2.move_to_child_of @issue1 + + # And 3 is a child of 2 + @issue3.move_to_child_of @issue2 + + @issue1.reload + @issue2.reload + @issue3.reload + + assert @issue2.parent == @issue1 + assert @issue2.children.include?( @issue3) + end + def test_overdue assert Issue.new(:due_date => 1.day.ago.to_date).overdue? assert !Issue.new(:due_date => Date.today).overdue? @@ -249,4 +399,5 @@ class IssueTest < Test::Unit::TestCase assert issue.save assert_equal 1, ActionMailer::Base.deliveries.size end + end