Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,13 @@ def journal_details
# A formatter may render a change as nothing, so what is worth heading is
# what comes out of them rather than what went in.
def rendered_details
@rendered_details ||= journal_details.filter_map { |detail| journal.render_detail(detail).presence }
@rendered_details ||= journal_details
.reject { |attribute, _| hidden_by_cause?(attribute) }
.filter_map { |detail| journal.render_detail(detail).presence }
end

def hidden_by_cause?(attribute)
attribute.to_s == "labels" && journal.cause_type == "label_deleted"
end

def has_details?
Expand Down
3 changes: 3 additions & 0 deletions app/models/journal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ class Journal < ApplicationRecord
status_name
status_id
status_changes
label_ids
label_names
meeting_id
source_meeting_id
message_id
Expand All @@ -107,6 +109,7 @@ class Journal < ApplicationRecord
default_attribute_written
forum_message
import
label_deleted
progress_mode_changed_to_status_based
status_changed
system_update
Expand Down
49 changes: 49 additions & 0 deletions app/models/journal/caused_by_label_deletion.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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, see <https://www.gnu.org/licenses/>.
#
# See COPYRIGHT and LICENSE files for more details.
#++

class Journal::CausedByLabelDeletion < CauseOfChange::Base
def initialize(label_ids:, label_names:)
super("label_deleted", "label_ids" => label_ids, "label_names" => label_names)
end

def aggregatable_with?(predecessor_cause)
predecessor_cause["type"] == type
end

def aggregate_with(predecessor_cause)
self.class.new(label_ids: predecessor_cause["label_ids"] | label_ids,
label_names: predecessor_cause["label_names"] | label_names)
end

private

def label_ids = additional_attributes["label_ids"]

def label_names = additional_attributes["label_names"]
end
9 changes: 8 additions & 1 deletion app/services/journals/create_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def aggregatable_predecessor(notes, internal, cause)

def create_journal(notes, internal, cause)
predecessor = aggregatable_predecessor(notes, internal, cause)
cause = aggregate_cause(predecessor, cause)

log_journal_creation(predecessor)

Expand Down Expand Up @@ -611,7 +612,13 @@ def same_user?(predecessor)
end

def only_one_or_same_cause?(predecessor, cause)
predecessor.cause.empty? || predecessor.cause == cause
predecessor.cause.empty? || predecessor.cause == cause || cause.try(:aggregatable_with?, predecessor.cause)
end

def aggregate_cause(predecessor, cause)
return cause unless predecessor && cause.try(:aggregatable_with?, predecessor.cause)

cause.aggregate_with(predecessor.cause)
end

def only_one_note?(predecessor, notes)
Expand Down
24 changes: 24 additions & 0 deletions app/services/labels/delete_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,29 @@

module Labels
class DeleteService < ::BaseServices::Delete
protected

def persist(service_result)
@labeled_work_package_ids = labeled_work_package_ids
super
end

def after_persist(service_result)
enqueue_journalization if @labeled_work_package_ids.any?
super
end

private

def labeled_work_package_ids
Labeling.where(label_id: model.id, labelable_type: WorkPackage.name).pluck(:labelable_id)
end

def enqueue_journalization
Labels::JournalizeDeletionJob.perform_later(work_package_ids: @labeled_work_package_ids,
label_id: model.id,
label_name: model.name,
user_id: user.id)
end
end
end
60 changes: 60 additions & 0 deletions app/workers/labels/journalize_deletion_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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, see <https://www.gnu.org/licenses/>.
#
# See COPYRIGHT and LICENSE files for more details.
#++

# Skips work packages that already carry the deletion journal, so a retried job adds no second entry.
# Deletions by the same admin inside the aggregation window merge into one entry listing every label.
class Labels::JournalizeDeletionJob < ApplicationJob
queue_with_priority :below_normal

def perform(work_package_ids:, label_id:, label_name:, user_id:)
user = User.find_by(id: user_id) || DeletedUser.first
cause = Journal::CausedByLabelDeletion.new(label_ids: [label_id], label_names: [label_name])

WorkPackage.where(id: pending_ids(work_package_ids, label_id)).find_each(batch_size: 100) do |work_package|
journalize(work_package, user, cause)
end
end

private

def pending_ids(ids, label_id)
journalized = Journal
.where(journable_type: WorkPackage.name, journable_id: ids)
.where("journals.cause @> ?", { type: "label_deleted", label_ids: [label_id] }.to_json)
.pluck(:journable_id)

ids - journalized
end

def journalize(work_package, user, cause)
OpenProject::Mutex.with_advisory_lock_transaction(work_package) do
Journals::CreateService.new(work_package, user).call(cause:)
end
end
end
4 changes: 4 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3793,6 +3793,9 @@ en:
header: "changes by %{author}"
migrated: "from Jira"
set_with_diff: "%{field} set (%{link})"
label_deleted:
one: "%{label_names} was deleted"
other: "%{label_names} were deleted"
meeting_cancelled: "(cancelled)"
meeting_deleted: "(deleted)"
meeting_series_backlog_html: "backlog for meeting series %{link}"
Expand Down Expand Up @@ -3846,6 +3849,7 @@ en:
default_attribute_written: "Read-only attributes written"
forum_message_html: "<strong>Created from forum message</strong> %{message_information}"
import: "Imported"
label_deleted: "Label removed:"
meeting_agenda_item_added_html: "<strong>Added to meeting</strong> %{meeting_title_information}"
meeting_agenda_item_added_template_html: "<strong>Added to</strong> %{meeting_title_information}"
meeting_agenda_item_discussed_html: "<strong>Discussed in meeting</strong> %{meeting_title_information}"
Expand Down
9 changes: 9 additions & 0 deletions lib/open_project/journal_formatter/cause.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ def cause_description
total_percent_complete_mode_changed_to_simple_average_message
when "import"
import_message
when "label_deleted"
label_deleted_message
when "work_package_parent_deleted"
I18n.t("journals.cause_descriptions.work_package_parent_deleted")
else
Expand Down Expand Up @@ -128,6 +130,13 @@ def system_update_message
html? ? message : Sanitize.fragment(message)
end

def label_deleted_message
names = Array(cause["label_names"])
label_names = names.map { |name| "'#{html? ? h(name) : name}'" }.join(", ")

I18n.t("journals.cause_descriptions.label_deleted", count: names.size, label_names:)
end

def working_days_changed_message(changed_dates)
day_changes_messages = changed_dates["working_days"].collect do |day, working|
working_day_change_message(day.to_i, working)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,13 @@ def blank?
def ==(other)
to_hash == other.to_hash
end

def aggregatable_with?(_predecessor_cause)
false
end

def aggregate_with(_predecessor_cause)
self
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,29 @@
end
end

context "with a labels change" do
let(:details) { { "labels" => ["1", nil] } }
let(:rendered) { { ["labels", ["1", nil]] => "Labels deleted A" } }

it "renders the labels line" do
expect(page).to have_text("Labels deleted A")
end

context "when written for a label deletion" do
let(:cause) { Journal::CausedByLabelDeletion.new(label_ids: [1], label_names: ["A"]).to_hash }
let(:journal) { build_stubbed(:work_package_journal, journable: work_package, user:, version: 2, cause:) }
let(:details) { { "labels" => ["1", nil], cause: [nil, cause] } }
let(:rendered) do
{ ["labels", ["1", nil]] => "Labels deleted A", [:cause, [nil, cause]] => "Label removed: 'A' was deleted" }
end

it "renders the cause but not the labels line", :aggregate_failures do
expect(page).to have_text("Label removed: 'A' was deleted")
expect(page).to have_no_text("Labels deleted A")
end
end
end

# A formatter that renders nothing leaves the entry with no change to show, so
# a header would announce an author and a time above an empty entry.
context "when every detail renders blank" do
Expand Down
1 change: 1 addition & 0 deletions spec/features/admin/labels/manage_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ def expect_row(label, usage:)
end

expect_flash(message: "Successful deletion.")
expect(Labels::JournalizeDeletionJob).to have_been_enqueued
expect(page).to have_no_test_selector(row_selector(label))
expect(Label.where(id: label.id)).not_to exist
expect(Labeling.where(label_id: label.id)).not_to exist
Expand Down
44 changes: 44 additions & 0 deletions spec/lib/open_project/journal_formatter/cause_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,50 @@ def render(cause, html:)
end
end

context "when the change was caused by the deletion of a label" do
subject(:cause) { Journal::CausedByLabelDeletion.new(label_ids: [42], label_names: ["Foo"]).to_hash }

it do
expect(cause).to render_html_variant(
"<strong>Label removed:</strong> 'Foo' was deleted"
)
end

it do
expect(cause).to render_raw_variant("Label removed: 'Foo' was deleted")
end

context "when several labels were deleted" do
subject(:cause) do
Journal::CausedByLabelDeletion.new(
label_ids: [1, 2, 3], label_names: %w[Frontend Backend Middleware]
).to_hash
end

it "lists every label name and pluralises the verb" do
expect(cause).to render_html_variant(
"<strong>Label removed:</strong> 'Frontend', 'Backend', 'Middleware' were deleted"
)
end
end

context "when the label name contains HTML (XSS prevention)" do
subject(:cause) do
Journal::CausedByLabelDeletion.new(label_ids: [42], label_names: ["<script>alert('x')</script>"]).to_hash
end

it "escapes the label name in HTML output" do
expect(cause).to render_html_variant(
a_string_including("&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;")
)
end

it "does not escape the label name in raw text output" do
expect(cause).to render_raw_variant(a_string_including("<script>alert('x')</script>"))
end
end
end

context "when the change was caused by an import" do
subject(:cause) do
{
Expand Down
32 changes: 32 additions & 0 deletions spec/models/work_package/work_package_acts_as_journalized_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1178,6 +1178,38 @@ def set_observed_in_versions(versions)
end
end

context "on consecutive label deletions within aggregation time", with_settings: { journal_aggregation_time_minutes: 5 } do
shared_let(:journable) do
create(:work_package, journals: { 10.minutes.ago => { user: } })
end

def journalize(cause)
Journals::CreateService.new(journable, user).call(cause:)
end

before do
journalize(Journal::CausedByLabelDeletion.new(label_ids: [1], label_names: ["A"]))
end

it "aggregates consecutive label deletions into one journal listing every label" do
expect { journalize(Journal::CausedByLabelDeletion.new(label_ids: [2], label_names: ["B"])) }
.not_to change { journable.journals.count }

expect(journable.journals.reload.last.cause)
.to eq("type" => "label_deleted", "label_ids" => [1, 2], "label_names" => %w[A B])
end

it "does not aggregate an unrelated cause into the label deletion journal" do
expect { journalize(Journal::CausedByWorkPackageParentDeletion.new) }
.to change { journable.journals.count }.by(1)

expect(journable.journals.reload[-2].cause)
.to eq("type" => "label_deleted", "label_ids" => [1], "label_names" => ["A"])
expect(journable.journals.reload.last.cause)
.to eq(Journal::CausedByWorkPackageParentDeletion.new.to_hash)
end
end

context "on a comment within aggregation time after a journal with a meeting cause" do
let(:meeting_cause) { { "type" => "meeting_agenda_item_added", "meeting_id" => 42 } }

Expand Down
Loading
Loading