[PATCH] Users can comment on epic stories. #168
by Darryl L. Pierce
Added a new migration that creates the mapping table for joining epics
to their messages.
Added a business rule, Epic.can_comment?. It only returns true if the
given user has a role in one of the products owned by the epic's
project. To support this a new business rule, Project.is_member?, was
added that returns whether a user is a member of project's product
teams.
When an epic is shown, an empty comment is created but not saved if the
user is allowed to post comments. On the epic detail page a comment form
is shown.
Added a new route, comment_epic_path, for posting comments. Added the
supporting action, EpicsController.comment, to receive and process the
comment.
Signed-off-by: Darryl L. Pierce <mcpierce(a)gmail.com>
---
app/controllers/epics_controller.rb | 49 ++++++++++++---
app/models/epic.rb | 6 ++
app/models/product_role.rb | 1 +
app/models/project.rb | 5 ++
app/views/epics/show.html.erb | 8 +++
config/routes.rb | 5 +-
db/migrate/023_create_epics_messages.rb | 36 +++++++++++
doc/ChangeLog | 1 +
test/fixtures/epics_messages.yml | 3 +
test/fixtures/messages.yml | 5 ++
test/functional/epics_controller_test.rb | 97 ++++++++++++++++++++++++------
test/unit/epic_test.rb | 21 +++++++
test/unit/project_test.rb | 35 +++++++++--
13 files changed, 235 insertions(+), 37 deletions(-)
create mode 100644 db/migrate/023_create_epics_messages.rb
create mode 100644 test/fixtures/epics_messages.yml
diff --git a/app/controllers/epics_controller.rb b/app/controllers/epics_controller.rb
index 5835f22..1d478c5 100644
--- a/app/controllers/epics_controller.rb
+++ b/app/controllers/epics_controller.rb
@@ -16,16 +16,18 @@
# +EpicsController+ allows users to work with +Epic+ stories.
class EpicsController < ApplicationController
- before_filter :authenticated, :except => [:index, :show]
- before_filter :load_project, :only => [:new, :create]
- before_filter :load_epic, :except => [:index, :new, :create]
- before_filter :verify_can_create, :only => [:new, :create]
- before_filter :verify_can_edit, :only => [:edit, :update]
- before_filter :verify_can_delete, :only => [:destroy]
- before_filter :verify_can_close, :only => [:close]
- before_filter :verify_can_reopen, :only => [:reopen]
- before_filter :path_to_list, :only => [:index, :new, :create]
- before_filter :path_to_one, :only => [:show, :edit, :update]
+ before_filter :authenticated, :except => [:index, :show]
+ before_filter :load_project, :only => [:new, :create]
+ before_filter :load_epic, :except => [:index, :new, :create]
+ before_filter :verify_can_create, :only => [:new, :create]
+ before_filter :verify_can_comment, :only => [:comment]
+ before_filter :verify_can_edit, :only => [:edit, :update]
+ before_filter :verify_can_delete, :only => [:destroy]
+ before_filter :verify_can_close, :only => [:close]
+ before_filter :verify_can_reopen, :only => [:reopen]
+ before_filter :load_comment, :only => [:comment]
+ before_filter :path_to_list, :only => [:index, :new, :create]
+ before_filter :path_to_one, :only => [:show, :edit, :update]
# GET /epics
def index
@@ -42,6 +44,7 @@ class EpicsController < ApplicationController
# GET /epics/1
def show
@title = "Epic:#{@epic.title}"
+ @comment = Message.new if @epic.can_comment?(@user)
end
# GET /epics/new
@@ -137,6 +140,24 @@ class EpicsController < ApplicationController
end
end
+ # POST /epics/1/comment
+ def comment
+ Epic.transaction do
+ respond_to do |format|
+ @comment.author = @user
+ @comment.posted = Time.current
+ if @comment.valid?
+ @epic.comments << @comment
+ @epic.save!
+ flash[:message] = "Comment saved."
+ format.html {redirect_to epic_path(@epic)}
+ else
+ format.html {render :action => :show}
+ end
+ end
+ end
+ end
+
private
def load_project
@@ -156,6 +177,10 @@ class EpicsController < ApplicationController
report_error "You are not allowed to created epics for this project." unless @project.can_create_epics?(@user)
end
+ def verify_can_comment
+ report_error("You are not allowed to comment on this epic.", epic_path(@epic)) unless @epic.can_comment?(@user)
+ end
+
def verify_can_edit
report_error "You are not allowed to edit this epic." unless @epic.can_edit?(@user)
end
@@ -172,6 +197,10 @@ class EpicsController < ApplicationController
report_error "You are not allowed to reopen this epic." unless @epic.can_reopen?(@user)
end
+ def load_comment
+ @comment = Message.new(params[:message])
+ end
+
def path_to_list
if @project || params[:project]
@project = Project.find_by_id(params[:project]) unless @project
diff --git a/app/models/epic.rb b/app/models/epic.rb
index d2e65a7..226a9f1 100644
--- a/app/models/epic.rb
+++ b/app/models/epic.rb
@@ -34,6 +34,7 @@ class Epic < ActiveRecord::Base
belongs_to :project
has_many :user_stories
+ has_and_belongs_to_many :comments, :class_name => "Message", :join_table => "epics_messages"
named_scope :for_project, lambda { |project_id|
{
@@ -46,6 +47,11 @@ class Epic < ActiveRecord::Base
"#{title} (#{priority})"
end
+ # Returns whether the user can post comments on this epic.
+ def can_comment?(user)
+ project.is_member?(user)
+ end
+
# Returns whether the user can edit this epic.
def can_edit?(user)
is_owner? user
diff --git a/app/models/product_role.rb b/app/models/product_role.rb
index fe77f43..f2824ef 100644
--- a/app/models/product_role.rb
+++ b/app/models/product_role.rb
@@ -32,6 +32,7 @@ class ProductRole < ActiveRecord::Base
belongs_to :user
belongs_to :role
+ named_scope :for_project, lambda {|project_id| {:conditions => ['product_id in (select id from products where project_id = ?)', project_id]}}
named_scope :for_product, lambda {|product_id| {:conditions => ['product_id = ?', product_id]}}
named_scope :approved, {:conditions => 'is_approved = true and pending = false'}
named_scope :for_user, lambda {|user_id| {:conditions => ['user_id = ?', user_id]}}
diff --git a/app/models/project.rb b/app/models/project.rb
index 96ddf10..00f90c8 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -43,6 +43,11 @@ class Project < ActiveRecord::Base
}
}
+ # Returns whether the user is a member of one of the project's product teams.
+ def is_member?(user)
+ user && !ProductRole.for_project(self).for_user(user).approved.empty?
+ end
+
# Returns whether the user can modify this project.
def can_edit?(user)
user && (user.privileges.admin_projects || (user.id == owner_id))
diff --git a/app/views/epics/show.html.erb b/app/views/epics/show.html.erb
index ea14757..713f55a 100644
--- a/app/views/epics/show.html.erb
+++ b/app/views/epics/show.html.erb
@@ -7,6 +7,14 @@
<dt><%= "This epic is #{(a)epic.closed ? 'closed' : 'open'}." %></dt>
</dl>
</div>
+
+ <% if @epic.can_comment?(@user) %>
+ <%= render :partial => 'messages/edit',
+ :locals => {:caption => "Comment On This Epic", :url => comment_epic_path(@epic),:message => @comment} %>
+ <% end %>
+
+ <%= render :partial => 'messages/list', :locals => {:caption => "Comments", :messages => @epic.comments} %>
+
</div>
<% render :layout => 'home/sidebar', :locals => {:title => 'Epic Commands'} do %>
diff --git a/config/routes.rb b/config/routes.rb
index 4c62ef3..143a207 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -30,8 +30,9 @@ ActionController::Routing::Routes.draw do |map|
map.resources :epics, :member =>
{
- :close => :put,
- :reopen => :put
+ :close => :put,
+ :reopen => :put,
+ :comment => :post
}
map.resources :projects, :member =>
diff --git a/db/migrate/023_create_epics_messages.rb b/db/migrate/023_create_epics_messages.rb
new file mode 100644
index 0000000..3669c18
--- /dev/null
+++ b/db/migrate/023_create_epics_messages.rb
@@ -0,0 +1,36 @@
+# 023_create_epics_messages.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 CreateEpicsMessages < ActiveRecord::Migration
+ def self.up
+ create_table :epics_messages do |t|
+ t.integer :epic_id, :null => false
+ t.integer :message_id, :null => false
+ end
+
+ add_index :epics_messages, :message_id, :unique => true
+
+ execute 'alter table epics_messages add constraint fk_epics_messages_epic
+ foreign key (epic_id) references epics(id)'
+ execute 'alter table epics_messages add constraint fk_epics_messages_message
+ foreign key (message_id) references messages(id)'
+ end
+
+ def self.down
+ remove_table :epics_messages
+ end
+end
diff --git a/doc/ChangeLog b/doc/ChangeLog
index 2abdbaa..232b8e8 100644
--- a/doc/ChangeLog
+++ b/doc/ChangeLog
@@ -5,6 +5,7 @@ Change Log (0.3.0):
* #160 - A product's RSS feed is displayed on the product details page.
* #161 - Users can post comments against user stories.
* #167 - Blocker messages are included in the daily updates email.
+ * #168 - Users can post comments for epic stories.
* #173 - Backlog items can be dropped from an active sprint.
* #174 - Deferred backlog items can be re-added to the active sprint.
* #175 - When viewing an unapproved project's product list, the sidebar is misplaced. (BUG)
diff --git a/test/fixtures/epics_messages.yml b/test/fixtures/epics_messages.yml
new file mode 100644
index 0000000..8cd286f
--- /dev/null
+++ b/test/fixtures/epics_messages.yml
@@ -0,0 +1,3 @@
+epic_comment:
+ epic_id: <%= Fixtures.identify(:user_stories_epic) %>
+ message_id: <%= Fixtures.identify(:epic_message) %>
diff --git a/test/fixtures/messages.yml b/test/fixtures/messages.yml
index 55984a6..8db897b 100644
--- a/test/fixtures/messages.yml
+++ b/test/fixtures/messages.yml
@@ -1,3 +1,8 @@
+epic_message:
+ author_id: <%= Fixtures.identify(:mcpierce) %>
+ posted: <%= Time.current %>
+ body: This is a message against an epic story.
+
user_story_message:
author_id: <%= Fixtures.identify(:mcpierce) %>
posted: <%= Time.current %>
diff --git a/test/functional/epics_controller_test.rb b/test/functional/epics_controller_test.rb
index ebf1313..69357be 100644
--- a/test/functional/epics_controller_test.rb
+++ b/test/functional/epics_controller_test.rb
@@ -45,10 +45,16 @@ class EpicsControllerTest < ActionController::TestCase
raise "Project cannot be approved!" if @unapproved_project.approved
raise "Project owner is wrong!" unless @unapproved_project.owner_id == @owner.id
+ @member = users(:mcpierce)
+ raise "Member must have a product role!" unless @project.is_member?(@member)
+ @nonmember = users(:celliot)
+ raise "Nonmember must not have a product role!" if @project.is_member?(@nonmember)
+
@nonowner = users(:mcpierce)
raise "Nonowner and owner cannot be the same user!" if @owner.id == @nonowner.id
- @new_epic = { :title => 'This is a new epic', :priority => 1 }
+ @new_epic = {:title => 'This is a new epic', :priority => 1}
+ @new_comment = {:body => "This is a comment"}
end
# Ensures that viewing a list of epics requires a valid project.
@@ -60,12 +66,12 @@ class EpicsControllerTest < ActionController::TestCase
# Ensures that viewing epics for a specific project limits those displayed.
def test_index_with_project
- get :index, { :project => @project.id }
+ get :index, {:project => @project.id}
assert_response :success
assert_template 'epics/index'
assert assigns['epics'], "Failed to load any epics."
- assigns['epics'].each { |epic| assert_equal @project.id, epic.project_id, "Only those epics for the specified project should have been loaded." }
+ assigns['epics'].each {|epic| assert_equal @project.id, epic.project_id, "Only those epics for the specified project should have been loaded."}
end
# Ensures that viewing a list of epics works as expected.
@@ -78,18 +84,28 @@ class EpicsControllerTest < ActionController::TestCase
# Ensures that a valid epic id is required.
def test_show_with_invalid_epic
- get :show, { }
+ get :show, {}
assert_redirected_to error_path
end
# Ensures that showing an epic works as expected.
def test_show
- get :show, { :id => @epic.id }
+ get :show, {:id => @epic.id}
+
+ assert_response :success
+ assert assigns['epic'], "Failed to load the epic."
+ assert_equal @epic.id, assigns['epic'].id, "Loaded the wrong epic."
+ end
+
+ # Ensures that showing an epic for a team member gets a comment.
+ def test_show_for_member
+ get :show, {:id => @epic.id}, {:user_id => @member.id}
assert_response :success
assert assigns['epic'], "Failed to load the epic."
assert_equal @epic.id, assigns['epic'].id, "Loaded the wrong epic."
+ assert assigns['comment'], "A comment should have been created."
end
# Ensures that anonymous users cannot create new epics.
@@ -101,7 +117,7 @@ class EpicsControllerTest < ActionController::TestCase
# Ensures that a valid project is required to create an epic.
def test_new_with_invalid_project
- get :new, { }, {:user_id => @owner.id}
+ get :new, {}, {:user_id => @owner.id}
assert_redirected_to error_path
end
@@ -115,7 +131,7 @@ class EpicsControllerTest < ActionController::TestCase
# Ensures that creating a new epic works as expected.
def test_new
- get :new, { :project => @project.id }, {:user_id => @owner.id}
+ get :new, {:project => @project.id}, {:user_id => @owner.id}
assert_response :success
assert assigns['epic'], "Failed to create a new epic."
@@ -130,28 +146,28 @@ class EpicsControllerTest < ActionController::TestCase
# Ensures that a valid project is required to create a new epic.
def test_create_with_invalid_project
- post :create, { }, {:user_id => @owner.id}
+ post :create, {}, {:user_id => @owner.id}
assert_redirected_to error_path
end
# Ensures that an unapproved project cannot have epics created.
def test_create_for_unapproved_project
- post :create, { :project => @unapproved_project.id, :epic => @new_epic}, {:user_id => @owner.id}
+ post :create, {:project => @unapproved_project.id, :epic => @new_epic}, {:user_id => @owner.id}
assert_redirected_to error_path
end
# Ensures that a non-owner cannot create a new epic.
def test_create_as_nonowner
- post :create, {:project => @project.id, :epic => @new_epic }, {:user_id => @nonowner.id}
+ post :create, {:project => @project.id, :epic => @new_epic}, {:user_id => @nonowner.id}
assert_redirected_to error_path
end
# Ensures that a malformed epic redisplays the edit window.
def test_create_with_invalid_epic
- post :create, {:project => @project.id, :epic => {:title => "Test"} }, {:user_id => @owner.id}
+ post :create, {:project => @project.id, :epic => {:title => "Test"}}, {:user_id => @owner.id}
assert_response :success
assert_template "edit"
@@ -159,7 +175,7 @@ class EpicsControllerTest < ActionController::TestCase
# Ensures that a well-formed epic is saved.
def test_create
- post :create, { :project => @project.id, :epic => @new_epic }, {:user_id => @owner.id}
+ post :create, {:project => @project.id, :epic => @new_epic}, {:user_id => @owner.id}
result = Epic.find_by_title(@new_epic[:title])
assert result, "The new epic was not saved."
@@ -175,7 +191,7 @@ class EpicsControllerTest < ActionController::TestCase
# Ensures that a valid epic id is required.
def test_edit_with_invalid_epic
- get :edit, { }, {:user_id => @owner.id}
+ get :edit, {}, {:user_id => @owner.id}
assert_redirected_to error_path
end
@@ -236,7 +252,7 @@ class EpicsControllerTest < ActionController::TestCase
# Ensures that epics are updated as expected.
def test_update
put :update,
- { :id => @epic.id, :epic => {:title => "test"}},
+ {:id => @epic.id, :epic => {:title => "test"}},
{:user_id => @owner.id}
assert_redirected_to epic_path(@epic)
@@ -292,7 +308,7 @@ class EpicsControllerTest < ActionController::TestCase
# Ensures that only the owner can mark a project completed.
def test_close_as_nonowner
- put :close, { :id => @epic.id}, {:user_id => @nonowner.id}
+ put :close, {:id => @epic.id}, {:user_id => @nonowner.id}
assert_redirected_to error_path
end
@@ -321,21 +337,21 @@ class EpicsControllerTest < ActionController::TestCase
# Ensures that a valid epic id is required.
def test_reopen_with_invalid_epic
- put :reopen, { }, {:user_id => @owner.id}
+ put :reopen, {}, {:user_id => @owner.id}
assert_redirected_to error_path
end
# Ensures that only the owner can reopen a closed epic.
def test_reopen_as_nonowner
- put :reopen, { :id => @closed_epic.id}, {:user_id => @nonowner.id}
+ put :reopen, {:id => @closed_epic.id}, {:user_id => @nonowner.id}
assert_redirected_to error_path
end
# Ensures that only an open epic cannot be reopened.
def test_reopen_with_open_epic
- put :reopen, { :id => @epic.id}, {:user_id => @owner.id}
+ put :reopen, {:id => @epic.id}, {:user_id => @owner.id}
assert_redirected_to error_path
end
@@ -347,4 +363,49 @@ class EpicsControllerTest < ActionController::TestCase
assert_redirected_to epic_path(@closed_epic)
assert !Epic.find_by_id((a)closed_epic.id).closed, "Epic should have been reopened."
end
+
+ # Ensures that anonymous user's cannot post comments.
+ def test_comment_as_anonymous
+ post :comment, {:message => @new_comment}
+
+ assert_redirected_to login_path
+ end
+
+ # Ensures that a valid id is required.
+ def test_comment_with_invalid_id
+ post :comment, {:message => @new_comment}, {:user_id => @member.id}
+
+ assert_redirected_to error_path
+ end
+
+ # Ensures that a nonmember cannot comment on epics.
+ def test_comment_as_nonmember
+ count = @epic.comments.size
+ post :comment, {:id => @epic.id, :message => @new_comment}, {:user_id => @nonmember.id}
+
+ assert_redirected_to epic_path(@epic)
+ result = Epic.find_by_id((a)epic.id)
+ assert_equal count, result.comments.size, "Comment should not have been posted."
+ end
+
+ # Ensures that a bad comment is redisplayed.
+ def test_comment_with_invalid_comment
+ count = @epic.comments.size
+ @new_comment[:body] = ''
+ post :comment, {:id => @epic.id, :message => @new_comment},{:user_id => @member.id}
+
+ assert_template 'epics/show'
+ result = Epic.find_by_id((a)epic.id)
+ assert_equal count, result.comments.size, "Comment should not have been posted."
+ end
+
+ # Ensures that posting a comment works as expected.
+ def test_comment
+ count = @epic.comments.size
+ post :comment, {:id => @epic.id, :message => @new_comment}, {:user_id => @member.id}
+
+ assert_redirected_to epic_path(@epic)
+ result = Epic.find_by_id((a)epic.id)
+ assert_equal count + 1, result.comments.size, "Comment should have been saved."
+ end
end
diff --git a/test/unit/epic_test.rb b/test/unit/epic_test.rb
index daefae4..e644309 100644
--- a/test/unit/epic_test.rb
+++ b/test/unit/epic_test.rb
@@ -34,6 +34,12 @@ class EpicTest < ActiveSupport::TestCase
raise "Epic must be part of the project!" unless @epic.project_id == @project.id
raise "Epic must be open!" if @epic.closed
+ @member = users(:mcpierce)
+ raise "Member must have a product member!" unless @epic.project.is_member?(@member)
+
+ @nonmember = users(:celliot)
+ raise "Non-member cannot be a part of any product team!" if @epic.project.is_member?(@nonmember)
+
@closed_epic = epics(:closed_epic)
raise "Epic must be closed!" unless @closed_epic.closed
@@ -145,4 +151,19 @@ class EpicTest < ActiveSupport::TestCase
def test_can_reopen
flunk "Closed tasks should be able to reopen." unless @closed_epic.can_reopen?(@owner)
end
+
+ # Ensures that an anonymous user cannot comment on epics.
+ def test_can_comment_fails_for_anonymous
+ flunk "Anonymous users cannot comment on epics." if @epic.can_comment?(nil)
+ end
+
+ # Ensures that users who aren't members of a project's products teams cannot comment.
+ def test_can_comment_as_nonmember
+ flunk "Non-product members cannot comment on epics." if @epic.can_comment?(@nonmember)
+ end
+
+ # Ensures that members can comment on epics.
+ def test_can_comment
+ flunk "Members must be able to comment on epics." unless @epic.can_comment?(@member)
+ end
end
diff --git a/test/unit/project_test.rb b/test/unit/project_test.rb
index 56d70f9..65ff86e 100644
--- a/test/unit/project_test.rb
+++ b/test/unit/project_test.rb
@@ -22,11 +22,11 @@ class ProjectTest < ActiveSupport::TestCase
fixtures :users
def setup
- @existing_project = projects(:projxp)
+ @project = projects(:projxp)
@unapproved_project = projects(:unapproved_project)
raise "Unapproved project cannot be approved!" if @unapproved_project.approved
- @owner = @existing_project.owner
+ @owner = @project.owner
@nonowner = users(:mcpierce)
raise "Owner and nonowner cannot be the same person!" if @owner.id == @nonowner.id
@@ -35,6 +35,12 @@ class ProjectTest < ActiveSupport::TestCase
@nonadmin = users(:mcpierce)
raise "Nonadmin must not have project admin rights!" if @nonadmin.privileges.admin_projects
+ @member = ProductRole.for_product((a)project.products.first).approved.first.user
+ raise "No product member found!" unless @member
+
+ @nonmember = users(:celliot)
+ Product.for_project((a)project).each {|product| raise "Nonmember cannot have product role!" if product.is_member?(@nonmember)}
+
@project_with_logo = projects(:projxp)
raise "Project must have a logo!" unless @project_with_logo.logo_name
@project_without_logo = projects(:teatime)
@@ -55,7 +61,7 @@ class ProjectTest < ActiveSupport::TestCase
# Ensures that a project must have a unique name.
def test_valid_fails_without_unique_name
- @new_project.name = @existing_project.name
+ @new_project.name = @project.name
flunk 'A project must have a unique name.' if @new_project.valid?
end
@@ -79,12 +85,12 @@ class ProjectTest < ActiveSupport::TestCase
# Ensures non-owners cannot create products.
def test_create_products_for_nonowners
- flunk "Non-owners should not be able to create products!" if @existing_project.can_create_products?(@nonowner)
+ flunk "Non-owners should not be able to create products!" if @project.can_create_products?(@nonowner)
end
# Ensures that a project owner can create products.
def test_create_products_for_project_owner
- flunk "A project owner should be able to create products." unless @existing_project.can_create_products?(@owner)
+ flunk "A project owner should be able to create products." unless @project.can_create_products?(@owner)
end
# Ensures that non admins cannot approve projects.
@@ -104,12 +110,12 @@ class ProjectTest < ActiveSupport::TestCase
# Ensures that non-admins cannot create epics.
def test_create_epics_for_nonadmins
- flunk "Non-admins cannot create epics." if @existing_project.can_create_epics?(@nonowner)
+ flunk "Non-admins cannot create epics." if @project.can_create_epics?(@nonowner)
end
# Ensures that creating epics checks is fine.
def test_create_epics
- flunk "A project owner can create epics." unless @existing_project.can_create_epics?(@owner)
+ flunk "A project owner can create epics." unless @project.can_create_epics?(@owner)
end
# Ensures that a project with no logo returns the default logo.
@@ -117,4 +123,19 @@ class ProjectTest < ActiveSupport::TestCase
expected = ConfigProperty.fetch("image.default_project_logo")
assert_equal expected, @project_without_logo.logo_url, "The default URL is not correct."
end
+
+ # Ensures that anonymous users aren't listed as members.
+ def test_is_member_for_anonymous
+ flunk "Anonymous users are not members." if @project.is_member?(nil)
+ end
+
+ # Ensures that users who are not members of a project's products are not listed as members.
+ def test_is_member_for_nonmember
+ flunk "This user is not a member!" if @project.is_member?(@nonmember)
+ end
+
+ # Ensures that membership is determined correctly.
+ def test_is_member
+ flunk "This user is a member!" unless @project.is_member?(@member)
+ end
end
--
1.6.0.6
15 years
[PATCH] Users can post comments against user stories. #161
by Darryl L. Pierce
Added a new table, user_stories_messages, that maps a Message back to
the UserStory against which it was posted.
Also cleaned up the user_story.rb and user_story_test.rb files.
Added a new business rule check, UserStory.can_comment?, that returns
whether the specified user is allowed to comment on the given user
story.
Created a new view for editing a message. Also a new action
StoriesController::comment for posting comments.
Signed-off-by: Darryl L. Pierce <mcpierce(a)gmail.com>
---
app/controllers/application.rb | 4 +-
app/controllers/sprints_controller.rb | 4 +-
app/controllers/stories_controller.rb | 47 +++++++++--
app/models/message.rb | 19 +++--
app/models/product.rb | 11 +--
app/models/product_role.rb | 4 +
app/models/user_story.rb | 53 +++++++------
app/views/messages/_edit.html.erb | 21 +++++
app/views/messages/_list.html.erb | 19 +++++
app/views/sprints/_edit.html.erb | 2 +-
app/views/stories/show.html.erb | 8 ++
config/routes.rb | 5 +-
db/migrate/022_create_user_stories_messages.rb | 34 ++++++++
doc/ChangeLog | 1 +
test/fixtures/messages.yml | 5 +
test/fixtures/user_stories_messages.yml | 3 +
test/functional/stories_controller_test.rb | 102 ++++++++++++++++++------
test/unit/user_story_test.rb | 51 ++++++++----
18 files changed, 296 insertions(+), 97 deletions(-)
create mode 100644 app/views/messages/_edit.html.erb
create mode 100644 app/views/messages/_list.html.erb
create mode 100644 db/migrate/022_create_user_stories_messages.rb
create mode 100644 test/fixtures/user_stories_messages.yml
diff --git a/app/controllers/application.rb b/app/controllers/application.rb
index 899db21..dd60773 100644
--- a/app/controllers/application.rb
+++ b/app/controllers/application.rb
@@ -75,11 +75,11 @@ class ApplicationController < ActionController::Base
# Redirects the user to the error page and displays
# the provided message.
- def report_error(message)
+ def report_error(message, url=nil)
flash[:error] = message
respond_to do |format|
- format.html { redirect_to error_path }
+ format.html { redirect_to url ? url : error_path }
end
end
diff --git a/app/controllers/sprints_controller.rb b/app/controllers/sprints_controller.rb
index 31c3d66..b2e4c52 100644
--- a/app/controllers/sprints_controller.rb
+++ b/app/controllers/sprints_controller.rb
@@ -289,7 +289,7 @@ class SprintsController < ApplicationController
def load_product
@product = Product.find_by_id(params[:product])
- @members = @product.users if @product
+ @members = @product.members if @product
@project = @product.project if @product
report_error "Missing or invalid product." unless @product
@@ -317,7 +317,7 @@ class SprintsController < ApplicationController
def prepare_for_edit
@selected = Array.new
- @members = @product.users
+ @members = @product.members
end
def add_users_to_sprint(selected)
diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb
index 8db78e4..5bf1b44 100644
--- a/app/controllers/stories_controller.rb
+++ b/app/controllers/stories_controller.rb
@@ -16,15 +16,17 @@
# +StoriesController+ performs CRUD operations for instances of +UserStory+.
class StoriesController < ApplicationController
- before_filter :authenticated, :except => [:index, :show]
- before_filter :load_product, :only => [:new, :create]
- before_filter :load_user_story, :except => [:index, :new, :create]
- before_filter :load_epics, :only => [:new, :edit, :create, :update]
- before_filter :verify_can_create, :only => [:new, :create]
- before_filter :verify_can_edit, :only => [:edit, :update]
- before_filter :verify_can_delete, :only => [:destroy]
- before_filter :path_to_list, :only => [:index, :new, :create]
- before_filter :path_to_one, :only => [:show, :edit, :update]
+ before_filter :authenticated, :except => [:index, :show]
+ before_filter :load_product, :only => [:new, :create]
+ before_filter :load_user_story, :except => [:index, :new, :create]
+ before_filter :load_epics, :only => [:new, :edit, :create, :update]
+ before_filter :verify_can_create, :only => [:new, :create]
+ before_filter :verify_can_edit, :only => [:edit, :update]
+ before_filter :verify_can_delete, :only => [:destroy]
+ before_filter :verify_can_comment, :only => [:comment]
+ before_filter :load_comment, :only => [:comment]
+ before_filter :path_to_list, :only => [:index, :new, :create]
+ before_filter :path_to_one, :only => [:show, :edit, :update]
# GET /stories
def index
@@ -39,6 +41,7 @@ class StoriesController < ApplicationController
# GET /stories/1
def show
@title = "User Story - #{(a)user_story.title}"
+ @comment = Message.new(:author => @user) if @user_story.can_comment?(@user)
respond_to do |format|
format.html
end
@@ -122,6 +125,24 @@ class StoriesController < ApplicationController
end
end
+ # POST /stories/1/comment
+ def comment
+ Message.transaction do
+ respond_to do |format|
+ @comment.author = @user
+ @comment.posted = Time.current
+ if @comment.valid?
+ @user_story.comments << @comment
+ @user_story.save!
+ flash[:message] = "Comment saved."
+ format.html {redirect_to story_path(@user_story)}
+ else
+ format.html {render :action => :show}
+ end
+ end
+ end
+ end
+
private
def load_product
@@ -151,6 +172,14 @@ class StoriesController < ApplicationController
report_error "You are not allowed to deslete this user story." unless @user_story.can_delete?(@user)
end
+ def verify_can_comment
+ report_error("You are not allowed to comment on this user story.", story_path(@user_story)) unless @user_story.can_comment?(@user)
+ end
+
+ def load_comment
+ @comment = Message.new(params[:message])
+ end
+
def load_epics
@epics = Epic.find_all_by_project_id((a)project.id, :order => 'priority', :conditions => "closed = false")
end
diff --git a/app/models/message.rb b/app/models/message.rb
index 58966f4..ffb49f0 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -17,15 +17,16 @@
# A +Message+ represents a single text body written by a user.
class Message < ActiveRecord::Base
- validates_presence_of :author_id, :message => 'An author is required.'
-
- validates_presence_of :body, :message => 'A message body is required.'
-
- validates_length_of :body, :within => 5..1000,
- :too_long => 'Maximum message length is 1000 characters.',
- :too_short => 'Please enter at least 5 characters.'
-
- validates_presence_of :posted, :message => 'A posting date is required.'
+ validates_presence_of(:author_id,
+ :message => 'An author is required.')
+ validates_presence_of(:body,
+ :message => 'A message body is required.')
+ validates_length_of(:body,
+ :within => 5..1000,
+ :too_long => 'Maximum message length is 1000 characters.',
+ :too_short => 'Please enter at least 5 characters.')
+ validates_presence_of(:posted,
+ :message => 'A posting date is required.')
belongs_to :author, :class_name => 'User', :foreign_key => :author_id
diff --git a/app/models/product.rb b/app/models/product.rb
index 9abcb8d..cc238fa 100644
--- a/app/models/product.rb
+++ b/app/models/product.rb
@@ -34,12 +34,10 @@ class Product < ActiveRecord::Base
:message => 'Mailing list must be a valid email address.'
belongs_to :project
- belongs_to :owner,
- :class_name => 'User',
- :foreign_key => 'owner_id'
+ belongs_to :owner, :class_name => 'User', :foreign_key => 'owner_id'
has_many :product_roles, :dependent => :destroy
- has_many :users, :through => :product_roles
+ has_many :members, :through => :product_roles, :source => :user
has_many :user_stories, :dependent => :destroy
has_many :sprints, :dependent => :destroy
has_and_belongs_to_many :rss_entries, :class_name => 'Feed', :join_table => :product_feeds, :order => 'created_at DESC'
@@ -106,10 +104,7 @@ class Product < ActiveRecord::Base
# Returns whether the specified user is a member of the product team.
def is_member?(user)
- if (user != nil)
- role = ProductRole.find_by_product_id_and_user_id(self.id, user.id)
- return role.approved if role
- end
+ user && !ProductRole.for_product(self).for_user(user).approved.empty?
end
# Returns whether the user has a pending membership.
diff --git a/app/models/product_role.rb b/app/models/product_role.rb
index 38164a3..fe77f43 100644
--- a/app/models/product_role.rb
+++ b/app/models/product_role.rb
@@ -32,6 +32,10 @@ class ProductRole < ActiveRecord::Base
belongs_to :user
belongs_to :role
+ named_scope :for_product, lambda {|product_id| {:conditions => ['product_id = ?', product_id]}}
+ named_scope :approved, {:conditions => 'is_approved = true and pending = false'}
+ named_scope :for_user, lambda {|user_id| {:conditions => ['user_id = ?', user_id]}}
+
# Sets the status for a pending role.
def approved=(status)
self.pending = false
diff --git a/app/models/user_story.rb b/app/models/user_story.rb
index 4a5f8a6..f1ad634 100644
--- a/app/models/user_story.rb
+++ b/app/models/user_story.rb
@@ -17,37 +17,35 @@
# +UserStory+ represents a single user story.
#
+# A user story can be related back to an +Epic+ story, which is a project level
+# feature. User stories are the expression of that epic within a single product.
+#
+# A user story can have one or more +BacklogItem+ objects that refer to it.
+#
class UserStory < ActiveRecord::Base
- validates_presence_of :product_id,
- :message => 'All user stories must belong to a product.'
-
- validates_presence_of :priority,
- :message => 'A user story must have a priority.'
- validates_numericality_of :priority,
- :message => 'The priority must be an integer value',
- :only_integer => true,
- :greater_than => 0
-
- validates_presence_of :title,
- :message => 'You must include a title for the user story.'
- validates_length_of :title,
- :message => 'The title must be at least 5 characters long.',
- :minimum => 1
+ validates_presence_of(:product_id,
+ :message => 'All user stories must belong to a product.')
+ validates_presence_of(:priority,
+ :message => 'A user story must have a priority.')
+ validates_numericality_of(:priority,
+ :message => 'The priority must be an integer value',
+ :only_integer => true,
+ :greater_than => 0)
+ validates_presence_of(:title,
+ :message => 'You must include a title for the user story.')
+ validates_length_of(:title,
+ :message => 'The title must be at least 5 characters long.',
+ :minimum => 1)
belongs_to :product
belongs_to :epic
has_many :backlog_items
+ has_and_belongs_to_many :comments, :join_table => :user_stories_messages, :class_name => 'Message'
- named_scope :default, { :order => 'priority ASC' }
- named_scope :for_product, lambda { |product_id|
- { :conditions => product_id ? ["product_id = ?", product_id] : [] }
- }
- named_scope :for_epic, lambda { |epic_id|
- {:conditions => ['epic_id = ?', epic_id]}
- }
- named_scope :not_in_sprint, lambda { |sprint_id|
- {:conditions => ['id not in (select user_story_id from backlog_items where sprint_id = ?)', sprint_id]}
- }
+ named_scope :default, {:order => 'priority ASC'}
+ named_scope :for_product, lambda {|product_id| {:conditions => product_id ? ["product_id = ?", product_id] : []}}
+ named_scope :for_epic, lambda {|epic_id| {:conditions => ['epic_id = ?', epic_id]}}
+ named_scope :not_in_sprint, lambda {|sprint_id| {:conditions => ['id not in (select user_story_id from backlog_items where sprint_id = ?)', sprint_id]}}
named_scope :open, {:conditions => 'closed = false'}
# Returns whether the user can edit this user story.
@@ -55,6 +53,11 @@ class UserStory < ActiveRecord::Base
user && (user.id == product.owner_id)
end
+ # Returns whether the user can comment on this user story.
+ def can_comment?(user)
+ product.is_member?(user)
+ end
+
# Returns whether the user can delete this user story.
def can_delete?(user)
user && (user.id == product.owner_id) && can_be_deleted?
diff --git a/app/views/messages/_edit.html.erb b/app/views/messages/_edit.html.erb
new file mode 100644
index 0000000..e49f442
--- /dev/null
+++ b/app/views/messages/_edit.html.erb
@@ -0,0 +1,21 @@
+<% form_for(:message, message, :url => url) do |form| %>
+
+<table class="edit">
+ <caption><%= "#{caption}" %></caption>
+ <tbody>
+ <tr>
+ <td>
+ <%= form.text_area :body, :rows => 10, :cols => 100 %>
+ <%= error_message_on(message, :body) %>
+ </td>
+ </tr>
+
+ <tr>
+ <td class="buttons" colspan="2">
+ <%= submit_tag "Post" %>
+ </td>
+ </tr>
+ </tbody>
+</table>
+
+<% end %>
diff --git a/app/views/messages/_list.html.erb b/app/views/messages/_list.html.erb
new file mode 100644
index 0000000..a08b514
--- /dev/null
+++ b/app/views/messages/_list.html.erb
@@ -0,0 +1,19 @@
+<table class="main-list">
+ <caption><%= caption %></caption>
+ <thead>
+ <tr>
+ <th span="col">#</th>
+ <th span="col" class="name"></th>
+ <th span="col">Posted</th>
+ </tr>
+ </thead>
+ <tbody>
+ <% messages.each do |message| %>
+ <tr class="<%= cycle('odd', 'even') %>">
+ <td><%= link_to "#{message.id}", message_path(message) %></td>
+ <td><%= RedCloth.new(message.body).to_html %></td>
+ <td><%= show_date(message.posted, true) %></td>
+ </tr>
+ <% end %>
+ </tbody>
+</table>
diff --git a/app/views/sprints/_edit.html.erb b/app/views/sprints/_edit.html.erb
index 31b1231..31d60db 100644
--- a/app/views/sprints/_edit.html.erb
+++ b/app/views/sprints/_edit.html.erb
@@ -16,7 +16,7 @@
<tr>
<td class="label-required">Team lead:</td>
<td class="value">
- <%= collection_select :sprint, :team_lead_id, @product.users, :id,
+ <%= collection_select :sprint, :team_lead_id, @product.members, :id,
:display_name, {:include_blank => false} %>
<%= error_message_on(:sprint, :team_lead_id) %>
</td>
diff --git a/app/views/stories/show.html.erb b/app/views/stories/show.html.erb
index a2ca708..85afce2 100644
--- a/app/views/stories/show.html.erb
+++ b/app/views/stories/show.html.erb
@@ -5,6 +5,14 @@
<dd><%= RedCloth.new((a)user_story.description).to_html %></dd>
</dl>
</div>
+
+ <% if @user_story.can_comment?(@user) %>
+ <%= render :partial => 'messages/edit',
+ :locals => {:caption => "Comment On This Story", :url => comment_story_path(@user_story),:message => @comment} %>
+ <% end %>
+
+ <%= render :partial => 'messages/list', :locals => {:caption => "User Story Comments", :messages => @user_story.comments} %>
+
</div>
<% render :layout => 'home/sidebar', :locals => {:title => 'User Story Commands'} do %>
diff --git a/config/routes.rb b/config/routes.rb
index 79f0cc1..4c62ef3 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -40,7 +40,10 @@ ActionController::Routing::Routes.draw do |map|
:reject => :put
}
- map.resources :stories
+ map.resources :stories, :member =>
+ {
+ :comment => :post,
+ }
map.resources :products, :member =>
{
diff --git a/db/migrate/022_create_user_stories_messages.rb b/db/migrate/022_create_user_stories_messages.rb
new file mode 100644
index 0000000..c773c4f
--- /dev/null
+++ b/db/migrate/022_create_user_stories_messages.rb
@@ -0,0 +1,34 @@
+# 022_create_user_stories_messages.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 CreateUserStoriesMessages < ActiveRecord::Migration
+ def self.up
+ create_table :user_stories_messages do |t|
+ t.integer :user_story_id, :null => false
+ t.integer :message_id, :null => false
+ end
+
+ execute 'alter table user_stories_messages add constraint fk_user_stories_messages_user_story
+ foreign key (user_story_id) references user_stories(id)'
+ execute 'alter table user_stories_messages add constraint fk_user_stories_messages_message
+ foreign key (message_id) references messages(id)'
+ end
+
+ def self.down
+ drop_table :user_stories_messages
+ end
+end
diff --git a/doc/ChangeLog b/doc/ChangeLog
index 023e95e..2abdbaa 100644
--- a/doc/ChangeLog
+++ b/doc/ChangeLog
@@ -3,6 +3,7 @@ Change Log (0.3.0):
* #157 - Items can be marked completed when a task is added.
* #159 - Users can watch an RSS feed of product activity.
* #160 - A product's RSS feed is displayed on the product details page.
+ * #161 - Users can post comments against user stories.
* #167 - Blocker messages are included in the daily updates email.
* #173 - Backlog items can be dropped from an active sprint.
* #174 - Deferred backlog items can be re-added to the active sprint.
diff --git a/test/fixtures/messages.yml b/test/fixtures/messages.yml
index 01c1baf..55984a6 100644
--- a/test/fixtures/messages.yml
+++ b/test/fixtures/messages.yml
@@ -1,3 +1,8 @@
+user_story_message:
+ author_id: <%= Fixtures.identify(:mcpierce) %>
+ posted: <%= Time.current %>
+ body: This user story is awesome. It's my personal favorite!
+
blocker_message:
author_id: <%= Fixtures.identify(:mcpierce) %>
posted: <%= Time.current %>
diff --git a/test/fixtures/user_stories_messages.yml b/test/fixtures/user_stories_messages.yml
new file mode 100644
index 0000000..3b8d014
--- /dev/null
+++ b/test/fixtures/user_stories_messages.yml
@@ -0,0 +1,3 @@
+create_user_story_message:
+ user_story_id: <%= Fixtures.identify(:create_user_story) %>
+ message_id: <%= Fixtures.identify(:user_story_message) %>
diff --git a/test/functional/stories_controller_test.rb b/test/functional/stories_controller_test.rb
index 1f17bf1..7dad60b 100644
--- a/test/functional/stories_controller_test.rb
+++ b/test/functional/stories_controller_test.rb
@@ -30,9 +30,15 @@ class StoriesControllerTest < ActionController::TestCase
@nonowner = users(:mcpierce)
raise "Owner and nonowner can't be the same person." if @owner.id == @nonowner.id
- @existing_story = user_stories(:freerange_user_story)
- raise "Story must use the right product." unless @existing_story.product_id == @product.id
- raise "Story must be deletable." unless @existing_story.can_be_deleted?
+ @story = user_stories(:freerange_user_story)
+ raise "Story must use the right product." unless @story.product_id == @product.id
+ raise "Story must be deletable." unless @story.can_be_deleted?
+
+ @member = ProductRole.for_product((a)story.product).approved.first.user
+ raise "No members were found!" unless @member
+
+ @nonmember = users(:celliot)
+ raise "Non-member cannot have a product role!" if @story.product.is_member?(@nonmember)
@story_with_backlog_items = user_stories(:create_login)
raise "Story must use the right product." unless @story_with_backlog_items.product_id == @product.id
@@ -42,6 +48,10 @@ class StoriesControllerTest < ActionController::TestCase
:title => 'This is a new user story',
:priority => 17
}
+
+ @new_comment = {
+ :body => "This is a comment."
+ }
end
# Ensures that all stories by product are viewable.
@@ -72,11 +82,11 @@ class StoriesControllerTest < ActionController::TestCase
# Ensures that showing a user story works.
def test_show
- get :show, {:id => @existing_story.id}
+ get :show, {:id => @story.id}
assert_response :success
assert assigns['user_story'], "Failed to load a user story."
- assert_equal @existing_story.id, assigns['user_story'].id,
+ assert_equal @story.id, assigns['user_story'].id,
"Failed to load the correct user story."
end
@@ -126,19 +136,19 @@ class StoriesControllerTest < ActionController::TestCase
# Ensures that only the product owner can edit a user story.
def test_edit_as_nonowner
- get :edit, {:id => @existing_story.id}, {:user_id => @nonowner.id}
+ get :edit, {:id => @story.id}, {:user_id => @nonowner.id}
assert_redirected_to error_path
end
# Ensures that editing a user story works as expected.
def test_edit
- get :edit, { :id => @existing_story.id}, {:user_id => @owner.id}
+ get :edit, { :id => @story.id}, {:user_id => @owner.id}
assert_response :success
assert assigns['user_story'], "Failed to load a user story to edit."
assert assigns['epics'], "Failed to load the set of epics."
- assert_equal @existing_story.id, assigns['user_story'].id,
+ assert_equal @story.id, assigns['user_story'].id,
"Failed to load the correct user story."
end
@@ -213,39 +223,39 @@ class StoriesControllerTest < ActionController::TestCase
# Ensures that only the product owner can update a user story.
def test_update_as_nonowner
- put :update, { :id => @existing_story.id, :user_story => {:title => 'Updated'}}, {:user_id => @nonowner.id}
+ put :update, { :id => @story.id, :user_story => {:title => 'Updated'}}, {:user_id => @nonowner.id}
assert_redirected_to error_path
- result = UserStory.find_by_id((a)existing_story.id)
- assert_equal @existing_story.title, result.title, "User story should not have been updated."
+ result = UserStory.find_by_id((a)story.id)
+ assert_equal @story.title, result.title, "User story should not have been updated."
end
# Ensures that an invalid user story gets sent back for editing.
def test_update_with_invalid_user_story
- put :update, { :id => @existing_story.id, :user_story => {:title => ''}}, {:user_id => @owner.id}
+ put :update, { :id => @story.id, :user_story => {:title => ''}}, {:user_id => @owner.id}
assert_response :success
- result = UserStory.find_by_id((a)existing_story.id)
- assert_equal @existing_story.title, result.title, "User story should not have been updated."
+ result = UserStory.find_by_id((a)story.id)
+ assert_equal @story.title, result.title, "User story should not have been updated."
end
# Ensures that updates work as expected.
def test_update
update = {:title => "This ia the new title for an existing story"}
- put :update, { :id => @existing_story.id, :user_story => update}, {:user_id => @owner.id}
+ put :update, { :id => @story.id, :user_story => update}, {:user_id => @owner.id}
assert_redirected_to stories_path(:product => @product)
- result = UserStory.find_by_id((a)existing_story.id)
+ result = UserStory.find_by_id((a)story.id)
assert_equal update[:title], result.title, "User story should have been updated."
end
# Ensures that updates return to a url when one is supplied.
def test_update_with_source
update = {:title => "This ia the new title for an existing story"}
- put :update,{ :id => @existing_story.id, :user_story => update, :source => "/farkle"}, {:user_id => @owner.id}
+ put :update,{ :id => @story.id, :user_story => update, :source => "/farkle"}, {:user_id => @owner.id}
assert_redirected_to "/farkle"
- result = UserStory.find_by_id((a)existing_story.id)
+ result = UserStory.find_by_id((a)story.id)
assert_equal update[:title], result.title, "User story should have been updated."
end
@@ -265,10 +275,10 @@ class StoriesControllerTest < ActionController::TestCase
# Ensures that only the owner can delete a user story.
def test_destroy_as_nonowner
- delete :destroy, { :id => @existing_story.id}, {:user_id => @nonowner.id}
+ delete :destroy, { :id => @story.id}, {:user_id => @nonowner.id}
assert_redirected_to error_path
- assert UserStory.find_by_id((a)existing_story.id), "User story should not have been deleted."
+ assert UserStory.find_by_id((a)story.id), "User story should not have been deleted."
end
# Ensures that user stories associated with backlog items can't be deleted.
@@ -281,17 +291,61 @@ class StoriesControllerTest < ActionController::TestCase
# Ensures that deleting a user story works as expected.
def test_destroy
- delete :destroy, {:id => @existing_story.id}, {:user_id => @owner.id}
+ delete :destroy, {:id => @story.id}, {:user_id => @owner.id}
assert_redirected_to stories_path(:product => @product)
- assert !UserStory.find_by_id((a)existing_story.id), "User story should have been deleted."
+ assert !UserStory.find_by_id((a)story.id), "User story should have been deleted."
end
# Ensures that deleting returns to the url if one is supplied.
def test_destroy_with_source
- delete :destroy,{ :id => @existing_story.id, :source => "/farkle"}, {:user_id => @owner.id}
+ delete :destroy,{ :id => @story.id, :source => "/farkle"}, {:user_id => @owner.id}
assert_redirected_to "/farkle"
- assert !UserStory.find_by_id((a)existing_story.id), "User story should have been deleted."
+ assert !UserStory.find_by_id((a)story.id), "User story should have been deleted."
+ end
+
+ # Ensures that an anonymous user cannot comment on a story.
+ def test_comment_as_anonymous
+ post :comment
+
+ assert_redirected_to login_path
+ end
+
+ # Ensures that a valid story is is required.
+ def test_comment_with_invalid_id
+ post :comment, {}, {:user_id => @member.id}
+
+ assert_redirected_to error_path
+ end
+
+ # Ensures that only product members can comment on stories.
+ def test_comment_as_nonmember
+ count = @story.comments.size
+ post :comment, {:id => @story.id}, {:user_id => @nonmember.id}
+
+ assert_redirected_to story_path(@story)
+ result = UserStory.find_by_id((a)story.id)
+ assert_equal count, result.comments.size, "Comment should not have been posted."
+ end
+
+ # Ensures that a comment body is required.
+ def test_comment_without_text
+ count = @story.comments.size
+ post :comment, {:id => @story.id}, {:user_id => @member.id}
+
+ assert_template 'stories/show'
+ result = UserStory.find_by_id((a)story.id)
+ assert_equal count, result.comments.size, "Comment should not have been posted."
+ end
+
+ # Ensures that a comment is posted properly.
+ def test_comment
+ count = @story.comments.size
+ post :comment, {:id => @story.id, :message => @new_comment}, {:user_id => @member.id}
+
+ assert_redirected_to story_path(@story)
+ result = UserStory.find_by_id((a)story.id)
+ assert_equal count + 1, result.comments.size, "Comment should have been saved!"
end
end
diff --git a/test/unit/user_story_test.rb b/test/unit/user_story_test.rb
index d3db6bb..05c922e 100644
--- a/test/unit/user_story_test.rb
+++ b/test/unit/user_story_test.rb
@@ -21,57 +21,76 @@ class UserStoryTest < ActiveSupport::TestCase
fixtures :user_stories
def setup
- @user_story = UserStory.new(
- :product_id => 1,
- :priority => 1,
- :title => 'This is a new user story.')
+ @new_story = UserStory.new(:product_id => 1,
+ :priority => 1,
+ :title => 'This is a new user story.')
+ @story = user_stories(:create_user_story)
+ @nonmember = users(:celliot)
+ raise "Non-member cannot be on the product team!" if @story.product.is_member?(@nonmember)
+ @member = ProductRole.for_product((a)story.product).approved.first.user
+ raise "No approved members were found!" unless @member
end
# Ensures that a product is required.
#
def test_valid_fails_without_product
- @user_story.product = nil
+ @new_story.product = nil
- flunk "A product must be required." if @user_story.valid?
+ flunk "A product must be required." if @new_story.valid?
end
# Ensures that a priority is required.
#
def test_valid_fails_without_priority
- @user_story.priority = nil
+ @new_story.priority = nil
- flunk "A priority is required." if @user_story.valid?
+ flunk "A priority is required." if @new_story.valid?
end
# Ensures that a priorty must be a numerical value.
#
def test_valid_fails_with_nonnumeric_priority
- @user_story.priority = 'abc'
+ @new_story.priority = 'abc'
- flunk "Priority must be a numeric value." if @user_story.valid?
+ flunk "Priority must be a numeric value." if @new_story.valid?
end
# Ensures that a priority must be a positive integer.
#
def test_valid_fails_with_negative_priority
- @user_story.priority = -1
+ @new_story.priority = -1
- flunk "Priority must not be negative." if @user_story.valid?
+ flunk "Priority must not be negative." if @new_story.valid?
end
# Ensures that the priority cannot be zero.
#
def test_valid_fails_with_zero_priority
- @user_story.priority = 0
+ @new_story.priority = 0
- flunk "Priority must be greater than zero." if @user_story.valid?
+ flunk "Priority must be greater than zero." if @new_story.valid?
end
# Ensures that a title must be supplied.
#
def test_valid_fails_without_title
- @user_story.title = ""
+ @new_story.title = ""
- flunk "A title must be supplied." if @user_story.valid?
+ flunk "A title must be supplied." if @new_story.valid?
+ end
+
+ # Ensures that a user story has messages.
+ def test_messages_is_not_empty
+ flunk "User story must have messages!" if @story.messages.empty?
+ end
+
+ # Ensures that only team members can comment on a user story.
+ def test_can_comment_for_nonmember
+ flunk "Non-members cannot comment on user stories." if @story.can_comment?(@nonmember)
+ end
+
+ # Ensures comments can be posted.
+ def test_can_comment
+ flunk "Members must be able to comment." unless @story.can_comment?(@member)
end
end
--
1.6.0.6
15 years
[PATCH] Product RSS feed is displayed on the details page. #160
by Darryl L. Pierce
Added a new div on the details. Within this are displayed the 25 latest
entries from the product's RSS feed.
Signed-off-by: Darryl L. Pierce <mcpierce(a)gmail.com>
---
app/views/products/show.html.erb | 21 +++++++++++++++++++--
public/stylesheets/tables.css | 15 +++++++++++++++
2 files changed, 34 insertions(+), 2 deletions(-)
diff --git a/app/views/products/show.html.erb b/app/views/products/show.html.erb
index 0a0cb33..7eb017a 100644
--- a/app/views/products/show.html.erb
+++ b/app/views/products/show.html.erb
@@ -19,8 +19,6 @@
<% end %>
</dl>
- <%= link_to(image_tag("/images/icons/rss.png", :title => "Product activity feed"), rss_product_path(@product)) %>
-
</div>
</div>
@@ -48,3 +46,22 @@
<%= link_to "RSS feed...",
rss_product_path(@product), :class => "command" %>
<% end %>
+
+<div id="activity">
+ <table>
+ <caption>Recent Activity</caption>
+ <thead>
+ <%= link_to(image_tag("/images/icons/rss.png", :title => "Product activity feed"), rss_product_path(@product)) %>
+ </thead>
+
+ <tbody>
+ <% @product.rss_entries.each_with_index do |entry, index| %>
+ <tr>
+ <% if index < 25 %>
+ <td><%= link_to entry.title, entry.link %></td>
+ <% end %>
+ </tr>
+ <% end %>
+ </tbody>
+ </table>
+</div>
diff --git a/public/stylesheets/tables.css b/public/stylesheets/tables.css
index b5e88bd..1b74a20 100644
--- a/public/stylesheets/tables.css
+++ b/public/stylesheets/tables.css
@@ -167,3 +167,18 @@ table.edit td.value {
padding-right: 15px;
text-align: left;
}
+
+/* activities table */
+
+div#activity table caption {
+ background-color: #606060;
+ color: #ffff00;
+ text-align: center;
+ font-size: smaller;
+ font-weight: bold;
+}
+
+div#activity table tr td {
+ text-align: left;
+ font-size: smaller;
+}
--
1.6.0.6
15 years
[PATCH] Users can consume an RSS feed of product activity. #159
by Darryl L. Pierce
Created a new model to represent an RSS feed element, named feed. Added
unit tests to ensure that the title and content for the feed is present.
Added a new image to represent the RSS link. On the product details page
this link is present at the bottom of the details section. Clicking on
that link takes the user to the /product/1/rss path.
Entries are added to the product's RSS feed when:
* the product is created, or its details updated
* a new sprint is created or updated
* a sprint is planned, started, completed or cancelled
* a sprint is populated
Signed-off-by: Darryl L. Pierce <mcpierce(a)gmail.com>
---
app/controllers/application.rb | 7 +++
app/controllers/products_controller.rb | 17 +++++-
app/controllers/sprints_controller.rb | 78 +++++++++++++++++++--------
app/models/feed.rb | 27 +++++++++
app/models/product.rb | 1 +
app/views/products/rss.rxml | 19 +++++++
app/views/products/show.html.erb | 6 ++
config/routes.rb | 5 ++
db/migrate/020_create_feeds.rb | 35 ++++++++++++
db/migrate/021_create_feeds_products.rb | 33 +++++++++++
doc/ChangeLog | 1 +
doc/Credits | 7 +++
public/images/icons/add.png | Bin 733 -> 0 bytes
public/images/icons/approve.png | Bin 619 -> 0 bytes
public/images/icons/back.png | Bin 680 -> 0 bytes
public/images/icons/delete.png | Bin 610 -> 0 bytes
public/images/icons/edit.png | Bin 703 -> 0 bytes
public/images/icons/email.png | Bin 647 -> 0 bytes
public/images/icons/healthy.png | Bin 725 -> 0 bytes
public/images/icons/item_accept.png | Bin 592 -> 0 bytes
public/images/icons/item_complete.png | Bin 781 -> 0 bytes
public/images/icons/item_drop.png | Bin 605 -> 0 bytes
public/images/icons/item_reopen.png | Bin 625 -> 0 bytes
public/images/icons/item_user_story.png | Bin 476 -> 0 bytes
public/images/icons/new.png | Bin 899 -> 0 bytes
public/images/icons/password.png | Bin 744 -> 0 bytes
public/images/icons/product_join.png | Bin 746 -> 0 bytes
public/images/icons/product_members.png | Bin 753 -> 0 bytes
public/images/icons/reject.png | Bin 601 -> 0 bytes
public/images/icons/role_delete.png | Bin 767 -> 0 bytes
public/images/icons/roles.png | Bin 770 -> 0 bytes
public/images/icons/rss.png | Bin 0 -> 530 bytes
public/images/icons/sprint_add.png | Bin 820 -> 0 bytes
public/images/icons/sprint_cancel.png | Bin 700 -> 0 bytes
public/images/icons/sprint_complete.png | Bin 623 -> 0 bytes
public/images/icons/sprint_plan.png | Bin 631 -> 0 bytes
public/images/icons/sprint_start.png | Bin 670 -> 0 bytes
public/images/icons/story_add.png | Bin 619 -> 0 bytes
public/images/icons/story_view.png | Bin 465 -> 0 bytes
public/images/icons/unhealthy.png | Bin 723 -> 0 bytes
public/images/icons/user.png | Bin 741 -> 0 bytes
public/images/icons/view.png | Bin 464 -> 0 bytes
test/fixtures/feeds.yml | 7 +++
test/fixtures/product_feeds.yml | 3 +
test/functional/products_controller_test.rb | 24 +++++++-
test/functional/sprints_controller_test.rb | 28 ++++++++--
test/unit/feed_test.rb | 68 +++++++++++++++++++++++
47 files changed, 333 insertions(+), 33 deletions(-)
create mode 100644 app/models/feed.rb
create mode 100644 app/views/products/rss.rxml
create mode 100644 db/migrate/020_create_feeds.rb
create mode 100644 db/migrate/021_create_feeds_products.rb
create mode 100644 doc/Credits
delete mode 100755 public/images/icons/add.png
delete mode 100644 public/images/icons/approve.png
delete mode 100755 public/images/icons/back.png
delete mode 100755 public/images/icons/delete.png
delete mode 100755 public/images/icons/edit.png
delete mode 100644 public/images/icons/email.png
delete mode 100755 public/images/icons/healthy.png
delete mode 100755 public/images/icons/item_accept.png
delete mode 100644 public/images/icons/item_complete.png
delete mode 100755 public/images/icons/item_drop.png
delete mode 100755 public/images/icons/item_reopen.png
delete mode 100755 public/images/icons/item_user_story.png
delete mode 100755 public/images/icons/new.png
delete mode 100755 public/images/icons/password.png
delete mode 100755 public/images/icons/product_join.png
delete mode 100755 public/images/icons/product_members.png
delete mode 100644 public/images/icons/reject.png
delete mode 100755 public/images/icons/role_delete.png
delete mode 100755 public/images/icons/roles.png
create mode 100644 public/images/icons/rss.png
delete mode 100755 public/images/icons/sprint_add.png
delete mode 100644 public/images/icons/sprint_cancel.png
delete mode 100644 public/images/icons/sprint_complete.png
delete mode 100755 public/images/icons/sprint_plan.png
delete mode 100644 public/images/icons/sprint_start.png
delete mode 100755 public/images/icons/story_add.png
delete mode 100644 public/images/icons/story_view.png
delete mode 100755 public/images/icons/unhealthy.png
delete mode 100755 public/images/icons/user.png
delete mode 100755 public/images/icons/view.png
create mode 100644 test/fixtures/feeds.yml
create mode 100644 test/fixtures/product_feeds.yml
create mode 100644 test/unit/feed_test.rb
diff --git a/app/controllers/application.rb b/app/controllers/application.rb
index b82613a..899db21 100644
--- a/app/controllers/application.rb
+++ b/app/controllers/application.rb
@@ -66,6 +66,13 @@ class ApplicationController < ActionController::Base
@breadcrumbs << Breadcrumb.new(text, url)
end
+ def create_rss_entry(title, link, content, author=nil)
+ Feed.new(:title => title,
+ :link => link,
+ :content => content,
+ :author => author ? author : @user)
+ end
+
# Redirects the user to the error page and displays
# the provided message.
def report_error(message)
diff --git a/app/controllers/products_controller.rb b/app/controllers/products_controller.rb
index e24d268..6cb4799 100644
--- a/app/controllers/products_controller.rb
+++ b/app/controllers/products_controller.rb
@@ -19,13 +19,20 @@
# +Product+.
#
class ProductsController < ApplicationController
- before_filter :authenticated, :except => [:index, :show]
+ before_filter :authenticated, :except => [:rss, :index, :show]
before_filter :load_project, :only => [:new, :create]
before_filter :is_approved, :only => [:new, :create]
- before_filter :load_product, :only => [:show, :edit, :update, :join]
+ before_filter :load_product, :only => [:rss, :show, :edit, :update, :join]
before_filter :path_to_one, :only => [:show, :edit, :update, :join]
before_filter :path_to_list, :only => [:index, :new, :create]
+ # GET /products/1/rss
+ def rss
+ @entries = @product.rss_entries
+
+ render :layout => false
+ end
+
# GET /products
def index
project_id = params[:project]
@@ -94,6 +101,9 @@ class ProductsController < ApplicationController
Product.transaction do
@product = Product.new(params[:product])
@product.project = @project
+ @product.rss_entries << create_rss_entry("New product created: #{(a)product.name}",
+ product_url(@product),
+ "A new product was created:\r#{@product.description}}")
if @product.project.can_create_products?(@user)
if @product.save
@@ -126,6 +136,9 @@ class ProductsController < ApplicationController
begin
Product.transaction do
@product.update_attributes(params[:product])
+ @product.rss_entries << create_rss_entry("Details updated: #{(a)product.name}",
+ product_url(@product),
+ "The product's details have been updated...")
if @product.save
flash[:message] = "#{(a)product.name} successfully updated."
diff --git a/app/controllers/sprints_controller.rb b/app/controllers/sprints_controller.rb
index 409c546..31c3d66 100644
--- a/app/controllers/sprints_controller.rb
+++ b/app/controllers/sprints_controller.rb
@@ -63,16 +63,21 @@ class SprintsController < ApplicationController
# POST /sprints
def create
add_breadcrumb "New"
- 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)
+ Sprint.transaction do
+ 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)
+ @product.sprints << @sprint
+
+ @product.rss_entries << create_rss_entry("New sprint created.",
+ sprint_url(@sprint),
+ "A new sprint was defined. The goals for this sprint are:\r#{@sprint.goals}")
- Sprint.transaction do
respond_to do |format|
- if @sprint.save
+ if @product.save
flash[:message] = "Sprint successfully created!"
format.html { redirect_to plan_sprint_path(@sprint) }
else
@@ -81,25 +86,28 @@ class SprintsController < ApplicationController
format.html { render :action => :edit }
end
end
+ else
+ report_error "You cannot create a sprint for this product."
end
- else
- report_error "You cannot create a sprint for this product."
end
end
# PUT /sprints/1
def update
- add_breadcrumb "Edit"
- @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)
-
Sprint.transaction do
+ add_breadcrumb "Edit"
+ @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)
+ @product.rss_entries << create_rss_entry("Sprint updated: #{(a)sprint.title}",
+ sprint_url(@sprint),
+ "The details for the sprint have been changed.")
+
respond_to do |format|
- if @sprint.save
+ if @sprint.save && @product.save
flash[:message] = "Sprint updated successfully."
format.html { redirect_to params[:url] ? params[:url] : sprints_path(:product => @product) }
else
@@ -113,8 +121,14 @@ class SprintsController < ApplicationController
# DELETE /sprints/1/destroy
def destroy
- if @sprint.can_delete?(@user)
- if @sprint.destroy
+ Product.transaction do
+ if @sprint.can_delete?(@user)
+ @product = @sprint.product
+ @product.sprints.delete(@sprint)
+ @product.rss_entries << create_rss_entry("Sprint deleted: #{(a)sprint.title}",
+ sprints_url(:product => @product),
+ "This sprint has been deleted.")
+ if @product.save
respond_to do |format|
flash[:message] = "Sprint deleted successfully."
format.html { redirect_to sprints_path(:product => @product) }
@@ -125,13 +139,18 @@ class SprintsController < ApplicationController
else
report_error "You are not allowed to delete this sprint."
end
+ end
end
# PUT /sprints/1/planning
def planning
Sprint.transaction do
if @sprint.can_plan?
+ @sprint.product.rss_entries << create_rss_entry("Sprint in planning: #{(a)sprint.title}",
+ sprint_url(@sprint),
+ "This sprint has been moved to the planning state.")
@sprint.plan
+ @sprint.product.save!
respond_to do |format|
flash[:message] = "The sprint is now moved to the planning state."
format.html {redirect_to sprint_path(@sprint)}
@@ -146,7 +165,11 @@ class SprintsController < ApplicationController
def start
Sprint.transaction do
if @sprint.can_start?
+ @sprint.product.rss_entries << create_rss_entry("Sprint started: #{(a)sprint.title}",
+ sprint_url(@sprint),
+ "This sprint has been started.")
@sprint.start
+ @sprint.product.save!
respond_to do |format|
flash[:message] = "This sprint is now started."
format.html {redirect_to sprint_path(@sprint)}
@@ -161,7 +184,11 @@ class SprintsController < ApplicationController
def complete
Sprint.transaction do
if @sprint.can_complete?
+ @sprint.product.rss_entries << create_rss_entry("Sprint completed: #{(a)sprint.title}",
+ sprint_url(@sprint),
+ "This sprint has been marked as completed.")
@sprint.complete
+ @sprint.product.save!
respond_to do |format|
flash[:message] = "This sprint is now completed."
format.html {redirect_to sprint_path(@sprint)}
@@ -176,7 +203,11 @@ class SprintsController < ApplicationController
def cancel
Sprint.transaction do
if @sprint.can_cancel?
+ @sprint.product.rss_entries << create_rss_entry("Sprint cancelled: #{(a)sprint.title}",
+ sprint_url(@sprint),
+ "This sprint has been cancelled.")
@sprint.cancel
+ @sprint.product.save!
respond_to do |format|
flash[:message] = "This sprint has been cancelled."
format.html {redirect_to sprint_path(@sprint)}
@@ -225,7 +256,10 @@ class SprintsController < ApplicationController
end
respond_to do |format|
- if @sprint.save
+ @sprint.product.rss_entries << create_rss_entry("Sprint has been populated: #{(a)sprint.title}",
+ sprint_url(@sprint),
+ "Total stories: #{(a)sprint.backlog_items.size}\nTotal hours: #{(a)sprint.estimated_hours}")
+ if @sprint.save! && @sprint.product.save!
flash[:message] = "Sprint backlog has been updated with #{(a)sprint.backlog_items.size} items."
format.html { redirect_to sprint_path(@sprint) }
else
diff --git a/app/models/feed.rb b/app/models/feed.rb
new file mode 100644
index 0000000..583f540
--- /dev/null
+++ b/app/models/feed.rb
@@ -0,0 +1,27 @@
+# feed.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 Feed < ActiveRecord::Base
+ validates_presence_of :title
+ validates_presence_of :link
+ validates_presence_of :author
+ validates_presence_of :content
+
+ belongs_to :author, :class_name => 'User'
+
+ named_scope :default, {:order => 'created_at DESC'}
+end
diff --git a/app/models/product.rb b/app/models/product.rb
index afd28f4..9abcb8d 100644
--- a/app/models/product.rb
+++ b/app/models/product.rb
@@ -42,6 +42,7 @@ class Product < ActiveRecord::Base
has_many :users, :through => :product_roles
has_many :user_stories, :dependent => :destroy
has_many :sprints, :dependent => :destroy
+ has_and_belongs_to_many :rss_entries, :class_name => 'Feed', :join_table => :product_feeds, :order => 'created_at DESC'
named_scope :default, { :group => 'project_id' }
named_scope :for_project, lambda { |project_id|
diff --git a/app/views/products/rss.rxml b/app/views/products/rss.rxml
new file mode 100644
index 0000000..cfe8d52
--- /dev/null
+++ b/app/views/products/rss.rxml
@@ -0,0 +1,19 @@
+xml.instruct! :xml, :version=>"1.0"
+xml.rss(:version=>"2.0"){
+ xml.channel{
+ xml.title((a)product.name)
+ xml.link(root_url)
+ xml.description("What your site is all about.")
+ xml.language('en-us')
+ for entry in @product.rss_entries
+ xml.item do
+ xml.title(entry.title)
+ xml.description(RedCloth.new(entry.content).to_html)
+ xml.author(entry.author.display_name)
+ xml.pubDate(entry.created_at.strftime("%a, %d %b %Y %H:%M:%S %z"))
+ xml.link(entry.link)
+ xml.guid(entry.link)
+ end
+ end
+ }
+}
diff --git a/app/views/products/show.html.erb b/app/views/products/show.html.erb
index 5c2d36d..0a0cb33 100644
--- a/app/views/products/show.html.erb
+++ b/app/views/products/show.html.erb
@@ -18,6 +18,9 @@
<dd><%= RedCloth.new((a)product.description).to_html %></dd>
<% end %>
</dl>
+
+ <%= link_to(image_tag("/images/icons/rss.png", :title => "Product activity feed"), rss_product_path(@product)) %>
+
</div>
</div>
@@ -41,4 +44,7 @@
<%= link_to "View sprints (#{(a)product.sprints.size} total)",
sprints_path(:product => @product), :class => "command" %>
+
+ <%= link_to "RSS feed...",
+ rss_product_path(@product), :class => "command" %>
<% end %>
diff --git a/config/routes.rb b/config/routes.rb
index 1d4c143..79f0cc1 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -42,6 +42,11 @@ ActionController::Routing::Routes.draw do |map|
map.resources :stories
+ map.resources :products, :member =>
+ {
+ :rss => :get
+ }
+
map.resources :products do |product|
product.resources :roles
end
diff --git a/db/migrate/020_create_feeds.rb b/db/migrate/020_create_feeds.rb
new file mode 100644
index 0000000..20d4f4a
--- /dev/null
+++ b/db/migrate/020_create_feeds.rb
@@ -0,0 +1,35 @@
+# 020_create_item_block_messages.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 CreateFeeds < ActiveRecord::Migration
+ def self.up
+ create_table :feeds do |t|
+ t.string :title, :null => false, :limit => 128
+ t.string :link, :null => false, :limit => 255
+ t.integer :author_id, :null => false
+ t.text :content, :null => false
+
+ t.timestamps
+ end
+
+ execute 'alter table feeds add constraint fk_feeds_author
+ foreign key (author_id) references users(id)'
+ end
+
+ def self.down
+ drop_table :feeds
+ end
+end
diff --git a/db/migrate/021_create_feeds_products.rb b/db/migrate/021_create_feeds_products.rb
new file mode 100644
index 0000000..9711388
--- /dev/null
+++ b/db/migrate/021_create_feeds_products.rb
@@ -0,0 +1,33 @@
+# 021_create_feeds_products.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 CreateFeedsProducts < ActiveRecord::Migration
+ def self.up
+ create_table :product_feeds do |t|
+ t.integer :product_id, :null => false
+ t.integer :feed_id, :null => false, :unique => true
+ end
+
+ execute 'alter table product_feeds add constraint fk_product_feeds_product_id
+ foreign key (product_id) references products(id)'
+ execute 'alter table product_feeds add constraint fk_product_feeds_feed_id
+ foreign key (feed_id) references feeds(id)'
+ end
+
+ def self.down
+ drop_table :product_feeds
+ end
+end
diff --git a/doc/ChangeLog b/doc/ChangeLog
index d345eee..236373f 100644
--- a/doc/ChangeLog
+++ b/doc/ChangeLog
@@ -1,6 +1,7 @@
Change Log (0.3.0):
* #156 - Backlog items can be marked as blocked.
* #157 - Items can be marked completed when a task is added.
+ * #159 - Users can watch an RSS feed of product activity.
* #167 - Blocker messages are included in the daily updates email.
* #173 - Backlog items can be dropped from an active sprint.
* #174 - Deferred backlog items can be re-added to the active sprint.
diff --git a/doc/Credits b/doc/Credits
new file mode 100644
index 0000000..8b630c4
--- /dev/null
+++ b/doc/Credits
@@ -0,0 +1,7 @@
+GRAPHICS USED BY THIS PROJECT
+=============================
+
+ProjXP would like to thank the following groups and individuals for the graphics
+we're using:
+
+http://www.famfamfam.com/
\ No newline at end of file
diff --git a/public/images/icons/add.png b/public/images/icons/add.png
deleted file mode 100755
index 6332fefea4be19eeadf211b0b202b272e8564898..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 733
zcmV<30wVp1P)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!Z%IT!R5;6}
zlj}>9VHk(~TedF+gQSL8D5xnVSSWAVY>J9b+m>@{iq7_KE}go~11+5s4;8hc+i0Xa
zI1j@EX5!S+Me6HNqKzU5YQwL;-W5$p%ZMKMeR<%zp69-~?<4?8|C8S?bklXr4v&Ov
zb&06v2|-x?qB`90yn>Qi%Sh2^G4n)$ZdyvTPf9}1)_buUT7>`e2G&2VU@~Bb(o+Mz
zi4)>IxlSY${Dj4k={-9RzU^W5g9|2V5RZ2Zu<x6&^l=W_1sO_5@*~{AJR(k@osu$W
zIOyKBVDIZThPU;2xYmJgUn>lL9s2xQbZ@r6eP9Ra5u(s|C0Nj#&4>wTSkb?%#=9?@
z^oxDy-O@tyN{L@by(WWvQ3%CyEu8x{+#Jb4-h&K9Owi)2p<DqQ{Z%tMS5;_RkwZ2s
ziU|ZZE*fUaAe}14z#AR(OU=04okqn3igEs-_q_}KZ*?@>gg+heWDyked|3R$$kL@A
z#sp1v-r+=G4B8D6DqsDH0@7OztA7aT9qc1Py{()w`m``?Y0&gi2=ROcc-9+nU^I6<
zT=e_Y=<?pf1$FXK3F5I5#ceAAN5BHvd?h5_(jPS+7l@o3)VYh{*frx)Pb%2=Sw~G2
znu{1!PYZ*jM}To^G}f>vSnG@?3Ue{BW5ONFttcE!R-R_W4O01|0-|K-YNXLo2`4Qv
z`r1LxR6#yf3FB%T95gJnaKKivA~Z}S9A(ZxE<qS_Liq!k=vg3Per<qx!vKx95m{X+
zQ8;uQPPk0h^qI`uo^&$1^CFd@NeQR5pADaHJwu&SvB+fQDdu0d@n7>DK}O3T04USJ
P00000NkvXXu0mjf^IS-S
diff --git a/public/images/icons/approve.png b/public/images/icons/approve.png
deleted file mode 100644
index 2bd16ccf294bd944c6bf17fa0345885469980820..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 619
zcmV-x0+juUP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz{YgYYR5;6(
zlR;|}Q4q%ywK*uB`~V(A58^?=k7B`|1LCO_4}zCs4T7aDg-Qh#6xy2?LjyqywQXvQ
z8xl6Gq05F18+Mmnc701Wq*CjlKM!whj5W|6=J4Lk|2H#l=B0qtk*RS8HF|(<f#xj_
z`9|3Ah2~wNX<NiS(9NHy&dlims<?m_yv6q+?ty0Zsk##<2S5rXy3KCu7f}~N0g9=z
z>EAdBAPt<OvwxAAGlT2MDr_6d)@wGr^P>T3aD}=*%{NbB*ZGcS?IC*5jinhDl=TC)
z^a(zA>EnR9^9j#^IEMZn^!id5#SnEtL|{9h>X0|4DCpP8H?Ix=ni&xHWU$qNT21yZ
z5^*RX2qmP^fMQ*vP*nzi&BkZ)YfvhI(E`_#urPuk5&HpnzVw-Gc(=*`uwjl<Z^+W4
zt_<kL65xm61>h!P+m)3pz5ENXE;8&W2W*v~T!vOF>0KmQeQ*+S#{tWRxh00jSgfv7
zu5dK}P{{s0ADkp>$Chu@w&3}~KY;h%H7W`eKSHG<MIw^f|A^ZiXtklK!{hlM!+;g{
z3@eR06bfJ%QXB{gGEDfQ!0h57xmn2OV(#Xw<E#A2NHbr*#oXF9Z&nX^u?*8s;Nhcv
z?oWN?-eivK<W0t(j*(p$`~P2%D}5Ho@NDsNavu4aegpALNqNzBB)0$n002ovPDHLk
FV1f!F7%cz*
diff --git a/public/images/icons/back.png b/public/images/icons/back.png
deleted file mode 100755
index 2e53c6980f00026b22e0d3710edf048e021b4da5..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 680
zcmV;Z0$2TsP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!I!Q!9R5;6}
zQ%z`7K@k34@)BZ7HC0S1Cb8BOF|<`u6qSHhu!5*4=utsv!5(@PJXiz;kDkQ}9tu`0
zLJ<|>r3gZ6sTvbKG`S=Qf*6}Nf4;Z7Z{zN!O--ANbYb~sXZM@=W_MPrR;$>|(APVL
z9?v<)29FSdeXBlhS+G{-_{fQm(s$|pt3F04wDk%Eai0K>nDfCMZg4J%Tm>-+#q|66
z8(eUXMZynJwbm5eoE>%wcnw@1t#WGynhm$k?XL&soFn!ri`4f$cVy<%2mLsExU->z
zZx*xss|zVtj$|^aY{nS+ySuS>yS}Tnbt?kCHpJucS_vZi7oW4eS02jdvmho#X{ij;
zASmWb3g!Cg&?GXM48q|syk4&gstfW5ufx~d(b5UV8T>6lg%x6ea*97xpv5pT@eDFq
zp-_Nnno3<Nm4Ys0Q>HNxizp%!hp9l*G6j*sWM<7rm?Hgq>7h^v`h)1B=y2@X=W?ZT
z$YtMx?9VKI<#P?vWl4;(2C}r%>Gaw&UVVOfffyvn3OyrEa&l~WX&F<uFTgMiRVyI`
zxm*s3L;{gW1c5+6`RcDA$UP$COMCJ7S7xgb<Z!uM^^9(}8y=5GO<mXFa5y$L()qhn
zN9{IkX7TqTC?%qHx`H+PV)TFtHvVQ-`02)0)_@;!qht5ZroI2--JH6QGXwq2|ALe*
zGiD0KrV849Ue#E!x6?|2_CLR|>&m?+<#OqiY~^j9wuSWM=zzA_@B9}<+jo_mWWEvr
O0000<MNUMnLSTZ{95urL
diff --git a/public/images/icons/delete.png b/public/images/icons/delete.png
deleted file mode 100755
index 0a335acf67423fd5a17caf2d18a67e66e870611f..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 610
zcmV-o0-gPdP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz^hrcPR2Uhh
z!B1$<aU93-=lk>hj32+Ia2PFSwHp_;8^u8>r6{e*krHy43#H^jT&z}Wa&xpokqZtA
z<tUdO<j+sGk=nuRH}?I$-!J>FR&Fehhe_gp*x{i&XLj%HzdqAeQvxDT1Rjn;gaWw}
z5^~2QShuR2o0yn9fA7Y?Xzt(DKhn`?rmhAn(VT1h2r!!4rBZw52P-vSDzPZb#g$`y
ztkj8XEoxZ`Y73PkKp{LJ5D~&7@Je_kT)~2i-c6l&IJJyK&5~gfN`_2W7%3TM2{XqE
zQA8qFq861?%N|ZG0Wt%FLJ$TKq7Wo2$Odl0Q&0;JYFQzcn1MtBWCjLiAQxdE1Cmih
zK`p|m<p^h)nXyD70Wu@9kt3LTc$4XeW3)fLrK78x9jo^M2gy}gvcwDnBw=QVMG^^^
zesqi3SL5tFa-7w@+nIm!m^IIz@?>Af*(48Y1z>=gVSoY2jATaZ?l66O4$*k`47+`l
zweRoK+p__ghH^x(%m8DN8Gl-sYSGx#3kS}zEMMMC5w<N)erS!K8ZHOIo$$cji3q$b
zi3mbsps}9z>qj&noaWd37uvw=_X01NGD-Z+i^1;8t2&z(w`{C(PM?|mR`Ky`;pI<V
wGD-Z4mxEg`%*{-lEZlm$(4=5&|KsZFZ(v3gV*E=9oB#j-07*qoM6N<$f@e4h(EtDd
diff --git a/public/images/icons/edit.png b/public/images/icons/edit.png
deleted file mode 100755
index fb2efb8775442af862ef0ef111f371e5857928d3..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 703
zcmV;w0zmzVP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!QAtEWR2Uhh
z!QE@kaU8($_vd$h=UC3>FxeKF=OosmQ6jmTLP{}-6iF>nO8f(+aY2!Xm^*5Av6NhR
zD0!Nar%`rnu~v@R4hO^8&iS41=VQN>+65QO>m_E!|B#IbuI^pAy5?9WYjHC`6;s8j
z!_-hy%sJEya}KA<gjn#aqa$^=^+eqnzh*_vsc2E$D=%U!;=i))6mZ7R2IgfmKN+jR
zz&wWe=8T4Ia~Z4MjXi{XGbNNSI1Df~oPuJg8cr2gFkHb1KHnw#{4$Z^D)yD#<JH;y
zJABLti6@8|<_RW-sNi{mh=+(nGuMgsKBb~+JBe9~`PMz3<gKRTzEelY155-2A_gj;
zYD5eQ>D$C|Z-~!ZLFQctG09Uhp@QPcl?mU}7$E{?cz}t3fC%LK?;}5+keI!OTyHb6
z@j}nbBm-HHT&CJnb^IYBAVSCka_RdNzT74;XDve?FCx*eM2ky^TZSvC<t*LT+UXU7
zfDs~y2q6)nFL|BphnpnkZeS>N4lf#zADBt{VLMZ58~8Xlk&tIj2}J+_M1=n24SuBB
zC|kIW{HG=&F(WrHgY=^pRBSp=QTYN)m5{Hh{2@SBTQi04uPMk>dS9PrQdx|l%yhmz
zOH#Sz0@1`YLTX0HPj&aS)SnFM)H&2CwbI1q`b)fRK1k<-HpW#}^Sv*{jiIgdH9W>t
zQ6<#EFflVmJF;g{aA;S(kLP%K=NdiTT|X03N>|n%ZExo<#LO72ZdK{vlG)|{vIVoS
lXs&IrKfQB(<E!`l!*8ZVD9)Bs;gA3T002ovPDHLkV1o10Km-5)
diff --git a/public/images/icons/email.png b/public/images/icons/email.png
deleted file mode 100644
index 2c3c46f44d10e2663721c57f0a0c70689326508a..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 647
zcmV;20(kw2P)<h;3K|Lk000e1NJLTq000;O000;W1^@s6;CDUv0000PbVXQnQ*UN;
zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!DM>^@RCwCdRXs=?K@@(o=MN`F674J!
z)GC!t;6kuSWuuKY7Ul|rSfz*{gh+@hq)-T?FqMTCC&D#a*;rYKc#tNQm6i&^DVT7$
zJGV1=Z)Rs_FWFP?ugK%x?#+AizHjEe**8o|39S?dS}bk!V2puWn_YsG;{t0CF~E$J
z1DCzuSf=bSm7<kt4c^y_l*p;FR%_~*`T;Kk6pKhCT==-Xtabn;PlJ~@;`*p{*2Q6(
zs3VOeS1t}h0?*UTq>U6EQw<9n0AJr~OjBg;;i`RKJ2|gkDL!1VPnd=v;5gvg0E=5#
zBwo8xoDn1o$Yu$`6&U%P#~ty9E6q3ZX;KsL7=bW5_%LQCe%Ybi-LEvy2*oSAuQ8(?
z#8)#vY6DjH?p`_hjZj?7r!fDifzfxbZQ!^4N}o}(8=?6XeotdYDe}Wp6xX>|7U26q
zrH{r&XkJ3R!i?`g`U$I?d0~OpkF<^6M#!JAyCBr47X@L}SyCT*GYqM%pZfpi58HCk
zo+R|03T!jx6t$Thlre7(e&$IeV(K}x=TGc0$8j}qN#GKqQ=|x<rUm@=d1XuaOZdbP
zbnTu(3E$Phtq^8J0FNbfO93~(v{6)PH8<nW9@LV09${&_q$Ma^+~zX9m)V|yet^!7
z#_j{^LFeaCxDvSx^*?nfFgAER;<%Y7EZdA*tZ*%JFAs%_*k^`-?1QL7O}Z&hQP1p+
h_CH_xEhqRRzyRjXu;TWwPKf{j002ovPDHLkV1oGXB*p*$
diff --git a/public/images/icons/healthy.png b/public/images/icons/healthy.png
deleted file mode 100755
index ade431851b0b6546eb6bee5cd85716df7f67e077..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 725
zcmV;`0xJE9P)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!XGugsR5;6}
zlg&%iVGzeZ&u?wj-EDm-p$H5kii)Tx)-Ga1Q4}5Y(8ZJ8>L_^0`U`~aor)ql1d5Pg
z6owrVjF7Of7j1n54cD~V+Wq~W=b7nXZIaQU<}$-D^Wh8g9iyuJPn7-MaF0z&l@1k7
zi;z(T6-GoDP~qCfbDCc}Z`LYsk4>`*H%xtJgQ?D-@mf%i7IOIIQNnlKSrOW6T6Aa~
zzzv!ft#0lKwzQ#%D*U(CNVtT$BA5z-ik%o65YF5q{4ms7cV2r-06S=ERhf?MznhZa
zGrpJw`vq|!>WixoQA~L~vGV<V8orPhVf)rL{TKkK&=G9!N@*M<%!2#MU|$X-0w3|@
zA&AZ6K9AwUNfLXyQsU_V2mnMlYib&iVit7;R4m8NRH9;0Z)s}IqY|Pvk6U?8S@kZ&
zb9OCYFXd^(t(2BjEb1cEM?Y$d`UnYgK#2D=lmIFe)D^)y9`!zD>h0X>qPIVC$n@kA
zgAe9Vv7m|wv0n;2S!@A_bF_Ik?_SL^JGp@R5EaWzcA1Gc%VZkUm>_}pGHl31$w(sf
z+uZ2Ept_z!a-O424U9dTV*JG{cuTU<kjYe$OvETlAE1z%#zyp)&bZuT>O^>-YP_8i
z`v#vMN1_$fg^2rkMu;VMV$!FWzJD?AEISTe*2Mszrd1b3X#X~G3v?T?bR#BKi;99M
zL3}eC>Q11E<1D@G!$!0px~z-qtUQ0moD|RX<lfGP_@;dX8v+oA3q4=w-?A{=opqK=
zhp*_sAAbR0_NGaRr&EN}-ji_=ILnB$3^+^ok*j}srhkd=tpkg_#{ZgA00000NkvXX
Hu0mjf#l%Z_
diff --git a/public/images/icons/item_accept.png b/public/images/icons/item_accept.png
deleted file mode 100755
index 28c2175e691b5bd77d51e0ff19df587d3cd0b8c2..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 592
zcmV-W0<ZmvP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz;z>k7R5;6}
zlg(=rK^VsO-|*I>7Z08a{s)Rsf(J|SV_Rz!8yef7kVJ&ghD4GkA*3yaw3HgOrMaY5
zCEF^>3bs|O#wgl0$9$0`oBc>UmB*RcC2f(EL>GQM^S=AeGc)h*Z~(affwPP)k4&ZW
zy8GEaTu%wfT{sti;{L*S+?&tAF_*=7d<S<Qe};YLBW$rPxZ>}1I*%E6Q~85vri^Z}
zN8TMU1G@6RU=6$lGjJvM&}=rl=7#UJVc>l73ynqtIzxx9>ufBFVp|%dhW84l0cn`3
zs_37{w$MmS3)?-Krs10Bm0>`;T~`z+b%IhiRXNdszQ6MDg<(KHRjnpNmSv_4xkj``
za)urv8+pROgH^-8k@yNpl2A3FW2q|rb?$g7jPaSPu!j1;KROSKbIdTX&wfIsQaRQG
z8XrBv^AAh#tPEl;H;pev0payKxEwx%-jP#5ZZx)yQmI5Jn`&^>U?^|_>2DFFe?(av
z+_;JG#w}{_o3lkXQ79A%1r!N|7R8ocbf3k`T;f<GSF_X90Q4>_-wuiF&l>i>!Ju~?
z*C#Th%WJGr{q8i**?JHVhTs!;Hrbcq4|mYXjU$DVmx8A;B@DBbsnVGBpwnwhiv0wY
eZrfBp&Hn+e<03_si;HOh0000<MNUMnLSTYC4GRna
diff --git a/public/images/icons/item_complete.png b/public/images/icons/item_complete.png
deleted file mode 100644
index 89c8129a490b329f3165f32fa0781701aab417ea..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 781
zcmV+o1M>WdP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!pGibPR5;6}
zlj}=UVI0T(J9f@?Yy$~oL?MENP<s_55<8j=#k?#_)7`z&He=XKi!yRv#z-kGv(##H
zPHpPc=GK%laoJHq%)RMCwPAFzZ|CgAOcEn{;0q5A&*$@du5ke3{wIG76!;E_a;FdK
zvpp$H#^e2A>4-QibtN)VXQDpczE`xXAkUjh%RI>;okxb7K@0kpyQ1k_Y(|Oe7$m(^
zNYX>mI||sUbmn+c<m#Le&eeX{US5M~t}+^~?^x|a<4hF}*!YoT8=u}L$nm5IGu=t+
z9L!Cu36!D2Ujog{8R*!Qv#Iu-h5hwCT%4+a*g~$0uam-<K;}*|sK&CQl{uILRo+uj
zOcz2iRRHx=A>3<&FnE=4u#()KBS^SH8e)Qs5i!#lY=$-1gbH6VluzU=m=EP78&5vQ
z-?+fFP-G2l&l_QzYealK$;1Rl?FkzXR&Jv<pn=v~#I9rjiy!8pnkdBB+E5h!vH2Zs
z&o03*QH;J|Cj>@fBPNjCr#AYRyJ7UJQ0v#?)7Ott=>3<sG1xL&549^BdiQDc2Rk6B
z`CZUMF*oL$(7tdPx=A_AzG_6ieU8GLKR01{dI4q5ENECkOP~(zUNfjFVrvVjw*&_H
zKpN~TcTqhdhuVD-b<^codbfbK*#?vj9*4ql0y<|7?610!ZaoaDlGr-LWGi2|kG&eR
zM}vpV9aN6yK|7oS)sPHI2Nw>`#-pV!7>9}>Q1jL)H6h&gkP@3nI=+F3nA~M>u#(n*
z8T!#8oEw&-mED4!h4s!N@Jo3S7N&Q6%6l3}nlcd~X@>;uelvPsSkXIgg~e+^T1zSf
z3SNj(5%jK~i8@b;C<CFVY6wQ4<%I<*UYM=Ou+dYcgy^ro@n7=`XV9$WdAihN00000
LNkvXXu0mjf{u*Ke
diff --git a/public/images/icons/item_drop.png b/public/images/icons/item_drop.png
deleted file mode 100755
index cd305ec83b6a7560de21a287fa7a780bad893efe..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 605
zcmV-j0;2tiP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz?@2^KR2Uhh
z!M{t?aU93-=jZOLT^;=*N<-?@Xu1}|ts5eOAZVyTq&X}M5nBq15Q?BBQ4p#A0wWxR
z7Hf!pKr~piSVO4c9ZH~*dRE_izhC)91cQD_k4KQi{|KkM1}_$lHs78tD=Pu+$Q^S-
zE?kbxa0!`V?rf`$m&V45mxu1O^heD{n)|9U*;Gx2jmR#Qtq<sr%CS=A*+n*LIaZ1l
zkxX1WQ^!Vi^iGn9Bu5e=ArKG(Z$?M?ox@E{m>Y=*2{WUucY-zdJL^dl3I%>;ad8o^
z7>UT-aEZGeIS2v;T;fmM9XBTt$%UH}0?1h*2qK@)Ge19v#5w^G0dO}Gk-On;h*g0V
zg0c5w{A6?(dp$&D`V;BhwH(@f3^+wpiDS%+&U-Vg>20eaQ79BhObrh(SA506v+ZnZ
z$g})zgzWPtJU(945y>2Pr{~-rKqTCq^~N9e(A;`~%*Utj>?T{b@1P-<WAuZ%B6r7K
zfD-_*s<H6^rBVq9d@oIqscnFhmsk^Z?<MV_J}Qn+KDaR$x40Q@j+x=^SRCW-xI{~4
zC*`+~$PTyjZF!n9u<#{~r6~nT{LPEjy6)<&*{l0@RiY?0#lq(b6EkUxzv_CB#J_mi
ry8p`3Z0UU3LeA1E3I<M$RBrtMF3c7U8sGJe00000NkvXXu0mjf%W((U
diff --git a/public/images/icons/item_reopen.png b/public/images/icons/item_reopen.png
deleted file mode 100755
index fdc394c7c59b83cc2b876abc41064c75eb365877..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 625
zcmV-%0*?KOP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!14%?dR5;6}
zl21sKQ540`H;#r{q+@0|4upc#+Evt|+}NrKW)TGuWV=voQ3}~o$dv_d+C|VJ!=QvV
zg=H;rBZXmqaGb$ppf*{NV|@3V`?b()L>FZj9*=u@@4&g|7erBn|Ebu&JU%m$={t4j
zMA?=F80s{QH%n(hsfF%mohhehBz1^t6ID+NGJzBtYm#sZbA{ZEu?vrT)(Lb!?K~){
zH(Aw`uB}X<y1W|kSR9H3xx-yxB$$Xd*=b}y&J8@d^rAlw0A1tfQZDb^pE!_isNRER
z77+`|Ko_)nJknZkM$lLR0C4~il}T=IO4nEI2Av2=WIw+{{!<=n7K~`zm4qbB&F6+E
zuDrZlD$ucWTMde~1j#Y}c6>q9=*Zczi=_)|A3QuQ<J3g|WY@YBNVQd#S0O+Qg)arr
zxZ(3k3cPE^^QE&m0MH1E2!a46f+07J@%h!sflWbTsHw#ti&(ZI5<BW(#>}znM~D7~
zjUb95oV<O!r?ECU)Uvk)iX2PJOPHOXMSdX<+8+)?t@ILRhCc*r7R)%BE98z;lvSkb
z6AcIvqP4jd&HGy5k>Kv23l4|e@$lZ*dJFeEA1zqO!B_8JKbXnR;M>>lDE=(2>_dXN
zph(~<P@*<Rx&7Lq6ipeD9Wu3%s*@=u3X<dZ?Q-O9%?BF<wo<<UiN|Q`jamP@00000
LNkvXXu0mjf7Wxxg
diff --git a/public/images/icons/item_user_story.png b/public/images/icons/item_user_story.png
deleted file mode 100755
index 6a93cdaa78cc42caed19920ae622af7e35f6067a..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 476
zcmV<20VDp2P)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzZb?KzR5;6}
zlif?gU=)Y_7o{kP=!2pHrIkZrGlB>yh(bgPr6Nj-nT19X7DZ8zeUKOl_Tl|>1YLMH
z=jlD?b+Sb_xgR_*#(SP~INR$20GAV7?rFuloUPPDd!HchGLr8gZI>aqRf53gD~KB}
z;7=4_D*g<@>Ld7K_aG;3>sUvEaJphdb1M87A#!by06TZW*wPh{Ku&%@CmC6|-~wsq
zjb0RsMFKOF(Fjbu5Fwh)ru|I#tR-Lz>qIaxcgh90?Z1KrCWc|y!1T|aZ~=a*-(Ag4
zSPUtVz{2m+IDzS#IpP9w-9EN;RO;3eG#U+?z$0h4Kv;Wfk8B+i3#{n6-UibhNOQqt
z?12jeFM)^6#o@C#4HjCL*d?a8z!%N)t>y1HEak5ffl*Zzs?{nz*J?G;G`=UdUjg$j
zC=)9;^k<DOUczwr90uoeY|F>AC{HL0z0x7{1P|EQ`>eA9tOij?;P^H=75o6^EkKQu
SuK{QP0000<MNUMnLSTYmlE&o#
diff --git a/public/images/icons/new.png b/public/images/icons/new.png
deleted file mode 100755
index 9c8a9da4ae49b7fb02af2eaf6e03e0f6c91ba01a..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 899
zcmV-}1AP36P)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU#6-h)vR5;6Z
zlS^n^br?s_otZmxC(V#aQqnZlD5Pl<W74DuN*^ky*$8b)6(R`rfi4u?6r~iG78I%o
z2E~eqZp4igq$XCQAx&GONeiL2d5mcuO(xAGoy_~r-1&c>i-HS{zx9u^IUGw>*=$qi
z4z(fju8Kxf<gL~t{Z}{pS^ox1_aClP(Kl7(_1dN*QBzps&fH*DML$y`A3oSSxV$M~
zW}wGb)fZIc?Z}Sqo!eTU!6$}nd<;9}XEip&#;x&r6*;XUUvwTDDf|PN9_Z;t^qo+3
z=fQ0)M{%l~K}(dAGu#|H#k~*g$5(M5#ax2hv2ikq8>4E>slBg^es4|nAN~@NV_SFj
zT24(nZf1>C;s&Oa#mmJBSw-p_cY~Y6U)hMyiI9#@b2~OpJ~{tQK#e@V>&ZUL%dC-&
z4<V&Ap?rdTI!=0ViM_oaV~4juZVt`Gu+r1iHgr%`16O{SeKw#XImN09Iw2x8jT92s
zsi=+;tqv0=5AFh(Ah=6VSR}tPN_OrN&izMGBe{TzfB;qyyQ&#G6v15`L$a&bez5X?
zfln!|{7fdjK{*_SU<mcfs0aZy!d(vrv<QJMICWi=gJB$Z9a;H_l$W9GRANFABwvEE
zBBe5F3;{Lfu23}~9!MUvNT6~Dk@{Vv?u_C294;({xV#o7?YflQ6bF+Nv=||vB4M9x
z17RqygA_qrFhy`0C@cK35M;`(<?%-!;+~pje*N<-=dWF0a>K3ksgdE?;g2XJ7a-IC
z{t7T9P!AB)6MkIq=xo`8@fr4Pe+pxHJkq|8i#gBoi7`)S7Dry495@-9|NUzWL5S=I
zI}8e@=#j{*V_TIRYJCHM<4>HSsjdT~`23%KH*e}YU%C<>JM(QmG2_K2&b4Ftok-)u
zSoT&#!hAJ4MD=!?;n}ksXzJm;^DmMqtaEL0%KcAFfAn>=sgaW^@?6tnFI$DxIX=HQ
z$n|KMeH{mAuJ2-I<tmpZM!0n|^?~(&IbS~A)~zBZ5DBWt`^T4a`+?Vh2#^6j`K@pH
Z#NQ7iuPIaGhb;gA002ovPDHLkV1nv&k?{Zk
diff --git a/public/images/icons/password.png b/public/images/icons/password.png
deleted file mode 100755
index 30b0dc316e52dba388d88112d4c1cc32672fffbb..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 744
zcmV<E0vG*>P)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!dPzh<R5;6x
zlU+!YVHk$LRU{on2NEG99t4gN<(k<vgSIj~$mk$~eksv@gmxl=Ow(;Fr^B3}b5Lr_
z2G&-MqzD~`qBeg)s5vYXON+MJpPSp<&)xfdS}mK_2lvVQa$nE=JkR@40I2o!$#_If
zgcYe*-~X369QeQ}9^~I<-#CKyR)jn~<T%PeX7qx)>jGlUfiHCke$)P}4*HwX@?mb`
zzgiE#M5fLDxtj=lZ6q=+Lkt2$!wyVqxC~@X03De&Gn$t$kdWJig()R`(|L#ldNHNi
zi?tK@-;xA1zab1r3XkO&T;m5wdrx2~XU8>fpl4v~7ZF1h+!NXGy*rQ6jw}?mrNKGI
zL&zzIrIL-VTRiLE`+jy5wt-SCISiy4pO}x6>V>$`&V{7&3{GoOF)87oTao_ek0H|L
zXxL5q?8f4p7$RLZL=Q4>?LH3$EqhS@^b{VAG@wL(0y*{DquI)BECvw!(t<WU7OeSj
z4slT2o&n>y8jr^s8DqzY3Mx|xw6AM%RhNVG>V(j48H=@2*+n87;k63kFsH&hc^Czx
zU)p@TON5%2#gM-!LRIG_NS|MUrcZ`*_YPuLB^9Iro+W2D85SRofmAHM&xik`9B1#a
z@o-oLow*L$!CJHqC<x>_n){?E(&Zwhf|^V!qqZ#X+&c)jMMwsg3*W31W<{FoWOGV1
zuOTTStWS(&DYr&0v}HowTZPN*IY_RcCU%rj3Cs*;4T3Shy&sFSmGFOV!%!{P*`>;C
zSiN43jAg&56(U(ojS}<bU;n~z%OUZEdjI^WYM*^X$^G8flvN$?agoUOo#Ks1ETcBX
ap8o(~AJmyDx~^sb0000<MNUMnLSTY)_fHN0
diff --git a/public/images/icons/product_join.png b/public/images/icons/product_join.png
deleted file mode 100755
index deae99bcff9815d8530a920e754d743700ddd5fb..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 746
zcmV<G0u}v<P)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!d`Uz>R5;6}
zkbg*1Q547RkCGx=+ZrJV#zL@SQK$qJV#CSW)Px1IG~1R<ooFb9Gk=6-&6YZ`Y^yDm
z;e1;&Lsyz}WRQ`CmeV2hmrxcVG_cf#%KP@-D?~x2(1j1@+{-<mbM6fR5b{5)d8moo
zRZ%DA%r;1OLN8{aLdc%Wh~ZUlNRSi5DY>7@Ce7&_JfOHMV?!rlp(uU_DLu%oT329{
zvd}^m>Zu}~l!lF)$vj*+!9ivWYi3=6E`B>+7|Su$tH+R~2!#ne6eorwU&M#{7<<90
zO$iMuFlR;n2z>v9nKwp!?XQQ|aSfBtZ^J)10wQ<s%vh^tOHe^$#{NP7`*utZJ25{>
zP}rH=KSJQ4N-!Ms3A?4X%~8iSR-zylzlSXd_?|M%f3%_Vax4V#xlec^^VT&5JP8rX
z6}RO@h&}Lqo`s_<751_e20DK|@e2`2FToK$y2KTlwyhFyPdYY*B{>w4$%B}fnn&9d
zQ0xQp-T@b00q1)Ft+NxwMg`RMry#!}42Vk(RW1t-$ePm<PwU|vT7t<76uc#oJ3u14
zk3?1%38k4pl~ajlz9x)*bHmWDf!rX#k^JylM)09LdTH9F#l2=16unees-v=-#JLU<
zXKYlgk)Uh50bLV~pjWNuA`G4!S`c6a-im#s-+dkF>K0V@&mp4&IBO>%w~~-qNF<jX
zMvu>em8*NKi5C3*h;eH{=}2kRqEdNlF-|c6B{jNg1xE|{xZ_qq=T<Pqx<19~31}wm
zd0~}TtGRp9T&Bo`)-Qa~<2im(#{xgmxgd~dF3M`7QRmKPI@6KfFaN<tpf1-?{G`YJ
c7i;MAKSmLw1Kmc8Pyhe`07*qoM6N<$f(U9xs{jB1
diff --git a/public/images/icons/product_members.png b/public/images/icons/product_members.png
deleted file mode 100755
index 7fb4e1f1e1cd6ee67d33ffd24f09ddd5c3478bec..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 753
zcmV<N0uKF&P)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!gGod|R5;6x
z(M?R!aU2G4>(G`SI(6vnfMgxg){D+Lwutc1Si0swhN#FwOv#}l83ts6rCW;r!9Q9l
zl<41549yYiq6clJ;(J(YgF_14nmKFB@QK(mo6I~sr{BJxJ$rsp0HSt^ntND0Z;o48
z>O2Ckm9}n?$F`*>$L{;{zT>f+bCm7tpaqw^4q<CrW%R~f0d1m##ByIE{H}a_8>@%k
z&cHHt3=3xZmt6rQ_dtDM#)Xwp66-Thu=<9?(zFvpy0gAr0U4Z3smE5f@pZNr!NoqT
zEjSPuCQzMw(H;?yvf{+e;!7(;4hv)+d%cjKFiBL%egy0aeCof8z<>rLEjMsF|CBRH
z86WcxAYvS6H;Yq)jY1Z-rrjWiu~m;clLmJlDAE7UhMJ*jBxp}s&nQkrZvqDXxsiv3
zSJ78>4W2GFIu$$+Ic&5Pq{1?zhIy(24enCZy35e>z6~XgVx$x%k(+>tPw)9SL~R?4
zs${`1bqjTFC3F)dxIIw>)!QP7$vk+;^#2c5r{lsjtwKYnfnn+<nnEcblp)KI1^=Tf
ze}TQ{n^Y!M@wUO@dF7DUvT?mzfC5SYNk=9w=ovnxw5rN$5eUP_dl=o44264j_%m?t
zxgDBz3QEF?$K&6jG%aC&O1CwJ*%}?`tD|U~79LMNxHhmDgx&+>j{~{GK;|I8rvPFU
z5NbS#W7m)ofjNER&&ggR6fXi0xd4%4143#8JZlhXW+2TN#8b=5@L&-EUlY^cTT=>w
zb_~+jfcRCYfdj}H0J49#sP#gtxE~%YBJiQ3AjMgoQJKuMITA}Iz|zizG7pw|7R*XF
j=$D`QjOCK>V3B}dL4UFUkhgq600000NkvXXu0mjf1x-zB
diff --git a/public/images/icons/reject.png b/public/images/icons/reject.png
deleted file mode 100644
index 3c832d4c83cc0f7869a83f88833699daff52fcf2..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 601
zcmV-f0;c_mP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz>q$gGR5;6x
zld)?PVHk%KwHXvA{{RQkK^zqPQ*`Jo3NFS)5G+C!og7>$D6NEu!3M;jM7RtZQ)9g&
zq2Y3PT)1!{94U9^xG!8xj6)xXFPclGw!?4uK6oB}yu5D$NbE`yI1Hp{cTP^<iM=xa
zQIC=|1{W~BPdM%loi11{Fs(6$`4z*wk8Yetvlr0pg?WKCIz%J5M5_g12;&Ki#xNX#
z7=RyuZtP%MP&WmemNPHd9G#)v0Yw2-1<#X$Xb2*bl2FL_0lY5#m=}~ACF)xe2!#ZO
zBN)WsKtK>eKY;ujW&%y5(3%q}8&Iu+Z9^2vgn!@=`cv5NLoOc^Y;-QuG-PEVz;+}o
zcENrCULQOkUgTneivF3xTc}jz)O*_!j#u{k5_Y%2vSBq76X>=lfe@Sy)M^rlSN41<
zFBak9y3SHYoe91;AFxb0c`@LxEMB>3K(+u^@@dlfv)n11o(YQ9DYCV@ysWAe)bA*@
zA?~zt*M)Z<VJR~qy|&2pYI3hlOaez}1ji{^%UIvQ?LoN=x7VL>wRm*@RfYubyBDcy
zzi@ld(BZ+WEz+fP2fGEuIfi0A$28@nAFW2*$}b-Lm=yg4isoRq;qlX-+{iBf5B^t>
nvr??6H@Wu&uC1-?2Lp-UJe5p>RuR>I00000NkvXXu0mjfVs`!d
diff --git a/public/images/icons/role_delete.png b/public/images/icons/role_delete.png
deleted file mode 100755
index acbb5630e51a12a1cd30ea799d659b309e7041cd..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 767
zcmV<b0s#GqP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!kx4{BR5;6}
zlWR!RQ5c7nxulD&ZH)?y<x0xJzNnunv9-S}=R`8lGA~oHsjN^e?@3}x3qvf^@-~LH
zt(jKh-LlBa#95XIeW+2c2&0AMrl_4?XZs)qqCy8A&f$gM^S+0}aRK1+KP+2J4cJ;x
zCgn|+%eFu*<)A>!wMF|0iq|AK>&MHw6~-ksQ9RtH+=$?!G=zinz|BN<kbikqL6wYy
z1{ze;P^ruT7gs_BxNww*Gk%<@RRyJio6uF24@;*SgSF{67bHN2WCzk15t0va=d{^!
z_Z0;-vBIA--#=jLMHRmGUdO1R5a#x)u$oQKDR)o3)1<CjQBV=Rf519)7n8$A*x%|X
zdgs|{(&0{m@9>IO*d}XYdmm2K>Qw%i9j?X9SgRBJn<lhm`olL+d0)WPtY20j2+_Io
zrHv_`(msrRnT8=R4Ej8|6UX`a_-}XyKLJl%cb+e<-tdorf85~e5p0MTh7MrdVn?F|
z$QT1sUIU3wfw&GpQ(lQ@F+S)Ov+;o0XzgIwxr>4W5%uAclWG_T7f?M4s9q5$`w5b|
z31S}-Tq~-?NahjDw3mU5cfqF5z)+g+pPplGDyLv1f8WAnTQ+Xiw;{fhcBLH^j|gI#
z2~IT;7{m9#PQ$2>16f?4#0x*vLFksSJ~;)W1wO>uQ-rAG1{C+&5Zw%%))Pc(2_k9<
z`smoi)Pkk!SK)SAcOy>0d#x(Rl;b_GP?XFE#P>r%M^9Kn>j@#I>k<JPn!V@(E9JA2
zg>HsrS$qYvKE>lwZZUsXcw4nFNHZZ~?%71a&2u7&aV|47ZvJKBUVO{)!ekB`ACp}1
xSnjsa#jtYM{A~v!cV^R$X2;lcpKyd7^}lwPp>~q=QF{OY002ovPDHLkV1lJ{Vb%Zu
diff --git a/public/images/icons/roles.png b/public/images/icons/roles.png
deleted file mode 100755
index 0eaa571757dcb6c6fde9f194cabf3bf2d1863903..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 770
zcmV+d1O5DoP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!lu1NER5;6h
zlkIEObr{A!-{0?SXJ=04#D>G16ocaoM?$_R#KKptL1a;2QYaEZL?ZqH!+`{1Z+cmQ
zQ1}3XixLgQQmBz(W@=0D%tV*%@ux?(vz_nL)r+<|qt*=<H{ADqUEJIcMiTS(?%ADs
zp|hJy*#JS25W^fIK6*B;*u<gFKjo`E=gd4OM~!*siTbNJ#DI@+&i?~zeJzD#fq?Z$
z;6lI#Plx@3m!~gagTsfpT-7Zu;H)Q4Bl)BO7d%f_hv-TZ9+{mWpB5;n31|Obpl|Tj
zudL2@&??dHCP<cVVdK;=k2Msz^z&t$^?i#99C^LJ_x#{DePhEJo2ig%dyQn-Gjz?|
zVf4m0r!So9`|-e)-dc{4g!^tyK}w5?N~7SwQ0v<}R#)CyS=<dtJ@)o7j{bD{%U9a#
z_DKmbir^~aZLf~qHj=RC+lJNHxk>^_D2k+IGc>M2?jCYw+~gHPWej16&S4+k!e$e1
z8c9@6m=~ktaJufDrrfG+$c_;0J)+AXR6rg~4wG9-^p5hyBV-q^eL<e>X8w@MpP7yr
zI9b1TYcYNFC1gjDpGL8Q!VH)cpWS8T^zmRH(%bc+_AeM>^BXFW=c(D>`upR9L6DHD
zC{{rhL(1sa$1_)@d!GESEwpUYlqoDZ<H{vu07VQ26i|$iGPVAo&Mdk{0MXD>*YYIB
z<dFB+>@KEmIZ1sp^{p%0ukJJJYwH*M!esx7LKIulW$P&a@Bxzp10@XKE-7~GSi1H(
z@@-F$4UO~wSO2qv5d5>I>G8t&$-Ru98~DgOHgvwPo%gSox^JHN<aaw`nVqI*Vaw9r
z82*@DrTyHzwuk3y<C6IPJ(|yM=g_IG{8AhL6JNfO&mH|mQUCw|07*qoM6N<$g1S(B
AoB#j-
diff --git a/public/images/icons/rss.png b/public/images/icons/rss.png
new file mode 100644
index 0000000000000000000000000000000000000000..1dc6ff30ba5020600aa4ba2646beb9eb25dc978f
GIT binary patch
literal 530
zcmV+t0`2{YP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzq)9|UR5;7U
zlh2FIQ5eQQ=iKi%S0M{YQdTBO%#<YmfLKjqL6az1varH}n&s@21*I%(Y(`xhnuR7i
z%0#hKQ-dt5Xz054{bg~^oiehz3vZpykIwnj^E~fK5y3c>8MDR)G%QU0LxY1jsDL`i
z5Y)<<0mFa+Gyv?I-SF1`9OhtF)?#(7@ZwPg0IYohcf#rgRl*cR36-)JAyh$ycISTR
z#|RJ*0Y`3-+O5RfdxRPJ@Fmbb_UtP8<ZV*hMVID@Pj5h^0#u8(aA>y?FXu?dZjo$&
zzrG9R<fE5Cej-n<&^dG#U0q%WWJC(U19Agf_JS+%{WJOab@J3j{KFz?#{|vk`}oIK
z;6);XAk+U<^tl6&4qhNu7Gb!CJ)R{9@_`xT!72QGkLJW&F;lGq23lzD;DRsTAx%uf
zkFRuQ9+JA-v3Co!=X-P}&!A6lR)B&)iuv8a<T-+G#dTFeiqj$0{ZR_4U`Cj+Y4aZF
zJ*S}`AVRV(X%wMZ$v&#N*`Z^Q0va!g)k<+~S0J(8Sy;eu!>p9<^S_%m{rBJa8vxT8
UVM82oKL7v#07*qoM6N<$f={^a^#A|>
literal 0
HcmV?d00001
diff --git a/public/images/icons/sprint_add.png b/public/images/icons/sprint_add.png
deleted file mode 100755
index d4195ff80251f62483a2759d15559b4393fdf7bd..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 820
zcmV-41Izr0P)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!#z{m$R5;6}
zlU+#DQ5eQ|-$iyAb=5_6Qy?T=*o8<SQIepL)<sJ!MG*;#%n#JIwdtmt-ybc?g{ApD
zb+)OPn3maxt!aMsg(;b+shO$Uex1KlF)F&~z{B~U|9PM1dCz;^BLigrgUt_DbMroD
zV`KDcU0u?0RaMwhdAZld-dthfj@j(&m}#wc?2A&_@i{s9_%8x3izU|C&@kg{vEpoN
zWu?BB#nMv8g3;(bS5UBb)?l!IGu>vjyOSSQ3)AUp#^U1ce29wLCXoZ3<~rO%128fK
z0}dD+W~IH2x%~W(GkSghlGw*#her=Vh)6Lr`b^%+<x1JA*=%(`e+n<3K~p1`%}`S#
z*1foTyK#03Qg093x&=3HLQxTvnQ%UR%y4MvxNNz)`jdNP5UkBmC!A^pxv~=O-t`DR
zXab{ApacpE1o9x;fa^&YZ-RmrWTIGK+=Km4FUS=YP<{_gCXZvuIw?<(a&iPw4;dMt
zQRC`tr~lZoIoYDgH0pA^f@<MfdfSuc1~E4m3<l8YAUz#aD!7&g*A=+BI<WiuFUS^d
z+$dhIz7IWJp7gf5X|=*p14<>Nrb2QOT)rfZSjMDEL7%U0vFvMJ-kzDPtoKWjh)|`Z
z(BmXQaZM0Y)`(VBB$snfb0O;_=Tt}0h8_C;!pCRVS|3cQ)dwe2Q+wyLGjT~*RU-<R
za1|00ApQysDQ8KmJV&D=pS}qj`j%);=?`!?W~Xkm6DQ*1w~t-CsQ4HiJwI<OhGub{
z$wDM9mT*k~^{)-o4~V0Qp=&*lzBN)3So*EE--U-C8aZ?3@l=u^x7(oA!ihw0EDj@E
zhuNMl3H<qi426Vv4Fm?}IsyXR;}LR>#O!uzZQ-bG@-?#BD*t;1TVeO}3l^1*6M@QL
y-F`A{5o^xUEUka3!M`AaH3IWe+Uv7M`u`JQP3y$y8AIa$0000<MNUMnLSTaOZhBz=
diff --git a/public/images/icons/sprint_cancel.png b/public/images/icons/sprint_cancel.png
deleted file mode 100644
index 0cfd585963d255190b8855a7689e8da1c4d7cf6b..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 700
zcmV;t0z>_YP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!PDw;TR5;6}
zl08pUK@^6c-MfIG7zy&ZdO=W0RM5go6AL;JW9dJzHAaJtjTV}aNP&t#jK9E25`KYJ
z7-190qI|g^MC3yjcJIA2b7#h4m*qn(j3+rcCzCnP`_AM<thM}4#10;;z8Sh6DK2^%
ziAG(kwZIsx(Ir;M1)a$}vRV(<KX@qr2v`|>*?Fk0YVb%?UEFajs1S?+YtYiPrjx0+
z+4<Bxpm%2`vh#$MS1&q>YbyJXwz!SX#yqTlhtNQ%Ku9=RNm$j)&+(}lZ!UGGp|@|O
z09YA#-dR#rIaGe;MBLe!ht*}!c?U}6YT!dfHDO%~>xtx&Klk-^WB==sC_vP4ddg4L
z#GN10u$+QGf$!(i3&8VpF6O6+ef~&gQ#>AVqCJH_utvKMAuOeG%3%mn<<%9)yb~#4
zHc70e5sYyQ03$?zFUko7D1Bg1=6jXvg#bUm1b(pVKuC*}koEKGdj<=zd<p?lVuY}}
z02!2`6>M#RWsl+kfRf;OU^G_BQh+Fc$z&F_AHuQYu(b)<Z0@4xK{j7~54O@!dlDrz
zdV71Z08s1e-K%5C<<PwV6}7OL&Ox&ILC?pe)0lb}|Ke4?jgBveYir&67HRs{%^PJ6
zZeR7KbB#q~?_p*Ntij5VPzbEsjJkY{l@Ft{naPQ}ZHX_#`v3q_9qnD^r<(?<&!3GK
z8HH8~V@z(R46J^dAd^VkZ=0Nb_S-7&M6&%#ms585NhIdDHVlQ)nus+e+V>aq=H_Fx
idDl8IBmWBc*Z2i=4uSP&;Q8VJ0000<MNUMnLSTZ|>p<iH
diff --git a/public/images/icons/sprint_complete.png b/public/images/icons/sprint_complete.png
deleted file mode 100644
index 0156c266e4e1fbcd1263fab2c2dd1f3712553d14..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 623
zcmV-#0+9WQP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!0ZBwbR5;6}
zlif?xQ5eU)^D^-Kemg@D!Y+!S3;%@vf@v>Wtv3@TMo36t?!us(ut2(qE`#u;Y2=pT
zq@2~2T~yXyeA(Qqy5;+9o4@n>4e8r+MnSPzjxHXahjaKm&-eRqP6dF9|A~@^;+?rt
z=H0jE_T4vTwd@82LFU{PN7lpd$oktP$3Gho==lu{2>(S8IKK0m@1<YyO?kI9VLH0$
zHr14Dke%Y+Gaio2BoF0Nk03)2cS29x1A#6Au`zB!kph~=3A`n1nuh$>#{_mk%-f(Z
z+<+0WLyL4{ZOnvyEl7}LBImWjh~I^nZG{LlL-;RKT!Im`W9MTdQZ5xqr;6nfwX(<p
z$xIhy(^L_-S^zcQd1&6Vq~;oO!43iyXORN(=9b<vGZ&Cg_Cn0HK`v19og?@Rv@aG?
zWF;H7vGLx5_;UrZVWp}_!Rn~`Fzde#J=jj5gijY>&09}<xQX>?3!+0xOT@w2!VY%$
zj|Qoisx?Xewm&q%SbjiFY^0(I!gmV#q77T0t|0EJMfe#*$tA9gs83{GdZFA;x{o96
zB^%K$7%@AxKer&}ti|FWs}2pYzTg0Ry6;OQZ&K!w#ON`Q%*5Js144tWhd>1Tnd7f7
zk-!t=?~tr%T0tsJ(--<#x2)&;s)i5r7k+}jpgM|^Wqqb{;s;8N-ZpQB0Ez$r002ov
JPDHLkV1fyHAhiGh
diff --git a/public/images/icons/sprint_plan.png b/public/images/icons/sprint_plan.png
deleted file mode 100755
index d88e2b9ab7ab168e884a7e1eb12891f426cdbfb6..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 631
zcmV--0*L*IP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!2}wjjR5;6x
zlTS#~VHn3h@9+0p4ojI9p`$<@LItU96)GNTAykJ!r=Xjsu-2sryjLVax9HHN({3Gt
zz*s>dIT>^aD;pwFhvkBWZF9u_yzl#T7>be(A9#6qA9z0R_kI2#BJAblMn+_(xWgXp
z)x^Y(aU~*~mrN!j#u&8LY<R6T`Fx&KDn&3DBoqn}i^bGNj<=Oouh;48>jPjbSVV|M
zqlgH}WOAE=Z2+w`0JF=DpBVvT!HUHgxOhTStyXsj)M_<EgmBlN9SaG8y#dnM4y;(T
zZ79$NtQD>dX)2XUa{$1Z?%%t#v!l6!YPE`pF!!SqBZ9VIO`{fKg{!CQ%@q_)0TE&B
zX#T&~KYW{G`o%n@)&kcaoG%s1Yma6gF5ce++)YpR><jq0B$u()l1L<Q90#Qo>n%B2
zd;W56>?}t*`<Y#O*_QtD{@!pr7}%UdYi+ONbIBAxBO-X7M>rfN5{Z!0D-0bQ#5jh*
z?s1H&GkkIs;HCp`J9WJsquw3#9pY{FHP%`@&%^UP9LHg;oX4jQ5IcSgfIAnS5js2o
z;P(KGHj_qMSt&28{k3+I*%_?Q;<_$MDGH@EmR1*7T%BiX{5jKaCh)s002L)7#N%;k
zwn#tuirbrGbYP7Bu2Zc1`pCkkd9vS&kGCyTGaHTvr-2(l8?X*MO+Q(k`Ug*NS(ok0
R0GI#(002ovPDHLkV1m!b5pn<k
diff --git a/public/images/icons/sprint_start.png b/public/images/icons/sprint_start.png
deleted file mode 100644
index b88c8578956ceec4ff17f81995b8652f6aa2b58d..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 670
zcmV;P0%84$P)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!FiAu~R5;6(
zlFdt$Q543Xd*AoIGtP{SV`!p;RDLwHXb}ZrB5nE)LV~1K+h`F41>rx?szq&Dw38OK
zY!^{rCAFy_2z8TV&4=Ube7+y|oYO*02OOyb5BD7I^ZdAQt`ZS+tMaFrb6^=AxbXHx
zH;=|4CCm%L{PZwSS3v3G^sH+#W3JcR_xs(&`Tqt8^J9}d0<R|rCcamAect<LKyh(u
z-Xya*?IZvI#_Kg=`Po5URsKAa5tggMd<86Lg=N(AWK)TBKRBv@gF2Kug0^2o*!0^%
zQV`;=!u&>vU#im5^f#04JL4qMaI^<NL$QE@B5bn&i7f;fXfpzdEhq|M!vJyELfEQR
zl!&1Cerm<b^q)*-kAa)3;BX6UEhM(!9Z5_OQgBj}2pXGc*Q>seoYDXwB>7;oyw=|M
z1!ayym?6XvqV3ae_f95{py8ukt2TxB^!VIzRRh4#rNu~y^X+P>L{SXo3_|Qqm>9wY
zz(9!5s#OBElpmj4DRyjO`0`RiEIkUg%7D)8y}}Ye3}prow;JG>UQOIs{kfZSJ9bYz
zskMPbH9)1H6FDf)1=ZKVfe+;jf`a(O{!9meiN~~d0iA$0qX=t0D6Ydx4#RO76h@#R
z9_k7Z;$fv6G>QeZ{Yu0n&xL4%!?l}UPj4!j&Vs@?dl=y8#_IQ`5I-5a_T$dJtJ_~5
z4&186>klZh{hfba<gb<&Ca&+F57N^8<m`vL#@n6$02)Sf{tFKxDF6Tf07*qoM6N<$
Eg88vBhX4Qo
diff --git a/public/images/icons/story_add.png b/public/images/icons/story_add.png
deleted file mode 100755
index 2e945076cf7686b3b408d6eb2cf913992100da15..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 619
zcmV-x0+juUP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz{YgYYR5;6}
zlRaouQ4oc{x$i|6ViYViE@^@WwJ=dwVMziCl2XAYjg?@diG^4guvJ*W9|*?6#==Sw
zF(3#bphXM;5ses9ScHJPip9o7vyr@=8H@K;W0%DX1E;ur_nbR3;_m#<k<*tRjSL<y
z+^E*Q-5oH)%}`U^9CO1}F?U=QHDmjhWOZ?|eC6rw;fc5~P#AB^wTIogmL6_jt?M{2
z5!VwB_3F=-t*<2>0Ag3?`k8$#1J0F}NdayEtTz+~+#EG995YAF(1xew#=1J)ogJuY
z3Lxu(1VP;KAh<YULI{FLKtz$qi7XH|()-rP08LT=Tm%Fn2NN)JB8MOmv4D!BA$4qO
zSp>`GKm^?X0f~UCV*)Nf5F(3GKr=#9qzp;L29U)<Y{vB4NnXuAr?$F6(nwg-{lH1$
zMw0w-Ab`6gBKQW-i#JbrU!LOFz)`w(_V9Ay8FL?}DURlE2G6oYfMhNt(?NtF!sP3R
z96r#82BmM$5Go19L;V0F!OZ?f$UZZO5N)V$-@ni%+c<UbA^?|9+-L8uUVvO&URr*9
z{!TJ%s+bz8ikYEFf|+6NRBJ0NRA!m4%rG|efYQ`ia?w_RU$U8PERN={b#?E!+TS}!
zPv;RneSOF5hZ#OE)o#gV*SR>FF98>T4&VoHZ|-ho>^FRq4ws;uOVa=V002ovPDHLk
FV1kR43LgLf
diff --git a/public/images/icons/story_view.png b/public/images/icons/story_view.png
deleted file mode 100644
index 3bc0bd32fceb21d70368f7842a00a53d6369ba48..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 465
zcmV;?0WSWDP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzV@X6oR5;6}
zlif?gU=+sv7bPi)=!3$7q?JQqQvwMoh(bhK+J{k6%rsgQVNn!z(HBXQkd^mAH(hwA
z^Yotc=4S08nh!j@F!ub;!`WU30603a!#SmnFKE?TaOVx=ZRYd~NSn_P*eHX4{Rzai
z68Mrum`pr?uyhCB_zlR(s~YAA5Xn~cSpFUUYax26696aMv1k4Q2q33l!H$M!&p1HZ
zs?dXQ!8A(HJcd!rMboIk$jLW=EvyAdm3{&e#VDQ4W|M-siV5fsA9Db1`>zJNu3H-P
zO&@UpeyZQXi7jKe-Hk?r-sue;aDce_XqkvXP+W#F_*ot`jB?BS93Uw71|U^ZjLH<w
zh;-sq3Vy5@0GB_@0Tc0CO9QIe)}UUmTN-qU84mEqu5JAXPM->`yP%FO7U<6!nLCG}
z$SDlW<k^-FX;JQ=20hXqbO&;*_AZ;O0?VLP0(5*EI|Y0J3Jfj)lKW#`00000NkvXX
Hu0mjf{J_IH
diff --git a/public/images/icons/unhealthy.png b/public/images/icons/unhealthy.png
deleted file mode 100755
index fd5d030ef773ddbd3f18925487a870ae7679b08b..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 723
zcmV;^0xbQBP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!Wl2OqR5;6}
zlTApJVGzfE&%3(ouAAvrkQRy|fr?lZdJ3a&k`8w0;>nI31uuPEx(b2EPDKzMg2IC!
z1S@@jQACFdqKw6YEZj=hS9jm{d7haL#U>dYYEHv2^W)FV{Ee#eKT-30!OfVgDx)f#
z5TQ>MRCpr7v<g=?ozif9zge%q&6oidu9?=ccGK90CUc-3Ef?|Mo)f(tFNiSGG^F=8
z09@V-Y2(11VEZtd*^FO%jYMBiR{>K;P}hO60nz*0M4#`U-gZXsRsl9|a;nTHdTwPU
zDB&vwuq%j*P+$205yhlhNu+vMo;@3i(AGSxUjqOYMuWBsSxtgO1#qk6CyO94_?QRx
zKx~LBy}(EB$aG%Fif0rc01#o&H1#8u0_w`BSPq^{qhe8SIWQ2S5}`K4Ej^>AaW~=_
zTo33X)quFA>X3>>U5xtpXKqm+BT*5E@SgVS8C58&t0eIbd`!4p;>FWBO5Z{dM}5Z7
z-Q7SY<xv7qMTEpJ1)c)73?vDcSD4MeqGd;x!^d}V=vW)gEomM<S|D60V}cCgYp@Y>
zRRtnU+2Zp(L1QcF+PCx%bW&eiMtlYJ5j$Iw<eGa)`;XK#^inR)V`HY)E0|b&bveXs
zB9ZMRn+BB#d|cf}+J7Wb-+`ujS$KFkv=I|kK+|bm6k%j-?gr=%5)J*BL=F`NQG(=F
zYPTIhGl%%{auOReviH0$Zg}N|o93u^uGV*rwI{c96VwJE4i|Y=7vAt`cD&#$=k{IH
z=|6e_;KOy370;LmC%mW6#o#PYoMqZs#`j<Pi<$l<z5@{@b~pE3oM`|6002ovPDHLk
FV1ngnJVyWk
diff --git a/public/images/icons/user.png b/public/images/icons/user.png
deleted file mode 100755
index 79f35ccbdad44489dbf07d1bf688c411aa3b612c..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 741
zcmV<B0vi2^P)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!cS%G+R5;6}
zlWj=TQ51&_D(S`6wpIkfYAK|mU(`?4(513DT?37*m2E2Kj9H4|>z1iyEv%?$mbQ(#
zwJpuiQJP8?X_`#S8b+U_G6=zi<fcr$zy3;$1erx29ynai?>YB!xPAcq{)ZJ0bECH@
zYx#`n8^Wzn^J!4>=q^bltNO15ry?0ecSLkjpT@vlid!jk)Fjf7&)q_V5zGs#3N%6*
zbW~7Hg=&P0&~Y(|g>$hC9FL?;ttzPDZbpZu9OLb33^e2;FNTGJxScp1&q4M+y2ntQ
z?C(=hpU$3~`Thx0eHwi0x`q+!d5k@|0_WHe%sG3e-s^MM`xM-ig!VcIA7H}X1ot~L
zg=MLB4w-Q;Bi!!u2|I+Qb;0{{4Q53YX6+4_aXena{nmt*!YG7ua~`qc>o=?@U?rOU
znS7%>klzi*muXnbM6i@4FR@s^8vTjDgy&%J?w?`u>NYMDFa_2%0SQ(qJE<3=<8Bzo
zfdU60e*<K`08TyyDqaGwKf1vd^kEZME2qV3)_MhDDOzz{sv1oL^QapE6#WD_Jp@_L
z2+~^#j#>y(^$RF%r$kl)p7=7tlCDa<g5mPL^AomW+}7G4rJ6FSiLy3=6U|iEM3ACg
zfJj4duokGi-i^r)75WQ9JYK<wGK4rgE0LD1Lv@!6l4jr-HT8&r3O5iOZUn?Sptxlg
ziRniC=&r(GQ7G{WJ{S}wLnXm;GTEiMXlV~*)YUWoK=zv;K`RtlrLJLB#4MGmTDFBT
zi#$M8TFu-gw$-s?^j|sg4_%yuW)CN>$+J7w>}DU(O#~fk>pYuRvHi1E9^msg{tLeV
XM&GIRvfA7%00000NkvXXu0mjf&%8>|
diff --git a/public/images/icons/view.png b/public/images/icons/view.png
deleted file mode 100755
index 1dee9e366094e87db68c606d0522d72d4b939818..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 464
zcmV;>0WbcEP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzVo5|nR2Uhh
z!69yxQ5c2c_uP9SO&YLZNmxQ12)2N!!D0vs#R^Ek(4<KhK?U2XBo(z)bV&OD-#N^k
zFa|g2^C$@qp|_uYe0=@p<lF5g#|dB)n{p8c$D|4;q>8e6`gpm!y1M!N^ZV(=IC*t)
z{^;nqJv-tM$9J1L2QJ2DN!#51=1_l@G`2=6e0lehL%sic%`_4--LFM}IF!KzJCseW
zq1I3__Z40|e?qyK1__gzP(qrBf-G7SQbQ`#Lw94WVe(o`qg+f4hy;Qju)q#I(9{`%
zQmAGomzhQ!b|gq>KqL@IkO~$=Koi}a$u6d07kiS}NoYVMJjAeZpaB*;wwcDdEbK@K
zNP;B7RzhQ|H9AlUO<`J>m1(5R)Pb-iLBb@7Jp)}LHdAb-VVgYxVoTzGoqu{~a>6uj
zeqCRFI9pC#h09bGwy9;oHcp6(RB%jeY^F=Ll!S+9JkVe4nDG7tJMQiP0000<MNUMn
GLSTZz`@eVq
diff --git a/test/fixtures/feeds.yml b/test/fixtures/feeds.yml
new file mode 100644
index 0000000..9c12786
--- /dev/null
+++ b/test/fixtures/feeds.yml
@@ -0,0 +1,7 @@
+# Read about fixtures at http://ar.rubyonrails.org/classes/Fixtures.html
+
+feed_one:
+ title: This is the first item in our list!
+ link: http://localhost/
+ author_id: <%= Fixtures.identify(:mcpierce) %>
+ content: We got a lot of stuff going on right now!
diff --git a/test/fixtures/product_feeds.yml b/test/fixtures/product_feeds.yml
new file mode 100644
index 0000000..f5d6042
--- /dev/null
+++ b/test/fixtures/product_feeds.yml
@@ -0,0 +1,3 @@
+projxp_web_feed_one:
+ product_id: <%= Fixtures.identify(:projxp_web) %>
+ feed_id: <%= Fixtures.identify(:feed_one) %>
diff --git a/test/functional/products_controller_test.rb b/test/functional/products_controller_test.rb
index 07f9d4b..74b489d 100644
--- a/test/functional/products_controller_test.rb
+++ b/test/functional/products_controller_test.rb
@@ -245,8 +245,9 @@ class ProductsControllerTest < ActionController::TestCase
{:user_id => @project_owner.id}
assert_redirected_to product_path(assigns['product'])
- assert Product.find_by_name(@new_product[:name]),
- "Failed to create the actual product."
+ result = Product.find_by_name(@new_product[:name])
+ assert result, "Failed to create the actual product."
+ assert !result.rss_entries.empty?, "Product should have an RSS entry for its creation."
end
# Ensures that anonymous users can't update a product.
@@ -277,7 +278,8 @@ class ProductsControllerTest < ActionController::TestCase
end
# Ensures that updating a product works for the project owner.
- def test_update_as_project_owner
+ def test_update
+ count = @existing_product.rss_entries.size
put :update,
{:id => @existing_product.id,
:product => @product_update},
@@ -287,6 +289,7 @@ class ProductsControllerTest < ActionController::TestCase
result = Product.find_by_id((a)existing_product.id)
assert_equal @product_update[:name], result.name,
"Name should have been updaed."
+ assert_equal count + 1, result.rss_entries.size, "Failed to add a new rss entry."
end
# Ensures that anonymous users can't delete.
@@ -302,4 +305,19 @@ class ProductsControllerTest < ActionController::TestCase
assert_redirected_to error_path
end
+
+ # Ensures that the RSS feed requires a valid product.
+ def test_rss_with_invalid_product
+ get :rss
+
+ assert_redirected_to error_path
+ end
+
+ # Ensures that the RSS feed does not require authentication.
+ def test_rss
+ get :rss, {:id => @existing_product.id}
+
+ assert_response :success
+ assert assigns['entries'], "Failed to load any feed content."
+ end
end
diff --git a/test/functional/sprints_controller_test.rb b/test/functional/sprints_controller_test.rb
index 3247a5f..d184fbc 100644
--- a/test/functional/sprints_controller_test.rb
+++ b/test/functional/sprints_controller_test.rb
@@ -212,6 +212,7 @@ class SprintsControllerTest < ActionController::TestCase
# Ensures that creating a sprint works as expected.
def test_create
+ rss_count = @product_with_no_sprints.rss_entries.size
post :create,{:product => @product_with_no_sprints.id, :sprint => @new_sprint, :selected => [@member.id]},
{:user_id => @product_with_no_sprints.owner_id}
@@ -220,6 +221,7 @@ class SprintsControllerTest < ActionController::TestCase
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."
+ assert_equal rss_count + 1, result.product.rss_entries.size, "Failed to add RSS entry for sprint creation."
end
# Ensures that anonymous users cannot update a sprint.
@@ -257,10 +259,9 @@ class SprintsControllerTest < ActionController::TestCase
# Ensures that updating a sprint works as expected.
def test_update
+ count = @active_sprint.product.rss_entries.size
sprint = {:title => "Do it!"}
- put :update,
- {:product => @product.id, :id => @active_sprint.id,
- :sprint => sprint, :selected => [@other_member.id]},
+ put :update, {:id => @active_sprint.id,:sprint => sprint, :selected => [@other_member.id]},
{:user_id => @product.owner_id}
assert_redirected_to sprints_path(:product => @product)
@@ -269,11 +270,13 @@ class SprintsControllerTest < ActionController::TestCase
"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."
+ assert_equal count + 1, result.product.rss_entries.size, "Failed to add an rss entry."
end
# Ensures that updating with a specified return URL sends the browser back
# to that URL.
def test_update_with_url
+ count = @active_sprint.product.rss_entries.size
sprint = {:title => "Do it!"}
put :update, {:id => @active_sprint.id, :sprint => sprint, :url => sprint_path(@active_sprint)},
{:user_id => @product.owner_id}
@@ -282,6 +285,7 @@ class SprintsControllerTest < ActionController::TestCase
result = Sprint.find_by_id((a)active_sprint.id)
assert_equal sprint[:title], result.title,
"Sprint should have been updated."
+ assert_equal count + 1, result.product.rss_entries.size, "Failed to insert an RSS entry."
end
# Ensures anonymous users can't delete sprins.
@@ -309,15 +313,16 @@ class SprintsControllerTest < ActionController::TestCase
# Ensures that deletion works as expected.
def test_delete
- delete :destroy,
- {:product => @product.id, :id => @active_sprint.id},
- {:user_id => @product.owner_id}
+ count = @active_sprint.product.rss_entries.size
+ delete :destroy, {:id => @active_sprint.id}, {:user_id => @product.owner_id}
assert_redirected_to sprints_path(:product => @product.id)
assert !Sprint.find_by_id((a)active_sprint.id),
"Sprint should have been deleted."
assert BacklogItem.find_all_by_sprint_id((a)active_sprint.id).empty?,
"All backlog items should have been deleted for this sprint."
+ product = Product.find_by_id((a)active_sprint.product_id)
+ assert_equal count + 1, product.rss_entries.size, "Failed to add an RSS entry."
end
# Ensures that an anonymous user cannot move sprints to the planning state.
@@ -350,11 +355,13 @@ class SprintsControllerTest < ActionController::TestCase
# Ensures that a sprint can be moved to the planned state.
def test_planning
+ count = @plannable_sprint.product.rss_entries.size
put :planning, {:id => @plannable_sprint.id}, {:user_id => @plannable_sprint.team_lead_id}
assert_redirected_to sprint_path(@plannable_sprint)
result = Sprint.find_by_id((a)plannable_sprint.id)
assert result.planning?, "Sprint should have been put into the planning state."
+ assert_equal count + 1, result.product.rss_entries.size, "Failed to add an RSS entry."
end
# Ensures that anonymous users can't start a sprint.
@@ -387,11 +394,13 @@ class SprintsControllerTest < ActionController::TestCase
# Ensures that starting a sprint work.s
def test_start
+ count = @startable_sprint.product.rss_entries.size
put :start, {:id => @startable_sprint.id}, {:user_id => @startable_sprint.team_lead_id}
assert_redirected_to sprint_path(@startable_sprint)
result = Sprint.find_by_id((a)startable_sprint.id)
assert result.active?, "Sprint should have been started."
+ assert_equal count + 1, result.product.rss_entries.size, "Failed to add an RSS entry."
end
# Ensures that an anonymous user cannot complete a sprint.
@@ -424,11 +433,13 @@ class SprintsControllerTest < ActionController::TestCase
# Ensures that a sprint can be completed.
def test_complete
+ count = @active_sprint.product.rss_entries.size
put :complete, {:id => @active_sprint.id}, {:user_id => @active_sprint.team_lead_id}
assert_redirected_to sprint_path(@active_sprint)
result = Sprint.find_by_id((a)active_sprint.id)
assert result.completed?, "Sprint was not moved to the completed state."
+ assert_equal count + 1, result.product.rss_entries.size, "Failed to add an RSS entry."
end
# Ensures that anonymous users can't cancel a sprint.
@@ -461,11 +472,13 @@ class SprintsControllerTest < ActionController::TestCase
# Ensures that cancelling a sprint works.
def test_cancel
+ count = @active_sprint.product.rss_entries.size
put :cancel, {:id => @active_sprint.id}, {:user_id => @active_sprint.team_lead_id}
assert_redirected_to sprint_path(@active_sprint)
result = Sprint.find_by_id((a)active_sprint.id)
assert result.cancelled?, "Sprint should have been marked cancelled."
+ assert_equal count + 1, result.product.rss_entries.size, "Failed to add an RSS entry."
end
# Ensures that a valid sprint is required.
@@ -539,12 +552,15 @@ class SprintsControllerTest < ActionController::TestCase
# Ensures that populating works.
def test_populate
+ count = @planned_sprint.product.rss_entries.size
post :populate, {:id => @planned_sprint.id,:estimates => @estimates, :selected => @selected},
{:user_id => @planned_sprint.product.owner_id}
assert_redirected_to sprint_path(@planned_sprint)
assert BacklogItem.find_by_sprint_id_and_user_story_id(@planned_sprint.id,(a)user_story.id),
"Backlog item should have been created"
+ product = Product.find_by_id((a)planned_sprint.product_id)
+ assert_equal count + 1, product.rss_entries.size, "Failed to add an RSS entry."
end
# Ensures that a valid product is required.
diff --git a/test/unit/feed_test.rb b/test/unit/feed_test.rb
new file mode 100644
index 0000000..88cff35
--- /dev/null
+++ b/test/unit/feed_test.rb
@@ -0,0 +1,68 @@
+# feeds_test.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/>.
+#
+
+require File.dirname(__FILE__) + '/../test_helper'
+
+class FeedTest < ActiveSupport::TestCase
+ def setup
+ @new_feed = Feed.new(:title => "Test title",
+ :link => "http://localhost/",
+ :author => users(:mcpierce),
+ :content => "Yo yo yo")
+ end
+
+ # Ensures a feed requires a title.
+ def test_valid_fails_without_title
+ @new_feed.title = nil
+ flunk "A feed must have a title." if @new_feed.valid?
+ end
+
+ # Ensures that the title must have text.
+ def test_valid_fails_for_empty_title
+ @new_feed.title = ""
+ flunk "A feed title must have text." if @new_feed.valid?
+ end
+
+ # Ensures that the feed must have a link.
+ def test_valid_fails_without_link
+ @new_feed.link = nil
+ flunk "A feed needs a link." if @new_feed.valid?
+ end
+
+ # Ensures that the feed must have an author.
+ def test_valid_fails_without_author
+ @new_feed.author = nil
+ flunk "A feed requires an author." if @new_feed.valid?
+ end
+
+ # Ensures that a feed has content.
+ def test_valid_fails_without_content
+ @new_feed.content = nil
+ flunk "A feed must have content." if @new_feed.valid?
+ end
+
+ # Ensures that content cannot be empty.
+ def test_valid_fails_with_empty_content
+ @new_feed.content = ""
+ flunk "A feed must have content." if @new_feed.valid?
+ end
+
+ # Ensures that a well-formed feed is allowed.
+ def test_valid
+ flunk "There is a general validation error." unless @new_feed.valid?
+ end
+end
--
1.6.0.6
15 years
[PATCH] Reworked the sprint state transitions. #185
by Darryl L. Pierce
Introduced the state_machine gem to the project. State transitions are
now more controllable through declarative statements rather than through
tracking flags.
Refactored all unit and functional tests to accomodate the new state
machine.
Added new actions to handle moving sprints between the various states.
Signed-off-by: Darryl L. Pierce <mcpierce(a)gmail.com>
---
app/controllers/report_controller.rb | 10 +-
app/controllers/sprints_controller.rb | 95 ++++++++---
app/models/sprint.rb | 103 +++++++------
app/models/task.rb | 2 +-
app/views/sprints/_edit.html.erb | 4 +-
app/views/sprints/index.html.erb | 8 +-
app/views/sprints/show.html.erb | 40 ++---
config/environment.rb | 1 +
config/routes.rb | 5 +-
db/migrate/011_create_sprints.rb | 4 +-
doc/ChangeLog | 1 +
test/fixtures/backlog_items.yml | 10 +-
test/fixtures/product_roles.yml | 7 +
test/fixtures/products.yml | 10 +-
test/fixtures/sprints.yml | 79 ++++++---
test/functional/items_controller_test.rb | 10 +-
test/functional/report_controller_test.rb | 10 +-
test/functional/sprints_controller_test.rb | 236 ++++++++++++++++++++--------
test/functional/user_items_test.rb | 16 +-
test/unit/backlog_item_test.rb | 22 ++--
test/unit/sprint_member_test.rb | 2 +-
test/unit/sprint_test.rb | 217 +++++++++++++-------------
test/unit/task_test.rb | 6 +-
23 files changed, 541 insertions(+), 357 deletions(-)
diff --git a/app/controllers/report_controller.rb b/app/controllers/report_controller.rb
index be9a85a..c70e895 100644
--- a/app/controllers/report_controller.rb
+++ b/app/controllers/report_controller.rb
@@ -49,8 +49,8 @@ class ReportController < ApplicationController
:all,
:conditions => ['(primary_id = ? or backup_id = ?) and
backlog_item_id in
- (select bi.id from backlog_items bi where bi.sprint_id in (select id from sprints where status = ?))',
- @this_user.id, @this_user.id, Sprint::STATUS_ACTIVE],
+ (select bi.id from backlog_items bi where bi.sprint_id in (select id from sprints where state = ?))',
+ @this_user.id, @this_user.id, 'active'],
:order => 'backlog_item_id')
end
@@ -70,9 +70,9 @@ class ReportController < ApplicationController
g.data("Remaining hours", @data)
g.data("Target", @goal)
g.labels= {
- 0 => @sprint.start.to_s(:date),
- (@sprint.duration / 2) => (@sprint.start + (@sprint.duration / 2)).to_s(:date),
- (@sprint.duration - 1) => (@sprint.start + (@sprint.duration - 1)).to_s(:date)}
+ 0 => @sprint.starts.to_s(:date),
+ (@sprint.duration / 2) => (@sprint.starts + (@sprint.duration / 2)).to_s(:date),
+ (@sprint.duration - 1) => (@sprint.starts + (@sprint.duration - 1)).to_s(:date)}
send_data(g.to_blob, :disposition => 'inline', :type => 'image/png', :filename => "site-stats.png")
else
diff --git a/app/controllers/sprints_controller.rb b/app/controllers/sprints_controller.rb
index 85e5097..409c546 100644
--- a/app/controllers/sprints_controller.rb
+++ b/app/controllers/sprints_controller.rb
@@ -20,12 +20,12 @@ class SprintsController < ApplicationController
before_filter :authenticated, :except => [:index, :show, :members]
before_filter :load_product, :only => [:new, :create]
before_filter :load_sprint, :except => [:index, :new, :create]
- before_filter :get_status, :only => [:status]
- before_filter :verify_can_edit, :only => [:edit, :update, :status]
+ 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 :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, :status, :plan, :populate, :members]
+ before_filter :path_to_one, :only => [:show, :edit, :update, :plan, :populate, :members]
# GET /sprints
def index
@@ -111,23 +111,6 @@ class SprintsController < ApplicationController
end
end
- # PUT /sprints/1/status
- def status
- if @sprint.allowed_status?(@status)
- respond_to do |format|
- Sprint.transaction do
- @sprint.status = @status
- @sprint.save!
-
- flash[:message] = "Sprint moved to #{(a)sprint.status_text}."
- format.html { redirect_to sprint_path(@sprint) }
- end
- end
- else
- report_error "You cannot move the sprint to #{(a)sprint.status_text}."
- end
- end
-
# DELETE /sprints/1/destroy
def destroy
if @sprint.can_delete?(@user)
@@ -144,6 +127,66 @@ class SprintsController < ApplicationController
end
end
+ # PUT /sprints/1/planning
+ def planning
+ Sprint.transaction do
+ if @sprint.can_plan?
+ @sprint.plan
+ respond_to do |format|
+ flash[:message] = "The sprint is now moved to the planning state."
+ format.html {redirect_to sprint_path(@sprint)}
+ end
+ else
+ report_error "This sprint cannot be moved to the planning state."
+ end
+ end
+ end
+
+ # PUT /sprints/1/start
+ def start
+ Sprint.transaction do
+ if @sprint.can_start?
+ @sprint.start
+ respond_to do |format|
+ flash[:message] = "This sprint is now started."
+ format.html {redirect_to sprint_path(@sprint)}
+ end
+ else
+ report_error "This sprint cannot be moved to the active state."
+ end
+ end
+ end
+
+ # PUT /sprints/1/complete
+ def complete
+ Sprint.transaction do
+ if @sprint.can_complete?
+ @sprint.complete
+ respond_to do |format|
+ flash[:message] = "This sprint is now completed."
+ format.html {redirect_to sprint_path(@sprint)}
+ end
+ else
+ report_error "This sprint cannot be moved to the completed state."
+ end
+ end
+ end
+
+ # PUT /sprints/1/cancel
+ def cancel
+ Sprint.transaction do
+ if @sprint.can_cancel?
+ @sprint.cancel
+ respond_to do |format|
+ flash[:message] = "This sprint has been cancelled."
+ format.html {redirect_to sprint_path(@sprint)}
+ end
+ else
+ report_error "This sprint cannot be cancelled."
+ end
+ end
+ end
+
# GET /sprints/1/plan
def plan
add_breadcrumb "Plan"
@@ -226,18 +269,16 @@ class SprintsController < ApplicationController
report_error "Missing or invalid sprint" unless @sprint
end
- def get_status
- @status = params[:status].to_i
-
- report_error "Missing or invalid status." unless @status && (0...Sprint::STATUS_TEXT.size).include?(@status)
- end
-
def verify_can_edit
report_error "You are not allowed to edit this sprint." unless @sprint.can_edit?(@user)
end
def verify_sprint_is_pending
- report_error "This can only be done for a pending sprint." unless @sprint.pending?
+ report_error "This can only be done for a pending sprint." unless @sprint.planning?
+ end
+
+ def verify_can_change_state
+ report_error "You are not allowed to change this sprint's state." unless @sprint.can_change_state?(@user)
end
def prepare_for_edit
diff --git a/app/models/sprint.rb b/app/models/sprint.rb
index f6d808f..b735e68 100644
--- a/app/models/sprint.rb
+++ b/app/models/sprint.rb
@@ -37,25 +37,13 @@
# sprint is considered healthy.
#
class Sprint < ActiveRecord::Base
- STATUS_PLANNED = 0
- STATUS_ACTIVE = 1
- STATUS_CLOSED = 2
- STATUS_CANCELED = 3
- STATUS_TEXT =
- {
- 'Planned' => STATUS_PLANNED,
- 'Active' => STATUS_ACTIVE,
- 'Closed' => STATUS_CLOSED,
- 'Canceled' => STATUS_CANCELED
- }.sort_by { |k,v| v }
-
validates_presence_of :product_id,
:message => 'Sprints must be associated with a product.'
validates_presence_of :title,
:message => 'Please give the sprint a title.'
- validates_presence_of :start,
+ validates_presence_of :starts,
:message => 'Please set the starting date for the sprint.'
validates_presence_of :duration,
@@ -89,16 +77,48 @@ class Sprint < ActiveRecord::Base
named_scope :for_product, lambda { |product_id|
{ :conditions => product_id ? ["product_id = ?", product_id]: []}
}
- named_scope :planned, {:conditions => ['status = ?', STATUS_PLANNED]}
+ named_scope :planned, {:conditions => "state = 'planning'"}
+ named_scope :active, {:conditions => "state = 'active'"}
+
+ STATES = {:planning => "Planning", :active => "Active", :completed => "Completed", :cancelled => "Cancelled"}
+
+ state_machine :initial => :planning do
+ event :start do
+ transition :planning => :active
+
+ def can_fire?(sprint)
+ sprint.planning? && Sprint.for_product(sprint.product).active.empty?
+ end
+ end
+
+ event :plan do
+ transition :active => :planning
- # Returns the text for the status.
- def status_text
- STATUS_TEXT[status][0]
+ def can_fire?(sprint)
+ sprint.active? && Sprint.for_product(sprint.product).planned.empty?
+ end
+ end
+
+ event :complete do
+ transition :active => :completed
+ end
+
+ event :cancel do
+ transition :active => :cancelled
+ end
+
+ event :restart do
+ transition [:completed, :cancelled] => :active
+
+ def can_fire?(sprint)
+ (sprint.completed? || sprint.cancelled?) && Sprint.for_product(sprint.product).active.empty?
+ end
+ end
end
# Returns the end date for the sprint.
def end_date
- self.start + (self.duration - 1)
+ self.starts + (self.duration - 1)
end
# Returns the total estimated hours.
@@ -127,12 +147,17 @@ class Sprint < ActiveRecord::Base
# Returns the number of hours remaining for the specified day.
def remaining_hours_for_day(day)
- backlog_items.inject(0) { |sum, item| sum + item.remaining_hours_for_date(start + day) }
+ backlog_items.inject(0) { |sum, item| sum + item.remaining_hours_for_date(starts + day) }
end
# Returns whether the specified user can modify this sprint.
def can_edit?(user)
- is_product_owner(user) || is_team_lead(user)
+ (active? || planning?) && (is_product_owner(user) || is_team_lead(user))
+ end
+
+ # returns whether the user can change this sprint's state.
+ def can_change_state?(user)
+ (is_product_owner(user) || is_team_lead(user))
end
# Returns whether the user can delete the current sprint.
@@ -142,49 +167,33 @@ class Sprint < ActiveRecord::Base
# Returns whether the specified user is allowed to populate this sprint.
def can_populate?(user)
- (is_product_owner(user) || is_team_lead(user)) && pending?
- end
-
- # Returns whether the sprint can be moved to the given status.
- def allowed_status?(status)
- case self.status
- when STATUS_PLANNED:
- return true if status == STATUS_ACTIVE
- when STATUS_ACTIVE:
- return true if (status == STATUS_PLANNED && actual_hours == 0 && Sprint.for_product(product).planned.empty?)
- return true if [STATUS_CANCELED, STATUS_CLOSED].include?(status)
-
- when STATUS_CLOSED: return true if status == STATUS_ACTIVE
- when STATUS_CANCELED: return true if status == STATUS_ACTIVE
- end
-
- return false
+ planning? && (is_product_owner(user) || is_team_lead(user))
end
# Returns whether the sprint is pending.
- def pending?
- status == STATUS_PLANNED
+ def planning?
+ state == 'planning'
end
# Returns whether the sprint is currently active or not.
def active?
- status == STATUS_ACTIVE
+ state == 'active'
end
- # Returns whether the sprint is current closed or not.
- def closed?
- status == STATUS_CLOSED
+ # Returns whether the sprint is current completed.
+ def completed?
+ state == 'completed'
end
# Returns whether a burndown chart can be viewed for this sprint.
def can_view_burndown?
- status != STATUS_PLANNED
+ !planning?
end
# Rethers whether the sprint can have backlog items added, and whether the
# specified user can add one.
def can_add_backlog_items?(user)
- (is_product_owner(user) || is_team_lead(user)) && status == STATUS_ACTIVE
+ (is_product_owner(user) || is_team_lead(user)) && active?
end
# Returns the data as of the given day into the sprint.
@@ -198,7 +207,7 @@ class Sprint < ActiveRecord::Base
# Returns whether the sprint is in a healthy state or not.
def healthy?
- days_into_sprint = Date.today - start
+ days_into_sprint = Date.today - starts
if (0..duration).include? days_into_sprint
@@ -234,7 +243,7 @@ class Sprint < ActiveRecord::Base
# When the user close a sprint, every user stories are close if the related
# backlog item are completed.
def close_user_stories
- return unless status == STATUS_CLOSED
+ return unless state == 'closed'
BacklogItem.transaction do
self.backlog_items.each do |backlog_item|
diff --git a/app/models/task.rb b/app/models/task.rb
index 75b2451..697bc21 100644
--- a/app/models/task.rb
+++ b/app/models/task.rb
@@ -70,7 +70,7 @@ class Task < ActiveRecord::Base
errors.add('backup_id',"The backup developer cannot also be #{primary.display_name}.")
end
if backlog_item
- unless (backlog_item.sprint.start...(backlog_item.sprint.start + backlog_item.sprint.duration)).include? self.when_entered
+ unless (backlog_item.sprint.starts...(backlog_item.sprint.starts + backlog_item.sprint.duration)).include? self.when_entered
errors.add('when_entered', 'Task must be done during the sprint.')
end
end
diff --git a/app/views/sprints/_edit.html.erb b/app/views/sprints/_edit.html.erb
index fc518e7..5761eac 100644
--- a/app/views/sprints/_edit.html.erb
+++ b/app/views/sprints/_edit.html.erb
@@ -24,8 +24,8 @@
<tr>
<td class="label-required">Starts on:</td>
<td class="value">
- <%= form.date_select :start %>
- <%= error_message_on(:sprint, :start) %>
+ <%= form.date_select :starts %>
+ <%= error_message_on(:sprint, :starts) %>
</td>
</tr>
<tr>
diff --git a/app/views/sprints/index.html.erb b/app/views/sprints/index.html.erb
index bd5b84b..ade1a68 100644
--- a/app/views/sprints/index.html.erb
+++ b/app/views/sprints/index.html.erb
@@ -12,17 +12,16 @@
<th scope="col">Team Lead</th>
<th scope="col">Items</th>
<th scope="col">Members</th>
- <th scope="col">Status</th>
<th scope="col">Starts</th>
</tr>
</thead>
<tbody>
<% @sprints.each do |sprint| %>
- <tr class="<%= "status-#{sprint.status}" %>">
+ <tr class="<%= "status-#{sprint.state}" %>">
<td><%= sprint.id %></td>
<td class="name">
- <%= link_to "#{sprint.title} (#{Sprint::STATUS_TEXT[sprint.status][0]})",
+ <%= link_to "#{sprint.title} (#{Sprint::STATES[sprint.state]})",
sprint_path(sprint) %>
<%= "Hours: #{show_hours_as_ear(sprint)}" %>
<%= RedCloth.new(get_first_sentence(sprint.goals)).to_html %>
@@ -36,8 +35,7 @@
</td>
<td><%= link_to "#{sprint.backlog_items.size}", items_path(:sprint => sprint) %></td>
<td><%= link_to "#{sprint.members.size}", members_sprint_path(sprint) %></td>
- <td><%= sprint.status_text %></td>
- <td><%= "#{show_date(sprint.start)} (#{sprint.duration} days)" %></td>
+ <td><%= "#{show_date(sprint.starts)} (#{sprint.duration} days)" %></td>
</tr>
<% end %>
</tbody>
diff --git a/app/views/sprints/show.html.erb b/app/views/sprints/show.html.erb
index ce58061..fb54101 100644
--- a/app/views/sprints/show.html.erb
+++ b/app/views/sprints/show.html.erb
@@ -1,7 +1,7 @@
<div id="content">
<div id="page-details">
<dl>
- <dt><%= "#{(a)sprint.title} (#{(a)sprint.status_text})" %></dt>
+ <dt><%= "#{(a)sprint.title} (#{(a)sprint.state})" %></dt>
<dd><%= RedCloth.new((a)sprint.goals).to_html %></dd>
<dt>Team Lead</dt>
<dd>
@@ -10,8 +10,8 @@
</dd>
<dt>Sprint Dates</dt>
<dd>
- Starts on <%= show_date((a)sprint.start) %> and continues until <%= show_date((a)sprint.end_date) %>.
- This sprint is currently in the <%= @sprint.status_text %> state.
+ Starts on <%= show_date((a)sprint.starts) %> and continues until <%= show_date((a)sprint.end_date) %>.
+ This sprint is currently in the <%= @sprint.state %> state.
</dd>
</dl>
</div>
@@ -41,33 +41,27 @@
<div class="header"><span class="title">Change Sprint Status</span></div>
- <% if @sprint.allowed_status?(Sprint::STATUS_PLANNED) %>
- <%= link_to "Move sprint back to planning...",
- status_sprint_path(@sprint, :status => Sprint::STATUS_PLANNED),
- :method => :put, :confirm => "Move to planning? Are you sure?",
- :class => "command" %>
+ <% if @sprint.can_change_state?(@user) %>
+ <% if @sprint.can_plan? %>
+ <%= link_to "Move sprint back to planning...", planning_sprint_path(@sprint),
+ :method => :put, :confirm => "Move to planning? Are you sure?", :class => "command" %>
<% end %>
- <% if @sprint.allowed_status?(Sprint::STATUS_ACTIVE) %>
- <%= link_to "Start this sprint...",
- status_sprint_path(@sprint, :status => Sprint::STATUS_ACTIVE),
- :method => :put, :confirm => "Start this sprint? Are you sure?",
- :class => "command" %>
+ <% if @sprint.can_start? %>
+ <%= link_to "Start this sprint...", start_sprint_path(@sprint),
+ :method => :put, :confirm => "Start this sprint? Are you sure?", :class => "command" %>
<% end %>
- <% if @sprint.allowed_status?(Sprint::STATUS_CLOSED) %>
- <%= link_to "Close this sprint...",
- status_sprint_path(@sprint, :status => Sprint::STATUS_CLOSED),
- :method => :put, :confirm => "Close this sprint? Are you sure?",
- :class => "command" %>
+ <% if @sprint.can_complete? %>
+ <%= link_to "Close this sprint...", complete_sprint_path(@sprint),
+ :method => :put, :confirm => "Close this sprint? Are you sure?", :class => "command" %>
<% end %>
- <% if @sprint.allowed_status?(Sprint::STATUS_CANCELED) %>
- <%= link_to "Cancel this sprint...",
- status_sprint_path(@sprint, :status => Sprint::STATUS_CANCELED),
- :method => :put, :confirm => "Cancel this sprint? Are you sure?",
- :class => "command" %>
+ <% if @sprint.can_cancel? %>
+ <%= link_to "Cancel this sprint...", cancel_sprint_path(@sprint),
+ :method => :put, :confirm => "Cancel this sprint? Are you sure?", :class => "command" %>
<% end %>
<% end %>
+ <% end %>
<% end %>
diff --git a/config/environment.rb b/config/environment.rb
index 464c8bf..3848e63 100644
--- a/config/environment.rb
+++ b/config/environment.rb
@@ -33,6 +33,7 @@ Rails::Initializer.run do |config|
:secret => 'bb5e22a8065951948272bccbeff6c1e1fa1eff29e85dabcefe7577b68795d9e42cfc9a18426f84aee6722bb4859bf8b144a98fa58891e6c1f215dc3b6f38d2b3'
}
+ config.gem 'state_machine'
config.gem 'rufus-scheduler'
config.gem "gruff"
config.gem "RedCloth"
diff --git a/config/routes.rb b/config/routes.rb
index 9738de7..1d4c143 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -48,9 +48,12 @@ ActionController::Routing::Routes.draw do |map|
map.resources :sprints, :member =>
{
+ :planning => :put,
+ :start => :put,
+ :complete => :put,
+ :cancel => :put,
:plan => :get,
:populate => :post,
- :status => :put,
:members => :get
}
diff --git a/db/migrate/011_create_sprints.rb b/db/migrate/011_create_sprints.rb
index 6222b1c..1c1b27b 100644
--- a/db/migrate/011_create_sprints.rb
+++ b/db/migrate/011_create_sprints.rb
@@ -20,9 +20,9 @@ class CreateSprints < ActiveRecord::Migration
t.integer :product_id, :null => false
t.integer :team_lead_id, :null => false
t.string :title, :null => false, :limit => 100
- t.date :start, :null => false
+ t.date :starts, :null => false
t.integer :duration, :null => false, :precision => 2, :scale => 0
- t.integer :status, :null => false, :default => Sprint::STATUS_PLANNED
+ t.string :state, :null => false, :default => "planning"
t.string :goals, :null => false, :limit => 1000
t.timestamps
diff --git a/doc/ChangeLog b/doc/ChangeLog
index da61547..1252467 100644
--- a/doc/ChangeLog
+++ b/doc/ChangeLog
@@ -12,6 +12,7 @@ Change Log (0.3.0):
* #182 - The member count on the sprint list goes to the member list page.
* #183 - Unown items have an accept link on the sprint backlog page.
* #184 - Only one planned sprint is allowed for a product.
+ * #185 - Only one active sprint is allowed for a product.
* #192 - Backlog items should not be reopened when the sprint is not active. (BUG)
* #193 - After writing an epic, the user should be returned to the epics list. (BUG)
* #195 - Product details page does not show number of members. (BUG)
diff --git a/test/fixtures/backlog_items.yml b/test/fixtures/backlog_items.yml
index 1b0cc23..f7b3c50 100644
--- a/test/fixtures/backlog_items.yml
+++ b/test/fixtures/backlog_items.yml
@@ -18,21 +18,21 @@ closed_backlog_item:
estimated_hours: 4.0
state: <%= BacklogItem::STATE_COMPLETED %>
-inactive_sprint_backlog_item:
- sprint_id: <%= Fixtures.identify(:inactive_sprint) %>
+planned_sprint_backlog_item:
+ sprint_id: <%= Fixtures.identify(:planned_sprint) %>
user_story_id: <%= Fixtures.identify(:create_update_profile) %>
estimated_hours: 3.5
state: <%= BacklogItem::STATE_PENDING %>
-owned_inactive_sprint_backlog_item:
- sprint_id: <%= Fixtures.identify(:inactive_sprint) %>
+owned_planned_sprint_backlog_item:
+ sprint_id: <%= Fixtures.identify(:planned_sprint) %>
user_story_id: <%= Fixtures.identify(:email_forgotten_password) %>
owner_id: <%= Fixtures.identify(:mcpierce) %>
estimated_hours: 3.5
state: <%= BacklogItem::STATE_PENDING %>
closed_sprint_backlog_item:
- sprint_id: <%= Fixtures.identify(:closed_sprint) %>
+ sprint_id: <%= Fixtures.identify(:completed_sprint) %>
user_story_id: <%= Fixtures.identify(:email_forgotten_password) %>
owner_id: <%= Fixtures.identify(:mcpierce) %>
estimated_hours: 4.5
diff --git a/test/fixtures/product_roles.yml b/test/fixtures/product_roles.yml
index 9078564..a5acfed 100644
--- a/test/fixtures/product_roles.yml
+++ b/test/fixtures/product_roles.yml
@@ -19,6 +19,13 @@ team_lead_projxp:
pending: false
is_approved: true
+team_lead_projxp_web_services:
+ product_id: <%= Fixtures.identify(:projxp_web_services) %>
+ user_id: <%= Fixtures.identify(:team_lead) %>
+ role_id: <%= Fixtures.identify(:developer) %>
+ pending: false
+ is_approved: true
+
verified_user_projxp:
product_id: <%= Fixtures.identify(:projxp_web) %>
user_id: <%= Fixtures.identify(:verified_user) %>
diff --git a/test/fixtures/products.yml b/test/fixtures/products.yml
index fae6dc4..18d457e 100644
--- a/test/fixtures/products.yml
+++ b/test/fixtures/products.yml
@@ -1,16 +1,22 @@
projxp_web:
project_id: <%= Fixtures.identify(:projxp) %>
owner_id: <%= Fixtures.identify(:projxp_owner) %>
- name: Cairo web application.
+ name: ProjXP web application
description: The Ruby on Rails application.
logo_name: projxp_web.png
projxp_web_services:
project_id: <%= Fixtures.identify(:projxp) %>
owner_id: <%= Fixtures.identify(:projxp_owner) %>
- name: Cairo web services.
+ name: ProjXP web services.
description: A set of web service APIs to interface with the RoR app.
+projxp_common_libraries:
+ project_id: <%= Fixtures.identify(:projxp) %>
+ owner_id: <%= Fixtures.identify(:projxp_owner) %>
+ name: ProjXP common libraries.
+ description: Shared components between all ProjXP elements.
+
teatime_midp:
project_id: <%= Fixtures.identify(:teatime) %>
owner_id: <%= Fixtures.identify(:teatime_owner) %>
diff --git a/test/fixtures/sprints.yml b/test/fixtures/sprints.yml
index 049d5f6..3af57e9 100644
--- a/test/fixtures/sprints.yml
+++ b/test/fixtures/sprints.yml
@@ -1,44 +1,71 @@
+planned_sprint:
+ product_id: <%= Fixtures.identify(:projxp_web) %>
+ title: Stuff we're going to do next.
+ starts: <%= DateTime.now.to_s(:db) %>
+ duration: 28
+ goals: Get things done.
+ state: planning
+ team_lead_id: <%= Fixtures.identify(:team_lead) %>
+
active_sprint:
product_id: <%= Fixtures.identify(:projxp_web) %>
title: Begin development for the web product.
- start: <%= DateTime.now.to_s(:db) %>
+ starts: <%= DateTime.now.to_s(:db) %>
duration: 28
goals: Get things done.
- status: <%= Sprint::STATUS_ACTIVE %>
+ state: active
+ team_lead_id: <%= Fixtures.identify(:team_lead) %>
+
+completed_sprint:
+ product_id: <%= Fixtures.identify(:projxp_web) %>
+ title: This sprint is completed.
+ starts: <%= DateTime.now.to_s(:db) %>
+ duration: 28
+ goals: Get more stuff done.
+ state: completed
team_lead_id: <%= Fixtures.identify(:team_lead) %>
-closed_sprint:
+cancelled_sprint:
product_id: <%= Fixtures.identify(:projxp_web) %>
- title: This sprint is closed.
- start: <%= DateTime.now.to_s(:db) %>
+ title: This sprint is cancelled.
+ starts: <%= DateTime.now.to_s(:db) %>
duration: 28
goals: Get more stuff done.
- status: <%= Sprint::STATUS_CLOSED %>
+ state: cancelled
team_lead_id: <%= Fixtures.identify(:team_lead) %>
-inactive_sprint:
+startable_sprint:
product_id: <%= Fixtures.identify(:projxp_web_services) %>
- title: This is the planned sprint.
- start: <%= (DateTime.now + 28).to_s(:db) %>
+ title: This sprint can be started.
+ starts: <%= DateTime.now.to_s(:db) %>
duration: 28
- goals: Get some web services written.
- status: <%= Sprint::STATUS_PLANNED %>
- team_lead_id: <%= Fixtures.identify(:mcpierce) %>
+ goals: Get some stuff done.
+ state: planning
+ team_lead_id: <%= Fixtures.identify(:team_lead) %>
-projxp_web_services_active_sprint:
+restartable_completed_sprint:
product_id: <%= Fixtures.identify(:projxp_web_services) %>
- title: This is the active sprint.
- start: <%= DateTime.now.to_s(:db) %>
+ title: This sprint can be started.
+ starts: <%= DateTime.now.to_s(:db) %>
duration: 28
- goals: Get some web services written.
- status: <%= Sprint::STATUS_ACTIVE %>
- team_lead_id: <%= Fixtures.identify(:mcpierce) %>
+ goals: Got some stuff done.
+ state: completed
+ team_lead_id: <%= Fixtures.identify(:team_lead) %>
-teatime_iphone_active_sprint:
- product_id: <%= Fixtures.identify(:teatime_iphone) %>
- title: This is an active sprint.
- start: <%= DateTime.now.to_s(:db) %>
- duration: 14
- goals: Get some iphone stuff done.
- status: <%= Sprint::STATUS_ACTIVE %>
- team_lead_id: <%= Fixtures.identify(:mcpierce) %>
+restartable_cancelled_sprint:
+ product_id: <%= Fixtures.identify(:projxp_web_services) %>
+ title: This sprint can be started.
+ starts: <%= DateTime.now.to_s(:db) %>
+ duration: 28
+ goals: Didn't get any stuff done.
+ state: cancelled
+ team_lead_id: <%= Fixtures.identify(:team_lead) %>
+
+plannable_sprint:
+ product_id: <%= Fixtures.identify(:projxp_common_libraries) %>
+ title: Stuff we're going to do next.
+ starts: <%= DateTime.now.to_s(:db) %>
+ duration: 28
+ goals: Get things done.
+ state: active
+ team_lead_id: <%= Fixtures.identify(:team_lead) %>
diff --git a/test/functional/items_controller_test.rb b/test/functional/items_controller_test.rb
index 3cb35ed..54eed34 100644
--- a/test/functional/items_controller_test.rb
+++ b/test/functional/items_controller_test.rb
@@ -44,13 +44,13 @@ class ItemsControllerTest < ActionController::TestCase
@non_team_lead = users(:jdonuts)
raise "Non-team lead cannot be the team lead!" if @team_lead.id == @non_team_lead.id
- @pending_sprint = sprints(:inactive_sprint)
- raise "Sprint should be pending!" unless @pending_sprint.pending?
+ @pending_sprint = sprints(:planned_sprint)
+ raise "Sprint should be pending!" unless @pending_sprint.planning?
- @pending_item = backlog_items(:inactive_sprint_backlog_item)
+ @pending_item = backlog_items(:planned_sprint_backlog_item)
raise "Pending item should be on a pending sprint." unless @pending_item.sprint_id == @pending_sprint.id
- @other_sprint = sprints(:inactive_sprint)
+ @other_sprint = sprints(:planned_sprint)
raise "Product and sprint are mismatched!" unless @product.id == @sprint.product_id
@item = backlog_items(:owned_backlog_item)
@@ -242,7 +242,7 @@ class ItemsControllerTest < ActionController::TestCase
end
# Ensures that the sprint must be active for an estimate to be updated.
- def test_estimate_on_inactive_sprint
+ def test_estimate_on_planned_sprint
post :estimate, {:id => @pending_item.id}, {:user_id => @item_owner.id}
assert_redirected_to error_path
diff --git a/test/functional/report_controller_test.rb b/test/functional/report_controller_test.rb
index be5ad3e..ed52087 100644
--- a/test/functional/report_controller_test.rb
+++ b/test/functional/report_controller_test.rb
@@ -22,7 +22,7 @@ class ReportControllerTest < ActionController::TestCase
fixtures :users
def setup
- @pending_sprint = sprints(:inactive_sprint)
+ @planned_sprint = sprints(:planned_sprint)
@sprint = sprints(:active_sprint)
@user = users(:mcpierce)
end
@@ -35,12 +35,12 @@ class ReportControllerTest < ActionController::TestCase
assert_redirected_to error_url
end
- # Ensures that trying to print a burndown report for a pending sprint fails.
+ # Ensures that trying to print a burndown report for a planned sprint fails.
#
- def test_burndown_graphic_fails_for_pending_sprint
- get :burndown_graphic, {:id => @pending_sprint.id}
+ def test_burndown_graphic_fails_for_planned_sprint
+ get :burndown_graphic, {:id => @planned_sprint.id}
- assert_redirected_to sprint_path(@pending_sprint)
+ assert_redirected_to sprint_path(@planned_sprint)
end
# Ensures that a burndown chart works as expected.
diff --git a/test/functional/sprints_controller_test.rb b/test/functional/sprints_controller_test.rb
index 3802af0..3247a5f 100644
--- a/test/functional/sprints_controller_test.rb
+++ b/test/functional/sprints_controller_test.rb
@@ -25,13 +25,21 @@ class SprintsControllerTest < ActionController::TestCase
def setup
@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?
+
+ @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
- @pending_sprint = sprints(:inactive_sprint)
- raise "Sprint must be pending!" unless @pending_sprint.pending?
- raise "Products must have the same owner!" unless @pending_sprint.product.owner_id == @active_sprint.product.owner_id
+ @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
+
+ @startable_sprint = sprints(:startable_sprint)
+ raise "Sprint must be in the planned start!" unless @startable_sprint.planning?
+ raise "Product cannot have an active sprint!" unless Sprint.for_product((a)startable_sprint).active.empty?
@product = @active_sprint.product
@other_product = products(:projxp_web_services)
@@ -52,12 +60,15 @@ class SprintsControllerTest < ActionController::TestCase
raise "Product cannot have anys sprints!" unless @product_with_no_sprints.sprints.empty?
raise "Member must be on product team!" unless @product_with_no_sprints.is_member?(@member)
- raise "Nonowner is the product owner!" if @pending_sprint.product.owner_id == @nonowner.id
- raise "Nonwner is the team lead!" if @pending_sprint.team_lead_id == @nonowner.id
+ raise "Nonowner is the product owner!" if @planned_sprint.product.owner_id == @nonowner.id
+ raise "Nonwner is the team lead!" if @planned_sprint.team_lead_id == @nonowner.id
+
+ @non_team_lead = users(:mcpierce)
+ raise "Non-team lead cannot be a team lead!" if [@active_sprint.id, @plannable_sprint.id].include? @non_team_lead.id
@new_sprint = {
:title => "New Sprint",
- :start => Date.today,
+ :starts => Date.today,
:duration => 28,
:goals => "Get lots of things done!",
:team_lead_id => @product.owner_id
@@ -309,6 +320,138 @@ class SprintsControllerTest < ActionController::TestCase
"All backlog items should have been deleted for this sprint."
end
+ # Ensures that an anonymous user cannot move sprints to the planning state.
+ def test_planning_as_anonymous
+ put :planning
+
+ assert_redirected_to login_path
+ end
+
+ # Ensures that a valid sprint is required.
+ def test_planning_with_invalid_sprint
+ put :planning, {}, {:user_id => @plannable_sprint.team_lead_id}
+
+ assert_redirected_to error_path
+ end
+
+ # Ensures that only team leads can move a sprint to the planning state.
+ def test_planning_as_non_team_lead
+ put :planning, {:id => @plannable_sprint.id}, {:user_id => @non_team_lead.id}
+ end
+
+ # Ensures that a sprint that cannot be planned fails.
+ def test_planning_with_state_failure
+ put :planning, {:id => @active_sprint.id}, {:user_id => @active_sprint.team_lead_id}
+
+ assert_redirected_to error_path
+ result = Sprint.find_by_id((a)active_sprint.id)
+ assert !result.planning?, "Sprint should not have been put into the planning state."
+ end
+
+ # Ensures that a sprint can be moved to the planned state.
+ def test_planning
+ put :planning, {:id => @plannable_sprint.id}, {:user_id => @plannable_sprint.team_lead_id}
+
+ assert_redirected_to sprint_path(@plannable_sprint)
+ result = Sprint.find_by_id((a)plannable_sprint.id)
+ assert result.planning?, "Sprint should have been put into the planning state."
+ end
+
+ # Ensures that anonymous users can't start a sprint.
+ def test_start_as_anonymous
+ put :start
+
+ assert_redirected_to login_path
+ end
+
+ # Ensures that a valid sprint id is required.
+ def test_start_with_invalid_id
+ put :start, {}, {:user_id => @planned_sprint.team_lead_id}
+
+ assert_redirected_to error_path
+ end
+
+ # Ensures that only the team lead can start a sprint.
+ def test_start_as_non_team_lead
+ put :start, {:id => @planned_sprint.id}, {:user_id => @non_team_lead.id }
+
+ assert_redirected_to error_path
+ end
+
+ # Ensures that a sprint that can't be started results in an error.
+ def test_start_with_nonstarter_sprint
+ put :start, {:id => @plannable_sprint.id },{:user_id => @plannable_sprint.team_lead_id}
+
+ assert_redirected_to error_path
+ end
+
+ # Ensures that starting a sprint work.s
+ def test_start
+ put :start, {:id => @startable_sprint.id}, {:user_id => @startable_sprint.team_lead_id}
+
+ assert_redirected_to sprint_path(@startable_sprint)
+ result = Sprint.find_by_id((a)startable_sprint.id)
+ assert result.active?, "Sprint should have been started."
+ end
+
+ # Ensures that an anonymous user cannot complete a sprint.
+ def test_complete_as_anonymous
+ put :complete
+
+ assert_redirected_to login_path
+ end
+
+ # Ensures that a valid sprint id is required.
+ def test_complete_with_invalid_sprint
+ put :complete, {}, {:user_id => @active_sprint.team_lead_id}
+
+ assert_redirected_to error_path
+ end
+
+ # Ensures that only the team lead can complete a sprint.
+ def test_complete_as_non_team_lead
+ put :complete, {:id => @active_sprint.id}, {:user_id => @non_team_lead.id}
+
+ assert_redirected_to error_path
+ end
+
+ # Ensures that a sprint that cannot be completed results in an error.
+ def test_complete_for_noncompletable_sprint
+ put :complete, {:id => @planned_sprint.id}, {:user_id => @planned_sprint.team_lead_id}
+
+ assert_redirected_to error_path
+ end
+
+ # Ensures that a sprint can be completed.
+ def test_complete
+ put :complete, {:id => @active_sprint.id}, {:user_id => @active_sprint.team_lead_id}
+
+ assert_redirected_to sprint_path(@active_sprint)
+ result = Sprint.find_by_id((a)active_sprint.id)
+ assert result.completed?, "Sprint was not moved to the completed state."
+ end
+
+ # Ensures that anonymous users can't cancel a sprint.
+ def test_cancel_as_anonymous
+ put :cancel
+
+ assert_redirected_to login_path
+ end
+
+ # Ensures that a valid sprint id is required.
+ def test_cancel_with_invalid_sprint
+ put :cancel, {}, {:user_id => @active_sprint.team_lead_id}
+
+ assert_redirected_to error_path
+ end
+
+ # Ensures that only the team lead can cancel a sprint.
+ def test_cancel_as_non_team_lead
+ put :cancel, {:id => @active_sprint.id}, {:user_id => @non_team_lead.id}
+
+ assert_redirected_to error_path
+ end
+
# Ensures that anonymous users can't plan.
def test_plan_as_anonymous
get :plan
@@ -316,6 +459,15 @@ class SprintsControllerTest < ActionController::TestCase
assert_redirected_to login_path
end
+ # Ensures that cancelling a sprint works.
+ def test_cancel
+ put :cancel, {:id => @active_sprint.id}, {:user_id => @active_sprint.team_lead_id}
+
+ assert_redirected_to sprint_path(@active_sprint)
+ result = Sprint.find_by_id((a)active_sprint.id)
+ assert result.cancelled?, "Sprint should have been marked cancelled."
+ end
+
# Ensures that a valid sprint is required.
def test_plan_with_invalid_sprint
get :plan, { }, {:user_id => @product.owner_id}
@@ -332,14 +484,14 @@ class SprintsControllerTest < ActionController::TestCase
# Ensures that only the owner can plan a sprint.
def test_plan_as_nonowner
- get :plan, {:id => @pending_sprint.id}, {:user_id => @nonowner.id}
+ get :plan, {:id => @planned_sprint.id}, {:user_id => @nonowner.id}
assert_redirected_to error_path
end
# Ensures that planning works.
def test_plan
- get :plan, { :id => @pending_sprint.id}, {:user_id => @pending_sprint.product.owner_id}
+ get :plan, { :id => @planned_sprint.id}, {:user_id => @planned_sprint.product.owner_id}
assert_response :success
assert assigns['user_stories'], "Failed to load any user stories."
@@ -370,83 +522,31 @@ class SprintsControllerTest < ActionController::TestCase
# Ensures that only the owner can populate a sprint.
def test_populate_as_nonowner
- post :populate, { :id => @pending_sprint.id, :estimates => @estimates, :selected => @selected},
+ post :populate, { :id => @planned_sprint.id, :estimates => @estimates, :selected => @selected},
{:user_id => @nonowner.id}
assert_redirected_to error_path
- assert !BacklogItem.find_by_sprint_id_and_user_story_id(@pending_sprint.id,(a)user_story.id),
+ assert !BacklogItem.find_by_sprint_id_and_user_story_id(@planned_sprint.id,(a)user_story.id),
"Backlog item should not have been created"
end
# Ensures that populating with no user stories is not an error. BUG#92
def test_populate_with_no_user_stories
- post :populate, { :id => @pending_sprint.id},{:user_id => @pending_sprint.product.owner_id}
+ post :populate, { :id => @planned_sprint.id},{:user_id => @planned_sprint.product.owner_id}
- assert_redirected_to sprint_path(@pending_sprint)
+ assert_redirected_to sprint_path(@planned_sprint)
end
# Ensures that populating works.
def test_populate
- post :populate, {:id => @pending_sprint.id,:estimates => @estimates, :selected => @selected},
- {:user_id => @pending_sprint.product.owner_id}
+ post :populate, {:id => @planned_sprint.id,:estimates => @estimates, :selected => @selected},
+ {:user_id => @planned_sprint.product.owner_id}
- assert_redirected_to sprint_path(@pending_sprint)
- assert BacklogItem.find_by_sprint_id_and_user_story_id(@pending_sprint.id,(a)user_story.id),
+ assert_redirected_to sprint_path(@planned_sprint)
+ assert BacklogItem.find_by_sprint_id_and_user_story_id(@planned_sprint.id,(a)user_story.id),
"Backlog item should have been created"
end
- # Ensures that an anonymous user can't change a sprint's status.
- def test_status_as_anonymous
- put :status
-
- assert_redirected_to login_path
- end
-
- # Ensures that a valid sprint is required.
- def test_status_with_invalid_sprint
- put :status, { }, {:user_id => @product.owner_id}
-
- assert_redirected_to error_path
- end
-
- # Ensures that non-product owners can't alter a sprint's status.
- def test_status_as_nonowner
- put :status, { :id => @active_sprint.id}, {:user_id => @nonowner.id}
-
- assert_redirected_to error_path
- end
-
- # Ensures that a status must be presented.
- def test_status_without_state
- put :status, { :id => @active_sprint.id}, {:user_id => @active_sprint.product.owner_id}
-
- assert_redirected_to error_path
- end
-
- # Ensures that if a status is not allowed then the sprint's not updated.
- def test_status_with_disallowed_status
- put :status, {:id => @active_sprint.id, :status => Sprint::STATUS_PLANNED}, {:user_id => @active_sprint.product.owner_id}
-
- assert_redirected_to error_path
- assert_equal @active_sprint.status, Sprint.find_by_id((a)active_sprint.id).status,
- "Sprint status should not have been updated."
- end
-
- # Ensures that a status can be updated as allowed.
- def test_status
- put :status, { :id => @active_sprint.id, :status => Sprint::STATUS_CLOSED}, {:user_id => @active_sprint.product.owner_id}
-
- assert_redirected_to sprint_path(@active_sprint)
- result = Sprint.find_by_id((a)active_sprint.id)
- assert result.closed?, "Sprint status was not updated."
- # ensure all backlog items were closed
- result.backlog_items.each do |item|
- if item.completed?
- assert item.user_story.closed?, "User story should have been closed."
- end
- end
- end
-
# Ensures that a valid product is required.
def test_members_with_invalid_product
get :members
diff --git a/test/functional/user_items_test.rb b/test/functional/user_items_test.rb
index d49bd44..f7f4da9 100644
--- a/test/functional/user_items_test.rb
+++ b/test/functional/user_items_test.rb
@@ -40,13 +40,13 @@ class UserItemsTest < ActionController::TestCase
@sprint = sprints(:active_sprint)
raise "Product and sprint are mismatched!" unless @product.id == @sprint.product_id
- @pending_sprint = sprints(:inactive_sprint)
- raise "Sprint should be pending!" unless @pending_sprint.pending?
+ @pending_sprint = sprints(:planned_sprint)
+ raise "Sprint should be pending!" unless @pending_sprint.planning?
- @pending_item = backlog_items(:inactive_sprint_backlog_item)
+ @pending_item = backlog_items(:planned_sprint_backlog_item)
raise "Pending item should be on a pending sprint." unless @pending_item.sprint_id == @pending_sprint.id
- @other_sprint = sprints(:inactive_sprint)
+ @other_sprint = sprints(:planned_sprint)
raise "Product and sprint are mismatched!" unless @product.id == @sprint.product_id
@item = backlog_items(:owned_backlog_item)
@@ -113,7 +113,7 @@ class UserItemsTest < ActionController::TestCase
end
# Ensures that only active sprint items can be accepted.
- def test_accept_from_inactive
+ def test_accept_from_planned
get :accept, {:id => @pending_item.id}, {:user_id => @member.id}
assert_redirected_to error_path
@@ -148,7 +148,7 @@ class UserItemsTest < ActionController::TestCase
end
# Ensures that only active sprint items can be dropped.
- def test_drop_from_inactive
+ def test_drop_from_planned
get :accept, {:id => @pending_item.id},{:user_id => @member.id}
assert_redirected_to error_path
@@ -183,7 +183,7 @@ class UserItemsTest < ActionController::TestCase
end
# Ensures that only active sprint items can be completed.
- def test_complete_from_inactive
+ def test_complete_from_planned
get :complete, {:id => @pending_item.id},{:user_id => @member.id}
assert_redirected_to error_path
@@ -225,7 +225,7 @@ class UserItemsTest < ActionController::TestCase
end
# Ensures that only active sprint items can be reopened.
- def test_reopen_from_inactive
+ def test_reopen_from_planned
get :reopen,{:id => @pending_item.id},{:user_id => @member.id}
assert_redirected_to error_path
diff --git a/test/unit/backlog_item_test.rb b/test/unit/backlog_item_test.rb
index bb4f65c..3df3636 100644
--- a/test/unit/backlog_item_test.rb
+++ b/test/unit/backlog_item_test.rb
@@ -54,13 +54,13 @@ class BacklogItemTest < ActiveSupport::TestCase
raise "Nonmember must be on the product team!" unless @product.is_member?(@nonmember)
raise "Nonmember cannot be the team lead!" if @sprint.team_lead_id == @nonmember.id
- @item_on_inactive_sprint = backlog_items(:inactive_sprint_backlog_item)
- raise "Item should not have an owner!" if @item_on_inactive_sprint.owner
- raise "That sprint must no be active!" if @item_on_inactive_sprint.sprint.active?
+ @item_on_planned_sprint = backlog_items(:planned_sprint_backlog_item)
+ raise "Item should not have an owner!" if @item_on_planned_sprint.owner
+ raise "That sprint must no be active!" if @item_on_planned_sprint.sprint.active?
@item_on_closed_sprint = backlog_items(:closed_sprint_backlog_item)
raise "Item must have an owner!" unless @item_on_closed_sprint.owner
- raise "That sprint must be closed!" unless @item_on_closed_sprint.sprint.closed?
+ raise "That sprint must be closed!" unless @item_on_closed_sprint.sprint.completed?
@deferrable_item = backlog_items(:owned_backlog_item)
end
@@ -118,9 +118,9 @@ class BacklogItemTest < ActiveSupport::TestCase
end
# Ensures that a user cannot accept an item on a non-active sprint.
- def test_accept_on_item_on_inactive_sprint
- assert !@item_on_inactive_sprint.can_accept?((a)owner),
- "Users must not be allowed to accept items on inactive sprints."
+ def test_accept_on_item_on_planned_sprint
+ assert !@item_on_planned_sprint.can_accept?((a)owner),
+ "Users must not be allowed to accept items on planned sprints."
end
# Ensures that a user cannot accept an already accepted backlog item.
@@ -172,7 +172,7 @@ class BacklogItemTest < ActiveSupport::TestCase
end
# Ensures that an item can only be reopened on an active sprint.
- def test_can_reopen_on_inactive_sprint
+ def test_can_reopen_on_planned_sprint
fail "You cannot reopen an item on a closed sprint." if @item_on_closed_sprint.can_reopen?((a)item_on_closed_sprint.owner)
end
@@ -187,7 +187,7 @@ class BacklogItemTest < ActiveSupport::TestCase
end
# Ensures that an item must be on an active sprint to be marked as blocked.
- def test_mark_blocked_for_inactive_sprint
+ def test_mark_blocked_for_planned_sprint
fail "Only items on active sprints can be marked as blocked." if @item_on_closed_sprint.can_mark_blocked?((a)item_on_closed_sprint.owner)
end
@@ -202,8 +202,8 @@ class BacklogItemTest < ActiveSupport::TestCase
end
# Ensures that an item not on an active sprint cannot be deferred.
- def test_defer_for_inactive_sprint_item
- fail "Defer is only for active sprints." if @item_on_inactive_sprint.can_defer?((a)item_on_inactive_sprint.sprint.team_lead)
+ def test_defer_for_planned_sprint_item
+ fail "Defer is only for active sprints." if @item_on_planned_sprint.can_defer?((a)item_on_planned_sprint.sprint.team_lead)
end
# Ensures that an owned item can be deferred
diff --git a/test/unit/sprint_member_test.rb b/test/unit/sprint_member_test.rb
index da4e3bc..fbefba7 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(:inactive_sprint)
+ @sprint = sprints(:planned_sprint)
@team_lead = @sprint.team_lead
@member = users(:mcpierce)
diff --git a/test/unit/sprint_test.rb b/test/unit/sprint_test.rb
index c01f1a0..cb8eeba 100644
--- a/test/unit/sprint_test.rb
+++ b/test/unit/sprint_test.rb
@@ -18,158 +18,155 @@
require File.dirname(__FILE__) + '/../test_helper'
class SprintTest < ActiveSupport::TestCase
- fixtures :sprints
- fixtures :products
- fixtures :users
-
def setup
- @product = products(:projxp_web)
- @new_sprint = Sprint.new(:product_id => @product.id,
- :title => 'Sprint1',
- :start => Date.today - 7,
- :duration => 14,
- :goals => "Things I'd like to accomplish.",
- :team_lead_id => @product.owner.id)
- @developer = users(:mcpierce)
- raise "Developer is not a member of the product team." unless @product.is_member?(@developer)
-
- @sprint = sprints(:active_sprint)
- @active_sprint = sprints(:projxp_web_services_active_sprint)
- raise "There needs to be a planned sprint!" if Sprint.for_product((a)active_sprint.product).planned.empty?
-
- backlog_item = BacklogItem.new(:estimated_hours => 100)
- backlog_item.tasks << Task.new(:hours => 50)
- backlog_item.update_remaining_hours(50.0,@developer)
- @healthy_backlog = [ backlog_item ]
-
- backlog_item = BacklogItem.new(:estimated_hours => 100)
- backlog_item.tasks << Task.new(:hours => 25)
- @unhealthy_backlog = [ backlog_item ]
-
- @existing_sprint = sprints(:active_sprint)
- @team_lead = @existing_sprint.team_lead
- @owner = @product.owner
- raise "Team lead and product owner cannot be the same user!" if @team_lead.id == @owner.id
-
- @closed_sprint = sprints(:closed_sprint)
- raise "Sprint must be closed!" unless @closed_sprint.closed?
-
- @active_sprint_only = sprints(:teatime_iphone_active_sprint)
- raise "Sprint must be in the active state!" unless @active_sprint_only.active?
- raise "Product cannot have a planned sprint!" unless Sprint.for_product((a)active_sprint_only).planned.empty?
- end
-
- # Ensures that a sprint has to have a product.
- def test_valid_fails_without_product
+ @new_sprint = Sprint.new(:product => products(:projxp_web),
+ :title => "Inaugural sprint",
+ :starts => Date.today,
+ :duration => 14,
+ :goals => "To get stuff done!",
+ :team_lead => users(:mcpierce)
+ )
+ @planned_sprint = sprints(:planned_sprint)
+ raise "Sprint must be in planning!" unless @planned_sprint.planning?
+ raise "Product must have an active sprint!" if Sprint.for_product((a)planned_sprint.product).active.empty?
+
+ @active_sprint = sprints(:active_sprint)
+ raise "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?
+
+ @completed_sprint = sprints(:completed_sprint)
+ raise "Sprint must be completed!" unless @completed_sprint.completed?
+ raise "Product must have an active sprint!" if Sprint.for_product((a)completed_sprint.product).active.empty?
+
+ @cancelled_sprint = sprints(:cancelled_sprint)
+ raise "Sprint must be cancelled!" unless @cancelled_sprint.cancelled?
+
+ @startable_sprint = sprints(:startable_sprint)
+ raise "Sprint must be planned." unless @startable_sprint.planning?
+ raise "Product must not have an active sprint." unless Sprint.for_product((a)startable_sprint).active.empty?
+
+ @restartable_completed_sprint = sprints(:restartable_completed_sprint)
+ raise "Sprint must be completed!" unless @restartable_completed_sprint.completed?
+ raise "Product must not have an active sprint." unless Sprint.for_product((a)restartable_completed_sprint.product).active.empty?
+
+ @restartable_cancelled_sprint = sprints(:restartable_cancelled_sprint)
+ raise "Sprint must be cancelled!" unless @restartable_cancelled_sprint.cancelled?
+ raise "Product must not have an active sprint." unless Sprint.for_product((a)restartable_cancelled_sprint.product).active.empty?
+ end
+
+ # Ensures a product id is required.
+ def test_valid_fails_without_product_id
@new_sprint.product_id = nil
-
- flunk 'A sprint must have a product.' if @new_sprint.valid?
+ flunk "A sprint must require a product." if @new_sprint.valid?
end
- # Ensures that a sprint has to have a title.
+ # Ensures a title is required.
def test_valid_fails_without_title
@new_sprint.title = nil
-
- flunk 'A sprint must have a title.' if @new_sprint.valid?
+ flunk "A sprint must require a title." if @new_sprint.valid?
end
- # Ensures that a sprint has to have a start date.
- def test_valid_fails_without_start
- @new_sprint.start = nil
+ # Ensures a title cannot be an empty string.
+ def test_valid_fails_for_empty_title
+ @new_sprint.title = ''
+ flunk "A sprint title cannot be empty." if @new_sprint.valid?
+ end
- flunk 'A sprint must have a start date.' if @new_sprint.valid?
+ # Ensures a start date is required.
+ def test_valid_fails_without_start_date
+ @new_sprint.starts = nil
+ flunk "A sprint must have a start date." if @new_sprint.valid?
end
- # Ensures that a sprint has a defined duration.
+ # Ensures a duration is required.
def test_valid_fails_without_duration
@new_sprint.duration = nil
+ flunk "A sprint must have a duration." if @new_sprint.valid?
+ end
- flunk 'A sprint must have a duration.' if @new_sprint.valid?
-
- @new_sprint.duration = 'a'
-
- flunk 'A sprint must have a numeric duration.' if @new_sprint.valid?
+ # Ensures a duration must be a positive integer.
+ def test_valid_fails_with_negative_duration
+ @new_sprint.duration = -1
+ flunk "A sprint must have a positive duration." if @new_sprint.valid?
end
- # Ensures that a sprint has goals.
+ # Ensures a sprint requires set of goals.
def test_valid_fails_without_goals
- @new_sprint.goals = ''
+ @new_sprint.goals = nil
+ flunk "A sprint must have goals." if @new_sprint.valid?
+ end
- flunk 'A sprint must have goals.' if @new_sprint.valid?
+ # Ensures that goals must have content.
+ def test_valid_fails_with_empty_goals
+ @new_sprint.goals = ''
+ flunk "A sprint must define goals." if @new_sprint.valid?
end
- # Ensures that a sprint must have a team lead.
- def test_valid_fails_without_team_lead
+ # Ensures that a team lead is defined.
+ def test_valid_fails_without_team_lead_id
@new_sprint.team_lead_id = nil
-
- flunk 'A sprint must have a team lead.' if @new_sprint.valid?
+ flunk "A sprint must have a team lead." if @new_sprint.valid?
end
- # Ensures that an unhealthy sprint returns the appropriate value.
- def test_health_for_unhealthy_sprint
- @new_sprint.backlog_items = @unhealthy_backlog
-
- assert_equal false, @new_sprint.healthy?, 'Sprint should not be considered healthy.'
+ # Ensures that a well-formed sprint is valid.
+ def test_valid
+ unless @new_sprint.valid?
+ puts @new_sprint.errors.full_messages
+ flunk "There is a general validation error."
+ end
end
- # Ensures that a healthy sprint returns the appropriate value.
- def test_health_for_healthy_sprint
- @new_sprint.backlog_items = @healthy_backlog
-
- assert @new_sprint.healthy?, 'Sprint should be considered healthy.'
+ # Ensures that nobody can edit a completed sprint.
+ def test_can_edit_with_completed_sprint
+ flunk "Completed sprints must not be edited by the team lead." if @completed_sprint.can_edit?((a)completed_sprint.team_lead)
+ flunk "Completed sprints must not be edited by the product owner." if @completed_sprint.can_edit?((a)completed_sprint.product.owner)
end
- # Ensures the estimated hours for a sprint are accurate.
- def test_estimated_hours
- assert_equal 17.5,
- @existing_sprint.estimated_hours,
- 'The estimated hours are wrong.'
+ # Ensures that nobody can edit a cancelled sprint.
+ def test_can_edit_with_cancelled_sprint
+ flunk "Cancelled sprints must not be edited by the team lead." if @cancelled_sprint.can_edit?((a)cancelled_sprint.team_lead)
+ flunk "Cancelled sprints must not be edited by the product owner." if @cancelled_sprint.can_edit?((a)cancelled_sprint.product.owner)
end
- # Ensures the actual hours for a sprint are accurate.
- def test_actual_hours
- assert_equal 11.5,
- @existing_sprint.actual_hours,
- 'The actual hours are wrong.'
+ # Ensures the team lead can edit an active sprint.
+ def test_can_edit_as_team_lead
+ flunk "Team lead must be able to edit an active sprint." unless @active_sprint.can_edit?((a)active_sprint.team_lead)
end
- # Ensures the remaining hours are correct.
- def test_remaining_hours
- assert_equal 13.0,
- @existing_sprint.remaining_hours.to_f,
- 'The remaining hours are wrong.'
+ # Ensures that only one planned sprint is allowed.
+ def test_plan_fails_if_there_is_a_planned_sprint
+ flunk "There's already a planning sprint." if @active_sprint.can_plan?
end
- # Ensures the end date calculation is accurate.
- def test_end_date
- # the sprint was set at 14 days, starting 7 days ago, so the end date should
- # be 6 days from now
- assert_equal @new_sprint.start + (@new_sprint.duration - 1), @new_sprint.end_date,
- "End date calculation is wrong."
+ # Ensures that only one sprint may be active.
+ def test_start_fails_if_there_is_an_active_sprint
+ flunk "There's already an active sprint." if @planned_sprint.can_start?
+ flunk "There's already an active sprint." if @completed_sprint.can_restart?
+ flunk "There's already an active sprint." if @cancelled_sprint.can_restart?
end
- # Ensures that a team lead can edit a sprint.
- def test_edit_as_team_lead
- flunk "Team leads must be allowed to edit sprints." unless @existing_sprint.can_edit?(@team_lead)
+ # Ensures that a planned sprint can be started.
+ def test_start
+ flunk "A sprint must be startable." unless @startable_sprint.can_start?
end
- # Ensures that a product owner can edit a sprint.
- def test_edit_as_product_owner
- flunk "Product owners must be allowed to edit sprints." unless @existing_sprint.can_edit?(@owner)
+ # Ensures that an active sprint can be cancelled.
+ def test_cancel
+ flunk "A sprint must be cancellable." unless @active_sprint.can_cancel?
end
- # Ensures that a sprint that a sprint that's not active cannot add a backlog item.
- def test_can_add_backlog_items_for_inactive_sprint
- flunk "Inactive sprints cannot add backlog items." if @closed_sprint.can_add_backlog_items?((a)closed_sprint.team_lead)
+ # Ensures that an active sprint can be completed.
+ def test_complete
+ flunk "A sprint must be completable." unless @active_sprint.can_complete?
end
- # Ensures that a sprint cannot be moved back to planned state if a planned sprint exists.
- def test_allowed_state_planned_with_planned_sprint_planned
- flunk "Product cannot have two planned sprints." if @active_sprint.allowed_status?(Sprint::STATUS_PLANNED)
+ # Ensures that a completed sprint can be restarted.
+ def test_restart_for_completed
+ flunk "A completed sprint must be restartable." unless @restartable_completed_sprint.can_restart?
end
- # Ensures that a sprint can be moved back to the planned state.
- def test_allowed_state_planned
- flunk "A sprint can move back to the planned state." unless @active_sprint_only.allowed_status?(Sprint::STATUS_PLANNED)
+ # Ensures that a cancelled sprint can be restarted.
+ def test_restart_for_cancelled
+ flunk "A cancelled sprint must be restartable." unless @restartable_cancelled_sprint.can_restart?
end
end
diff --git a/test/unit/task_test.rb b/test/unit/task_test.rb
index 4afb061..39e1d2d 100644
--- a/test/unit/task_test.rb
+++ b/test/unit/task_test.rb
@@ -28,7 +28,7 @@ class TaskTest < ActiveSupport::TestCase
:primary_id => @backlog_item.owner_id,
:description => 'This is a task',
:hours => 5,
- :when_entered => @backlog_item.sprint.start + (@backlog_item.sprint.duration / 2))
+ :when_entered => @backlog_item.sprint.starts + (@backlog_item.sprint.duration / 2))
@new_task.backlog_item = @backlog_item
@backup = users(:jdonuts)
@@ -93,14 +93,14 @@ class TaskTest < ActiveSupport::TestCase
# Ensures that the date for a task can't be before the sprint starts.
def test_valid_fails_when_date_preceeds_sprint
- @new_task.when_entered = @backlog_item.sprint.start - 1
+ @new_task.when_entered = @backlog_item.sprint.starts - 1
flunk "Date cannot be before the sprint starts." if @new_task.valid?
end
# Ensures that the date for a task can't be after the sprint ends.
def test_valid_fail_when_date_exceeds_sprint
- @new_task.when_entered = @backlog_item.sprint.start + @backlog_item.sprint.duration
+ @new_task.when_entered = @backlog_item.sprint.starts + @backlog_item.sprint.duration
flunk "Date cannot be after the sprint ends." if @new_task.valid?
end
--
1.6.0.6
15 years
[PATCH] Only one active sprint is allowed per product. #185
by Darryl L. Pierce
Added checks to ensure that only one active sprint is allowed for a
product. If an active sprint already exists then other sprints are
disallowed from going into the active state.
Signed-off-by: Darryl L. Pierce <mcpierce(a)gmail.com>
---
app/models/sprint.rb | 32 ++++++++++++++++------------
app/views/sprints/show.html.erb | 4 +-
public/stylesheets/tables.css | 2 +-
test/fixtures/sprints.yml | 18 ++++++++++++++++
test/unit/sprint_test.rb | 43 ++++++++++++++++++++++++++++++++++++++-
5 files changed, 81 insertions(+), 18 deletions(-)
diff --git a/app/models/sprint.rb b/app/models/sprint.rb
index f6d808f..a185f93 100644
--- a/app/models/sprint.rb
+++ b/app/models/sprint.rb
@@ -37,16 +37,16 @@
# sprint is considered healthy.
#
class Sprint < ActiveRecord::Base
- STATUS_PLANNED = 0
- STATUS_ACTIVE = 1
- STATUS_CLOSED = 2
- STATUS_CANCELED = 3
+ STATUS_PLANNED = 0
+ STATUS_ACTIVE = 1
+ STATUS_CLOSED = 2
+ STATUS_CANCELLED = 3
STATUS_TEXT =
{
- 'Planned' => STATUS_PLANNED,
- 'Active' => STATUS_ACTIVE,
- 'Closed' => STATUS_CLOSED,
- 'Canceled' => STATUS_CANCELED
+ 'Planned' => STATUS_PLANNED,
+ 'Active' => STATUS_ACTIVE,
+ 'Closed' => STATUS_CLOSED,
+ 'Cancelled' => STATUS_CANCELLED
}.sort_by { |k,v| v }
validates_presence_of :product_id,
@@ -90,6 +90,7 @@ class Sprint < ActiveRecord::Base
{ :conditions => product_id ? ["product_id = ?", product_id]: []}
}
named_scope :planned, {:conditions => ['status = ?', STATUS_PLANNED]}
+ named_scope :active, {:conditions => ['status = ?', STATUS_ACTIVE]}
# Returns the text for the status.
def status_text
@@ -148,14 +149,12 @@ class Sprint < ActiveRecord::Base
# Returns whether the sprint can be moved to the given status.
def allowed_status?(status)
case self.status
- when STATUS_PLANNED:
- return true if status == STATUS_ACTIVE
+ when STATUS_PLANNED: return status == STATUS_ACTIVE && Sprint.for_product(product).active.empty?
+ when STATUS_CLOSED: return status == STATUS_ACTIVE && Sprint.for_product(product).active.empty?
+ when STATUS_CANCELLED: return status == STATUS_ACTIVE && Sprint.for_product(product).active.empty?
when STATUS_ACTIVE:
return true if (status == STATUS_PLANNED && actual_hours == 0 && Sprint.for_product(product).planned.empty?)
- return true if [STATUS_CANCELED, STATUS_CLOSED].include?(status)
-
- when STATUS_CLOSED: return true if status == STATUS_ACTIVE
- when STATUS_CANCELED: return true if status == STATUS_ACTIVE
+ return true if [STATUS_CANCELLED, STATUS_CLOSED].include?(status)
end
return false
@@ -176,6 +175,11 @@ class Sprint < ActiveRecord::Base
status == STATUS_CLOSED
end
+ # Returns whether the sprint is cancelled.
+ def cancelled?
+ status == STATUS_CANCELLED
+ end
+
# Returns whether a burndown chart can be viewed for this sprint.
def can_view_burndown?
status != STATUS_PLANNED
diff --git a/app/views/sprints/show.html.erb b/app/views/sprints/show.html.erb
index ce58061..51e3380 100644
--- a/app/views/sprints/show.html.erb
+++ b/app/views/sprints/show.html.erb
@@ -62,9 +62,9 @@
:class => "command" %>
<% end %>
- <% if @sprint.allowed_status?(Sprint::STATUS_CANCELED) %>
+ <% if @sprint.allowed_status?(Sprint::STATUS_CANCELLED) %>
<%= link_to "Cancel this sprint...",
- status_sprint_path(@sprint, :status => Sprint::STATUS_CANCELED),
+ status_sprint_path(@sprint, :status => Sprint::STATUS_CANCELLED),
:method => :put, :confirm => "Cancel this sprint? Are you sure?",
:class => "command" %>
<% end %>
diff --git a/public/stylesheets/tables.css b/public/stylesheets/tables.css
index b5e88bd..24ba278 100644
--- a/public/stylesheets/tables.css
+++ b/public/stylesheets/tables.css
@@ -92,7 +92,7 @@ table.main-list tr.status-2 { /* closed */
background-color: #0ff;
}
-table.main-list tr.status-3 { /* canceled */
+table.main-list tr.status-3 { /* cancelled */
background-color: #f00;
}
diff --git a/test/fixtures/sprints.yml b/test/fixtures/sprints.yml
index 049d5f6..33d834b 100644
--- a/test/fixtures/sprints.yml
+++ b/test/fixtures/sprints.yml
@@ -16,6 +16,15 @@ closed_sprint:
status: <%= Sprint::STATUS_CLOSED %>
team_lead_id: <%= Fixtures.identify(:team_lead) %>
+cancelled_sprint:
+ product_id: <%= Fixtures.identify(:projxp_web) %>
+ title: This sprint is cancelled.
+ start: <%= DateTime.now.to_s(:db) %>
+ duration: 28
+ goals: Get more stuff done.
+ status: <%= Sprint::STATUS_CANCELLED %>
+ team_lead_id: <%= Fixtures.identify(:team_lead) %>
+
inactive_sprint:
product_id: <%= Fixtures.identify(:projxp_web_services) %>
title: This is the planned sprint.
@@ -42,3 +51,12 @@ teatime_iphone_active_sprint:
goals: Get some iphone stuff done.
status: <%= Sprint::STATUS_ACTIVE %>
team_lead_id: <%= Fixtures.identify(:mcpierce) %>
+
+teatime_midp_planned_sprint:
+ product_id: <%= Fixtures.identify(:teatime_midp) %>
+ title: This is the planned sprint.
+ start: <%= DateTime.now.to_s(:db) %>
+ duration: 14
+ goals: Things and things and things.
+ status: <%= Sprint::STATUS_PLANNED %>
+ team_lead_id: <%= Fixtures.identify(:mcpierce) %>
diff --git a/test/unit/sprint_test.rb b/test/unit/sprint_test.rb
index c01f1a0..986963a 100644
--- a/test/unit/sprint_test.rb
+++ b/test/unit/sprint_test.rb
@@ -46,6 +46,10 @@ class SprintTest < ActiveSupport::TestCase
backlog_item.tasks << Task.new(:hours => 25)
@unhealthy_backlog = [ backlog_item ]
+ @planned_sprint = sprints(:teatime_midp_planned_sprint)
+ raise "Sprint must be in planned state!" unless @planned_sprint.pending?
+ raise "Product cannot have active sprint!" unless Sprint.for_product((a)planned_sprint.product).active.empty?
+
@existing_sprint = sprints(:active_sprint)
@team_lead = @existing_sprint.team_lead
@owner = @product.owner
@@ -53,10 +57,23 @@ class SprintTest < ActiveSupport::TestCase
@closed_sprint = sprints(:closed_sprint)
raise "Sprint must be closed!" unless @closed_sprint.closed?
+ raise "Sprint must be for the same product!" unless @closed_sprint.product_id == @sprint.product_id
+
+ @cancelled_sprint = sprints(:cancelled_sprint)
+ raise "Sprint must be cancelled!" unless @cancelled_sprint.cancelled?
+ raise "Sprint must be for same product!" unless @cancelled_sprint.product_id == @closed_sprint.product_id
@active_sprint_only = sprints(:teatime_iphone_active_sprint)
raise "Sprint must be in the active state!" unless @active_sprint_only.active?
raise "Product cannot have a planned sprint!" unless Sprint.for_product((a)active_sprint_only).planned.empty?
+
+ product_with_active_sprint = @active_sprint_only.product
+ @new_planned_sprint = Sprint.new(:product => product_with_active_sprint,
+ :title => "Active sprint",
+ :start => Date.today,
+ :duration => 14,
+ :goals => "Get things done",
+ :team_lead => product_with_active_sprint.owner)
end
# Ensures that a sprint has to have a product.
@@ -164,7 +181,7 @@ class SprintTest < ActiveSupport::TestCase
end
# Ensures that a sprint cannot be moved back to planned state if a planned sprint exists.
- def test_allowed_state_planned_with_planned_sprint_planned
+ def test_allowed_state_planned_with_planned_sprint
flunk "Product cannot have two planned sprints." if @active_sprint.allowed_status?(Sprint::STATUS_PLANNED)
end
@@ -172,4 +189,28 @@ class SprintTest < ActiveSupport::TestCase
def test_allowed_state_planned
flunk "A sprint can move back to the planned state." unless @active_sprint_only.allowed_status?(Sprint::STATUS_PLANNED)
end
+
+ # Ensures that a planned sprint cannot be made active if an active sprint exists.
+ def test_allowed_state_fails_on_planned_with_active_sprint
+ if @new_planned_sprint.save
+ flunk "Only one active sprint is allowed." if @new_planned_sprint.allowed_status?(Sprint::STATUS_ACTIVE)
+ else
+ raise "Sprint should have saved!"
+ end
+ end
+
+ # Ensures that a closed sprint cannot be made active if an active sprint exists.
+ def test_allowed_state_fails_on_closed_with_active_sprint
+ flunk "Only one active sprint is allowed!" if @closed_sprint.allowed_status?(Sprint::STATUS_ACTIVE)
+ end
+
+ # Ensures that a cancelled sprint cannot be made active if an active sprint exists.
+ def test_allowed_state_fails_on_cancelled_with_active
+ flunk "Only one active sprint is allowed!" if @cancelled_sprint.allowed_status?(Sprint::STATUS_ACTIVE)
+ end
+
+ # Ensures that a planned sprint can be moved to the active state.
+ def test_allowed_state_planned_to_active
+ flunk "Planned sprints should be able to become active." unless @planned_sprint.allowed_status?(Sprint::STATUS_ACTIVE)
+ end
end
--
1.6.0.6
15 years
[PATCH] Only one planned sprint is allowed. #184
by Darryl L. Pierce
Added a new named scope to Sprint, called planned, that only returns
sprints that are in the planned state.
Product.can_create_sprints? now checks whether there are any sprints
that are currently in the planned state. If there are, then it returns
that a sprint cannot be created.
Fixed the use cases and added additional fixtures to create products
with no sprints.
Signed-off-by: Darryl L. Pierce <mcpierce(a)gmail.com>
---
app/models/product.rb | 2 +-
app/models/sprint.rb | 32 ++++++++++++++--------------
test/fixtures/product_roles.yml | 7 ++++++
test/fixtures/products.yml | 6 +++++
test/fixtures/sprints.yml | 27 +++++++++++++++++++++++
test/fixtures/user_stories.yml | 7 ++++++
test/functional/sprints_controller_test.rb | 23 ++++++++++++-------
test/unit/product_test.rb | 13 +++++++++-
test/unit/sprint_test.rb | 16 ++++++++++++++
9 files changed, 105 insertions(+), 28 deletions(-)
diff --git a/app/models/product.rb b/app/models/product.rb
index 5b12778..3611c97 100644
--- a/app/models/product.rb
+++ b/app/models/product.rb
@@ -94,7 +94,7 @@ class Product < ActiveRecord::Base
# Returns whether the user can create sprints for this product.
def can_create_sprints?(user)
- user && (user.id == owner.id) && !user_stories.empty?
+ user && (user.id == owner.id) && !user_stories.empty? && Sprint.for_product(self).planned.empty?
end
# Returns whether the user specified is the product owner.
diff --git a/app/models/sprint.rb b/app/models/sprint.rb
index 7b855a1..f6d808f 100644
--- a/app/models/sprint.rb
+++ b/app/models/sprint.rb
@@ -37,6 +37,18 @@
# sprint is considered healthy.
#
class Sprint < ActiveRecord::Base
+ STATUS_PLANNED = 0
+ STATUS_ACTIVE = 1
+ STATUS_CLOSED = 2
+ STATUS_CANCELED = 3
+ STATUS_TEXT =
+ {
+ 'Planned' => STATUS_PLANNED,
+ 'Active' => STATUS_ACTIVE,
+ 'Closed' => STATUS_CLOSED,
+ 'Canceled' => STATUS_CANCELED
+ }.sort_by { |k,v| v }
+
validates_presence_of :product_id,
:message => 'Sprints must be associated with a product.'
@@ -77,18 +89,7 @@ class Sprint < ActiveRecord::Base
named_scope :for_product, lambda { |product_id|
{ :conditions => product_id ? ["product_id = ?", product_id]: []}
}
-
- STATUS_PLANNED = 0
- STATUS_ACTIVE = 1
- STATUS_CLOSED = 2
- STATUS_CANCELED = 3
- STATUS_TEXT =
- {
- 'Planned' => STATUS_PLANNED,
- 'Active' => STATUS_ACTIVE,
- 'Closed' => STATUS_CLOSED,
- 'Canceled' => STATUS_CANCELED
- }.sort_by { |k,v| v }
+ named_scope :planned, {:conditions => ['status = ?', STATUS_PLANNED]}
# Returns the text for the status.
def status_text
@@ -150,10 +151,9 @@ class Sprint < ActiveRecord::Base
when STATUS_PLANNED:
return true if status == STATUS_ACTIVE
when STATUS_ACTIVE:
- if(status == STATUS_PLANNED && actual_hours == 0) ||
- ([STATUS_CANCELED, STATUS_CLOSED].include?(status))
- return true
- end
+ return true if (status == STATUS_PLANNED && actual_hours == 0 && Sprint.for_product(product).planned.empty?)
+ return true if [STATUS_CANCELED, STATUS_CLOSED].include?(status)
+
when STATUS_CLOSED: return true if status == STATUS_ACTIVE
when STATUS_CANCELED: return true if status == STATUS_ACTIVE
end
diff --git a/test/fixtures/product_roles.yml b/test/fixtures/product_roles.yml
index 27ec3ce..28fdfd8 100644
--- a/test/fixtures/product_roles.yml
+++ b/test/fixtures/product_roles.yml
@@ -32,3 +32,10 @@ mcpierce_projxp_web_services:
role_id: <%= Fixtures.identify(:developer) %>
pending: false
is_approved: true
+
+mcpierce_teatime_iphone:
+ product_id: <%= Fixtures.identify(:teatime_iphone) %>
+ user_id: <%= Fixtures.identify(:mcpierce) %>
+ role_id: <%= Fixtures.identify(:developer) %>
+ pending: false
+ is_approved: true
diff --git a/test/fixtures/products.yml b/test/fixtures/products.yml
index 7c7ba03..cfe4a5a 100644
--- a/test/fixtures/products.yml
+++ b/test/fixtures/products.yml
@@ -16,3 +16,9 @@ teatime_midp:
owner_id: <%= Fixtures.identify(:teatime_owner) %>
name: Teatime for JavaME devices.
description: JavaME implementation.
+
+teatime_iphone:
+ project_id: <%= Fixtures.identify(:teatime) %>
+ owner_id: <%= Fixtures.identify(:teatime_owner) %>
+ name: Teatime for the iPhone.
+ description: Teatime for the iPhone.
diff --git a/test/fixtures/sprints.yml b/test/fixtures/sprints.yml
index e6263c3..f1034d5 100644
--- a/test/fixtures/sprints.yml
+++ b/test/fixtures/sprints.yml
@@ -24,3 +24,30 @@ closed_sprint:
goals: Get more stuff done.
status: <%= Sprint::STATUS_CLOSED %>
team_lead_id: <%= Fixtures.identify(:team_lead) %>
+
+projxp_web_services_planned_sprint:
+ product_id: <%= Fixtures.identify(:projxp_web_services) %>
+ title: This is the planned sprint.
+ start: <%= (DateTime.now + 28).to_s(:db) %>
+ duration: 28
+ goals: Get some web services written.
+ status: <%= Sprint::STATUS_PLANNED %>
+ team_lead_id: <%= Fixtures.identify(:mcpierce) %>
+
+projxp_web_services_active_sprint:
+ product_id: <%= Fixtures.identify(:projxp_web_services) %>
+ title: This is the active sprint.
+ start: <%= DateTime.now.to_s(:db) %>
+ duration: 28
+ goals: Get some web services written.
+ status: <%= Sprint::STATUS_ACTIVE %>
+ team_lead_id: <%= Fixtures.identify(:mcpierce) %>
+
+teatime_iphone_active_sprint:
+ product_id: <%= Fixtures.identify(:teatime_iphone) %>
+ title: This is an active sprint.
+ start: <%= DateTime.now.to_s(:db) %>
+ duration: 14
+ goals: Get some iphone stuff done.
+ status: <%= Sprint::STATUS_ACTIVE %>
+ team_lead_id: <%= Fixtures.identify(:mcpierce) %>
diff --git a/test/fixtures/user_stories.yml b/test/fixtures/user_stories.yml
index 216be55..0c7035d 100644
--- a/test/fixtures/user_stories.yml
+++ b/test/fixtures/user_stories.yml
@@ -56,3 +56,10 @@ freerange_user_story:
title: Just something to fix the unit tests.
description: More words than you'll care to read.
closed: false
+
+login_teatime_iphone:
+ product_id: <%= Fixtures.identify(:teatime_iphone) %>
+ priority: 1
+ title: Users on the iPhone can log into the server.
+ description: Read the title!
+ closed: false
diff --git a/test/functional/sprints_controller_test.rb b/test/functional/sprints_controller_test.rb
index 6bc0781..b1db71d 100644
--- a/test/functional/sprints_controller_test.rb
+++ b/test/functional/sprints_controller_test.rb
@@ -49,6 +49,10 @@ class SprintsControllerTest < ActionController::TestCase
@product_with_no_stories = products(:teatime_midp)
raise "Product cannot have user stories!" unless @product_with_no_stories.user_stories.empty?
+ @product_with_no_sprints = products(:teatime_iphone)
+ raise "Product cannot have anys prints!" unless @product_with_no_sprints.sprints.empty?
+ raise "Member must be on product team!" unless @product_with_no_sprints.is_member?(@member)
+
@new_sprint = {
:title => "New Sprint",
:start => Date.today,
@@ -105,19 +109,19 @@ class SprintsControllerTest < ActionController::TestCase
# Ensures that only the owner can create a new sprint.
def test_new_as_nonowner
- get :new, {:product => @product.id}, {:user_id => @nonowner.id}
+ get :new, {:product => @product_with_no_sprints.id}, {:user_id => @nonowner.id}
assert_redirected_to error_path
end
# Ensures that creating a new sprint works as expected.
def test_new
- get :new, {:product => @product.id}, {:user_id => @owner.id}
+ get :new, {:product => @product_with_no_sprints.id}, {:user_id => @product_with_no_sprints.owner_id}
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,
+ assert_equal @product_with_no_sprints.id, assigns['sprint'].product_id,
"Did not set the product correctly."
end
@@ -160,16 +164,17 @@ class SprintsControllerTest < ActionController::TestCase
# Ensures that an invalid sprint redirects to the edit page.
def test_create_with_invalid_sprint
- post :create, {:product => @product.id, :sprint => {:title => "Invalid Sprint"}}, {:user_id => @owner.id}
+ post :create, {:product => @product_with_no_sprints.id, :sprint => {:title => "Invalid Sprint"}},
+ {:user_id => @product_with_no_sprints.owner_id}
assert_response :success
- assert !Sprint.find_by_title("Invalid Sprint"),
- "Should not have saved the sprint."
+ assert !Sprint.find_by_title("Invalid Sprint"), "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 => @product.id, :sprint => @new_sprint}, {:user_id => @owner.id}
+ post :create, {:product => @product_with_no_sprints.id, :sprint => @new_sprint},
+ {:user_id => @product_with_no_sprints.owner_id}
assert_redirected_to plan_sprint_path(assigns['sprint'])
assert assigns['sprint'].members.empty?, "No members should be defined"
@@ -194,8 +199,8 @@ class SprintsControllerTest < ActionController::TestCase
# Ensures that creating a sprint works as expected.
def test_create
- post :create,{:product => @product.id, :sprint => @new_sprint, :selected => [@member.id]},
- {:user_id => @owner.id}
+ post :create,{:product => @product_with_no_sprints.id, :sprint => @new_sprint, :selected => [@member.id]},
+ {:user_id => @product_with_no_sprints.owner_id}
assert_redirected_to plan_sprint_path(assigns['sprint'])
result = Sprint.find_by_title(@new_sprint[:title])
diff --git a/test/unit/product_test.rb b/test/unit/product_test.rb
index e6cf5c3..5aac4ce 100644
--- a/test/unit/product_test.rb
+++ b/test/unit/product_test.rb
@@ -43,6 +43,9 @@ class ProductTest < ActiveSupport::TestCase
@product_with_no_stories = products(:teatime_midp)
raise "Product must not have stories!" unless @product_with_no_stories.user_stories.empty?
+
+ @product_with_planned_sprint = products(:projxp_web)
+ raise "Product must have at least one planned sprint!" if Sprint.for_product((a)product_with_planned_sprint).planned.empty?
end
# Ensures that a project is required.
@@ -102,7 +105,13 @@ class ProductTest < ActiveSupport::TestCase
# Ensures that a product with no user stories won't allow sprints.
def test_can_create_with_no_user_stories
- assert !@product_with_no_stories.can_create_sprints?((a)product_with_no_stories.owner),
- "The owner should not be allowed to create sprints when there are no stories."
+ flunk "The owner should not be allowed to create sprints when there are no stories." if
+ @product_with_no_stories.can_create_sprints?((a)product_with_no_stories.owner)
+ end
+
+ # Ensures that a new sprint cannot be created if an existing one exists in the planned state.
+ def test_can_create_sprint_with_existing_planned_sprint
+ flunk "You cannot have two planned sprints." if
+ @product_with_planned_sprint.can_create_sprints?((a)product_with_planned_sprint.owner)
end
end
diff --git a/test/unit/sprint_test.rb b/test/unit/sprint_test.rb
index c57cde6..d05f935 100644
--- a/test/unit/sprint_test.rb
+++ b/test/unit/sprint_test.rb
@@ -33,6 +33,10 @@ class SprintTest < ActiveSupport::TestCase
@developer = users(:mcpierce)
raise "Developer is not a member of the product team." unless @product.is_member?(@developer)
+ @sprint = sprints(:active_sprint)
+ @active_sprint = sprints(:projxp_web_services_active_sprint)
+ raise "There needs to be a planned sprint!" if Sprint.for_product((a)active_sprint.product).planned.empty?
+
backlog_item = BacklogItem.new(:estimated_hours => 100)
backlog_item.tasks << Task.new(:hours => 50)
backlog_item.update_remaining_hours(50.0,@developer)
@@ -154,4 +158,16 @@ class SprintTest < ActiveSupport::TestCase
def test_can_add_backlog_items_for_inactive_sprint
flunk "Inactive sprints cannot add backlog items." if @closed_sprint.can_add_backlog_items?((a)closed_sprint.team_lead)
end
+
+ # Ensures that a sprint cannot be moved back to planned state if a planned sprint exists.
+ def test_allowed_state_planned_with_planned_sprint_planned
+ flunk "Product cannot have two planned sprints." if @active_sprint.allowed_status?(Sprint::STATUS_PLANNED)
+ end
+
+ # Ensures that a sprint can be moved back to the planned state.
+ def test_allowed_state_planned
+ sprint = sprints(:teatime_iphone_active_sprint)
+
+ flunk "A sprint can move back to the planned state." unless sprint.allowed_status?(Sprint::STATUS_PLANNED)
+ end
end
--
1.6.0.6
15 years
[PATCH] Items can be deferred by the team lead. #173
by Darryl L. Pierce
Added a new business rule for enforcing deferred rules. Only items on
active sprints can be deferred.
Added an action to ItemsController, with tests, to allow users to defer
backlog items.
Fixed the styling for deferred backlog items displayed within the item
list.
Signed-off-by: Darryl L. Pierce <mcpierce(a)gmail.com>
---
app/controllers/items_controller.rb | 21 ++++++++++
app/models/backlog_item.rb | 27 +++++++++----
app/views/items/show.html.erb | 5 ++
config/routes.rb | 3 +-
doc/ChangeLog | 1 +
public/stylesheets/tables.css | 7 +++-
test/functional/items_controller_test.rb | 30 +++++++++++++++
test/unit/backlog_item_test.rb | 60 ++++++++++++++++++++++++-----
8 files changed, 133 insertions(+), 21 deletions(-)
diff --git a/app/controllers/items_controller.rb b/app/controllers/items_controller.rb
index 0b35ebe..f0037ea 100644
--- a/app/controllers/items_controller.rb
+++ b/app/controllers/items_controller.rb
@@ -22,6 +22,7 @@ class ItemsController < ApplicationController
before_filter :load_sprint, :only => [:new, :create]
before_filter :load_backlog_item, :except => [:index, :new, :create]
before_filter :verify_can_delete, :only => [:destroy]
+ before_filter :verify_can_defer, :only => [:defer]
before_filter :verify_can_create, :only => [:new, :create]
before_filter :verify_sprint_is_active, :only => [:accept, :estimate, :drop, :complete, :reopen, :blocked, :update_blocked]
before_filter :verify_is_member, :only => [:accept, :drop, :complete, :reopen]
@@ -162,6 +163,22 @@ class ItemsController < ApplicationController
end
end
+ # PUT /items/1/defer
+ def defer
+ BacklogItem.transaction do
+ @backlog_item.defer
+
+ if @backlog_item.save
+ flash[:message] = "Item has been deferred."
+ respond_to do |format|
+ format.html {redirect_to items_path(:sprint => @sprint)}
+ end
+ else
+ report_error "Unable to defer item at this time."
+ end
+ end
+ end
+
# POST /items/1/estimate
def estimate
if @backlog_item.can_estimate?(@user)
@@ -258,6 +275,10 @@ class ItemsController < ApplicationController
report_error "You are not allowed to delete this item." unless @backlog_item.can_delete?(@user)
end
+ def verify_can_defer
+ report_error "You may not defer this item." unless @backlog_item.can_defer?(@user)
+ end
+
def verify_can_create
report_error "You are not allowed to create or edit backlog items." unless @sprint.can_add_backlog_items?(@user)
end
diff --git a/app/models/backlog_item.rb b/app/models/backlog_item.rb
index 9f14862..407efb1 100644
--- a/app/models/backlog_item.rb
+++ b/app/models/backlog_item.rb
@@ -42,7 +42,7 @@ class BacklogItem < ActiveRecord::Base
STATE_ASSIGNED = 1
STATE_COMPLETED = 2
STATE_DROPPED = 3
- STATE_CANCELED = 4
+ STATE_DEFERRED = 4
STATE_TEXT =
{
@@ -50,7 +50,7 @@ class BacklogItem < ActiveRecord::Base
STATE_ASSIGNED => 'Assigned',
STATE_COMPLETED => 'Completed',
STATE_DROPPED => 'Dropped',
- STATE_CANCELED => 'Canceled'
+ STATE_DEFERRED => 'Deferred'
}
named_scope :default, { :order => 'priority ASC' }
@@ -71,7 +71,7 @@ class BacklogItem < ActiveRecord::Base
end
def remaining_hours
- return 0.0 if [STATE_COMPLETED, STATE_CANCELED].include?(state)
+ return 0.0 if [STATE_COMPLETED, STATE_DEFERRED].include?(state)
remaining_hours_estimates.empty? ?
estimated_hours :
@@ -109,6 +109,11 @@ class BacklogItem < ActiveRecord::Base
state == STATE_COMPLETED
end
+ # Returns whether the item is deferred.
+ def deferred?
+ state == STATE_DEFERRED
+ end
+
# Sets the state to accepted.
def accept(user)
if user
@@ -133,6 +138,12 @@ class BacklogItem < ActiveRecord::Base
self.state = STATE_COMPLETED
end
+ # Marks the backlog item as deferred.
+ def defer
+ self.state = STATE_DEFERRED
+ self.owner = nil
+ end
+
# Marks the backlog item as reopened.
def reopen
self.state = STATE_PENDING
@@ -151,7 +162,7 @@ class BacklogItem < ActiveRecord::Base
# Returns whether the user can assign this item to another.
def can_assign?(user)
- product_owner?(user) && ![STATE_COMPLETED, STATE_CANCELED].include?(state)
+ product_owner?(user) && ![STATE_COMPLETED, STATE_DEFERRED].include?(state)
end
# Returns whether the user can accept this item.
@@ -197,16 +208,16 @@ class BacklogItem < ActiveRecord::Base
product_owner?(user) && tasks.empty? && !sprint.active?
end
- # Returns whether the user can cancel this backlog item.
- def can_cancel?(user)
- product_owner?(user) && sprint.active? &&
+ # Returns whether the user can defer this backlog item.
+ def can_defer?(user)
+ team_lead?(user) && sprint.active? &&
[STATE_PENDING, STATE_ASSIGNED, STATE_DROPPED].include?(state)
end
# Returns whether the user can reopen this backlog item.
def can_reopen?(user)
(owner?(user) || product_owner?(user) || team_lead?(user)) &&
- [STATE_COMPLETED, STATE_CANCELED].include?(state) &&
+ [STATE_COMPLETED, STATE_DEFERRED].include?(state) &&
sprint.active?
end
diff --git a/app/views/items/show.html.erb b/app/views/items/show.html.erb
index cd172f9..e6e352f 100644
--- a/app/views/items/show.html.erb
+++ b/app/views/items/show.html.erb
@@ -82,6 +82,11 @@
:confirm => "Drop this item? Are you sure?", :class => "command" %>
<% end %>
+ <% if @backlog_item.can_defer?(@user) %>
+ <%= link_to "Defer this item...", defer_item_path(@backlog_item),
+ :method => :put, :confirm => "Defer this item? Are you sure?", :class => "command" %>
+ <% end %>
+
<% if @backlog_item.can_complete?(@user) %>
<%= link_to"Mark this item completed...", complete_item_path(@backlog_item, :url => request.request_uri),
:confirm => "Mark as completed? Are you sure?", :class => "command" %>
diff --git a/config/routes.rb b/config/routes.rb
index e52c84e..9738de7 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -62,7 +62,8 @@ ActionController::Routing::Routes.draw do |map|
:drop => :get,
:complete => :get,
:reopen => :get,
- :estimate => :post
+ :estimate => :post,
+ :defer => :put
}
map.resources :users, :member =>
diff --git a/doc/ChangeLog b/doc/ChangeLog
index 819772a..7d42ef9 100644
--- a/doc/ChangeLog
+++ b/doc/ChangeLog
@@ -2,6 +2,7 @@ Change Log (0.3.0):
* #156 - Backlog items can be marked as blocked.
* #157 - Items can be marked completed when a task is added.
* #167 - Blocker messages are included in the daily updates email.
+ * #173 - Backlog items can be dropped from an active sprint.
* #175 - When viewing an unapproved project's product list, the sidebar is misplaced. (BUG)
* #176 - Added a breadcrumb trail to the navigation bar.
* #177 - Epics cannot be created for unapproved projects.
diff --git a/public/stylesheets/tables.css b/public/stylesheets/tables.css
index bc60ca6..b5e88bd 100644
--- a/public/stylesheets/tables.css
+++ b/public/stylesheets/tables.css
@@ -126,8 +126,13 @@ table.main-list tr.state-3 { /* dropped */
background-color: #cff;
}
-table.main-list tr.state-4 { /* canceled */
+table.main-list tr.state-4 { /* deferred */
background-color: #202020;
+ color: #afafaf;
+}
+
+table.main-list tr.state-4 a {
+ color: #a0a0a0;
}
/* edit table styles */
diff --git a/test/functional/items_controller_test.rb b/test/functional/items_controller_test.rb
index 1960059..3cb35ed 100644
--- a/test/functional/items_controller_test.rb
+++ b/test/functional/items_controller_test.rb
@@ -362,4 +362,34 @@ class ItemsControllerTest < ActionController::TestCase
assert result.blocked, "Item should have been marked as blocked."
assert_equal "Message", result.blocker_message.body, "The message was not properly saved."
end
+
+ # Ensures that anonymous users cannot defer items.
+ def test_defer_as_anonymous
+ put :defer
+
+ assert_redirected_to login_path
+ end
+
+ # Ensures that a valid item id is required.
+ def test_defer_with_invalid_item_id
+ put :defer, {}, {:user_id => @team_lead.id}
+
+ assert_redirected_to error_path
+ end
+
+ # Ensures that deferring rules are applied.
+ def test_defer_as_non_team_lead
+ put :defer, {:id => @item.id}, {:user_id => @non_team_lead.id}
+
+ assert_redirected_to error_path
+ end
+
+ # Ensure that deferring works as expected.
+ def test_defer
+ put :defer, {:id => @item.id}, {:user_id => @team_lead.id}
+
+ assert_redirected_to items_path(:sprint => @item.sprint)
+ result = BacklogItem.find_by_id((a)item.id)
+ assert result.deferred?, "Item should have been deferred."
+ end
end
diff --git a/test/unit/backlog_item_test.rb b/test/unit/backlog_item_test.rb
index 0eb43e6..d6521da 100644
--- a/test/unit/backlog_item_test.rb
+++ b/test/unit/backlog_item_test.rb
@@ -25,9 +25,13 @@ class BacklogItemTest < ActiveSupport::TestCase
def setup
@sprint = sprints(:active_sprint)
+ @team_lead = @sprint.team_lead
@product = @sprint.product
- @item = BacklogItem.new(
+ @non_team_lead = users(:mcpierce)
+ raise "Non-team lead cannot be the team lead!" if @non_team_lead.id == @team_lead.id
+
+ @new_item = BacklogItem.new(
:sprint_id => @sprint.id,
:user_story_id => user_stories(:create_login).id,
:estimated_hours => 5.0)
@@ -57,45 +61,47 @@ class BacklogItemTest < ActiveSupport::TestCase
@item_on_closed_sprint = backlog_items(:closed_sprint_backlog_item)
raise "Item must have an owner!" unless @item_on_closed_sprint.owner
raise "That sprint must be closed!" unless @item_on_closed_sprint.sprint.closed?
+
+ @deferrable_item = backlog_items(:owned_backlog_item)
end
# Ensures that a sprint is required.
def test_valid_fails_without_sprint
- @item.sprint_id = nil
+ @new_item.sprint_id = nil
- flunk "A sprint is required." if @item.valid?
+ flunk "A sprint is required." if @new_item.valid?
end
# Ensures that a user story is required.
def test_valid_fails_without_user_story
- @item.user_story_id = nil
+ @new_item.user_story_id = nil
- flunk "A user story is required." if @item.valid?
+ flunk "A user story is required." if @new_item.valid?
end
# Ensures a well-formed story passes validation.
def test_valid
- flunk "Something's fundamentally wrong." unless @item.valid?
+ flunk "Something's fundamentally wrong." unless @new_item.valid?
end
# Ensures that the default value for remaining hours is the original estimated
# hours.
def test_remaining_when_undefined
- @item.estimated_hours = 2.5
+ @new_item.estimated_hours = 2.5
assert_equal 2.5,
- @item.remaining_hours,
+ @new_item.remaining_hours,
'Remaining hours should equal estimated hours.'
end
# Ensures that adding a new remaining hours estimation affects the hours
# reported.
def test_remaining_hours_when_updated
- @item.estimated_hours = 5.0
- @item.remaining_hours_estimates << RemainingHoursEstimate.new(:hours => 2.5, :estimated_on => Date.today.next)
+ @new_item.estimated_hours = 5.0
+ @new_item.remaining_hours_estimates << RemainingHoursEstimate.new(:hours => 2.5, :estimated_on => Date.today.next)
assert_equal 2.5,
- @item.remaining_hours,
+ @new_item.remaining_hours,
'Remaining hours is incorrectly reported.'
end
@@ -189,4 +195,36 @@ class BacklogItemTest < ActiveSupport::TestCase
def test_mark_blocked
fail "An item must allow the owner to mark it as blocked." unless @owned_item.can_mark_blocked?(@owner)
end
+
+ # Ensures that only the team lead can defer an item.
+ def test_defer_as_non_team_lead
+ fail "Only the team lead can defer an item." if @deferrable_item.can_defer?(@non_team_lead)
+ end
+
+ # Ensures that an item not on an active sprint cannot be deferred.
+ def test_defer_for_inactive_sprint_item
+ fail "Defer is only for active sprints." if @item_on_inactive_sprint.can_defer?((a)item_on_inactive_sprint.sprint.team_lead)
+ end
+
+ # Ensures that an owned item can be deferred
+ def test_defer_for_owned_items
+ fail "Items should be deferrable by the team lead." unless @owned_item.can_defer?((a)owned_item.sprint.team_lead)
+
+ @owned_item.defer
+ assert @owned_item.deferred?, "Item should have been marked as deferred."
+ assert !(a)owned_item.owner, "Deferred items cannot have owners."
+ end
+
+ # Ensures that completed items cannot be deferred.
+ def test_defer_for_completed_items
+ fail "Closed items cannot be deferred." if @closed_item.can_defer?((a)closed_item.sprint.team_lead)
+ end
+
+ # Ensures that items can be deferred.
+ def test_defer
+ fail "Items should be deferrable by the team lead." unless @unowned_item.can_defer?((a)unowned_item.sprint.team_lead)
+
+ @unowned_item.defer
+ assert @unowned_item.deferred?, "Item should have been deferred."
+ end
end
--
1.6.0.6
15 years
[PATCH] Backlog items can be added to an active sprint. #158
by Darryl L. Pierce
Added actions to create new backlog items, plus unit tests to enforce
conditions.
Signed-off-by: Darryl L. Pierce <mcpierce(a)gmail.com>
---
app/controllers/items_controller.rb | 47 +++++++++++++++-
app/models/backlog_item.rb | 5 ++
app/models/sprint.rb | 23 ++++++--
app/models/user_story.rb | 7 +++
app/views/items/_edit.html.erb | 48 +++++++++++++++++
app/views/items/new.html.erb | 1 +
app/views/sprints/show.html.erb | 5 ++
test/functional/items_controller_test.rb | 86 ++++++++++++++++++++++++++++++
test/unit/sprint_test.rb | 8 +++
9 files changed, 221 insertions(+), 9 deletions(-)
create mode 100644 app/views/items/_edit.html.erb
create mode 100644 app/views/items/new.html.erb
diff --git a/app/controllers/items_controller.rb b/app/controllers/items_controller.rb
index eb92906..0b35ebe 100644
--- a/app/controllers/items_controller.rb
+++ b/app/controllers/items_controller.rb
@@ -17,17 +17,19 @@
# +ItemsController+ handles CRUD operations on instances of +BacklogItem+.
class ItemsController < ApplicationController
- before_filter :unsupported, :only => [:new, :edit, :create, :update]
+ before_filter :unsupported, :only => [:edit, :update]
before_filter :authenticated, :except => [:index, :show]
- before_filter :load_backlog_item, :except => [:index]
+ before_filter :load_sprint, :only => [:new, :create]
+ before_filter :load_backlog_item, :except => [:index, :new, :create]
before_filter :verify_can_delete, :only => [:destroy]
+ before_filter :verify_can_create, :only => [:new, :create]
before_filter :verify_sprint_is_active, :only => [:accept, :estimate, :drop, :complete, :reopen, :blocked, :update_blocked]
before_filter :verify_is_member, :only => [:accept, :drop, :complete, :reopen]
before_filter :verify_is_owner, :only => [:drop, :complete, :estimate, :blocked, :update_blocked]
before_filter :verify_can_mark_blocked, :only => [:blocked, :update_blocked]
before_filter :load_message, :only => [:blocked, :update_blocked]
before_filter :load_hours, :only => [:estimate]
- before_filter :path_to_list, :only => [:index]
+ before_filter :path_to_list, :only => [:index, :new]
before_filter :path_to_one, :only => [:show]
# GET /items
@@ -49,6 +51,32 @@ class ItemsController < ApplicationController
end
end
+ # GET /items/new?sprint=1
+ def new
+ add_breadcrumb("New")
+ @backlog_item = BacklogItem.new(:sprint => @sprint)
+ @backlog_item.discovered = true
+ end
+
+ # POST /items/new
+ def create
+ BacklogItem.transaction do
+ @backlog_item = BacklogItem.new(params[:backlog_item])
+ @backlog_item.sprint = @sprint
+ @backlog_item.discovered = true
+
+ respond_to do |format|
+ if @backlog_item.save
+ flash[:message] = "Backlog item created."
+ format.html {redirect_to item_path(@backlog_item)}
+ else
+ @backlog_item.valid?
+ format.html {render :action => :new}
+ end
+ end
+ end
+ end
+
# DELETE /items/1
def destroy
UserStory.transaction do
@@ -198,10 +226,19 @@ class ItemsController < ApplicationController
report_error "That function is not supported for backlog items."
end
+ def load_sprint
+ @sprint = Sprint.find_by_id(params[:sprint])
+ @team_lead = @sprint.team_lead if @sprint
+ @user_stories = UserStory.for_product(@sprint.product).not_in_sprint((a)sprint)
+
+ report_error "Missing or invalid sprint." unless @sprint
+ end
+
def load_backlog_item
@backlog_item = BacklogItem.find_by_id(params[:id])
@tasks = @backlog_item.tasks if @backlog_item
@sprint = @backlog_item.sprint if @backlog_item
+ @team_lead = @sprint.team_lead if @backlog_item
report_error "Missing or invalid backlog item." unless @backlog_item && (@backlog_item.sprint_id == @sprint.id)
end
@@ -221,6 +258,10 @@ class ItemsController < ApplicationController
report_error "You are not allowed to delete this item." unless @backlog_item.can_delete?(@user)
end
+ def verify_can_create
+ report_error "You are not allowed to create or edit backlog items." unless @sprint.can_add_backlog_items?(@user)
+ end
+
def verify_sprint_is_active
report_error "This sprint is not in the active state." unless @sprint.active?
end
diff --git a/app/models/backlog_item.rb b/app/models/backlog_item.rb
index 07dc2b4..6b0b0b2 100644
--- a/app/models/backlog_item.rb
+++ b/app/models/backlog_item.rb
@@ -139,6 +139,11 @@ class BacklogItem < ActiveRecord::Base
self.owner = nil
end
+ # Returns whether the item was discovered.
+ def discovered?
+ discovered
+ end
+
# Returns whether the given user can modify this backlog item.
def can_edit?(user)
(owner?(user) || product_owner?(user)) && (state != STATE_COMPLETED)
diff --git a/app/models/sprint.rb b/app/models/sprint.rb
index a26a994..7b855a1 100644
--- a/app/models/sprint.rb
+++ b/app/models/sprint.rb
@@ -131,20 +131,17 @@ class Sprint < ActiveRecord::Base
# Returns whether the specified user can modify this sprint.
def can_edit?(user)
- user && ((user.id == product.owner.id) || (user.id == team_lead.id))
+ is_product_owner(user) || is_team_lead(user)
end
# Returns whether the user can delete the current sprint.
def can_delete?(user)
- user && (user.id == product.owner_id)
+ is_product_owner(user)
end
# Returns whether the specified user is allowed to populate this sprint.
def can_populate?(user)
- user &&
- ((user.id == product.owner.id) ||
- (user.id == team_lead_id)) &&
- pending?
+ (is_product_owner(user) || is_team_lead(user)) && pending?
end
# Returns whether the sprint can be moved to the given status.
@@ -184,6 +181,12 @@ class Sprint < ActiveRecord::Base
status != STATUS_PLANNED
end
+ # Rethers whether the sprint can have backlog items added, and whether the
+ # specified user can add one.
+ def can_add_backlog_items?(user)
+ (is_product_owner(user) || is_team_lead(user)) && status == STATUS_ACTIVE
+ end
+
# Returns the data as of the given day into the sprint.
def burndown_data
data = []
@@ -220,6 +223,14 @@ class Sprint < ActiveRecord::Base
private
+ def is_product_owner(user)
+ user != nil && user.id == product.owner_id
+ end
+
+ def is_team_lead(user)
+ user != nil && user.id == team_lead_id
+ end
+
# When the user close a sprint, every user stories are close if the related
# backlog item are completed.
def close_user_stories
diff --git a/app/models/user_story.rb b/app/models/user_story.rb
index 4007790..57a20d6 100644
--- a/app/models/user_story.rb
+++ b/app/models/user_story.rb
@@ -42,6 +42,9 @@ class UserStory < ActiveRecord::Base
named_scope :for_product, lambda { |product_id|
{ :conditions => product_id ? ["product_id = ?", product_id] : [] }
}
+ named_scope :not_in_sprint, lambda { |sprint_id|
+ {:conditions => ['id not in (select user_story_id from backlog_items where sprint_id = ?)', sprint_id]}
+ }
# Returns whether the user can edit this user story.
def can_edit?(user)
@@ -62,4 +65,8 @@ class UserStory < ActiveRecord::Base
def closed?
closed
end
+
+ def selectable_title
+ return "#{title} (#{priority})"
+ end
end
diff --git a/app/views/items/_edit.html.erb b/app/views/items/_edit.html.erb
new file mode 100644
index 0000000..04ab68a
--- /dev/null
+++ b/app/views/items/_edit.html.erb
@@ -0,0 +1,48 @@
+<div id="content-no-sidebar">
+
+ <% html = @backlog_item.new_record? ? {:method => :post} : {:method => :put} %>
+ <% form_for :backlog_item, @backlog_item, :url => item_path(@backlog_item), :html => html do |form| %>
+
+ <%= hidden_field_tag :sprint, @sprint.id %>
+
+ <table class="edit">
+ <caption><%= "#{(a)backlog_item.new_record? ? 'Create' : 'Edit'} A Backlog Item." %></caption>
+
+ <tbody>
+ <tr>
+ <td class="label-required">For user story:</td>
+ <td class="value">
+ <%= collection_select(:backlog_item, :user_story_id, @user_stories,
+ :id, :selectable_title) %>
+ <%= error_message_on(:backlog_item, :user_story_id) %>
+ </td>
+ </tr>
+
+ <tr>
+ <td class="label-required">Estimated hours</td>
+ <td class="value">
+ <%= form.text_field :estimated_hours, :size => 3, :maxlength => 5 %>
+ <%= error_message_on(:backlog_item, :estimated_hours) %>
+ </td>
+ </tr>
+
+ <tr>
+ <td class="label"></td>
+ <td class="value">
+ <%= form.check_box :discovered, :disabled => true %>
+ <%= form.label :discovered, "This item was discovered after the sprint started." %>
+ </td>
+ </tr>
+
+ <tr>
+ <td class="buttons" colspan="2">
+ <%= submit_tag "#{(a)backlog_item.new_record? ? 'Save' : 'Update'}" %>
+ </td>
+ </tr>
+ </tbody>
+
+ </table>
+
+ <% end %>
+
+</div>
diff --git a/app/views/items/new.html.erb b/app/views/items/new.html.erb
new file mode 100644
index 0000000..0b10edb
--- /dev/null
+++ b/app/views/items/new.html.erb
@@ -0,0 +1 @@
+<%= render :partial => 'edit', :locals => {:backlog_item => @backlog_item, :sprint => @sprint} %>
diff --git a/app/views/sprints/show.html.erb b/app/views/sprints/show.html.erb
index f94af3a..ce58061 100644
--- a/app/views/sprints/show.html.erb
+++ b/app/views/sprints/show.html.erb
@@ -34,6 +34,11 @@
plan_sprint_path(@sprint), :class => "command" %>
<% end %>
+ <% if @sprint.can_add_backlog_items?(@user) %>
+ <%= link_to "Add backlog item",
+ new_item_path(:sprint => @sprint), :class => "command" %>
+ <% end %>
+
<div class="header"><span class="title">Change Sprint Status</span></div>
<% if @sprint.allowed_status?(Sprint::STATUS_PLANNED) %>
diff --git a/test/functional/items_controller_test.rb b/test/functional/items_controller_test.rb
index 972f2b7..1960059 100644
--- a/test/functional/items_controller_test.rb
+++ b/test/functional/items_controller_test.rb
@@ -30,12 +30,19 @@ class ItemsControllerTest < ActionController::TestCase
@other_product = products(:projxp_web_services)
raise "Product and other cannot be the same!" if @product.id == @other_product.id
+ @user_story = user_stories(:freerange_user_story)
+ raise "User story not found!" unless @user_story
+
@owner = @product.owner
@nonowner = users(:mcpierce)
raise "Owner and nonowner cannot be the same person." if @owner.id == @nonowner.id
@sprint = sprints(:active_sprint)
raise "Product and sprint are mismatched!" unless @product.id == @sprint.product_id
+ @team_lead = @sprint.team_lead
+
+ @non_team_lead = users(:jdonuts)
+ raise "Non-team lead cannot be the team lead!" if @team_lead.id == @non_team_lead.id
@pending_sprint = sprints(:inactive_sprint)
raise "Sprint should be pending!" unless @pending_sprint.pending?
@@ -60,6 +67,8 @@ class ItemsControllerTest < ActionController::TestCase
raise "Item must be marked as blocked." unless @blocked_item.blocked
@blocker_message = Message.blocker_messages((a)blocked_item).last
raise "The blocker message should have been loaded!" unless @blocker_message
+
+ @new_backlog_item = {:user_story_id => @user_story.id, :estimated_hours => 4.5}
end
# Ensures that viewing an index works as expected.
@@ -94,6 +103,83 @@ class ItemsControllerTest < ActionController::TestCase
assert assigns['tasks'], "Failed to load the tasks for this item."
end
+ # Ensures that anonymous users cannot create a new backlog item.
+ def test_new_as_anonymous
+ get :new
+
+ assert_redirected_to login_path
+ end
+
+ # Ensures that sprint must be specified.
+ def test_new_with_invalid_sprint
+ get :new, {}, {:user_id => @owner.id}
+
+ assert_redirected_to error_path
+ end
+
+ # Ensures that only the team lead can add a backlog item.
+ def test_new_as_not_team_lead
+ get :new, {:sprint => @sprint.id}, {:user_id => @non_team_lead.id}
+
+ assert_redirected_to error_path
+ end
+
+ # Ensures that starting a new item works as expected.
+ def test_new
+ get :new, {:sprint => @sprint.id}, {:user_id => @team_lead.id}
+
+ assert_response :success
+ assert assigns['backlog_item'], "Failed to create a backlog item."
+ assert_equal @sprint.id, assigns['backlog_item'].sprint_id, "Did not set the right sprint."
+ assert assigns['backlog_item'].discovered?, "Item was not marked as discovered."
+ assert assigns['user_stories'], "Failed to load the list of user stories."
+ end
+
+ # Ensures anonymous users can't create backlog items.
+ def test_create_as_anonymous
+ post :create
+
+ assert_redirected_to login_path
+ end
+
+ # Ensures that a valids print must be defined.
+ def test_create_with_invalid_sprint
+ post :create, {}, {:user_id => @team_lead.id}
+
+ assert_redirected_to error_path
+ end
+
+ # Ensures that only the team lead can add a backlog item.
+ def test_create_as_non_team_lead
+ post :create, {:sprint => @sprint.id},{:user_id => @non_team_lead.id}
+
+ assert_redirected_to error_path
+ end
+
+ # Ensures that, if the backlog item is malformed, the user is reshown the edit page.
+ def test_create_with_invalid_item
+ @new_backlog_item[:user_story_id] = nil
+ post :create, {:sprint => @sprint.id, :backlog_item => @new_backlog_item}, {:user_id => @sprint.team_lead_id}
+
+ assert_response :success
+ assert_template 'items/new'
+ assert assigns['backlog_item'], "Failed to maintain the backlog item."
+ assert_equal @new_backlog_item[:estimated_hours], assigns['backlog_item'].estimated_hours, "Lost passed in values."
+ assert_equal @sprint.id, assigns['backlog_item'].sprint_id, "Did not set the right sprint."
+ assert assigns['backlog_item'].discovered?, "Item was not marked as discovered."
+ assert assigns['user_stories'], "Failed to load the list of user stories."
+ end
+
+ # Ensures that creating an item works as expected.
+ def test_create
+ post :create, {:sprint => @sprint.id, :backlog_item => @new_backlog_item}, {:user_id => @sprint.team_lead_id}
+
+ result = BacklogItem.find_by_sprint_id_and_user_story_id((a)sprint.id, @user_story.id)
+ assert result, "Failed to create backlog item."
+ assert_redirected_to item_path(result)
+ assert result.discovered?, "Item was not marked as discovered."
+ end
+
# Ensures that anonymous users can't delete.
def test_destroy_as_anonymous
delete :destroy
diff --git a/test/unit/sprint_test.rb b/test/unit/sprint_test.rb
index a3ca8b5..c57cde6 100644
--- a/test/unit/sprint_test.rb
+++ b/test/unit/sprint_test.rb
@@ -46,6 +46,9 @@ class SprintTest < ActiveSupport::TestCase
@team_lead = @existing_sprint.team_lead
@owner = @product.owner
raise "Team lead and product owner cannot be the same user!" if @team_lead.id == @owner.id
+
+ @closed_sprint = sprints(:closed_sprint)
+ raise "Sprint must be closed!" unless @closed_sprint.closed?
end
# Ensures that a sprint has to have a product.
@@ -146,4 +149,9 @@ class SprintTest < ActiveSupport::TestCase
def test_edit_as_product_owner
flunk "Product owners must be allowed to edit sprints." unless @existing_sprint.can_edit?(@owner)
end
+
+ # Ensures that a sprint that a sprint that's not active cannot add a backlog item.
+ def test_can_add_backlog_items_for_inactive_sprint
+ flunk "Inactive sprints cannot add backlog items." if @closed_sprint.can_add_backlog_items?((a)closed_sprint.team_lead)
+ end
end
--
1.6.0.6
15 years
[PATCH] Users can mark an item completed when adding a task. #157
by Darryl L. Pierce
When the user creates a new task against a backlog item, then can set a
checkbox that marks the item as completed. If the task is saved, then
the item will be marked as completed. This is only available for an
assigned item on an active sprint and only to the item owner.
Signed-off-by: Darryl L. Pierce <mcpierce(a)gmail.com>
---
app/controllers/tasks_controller.rb | 5 +-
app/models/backlog_item.rb | 12 ++++-
app/views/tasks/_edit.html.erb | 71 +++++++++++++++++-------------
doc/ChangeLog | 1 +
test/functional/tasks_controller_test.rb | 11 +++++
test/functional/user_items_test.rb | 4 +-
6 files changed, 67 insertions(+), 37 deletions(-)
diff --git a/app/controllers/tasks_controller.rb b/app/controllers/tasks_controller.rb
index f3e6709..600fa97 100644
--- a/app/controllers/tasks_controller.rb
+++ b/app/controllers/tasks_controller.rb
@@ -63,13 +63,14 @@ class TasksController < ApplicationController
Task.transaction do
@task = Task.new(params[:task])
@task.primary = @user
- @task.backlog_item = @backlog_item
+ @task.backlog_item_id = @backlog_item.id
+ @task.backlog_item.completed if params[:completed]
if @task.backup_id && !@sprint.is_team_member?((a)task.backup)
report_error "Backup must be a member of the sprint team."
else
respond_to do |format|
- if @task.save
+ if @task.save && @task.backlog_item.save
flash[:message] = "Recorded #{(a)task.hours} hours against this backlog item."
format.html { redirect_to task_path(@task) }
else
diff --git a/app/models/backlog_item.rb b/app/models/backlog_item.rb
index 07dc2b4..a3c0598 100644
--- a/app/models/backlog_item.rb
+++ b/app/models/backlog_item.rb
@@ -33,7 +33,7 @@ class BacklogItem < ActiveRecord::Base
belongs_to :owner, :class_name => 'User', :foreign_key => 'owner_id'
belongs_to :backup, :class_name => 'User', :foreign_key => 'backup_id'
- has_many :tasks
+ has_many :tasks, :dependent => :destroy
has_many :remaining_hours_estimates
has_and_belongs_to_many :messages, :join_table => :backlog_items_messages
@@ -172,8 +172,14 @@ class BacklogItem < ActiveRecord::Base
end
# Returns whether the user can complete this backlog item.
- def can_complete?(user)
- owner?(user) && (state != STATE_COMPLETED) && !tasks.empty?
+ # The +with_tasks+ argument is used to check this rule regardless of
+ # whether there are tasks.
+ def can_complete?(user, with_tasks=false)
+ if with_tasks
+ owner?(user) && (state != STATE_COMPLETED)
+ else
+ owner?(user) && (state != STATE_COMPLETED) && !tasks.empty?
+ end
end
# Returns whether the user can mark the item as blocked.
diff --git a/app/views/tasks/_edit.html.erb b/app/views/tasks/_edit.html.erb
index e3d58d5..c79c92c 100644
--- a/app/views/tasks/_edit.html.erb
+++ b/app/views/tasks/_edit.html.erb
@@ -6,49 +6,60 @@
<caption><%= "#{(a)task.new_record? ? 'Create' : 'Edit'} A Task" %></caption>
<tbody>
<tr>
- <td class="label-required">When:</td>
- <td class="value">
- <%= form.date_select :when_entered %>
- <%= error_message_on(:task, :when_entered) %>
- </td>
+ <td class="label-required">When:</td>
+ <td class="value">
+ <%= form.date_select :when_entered %>
+ <%= error_message_on(:task, :when_entered) %>
+ </td>
</tr>
<tr>
- <td class="label-required">Primary:</td>
- <td class="value"><%= @task.primary.display_name %></td>
+ <td class="label-required">Primary:</td>
+ <td class="value"><%= @task.primary.display_name %></td>
</tr>
<tr>
- <td class="label">Backup:</td>
- <td class="value">
- <%= collection_select :task, :backup_id, @users, :id,
- :display_name, {:include_blank => true} %>
- <%= error_message_on(:task, :backup_id) %>
- </td>
+ <td class="label">Backup:</td>
+ <td class="value">
+ <%= collection_select :task, :backup_id, @users, :id,
+ :display_name, {:include_blank => true} %>
+ <%= error_message_on(:task, :backup_id) %>
+ </td>
</tr>
<tr>
- <td class="label-required">Hours:</td>
- <td class="value">
- <%= form.text_field :hours, :size => 3, :maxlength => 5 %>
- <%= error_message_on(:task, :hours) %>
- </td>
+ <td class="label-required">Hours:</td>
+ <td class="value">
+ <%= form.text_field :hours, :size => 3, :maxlength => 5 %>
+ <%= error_message_on(:task, :hours) %>
+ </td>
</tr>
<tr>
- <td class="label">
+ <td class="label">
Description:
(you can use wiki markup)
</td>
- <td class="value">
- <%= form.text_area :description, :cols => 100, :rows => 10 %>
- <%= error_message_on(:task, :description) %>
- </td>
+ <td class="value">
+ <%= form.text_area :description, :cols => 100, :rows => 10 %>
+ <%= error_message_on(:task, :description) %>
+ </td>
</tr>
+
+ <% if @backlog_item.can_complete?(@user, true) %>
<tr>
- <td class="buttons" colspan="2">
- <% if @task.new_record? %>
- <%= submit_tag "Create", :class => 'button' %>
- <% else %>
- <%= submit_tag "Update", :class => 'button' %>
- <% end %>
- </td>
+ <td class="label" />
+ <td class="value">
+ <%= check_box_tag :completed, true, params[:completed] %>
+ <%= label_tag :completed, "Mark this item as completed." %>
+ </td>
+ </tr>
+ <% end %>
+
+ <tr>
+ <td class="buttons" colspan="2">
+ <% if @task.new_record? %>
+ <%= submit_tag "Create", :class => 'button' %>
+ <% else %>
+ <%= submit_tag "Update", :class => 'button' %>
+ <% end %>
+ </td>
</tr>
</tbody>
</table>
diff --git a/doc/ChangeLog b/doc/ChangeLog
index 6ad0279..8169802 100644
--- a/doc/ChangeLog
+++ b/doc/ChangeLog
@@ -1,5 +1,6 @@
Change Log (0.3.0):
* #156 - Backlog items can be marked as blocked.
+ * #157 - Items can be marked completed when a task is added.
* #167 - Blocker messages are included in the daily updates email.
* #175 - Fixed display of products for an unapproved project. (BUG)
* #176 - Added a breadcrumb trail to the navigation bar.
diff --git a/test/functional/tasks_controller_test.rb b/test/functional/tasks_controller_test.rb
index 5ecf7ee..24eaa29 100644
--- a/test/functional/tasks_controller_test.rb
+++ b/test/functional/tasks_controller_test.rb
@@ -226,6 +226,17 @@ class TasksControllerTest < ActionController::TestCase
result = Task.find_by_description(@new_task[:description])
assert result, "Task should have been created."
assert_redirected_to task_path(result)
+ assert !result.backlog_item.completed?, "Backlog item should not have been marked completed."
+ end
+
+ # Ensures that creating a task that marks the item completed works as expected.
+ def test_create_and_complete
+ post :create, {:item => @item.id, :task => @new_task, :completed => true}, {:user_id => @owner.id}
+
+ result = Task.find_by_description(@new_task[:description])
+ assert result, "Task should have been created."
+ assert_redirected_to task_path(result)
+ assert result.backlog_item.completed?, "Item should have been marked as completed."
end
# Ensures anonymous users can't update a task.
diff --git a/test/functional/user_items_test.rb b/test/functional/user_items_test.rb
index ae17fb1..d49bd44 100644
--- a/test/functional/user_items_test.rb
+++ b/test/functional/user_items_test.rb
@@ -193,12 +193,12 @@ class UserItemsTest < ActionController::TestCase
# Ensures that completing works as expected.
def test_complete
- get :complete,{:id => @owned_item.id}, {:user_id => @member.id}
+ get :complete,{:id => @owned_item.id}, {:user_id => @owned_item.owner_id}
assert_redirected_to items_path(:sprint => @sprint)
result = BacklogItem.find_by_id((a)owned_item.id)
assert_equal BacklogItem::STATE_COMPLETED, result.state,
- "Items hould be marked as completed."
+ "Item should be marked as completed."
end
# Ensures an anonymous user can't reopen an item.
--
1.6.0.6
15 years