diff --git a/app/controllers/watchers_controller.rb b/app/controllers/watchers_controller.rb
new file mode 100644
index 000000000..09ec5bcd7
--- /dev/null
+++ b/app/controllers/watchers_controller.rb
@@ -0,0 +1,38 @@
+# redMine - project management software
+# Copyright (C) 2006-2007 Jean-Philippe Lang
+#
+# 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 2
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+class WatchersController < ApplicationController
+ layout 'base'
+ before_filter :require_login, :find_project, :check_project_privacy
+
+ def add
+ @issue.add_watcher(logged_in_user)
+ redirect_to :controller => 'issues', :action => 'show', :id => @issue
+ end
+
+ def remove
+ @issue.remove_watcher(logged_in_user)
+ redirect_to :controller => 'issues', :action => 'show', :id => @issue
+ end
+
+private
+
+ def find_project
+ @issue = Issue.find(params[:issue_id])
+ @project = @issue.project
+ end
+end
diff --git a/app/helpers/watchers_helper.rb b/app/helpers/watchers_helper.rb
new file mode 100644
index 000000000..23f767611
--- /dev/null
+++ b/app/helpers/watchers_helper.rb
@@ -0,0 +1,19 @@
+# redMine - project management software
+# Copyright (C) 2006-2007 Jean-Philippe Lang
+#
+# 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 2
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+module WatchersHelper
+end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index bb7797c40..0f44cdd30 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -32,6 +32,8 @@ class Issue < ActiveRecord::Base
has_many :custom_values, :dependent => :delete_all, :as => :customized
has_many :custom_fields, :through => :custom_values
+ acts_as_watchable
+
validates_presence_of :subject, :description, :priority, :tracker, :author, :status
validates_inclusion_of :done_ratio, :in => 0..100
validates_associated :custom_values, :on => :update
diff --git a/app/models/mailer.rb b/app/models/mailer.rb
index 36bcddc2a..5d835289a 100644
--- a/app/models/mailer.rb
+++ b/app/models/mailer.rb
@@ -32,6 +32,8 @@ class Mailer < ActionMailer::Base
# Sends to all project members
issue = journal.journalized
@recipients = issue.project.members.collect { |m| m.user.mail if m.user.mail_notification }.compact
+ # Watchers in cc
+ @cc = issue.watcher_recipients - @recipients
@from = Setting.mail_from
@subject = "[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}] #{issue.status.name} - #{issue.subject}"
@body['issue'] = issue
diff --git a/app/models/watcher.rb b/app/models/watcher.rb
new file mode 100644
index 000000000..cb6ff52ea
--- /dev/null
+++ b/app/models/watcher.rb
@@ -0,0 +1,23 @@
+# redMine - project management software
+# Copyright (C) 2006-2007 Jean-Philippe Lang
+#
+# 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 2
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+class Watcher < ActiveRecord::Base
+ belongs_to :watchable, :polymorphic => true
+ belongs_to :user
+
+ validates_uniqueness_of :user_id, :scope => [:watchable_type, :watchable_id]
+end
diff --git a/app/views/issues/show.rhtml b/app/views/issues/show.rhtml
index a5569c9d4..3c1ac0e2e 100644
--- a/app/views/issues/show.rhtml
+++ b/app/views/issues/show.rhtml
@@ -53,6 +53,13 @@ end %>
<%= link_to_if_authorized l(:button_edit), {:controller => 'issues', :action => 'edit', :id => @issue}, :class => 'icon icon-edit' %>
<%= link_to_if_authorized l(:button_log_time), {:controller => 'timelog', :action => 'edit', :issue_id => @issue}, :class => 'icon icon-time' %>
+<% if @logged_in_user %>
+ <% if @issue.watched_by?(@logged_in_user) %>
+<%= link_to l(:button_unwatch), {:controller => 'watchers', :action => 'remove', :issue_id => @issue}, :class => 'icon icon-fav' %>
+ <% else %>
+<%= link_to l(:button_watch), {:controller => 'watchers', :action => 'add', :issue_id => @issue}, :class => 'icon icon-fav' %>
+ <% end %>
+<% end %>
<%= link_to_if_authorized l(:button_move), {:controller => 'projects', :action => 'move_issues', :id => @project, "issue_ids[]" => @issue.id }, :class => 'icon icon-move' %>
<%= link_to_if_authorized l(:button_delete), {:controller => 'issues', :action => 'destroy', :id => @issue}, :confirm => l(:text_are_you_sure), :method => :post, :class => 'icon icon-del' %>
diff --git a/config/environment.rb b/config/environment.rb
index a73dc9a4c..8ff43e0ac 100644
--- a/config/environment.rb
+++ b/config/environment.rb
@@ -82,4 +82,5 @@ GLoc.set_kcode
GLoc.load_localized_strings
GLoc.set_config(:raise_string_not_found_errors => false)
+require 'redmine'
diff --git a/lang/de.yml b/lang/de.yml
index 95ae4a635..590e5e0d5 100644
--- a/lang/de.yml
+++ b/lang/de.yml
@@ -383,6 +383,8 @@ button_activate: Aktivieren
button_sort: Sortieren
button_log_time: Log time
button_rollback: Rollback to this version
+button_watch: Watch
+button_unwatch: Unwatch
status_active: aktiv
status_registered: angemeldet
diff --git a/lang/en.yml b/lang/en.yml
index 0e66a1faf..e40c915f2 100644
--- a/lang/en.yml
+++ b/lang/en.yml
@@ -383,6 +383,8 @@ button_activate: Activate
button_sort: Sort
button_log_time: Log time
button_rollback: Rollback to this version
+button_watch: Watch
+button_unwatch: Unwatch
status_active: active
status_registered: registered
diff --git a/lang/es.yml b/lang/es.yml
index a245d05fb..103c399c0 100644
--- a/lang/es.yml
+++ b/lang/es.yml
@@ -383,6 +383,8 @@ button_activate: Activar
button_sort: Clasificar
button_log_time: Log time
button_rollback: Rollback to this version
+button_watch: Watch
+button_unwatch: Unwatch
status_active: active
status_registered: registered
diff --git a/lang/fr.yml b/lang/fr.yml
index 5cc05ef64..d6666169c 100644
--- a/lang/fr.yml
+++ b/lang/fr.yml
@@ -383,6 +383,8 @@ button_activate: Activer
button_sort: Trier
button_log_time: Saisir temps
button_rollback: Revenir à cette version
+button_watch: Surveiller
+button_unwatch: Ne plus surveiller
status_active: actif
status_registered: enregistré
diff --git a/lang/it.yml b/lang/it.yml
index 6beb963ed..6af04b2f4 100644
--- a/lang/it.yml
+++ b/lang/it.yml
@@ -383,6 +383,8 @@ button_activate: Attiva
button_sort: Ordina
button_log_time: Log time
button_rollback: Rollback to this version
+button_watch: Watch
+button_unwatch: Unwatch
status_active: active
status_registered: registered
diff --git a/lang/ja.yml b/lang/ja.yml
index 8bcf77fe3..5cc9bb907 100644
--- a/lang/ja.yml
+++ b/lang/ja.yml
@@ -384,6 +384,8 @@ button_activate: 有効にする
button_sort: ソート
button_log_time: 時間を記録
button_rollback: このバージョンにロールバック
+button_watch: Watch
+button_unwatch: Unwatch
status_active: 有効
status_registered: 登録
diff --git a/lang/zh.yml b/lang/zh.yml
index be1c91f8a..78e093d59 100644
--- a/lang/zh.yml
+++ b/lang/zh.yml
@@ -386,6 +386,8 @@ button_activate: 激活
button_sort: 排序
button_log_time: 登记工时
button_rollback: Rollback to this version
+button_watch: Watch
+button_unwatch: Unwatch
status_active: 激活
status_registered: 已注册
diff --git a/lib/redmine.rb b/lib/redmine.rb
index 20b30c037..cfcccccf9 100644
--- a/lib/redmine.rb
+++ b/lib/redmine.rb
@@ -1,11 +1,2 @@
-module Redmine
- module VERSION #:nodoc:
- MAJOR = 0
- MINOR = 5
- TINY = 0
-
- STRING= [MAJOR, MINOR, TINY].join('.')
-
- def self.to_s; STRING end
- end
-end
\ No newline at end of file
+require 'redmine/version'
+require 'redmine/acts_as_watchable/init'
diff --git a/lib/redmine/acts_as_watchable/init.rb b/lib/redmine/acts_as_watchable/init.rb
new file mode 100644
index 000000000..f39cc7d18
--- /dev/null
+++ b/lib/redmine/acts_as_watchable/init.rb
@@ -0,0 +1,3 @@
+# Include hook code here
+require File.dirname(__FILE__) + '/lib/acts_as_watchable'
+ActiveRecord::Base.send(:include, Redmine::Acts::Watchable)
diff --git a/lib/redmine/acts_as_watchable/lib/acts_as_watchable.rb b/lib/redmine/acts_as_watchable/lib/acts_as_watchable.rb
new file mode 100644
index 000000000..d62742cac
--- /dev/null
+++ b/lib/redmine/acts_as_watchable/lib/acts_as_watchable.rb
@@ -0,0 +1,53 @@
+# ActsAsWatchable
+module Redmine
+ module Acts
+ module Watchable
+ def self.included(base)
+ base.extend ClassMethods
+ end
+
+ module ClassMethods
+ def acts_as_watchable(options = {})
+ return if self.included_modules.include?(Redmine::Acts::Watchable::InstanceMethods)
+ send :include, Redmine::Acts::Watchable::InstanceMethods
+
+ class_eval do
+ has_many :watchers, :as => :watchable, :dependent => :delete_all
+ end
+ end
+ end
+
+ module InstanceMethods
+ def self.included(base)
+ base.extend ClassMethods
+ end
+
+ def add_watcher(user)
+ self.watchers << Watcher.new(:user => user)
+ end
+
+ def remove_watcher(user)
+ return nil unless user && user.is_a?(User)
+ Watcher.delete_all "watchable_type = '#{self.class}' AND watchable_id = #{self.id} AND user_id = #{user.id}"
+ end
+
+ def watched_by?(user)
+ !self.watchers.find(:first,
+ :conditions => ["#{Watcher.table_name}.user_id = ?", user.id]).nil?
+ end
+
+ def watcher_recipients
+ self.watchers.collect { |w| w.user.mail if w.user.mail_notification }.compact
+ end
+
+ module ClassMethods
+ def watched_by(user)
+ find(:all,
+ :include => :watchers,
+ :conditions => ["#{Watcher.table_name}.user_id = ?", user.id])
+ end
+ end
+ end
+ end
+ end
+end
\ No newline at end of file
diff --git a/lib/redmine/version.rb b/lib/redmine/version.rb
new file mode 100644
index 000000000..630fb1ff8
--- /dev/null
+++ b/lib/redmine/version.rb
@@ -0,0 +1,11 @@
+module Redmine
+ module VERSION #:nodoc:
+ MAJOR = 0
+ MINOR = 5
+ TINY = 0
+
+ STRING= [MAJOR, MINOR, TINY].join('.')
+
+ def self.to_s; STRING end
+ end
+end
diff --git a/public/images/fav.png b/public/images/fav.png
new file mode 100644
index 000000000..49c0f473a
Binary files /dev/null and b/public/images/fav.png differ
diff --git a/public/stylesheets/application.css b/public/stylesheets/application.css
index 7461c74a3..ced43768d 100644
--- a/public/stylesheets/application.css
+++ b/public/stylesheets/application.css
@@ -158,6 +158,7 @@ vertical-align: middle;
.icon-time { background-image: url(../images/time.png); }
.icon-stats { background-image: url(../images/stats.png); }
.icon-warning { background-image: url(../images/warning.png); }
+.icon-fav { background-image: url(../images/fav.png); }
.icon22-projects { background-image: url(../images/22x22/projects.png); }
.icon22-users { background-image: url(../images/22x22/users.png); }
diff --git a/test/unit/watcher_test.rb b/test/unit/watcher_test.rb
new file mode 100644
index 000000000..b8a095426
--- /dev/null
+++ b/test/unit/watcher_test.rb
@@ -0,0 +1,69 @@
+# redMine - project management software
+# Copyright (C) 2006-2007 Jean-Philippe Lang
+#
+# 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 2
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+require File.dirname(__FILE__) + '/../test_helper'
+
+class WatcherTest < Test::Unit::TestCase
+ fixtures :issues, :users
+
+ def setup
+ @user = User.find(1)
+ @issue = Issue.find(1)
+ end
+
+ def test_watch
+ assert @issue.add_watcher(@user)
+ @issue.reload
+ assert @issue.watchers.detect { |w| w.user == @user }
+ end
+
+ def test_cant_watch_twice
+ assert @issue.add_watcher(@user)
+ assert !@issue.add_watcher(@user)
+ end
+
+ def test_watched_by
+ assert @issue.add_watcher(@user)
+ @issue.reload
+ assert @issue.watched_by?(@user)
+ assert Issue.watched_by(@user).include?(@issue)
+ end
+
+ def test_recipients
+ @issue.watchers.delete_all
+ @issue.reload
+
+ assert @issue.watcher_recipients.empty?
+ assert @issue.add_watcher(@user)
+
+ @user.mail_notification = true
+ @user.save
+ @issue.reload
+ assert @issue.watcher_recipients.include?(@user.mail)
+
+ @user.mail_notification = false
+ @user.save
+ @issue.reload
+ assert !@issue.watcher_recipients.include?(@user.mail)
+ end
+
+ def test_unwatch
+ assert @issue.add_watcher(@user)
+ @issue.reload
+ assert_equal 1, @issue.remove_watcher(@user)
+ end
+end