diff options
author | Jean-Philippe Lang <jp_lang@yahoo.fr> | 2013-07-13 09:20:11 +0000 |
---|---|---|
committer | Jean-Philippe Lang <jp_lang@yahoo.fr> | 2013-07-13 09:20:11 +0000 |
commit | 628d05629b734371d3e850a95dadf0be30c5ef20 (patch) | |
tree | 58a9da4e8266ee45a0800996f9228e9d2a45108c /app/models | |
parent | a74d55edd99a4bae23e7d9cbd76136ffa7707ccf (diff) | |
download | redmine-628d05629b734371d3e850a95dadf0be30c5ef20.tar.gz redmine-628d05629b734371d3e850a95dadf0be30c5ef20.zip |
Role-based issue custom field visibility (#5037).
git-svn-id: svn+ssh://rubyforge.org/var/svn/redmine/trunk@12012 e93f8b46-1217-0410-a6f0-8f06a7374b81
Diffstat (limited to 'app/models')
-rw-r--r-- | app/models/custom_field.rb | 52 | ||||
-rw-r--r-- | app/models/issue.rb | 30 | ||||
-rw-r--r-- | app/models/issue_custom_field.rb | 10 | ||||
-rw-r--r-- | app/models/issue_observer.rb | 2 | ||||
-rw-r--r-- | app/models/issue_query.rb | 4 | ||||
-rw-r--r-- | app/models/journal.rb | 28 | ||||
-rw-r--r-- | app/models/journal_observer.rb | 2 | ||||
-rw-r--r-- | app/models/mail_handler.rb | 2 | ||||
-rw-r--r-- | app/models/mailer.rb | 88 | ||||
-rw-r--r-- | app/models/query.rb | 22 | ||||
-rw-r--r-- | app/models/role.rb | 1 | ||||
-rw-r--r-- | app/models/time_entry_query.rb | 4 |
12 files changed, 194 insertions, 51 deletions
diff --git a/app/models/custom_field.rb b/app/models/custom_field.rb index c15192388..c2b717d0e 100644 --- a/app/models/custom_field.rb +++ b/app/models/custom_field.rb @@ -19,6 +19,7 @@ class CustomField < ActiveRecord::Base include Redmine::SubclassFactory has_many :custom_values, :dependent => :delete_all + has_and_belongs_to_many :roles, :join_table => "#{table_name_prefix}custom_fields_roles#{table_name_suffix}", :foreign_key => "custom_field_id" acts_as_list :scope => 'type = \'#{self.class}\'' serialize :possible_values @@ -26,12 +27,31 @@ class CustomField < ActiveRecord::Base validates_uniqueness_of :name, :scope => :type validates_length_of :name, :maximum => 30 validates_inclusion_of :field_format, :in => Redmine::CustomFieldFormat.available_formats - validate :validate_custom_field + before_validation :set_searchable after_save :handle_multiplicity_change + after_save do |field| + if field.visible_changed? && field.visible + field.roles.clear + end + end scope :sorted, lambda { order("#{table_name}.position ASC") } + scope :visible, lambda {|*args| + user = args.shift || User.current + if user.admin? + # nop + elsif user.memberships.any? + where("#{table_name}.visible = ? OR #{table_name}.id IN (SELECT DISTINCT cfr.custom_field_id FROM #{Member.table_name} m" + + " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" + + " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" + + " WHERE m.user_id = ?)", + true, user.id) + else + where(:visible => true) + end + } CUSTOM_FIELDS_TABS = [ {:name => 'IssueCustomField', :partial => 'custom_fields/index', @@ -215,6 +235,7 @@ class CustomField < ActiveRecord::Base " ON #{join_alias}.customized_type = '#{self.class.customized_class.base_class.name}'" + " AND #{join_alias}.customized_id = #{self.class.customized_class.table_name}.id" + " AND #{join_alias}.custom_field_id = #{id}" + + " AND (#{visibility_by_project_condition})" + " AND #{join_alias}.value <> ''" + " AND #{join_alias}.id = (SELECT max(#{join_alias}_2.id) FROM #{CustomValue.table_name} #{join_alias}_2" + " WHERE #{join_alias}_2.customized_type = #{join_alias}.customized_type" + @@ -227,6 +248,7 @@ class CustomField < ActiveRecord::Base " ON #{join_alias}.customized_type = '#{self.class.customized_class.base_class.name}'" + " AND #{join_alias}.customized_id = #{self.class.customized_class.table_name}.id" + " AND #{join_alias}.custom_field_id = #{id}" + + " AND (#{visibility_by_project_condition})" + " AND #{join_alias}.value <> ''" + " AND #{join_alias}.id = (SELECT max(#{join_alias}_2.id) FROM #{CustomValue.table_name} #{join_alias}_2" + " WHERE #{join_alias}_2.customized_type = #{join_alias}.customized_type" + @@ -237,6 +259,7 @@ class CustomField < ActiveRecord::Base " ON #{join_alias}.customized_type = '#{self.class.customized_class.base_class.name}'" + " AND #{join_alias}.customized_id = #{self.class.customized_class.table_name}.id" + " AND #{join_alias}.custom_field_id = #{id}" + + " AND (#{visibility_by_project_condition})" + " AND #{join_alias}.id = (SELECT max(#{join_alias}_2.id) FROM #{CustomValue.table_name} #{join_alias}_2" + " WHERE #{join_alias}_2.customized_type = #{join_alias}.customized_type" + " AND #{join_alias}_2.customized_id = #{join_alias}.customized_id" + @@ -254,6 +277,33 @@ class CustomField < ActiveRecord::Base join_alias + "_" + field_format end + def visibility_by_project_condition(project_key=nil, user=User.current) + if visible? || user.admin? + "1=1" + elsif user.anonymous? + "1=0" + else + project_key ||= "#{self.class.customized_class.table_name}.project_id" + "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" + + " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" + + " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" + + " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id})" + end + end + + def self.visibility_condition + if user.admin? + "1=1" + elsif user.anonymous? + "#{table_name}.visible" + else + "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" + + " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" + + " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" + + " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id})" + end + end + def <=>(field) position <=> field.position end diff --git a/app/models/issue.rb b/app/models/issue.rb index ccedcfd4c..69ace9f31 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -198,6 +198,13 @@ class Issue < ActiveRecord::Base (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : [] end + def visible_custom_field_values(user=nil) + user_real = user || User.current + custom_field_values.select do |value| + value.custom_field.visible_by?(project, user_real) + end + end + # Copies attributes from another issue, arg can be an id or an Issue def copy_from(arg, options={}) issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg) @@ -445,11 +452,13 @@ class Issue < ActiveRecord::Base end if attrs['custom_field_values'].present? - attrs['custom_field_values'] = attrs['custom_field_values'].reject {|k, v| read_only_attribute_names(user).include? k.to_s} + editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s} + attrs['custom_field_values'] = attrs['custom_field_values'].select {|k, v| editable_custom_field_ids.include? k.to_s} end if attrs['custom_fields'].present? - attrs['custom_fields'] = attrs['custom_fields'].reject {|c| read_only_attribute_names(user).include? c['id'].to_s} + editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s} + attrs['custom_fields'] = attrs['custom_fields'].select {|c| editable_custom_field_ids.include? c['id'].to_s} end # mass-assignment security bypass @@ -462,7 +471,7 @@ class Issue < ActiveRecord::Base # Returns the custom_field_values that can be edited by the given user def editable_custom_field_values(user=nil) - custom_field_values.reject do |value| + visible_custom_field_values(user).reject do |value| read_only_attribute_names(user).include?(value.custom_field_id.to_s) end end @@ -790,6 +799,21 @@ class Issue < ActiveRecord::Base notified_users.collect(&:mail) end + def each_notification(users, &block) + if users.any? + if custom_field_values.detect {|value| !value.custom_field.visible?} + users_by_custom_field_visibility = users.group_by do |user| + visible_custom_field_values(user).map(&:custom_field_id).sort + end + users_by_custom_field_visibility.values.each do |users| + yield(users) + end + else + yield(users) + end + end + end + # Returns the number of hours spent on this issue def spent_hours @spent_hours ||= time_entries.sum(:hours) || 0 diff --git a/app/models/issue_custom_field.rb b/app/models/issue_custom_field.rb index e7ea9c0b3..9ddedc882 100644 --- a/app/models/issue_custom_field.rb +++ b/app/models/issue_custom_field.rb @@ -23,5 +23,13 @@ class IssueCustomField < CustomField def type_name :label_issue_plural end -end + def visible_by?(project, user=User.current) + visible? || user.admin? || (roles & user.roles_for_project(project)).present? + end + + def validate_custom_field + super + errors.add(:base, l(:label_role_plural) + ' ' + l('activerecord.errors.messages.blank')) unless visible? || roles.present? + end +end diff --git a/app/models/issue_observer.rb b/app/models/issue_observer.rb index a75194286..58b636031 100644 --- a/app/models/issue_observer.rb +++ b/app/models/issue_observer.rb @@ -17,6 +17,6 @@ class IssueObserver < ActiveRecord::Observer def after_create(issue) - Mailer.issue_add(issue).deliver if Setting.notified_events.include?('issue_added') + Mailer.deliver_issue_add(issue) if Setting.notified_events.include?('issue_added') end end diff --git a/app/models/issue_query.rb b/app/models/issue_query.rb index b2e470d7a..323e46f4c 100644 --- a/app/models/issue_query.rb +++ b/app/models/issue_query.rb @@ -225,8 +225,8 @@ class IssueQuery < Query @available_columns = self.class.available_columns.dup @available_columns += (project ? project.all_issue_custom_fields : - IssueCustomField.all - ).collect {|cf| QueryCustomFieldColumn.new(cf) } + IssueCustomField + ).visible.collect {|cf| QueryCustomFieldColumn.new(cf) } if User.current.allowed_to?(:view_time_entries, project, :global => true) index = nil diff --git a/app/models/journal.rb b/app/models/journal.rb index a75c112db..c14051f83 100644 --- a/app/models/journal.rb +++ b/app/models/journal.rb @@ -53,6 +53,18 @@ class Journal < ActiveRecord::Base (details.empty? && notes.blank?) ? false : super end + def visible_details(user=User.current) + details.select do |detail| + if detail.property == 'cf' + field_id = detail.prop_key + field = CustomField.find_by_id(field_id) + field && field.visible_by?(project, user) + else + true + end + end + end + # Returns the new status if the journal contains a status change, otherwise nil def new_status c = details.detect {|detail| detail.prop_key == 'status_id'} @@ -93,20 +105,28 @@ class Journal < ActiveRecord::Base @notify = arg end - def recipients + def notified_users notified = journalized.notified_users if private_notes? notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)} end - notified.map(&:mail) + notified end - def watcher_recipients + def recipients + notified_users.map(&:mail) + end + + def notified_watchers notified = journalized.notified_watchers if private_notes? notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)} end - notified.map(&:mail) + notified + end + + def watcher_recipients + notified_watchers.map(&:mail) end private diff --git a/app/models/journal_observer.rb b/app/models/journal_observer.rb index fe937de07..ff4b89837 100644 --- a/app/models/journal_observer.rb +++ b/app/models/journal_observer.rb @@ -23,7 +23,7 @@ class JournalObserver < ActiveRecord::Observer (Setting.notified_events.include?('issue_status_updated') && journal.new_status.present?) || (Setting.notified_events.include?('issue_priority_updated') && journal.new_value_for('priority_id').present?) ) - Mailer.issue_edit(journal).deliver + Mailer.deliver_issue_edit(journal) end end end diff --git a/app/models/mail_handler.rb b/app/models/mail_handler.rb index 90850dffb..fb30374bd 100644 --- a/app/models/mail_handler.rb +++ b/app/models/mail_handler.rb @@ -136,7 +136,7 @@ class MailHandler < ActionMailer::Base private - MESSAGE_ID_RE = %r{^<?redmine\.([a-z0-9_]+)\-(\d+)\.\d+@} + MESSAGE_ID_RE = %r{^<?redmine\.([a-z0-9_]+)\-(\d+)\.\d+(\.[a-f0-9]+)?@} ISSUE_REPLY_SUBJECT_RE = %r{\[[^\]]*#(\d+)\]} MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]*msg(\d+)\]} diff --git a/app/models/mailer.rb b/app/models/mailer.rb index 6e07d61f7..6a8aded13 100644 --- a/app/models/mailer.rb +++ b/app/models/mailer.rb @@ -27,34 +27,35 @@ class Mailer < ActionMailer::Base { :host => Setting.host_name, :protocol => Setting.protocol } end - # Builds a Mail::Message object used to email recipients of the added issue. - # - # Example: - # issue_add(issue) => Mail::Message object - # Mailer.issue_add(issue).deliver => sends an email to issue recipients - def issue_add(issue) + # Builds a mail for notifying to_users and cc_users about a new issue + def issue_add(issue, to_users, cc_users) redmine_headers 'Project' => issue.project.identifier, 'Issue-Id' => issue.id, 'Issue-Author' => issue.author.login redmine_headers 'Issue-Assignee' => issue.assigned_to.login if issue.assigned_to message_id issue + references issue @author = issue.author @issue = issue + @users = to_users + cc_users @issue_url = url_for(:controller => 'issues', :action => 'show', :id => issue) - recipients = issue.recipients - cc = issue.watcher_recipients - recipients - mail :to => recipients, - :cc => cc, + mail :to => to_users.map(&:mail), + :cc => cc_users.map(&:mail), :subject => "[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}] (#{issue.status.name}) #{issue.subject}" end - # Builds a Mail::Message object used to email recipients of the edited issue. - # - # Example: - # issue_edit(journal) => Mail::Message object - # Mailer.issue_edit(journal).deliver => sends an email to issue recipients - def issue_edit(journal) - issue = journal.journalized.reload + # Notifies users about a new issue + def self.deliver_issue_add(issue) + to = issue.notified_users + cc = issue.notified_watchers - to + issue.each_notification(to + cc) do |users| + Mailer.issue_add(issue, to & users, cc & users).deliver + end + end + + # Builds a mail for notifying to_users and cc_users about an issue update + def issue_edit(journal, to_users, cc_users) + issue = journal.journalized redmine_headers 'Project' => issue.project.identifier, 'Issue-Id' => issue.id, 'Issue-Author' => issue.author.login @@ -62,20 +63,30 @@ class Mailer < ActionMailer::Base message_id journal references issue @author = journal.user - recipients = journal.recipients - # Watchers in cc - cc = journal.watcher_recipients - recipients s = "[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}] " s << "(#{issue.status.name}) " if journal.new_value_for('status_id') s << issue.subject @issue = issue + @users = to_users + cc_users @journal = journal + @journal_details = journal.visible_details(@users.first) @issue_url = url_for(:controller => 'issues', :action => 'show', :id => issue, :anchor => "change-#{journal.id}") - mail :to => recipients, - :cc => cc, + mail :to => to_users.map(&:mail), + :cc => cc_users.map(&:mail), :subject => s end + # Notifies users about an issue update + def self.deliver_issue_edit(journal) + issue = journal.journalized.reload + to = journal.notified_users + cc = journal.notified_watchers + issue.each_notification(to + cc) do |users| + next unless journal.notes? || journal.visible_details(users.first).any? + Mailer.issue_edit(journal, to & users, cc & users).deliver + end + end + def reminder(user, issues, days) set_language_if_valid user.language @issues = issues @@ -142,6 +153,7 @@ class Mailer < ActionMailer::Base redmine_headers 'Project' => news.project.identifier @author = news.author message_id news + references news @news = news @news_url = url_for(:controller => 'news', :action => 'show', :id => news) mail :to => news.recipients, @@ -158,6 +170,7 @@ class Mailer < ActionMailer::Base redmine_headers 'Project' => news.project.identifier @author = comment.author message_id comment + references news @news = news @comment = comment @news_url = url_for(:controller => 'news', :action => 'show', :id => news) @@ -176,7 +189,7 @@ class Mailer < ActionMailer::Base 'Topic-Id' => (message.parent_id || message.id) @author = message.author message_id message - references message.parent unless message.parent.nil? + references message.root recipients = message.recipients cc = ((message.root.watcher_recipients + message.board.watcher_recipients).uniq - recipients) @message = message @@ -386,7 +399,7 @@ class Mailer < ActionMailer::Base headers[:message_id] = "<#{self.class.message_id_for(@message_id_object)}>" end if @references_objects - headers[:references] = @references_objects.collect {|o| "<#{self.class.message_id_for(o)}>"}.join(' ') + headers[:references] = @references_objects.collect {|o| "<#{self.class.references_for(o)}>"}.join(' ') end super headers do |format| @@ -434,15 +447,30 @@ class Mailer < ActionMailer::Base h.each { |k,v| headers["X-Redmine-#{k}"] = v.to_s } end - # Returns a predictable Message-Id for the given object - def self.message_id_for(object) - # id + timestamp should reduce the odds of a collision - # as far as we don't send multiple emails for the same object + def self.token_for(object, rand=true) timestamp = object.send(object.respond_to?(:created_on) ? :created_on : :updated_on) - hash = "redmine.#{object.class.name.demodulize.underscore}-#{object.id}.#{timestamp.strftime("%Y%m%d%H%M%S")}" + hash = [ + "redmine", + "#{object.class.name.demodulize.underscore}-#{object.id}", + timestamp.strftime("%Y%m%d%H%M%S") + ] + if rand + hash << Redmine::Utils.random_hex(8) + end host = Setting.mail_from.to_s.gsub(%r{^.*@}, '') host = "#{::Socket.gethostname}.redmine" if host.empty? - "#{hash}@#{host}" + "#{hash.join('.')}@#{host}" + end + + # Returns a Message-Id for the given object + def self.message_id_for(object) + token_for(object, true) + end + + # Returns a uniq token for a given object referenced by all notifications + # related to this object + def self.references_for(object) + token_for(object, false) end def message_id(object) diff --git a/app/models/query.rb b/app/models/query.rb index 2753c2768..641a263c6 100644 --- a/app/models/query.rb +++ b/app/models/query.rb @@ -81,8 +81,12 @@ class QueryCustomFieldColumn < QueryColumn end def value(object) - cv = object.custom_values.select {|v| v.custom_field_id == @cf.id}.collect {|v| @cf.cast_value(v.value)} - cv.size > 1 ? cv.sort {|a,b| a.to_s <=> b.to_s} : cv.first + if custom_field.visible_by?(object.project, User.current) + cv = object.custom_values.select {|v| v.custom_field_id == @cf.id}.collect {|v| @cf.cast_value(v.value)} + cv.size > 1 ? cv.sort {|a,b| a.to_s <=> b.to_s} : cv.first + else + nil + end end def css_classes @@ -560,6 +564,11 @@ class Query < ActiveRecord::Base end end if filters and valid? + if (c = group_by_column) && c.is_a?(QueryCustomFieldColumn) + # Excludes results for which the grouped custom field is not visible + filters_clauses << c.custom_field.visibility_by_project_condition + end + filters_clauses << project_statement filters_clauses.reject!(&:blank?) @@ -596,7 +605,10 @@ class Query < ActiveRecord::Base if operator =~ /[<>]/ where = "(#{where}) AND #{db_table}.#{db_field} <> ''" end - "#{queried_table_name}.#{customized_key} #{not_in} IN (SELECT #{customized_class.table_name}.id FROM #{customized_class.table_name} LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='#{customized_class}' AND #{db_table}.customized_id=#{customized_class.table_name}.id AND #{db_table}.custom_field_id=#{custom_field_id} WHERE #{where})" + "#{queried_table_name}.#{customized_key} #{not_in} IN (" + + "SELECT #{customized_class.table_name}.id FROM #{customized_class.table_name}" + + " LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='#{customized_class}' AND #{db_table}.customized_id=#{customized_class.table_name}.id AND #{db_table}.custom_field_id=#{custom_field_id}" + + " WHERE (#{where}) AND (#{filter[:field].visibility_by_project_condition}))" end # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+ @@ -785,14 +797,14 @@ class Query < ActiveRecord::Base # Adds filters for the given custom fields scope def add_custom_fields_filters(scope, assoc=nil) - scope.where(:is_filter => true).sorted.each do |field| + scope.visible.where(:is_filter => true).sorted.each do |field| add_custom_field_filter(field, assoc) end end # Adds filters for the given associations custom fields def add_associations_custom_fields_filters(*associations) - fields_by_class = CustomField.where(:is_filter => true).group_by(&:class) + fields_by_class = CustomField.visible.where(:is_filter => true).group_by(&:class) associations.each do |assoc| association_klass = queried_class.reflect_on_association(assoc).klass fields_by_class.each do |field_class, fields| diff --git a/app/models/role.rb b/app/models/role.rb index d24103663..751ec6eb4 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -52,6 +52,7 @@ class Role < ActiveRecord::Base WorkflowRule.copy(nil, source_role, nil, proxy_association.owner) end end + has_and_belongs_to_many :custom_fields, :join_table => "#{table_name_prefix}custom_fields_roles#{table_name_suffix}", :foreign_key => "role_id" has_many :member_roles, :dependent => :destroy has_many :members, :through => :member_roles diff --git a/app/models/time_entry_query.rb b/app/models/time_entry_query.rb index dbccea03d..3a6ab64ef 100644 --- a/app/models/time_entry_query.rb +++ b/app/models/time_entry_query.rb @@ -91,8 +91,8 @@ class TimeEntryQuery < Query def available_columns return @available_columns if @available_columns @available_columns = self.class.available_columns.dup - @available_columns += TimeEntryCustomField.all.map {|cf| QueryCustomFieldColumn.new(cf) } - @available_columns += IssueCustomField.all.map {|cf| QueryAssociationCustomFieldColumn.new(:issue, cf) } + @available_columns += TimeEntryCustomField.visible.all.map {|cf| QueryCustomFieldColumn.new(cf) } + @available_columns += IssueCustomField.visible.all.map {|cf| QueryAssociationCustomFieldColumn.new(:issue, cf) } @available_columns end |