##// END OF EJS Templates
Adds pagination to admin project list....
Jean-Philippe Lang -
r15373:daab32923d85
parent child
Show More
@@ -1,84 +1,87
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 AdminController < ApplicationController
19 19 layout 'admin'
20 20 menu_item :projects, :only => :projects
21 21 menu_item :plugins, :only => :plugins
22 22 menu_item :info, :only => :info
23 23
24 24 before_action :require_admin
25 25 helper :sort
26 26 include SortHelper
27 27
28 28 def index
29 29 @no_configuration_data = Redmine::DefaultData::Loader::no_data?
30 30 end
31 31
32 32 def projects
33 33 @status = params[:status] || 1
34 34
35 35 scope = Project.status(@status).sorted
36 36 scope = scope.like(params[:name]) if params[:name].present?
37 @projects = scope.to_a
37
38 @project_count = scope.count
39 @project_pages = Paginator.new @project_count, per_page_option, params['page']
40 @projects = scope.limit(@project_pages.per_page).offset(@project_pages.offset).to_a
38 41
39 42 render :action => "projects", :layout => false if request.xhr?
40 43 end
41 44
42 45 def plugins
43 46 @plugins = Redmine::Plugin.all
44 47 end
45 48
46 49 # Loads the default configuration
47 50 # (roles, trackers, statuses, workflow, enumerations)
48 51 def default_configuration
49 52 if request.post?
50 53 begin
51 54 Redmine::DefaultData::Loader::load(params[:lang])
52 55 flash[:notice] = l(:notice_default_data_loaded)
53 56 rescue Exception => e
54 57 flash[:error] = l(:error_can_t_load_default_data, ERB::Util.h(e.message))
55 58 end
56 59 end
57 60 redirect_to admin_path
58 61 end
59 62
60 63 def test_email
61 64 raise_delivery_errors = ActionMailer::Base.raise_delivery_errors
62 65 # Force ActionMailer to raise delivery errors so we can catch it
63 66 ActionMailer::Base.raise_delivery_errors = true
64 67 begin
65 68 @test = Mailer.test_email(User.current).deliver
66 69 flash[:notice] = l(:notice_email_sent, ERB::Util.h(User.current.mail))
67 70 rescue Exception => e
68 71 flash[:error] = l(:notice_email_error, ERB::Util.h(Redmine::CodesetUtil.replace_invalid_utf8(e.message.dup)))
69 72 end
70 73 ActionMailer::Base.raise_delivery_errors = raise_delivery_errors
71 74 redirect_to settings_path(:tab => 'notifications')
72 75 end
73 76
74 77 def info
75 78 @db_adapter_name = ActiveRecord::Base.connection.adapter_name
76 79 @checklist = [
77 80 [:text_default_administrator_account_changed, User.default_admin_account_changed?],
78 81 [:text_file_repository_writable, File.writable?(Attachment.storage_path)],
79 82 ["#{l :text_plugin_assets_writable} (./public/plugin_assets)", File.writable?(Redmine::Plugin.public_directory)],
80 83 [:text_rmagick_available, Object.const_defined?(:Magick)],
81 84 [:text_convert_available, Redmine::Thumbnail.convert_available?]
82 85 ]
83 86 end
84 87 end
@@ -1,228 +1,228
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_action :find_project, :except => [ :index, :list, :new, :create, :copy ]
23 23 before_action :authorize, :except => [ :index, :list, :new, :create, :copy, :archive, :unarchive, :destroy]
24 24 before_action :authorize_global, :only => [:new, :create]
25 25 before_action :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 helper :custom_fields
31 31 helper :issues
32 32 helper :queries
33 33 helper :repositories
34 34 helper :members
35 35
36 36 # Lists visible projects
37 37 def index
38 38 scope = Project.visible.sorted
39 39
40 40 respond_to do |format|
41 41 format.html {
42 42 unless params[:closed]
43 43 scope = scope.active
44 44 end
45 45 @projects = scope.to_a
46 46 }
47 47 format.api {
48 48 @offset, @limit = api_offset_and_limit
49 49 @project_count = scope.count
50 50 @projects = scope.offset(@offset).limit(@limit).to_a
51 51 }
52 52 format.atom {
53 53 projects = scope.reorder(:created_on => :desc).limit(Setting.feeds_limit.to_i).to_a
54 54 render_feed(projects, :title => "#{Setting.app_title}: #{l(:label_project_latest)}")
55 55 }
56 56 end
57 57 end
58 58
59 59 def new
60 60 @issue_custom_fields = IssueCustomField.sorted.to_a
61 61 @trackers = Tracker.sorted.to_a
62 62 @project = Project.new
63 63 @project.safe_attributes = params[:project]
64 64 end
65 65
66 66 def create
67 67 @issue_custom_fields = IssueCustomField.sorted.to_a
68 68 @trackers = Tracker.sorted.to_a
69 69 @project = Project.new
70 70 @project.safe_attributes = params[:project]
71 71
72 72 if @project.save
73 73 unless User.current.admin?
74 74 @project.add_default_member(User.current)
75 75 end
76 76 respond_to do |format|
77 77 format.html {
78 78 flash[:notice] = l(:notice_successful_create)
79 79 if params[:continue]
80 80 attrs = {:parent_id => @project.parent_id}.reject {|k,v| v.nil?}
81 81 redirect_to new_project_path(attrs)
82 82 else
83 83 redirect_to settings_project_path(@project)
84 84 end
85 85 }
86 86 format.api { render :action => 'show', :status => :created, :location => url_for(:controller => 'projects', :action => 'show', :id => @project.id) }
87 87 end
88 88 else
89 89 respond_to do |format|
90 90 format.html { render :action => 'new' }
91 91 format.api { render_validation_errors(@project) }
92 92 end
93 93 end
94 94 end
95 95
96 96 def copy
97 97 @issue_custom_fields = IssueCustomField.sorted.to_a
98 98 @trackers = Tracker.sorted.to_a
99 99 @source_project = Project.find(params[:id])
100 100 if request.get?
101 101 @project = Project.copy_from(@source_project)
102 102 @project.identifier = Project.next_identifier if Setting.sequential_project_identifiers?
103 103 else
104 104 Mailer.with_deliveries(params[:notifications] == '1') do
105 105 @project = Project.new
106 106 @project.safe_attributes = params[:project]
107 107 if @project.copy(@source_project, :only => params[:only])
108 108 flash[:notice] = l(:notice_successful_create)
109 109 redirect_to settings_project_path(@project)
110 110 elsif !@project.new_record?
111 111 # Project was created
112 112 # But some objects were not copied due to validation failures
113 113 # (eg. issues from disabled trackers)
114 114 # TODO: inform about that
115 115 redirect_to settings_project_path(@project)
116 116 end
117 117 end
118 118 end
119 119 rescue ActiveRecord::RecordNotFound
120 120 # source_project not found
121 121 render_404
122 122 end
123 123
124 124 # Show @project
125 125 def show
126 126 # try to redirect to the requested menu item
127 127 if params[:jump] && redirect_to_project_menu_item(@project, params[:jump])
128 128 return
129 129 end
130 130
131 131 @users_by_role = @project.users_by_role
132 132 @subprojects = @project.children.visible.to_a
133 133 @news = @project.news.limit(5).includes(:author, :project).reorder("#{News.table_name}.created_on DESC").to_a
134 134 @trackers = @project.rolled_up_trackers.visible
135 135
136 136 cond = @project.project_condition(Setting.display_subprojects_issues?)
137 137
138 138 @open_issues_by_tracker = Issue.visible.open.where(cond).group(:tracker).count
139 139 @total_issues_by_tracker = Issue.visible.where(cond).group(:tracker).count
140 140
141 141 if User.current.allowed_to_view_all_time_entries?(@project)
142 142 @total_hours = TimeEntry.visible.where(cond).sum(:hours).to_f
143 143 end
144 144
145 145 @key = User.current.rss_key
146 146
147 147 respond_to do |format|
148 148 format.html
149 149 format.api
150 150 end
151 151 end
152 152
153 153 def settings
154 154 @issue_custom_fields = IssueCustomField.sorted.to_a
155 155 @issue_category ||= IssueCategory.new
156 156 @member ||= @project.members.new
157 157 @trackers = Tracker.sorted.to_a
158 158 @wiki ||= @project.wiki || Wiki.new(:project => @project)
159 159 end
160 160
161 161 def edit
162 162 end
163 163
164 164 def update
165 165 @project.safe_attributes = params[:project]
166 166 if @project.save
167 167 respond_to do |format|
168 168 format.html {
169 169 flash[:notice] = l(:notice_successful_update)
170 170 redirect_to settings_project_path(@project)
171 171 }
172 172 format.api { render_api_ok }
173 173 end
174 174 else
175 175 respond_to do |format|
176 176 format.html {
177 177 settings
178 178 render :action => 'settings'
179 179 }
180 180 format.api { render_validation_errors(@project) }
181 181 end
182 182 end
183 183 end
184 184
185 185 def modules
186 186 @project.enabled_module_names = params[:enabled_module_names]
187 187 flash[:notice] = l(:notice_successful_update)
188 188 redirect_to settings_project_path(@project, :tab => 'modules')
189 189 end
190 190
191 191 def archive
192 192 unless @project.archive
193 193 flash[:error] = l(:error_can_not_archive_project)
194 194 end
195 redirect_to admin_projects_path(:status => params[:status])
195 redirect_to_referer_or admin_projects_path(:status => params[:status])
196 196 end
197 197
198 198 def unarchive
199 199 unless @project.active?
200 200 @project.unarchive
201 201 end
202 redirect_to admin_projects_path(:status => params[:status])
202 redirect_to_referer_or admin_projects_path(:status => params[:status])
203 203 end
204 204
205 205 def close
206 206 @project.close
207 207 redirect_to project_path(@project)
208 208 end
209 209
210 210 def reopen
211 211 @project.reopen
212 212 redirect_to project_path(@project)
213 213 end
214 214
215 215 # Delete @project
216 216 def destroy
217 217 @project_to_destroy = @project
218 218 if api_request? || params[:confirm]
219 219 @project_to_destroy.destroy
220 220 respond_to do |format|
221 221 format.html { redirect_to admin_projects_path }
222 222 format.api { render_api_ok }
223 223 end
224 224 end
225 225 # hide project in layout
226 226 @project = nil
227 227 end
228 228 end
@@ -1,1371 +1,1371
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2016 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 require 'forwardable'
21 21 require 'cgi'
22 22
23 23 module ApplicationHelper
24 24 include Redmine::WikiFormatting::Macros::Definitions
25 25 include Redmine::I18n
26 26 include GravatarHelper::PublicMethods
27 27 include Redmine::Pagination::Helper
28 28 include Redmine::SudoMode::Helper
29 29 include Redmine::Themes::Helper
30 30 include Redmine::Hook::Helper
31 31 include Redmine::Helpers::URL
32 32
33 33 extend Forwardable
34 34 def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter
35 35
36 36 # Return true if user is authorized for controller/action, otherwise false
37 37 def authorize_for(controller, action)
38 38 User.current.allowed_to?({:controller => controller, :action => action}, @project)
39 39 end
40 40
41 41 # Display a link if user is authorized
42 42 #
43 43 # @param [String] name Anchor text (passed to link_to)
44 44 # @param [Hash] options Hash params. This will checked by authorize_for to see if the user is authorized
45 45 # @param [optional, Hash] html_options Options passed to link_to
46 46 # @param [optional, Hash] parameters_for_method_reference Extra parameters for link_to
47 47 def link_to_if_authorized(name, options = {}, html_options = nil, *parameters_for_method_reference)
48 48 link_to(name, options, html_options, *parameters_for_method_reference) if authorize_for(options[:controller] || params[:controller], options[:action])
49 49 end
50 50
51 51 # Displays a link to user's account page if active
52 52 def link_to_user(user, options={})
53 53 if user.is_a?(User)
54 54 name = h(user.name(options[:format]))
55 55 if user.active? || (User.current.admin? && user.logged?)
56 56 link_to name, user_path(user), :class => user.css_classes
57 57 else
58 58 name
59 59 end
60 60 else
61 61 h(user.to_s)
62 62 end
63 63 end
64 64
65 65 # Displays a link to +issue+ with its subject.
66 66 # Examples:
67 67 #
68 68 # link_to_issue(issue) # => Defect #6: This is the subject
69 69 # link_to_issue(issue, :truncate => 6) # => Defect #6: This i...
70 70 # link_to_issue(issue, :subject => false) # => Defect #6
71 71 # link_to_issue(issue, :project => true) # => Foo - Defect #6
72 72 # link_to_issue(issue, :subject => false, :tracker => false) # => #6
73 73 #
74 74 def link_to_issue(issue, options={})
75 75 title = nil
76 76 subject = nil
77 77 text = options[:tracker] == false ? "##{issue.id}" : "#{issue.tracker} ##{issue.id}"
78 78 if options[:subject] == false
79 79 title = issue.subject.truncate(60)
80 80 else
81 81 subject = issue.subject
82 82 if truncate_length = options[:truncate]
83 83 subject = subject.truncate(truncate_length)
84 84 end
85 85 end
86 86 only_path = options[:only_path].nil? ? true : options[:only_path]
87 87 s = link_to(text, issue_url(issue, :only_path => only_path),
88 88 :class => issue.css_classes, :title => title)
89 89 s << h(": #{subject}") if subject
90 90 s = h("#{issue.project} - ") + s if options[:project]
91 91 s
92 92 end
93 93
94 94 # Generates a link to an attachment.
95 95 # Options:
96 96 # * :text - Link text (default to attachment filename)
97 97 # * :download - Force download (default: false)
98 98 def link_to_attachment(attachment, options={})
99 99 text = options.delete(:text) || attachment.filename
100 100 route_method = options.delete(:download) ? :download_named_attachment_url : :named_attachment_url
101 101 html_options = options.slice!(:only_path)
102 102 options[:only_path] = true unless options.key?(:only_path)
103 103 url = send(route_method, attachment, attachment.filename, options)
104 104 link_to text, url, html_options
105 105 end
106 106
107 107 # Generates a link to a SCM revision
108 108 # Options:
109 109 # * :text - Link text (default to the formatted revision)
110 110 def link_to_revision(revision, repository, options={})
111 111 if repository.is_a?(Project)
112 112 repository = repository.repository
113 113 end
114 114 text = options.delete(:text) || format_revision(revision)
115 115 rev = revision.respond_to?(:identifier) ? revision.identifier : revision
116 116 link_to(
117 117 h(text),
118 118 {:controller => 'repositories', :action => 'revision', :id => repository.project, :repository_id => repository.identifier_param, :rev => rev},
119 119 :title => l(:label_revision_id, format_revision(revision)),
120 120 :accesskey => options[:accesskey]
121 121 )
122 122 end
123 123
124 124 # Generates a link to a message
125 125 def link_to_message(message, options={}, html_options = nil)
126 126 link_to(
127 127 message.subject.truncate(60),
128 128 board_message_url(message.board_id, message.parent_id || message.id, {
129 129 :r => (message.parent_id && message.id),
130 130 :anchor => (message.parent_id ? "message-#{message.id}" : nil),
131 131 :only_path => true
132 132 }.merge(options)),
133 133 html_options
134 134 )
135 135 end
136 136
137 137 # Generates a link to a project if active
138 138 # Examples:
139 139 #
140 140 # link_to_project(project) # => link to the specified project overview
141 141 # link_to_project(project, {:only_path => false}, :class => "project") # => 3rd arg adds html options
142 142 # link_to_project(project, {}, :class => "project") # => html options with default url (project overview)
143 143 #
144 144 def link_to_project(project, options={}, html_options = nil)
145 145 if project.archived?
146 146 h(project.name)
147 147 else
148 148 link_to project.name,
149 149 project_url(project, {:only_path => true}.merge(options)),
150 150 html_options
151 151 end
152 152 end
153 153
154 154 # Generates a link to a project settings if active
155 155 def link_to_project_settings(project, options={}, html_options=nil)
156 156 if project.active?
157 157 link_to project.name, settings_project_path(project, options), html_options
158 158 elsif project.archived?
159 159 h(project.name)
160 160 else
161 161 link_to project.name, project_path(project, options), html_options
162 162 end
163 163 end
164 164
165 165 # Generates a link to a version
166 166 def link_to_version(version, options = {})
167 167 return '' unless version && version.is_a?(Version)
168 168 options = {:title => format_date(version.effective_date)}.merge(options)
169 169 link_to_if version.visible?, format_version_name(version), version_path(version), options
170 170 end
171 171
172 172 # Helper that formats object for html or text rendering
173 173 def format_object(object, html=true, &block)
174 174 if block_given?
175 175 object = yield object
176 176 end
177 177 case object.class.name
178 178 when 'Array'
179 179 object.map {|o| format_object(o, html)}.join(', ').html_safe
180 180 when 'Time'
181 181 format_time(object)
182 182 when 'Date'
183 183 format_date(object)
184 184 when 'Fixnum'
185 185 object.to_s
186 186 when 'Float'
187 187 sprintf "%.2f", object
188 188 when 'User'
189 189 html ? link_to_user(object) : object.to_s
190 190 when 'Project'
191 191 html ? link_to_project(object) : object.to_s
192 192 when 'Version'
193 193 html ? link_to_version(object) : object.to_s
194 194 when 'TrueClass'
195 195 l(:general_text_Yes)
196 196 when 'FalseClass'
197 197 l(:general_text_No)
198 198 when 'Issue'
199 199 object.visible? && html ? link_to_issue(object) : "##{object.id}"
200 200 when 'CustomValue', 'CustomFieldValue'
201 201 if object.custom_field
202 202 f = object.custom_field.format.formatted_custom_value(self, object, html)
203 203 if f.nil? || f.is_a?(String)
204 204 f
205 205 else
206 206 format_object(f, html, &block)
207 207 end
208 208 else
209 209 object.value.to_s
210 210 end
211 211 else
212 212 html ? h(object) : object.to_s
213 213 end
214 214 end
215 215
216 216 def wiki_page_path(page, options={})
217 217 url_for({:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title}.merge(options))
218 218 end
219 219
220 220 def thumbnail_tag(attachment)
221 221 link_to image_tag(thumbnail_path(attachment)),
222 222 named_attachment_path(attachment, attachment.filename),
223 223 :title => attachment.filename
224 224 end
225 225
226 226 def toggle_link(name, id, options={})
227 227 onclick = "$('##{id}').toggle(); "
228 228 onclick << (options[:focus] ? "$('##{options[:focus]}').focus(); " : "this.blur(); ")
229 229 onclick << "return false;"
230 230 link_to(name, "#", :onclick => onclick)
231 231 end
232 232
233 233 def format_activity_title(text)
234 234 h(truncate_single_line_raw(text, 100))
235 235 end
236 236
237 237 def format_activity_day(date)
238 238 date == User.current.today ? l(:label_today).titleize : format_date(date)
239 239 end
240 240
241 241 def format_activity_description(text)
242 242 h(text.to_s.truncate(120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')
243 243 ).gsub(/[\r\n]+/, "<br />").html_safe
244 244 end
245 245
246 246 def format_version_name(version)
247 247 if version.project == @project
248 248 h(version)
249 249 else
250 250 h("#{version.project} - #{version}")
251 251 end
252 252 end
253 253
254 254 def due_date_distance_in_words(date)
255 255 if date
256 256 l((date < User.current.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(User.current.today, date))
257 257 end
258 258 end
259 259
260 260 # Renders a tree of projects as a nested set of unordered lists
261 261 # The given collection may be a subset of the whole project tree
262 262 # (eg. some intermediate nodes are private and can not be seen)
263 263 def render_project_nested_lists(projects, &block)
264 264 s = ''
265 265 if projects.any?
266 266 ancestors = []
267 267 original_project = @project
268 268 projects.sort_by(&:lft).each do |project|
269 269 # set the project environment to please macros.
270 270 @project = project
271 271 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
272 272 s << "<ul class='projects #{ ancestors.empty? ? 'root' : nil}'>\n"
273 273 else
274 274 ancestors.pop
275 275 s << "</li>"
276 276 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
277 277 ancestors.pop
278 278 s << "</ul></li>\n"
279 279 end
280 280 end
281 281 classes = (ancestors.empty? ? 'root' : 'child')
282 282 s << "<li class='#{classes}'><div class='#{classes}'>"
283 283 s << h(block_given? ? capture(project, &block) : project.name)
284 284 s << "</div>\n"
285 285 ancestors << project
286 286 end
287 287 s << ("</li></ul>\n" * ancestors.size)
288 288 @project = original_project
289 289 end
290 290 s.html_safe
291 291 end
292 292
293 293 def render_page_hierarchy(pages, node=nil, options={})
294 294 content = ''
295 295 if pages[node]
296 296 content << "<ul class=\"pages-hierarchy\">\n"
297 297 pages[node].each do |page|
298 298 content << "<li>"
299 299 content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title, :version => nil},
300 300 :title => (options[:timestamp] && page.updated_on ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
301 301 content << "\n" + render_page_hierarchy(pages, page.id, options) if pages[page.id]
302 302 content << "</li>\n"
303 303 end
304 304 content << "</ul>\n"
305 305 end
306 306 content.html_safe
307 307 end
308 308
309 309 # Renders flash messages
310 310 def render_flash_messages
311 311 s = ''
312 312 flash.each do |k,v|
313 313 s << content_tag('div', v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
314 314 end
315 315 s.html_safe
316 316 end
317 317
318 318 # Renders tabs and their content
319 319 def render_tabs(tabs, selected=params[:tab])
320 320 if tabs.any?
321 321 unless tabs.detect {|tab| tab[:name] == selected}
322 322 selected = nil
323 323 end
324 324 selected ||= tabs.first[:name]
325 325 render :partial => 'common/tabs', :locals => {:tabs => tabs, :selected_tab => selected}
326 326 else
327 327 content_tag 'p', l(:label_no_data), :class => "nodata"
328 328 end
329 329 end
330 330
331 331 # Renders the project quick-jump box
332 332 def render_project_jump_box
333 333 return unless User.current.logged?
334 334 projects = User.current.projects.active.select(:id, :name, :identifier, :lft, :rgt).to_a
335 335 if projects.any?
336 336 options =
337 337 ("<option value=''>#{ l(:label_jump_to_a_project) }</option>" +
338 338 '<option value="" disabled="disabled">---</option>').html_safe
339 339
340 340 options << project_tree_options_for_select(projects, :selected => @project) do |p|
341 341 { :value => project_path(:id => p, :jump => current_menu_item) }
342 342 end
343 343
344 344 content_tag( :span, nil, :class => 'jump-box-arrow') +
345 345 select_tag('project_quick_jump_box', options, :onchange => 'if (this.value != \'\') { window.location = this.value; }')
346 346 end
347 347 end
348 348
349 349 def project_tree_options_for_select(projects, options = {})
350 350 s = ''.html_safe
351 351 if blank_text = options[:include_blank]
352 352 if blank_text == true
353 353 blank_text = '&nbsp;'.html_safe
354 354 end
355 355 s << content_tag('option', blank_text, :value => '')
356 356 end
357 357 project_tree(projects) do |project, level|
358 358 name_prefix = (level > 0 ? '&nbsp;' * 2 * level + '&#187; ' : '').html_safe
359 359 tag_options = {:value => project.id}
360 360 if project == options[:selected] || (options[:selected].respond_to?(:include?) && options[:selected].include?(project))
361 361 tag_options[:selected] = 'selected'
362 362 else
363 363 tag_options[:selected] = nil
364 364 end
365 365 tag_options.merge!(yield(project)) if block_given?
366 366 s << content_tag('option', name_prefix + h(project), tag_options)
367 367 end
368 368 s.html_safe
369 369 end
370 370
371 371 # Yields the given block for each project with its level in the tree
372 372 #
373 373 # Wrapper for Project#project_tree
374 def project_tree(projects, &block)
375 Project.project_tree(projects, &block)
374 def project_tree(projects, options={}, &block)
375 Project.project_tree(projects, options, &block)
376 376 end
377 377
378 378 def principals_check_box_tags(name, principals)
379 379 s = ''
380 380 principals.each do |principal|
381 381 s << "<label>#{ check_box_tag name, principal.id, false, :id => nil } #{h principal}</label>\n"
382 382 end
383 383 s.html_safe
384 384 end
385 385
386 386 # Returns a string for users/groups option tags
387 387 def principals_options_for_select(collection, selected=nil)
388 388 s = ''
389 389 if collection.include?(User.current)
390 390 s << content_tag('option', "<< #{l(:label_me)} >>", :value => User.current.id)
391 391 end
392 392 groups = ''
393 393 collection.sort.each do |element|
394 394 selected_attribute = ' selected="selected"' if option_value_selected?(element, selected) || element.id.to_s == selected
395 395 (element.is_a?(Group) ? groups : s) << %(<option value="#{element.id}"#{selected_attribute}>#{h element.name}</option>)
396 396 end
397 397 unless groups.empty?
398 398 s << %(<optgroup label="#{h(l(:label_group_plural))}">#{groups}</optgroup>)
399 399 end
400 400 s.html_safe
401 401 end
402 402
403 403 def option_tag(name, text, value, selected=nil, options={})
404 404 content_tag 'option', value, options.merge(:value => value, :selected => (value == selected))
405 405 end
406 406
407 407 def truncate_single_line_raw(string, length)
408 408 string.to_s.truncate(length).gsub(%r{[\r\n]+}m, ' ')
409 409 end
410 410
411 411 # Truncates at line break after 250 characters or options[:length]
412 412 def truncate_lines(string, options={})
413 413 length = options[:length] || 250
414 414 if string.to_s =~ /\A(.{#{length}}.*?)$/m
415 415 "#{$1}..."
416 416 else
417 417 string
418 418 end
419 419 end
420 420
421 421 def anchor(text)
422 422 text.to_s.gsub(' ', '_')
423 423 end
424 424
425 425 def html_hours(text)
426 426 text.gsub(%r{(\d+)\.(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">.\2</span>').html_safe
427 427 end
428 428
429 429 def authoring(created, author, options={})
430 430 l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created)).html_safe
431 431 end
432 432
433 433 def time_tag(time)
434 434 text = distance_of_time_in_words(Time.now, time)
435 435 if @project
436 436 link_to(text, project_activity_path(@project, :from => User.current.time_to_date(time)), :title => format_time(time))
437 437 else
438 438 content_tag('abbr', text, :title => format_time(time))
439 439 end
440 440 end
441 441
442 442 def syntax_highlight_lines(name, content)
443 443 lines = []
444 444 syntax_highlight(name, content).each_line { |line| lines << line }
445 445 lines
446 446 end
447 447
448 448 def syntax_highlight(name, content)
449 449 Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
450 450 end
451 451
452 452 def to_path_param(path)
453 453 str = path.to_s.split(%r{[/\\]}).select{|p| !p.blank?}.join("/")
454 454 str.blank? ? nil : str
455 455 end
456 456
457 457 def reorder_links(name, url, method = :post)
458 458 # TODO: remove associated styles from application.css too
459 459 ActiveSupport::Deprecation.warn "Application#reorder_links will be removed in Redmine 4."
460 460
461 461 link_to(l(:label_sort_highest),
462 462 url.merge({"#{name}[move_to]" => 'highest'}), :method => method,
463 463 :title => l(:label_sort_highest), :class => 'icon-only icon-move-top') +
464 464 link_to(l(:label_sort_higher),
465 465 url.merge({"#{name}[move_to]" => 'higher'}), :method => method,
466 466 :title => l(:label_sort_higher), :class => 'icon-only icon-move-up') +
467 467 link_to(l(:label_sort_lower),
468 468 url.merge({"#{name}[move_to]" => 'lower'}), :method => method,
469 469 :title => l(:label_sort_lower), :class => 'icon-only icon-move-down') +
470 470 link_to(l(:label_sort_lowest),
471 471 url.merge({"#{name}[move_to]" => 'lowest'}), :method => method,
472 472 :title => l(:label_sort_lowest), :class => 'icon-only icon-move-bottom')
473 473 end
474 474
475 475 def reorder_handle(object, options={})
476 476 data = {
477 477 :reorder_url => options[:url] || url_for(object),
478 478 :reorder_param => options[:param] || object.class.name.underscore
479 479 }
480 480 content_tag('span', '',
481 481 :class => "sort-handle",
482 482 :data => data,
483 483 :title => l(:button_sort))
484 484 end
485 485
486 486 def breadcrumb(*args)
487 487 elements = args.flatten
488 488 elements.any? ? content_tag('p', (args.join(" \xc2\xbb ") + " \xc2\xbb ").html_safe, :class => 'breadcrumb') : nil
489 489 end
490 490
491 491 def other_formats_links(&block)
492 492 concat('<p class="other-formats">'.html_safe + l(:label_export_to))
493 493 yield Redmine::Views::OtherFormatsBuilder.new(self)
494 494 concat('</p>'.html_safe)
495 495 end
496 496
497 497 def page_header_title
498 498 if @project.nil? || @project.new_record?
499 499 h(Setting.app_title)
500 500 else
501 501 b = []
502 502 ancestors = (@project.root? ? [] : @project.ancestors.visible.to_a)
503 503 if ancestors.any?
504 504 root = ancestors.shift
505 505 b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
506 506 if ancestors.size > 2
507 507 b << "\xe2\x80\xa6"
508 508 ancestors = ancestors[-2, 2]
509 509 end
510 510 b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
511 511 end
512 512 b << content_tag(:span, h(@project), class: 'current-project')
513 513 if b.size > 1
514 514 separator = content_tag(:span, ' &raquo; '.html_safe, class: 'separator')
515 515 path = safe_join(b[0..-2], separator) + separator
516 516 b = [content_tag(:span, path.html_safe, class: 'breadcrumbs'), b[-1]]
517 517 end
518 518 safe_join b
519 519 end
520 520 end
521 521
522 522 # Returns a h2 tag and sets the html title with the given arguments
523 523 def title(*args)
524 524 strings = args.map do |arg|
525 525 if arg.is_a?(Array) && arg.size >= 2
526 526 link_to(*arg)
527 527 else
528 528 h(arg.to_s)
529 529 end
530 530 end
531 531 html_title args.reverse.map {|s| (s.is_a?(Array) ? s.first : s).to_s}
532 532 content_tag('h2', strings.join(' &#187; ').html_safe)
533 533 end
534 534
535 535 # Sets the html title
536 536 # Returns the html title when called without arguments
537 537 # Current project name and app_title and automatically appended
538 538 # Exemples:
539 539 # html_title 'Foo', 'Bar'
540 540 # html_title # => 'Foo - Bar - My Project - Redmine'
541 541 def html_title(*args)
542 542 if args.empty?
543 543 title = @html_title || []
544 544 title << @project.name if @project
545 545 title << Setting.app_title unless Setting.app_title == title.last
546 546 title.reject(&:blank?).join(' - ')
547 547 else
548 548 @html_title ||= []
549 549 @html_title += args
550 550 end
551 551 end
552 552
553 553 # Returns the theme, controller name, and action as css classes for the
554 554 # HTML body.
555 555 def body_css_classes
556 556 css = []
557 557 if theme = Redmine::Themes.theme(Setting.ui_theme)
558 558 css << 'theme-' + theme.name
559 559 end
560 560
561 561 css << 'project-' + @project.identifier if @project && @project.identifier.present?
562 562 css << 'controller-' + controller_name
563 563 css << 'action-' + action_name
564 564 if UserPreference::TEXTAREA_FONT_OPTIONS.include?(User.current.pref.textarea_font)
565 565 css << "textarea-#{User.current.pref.textarea_font}"
566 566 end
567 567 css.join(' ')
568 568 end
569 569
570 570 def accesskey(s)
571 571 @used_accesskeys ||= []
572 572 key = Redmine::AccessKeys.key_for(s)
573 573 return nil if @used_accesskeys.include?(key)
574 574 @used_accesskeys << key
575 575 key
576 576 end
577 577
578 578 # Formats text according to system settings.
579 579 # 2 ways to call this method:
580 580 # * with a String: textilizable(text, options)
581 581 # * with an object and one of its attribute: textilizable(issue, :description, options)
582 582 def textilizable(*args)
583 583 options = args.last.is_a?(Hash) ? args.pop : {}
584 584 case args.size
585 585 when 1
586 586 obj = options[:object]
587 587 text = args.shift
588 588 when 2
589 589 obj = args.shift
590 590 attr = args.shift
591 591 text = obj.send(attr).to_s
592 592 else
593 593 raise ArgumentError, 'invalid arguments to textilizable'
594 594 end
595 595 return '' if text.blank?
596 596 project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
597 597 @only_path = only_path = options.delete(:only_path) == false ? false : true
598 598
599 599 text = text.dup
600 600 macros = catch_macros(text)
601 601 text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr)
602 602
603 603 @parsed_headings = []
604 604 @heading_anchors = {}
605 605 @current_section = 0 if options[:edit_section_links]
606 606
607 607 parse_sections(text, project, obj, attr, only_path, options)
608 608 text = parse_non_pre_blocks(text, obj, macros) do |text|
609 609 [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links].each do |method_name|
610 610 send method_name, text, project, obj, attr, only_path, options
611 611 end
612 612 end
613 613 parse_headings(text, project, obj, attr, only_path, options)
614 614
615 615 if @parsed_headings.any?
616 616 replace_toc(text, @parsed_headings)
617 617 end
618 618
619 619 text.html_safe
620 620 end
621 621
622 622 def parse_non_pre_blocks(text, obj, macros)
623 623 s = StringScanner.new(text)
624 624 tags = []
625 625 parsed = ''
626 626 while !s.eos?
627 627 s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
628 628 text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
629 629 if tags.empty?
630 630 yield text
631 631 inject_macros(text, obj, macros) if macros.any?
632 632 else
633 633 inject_macros(text, obj, macros, false) if macros.any?
634 634 end
635 635 parsed << text
636 636 if tag
637 637 if closing
638 638 if tags.last && tags.last.casecmp(tag) == 0
639 639 tags.pop
640 640 end
641 641 else
642 642 tags << tag.downcase
643 643 end
644 644 parsed << full_tag
645 645 end
646 646 end
647 647 # Close any non closing tags
648 648 while tag = tags.pop
649 649 parsed << "</#{tag}>"
650 650 end
651 651 parsed
652 652 end
653 653
654 654 def parse_inline_attachments(text, project, obj, attr, only_path, options)
655 655 return if options[:inline_attachments] == false
656 656
657 657 # when using an image link, try to use an attachment, if possible
658 658 attachments = options[:attachments] || []
659 659 attachments += obj.attachments if obj.respond_to?(:attachments)
660 660 if attachments.present?
661 661 text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
662 662 filename, ext, alt, alttext = $1.downcase, $2, $3, $4
663 663 # search for the picture in attachments
664 664 if found = Attachment.latest_attach(attachments, CGI.unescape(filename))
665 665 image_url = download_named_attachment_url(found, found.filename, :only_path => only_path)
666 666 desc = found.description.to_s.gsub('"', '')
667 667 if !desc.blank? && alttext.blank?
668 668 alt = " title=\"#{desc}\" alt=\"#{desc}\""
669 669 end
670 670 "src=\"#{image_url}\"#{alt}"
671 671 else
672 672 m
673 673 end
674 674 end
675 675 end
676 676 end
677 677
678 678 # Wiki links
679 679 #
680 680 # Examples:
681 681 # [[mypage]]
682 682 # [[mypage|mytext]]
683 683 # wiki links can refer other project wikis, using project name or identifier:
684 684 # [[project:]] -> wiki starting page
685 685 # [[project:|mytext]]
686 686 # [[project:mypage]]
687 687 # [[project:mypage|mytext]]
688 688 def parse_wiki_links(text, project, obj, attr, only_path, options)
689 689 text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
690 690 link_project = project
691 691 esc, all, page, title = $1, $2, $3, $5
692 692 if esc.nil?
693 693 if page =~ /^([^\:]+)\:(.*)$/
694 694 identifier, page = $1, $2
695 695 link_project = Project.find_by_identifier(identifier) || Project.find_by_name(identifier)
696 696 title ||= identifier if page.blank?
697 697 end
698 698
699 699 if link_project && link_project.wiki
700 700 # extract anchor
701 701 anchor = nil
702 702 if page =~ /^(.+?)\#(.+)$/
703 703 page, anchor = $1, $2
704 704 end
705 705 anchor = sanitize_anchor_name(anchor) if anchor.present?
706 706 # check if page exists
707 707 wiki_page = link_project.wiki.find_page(page)
708 708 url = if anchor.present? && wiki_page.present? && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version)) && obj.page == wiki_page
709 709 "##{anchor}"
710 710 else
711 711 case options[:wiki_links]
712 712 when :local; "#{page.present? ? Wiki.titleize(page) : ''}.html" + (anchor.present? ? "##{anchor}" : '')
713 713 when :anchor; "##{page.present? ? Wiki.titleize(page) : title}" + (anchor.present? ? "_#{anchor}" : '') # used for single-file wiki export
714 714 else
715 715 wiki_page_id = page.present? ? Wiki.titleize(page) : nil
716 716 parent = wiki_page.nil? && obj.is_a?(WikiContent) && obj.page && project == link_project ? obj.page.title : nil
717 717 url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project,
718 718 :id => wiki_page_id, :version => nil, :anchor => anchor, :parent => parent)
719 719 end
720 720 end
721 721 link_to(title.present? ? title.html_safe : h(page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
722 722 else
723 723 # project or wiki doesn't exist
724 724 all
725 725 end
726 726 else
727 727 all
728 728 end
729 729 end
730 730 end
731 731
732 732 # Redmine links
733 733 #
734 734 # Examples:
735 735 # Issues:
736 736 # #52 -> Link to issue #52
737 737 # Changesets:
738 738 # r52 -> Link to revision 52
739 739 # commit:a85130f -> Link to scmid starting with a85130f
740 740 # Documents:
741 741 # document#17 -> Link to document with id 17
742 742 # document:Greetings -> Link to the document with title "Greetings"
743 743 # document:"Some document" -> Link to the document with title "Some document"
744 744 # Versions:
745 745 # version#3 -> Link to version with id 3
746 746 # version:1.0.0 -> Link to version named "1.0.0"
747 747 # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
748 748 # Attachments:
749 749 # attachment:file.zip -> Link to the attachment of the current object named file.zip
750 750 # Source files:
751 751 # source:some/file -> Link to the file located at /some/file in the project's repository
752 752 # source:some/file@52 -> Link to the file's revision 52
753 753 # source:some/file#L120 -> Link to line 120 of the file
754 754 # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
755 755 # export:some/file -> Force the download of the file
756 756 # Forum messages:
757 757 # message#1218 -> Link to message with id 1218
758 758 # Projects:
759 759 # project:someproject -> Link to project named "someproject"
760 760 # project#3 -> Link to project with id 3
761 761 #
762 762 # Links can refer other objects from other projects, using project identifier:
763 763 # identifier:r52
764 764 # identifier:document:"Some document"
765 765 # identifier:version:1.0.0
766 766 # identifier:source:some/file
767 767 def parse_redmine_links(text, default_project, obj, attr, only_path, options)
768 768 text.gsub!(%r{<a( [^>]+?)?>(.*?)</a>|([\s\(,\-\[\>]|^)(!)?(([a-z0-9\-_]+):)?(attachment|document|version|forum|news|message|project|commit|source|export)?(((#)|((([a-z0-9\-_]+)\|)?(r)))((\d+)((#note)?-(\d+))?)|(:)([^"\s<>][^\s<>]*?|"[^"]+?"))(?=(?=[[:punct:]][^A-Za-z0-9_/])|,|\s|\]|<|$)}) do |m|
769 769 tag_content, leading, esc, project_prefix, project_identifier, prefix, repo_prefix, repo_identifier, sep, identifier, comment_suffix, comment_id = $2, $3, $4, $5, $6, $7, $12, $13, $10 || $14 || $20, $16 || $21, $17, $19
770 770 if tag_content
771 771 $&
772 772 else
773 773 link = nil
774 774 project = default_project
775 775 if project_identifier
776 776 project = Project.visible.find_by_identifier(project_identifier)
777 777 end
778 778 if esc.nil?
779 779 if prefix.nil? && sep == 'r'
780 780 if project
781 781 repository = nil
782 782 if repo_identifier
783 783 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
784 784 else
785 785 repository = project.repository
786 786 end
787 787 # project.changesets.visible raises an SQL error because of a double join on repositories
788 788 if repository &&
789 789 (changeset = Changeset.visible.
790 790 find_by_repository_id_and_revision(repository.id, identifier))
791 791 link = link_to(h("#{project_prefix}#{repo_prefix}r#{identifier}"),
792 792 {:only_path => only_path, :controller => 'repositories',
793 793 :action => 'revision', :id => project,
794 794 :repository_id => repository.identifier_param,
795 795 :rev => changeset.revision},
796 796 :class => 'changeset',
797 797 :title => truncate_single_line_raw(changeset.comments, 100))
798 798 end
799 799 end
800 800 elsif sep == '#'
801 801 oid = identifier.to_i
802 802 case prefix
803 803 when nil
804 804 if oid.to_s == identifier &&
805 805 issue = Issue.visible.find_by_id(oid)
806 806 anchor = comment_id ? "note-#{comment_id}" : nil
807 807 link = link_to("##{oid}#{comment_suffix}",
808 808 issue_url(issue, :only_path => only_path, :anchor => anchor),
809 809 :class => issue.css_classes,
810 810 :title => "#{issue.tracker.name}: #{issue.subject.truncate(100)} (#{issue.status.name})")
811 811 end
812 812 when 'document'
813 813 if document = Document.visible.find_by_id(oid)
814 814 link = link_to(document.title, document_url(document, :only_path => only_path), :class => 'document')
815 815 end
816 816 when 'version'
817 817 if version = Version.visible.find_by_id(oid)
818 818 link = link_to(version.name, version_url(version, :only_path => only_path), :class => 'version')
819 819 end
820 820 when 'message'
821 821 if message = Message.visible.find_by_id(oid)
822 822 link = link_to_message(message, {:only_path => only_path}, :class => 'message')
823 823 end
824 824 when 'forum'
825 825 if board = Board.visible.find_by_id(oid)
826 826 link = link_to(board.name, project_board_url(board.project, board, :only_path => only_path), :class => 'board')
827 827 end
828 828 when 'news'
829 829 if news = News.visible.find_by_id(oid)
830 830 link = link_to(news.title, news_url(news, :only_path => only_path), :class => 'news')
831 831 end
832 832 when 'project'
833 833 if p = Project.visible.find_by_id(oid)
834 834 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
835 835 end
836 836 end
837 837 elsif sep == ':'
838 838 # removes the double quotes if any
839 839 name = identifier.gsub(%r{^"(.*)"$}, "\\1")
840 840 name = CGI.unescapeHTML(name)
841 841 case prefix
842 842 when 'document'
843 843 if project && document = project.documents.visible.find_by_title(name)
844 844 link = link_to(document.title, document_url(document, :only_path => only_path), :class => 'document')
845 845 end
846 846 when 'version'
847 847 if project && version = project.versions.visible.find_by_name(name)
848 848 link = link_to(version.name, version_url(version, :only_path => only_path), :class => 'version')
849 849 end
850 850 when 'forum'
851 851 if project && board = project.boards.visible.find_by_name(name)
852 852 link = link_to(board.name, project_board_url(board.project, board, :only_path => only_path), :class => 'board')
853 853 end
854 854 when 'news'
855 855 if project && news = project.news.visible.find_by_title(name)
856 856 link = link_to(news.title, news_url(news, :only_path => only_path), :class => 'news')
857 857 end
858 858 when 'commit', 'source', 'export'
859 859 if project
860 860 repository = nil
861 861 if name =~ %r{^(([a-z0-9\-_]+)\|)(.+)$}
862 862 repo_prefix, repo_identifier, name = $1, $2, $3
863 863 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
864 864 else
865 865 repository = project.repository
866 866 end
867 867 if prefix == 'commit'
868 868 if repository && (changeset = Changeset.visible.where("repository_id = ? AND scmid LIKE ?", repository.id, "#{name}%").first)
869 869 link = link_to h("#{project_prefix}#{repo_prefix}#{name}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :repository_id => repository.identifier_param, :rev => changeset.identifier},
870 870 :class => 'changeset',
871 871 :title => truncate_single_line_raw(changeset.comments, 100)
872 872 end
873 873 else
874 874 if repository && User.current.allowed_to?(:browse_repository, project)
875 875 name =~ %r{^[/\\]*(.*?)(@([^/\\@]+?))?(#(L\d+))?$}
876 876 path, rev, anchor = $1, $3, $5
877 877 link = link_to h("#{project_prefix}#{prefix}:#{repo_prefix}#{name}"), {:only_path => only_path, :controller => 'repositories', :action => (prefix == 'export' ? 'raw' : 'entry'), :id => project, :repository_id => repository.identifier_param,
878 878 :path => to_path_param(path),
879 879 :rev => rev,
880 880 :anchor => anchor},
881 881 :class => (prefix == 'export' ? 'source download' : 'source')
882 882 end
883 883 end
884 884 repo_prefix = nil
885 885 end
886 886 when 'attachment'
887 887 attachments = options[:attachments] || []
888 888 attachments += obj.attachments if obj.respond_to?(:attachments)
889 889 if attachments && attachment = Attachment.latest_attach(attachments, name)
890 890 link = link_to_attachment(attachment, :only_path => only_path, :download => true, :class => 'attachment')
891 891 end
892 892 when 'project'
893 893 if p = Project.visible.where("identifier = :s OR LOWER(name) = :s", :s => name.downcase).first
894 894 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
895 895 end
896 896 end
897 897 end
898 898 end
899 899 (leading + (link || "#{project_prefix}#{prefix}#{repo_prefix}#{sep}#{identifier}#{comment_suffix}"))
900 900 end
901 901 end
902 902 end
903 903
904 904 HEADING_RE = /(<h(\d)( [^>]+)?>(.+?)<\/h(\d)>)/i unless const_defined?(:HEADING_RE)
905 905
906 906 def parse_sections(text, project, obj, attr, only_path, options)
907 907 return unless options[:edit_section_links]
908 908 text.gsub!(HEADING_RE) do
909 909 heading, level = $1, $2
910 910 @current_section += 1
911 911 if @current_section > 1
912 912 content_tag('div',
913 913 link_to(l(:button_edit_section), options[:edit_section_links].merge(:section => @current_section),
914 914 :class => 'icon-only icon-edit'),
915 915 :class => "contextual heading-#{level}",
916 916 :title => l(:button_edit_section),
917 917 :id => "section-#{@current_section}") + heading.html_safe
918 918 else
919 919 heading
920 920 end
921 921 end
922 922 end
923 923
924 924 # Headings and TOC
925 925 # Adds ids and links to headings unless options[:headings] is set to false
926 926 def parse_headings(text, project, obj, attr, only_path, options)
927 927 return if options[:headings] == false
928 928
929 929 text.gsub!(HEADING_RE) do
930 930 level, attrs, content = $2.to_i, $3, $4
931 931 item = strip_tags(content).strip
932 932 anchor = sanitize_anchor_name(item)
933 933 # used for single-file wiki export
934 934 anchor = "#{obj.page.title}_#{anchor}" if options[:wiki_links] == :anchor && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version))
935 935 @heading_anchors[anchor] ||= 0
936 936 idx = (@heading_anchors[anchor] += 1)
937 937 if idx > 1
938 938 anchor = "#{anchor}-#{idx}"
939 939 end
940 940 @parsed_headings << [level, anchor, item]
941 941 "<a name=\"#{anchor}\"></a>\n<h#{level} #{attrs}>#{content}<a href=\"##{anchor}\" class=\"wiki-anchor\">&para;</a></h#{level}>"
942 942 end
943 943 end
944 944
945 945 MACROS_RE = /(
946 946 (!)? # escaping
947 947 (
948 948 \{\{ # opening tag
949 949 ([\w]+) # macro name
950 950 (\(([^\n\r]*?)\))? # optional arguments
951 951 ([\n\r].*?[\n\r])? # optional block of text
952 952 \}\} # closing tag
953 953 )
954 954 )/mx unless const_defined?(:MACROS_RE)
955 955
956 956 MACRO_SUB_RE = /(
957 957 \{\{
958 958 macro\((\d+)\)
959 959 \}\}
960 960 )/x unless const_defined?(:MACRO_SUB_RE)
961 961
962 962 # Extracts macros from text
963 963 def catch_macros(text)
964 964 macros = {}
965 965 text.gsub!(MACROS_RE) do
966 966 all, macro = $1, $4.downcase
967 967 if macro_exists?(macro) || all =~ MACRO_SUB_RE
968 968 index = macros.size
969 969 macros[index] = all
970 970 "{{macro(#{index})}}"
971 971 else
972 972 all
973 973 end
974 974 end
975 975 macros
976 976 end
977 977
978 978 # Executes and replaces macros in text
979 979 def inject_macros(text, obj, macros, execute=true)
980 980 text.gsub!(MACRO_SUB_RE) do
981 981 all, index = $1, $2.to_i
982 982 orig = macros.delete(index)
983 983 if execute && orig && orig =~ MACROS_RE
984 984 esc, all, macro, args, block = $2, $3, $4.downcase, $6.to_s, $7.try(:strip)
985 985 if esc.nil?
986 986 h(exec_macro(macro, obj, args, block) || all)
987 987 else
988 988 h(all)
989 989 end
990 990 elsif orig
991 991 h(orig)
992 992 else
993 993 h(all)
994 994 end
995 995 end
996 996 end
997 997
998 998 TOC_RE = /<p>\{\{((<|&lt;)|(>|&gt;))?toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
999 999
1000 1000 # Renders the TOC with given headings
1001 1001 def replace_toc(text, headings)
1002 1002 text.gsub!(TOC_RE) do
1003 1003 left_align, right_align = $2, $3
1004 1004 # Keep only the 4 first levels
1005 1005 headings = headings.select{|level, anchor, item| level <= 4}
1006 1006 if headings.empty?
1007 1007 ''
1008 1008 else
1009 1009 div_class = 'toc'
1010 1010 div_class << ' right' if right_align
1011 1011 div_class << ' left' if left_align
1012 1012 out = "<ul class=\"#{div_class}\"><li>"
1013 1013 root = headings.map(&:first).min
1014 1014 current = root
1015 1015 started = false
1016 1016 headings.each do |level, anchor, item|
1017 1017 if level > current
1018 1018 out << '<ul><li>' * (level - current)
1019 1019 elsif level < current
1020 1020 out << "</li></ul>\n" * (current - level) + "</li><li>"
1021 1021 elsif started
1022 1022 out << '</li><li>'
1023 1023 end
1024 1024 out << "<a href=\"##{anchor}\">#{item}</a>"
1025 1025 current = level
1026 1026 started = true
1027 1027 end
1028 1028 out << '</li></ul>' * (current - root)
1029 1029 out << '</li></ul>'
1030 1030 end
1031 1031 end
1032 1032 end
1033 1033
1034 1034 # Same as Rails' simple_format helper without using paragraphs
1035 1035 def simple_format_without_paragraph(text)
1036 1036 text.to_s.
1037 1037 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
1038 1038 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
1039 1039 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />'). # 1 newline -> br
1040 1040 html_safe
1041 1041 end
1042 1042
1043 1043 def lang_options_for_select(blank=true)
1044 1044 (blank ? [["(auto)", ""]] : []) + languages_options
1045 1045 end
1046 1046
1047 1047 def labelled_form_for(*args, &proc)
1048 1048 args << {} unless args.last.is_a?(Hash)
1049 1049 options = args.last
1050 1050 if args.first.is_a?(Symbol)
1051 1051 options.merge!(:as => args.shift)
1052 1052 end
1053 1053 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
1054 1054 form_for(*args, &proc)
1055 1055 end
1056 1056
1057 1057 def labelled_fields_for(*args, &proc)
1058 1058 args << {} unless args.last.is_a?(Hash)
1059 1059 options = args.last
1060 1060 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
1061 1061 fields_for(*args, &proc)
1062 1062 end
1063 1063
1064 1064 # Render the error messages for the given objects
1065 1065 def error_messages_for(*objects)
1066 1066 objects = objects.map {|o| o.is_a?(String) ? instance_variable_get("@#{o}") : o}.compact
1067 1067 errors = objects.map {|o| o.errors.full_messages}.flatten
1068 1068 render_error_messages(errors)
1069 1069 end
1070 1070
1071 1071 # Renders a list of error messages
1072 1072 def render_error_messages(errors)
1073 1073 html = ""
1074 1074 if errors.present?
1075 1075 html << "<div id='errorExplanation'><ul>\n"
1076 1076 errors.each do |error|
1077 1077 html << "<li>#{h error}</li>\n"
1078 1078 end
1079 1079 html << "</ul></div>\n"
1080 1080 end
1081 1081 html.html_safe
1082 1082 end
1083 1083
1084 1084 def delete_link(url, options={})
1085 1085 options = {
1086 1086 :method => :delete,
1087 1087 :data => {:confirm => l(:text_are_you_sure)},
1088 1088 :class => 'icon icon-del'
1089 1089 }.merge(options)
1090 1090
1091 1091 link_to l(:button_delete), url, options
1092 1092 end
1093 1093
1094 1094 def preview_link(url, form, target='preview', options={})
1095 1095 content_tag 'a', l(:label_preview), {
1096 1096 :href => "#",
1097 1097 :onclick => %|submitPreview("#{escape_javascript url_for(url)}", "#{escape_javascript form}", "#{escape_javascript target}"); return false;|,
1098 1098 :accesskey => accesskey(:preview)
1099 1099 }.merge(options)
1100 1100 end
1101 1101
1102 1102 def link_to_function(name, function, html_options={})
1103 1103 content_tag(:a, name, {:href => '#', :onclick => "#{function}; return false;"}.merge(html_options))
1104 1104 end
1105 1105
1106 1106 # Helper to render JSON in views
1107 1107 def raw_json(arg)
1108 1108 arg.to_json.to_s.gsub('/', '\/').html_safe
1109 1109 end
1110 1110
1111 1111 def back_url
1112 1112 url = params[:back_url]
1113 1113 if url.nil? && referer = request.env['HTTP_REFERER']
1114 1114 url = CGI.unescape(referer.to_s)
1115 1115 # URLs that contains the utf8=[checkmark] parameter added by Rails are
1116 1116 # parsed as invalid by URI.parse so the redirect to the back URL would
1117 1117 # not be accepted (ApplicationController#validate_back_url would return
1118 1118 # false)
1119 1119 url.gsub!(/(\?|&)utf8=\u2713&?/, '\1')
1120 1120 end
1121 1121 url
1122 1122 end
1123 1123
1124 1124 def back_url_hidden_field_tag
1125 1125 url = back_url
1126 1126 hidden_field_tag('back_url', url, :id => nil) unless url.blank?
1127 1127 end
1128 1128
1129 1129 def check_all_links(form_name)
1130 1130 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
1131 1131 " | ".html_safe +
1132 1132 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
1133 1133 end
1134 1134
1135 1135 def toggle_checkboxes_link(selector)
1136 1136 link_to_function '',
1137 1137 "toggleCheckboxesBySelector('#{selector}')",
1138 1138 :title => "#{l(:button_check_all)} / #{l(:button_uncheck_all)}",
1139 1139 :class => 'toggle-checkboxes'
1140 1140 end
1141 1141
1142 1142 def progress_bar(pcts, options={})
1143 1143 pcts = [pcts, pcts] unless pcts.is_a?(Array)
1144 1144 pcts = pcts.collect(&:round)
1145 1145 pcts[1] = pcts[1] - pcts[0]
1146 1146 pcts << (100 - pcts[1] - pcts[0])
1147 1147 titles = options[:titles].to_a
1148 1148 titles[0] = "#{pcts[0]}%" if titles[0].blank?
1149 1149 legend = options[:legend] || ''
1150 1150 content_tag('table',
1151 1151 content_tag('tr',
1152 1152 (pcts[0] > 0 ? content_tag('td', '', :style => "width: #{pcts[0]}%;", :class => 'closed', :title => titles[0]) : ''.html_safe) +
1153 1153 (pcts[1] > 0 ? content_tag('td', '', :style => "width: #{pcts[1]}%;", :class => 'done', :title => titles[1]) : ''.html_safe) +
1154 1154 (pcts[2] > 0 ? content_tag('td', '', :style => "width: #{pcts[2]}%;", :class => 'todo', :title => titles[2]) : ''.html_safe)
1155 1155 ), :class => "progress progress-#{pcts[0]}").html_safe +
1156 1156 content_tag('p', legend, :class => 'percent').html_safe
1157 1157 end
1158 1158
1159 1159 def checked_image(checked=true)
1160 1160 if checked
1161 1161 @checked_image_tag ||= content_tag(:span, nil, :class => 'icon-only icon-checked')
1162 1162 end
1163 1163 end
1164 1164
1165 1165 def context_menu(url)
1166 1166 unless @context_menu_included
1167 1167 content_for :header_tags do
1168 1168 javascript_include_tag('context_menu') +
1169 1169 stylesheet_link_tag('context_menu')
1170 1170 end
1171 1171 if l(:direction) == 'rtl'
1172 1172 content_for :header_tags do
1173 1173 stylesheet_link_tag('context_menu_rtl')
1174 1174 end
1175 1175 end
1176 1176 @context_menu_included = true
1177 1177 end
1178 1178 javascript_tag "contextMenuInit('#{ url_for(url) }')"
1179 1179 end
1180 1180
1181 1181 def calendar_for(field_id)
1182 1182 include_calendar_headers_tags
1183 1183 javascript_tag("$(function() { $('##{field_id}').addClass('date').datepickerFallback(datepickerOptions); });")
1184 1184 end
1185 1185
1186 1186 def include_calendar_headers_tags
1187 1187 unless @calendar_headers_tags_included
1188 1188 tags = ''.html_safe
1189 1189 @calendar_headers_tags_included = true
1190 1190 content_for :header_tags do
1191 1191 start_of_week = Setting.start_of_week
1192 1192 start_of_week = l(:general_first_day_of_week, :default => '1') if start_of_week.blank?
1193 1193 # Redmine uses 1..7 (monday..sunday) in settings and locales
1194 1194 # JQuery uses 0..6 (sunday..saturday), 7 needs to be changed to 0
1195 1195 start_of_week = start_of_week.to_i % 7
1196 1196 tags << javascript_tag(
1197 1197 "var datepickerOptions={dateFormat: 'yy-mm-dd', firstDay: #{start_of_week}, " +
1198 1198 "showOn: 'button', buttonImageOnly: true, buttonImage: '" +
1199 1199 path_to_image('/images/calendar.png') +
1200 1200 "', showButtonPanel: true, showWeek: true, showOtherMonths: true, " +
1201 1201 "selectOtherMonths: true, changeMonth: true, changeYear: true, " +
1202 1202 "beforeShow: beforeShowDatePicker};")
1203 1203 jquery_locale = l('jquery.locale', :default => current_language.to_s)
1204 1204 unless jquery_locale == 'en'
1205 1205 tags << javascript_include_tag("i18n/datepicker-#{jquery_locale}.js")
1206 1206 end
1207 1207 tags
1208 1208 end
1209 1209 end
1210 1210 end
1211 1211
1212 1212 # Overrides Rails' stylesheet_link_tag with themes and plugins support.
1213 1213 # Examples:
1214 1214 # stylesheet_link_tag('styles') # => picks styles.css from the current theme or defaults
1215 1215 # stylesheet_link_tag('styles', :plugin => 'foo) # => picks styles.css from plugin's assets
1216 1216 #
1217 1217 def stylesheet_link_tag(*sources)
1218 1218 options = sources.last.is_a?(Hash) ? sources.pop : {}
1219 1219 plugin = options.delete(:plugin)
1220 1220 sources = sources.map do |source|
1221 1221 if plugin
1222 1222 "/plugin_assets/#{plugin}/stylesheets/#{source}"
1223 1223 elsif current_theme && current_theme.stylesheets.include?(source)
1224 1224 current_theme.stylesheet_path(source)
1225 1225 else
1226 1226 source
1227 1227 end
1228 1228 end
1229 1229 super *sources, options
1230 1230 end
1231 1231
1232 1232 # Overrides Rails' image_tag with themes and plugins support.
1233 1233 # Examples:
1234 1234 # image_tag('image.png') # => picks image.png from the current theme or defaults
1235 1235 # image_tag('image.png', :plugin => 'foo) # => picks image.png from plugin's assets
1236 1236 #
1237 1237 def image_tag(source, options={})
1238 1238 if plugin = options.delete(:plugin)
1239 1239 source = "/plugin_assets/#{plugin}/images/#{source}"
1240 1240 elsif current_theme && current_theme.images.include?(source)
1241 1241 source = current_theme.image_path(source)
1242 1242 end
1243 1243 super source, options
1244 1244 end
1245 1245
1246 1246 # Overrides Rails' javascript_include_tag with plugins support
1247 1247 # Examples:
1248 1248 # javascript_include_tag('scripts') # => picks scripts.js from defaults
1249 1249 # javascript_include_tag('scripts', :plugin => 'foo) # => picks scripts.js from plugin's assets
1250 1250 #
1251 1251 def javascript_include_tag(*sources)
1252 1252 options = sources.last.is_a?(Hash) ? sources.pop : {}
1253 1253 if plugin = options.delete(:plugin)
1254 1254 sources = sources.map do |source|
1255 1255 if plugin
1256 1256 "/plugin_assets/#{plugin}/javascripts/#{source}"
1257 1257 else
1258 1258 source
1259 1259 end
1260 1260 end
1261 1261 end
1262 1262 super *sources, options
1263 1263 end
1264 1264
1265 1265 def sidebar_content?
1266 1266 content_for?(:sidebar) || view_layouts_base_sidebar_hook_response.present?
1267 1267 end
1268 1268
1269 1269 def view_layouts_base_sidebar_hook_response
1270 1270 @view_layouts_base_sidebar_hook_response ||= call_hook(:view_layouts_base_sidebar)
1271 1271 end
1272 1272
1273 1273 def email_delivery_enabled?
1274 1274 !!ActionMailer::Base.perform_deliveries
1275 1275 end
1276 1276
1277 1277 # Returns the avatar image tag for the given +user+ if avatars are enabled
1278 1278 # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
1279 1279 def avatar(user, options = { })
1280 1280 if Setting.gravatar_enabled?
1281 1281 options.merge!(:default => Setting.gravatar_default)
1282 1282 email = nil
1283 1283 if user.respond_to?(:mail)
1284 1284 email = user.mail
1285 1285 elsif user.to_s =~ %r{<(.+?)>}
1286 1286 email = $1
1287 1287 end
1288 1288 return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
1289 1289 else
1290 1290 ''
1291 1291 end
1292 1292 end
1293 1293
1294 1294 # Returns a link to edit user's avatar if avatars are enabled
1295 1295 def avatar_edit_link(user, options={})
1296 1296 if Setting.gravatar_enabled?
1297 1297 url = "https://gravatar.com"
1298 1298 link_to avatar(user, {:title => l(:button_edit)}.merge(options)), url, :target => '_blank'
1299 1299 end
1300 1300 end
1301 1301
1302 1302 def sanitize_anchor_name(anchor)
1303 1303 anchor.gsub(%r{[^\s\-\p{Word}]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1304 1304 end
1305 1305
1306 1306 # Returns the javascript tags that are included in the html layout head
1307 1307 def javascript_heads
1308 1308 tags = javascript_include_tag('jquery-1.11.1-ui-1.11.0-ujs-3.1.4', 'application', 'responsive')
1309 1309 unless User.current.pref.warn_on_leaving_unsaved == '0'
1310 1310 tags << "\n".html_safe + javascript_tag("$(window).load(function(){ warnLeavingUnsaved('#{escape_javascript l(:text_warn_on_leaving_unsaved)}'); });")
1311 1311 end
1312 1312 tags
1313 1313 end
1314 1314
1315 1315 def favicon
1316 1316 "<link rel='shortcut icon' href='#{favicon_path}' />".html_safe
1317 1317 end
1318 1318
1319 1319 # Returns the path to the favicon
1320 1320 def favicon_path
1321 1321 icon = (current_theme && current_theme.favicon?) ? current_theme.favicon_path : '/favicon.ico'
1322 1322 image_path(icon)
1323 1323 end
1324 1324
1325 1325 # Returns the full URL to the favicon
1326 1326 def favicon_url
1327 1327 # TODO: use #image_url introduced in Rails4
1328 1328 path = favicon_path
1329 1329 base = url_for(:controller => 'welcome', :action => 'index', :only_path => false)
1330 1330 base.sub(%r{/+$},'') + '/' + path.sub(%r{^/+},'')
1331 1331 end
1332 1332
1333 1333 def robot_exclusion_tag
1334 1334 '<meta name="robots" content="noindex,follow,noarchive" />'.html_safe
1335 1335 end
1336 1336
1337 1337 # Returns true if arg is expected in the API response
1338 1338 def include_in_api_response?(arg)
1339 1339 unless @included_in_api_response
1340 1340 param = params[:include]
1341 1341 @included_in_api_response = param.is_a?(Array) ? param.collect(&:to_s) : param.to_s.split(',')
1342 1342 @included_in_api_response.collect!(&:strip)
1343 1343 end
1344 1344 @included_in_api_response.include?(arg.to_s)
1345 1345 end
1346 1346
1347 1347 # Returns options or nil if nometa param or X-Redmine-Nometa header
1348 1348 # was set in the request
1349 1349 def api_meta(options)
1350 1350 if params[:nometa].present? || request.headers['X-Redmine-Nometa']
1351 1351 # compatibility mode for activeresource clients that raise
1352 1352 # an error when deserializing an array with attributes
1353 1353 nil
1354 1354 else
1355 1355 options
1356 1356 end
1357 1357 end
1358 1358
1359 1359 def generate_csv(&block)
1360 1360 decimal_separator = l(:general_csv_decimal_separator)
1361 1361 encoding = l(:general_csv_encoding)
1362 1362 end
1363 1363
1364 1364 private
1365 1365
1366 1366 def wiki_helper
1367 1367 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
1368 1368 extend helper
1369 1369 return self
1370 1370 end
1371 1371 end
@@ -1,1084 +1,1087
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 group = role.anonymous? ? Group.anonymous : Group.non_member
201 201 principal_ids = [user.id, group.id].compact
202 202 s = "(#{s} AND #{Project.table_name}.id NOT IN (SELECT project_id FROM #{Member.table_name} WHERE user_id IN (#{principal_ids.join(',')})))"
203 203 end
204 204 statement_by_role[role] = s
205 205 end
206 206 end
207 207 user.projects_by_role.each do |role, projects|
208 208 if role.allowed_to?(permission) && projects.any?
209 209 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
210 210 end
211 211 end
212 212 if statement_by_role.empty?
213 213 "1=0"
214 214 else
215 215 if block_given?
216 216 statement_by_role.each do |role, statement|
217 217 if s = yield(role, user)
218 218 statement_by_role[role] = "(#{statement} AND (#{s}))"
219 219 end
220 220 end
221 221 end
222 222 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
223 223 end
224 224 end
225 225 end
226 226
227 227 def override_roles(role)
228 228 @override_members ||= memberships.
229 229 joins(:principal).
230 230 where(:users => {:type => ['GroupAnonymous', 'GroupNonMember']}).to_a
231 231
232 232 group_class = role.anonymous? ? GroupAnonymous : GroupNonMember
233 233 member = @override_members.detect {|m| m.principal.is_a? group_class}
234 234 member ? member.roles.to_a : [role]
235 235 end
236 236
237 237 def principals
238 238 @principals ||= Principal.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).distinct
239 239 end
240 240
241 241 def users
242 242 @users ||= User.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).distinct
243 243 end
244 244
245 245 # Returns the Systemwide and project specific activities
246 246 def activities(include_inactive=false)
247 247 t = TimeEntryActivity.table_name
248 248 scope = TimeEntryActivity.where("#{t}.project_id IS NULL OR #{t}.project_id = ?", id)
249 249
250 250 overridden_activity_ids = self.time_entry_activities.pluck(:parent_id).compact
251 251 if overridden_activity_ids.any?
252 252 scope = scope.where("#{t}.id NOT IN (?)", overridden_activity_ids)
253 253 end
254 254 unless include_inactive
255 255 scope = scope.active
256 256 end
257 257 scope
258 258 end
259 259
260 260 # Will create a new Project specific Activity or update an existing one
261 261 #
262 262 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
263 263 # does not successfully save.
264 264 def update_or_create_time_entry_activity(id, activity_hash)
265 265 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
266 266 self.create_time_entry_activity_if_needed(activity_hash)
267 267 else
268 268 activity = project.time_entry_activities.find_by_id(id.to_i)
269 269 activity.update_attributes(activity_hash) if activity
270 270 end
271 271 end
272 272
273 273 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
274 274 #
275 275 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
276 276 # does not successfully save.
277 277 def create_time_entry_activity_if_needed(activity)
278 278 if activity['parent_id']
279 279 parent_activity = TimeEntryActivity.find(activity['parent_id'])
280 280 activity['name'] = parent_activity.name
281 281 activity['position'] = parent_activity.position
282 282 if Enumeration.overriding_change?(activity, parent_activity)
283 283 project_activity = self.time_entry_activities.create(activity)
284 284 if project_activity.new_record?
285 285 raise ActiveRecord::Rollback, "Overriding TimeEntryActivity was not successfully saved"
286 286 else
287 287 self.time_entries.
288 288 where(:activity_id => parent_activity.id).
289 289 update_all(:activity_id => project_activity.id)
290 290 end
291 291 end
292 292 end
293 293 end
294 294
295 295 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
296 296 #
297 297 # Examples:
298 298 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
299 299 # project.project_condition(false) => "projects.id = 1"
300 300 def project_condition(with_subprojects)
301 301 cond = "#{Project.table_name}.id = #{id}"
302 302 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
303 303 cond
304 304 end
305 305
306 306 def self.find(*args)
307 307 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
308 308 project = find_by_identifier(*args)
309 309 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
310 310 project
311 311 else
312 312 super
313 313 end
314 314 end
315 315
316 316 def self.find_by_param(*args)
317 317 self.find(*args)
318 318 end
319 319
320 320 alias :base_reload :reload
321 321 def reload(*args)
322 322 @principals = nil
323 323 @users = nil
324 324 @shared_versions = nil
325 325 @rolled_up_versions = nil
326 326 @rolled_up_trackers = nil
327 327 @all_issue_custom_fields = nil
328 328 @all_time_entry_custom_fields = nil
329 329 @to_param = nil
330 330 @allowed_parents = nil
331 331 @allowed_permissions = nil
332 332 @actions_allowed = nil
333 333 @start_date = nil
334 334 @due_date = nil
335 335 @override_members = nil
336 336 @assignable_users = nil
337 337 base_reload(*args)
338 338 end
339 339
340 340 def to_param
341 341 if new_record?
342 342 nil
343 343 else
344 344 # id is used for projects with a numeric identifier (compatibility)
345 345 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
346 346 end
347 347 end
348 348
349 349 def active?
350 350 self.status == STATUS_ACTIVE
351 351 end
352 352
353 353 def archived?
354 354 self.status == STATUS_ARCHIVED
355 355 end
356 356
357 357 # Archives the project and its descendants
358 358 def archive
359 359 # Check that there is no issue of a non descendant project that is assigned
360 360 # to one of the project or descendant versions
361 361 version_ids = self_and_descendants.joins(:versions).pluck("#{Version.table_name}.id")
362 362
363 363 if version_ids.any? &&
364 364 Issue.
365 365 includes(:project).
366 366 where("#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?", lft, rgt).
367 367 where(:fixed_version_id => version_ids).
368 368 exists?
369 369 return false
370 370 end
371 371 Project.transaction do
372 372 archive!
373 373 end
374 374 true
375 375 end
376 376
377 377 # Unarchives the project
378 378 # All its ancestors must be active
379 379 def unarchive
380 380 return false if ancestors.detect {|a| !a.active?}
381 381 update_attribute :status, STATUS_ACTIVE
382 382 end
383 383
384 384 def close
385 385 self_and_descendants.status(STATUS_ACTIVE).update_all :status => STATUS_CLOSED
386 386 end
387 387
388 388 def reopen
389 389 self_and_descendants.status(STATUS_CLOSED).update_all :status => STATUS_ACTIVE
390 390 end
391 391
392 392 # Returns an array of projects the project can be moved to
393 393 # by the current user
394 394 def allowed_parents(user=User.current)
395 395 return @allowed_parents if @allowed_parents
396 396 @allowed_parents = Project.allowed_to(user, :add_subprojects).to_a
397 397 @allowed_parents = @allowed_parents - self_and_descendants
398 398 if user.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
399 399 @allowed_parents << nil
400 400 end
401 401 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
402 402 @allowed_parents << parent
403 403 end
404 404 @allowed_parents
405 405 end
406 406
407 407 # Sets the parent of the project with authorization check
408 408 def set_allowed_parent!(p)
409 409 ActiveSupport::Deprecation.warn "Project#set_allowed_parent! is deprecated and will be removed in Redmine 4, use #safe_attributes= instead."
410 410 p = p.id if p.is_a?(Project)
411 411 send :safe_attributes, {:project_id => p}
412 412 save
413 413 end
414 414
415 415 # Sets the parent of the project and saves the project
416 416 # Argument can be either a Project, a String, a Fixnum or nil
417 417 def set_parent!(p)
418 418 if p.is_a?(Project)
419 419 self.parent = p
420 420 else
421 421 self.parent_id = p
422 422 end
423 423 save
424 424 end
425 425
426 426 # Returns a scope of the trackers used by the project and its active sub projects
427 427 def rolled_up_trackers(include_subprojects=true)
428 428 if include_subprojects
429 429 @rolled_up_trackers ||= rolled_up_trackers_base_scope.
430 430 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ?", lft, rgt)
431 431 else
432 432 rolled_up_trackers_base_scope.
433 433 where(:projects => {:id => id})
434 434 end
435 435 end
436 436
437 437 def rolled_up_trackers_base_scope
438 438 Tracker.
439 439 joins(projects: :enabled_modules).
440 440 where("#{Project.table_name}.status <> ?", STATUS_ARCHIVED).
441 441 where(:enabled_modules => {:name => 'issue_tracking'}).
442 442 distinct.
443 443 sorted
444 444 end
445 445
446 446 # Closes open and locked project versions that are completed
447 447 def close_completed_versions
448 448 Version.transaction do
449 449 versions.where(:status => %w(open locked)).each do |version|
450 450 if version.completed?
451 451 version.update_attribute(:status, 'closed')
452 452 end
453 453 end
454 454 end
455 455 end
456 456
457 457 # Returns a scope of the Versions on subprojects
458 458 def rolled_up_versions
459 459 @rolled_up_versions ||=
460 460 Version.
461 461 joins(:project).
462 462 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> ?", lft, rgt, STATUS_ARCHIVED)
463 463 end
464 464
465 465 # Returns a scope of the Versions used by the project
466 466 def shared_versions
467 467 if new_record?
468 468 Version.
469 469 joins(:project).
470 470 preload(:project).
471 471 where("#{Project.table_name}.status <> ? AND #{Version.table_name}.sharing = 'system'", STATUS_ARCHIVED)
472 472 else
473 473 @shared_versions ||= begin
474 474 r = root? ? self : root
475 475 Version.
476 476 joins(:project).
477 477 preload(:project).
478 478 where("#{Project.table_name}.id = #{id}" +
479 479 " OR (#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND (" +
480 480 " #{Version.table_name}.sharing = 'system'" +
481 481 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
482 482 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
483 483 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
484 484 "))")
485 485 end
486 486 end
487 487 end
488 488
489 489 # Returns a hash of project users grouped by role
490 490 def users_by_role
491 491 members.includes(:user, :roles).inject({}) do |h, m|
492 492 m.roles.each do |r|
493 493 h[r] ||= []
494 494 h[r] << m.user
495 495 end
496 496 h
497 497 end
498 498 end
499 499
500 500 # Adds user as a project member with the default role
501 501 # Used for when a non-admin user creates a project
502 502 def add_default_member(user)
503 503 role = self.class.default_member_role
504 504 member = Member.new(:project => self, :principal => user, :roles => [role])
505 505 self.members << member
506 506 member
507 507 end
508 508
509 509 # Default role that is given to non-admin users that
510 510 # create a project
511 511 def self.default_member_role
512 512 Role.givable.find_by_id(Setting.new_project_user_role_id.to_i) || Role.givable.first
513 513 end
514 514
515 515 # Deletes all project's members
516 516 def delete_all_members
517 517 me, mr = Member.table_name, MemberRole.table_name
518 518 self.class.connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
519 519 Member.where(:project_id => id).delete_all
520 520 end
521 521
522 522 # Return a Principal scope of users/groups issues can be assigned to
523 523 def assignable_users(tracker=nil)
524 524 return @assignable_users[tracker] if @assignable_users && @assignable_users[tracker]
525 525
526 526 types = ['User']
527 527 types << 'Group' if Setting.issue_group_assignment?
528 528
529 529 scope = Principal.
530 530 active.
531 531 joins(:members => :roles).
532 532 where(:type => types, :members => {:project_id => id}, :roles => {:assignable => true}).
533 533 distinct.
534 534 sorted
535 535
536 536 if tracker
537 537 # Rejects users that cannot the view the tracker
538 538 roles = Role.where(:assignable => true).select {|role| role.permissions_tracker?(:view_issues, tracker)}
539 539 scope = scope.where(:roles => {:id => roles.map(&:id)})
540 540 end
541 541
542 542 @assignable_users ||= {}
543 543 @assignable_users[tracker] = scope
544 544 end
545 545
546 546 # Returns the mail addresses of users that should be always notified on project events
547 547 def recipients
548 548 notified_users.collect {|user| user.mail}
549 549 end
550 550
551 551 # Returns the users that should be notified on project events
552 552 def notified_users
553 553 # TODO: User part should be extracted to User#notify_about?
554 554 members.preload(:principal).select {|m| m.principal.present? && (m.mail_notification? || m.principal.mail_notification == 'all')}.collect {|m| m.principal}
555 555 end
556 556
557 557 # Returns a scope of all custom fields enabled for project issues
558 558 # (explicitly associated custom fields and custom fields enabled for all projects)
559 559 def all_issue_custom_fields
560 560 if new_record?
561 561 @all_issue_custom_fields ||= IssueCustomField.
562 562 sorted.
563 563 where("is_for_all = ? OR id IN (?)", true, issue_custom_field_ids)
564 564 else
565 565 @all_issue_custom_fields ||= IssueCustomField.
566 566 sorted.
567 567 where("is_for_all = ? OR id IN (SELECT DISTINCT cfp.custom_field_id" +
568 568 " FROM #{table_name_prefix}custom_fields_projects#{table_name_suffix} cfp" +
569 569 " WHERE cfp.project_id = ?)", true, id)
570 570 end
571 571 end
572 572
573 573 def project
574 574 self
575 575 end
576 576
577 577 def <=>(project)
578 578 name.casecmp(project.name)
579 579 end
580 580
581 581 def to_s
582 582 name
583 583 end
584 584
585 585 # Returns a short description of the projects (first lines)
586 586 def short_description(length = 255)
587 587 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
588 588 end
589 589
590 590 def css_classes
591 591 s = 'project'
592 592 s << ' root' if root?
593 593 s << ' child' if child?
594 594 s << (leaf? ? ' leaf' : ' parent')
595 595 unless active?
596 596 if archived?
597 597 s << ' archived'
598 598 else
599 599 s << ' closed'
600 600 end
601 601 end
602 602 s
603 603 end
604 604
605 605 # The earliest start date of a project, based on it's issues and versions
606 606 def start_date
607 607 @start_date ||= [
608 608 issues.minimum('start_date'),
609 609 shared_versions.minimum('effective_date'),
610 610 Issue.fixed_version(shared_versions).minimum('start_date')
611 611 ].compact.min
612 612 end
613 613
614 614 # The latest due date of an issue or version
615 615 def due_date
616 616 @due_date ||= [
617 617 issues.maximum('due_date'),
618 618 shared_versions.maximum('effective_date'),
619 619 Issue.fixed_version(shared_versions).maximum('due_date')
620 620 ].compact.max
621 621 end
622 622
623 623 def overdue?
624 624 active? && !due_date.nil? && (due_date < User.current.today)
625 625 end
626 626
627 627 # Returns the percent completed for this project, based on the
628 628 # progress on it's versions.
629 629 def completed_percent(options={:include_subprojects => false})
630 630 if options.delete(:include_subprojects)
631 631 total = self_and_descendants.collect(&:completed_percent).sum
632 632
633 633 total / self_and_descendants.count
634 634 else
635 635 if versions.count > 0
636 636 total = versions.collect(&:completed_percent).sum
637 637
638 638 total / versions.count
639 639 else
640 640 100
641 641 end
642 642 end
643 643 end
644 644
645 645 # Return true if this project allows to do the specified action.
646 646 # action can be:
647 647 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
648 648 # * a permission Symbol (eg. :edit_project)
649 649 def allows_to?(action)
650 650 if archived?
651 651 # No action allowed on archived projects
652 652 return false
653 653 end
654 654 unless active? || Redmine::AccessControl.read_action?(action)
655 655 # No write action allowed on closed projects
656 656 return false
657 657 end
658 658 # No action allowed on disabled modules
659 659 if action.is_a? Hash
660 660 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
661 661 else
662 662 allowed_permissions.include? action
663 663 end
664 664 end
665 665
666 666 # Return the enabled module with the given name
667 667 # or nil if the module is not enabled for the project
668 668 def enabled_module(name)
669 669 name = name.to_s
670 670 enabled_modules.detect {|m| m.name == name}
671 671 end
672 672
673 673 # Return true if the module with the given name is enabled
674 674 def module_enabled?(name)
675 675 enabled_module(name).present?
676 676 end
677 677
678 678 def enabled_module_names=(module_names)
679 679 if module_names && module_names.is_a?(Array)
680 680 module_names = module_names.collect(&:to_s).reject(&:blank?)
681 681 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
682 682 else
683 683 enabled_modules.clear
684 684 end
685 685 end
686 686
687 687 # Returns an array of the enabled modules names
688 688 def enabled_module_names
689 689 enabled_modules.collect(&:name)
690 690 end
691 691
692 692 # Enable a specific module
693 693 #
694 694 # Examples:
695 695 # project.enable_module!(:issue_tracking)
696 696 # project.enable_module!("issue_tracking")
697 697 def enable_module!(name)
698 698 enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
699 699 end
700 700
701 701 # Disable a module if it exists
702 702 #
703 703 # Examples:
704 704 # project.disable_module!(:issue_tracking)
705 705 # project.disable_module!("issue_tracking")
706 706 # project.disable_module!(project.enabled_modules.first)
707 707 def disable_module!(target)
708 708 target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
709 709 target.destroy unless target.blank?
710 710 end
711 711
712 712 safe_attributes 'name',
713 713 'description',
714 714 'homepage',
715 715 'is_public',
716 716 'identifier',
717 717 'custom_field_values',
718 718 'custom_fields',
719 719 'tracker_ids',
720 720 'issue_custom_field_ids',
721 721 'parent_id',
722 722 'default_version_id'
723 723
724 724 safe_attributes 'enabled_module_names',
725 725 :if => lambda {|project, user|
726 726 if project.new_record?
727 727 if user.admin?
728 728 true
729 729 else
730 730 default_member_role.has_permission?(:select_project_modules)
731 731 end
732 732 else
733 733 user.allowed_to?(:select_project_modules, project)
734 734 end
735 735 }
736 736
737 737 safe_attributes 'inherit_members',
738 738 :if => lambda {|project, user| project.parent.nil? || project.parent.visible?(user)}
739 739
740 740 def safe_attributes=(attrs, user=User.current)
741 741 return unless attrs.is_a?(Hash)
742 742 attrs = attrs.deep_dup
743 743
744 744 @unallowed_parent_id = nil
745 745 if new_record? || attrs.key?('parent_id')
746 746 parent_id_param = attrs['parent_id'].to_s
747 747 if new_record? || parent_id_param != parent_id.to_s
748 748 p = parent_id_param.present? ? Project.find_by_id(parent_id_param) : nil
749 749 unless allowed_parents(user).include?(p)
750 750 attrs.delete('parent_id')
751 751 @unallowed_parent_id = true
752 752 end
753 753 end
754 754 end
755 755
756 756 super(attrs, user)
757 757 end
758 758
759 759 # Returns an auto-generated project identifier based on the last identifier used
760 760 def self.next_identifier
761 761 p = Project.order('id DESC').first
762 762 p.nil? ? nil : p.identifier.to_s.succ
763 763 end
764 764
765 765 # Copies and saves the Project instance based on the +project+.
766 766 # Duplicates the source project's:
767 767 # * Wiki
768 768 # * Versions
769 769 # * Categories
770 770 # * Issues
771 771 # * Members
772 772 # * Queries
773 773 #
774 774 # Accepts an +options+ argument to specify what to copy
775 775 #
776 776 # Examples:
777 777 # project.copy(1) # => copies everything
778 778 # project.copy(1, :only => 'members') # => copies members only
779 779 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
780 780 def copy(project, options={})
781 781 project = project.is_a?(Project) ? project : Project.find(project)
782 782
783 783 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
784 784 to_be_copied = to_be_copied & Array.wrap(options[:only]) unless options[:only].nil?
785 785
786 786 Project.transaction do
787 787 if save
788 788 reload
789 789 to_be_copied.each do |name|
790 790 send "copy_#{name}", project
791 791 end
792 792 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
793 793 save
794 794 else
795 795 false
796 796 end
797 797 end
798 798 end
799 799
800 800 def member_principals
801 801 ActiveSupport::Deprecation.warn "Project#member_principals is deprecated and will be removed in Redmine 4.0. Use #memberships.active instead."
802 802 memberships.active
803 803 end
804 804
805 805 # Returns a new unsaved Project instance with attributes copied from +project+
806 806 def self.copy_from(project)
807 807 project = project.is_a?(Project) ? project : Project.find(project)
808 808 # clear unique attributes
809 809 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
810 810 copy = Project.new(attributes)
811 811 copy.enabled_module_names = project.enabled_module_names
812 812 copy.trackers = project.trackers
813 813 copy.custom_values = project.custom_values.collect {|v| v.clone}
814 814 copy.issue_custom_fields = project.issue_custom_fields
815 815 copy
816 816 end
817 817
818 818 # Yields the given block for each project with its level in the tree
819 def self.project_tree(projects, &block)
819 def self.project_tree(projects, options={}, &block)
820 820 ancestors = []
821 if options[:init_level] && projects.first
822 ancestors = projects.first.ancestors.to_a
823 end
821 824 projects.sort_by(&:lft).each do |project|
822 825 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
823 826 ancestors.pop
824 827 end
825 828 yield project, ancestors.size
826 829 ancestors << project
827 830 end
828 831 end
829 832
830 833 private
831 834
832 835 def update_inherited_members
833 836 if parent
834 837 if inherit_members? && !inherit_members_was
835 838 remove_inherited_member_roles
836 839 add_inherited_member_roles
837 840 elsif !inherit_members? && inherit_members_was
838 841 remove_inherited_member_roles
839 842 end
840 843 end
841 844 end
842 845
843 846 def remove_inherited_member_roles
844 847 member_roles = memberships.map(&:member_roles).flatten
845 848 member_role_ids = member_roles.map(&:id)
846 849 member_roles.each do |member_role|
847 850 if member_role.inherited_from && !member_role_ids.include?(member_role.inherited_from)
848 851 member_role.destroy
849 852 end
850 853 end
851 854 end
852 855
853 856 def add_inherited_member_roles
854 857 if inherit_members? && parent
855 858 parent.memberships.each do |parent_member|
856 859 member = Member.find_or_new(self.id, parent_member.user_id)
857 860 parent_member.member_roles.each do |parent_member_role|
858 861 member.member_roles << MemberRole.new(:role => parent_member_role.role, :inherited_from => parent_member_role.id)
859 862 end
860 863 member.save!
861 864 end
862 865 memberships.reset
863 866 end
864 867 end
865 868
866 869 def update_versions_from_hierarchy_change
867 870 Issue.update_versions_from_hierarchy_change(self)
868 871 end
869 872
870 873 def validate_parent
871 874 if @unallowed_parent_id
872 875 errors.add(:parent_id, :invalid)
873 876 elsif parent_id_changed?
874 877 unless parent.nil? || (parent.active? && move_possible?(parent))
875 878 errors.add(:parent_id, :invalid)
876 879 end
877 880 end
878 881 end
879 882
880 883 # Copies wiki from +project+
881 884 def copy_wiki(project)
882 885 # Check that the source project has a wiki first
883 886 unless project.wiki.nil?
884 887 wiki = self.wiki || Wiki.new
885 888 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
886 889 wiki_pages_map = {}
887 890 project.wiki.pages.each do |page|
888 891 # Skip pages without content
889 892 next if page.content.nil?
890 893 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
891 894 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
892 895 new_wiki_page.content = new_wiki_content
893 896 wiki.pages << new_wiki_page
894 897 wiki_pages_map[page.id] = new_wiki_page
895 898 end
896 899
897 900 self.wiki = wiki
898 901 wiki.save
899 902 # Reproduce page hierarchy
900 903 project.wiki.pages.each do |page|
901 904 if page.parent_id && wiki_pages_map[page.id]
902 905 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
903 906 wiki_pages_map[page.id].save
904 907 end
905 908 end
906 909 end
907 910 end
908 911
909 912 # Copies versions from +project+
910 913 def copy_versions(project)
911 914 project.versions.each do |version|
912 915 new_version = Version.new
913 916 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
914 917 self.versions << new_version
915 918 end
916 919 end
917 920
918 921 # Copies issue categories from +project+
919 922 def copy_issue_categories(project)
920 923 project.issue_categories.each do |issue_category|
921 924 new_issue_category = IssueCategory.new
922 925 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
923 926 self.issue_categories << new_issue_category
924 927 end
925 928 end
926 929
927 930 # Copies issues from +project+
928 931 def copy_issues(project)
929 932 # Stores the source issue id as a key and the copied issues as the
930 933 # value. Used to map the two together for issue relations.
931 934 issues_map = {}
932 935
933 936 # Store status and reopen locked/closed versions
934 937 version_statuses = versions.reject(&:open?).map {|version| [version, version.status]}
935 938 version_statuses.each do |version, status|
936 939 version.update_attribute :status, 'open'
937 940 end
938 941
939 942 # Get issues sorted by root_id, lft so that parent issues
940 943 # get copied before their children
941 944 project.issues.reorder('root_id, lft').each do |issue|
942 945 new_issue = Issue.new
943 946 new_issue.copy_from(issue, :subtasks => false, :link => false)
944 947 new_issue.project = self
945 948 # Changing project resets the custom field values
946 949 # TODO: handle this in Issue#project=
947 950 new_issue.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
948 951 # Reassign fixed_versions by name, since names are unique per project
949 952 if issue.fixed_version && issue.fixed_version.project == project
950 953 new_issue.fixed_version = self.versions.detect {|v| v.name == issue.fixed_version.name}
951 954 end
952 955 # Reassign version custom field values
953 956 new_issue.custom_field_values.each do |custom_value|
954 957 if custom_value.custom_field.field_format == 'version' && custom_value.value.present?
955 958 versions = Version.where(:id => custom_value.value).to_a
956 959 new_value = versions.map do |version|
957 960 if version.project == project
958 961 self.versions.detect {|v| v.name == version.name}.try(:id)
959 962 else
960 963 version.id
961 964 end
962 965 end
963 966 new_value.compact!
964 967 new_value = new_value.first unless custom_value.custom_field.multiple?
965 968 custom_value.value = new_value
966 969 end
967 970 end
968 971 # Reassign the category by name, since names are unique per project
969 972 if issue.category
970 973 new_issue.category = self.issue_categories.detect {|c| c.name == issue.category.name}
971 974 end
972 975 # Parent issue
973 976 if issue.parent_id
974 977 if copied_parent = issues_map[issue.parent_id]
975 978 new_issue.parent_issue_id = copied_parent.id
976 979 end
977 980 end
978 981
979 982 self.issues << new_issue
980 983 if new_issue.new_record?
981 984 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info?
982 985 else
983 986 issues_map[issue.id] = new_issue unless new_issue.new_record?
984 987 end
985 988 end
986 989
987 990 # Restore locked/closed version statuses
988 991 version_statuses.each do |version, status|
989 992 version.update_attribute :status, status
990 993 end
991 994
992 995 # Relations after in case issues related each other
993 996 project.issues.each do |issue|
994 997 new_issue = issues_map[issue.id]
995 998 unless new_issue
996 999 # Issue was not copied
997 1000 next
998 1001 end
999 1002
1000 1003 # Relations
1001 1004 issue.relations_from.each do |source_relation|
1002 1005 new_issue_relation = IssueRelation.new
1003 1006 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
1004 1007 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
1005 1008 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
1006 1009 new_issue_relation.issue_to = source_relation.issue_to
1007 1010 end
1008 1011 new_issue.relations_from << new_issue_relation
1009 1012 end
1010 1013
1011 1014 issue.relations_to.each do |source_relation|
1012 1015 new_issue_relation = IssueRelation.new
1013 1016 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
1014 1017 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
1015 1018 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
1016 1019 new_issue_relation.issue_from = source_relation.issue_from
1017 1020 end
1018 1021 new_issue.relations_to << new_issue_relation
1019 1022 end
1020 1023 end
1021 1024 end
1022 1025
1023 1026 # Copies members from +project+
1024 1027 def copy_members(project)
1025 1028 # Copy users first, then groups to handle members with inherited and given roles
1026 1029 members_to_copy = []
1027 1030 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
1028 1031 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
1029 1032
1030 1033 members_to_copy.each do |member|
1031 1034 new_member = Member.new
1032 1035 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
1033 1036 # only copy non inherited roles
1034 1037 # inherited roles will be added when copying the group membership
1035 1038 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
1036 1039 next if role_ids.empty?
1037 1040 new_member.role_ids = role_ids
1038 1041 new_member.project = self
1039 1042 self.members << new_member
1040 1043 end
1041 1044 end
1042 1045
1043 1046 # Copies queries from +project+
1044 1047 def copy_queries(project)
1045 1048 project.queries.each do |query|
1046 1049 new_query = IssueQuery.new
1047 1050 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria", "user_id", "type")
1048 1051 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
1049 1052 new_query.project = self
1050 1053 new_query.user_id = query.user_id
1051 1054 new_query.role_ids = query.role_ids if query.visibility == IssueQuery::VISIBILITY_ROLES
1052 1055 self.queries << new_query
1053 1056 end
1054 1057 end
1055 1058
1056 1059 # Copies boards from +project+
1057 1060 def copy_boards(project)
1058 1061 project.boards.each do |board|
1059 1062 new_board = Board.new
1060 1063 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
1061 1064 new_board.project = self
1062 1065 self.boards << new_board
1063 1066 end
1064 1067 end
1065 1068
1066 1069 def allowed_permissions
1067 1070 @allowed_permissions ||= begin
1068 1071 module_names = enabled_modules.loaded? ? enabled_modules.map(&:name) : enabled_modules.pluck(:name)
1069 1072 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
1070 1073 end
1071 1074 end
1072 1075
1073 1076 def allowed_actions
1074 1077 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
1075 1078 end
1076 1079
1077 1080 # Archives subprojects recursively
1078 1081 def archive!
1079 1082 children.each do |subproject|
1080 1083 subproject.send :archive!
1081 1084 end
1082 1085 update_attribute :status, STATUS_ARCHIVED
1083 1086 end
1084 1087 end
@@ -1,43 +1,44
1 1 <div class="contextual">
2 2 <%= link_to l(:label_project_new), new_project_path, :class => 'icon icon-add' %>
3 3 </div>
4 4
5 5 <%= title l(:label_project_plural) %>
6 6
7 7 <%= form_tag({}, :method => :get) do %>
8 8 <fieldset><legend><%= l(:label_filter_plural) %></legend>
9 9 <label for='status'><%= l(:field_status) %> :</label>
10 10 <%= select_tag 'status', project_status_options_for_select(@status), :class => "small", :onchange => "this.form.submit(); return false;" %>
11 11 <label for='name'><%= l(:label_project) %>:</label>
12 12 <%= text_field_tag 'name', params[:name], :size => 30 %>
13 13 <%= submit_tag l(:button_apply), :class => "small", :name => nil %>
14 14 <%= link_to l(:button_clear), admin_projects_path, :class => 'icon icon-reload' %>
15 15 </fieldset>
16 16 <% end %>
17 17 &nbsp;
18 18
19 19 <div class="autoscroll">
20 20 <table class="list">
21 21 <thead><tr>
22 22 <th><%=l(:label_project)%></th>
23 23 <th><%=l(:field_is_public)%></th>
24 24 <th><%=l(:field_created_on)%></th>
25 25 <th></th>
26 26 </tr></thead>
27 27 <tbody>
28 <% project_tree(@projects) do |project, level| %>
28 <% project_tree(@projects, :init_level => true) do |project, level| %>
29 29 <tr class="<%= cycle("odd", "even") %> <%= project.css_classes %> <%= level > 0 ? "idnt idnt-#{level}" : nil %>">
30 30 <td class="name"><span><%= link_to_project_settings(project, {}, :title => project.short_description) %></span></td>
31 31 <td><%= checked_image project.is_public? %></td>
32 32 <td><%= format_date(project.created_on) %></td>
33 33 <td class="buttons">
34 34 <%= link_to(l(:button_archive), archive_project_path(project, :status => params[:status]), :data => {:confirm => l(:text_are_you_sure)}, :method => :post, :class => 'icon icon-lock') unless project.archived? %>
35 35 <%= link_to(l(:button_unarchive), unarchive_project_path(project, :status => params[:status]), :method => :post, :class => 'icon icon-unlock') if project.archived? && (project.parent.nil? || !project.parent.archived?) %>
36 36 <%= link_to(l(:button_copy), copy_project_path(project), :class => 'icon icon-copy') %>
37 37 <%= link_to(l(:button_delete), project_path(project), :method => :delete, :class => 'icon icon-del') %>
38 38 </td>
39 39 </tr>
40 40 <% end %>
41 41 </tbody>
42 42 </table>
43 43 </div>
44 <span class="pagination"><%= pagination_links_full @project_pages, @project_count %></span> No newline at end of file
General Comments 0
You need to be logged in to leave comments. Login now