##// END OF EJS Templates
Merged r11760 from trunk (#13850)....
Jean-Philippe Lang -
r11607:af632568e3e9
parent child
Show More
@@ -1,289 +1,290
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2013 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 Version < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 after_update :update_issues_from_sharing_change
21 21 belongs_to :project
22 22 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
23 23 acts_as_customizable
24 24 acts_as_attachable :view_permission => :view_files,
25 25 :delete_permission => :manage_files
26 26
27 27 VERSION_STATUSES = %w(open locked closed)
28 28 VERSION_SHARINGS = %w(none descendants hierarchy tree system)
29 29
30 30 validates_presence_of :name
31 31 validates_uniqueness_of :name, :scope => [:project_id]
32 32 validates_length_of :name, :maximum => 60
33 33 validates :effective_date, :date => true
34 34 validates_inclusion_of :status, :in => VERSION_STATUSES
35 35 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
36 36
37 37 scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
38 38 scope :open, lambda { where(:status => 'open') }
39 39 scope :visible, lambda {|*args|
40 40 includes(:project).where(Project.allowed_to_condition(args.first || User.current, :view_issues))
41 41 }
42 42
43 43 safe_attributes 'name',
44 44 'description',
45 45 'effective_date',
46 46 'due_date',
47 47 'wiki_page_title',
48 48 'status',
49 49 'sharing',
50 'custom_field_values'
50 'custom_field_values',
51 'custom_fields'
51 52
52 53 # Returns true if +user+ or current user is allowed to view the version
53 54 def visible?(user=User.current)
54 55 user.allowed_to?(:view_issues, self.project)
55 56 end
56 57
57 58 # Version files have same visibility as project files
58 59 def attachments_visible?(*args)
59 60 project.present? && project.attachments_visible?(*args)
60 61 end
61 62
62 63 def start_date
63 64 @start_date ||= fixed_issues.minimum('start_date')
64 65 end
65 66
66 67 def due_date
67 68 effective_date
68 69 end
69 70
70 71 def due_date=(arg)
71 72 self.effective_date=(arg)
72 73 end
73 74
74 75 # Returns the total estimated time for this version
75 76 # (sum of leaves estimated_hours)
76 77 def estimated_hours
77 78 @estimated_hours ||= fixed_issues.leaves.sum(:estimated_hours).to_f
78 79 end
79 80
80 81 # Returns the total reported time for this version
81 82 def spent_hours
82 83 @spent_hours ||= TimeEntry.joins(:issue).where("#{Issue.table_name}.fixed_version_id = ?", id).sum(:hours).to_f
83 84 end
84 85
85 86 def closed?
86 87 status == 'closed'
87 88 end
88 89
89 90 def open?
90 91 status == 'open'
91 92 end
92 93
93 94 # Returns true if the version is completed: due date reached and no open issues
94 95 def completed?
95 96 effective_date && (effective_date < Date.today) && (open_issues_count == 0)
96 97 end
97 98
98 99 def behind_schedule?
99 100 if completed_percent == 100
100 101 return false
101 102 elsif due_date && start_date
102 103 done_date = start_date + ((due_date - start_date+1)* completed_percent/100).floor
103 104 return done_date <= Date.today
104 105 else
105 106 false # No issues so it's not late
106 107 end
107 108 end
108 109
109 110 # Returns the completion percentage of this version based on the amount of open/closed issues
110 111 # and the time spent on the open issues.
111 112 def completed_percent
112 113 if issues_count == 0
113 114 0
114 115 elsif open_issues_count == 0
115 116 100
116 117 else
117 118 issues_progress(false) + issues_progress(true)
118 119 end
119 120 end
120 121
121 122 # TODO: remove in Redmine 3.0
122 123 def completed_pourcent
123 124 ActiveSupport::Deprecation.warn "Version#completed_pourcent is deprecated and will be removed in Redmine 3.0. Please use #completed_percent instead."
124 125 completed_percent
125 126 end
126 127
127 128 # Returns the percentage of issues that have been marked as 'closed'.
128 129 def closed_percent
129 130 if issues_count == 0
130 131 0
131 132 else
132 133 issues_progress(false)
133 134 end
134 135 end
135 136
136 137 # TODO: remove in Redmine 3.0
137 138 def closed_pourcent
138 139 ActiveSupport::Deprecation.warn "Version#closed_pourcent is deprecated and will be removed in Redmine 3.0. Please use #closed_percent instead."
139 140 closed_percent
140 141 end
141 142
142 143 # Returns true if the version is overdue: due date reached and some open issues
143 144 def overdue?
144 145 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
145 146 end
146 147
147 148 # Returns assigned issues count
148 149 def issues_count
149 150 load_issue_counts
150 151 @issue_count
151 152 end
152 153
153 154 # Returns the total amount of open issues for this version.
154 155 def open_issues_count
155 156 load_issue_counts
156 157 @open_issues_count
157 158 end
158 159
159 160 # Returns the total amount of closed issues for this version.
160 161 def closed_issues_count
161 162 load_issue_counts
162 163 @closed_issues_count
163 164 end
164 165
165 166 def wiki_page
166 167 if project.wiki && !wiki_page_title.blank?
167 168 @wiki_page ||= project.wiki.find_page(wiki_page_title)
168 169 end
169 170 @wiki_page
170 171 end
171 172
172 173 def to_s; name end
173 174
174 175 def to_s_with_project
175 176 "#{project} - #{name}"
176 177 end
177 178
178 179 # Versions are sorted by effective_date and name
179 180 # Those with no effective_date are at the end, sorted by name
180 181 def <=>(version)
181 182 if self.effective_date
182 183 if version.effective_date
183 184 if self.effective_date == version.effective_date
184 185 name == version.name ? id <=> version.id : name <=> version.name
185 186 else
186 187 self.effective_date <=> version.effective_date
187 188 end
188 189 else
189 190 -1
190 191 end
191 192 else
192 193 if version.effective_date
193 194 1
194 195 else
195 196 name == version.name ? id <=> version.id : name <=> version.name
196 197 end
197 198 end
198 199 end
199 200
200 201 def self.fields_for_order_statement(table=nil)
201 202 table ||= table_name
202 203 ["(CASE WHEN #{table}.effective_date IS NULL THEN 1 ELSE 0 END)", "#{table}.effective_date", "#{table}.name", "#{table}.id"]
203 204 end
204 205
205 206 scope :sorted, order(fields_for_order_statement)
206 207
207 208 # Returns the sharings that +user+ can set the version to
208 209 def allowed_sharings(user = User.current)
209 210 VERSION_SHARINGS.select do |s|
210 211 if sharing == s
211 212 true
212 213 else
213 214 case s
214 215 when 'system'
215 216 # Only admin users can set a systemwide sharing
216 217 user.admin?
217 218 when 'hierarchy', 'tree'
218 219 # Only users allowed to manage versions of the root project can
219 220 # set sharing to hierarchy or tree
220 221 project.nil? || user.allowed_to?(:manage_versions, project.root)
221 222 else
222 223 true
223 224 end
224 225 end
225 226 end
226 227 end
227 228
228 229 private
229 230
230 231 def load_issue_counts
231 232 unless @issue_count
232 233 @open_issues_count = 0
233 234 @closed_issues_count = 0
234 235 fixed_issues.count(:all, :group => :status).each do |status, count|
235 236 if status.is_closed?
236 237 @closed_issues_count += count
237 238 else
238 239 @open_issues_count += count
239 240 end
240 241 end
241 242 @issue_count = @open_issues_count + @closed_issues_count
242 243 end
243 244 end
244 245
245 246 # Update the issue's fixed versions. Used if a version's sharing changes.
246 247 def update_issues_from_sharing_change
247 248 if sharing_changed?
248 249 if VERSION_SHARINGS.index(sharing_was).nil? ||
249 250 VERSION_SHARINGS.index(sharing).nil? ||
250 251 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
251 252 Issue.update_versions_from_sharing_change self
252 253 end
253 254 end
254 255 end
255 256
256 257 # Returns the average estimated time of assigned issues
257 258 # or 1 if no issue has an estimated time
258 259 # Used to weigth unestimated issues in progress calculation
259 260 def estimated_average
260 261 if @estimated_average.nil?
261 262 average = fixed_issues.average(:estimated_hours).to_f
262 263 if average == 0
263 264 average = 1
264 265 end
265 266 @estimated_average = average
266 267 end
267 268 @estimated_average
268 269 end
269 270
270 271 # Returns the total progress of open or closed issues. The returned percentage takes into account
271 272 # the amount of estimated time set for this version.
272 273 #
273 274 # Examples:
274 275 # issues_progress(true) => returns the progress percentage for open issues.
275 276 # issues_progress(false) => returns the progress percentage for closed issues.
276 277 def issues_progress(open)
277 278 @issues_progress ||= {}
278 279 @issues_progress[open] ||= begin
279 280 progress = 0
280 281 if issues_count > 0
281 282 ratio = open ? 'done_ratio' : 100
282 283
283 284 done = fixed_issues.open(open).sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}").to_f
284 285 progress = done / (estimated_average * issues_count)
285 286 end
286 287 progress
287 288 end
288 289 end
289 290 end
@@ -1,135 +1,158
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2013 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../../../test_helper', __FILE__)
19 19
20 20 class Redmine::ApiTest::VersionsTest < Redmine::ApiTest::Base
21 21 fixtures :projects, :trackers, :issue_statuses, :issues,
22 22 :enumerations, :users, :issue_categories,
23 23 :projects_trackers,
24 24 :roles,
25 25 :member_roles,
26 26 :members,
27 27 :enabled_modules,
28 28 :versions
29 29
30 30 def setup
31 31 Setting.rest_api_enabled = '1'
32 32 end
33 33
34 34 context "/projects/:project_id/versions" do
35 35 context "GET" do
36 36 should "return project versions" do
37 37 get '/projects/1/versions.xml'
38 38
39 39 assert_response :success
40 40 assert_equal 'application/xml', @response.content_type
41 41 assert_tag :tag => 'versions',
42 42 :attributes => {:type => 'array'},
43 43 :child => {
44 44 :tag => 'version',
45 45 :child => {
46 46 :tag => 'id',
47 47 :content => '2',
48 48 :sibling => {
49 49 :tag => 'name',
50 50 :content => '1.0'
51 51 }
52 52 }
53 53 }
54 54 end
55 55 end
56 56
57 57 context "POST" do
58 58 should "create the version" do
59 59 assert_difference 'Version.count' do
60 60 post '/projects/1/versions.xml', {:version => {:name => 'API test'}}, credentials('jsmith')
61 61 end
62 62
63 63 version = Version.first(:order => 'id DESC')
64 64 assert_equal 'API test', version.name
65 65
66 66 assert_response :created
67 67 assert_equal 'application/xml', @response.content_type
68 68 assert_tag 'version', :child => {:tag => 'id', :content => version.id.to_s}
69 69 end
70 70
71 71 should "create the version with due date" do
72 72 assert_difference 'Version.count' do
73 73 post '/projects/1/versions.xml', {:version => {:name => 'API test', :due_date => '2012-01-24'}}, credentials('jsmith')
74 74 end
75 75
76 76 version = Version.first(:order => 'id DESC')
77 77 assert_equal 'API test', version.name
78 78 assert_equal Date.parse('2012-01-24'), version.due_date
79 79
80 80 assert_response :created
81 81 assert_equal 'application/xml', @response.content_type
82 82 assert_tag 'version', :child => {:tag => 'id', :content => version.id.to_s}
83 83 end
84 84
85 should "create the version with custom fields" do
86 field = VersionCustomField.generate!
87
88 assert_difference 'Version.count' do
89 post '/projects/1/versions.xml', {
90 :version => {
91 :name => 'API test',
92 :custom_fields => [
93 {'id' => field.id.to_s, 'value' => 'Some value'}
94 ]
95 }
96 }, credentials('jsmith')
97 end
98
99 version = Version.first(:order => 'id DESC')
100 assert_equal 'API test', version.name
101 assert_equal 'Some value', version.custom_field_value(field)
102
103 assert_response :created
104 assert_equal 'application/xml', @response.content_type
105 assert_select 'version>custom_fields>custom_field[id=?]>value', field.id.to_s, 'Some value'
106 end
107
85 108 context "with failure" do
86 109 should "return the errors" do
87 110 assert_no_difference('Version.count') do
88 111 post '/projects/1/versions.xml', {:version => {:name => ''}}, credentials('jsmith')
89 112 end
90 113
91 114 assert_response :unprocessable_entity
92 115 assert_tag :errors, :child => {:tag => 'error', :content => "Name can't be blank"}
93 116 end
94 117 end
95 118 end
96 119 end
97 120
98 121 context "/versions/:id" do
99 122 context "GET" do
100 123 should "return the version" do
101 124 get '/versions/2.xml'
102 125
103 126 assert_response :success
104 127 assert_equal 'application/xml', @response.content_type
105 128 assert_select 'version' do
106 129 assert_select 'id', :text => '2'
107 130 assert_select 'name', :text => '1.0'
108 131 assert_select 'sharing', :text => 'none'
109 132 end
110 133 end
111 134 end
112 135
113 136 context "PUT" do
114 137 should "update the version" do
115 138 put '/versions/2.xml', {:version => {:name => 'API update'}}, credentials('jsmith')
116 139
117 140 assert_response :ok
118 141 assert_equal '', @response.body
119 142 assert_equal 'API update', Version.find(2).name
120 143 end
121 144 end
122 145
123 146 context "DELETE" do
124 147 should "destroy the version" do
125 148 assert_difference 'Version.count', -1 do
126 149 delete '/versions/3.xml', {}, credentials('jsmith')
127 150 end
128 151
129 152 assert_response :ok
130 153 assert_equal '', @response.body
131 154 assert_nil Version.find_by_id(3)
132 155 end
133 156 end
134 157 end
135 158 end
@@ -1,151 +1,162
1 1 module ObjectHelpers
2 2 def User.generate!(attributes={})
3 3 @generated_user_login ||= 'user0'
4 4 @generated_user_login.succ!
5 5 user = User.new(attributes)
6 6 user.login = @generated_user_login.dup if user.login.blank?
7 7 user.mail = "#{@generated_user_login}@example.com" if user.mail.blank?
8 8 user.firstname = "Bob" if user.firstname.blank?
9 9 user.lastname = "Doe" if user.lastname.blank?
10 10 yield user if block_given?
11 11 user.save!
12 12 user
13 13 end
14 14
15 15 def User.add_to_project(user, project, roles=nil)
16 16 roles = Role.find(1) if roles.nil?
17 17 roles = [roles] unless roles.is_a?(Array)
18 18 Member.create!(:principal => user, :project => project, :roles => roles)
19 19 end
20 20
21 21 def Group.generate!(attributes={})
22 22 @generated_group_name ||= 'Group 0'
23 23 @generated_group_name.succ!
24 24 group = Group.new(attributes)
25 25 group.name = @generated_group_name.dup if group.name.blank?
26 26 yield group if block_given?
27 27 group.save!
28 28 group
29 29 end
30 30
31 31 def Project.generate!(attributes={})
32 32 @generated_project_identifier ||= 'project-0000'
33 33 @generated_project_identifier.succ!
34 34 project = Project.new(attributes)
35 35 project.name = @generated_project_identifier.dup if project.name.blank?
36 36 project.identifier = @generated_project_identifier.dup if project.identifier.blank?
37 37 yield project if block_given?
38 38 project.save!
39 39 project
40 40 end
41 41
42 42 def Project.generate_with_parent!(parent, attributes={})
43 43 project = Project.generate!(attributes)
44 44 project.set_parent!(parent)
45 45 project
46 46 end
47 47
48 48 def Tracker.generate!(attributes={})
49 49 @generated_tracker_name ||= 'Tracker 0'
50 50 @generated_tracker_name.succ!
51 51 tracker = Tracker.new(attributes)
52 52 tracker.name = @generated_tracker_name.dup if tracker.name.blank?
53 53 yield tracker if block_given?
54 54 tracker.save!
55 55 tracker
56 56 end
57 57
58 58 def Role.generate!(attributes={})
59 59 @generated_role_name ||= 'Role 0'
60 60 @generated_role_name.succ!
61 61 role = Role.new(attributes)
62 62 role.name = @generated_role_name.dup if role.name.blank?
63 63 yield role if block_given?
64 64 role.save!
65 65 role
66 66 end
67 67
68 68 def Issue.generate!(attributes={})
69 69 issue = Issue.new(attributes)
70 70 issue.project ||= Project.find(1)
71 71 issue.tracker ||= issue.project.trackers.first
72 72 issue.subject = 'Generated' if issue.subject.blank?
73 73 issue.author ||= User.find(2)
74 74 yield issue if block_given?
75 75 issue.save!
76 76 issue
77 77 end
78 78
79 79 # Generates an issue with 2 children and a grandchild
80 80 def Issue.generate_with_descendants!(attributes={})
81 81 issue = Issue.generate!(attributes)
82 82 child = Issue.generate!(:project => issue.project, :subject => 'Child1', :parent_issue_id => issue.id)
83 83 Issue.generate!(:project => issue.project, :subject => 'Child2', :parent_issue_id => issue.id)
84 84 Issue.generate!(:project => issue.project, :subject => 'Child11', :parent_issue_id => child.id)
85 85 issue.reload
86 86 end
87 87
88 88 def Journal.generate!(attributes={})
89 89 journal = Journal.new(attributes)
90 90 journal.user ||= User.first
91 91 journal.journalized ||= Issue.first
92 92 yield journal if block_given?
93 93 journal.save!
94 94 journal
95 95 end
96 96
97 97 def Version.generate!(attributes={})
98 98 @generated_version_name ||= 'Version 0'
99 99 @generated_version_name.succ!
100 100 version = Version.new(attributes)
101 101 version.name = @generated_version_name.dup if version.name.blank?
102 102 yield version if block_given?
103 103 version.save!
104 104 version
105 105 end
106 106
107 107 def TimeEntry.generate!(attributes={})
108 108 entry = TimeEntry.new(attributes)
109 109 entry.user ||= User.find(2)
110 110 entry.issue ||= Issue.find(1) unless entry.project
111 111 entry.project ||= entry.issue.project
112 112 entry.activity ||= TimeEntryActivity.first
113 113 entry.spent_on ||= Date.today
114 114 entry.hours ||= 1.0
115 115 entry.save!
116 116 entry
117 117 end
118 118
119 119 def AuthSource.generate!(attributes={})
120 120 @generated_auth_source_name ||= 'Auth 0'
121 121 @generated_auth_source_name.succ!
122 122 source = AuthSource.new(attributes)
123 123 source.name = @generated_auth_source_name.dup if source.name.blank?
124 124 yield source if block_given?
125 125 source.save!
126 126 source
127 127 end
128 128
129 129 def Board.generate!(attributes={})
130 130 @generated_board_name ||= 'Forum 0'
131 131 @generated_board_name.succ!
132 132 board = Board.new(attributes)
133 133 board.name = @generated_board_name.dup if board.name.blank?
134 134 board.description = @generated_board_name.dup if board.description.blank?
135 135 yield board if block_given?
136 136 board.save!
137 137 board
138 138 end
139 139
140 140 def Attachment.generate!(attributes={})
141 141 @generated_filename ||= 'testfile0'
142 142 @generated_filename.succ!
143 143 attributes = attributes.dup
144 144 attachment = Attachment.new(attributes)
145 145 attachment.container ||= Issue.find(1)
146 146 attachment.author ||= User.find(2)
147 147 attachment.filename = @generated_filename.dup if attachment.filename.blank?
148 148 attachment.save!
149 149 attachment
150 150 end
151
152 def CustomField.generate!(attributes={})
153 @generated_custom_field_name ||= 'Custom field 0'
154 @generated_custom_field_name.succ!
155 field = new(attributes)
156 field.name = @generated_custom_field_name.dup if field.name.blank?
157 field.field_format = 'string' if field.field_format.blank?
158 yield field if block_given?
159 field.save!
160 field
161 end
151 162 end
General Comments 0
You need to be logged in to leave comments. Login now