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 `````` is a number taken from migration file name in _issue_recurring/db/migrate_): ``` - ca /var/lib/redmine + cd /var/lib/redmine RAILS_ENV=production bundle exec rake redmine:plugins:migrate VERSION= NAME=issue_recurring ``` Keep in mind though, that downgrading database might cause some information to be lost irreversibly. This is because some downgrades may require deletion of tables/columns that were introduced in higher version. Also structure of the data may not be compatible between versions, so the automatic conversion can be lossy. diff --git a/app/models/issue_recurrence.rb b/app/models/issue_recurrence.rb index 61ec435..38935d5 100644 --- a/app/models/issue_recurrence.rb +++ b/app/models/issue_recurrence.rb @@ -422,6 +422,9 @@ def create(dates) log(:warning_author, id: new_issue.id, login: author_login) end + relation_types = Array(Setting.plugin_issue_recurring[:copy_relation_types]) + copy_issue_relations(ref_issue, new_issue, relation_types) + if self.include_subtasks target_label = self.anchor_to_start ? :start : :due new_issue.children.each do |child| @@ -670,10 +673,74 @@ def self.renew_all(quiet=false) private + def copy_issue_relations(ref_issue, new_issue, relation_types) + relation_types = Array(relation_types).map(&:to_s) + + (new_issue.relations_from.to_a + new_issue.relations_to.to_a).uniq.each do |relation| + type_for_issue = relation_type_for_issue(relation, new_issue) + next if relation_types.include?(type_for_issue) + + relation.destroy + end + + return if relation_types.blank? + + ref_issue.relations.each do |relation| + type_for_issue = relation_type_for_issue(relation, ref_issue) + next unless relation_types.include?(type_for_issue) + + issue_from = relation.issue_from == ref_issue ? new_issue : relation.issue_from + issue_to = relation.issue_to == ref_issue ? new_issue : relation.issue_to + + next if issue_from == issue_to + + existing = IssueRelation + .where(relation_type: relation.relation_type) + .where( + "(issue_from_id = :from_id AND issue_to_id = :to_id) OR " \ + "(issue_from_id = :to_id AND issue_to_id = :from_id)", + from_id: issue_from.id, + to_id: issue_to.id + ) + .exists? + next if existing + + new_relation = IssueRelation.new(relation_type: relation.relation_type, + delay: relation.delay) + new_relation.issue_from = issue_from + new_relation.issue_to = issue_to + + next if new_relation.save + + log(:warning_copy_relation, + id: new_issue.id, + relation: relation.relation_type, + errors: new_relation.errors.full_messages.to_sentence) + end + end + def log(label, **args) @journal_notes << "#{l(label, args)}\r\n" end + INVERSE_RELATION_TYPES = { + 'relates' => 'relates', + 'duplicates' => 'duplicated', + 'duplicated' => 'duplicates', + 'blocks' => 'blocked', + 'blocked' => 'blocks', + 'precedes' => 'follows', + 'follows' => 'precedes', + 'copied_to' => 'copied_from', + 'copied_from' => 'copied_to' + }.freeze + + def relation_type_for_issue(relation, issue) + return relation.relation_type if relation.issue_from_id == issue.id + + INVERSE_RELATION_TYPES.fetch(relation.relation_type, relation.relation_type) + end + class Date < ::Date def self.today # Due to its nature, Date.today may sometimes be equal to Date.yesterday/tomorrow. diff --git a/app/views/layouts/base.js.erb b/app/views/layouts/base.js.erb deleted file mode 100644 index 3c22679..0000000 --- a/app/views/layouts/base.js.erb +++ /dev/null @@ -1,3 +0,0 @@ -$('div[id^=flash_]').remove(); -$('#content').prepend('<%= j render_flash_messages %>'); -<%= yield %> diff --git a/app/views/settings/_issue_recurrences.html.erb b/app/views/settings/_issue_recurrences.html.erb index 4c40000..2be0059 100644 --- a/app/views/settings/_issue_recurrences.html.erb +++ b/app/views/settings/_issue_recurrences.html.erb @@ -18,6 +18,11 @@ <%= t('.copy_recurrences_hint') %>

+<%= 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