Skip to content
Open
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
3 changes: 2 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<br/>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<br/>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
Expand Down Expand Up @@ -157,7 +158,7 @@ Upgrade steps should work for downgrade also, given that you do them in reverse

Database downgrade (```VERSION``` number ```<NNN>``` 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=<NNN> 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.
Expand Down
67 changes: 67 additions & 0 deletions app/models/issue_recurrence.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 0 additions & 3 deletions app/views/layouts/base.js.erb

This file was deleted.

5 changes: 5 additions & 0 deletions app/views/settings/_issue_recurrences.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
<em class="info"><%= t('.copy_recurrences_hint') %></em>
</p>
<p>
<%= label_tag :settings_copy_relation_types, t('.copy_relation_types') %>
<%= relation_copy_options(@settings[:copy_relation_types]) %>
<em class="info"><%= t('.copy_relation_types_hint') %></em>
</p>
<p>
<%= label_tag :settings_renew_ahead, t('.renew_ahead') %>
<%= number_field_tag 'settings[ahead_multiplier]', @settings[:ahead_multiplier], min: 0,
step: 1, size: 3 %>
Expand Down
5 changes: 5 additions & 0 deletions config/locales/bg.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: 'Авторът на новата задача да бъде'
Expand All @@ -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)'
Expand Down
5 changes: 5 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)'
Expand Down
5 changes: 5 additions & 0 deletions config/locales/es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)'
Expand Down
3 changes: 2 additions & 1 deletion init.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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'
Expand Down
60 changes: 32 additions & 28 deletions lib/issue_recurring/issue_patch.rb
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

4 changes: 4 additions & 0 deletions lib/issue_recurring/settings_controller_patch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) ?
Expand Down
18 changes: 18 additions & 0 deletions lib/issue_recurring/settings_helper_patch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Loading