diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7205797..f203dd4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,13 @@ Improvements:
Fixes:
* email notifications not delivered after recurrence renewal by cron task ([#42](https://it.michalczyk.pro/issues/42))
* handling of recurrences based on fixed date when limit is set could generate errors ([#48](https://it.michalczyk.pro/issues/48))
+* removed obsolete `base.js.erb` layout that interfered with Redmine admin pages
+
+## 1.7.2 [2025-10-31]
+
+Improvements:
+* added plugin setting to copy selected issue relations when creating recurrences
+* prevented relation copying from creating self-referential "copied to" links
## 1.7 [2022-10-21]
diff --git a/Gemfile b/Gemfile
index 590d393..b081997 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,5 +1,6 @@
group :development do
- gem 'web-console'
+ # web-console is not required for plugin tests
+ # gem 'web-console', '~> 4.1.0'
end
group :development, :test do
diff --git a/README.md b/README.md
index da71c14..ca78c35 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,7 @@ The most notable features of this plugin include:
* handling recurrence attributes, including keeping _parent_, _custom fields_, _priority_ and resetting _done ratio_, _time entries_ and _status_,
* ability to recur with or without subtasks,
* ability to have recurrence schemes copied regardless of whether individual issues or whole projects are copied,
+* ability to copy selected issue relations to new recurrences,
* ability to limit recurrence schedule by final date or recurrence count,
* ability to create recurrences ahead in the future,
* showing last recurrence and dates of next/predicted recurrences,
@@ -53,12 +54,12 @@ The most notable features of this plugin include:
1. Check prerequisites. To use this plugin you need to have [Redmine](https://www.redmine.org) installed. Check that your Redmine version is compatible with plugin. Only [stable Readmine releases](https://redmine.org/projects/redmine/wiki/Download#Stable-releases) are supported by new releases. Currently supported are following versions:
- |Redmine |Compatible plugin versions|Tested with |
- |--------|--------------------------|-------------------------------------------------------------------------------------------------------------------|
- |5.0 |1.7 - |Redmine 5.0.2, Ruby 2.7.6p219, Rails 6.1.6 |
- |4.2 |1.7 - |Redmine 4.2.7, Ruby 2.7.6p219, Rails 5.2.8 |
- |4.0 |1.2 - 1.6 |Redmine 4.0.4, Ruby 2.4.6p354, Rails 5.2.3 |
- |3.4 |1.0 - 1.6 |1.5 - 1.6: Redmine 3.4.5, Ruby 2.4.7p357, Rails 4.2.11.1
1.0 - 1.4: Redmine 3.4.5, Ruby 2.3.8p459, Rails 4.2.11|
+ |Redmine | Compatible plugin versions |Tested with |
+ |--------|----------------------------|-------------------------------------------------------------------------------------------------------------------|
+ |5.0 | 1.7 - |Redmine 5.0.2, Ruby 2.7.6p219, Rails 6.1.6 |
+ |4.2 | 1.7 - |Redmine 4.2.7, Ruby 2.7.6p219, Rails 5.2.8 |
+ |4.0 | 1.2 - 1.6 |Redmine 4.0.4, Ruby 2.4.6p354, Rails 5.2.3 |
+ |3.4 | 1.0 - 1.6 |1.5 - 1.6: Redmine 3.4.5, Ruby 2.4.7p357, Rails 4.2.11.1
1.0 - 1.4: Redmine 3.4.5, Ruby 2.3.8p459, Rails 4.2.11|
You may try and find this plugin working on other versions too. The best is to
run test suite and if it passes without errors, everything will most
@@ -157,7 +158,7 @@ Upgrade steps should work for downgrade also, given that you do them in reverse
Database downgrade (```VERSION``` number ```
+<%= label_tag :settings_copy_relation_types, t('.copy_relation_types') %> +<%= relation_copy_options(@settings[:copy_relation_types]) %> +<%= t('.copy_relation_types_hint') %> +
+<%= label_tag :settings_renew_ahead, t('.renew_ahead') %> <%= number_field_tag 'settings[ahead_multiplier]', @settings[:ahead_multiplier], min: 0, step: 1, size: 3 %> diff --git a/config/locales/bg.yml b/config/locales/bg.yml index cb9196a..4ca4bce 100644 --- a/config/locales/bg.yml +++ b/config/locales/bg.yml @@ -51,6 +51,8 @@ bg: warning_author: "*Warning!* Nonexistent user %{login} can't be set as the author of newly recurred issue #%{id}. Keeping author from reference issue. Please select existing user in plugin settings for future recurrences." + warning_copy_relation: "*Warning!* Couldn't copy relation %{relation} to issue #%{id}: + %{errors}." settings: issue_recurrences: author: 'Авторът на новата задача да бъде' @@ -68,6 +70,9 @@ bg: copy_recurrences: 'Копиране на цикличностите при копиране на задачата' copy_recurrences_hint: 'прилага се независимо дали задачите се копират директно или като резултат от копиране на проект' + copy_relation_types: 'Копиране на връзките при създаване на повторения' + copy_relation_types_hint: 'изберете типовете връзки, които да се копират от отправната + задача към всяко повторение' renew_ahead: 'Renew fixed recurrences ahead for' renew_ahead_hint: 'ensures that the last created recurrence is at least that far into the future (recurrences based on close date are not affected)' diff --git a/config/locales/en.yml b/config/locales/en.yml index 81d4111..9928af2 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -39,6 +39,8 @@ en: warning_author: "*Warning!* Nonexistent user %{login} can't be set as the author of newly recurred issue #%{id}. Keeping author from reference issue. Please select existing user in plugin settings for future recurrences." + warning_copy_relation: "*Warning!* Couldn't copy relation %{relation} to issue #%{id}: + %{errors}." settings: issue_recurrences: author: 'Set author of new recurrence to' @@ -54,6 +56,9 @@ en: copy_recurrences: 'Copy recurrences on issue copy' copy_recurrences_hint: 'applies regardless of whether issues are copied directly or as a result of project copy' + copy_relation_types: 'Copy relations when creating recurrences' + copy_relation_types_hint: 'select relation types to copy from the reference issue into + each recurrence' renew_ahead: 'Renew fixed recurrences ahead for' renew_ahead_hint: 'ensures that the last created recurrence is at least that far into the future (recurrences based on close date are not affected)' diff --git a/config/locales/es.yml b/config/locales/es.yml index 9c76905..e52c31e 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -47,6 +47,8 @@ es: warning_author: "*Warning!* Nonexistent user %{login} can't be set as the author of newly recurred issue #%{id}. Keeping author from reference issue. Please select existing user in plugin settings for future recurrences." + warning_copy_relation: "*Warning!* Couldn't copy relation %{relation} to issue #%{id}: + %{errors}." settings: issue_recurrences: author: 'Establecer autor para nueva recurrencia a' @@ -64,6 +66,9 @@ es: copy_recurrences: 'Copy recurrences on issue copy' copy_recurrences_hint: 'applies regardless of whether issues are copied directly or as a result of project copy' + copy_relation_types: 'Copy relations when creating recurrences' + copy_relation_types_hint: 'select relation types to copy from the reference issue into + each recurrence' renew_ahead: 'Renew fixed recurrences ahead for' renew_ahead_hint: 'ensures that the last created recurrence is at least that far into the future (recurrences based on close date are not affected)' diff --git a/init.rb b/init.rb index daccc93..17f4f5e 100644 --- a/init.rb +++ b/init.rb @@ -46,7 +46,7 @@ def load_patches name 'Issue recurring plugin' author 'cryptogopher' description 'Schedule Redmine issue recurrence based on multiple conditions' - version '1.7' + version '1.7.2' url 'https://github.com/cryptogopher/issue_recurring' author_url 'https://github.com/cryptogopher' @@ -68,6 +68,7 @@ def load_patches keep_assignee: false, journal_mode: :never, copy_recurrences: false, + copy_relation_types: [], ahead_multiplier: 0, ahead_mode: :days }, partial: 'settings/issue_recurrences' diff --git a/lib/issue_recurring/issue_patch.rb b/lib/issue_recurring/issue_patch.rb index 6dfefdf..4d64f77 100644 --- a/lib/issue_recurring/issue_patch.rb +++ b/lib/issue_recurring/issue_patch.rb @@ -1,23 +1,9 @@ module IssueRecurring - module CopyFromWithRecurrences - def copy_from(arg, options={}) - super - - unless options[:skip_recurrences] - self.recurrence_of = nil - - if Setting.plugin_issue_recurring[:copy_recurrences] - self.recurrences = @copied_from.recurrences.map(&:dup) - end - end - - self - end - end - module IssuePatch - Issue.class_eval do - prepend CopyFromWithRecurrences + extend ActiveSupport::Concern + + included do + include InstanceMethods has_many :recurrences, class_name: 'IssueRecurrence', dependent: :destroy @@ -26,19 +12,37 @@ module IssuePatch dependent: :nullify after_destroy :substitute_if_last_issue - end - def substitute_if_last_issue - return if self.recurrence_of.blank? - r = self.recurrence_of.recurrences.find_by(last_issue: self) - return if r.nil? - r.update!(last_issue: r.issue.recurrence_copies.last) + alias_method :copy_from_without_recurrences, :copy_from + alias_method :copy_from, :copy_from_with_recurrences end - def default_reassign - self.assigned_to = nil - default_assign + module InstanceMethods + def copy_from_with_recurrences(arg, options={}) + copy_from_without_recurrences(arg, options) + + unless options[:skip_recurrences] + self.recurrence_of = nil + + if Setting.plugin_issue_recurring[:copy_recurrences] + self.recurrences = @copied_from.recurrences.map(&:dup) + end + end + + self + end + + def substitute_if_last_issue + return if self.recurrence_of.blank? + r = self.recurrence_of.recurrences.find_by(last_issue: self) + return if r.nil? + r.update!(last_issue: r.issue.recurrence_copies.last) + end + + def default_reassign + self.assigned_to = nil + default_assign + end end end end - diff --git a/lib/issue_recurring/settings_controller_patch.rb b/lib/issue_recurring/settings_controller_patch.rb index e5a653c..dae4960 100644 --- a/lib/issue_recurring/settings_controller_patch.rb +++ b/lib/issue_recurring/settings_controller_patch.rb @@ -25,6 +25,10 @@ def save_issue_recurring_settings settings[:copy_recurrences] = params[:settings][:copy_recurrences] == 'true' ? true : false + relation_types = Array(params[:settings][:copy_relation_types]).map(&:to_s) + settings[:copy_relation_types] = + relation_types & IssueRelation::TYPES.keys + settings[:ahead_multiplier] = params[:settings][:ahead_multiplier].to_i.abs ahead_mode = params[:settings][:ahead_mode].to_sym settings[:ahead_mode] = IssueRecurrence::AHEAD_MODES.include?(ahead_mode) ? diff --git a/lib/issue_recurring/settings_helper_patch.rb b/lib/issue_recurring/settings_helper_patch.rb index 3d8d90e..21eaaa7 100644 --- a/lib/issue_recurring/settings_helper_patch.rb +++ b/lib/issue_recurring/settings_helper_patch.rb @@ -18,6 +18,24 @@ def ahead_mode_options(default) modes.map { |am| [t("issues.recurrences.form.delay_modes.#{am}"), am] }, default ) end + + def relation_copy_options(selected) + selected = Array(selected).map(&:to_s) + types = IssueRelation::TYPES.keys + types = types.sort_by { |type| l(IssueRelation::TYPES[type][:name]) } + types.map do |type| + label = l(IssueRelation::TYPES[type][:name]) + content_tag(:label, class: 'block') do + safe_join([ + check_box_tag('settings[copy_relation_types][]', type, + selected.include?(type), + id: "settings_copy_relation_types_#{type}"), + ' ', + h(label) + ]) + end + end.join.html_safe + end end end diff --git a/test/integration/issue_recurrences_test.rb b/test/integration/issue_recurrences_test.rb index b205f72..34ef436 100644 --- a/test/integration/issue_recurrences_test.rb +++ b/test/integration/issue_recurrences_test.rb @@ -2506,6 +2506,115 @@ def test_renew_creation_mode_copy_last assert_equal rel2.issue_to, r2 end + def test_renew_copies_selected_relations + logout_user + log_user 'admin', 'foo' + update_plugin_settings(copy_relation_types: ['relates']) + logout_user + log_user 'alice', 'foo' + + @issue1.update!(start_date: Date.new(2018,9,15), due_date: Date.new(2018,9,20)) + IssueRelation.create!(issue_from: @issue1, issue_to: @issue2, relation_type: 'relates') + + create_recurrence + travel_to(@issue1.start_date) + new_issue = renew_all(1) + + relation = IssueRelation.find_by(issue_from: new_issue, issue_to: @issue2, + relation_type: 'relates') + relation ||= IssueRelation.find_by(issue_from: @issue2, issue_to: new_issue, + relation_type: 'relates') + assert relation + ensure + logout_user + log_user 'admin', 'foo' + update_plugin_settings(copy_relation_types: []) + logout_user + log_user 'alice', 'foo' + end + + def test_renew_copies_only_selected_blocked_relations + logout_user + log_user 'admin', 'foo' + update_plugin_settings(copy_relation_types: ['blocked']) + logout_user + log_user 'alice', 'foo' + + @issue1.update!(start_date: Date.new(2018,9,15), due_date: Date.new(2018,9,20)) + IssueRelation.create!(issue_from: @issue3, issue_to: @issue1, relation_type: 'blocks') + + create_recurrence + travel_to(@issue1.start_date) + new_issue = renew_all(1) + + relation = IssueRelation.find_by(issue_from: @issue3, issue_to: new_issue, + relation_type: 'blocks') + assert relation + + refute IssueRelation.exists?(issue_from: new_issue, relation_type: 'blocks') + ensure + logout_user + log_user 'admin', 'foo' + update_plugin_settings(copy_relation_types: []) + logout_user + log_user 'alice', 'foo' + end + + def test_renew_copies_only_selected_blocks_relations + logout_user + log_user 'admin', 'foo' + update_plugin_settings(copy_relation_types: ['blocks']) + logout_user + log_user 'alice', 'foo' + + @issue1.update!(start_date: Date.new(2018,9,15), due_date: Date.new(2018,9,20)) + IssueRelation.create!(issue_from: @issue1, issue_to: @issue3, relation_type: 'blocks') + + create_recurrence + travel_to(@issue1.start_date) + new_issue = renew_all(1) + + relation = IssueRelation.find_by(issue_from: new_issue, issue_to: @issue3, + relation_type: 'blocks') + assert relation + + refute IssueRelation.exists?(issue_to: new_issue, relation_type: 'blocks') + ensure + logout_user + log_user 'admin', 'foo' + update_plugin_settings(copy_relation_types: []) + logout_user + log_user 'alice', 'foo' + end + + def test_renew_copied_to_relations_skip_self_reference + logout_user + log_user 'admin', 'foo' + update_plugin_settings(copy_relation_types: ['copied_to']) + logout_user + log_user 'alice', 'foo' + + @issue1.update!(start_date: Date.new(2018,9,15), due_date: Date.new(2018,9,20)) + IssueRelation.create!(issue_from: @issue1, issue_to: @issue3, relation_type: 'copied_to') + + create_recurrence + travel_to(@issue1.start_date) + new_issue = renew_all(1) + + assert_nil IssueRelation.find_by(issue_from: new_issue, issue_to: new_issue, + relation_type: 'copied_to') + + relation = IssueRelation.find_by(issue_from: new_issue, issue_to: @issue3, + relation_type: 'copied_to') + assert relation + ensure + logout_user + log_user 'admin', 'foo' + update_plugin_settings(copy_relation_types: []) + logout_user + log_user 'alice', 'foo' + end + def test_renew_creation_mode_reopen @issue1.update!(start_date: Date.new(2018,9,15), due_date: Date.new(2018,9,20)) diff --git a/test/unit/issue_recurrence_test.rb b/test/unit/issue_recurrence_test.rb index d062c97..16f9c89 100644 --- a/test/unit/issue_recurrence_test.rb +++ b/test/unit/issue_recurrence_test.rb @@ -9,6 +9,8 @@ class IssueRecurrenceTest < ActiveSupport::TestCase def setup @issue1 = issues(:issue_01) + @issue2 = issues(:issue_02) + @issue3 = issues(:issue_03) User.current = users(:alice) end @@ -19,4 +21,104 @@ def test_new assert ir ir.save! end + + def test_copy_issue_relations_removes_unselected_types + recurrence = IssueRecurrence.new(issue: @issue1) + stray = IssueRelation.create!(issue_from: @issue2, issue_to: @issue3, + relation_type: 'blocks') + + recurrence.send(:copy_issue_relations, @issue1, @issue2, []) + + assert_not IssueRelation.exists?(stray.id) + ensure + stray.destroy if stray&.persisted? + end + + def test_copy_issue_relations_removes_unselected_incoming_types + recurrence = IssueRecurrence.new(issue: @issue1) + stray = IssueRelation.create!(issue_from: @issue3, issue_to: @issue2, + relation_type: 'blocks') + + recurrence.send(:copy_issue_relations, @issue1, @issue2, []) + + assert_not IssueRelation.exists?(stray.id) + ensure + stray.destroy if stray&.persisted? + end + + def test_copy_issue_relations_skips_duplicate_relations + recurrence = IssueRecurrence.new(issue: @issue1) + source = IssueRelation.create!(issue_from: @issue1, issue_to: @issue3, + relation_type: 'relates') + duplicate = IssueRelation.create!(issue_from: @issue2, issue_to: @issue3, + relation_type: 'relates') + + assert_no_difference 'IssueRelation.count' do + recurrence.send(:copy_issue_relations, @issue1, @issue2, ['relates']) + end + assert_equal '', recurrence.journal_notes + ensure + source.destroy if source&.persisted? + duplicate.destroy if duplicate&.persisted? + end + + def test_copy_issue_relations_copies_only_selected_outgoing_type + recurrence = IssueRecurrence.new(issue: @issue1) + source = IssueRelation.create!(issue_from: @issue1, issue_to: @issue3, + relation_type: 'blocks') + + recurrence.send(:copy_issue_relations, @issue1, @issue2, ['blocks']) + + copied = IssueRelation.find_by(issue_from: @issue2, issue_to: @issue3, + relation_type: 'blocks') + assert copied + assert_equal 1, IssueRelation.where(issue_from: @issue2, issue_to: @issue3, + relation_type: 'blocks').count + ensure + source.destroy if source&.persisted? + copied&.destroy if copied&.persisted? + end + + def test_copy_issue_relations_copies_only_selected_incoming_type + recurrence = IssueRecurrence.new(issue: @issue1) + source = IssueRelation.create!(issue_from: @issue3, issue_to: @issue1, + relation_type: 'blocks') + + recurrence.send(:copy_issue_relations, @issue1, @issue2, ['blocked']) + + copied = IssueRelation.find_by(issue_from: @issue3, issue_to: @issue2, + relation_type: 'blocks') + assert copied + assert_equal 1, IssueRelation.where(issue_from: @issue3, issue_to: @issue2, + relation_type: 'blocks').count + ensure + source.destroy if source&.persisted? + copied&.destroy if copied&.persisted? + end + + def test_copy_issue_relations_skips_unselected_outgoing_type + recurrence = IssueRecurrence.new(issue: @issue1) + source = IssueRelation.create!(issue_from: @issue1, issue_to: @issue3, + relation_type: 'blocks') + + recurrence.send(:copy_issue_relations, @issue1, @issue2, ['blocked']) + + refute IssueRelation.exists?(issue_from: @issue2, issue_to: @issue3, + relation_type: 'blocks') + ensure + source.destroy if source&.persisted? + end + + def test_copy_issue_relations_skips_unselected_incoming_type + recurrence = IssueRecurrence.new(issue: @issue1) + source = IssueRelation.create!(issue_from: @issue3, issue_to: @issue1, + relation_type: 'blocks') + + recurrence.send(:copy_issue_relations, @issue1, @issue2, ['blocks']) + + refute IssueRelation.exists?(issue_from: @issue3, issue_to: @issue2, + relation_type: 'blocks') + ensure + source.destroy if source&.persisted? + end end