Project

General

Profile

Patch #44274 » 0002-journal-decoupling-revised.patch

Florian Walchshofer, 2026-10-18 21:29

View differences:

app/controllers/journals_controller.rb
46 46
  end
47 47

  
48 48
  def diff
49
    @issue = @journal.issue
49
    @issue = @journal.journalized
50 50
    if params[:detail_id].present?
51 51
      @detail = @journal.details.find_by_id(params[:detail_id])
52 52
    else
......
100 100
  private
101 101

  
102 102
  def find_journal
103
    @journal = Journal.visible.find(params[:id])
104
    @project = @journal.journalized.project
103
    # Only issue journals are handled by this controller for now
104
    @journal = Journal.where(:journalized_type => 'Issue').visible.find(params[:id])
105
    @project = @journal.project
105 106
  rescue ActiveRecord::RecordNotFound
106 107
    render_404
107 108
  end
app/helpers/journals_helper.rb
30 30
  def render_journal_actions(issue, journal, options={})
31 31
    links = []
32 32
    dropbown_links = []
33
    indice = journal.indice || journal.issue.visible_journals_with_index.find{|j| j.id == journal.id}&.indice
33
    indice = journal.indice || journal.journalized.visible_journals_with_index.find{|j| j.id == journal.id}&.indice
34 34

  
35 35
    dropbown_links << copy_object_url_link(issue_url(issue, anchor: "note-#{indice}", only_path: false))
36 36
    if journal.attachments.size > 1
app/models/issue.rb
38 38
  belongs_to :priority, :class_name => 'IssuePriority'
39 39
  belongs_to :category, :class_name => 'IssueCategory'
40 40

  
41
  has_many :journals, :as => :journalized, :dependent => :destroy, :inverse_of => :journalized
42 41
  has_many :time_entries, :dependent => :destroy
43 42
  has_and_belongs_to_many :changesets, lambda {order("#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC")}
44 43

  
......
48 47
  acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
49 48
  acts_as_customizable
50 49
  acts_as_watchable
50
  acts_as_journalized :activity_type => 'issues',
51
                      :permissions => {
52
                        :edit_notes => :edit_issue_notes,
53
                        :edit_own_notes => :edit_own_issue_notes,
54
                        :add_watchers => :add_issue_watchers
55
                      }
51 56
  acts_as_searchable :columns => ['subject', "#{table_name}.description"],
52 57
                     :preload => [:project, :status, :tracker],
53 58
                     :scope => lambda {|options| options[:open_issues] ? self.open : self.all}
......
1304 1309
  def self.load_visible_last_updated_by(issues, user=User.current)
1305 1310
    if issues.any?
1306 1311
      issue_ids = issues.map(&:id)
1307
      journal_ids = Journal.joins(issue: :project).
1308
        where(:journalized_type => 'Issue', :journalized_id => issue_ids).
1312
      journal_ids = Journal.joins_journalized(Issue).
1313
        where(:journalized_id => issue_ids).
1309 1314
        where(Journal.visible_notes_condition(user, :skip_pre_condition => true)).
1310 1315
        group(:journalized_id).
1311 1316
        maximum(:id).
......
1323 1328
  def self.load_visible_last_notes(issues, user=User.current)
1324 1329
    if issues.any?
1325 1330
      issue_ids = issues.map(&:id)
1326
      journal_ids = Journal.joins(issue: :project).
1327
        where(:journalized_type => 'Issue', :journalized_id => issue_ids).
1331
      journal_ids = Journal.joins_journalized(Issue).
1332
        where(:journalized_id => issue_ids).
1328 1333
        where(Journal.visible_notes_condition(user, :skip_pre_condition => true)).
1329 1334
        where.not(notes: '').
1330 1335
        group(:journalized_id).
......
1721 1726
    end
1722 1727
  end
1723 1728

  
1729
  def self.journal_visible_join_condition(user=User.current, options={})
1730
    "(#{visible_condition(user, options)}) AND #{Journal.visible_notes_condition(user, :skip_pre_condition => true)}"
1731
  end
1732

  
1733
  def self.journal_activity_preload
1734
    [{:journalized => [:project, :tracker]}, :user]
1735
  end
1736

  
1737
  def self.journal_activity_scope(scope)
1738
    scope.joins("LEFT OUTER JOIN #{JournalDetail.table_name} ON #{JournalDetail.table_name}.journal_id = #{Journal.table_name}.id").
1739
      where("#{JournalDetail.table_name}.prop_key = 'status_id' OR #{Journal.table_name}.notes <> ''").distinct
1740
  end
1741

  
1742
  def journal_event_title(journal)
1743
    status = (s = journal_new_status(journal)) ? " (#{s})" : nil
1744
    "#{tracker} ##{id}#{status}: #{subject}"
1745
  end
1746

  
1747
  def journal_event_type(journal)
1748
    if (status = journal_new_status(journal))
1749
      status.is_closed? ? 'issue-closed' : 'issue-edit'
1750
    else
1751
      'issue-note'
1752
    end
1753
  end
1754

  
1755
  def journal_relation_visible?(detail, user)
1756
    Issue.find_by_id(detail.value || detail.old_value).try(:visible?, user)
1757
  end
1758

  
1759
  def journal_new_status(journal)
1760
    s = journal.new_value_for('status_id')
1761
    s ? IssueStatus.find_by_id(s.to_i) : nil
1762
  end
1763

  
1764
  def journal_notify(journal)
1765
    if Setting.notified_events.include?('issue_updated') ||
1766
       (Setting.notified_events.include?('issue_note_added') && journal.notes.present?) ||
1767
       (Setting.notified_events.include?('issue_status_updated') && journal_new_status(journal).present?) ||
1768
       (Setting.notified_events.include?('issue_assigned_to_updated') && journal.detail_for_attribute('assigned_to_id').present?) ||
1769
       (Setting.notified_events.include?('issue_priority_updated') && journal.new_value_for('priority_id').present?) ||
1770
       (Setting.notified_events.include?('issue_fixed_version_updated') && journal.detail_for_attribute('fixed_version_id').present?) ||
1771
       (Setting.notified_events.include?('issue_attachment_added') && journal.details.any? {|d| d.property == 'attachment' && d.value })
1772
      Mailer.deliver_issue_edit(journal)
1773
    end
1774
  end
1775

  
1776
  def journal_process_watchers(journal)
1777
    user = journal.user
1778
    if user.is_a?(User) &&
1779
       user.pref.auto_watch_on?('issue_contributed_to') &&
1780
       journal_valid_watcher?(user)
1781
      set_watcher(user, true)
1782
    end
1783

  
1784
    if assigned_to.is_a?(User) &&
1785
       assigned_to.pref.auto_watch_on?('issue_assigned_to_me') &&
1786
       journal_valid_watcher?(assigned_to)
1787
      set_watcher(assigned_to, true)
1788
    end
1789
  end
1790

  
1724 1791
  private
1725 1792

  
1726 1793
  def user_tracker_permission?(user, permission)
app/models/issue_query.rb
477 477
  # Returns the journals
478 478
  # Valid options are :order, :offset, :limit
479 479
  def journals(options={})
480
    Journal.visible.
481
      joins(:issue => [:project, :status]).
480
    Journal.joins_journalized(Issue).
481
      visible.
482
      joins("INNER JOIN #{IssueStatus.table_name} ON #{IssueStatus.table_name}.id = #{Issue.table_name}.status_id").
482 483
      where(statement).
483 484
      order(options[:order]).
484 485
      limit(options[:limit]).
485 486
      offset(options[:offset]).
486
      preload(:details, :user, {:issue => [:project, :author, :tracker, :status]}).
487
      preload(:details, :user, {:journalized => [:project, :author, :tracker, :status]}).
487 488
      to_a
488 489
  rescue ::ActiveRecord::StatementInvalid => e
489 490
    raise StatementInvalid.new(e.message)
app/models/journal.rb
21 21
  include Redmine::SafeAttributes
22 22
  include Redmine::Reaction::Reactable
23 23

  
24
  belongs_to :journalized, :polymorphic => true
25
  # added as a quick fix to allow eager loading of the polymorphic association
26
  # since always associated to an issue, for now
27
  belongs_to :issue, :foreign_key => :journalized_id
28

  
24
  belongs_to :journalized, :polymorphic => true, :inverse_of => :journals
29 25
  belongs_to :user
30 26
  belongs_to :updated_by, :class_name => 'User'
27

  
31 28
  has_many :details, lambda {order(:id)}, :class_name => "JournalDetail", :dependent => :delete_all, :inverse_of => :journal
32 29
  attr_accessor :indice
33 30

  
31
  # Returns journals of klass joined with the table of klass (and its project)
32
  def self.joins_journalized(klass)
33
    where(:journalized_type => klass.polymorphic_name).joins(*klass.journal_joins)
34
  end
35

  
36
  def self.activity_scope_for(klass)
37
    klass.journal_activity_scope(joins_journalized(klass).preload(*klass.journal_activity_preload))
38
  end
39

  
40
  def self.register_activity_provider(klass)
41
    # Resolve the class at runtime so that the scope survives code reloading
42
    class_name = klass.name
43
    acts_as_activity_provider(
44
      :type => klass.journalized_options[:activity_type],
45
      :author_key => :user_id,
46
      :scope => proc { activity_scope_for(class_name.constantize) }
47
    )
48
  end
49

  
34 50
  acts_as_event(
35
    :title =>
36
       Proc.new do |o|
37
         status = ((s = o.new_status) ? " (#{s})" : nil)
38
         "#{o.issue.tracker} ##{o.issue.id}#{status}: #{o.issue.subject}"
39
       end,
40
    :description => :notes,
41
    :author => :user,
42
    :group => :issue,
43
    :type =>
44
      Proc.new do |o|
45
        (s = o.new_status) ? (s.is_closed? ? 'issue-closed' : 'issue-edit') : 'issue-note'
46
      end,
47
    :url =>
48
      Proc.new do |o|
49
        {:controller => 'issues', :action => 'show', :id => o.issue.id, :anchor => "change-#{o.id}"}
50
      end
51
  )
52
  acts_as_activity_provider(
53
    :type => 'issues',
54
    :author_key => :user_id,
55
    :scope =>
56
      proc do
57
        preload({:issue => :project}, {:issue => :tracker}, :user).
58
          joins("LEFT OUTER JOIN #{JournalDetail.table_name} ON #{JournalDetail.table_name}.journal_id = #{Journal.table_name}.id").
59
            where("#{Journal.table_name}.journalized_type = 'Issue' AND" +
60
                  " (#{JournalDetail.table_name}.prop_key = 'status_id' OR #{Journal.table_name}.notes <> '')").distinct
61
      end
51
    title: proc {|o| o.journalized.journal_event_title(o) },
52
    description: :notes,
53
    author: :user,
54
    group: :journalized,
55
    type: proc {|o| o.journalized.journal_event_type(o) },
56
    url: proc {|o| o.journalized.journal_event_url(o) }
62 57
  )
63 58
  acts_as_mentionable :attributes => ['notes']
64 59
  before_create :split_private_notes
......
69 64
    user = args.shift || User.current
70 65
    options = args.shift || {}
71 66

  
72
    joins(:issue => :project).
73
      where(Issue.visible_condition(user, options)).
74
      where(Journal.visible_notes_condition(user, :skip_pre_condition => true))
67
    classes = Redmine::Acts::Journalized.journalized_classes
68
    # Only consider the types the current scope is restricted to (eg. activity)
69
    if (types = Array(where_values_hash['journalized_type'])).any?
70
      classes = classes.select {|klass| types.include?(klass.polymorphic_name)}
71
    end
72

  
73
    if classes.size == 1 && (condition = classes.first.journal_visible_join_condition(user, options))
74
      # Joining the journalized table lets the database use the same query
75
      # plans as before journals were decoupled from issues
76
      joins_journalized(classes.first).where(condition)
77
    elsif classes.any?
78
      where(classes.map {|klass| "(#{klass.journal_visible_condition(user, options)})"}.join(' OR '))
79
    else
80
      none
81
    end
75 82
  end)
76 83

  
77 84
  safe_attributes(
......
79 86
    :if => lambda {|journal, user| journal.new_record? || journal.editable_by?(user)})
80 87
  safe_attributes(
81 88
    'private_notes',
82
    :if => lambda {|journal, user| user.allowed_to?(:set_notes_private, journal.project)})
89
    :if => lambda {|journal, user| journal.journalized&.journal_private_notes_allowed?(user)})
83 90
  safe_attributes 'updated_by'
84 91

  
85 92
  # Returns a SQL condition to filter out journals with notes that are not visible to user
......
109 116
    notes.blank? && details.empty?
110 117
  end
111 118

  
112
  def journalized
113
    if journalized_type == 'Issue' && association(:issue).loaded?
114
      # Avoid extra query by using preloaded association
115
      issue
116
    else
117
      super
118
    end
119
  # @deprecated Use #journalized instead
120
  def issue
121
    Rails.application.deprecators[:redmine].warn "Journal#issue is deprecated and will be removed. Use Journal#journalized instead."
122
    journalized if journalized_type == 'Issue'
119 123
  end
120 124

  
121 125
  # Returns journal details that are visible to user
......
124 128
      if detail.property == 'cf'
125 129
        detail.custom_field && detail.custom_field.visible_by?(project, user)
126 130
      elsif detail.property == 'relation'
127
        Issue.find_by_id(detail.value || detail.old_value).try(:visible?, user)
131
        journalized&.journal_relation_visible?(detail, user)
128 132
      else
129 133
        true
130 134
      end
......
139 143

  
140 144
  # Returns the new status if the journal contains a status change, otherwise nil
141 145
  def new_status
142
    s = new_value_for('status_id')
143
    s ? IssueStatus.find_by_id(s.to_i) : nil
146
    journalized&.journal_new_status(self)
144 147
  end
145 148

  
146 149
  def new_value_for(prop)
......
148 151
  end
149 152

  
150 153
  def editable_by?(usr)
151
    usr && usr.logged? && (usr.allowed_to?(:edit_issue_notes, project) || (self.user == usr && usr.allowed_to?(:edit_own_issue_notes, project)))
154
    journalized.present? && journalized.journal_editable_by?(self, usr)
152 155
  end
153 156

  
154 157
  def project
155
    journalized.respond_to?(:project) ? journalized.project : nil
158
    journalized&.journal_project
156 159
  end
157 160

  
158 161
  def attachments
......
163 166
  end
164 167

  
165 168
  def visible?(*)
166
    journalized.visible?(*)
169
    journalized.present? && journalized.journal_visible?(*)
167 170
  end
168 171

  
169 172
  def attachments_visible?
......
188 191
  end
189 192

  
190 193
  def notified_users
191
    notified = journalized.notified_users
192
    if private_notes?
193
      notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
194
    end
195
    notified
194
    notified = journalized.journal_notified_users
195
    select_journal_visible_user(notified)
196 196
  end
197 197

  
198 198
  def recipients
......
200 200
  end
201 201

  
202 202
  def notified_watchers
203
    notified = journalized.notified_watchers
203
    notified = journalized.journal_notified_watchers
204 204
    select_journal_visible_user(notified)
205 205
  end
206 206

  
......
235 235
      @attributes_before_change = journalized.journalized_attribute_names.index_with do |attribute|
236 236
        journalized.send(attribute)
237 237
      end
238
      @custom_values_before_change = journalized.custom_field_values.to_h do |c|
239
        [c.custom_field_id, c.value]
238
      if journalized.respond_to?(:custom_field_values)
239
        @custom_values_before_change = journalized.custom_field_values.to_h do |c|
240
          [c.custom_field_id, c.value]
241
        end
240 242
      end
241 243
    end
242 244
    self
......
354 356
  end
355 357

  
356 358
  def add_watcher
357
    if user.is_a?(User) &&
358
       user.pref.auto_watch_on?('issue_contributed_to') &&
359
       valid_watcher?(user)
360
      journalized.set_watcher(user, true)
361
    end
362

  
363
    assignee = journalized.assigned_to
364
    if assignee.is_a?(User) &&
365
       assignee.pref.auto_watch_on?('issue_assigned_to_me') &&
366
       valid_watcher?(assignee)
367
      journalized.set_watcher(assignee, true)
368
    end
369
  end
370

  
371
  def valid_watcher?(user)
372
    user.active? &&
373
      user.allowed_to?(:add_issue_watchers, journalized.project) &&
374
      journalized.valid_watcher?(user) &&
375
      !journalized.watched_by?(user)
359
    journalized.journal_process_watchers(self)
376 360
  end
377 361

  
378 362
  def send_notification
379
    if notify? &&
380
        (
381
          Setting.notified_events.include?('issue_updated') ||
382
          (Setting.notified_events.include?('issue_note_added') && notes.present?) ||
383
          (Setting.notified_events.include?('issue_status_updated') && new_status.present?) ||
384
          (Setting.notified_events.include?('issue_assigned_to_updated') && detail_for_attribute('assigned_to_id').present?) ||
385
          (Setting.notified_events.include?('issue_priority_updated') && new_value_for('priority_id').present?) ||
386
          (Setting.notified_events.include?('issue_fixed_version_updated') && detail_for_attribute('fixed_version_id').present?) ||
387
          (Setting.notified_events.include?('issue_attachment_added') && details.any? {|d| d.property == 'attachment' && d.value })
388
        )
389
      Mailer.deliver_issue_edit(self)
390
    end
363
    journalized.journal_notify(self) if notify?
391 364
  end
392 365

  
393 366
  def select_journal_visible_user(notified)
394 367
    if private_notes?
395
      notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
368
      notified = notified.select {|user| journalized.journal_user_can_view_private_notes?(user)}
396 369
    end
397 370
    notified
398 371
  end
app/views/journals/_notes_form.html.erb
18 18
    <p><%= submit_tag l(:button_save) %>
19 19
    <%= link_to l(:button_cancel), '#', :onclick => "$('#journal-#{@journal.id}-form').remove(); $('#journal-#{@journal.id}-notes').show(); return false;" %></p>
20 20
<% end %>
21
<%= wikitoolbar_for "journal_#{@journal.id}_notes", preview_issue_path(:project_id => @project, :issue_id => @journal.issue) %>
21
<%= wikitoolbar_for "journal_#{@journal.id}_notes", preview_issue_path(:project_id => @project, :issue_id => @journal.journalized) %>
app/views/journals/index.builder
10 10
  xml.updated((@journals.first ? @journals.first.event_datetime : Time.now).xmlschema)
11 11
  xml.author  {xml.name "#{Setting.app_title}"}
12 12
  @journals.each do |change|
13
    issue = change.issue
13
    issue = change.journalized
14 14
    xml.entry do
15 15
      xml.title   "#{issue.project.name} - #{issue.tracker.name} ##{issue.id}: #{issue.subject}"
16 16
      xml.link    "rel" => "alternate", "href" => issue_url(issue)
app/views/journals/update.js.erb
2 2
  $("#change-<%= @journal.id %>").remove();
3 3
<% else %>
4 4
  $("#change-<%= @journal.id %>").attr('class', '<%= @journal.css_classes %>');
5
  $("#change-<%= @journal.id %> .journal-actions").html('<%= escape_javascript(render_journal_actions(@journal.issue, @journal, :reply_links => authorize_for('issues', 'edit'))) %>');
5
  $("#change-<%= @journal.id %> .journal-actions").html('<%= escape_javascript(render_journal_actions(@journal.journalized, @journal, :reply_links => authorize_for('issues', 'edit'))) %>');
6 6
  $("#journal-<%= @journal.id %>-private_notes").replaceWith('<%= escape_javascript(render_private_notes_indicator(@journal)) %>');
7
  $("#journal-<%= @journal.id %>-notes").replaceWith('<%= escape_javascript(render_notes(@journal.issue, @journal, :reply_links => authorize_for('issues', 'edit'))) %>');
7
  $("#journal-<%= @journal.id %>-notes").replaceWith('<%= escape_javascript(render_notes(@journal.journalized, @journal, :reply_links => authorize_for('issues', 'edit'))) %>');
8 8
  $("#journal-<%= @journal.id %>-notes").show();
9 9
  $("#journal-<%= @journal.id %>-form").remove();
10 10
  var journal_header = $("#change-<%= @journal.id %>>div.note>h4.journal-header>.journal-info");
lib/plugins/acts_as_journalized/init.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative 'lib/acts_as_journalized'
21
Rails.application.reloader.to_prepare do
22
  ApplicationRecord.send(:include, Redmine::Acts::Journalized)
23
end
lib/plugins/acts_as_journalized/lib/acts_as_journalized.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
module Redmine
21
  module Acts
22
    # Makes a model journalized, i.e. its changes and notes are recorded
23
    # as Journal records.
24
    #
25
    #   acts_as_journalized(
26
    #     :activity_type => 'issues',
27
    #     :attributes => %w(subject description),
28
    #     :permissions => {
29
    #       :edit_notes => :edit_issue_notes,
30
    #       :edit_own_notes => :edit_own_issue_notes,
31
    #       :add_watchers => :add_issue_watchers
32
    #     }
33
    #   )
34
    #
35
    # Options:
36
    # * :activity_type - registers the journals of the model as activity
37
    #   provider of the given event type (optional)
38
    # * :attributes - names of the attributes to journalize, unless the model
39
    #   defines #journalized_attribute_names
40
    # * :permissions - permissions used by the default implementations, see
41
    #   DEFAULT_PERMISSIONS. A nil permission is never granted.
42
    #
43
    # The defaults assume that the model has a visible scope and a visible?
44
    # method, and optionally a project association. Every journal_* method
45
    # can be overridden by the model for specific behavior.
46
    module Journalized
47
      DEFAULT_PERMISSIONS = {
48
        :edit_notes => nil,
49
        :edit_own_notes => nil,
50
        :view_private_notes => :view_private_notes,
51
        :set_notes_private => :set_notes_private,
52
        :add_watchers => nil
53
      }.freeze
54

  
55
      @journalized_type_names = []
56

  
57
      class << self
58
        # Registers a journalized type by its name, so that journals of this
59
        # type are taken into account even if the class is not loaded yet
60
        def register(class_name)
61
          name = class_name.to_s
62
          @journalized_type_names << name unless @journalized_type_names.include?(name)
63
        end
64

  
65
        def unregister(class_name)
66
          @journalized_type_names.delete(class_name.to_s)
67
        end
68

  
69
        def journalized_type_names
70
          @journalized_type_names.dup
71
        end
72

  
73
        # Returns the classes of the registered journalized types
74
        def journalized_classes
75
          @journalized_type_names.filter_map do |name|
76
            klass = name.safe_constantize
77
            klass if klass.respond_to?(:journalized_options)
78
          end
79
        end
80
      end
81

  
82
      def self.included(base)
83
        base.extend ClassMethods
84
      end
85

  
86
      module ClassMethods
87
        def acts_as_journalized(options = {})
88
          options.assert_valid_keys(:activity_type, :attributes, :permissions)
89
          permissions = options[:permissions] || {}
90
          permissions.assert_valid_keys(*DEFAULT_PERMISSIONS.keys)
91

  
92
          class_attribute :journalized_options, :instance_writer => false
93
          self.journalized_options = {
94
            :activity_type => options[:activity_type]&.to_s,
95
            :attributes => Array(options[:attributes]).map(&:to_s),
96
            :permissions => DEFAULT_PERMISSIONS.merge(permissions)
97
          }
98

  
99
          has_many :journals, :as => :journalized, :dependent => :destroy, :inverse_of => :journalized
100

  
101
          extend SingletonMethods
102
          include InstanceMethods
103

  
104
          Redmine::Acts::Journalized.register(polymorphic_name)
105
          Journal.register_activity_provider(self) if journalized_options[:activity_type]
106
        end
107
      end
108

  
109
      module SingletonMethods
110
        # Returns a SQL condition on the tables joined by journal_joins that
111
        # restricts journals to the ones visible to user, including private
112
        # notes, or nil if the model does not provide one.
113
        #
114
        # Journal.visible uses it when only the journals of this type are
115
        # queried. Joins usually perform better than the EXISTS subquery of
116
        # journal_visible_condition, which is used otherwise.
117
        def journal_visible_join_condition(user=User.current, options={})
118
          nil
119
        end
120

  
121
        # Returns a SQL condition that restricts journals to the journals
122
        # of this type that are visible to user, including private notes
123
        def journal_visible_condition(user=User.current, options={})
124
          return '1=0' unless respond_to?(:visible)
125

  
126
          journals = Journal.table_name
127
          records = visible(user, options).
128
            where("#{table_name}.#{primary_key} = #{journals}.journalized_id").
129
            where(journal_private_notes_condition(user, options))
130
          "#{journals}.journalized_type = #{connection.quote(polymorphic_name)}" \
131
            " AND EXISTS (#{records.select('1').to_sql})"
132
        end
133

  
134
        # Returns a SQL condition to filter out journals with private notes
135
        # that are not visible to user
136
        def journal_private_notes_condition(user=User.current, options={})
137
          journals = Journal.table_name
138
          permission = journalized_options[:permissions][:view_private_notes]
139
          allowed =
140
            if user.admin?
141
              '1=1'
142
            elsif permission && (reflection = journal_project_reflection)
143
              project_condition =
144
                Project.allowed_to_condition(user, permission, options.merge(:skip_pre_condition => true))
145
              "#{table_name}.#{reflection.foreign_key} IN" \
146
                " (SELECT #{Project.table_name}.id FROM #{Project.table_name} WHERE #{project_condition})"
147
            else
148
              '1=0'
149
            end
150
          sanitize_sql_for_conditions(
151
            ["(#{journals}.private_notes = ? OR #{journals}.user_id = ? OR (#{allowed}))", false, user.id]
152
          )
153
        end
154

  
155
        # Returns the SQL joins from the journals table to the table of this
156
        # type and, if any, to the projects table. The journals must also be
157
        # restricted to this type, see Journal.joins_journalized
158
        def journal_joins
159
          journals = Journal.table_name
160
          joins = ["INNER JOIN #{table_name} ON #{table_name}.#{primary_key} = #{journals}.journalized_id"]
161
          if (reflection = journal_project_reflection)
162
            joins << "INNER JOIN #{Project.table_name} ON #{Project.table_name}.id = #{table_name}.#{reflection.foreign_key}"
163
          end
164
          joins
165
        end
166

  
167
        # Returns the scope of the activity events. The given scope contains
168
        # the journals of this type only, joined with journal_joins
169
        def journal_activity_scope(scope)
170
          scope
171
        end
172

  
173
        def journal_activity_preload
174
          journal_project_reflection ? [{:journalized => :project}, :user] : [:user]
175
        end
176

  
177
        def journal_project_reflection
178
          reflection = reflect_on_association(:project)
179
          reflection if reflection&.belongs_to?
180
        end
181
      end
182

  
183
      module InstanceMethods
184
        # Event API
185
        def journal_event_title(journal)
186
          "#{self.class.model_name.human} ##{id}"
187
        end
188

  
189
        def journal_event_type(journal)
190
          'journal-update'
191
        end
192

  
193
        def journal_event_url(journal)
194
          {:controller => self.class.model_name.plural, :action => 'show', :id => id, :anchor => "change-#{journal.id}"}
195
        end
196

  
197
        # Called after a journal is created, if notifications are enabled
198
        def journal_notify(journal)
199
        end
200

  
201
        # Called before a journal is created
202
        def journal_process_watchers(journal)
203
        end
204

  
205
        # Attributes whose changes are journalized
206
        def journalized_attribute_names
207
          journalized_options[:attributes]
208
        end
209

  
210
        def journal_notified_users
211
          respond_to?(:notified_users) ? notified_users : []
212
        end
213

  
214
        def journal_notified_watchers
215
          respond_to?(:notified_watchers) ? notified_watchers : []
216
        end
217

  
218
        def journal_visible?(user=User.current)
219
          respond_to?(:visible?) && visible?(user)
220
        end
221

  
222
        def journal_project
223
          respond_to?(:project) ? project : nil
224
        end
225

  
226
        def journal_editable_by?(journal, user)
227
          return false unless user&.logged?
228

  
229
          journal_allowed_to?(user, :edit_notes) ||
230
            (journal.user == user && journal_allowed_to?(user, :edit_own_notes))
231
        end
232

  
233
        def journal_user_can_view_private_notes?(user)
234
          journal_allowed_to?(user, :view_private_notes)
235
        end
236

  
237
        def journal_private_notes_allowed?(user)
238
          journal_allowed_to?(user, :set_notes_private)
239
        end
240

  
241
        def journal_valid_watcher?(user)
242
          user.active? &&
243
            journal_allowed_to?(user, :add_watchers) &&
244
            respond_to?(:valid_watcher?) && valid_watcher?(user) &&
245
            !watched_by?(user)
246
        end
247

  
248
        # Returns true if the relation of the given journal detail is visible to user
249
        def journal_relation_visible?(detail, user)
250
          false
251
        end
252

  
253
        # Returns the new status if the journal contains a status change, otherwise nil
254
        def journal_new_status(journal)
255
          nil
256
        end
257

  
258
        private
259

  
260
        # Checks the permission configured for key (see DEFAULT_PERMISSIONS)
261
        # on the journalized project, or globally if there is no project
262
        def journal_allowed_to?(user, key)
263
          permission = journalized_options[:permissions][key]
264
          return false if permission.nil? || user.nil?
265

  
266
          if (project = journal_project)
267
            user.allowed_to?(permission, project)
268
          else
269
            user.allowed_to?(permission, nil, :global => true)
270
          end
271
        end
272
      end
273
    end
274
  end
275
end
lib/redmine/preparation.rb
25 25
      ApplicationRecord.include Redmine::Acts::Webhookable
26 26
      ApplicationRecord.include Redmine::I18n
27 27

  
28
      # Journalized types, registered before their classes are loaded
29
      Redmine::Acts::Journalized.register 'Issue'
30

  
28 31
      Scm::Base.add "Subversion"
29 32
      Scm::Base.add "Mercurial"
30 33
      Scm::Base.add "Cvs"
test/unit/changeset_test.rb
313 313
      end
314 314
      assert issue.reload.closed?
315 315
      journal = Journal.order(id: :desc).first
316
      assert_equal issue, journal.issue
316
      assert_equal issue, journal.journalized
317 317
      assert_include "Applied in changeset ecookbook:r12345.", journal.notes
318 318
    end
319 319
  end
test/unit/journal_test.rb
26 26
  end
27 27

  
28 28
  def test_journalized_is_an_issue
29
    issue = @journal.issue
29
    issue = @journal.journalized
30 30
    assert_kind_of Issue, issue
31 31
    assert_equal 1, issue.id
32 32
  end
......
181 181
    # Anonymous user should see issues of public projects only
182 182
    journals = Journal.visible(User.anonymous).to_a
183 183
    assert journals.any?
184
    assert_nil journals.detect {|journal| !journal.issue.project.is_public?}
184
    assert_nil journals.detect {|journal| !journal.project.is_public?}
185 185
    # Anonymous user should not see issues without permission
186 186
    Role.anonymous.remove_permission!(:view_issues)
187 187
    journals = Journal.visible(User.anonymous).to_a
......
194 194
    # Non member user should see issues of public projects only
195 195
    journals = Journal.visible(user).to_a
196 196
    assert journals.any?
197
    assert_nil journals.detect {|journal| !journal.issue.project.is_public?}
197
    assert_nil journals.detect {|journal| !journal.project.is_public?}
198 198
    # Non member user should not see issues without permission
199 199
    Role.non_member.remove_permission!(:view_issues)
200 200
    user.reload
......
205 205
    user.reload
206 206
    journals = Journal.visible(user).to_a
207 207
    assert journals.any?
208
    assert_nil journals.detect {|journal| journal.issue.project_id != 1}
208
    assert_nil journals.detect {|journal| journal.journalized.project_id != 1}
209 209
  end
210 210

  
211 211
  def test_visible_scope_for_admin
......
215 215
    journals = Journal.visible(user).to_a
216 216
    assert journals.any?
217 217
    # Admin should see issues on private projects that admin does not belong to
218
    assert journals.detect {|journal| !journal.issue.project.is_public?}
218
    assert journals.detect {|journal| !journal.project.is_public?}
219 219
  end
220 220

  
221 221
  def test_preload_journals_details_custom_fields_should_set_custom_field_instance_variable
......
268 268
    visible_issue = Issue.generate!
269 269
    hidden_issue = Issue.generate!(:is_private => true)
270 270

  
271
    journal = Journal.new
271
    journal = Journal.new(journalized: issue)
272 272
    journal.details << JournalDetail.new(:property => 'relation', :prop_key => 'relates', :value => visible_issue.id)
273 273
    journal.details << JournalDetail.new(:property => 'relation', :prop_key => 'relates', :value => hidden_issue.id)
274 274

  
test/unit/lib/redmine/acts/journalized_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative '../../../../test_helper'
21

  
22
class Redmine::Acts::JournalizedTest < ActiveSupport::TestCase
23
  def setup
24
    User.current = nil
25
    # Use News as a second journalized type
26
    News.acts_as_journalized unless News.respond_to?(:journalized_options)
27
    Redmine::Acts::Journalized.register('News')
28
    @news = News.find(1)
29
  end
30

  
31
  def teardown
32
    Redmine::Acts::Journalized.unregister('News')
33
  end
34

  
35
  def test_issue_should_be_registered
36
    assert_include 'Issue', Redmine::Acts::Journalized.journalized_type_names
37
    assert_include Issue, Redmine::Acts::Journalized.journalized_classes
38
  end
39

  
40
  def test_acts_as_journalized_should_reject_invalid_options
41
    assert_raise ArgumentError do
42
      Class.new(ApplicationRecord) { acts_as_journalized :foo => 'bar' }
43
    end
44
  end
45

  
46
  def test_visible_should_include_journals_of_each_journalized_type
47
    journal = Journal.create!(:journalized => @news, :user => User.find(2), :notes => 'News note')
48
    journals = Journal.visible(User.anonymous)
49

  
50
    assert_include journal, journals
51
    assert journals.where(:journalized_type => 'Issue').exists?
52
    assert_equal Journal.where(:journalized_type => 'Issue').visible(User.anonymous).sort,
53
                 journals.where(:journalized_type => 'Issue').sort
54
  end
55

  
56
  def test_visible_should_not_include_journals_of_invisible_records
57
    journal = Journal.create!(:journalized => @news, :user => User.find(2), :notes => 'News note')
58
    Role.anonymous.remove_permission!(:view_news)
59

  
60
    assert_not_include journal, Journal.visible(User.anonymous)
61
    assert_not journal.visible?(User.anonymous)
62
  end
63

  
64
  def test_visible_should_not_include_private_notes_without_permission
65
    journal = Journal.create!(:journalized => @news, :user => User.find(2), :notes => 'Private', :private_notes => true)
66

  
67
    assert_not_include journal, Journal.visible(User.anonymous)
68
    assert_include journal, Journal.visible(User.find(2))
69
    assert_include journal, Journal.visible(User.find(1))
70
  end
71

  
72
  def test_notified_users_should_exclude_users_not_allowed_to_view_private_notes
73
    journal = Journal.new(:journalized => @news, :user => User.find(2), :notes => 'Private', :private_notes => true)
74
    Role.find(1).remove_permission!(:view_private_notes)
75

  
76
    assert journal.notified_users.none? {|user| user.roles_for_project(@news.project).include?(Role.find(1)) && !user.admin?}
77
  end
78

  
79
  def test_editable_by_should_be_false_without_configured_permission
80
    journal = Journal.create!(:journalized => @news, :user => User.find(2), :notes => 'News note')
81

  
82
    assert_not journal.editable_by?(User.find(2))
83
  end
84

  
85
  def test_visible_should_join_journalized_table_when_restricted_to_one_type
86
    sql = Journal.where(:journalized_type => 'Issue').visible(User.anonymous).to_sql
87
    assert_include 'INNER JOIN issues', sql
88
    assert_not_include 'EXISTS (SELECT 1 FROM "issues"', sql
89
  end
90

  
91
  def test_visible_should_use_exists_conditions_for_several_types
92
    sql = Journal.visible(User.anonymous).to_sql
93
    assert_not_include 'INNER JOIN issues', sql
94
    assert_include "journals.journalized_type = 'News' AND EXISTS", sql
95
  end
96

  
97
  def test_visible_with_join_and_exists_conditions_should_return_the_same_issue_journals
98
    user = User.find(2)
99
    assert_equal Journal.where(:journalized_type => 'Issue').visible(user).ids.sort,
100
                 Journal.where(:journalized_type => 'Issue').where(Issue.journal_visible_condition(user)).ids.sort
101
  end
102

  
103
  def test_joins_journalized_should_join_journalized_table_and_project
104
    journals = Journal.joins_journalized(Issue).where(:issues => {:project_id => 1})
105

  
106
    assert journals.any?
107
    assert journals.all? {|journal| journal.journalized.is_a?(Issue) && journal.project.id == 1}
108
  end
109
end
test/unit/mail_handler_test.rb
944 944
    assert_equal Issue.find(2), journal.journalized
945 945
    assert_match /This is reply/, journal.notes
946 946
    assert_equal false, journal.private_notes
947
    assert_equal 'Feature request', journal.issue.tracker.name
947
    assert_equal 'Feature request', journal.journalized.tracker.name
948 948
  end
949 949

  
950 950
  def test_update_issue_should_accept_issue_id_after_space_inside_brackets
......
977 977
                                               'start_date', 'due_date',
978 978
                                               'float field'])
979 979
    assert journal.is_a?(Journal)
980
    issue = Issue.find(journal.issue.id)
980
    issue = Issue.find(journal.journalized.id)
981 981
    assert_equal User.find_by_login('jsmith'), journal.user
982 982
    assert_equal Issue.find(2), journal.journalized
983 983
    assert_match /This is reply/, journal.notes
984
    assert_equal 'Feature request', journal.issue.tracker.name
984
    assert_equal 'Feature request', journal.journalized.tracker.name
985 985
    assert_equal IssueStatus.find_by_name("Resolved"), issue.status
986 986
    assert_equal '2010-01-01', issue.start_date.to_s
987 987
    assert_equal '2010-12-31', issue.due_date.to_s
......
1042 1042
      )
1043 1043
    assert journal.is_a?(Journal)
1044 1044
    assert_match /This is reply/, journal.notes
1045
    assert_equal 'Feature request', journal.issue.tracker.name
1046
    assert_equal 'Normal', journal.issue.priority.name
1045
    assert_equal 'Feature request', journal.journalized.tracker.name
1046
    assert_equal 'Normal', journal.journalized.priority.name
1047 1047
  end
1048 1048

  
1049 1049
  def test_update_issue_should_add_cc_as_watchers
test/unit/mailer_test.rb
362 362

  
363 363
  def test_issue_edit_message_id
364 364
    journal = Journal.find(3)
365
    journal.issue = Issue.find(2)
365
    journal.journalized = Issue.find(2)
366 366

  
367 367
    Mailer.deliver_issue_edit(journal)
368 368
    mail = last_email
(4-4/4)