##// END OF EJS Templates
Queries can be marked as 'For all projects'. Such queries will be available on all projects and on the global issue list (#897, closes #671)....
Jean-Philippe Lang -
r1296:287d86e36325
parent child
Show More
@@ -0,0 +1,185
1 # redMine - project management software
2 # Copyright (C) 2006-2008 Jean-Philippe Lang
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18 require File.dirname(__FILE__) + '/../test_helper'
19 require 'queries_controller'
20
21 # Re-raise errors caught by the controller.
22 class QueriesController; def rescue_action(e) raise e end; end
23
24 class QueriesControllerTest < Test::Unit::TestCase
25 fixtures :projects, :users, :members, :roles, :trackers, :issue_statuses, :issue_categories, :enumerations, :issues, :custom_fields, :custom_values, :queries
26
27 def setup
28 @controller = QueriesController.new
29 @request = ActionController::TestRequest.new
30 @response = ActionController::TestResponse.new
31 User.current = nil
32 end
33
34 def test_get_new
35 @request.session[:user_id] = 2
36 get :new, :project_id => 1
37 assert_response :success
38 assert_template 'new'
39 assert_tag :tag => 'input', :attributes => { :type => 'checkbox',
40 :name => 'query[is_public]',
41 :checked => nil }
42 assert_tag :tag => 'input', :attributes => { :type => 'checkbox',
43 :name => 'query_is_for_all',
44 :checked => nil,
45 :disabled => nil }
46 end
47
48 def test_new_project_public_query
49 @request.session[:user_id] = 2
50 post :new,
51 :project_id => 'ecookbook',
52 :confirm => '1',
53 :default_columns => '1',
54 :fields => ["status_id", "assigned_to_id"],
55 :operators => {"assigned_to_id" => "=", "status_id" => "o"},
56 :values => { "assigned_to_id" => ["1"], "status_id" => ["1"]},
57 :query => {"name" => "test_new_project_public_query", "is_public" => "1"},
58 :column_names => ["", "tracker", "status", "priority", "subject", "updated_on", "category"]
59
60 q = Query.find_by_name('test_new_project_public_query')
61 assert_redirected_to :controller => 'issues', :action => 'index', :query_id => q
62 assert q.is_public?
63 assert q.has_default_columns?
64 assert q.valid?
65 end
66
67 def test_new_project_private_query
68 @request.session[:user_id] = 3
69 post :new,
70 :project_id => 'ecookbook',
71 :confirm => '1',
72 :default_columns => '1',
73 :fields => ["status_id", "assigned_to_id"],
74 :operators => {"assigned_to_id" => "=", "status_id" => "o"},
75 :values => { "assigned_to_id" => ["1"], "status_id" => ["1"]},
76 :query => {"name" => "test_new_project_private_query", "is_public" => "1"},
77 :column_names => ["", "tracker", "status", "priority", "subject", "updated_on", "category"]
78
79 q = Query.find_by_name('test_new_project_private_query')
80 assert_redirected_to :controller => 'issues', :action => 'index', :query_id => q
81 assert !q.is_public?
82 assert q.has_default_columns?
83 assert q.valid?
84 end
85
86 def test_get_edit_global_public_query
87 @request.session[:user_id] = 1
88 get :edit, :id => 4
89 assert_response :success
90 assert_template 'edit'
91 assert_tag :tag => 'input', :attributes => { :type => 'checkbox',
92 :name => 'query[is_public]',
93 :checked => 'checked' }
94 assert_tag :tag => 'input', :attributes => { :type => 'checkbox',
95 :name => 'query_is_for_all',
96 :checked => 'checked',
97 :disabled => 'disabled' }
98 end
99
100 def test_edit_global_public_query
101 @request.session[:user_id] = 1
102 post :edit,
103 :id => 4,
104 :confirm => '1',
105 :default_columns => '1',
106 :fields => ["status_id", "assigned_to_id"],
107 :operators => {"assigned_to_id" => "=", "status_id" => "o"},
108 :values => { "assigned_to_id" => ["1"], "status_id" => ["1"]},
109 :query => {"name" => "test_edit_global_public_query", "is_public" => "1"},
110 :column_names => ["", "tracker", "status", "priority", "subject", "updated_on", "category"]
111
112 assert_redirected_to :controller => 'issues', :action => 'index', :query_id => 4
113 q = Query.find_by_name('test_edit_global_public_query')
114 assert q.is_public?
115 assert q.has_default_columns?
116 assert q.valid?
117 end
118
119 def test_get_edit_global_private_query
120 @request.session[:user_id] = 3
121 get :edit, :id => 3
122 assert_response :success
123 assert_template 'edit'
124 assert_no_tag :tag => 'input', :attributes => { :type => 'checkbox',
125 :name => 'query[is_public]' }
126 assert_tag :tag => 'input', :attributes => { :type => 'checkbox',
127 :name => 'query_is_for_all',
128 :checked => 'checked',
129 :disabled => 'disabled' }
130 end
131
132 def test_edit_global_private_query
133 @request.session[:user_id] = 3
134 post :edit,
135 :id => 3,
136 :confirm => '1',
137 :default_columns => '1',
138 :fields => ["status_id", "assigned_to_id"],
139 :operators => {"assigned_to_id" => "=", "status_id" => "o"},
140 :values => { "assigned_to_id" => ["me"], "status_id" => ["1"]},
141 :query => {"name" => "test_edit_global_private_query", "is_public" => "1"},
142 :column_names => ["", "tracker", "status", "priority", "subject", "updated_on", "category"]
143
144 assert_redirected_to :controller => 'issues', :action => 'index', :query_id => 3
145 q = Query.find_by_name('test_edit_global_private_query')
146 assert !q.is_public?
147 assert q.has_default_columns?
148 assert q.valid?
149 end
150
151 def test_get_edit_project_private_query
152 @request.session[:user_id] = 3
153 get :edit, :id => 2
154 assert_response :success
155 assert_template 'edit'
156 assert_no_tag :tag => 'input', :attributes => { :type => 'checkbox',
157 :name => 'query[is_public]' }
158 assert_tag :tag => 'input', :attributes => { :type => 'checkbox',
159 :name => 'query_is_for_all',
160 :checked => nil,
161 :disabled => nil }
162 end
163
164 def test_get_edit_project_public_query
165 @request.session[:user_id] = 2
166 get :edit, :id => 1
167 assert_response :success
168 assert_template 'edit'
169 assert_tag :tag => 'input', :attributes => { :type => 'checkbox',
170 :name => 'query[is_public]',
171 :checked => 'checked'
172 }
173 assert_tag :tag => 'input', :attributes => { :type => 'checkbox',
174 :name => 'query_is_for_all',
175 :checked => nil,
176 :disabled => 'disabled' }
177 end
178
179 def test_destroy
180 @request.session[:user_id] = 2
181 post :destroy, :id => 1
182 assert_redirected_to :controller => 'issues', :action => 'index', :project_id => 'ecookbook', :set_filter => 1, :query_id => nil
183 assert_nil Query.find_by_id(1)
184 end
185 end
@@ -1,410 +1,417
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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 IssuesController < ApplicationController
19 19 layout 'base'
20 20 menu_item :new_issue, :only => :new
21 21
22 22 before_filter :find_issue, :only => [:show, :edit, :destroy_attachment]
23 23 before_filter :find_issues, :only => [:bulk_edit, :move, :destroy]
24 24 before_filter :find_project, :only => [:new, :update_form, :preview]
25 25 before_filter :authorize, :except => [:index, :changes, :preview, :update_form, :context_menu]
26 26 before_filter :find_optional_project, :only => [:index, :changes]
27 27 accept_key_auth :index, :changes
28 28
29 29 helper :journals
30 30 helper :projects
31 31 include ProjectsHelper
32 32 helper :custom_fields
33 33 include CustomFieldsHelper
34 34 helper :ifpdf
35 35 include IfpdfHelper
36 36 helper :issue_relations
37 37 include IssueRelationsHelper
38 38 helper :watchers
39 39 include WatchersHelper
40 40 helper :attachments
41 41 include AttachmentsHelper
42 42 helper :queries
43 43 helper :sort
44 44 include SortHelper
45 45 include IssuesHelper
46 46
47 47 def index
48 48 sort_init "#{Issue.table_name}.id", "desc"
49 49 sort_update
50 50 retrieve_query
51 51 if @query.valid?
52 52 limit = per_page_option
53 53 respond_to do |format|
54 54 format.html { }
55 55 format.atom { }
56 56 format.csv { limit = Setting.issues_export_limit.to_i }
57 57 format.pdf { limit = Setting.issues_export_limit.to_i }
58 58 end
59 59 @issue_count = Issue.count(:include => [:status, :project], :conditions => @query.statement)
60 60 @issue_pages = Paginator.new self, @issue_count, limit, params['page']
61 61 @issues = Issue.find :all, :order => sort_clause,
62 62 :include => [ :assigned_to, :status, :tracker, :project, :priority, :category, :fixed_version ],
63 63 :conditions => @query.statement,
64 64 :limit => limit,
65 65 :offset => @issue_pages.current.offset
66 66 respond_to do |format|
67 67 format.html { render :template => 'issues/index.rhtml', :layout => !request.xhr? }
68 68 format.atom { render_feed(@issues, :title => l(:label_issue_plural)) }
69 69 format.csv { send_data(issues_to_csv(@issues, @project).read, :type => 'text/csv; header=present', :filename => 'export.csv') }
70 70 format.pdf { send_data(render(:template => 'issues/index.rfpdf', :layout => false), :type => 'application/pdf', :filename => 'export.pdf') }
71 71 end
72 72 else
73 73 # Send html if the query is not valid
74 74 render(:template => 'issues/index.rhtml', :layout => !request.xhr?)
75 75 end
76 rescue ActiveRecord::RecordNotFound
77 render_404
76 78 end
77 79
78 80 def changes
79 81 sort_init "#{Issue.table_name}.id", "desc"
80 82 sort_update
81 83 retrieve_query
82 84 if @query.valid?
83 85 @journals = Journal.find :all, :include => [ :details, :user, {:issue => [:project, :author, :tracker, :status]} ],
84 86 :conditions => @query.statement,
85 87 :limit => 25,
86 88 :order => "#{Journal.table_name}.created_on DESC"
87 89 end
88 90 @title = (@project ? @project.name : Setting.app_title) + ": " + (@query.new_record? ? l(:label_changes_details) : @query.name)
89 91 render :layout => false, :content_type => 'application/atom+xml'
92 rescue ActiveRecord::RecordNotFound
93 render_404
90 94 end
91 95
92 96 def show
93 97 @custom_values = @project.custom_fields_for_issues(@issue.tracker).collect { |x| @issue.custom_values.find_by_custom_field_id(x.id) || CustomValue.new(:custom_field => x, :customized => @issue) }
94 98 @journals = @issue.journals.find(:all, :include => [:user, :details], :order => "#{Journal.table_name}.created_on ASC")
95 99 @journals.each_with_index {|j,i| j.indice = i+1}
96 100 @journals.reverse! if User.current.wants_comments_in_reverse_order?
97 101 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
98 102 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
99 103 @activities = Enumeration::get_values('ACTI')
100 104 @priorities = Enumeration::get_values('IPRI')
101 105 respond_to do |format|
102 106 format.html { render :template => 'issues/show.rhtml' }
103 107 format.atom { render :action => 'changes', :layout => false, :content_type => 'application/atom+xml' }
104 108 format.pdf { send_data(render(:template => 'issues/show.rfpdf', :layout => false), :type => 'application/pdf', :filename => "#{@project.identifier}-#{@issue.id}.pdf") }
105 109 end
106 110 end
107 111
108 112 # Add a new issue
109 113 # The new issue will be created from an existing one if copy_from parameter is given
110 114 def new
111 115 @issue = params[:copy_from] ? Issue.new.copy_from(params[:copy_from]) : Issue.new(params[:issue])
112 116 @issue.project = @project
113 117 @issue.author = User.current
114 118 @issue.tracker ||= @project.trackers.find(params[:tracker_id] ? params[:tracker_id] : :first)
115 119 if @issue.tracker.nil?
116 120 flash.now[:error] = 'No tracker is associated to this project. Please check the Project settings.'
117 121 render :nothing => true, :layout => true
118 122 return
119 123 end
120 124
121 125 default_status = IssueStatus.default
122 126 unless default_status
123 127 flash.now[:error] = 'No default issue status is defined. Please check your configuration (Go to "Administration -> Issue statuses").'
124 128 render :nothing => true, :layout => true
125 129 return
126 130 end
127 131 @issue.status = default_status
128 132 @allowed_statuses = ([default_status] + default_status.find_new_statuses_allowed_to(User.current.role_for_project(@project), @issue.tracker)).uniq
129 133
130 134 if request.get? || request.xhr?
131 135 @issue.start_date ||= Date.today
132 136 @custom_values = @issue.custom_values.empty? ?
133 137 @project.custom_fields_for_issues(@issue.tracker).collect { |x| CustomValue.new(:custom_field => x, :customized => @issue) } :
134 138 @issue.custom_values
135 139 else
136 140 requested_status = IssueStatus.find_by_id(params[:issue][:status_id])
137 141 # Check that the user is allowed to apply the requested status
138 142 @issue.status = (@allowed_statuses.include? requested_status) ? requested_status : default_status
139 143 @custom_values = @project.custom_fields_for_issues(@issue.tracker).collect { |x| CustomValue.new(:custom_field => x, :customized => @issue, :value => params["custom_fields"][x.id.to_s]) }
140 144 @issue.custom_values = @custom_values
141 145 if @issue.save
142 146 attach_files(@issue, params[:attachments])
143 147 flash[:notice] = l(:notice_successful_create)
144 148 Mailer.deliver_issue_add(@issue) if Setting.notified_events.include?('issue_added')
145 149 redirect_to :controller => 'issues', :action => 'show', :id => @issue, :project_id => @project
146 150 return
147 151 end
148 152 end
149 153 @priorities = Enumeration::get_values('IPRI')
150 154 render :layout => !request.xhr?
151 155 end
152 156
153 157 # Attributes that can be updated on workflow transition (without :edit permission)
154 158 # TODO: make it configurable (at least per role)
155 159 UPDATABLE_ATTRS_ON_TRANSITION = %w(status_id assigned_to_id fixed_version_id done_ratio) unless const_defined?(:UPDATABLE_ATTRS_ON_TRANSITION)
156 160
157 161 def edit
158 162 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
159 163 @activities = Enumeration::get_values('ACTI')
160 164 @priorities = Enumeration::get_values('IPRI')
161 165 @custom_values = []
162 166 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
163 167
164 168 @notes = params[:notes]
165 169 journal = @issue.init_journal(User.current, @notes)
166 170 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
167 171 if (@edit_allowed || !@allowed_statuses.empty?) && params[:issue]
168 172 attrs = params[:issue].dup
169 173 attrs.delete_if {|k,v| !UPDATABLE_ATTRS_ON_TRANSITION.include?(k) } unless @edit_allowed
170 174 attrs.delete(:status_id) unless @allowed_statuses.detect {|s| s.id.to_s == attrs[:status_id].to_s}
171 175 @issue.attributes = attrs
172 176 end
173 177
174 178 if request.get?
175 179 @custom_values = @project.custom_fields_for_issues(@issue.tracker).collect { |x| @issue.custom_values.find_by_custom_field_id(x.id) || CustomValue.new(:custom_field => x, :customized => @issue) }
176 180 else
177 181 # Update custom fields if user has :edit permission
178 182 if @edit_allowed && params[:custom_fields]
179 183 @custom_values = @project.custom_fields_for_issues(@issue.tracker).collect { |x| CustomValue.new(:custom_field => x, :customized => @issue, :value => params["custom_fields"][x.id.to_s]) }
180 184 @issue.custom_values = @custom_values
181 185 end
182 186 attachments = attach_files(@issue, params[:attachments])
183 187 attachments.each {|a| journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
184 188 if @issue.save
185 189 # Log spend time
186 190 if current_role.allowed_to?(:log_time)
187 191 @time_entry = TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => Date.today)
188 192 @time_entry.attributes = params[:time_entry]
189 193 @time_entry.save
190 194 end
191 195 if !journal.new_record?
192 196 # Only send notification if something was actually changed
193 197 flash[:notice] = l(:notice_successful_update)
194 198 Mailer.deliver_issue_edit(journal) if Setting.notified_events.include?('issue_updated')
195 199 end
196 200 redirect_to(params[:back_to] || {:action => 'show', :id => @issue})
197 201 end
198 202 end
199 203 rescue ActiveRecord::StaleObjectError
200 204 # Optimistic locking exception
201 205 flash.now[:error] = l(:notice_locking_conflict)
202 206 end
203 207
204 208 # Bulk edit a set of issues
205 209 def bulk_edit
206 210 if request.post?
207 211 status = params[:status_id].blank? ? nil : IssueStatus.find_by_id(params[:status_id])
208 212 priority = params[:priority_id].blank? ? nil : Enumeration.find_by_id(params[:priority_id])
209 213 assigned_to = (params[:assigned_to_id].blank? || params[:assigned_to_id] == 'none') ? nil : User.find_by_id(params[:assigned_to_id])
210 214 category = (params[:category_id].blank? || params[:category_id] == 'none') ? nil : @project.issue_categories.find_by_id(params[:category_id])
211 215 fixed_version = (params[:fixed_version_id].blank? || params[:fixed_version_id] == 'none') ? nil : @project.versions.find_by_id(params[:fixed_version_id])
212 216
213 217 unsaved_issue_ids = []
214 218 @issues.each do |issue|
215 219 journal = issue.init_journal(User.current, params[:notes])
216 220 issue.priority = priority if priority
217 221 issue.assigned_to = assigned_to if assigned_to || params[:assigned_to_id] == 'none'
218 222 issue.category = category if category || params[:category_id] == 'none'
219 223 issue.fixed_version = fixed_version if fixed_version || params[:fixed_version_id] == 'none'
220 224 issue.start_date = params[:start_date] unless params[:start_date].blank?
221 225 issue.due_date = params[:due_date] unless params[:due_date].blank?
222 226 issue.done_ratio = params[:done_ratio] unless params[:done_ratio].blank?
223 227 # Don't save any change to the issue if the user is not authorized to apply the requested status
224 228 if (status.nil? || (issue.status.new_status_allowed_to?(status, current_role, issue.tracker) && issue.status = status)) && issue.save
225 229 # Send notification for each issue (if changed)
226 230 Mailer.deliver_issue_edit(journal) if journal.details.any? && Setting.notified_events.include?('issue_updated')
227 231 else
228 232 # Keep unsaved issue ids to display them in flash error
229 233 unsaved_issue_ids << issue.id
230 234 end
231 235 end
232 236 if unsaved_issue_ids.empty?
233 237 flash[:notice] = l(:notice_successful_update) unless @issues.empty?
234 238 else
235 239 flash[:error] = l(:notice_failed_to_save_issues, unsaved_issue_ids.size, @issues.size, '#' + unsaved_issue_ids.join(', #'))
236 240 end
237 241 redirect_to :controller => 'issues', :action => 'index', :project_id => @project
238 242 return
239 243 end
240 244 # Find potential statuses the user could be allowed to switch issues to
241 245 @available_statuses = Workflow.find(:all, :include => :new_status,
242 246 :conditions => {:role_id => current_role.id}).collect(&:new_status).compact.uniq
243 247 end
244 248
245 249 def move
246 250 @allowed_projects = []
247 251 # find projects to which the user is allowed to move the issue
248 252 if User.current.admin?
249 253 # admin is allowed to move issues to any active (visible) project
250 254 @allowed_projects = Project.find(:all, :conditions => Project.visible_by(User.current), :order => 'name')
251 255 else
252 256 User.current.memberships.each {|m| @allowed_projects << m.project if m.role.allowed_to?(:move_issues)}
253 257 end
254 258 @target_project = @allowed_projects.detect {|p| p.id.to_s == params[:new_project_id]} if params[:new_project_id]
255 259 @target_project ||= @project
256 260 @trackers = @target_project.trackers
257 261 if request.post?
258 262 new_tracker = params[:new_tracker_id].blank? ? nil : @target_project.trackers.find_by_id(params[:new_tracker_id])
259 263 unsaved_issue_ids = []
260 264 @issues.each do |issue|
261 265 unsaved_issue_ids << issue.id unless issue.move_to(@target_project, new_tracker)
262 266 end
263 267 if unsaved_issue_ids.empty?
264 268 flash[:notice] = l(:notice_successful_update) unless @issues.empty?
265 269 else
266 270 flash[:error] = l(:notice_failed_to_save_issues, unsaved_issue_ids.size, @issues.size, '#' + unsaved_issue_ids.join(', #'))
267 271 end
268 272 redirect_to :controller => 'issues', :action => 'index', :project_id => @project
269 273 return
270 274 end
271 275 render :layout => false if request.xhr?
272 276 end
273 277
274 278 def destroy
275 279 @hours = TimeEntry.sum(:hours, :conditions => ['issue_id IN (?)', @issues]).to_f
276 280 if @hours > 0
277 281 case params[:todo]
278 282 when 'destroy'
279 283 # nothing to do
280 284 when 'nullify'
281 285 TimeEntry.update_all('issue_id = NULL', ['issue_id IN (?)', @issues])
282 286 when 'reassign'
283 287 reassign_to = @project.issues.find_by_id(params[:reassign_to_id])
284 288 if reassign_to.nil?
285 289 flash.now[:error] = l(:error_issue_not_found_in_project)
286 290 return
287 291 else
288 292 TimeEntry.update_all("issue_id = #{reassign_to.id}", ['issue_id IN (?)', @issues])
289 293 end
290 294 else
291 295 # display the destroy form
292 296 return
293 297 end
294 298 end
295 299 @issues.each(&:destroy)
296 300 redirect_to :action => 'index', :project_id => @project
297 301 end
298 302
299 303 def destroy_attachment
300 304 a = @issue.attachments.find(params[:attachment_id])
301 305 a.destroy
302 306 journal = @issue.init_journal(User.current)
303 307 journal.details << JournalDetail.new(:property => 'attachment',
304 308 :prop_key => a.id,
305 309 :old_value => a.filename)
306 310 journal.save
307 311 redirect_to :action => 'show', :id => @issue
308 312 end
309 313
310 314 def context_menu
311 315 @issues = Issue.find_all_by_id(params[:ids], :include => :project)
312 316 if (@issues.size == 1)
313 317 @issue = @issues.first
314 318 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
315 319 @assignables = @issue.assignable_users
316 320 @assignables << @issue.assigned_to if @issue.assigned_to && !@assignables.include?(@issue.assigned_to)
317 321 end
318 322 projects = @issues.collect(&:project).compact.uniq
319 323 @project = projects.first if projects.size == 1
320 324
321 325 @can = {:edit => (@project && User.current.allowed_to?(:edit_issues, @project)),
322 326 :update => (@issue && (User.current.allowed_to?(:edit_issues, @project) || (User.current.allowed_to?(:change_status, @project) && !@allowed_statuses.empty?))),
323 327 :move => (@project && User.current.allowed_to?(:move_issues, @project)),
324 328 :copy => (@issue && @project.trackers.include?(@issue.tracker) && User.current.allowed_to?(:add_issues, @project)),
325 329 :delete => (@project && User.current.allowed_to?(:delete_issues, @project))
326 330 }
327 331
328 332 @priorities = Enumeration.get_values('IPRI').reverse
329 333 @statuses = IssueStatus.find(:all, :order => 'position')
330 334 @back = request.env['HTTP_REFERER']
331 335
332 336 render :layout => false
333 337 end
334 338
335 339 def update_form
336 340 @issue = Issue.new(params[:issue])
337 341 render :action => :new, :layout => false
338 342 end
339 343
340 344 def preview
341 345 issue = @project.issues.find_by_id(params[:id])
342 346 @attachements = issue.attachments if issue
343 347 @text = params[:notes] || (params[:issue] ? params[:issue][:description] : nil)
344 348 render :partial => 'common/preview'
345 349 end
346 350
347 351 private
348 352 def find_issue
349 353 @issue = Issue.find(params[:id], :include => [:project, :tracker, :status, :author, :priority, :category])
350 354 @project = @issue.project
351 355 rescue ActiveRecord::RecordNotFound
352 356 render_404
353 357 end
354 358
355 359 # Filter for bulk operations
356 360 def find_issues
357 361 @issues = Issue.find_all_by_id(params[:id] || params[:ids])
358 362 raise ActiveRecord::RecordNotFound if @issues.empty?
359 363 projects = @issues.collect(&:project).compact.uniq
360 364 if projects.size == 1
361 365 @project = projects.first
362 366 else
363 367 # TODO: let users bulk edit/move/destroy issues from different projects
364 368 render_error 'Can not bulk edit/move/destroy issues from different projects' and return false
365 369 end
366 370 rescue ActiveRecord::RecordNotFound
367 371 render_404
368 372 end
369 373
370 374 def find_project
371 375 @project = Project.find(params[:project_id])
372 376 rescue ActiveRecord::RecordNotFound
373 377 render_404
374 378 end
375 379
376 380 def find_optional_project
377 381 return true unless params[:project_id]
378 382 @project = Project.find(params[:project_id])
379 383 authorize
380 384 rescue ActiveRecord::RecordNotFound
381 385 render_404
382 386 end
383 387
384 388 # Retrieve query from session or build a new query
385 389 def retrieve_query
386 390 if !params[:query_id].blank?
387 @query = Query.find(params[:query_id], :conditions => {:project_id => (@project ? @project.id : nil)})
391 cond = "project_id IS NULL"
392 cond << " OR project_id = #{@project.id}" if @project
393 @query = Query.find(params[:query_id], :conditions => cond)
394 @query.project = @project
388 395 session[:query] = {:id => @query.id, :project_id => @query.project_id}
389 396 else
390 397 if params[:set_filter] || session[:query].nil? || session[:query][:project_id] != (@project ? @project.id : nil)
391 398 # Give it a name, required to be valid
392 399 @query = Query.new(:name => "_")
393 400 @query.project = @project
394 401 if params[:fields] and params[:fields].is_a? Array
395 402 params[:fields].each do |field|
396 403 @query.add_filter(field, params[:operators][field], params[:values][field])
397 404 end
398 405 else
399 406 @query.available_filters.keys.each do |field|
400 407 @query.add_short_filter(field, params[field]) if params[field]
401 408 end
402 409 end
403 410 session[:query] = {:project_id => @query.project_id, :filters => @query.filters}
404 411 else
405 412 @query = Query.find_by_id(session[:query][:id]) if session[:query][:id]
406 413 @query ||= Query.new(:name => "_", :project => @project, :filters => session[:query][:filters])
407 414 end
408 415 end
409 416 end
410 417 end
@@ -1,82 +1,80
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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 QueriesController < ApplicationController
19 19 layout 'base'
20 20 menu_item :issues
21 before_filter :find_project, :authorize
22
23 def index
24 @queries = @project.queries.find(:all,
25 :order => "name ASC",
26 :conditions => ["is_public = ? or user_id = ?", true, (User.current.logged? ? User.current.id : 0)])
27 end
21 before_filter :find_query, :except => :new
22 before_filter :find_project, :authorize, :only => :new
28 23
29 24 def new
30 25 @query = Query.new(params[:query])
31 @query.project = @project
26 @query.project = params[:query_is_for_all] ? nil : @project
32 27 @query.user = User.current
33 @query.is_public = false unless current_role.allowed_to?(:manage_public_queries)
28 @query.is_public = false unless (@query.project && current_role.allowed_to?(:manage_public_queries)) || User.current.admin?
34 29 @query.column_names = nil if params[:default_columns]
35 30
36 31 params[:fields].each do |field|
37 32 @query.add_filter(field, params[:operators][field], params[:values][field])
38 33 end if params[:fields]
39 34
40 35 if request.post? && params[:confirm] && @query.save
41 36 flash[:notice] = l(:notice_successful_create)
42 37 redirect_to :controller => 'issues', :action => 'index', :project_id => @project, :query_id => @query
43 38 return
44 39 end
45 40 render :layout => false if request.xhr?
46 41 end
47 42
48 43 def edit
49 44 if request.post?
50 45 @query.filters = {}
51 46 params[:fields].each do |field|
52 47 @query.add_filter(field, params[:operators][field], params[:values][field])
53 48 end if params[:fields]
54 49 @query.attributes = params[:query]
55 @query.is_public = false unless current_role.allowed_to?(:manage_public_queries)
50 @query.project = nil if params[:query_is_for_all]
51 @query.is_public = false unless (@query.project && current_role.allowed_to?(:manage_public_queries)) || User.current.admin?
56 52 @query.column_names = nil if params[:default_columns]
57 53
58 54 if @query.save
59 55 flash[:notice] = l(:notice_successful_update)
60 56 redirect_to :controller => 'issues', :action => 'index', :project_id => @project, :query_id => @query
61 57 end
62 58 end
63 59 end
64 60
65 61 def destroy
66 62 @query.destroy if request.post?
67 redirect_to :controller => 'queries', :project_id => @project
63 redirect_to :controller => 'issues', :action => 'index', :project_id => @project, :set_filter => 1
68 64 end
69 65
70 66 private
67 def find_query
68 @query = Query.find(params[:id])
69 @project = @query.project
70 render_403 unless @query.editable_by?(User.current)
71 rescue ActiveRecord::RecordNotFound
72 render_404
73 end
74
71 75 def find_project
72 if params[:id]
73 @query = Query.find(params[:id])
74 @project = @query.project
75 render_403 unless @query.editable_by?(User.current)
76 else
77 @project = Project.find(params[:project_id])
78 end
76 @project = Project.find(params[:project_id])
79 77 rescue ActiveRecord::RecordNotFound
80 78 render_404
81 79 end
82 80 end
@@ -1,164 +1,177
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 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 'csv'
19 19
20 20 module IssuesHelper
21 21 include ApplicationHelper
22 22
23 23 def render_issue_tooltip(issue)
24 24 @cached_label_start_date ||= l(:field_start_date)
25 25 @cached_label_due_date ||= l(:field_due_date)
26 26 @cached_label_assigned_to ||= l(:field_assigned_to)
27 27 @cached_label_priority ||= l(:field_priority)
28 28
29 29 link_to_issue(issue) + ": #{h(issue.subject)}<br /><br />" +
30 30 "<strong>#{@cached_label_start_date}</strong>: #{format_date(issue.start_date)}<br />" +
31 31 "<strong>#{@cached_label_due_date}</strong>: #{format_date(issue.due_date)}<br />" +
32 32 "<strong>#{@cached_label_assigned_to}</strong>: #{issue.assigned_to}<br />" +
33 33 "<strong>#{@cached_label_priority}</strong>: #{issue.priority.name}"
34 34 end
35
36 def sidebar_queries
37 unless @sidebar_queries
38 # User can see public queries and his own queries
39 visible = ARCondition.new(["is_public = ? OR user_id = ?", true, (User.current.logged? ? User.current.id : 0)])
40 # Project specific queries and global queries
41 visible << (@project.nil? ? ["project_id IS NULL"] : ["project_id IS NULL OR project_id = ?", @project.id])
42 @sidebar_queries = Query.find(:all,
43 :order => "name ASC",
44 :conditions => visible.conditions)
45 end
46 @sidebar_queries
47 end
35 48
36 49 def show_detail(detail, no_html=false)
37 50 case detail.property
38 51 when 'attr'
39 52 label = l(("field_" + detail.prop_key.to_s.gsub(/\_id$/, "")).to_sym)
40 53 case detail.prop_key
41 54 when 'due_date', 'start_date'
42 55 value = format_date(detail.value.to_date) if detail.value
43 56 old_value = format_date(detail.old_value.to_date) if detail.old_value
44 57 when 'status_id'
45 58 s = IssueStatus.find_by_id(detail.value) and value = s.name if detail.value
46 59 s = IssueStatus.find_by_id(detail.old_value) and old_value = s.name if detail.old_value
47 60 when 'assigned_to_id'
48 61 u = User.find_by_id(detail.value) and value = u.name if detail.value
49 62 u = User.find_by_id(detail.old_value) and old_value = u.name if detail.old_value
50 63 when 'priority_id'
51 64 e = Enumeration.find_by_id(detail.value) and value = e.name if detail.value
52 65 e = Enumeration.find_by_id(detail.old_value) and old_value = e.name if detail.old_value
53 66 when 'category_id'
54 67 c = IssueCategory.find_by_id(detail.value) and value = c.name if detail.value
55 68 c = IssueCategory.find_by_id(detail.old_value) and old_value = c.name if detail.old_value
56 69 when 'fixed_version_id'
57 70 v = Version.find_by_id(detail.value) and value = v.name if detail.value
58 71 v = Version.find_by_id(detail.old_value) and old_value = v.name if detail.old_value
59 72 end
60 73 when 'cf'
61 74 custom_field = CustomField.find_by_id(detail.prop_key)
62 75 if custom_field
63 76 label = custom_field.name
64 77 value = format_value(detail.value, custom_field.field_format) if detail.value
65 78 old_value = format_value(detail.old_value, custom_field.field_format) if detail.old_value
66 79 end
67 80 when 'attachment'
68 81 label = l(:label_attachment)
69 82 end
70 83
71 84 label ||= detail.prop_key
72 85 value ||= detail.value
73 86 old_value ||= detail.old_value
74 87
75 88 unless no_html
76 89 label = content_tag('strong', label)
77 90 old_value = content_tag("i", h(old_value)) if detail.old_value
78 91 old_value = content_tag("strike", old_value) if detail.old_value and (!detail.value or detail.value.empty?)
79 92 if detail.property == 'attachment' && !value.blank? && Attachment.find_by_id(detail.prop_key)
80 93 # Link to the attachment if it has not been removed
81 94 value = link_to(value, :controller => 'attachments', :action => 'download', :id => detail.prop_key)
82 95 else
83 96 value = content_tag("i", h(value)) if value
84 97 end
85 98 end
86 99
87 100 if !detail.value.blank?
88 101 case detail.property
89 102 when 'attr', 'cf'
90 103 if !detail.old_value.blank?
91 104 label + " " + l(:text_journal_changed, old_value, value)
92 105 else
93 106 label + " " + l(:text_journal_set_to, value)
94 107 end
95 108 when 'attachment'
96 109 "#{label} #{value} #{l(:label_added)}"
97 110 end
98 111 else
99 112 case detail.property
100 113 when 'attr', 'cf'
101 114 label + " " + l(:text_journal_deleted) + " (#{old_value})"
102 115 when 'attachment'
103 116 "#{label} #{old_value} #{l(:label_deleted)}"
104 117 end
105 118 end
106 119 end
107 120
108 121 def issues_to_csv(issues, project = nil)
109 122 ic = Iconv.new(l(:general_csv_encoding), 'UTF-8')
110 123 export = StringIO.new
111 124 CSV::Writer.generate(export, l(:general_csv_separator)) do |csv|
112 125 # csv header fields
113 126 headers = [ "#",
114 127 l(:field_status),
115 128 l(:field_project),
116 129 l(:field_tracker),
117 130 l(:field_priority),
118 131 l(:field_subject),
119 132 l(:field_assigned_to),
120 133 l(:field_category),
121 134 l(:field_fixed_version),
122 135 l(:field_author),
123 136 l(:field_start_date),
124 137 l(:field_due_date),
125 138 l(:field_done_ratio),
126 139 l(:field_estimated_hours),
127 140 l(:field_created_on),
128 141 l(:field_updated_on)
129 142 ]
130 143 # Export project custom fields if project is given
131 144 # otherwise export custom fields marked as "For all projects"
132 145 custom_fields = project.nil? ? IssueCustomField.for_all : project.all_custom_fields
133 146 custom_fields.each {|f| headers << f.name}
134 147 # Description in the last column
135 148 headers << l(:field_description)
136 149 csv << headers.collect {|c| begin; ic.iconv(c.to_s); rescue; c.to_s; end }
137 150 # csv lines
138 151 issues.each do |issue|
139 152 fields = [issue.id,
140 153 issue.status.name,
141 154 issue.project.name,
142 155 issue.tracker.name,
143 156 issue.priority.name,
144 157 issue.subject,
145 158 issue.assigned_to,
146 159 issue.category,
147 160 issue.fixed_version,
148 161 issue.author.name,
149 162 format_date(issue.start_date),
150 163 format_date(issue.due_date),
151 164 issue.done_ratio,
152 165 issue.estimated_hours,
153 166 format_time(issue.created_on),
154 167 format_time(issue.updated_on)
155 168 ]
156 169 custom_fields.each {|f| fields << show_value(issue.custom_value_for(f)) }
157 170 fields << issue.description
158 171 csv << fields.collect {|c| begin; ic.iconv(c.to_s); rescue; c.to_s; end }
159 172 end
160 173 end
161 174 export.rewind
162 175 export
163 176 end
164 177 end
@@ -1,361 +1,368
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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, :default_order
20 20 include GLoc
21 21
22 22 def initialize(name, options={})
23 23 self.name = name
24 24 self.sortable = options[:sortable]
25 25 self.default_order = options[:default_order]
26 26 end
27 27
28 28 def caption
29 29 set_language_if_valid(User.current.language)
30 30 l("field_#{name}")
31 31 end
32 32 end
33 33
34 34 class QueryCustomFieldColumn < QueryColumn
35 35
36 36 def initialize(custom_field)
37 37 self.name = "cf_#{custom_field.id}".to_sym
38 38 self.sortable = false
39 39 @cf = custom_field
40 40 end
41 41
42 42 def caption
43 43 @cf.name
44 44 end
45 45
46 46 def custom_field
47 47 @cf
48 48 end
49 49 end
50 50
51 51 class Query < ActiveRecord::Base
52 52 belongs_to :project
53 53 belongs_to :user
54 54 serialize :filters
55 55 serialize :column_names
56 56
57 57 attr_protected :project_id, :user_id
58 58
59 59 validates_presence_of :name, :on => :save
60 60 validates_length_of :name, :maximum => 255
61 61
62 62 @@operators = { "=" => :label_equals,
63 63 "!" => :label_not_equals,
64 64 "o" => :label_open_issues,
65 65 "c" => :label_closed_issues,
66 66 "!*" => :label_none,
67 67 "*" => :label_all,
68 68 ">=" => '>=',
69 69 "<=" => '<=',
70 70 "<t+" => :label_in_less_than,
71 71 ">t+" => :label_in_more_than,
72 72 "t+" => :label_in,
73 73 "t" => :label_today,
74 74 "w" => :label_this_week,
75 75 ">t-" => :label_less_than_ago,
76 76 "<t-" => :label_more_than_ago,
77 77 "t-" => :label_ago,
78 78 "~" => :label_contains,
79 79 "!~" => :label_not_contains }
80 80
81 81 cattr_reader :operators
82 82
83 83 @@operators_by_filter_type = { :list => [ "=", "!" ],
84 84 :list_status => [ "o", "=", "!", "c", "*" ],
85 85 :list_optional => [ "=", "!", "!*", "*" ],
86 86 :list_subprojects => [ "*", "!*", "=" ],
87 87 :date => [ "<t+", ">t+", "t+", "t", "w", ">t-", "<t-", "t-" ],
88 88 :date_past => [ ">t-", "<t-", "t-", "t", "w" ],
89 89 :string => [ "=", "~", "!", "!~" ],
90 90 :text => [ "~", "!~" ],
91 91 :integer => [ "=", ">=", "<=" ] }
92 92
93 93 cattr_reader :operators_by_filter_type
94 94
95 95 @@available_columns = [
96 96 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position"),
97 97 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position"),
98 98 QueryColumn.new(:priority, :sortable => "#{Enumeration.table_name}.position", :default_order => 'desc'),
99 99 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
100 100 QueryColumn.new(:author),
101 101 QueryColumn.new(:assigned_to, :sortable => "#{User.table_name}.lastname"),
102 102 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
103 103 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name"),
104 104 QueryColumn.new(:fixed_version, :sortable => "#{Version.table_name}.effective_date", :default_order => 'desc'),
105 105 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
106 106 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
107 107 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
108 108 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio"),
109 109 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
110 110 ]
111 111 cattr_reader :available_columns
112 112
113 113 def initialize(attributes = nil)
114 114 super attributes
115 115 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
116 116 set_language_if_valid(User.current.language)
117 117 end
118 118
119 def after_initialize
120 # Store the fact that project is nil (used in #editable_by?)
121 @is_for_all = project.nil?
122 end
123
119 124 def validate
120 125 filters.each_key do |field|
121 126 errors.add label_for(field), :activerecord_error_blank unless
122 127 # filter requires one or more values
123 128 (values_for(field) and !values_for(field).first.empty?) or
124 129 # filter doesn't require any value
125 130 ["o", "c", "!*", "*", "t", "w"].include? operator_for(field)
126 131 end if filters
127 132 end
128 133
129 134 def editable_by?(user)
130 135 return false unless user
131 return true if !is_public && self.user_id == user.id
132 is_public && user.allowed_to?(:manage_public_queries, project)
136 # Admin can edit them all and regular users can edit their private queries
137 return true if user.admin? || (!is_public && self.user_id == user.id)
138 # Members can not edit public queries that are for all project (only admin is allowed to)
139 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
133 140 end
134 141
135 142 def available_filters
136 143 return @available_filters if @available_filters
137 144
138 145 trackers = project.nil? ? Tracker.find(:all, :order => 'position') : project.rolled_up_trackers
139 146
140 147 @available_filters = { "status_id" => { :type => :list_status, :order => 1, :values => IssueStatus.find(:all, :order => 'position').collect{|s| [s.name, s.id.to_s] } },
141 148 "tracker_id" => { :type => :list, :order => 2, :values => trackers.collect{|s| [s.name, s.id.to_s] } },
142 149 "priority_id" => { :type => :list, :order => 3, :values => Enumeration.find(:all, :conditions => ['opt=?','IPRI'], :order => 'position').collect{|s| [s.name, s.id.to_s] } },
143 150 "subject" => { :type => :text, :order => 8 },
144 151 "created_on" => { :type => :date_past, :order => 9 },
145 152 "updated_on" => { :type => :date_past, :order => 10 },
146 153 "start_date" => { :type => :date, :order => 11 },
147 154 "due_date" => { :type => :date, :order => 12 },
148 155 "done_ratio" => { :type => :integer, :order => 13 }}
149 156
150 157 user_values = []
151 158 user_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
152 159 if project
153 160 user_values += project.users.sort.collect{|s| [s.name, s.id.to_s] }
154 161 else
155 162 # members of the user's projects
156 163 user_values += User.current.projects.collect(&:users).flatten.uniq.sort.collect{|s| [s.name, s.id.to_s] }
157 164 end
158 165 @available_filters["assigned_to_id"] = { :type => :list_optional, :order => 4, :values => user_values } unless user_values.empty?
159 166 @available_filters["author_id"] = { :type => :list, :order => 5, :values => user_values } unless user_values.empty?
160 167
161 168 if project
162 169 # project specific filters
163 170 @available_filters["category_id"] = { :type => :list_optional, :order => 6, :values => @project.issue_categories.collect{|s| [s.name, s.id.to_s] } }
164 171 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => @project.versions.sort.collect{|s| [s.name, s.id.to_s] } }
165 172 unless @project.active_children.empty?
166 173 @available_filters["subproject_id"] = { :type => :list_subprojects, :order => 13, :values => @project.active_children.collect{|s| [s.name, s.id.to_s] } }
167 174 end
168 175 @project.all_custom_fields.select(&:is_filter?).each do |field|
169 176 case field.field_format
170 177 when "text"
171 178 options = { :type => :text, :order => 20 }
172 179 when "list"
173 180 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
174 181 when "date"
175 182 options = { :type => :date, :order => 20 }
176 183 when "bool"
177 184 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
178 185 else
179 186 options = { :type => :string, :order => 20 }
180 187 end
181 188 @available_filters["cf_#{field.id}"] = options.merge({ :name => field.name })
182 189 end
183 190 # remove category filter if no category defined
184 191 @available_filters.delete "category_id" if @available_filters["category_id"][:values].empty?
185 192 end
186 193 @available_filters
187 194 end
188 195
189 196 def add_filter(field, operator, values)
190 197 # values must be an array
191 198 return unless values and values.is_a? Array # and !values.first.empty?
192 199 # check if field is defined as an available filter
193 200 if available_filters.has_key? field
194 201 filter_options = available_filters[field]
195 202 # check if operator is allowed for that filter
196 203 #if @@operators_by_filter_type[filter_options[:type]].include? operator
197 204 # allowed_values = values & ([""] + (filter_options[:values] || []).collect {|val| val[1]})
198 205 # filters[field] = {:operator => operator, :values => allowed_values } if (allowed_values.first and !allowed_values.first.empty?) or ["o", "c", "!*", "*", "t"].include? operator
199 206 #end
200 207 filters[field] = {:operator => operator, :values => values }
201 208 end
202 209 end
203 210
204 211 def add_short_filter(field, expression)
205 212 return unless expression
206 213 parms = expression.scan(/^(o|c|\!|\*)?(.*)$/).first
207 214 add_filter field, (parms[0] || "="), [parms[1] || ""]
208 215 end
209 216
210 217 def has_filter?(field)
211 218 filters and filters[field]
212 219 end
213 220
214 221 def operator_for(field)
215 222 has_filter?(field) ? filters[field][:operator] : nil
216 223 end
217 224
218 225 def values_for(field)
219 226 has_filter?(field) ? filters[field][:values] : nil
220 227 end
221 228
222 229 def label_for(field)
223 230 label = @available_filters[field][:name] if @available_filters.has_key?(field)
224 231 label ||= field.gsub(/\_id$/, "")
225 232 end
226 233
227 234 def available_columns
228 235 return @available_columns if @available_columns
229 236 @available_columns = Query.available_columns
230 237 @available_columns += (project ?
231 238 project.all_custom_fields :
232 239 IssueCustomField.find(:all, :conditions => {:is_for_all => true})
233 240 ).collect {|cf| QueryCustomFieldColumn.new(cf) }
234 241 end
235 242
236 243 def columns
237 244 if has_default_columns?
238 245 available_columns.select {|c| Setting.issue_list_default_columns.include?(c.name.to_s) }
239 246 else
240 247 # preserve the column_names order
241 248 column_names.collect {|name| available_columns.find {|col| col.name == name}}.compact
242 249 end
243 250 end
244 251
245 252 def column_names=(names)
246 253 names = names.select {|n| n.is_a?(Symbol) || !n.blank? } if names
247 254 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym } if names
248 255 write_attribute(:column_names, names)
249 256 end
250 257
251 258 def has_column?(column)
252 259 column_names && column_names.include?(column.name)
253 260 end
254 261
255 262 def has_default_columns?
256 263 column_names.nil? || column_names.empty?
257 264 end
258 265
259 266 def statement
260 267 # project/subprojects clause
261 268 clause = ''
262 269 if project && !@project.active_children.empty?
263 270 ids = [project.id]
264 271 if has_filter?("subproject_id")
265 272 case operator_for("subproject_id")
266 273 when '='
267 274 # include the selected subprojects
268 275 ids += values_for("subproject_id").each(&:to_i)
269 276 when '!*'
270 277 # main project only
271 278 else
272 279 # all subprojects
273 280 ids += project.active_children.collect{|p| p.id}
274 281 end
275 282 elsif Setting.display_subprojects_issues?
276 283 ids += project.active_children.collect{|p| p.id}
277 284 end
278 285 clause << "#{Issue.table_name}.project_id IN (%s)" % ids.join(',')
279 286 elsif project
280 287 clause << "#{Issue.table_name}.project_id = %d" % project.id
281 288 else
282 289 clause << Project.visible_by(User.current)
283 290 end
284 291
285 292 # filters clauses
286 293 filters_clauses = []
287 294 filters.each_key do |field|
288 295 next if field == "subproject_id"
289 296 v = values_for(field).clone
290 297 next unless v and !v.empty?
291 298
292 299 sql = ''
293 300 if field =~ /^cf_(\d+)$/
294 301 # custom field
295 302 db_table = CustomValue.table_name
296 303 db_field = 'value'
297 304 sql << "#{Issue.table_name}.id IN (SELECT #{db_table}.customized_id FROM #{db_table} where #{db_table}.customized_type='Issue' AND #{db_table}.customized_id=#{Issue.table_name}.id AND #{db_table}.custom_field_id=#{$1} AND "
298 305 else
299 306 # regular field
300 307 db_table = Issue.table_name
301 308 db_field = field
302 309 sql << '('
303 310 end
304 311
305 312 # "me" value subsitution
306 313 if %w(assigned_to_id author_id).include?(field)
307 314 v.push(User.current.logged? ? User.current.id.to_s : "0") if v.delete("me")
308 315 end
309 316
310 317 case operator_for field
311 318 when "="
312 319 sql = sql + "#{db_table}.#{db_field} IN (" + v.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
313 320 when "!"
314 321 sql = sql + "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + v.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
315 322 when "!*"
316 323 sql = sql + "#{db_table}.#{db_field} IS NULL"
317 324 when "*"
318 325 sql = sql + "#{db_table}.#{db_field} IS NOT NULL"
319 326 when ">="
320 327 sql = sql + "#{db_table}.#{db_field} >= #{v.first.to_i}"
321 328 when "<="
322 329 sql = sql + "#{db_table}.#{db_field} <= #{v.first.to_i}"
323 330 when "o"
324 331 sql = sql + "#{IssueStatus.table_name}.is_closed=#{connection.quoted_false}" if field == "status_id"
325 332 when "c"
326 333 sql = sql + "#{IssueStatus.table_name}.is_closed=#{connection.quoted_true}" if field == "status_id"
327 334 when ">t-"
328 335 sql = sql + "#{db_table}.#{db_field} BETWEEN '%s' AND '%s'" % [connection.quoted_date((Date.today - v.first.to_i).to_time), connection.quoted_date((Date.today + 1).to_time)]
329 336 when "<t-"
330 337 sql = sql + "#{db_table}.#{db_field} <= '%s'" % connection.quoted_date((Date.today - v.first.to_i).to_time)
331 338 when "t-"
332 339 sql = sql + "#{db_table}.#{db_field} BETWEEN '%s' AND '%s'" % [connection.quoted_date((Date.today - v.first.to_i).to_time), connection.quoted_date((Date.today - v.first.to_i + 1).to_time)]
333 340 when ">t+"
334 341 sql = sql + "#{db_table}.#{db_field} >= '%s'" % connection.quoted_date((Date.today + v.first.to_i).to_time)
335 342 when "<t+"
336 343 sql = sql + "#{db_table}.#{db_field} BETWEEN '%s' AND '%s'" % [connection.quoted_date(Date.today.to_time), connection.quoted_date((Date.today + v.first.to_i + 1).to_time)]
337 344 when "t+"
338 345 sql = sql + "#{db_table}.#{db_field} BETWEEN '%s' AND '%s'" % [connection.quoted_date((Date.today + v.first.to_i).to_time), connection.quoted_date((Date.today + v.first.to_i + 1).to_time)]
339 346 when "t"
340 347 sql = sql + "#{db_table}.#{db_field} BETWEEN '%s' AND '%s'" % [connection.quoted_date(Date.today.to_time), connection.quoted_date((Date.today+1).to_time)]
341 348 when "w"
342 349 from = l(:general_first_day_of_week) == '7' ?
343 350 # week starts on sunday
344 351 ((Date.today.cwday == 7) ? Time.now.at_beginning_of_day : Time.now.at_beginning_of_week - 1.day) :
345 352 # week starts on monday (Rails default)
346 353 Time.now.at_beginning_of_week
347 354 sql = sql + "#{db_table}.#{db_field} BETWEEN '%s' AND '%s'" % [connection.quoted_date(from), connection.quoted_date(from + 7.days)]
348 355 when "~"
349 356 sql = sql + "#{db_table}.#{db_field} LIKE '%#{connection.quote_string(v.first)}%'"
350 357 when "!~"
351 358 sql = sql + "#{db_table}.#{db_field} NOT LIKE '%#{connection.quote_string(v.first)}%'"
352 359 end
353 360 sql << ')'
354 361 filters_clauses << sql
355 362 end if filters and valid?
356 363
357 364 clause << ' AND ' unless clause.empty?
358 365 clause << filters_clauses.join(' AND ') unless filters_clauses.empty?
359 366 clause
360 367 end
361 368 end
@@ -1,13 +1,14
1 <% if @project %>
1 2 <h3><%= l(:label_issue_plural) %></h3>
2 3 <%= link_to l(:label_issue_view_all), { :controller => 'issues', :action => 'index', :project_id => @project, :set_filter => 1 } %><br />
3 4 <%= link_to l(:field_summary), :controller => 'reports', :action => 'issue_report', :id => @project %><br />
4 5 <%= link_to l(:label_change_log), :controller => 'projects', :action => 'changelog', :id => @project %>
6 <% end %>
5 7
8 <% unless sidebar_queries.empty? -%>
6 9 <h3><%= l(:label_query_plural) %></h3>
7 10
8 <% queries = @project.queries.find(:all,
9 :order => "name ASC",
10 :conditions => ["is_public = ? or user_id = ?", true, (User.current.logged? ? User.current.id : 0)])
11 queries.each do |query| %>
11 <% sidebar_queries.each do |query| -%>
12 12 <%= link_to query.name, :controller => 'issues', :action => 'index', :project_id => @project, :query_id => query %><br />
13 <% end %>
13 <% end -%>
14 <% end -%>
@@ -1,67 +1,67
1 1 <% if @query.new_record? %>
2 2 <h2><%=l(:label_issue_plural)%></h2>
3 3 <% html_title(l(:label_issue_plural)) %>
4 4
5 5 <% form_tag({ :controller => 'queries', :action => 'new' }, :id => 'query_form') do %>
6 6 <%= hidden_field_tag('project_id', @project.id) if @project %>
7 7 <fieldset id="filters"><legend><%= l(:label_filter_plural) %></legend>
8 8 <%= render :partial => 'queries/filters', :locals => {:query => @query} %>
9 9 <p class="buttons">
10 10 <%= link_to_remote l(:button_apply),
11 11 { :url => { :set_filter => 1 },
12 12 :update => "content",
13 13 :with => "Form.serialize('query_form')"
14 14 }, :class => 'icon icon-checked' %>
15 15
16 16 <%= link_to_remote l(:button_clear),
17 17 { :url => { :set_filter => 1 },
18 18 :update => "content",
19 19 }, :class => 'icon icon-reload' %>
20 20
21 21 <% if current_role && current_role.allowed_to?(:save_queries) %>
22 22 <%= link_to l(:button_save), {}, :onclick => "$('query_form').submit(); return false;", :class => 'icon icon-save' %>
23 23 <% end %>
24 24 </p>
25 25 </fieldset>
26 26 <% end %>
27 27 <% else %>
28 28 <div class="contextual">
29 29 <% if @query.editable_by?(User.current) %>
30 30 <%= link_to l(:button_edit), {:controller => 'queries', :action => 'edit', :id => @query}, :class => 'icon icon-edit' %>
31 31 <%= link_to l(:button_delete), {:controller => 'queries', :action => 'destroy', :id => @query}, :confirm => l(:text_are_you_sure), :method => :post, :class => 'icon icon-del' %>
32 32 <% end %>
33 33 </div>
34 34 <h2><%=h @query.name %></h2>
35 35 <div id="query_form"></div>
36 36 <% html_title @query.name %>
37 37 <% end %>
38 38 <%= error_messages_for 'query' %>
39 39 <% if @query.valid? %>
40 40 <% if @issues.empty? %>
41 41 <p class="nodata"><%= l(:label_no_data) %></p>
42 42 <% else %>
43 43 <%= render :partial => 'issues/list', :locals => {:issues => @issues, :query => @query} %>
44 44 <p class="pagination"><%= pagination_links_full @issue_pages, @issue_count %></p>
45 45
46 46 <p class="other-formats">
47 47 <%= l(:label_export_to) %>
48 48 <span><%= link_to 'Atom', {:format => 'atom', :key => User.current.rss_key}, :class => 'feed' %></span>
49 49 <span><%= link_to 'CSV', {:format => 'csv'}, :class => 'csv' %></span>
50 50 <span><%= link_to 'PDF', {:format => 'pdf'}, :class => 'pdf' %></span>
51 51 </p>
52 52 <% end %>
53 53 <% end %>
54 54
55 55 <% content_for :sidebar do %>
56 56 <%= render :partial => 'issues/sidebar' %>
57 <% end if @project%>
57 <% end %>
58 58
59 59 <% content_for :header_tags do %>
60 60 <%= auto_discovery_link_tag(:atom, {:query_id => @query, :format => 'atom', :page => nil, :key => User.current.rss_key}, :title => l(:label_issue_plural)) %>
61 61 <%= auto_discovery_link_tag(:atom, {:action => 'changes', :query_id => @query, :format => 'atom', :page => nil, :key => User.current.rss_key}, :title => l(:label_changes_details)) %>
62 62 <%= javascript_include_tag 'context_menu' %>
63 63 <%= stylesheet_link_tag 'context_menu' %>
64 64 <% end %>
65 65
66 66 <div id="context-menu" style="display: none;"></div>
67 67 <%= javascript_tag "new ContextMenu('#{url_for(:controller => 'issues', :action => 'context_menu')}')" %>
@@ -1,24 +1,29
1 1 <%= error_messages_for 'query' %>
2 2 <%= hidden_field_tag 'confirm', 1 %>
3 3
4 4 <div class="box">
5 5 <div class="tabular">
6 6 <p><label for="query_name"><%=l(:field_name)%></label>
7 7 <%= text_field 'query', 'name', :size => 80 %></p>
8 8
9 <% if current_role.allowed_to?(:manage_public_queries) %>
10 <p><label for="query_is_public"><%=l(:field_is_public)%></label>
11 <%= check_box 'query', 'is_public' %></p>
9 <% if User.current.admin? || (@project && current_role.allowed_to?(:manage_public_queries)) %>
10 <p><label for="query_is_public"><%=l(:field_is_public)%></label>
11 <%= check_box 'query', 'is_public',
12 :onchange => (User.current.admin? ? nil : 'if (this.checked) {$("query_is_for_all").checked = false; $("query_is_for_all").disabled = true;} else {$("query_is_for_all").disabled = false;}') %></p>
12 13 <% end %>
13 14
15 <p><label for="query_is_for_all"><%=l(:field_is_for_all)%></label>
16 <%= check_box_tag 'query_is_for_all', 1, @query.project.nil?,
17 :disabled => (!@query.new_record? && (@query.project.nil? || (@query.is_public? && !User.current.admin?))) %></p>
18
14 19 <p><label for="query_default_columns"><%=l(:label_default_columns)%></label>
15 20 <%= check_box_tag 'default_columns', 1, @query.has_default_columns?, :id => 'query_default_columns',
16 21 :onclick => 'if (this.checked) {Element.hide("columns")} else {Element.show("columns")}' %></p>
17 22 </div>
18 23
19 24 <fieldset><legend><%= l(:label_filter_plural) %></legend>
20 25 <%= render :partial => 'queries/filters', :locals => {:query => query}%>
21 26 </fieldset>
22 27
23 28 <%= render :partial => 'queries/columns', :locals => {:query => query}%>
24 29 </div>
@@ -1,22 +1,69
1 1 ---
2 2 queries_001:
3 name: Multiple custom fields query
3 id: 1
4 4 project_id: 1
5 is_public: true
6 name: Multiple custom fields query
5 7 filters: |
6 8 ---
7 9 cf_1:
8 10 :values:
9 11 - MySQL
10 12 :operator: "="
11 13 status_id:
12 14 :values:
13 15 - "1"
14 16 :operator: o
15 17 cf_2:
16 18 :values:
17 19 - "125"
18 20 :operator: "="
19 21
20 id: 1
21 is_public: true
22 22 user_id: 1
23 column_names:
24 queries_002:
25 id: 2
26 project_id: 1
27 is_public: false
28 name: Private query for cookbook
29 filters: |
30 ---
31 tracker_id:
32 :values:
33 - "3"
34 :operator: "="
35 status_id:
36 :values:
37 - "1"
38 :operator: o
39
40 user_id: 3
41 column_names:
42 queries_003:
43 id: 3
44 project_id:
45 is_public: false
46 name: Private query for all projects
47 filters: |
48 ---
49 tracker_id:
50 :values:
51 - "3"
52 :operator: "="
53
54 user_id: 3
55 column_names:
56 queries_004:
57 id: 4
58 project_id:
59 is_public: true
60 name: Public query for all projects
61 filters: |
62 ---
63 tracker_id:
64 :values:
65 - "3"
66 :operator: "="
67
68 user_id: 2
69 column_names:
@@ -1,163 +1,161
1 1 ---
2 2 roles_001:
3 3 name: Manager
4 4 id: 1
5 5 builtin: 0
6 6 permissions: |
7 7 ---
8 8 - :edit_project
9 9 - :manage_members
10 10 - :manage_versions
11 11 - :manage_categories
12 12 - :add_issues
13 13 - :edit_issues
14 14 - :manage_issue_relations
15 15 - :add_issue_notes
16 16 - :move_issues
17 17 - :delete_issues
18 18 - :manage_public_queries
19 19 - :save_queries
20 20 - :view_gantt
21 21 - :view_calendar
22 22 - :log_time
23 23 - :view_time_entries
24 24 - :edit_time_entries
25 25 - :delete_time_entries
26 26 - :manage_news
27 27 - :comment_news
28 28 - :view_documents
29 29 - :manage_documents
30 30 - :view_wiki_pages
31 31 - :edit_wiki_pages
32 32 - :delete_wiki_pages
33 33 - :rename_wiki_pages
34 34 - :add_messages
35 35 - :edit_messages
36 36 - :delete_messages
37 37 - :manage_boards
38 38 - :view_files
39 39 - :manage_files
40 40 - :browse_repository
41 41 - :view_changesets
42 42
43 43 position: 1
44 44 roles_002:
45 45 name: Developer
46 46 id: 2
47 47 builtin: 0
48 48 permissions: |
49 49 ---
50 50 - :edit_project
51 51 - :manage_members
52 52 - :manage_versions
53 53 - :manage_categories
54 54 - :add_issues
55 55 - :edit_issues
56 56 - :manage_issue_relations
57 57 - :add_issue_notes
58 58 - :move_issues
59 59 - :delete_issues
60 - :manage_public_queries
61 60 - :save_queries
62 61 - :view_gantt
63 62 - :view_calendar
64 63 - :log_time
65 64 - :view_time_entries
66 65 - :edit_own_time_entries
67 66 - :manage_news
68 67 - :comment_news
69 68 - :view_documents
70 69 - :manage_documents
71 70 - :view_wiki_pages
72 71 - :edit_wiki_pages
73 72 - :delete_wiki_pages
74 73 - :add_messages
75 74 - :manage_boards
76 75 - :view_files
77 76 - :manage_files
78 77 - :browse_repository
79 78 - :view_changesets
80 79
81 80 position: 2
82 81 roles_003:
83 82 name: Reporter
84 83 id: 3
85 84 builtin: 0
86 85 permissions: |
87 86 ---
88 87 - :edit_project
89 88 - :manage_members
90 89 - :manage_versions
91 90 - :manage_categories
92 91 - :add_issues
93 92 - :edit_issues
94 93 - :manage_issue_relations
95 94 - :add_issue_notes
96 95 - :move_issues
97 - :manage_public_queries
98 96 - :save_queries
99 97 - :view_gantt
100 98 - :view_calendar
101 99 - :log_time
102 100 - :view_time_entries
103 101 - :manage_news
104 102 - :comment_news
105 103 - :view_documents
106 104 - :manage_documents
107 105 - :view_wiki_pages
108 106 - :edit_wiki_pages
109 107 - :delete_wiki_pages
110 108 - :add_messages
111 109 - :manage_boards
112 110 - :view_files
113 111 - :manage_files
114 112 - :browse_repository
115 113 - :view_changesets
116 114
117 115 position: 3
118 116 roles_004:
119 117 name: Non member
120 118 id: 4
121 119 builtin: 1
122 120 permissions: |
123 121 ---
124 122 - :add_issues
125 123 - :edit_issues
126 124 - :manage_issue_relations
127 125 - :add_issue_notes
128 126 - :move_issues
129 127 - :save_queries
130 128 - :view_gantt
131 129 - :view_calendar
132 130 - :log_time
133 131 - :view_time_entries
134 132 - :comment_news
135 133 - :view_documents
136 134 - :manage_documents
137 135 - :view_wiki_pages
138 136 - :edit_wiki_pages
139 137 - :add_messages
140 138 - :view_files
141 139 - :manage_files
142 140 - :browse_repository
143 141 - :view_changesets
144 142
145 143 position: 4
146 144 roles_005:
147 145 name: Anonymous
148 146 id: 5
149 147 builtin: 2
150 148 permissions: |
151 149 ---
152 150 - :add_issue_notes
153 151 - :view_gantt
154 152 - :view_calendar
155 153 - :view_time_entries
156 154 - :view_documents
157 155 - :view_wiki_pages
158 156 - :view_files
159 157 - :browse_repository
160 158 - :view_changesets
161 159
162 160 position: 5
163 161 No newline at end of file
@@ -1,44 +1,74
1 1 # redMine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
2 # Copyright (C) 2006-2008 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.dirname(__FILE__) + '/../test_helper'
19 19
20 20 class QueryTest < Test::Unit::TestCase
21 fixtures :projects, :users, :trackers, :issue_statuses, :issue_categories, :enumerations, :issues, :custom_fields, :custom_values, :queries
21 fixtures :projects, :users, :members, :roles, :trackers, :issue_statuses, :issue_categories, :enumerations, :issues, :custom_fields, :custom_values, :queries
22 22
23 23 def test_query_with_multiple_custom_fields
24 24 query = Query.find(1)
25 25 assert query.valid?
26 26 assert query.statement.include?("custom_values.value IN ('MySQL')")
27 27 issues = Issue.find :all,:include => [ :assigned_to, :status, :tracker, :project, :priority ], :conditions => query.statement
28 28 assert_equal 1, issues.length
29 29 assert_equal Issue.find(3), issues.first
30 30 end
31 31
32 32 def test_default_columns
33 33 q = Query.new
34 34 assert !q.columns.empty?
35 35 end
36 36
37 37 def test_set_column_names
38 38 q = Query.new
39 39 q.column_names = ['tracker', :subject, '', 'unknonw_column']
40 40 assert_equal [:tracker, :subject], q.columns.collect {|c| c.name}
41 41 c = q.columns.first
42 42 assert q.has_column?(c)
43 43 end
44
45 def test_editable_by
46 admin = User.find(1)
47 manager = User.find(2)
48 developer = User.find(3)
49
50 # Public query on project 1
51 q = Query.find(1)
52 assert q.editable_by?(admin)
53 assert q.editable_by?(manager)
54 assert !q.editable_by?(developer)
55
56 # Private query on project 1
57 q = Query.find(2)
58 assert q.editable_by?(admin)
59 assert !q.editable_by?(manager)
60 assert q.editable_by?(developer)
61
62 # Private query for all projects
63 q = Query.find(3)
64 assert q.editable_by?(admin)
65 assert !q.editable_by?(manager)
66 assert q.editable_by?(developer)
67
68 # Public query for all projects
69 q = Query.find(4)
70 assert q.editable_by?(admin)
71 assert !q.editable_by?(manager)
72 assert !q.editable_by?(developer)
73 end
44 74 end
General Comments 0
You need to be logged in to leave comments. Login now