[PATCH] Users can vote on backlog items. #164
by Darryl L. Pierce
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
14 years, 10 months
[PATCH] Users can disable emails on replies. #213
by Darryl L. Pierce
Added a new column to the notifications table, email_on_replies. If set
to true then replies are emailed to the user.
The email notice is only sent if the author of the message being replied
to has email notices enabled for replies.
Added a configuration box to the user's preferences page to
enable/disable email noticies on replies.
Signed-off-by: Darryl L. Pierce <mcpierce(a)gmail.com>
---
app/controllers/messages_controller.rb | 2 +-
app/views/users/_notifications.html.erb | 6 ++++++
db/migrate/017_create_notifications.rb | 1 +
doc/ChangeLog | 1 +
test/functional/messages_controller_test.rb | 15 +++++++++++++++
5 files changed, 24 insertions(+), 1 deletions(-)
diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb
index 5677d0e..b1e5ff3 100644
--- a/app/controllers/messages_controller.rb
+++ b/app/controllers/messages_controller.rb
@@ -47,7 +47,7 @@ class MessagesController < ApplicationController
respond_to do |format|
if @message.save
- UserMailer.deliver_message_reply_notice(@reply)
+ UserMailer.deliver_message_reply_notice(@reply) if @message.author.notifications.email_on_replies
flash[:message] = "Reply posted."
if @landing_page
format.html {redirect_to @landing_page}
diff --git a/app/views/users/_notifications.html.erb b/app/views/users/_notifications.html.erb
index 89a5dfd..905c480 100644
--- a/app/views/users/_notifications.html.erb
+++ b/app/views/users/_notifications.html.erb
@@ -28,6 +28,12 @@
</tr>
<tr>
+ <td class="value">
+ <%= form.check_box :email_on_replies %>
+ <%= form.label :email_on_replies, "Send an email when some replies to a message you posted." %>
+ </tr>
+
+ <tr>
<td class="buttons">
<%= submit_tag "Update" %>
</td>
diff --git a/db/migrate/017_create_notifications.rb b/db/migrate/017_create_notifications.rb
index 8304a36..edfa008 100644
--- a/db/migrate/017_create_notifications.rb
+++ b/db/migrate/017_create_notifications.rb
@@ -22,6 +22,7 @@ class CreateNotifications < ActiveRecord::Migration
t.boolean :task_reminders, :null => false, :default => true
t.boolean :daily_updates, :null => false, :default => true
t.boolean :updates_to_product_list, :null => false, :default => false
+ t.boolean :email_on_replies, :null => false, :default => true
t.timestamps
end
diff --git a/doc/ChangeLog b/doc/ChangeLog
index e12f4b9..ff6368e 100644
--- a/doc/ChangeLog
+++ b/doc/ChangeLog
@@ -34,6 +34,7 @@ Change Log (0.3.0):
* #208 - Blocker history is shown for backlog items.
* #209 - A blocked item is shown in a different color on the item list page.
* #210 - Epics with no user stories should only be deleted. (BUG)
+ * #213 - Users can disable receiving emails when replies are posted.
* #216 - The user's most recent postings are displayed on their details page.
* #217 - All of a user's posts can be viewed.
diff --git a/test/functional/messages_controller_test.rb b/test/functional/messages_controller_test.rb
index 1a3efb4..d5287ac 100644
--- a/test/functional/messages_controller_test.rb
+++ b/test/functional/messages_controller_test.rb
@@ -133,4 +133,19 @@ class MessagesControllerTest < ActionController::TestCase
assert email, "An email should have been sent."
assert_equal @message.author.email, email.to[0], "Email was sent to the wrong user."
end
+
+ # Ensures that a landing page is provided.
+ def test_post_reply_with_emails_on_replies_disabled
+ @message.author.notifications.email_on_replies = false
+ @message.author.notifications.save!
+
+ post :post_reply, {:id => @message.id, :reply => @new_reply}, {:user_id => @user.id}
+
+ assert_redirected_to message_path(@message)
+ result = Message.find_by_id((a)message.id).replies.last
+ assert result, "Did not save the reply."
+ assert_equal @new_reply[:body], result.body, "Did not save the message."
+ # ensures that emails were sent
+ assert @emails.empty?, "No emails should have been sent."
+ end
end
--
1.6.0.6
14 years, 10 months
[PATCH] Users receive emails when a reply is posted to their message. #170
by Darryl L. Pierce
Added a check to ensure that an email was sent. Added a new email
template that sends the user the text of the reply.
Signed-off-by: Darryl L. Pierce <mcpierce(a)gmail.com>
---
app/controllers/messages_controller.rb | 1 +
app/models/user_mailer.rb | 8 ++++++++
.../user_mailer/message_reply_notice.html.erb | 9 +++++++++
test/functional/messages_controller_test.rb | 16 +++++++++++++++-
4 files changed, 33 insertions(+), 1 deletions(-)
create mode 100644 app/views/user_mailer/message_reply_notice.html.erb
diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb
index 0cb02d5..5677d0e 100644
--- a/app/controllers/messages_controller.rb
+++ b/app/controllers/messages_controller.rb
@@ -47,6 +47,7 @@ class MessagesController < ApplicationController
respond_to do |format|
if @message.save
+ UserMailer.deliver_message_reply_notice(@reply)
flash[:message] = "Reply posted."
if @landing_page
format.html {redirect_to @landing_page}
diff --git a/app/models/user_mailer.rb b/app/models/user_mailer.rb
index 9654c57..18afd18 100644
--- a/app/models/user_mailer.rb
+++ b/app/models/user_mailer.rb
@@ -109,4 +109,12 @@ class UserMailer < ActionMailer::Base
subject "Status of sprints"
body :user => user, :products => products
end
+
+ # Sends an email to a message posted that an reply was posted to their comment.
+ def message_reply_notice(reply)
+ recipients reply.reference.author.email
+ from MAIL_CONFIG[:from]
+ subject "Someone replied to your comment..."
+ body :reply => reply
+ end
end
diff --git a/app/views/user_mailer/message_reply_notice.html.erb b/app/views/user_mailer/message_reply_notice.html.erb
new file mode 100644
index 0000000..d5e9ace
--- /dev/null
+++ b/app/views/user_mailer/message_reply_notice.html.erb
@@ -0,0 +1,9 @@
+<%= "#{(a)reply.reference.author.display_name}:" %>
+
+A reply has been posted in response to your posting:
+
+<%= "#{(a)reply.author.display_name} wrote:" %>
+<%= "#{(a)reply.body}" %>
+
+To view this reply, please go here: <%= message_url(@reply) %>
+To view your message, please go here: <%= message_url((a)reply.reference) %>
diff --git a/test/functional/messages_controller_test.rb b/test/functional/messages_controller_test.rb
index 96fd2f4..1a3efb4 100644
--- a/test/functional/messages_controller_test.rb
+++ b/test/functional/messages_controller_test.rb
@@ -22,6 +22,13 @@ class MessagesControllerTest < ActionController::TestCase
@landing_page = dashboard_path
@new_reply = {:body => 'This is a reply', :reference_id => @message.id}
+
+ ActionMailer::Base.delivery_method = :test
+ ActionMailer::Base.perform_deliveries = true
+ ActionMailer::Base.deliveries = []
+
+ @emails = ActionMailer::Base.deliveries
+ @emails.clear
end
# Ensures that viewing the list of messages works.
@@ -106,8 +113,11 @@ class MessagesControllerTest < ActionController::TestCase
assert_redirected_to @landing_page
result = Message.find_by_id((a)message.id).replies.last
- assert result, "Did not save the reply."
+ assert result, "Reply should have been saved."
assert_equal @new_reply[:body], result.body, "Did not save the message."
+ email = @emails.first
+ assert email, "An email should have been sent."
+ assert_equal @message.author.email, email.to[0], "Email was sent to the wrong user."
end
# Ensures that a landing page is provided.
@@ -118,5 +128,9 @@ class MessagesControllerTest < ActionController::TestCase
result = Message.find_by_id((a)message.id).replies.last
assert result, "Did not save the reply."
assert_equal @new_reply[:body], result.body, "Did not save the message."
+ # ensures that emails were sent
+ email = @emails.first
+ assert email, "An email should have been sent."
+ assert_equal @message.author.email, email.to[0], "Email was sent to the wrong user."
end
end
--
1.6.0.6
14 years, 10 months
[PATCH] All of a user's posts can be viewed. #217
by Darryl L. Pierce
When viewing the list of messages by a specific author, only that user's
posts are displayed.
>From the user details page, a user can click a link to see all posts by
the detailed user.
On the user list page the number of posts by each user is shown. That
number is a link to list all posts by that user.
Signed-off-by: Darryl L. Pierce <mcpierce(a)gmail.com>
---
app/controllers/messages_controller.rb | 13 +++++++--
app/models/message.rb | 9 ++----
app/views/messages/_row.html.erb | 40 +++++++++++++-------------
app/views/messages/index.html.erb | 6 ++++
app/views/users/index.html.erb | 2 +
app/views/users/show.html.erb | 2 +-
doc/ChangeLog | 1 +
public/stylesheets/tables.css | 5 +++
test/functional/messages_controller_test.rb | 17 +++++++++++
9 files changed, 65 insertions(+), 30 deletions(-)
create mode 100644 app/views/messages/index.html.erb
diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb
index 3c6a5af..0cb02d5 100644
--- a/app/controllers/messages_controller.rb
+++ b/app/controllers/messages_controller.rb
@@ -16,9 +16,16 @@
# +MessageController+ enforces business rules on instances of +Message+.
class MessagesController < ApplicationController
- before_filter :authenticated, :except => [:show]
- before_filter :load_message
- before_filter :get_landing_page, :except => [:show]
+ before_filter :authenticated, :except => [:index, :show]
+ before_filter :load_message, :except => [:index]
+ before_filter :get_landing_page, :except => [:index, :show]
+
+ # GET /messages
+ def index
+ @author = User.find_by_id(params[:author])
+ @messages = Message.for_author(@author).paginate(:page => params[:page],
+ :per_page => 10)
+ end
# GET /messages/1
def show
diff --git a/app/models/message.rb b/app/models/message.rb
index b48b920..4536723 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -34,12 +34,9 @@ class Message < ActiveRecord::Base
has_many :replies, :class_name => 'Message', :foreign_key => :reference_id, :dependent => :delete_all
belongs_to :reference, :class_name => 'Message', :foreign_key => :reference_id
- named_scope :blocker_messages, lambda {|backlog_item|
- {
- :conditions => ['id in (select message_id from backlog_items_messages where backlog_item_id = ?)', backlog_item.id],
- :order => 'created_at ASC'
- }
- }
+ named_scope :default, :order => "created_at ASC"
+ named_scope :blocker_messages, lambda {|backlog_item| {:conditions => ['id in (select message_id from backlog_items_messages where backlog_item_id = ?)', backlog_item.id]}}
+ named_scope :for_author, lambda {|author| {:conditions => author ? ['author_id = ?', author.id] : ""}}
def can_edit?(user)
true
diff --git a/app/views/messages/_row.html.erb b/app/views/messages/_row.html.erb
index 1a81294..62abab1 100644
--- a/app/views/messages/_row.html.erb
+++ b/app/views/messages/_row.html.erb
@@ -3,32 +3,32 @@
<tr class="<%= cycle('odd', 'even') %>">
<td class="avatar"><div class="small-icon"><%= link_to(image_tag(message.author.avatar_url), user_path(message.author)) %></div></td>
<td class="content">
- <a name='<%= "message-#{message.id}" %>'>
- <div class="message-actions-box">
- <ul class="message-actions">
- <% if message.reference_id %>
- <li><%= link_to "Reference", "#message-#{message.reference_id}" %></li>
- <% end %>
+ <div class="message-actions-box">
+ <ul class="message-actions">
+ <% if message.reference_id %>
+ <li><%= link_to_unless_current "Reference", message_path(message.reference) %></li>
+ <% end %>
- <li><%= link_to "Reply", reply_message_path(message, :dest => request.request_uri) %></li>
+ <li><%= link_to "Reply", reply_message_path(message, :dest => request.request_uri) %></li>
- <% if message.can_edit?(@user) %>
- <li><%= link_to "Edit", "javascript:void(0)" %></li>
- <% end %>
+ <% if message.can_edit?(@user) %>
+ <li><%= link_to "Edit", "javascript:void(0)" %></li>
+ <% end %>
- <% if message.can_delete?(@user) %>
- <li><%= link_to "Delete", "javascript:void(0)" %></li>
- <% end %>
- </ul>
- </div>
- <div class="message-header">
- <%= "#{link_to(message.author.display_name, user_path(message.author))} wrote on #{show_date(message.posted, true)}:" %>
- </div>
- <div class="message-content"><%= RedCloth.new(message.body).to_html %></div>
- </a>
+ <% if message.can_delete?(@user) %>
+ <li><%= link_to "Delete", "javascript:void(0)" %></li>
+ <% end %>
+ </ul>
+ </div>
+ <div class="message-header">
+ <%= "#{link_to(message.author.display_name, user_path(message.author))} wrote on #{show_date(message.posted, true)}:" %>
+ </div>
+ <div class="message-content"><%= RedCloth.new(message.body).to_html %></div>
</td>
</tr>
+<% unless @author %>
<% unless message.replies.empty? %>
<%= render :partial => 'messages/row', :collection => message.replies %>
<% end %>
+<% end %>
diff --git a/app/views/messages/index.html.erb b/app/views/messages/index.html.erb
new file mode 100644
index 0000000..05c4e77
--- /dev/null
+++ b/app/views/messages/index.html.erb
@@ -0,0 +1,6 @@
+<div id="content-no-sidebar">
+ <div>
+ <%= will_paginate @messages %>
+ </div>
+ <%= render :partial => 'list', :locals => {:messages => @messages} %>
+</div>
diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb
index ef4b155..b9b9400 100644
--- a/app/views/users/index.html.erb
+++ b/app/views/users/index.html.erb
@@ -10,6 +10,7 @@
<th scope="col" class="name">Name</th>
<th scopes="col">Roles</th>
<th scope="col">Backlog</th>
+ <th scope="col">Posts</th>
<th scope="col">Since</th>
</tr>
</thead>
@@ -24,6 +25,7 @@
</td>
<td><%= link_to "#{user.product_roles.size}", roles_user_path(user) %></td>
<td><%= link_to "#{user.backlog.size}", backlog_user_path(user) %></td>
+ <td><%= link_to "#{user.posts.size}", messages_path(:author => user) %></td>
<td><%= show_date user.created_at %></td>
</tr>
<% end %>
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb
index 3a3c288..ec23410 100644
--- a/app/views/users/show.html.erb
+++ b/app/views/users/show.html.erb
@@ -41,7 +41,7 @@
<div id="activity">
<table>
- <caption>Recent Posts</caption>
+ <caption>Recent Posts (<%= link_to "See All", messages_path(:author => @this_user) %>)</caption>
<tbody>
<% @this_user.posts.slice(0..9).each do |post| %>
<tr class="<%= cycle('odd', 'even') %>">
diff --git a/doc/ChangeLog b/doc/ChangeLog
index dd037ea..583375e 100644
--- a/doc/ChangeLog
+++ b/doc/ChangeLog
@@ -34,6 +34,7 @@ Change Log (0.3.0):
* #209 - A blocked item is shown in a different color on the item list page.
* #210 - Epics with no user stories should only be deleted. (BUG)
* #216 - The user's most recent postings are displayed on their details page.
+ * #216 - All of a user's posts can be viewed.
Change Log (0.2.0):
* #97 - Users can request new projects.
diff --git a/public/stylesheets/tables.css b/public/stylesheets/tables.css
index 039e218..8c713ba 100644
--- a/public/stylesheets/tables.css
+++ b/public/stylesheets/tables.css
@@ -184,6 +184,11 @@ div#activity table caption {
font-weight: bold;
}
+div#activity table caption a {
+ color: #ffffff;
+ text-decoration: none;
+}
+
div#activity table tr td {
text-align: left;
}
diff --git a/test/functional/messages_controller_test.rb b/test/functional/messages_controller_test.rb
index a051dcd..96fd2f4 100644
--- a/test/functional/messages_controller_test.rb
+++ b/test/functional/messages_controller_test.rb
@@ -24,6 +24,23 @@ class MessagesControllerTest < ActionController::TestCase
@new_reply = {:body => 'This is a reply', :reference_id => @message.id}
end
+ # Ensures that viewing the list of messages works.
+ def test_index
+ get :index
+
+ assert_response :success
+ assert assigns['messages'], "Failed to load messages."
+ end
+
+ # Ensures that viewing all messages by a user works as expected.
+ def test_index_with_author
+ get :index, {:author => @user.id}
+
+ assert_response :success
+ assert assigns['messages'], "Failed toload any messages."
+ assigns['messages'].each {|message| fail "Loaded messages for the wrong user." unless message.author_id == @user.id}
+ end
+
# Ensures that a valid id is require to view a message.
def test_show_with_invalid_id
get :show
--
1.6.0.6
14 years, 10 months