##// END OF EJS Templates
Limits the tracker list in filters and issue counts (#285)....
Jean-Philippe Lang -
r15158:6cd84af522bb
parent child
Show More
@@ -1,234 +1,234
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class ProjectsController < ApplicationController
19 19 menu_item :overview
20 20 menu_item :settings, :only => :settings
21 21
22 22 before_filter :find_project, :except => [ :index, :list, :new, :create, :copy ]
23 23 before_filter :authorize, :except => [ :index, :list, :new, :create, :copy, :archive, :unarchive, :destroy]
24 24 before_filter :authorize_global, :only => [:new, :create]
25 25 before_filter :require_admin, :only => [ :copy, :archive, :unarchive, :destroy ]
26 26 accept_rss_auth :index
27 27 accept_api_auth :index, :show, :create, :update, :destroy
28 28 require_sudo_mode :destroy
29 29
30 30 after_filter :only => [:create, :edit, :update, :archive, :unarchive, :destroy] do |controller|
31 31 if controller.request.post?
32 32 controller.send :expire_action, :controller => 'welcome', :action => 'robots'
33 33 end
34 34 end
35 35
36 36 helper :custom_fields
37 37 helper :issues
38 38 helper :queries
39 39 helper :repositories
40 40 helper :members
41 41
42 42 # Lists visible projects
43 43 def index
44 44 scope = Project.visible.sorted
45 45
46 46 respond_to do |format|
47 47 format.html {
48 48 unless params[:closed]
49 49 scope = scope.active
50 50 end
51 51 @projects = scope.to_a
52 52 }
53 53 format.api {
54 54 @offset, @limit = api_offset_and_limit
55 55 @project_count = scope.count
56 56 @projects = scope.offset(@offset).limit(@limit).to_a
57 57 }
58 58 format.atom {
59 59 projects = scope.reorder(:created_on => :desc).limit(Setting.feeds_limit.to_i).to_a
60 60 render_feed(projects, :title => "#{Setting.app_title}: #{l(:label_project_latest)}")
61 61 }
62 62 end
63 63 end
64 64
65 65 def new
66 66 @issue_custom_fields = IssueCustomField.sorted.to_a
67 67 @trackers = Tracker.sorted.to_a
68 68 @project = Project.new
69 69 @project.safe_attributes = params[:project]
70 70 end
71 71
72 72 def create
73 73 @issue_custom_fields = IssueCustomField.sorted.to_a
74 74 @trackers = Tracker.sorted.to_a
75 75 @project = Project.new
76 76 @project.safe_attributes = params[:project]
77 77
78 78 if @project.save
79 79 unless User.current.admin?
80 80 @project.add_default_member(User.current)
81 81 end
82 82 respond_to do |format|
83 83 format.html {
84 84 flash[:notice] = l(:notice_successful_create)
85 85 if params[:continue]
86 86 attrs = {:parent_id => @project.parent_id}.reject {|k,v| v.nil?}
87 87 redirect_to new_project_path(attrs)
88 88 else
89 89 redirect_to settings_project_path(@project)
90 90 end
91 91 }
92 92 format.api { render :action => 'show', :status => :created, :location => url_for(:controller => 'projects', :action => 'show', :id => @project.id) }
93 93 end
94 94 else
95 95 respond_to do |format|
96 96 format.html { render :action => 'new' }
97 97 format.api { render_validation_errors(@project) }
98 98 end
99 99 end
100 100 end
101 101
102 102 def copy
103 103 @issue_custom_fields = IssueCustomField.sorted.to_a
104 104 @trackers = Tracker.sorted.to_a
105 105 @source_project = Project.find(params[:id])
106 106 if request.get?
107 107 @project = Project.copy_from(@source_project)
108 108 @project.identifier = Project.next_identifier if Setting.sequential_project_identifiers?
109 109 else
110 110 Mailer.with_deliveries(params[:notifications] == '1') do
111 111 @project = Project.new
112 112 @project.safe_attributes = params[:project]
113 113 if @project.copy(@source_project, :only => params[:only])
114 114 flash[:notice] = l(:notice_successful_create)
115 115 redirect_to settings_project_path(@project)
116 116 elsif !@project.new_record?
117 117 # Project was created
118 118 # But some objects were not copied due to validation failures
119 119 # (eg. issues from disabled trackers)
120 120 # TODO: inform about that
121 121 redirect_to settings_project_path(@project)
122 122 end
123 123 end
124 124 end
125 125 rescue ActiveRecord::RecordNotFound
126 126 # source_project not found
127 127 render_404
128 128 end
129 129
130 130 # Show @project
131 131 def show
132 132 # try to redirect to the requested menu item
133 133 if params[:jump] && redirect_to_project_menu_item(@project, params[:jump])
134 134 return
135 135 end
136 136
137 137 @users_by_role = @project.users_by_role
138 138 @subprojects = @project.children.visible.to_a
139 139 @news = @project.news.limit(5).includes(:author, :project).reorder("#{News.table_name}.created_on DESC").to_a
140 @trackers = @project.rolled_up_trackers
140 @trackers = @project.rolled_up_trackers.visible
141 141
142 142 cond = @project.project_condition(Setting.display_subprojects_issues?)
143 143
144 144 @open_issues_by_tracker = Issue.visible.open.where(cond).group(:tracker).count
145 145 @total_issues_by_tracker = Issue.visible.where(cond).group(:tracker).count
146 146
147 147 if User.current.allowed_to_view_all_time_entries?(@project)
148 148 @total_hours = TimeEntry.visible.where(cond).sum(:hours).to_f
149 149 end
150 150
151 151 @key = User.current.rss_key
152 152
153 153 respond_to do |format|
154 154 format.html
155 155 format.api
156 156 end
157 157 end
158 158
159 159 def settings
160 160 @issue_custom_fields = IssueCustomField.sorted.to_a
161 161 @issue_category ||= IssueCategory.new
162 162 @member ||= @project.members.new
163 163 @trackers = Tracker.sorted.to_a
164 164 @wiki ||= @project.wiki || Wiki.new(:project => @project)
165 165 end
166 166
167 167 def edit
168 168 end
169 169
170 170 def update
171 171 @project.safe_attributes = params[:project]
172 172 if @project.save
173 173 respond_to do |format|
174 174 format.html {
175 175 flash[:notice] = l(:notice_successful_update)
176 176 redirect_to settings_project_path(@project)
177 177 }
178 178 format.api { render_api_ok }
179 179 end
180 180 else
181 181 respond_to do |format|
182 182 format.html {
183 183 settings
184 184 render :action => 'settings'
185 185 }
186 186 format.api { render_validation_errors(@project) }
187 187 end
188 188 end
189 189 end
190 190
191 191 def modules
192 192 @project.enabled_module_names = params[:enabled_module_names]
193 193 flash[:notice] = l(:notice_successful_update)
194 194 redirect_to settings_project_path(@project, :tab => 'modules')
195 195 end
196 196
197 197 def archive
198 198 unless @project.archive
199 199 flash[:error] = l(:error_can_not_archive_project)
200 200 end
201 201 redirect_to admin_projects_path(:status => params[:status])
202 202 end
203 203
204 204 def unarchive
205 205 unless @project.active?
206 206 @project.unarchive
207 207 end
208 208 redirect_to admin_projects_path(:status => params[:status])
209 209 end
210 210
211 211 def close
212 212 @project.close
213 213 redirect_to project_path(@project)
214 214 end
215 215
216 216 def reopen
217 217 @project.reopen
218 218 redirect_to project_path(@project)
219 219 end
220 220
221 221 # Delete @project
222 222 def destroy
223 223 @project_to_destroy = @project
224 224 if api_request? || params[:confirm]
225 225 @project_to_destroy.destroy
226 226 respond_to do |format|
227 227 format.html { redirect_to admin_projects_path }
228 228 format.api { render_api_ok }
229 229 end
230 230 end
231 231 # hide project in layout
232 232 @project = nil
233 233 end
234 234 end
@@ -1,95 +1,95
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class ReportsController < ApplicationController
19 19 menu_item :issues
20 20 before_filter :find_project, :authorize, :find_issue_statuses
21 21
22 22 def issue_report
23 @trackers = @project.trackers
23 @trackers = @project.rolled_up_trackers(false).visible
24 24 @versions = @project.shared_versions.sort
25 25 @priorities = IssuePriority.all.reverse
26 26 @categories = @project.issue_categories
27 27 @assignees = (Setting.issue_group_assignment? ? @project.principals : @project.users).sort
28 28 @authors = @project.users.sort
29 29 @subprojects = @project.descendants.visible
30 30
31 31 @issues_by_tracker = Issue.by_tracker(@project)
32 32 @issues_by_version = Issue.by_version(@project)
33 33 @issues_by_priority = Issue.by_priority(@project)
34 34 @issues_by_category = Issue.by_category(@project)
35 35 @issues_by_assigned_to = Issue.by_assigned_to(@project)
36 36 @issues_by_author = Issue.by_author(@project)
37 37 @issues_by_subproject = Issue.by_subproject(@project) || []
38 38
39 39 render :template => "reports/issue_report"
40 40 end
41 41
42 42 def issue_report_details
43 43 case params[:detail]
44 44 when "tracker"
45 45 @field = "tracker_id"
46 @rows = @project.trackers
46 @rows = @project.rolled_up_trackers(false).visible
47 47 @data = Issue.by_tracker(@project)
48 48 @report_title = l(:field_tracker)
49 49 when "version"
50 50 @field = "fixed_version_id"
51 51 @rows = @project.shared_versions.sort
52 52 @data = Issue.by_version(@project)
53 53 @report_title = l(:field_version)
54 54 when "priority"
55 55 @field = "priority_id"
56 56 @rows = IssuePriority.all.reverse
57 57 @data = Issue.by_priority(@project)
58 58 @report_title = l(:field_priority)
59 59 when "category"
60 60 @field = "category_id"
61 61 @rows = @project.issue_categories
62 62 @data = Issue.by_category(@project)
63 63 @report_title = l(:field_category)
64 64 when "assigned_to"
65 65 @field = "assigned_to_id"
66 66 @rows = (Setting.issue_group_assignment? ? @project.principals : @project.users).sort
67 67 @data = Issue.by_assigned_to(@project)
68 68 @report_title = l(:field_assigned_to)
69 69 when "author"
70 70 @field = "author_id"
71 71 @rows = @project.users.sort
72 72 @data = Issue.by_author(@project)
73 73 @report_title = l(:field_author)
74 74 when "subproject"
75 75 @field = "project_id"
76 76 @rows = @project.descendants.visible
77 77 @data = Issue.by_subproject(@project) || []
78 78 @report_title = l(:field_subproject)
79 79 end
80 80
81 81 respond_to do |format|
82 82 if @field
83 83 format.html {}
84 84 else
85 85 format.html { redirect_to :action => 'issue_report', :id => @project }
86 86 end
87 87 end
88 88 end
89 89
90 90 private
91 91
92 92 def find_issue_statuses
93 93 @statuses = IssueStatus.sorted.to_a
94 94 end
95 95 end
@@ -1,1047 +1,1055
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Project < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 include Redmine::NestedSet::ProjectNestedSet
21 21
22 22 # Project statuses
23 23 STATUS_ACTIVE = 1
24 24 STATUS_CLOSED = 5
25 25 STATUS_ARCHIVED = 9
26 26
27 27 # Maximum length for project identifiers
28 28 IDENTIFIER_MAX_LENGTH = 100
29 29
30 30 # Specific overridden Activities
31 31 has_many :time_entry_activities
32 32 has_many :memberships, :class_name => 'Member', :inverse_of => :project
33 33 # Memberships of active users only
34 34 has_many :members,
35 35 lambda { joins(:principal).where(:users => {:type => 'User', :status => Principal::STATUS_ACTIVE}) }
36 36 has_many :enabled_modules, :dependent => :delete_all
37 37 has_and_belongs_to_many :trackers, lambda {order(:position)}
38 38 has_many :issues, :dependent => :destroy
39 39 has_many :issue_changes, :through => :issues, :source => :journals
40 40 has_many :versions, lambda {order("#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC")}, :dependent => :destroy
41 41 belongs_to :default_version, :class_name => 'Version'
42 42 has_many :time_entries, :dependent => :destroy
43 43 has_many :queries, :class_name => 'IssueQuery', :dependent => :delete_all
44 44 has_many :documents, :dependent => :destroy
45 45 has_many :news, lambda {includes(:author)}, :dependent => :destroy
46 46 has_many :issue_categories, lambda {order("#{IssueCategory.table_name}.name")}, :dependent => :delete_all
47 47 has_many :boards, lambda {order("position ASC")}, :dependent => :destroy
48 48 has_one :repository, lambda {where(["is_default = ?", true])}
49 49 has_many :repositories, :dependent => :destroy
50 50 has_many :changesets, :through => :repository
51 51 has_one :wiki, :dependent => :destroy
52 52 # Custom field for the project issues
53 53 has_and_belongs_to_many :issue_custom_fields,
54 54 lambda {order("#{CustomField.table_name}.position")},
55 55 :class_name => 'IssueCustomField',
56 56 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
57 57 :association_foreign_key => 'custom_field_id'
58 58
59 59 acts_as_attachable :view_permission => :view_files,
60 60 :edit_permission => :manage_files,
61 61 :delete_permission => :manage_files
62 62
63 63 acts_as_customizable
64 64 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => "#{Project.table_name}.id", :permission => nil
65 65 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
66 66 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
67 67 :author => nil
68 68
69 69 attr_protected :status
70 70
71 71 validates_presence_of :name, :identifier
72 72 validates_uniqueness_of :identifier, :if => Proc.new {|p| p.identifier_changed?}
73 73 validates_length_of :name, :maximum => 255
74 74 validates_length_of :homepage, :maximum => 255
75 75 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
76 76 # downcase letters, digits, dashes but not digits only
77 77 validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :if => Proc.new { |p| p.identifier_changed? }
78 78 # reserved words
79 79 validates_exclusion_of :identifier, :in => %w( new )
80 80 validate :validate_parent
81 81
82 82 after_save :update_inherited_members, :if => Proc.new {|project| project.inherit_members_changed?}
83 83 after_save :remove_inherited_member_roles, :add_inherited_member_roles, :if => Proc.new {|project| project.parent_id_changed?}
84 84 after_update :update_versions_from_hierarchy_change, :if => Proc.new {|project| project.parent_id_changed?}
85 85 before_destroy :delete_all_members
86 86
87 87 scope :has_module, lambda {|mod|
88 88 where("#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s)
89 89 }
90 90 scope :active, lambda { where(:status => STATUS_ACTIVE) }
91 91 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
92 92 scope :all_public, lambda { where(:is_public => true) }
93 93 scope :visible, lambda {|*args| where(Project.visible_condition(args.shift || User.current, *args)) }
94 94 scope :allowed_to, lambda {|*args|
95 95 user = User.current
96 96 permission = nil
97 97 if args.first.is_a?(Symbol)
98 98 permission = args.shift
99 99 else
100 100 user = args.shift
101 101 permission = args.shift
102 102 end
103 103 where(Project.allowed_to_condition(user, permission, *args))
104 104 }
105 105 scope :like, lambda {|arg|
106 106 if arg.blank?
107 107 where(nil)
108 108 else
109 109 pattern = "%#{arg.to_s.strip.downcase}%"
110 110 where("LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", :p => pattern)
111 111 end
112 112 }
113 113 scope :sorted, lambda {order(:lft)}
114 114 scope :having_trackers, lambda {
115 115 where("#{Project.table_name}.id IN (SELECT DISTINCT project_id FROM #{table_name_prefix}projects_trackers#{table_name_suffix})")
116 116 }
117 117
118 118 def initialize(attributes=nil, *args)
119 119 super
120 120
121 121 initialized = (attributes || {}).stringify_keys
122 122 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
123 123 self.identifier = Project.next_identifier
124 124 end
125 125 if !initialized.key?('is_public')
126 126 self.is_public = Setting.default_projects_public?
127 127 end
128 128 if !initialized.key?('enabled_module_names')
129 129 self.enabled_module_names = Setting.default_projects_modules
130 130 end
131 131 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
132 132 default = Setting.default_projects_tracker_ids
133 133 if default.is_a?(Array)
134 134 self.trackers = Tracker.where(:id => default.map(&:to_i)).sorted.to_a
135 135 else
136 136 self.trackers = Tracker.sorted.to_a
137 137 end
138 138 end
139 139 end
140 140
141 141 def identifier=(identifier)
142 142 super unless identifier_frozen?
143 143 end
144 144
145 145 def identifier_frozen?
146 146 errors[:identifier].blank? && !(new_record? || identifier.blank?)
147 147 end
148 148
149 149 # returns latest created projects
150 150 # non public projects will be returned only if user is a member of those
151 151 def self.latest(user=nil, count=5)
152 152 visible(user).limit(count).
153 153 order(:created_on => :desc).
154 154 where("#{table_name}.created_on >= ?", 30.days.ago).
155 155 to_a
156 156 end
157 157
158 158 # Returns true if the project is visible to +user+ or to the current user.
159 159 def visible?(user=User.current)
160 160 user.allowed_to?(:view_project, self)
161 161 end
162 162
163 163 # Returns a SQL conditions string used to find all projects visible by the specified user.
164 164 #
165 165 # Examples:
166 166 # Project.visible_condition(admin) => "projects.status = 1"
167 167 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
168 168 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
169 169 def self.visible_condition(user, options={})
170 170 allowed_to_condition(user, :view_project, options)
171 171 end
172 172
173 173 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
174 174 #
175 175 # Valid options:
176 176 # * :project => limit the condition to project
177 177 # * :with_subprojects => limit the condition to project and its subprojects
178 178 # * :member => limit the condition to the user projects
179 179 def self.allowed_to_condition(user, permission, options={})
180 180 perm = Redmine::AccessControl.permission(permission)
181 181 base_statement = (perm && perm.read? ? "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED}" : "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}")
182 182 if perm && perm.project_module
183 183 # If the permission belongs to a project module, make sure the module is enabled
184 184 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
185 185 end
186 186 if project = options[:project]
187 187 project_statement = project.project_condition(options[:with_subprojects])
188 188 base_statement = "(#{project_statement}) AND (#{base_statement})"
189 189 end
190 190
191 191 if user.admin?
192 192 base_statement
193 193 else
194 194 statement_by_role = {}
195 195 unless options[:member]
196 196 role = user.builtin_role
197 197 if role.allowed_to?(permission)
198 198 s = "#{Project.table_name}.is_public = #{connection.quoted_true}"
199 199 if user.id
200 200 s = "(#{s} AND #{Project.table_name}.id NOT IN (SELECT project_id FROM #{Member.table_name} WHERE user_id = #{user.id}))"
201 201 end
202 202 statement_by_role[role] = s
203 203 end
204 204 end
205 205 user.projects_by_role.each do |role, projects|
206 206 if role.allowed_to?(permission) && projects.any?
207 207 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
208 208 end
209 209 end
210 210 if statement_by_role.empty?
211 211 "1=0"
212 212 else
213 213 if block_given?
214 214 statement_by_role.each do |role, statement|
215 215 if s = yield(role, user)
216 216 statement_by_role[role] = "(#{statement} AND (#{s}))"
217 217 end
218 218 end
219 219 end
220 220 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
221 221 end
222 222 end
223 223 end
224 224
225 225 def override_roles(role)
226 226 @override_members ||= memberships.
227 227 joins(:principal).
228 228 where(:users => {:type => ['GroupAnonymous', 'GroupNonMember']}).to_a
229 229
230 230 group_class = role.anonymous? ? GroupAnonymous : GroupNonMember
231 231 member = @override_members.detect {|m| m.principal.is_a? group_class}
232 232 member ? member.roles.to_a : [role]
233 233 end
234 234
235 235 def principals
236 236 @principals ||= Principal.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).uniq
237 237 end
238 238
239 239 def users
240 240 @users ||= User.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).uniq
241 241 end
242 242
243 243 # Returns the Systemwide and project specific activities
244 244 def activities(include_inactive=false)
245 245 t = TimeEntryActivity.table_name
246 246 scope = TimeEntryActivity.where("#{t}.project_id IS NULL OR #{t}.project_id = ?", id)
247 247
248 248 overridden_activity_ids = self.time_entry_activities.pluck(:parent_id).compact
249 249 if overridden_activity_ids.any?
250 250 scope = scope.where("#{t}.id NOT IN (?)", overridden_activity_ids)
251 251 end
252 252 unless include_inactive
253 253 scope = scope.active
254 254 end
255 255 scope
256 256 end
257 257
258 258 # Will create a new Project specific Activity or update an existing one
259 259 #
260 260 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
261 261 # does not successfully save.
262 262 def update_or_create_time_entry_activity(id, activity_hash)
263 263 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
264 264 self.create_time_entry_activity_if_needed(activity_hash)
265 265 else
266 266 activity = project.time_entry_activities.find_by_id(id.to_i)
267 267 activity.update_attributes(activity_hash) if activity
268 268 end
269 269 end
270 270
271 271 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
272 272 #
273 273 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
274 274 # does not successfully save.
275 275 def create_time_entry_activity_if_needed(activity)
276 276 if activity['parent_id']
277 277 parent_activity = TimeEntryActivity.find(activity['parent_id'])
278 278 activity['name'] = parent_activity.name
279 279 activity['position'] = parent_activity.position
280 280 if Enumeration.overriding_change?(activity, parent_activity)
281 281 project_activity = self.time_entry_activities.create(activity)
282 282 if project_activity.new_record?
283 283 raise ActiveRecord::Rollback, "Overriding TimeEntryActivity was not successfully saved"
284 284 else
285 285 self.time_entries.
286 286 where(:activity_id => parent_activity.id).
287 287 update_all(:activity_id => project_activity.id)
288 288 end
289 289 end
290 290 end
291 291 end
292 292
293 293 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
294 294 #
295 295 # Examples:
296 296 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
297 297 # project.project_condition(false) => "projects.id = 1"
298 298 def project_condition(with_subprojects)
299 299 cond = "#{Project.table_name}.id = #{id}"
300 300 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
301 301 cond
302 302 end
303 303
304 304 def self.find(*args)
305 305 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
306 306 project = find_by_identifier(*args)
307 307 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
308 308 project
309 309 else
310 310 super
311 311 end
312 312 end
313 313
314 314 def self.find_by_param(*args)
315 315 self.find(*args)
316 316 end
317 317
318 318 alias :base_reload :reload
319 319 def reload(*args)
320 320 @principals = nil
321 321 @users = nil
322 322 @shared_versions = nil
323 323 @rolled_up_versions = nil
324 324 @rolled_up_trackers = nil
325 325 @all_issue_custom_fields = nil
326 326 @all_time_entry_custom_fields = nil
327 327 @to_param = nil
328 328 @allowed_parents = nil
329 329 @allowed_permissions = nil
330 330 @actions_allowed = nil
331 331 @start_date = nil
332 332 @due_date = nil
333 333 @override_members = nil
334 334 @assignable_users = nil
335 335 base_reload(*args)
336 336 end
337 337
338 338 def to_param
339 339 if new_record?
340 340 nil
341 341 else
342 342 # id is used for projects with a numeric identifier (compatibility)
343 343 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
344 344 end
345 345 end
346 346
347 347 def active?
348 348 self.status == STATUS_ACTIVE
349 349 end
350 350
351 351 def archived?
352 352 self.status == STATUS_ARCHIVED
353 353 end
354 354
355 355 # Archives the project and its descendants
356 356 def archive
357 357 # Check that there is no issue of a non descendant project that is assigned
358 358 # to one of the project or descendant versions
359 359 version_ids = self_and_descendants.joins(:versions).pluck("#{Version.table_name}.id")
360 360
361 361 if version_ids.any? &&
362 362 Issue.
363 363 includes(:project).
364 364 where("#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?", lft, rgt).
365 365 where(:fixed_version_id => version_ids).
366 366 exists?
367 367 return false
368 368 end
369 369 Project.transaction do
370 370 archive!
371 371 end
372 372 true
373 373 end
374 374
375 375 # Unarchives the project
376 376 # All its ancestors must be active
377 377 def unarchive
378 378 return false if ancestors.detect {|a| !a.active?}
379 379 update_attribute :status, STATUS_ACTIVE
380 380 end
381 381
382 382 def close
383 383 self_and_descendants.status(STATUS_ACTIVE).update_all :status => STATUS_CLOSED
384 384 end
385 385
386 386 def reopen
387 387 self_and_descendants.status(STATUS_CLOSED).update_all :status => STATUS_ACTIVE
388 388 end
389 389
390 390 # Returns an array of projects the project can be moved to
391 391 # by the current user
392 392 def allowed_parents(user=User.current)
393 393 return @allowed_parents if @allowed_parents
394 394 @allowed_parents = Project.allowed_to(user, :add_subprojects).to_a
395 395 @allowed_parents = @allowed_parents - self_and_descendants
396 396 if user.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
397 397 @allowed_parents << nil
398 398 end
399 399 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
400 400 @allowed_parents << parent
401 401 end
402 402 @allowed_parents
403 403 end
404 404
405 405 # Sets the parent of the project with authorization check
406 406 def set_allowed_parent!(p)
407 407 ActiveSupport::Deprecation.warn "Project#set_allowed_parent! is deprecated and will be removed in Redmine 4, use #safe_attributes= instead."
408 408 p = p.id if p.is_a?(Project)
409 409 send :safe_attributes, {:project_id => p}
410 410 save
411 411 end
412 412
413 413 # Sets the parent of the project and saves the project
414 414 # Argument can be either a Project, a String, a Fixnum or nil
415 415 def set_parent!(p)
416 416 if p.is_a?(Project)
417 417 self.parent = p
418 418 else
419 419 self.parent_id = p
420 420 end
421 421 save
422 422 end
423 423
424 # Returns an array of the trackers used by the project and its active sub projects
425 def rolled_up_trackers
426 @rolled_up_trackers ||=
427 Tracker.
428 joins(projects: :enabled_modules).
429 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> ?", lft, rgt, STATUS_ARCHIVED).
430 where("#{EnabledModule.table_name}.name = ?", 'issue_tracking').
431 uniq.
432 sorted.
433 to_a
424 # Returns a scope of the trackers used by the project and its active sub projects
425 def rolled_up_trackers(include_subprojects=true)
426 if include_subprojects
427 @rolled_up_trackers ||= rolled_up_trackers_base_scope.
428 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ?", lft, rgt)
429 else
430 rolled_up_trackers_base_scope.
431 where(:projects => {:id => id})
432 end
433 end
434
435 def rolled_up_trackers_base_scope
436 Tracker.
437 joins(projects: :enabled_modules).
438 where("#{Project.table_name}.status <> ?", STATUS_ARCHIVED).
439 where(:enabled_modules => {:name => 'issue_tracking'}).
440 uniq.
441 sorted
434 442 end
435 443
436 444 # Closes open and locked project versions that are completed
437 445 def close_completed_versions
438 446 Version.transaction do
439 447 versions.where(:status => %w(open locked)).each do |version|
440 448 if version.completed?
441 449 version.update_attribute(:status, 'closed')
442 450 end
443 451 end
444 452 end
445 453 end
446 454
447 455 # Returns a scope of the Versions on subprojects
448 456 def rolled_up_versions
449 457 @rolled_up_versions ||=
450 458 Version.
451 459 joins(:project).
452 460 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> ?", lft, rgt, STATUS_ARCHIVED)
453 461 end
454 462
455 463 # Returns a scope of the Versions used by the project
456 464 def shared_versions
457 465 if new_record?
458 466 Version.
459 467 joins(:project).
460 468 preload(:project).
461 469 where("#{Project.table_name}.status <> ? AND #{Version.table_name}.sharing = 'system'", STATUS_ARCHIVED)
462 470 else
463 471 @shared_versions ||= begin
464 472 r = root? ? self : root
465 473 Version.
466 474 joins(:project).
467 475 preload(:project).
468 476 where("#{Project.table_name}.id = #{id}" +
469 477 " OR (#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND (" +
470 478 " #{Version.table_name}.sharing = 'system'" +
471 479 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
472 480 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
473 481 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
474 482 "))")
475 483 end
476 484 end
477 485 end
478 486
479 487 # Returns a hash of project users grouped by role
480 488 def users_by_role
481 489 members.includes(:user, :roles).inject({}) do |h, m|
482 490 m.roles.each do |r|
483 491 h[r] ||= []
484 492 h[r] << m.user
485 493 end
486 494 h
487 495 end
488 496 end
489 497
490 498 # Adds user as a project member with the default role
491 499 # Used for when a non-admin user creates a project
492 500 def add_default_member(user)
493 501 role = Role.givable.find_by_id(Setting.new_project_user_role_id.to_i) || Role.givable.first
494 502 member = Member.new(:project => self, :principal => user, :roles => [role])
495 503 self.members << member
496 504 member
497 505 end
498 506
499 507 # Deletes all project's members
500 508 def delete_all_members
501 509 me, mr = Member.table_name, MemberRole.table_name
502 510 self.class.connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
503 511 Member.delete_all(['project_id = ?', id])
504 512 end
505 513
506 514 # Return a Principal scope of users/groups issues can be assigned to
507 515 def assignable_users
508 516 types = ['User']
509 517 types << 'Group' if Setting.issue_group_assignment?
510 518
511 519 @assignable_users ||= Principal.
512 520 active.
513 521 joins(:members => :roles).
514 522 where(:type => types, :members => {:project_id => id}, :roles => {:assignable => true}).
515 523 uniq.
516 524 sorted
517 525 end
518 526
519 527 # Returns the mail addresses of users that should be always notified on project events
520 528 def recipients
521 529 notified_users.collect {|user| user.mail}
522 530 end
523 531
524 532 # Returns the users that should be notified on project events
525 533 def notified_users
526 534 # TODO: User part should be extracted to User#notify_about?
527 535 members.preload(:principal).select {|m| m.principal.present? && (m.mail_notification? || m.principal.mail_notification == 'all')}.collect {|m| m.principal}
528 536 end
529 537
530 538 # Returns a scope of all custom fields enabled for project issues
531 539 # (explicitly associated custom fields and custom fields enabled for all projects)
532 540 def all_issue_custom_fields
533 541 if new_record?
534 542 @all_issue_custom_fields ||= IssueCustomField.
535 543 sorted.
536 544 where("is_for_all = ? OR id IN (?)", true, issue_custom_field_ids)
537 545 else
538 546 @all_issue_custom_fields ||= IssueCustomField.
539 547 sorted.
540 548 where("is_for_all = ? OR id IN (SELECT DISTINCT cfp.custom_field_id" +
541 549 " FROM #{table_name_prefix}custom_fields_projects#{table_name_suffix} cfp" +
542 550 " WHERE cfp.project_id = ?)", true, id)
543 551 end
544 552 end
545 553
546 554 def project
547 555 self
548 556 end
549 557
550 558 def <=>(project)
551 559 name.casecmp(project.name)
552 560 end
553 561
554 562 def to_s
555 563 name
556 564 end
557 565
558 566 # Returns a short description of the projects (first lines)
559 567 def short_description(length = 255)
560 568 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
561 569 end
562 570
563 571 def css_classes
564 572 s = 'project'
565 573 s << ' root' if root?
566 574 s << ' child' if child?
567 575 s << (leaf? ? ' leaf' : ' parent')
568 576 unless active?
569 577 if archived?
570 578 s << ' archived'
571 579 else
572 580 s << ' closed'
573 581 end
574 582 end
575 583 s
576 584 end
577 585
578 586 # The earliest start date of a project, based on it's issues and versions
579 587 def start_date
580 588 @start_date ||= [
581 589 issues.minimum('start_date'),
582 590 shared_versions.minimum('effective_date'),
583 591 Issue.fixed_version(shared_versions).minimum('start_date')
584 592 ].compact.min
585 593 end
586 594
587 595 # The latest due date of an issue or version
588 596 def due_date
589 597 @due_date ||= [
590 598 issues.maximum('due_date'),
591 599 shared_versions.maximum('effective_date'),
592 600 Issue.fixed_version(shared_versions).maximum('due_date')
593 601 ].compact.max
594 602 end
595 603
596 604 def overdue?
597 605 active? && !due_date.nil? && (due_date < User.current.today)
598 606 end
599 607
600 608 # Returns the percent completed for this project, based on the
601 609 # progress on it's versions.
602 610 def completed_percent(options={:include_subprojects => false})
603 611 if options.delete(:include_subprojects)
604 612 total = self_and_descendants.collect(&:completed_percent).sum
605 613
606 614 total / self_and_descendants.count
607 615 else
608 616 if versions.count > 0
609 617 total = versions.collect(&:completed_percent).sum
610 618
611 619 total / versions.count
612 620 else
613 621 100
614 622 end
615 623 end
616 624 end
617 625
618 626 # Return true if this project allows to do the specified action.
619 627 # action can be:
620 628 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
621 629 # * a permission Symbol (eg. :edit_project)
622 630 def allows_to?(action)
623 631 if archived?
624 632 # No action allowed on archived projects
625 633 return false
626 634 end
627 635 unless active? || Redmine::AccessControl.read_action?(action)
628 636 # No write action allowed on closed projects
629 637 return false
630 638 end
631 639 # No action allowed on disabled modules
632 640 if action.is_a? Hash
633 641 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
634 642 else
635 643 allowed_permissions.include? action
636 644 end
637 645 end
638 646
639 647 # Return the enabled module with the given name
640 648 # or nil if the module is not enabled for the project
641 649 def enabled_module(name)
642 650 name = name.to_s
643 651 enabled_modules.detect {|m| m.name == name}
644 652 end
645 653
646 654 # Return true if the module with the given name is enabled
647 655 def module_enabled?(name)
648 656 enabled_module(name).present?
649 657 end
650 658
651 659 def enabled_module_names=(module_names)
652 660 if module_names && module_names.is_a?(Array)
653 661 module_names = module_names.collect(&:to_s).reject(&:blank?)
654 662 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
655 663 else
656 664 enabled_modules.clear
657 665 end
658 666 end
659 667
660 668 # Returns an array of the enabled modules names
661 669 def enabled_module_names
662 670 enabled_modules.collect(&:name)
663 671 end
664 672
665 673 # Enable a specific module
666 674 #
667 675 # Examples:
668 676 # project.enable_module!(:issue_tracking)
669 677 # project.enable_module!("issue_tracking")
670 678 def enable_module!(name)
671 679 enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
672 680 end
673 681
674 682 # Disable a module if it exists
675 683 #
676 684 # Examples:
677 685 # project.disable_module!(:issue_tracking)
678 686 # project.disable_module!("issue_tracking")
679 687 # project.disable_module!(project.enabled_modules.first)
680 688 def disable_module!(target)
681 689 target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
682 690 target.destroy unless target.blank?
683 691 end
684 692
685 693 safe_attributes 'name',
686 694 'description',
687 695 'homepage',
688 696 'is_public',
689 697 'identifier',
690 698 'custom_field_values',
691 699 'custom_fields',
692 700 'tracker_ids',
693 701 'issue_custom_field_ids',
694 702 'parent_id',
695 703 'default_version_id'
696 704
697 705 safe_attributes 'enabled_module_names',
698 706 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
699 707
700 708 safe_attributes 'inherit_members',
701 709 :if => lambda {|project, user| project.parent.nil? || project.parent.visible?(user)}
702 710
703 711 def safe_attributes=(attrs, user=User.current)
704 712 return unless attrs.is_a?(Hash)
705 713 attrs = attrs.deep_dup
706 714
707 715 @unallowed_parent_id = nil
708 716 if new_record? || attrs.key?('parent_id')
709 717 parent_id_param = attrs['parent_id'].to_s
710 718 if new_record? || parent_id_param != parent_id.to_s
711 719 p = parent_id_param.present? ? Project.find_by_id(parent_id_param) : nil
712 720 unless allowed_parents(user).include?(p)
713 721 attrs.delete('parent_id')
714 722 @unallowed_parent_id = true
715 723 end
716 724 end
717 725 end
718 726
719 727 super(attrs, user)
720 728 end
721 729
722 730 # Returns an auto-generated project identifier based on the last identifier used
723 731 def self.next_identifier
724 732 p = Project.order('id DESC').first
725 733 p.nil? ? nil : p.identifier.to_s.succ
726 734 end
727 735
728 736 # Copies and saves the Project instance based on the +project+.
729 737 # Duplicates the source project's:
730 738 # * Wiki
731 739 # * Versions
732 740 # * Categories
733 741 # * Issues
734 742 # * Members
735 743 # * Queries
736 744 #
737 745 # Accepts an +options+ argument to specify what to copy
738 746 #
739 747 # Examples:
740 748 # project.copy(1) # => copies everything
741 749 # project.copy(1, :only => 'members') # => copies members only
742 750 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
743 751 def copy(project, options={})
744 752 project = project.is_a?(Project) ? project : Project.find(project)
745 753
746 754 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
747 755 to_be_copied = to_be_copied & Array.wrap(options[:only]) unless options[:only].nil?
748 756
749 757 Project.transaction do
750 758 if save
751 759 reload
752 760 to_be_copied.each do |name|
753 761 send "copy_#{name}", project
754 762 end
755 763 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
756 764 save
757 765 else
758 766 false
759 767 end
760 768 end
761 769 end
762 770
763 771 def member_principals
764 772 ActiveSupport::Deprecation.warn "Project#member_principals is deprecated and will be removed in Redmine 4.0. Use #memberships.active instead."
765 773 memberships.active
766 774 end
767 775
768 776 # Returns a new unsaved Project instance with attributes copied from +project+
769 777 def self.copy_from(project)
770 778 project = project.is_a?(Project) ? project : Project.find(project)
771 779 # clear unique attributes
772 780 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
773 781 copy = Project.new(attributes)
774 782 copy.enabled_module_names = project.enabled_module_names
775 783 copy.trackers = project.trackers
776 784 copy.custom_values = project.custom_values.collect {|v| v.clone}
777 785 copy.issue_custom_fields = project.issue_custom_fields
778 786 copy
779 787 end
780 788
781 789 # Yields the given block for each project with its level in the tree
782 790 def self.project_tree(projects, &block)
783 791 ancestors = []
784 792 projects.sort_by(&:lft).each do |project|
785 793 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
786 794 ancestors.pop
787 795 end
788 796 yield project, ancestors.size
789 797 ancestors << project
790 798 end
791 799 end
792 800
793 801 private
794 802
795 803 def update_inherited_members
796 804 if parent
797 805 if inherit_members? && !inherit_members_was
798 806 remove_inherited_member_roles
799 807 add_inherited_member_roles
800 808 elsif !inherit_members? && inherit_members_was
801 809 remove_inherited_member_roles
802 810 end
803 811 end
804 812 end
805 813
806 814 def remove_inherited_member_roles
807 815 member_roles = memberships.map(&:member_roles).flatten
808 816 member_role_ids = member_roles.map(&:id)
809 817 member_roles.each do |member_role|
810 818 if member_role.inherited_from && !member_role_ids.include?(member_role.inherited_from)
811 819 member_role.destroy
812 820 end
813 821 end
814 822 end
815 823
816 824 def add_inherited_member_roles
817 825 if inherit_members? && parent
818 826 parent.memberships.each do |parent_member|
819 827 member = Member.find_or_new(self.id, parent_member.user_id)
820 828 parent_member.member_roles.each do |parent_member_role|
821 829 member.member_roles << MemberRole.new(:role => parent_member_role.role, :inherited_from => parent_member_role.id)
822 830 end
823 831 member.save!
824 832 end
825 833 memberships.reset
826 834 end
827 835 end
828 836
829 837 def update_versions_from_hierarchy_change
830 838 Issue.update_versions_from_hierarchy_change(self)
831 839 end
832 840
833 841 def validate_parent
834 842 if @unallowed_parent_id
835 843 errors.add(:parent_id, :invalid)
836 844 elsif parent_id_changed?
837 845 unless parent.nil? || (parent.active? && move_possible?(parent))
838 846 errors.add(:parent_id, :invalid)
839 847 end
840 848 end
841 849 end
842 850
843 851 # Copies wiki from +project+
844 852 def copy_wiki(project)
845 853 # Check that the source project has a wiki first
846 854 unless project.wiki.nil?
847 855 wiki = self.wiki || Wiki.new
848 856 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
849 857 wiki_pages_map = {}
850 858 project.wiki.pages.each do |page|
851 859 # Skip pages without content
852 860 next if page.content.nil?
853 861 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
854 862 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
855 863 new_wiki_page.content = new_wiki_content
856 864 wiki.pages << new_wiki_page
857 865 wiki_pages_map[page.id] = new_wiki_page
858 866 end
859 867
860 868 self.wiki = wiki
861 869 wiki.save
862 870 # Reproduce page hierarchy
863 871 project.wiki.pages.each do |page|
864 872 if page.parent_id && wiki_pages_map[page.id]
865 873 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
866 874 wiki_pages_map[page.id].save
867 875 end
868 876 end
869 877 end
870 878 end
871 879
872 880 # Copies versions from +project+
873 881 def copy_versions(project)
874 882 project.versions.each do |version|
875 883 new_version = Version.new
876 884 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
877 885 self.versions << new_version
878 886 end
879 887 end
880 888
881 889 # Copies issue categories from +project+
882 890 def copy_issue_categories(project)
883 891 project.issue_categories.each do |issue_category|
884 892 new_issue_category = IssueCategory.new
885 893 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
886 894 self.issue_categories << new_issue_category
887 895 end
888 896 end
889 897
890 898 # Copies issues from +project+
891 899 def copy_issues(project)
892 900 # Stores the source issue id as a key and the copied issues as the
893 901 # value. Used to map the two together for issue relations.
894 902 issues_map = {}
895 903
896 904 # Store status and reopen locked/closed versions
897 905 version_statuses = versions.reject(&:open?).map {|version| [version, version.status]}
898 906 version_statuses.each do |version, status|
899 907 version.update_attribute :status, 'open'
900 908 end
901 909
902 910 # Get issues sorted by root_id, lft so that parent issues
903 911 # get copied before their children
904 912 project.issues.reorder('root_id, lft').each do |issue|
905 913 new_issue = Issue.new
906 914 new_issue.copy_from(issue, :subtasks => false, :link => false)
907 915 new_issue.project = self
908 916 # Changing project resets the custom field values
909 917 # TODO: handle this in Issue#project=
910 918 new_issue.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
911 919 # Reassign fixed_versions by name, since names are unique per project
912 920 if issue.fixed_version && issue.fixed_version.project == project
913 921 new_issue.fixed_version = self.versions.detect {|v| v.name == issue.fixed_version.name}
914 922 end
915 923 # Reassign version custom field values
916 924 new_issue.custom_field_values.each do |custom_value|
917 925 if custom_value.custom_field.field_format == 'version' && custom_value.value.present?
918 926 versions = Version.where(:id => custom_value.value).to_a
919 927 new_value = versions.map do |version|
920 928 if version.project == project
921 929 self.versions.detect {|v| v.name == version.name}.try(:id)
922 930 else
923 931 version.id
924 932 end
925 933 end
926 934 new_value.compact!
927 935 new_value = new_value.first unless custom_value.custom_field.multiple?
928 936 custom_value.value = new_value
929 937 end
930 938 end
931 939 # Reassign the category by name, since names are unique per project
932 940 if issue.category
933 941 new_issue.category = self.issue_categories.detect {|c| c.name == issue.category.name}
934 942 end
935 943 # Parent issue
936 944 if issue.parent_id
937 945 if copied_parent = issues_map[issue.parent_id]
938 946 new_issue.parent_issue_id = copied_parent.id
939 947 end
940 948 end
941 949
942 950 self.issues << new_issue
943 951 if new_issue.new_record?
944 952 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info?
945 953 else
946 954 issues_map[issue.id] = new_issue unless new_issue.new_record?
947 955 end
948 956 end
949 957
950 958 # Restore locked/closed version statuses
951 959 version_statuses.each do |version, status|
952 960 version.update_attribute :status, status
953 961 end
954 962
955 963 # Relations after in case issues related each other
956 964 project.issues.each do |issue|
957 965 new_issue = issues_map[issue.id]
958 966 unless new_issue
959 967 # Issue was not copied
960 968 next
961 969 end
962 970
963 971 # Relations
964 972 issue.relations_from.each do |source_relation|
965 973 new_issue_relation = IssueRelation.new
966 974 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
967 975 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
968 976 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
969 977 new_issue_relation.issue_to = source_relation.issue_to
970 978 end
971 979 new_issue.relations_from << new_issue_relation
972 980 end
973 981
974 982 issue.relations_to.each do |source_relation|
975 983 new_issue_relation = IssueRelation.new
976 984 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
977 985 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
978 986 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
979 987 new_issue_relation.issue_from = source_relation.issue_from
980 988 end
981 989 new_issue.relations_to << new_issue_relation
982 990 end
983 991 end
984 992 end
985 993
986 994 # Copies members from +project+
987 995 def copy_members(project)
988 996 # Copy users first, then groups to handle members with inherited and given roles
989 997 members_to_copy = []
990 998 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
991 999 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
992 1000
993 1001 members_to_copy.each do |member|
994 1002 new_member = Member.new
995 1003 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
996 1004 # only copy non inherited roles
997 1005 # inherited roles will be added when copying the group membership
998 1006 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
999 1007 next if role_ids.empty?
1000 1008 new_member.role_ids = role_ids
1001 1009 new_member.project = self
1002 1010 self.members << new_member
1003 1011 end
1004 1012 end
1005 1013
1006 1014 # Copies queries from +project+
1007 1015 def copy_queries(project)
1008 1016 project.queries.each do |query|
1009 1017 new_query = IssueQuery.new
1010 1018 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria", "user_id", "type")
1011 1019 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
1012 1020 new_query.project = self
1013 1021 new_query.user_id = query.user_id
1014 1022 new_query.role_ids = query.role_ids if query.visibility == IssueQuery::VISIBILITY_ROLES
1015 1023 self.queries << new_query
1016 1024 end
1017 1025 end
1018 1026
1019 1027 # Copies boards from +project+
1020 1028 def copy_boards(project)
1021 1029 project.boards.each do |board|
1022 1030 new_board = Board.new
1023 1031 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
1024 1032 new_board.project = self
1025 1033 self.boards << new_board
1026 1034 end
1027 1035 end
1028 1036
1029 1037 def allowed_permissions
1030 1038 @allowed_permissions ||= begin
1031 1039 module_names = enabled_modules.loaded? ? enabled_modules.map(&:name) : enabled_modules.pluck(:name)
1032 1040 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
1033 1041 end
1034 1042 end
1035 1043
1036 1044 def allowed_actions
1037 1045 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
1038 1046 end
1039 1047
1040 1048 # Archives subprojects recursively
1041 1049 def archive!
1042 1050 children.each do |subproject|
1043 1051 subproject.send :archive!
1044 1052 end
1045 1053 update_attribute :status, STATUS_ARCHIVED
1046 1054 end
1047 1055 end
@@ -1,1039 +1,1039
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class QueryColumn
19 19 attr_accessor :name, :sortable, :groupable, :totalable, :default_order
20 20 include Redmine::I18n
21 21
22 22 def initialize(name, options={})
23 23 self.name = name
24 24 self.sortable = options[:sortable]
25 25 self.groupable = options[:groupable] || false
26 26 if groupable == true
27 27 self.groupable = name.to_s
28 28 end
29 29 self.totalable = options[:totalable] || false
30 30 self.default_order = options[:default_order]
31 31 @inline = options.key?(:inline) ? options[:inline] : true
32 32 @caption_key = options[:caption] || "field_#{name}".to_sym
33 33 @frozen = options[:frozen]
34 34 end
35 35
36 36 def caption
37 37 case @caption_key
38 38 when Symbol
39 39 l(@caption_key)
40 40 when Proc
41 41 @caption_key.call
42 42 else
43 43 @caption_key
44 44 end
45 45 end
46 46
47 47 # Returns true if the column is sortable, otherwise false
48 48 def sortable?
49 49 !@sortable.nil?
50 50 end
51 51
52 52 def sortable
53 53 @sortable.is_a?(Proc) ? @sortable.call : @sortable
54 54 end
55 55
56 56 def inline?
57 57 @inline
58 58 end
59 59
60 60 def frozen?
61 61 @frozen
62 62 end
63 63
64 64 def value(object)
65 65 object.send name
66 66 end
67 67
68 68 def value_object(object)
69 69 object.send name
70 70 end
71 71
72 72 def css_classes
73 73 name
74 74 end
75 75 end
76 76
77 77 class QueryCustomFieldColumn < QueryColumn
78 78
79 79 def initialize(custom_field)
80 80 self.name = "cf_#{custom_field.id}".to_sym
81 81 self.sortable = custom_field.order_statement || false
82 82 self.groupable = custom_field.group_statement || false
83 83 self.totalable = custom_field.totalable?
84 84 @inline = true
85 85 @cf = custom_field
86 86 end
87 87
88 88 def caption
89 89 @cf.name
90 90 end
91 91
92 92 def custom_field
93 93 @cf
94 94 end
95 95
96 96 def value_object(object)
97 97 if custom_field.visible_by?(object.project, User.current)
98 98 cv = object.custom_values.select {|v| v.custom_field_id == @cf.id}
99 99 cv.size > 1 ? cv.sort {|a,b| a.value.to_s <=> b.value.to_s} : cv.first
100 100 else
101 101 nil
102 102 end
103 103 end
104 104
105 105 def value(object)
106 106 raw = value_object(object)
107 107 if raw.is_a?(Array)
108 108 raw.map {|r| @cf.cast_value(r.value)}
109 109 elsif raw
110 110 @cf.cast_value(raw.value)
111 111 else
112 112 nil
113 113 end
114 114 end
115 115
116 116 def css_classes
117 117 @css_classes ||= "#{name} #{@cf.field_format}"
118 118 end
119 119 end
120 120
121 121 class QueryAssociationCustomFieldColumn < QueryCustomFieldColumn
122 122
123 123 def initialize(association, custom_field)
124 124 super(custom_field)
125 125 self.name = "#{association}.cf_#{custom_field.id}".to_sym
126 126 # TODO: support sorting/grouping by association custom field
127 127 self.sortable = false
128 128 self.groupable = false
129 129 @association = association
130 130 end
131 131
132 132 def value_object(object)
133 133 if assoc = object.send(@association)
134 134 super(assoc)
135 135 end
136 136 end
137 137
138 138 def css_classes
139 139 @css_classes ||= "#{@association}_cf_#{@cf.id} #{@cf.field_format}"
140 140 end
141 141 end
142 142
143 143 class Query < ActiveRecord::Base
144 144 class StatementInvalid < ::ActiveRecord::StatementInvalid
145 145 end
146 146
147 147 VISIBILITY_PRIVATE = 0
148 148 VISIBILITY_ROLES = 1
149 149 VISIBILITY_PUBLIC = 2
150 150
151 151 belongs_to :project
152 152 belongs_to :user
153 153 has_and_belongs_to_many :roles, :join_table => "#{table_name_prefix}queries_roles#{table_name_suffix}", :foreign_key => "query_id"
154 154 serialize :filters
155 155 serialize :column_names
156 156 serialize :sort_criteria, Array
157 157 serialize :options, Hash
158 158
159 159 attr_protected :project_id, :user_id
160 160
161 161 validates_presence_of :name
162 162 validates_length_of :name, :maximum => 255
163 163 validates :visibility, :inclusion => { :in => [VISIBILITY_PUBLIC, VISIBILITY_ROLES, VISIBILITY_PRIVATE] }
164 164 validate :validate_query_filters
165 165 validate do |query|
166 166 errors.add(:base, l(:label_role_plural) + ' ' + l('activerecord.errors.messages.blank')) if query.visibility == VISIBILITY_ROLES && roles.blank?
167 167 end
168 168
169 169 after_save do |query|
170 170 if query.visibility_changed? && query.visibility != VISIBILITY_ROLES
171 171 query.roles.clear
172 172 end
173 173 end
174 174
175 175 class_attribute :operators
176 176 self.operators = {
177 177 "=" => :label_equals,
178 178 "!" => :label_not_equals,
179 179 "o" => :label_open_issues,
180 180 "c" => :label_closed_issues,
181 181 "!*" => :label_none,
182 182 "*" => :label_any,
183 183 ">=" => :label_greater_or_equal,
184 184 "<=" => :label_less_or_equal,
185 185 "><" => :label_between,
186 186 "<t+" => :label_in_less_than,
187 187 ">t+" => :label_in_more_than,
188 188 "><t+"=> :label_in_the_next_days,
189 189 "t+" => :label_in,
190 190 "t" => :label_today,
191 191 "ld" => :label_yesterday,
192 192 "w" => :label_this_week,
193 193 "lw" => :label_last_week,
194 194 "l2w" => [:label_last_n_weeks, {:count => 2}],
195 195 "m" => :label_this_month,
196 196 "lm" => :label_last_month,
197 197 "y" => :label_this_year,
198 198 ">t-" => :label_less_than_ago,
199 199 "<t-" => :label_more_than_ago,
200 200 "><t-"=> :label_in_the_past_days,
201 201 "t-" => :label_ago,
202 202 "~" => :label_contains,
203 203 "!~" => :label_not_contains,
204 204 "=p" => :label_any_issues_in_project,
205 205 "=!p" => :label_any_issues_not_in_project,
206 206 "!p" => :label_no_issues_in_project,
207 207 "*o" => :label_any_open_issues,
208 208 "!o" => :label_no_open_issues
209 209 }
210 210
211 211 class_attribute :operators_by_filter_type
212 212 self.operators_by_filter_type = {
213 213 :list => [ "=", "!" ],
214 214 :list_status => [ "o", "=", "!", "c", "*" ],
215 215 :list_optional => [ "=", "!", "!*", "*" ],
216 216 :list_subprojects => [ "*", "!*", "=" ],
217 217 :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "><t+", "t+", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", ">t-", "<t-", "><t-", "t-", "!*", "*" ],
218 218 :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "><t-", "t-", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", "!*", "*" ],
219 219 :string => [ "=", "~", "!", "!~", "!*", "*" ],
220 220 :text => [ "~", "!~", "!*", "*" ],
221 221 :integer => [ "=", ">=", "<=", "><", "!*", "*" ],
222 222 :float => [ "=", ">=", "<=", "><", "!*", "*" ],
223 223 :relation => ["=", "=p", "=!p", "!p", "*o", "!o", "!*", "*"],
224 224 :tree => ["=", "~", "!*", "*"]
225 225 }
226 226
227 227 class_attribute :available_columns
228 228 self.available_columns = []
229 229
230 230 class_attribute :queried_class
231 231
232 232 def queried_table_name
233 233 @queried_table_name ||= self.class.queried_class.table_name
234 234 end
235 235
236 236 def initialize(attributes=nil, *args)
237 237 super attributes
238 238 @is_for_all = project.nil?
239 239 end
240 240
241 241 # Builds the query from the given params
242 242 def build_from_params(params)
243 243 if params[:fields] || params[:f]
244 244 self.filters = {}
245 245 add_filters(params[:fields] || params[:f], params[:operators] || params[:op], params[:values] || params[:v])
246 246 else
247 247 available_filters.keys.each do |field|
248 248 add_short_filter(field, params[field]) if params[field]
249 249 end
250 250 end
251 251 self.group_by = params[:group_by] || (params[:query] && params[:query][:group_by])
252 252 self.column_names = params[:c] || (params[:query] && params[:query][:column_names])
253 253 self.totalable_names = params[:t] || (params[:query] && params[:query][:totalable_names])
254 254 self
255 255 end
256 256
257 257 # Builds a new query from the given params and attributes
258 258 def self.build_from_params(params, attributes={})
259 259 new(attributes).build_from_params(params)
260 260 end
261 261
262 262 def validate_query_filters
263 263 filters.each_key do |field|
264 264 if values_for(field)
265 265 case type_for(field)
266 266 when :integer
267 267 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/\A[+-]?\d+(,[+-]?\d+)*\z/) }
268 268 when :float
269 269 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/\A[+-]?\d+(\.\d*)?\z/) }
270 270 when :date, :date_past
271 271 case operator_for(field)
272 272 when "=", ">=", "<=", "><"
273 273 add_filter_error(field, :invalid) if values_for(field).detect {|v|
274 274 v.present? && (!v.match(/\A\d{4}-\d{2}-\d{2}(T\d{2}((:)?\d{2}){0,2}(Z|\d{2}:?\d{2})?)?\z/) || parse_date(v).nil?)
275 275 }
276 276 when ">t-", "<t-", "t-", ">t+", "<t+", "t+", "><t+", "><t-"
277 277 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
278 278 end
279 279 end
280 280 end
281 281
282 282 add_filter_error(field, :blank) unless
283 283 # filter requires one or more values
284 284 (values_for(field) and !values_for(field).first.blank?) or
285 285 # filter doesn't require any value
286 286 ["o", "c", "!*", "*", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", "*o", "!o"].include? operator_for(field)
287 287 end if filters
288 288 end
289 289
290 290 def add_filter_error(field, message)
291 291 m = label_for(field) + " " + l(message, :scope => 'activerecord.errors.messages')
292 292 errors.add(:base, m)
293 293 end
294 294
295 295 def editable_by?(user)
296 296 return false unless user
297 297 # Admin can edit them all and regular users can edit their private queries
298 298 return true if user.admin? || (is_private? && self.user_id == user.id)
299 299 # Members can not edit public queries that are for all project (only admin is allowed to)
300 300 is_public? && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
301 301 end
302 302
303 303 def trackers
304 @trackers ||= project.nil? ? Tracker.sorted.to_a : project.rolled_up_trackers
304 @trackers ||= (project.nil? ? Tracker.all : project.rolled_up_trackers).visible.sorted
305 305 end
306 306
307 307 # Returns a hash of localized labels for all filter operators
308 308 def self.operators_labels
309 309 operators.inject({}) {|h, operator| h[operator.first] = l(*operator.last); h}
310 310 end
311 311
312 312 # Returns a representation of the available filters for JSON serialization
313 313 def available_filters_as_json
314 314 json = {}
315 315 available_filters.each do |field, options|
316 316 options = options.slice(:type, :name, :values)
317 317 if options[:values] && values_for(field)
318 318 missing = Array(values_for(field)).select(&:present?) - options[:values].map(&:last)
319 319 if missing.any? && respond_to?(method = "find_#{field}_filter_values")
320 320 options[:values] += send(method, missing)
321 321 end
322 322 end
323 323 json[field] = options.stringify_keys
324 324 end
325 325 json
326 326 end
327 327
328 328 def all_projects
329 329 @all_projects ||= Project.visible.to_a
330 330 end
331 331
332 332 def all_projects_values
333 333 return @all_projects_values if @all_projects_values
334 334
335 335 values = []
336 336 Project.project_tree(all_projects) do |p, level|
337 337 prefix = (level > 0 ? ('--' * level + ' ') : '')
338 338 values << ["#{prefix}#{p.name}", p.id.to_s]
339 339 end
340 340 @all_projects_values = values
341 341 end
342 342
343 343 # Adds available filters
344 344 def initialize_available_filters
345 345 # implemented by sub-classes
346 346 end
347 347 protected :initialize_available_filters
348 348
349 349 # Adds an available filter
350 350 def add_available_filter(field, options)
351 351 @available_filters ||= ActiveSupport::OrderedHash.new
352 352 @available_filters[field] = options
353 353 @available_filters
354 354 end
355 355
356 356 # Removes an available filter
357 357 def delete_available_filter(field)
358 358 if @available_filters
359 359 @available_filters.delete(field)
360 360 end
361 361 end
362 362
363 363 # Return a hash of available filters
364 364 def available_filters
365 365 unless @available_filters
366 366 initialize_available_filters
367 367 @available_filters.each do |field, options|
368 368 options[:name] ||= l(options[:label] || "field_#{field}".gsub(/_id$/, ''))
369 369 end
370 370 end
371 371 @available_filters
372 372 end
373 373
374 374 def add_filter(field, operator, values=nil)
375 375 # values must be an array
376 376 return unless values.nil? || values.is_a?(Array)
377 377 # check if field is defined as an available filter
378 378 if available_filters.has_key? field
379 379 filter_options = available_filters[field]
380 380 filters[field] = {:operator => operator, :values => (values || [''])}
381 381 end
382 382 end
383 383
384 384 def add_short_filter(field, expression)
385 385 return unless expression && available_filters.has_key?(field)
386 386 field_type = available_filters[field][:type]
387 387 operators_by_filter_type[field_type].sort.reverse.detect do |operator|
388 388 next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
389 389 values = $1
390 390 add_filter field, operator, values.present? ? values.split('|') : ['']
391 391 end || add_filter(field, '=', expression.split('|'))
392 392 end
393 393
394 394 # Add multiple filters using +add_filter+
395 395 def add_filters(fields, operators, values)
396 396 if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
397 397 fields.each do |field|
398 398 add_filter(field, operators[field], values && values[field])
399 399 end
400 400 end
401 401 end
402 402
403 403 def has_filter?(field)
404 404 filters and filters[field]
405 405 end
406 406
407 407 def type_for(field)
408 408 available_filters[field][:type] if available_filters.has_key?(field)
409 409 end
410 410
411 411 def operator_for(field)
412 412 has_filter?(field) ? filters[field][:operator] : nil
413 413 end
414 414
415 415 def values_for(field)
416 416 has_filter?(field) ? filters[field][:values] : nil
417 417 end
418 418
419 419 def value_for(field, index=0)
420 420 (values_for(field) || [])[index]
421 421 end
422 422
423 423 def label_for(field)
424 424 label = available_filters[field][:name] if available_filters.has_key?(field)
425 425 label ||= queried_class.human_attribute_name(field, :default => field)
426 426 end
427 427
428 428 def self.add_available_column(column)
429 429 self.available_columns << (column) if column.is_a?(QueryColumn)
430 430 end
431 431
432 432 # Returns an array of columns that can be used to group the results
433 433 def groupable_columns
434 434 available_columns.select {|c| c.groupable}
435 435 end
436 436
437 437 # Returns a Hash of columns and the key for sorting
438 438 def sortable_columns
439 439 available_columns.inject({}) {|h, column|
440 440 h[column.name.to_s] = column.sortable
441 441 h
442 442 }
443 443 end
444 444
445 445 def columns
446 446 # preserve the column_names order
447 447 cols = (has_default_columns? ? default_columns_names : column_names).collect do |name|
448 448 available_columns.find { |col| col.name == name }
449 449 end.compact
450 450 available_columns.select(&:frozen?) | cols
451 451 end
452 452
453 453 def inline_columns
454 454 columns.select(&:inline?)
455 455 end
456 456
457 457 def block_columns
458 458 columns.reject(&:inline?)
459 459 end
460 460
461 461 def available_inline_columns
462 462 available_columns.select(&:inline?)
463 463 end
464 464
465 465 def available_block_columns
466 466 available_columns.reject(&:inline?)
467 467 end
468 468
469 469 def available_totalable_columns
470 470 available_columns.select(&:totalable)
471 471 end
472 472
473 473 def default_columns_names
474 474 []
475 475 end
476 476
477 477 def column_names=(names)
478 478 if names
479 479 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
480 480 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
481 481 # Set column_names to nil if default columns
482 482 if names == default_columns_names
483 483 names = nil
484 484 end
485 485 end
486 486 write_attribute(:column_names, names)
487 487 end
488 488
489 489 def has_column?(column)
490 490 column_names && column_names.include?(column.is_a?(QueryColumn) ? column.name : column)
491 491 end
492 492
493 493 def has_custom_field_column?
494 494 columns.any? {|column| column.is_a? QueryCustomFieldColumn}
495 495 end
496 496
497 497 def has_default_columns?
498 498 column_names.nil? || column_names.empty?
499 499 end
500 500
501 501 def totalable_columns
502 502 names = totalable_names
503 503 available_totalable_columns.select {|column| names.include?(column.name)}
504 504 end
505 505
506 506 def totalable_names=(names)
507 507 if names
508 508 names = names.select(&:present?).map {|n| n.is_a?(Symbol) ? n : n.to_sym}
509 509 end
510 510 options[:totalable_names] = names
511 511 end
512 512
513 513 def totalable_names
514 514 options[:totalable_names] || Setting.issue_list_default_totals.map(&:to_sym) || []
515 515 end
516 516
517 517 def sort_criteria=(arg)
518 518 c = []
519 519 if arg.is_a?(Hash)
520 520 arg = arg.keys.sort.collect {|k| arg[k]}
521 521 end
522 522 if arg
523 523 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, (o == 'desc' || o == false) ? 'desc' : 'asc']}
524 524 end
525 525 write_attribute(:sort_criteria, c)
526 526 end
527 527
528 528 def sort_criteria
529 529 read_attribute(:sort_criteria) || []
530 530 end
531 531
532 532 def sort_criteria_key(arg)
533 533 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
534 534 end
535 535
536 536 def sort_criteria_order(arg)
537 537 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
538 538 end
539 539
540 540 def sort_criteria_order_for(key)
541 541 sort_criteria.detect {|k, order| key.to_s == k}.try(:last)
542 542 end
543 543
544 544 # Returns the SQL sort order that should be prepended for grouping
545 545 def group_by_sort_order
546 546 if column = group_by_column
547 547 order = (sort_criteria_order_for(column.name) || column.default_order).try(:upcase)
548 548 Array(column.sortable).map {|s| "#{s} #{order}"}
549 549 end
550 550 end
551 551
552 552 # Returns true if the query is a grouped query
553 553 def grouped?
554 554 !group_by_column.nil?
555 555 end
556 556
557 557 def group_by_column
558 558 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
559 559 end
560 560
561 561 def group_by_statement
562 562 group_by_column.try(:groupable)
563 563 end
564 564
565 565 def project_statement
566 566 project_clauses = []
567 567 if project && !project.descendants.active.empty?
568 568 if has_filter?("subproject_id")
569 569 case operator_for("subproject_id")
570 570 when '='
571 571 # include the selected subprojects
572 572 ids = [project.id] + values_for("subproject_id").each(&:to_i)
573 573 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
574 574 when '!*'
575 575 # main project only
576 576 project_clauses << "#{Project.table_name}.id = %d" % project.id
577 577 else
578 578 # all subprojects
579 579 project_clauses << "#{Project.table_name}.lft >= #{project.lft} AND #{Project.table_name}.rgt <= #{project.rgt}"
580 580 end
581 581 elsif Setting.display_subprojects_issues?
582 582 project_clauses << "#{Project.table_name}.lft >= #{project.lft} AND #{Project.table_name}.rgt <= #{project.rgt}"
583 583 else
584 584 project_clauses << "#{Project.table_name}.id = %d" % project.id
585 585 end
586 586 elsif project
587 587 project_clauses << "#{Project.table_name}.id = %d" % project.id
588 588 end
589 589 project_clauses.any? ? project_clauses.join(' AND ') : nil
590 590 end
591 591
592 592 def statement
593 593 # filters clauses
594 594 filters_clauses = []
595 595 filters.each_key do |field|
596 596 next if field == "subproject_id"
597 597 v = values_for(field).clone
598 598 next unless v and !v.empty?
599 599 operator = operator_for(field)
600 600
601 601 # "me" value substitution
602 602 if %w(assigned_to_id author_id user_id watcher_id).include?(field)
603 603 if v.delete("me")
604 604 if User.current.logged?
605 605 v.push(User.current.id.to_s)
606 606 v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id'
607 607 else
608 608 v.push("0")
609 609 end
610 610 end
611 611 end
612 612
613 613 if field == 'project_id'
614 614 if v.delete('mine')
615 615 v += User.current.memberships.map(&:project_id).map(&:to_s)
616 616 end
617 617 end
618 618
619 619 if field =~ /cf_(\d+)$/
620 620 # custom field
621 621 filters_clauses << sql_for_custom_field(field, operator, v, $1)
622 622 elsif respond_to?("sql_for_#{field}_field")
623 623 # specific statement
624 624 filters_clauses << send("sql_for_#{field}_field", field, operator, v)
625 625 else
626 626 # regular field
627 627 filters_clauses << '(' + sql_for_field(field, operator, v, queried_table_name, field) + ')'
628 628 end
629 629 end if filters and valid?
630 630
631 631 if (c = group_by_column) && c.is_a?(QueryCustomFieldColumn)
632 632 # Excludes results for which the grouped custom field is not visible
633 633 filters_clauses << c.custom_field.visibility_by_project_condition
634 634 end
635 635
636 636 filters_clauses << project_statement
637 637 filters_clauses.reject!(&:blank?)
638 638
639 639 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
640 640 end
641 641
642 642 # Returns the sum of values for the given column
643 643 def total_for(column)
644 644 total_with_scope(column, base_scope)
645 645 end
646 646
647 647 # Returns a hash of the sum of the given column for each group,
648 648 # or nil if the query is not grouped
649 649 def total_by_group_for(column)
650 650 grouped_query do |scope|
651 651 total_with_scope(column, scope)
652 652 end
653 653 end
654 654
655 655 def totals
656 656 totals = totalable_columns.map {|column| [column, total_for(column)]}
657 657 yield totals if block_given?
658 658 totals
659 659 end
660 660
661 661 def totals_by_group
662 662 totals = totalable_columns.map {|column| [column, total_by_group_for(column)]}
663 663 yield totals if block_given?
664 664 totals
665 665 end
666 666
667 667 private
668 668
669 669 def grouped_query(&block)
670 670 r = nil
671 671 if grouped?
672 672 begin
673 673 # Rails3 will raise an (unexpected) RecordNotFound if there's only a nil group value
674 674 r = yield base_group_scope
675 675 rescue ActiveRecord::RecordNotFound
676 676 r = {nil => yield(base_scope)}
677 677 end
678 678 c = group_by_column
679 679 if c.is_a?(QueryCustomFieldColumn)
680 680 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
681 681 end
682 682 end
683 683 r
684 684 rescue ::ActiveRecord::StatementInvalid => e
685 685 raise StatementInvalid.new(e.message)
686 686 end
687 687
688 688 def total_with_scope(column, scope)
689 689 unless column.is_a?(QueryColumn)
690 690 column = column.to_sym
691 691 column = available_totalable_columns.detect {|c| c.name == column}
692 692 end
693 693 if column.is_a?(QueryCustomFieldColumn)
694 694 custom_field = column.custom_field
695 695 send "total_for_custom_field", custom_field, scope
696 696 else
697 697 send "total_for_#{column.name}", scope
698 698 end
699 699 rescue ::ActiveRecord::StatementInvalid => e
700 700 raise StatementInvalid.new(e.message)
701 701 end
702 702
703 703 def base_scope
704 704 raise "unimplemented"
705 705 end
706 706
707 707 def base_group_scope
708 708 base_scope.
709 709 joins(joins_for_order_statement(group_by_statement)).
710 710 group(group_by_statement)
711 711 end
712 712
713 713 def total_for_custom_field(custom_field, scope, &block)
714 714 total = custom_field.format.total_for_scope(custom_field, scope)
715 715 total = map_total(total) {|t| custom_field.format.cast_total_value(custom_field, t)}
716 716 total
717 717 end
718 718
719 719 def map_total(total, &block)
720 720 if total.is_a?(Hash)
721 721 total.keys.each {|k| total[k] = yield total[k]}
722 722 else
723 723 total = yield total
724 724 end
725 725 total
726 726 end
727 727
728 728 def sql_for_custom_field(field, operator, value, custom_field_id)
729 729 db_table = CustomValue.table_name
730 730 db_field = 'value'
731 731 filter = @available_filters[field]
732 732 return nil unless filter
733 733 if filter[:field].format.target_class && filter[:field].format.target_class <= User
734 734 if value.delete('me')
735 735 value.push User.current.id.to_s
736 736 end
737 737 end
738 738 not_in = nil
739 739 if operator == '!'
740 740 # Makes ! operator work for custom fields with multiple values
741 741 operator = '='
742 742 not_in = 'NOT'
743 743 end
744 744 customized_key = "id"
745 745 customized_class = queried_class
746 746 if field =~ /^(.+)\.cf_/
747 747 assoc = $1
748 748 customized_key = "#{assoc}_id"
749 749 customized_class = queried_class.reflect_on_association(assoc.to_sym).klass.base_class rescue nil
750 750 raise "Unknown #{queried_class.name} association #{assoc}" unless customized_class
751 751 end
752 752 where = sql_for_field(field, operator, value, db_table, db_field, true)
753 753 if operator =~ /[<>]/
754 754 where = "(#{where}) AND #{db_table}.#{db_field} <> ''"
755 755 end
756 756 "#{queried_table_name}.#{customized_key} #{not_in} IN (" +
757 757 "SELECT #{customized_class.table_name}.id FROM #{customized_class.table_name}" +
758 758 " LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='#{customized_class}' AND #{db_table}.customized_id=#{customized_class.table_name}.id AND #{db_table}.custom_field_id=#{custom_field_id}" +
759 759 " WHERE (#{where}) AND (#{filter[:field].visibility_by_project_condition}))"
760 760 end
761 761
762 762 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
763 763 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
764 764 sql = ''
765 765 case operator
766 766 when "="
767 767 if value.any?
768 768 case type_for(field)
769 769 when :date, :date_past
770 770 sql = date_clause(db_table, db_field, parse_date(value.first), parse_date(value.first), is_custom_filter)
771 771 when :integer
772 772 int_values = value.first.to_s.scan(/[+-]?\d+/).map(&:to_i).join(",")
773 773 if int_values.present?
774 774 if is_custom_filter
775 775 sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) IN (#{int_values}))"
776 776 else
777 777 sql = "#{db_table}.#{db_field} IN (#{int_values})"
778 778 end
779 779 else
780 780 sql = "1=0"
781 781 end
782 782 when :float
783 783 if is_custom_filter
784 784 sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5})"
785 785 else
786 786 sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
787 787 end
788 788 else
789 789 sql = queried_class.send(:sanitize_sql_for_conditions, ["#{db_table}.#{db_field} IN (?)", value])
790 790 end
791 791 else
792 792 # IN an empty set
793 793 sql = "1=0"
794 794 end
795 795 when "!"
796 796 if value.any?
797 797 sql = queried_class.send(:sanitize_sql_for_conditions, ["(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (?))", value])
798 798 else
799 799 # NOT IN an empty set
800 800 sql = "1=1"
801 801 end
802 802 when "!*"
803 803 sql = "#{db_table}.#{db_field} IS NULL"
804 804 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
805 805 when "*"
806 806 sql = "#{db_table}.#{db_field} IS NOT NULL"
807 807 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
808 808 when ">="
809 809 if [:date, :date_past].include?(type_for(field))
810 810 sql = date_clause(db_table, db_field, parse_date(value.first), nil, is_custom_filter)
811 811 else
812 812 if is_custom_filter
813 813 sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) >= #{value.first.to_f})"
814 814 else
815 815 sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
816 816 end
817 817 end
818 818 when "<="
819 819 if [:date, :date_past].include?(type_for(field))
820 820 sql = date_clause(db_table, db_field, nil, parse_date(value.first), is_custom_filter)
821 821 else
822 822 if is_custom_filter
823 823 sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) <= #{value.first.to_f})"
824 824 else
825 825 sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
826 826 end
827 827 end
828 828 when "><"
829 829 if [:date, :date_past].include?(type_for(field))
830 830 sql = date_clause(db_table, db_field, parse_date(value[0]), parse_date(value[1]), is_custom_filter)
831 831 else
832 832 if is_custom_filter
833 833 sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) BETWEEN #{value[0].to_f} AND #{value[1].to_f})"
834 834 else
835 835 sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
836 836 end
837 837 end
838 838 when "o"
839 839 sql = "#{queried_table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{self.class.connection.quoted_false})" if field == "status_id"
840 840 when "c"
841 841 sql = "#{queried_table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{self.class.connection.quoted_true})" if field == "status_id"
842 842 when "><t-"
843 843 # between today - n days and today
844 844 sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0, is_custom_filter)
845 845 when ">t-"
846 846 # >= today - n days
847 847 sql = relative_date_clause(db_table, db_field, - value.first.to_i, nil, is_custom_filter)
848 848 when "<t-"
849 849 # <= today - n days
850 850 sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i, is_custom_filter)
851 851 when "t-"
852 852 # = n days in past
853 853 sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i, is_custom_filter)
854 854 when "><t+"
855 855 # between today and today + n days
856 856 sql = relative_date_clause(db_table, db_field, 0, value.first.to_i, is_custom_filter)
857 857 when ">t+"
858 858 # >= today + n days
859 859 sql = relative_date_clause(db_table, db_field, value.first.to_i, nil, is_custom_filter)
860 860 when "<t+"
861 861 # <= today + n days
862 862 sql = relative_date_clause(db_table, db_field, nil, value.first.to_i, is_custom_filter)
863 863 when "t+"
864 864 # = today + n days
865 865 sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i, is_custom_filter)
866 866 when "t"
867 867 # = today
868 868 sql = relative_date_clause(db_table, db_field, 0, 0, is_custom_filter)
869 869 when "ld"
870 870 # = yesterday
871 871 sql = relative_date_clause(db_table, db_field, -1, -1, is_custom_filter)
872 872 when "w"
873 873 # = this week
874 874 first_day_of_week = l(:general_first_day_of_week).to_i
875 875 day_of_week = User.current.today.cwday
876 876 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
877 877 sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6, is_custom_filter)
878 878 when "lw"
879 879 # = last week
880 880 first_day_of_week = l(:general_first_day_of_week).to_i
881 881 day_of_week = User.current.today.cwday
882 882 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
883 883 sql = relative_date_clause(db_table, db_field, - days_ago - 7, - days_ago - 1, is_custom_filter)
884 884 when "l2w"
885 885 # = last 2 weeks
886 886 first_day_of_week = l(:general_first_day_of_week).to_i
887 887 day_of_week = User.current.today.cwday
888 888 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
889 889 sql = relative_date_clause(db_table, db_field, - days_ago - 14, - days_ago - 1, is_custom_filter)
890 890 when "m"
891 891 # = this month
892 892 date = User.current.today
893 893 sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month, is_custom_filter)
894 894 when "lm"
895 895 # = last month
896 896 date = User.current.today.prev_month
897 897 sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month, is_custom_filter)
898 898 when "y"
899 899 # = this year
900 900 date = User.current.today
901 901 sql = date_clause(db_table, db_field, date.beginning_of_year, date.end_of_year, is_custom_filter)
902 902 when "~"
903 903 sql = sql_contains("#{db_table}.#{db_field}", value.first)
904 904 when "!~"
905 905 sql = sql_contains("#{db_table}.#{db_field}", value.first, false)
906 906 else
907 907 raise "Unknown query operator #{operator}"
908 908 end
909 909
910 910 return sql
911 911 end
912 912
913 913 # Returns a SQL LIKE statement with wildcards
914 914 def sql_contains(db_field, value, match=true)
915 915 queried_class.send :sanitize_sql_for_conditions,
916 916 [Redmine::Database.like(db_field, '?', :match => match), "%#{value}%"]
917 917 end
918 918
919 919 # Adds a filter for the given custom field
920 920 def add_custom_field_filter(field, assoc=nil)
921 921 options = field.query_filter_options(self)
922 922 if field.format.target_class && field.format.target_class <= User
923 923 if options[:values].is_a?(Array) && User.current.logged?
924 924 options[:values].unshift ["<< #{l(:label_me)} >>", "me"]
925 925 end
926 926 end
927 927
928 928 filter_id = "cf_#{field.id}"
929 929 filter_name = field.name
930 930 if assoc.present?
931 931 filter_id = "#{assoc}.#{filter_id}"
932 932 filter_name = l("label_attribute_of_#{assoc}", :name => filter_name)
933 933 end
934 934 add_available_filter filter_id, options.merge({
935 935 :name => filter_name,
936 936 :field => field
937 937 })
938 938 end
939 939
940 940 # Adds filters for the given custom fields scope
941 941 def add_custom_fields_filters(scope, assoc=nil)
942 942 scope.visible.where(:is_filter => true).sorted.each do |field|
943 943 add_custom_field_filter(field, assoc)
944 944 end
945 945 end
946 946
947 947 # Adds filters for the given associations custom fields
948 948 def add_associations_custom_fields_filters(*associations)
949 949 fields_by_class = CustomField.visible.where(:is_filter => true).group_by(&:class)
950 950 associations.each do |assoc|
951 951 association_klass = queried_class.reflect_on_association(assoc).klass
952 952 fields_by_class.each do |field_class, fields|
953 953 if field_class.customized_class <= association_klass
954 954 fields.sort.each do |field|
955 955 add_custom_field_filter(field, assoc)
956 956 end
957 957 end
958 958 end
959 959 end
960 960 end
961 961
962 962 def quoted_time(time, is_custom_filter)
963 963 if is_custom_filter
964 964 # Custom field values are stored as strings in the DB
965 965 # using this format that does not depend on DB date representation
966 966 time.strftime("%Y-%m-%d %H:%M:%S")
967 967 else
968 968 self.class.connection.quoted_date(time)
969 969 end
970 970 end
971 971
972 972 def date_for_user_time_zone(y, m, d)
973 973 if tz = User.current.time_zone
974 974 tz.local y, m, d
975 975 else
976 976 Time.local y, m, d
977 977 end
978 978 end
979 979
980 980 # Returns a SQL clause for a date or datetime field.
981 981 def date_clause(table, field, from, to, is_custom_filter)
982 982 s = []
983 983 if from
984 984 if from.is_a?(Date)
985 985 from = date_for_user_time_zone(from.year, from.month, from.day).yesterday.end_of_day
986 986 else
987 987 from = from - 1 # second
988 988 end
989 989 if self.class.default_timezone == :utc
990 990 from = from.utc
991 991 end
992 992 s << ("#{table}.#{field} > '%s'" % [quoted_time(from, is_custom_filter)])
993 993 end
994 994 if to
995 995 if to.is_a?(Date)
996 996 to = date_for_user_time_zone(to.year, to.month, to.day).end_of_day
997 997 end
998 998 if self.class.default_timezone == :utc
999 999 to = to.utc
1000 1000 end
1001 1001 s << ("#{table}.#{field} <= '%s'" % [quoted_time(to, is_custom_filter)])
1002 1002 end
1003 1003 s.join(' AND ')
1004 1004 end
1005 1005
1006 1006 # Returns a SQL clause for a date or datetime field using relative dates.
1007 1007 def relative_date_clause(table, field, days_from, days_to, is_custom_filter)
1008 1008 date_clause(table, field, (days_from ? User.current.today + days_from : nil), (days_to ? User.current.today + days_to : nil), is_custom_filter)
1009 1009 end
1010 1010
1011 1011 # Returns a Date or Time from the given filter value
1012 1012 def parse_date(arg)
1013 1013 if arg.to_s =~ /\A\d{4}-\d{2}-\d{2}T/
1014 1014 Time.parse(arg) rescue nil
1015 1015 else
1016 1016 Date.parse(arg) rescue nil
1017 1017 end
1018 1018 end
1019 1019
1020 1020 # Additional joins required for the given sort options
1021 1021 def joins_for_order_statement(order_options)
1022 1022 joins = []
1023 1023
1024 1024 if order_options
1025 1025 if order_options.include?('authors')
1026 1026 joins << "LEFT OUTER JOIN #{User.table_name} authors ON authors.id = #{queried_table_name}.author_id"
1027 1027 end
1028 1028 order_options.scan(/cf_\d+/).uniq.each do |name|
1029 1029 column = available_columns.detect {|c| c.name.to_s == name}
1030 1030 join = column && column.custom_field.join_for_order_statement
1031 1031 if join
1032 1032 joins << join
1033 1033 end
1034 1034 end
1035 1035 end
1036 1036
1037 1037 joins.any? ? joins.join(' ') : nil
1038 1038 end
1039 1039 end
@@ -1,114 +1,137
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Tracker < ActiveRecord::Base
19 19
20 20 CORE_FIELDS_UNDISABLABLE = %w(project_id tracker_id subject description priority_id is_private).freeze
21 21 # Fields that can be disabled
22 22 # Other (future) fields should be appended, not inserted!
23 23 CORE_FIELDS = %w(assigned_to_id category_id fixed_version_id parent_issue_id start_date due_date estimated_hours done_ratio).freeze
24 24 CORE_FIELDS_ALL = (CORE_FIELDS_UNDISABLABLE + CORE_FIELDS).freeze
25 25
26 26 before_destroy :check_integrity
27 27 belongs_to :default_status, :class_name => 'IssueStatus'
28 28 has_many :issues
29 29 has_many :workflow_rules, :dependent => :delete_all do
30 30 def copy(source_tracker)
31 31 WorkflowRule.copy(source_tracker, nil, proxy_association.owner, nil)
32 32 end
33 33 end
34 34
35 35 has_and_belongs_to_many :projects
36 36 has_and_belongs_to_many :custom_fields, :class_name => 'IssueCustomField', :join_table => "#{table_name_prefix}custom_fields_trackers#{table_name_suffix}", :association_foreign_key => 'custom_field_id'
37 37 acts_as_positioned
38 38
39 39 attr_protected :fields_bits
40 40
41 41 validates_presence_of :default_status
42 42 validates_presence_of :name
43 43 validates_uniqueness_of :name
44 44 validates_length_of :name, :maximum => 30
45 45
46 46 scope :sorted, lambda { order(:position) }
47 47 scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
48 48
49 # Returns the trackers that are visible by the user.
50 #
51 # Examples:
52 # project.trackers.visible(user)
53 # => returns the trackers that are visible by the user in project
54 #
55 # Tracker.visible(user)
56 # => returns the trackers that are visible by the user in at least on project
57 scope :visible, lambda {|*args|
58 user = args.shift || User.current
59 condition = Project.allowed_to_condition(user, :view_issues) do |role, user|
60 unless role.permissions_all_trackers?(:view_issues)
61 tracker_ids = role.permissions_tracker_ids(:view_issues)
62 if tracker_ids.any?
63 "#{Tracker.table_name}.id IN (#{tracker_ids.join(',')})"
64 else
65 '1=0'
66 end
67 end
68 end
69 joins(:projects).where(condition).uniq
70 }
71
49 72 def to_s; name end
50 73
51 74 def <=>(tracker)
52 75 position <=> tracker.position
53 76 end
54 77
55 78 # Returns an array of IssueStatus that are used
56 79 # in the tracker's workflows
57 80 def issue_statuses
58 81 @issue_statuses ||= IssueStatus.where(:id => issue_status_ids).to_a.sort
59 82 end
60 83
61 84 def issue_status_ids
62 85 if new_record?
63 86 []
64 87 else
65 88 @issue_status_ids ||= WorkflowTransition.where(:tracker_id => id).uniq.pluck(:old_status_id, :new_status_id).flatten.uniq
66 89 end
67 90 end
68 91
69 92 def disabled_core_fields
70 93 i = -1
71 94 @disabled_core_fields ||= CORE_FIELDS.select { i += 1; (fields_bits || 0) & (2 ** i) != 0}
72 95 end
73 96
74 97 def core_fields
75 98 CORE_FIELDS - disabled_core_fields
76 99 end
77 100
78 101 def core_fields=(fields)
79 102 raise ArgumentError.new("Tracker.core_fields takes an array") unless fields.is_a?(Array)
80 103
81 104 bits = 0
82 105 CORE_FIELDS.each_with_index do |field, i|
83 106 unless fields.include?(field)
84 107 bits |= 2 ** i
85 108 end
86 109 end
87 110 self.fields_bits = bits
88 111 @disabled_core_fields = nil
89 112 core_fields
90 113 end
91 114
92 115 # Returns the fields that are disabled for all the given trackers
93 116 def self.disabled_core_fields(trackers)
94 117 if trackers.present?
95 118 trackers.map(&:disabled_core_fields).reduce(:&)
96 119 else
97 120 []
98 121 end
99 122 end
100 123
101 124 # Returns the fields that are enabled for one tracker at least
102 125 def self.core_fields(trackers)
103 126 if trackers.present?
104 127 trackers.uniq.map(&:core_fields).reduce(:|)
105 128 else
106 129 CORE_FIELDS.dup
107 130 end
108 131 end
109 132
110 133 private
111 134 def check_integrity
112 135 raise Exception.new("Cannot delete tracker") if Issue.where(:tracker_id => self.id).any?
113 136 end
114 137 end
@@ -1,119 +1,131
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../../test_helper', __FILE__)
19 19
20 20 class TrackerTest < ActiveSupport::TestCase
21 fixtures :trackers, :workflows, :issue_statuses, :roles, :issues
21 fixtures :trackers, :workflows, :issue_statuses, :roles, :issues, :projects, :projects_trackers
22 22
23 23 def test_sorted_scope
24 24 assert_equal Tracker.all.sort, Tracker.sorted.to_a
25 25 end
26 26
27 27 def test_named_scope
28 28 assert_equal Tracker.find_by_name('Feature'), Tracker.named('feature').first
29 29 end
30 30
31 def test_visible_scope_chained_with_project_rolled_up_trackers
32 project = Project.find(1)
33 role = Role.generate!
34 role.add_permission! :view_issues
35 role.set_permission_trackers :view_issues, [2]
36 role.save!
37 user = User.generate!
38 User.add_to_project user, project, role
39
40 assert_equal [2], project.rolled_up_trackers(false).visible(user).map(&:id)
41 end
42
31 43 def test_copy_workflows
32 44 source = Tracker.find(1)
33 45 rules_count = source.workflow_rules.count
34 46 assert rules_count > 0
35 47
36 48 target = Tracker.new(:name => 'Target', :default_status_id => 1)
37 49 assert target.save
38 50 target.workflow_rules.copy(source)
39 51 target.reload
40 52 assert_equal rules_count, target.workflow_rules.size
41 53 end
42 54
43 55 def test_issue_statuses
44 56 tracker = Tracker.find(1)
45 57 WorkflowTransition.delete_all
46 58 WorkflowTransition.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 2, :new_status_id => 3)
47 59 WorkflowTransition.create!(:role_id => 2, :tracker_id => 1, :old_status_id => 3, :new_status_id => 5)
48 60
49 61 assert_kind_of Array, tracker.issue_statuses
50 62 assert_kind_of IssueStatus, tracker.issue_statuses.first
51 63 assert_equal [2, 3, 5], Tracker.find(1).issue_statuses.collect(&:id)
52 64 end
53 65
54 66 def test_issue_statuses_empty
55 67 WorkflowTransition.delete_all("tracker_id = 1")
56 68 assert_equal [], Tracker.find(1).issue_statuses
57 69 end
58 70
59 71 def test_issue_statuses_should_be_empty_for_new_record
60 72 assert_equal [], Tracker.new.issue_statuses
61 73 end
62 74
63 75 def test_core_fields_should_be_enabled_by_default
64 76 tracker = Tracker.new
65 77 assert_equal Tracker::CORE_FIELDS, tracker.core_fields
66 78 assert_equal [], tracker.disabled_core_fields
67 79 end
68 80
69 81 def test_core_fields
70 82 tracker = Tracker.new
71 83 tracker.core_fields = %w(assigned_to_id due_date)
72 84
73 85 assert_equal %w(assigned_to_id due_date), tracker.core_fields
74 86 assert_equal Tracker::CORE_FIELDS - %w(assigned_to_id due_date), tracker.disabled_core_fields
75 87 end
76 88
77 89 def test_core_fields_should_return_fields_enabled_for_any_tracker
78 90 trackers = []
79 91 trackers << Tracker.new(:core_fields => %w(assigned_to_id due_date))
80 92 trackers << Tracker.new(:core_fields => %w(assigned_to_id done_ratio))
81 93 trackers << Tracker.new(:core_fields => [])
82 94
83 95 assert_equal %w(assigned_to_id due_date done_ratio), Tracker.core_fields(trackers)
84 96 assert_equal Tracker::CORE_FIELDS - %w(assigned_to_id due_date done_ratio), Tracker.disabled_core_fields(trackers)
85 97 end
86 98
87 99 def test_core_fields_should_return_all_fields_for_an_empty_argument
88 100 assert_equal Tracker::CORE_FIELDS, Tracker.core_fields([])
89 101 assert_equal [], Tracker.disabled_core_fields([])
90 102 end
91 103
92 104 def test_sort_should_sort_by_position
93 105 a = Tracker.new(:name => 'Tracker A', :position => 2)
94 106 b = Tracker.new(:name => 'Tracker B', :position => 1)
95 107
96 108 assert_equal [b, a], [a, b].sort
97 109 end
98 110
99 111 def test_destroying_a_tracker_without_issues_should_not_raise_an_error
100 112 tracker = Tracker.find(1)
101 113 Issue.delete_all :tracker_id => tracker.id
102 114
103 115 assert_difference 'Tracker.count', -1 do
104 116 assert_nothing_raised do
105 117 tracker.destroy
106 118 end
107 119 end
108 120 end
109 121
110 122 def test_destroying_a_tracker_with_issues_should_raise_an_error
111 123 tracker = Tracker.find(1)
112 124
113 125 assert_no_difference 'Tracker.count' do
114 126 assert_raise Exception do
115 127 tracker.destroy
116 128 end
117 129 end
118 130 end
119 131 end
General Comments 0
You need to be logged in to leave comments. Login now