Created a new model object, BacklogItemVote, which ties together a user
with a backlog item and their vote. The vote itself is in the form of a
numeric value: -1 is a NAK, 0 is an abstain, and 1 is an ACK. Any other
value is rejected.
Added a new action, SprintsController::vote, that sets up the page for a
user to cast their votes. Adjusted a few fixtures to accomodate the new
tests.
Added a vote page. The voting page has a dropdown list for the user to
select their vote. By default the vote is "Abstain".
Added a new action, SprintsController::cast_votese, that accepts the
user's votes and inserts them into the database.
Signed-off-by: Darryl L. Pierce <mcpierce(a)gmail.com>
---
app/controllers/sprints_controller.rb | 50 +++++++++++++-
app/models/backlog_item.rb | 2 +
app/models/backlog_item_vote.rb | 40 ++++++++++
app/views/sprints/plan.html.erb | 11 +++-
app/views/sprints/show.html.erb | 5 ++
app/views/sprints/vote.html.erb | 35 +++++++++
config/routes.rb | 16 +++--
db/migrate/024_create_backlog_item_votes.rb | 39 ++++++++++
doc/ChangeLog | 1 +
test/fixtures/backlog_item_votes.yml | 9 +++
test/fixtures/sprint_members.yml | 4 +
test/functional/sprints_controller_test.rb | 105 ++++++++++++++++++++++++++-
test/unit/backlog_item_vote_test.rb | 60 +++++++++++++++
test/unit/sprint_member_test.rb | 2 +-
14 files changed, 365 insertions(+), 14 deletions(-)
create mode 100644 app/models/backlog_item_vote.rb
create mode 100644 app/views/sprints/vote.html.erb
create mode 100644 db/migrate/024_create_backlog_item_votes.rb
create mode 100644 test/fixtures/backlog_item_votes.yml
create mode 100644 test/unit/backlog_item_vote_test.rb
diff --git a/app/controllers/sprints_controller.rb
b/app/controllers/sprints_controller.rb
index b2e4c52..0119bd7 100644
--- a/app/controllers/sprints_controller.rb
+++ b/app/controllers/sprints_controller.rb
@@ -23,9 +23,13 @@ class SprintsController < ApplicationController
before_filter :verify_can_edit, :only => [:edit, :update]
before_filter :verify_sprint_is_pending, :only => [:plan, :populate]
before_filter :verify_can_change_state, :only => [:planning, :start, :complete,
:cancel]
+ before_filter :verify_can_vote, :only => [:vote, :cast_votes]
+ before_filter :verify_planning, :only => [:vote, :cast_votes]
+ before_filter :verify_cast_votes, :only => [:cast_votes]
+ before_filter :load_user_votes, :only => [:vote, :cast_votes]
before_filter :prepare_for_edit, :only => [:new, :create, :edit, :update,
:plan, :populate]
before_filter :path_to_list, :only => [:index, :new, :create]
- before_filter :path_to_one, :only => [:show, :edit, :update, :plan,
:populate, :members]
+ before_filter :path_to_one, :only => [:show, :edit, :update, :plan,
:populate, :vote, :members]
# GET /sprints
def index
@@ -161,6 +165,25 @@ class SprintsController < ApplicationController
end
end
+ # GET /sprints/1/vote
+ def vote
+
+ end
+
+ # POST /sprints/1/cast_votes
+ def cast_votes
+ BacklogItemVote.transaction do
+ BacklogItemVote.for_sprint(@sprint).for_user((a)user).destroy_all
+ @votes = params[:votes]
+ @votes.each_pair do |item_id, vote|
+ BacklogItemVote.create(:backlog_item_id => item_id, :user => @user, :vote
=> vote)
+ end
+ respond_to do |format|
+ format.html {redirect_to sprint_path(@sprint)}
+ end
+ end
+ end
+
# PUT /sprints/1/start
def start
Sprint.transaction do
@@ -315,6 +338,31 @@ class SprintsController < ApplicationController
report_error "You are not allowed to change this sprint's state."
unless @sprint.can_change_state?(@user)
end
+ def verify_can_vote
+ report_error("You are not allowed to vote on this sprint.",
sprint_path(@sprint)) unless @sprint.is_team_member?(@user)
+ end
+
+ def verify_planning
+ report_error("You can only vote on items while a sprint is in planning.",
sprint_path(@sprint)) unless @sprint.planning?
+ end
+
+ def verify_cast_votes
+ params[:votes].each_pair do |item_id, vote|
+ item = BacklogItem.find_by_id(item_id)
+ if !item
+ report_error("A vote was cast for a nonexistent backlog item.")
+ elsif item.sprint_id != @sprint.id then
+ report_error("The backlog item \"#{item.user_story.title}\" is not
on this sprint.")
+ end
+ end
+ end
+
+ def load_user_votes
+ @votes = {}
+ @sprint.backlog_items.each {|item| @votes[item.id] = BacklogItemVote::ABSTAIN}
+ BacklogItemVote.for_sprint(@sprint).for_user((a)user).each {|vote|
@votes[vote.backlog_item.id] = vote.vote}
+ end
+
def prepare_for_edit
@selected = Array.new
@members = @product.members
diff --git a/app/models/backlog_item.rb b/app/models/backlog_item.rb
index 51bd751..0c41ff7 100644
--- a/app/models/backlog_item.rb
+++ b/app/models/backlog_item.rb
@@ -35,6 +35,8 @@ class BacklogItem < ActiveRecord::Base
has_many :tasks, :dependent => :destroy
has_many :remaining_hours_estimates
+ has_many :votes, :class_name => 'BacklogItemVote'
+ has_many :votes_in_favor, :class_name => 'BacklogItemVote', :conditions
=> "vote = #{BacklogItemVote::ACK}"
has_and_belongs_to_many :messages, :join_table => :backlog_items_messages
diff --git a/app/models/backlog_item_vote.rb b/app/models/backlog_item_vote.rb
new file mode 100644
index 0000000..59a26bf
--- /dev/null
+++ b/app/models/backlog_item_vote.rb
@@ -0,0 +1,40 @@
+# backlog_item_vote.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 +BacklogItemVote+ represents a single user's vote for or against a backlog item
+# on a sprint backlog.
+class BacklogItemVote < ActiveRecord::Base
+ validates_presence_of(:user_id)
+ validates_presence_of(:backlog_item_id)
+ validates_presence_of(:vote, :message => "A ballot must be cast.")
+ validates_numericality_of(:vote, :message => "A vote must be -1, 0 or
1.",
+ :only_integer => true,
+ :greater_than_or_equal_to => -1,
+ :less_than_or_equal_to => 1)
+
+ belongs_to :user
+ belongs_to :backlog_item
+
+ named_scope :for_sprint, lambda {|sprint_id| {:conditions => ["backlog_item_id
in (select id from backlog_items where sprint_id = ?)", sprint_id]}}
+ named_scope :for_user, lambda {|user_id| {:conditions => ["user_id = ?",
user_id]}}
+
+ ABSTAIN = 0
+ ACK = 1
+ NAK = -1
+
+ VOTES = {"Abstain" => ABSTAIN, "Accept" => ACK,
"Reject" => NAK}
+end
diff --git a/app/views/sprints/plan.html.erb b/app/views/sprints/plan.html.erb
index b2d6e90..4e8e20f 100644
--- a/app/views/sprints/plan.html.erb
+++ b/app/views/sprints/plan.html.erb
@@ -1,14 +1,15 @@
-<div id="content">
+<div id="content-no-sidebar">
<%= form_tag populate_sprint_path(@sprint), :html => {:method => :post} %>
<%= submit_tag "Populate" %>
<table class="main-list">
- <caption><%= "Plan #{(a)sprint.title}" %><?caption>
+ <caption><%= "Planning For: #{(a)sprint.title}"
%><?caption>
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Rank</th>
<th scope="col" class="Name">Title</th>
<th scope="col">Estimate</th>
+ <th scope="col">Votes</th>
</tr>
</thead>
<tbody>
@@ -30,6 +31,12 @@
(backlog_item.estimated_hours if backlog_item),
:size => 4, :maxlength => 4%>
</td>
+ <td>
+ <% if backlog_item %>
+ <%= "#{backlog_item.votes_in_favor.size} of
#{backlog_item.votes.size}" %>
+ <% else %>
+ 0
+ <% end %>
</tr>
<% end %>
</tbody>
diff --git a/app/views/sprints/show.html.erb b/app/views/sprints/show.html.erb
index 3008a3b..c1eed47 100644
--- a/app/views/sprints/show.html.erb
+++ b/app/views/sprints/show.html.erb
@@ -40,6 +40,11 @@
<% end %>
<% end %>
+ <% if @sprint.is_team_member?(@user) && @sprint.planning? %>
+ <%= link_to "Vote on sprint backlog",
+ vote_sprint_path(@sprint), :class => "command" %>
+ <% end %>
+
<div class="header"><span class="title">Change Sprint
Status</span></div>
<% if @sprint.can_change_state?(@user) %>
diff --git a/app/views/sprints/vote.html.erb b/app/views/sprints/vote.html.erb
new file mode 100644
index 0000000..4610a5a
--- /dev/null
+++ b/app/views/sprints/vote.html.erb
@@ -0,0 +1,35 @@
+<div id="content-no-sidebar">
+ <% form_tag(cast_votes_sprint_path(@sprint)) do %>
+
+ <%= submit_tag "Cast Votes" %>
+
+ <table class="main-list">
+ <caption><%= "Vote On: \"#{(a)sprint.title}\""
%></caption>
+
+ <thead>
+ <tr>
+ <th scope="col">#</th>
+ <th scope="col" class="name">Title</th>
+ <th scope="col"># Votes</th>
+ <th scope="col">Vote</th>
+ <th scope="col">Status</th>
+ </tr>
+ </thead>
+
+ <% @sprint.backlog_items.each do |item| %>
+
+ <tr class="<%= cycle('odd', 'even') %>">
+ <td><%= item.id %></td>
+ <td class="name"><%= item.user_story.title %></td>
+ <td><%= item.votes.size %></td>
+ <td><%= select_tag "votes[#{item.id}]",
options_for_select(BacklogItemVote::VOTES, @votes[item.id].to_i) %></td>
+ <td></td>
+ </tr>
+
+ <% end %>
+ </table>
+
+ <%= submit_tag "Cast Votes" %>
+
+ <% end %>
+</div>
diff --git a/config/routes.rb b/config/routes.rb
index 143a207..55e3524 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -57,13 +57,15 @@ ActionController::Routing::Routes.draw do |map|
map.resources :sprints, :member =>
{
- :planning => :put,
- :start => :put,
- :complete => :put,
- :cancel => :put,
- :plan => :get,
- :populate => :post,
- :members => :get
+ :planning => :put,
+ :start => :put,
+ :complete => :put,
+ :cancel => :put,
+ :plan => :get,
+ :populate => :post,
+ :members => :get,
+ :vote => :get,
+ :cast_votes => :post
}
map.resources :items, :member =>
diff --git a/db/migrate/024_create_backlog_item_votes.rb
b/db/migrate/024_create_backlog_item_votes.rb
new file mode 100644
index 0000000..d135b27
--- /dev/null
+++ b/db/migrate/024_create_backlog_item_votes.rb
@@ -0,0 +1,39 @@
+# 024_create_backlog_item_votes
+# 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 CreateBacklogItemVotes < ActiveRecord::Migration
+ def self.up
+ create_table :backlog_item_votes do |t|
+ t.integer :user_id, :null => false
+ t.integer :backlog_item_id, :null => false
+ t.integer :vote, :null => false, :default => 0
+
+ t.timestamps
+ end
+
+ add_index :backlog_item_votes, [:user_id, :backlog_item_id], :unique => true
+
+ execute 'alter table backlog_item_votes add constraint
fk_backlog_items_votes_user
+ foreign key (user_id) references users(id)'
+ execute 'alter table backlog_item_votes add constraint
fk_backlog_items_votes_item
+ foreign key (backlog_item_id) references backlog_items(id)'
+ end
+
+ def self.down
+ drop_table :backlog_item_votes
+ end
+end
diff --git a/doc/ChangeLog b/doc/ChangeLog
index 2b3eb6f..eb357a1 100644
--- a/doc/ChangeLog
+++ b/doc/ChangeLog
@@ -6,6 +6,7 @@ Change Log (0.3.0):
* #161 - Users can post comments against user stories.
* #162 - Users can reply to comments.
* #163 - Comments on user stories are shown on the details page.
+ * #164 - Users can vote on backlog items.
* #167 - Blocker messages are included in the daily updates email.
* #168 - Users can post comments for epic stories.
* #169 - Comments on epics are shown on the details page.
diff --git a/test/fixtures/backlog_item_votes.yml b/test/fixtures/backlog_item_votes.yml
new file mode 100644
index 0000000..bfff55d
--- /dev/null
+++ b/test/fixtures/backlog_item_votes.yml
@@ -0,0 +1,9 @@
+planned_sprint_backlog_item_jdonuts_vote:
+ user_id: <%= Fixtures.identify(:jdonuts) %>
+ backlog_item_id: <%= Fixtures.identify(:planned_sprint_backlog_item) %>
+ vote: <%= BacklogItemVote::ACK %>
+
+planned_sprint_backlog_item_mcpierce_vote:
+ user_id: <%= Fixtures.identify(:mcpierce) %>
+ backlog_item_id: <%= Fixtures.identify(:planned_sprint_backlog_item) %>
+ vote: <%= BacklogItemVote::NAK %>
diff --git a/test/fixtures/sprint_members.yml b/test/fixtures/sprint_members.yml
index 8cc07a2..beec376 100644
--- a/test/fixtures/sprint_members.yml
+++ b/test/fixtures/sprint_members.yml
@@ -1,3 +1,7 @@
mcpierce_active_sprint:
sprint_id: <%= Fixtures.identify(:active_sprint) %>
member_id: <%= Fixtures.identify(:mcpierce) %>
+
+mcpierce_planned_sprint:
+ sprint_id: <%= Fixtures.identify(:planned_sprint) %>
+ member_id: <%= Fixtures.identify(:mcpierce) %>
diff --git a/test/functional/sprints_controller_test.rb
b/test/functional/sprints_controller_test.rb
index d184fbc..946ccb2 100644
--- a/test/functional/sprints_controller_test.rb
+++ b/test/functional/sprints_controller_test.rb
@@ -23,19 +23,24 @@ class SprintsControllerTest < ActionController::TestCase
fixtures :users
def setup
+ @member = users(:mcpierce)
+ @nonmember = users(:celliot)
+
@active_sprint = sprints(:active_sprint)
raise "An active sprint must be active!" unless @active_sprint.active?
raise "Product must have a planned sprint!" if
Sprint.for_product((a)active_sprint.product).planned.empty?
+ raise "Member must be on this team!" unless
@active_sprint.is_team_member?(@member)
@plannable_sprint = sprints(:plannable_sprint)
raise "Sprint must be in a non-planning state!" if
@plannable_sprint.planning?
- @member = @active_sprint.members.first
- raise "The active sprint has to have members!" unless @member
-
@planned_sprint = sprints(:planned_sprint)
raise "Sprint must be pending!" unless @planned_sprint.planning?
raise "Products must have the same owner!" unless
@planned_sprint.product.owner_id == @active_sprint.product.owner_id
+ raise "Member must be a team member!" unless
@planned_sprint.is_team_member?(@member)
+ raise "Nonmember cannot be a team member!" if
@planned_sprint.is_team_member?(@nonmember)
+
+ @votes = {(a)planned_sprint.backlog_items.first.id => BacklogItemVote::ACK}
@startable_sprint = sprints(:startable_sprint)
raise "Sprint must be in the planned start!" unless
@startable_sprint.planning?
@@ -585,4 +590,98 @@ class SprintsControllerTest < ActionController::TestCase
assert assigns['sprint'], "Failed to load a sprint."
assert_equal @active_sprint.id, assigns['sprint'].id, "Failed to load
the correct sprint."
end
+
+ # Ensures that anonymous users cannot vote on sprint backlog items.
+ def test_vote_as_anonymous
+ get :vote
+
+ assert_redirected_to login_path
+ end
+
+ # Ensures that a valid sprint is required.
+ def test_vote_with_invalid_sprint
+ get :vote, {}, {:user_id => @member.id}
+
+ assert_redirected_to error_path
+ end
+
+ # Ensures that only a team member can vote on sprint items.
+ def test_vote_with_nonmember
+ get :vote, {:id => @planned_sprint.id}, {:user_id => @nonmember.id}
+
+ assert_redirected_to sprint_path(@planned_sprint)
+ end
+
+ # Ensures that only a sprint in planning can get votes.
+ def test_vote_with_nonplanning_sprint
+ get :vote, {:id => @active_sprint.id}, {:user_id => @member.id}
+
+ assert_redirected_to sprint_path(@active_sprint)
+ end
+
+ # Ensures that the vote action works as expected.
+ def test_vote
+ get :vote, {:id => @planned_sprint.id}, {:user_id => @member.id}
+
+ assert_response :success
+ assert assigns['votes'], "Failed to setup the votes."
+ end
+
+ # Ensures that anonymous users cannot cast votes.
+ def test_cast_vote_as_anonymous
+ post :cast_votes
+
+ assert_redirected_to login_path
+ end
+
+ # Ensures that a valid sprint is required.
+ def test_cast_vote_with_invalid_sprint
+ post :cast_votes, {}, {:user_id => @member.id}
+
+ assert_redirected_to error_path
+ end
+
+ # Ensures that only members can cast votes.
+ def test_cast_vote_as_nonmember
+ count = BacklogItemVote.for_sprint((a)planned_sprint).size
+ post :cast_votes, {:id => @planned_sprint.id}, {:user_id => @nonmember.id}
+
+ assert_redirected_to sprint_path(@planned_sprint)
+ assert_equal count, BacklogItemVote.for_sprint((a)planned_sprint).size, "No new
votes should have been cast."
+ end
+
+ # Ensures that only sprints in planning can have votes.
+ def test_cast_vote_for_nonplanning_sprint
+ count = BacklogItemVote.for_sprint((a)active_sprint).size
+ post :cast_votes, {:id => @active_sprint.id}, {:user_id => @member.id}
+
+ assert_redirected_to sprint_path(@active_sprint)
+ assert_equal count, BacklogItemVote.for_sprint((a)active_sprint).size, "No new
votes should have been cast."
+ end
+
+ # Ensures that a vote cast for a non-existent backlog item fails.
+ def test_cast_vote_for_nonexistent_backlog_item
+ count = BacklogItemVote.for_sprint((a)planned_sprint).size
+ post :cast_votes, {:id => @planned_sprint.id, :votes => {9999 =>
BacklogItemVote::ACK}}, {:user_id => @member.id}
+
+ assert_redirected_to error_path
+ assert_equal count, BacklogItemVote.for_sprint((a)planned_sprint).size, "Vote
should not have been cast."
+ end
+
+ # Ensures that a vote cast for an item on a different sprint fails.
+ def test_cast_vote_for_item_on_different_sprint
+ count = BacklogItemVote.for_sprint((a)planned_sprint).size
+ post :cast_votes, {:id => @planned_sprint.id, :votes =>
{(a)active_sprint.backlog_items.first.id => BacklogItemVote::ACK}}, {:user_id =>
@member.id}
+
+ assert_redirected_to error_path
+ assert_equal count, BacklogItemVote.for_sprint((a)planned_sprint).size, "Vote
should not have been cast."
+ end
+
+ # Ensures that casting votes works as expected.
+ def test_cast_vote
+ post :cast_votes, {:id => @planned_sprint.id, :votes => @votes}, {:user_id
=> @member.id}
+
+ assert_redirected_to sprint_path(@planned_sprint)
+ assert_equal 1, BacklogItemVote.for_user(@member).for_sprint((a)planned_sprint).size,
"A new vote should have been cast."
+ end
end
diff --git a/test/unit/backlog_item_vote_test.rb b/test/unit/backlog_item_vote_test.rb
new file mode 100644
index 0000000..6dd26f5
--- /dev/null
+++ b/test/unit/backlog_item_vote_test.rb
@@ -0,0 +1,60 @@
+# backlog_item_vote_test.rb
+# Copyright (C) 2008, 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 BacklogItemVoteTest < ActiveSupport::TestCase
+ def setup
+ @new_vote = BacklogItemVote.new(:user_id => users(:mcpierce),
+ :backlog_item_id =>
backlog_items(:planned_sprint_backlog_item))
+ end
+
+ # Ensures that a vote requires a user.
+ def test_valid_fails_without_user
+ @new_vote.user_id = nil
+ fail "A user is required." if @new_vote.valid?
+ end
+
+ # Ensures that a vote requires a backlog item.
+ def test_valid_fails_without_backlog_item
+ @new_vote.backlog_item_id = nil
+ fail "A backlog item is required." if @new_vote.valid?
+ end
+
+ # Ensures that a vote requires a vote value.
+ def test_valid_fails_without_vote
+ @new_vote.vote = nil
+ fail "A vote is required." if @new_vote.valid?
+ end
+
+ # Ensures that a vote must be numeric.
+ def test_valid_fails_with_nonnumeric_vote
+ @new_vote.vote = 'a'
+ fail "A vote must be numeric!" if @new_vote.valid?
+ end
+
+ # Ensures that a vote can only be in the set of -1, 0 and 1.
+ def test_valid_fails_with_invalid_votes
+ [-1, 0, 1].each {|vote| @new_vote.vote = vote; fail "#{vote} must be a valid
vote!" unless @new_vote.valid?}
+ [-3, -2, 2, 3, 1.1].each {|vote| @new_vote.vote = vote; fails "#{vote} must be
considered invalid!" if @new_vote.valid?}
+ end
+
+ # Ensures that validation works as expected.
+ def test_valid
+ fail "There is a general validation error." unless @new_vote.valid?
+ end
+end
diff --git a/test/unit/sprint_member_test.rb b/test/unit/sprint_member_test.rb
index fbefba7..4518b03 100644
--- a/test/unit/sprint_member_test.rb
+++ b/test/unit/sprint_member_test.rb
@@ -22,7 +22,7 @@ class SprintTeamTest < ActiveSupport::TestCase
fixtures :users
def setup
- @sprint = sprints(:planned_sprint)
+ @sprint = sprints(:startable_sprint)
@team_lead = @sprint.team_lead
@member = users(:mcpierce)
--
1.6.0.6