THIS PATCH REQUIRES A MIGRATION.
A new table and domain object, SprintMember, was created. This maps
users to the sprint for which they're a member.
Each sprint will now load its list of members and make them available
through the new "members" collection. Members can be added to the
membership list via the "sprint_members" association of Sprint.
When a sprint is edited, its list of current members is preselected in
the list that's displayed.
In the sprint list view there's a new column that shows the number of
team members on that sprint.
Signed-off-by: Darryl L. Pierce <mcpierce(a)gmail.com>
---
app/controllers/application.rb | 1 +
app/controllers/sprints_controller.rb | 57 +++++++++++++++++++++-------
app/models/sprint.rb | 4 ++
app/models/sprint_member.rb | 27 +++++++++++++
app/views/sprints/_edit.html.erb | 26 ++++++++++--
app/views/sprints/_list.html.erb | 7 ++-
app/views/sprints/show.html.erb | 5 ++
db/migrate/027_create_sprint_members.rb | 33 ++++++++++++++++
test/fixtures/product_roles.yml | 7 +++
test/fixtures/sprint_members.yml | 3 +
test/functional/sprints_controller_test.rb | 56 +++++++++++++++++++++++++--
test/unit/sprint_member_test.rb | 56 +++++++++++++++++++++++++++
12 files changed, 256 insertions(+), 26 deletions(-)
create mode 100644 app/models/sprint_member.rb
create mode 100644 db/migrate/027_create_sprint_members.rb
create mode 100644 test/fixtures/sprint_members.yml
create mode 100644 test/unit/sprint_member_test.rb
diff --git a/app/controllers/application.rb b/app/controllers/application.rb
index 22c7661..17b7095 100644
--- a/app/controllers/application.rb
+++ b/app/controllers/application.rb
@@ -58,6 +58,7 @@ class ApplicationController < ActionController::Base
def handle_exceptions
yield
rescue Exception => error
+ puts "#{error.message}"
puts error.backtrace
erase_results
@title = "An Error Has Occurred."
diff --git a/app/controllers/sprints_controller.rb
b/app/controllers/sprints_controller.rb
index f4e7557..528c1ac 100644
--- a/app/controllers/sprints_controller.rb
+++ b/app/controllers/sprints_controller.rb
@@ -21,6 +21,7 @@ class SprintsController < ApplicationController
before_filter :load_product
before_filter :load_sprint, :except => [:index, :new, :create]
before_filter :get_status, :only => [:status]
+ before_filter :prepare_for_edit, :only => [:new, :edit]
# GET /products/1/sprints
def index
@@ -69,14 +70,19 @@ class SprintsController < ApplicationController
if @product.can_create_sprints?(@user)
@sprint = Sprint.new(params[:sprint])
@sprint.product = @product
+ @selected = params[:selected]
+ @selected = Array.new unless @selected # if no members were selected, use an
empty array
+ add_users_to_sprint(@selected)
- if @sprint.save
- flash[:message] = "Sprint successfully created!"
- format.html { redirect_to plan_product_sprint_path(@product,@sprint) }
- else
- @title = "New Sprint For #{(a)product.name}"
- @sprint.valid?
- format.html { render :action => :edit }
+ Sprint.transaction do
+ if @sprint.save
+ flash[:message] = "Sprint successfully created!"
+ format.html { redirect_to plan_product_sprint_path(@product,@sprint) }
+ else
+ @title = "New Sprint For #{(a)product.name}"
+ @sprint.valid?
+ format.html { render :action => :edit }
+ end
end
else
flash[:error] = "You are not allowed to create sprints for
#{(a)product.name}."
@@ -90,14 +96,21 @@ class SprintsController < ApplicationController
respond_to do |format|
if @sprint.can_edit?(@user)
@sprint.update_attributes(params[:sprint])
+ # empty the member list
+ @sprint.sprint_members.clear
+ @selected = params[:selected]
+ @selected = Array.new unless @selected
+ add_users_to_sprint(@selected)
- if @sprint.save
- flash[:message] = "Sprint updated successfully."
- format.html { redirect_to params[:url] ? params[:url] :
product_sprints_path(@product) }
- else
- @title = "Sprint #{(a)sprint.id} (Edit)"
- @sprint.valid?
- format.html { render :action => :edit }
+ Sprint.transaction do
+ if @sprint.save
+ flash[:message] = "Sprint updated successfully."
+ format.html { redirect_to params[:url] ? params[:url] :
product_sprints_path(@product) }
+ else
+ @title = "Sprint #{(a)sprint.id} (Edit)"
+ @sprint.valid?
+ format.html { render :action => :edit }
+ end
end
else
flash[:error] = "You are now allowed to edit sprints for
#{(a)product.name}."
@@ -220,6 +233,7 @@ class SprintsController < ApplicationController
def load_product
@product = Product.find_by_id(params[:product_id])
+ @members = @product.users if @product
unless @product
flash[:error] = "Missing or invalid product."
@@ -252,4 +266,19 @@ class SprintsController < ApplicationController
end
end
end
+
+ def prepare_for_edit
+ @selected = Array.new
+ if @sprint
+ @sprint.members.each { |member| @selected << member.id }
+ end
+ end
+
+ def add_users_to_sprint(selected)
+ selected.each do |id|
+ user = User.find_by_id(id)
+ raise "Invalid user: #{user.display_name} is not a member of the
#{(a)product.name}." unless @product.is_member?(user)
+ @sprint.sprint_members << SprintMember.new(:sprint => @sprint, :member
=> user)
+ end
+ end
end
diff --git a/app/models/sprint.rb b/app/models/sprint.rb
index f2cb1f9..b50938f 100644
--- a/app/models/sprint.rb
+++ b/app/models/sprint.rb
@@ -64,6 +64,10 @@ class Sprint < ActiveRecord::Base
belongs_to :product
belongs_to :team_lead, :class_name => 'User', :foreign_key =>
:team_lead_id
+ # sprint membership
+ has_many :sprint_members, :dependent => :destroy
+ has_many :members, :through => :sprint_members
+
has_many :backlog_items,
:order => '(select priority from user_stories where id = user_story_id)
ASC',
:dependent => :destroy
diff --git a/app/models/sprint_member.rb b/app/models/sprint_member.rb
new file mode 100644
index 0000000..4482d7e
--- /dev/null
+++ b/app/models/sprint_member.rb
@@ -0,0 +1,27 @@
+# sprint_member.rb
+# Copyright (C) 2009, Darryl L. Pierce <mcpierce(a)gmail.com>
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program. If not, see <
http://www.gnu.org/licenses/>.
+#
+
+# A +SprintMember+ maps a user's membership to a sprint.
+class SprintMember < ActiveRecord::Base
+ validates_presence_of :sprint, :message => "A sprint is required."
+ validates_presence_of :member_id, :message => "A member is required."
+
+ validates_uniqueness_of :member_id, :scope => [:sprint_id]
+
+ belongs_to :sprint
+ belongs_to :member, :class_name => 'User', :foreign_key =>
'member_id'
+end
diff --git a/app/views/sprints/_edit.html.erb b/app/views/sprints/_edit.html.erb
index 26ef5d8..f7b0b77 100644
--- a/app/views/sprints/_edit.html.erb
+++ b/app/views/sprints/_edit.html.erb
@@ -52,15 +52,31 @@
<%= error_message_on(:sprint, :goals) %>
</td>
</tr>
+ </tbody>
+ </table>
- <tr>
- <td class="buttons" colspan="2">
- <%= submit_tag "Save" %>
- </td>
- </tr>
+ <table class="list">
+ <thead>
+ <tr>
+ <th>Select Sprint Team Members</th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <% @members.each_with_index do |member, index| %>
+ <% row_class = index%2 == 0 ? 'even' : 'odd' %>
+ <tr class="<%= row_class %>">
+ <td>
+ <%= check_box_tag "selected[]", member.id,
@selected.include?(member.id) %>
+ <%= label_tag "selected[]", member.display_name %>
+ </td>
+ </tr>
+ <% end %>
</tbody>
</table>
+ <%= submit_tag "Save" %>
+
</fieldset>
<% end %>
diff --git a/app/views/sprints/_list.html.erb b/app/views/sprints/_list.html.erb
index 4d6f956..5d327bf 100644
--- a/app/views/sprints/_list.html.erb
+++ b/app/views/sprints/_list.html.erb
@@ -3,6 +3,7 @@
<col class="row_id" />
<col class="name" />
<col class="description" />
+ <col class="number" />
<col class="status" />
<col class="date" />
<col class="date" />
@@ -12,11 +13,12 @@
<thead>
<tr>
- <th class="title" colspan="8">Sprints</th>
+ <th class="title" colspan="9">Sprints</th>
<tr>
<th>#</th>
<th>Team Lead</th>
<th>Title</th>
+ <th>Members</th>
<th>Status</th>
<th>Starts</th>
<th>Ends</th>
@@ -28,7 +30,7 @@
<tbody>
<% if @sprints.empty? %>
<tr>
- <td colspan="8">No sprints found...</td>
+ <td colspan="9">No sprints found...</td>
</tr>
<% else %>
<% @sprints.each_with_index do |sprint, index| %>
@@ -37,6 +39,7 @@
<td><%= "#{sprint.id}" %></td>
<td><%= mail_to sprint.team_lead.email, sprint.team_lead.display_name
%></td>
<td><%= sprint.title %></td>
+ <td><%= sprint.members.size %></td>
<td><%= sprint.status_text %></td>
<td><%= show_date(sprint.start) %></td>
<td><%= "#{show_date(sprint.end_date)} (#{sprint.duration}
days)" %></td>
diff --git a/app/views/sprints/show.html.erb b/app/views/sprints/show.html.erb
index a5a3264..547a0f7 100644
--- a/app/views/sprints/show.html.erb
+++ b/app/views/sprints/show.html.erb
@@ -53,6 +53,11 @@
</tr>
<tr>
+ <td class="label">Team Size:</td>
+ <td class="value"><%= "#{(a)sprint.members.size}
Developer#{(a)sprint.members.size ==1 ? '' : 's'}" %></td>
+ </tr>
+
+ <tr>
<td class="label">Status:</td>
<td class="value">
<%= @sprint.status_text %>
diff --git a/db/migrate/027_create_sprint_members.rb
b/db/migrate/027_create_sprint_members.rb
new file mode 100644
index 0000000..c6ccce5
--- /dev/null
+++ b/db/migrate/027_create_sprint_members.rb
@@ -0,0 +1,33 @@
+# 027_create_sprint_members.rb
+# Copyright (C) 2009, Darryl L. Pierce <mcpierce(a)gmail.com>
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program. If not, see <
http://www.gnu.org/licenses/>.
+#
+
+class CreateSprintMembers < ActiveRecord::Migration
+ def self.up
+ create_table :sprint_members do |t|
+ t.integer :sprint_id, :null => false
+ t.integer :member_id, :null => false
+
+ t.timestamps
+ end
+
+ add_index :sprint_members, [:sprint_id, :member_id], :unique => true, :name =>
:single_membership
+ end
+
+ def self.down
+ drop_table :sprint_members
+ end
+end
diff --git a/test/fixtures/product_roles.yml b/test/fixtures/product_roles.yml
index 0eb7cba..6c4897f 100644
--- a/test/fixtures/product_roles.yml
+++ b/test/fixtures/product_roles.yml
@@ -5,6 +5,13 @@ mcpierce_projxp:
pending: false
is_approved: true
+team_lead_projxp:
+ product_id: <%= Fixtures.identify(:projxp_web) %>
+ user_id: <%= Fixtures.identify(:team_lead) %>
+ role_id: <%= Fixtures.identify(:developer) %>
+ pending: false
+ is_approved: true
+
jdonuts_projxp:
product_id: <%= Fixtures.identify(:projxp_web) %>
user_id: <%= Fixtures.identify(:jdonuts) %>
diff --git a/test/fixtures/sprint_members.yml b/test/fixtures/sprint_members.yml
new file mode 100644
index 0000000..8cc07a2
--- /dev/null
+++ b/test/fixtures/sprint_members.yml
@@ -0,0 +1,3 @@
+mcpierce_active_sprint:
+ sprint_id: <%= Fixtures.identify(:active_sprint) %>
+ member_id: <%= Fixtures.identify(:mcpierce) %>
diff --git a/test/functional/sprints_controller_test.rb
b/test/functional/sprints_controller_test.rb
index f39094a..b19b0c1 100644
--- a/test/functional/sprints_controller_test.rb
+++ b/test/functional/sprints_controller_test.rb
@@ -26,6 +26,9 @@ class SprintsControllerTest < ActionController::TestCase
@active_sprint = sprints(:active_sprint)
raise "An active sprint must be active!" unless @active_sprint.active?
+ @member = @active_sprint.members.first
+ raise "The active sprint has to have members!" unless @member
+
@pending_sprint = sprints(:inactive_sprint)
raise "Sprint must be pending!" unless @pending_sprint.pending?
raise "Sprints must be for the same product!" unless
@pending_sprint.product_id == @active_sprint.product_id
@@ -38,6 +41,11 @@ class SprintsControllerTest < ActionController::TestCase
@nonowner = users(:mcpierce)
raise "Nonowner and owner must be different people!" if @owner.id ==
@nonowner.id
+ @product_nonmember = users(:celliot)
+ raise "Nonmembers cannot be members of the product team!" if
@product.is_member?(@product_nonmember)
+ @other_member = users(:team_lead)
+ raise "Other member must be a part of the same product team!" unless
@product.is_member?(@other_member)
+
@new_sprint = {
:title => "New Sprint",
:start => Date.today,
@@ -132,6 +140,7 @@ class SprintsControllerTest < ActionController::TestCase
assert_response :success
assert assigns['sprint'], "Failed to create a new sprint."
+ assert assigns['selected'], "Failed to create the default selection
list."
assert_equal @product.id, assigns['sprint'].product_id,
"Did not set the product correctly."
end
@@ -176,6 +185,10 @@ class SprintsControllerTest < ActionController::TestCase
assert_response :success
assert assigns['sprint'], "Failed to load a sprint."
+ assert assigns['selected'], "Failed to load the selection list."
+ first = assigns['selected'].first
+ assert_equal assigns['selected'].first, @member.id,
+ "The selection list was not initialized correctly."
assert_equal @active_sprint.id, assigns['sprint'].id,
"Failed to load the right sprint."
end
@@ -215,16 +228,37 @@ class SprintsControllerTest < ActionController::TestCase
"Should not have saved the sprint."
end
+ # Ensures that, if no members were selected, the sprint is created with an empty
membership.
+ def test_create_with_no_members
+ post :create,
+ {:product_id => @product.id, :sprint => @new_sprint},
+ {:user_id => @owner.id}
+
+ assert_redirected_to plan_product_sprint_path(@product, assigns['sprint'])
+ assert assigns['sprint'].members.empty?, "No members should be
defined"
+ end
+
+ # Ensures that non-product members cannot be added to the sprint team.
+ def test_create_with_non_product_member
+ post :create,
+ {:product_id => @product.id, :sprint => @new_sprint, :selected =>
[@product_nonmember.id]},
+ {:user_id => @owner.id}
+
+ assert_redirected_to error_path
+ assert !Sprint.find_by_title(@new_sprint[:title]), "Sprint should not have been
saved!"
+ end
+
# Ensures that creating a sprint works as expected.
def test_create
post :create,
- {:product_id => @product.id,
- :sprint => @new_sprint},
+ {:product_id => @product.id, :sprint => @new_sprint, :selected =>
[@member.id]},
{:user_id => @owner.id}
assert_redirected_to plan_product_sprint_path(@product,assigns['sprint'])
- assert Sprint.find_by_title(@new_sprint[:title]),
- "Failed to save the sprint."
+ result = Sprint.find_by_title(@new_sprint[:title])
+ assert result, "Failed to save the sprint."
+ assert_equal 1, result.members.size, "Did not populate the sprint team
correctly."
+ assert_equal @member.id, result.members.first.id, "Did not put the right person
in the team."
end
# Ensures that anonymous users cannot update a sprint.
@@ -270,18 +304,30 @@ class SprintsControllerTest < ActionController::TestCase
"Sprint should not have been updated."
end
+ # Ensures that non-product members cannot be added to the sprint team.
+ def test_update_with_non_product_member
+ put :update,
+ {:product_id => @product.id, :id => @active_sprint.id,
+ :sprint => {}, :selected => [@product_nonmember.id]},
+ {:user_id => @owner.id}
+
+ assert_redirected_to error_path
+ end
+
# Ensures that updating a sprint works as expected.
def test_update
sprint = {:title => "Do it!"}
put :update,
{:product_id => @product.id, :id => @active_sprint.id,
- :sprint => sprint},
+ :sprint => sprint, :selected => [@other_member.id]},
{:user_id => @owner.id}
assert_redirected_to product_sprints_path(@product)
result = Sprint.find_by_id((a)active_sprint.id)
assert_equal sprint[:title], result.title,
"Sprint should have been updated."
+ assert_equal 1, result.members.size, "Failed to populate the sprint."
+ assert_equal @other_member.id, result.members.first.id, "Failed to populate the
sprint correctly."
end
# Ensures that updating with a specified return URL sends the browser back
diff --git a/test/unit/sprint_member_test.rb b/test/unit/sprint_member_test.rb
new file mode 100644
index 0000000..da4e3bc
--- /dev/null
+++ b/test/unit/sprint_member_test.rb
@@ -0,0 +1,56 @@
+# Copyright (C) 2009, Darryl L. Pierce <mcpierce(a)gmail.com>
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program. If not, see <
http://www.gnu.org/licenses/>.
+#
+
+require File.dirname(__FILE__) + '/../test_helper'
+
+class SprintTeamTest < ActiveSupport::TestCase
+ fixtures :sprint_members
+ fixtures :sprints
+ fixtures :users
+
+ def setup
+ @sprint = sprints(:inactive_sprint)
+ @team_lead = @sprint.team_lead
+ @member = users(:mcpierce)
+
+ @membership = SprintMember.new(:sprint => @sprint, :member => @member)
+ @existing_membership = sprint_members(:mcpierce_active_sprint)
+ end
+
+ # Ensures that a membership requires a sprint reference.
+ def test_sprint_is_required
+ @membership.sprint = nil
+ flunk "Membership requires a sprint." if @membership.valid?
+ end
+
+ # Ensures that a membership requires a user reference.
+ def test_member_is_required
+ @membership.member = nil
+ flunk "Membership requires a member." if @membership.valid?
+ end
+
+ # Ensures that a well-formed membership is valid.
+ def test_valid
+ flunk "There is a general validation error." unless @membership.valid?
+ end
+
+ # Ensures that a user cannot have two memberships to the same sprint.
+ def test_unique_memberships
+ membership = SprintMember.new(:sprint => @existing_membership.sprint,
+ :member => @existing_membership.member)
+ flunk "There is a problem with uniqueness constraints." if
membership.valid?
+ end
+end
--
1.6.0.6