├── .gitignore ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── app ├── controllers │ ├── concerns │ │ └── load_issue_recurrences.rb │ └── issue_recurrences_controller.rb ├── helpers │ └── issue_recurrences_helper.rb ├── models │ └── issue_recurrence.rb └── views │ ├── issue_recurrences │ ├── create.js.erb │ ├── destroy.js.erb │ ├── index.html.erb │ └── new.js.erb │ ├── issues │ ├── _issue_recurrences_hook.html.erb │ └── recurrences │ │ ├── _form.html.erb │ │ └── _index.html.erb │ ├── layouts │ └── base.js.erb │ └── settings │ └── _issue_recurrences.html.erb ├── config ├── locales │ ├── bg.yml │ ├── en.yml │ └── es.yml └── routes.rb ├── db └── migrate │ ├── 001_create_issue_recurrences.rb │ ├── 002_add_recurrence_of_to_issues.rb │ ├── 003_add_anchor_to_start_to_issue_recurrences.rb │ ├── 004_add_anchor_date_to_issue_recurrence.rb │ ├── 005_extend_and_change_setting_name_add_journal_to_journal_mode.rb │ ├── 006_convert_author_id_setting_to_author_login.rb │ ├── 007_add_renew_ahead_settings_defaults.rb │ └── 008_rename_journal_modes_setting_inplace_to_on_reopen.rb ├── init.rb ├── lib ├── issue_recurring │ ├── issue_patch.rb │ ├── issue_recurrences_view_listener.rb │ ├── issues_controller_patch.rb │ ├── issues_helper_patch.rb │ ├── project_patch.rb │ ├── schema_dumper_patch.rb │ ├── schema_patch.rb │ ├── schema_statements_patch.rb │ ├── settings_controller_patch.rb │ ├── settings_helper_patch.rb │ └── system_test_case_patch.rb └── tasks │ └── issue_recurring.rake └── test ├── application_system_test_case.rb ├── fixtures ├── custom_fields.yml ├── email_addresses.yml ├── enabled_modules.yml ├── enumerations.yml ├── issue_priorities.yml ├── issue_statuses.yml ├── issues.yml ├── member_roles.yml ├── members.yml ├── projects.yml ├── roles.yml ├── trackers.yml ├── users.yml └── workflow_transitions.yml ├── integration └── issue_recurrences_test.rb ├── migration └── migration_test.rb ├── system └── issue_recurrences_test.rb ├── test_case.rb ├── test_helper.rb └── unit └── issue_recurrence_test.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .byebug_history 2 | Gemfile.lock 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.8 [coming soon] 4 | 5 | New features: 6 | * recurrence editing ([#11](https://it.michalczyk.pro/issues/11)) 7 | * successful operations on issue page confirmed by flash messages 8 | * enabled _copy_ recurrences based on fixed date (previously only _reopen_ was allowed) ([#36](https://it.michalczyk.pro/issues/36)) 9 | 10 | Improvements: 11 | * migration and system tests ([#16](https://it.michalczyk.pro/issues/16)) 12 | * reworded 'in-place' to 'reopen' for better understanding 13 | 14 | Fixes: 15 | * email notifications not delivered after recurrence renewal by cron task ([#42](https://it.michalczyk.pro/issues/42)) 16 | * handling of recurrences based on fixed date when limit is set could generate errors ([#48](https://it.michalczyk.pro/issues/48)) 17 | 18 | ## 1.7 [2022-10-21] 19 | 20 | New features: 21 | * Redmine versions supported: 4.2 and 5.0 ([#28](https://it.michalczyk.pro/issues/28), [#34](https://it.michalczyk.pro/issues/34)) 22 | * obsolete Redmine versions: 3.4, 4.0 23 | * more than one future recurrence for fixed schedules can be created by setting 24 | renew ahead period ([#27](https://it.michalczyk.pro/issues/27)) 25 | * Bulgarian translation, thanks to [jwalkerbg](https://github.com/jwalkerbg) 26 | 27 | Improvements: 28 | * when previous assignee is no longer assignable (e.g. due to account being 29 | blocked or any other reason recognized by Redmine) and ```keep_assignee``` setting 30 | is used, default Redmine assignment applies and warning is recorded in 31 | issue's note ([#26](https://it.michalczyk.pro/issues/26)) 32 | * when author of new recurrence is given in settings and user does not exist 33 | (e.g. he because was deleted in the meantime), author is set to that of 34 | reference issue and warning is recorded in issue's note 35 | * it is now possible to specify Anonymous user as the author of new 36 | recurrences in plugin settings 37 | 38 | Fixes: 39 | * formatting of warning messages in journal 40 | * migrations no longer depend on model, which caused some of them to 41 | fail on upgrade ([#35](https://it.michalczyk.pro/issues/35)) 42 | 43 | ## 1.6 [2020-04-11] 44 | 45 | * upgraded wording of anchor modes in recurrence form 46 | * added new value for option specifying whether to add journal for new recurrence; now journal can not only be enabled/diabled, but also enabled selectively for _in-place_ recurrences; if you use this option, you can eliminate email notifications on (less important) journal updates on reference issues with _copy_ recurrences, while still getting notifications on: a) new issues created from _copy_ recurrences and b) journal updates on issues with _in-place_ recurrences ([#24](https://it.michalczyk.pro/issues/24)) 47 | * issue recurrence schemes are now copied along with issue, regardless of whether you copy individual issues or projects; this behavior can be controlled by plugin setting ([#21](https://it.michalczyk.pro/issues/21)); if recurrence copy fails on project copy (e.g. because issue recurring module is not enabled for project) it is silently ignored; if recurrence copy fails on issue (e.g. because required issue date has been removed) - issue copy is aborted and error reported 48 | 49 | ## 1.5 [2019-11-29] 50 | 51 | * properly handling parent attribute of recurred issue 52 | * reporting in-place recurrence without subtasks as invalid if issue dates are derived from children (previously it was possible to create such recurrence, though it wouldn't recur properly) 53 | 54 | ## 1.4 [2019-08-19] 55 | 56 | * added Spanish translation, thanks to [lupa18](https://github.com/lupa18/) 57 | * introduced order independent recurrence scheduling when there is more than 1 recurrence schedule assigned to issue; this is rare configuration and situations where order does really matter are even more rare (e.g. when there is non-inplace and inplace schedule or when there are multiple inplace schedules) 58 | * fixed display of _Next_ recurrence dates; _Next_ dates show what recurrences will be created if the renewal process is executed _now_ 59 | * added display of _Predicted_ recurrence dates; _Predicted_ dates show what recurrences will be created in future given that no issue dates will change and assuming that non-closed issues will be closed today; this is to give you overview how your schedule(s) work(s) and in future may be extended to show more than 1 future date at a time 60 | 61 | ## 1.3 [2019-07-14] 62 | 63 | * added 2 new scheduling algorithms: 64 | * based on last recurrence dates, but recurs only after last recurrence has been closed (i.e. after its close date) 65 | * based on fixed date configured separately from issue's own start/due dates; recurs only after last recurrence close date; this is the only recurrence scheme that allows multiple in-place recurrence schemes for one issue (and has been introduced exactly to allow that) 66 | * changed input form for recurrence creation: 67 | * recurrence limit input has been changed from radio buttons to drop-down list; it makes form more compact/consistent 68 | * wording and order of some options has been changed to create (hopefully) more natural reading experience 69 | * for readability inactive form inputs now fade out and hide instead of being visible but disabled 70 | 71 | ## 1.2 [2019-07-03] 72 | 73 | * plugin is now compatible with Redmine 4.0/Rails 5.2, (2019-07-14: well, actually it is compatible with Redmine 4.0, but due to mistake migrations don't work with Redmine 3.4; either update to v1.3 or copy migration files from there) 74 | * it is now disallowed to create multiple in-place recurrence schedules for single issue. No real world scenario could justify such configuration and it might cause problems for the unwary (2020-01-17: this has actually changed in v1.3 and there is new scheduling algorithm introduced to allow multiple in-place recurrences) 75 | 76 | ## 1.1 [2019-05-04] 77 | 78 | * from now on it is possible to explicitly specify if recurrence will be based on start or due date, for every recurrence type. Previously it was only possible for monthly recurrences. All other recurrences were treated automatically, depending on start/due date availability. You can use this feature to e.g. decide how recurrences based on close date will be treated: you can have either start or due date of next recurrence based on close date of the previous one. Upon upgrading all existing recurrences will be migrated according to previous rules, which were as follows: 79 | * for monthly recurrences things will be kept unchanged (monthly recurrences already had distinct start/due options) 80 | * for all of the rest: if start date is available and due date is empty for reference issue - recurrence will be based on start date; otherwise recurrence will be based on due date (that is also true for recurrences based on close date, which may have both start and due dates missing; such recurrence will be based on due date as well) 81 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | group :development do 2 | gem 'web-console' 3 | end 4 | 5 | group :development, :test do 6 | gem 'rails-controller-testing' 7 | gem 'byebug' 8 | end 9 | 10 | group :test do 11 | gem 'ruby-prof' 12 | end 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | Plugin to schedule Redmine issue recurrence (see __Features__ below for possible scheduling options). Plugin creates new issue or reopens existing issue for each recurrence. 4 | 5 | [Changelog](https://github.com/cryptogopher/issue_recurring/blob/master/CHANGELOG.md) 6 | 7 | [Issue tracker](https://it.michalczyk.pro/projects/issue-recurring/issues) (you can register and login there with your __Github account__). 8 | 9 | [Screenshots](https://it.michalczyk.pro/projects/issue-recurring/wiki/Screenshots) (outdated) 10 | 11 | ## Features 12 | 13 | Greatest emphasis in development is put on reliability. Scheduling algorithms are tested for accuracy. Unusual situations are reported to user in a visible manner during use. Avoiding regressions and eliminating bugs is valued over new functionalities. 14 | 15 | The most notable features of this plugin include: 16 | * seamless integration with Redmine regarding look and workflow, 17 | * recurrence schedule creation/edition/deletion directly from form on issue page (no page reloading), 18 | * multiple recurrence schedules per issue possible (except for _reopen_ recurrence based on close date, where only 1 schedule is possible), 19 | * creation of next recurrence by means of: 20 | * copying first issue, 21 | * copying last recurrence of issue, 22 | * without copying, by reopening issue 23 | * specification of recurrence frequency by means of: 24 | * days, 25 | * working days (according to non-working week days specified in Redmine settings), 26 | * weeks, 27 | * months with dates keeping fixed distance from the beginning or to the end of the month (e.g. 2nd to last day of month), 28 | * months with dates keeping the same day of week from the beginning or to the end of the month (e.g. 2nd Tuesday of month or 1st to last Friday of month), 29 | * months with dates keeping the same working day (according to non-working week days specified in Redmine settings) from the beginning or to the end of the month (e.g. 2nd working day of month or 1st to last working day of month), 30 | * years, 31 | * ability to decide whether start or due date is inferred from base date (the other date is calculated to keep datespan of issue unchanged), 32 | * setting next recurrence date based on: 33 | * original issue date, 34 | * last recurrence date, 35 | * close date of last recurrence, 36 | * last recurrence date if closed on time or close date otherwise, 37 | * last recurrence date but only after it has been closed, 38 | * fixed date after last recurrence has been closed 39 | * ability to delay recurrence against base date to create multiple recurrences of the same frequency with different time offset (e.g. monthly recurrence on 10th, 20th and 30th day of month), 40 | * handling recurrence attributes, including keeping _parent_, _custom fields_, _priority_ and resetting _done ratio_, _time entries_ and _status_, 41 | * ability to recur with or without subtasks, 42 | * ability to have recurrence schemes copied regardless of whether individual issues or whole projects are copied, 43 | * ability to limit recurrence schedule by final date or recurrence count, 44 | * ability to create recurrences ahead in the future, 45 | * showing last recurrence and dates of next/predicted recurrences, 46 | * logging errors as an issue note if unable to renew recurrence (instead of logging into web-inaccessible log file), 47 | * permissions to view/manage recurrence schedules managed by Redmine roles, 48 | * per project enabling of plugin, 49 | * specification of recurrence author: selected Redmine user (including Anonymous) or author of previous recurrence, 50 | * specification of recurrence assignment: keep unchanged from previous recurrence or set to Redmine's default. 51 | 52 | ## Installation 53 | 54 | 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: 55 | 56 | |Redmine |Compatible plugin versions|Tested with | 57 | |--------|--------------------------|-------------------------------------------------------------------------------------------------------------------| 58 | |5.0 |1.7 - |Redmine 5.0.2, Ruby 2.7.6p219, Rails 6.1.6 | 59 | |4.2 |1.7 - |Redmine 4.2.7, Ruby 2.7.6p219, Rails 5.2.8 | 60 | |4.0 |1.2 - 1.6 |Redmine 4.0.4, Ruby 2.4.6p354, Rails 5.2.3 | 61 | |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| 62 | 63 | You may try and find this plugin working on other versions too. The best is to 64 | run test suite and if it passes without errors, everything will most 65 | probably be ok: 66 | ``` 67 | cd /var/lib/redmine 68 | RAILS_ENV=test bundle exec rake redmine:plugins:test NAME=issue_recurring 69 | ``` 70 | 71 | 2. Login to shell, change to redmine user, clone plugin to your plugins directory, list and choose which plugin version to install, install gemfiles and migrate database: 72 | ``` 73 | su - redmine 74 | cd /var/lib/redmine/plugins/ 75 | git clone https://github.com/cryptogopher/issue_recurring.git 76 | 77 | # Following 2 steps allow you to run particular version of plugin. Generally it is advised to go with the highest 78 | # available version, as it is most feature rich. But you can omit those 2 commands ang go with latest code as well. 79 | git -C issue_recurring/ tag 80 | # Doing checkout this way you will get "You are in 'detached HEAD' state." warning; it's ok to ignore it 81 | git -C issue_recurring/ checkout tags/1.4 82 | 83 | cd /var/lib/redmine 84 | bundle install 85 | RAILS_ENV=production bundle exec rake redmine:plugins:migrate NAME=issue_recurring 86 | ``` 87 | 88 | 3. Restart Redmine. Exact steps depend on your installation of Redmine. You may need to restart Apache (when using Passenger) or just Redmine daemon/service. 89 | 90 | 4. Update Redmine settings. 91 | * enable _Issue recurring_ module per project (choose project -> Settings -> Modules -> check Issue recurring) 92 | * (optional) create separate Redmine user as an author of issue recurrences (Administration -> Users -> New user) 93 | * grant issue recurring permissions to roles (Administration -> Roles and permissions -> Permissions report). Issue recurring permissions are inside _Issue recurring_ group. There are 2 types of permissions: 94 | * _View issue recurrences_ - should be granted to everybody who needs to view recurrence information 95 | * _Manage issue recurrences_ - should be granted for roles responsible for creating/deleting issue recurrences 96 | 97 | 5. Update plugin settings. (Administration -> Plugins -> Issue recurring plugin -> Configure) 98 | 99 | 6. Add cron task to enable recurrence creation at least once a day. 100 | ``` 101 | 12 6 * * * cd /var/lib/redmine && RAILS_ENV=production bundle exec rake redmine:issue_recurring:renew_all >> log/cron-issue_recurring.log 102 | ``` 103 | 104 | 7. Go to Redmine, create/open issue, add issue recurrence. 105 | 106 | 8. Have fun! 107 | 108 | ## Troubleshooting 109 | 110 | Problems often arise when there are multiple plugins installed. If you notice issues, please follow steps: 111 | 112 | 1. Uninstall all plugins except issue_recurring. Check if problem persist. If yes, go to step 3. 113 | 114 | 2. Install & remove other plugins one by one, each time trying to reproduce the issue. There can be more than 1 plugin causing issues, so it's best to test one by one to identify all of them. Once you'll find plugin(s) responsible for problems, go to step 3. 115 | 116 | 3. [Fill bug report](https://it.michalczyk.pro/projects/issue-recurring/issues) sharing your discoveries. You may want to additionally attach: 117 | * Redmine log file (_log/production.log_), with log level set to :debug if possible, 118 | * Redmine Info page contents (http(s)://your.redmine.com/admin/info, you need to be logged in as Administrator), 119 | * command line output - if problem occurs during cron job. 120 | 121 | ## Upgrade 122 | 123 | 1. Read [Changelog](https://github.com/cryptogopher/issue_recurring/blob/master/CHANGELOG.md) to know what to expect from upgrade. Sometimes upgrade may require additional steps to be taken. Exact information will be given there. 124 | 125 | 2. Create backup of current plugin installation. Upgrade process should be reversible in case you only do it between released versions (as opposed to upgrading to some particular git commit). But it's better to be safe than sorry, so make a copy of plugin directory and database. It should go like that (but the exact steps can vary depending on your installation, e.g. database backup step is given for MySQL only): 126 | ``` 127 | cd /var/lib/redmine 128 | # stop Redmine instance before continuing 129 | tar czvf /backup/issue_recurring-$(date +%Y%m%d).tar.gz -C plugins/issue_recurring/ . 130 | mysqldump --host --user -p > /backup/issue_recurring-$(date +%Y%m%d).sql 131 | # start Redmine instance before continuing 132 | ``` 133 | 134 | 3. Using redmine user update plugin code to desired version (version number is at the end of ```git checkout``` command) , check gem requirements and migrate database: 135 | 136 | ``` 137 | su - redmine 138 | cd /var/lib/redmine/plugins/issue_recurring/ 139 | git fetch --prune --prune-tags 140 | # choose version from this list 141 | git tag 142 | # doing checkout this way you can get "You are in 'detached HEAD' state." warning; it's ok to ignore it 143 | git checkout tags/1.4 144 | 145 | cd /var/lib/redmine 146 | bundle update 147 | RAILS_ENV=production bundle exec rake redmine:plugins:migrate NAME=issue_recurring 148 | ``` 149 | 150 | 4. Restart Redmine. Exact steps depend on your installation of Redmine. 151 | 152 | 5. Check for new plugin settings and set if necessary. (Administration -> Plugins -> Issue recurring plugin -> Configure) 153 | 154 | ## Downgrade 155 | 156 | Upgrade steps should work for downgrade also, given that you do them in reverse - except for backup, which should be done first ;) (e.g. backup, downgrade database, then pull older plugin code version). 157 | 158 | Database downgrade (```VERSION``` number `````` is a number taken from migration file name in _issue_recurring/db/migrate_): 159 | ``` 160 | ca /var/lib/redmine 161 | RAILS_ENV=production bundle exec rake redmine:plugins:migrate VERSION= NAME=issue_recurring 162 | ``` 163 | 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. 164 | 165 | ## Development 166 | 167 | Running tests: 168 | * all, including system tests: 169 | 170 | ``` 171 | cd /var/lib/redmine 172 | RAILS_ENV=test bundle exec rake redmine:plugins:test NAME=issue_recurring 173 | ``` 174 | * single test, optionally with seed 175 | ``` 176 | RAILS_ENV=test bundle exec ruby plugins/issue_recurring/test/system/issue_recurrences_test.rb --name test_update_recurrence --seed 63157 177 | ``` 178 | -------------------------------------------------------------------------------- /app/controllers/concerns/load_issue_recurrences.rb: -------------------------------------------------------------------------------- 1 | module LoadIssueRecurrences 2 | extend ActiveSupport::Concern 3 | 4 | def load_issue_recurrences(reload: false) 5 | @issue.recurrences.reload if reload 6 | @recurrences = @issue.recurrences.select {|r| r.visible?} 7 | @next_dates = IssueRecurrence.issue_dates(@issue) 8 | @predicted_dates = IssueRecurrence.issue_dates(@issue, true) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/controllers/issue_recurrences_controller.rb: -------------------------------------------------------------------------------- 1 | class IssueRecurrencesController < ApplicationController 2 | include LoadIssueRecurrences 3 | 4 | before_action :find_project, only: [:index] 5 | before_action :find_issue, only: [:new, :create] 6 | before_action :find_recurrence, only: [:edit, :update, :destroy] 7 | before_action :authorize 8 | 9 | helper :issues 10 | 11 | def index 12 | @recurrences = @project.recurrences.select {|r| r.visible?} 13 | @next_dates = IssueRecurrence.recurrences_dates(@recurrences) 14 | @predicted_dates = IssueRecurrence.recurrences_dates(@recurrences, true) 15 | end 16 | 17 | def new 18 | @recurrence = IssueRecurrence.new(anchor_to_start: 19 | @issue.start_date.present? && @issue.due_date.blank?) 20 | end 21 | 22 | def create 23 | @recurrence = IssueRecurrence.new(recurrence_params) 24 | @recurrence.issue = @issue 25 | @recurrence.save 26 | raise Unauthorized if @recurrence.errors.added?(:issue, :insufficient_privileges) 27 | load_issue_recurrences(reload: true) 28 | flash.now[:notice] = t('.success') 29 | end 30 | 31 | def edit 32 | render :new 33 | end 34 | 35 | def update 36 | @recurrence.update(recurrence_params) 37 | raise Unauthorized if @recurrence.errors.added?(:issue, :insufficient_privileges) 38 | load_issue_recurrences(reload: true) 39 | flash.now[:notice] = t('.success') 40 | render :create 41 | end 42 | 43 | def destroy 44 | raise Unauthorized unless @recurrence.destroy 45 | load_issue_recurrences(reload: true) 46 | flash.now[:notice] = t('.success') 47 | end 48 | 49 | private 50 | 51 | def recurrence_params 52 | # In order of appearance on the form 53 | params.require(:recurrence).permit( 54 | :creation_mode, 55 | :include_subtasks, 56 | :multiplier, 57 | :mode, 58 | :anchor_to_start, 59 | :anchor_mode, 60 | :anchor_date, 61 | :delay_multiplier, 62 | :delay_mode, 63 | :date_limit, 64 | :count_limit 65 | ) 66 | end 67 | 68 | # :find_* methods are called before :authorize, 69 | # @project is required for :authorize to succeed 70 | def find_project 71 | @project = Project.find(params[:project_id]) 72 | rescue ActiveRecord::RecordNotFound 73 | render_404 74 | end 75 | 76 | def find_issue 77 | @issue = Issue.find(params[:issue_id]) 78 | @project = @issue.project 79 | rescue ActiveRecord::RecordNotFound 80 | render_404 81 | end 82 | 83 | def find_recurrence 84 | @recurrence = IssueRecurrence.find(params[:id]) 85 | @issue = @recurrence.issue 86 | @project = @issue.project 87 | rescue ActiveRecord::RecordNotFound 88 | render_404 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /app/helpers/issue_recurrences_helper.rb: -------------------------------------------------------------------------------- 1 | module IssueRecurrencesHelper 2 | def issue_link(r) 3 | link_to "##{r.issue.id}: #{r.issue.subject}", issue_path(r.issue) 4 | end 5 | 6 | def mode(r) 7 | s = 'issues.recurrences.form.mode_intervals' 8 | "#{r.multiplier} #{l("#{s}.#{r.mode}").pluralize(r.multiplier)}" 9 | end 10 | 11 | def creation_mode(r) 12 | r.creation_mode.to_s.tr('_', ' ') 13 | end 14 | 15 | def anchor_mode(r) 16 | s = 'issues.recurrences.form' 17 | t = "#{l("issue_recurrences.index.anchor_modes.#{r.anchor_mode}")}" \ 18 | " #{strip_tags(l("#{s}.anchor_to_start.#{r.anchor_to_start}"))}" 19 | t += r.delay_multiplier > 0 ? " + #{r.delay_multiplier}" \ 20 | " #{l("#{s}.delay_intervals.#{r.delay_mode}").pluralize(r.delay_multiplier)}" : '' 21 | t 22 | end 23 | 24 | def limit_condition(r) 25 | return "-" if r.date_limit.nil? && r.count_limit.nil? 26 | return "date: #{r.date_limit}" if r.date_limit.present? 27 | return "count: #{r.count_limit}" if r.count_limit.present? 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/models/issue_recurrence.rb: -------------------------------------------------------------------------------- 1 | class IssueRecurrence < ActiveRecord::Base 2 | include Redmine::Utils::DateCalculation 3 | 4 | belongs_to :issue, validate: true 5 | belongs_to :last_issue, class_name: 'Issue', validate: true 6 | 7 | enum creation_mode: { 8 | copy_first: 0, 9 | copy_last: 1, 10 | reopen: 2 11 | } 12 | 13 | enum anchor_mode: { 14 | first_issue_fixed: 0, 15 | last_issue_fixed: 1, 16 | last_issue_flexible: 2, 17 | last_issue_flexible_on_delay: 3, # TODO: rename to _flexible_if_late ? 18 | last_issue_fixed_after_close: 4, 19 | date_fixed_after_close: 5, 20 | } 21 | FLEXIBLE_ANCHORS = anchor_modes.keys.select { |m| m.include?('_flexible') } 22 | 23 | enum mode: { 24 | daily: 0, 25 | daily_wday: 1, 26 | weekly: 100, 27 | monthly_day_from_first: 202, 28 | monthly_day_to_last: 212, 29 | monthly_dow_from_first: 222, 30 | monthly_dow_to_last: 232, 31 | monthly_wday_from_first: 242, 32 | monthly_wday_to_last: 252, 33 | yearly: 300 34 | } 35 | WDAY_MODES = modes.keys.select { |m| m.include?('_wday') } 36 | MONTHLY_MODES = modes.keys.select { |m| m.include?('monthly_') } 37 | 38 | enum delay_mode: { 39 | days: 0, 40 | weeks: 1, 41 | months: 2 42 | } 43 | 44 | JOURNAL_MODES = [:never, :always, :on_reopen] 45 | AHEAD_MODES = [:days, :weeks, :months, :years] 46 | 47 | # Don't check privileges on :renew 48 | validate on: [:create, :update] do 49 | errors.add(:issue, :insufficient_privileges) unless editable? 50 | end 51 | validates :count, numericality: {greater_than_or_equal: 0, only_integer: true} 52 | validates :creation_mode, inclusion: creation_modes.keys 53 | # Locking inside validator is an app level solution to ensuring partial 54 | # uniqueness of creation_mode. Partial indexes are currently not 55 | # supported by MySQL, so uniqueness cannot be assured by adding 56 | # unique index: 57 | # add_index :issue_recurrences, [:issue_id, :creation_mode], unique: true, 58 | # where: "creation_mode = 2" 59 | # Should work as long as validation and saving is in one transaction. 60 | validates :creation_mode, uniqueness: { 61 | scope: :issue_id, 62 | conditions: -> { lock.reopen.where.not(anchor_mode: :date_fixed_after_close) }, 63 | if: -> { 64 | ['last_issue_flexible', 65 | 'last_issue_flexible_on_delay', 66 | 'last_issue_fixed_after_close'].include?(anchor_mode) 67 | }, 68 | message: :only_one_reopen 69 | } 70 | validates :anchor_mode, inclusion: anchor_modes.keys 71 | validates :anchor_mode, exclusion: { 72 | in: FLEXIBLE_ANCHORS, 73 | if: -> { delay_multiplier > 0 }, 74 | message: :delay_requires_fixed_anchor 75 | } 76 | # reopen only allowed for schemes that disallow multiple open recurrences 77 | validates :anchor_mode, inclusion: { 78 | in: ['last_issue_flexible', 'last_issue_flexible_on_delay', 79 | 'last_issue_fixed_after_close', 'date_fixed_after_close'], 80 | if: -> { creation_mode == 'reopen' }, 81 | message: :reopen_requires_close_date_based 82 | } 83 | validates :anchor_to_start, inclusion: [true, false] 84 | validates :anchor_date, absence: {unless: -> { anchor_mode == 'date_fixed_after_close' }}, 85 | presence: {if: -> { anchor_mode == 'date_fixed_after_close' }} 86 | # Validates Issue attributes that may become invalid during IssueRecurrence lifetime. 87 | # Besides being checked on IssueRecurrence validation, they should be checked 88 | # every time new recurrence has to be provided. 89 | validate :validate_base_dates 90 | def validate_base_dates 91 | issue, base = self.base_dates 92 | date_required = ['last_issue_flexible', 93 | 'last_issue_flexible_on_delay', 94 | 'date_fixed_after_close'].exclude?(anchor_mode) 95 | if date_required && (base[:start] || base[:due]).blank? 96 | errors.add(:anchor_mode, :blank_issue_dates_require_reopen) 97 | end 98 | if anchor_to_start && base[:start].blank? && base[:due].present? 99 | errors.add(:anchor_to_start, :start_mode_requires_date) 100 | end 101 | if !anchor_to_start && base[:start].present? && base[:due].blank? 102 | errors.add(:anchor_to_start, :due_mode_requires_date) 103 | end 104 | if (creation_mode == 'reopen') && !include_subtasks && issue.dates_derived? 105 | errors.add(:creation_mode, :derived_dates_reopen_requires_subtasks) 106 | end 107 | end 108 | validates :mode, inclusion: modes.keys 109 | validates :multiplier, numericality: {greater_than: 0, only_integer: true} 110 | validates :delay_mode, inclusion: delay_modes.keys 111 | validates :delay_multiplier, numericality: {greater_than_or_equal_to: 0, only_integer: true} 112 | validates :include_subtasks, inclusion: [true, false] 113 | validates :date_limit, absence: {if: -> { count_limit.present? } } 114 | validate if: -> { date_limit.present? && date_fixed_after_close? } do 115 | errors.add(:date_limit, :not_after_anchor_date) unless anchor_date < date_limit 116 | end 117 | validate on: :create, if: -> { date_limit.present? } do 118 | errors.add(:date_limit, :not_in_future) unless Date.current < date_limit 119 | end 120 | validates :count_limit, absence: {if: -> { date_limit.present? } }, 121 | numericality: {allow_nil: true, only_integer: true} 122 | 123 | after_initialize do 124 | if new_record? 125 | self.count = 0 126 | self.creation_mode ||= :copy_first 127 | self.anchor_mode ||= :first_issue_fixed 128 | self.anchor_to_start = false if self.anchor_to_start.nil? 129 | self.mode ||= :monthly_day_from_first 130 | self.multiplier ||= 1 131 | self.delay_mode ||= :days 132 | self.delay_multiplier ||= 0 133 | self.include_subtasks = false if self.include_subtasks.nil? 134 | end 135 | 136 | @journal_notes = '' 137 | end 138 | before_destroy :editable? 139 | 140 | attr_reader :journal_notes 141 | 142 | def visible? 143 | self.issue.visible? && 144 | User.current.allowed_to?(:view_issue_recurrences, self.issue.project) 145 | end 146 | 147 | def editable? 148 | self.visible? && 149 | self.issue.attributes_editable?(User.current) && 150 | User.current.allowed_to?(:manage_issue_recurrences, self.issue.project) 151 | end 152 | 153 | def to_s 154 | s = 'issues.recurrences.form' 155 | 156 | *, ref_dates = self.reference_dates(assume_closed_at = Date.current) 157 | ref_description = '' 158 | if ref_dates.nil? || FLEXIBLE_ANCHORS.include?(self.anchor_mode) 159 | ref_description = " #{l("#{s}.mode_descriptions.#{self.mode}")}" 160 | elsif MONTHLY_MODES.include?(self.mode) 161 | label = self.anchor_to_start ? :start : :due 162 | date = ref_dates[label] 163 | unless date.nil? 164 | days_to_eom = (date.end_of_month.mday - date.mday + 1).to_i 165 | values = { 166 | days_from_bom: date.mday.ordinalize, 167 | days_to_eom: days_to_eom.ordinalize, 168 | day_of_week: date.strftime("%A"), 169 | dows_from_bom: ((date.mday - 1) / 7 + 1).ordinalize, 170 | dows_to_eom: ((days_to_eom - 1) / 7 + 1).ordinalize, 171 | wdays_from_bom: (working_days(date.beginning_of_month, date) + 1).ordinalize, 172 | wdays_to_eom: (working_days(date, date.end_of_month) + 1).ordinalize 173 | } 174 | ref_description = " #{l("#{s}.mode_modifiers.#{self.mode}", values)}" 175 | end 176 | end 177 | 178 | delay_info = self.delay_multiplier > 0 ? 179 | "#{l("#{s}.delayed_by")} #{self.delay_multiplier}" \ 180 | " #{l("#{s}.delay_intervals.#{self.delay_mode}").pluralize(self.delay_multiplier)}" \ 181 | "" : '' 182 | 183 | count_limit_info = self.count_limit.present? ? " #{"#{self.count_limit}" \ 184 | " #{l("#{s}.recurrence").pluralize(self.count_limit)}."}" : '' 185 | 186 | "#{l("#{s}.creation_modes.#{self.creation_mode}")}" \ 187 | " #{l("#{s}.issue")}" \ 188 | " #{l("#{s}.include_subtasks.true") if self.include_subtasks}" \ 189 | " #{l("#{s}.every")}" \ 190 | " #{self.multiplier}" \ 191 | " #{l("#{s}.mode_intervals.#{self.mode}").pluralize(self.multiplier)}," \ 192 | "#{ref_description}" \ 193 | " #{l("#{s}.based_on")}" \ 194 | " #{l("#{s}.anchor_to_start.#{self.anchor_to_start}")}" \ 195 | " #{l("#{s}.anchor_modes.#{self.anchor_mode}", ref_dates)}" \ 196 | "#{" #{self.anchor_date}" if self.anchor_date.present?}" \ 197 | "#{delay_info}" \ 198 | "#{"." if self.date_limit.nil? && self.count_limit.nil?}" \ 199 | " #{l("#{s}.until") if self.date_limit.present? || self.count_limit.present?}" \ 200 | " #{"#{self.date_limit}." if self.date_limit.present?}" \ 201 | "#{count_limit_info}".html_safe 202 | end 203 | 204 | def limit_mode 205 | return case 206 | when self.date_limit.present? 207 | :date_limit 208 | when self.count_limit.present? 209 | :count_limit 210 | else 211 | :no_limit 212 | end 213 | end 214 | 215 | def initialize_dup(other) 216 | self.last_issue = nil 217 | self.count = 0 218 | super 219 | end 220 | 221 | # TODO: make methods private as appropriate 222 | 223 | # Advance 'dates' according to recurrence mode and adjustment (+/- # of periods). 224 | # Return advanced 'dates' or nil if recurrence limit reached. 225 | # TODO: change internals so that adj == -1 is never used and [adj -1 -> adj 0]. 226 | # Current choice is not intuitive. 227 | def advance(adj=0, **dates) 228 | adj_count = self.count + adj 229 | adj_count = [self.count, adj_count].min if self.date_fixed_after_close? 230 | return nil if self.count_limit.present? && adj_count >= self.count_limit 231 | 232 | shift = if self.first_issue_fixed? || self.date_fixed_after_close? 233 | self.multiplier*(self.count + 1 + adj) 234 | else 235 | self.multiplier 236 | end 237 | 238 | case self.mode.to_sym 239 | when :daily 240 | dates.each do |label, date| 241 | dates[label] = date + shift.days if date.present? 242 | end 243 | when :daily_wday 244 | dates.each do |label, date| 245 | dates[label] = add_working_days(date, shift) if date.present? 246 | end 247 | when :weekly 248 | dates.each do |label, date| 249 | dates[label] = date + shift.weeks if date.present? 250 | end 251 | when :monthly_day_from_first 252 | label = self.anchor_to_start ? :start : :due 253 | date = dates[label] 254 | target_date = date + shift.months 255 | dates = self.offset(target_date, label, dates) 256 | when :monthly_day_to_last 257 | label = self.anchor_to_start ? :start : :due 258 | date = dates[label] 259 | days_to_last = date.end_of_month - date 260 | target_eom = (date + shift.months).end_of_month 261 | target_date = target_eom - [days_to_last, target_eom.mday-1].min 262 | dates = self.offset(target_date, label, dates) 263 | when :monthly_dow_from_first 264 | label = self.anchor_to_start ? :start : :due 265 | date = dates[label] 266 | source_dow = date.days_to_week_start 267 | target_bom = (date + shift.months).beginning_of_month 268 | target_bom_dow = target_bom.days_to_week_start 269 | week = ((date.mday - 1) / 7) + (source_dow >= target_bom_dow ? 0 : 1) 270 | target_bom_shift = week.weeks + (source_dow - target_bom_dow).days 271 | overflow = target_bom_shift > (target_bom.end_of_month.mday-1).days ? 1.week : 0 272 | target_date = target_bom + target_bom_shift - overflow 273 | dates = self.offset(target_date, label, dates) 274 | when :monthly_dow_to_last 275 | label = self.anchor_to_start ? :start : :due 276 | date = dates[label] 277 | source_dow = date.days_to_week_start 278 | target_eom = (date + shift.months).end_of_month 279 | target_eom_dow = target_eom.days_to_week_start 280 | week = ((date.end_of_month - date).to_i / 7) + (source_dow > target_eom_dow ? 1 : 0) 281 | target_eom_shift = week.weeks + (target_eom_dow - source_dow).days 282 | overflow = target_eom_shift > (target_eom.mday-1).days ? 1.week : 0 283 | target_date = target_eom - target_eom_shift + overflow 284 | dates = self.offset(target_date, label, dates) 285 | when :monthly_wday_from_first 286 | label = self.anchor_to_start ? :start : :due 287 | date = dates[label] 288 | source_wdays = date.beginning_of_month.step(date.end_of_month).reject do |d| 289 | non_working_week_days.include?(d.cwday) 290 | end 291 | wday = source_wdays.bsearch_index { |d| d >= date } || source_wdays.length-1 292 | target_date = date + shift.months 293 | target_wdays = target_date.beginning_of_month 294 | .step(target_date.end_of_month).reject do |d| 295 | non_working_week_days.include?(d.cwday) 296 | end 297 | target_wdate = target_wdays[wday] || target_wdays.last 298 | dates = self.offset(target_wdate, label, dates) 299 | when :monthly_wday_to_last 300 | label = self.anchor_to_start ? :start : :due 301 | date = dates[label] 302 | source_wdays = date.beginning_of_month.step(date.end_of_month).reject do |d| 303 | non_working_week_days.include?(d.cwday) 304 | end 305 | wday = source_wdays.reverse.bsearch_index { |d| d <= date } || 0 306 | target_date = date + shift.months 307 | target_wdays = target_date.beginning_of_month 308 | .step(target_date.end_of_month).reject do |d| 309 | non_working_week_days.include?(d.cwday) 310 | end 311 | target_wdate = target_wdays.reverse[wday] || target_wdays.first 312 | dates = self.offset(target_wdate, label, dates) 313 | when :yearly 314 | label = self.anchor_to_start ? :start : :due 315 | date = dates[label] 316 | target_date = date + shift.years 317 | dates = self.offset(target_date, label, dates) 318 | end 319 | 320 | case self.anchor_mode.to_sym 321 | when :first_issue_fixed, :date_fixed_after_close 322 | dates = self.delay(dates) if self.count + 1 + adj > 0 323 | when :last_issue_fixed, :last_issue_fixed_after_close 324 | dates = self.delay(dates) if self.count + adj == 0 325 | end 326 | 327 | return nil if self.date_limit.present? && (dates[:start] || dates[:due]) > self.date_limit 328 | 329 | dates 330 | end 331 | 332 | # Offset 'dates' so date with 'label' is equal 'target'. 333 | # Return offset 'dates' or nil if 'dates' does not include 'label'. 334 | def offset(target_date, target_label, dates) 335 | nil if dates[target_label].nil? 336 | dates.each do |label, date| 337 | next if (label == target_label) || date.nil? 338 | if WDAY_MODES.include?(self.mode) 339 | if date >= dates[target_label] 340 | timespan = working_days(dates[target_label], date) 341 | dates[label] = add_working_days(target_date, timespan) 342 | else 343 | timespan = working_days(date, dates[target_label]) 344 | dates[label] = subtract_working_days(target_date, timespan) 345 | end 346 | else 347 | timespan = date - dates[target_label] 348 | dates[label] = target_date + timespan 349 | end 350 | end 351 | dates[target_label] = target_date 352 | dates 353 | end 354 | 355 | # Based on Redmine's add_working_days. 356 | def subtract_working_days(date, working_days) 357 | if working_days > 0 358 | weeks = working_days / (7 - non_working_week_days.size) 359 | result = weeks * 7 360 | days_left = working_days - weeks * (7 - non_working_week_days.size) 361 | cwday = date.cwday 362 | while days_left > 0 363 | cwday -= 1 364 | unless non_working_week_days.include?(((cwday - 1) % 7) + 1) 365 | days_left -= 1 366 | end 367 | result += 1 368 | end 369 | next_working_date(date - result) 370 | else 371 | date 372 | end 373 | end 374 | 375 | # Delay 'dates' according to delay mode. 376 | def delay(dates) 377 | return dates if self.delay_multiplier == 0 378 | delay = self.delay_multiplier.send(self.delay_mode) 379 | dates.map { |label, date| [label, date ? date + delay : date] }.to_h 380 | end 381 | 382 | # Create next recurrence issue at given dates. 383 | def create(dates) 384 | ref_issue = self.last_issue if self.copy_last? 385 | ref_issue ||= self.issue 386 | prev_dates = {start: ref_issue.start_date, due: ref_issue.due_date} 387 | 388 | prev_user = User.current 389 | author_login = Setting.plugin_issue_recurring[:author_login] 390 | author = User.find_by(login: author_login) 391 | User.current = author || ref_issue.author 392 | 393 | IssueRecurrence.transaction do 394 | if Setting.plugin_issue_recurring[:journal_mode] == :always || 395 | (Setting.plugin_issue_recurring[:journal_mode] == :on_reopen && self.reopen?) 396 | # Setting journal on self.issue won't record copy if :copy_last is used 397 | ref_issue.init_journal(User.current) 398 | end 399 | 400 | new_issue = self.reopen? ? ref_issue : 401 | ref_issue.copy(nil, subtasks: self.include_subtasks, skip_recurrences: true) 402 | 403 | new_issue.start_date = dates[:start] 404 | new_issue.due_date = dates[:due] 405 | new_issue.parent = ref_issue.parent 406 | new_issue.done_ratio = 0 407 | new_issue.status = new_issue.tracker.default_status 408 | new_issue.recurrence_of = self.issue 409 | assignee = new_issue.assigned_to 410 | is_assignee_valid = assignee.blank? || new_issue.assignable_users.include?(assignee) 411 | keep_assignee = Setting.plugin_issue_recurring[:keep_assignee] 412 | unless keep_assignee && is_assignee_valid 413 | new_issue.default_reassign 414 | end 415 | new_issue.save! 416 | 417 | # Errors containing issue ID reported only after #save 418 | if keep_assignee && !is_assignee_valid 419 | log(:warning_keep_assignee, id: new_issue.id, login: assignee.login) 420 | end 421 | if author_login && !author 422 | log(:warning_author, id: new_issue.id, login: author_login) 423 | end 424 | 425 | if self.include_subtasks 426 | target_label = self.anchor_to_start ? :start : :due 427 | new_issue.children.each do |child| 428 | child_dates = self.offset(dates[target_label], :parent, 429 | {parent: prev_dates[target_label], start: child.start_date, due: child.due_date}) 430 | child.start_date = child_dates[:start] 431 | child.due_date = child_dates[:due] 432 | child.done_ratio = 0 433 | child.status = child.tracker.default_status 434 | child.recurrence_of = self.issue 435 | assignee = child.assigned_to 436 | is_assignee_valid = assignee.blank? || child.assignable_users.include?(assignee) 437 | unless keep_assignee && is_assignee_valid 438 | child.default_reassign 439 | end 440 | child.save! 441 | 442 | # Errors containing issue ID reported only after #save 443 | if keep_assignee && !is_assignee_valid 444 | log(:warning_keep_assignee, id: child.id, login: assignee.login) 445 | end 446 | end 447 | end 448 | 449 | # Renewal should happen irrespective of author's (= User.current) privileges. 450 | # No user-assignable attribues are/should be changed. 451 | self.last_issue = new_issue 452 | self.count += 1 453 | self.save!(context: :renew) 454 | end 455 | 456 | User.current = prev_user 457 | end 458 | 459 | # Return reference issue and base dates used for calculation of reference dates. 460 | # Base dates are validated for start/due date availability according to anchor_to_start. 461 | def base_dates 462 | case self.anchor_mode.to_sym 463 | when :first_issue_fixed 464 | ref_issue = self.issue 465 | base_dates = {start: ref_issue.start_date, due: ref_issue.due_date} 466 | when :last_issue_fixed, :last_issue_flexible, :last_issue_flexible_on_delay, 467 | :last_issue_fixed_after_close 468 | ref_issue = self.last_issue || self.issue 469 | base_dates = {start: ref_issue.start_date, due: ref_issue.due_date} 470 | when :date_fixed_after_close 471 | ref_issue = self.last_issue || self.issue 472 | base_dates = {start: self.issue.start_date, due: self.issue.due_date} 473 | end 474 | [ref_issue, base_dates] 475 | end 476 | 477 | # Return reference dates for next recurrence or nil if no suitable dates found. 478 | # Used by 'to_s' to display recurrence characteristics. 479 | # assume_closed_at gives you predicted reference_dates assuming issue has been 480 | # closed at given date. 481 | def reference_dates(assume_closed_at=nil) 482 | ref_issue, base_dates = self.base_dates 483 | ref_dates = nil 484 | 485 | self.validate_base_dates 486 | unless self.errors.empty? 487 | # Problems are always logged to master issue, so no need to refer to self.issue 488 | # Linking ref_issue though, as source of problems (lack of dates etc.) lies in it 489 | log(:warning_renew, id: ref_issue.id, errors: errors.messages.values.flatten.to_sentence) 490 | return nil 491 | end 492 | 493 | case self.anchor_mode.to_sym 494 | when :first_issue_fixed, :last_issue_fixed 495 | ref_dates = base_dates 496 | when :last_issue_flexible 497 | if ref_issue.closed? || assume_closed_at 498 | closed_date = assume_closed_at || ref_issue.closed_on.to_date 499 | ref_label = self.anchor_to_start ? :start : :due 500 | if (base_dates[:start] || base_dates[:due]).present? 501 | ref_dates = self.offset(closed_date, ref_label, base_dates) 502 | else 503 | ref_dates = base_dates.update(ref_label => closed_date) 504 | end 505 | end 506 | when :last_issue_flexible_on_delay 507 | if ref_issue.closed? || assume_closed_at 508 | closed_date = assume_closed_at || ref_issue.closed_on.to_date 509 | boundary_date = base_dates[:due] || base_dates[:start] 510 | ref_label = self.anchor_to_start ? :start : :due 511 | if boundary_date.present? 512 | if boundary_date < closed_date 513 | ref_dates = self.offset(closed_date, ref_label, base_dates) 514 | else 515 | ref_dates = base_dates 516 | end 517 | else 518 | ref_dates = base_dates.update(ref_label => closed_date) 519 | end 520 | end 521 | when :last_issue_fixed_after_close 522 | if ref_issue.closed? || assume_closed_at 523 | ref_dates = base_dates 524 | end 525 | when :date_fixed_after_close 526 | if ref_issue.closed? || assume_closed_at 527 | ref_label = self.anchor_to_start ? :start : :due 528 | if (base_dates[:start] || base_dates[:due]).present? 529 | ref_dates = self.offset(self.anchor_date, ref_label, base_dates) 530 | else 531 | ref_dates = base_dates.update(ref_label => self.anchor_date) 532 | end 533 | end 534 | end 535 | 536 | [ref_issue, ref_dates] 537 | end 538 | 539 | # Estimate future recurrence dates. 540 | # Returns first 3 dates and last date if recurrence limited. 541 | #def estimate_schedule 542 | # schedule = [] 543 | # saved_count = self.count 544 | # 545 | # begin 546 | # dates = self.next_dates 547 | # schedule << dates if dates.present? 548 | # self.count += 1 549 | # end while dates.present? && 550 | # ((self.date_limit || self.count_limit).present? || (self.count - saved_count < 3)) 551 | # 552 | # self.count = saved_count 553 | # schedule 554 | #end 555 | 556 | # Yield next recurrence dates (can yield multiple times for fixed schedules). 557 | # Does not yield nil. 558 | # Computing dates must assume that yield does not result in a call to 'create', 559 | # i.e. IssueRecurrence fields are unchanged after yield. 560 | # Dates can only be modified here by the 'advance' method. All other 561 | # delays/offsets have to be incorporated in 'reference_dates'. 562 | # If 'predict', return at most one future date. For close based recurrences assume 563 | # issue closed today if open or return nothing if already closed. 564 | def next_dates(predict) 565 | ref_issue, ref_dates = self.reference_dates(predict ? Date.current : nil) 566 | return if ref_dates.nil? 567 | 568 | case self.anchor_mode.to_sym 569 | when :first_issue_fixed, :last_issue_fixed 570 | settings = Setting.plugin_issue_recurring 571 | renew_ahead_to = Date.tomorrow + settings[:ahead_multiplier].send(settings[:ahead_mode]) 572 | 573 | new_dates = self.first_issue_fixed? ? self.advance(-1, **ref_dates) : ref_dates 574 | adj = 0 575 | while (new_dates[:start] || new_dates[:due]) < renew_ahead_to 576 | new_dates = self.advance(adj, **ref_dates) 577 | break if new_dates.nil? 578 | yield(new_dates) unless predict 579 | ref_dates = new_dates if self.last_issue_fixed? 580 | adj += 1 581 | end 582 | predicted_dates = predict ? self.advance(adj, **ref_dates) : nil 583 | yield(predicted_dates) if predicted_dates 584 | when :last_issue_flexible, :last_issue_flexible_on_delay 585 | new_dates = self.advance(**ref_dates) 586 | yield(new_dates) unless new_dates.nil? || (predict && ref_issue.closed?) 587 | when :last_issue_fixed_after_close 588 | closed_date = predict ? Date.current : ref_issue.closed_on.to_date 589 | barrier_date = [closed_date, ref_dates[:start] || ref_dates[:due]].max 590 | while (ref_dates[:start] || ref_dates[:due]) <= barrier_date 591 | new_dates = self.advance(**ref_dates) 592 | break if new_dates.nil? 593 | ref_dates = new_dates 594 | end 595 | yield(ref_dates) unless new_dates.nil? || (predict && ref_issue.closed?) 596 | when :date_fixed_after_close 597 | closed_date = predict ? Date.current : ref_issue.closed_on.to_date 598 | barrier_date = [ 599 | closed_date, 600 | ref_issue.start_date || ref_issue.due_date || ref_dates[:start] || ref_dates[:due] 601 | ].max 602 | adj = -1 603 | begin 604 | new_dates = self.advance(adj, **ref_dates) 605 | adj += 1 606 | end until new_dates.nil? || ((new_dates[:start] || new_dates[:due]) > barrier_date) 607 | yield(new_dates) unless new_dates.nil? || (predict && ref_issue.closed?) 608 | end 609 | end 610 | 611 | # Depending on 'predict': 612 | # = false: give next recurrence dates for all issue schedules, where renewal is due NOW, 613 | # = true: predict 1 recurrence ahead, assuming issue closed today for non-closed 614 | # close based schedules. 615 | # Return hash: {r1 => dates_array1, r2 => dates_array2, ...} 616 | def self.issue_dates(issue, predict=false) 617 | reopen = nil 618 | result = Hash.new { |h,k| h[k] = [] } 619 | 620 | issue.recurrences.each do |r| 621 | r.next_dates(predict) do |dates| 622 | if r.reopen? 623 | current_date = dates[:start] || dates[:due] 624 | earliest_date = reopen[:dates][:start] || reopen[:dates][:due] if reopen 625 | reopen = {r: r, dates: dates} if reopen.nil? || (current_date < earliest_date) 626 | else 627 | result[r] << dates 628 | end 629 | end 630 | end 631 | 632 | result[reopen[:r]] << reopen[:dates] if reopen 633 | result 634 | end 635 | 636 | def self.recurrences_dates(rs, predict=false) 637 | issues = rs.map { |r| r.issue }.uniq 638 | issues.map! { |issue| self.issue_dates(issue, predict) }.reduce(:merge) 639 | end 640 | 641 | def self.renew_all(quiet=false) 642 | IssueRecurrence.select(:issue_id).distinct.includes(:issue).each do |r| 643 | self.issue_dates(r.issue).each do |recurrence, dates_list| 644 | puts "Recurring issue #{r.issue}" unless quiet 645 | dates_list.each do |dates| 646 | puts " - creating recurrence at #{dates}" unless quiet 647 | recurrence.create(dates) 648 | end 649 | puts "...done" unless quiet 650 | end 651 | 652 | # Problems are always logged to master issue, not recurrences (as opposed 653 | # to normal journal entries, which go to the ref_issues) 654 | journal_notes = r.issue.recurrences.map(&:journal_notes).join 655 | if journal_notes.present? 656 | prev_user = User.current 657 | author_login = Setting.plugin_issue_recurring[:author_login] 658 | User.current = User.find_by(login: author_login) || r.issue.author 659 | journal = r.issue.init_journal(User.current) 660 | journal.notes << journal_notes 661 | journal.save 662 | User.current = prev_user 663 | end 664 | end 665 | rescue Exception 666 | puts "...exception raised. Check output for errors. Either there is bug you may want" \ 667 | " to report or your db is corrupted." 668 | raise 669 | end 670 | 671 | private 672 | 673 | def log(label, **args) 674 | @journal_notes << "#{l(label, args)}\r\n" 675 | end 676 | 677 | class Date < ::Date 678 | def self.today 679 | # Due to its nature, Date.today may sometimes be equal to Date.yesterday/tomorrow. 680 | # https://rails.lighthouseapp.com/projects/8994-ruby-on-rails/tickets 681 | # /6410-dateyesterday-datetoday 682 | # For this reason WE SHOULD NOT USE Date.today anywhere in the code and use 683 | # Date.current instead. 684 | raise "Date.today should not be called!" 685 | end 686 | end 687 | end 688 | -------------------------------------------------------------------------------- /app/views/issue_recurrences/create.js.erb: -------------------------------------------------------------------------------- 1 | <% if @recurrence.errors.present? %> 2 | $('#recurrence-errors').html( 3 | '<%= escape_javascript(nameless_error_messages_for 'recurrence') %>' 4 | ); 5 | <% else %> 6 | $('#new-recurrence').empty(); 7 | $('#recurrences') 8 | .html('<%= escape_javascript(render :partial => 'issues/recurrences/index') %>'); 9 | <% end %> 10 | -------------------------------------------------------------------------------- /app/views/issue_recurrences/destroy.js.erb: -------------------------------------------------------------------------------- 1 | $('#recurrence-<%= @recurrence.id %>').remove(); 2 | -------------------------------------------------------------------------------- /app/views/issue_recurrences/index.html.erb: -------------------------------------------------------------------------------- 1 |

<%= t ".heading" %>

2 | 3 | <% if @recurrences.present? %> 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | <% @recurrences.each do |r| %> 22 | <% if r.visible? %> 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | <% end %> 37 | <% end %> 38 | <% reset_cycle %> 39 | 40 |
<%= t '.issue' %><%= t '.mode' %><%= t '.last_recurrence' %><%= t '.next_recurrence' %><%= t '.predicted_recurrence' %><%= t '.include_subtasks' %><%= t '.creation_mode' %><%= t '.anchor_mode' %><%= t '.limit' %><%= t '.count' %>
<%= issue_link(r) %><%= mode(r) %><%= last_recurrence(r, false) %><%= next_recurrences(@next_dates[r], false) %><%= predicted_recurrences(@predicted_dates[r], false) %><%= checked_image r.include_subtasks %><%= creation_mode(r) %><%= anchor_mode(r) %><%= limit_condition(r) %><%= r.count %><%= delete_button(r) if r.editable? %>
41 | <% else %> 42 |
<%= l(:label_no_data) %>
43 | <% end %> 44 | -------------------------------------------------------------------------------- /app/views/issue_recurrences/new.js.erb: -------------------------------------------------------------------------------- 1 | $('#new-recurrence') 2 | .html('<%= escape_javascript(render :partial => 'issues/recurrences/form') %>'); 3 | $('recurrence_creation_mode').focus(); 4 | -------------------------------------------------------------------------------- /app/views/issues/_issue_recurrences_hook.html.erb: -------------------------------------------------------------------------------- 1 | <% if User.current.allowed_to?(:view_issue_recurrences, @project) %> 2 |
3 |
4 |
5 | <% if User.current.allowed_to?(:manage_issue_recurrences, @project) %> 6 | <%= link_to t(:button_add), new_issue_recurrence_path(@issue), remote: true %> 7 | <% end %> 8 |
9 | 10 |

<%= t('.recurrences') %>

11 | 12 | <% if @issue.recurrence_of.present? %> 13 |

14 | <%= "#{t('.this_is_recurrence')} " %> 15 | <%= link_to "##{@issue.recurrence_of.id}: #{@issue.recurrence_of.subject}", 16 | issue_path(@issue.recurrence_of) %> 17 |

18 | <% end %> 19 | 20 |
21 | <%= render partial: 'issues/recurrences/index' %> 22 |
23 | 24 |
25 |
26 | <% end %> 27 | -------------------------------------------------------------------------------- /app/views/issues/recurrences/_form.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <%= nameless_error_messages_for 'recurrence' %> 3 |
4 | 5 | <%= form_for @recurrence, as: :recurrence, remote: true, 6 | html: {id: 'recurrence-form'} do |f| %> 7 | 8 |

9 | <%= f.select :creation_mode, creation_mode_options -%> 10 | <%= t '.issue' -%> 11 | <%= f.select :include_subtasks, include_subtasks_options -%> 12 | <%= t '.every' -%> 13 | <%= f.number_field :multiplier, size: 3, min: 1, step: 1 -%> 14 | <%= f.select :mode, mode_options -%> 15 | <%= t '.based_on' -%> 16 | <%= f.select :anchor_to_start, anchor_to_start_options, 17 | disabled: anchor_to_start_disabled -%> 18 | <%= f.select :anchor_mode, anchor_mode_options -%> 19 | 20 | <%# All unused fields need to be unset on update. Hidden fields ensure 21 | that these optional values will be sent correctly as either nil or 0. %> 22 | <%# Fields that don't have model-defined default value, will have some 23 | value assigned for convenience. %> 24 | <%# TODO: set INPUT 'display' properly to avoid form flickering on load %> 25 | <%= f.hidden_field :anchor_date, value: '', id: nil -%> 26 | <%= f.date_field :anchor_date, value: f.object.anchor_date || Date.current -%> 27 | 28 | <%= t '.delayed_by' -%> 29 | <%= f.hidden_field :delay_multiplier, value: 0, id: nil -%> 30 | <%= f.number_field :delay_multiplier, size: 3, min: 0, step: 1 -%> 31 | <%= f.select :delay_mode, delay_mode_options -%> 32 | 33 | <%= t '.until' -%> 34 | <%= f.select :limit_mode, limit_mode_options -%> 35 | <%= f.hidden_field :date_limit, value: '', id: nil -%> 36 | <%= f.date_field :date_limit, disabled: true, 37 | min: (f.object.anchor_date || Date.current) + 1.day, 38 | value: f.object.date_limit || Date.current.next_month -%> 39 | <%= f.hidden_field :count_limit, value: '', id: nil -%> 40 | <%= f.number_field :count_limit, disabled: true, size: 3, min: 1, step: 1, 41 | value: f.object.count_limit || 1 -%> 42 | <%= '.' -%> 43 |

44 | 45 |

46 | <%= submit_tag l(:button_submit) -%> 47 | <%= link_to_function l(:button_cancel), '$("#new-recurrence").empty();' -%> 48 |

49 | <% end %> 50 | 51 | <%# TODO: change 'click' event to 'change' ? %> 52 | <%= javascript_tag do %> 53 | function creationModeChange() { 54 | $('#recurrence_anchor_mode option') 55 | .filter('[value="first_issue_fixed"],[value="last_issue_fixed"]') 56 | .prop('disabled', 57 | $('#recurrence_creation_mode option[value="reopen"]').prop('selected') || 58 | <%= (@issue.start_date || @issue.due_date).blank? ? 'true' : 'false' %> 59 | ) 60 | $('#recurrence_anchor_mode option:selected:disabled').prop('selected', false); 61 | 62 | $('#recurrence_include_subtasks option').filter('[value="false"]') 63 | .prop('disabled', 64 | $('#recurrence_creation_mode option[value="reopen"]').prop('selected') && 65 | <%= @issue.dates_derived? ? 'true' : 'false' %> 66 | ) 67 | $('#recurrence_include_subtasks option:selected:disabled').prop('selected', false); 68 | 69 | anchorModeChange(); 70 | } 71 | $('#recurrence-form').on('click', '#recurrence_creation_mode', creationModeChange); 72 | 73 | function anchorModeChange() { 74 | if ( $('#recurrence_anchor_mode option:selected').val().includes('_flexible') ) { 75 | $('[id^=recurrence_delay]').prop('disabled', true).hide(); 76 | } else { 77 | $('[id^=recurrence_delay]').prop('disabled', false).show(); 78 | } 79 | if ( $('#recurrence_anchor_mode option:selected').val() == 'date_fixed_after_close' ) { 80 | $('#recurrence_anchor_date').prop('disabled', false).show(); 81 | } else { 82 | $('#recurrence_anchor_date').prop('disabled', true).hide(); 83 | } 84 | } 85 | $('#recurrence-form').on('click', '#recurrence_anchor_mode', anchorModeChange); 86 | 87 | function anchorDateChange() { 88 | var date_limit = new Date($('#recurrence_anchor_date').val()); 89 | date_limit.setDate(date_limit.getDate() + 1); 90 | var date_limit_string = date_limit.toISOString().split("T")[0]; 91 | $('#recurrence_date_limit').prop('min', date_limit_string); 92 | if ( new Date($('#recurrence_date_limit').val()) < date_limit ) { 93 | $('#recurrence_date_limit').val(date_limit_string); 94 | } 95 | } 96 | $('#recurrence-form').on('change', '#recurrence_anchor_date', anchorDateChange); 97 | 98 | function limitModeChange() { 99 | if ( $('#recurrence_limit_mode option:selected').val() == 'no_limit' ) { 100 | $('[id$=_limit]').prop('disabled', true).hide(); 101 | } 102 | if ( $('#recurrence_limit_mode option:selected').val() == 'date_limit' ) { 103 | $('[id$=_count_limit]').prop('disabled', true).hide(); 104 | $('[id$=_date_limit]').prop('disabled', false).show(); 105 | } 106 | if ( $('#recurrence_limit_mode option:selected').val() == 'count_limit' ) { 107 | $('[id$=_date_limit]').prop('disabled', true).hide(); 108 | $('[id$=_count_limit]').prop('disabled', false).show(); 109 | } 110 | } 111 | $('#recurrence-form').on('click', '#recurrence_limit_mode', limitModeChange); 112 | 113 | $(document).ready(function() { 114 | creationModeChange(); 115 | anchorModeChange(); 116 | limitModeChange(); 117 | }); 118 | <% end %> 119 | -------------------------------------------------------------------------------- /app/views/issues/recurrences/_index.html.erb: -------------------------------------------------------------------------------- 1 | <% if @recurrences.present? %> 2 | 3 | <% @recurrences.each do |r| %> 4 | <% if r.visible? %> 5 | 6 | 7 | 8 | 14 | <% if r.editable? %> 15 | 16 | <% end %> 17 | 18 | <% end %> 19 | <% end %> 20 | <% reset_cycle %> 21 |
<%= r.to_s %><%= last_recurrence(r) %> 9 |

<%= next_recurrences(@next_dates[r]) %>

10 |

11 | <%= predicted_recurrences(@predicted_dates[r]) %> 12 |

13 |
<%= edit_button(r) %><%= delete_button(r) %>
22 | <% end %> 23 | -------------------------------------------------------------------------------- /app/views/layouts/base.js.erb: -------------------------------------------------------------------------------- 1 | $('div[id^=flash_]').remove(); 2 | $('#content').prepend('<%= j render_flash_messages %>'); 3 | <%= yield %> 4 | -------------------------------------------------------------------------------- /app/views/settings/_issue_recurrences.html.erb: -------------------------------------------------------------------------------- 1 |

2 | <%= label_tag :settings_author_id, t('.author') %> 3 | <%= select_tag 'settings[author_id]', authors(@settings[:author_login]) %> 4 |

5 |

6 | <%= label_tag :settings_keep_assignee, t('.keep_assignee') %> 7 | <%= check_box_tag 'settings[keep_assignee]', true, @settings[:keep_assignee] %> 8 | <%= t('.keep_assignee_hint') %> 9 |

10 |

11 | <%= label_tag :settings_journal_mode, t('.journal_mode') %> 12 | <%= select_tag 'settings[journal_mode]', journal_mode_options(@settings[:journal_mode]) %> 13 | <%= t('.journal_mode_hint') %> 14 |

15 |

16 | <%= label_tag :settings_copy_recurrences, t('.copy_recurrences') %> 17 | <%= check_box_tag 'settings[copy_recurrences]', true, @settings[:copy_recurrences] %> 18 | <%= t('.copy_recurrences_hint') %> 19 |

20 |

21 | <%= label_tag :settings_renew_ahead, t('.renew_ahead') %> 22 | <%= number_field_tag 'settings[ahead_multiplier]', @settings[:ahead_multiplier], min: 0, 23 | step: 1, size: 3 %> 24 | <%= select_tag 'settings[ahead_mode]', ahead_mode_options(@settings[:ahead_mode]) %> 25 | <%= t('.renew_ahead_hint') %> 26 |

27 | -------------------------------------------------------------------------------- /config/locales/bg.yml: -------------------------------------------------------------------------------- 1 | # Bulgarian translation - превод на български език, author: https://github.com/jwalkerbg 2 | bg: 3 | field_recurrence_of: 'Цикличност на' 4 | activerecord: 5 | errors: 6 | models: 7 | issue_recurrence: 8 | attributes: 9 | issue: 10 | insufficient_privileges: 'вие имате недостатъчни права за да редактирате 11 | цикличността на тази задача' 12 | anchor_mode: 13 | delay_requires_fixed_anchor: 'закъснение не може да бъде задавано на 14 | цикличности, базирани на дата на затваряне' 15 | blank_issue_dates_require_reopen: 'Цикличности, базирани на датите на 16 | задачите или последните им повторения не могат да бъдат създадени, ако не 17 | са зададени и двете начална и крайна дати' 18 | # TODO: update translation, previous version saved for reference 19 | #reopen_requires_close_date_based: 'само цикличности, зависими от дата на 20 | # затваряне могат модифицират задача на място' 21 | reopen_requires_close_date_based: 'only recurrences dependent on close date 22 | can reopen issue' 23 | creation_mode: 24 | # TODO: update translation, previous version saved for reference 25 | #only_one_reopen: 'допълнителни цикличности на място са позволени, само ако 26 | # са базирани на фиксирана дата' 27 | only_one_reopen: 'only one reopening recurrence calculated from close date 28 | is allowed' 29 | # TODO: update translation, previous version saved for reference 30 | #derived_dates_reopen_requires_subtasks: 'не е възможно да се повтори задача на 31 | # място без подзадачи, ако началната и крaйната дати се вземат от подзадачи 32 | # (вижте Администрация -> Настройки -> Тракинг -> Атрибути на родителските 33 | # задачи)' 34 | derived_dates_reopen_requires_subtasks: 'cannot reopen issue excluding 35 | subtasks if start and due dates are derived from subtasks (check 36 | Administration -> Settings -> Issue tracking -> Parent tasks attributes)' 37 | anchor_to_start: 38 | start_mode_requires_date: 'цикличност, базирана на начална дата не може да 39 | бъде създадена за задача без начална дата' 40 | due_mode_requires_date: 'цикличност, базирана на крайна дата не може да бъде 41 | създадена за задача без крайна дата' 42 | date_limit: 43 | not_in_future: 'Крайната дата на цикличност трябва да бъде в бъдещето' 44 | not_after_anchor_date: 'user provided limit date has to be later than the 45 | fixed date the recurrence is based on' 46 | issue_recurrences_menu_caption: 'Цикличност' 47 | warning_renew: "*Warning!* Can't renew recurrence referring to issue #%{id}: %{errors}." 48 | # changed from: "Внимание: не е възможно да се поднови цикличност #%{id}: %{errors}." 49 | warning_keep_assignee: "*Warning!* Can't assign newly recurred issue #%{id} to @%{login}. 50 | Default assignment rules will apply." 51 | warning_author: "*Warning!* Nonexistent user %{login} can't be set as the author of newly 52 | recurred issue #%{id}. Keeping author from reference issue. Please select existing user in 53 | plugin settings for future recurrences." 54 | settings: 55 | issue_recurrences: 56 | author: 'Авторът на новата задача да бъде' 57 | author_unchanged: 'авторът на старата задача' 58 | keep_assignee: 'Запазване на изпълнителя от предишното повторение' 59 | keep_assignee_hint: 'ако не е избрано, Redmine ще използва подразбиращите се правила 60 | за назначаване' 61 | journal_mode: 'Добавяне на запис в журнала при създаване на нова задача' 62 | journal_mode_hint: 'записът се добавя само за отправната задача (не и за новата 63 | задача)' 64 | journal_modes: 65 | never: 'никога' 66 | always: 'винаги' 67 | on_reopen: 'on reopen only' 68 | copy_recurrences: 'Копиране на цикличностите при копиране на задачата' 69 | copy_recurrences_hint: 'прилага се независимо дали задачите се копират директно или 70 | като резултат от копиране на проект' 71 | renew_ahead: 'Renew fixed recurrences ahead for' 72 | renew_ahead_hint: 'ensures that the last created recurrence is at least that far 73 | into the future (recurrences based on close date are not affected)' 74 | issue_recurrences: 75 | index: 76 | heading: 'Цикличност на задачите' 77 | issue: 'Задача' 78 | mode: 'Интервал' 79 | last_recurrence: 'Последно' 80 | next_recurrence: 'Следващо' 81 | predicted_recurrence: 'Предсказано' 82 | include_subtasks: 'Подзадачите?' 83 | creation_mode: 'Създаване' 84 | anchor_mode: 'На базата на' 85 | limit: 'Край' 86 | count: '#' 87 | anchor_modes: 88 | first_issue_fixed: 'първата задача' 89 | last_issue_fixed: 'последната задача' 90 | last_issue_flexible: 'затварянето на последната задача като' 91 | last_issue_flexible_on_delay: 'датата на затваряне на последната задача, ако е 92 | закъсняла като' 93 | last_issue_fixed_after_close: 'последната задача (след датата на затваряне) като' 94 | date_fixed_after_close: 'фиксирана задача (след датата на затваряне) като' 95 | create: 96 | success: 'New issue recurrence created.' 97 | update: 98 | success: 'Issue recurrence updated.' 99 | destroy: 100 | success: 'Issue recurrence deleted.' 101 | issues: 102 | issue_recurrences_hook: 103 | recurrences: 'Цикличност' 104 | this_is_recurrence: 'Това е повторение на' 105 | recurrences: 106 | index: 107 | last_recurrence: 'Последно:' 108 | next_recurrence: 'Следващо:' 109 | predicted_recurrence: 'Предсказано:' 110 | form: 111 | creation_modes: 112 | copy_first: 'Копиране' 113 | copy_last: 'Копиране на последното повторение' 114 | reopen: 'Reopen' 115 | issue: 'на задачата' 116 | include_subtasks: 117 | :true: 'заедно с подзадачите' 118 | :false: 'без подзадачите' 119 | subtasks: 'подзадачи' 120 | every: 'всеки/всяка' 121 | mode_intervals: 122 | daily: 'ден' 123 | daily_wday: 'работен ден' 124 | weekly: 'седмица' 125 | monthly_day_from_first: 'месец' 126 | monthly_day_to_last: 'месец' 127 | monthly_dow_from_first: 'месец' 128 | monthly_dow_to_last: 'месец' 129 | monthly_wday_from_first: 'месец' 130 | monthly_wday_to_last: 'месец' 131 | yearly: 'година' 132 | mode_descriptions: 133 | daily: '' 134 | daily_wday: '' 135 | weekly: '' 136 | monthly_day_from_first: 'на същия ден в месеца' 137 | monthly_day_to_last: 'на същия ден от края на месеца' 138 | monthly_dow_from_first: 'на същия ден от седмицата в месеца' 139 | monthly_dow_to_last: 'на същия ден от седмицата от края на месеца' 140 | monthly_wday_from_first: 'на същия работен ден от месеца' 141 | monthly_wday_to_last: 'на същия работен ден от края на месеца' 142 | yearly: '' 143 | mode_modifiers: 144 | daily: '' 145 | daily_wday: '' 146 | weekly: '' 147 | monthly_day_from_first: 'на %{days_from_bom} ден' 148 | monthly_day_to_last: 'на %{days_to_eom} от последния ден' 149 | monthly_dow_from_first: 'на %{dows_from_bom} %{day_of_week}' 150 | monthly_dow_to_last: 'на %{dows_to_eom} до последния %{day_of_week}' 151 | monthly_wday_from_first: 'на %{wdays_from_bom} работен ден' 152 | monthly_wday_to_last: 'на %{wdays_to_eom} до последния работен ден' 153 | yearly: '' 154 | based_on: 'базирано на' 155 | anchor_modes: 156 | first_issue_fixed: 'на тази задача' 157 | last_issue_fixed: 'на последната задача' 158 | last_issue_flexible: 'назначена след датата на затваряне на последната 159 | задача' 160 | last_issue_flexible_on_delay: 'след последната задача, ако е затворена 161 | навреме, иначе от датата на затваряне' 162 | last_issue_fixed_after_close: 'след последната задача (повтаря само след датата 163 | на затваряне)' 164 | date_fixed_after_close: 'назначена след фиксирана дата (повтаря само след 165 | датата на затваряне на последната задача):' 166 | anchor_to_start: 167 | :true: 'начална дата' 168 | :false: 'крайна дата' 169 | delayed_by: ', забавена с' 170 | delay_modes: 171 | days: 'дена' 172 | weeks: 'седмици' 173 | months: 'месеца' 174 | years: 'year(s)' 175 | delay_intervals: 176 | days: 'ден' 177 | weeks: 'седмица' 178 | months: 'месец' 179 | until: 'и повтаряне до' 180 | limit_modes: 181 | no_limit: 'завинаги' 182 | date_limit: 'фиксирана дата (включително):' 183 | count_limit: 'броят на повторенията достигне:' 184 | recurrence: 'повторение' 185 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # English strings go here for Rails i18n 2 | en: 3 | field_recurrence_of: 'Recurrence of' 4 | activerecord: 5 | errors: 6 | models: 7 | issue_recurrence: 8 | attributes: 9 | issue: 10 | insufficient_privileges: 'you have insufficient privileges to edit recurrences 11 | for this issue' 12 | anchor_mode: 13 | delay_requires_fixed_anchor: 'delay cannot be specified for recurrences based 14 | on close date' 15 | blank_issue_dates_require_reopen: 'recurrences based on issue or last 16 | recurrence dates cannot be created when both dates (start and due) are 17 | blank' 18 | reopen_requires_close_date_based: 'only recurrences dependent on close date 19 | can reopen issue' 20 | creation_mode: 21 | only_one_reopen: 'only one reopening recurrence calculated from close date 22 | is allowed' 23 | derived_dates_reopen_requires_subtasks: 'cannot reopen issue excluding 24 | subtasks if start and due dates are derived from subtasks (check 25 | Administration -> Settings -> Issue tracking -> Parent tasks attributes)' 26 | anchor_to_start: 27 | start_mode_requires_date: 'recurrence based on start date cannot be created 28 | for issue without start date' 29 | due_mode_requires_date: 'recurrence based on due date cannot be created for 30 | issue without due date' 31 | date_limit: 32 | not_in_future: 'user provided recurrence limit date has to be in future' 33 | not_after_anchor_date: 'user provided limit date has to be later than the 34 | fixed date the recurrence is based on' 35 | issue_recurrences_menu_caption: 'Issue recurrences' 36 | warning_renew: "*Warning!* Can't renew recurrence referring to issue #%{id}: %{errors}." 37 | warning_keep_assignee: "*Warning!* Can't assign newly recurred issue #%{id} to @%{login}. 38 | Default assignment rules will apply." 39 | warning_author: "*Warning!* Nonexistent user %{login} can't be set as the author of newly 40 | recurred issue #%{id}. Keeping author from reference issue. Please select existing user 41 | in plugin settings for future recurrences." 42 | settings: 43 | issue_recurrences: 44 | author: 'Set author of new recurrence to' 45 | author_unchanged: 'the author of source issue' 46 | keep_assignee: 'Keep assignee from previous recurrence' 47 | keep_assignee_hint: 'if unchecked, Redmine default assignment rules will apply' 48 | journal_mode: 'Add journal entry on recurrence renewal' 49 | journal_mode_hint: 'journal is added only on reference issue (not on new copy)' 50 | journal_modes: 51 | never: 'never' 52 | always: 'always' 53 | on_reopen: 'on reopen only' 54 | copy_recurrences: 'Copy recurrences on issue copy' 55 | copy_recurrences_hint: 'applies regardless of whether issues are copied directly or 56 | as a result of project copy' 57 | renew_ahead: 'Renew fixed recurrences ahead for' 58 | renew_ahead_hint: 'ensures that the last created recurrence is at least that far 59 | into the future (recurrences based on close date are not affected)' 60 | issue_recurrences: 61 | index: 62 | heading: 'Issue recurrences' 63 | issue: 'Issue' 64 | mode: 'Every' 65 | last_recurrence: 'Last' 66 | next_recurrence: 'Next' 67 | predicted_recurrence: 'Predicted' 68 | include_subtasks: 'Subtasks?' 69 | creation_mode: 'Create' 70 | anchor_mode: 'Based on' 71 | limit: 'Limit' 72 | count: '#' 73 | anchor_modes: 74 | first_issue_fixed: 'first issue' 75 | last_issue_fixed: 'last issue' 76 | last_issue_flexible: 'last issue close as' 77 | last_issue_flexible_on_delay: 'last issue close if delayed as' 78 | last_issue_fixed_after_close: 'last issue (after close date) as' 79 | date_fixed_after_close: 'fixed date (after close date) as' 80 | create: 81 | success: 'New issue recurrence created.' 82 | update: 83 | success: 'Issue recurrence updated.' 84 | destroy: 85 | success: 'Issue recurrence deleted.' 86 | issues: 87 | issue_recurrences_hook: 88 | recurrences: 'Recurrences' 89 | this_is_recurrence: 'This is a recurrence of' 90 | recurrences: 91 | index: 92 | last_recurrence: 'Last:' 93 | next_recurrence: 'Next:' 94 | predicted_recurrence: 'Predicted:' 95 | form: 96 | creation_modes: 97 | copy_first: 'Copy' 98 | copy_last: 'Copy last recurrence of' 99 | reopen: 'Reopen' 100 | issue: 'issue' 101 | include_subtasks: 102 | :true: 'including subtasks' 103 | :false: 'excluding subtasks' 104 | subtasks: 'subtasks' 105 | every: 'every' 106 | mode_intervals: 107 | daily: 'day' 108 | daily_wday: 'working day' 109 | weekly: 'week' 110 | monthly_day_from_first: 'month' 111 | monthly_day_to_last: 'month' 112 | monthly_dow_from_first: 'month' 113 | monthly_dow_to_last: 'month' 114 | monthly_wday_from_first: 'month' 115 | monthly_wday_to_last: 'month' 116 | yearly: 'year' 117 | mode_descriptions: 118 | daily: '' 119 | daily_wday: '' 120 | weekly: '' 121 | monthly_day_from_first: 'on the same day of month' 122 | monthly_day_to_last: 'on the same day from the end of month' 123 | monthly_dow_from_first: 'on the same weekday of month' 124 | monthly_dow_to_last: 'on the same weekday from the end of month' 125 | monthly_wday_from_first: 'on the same working day of month' 126 | monthly_wday_to_last: 'on the same working day from the end of month' 127 | yearly: '' 128 | mode_modifiers: 129 | daily: '' 130 | daily_wday: '' 131 | weekly: '' 132 | monthly_day_from_first: 'on %{days_from_bom} day' 133 | monthly_day_to_last: 'on %{days_to_eom} to last day' 134 | monthly_dow_from_first: 'on %{dows_from_bom} %{day_of_week}' 135 | monthly_dow_to_last: 'on %{dows_to_eom} to last %{day_of_week}' 136 | monthly_wday_from_first: 'on %{wdays_from_bom} working day' 137 | monthly_wday_to_last: 'on %{wdays_to_eom} to last working day' 138 | yearly: '' 139 | based_on: 'based on' 140 | anchor_modes: 141 | first_issue_fixed: 'of this issue' 142 | last_issue_fixed: 'of last recurrence' 143 | last_issue_flexible: 'assigned from close date of last recurrence' 144 | last_issue_flexible_on_delay: 'of last recurrence if closed on time, 145 | otherwise assigned from close date' 146 | last_issue_fixed_after_close: 'of last recurrence (recurs only after close 147 | date)' 148 | date_fixed_after_close: 'assigned from fixed date (recurs only after 149 | last recurrence close date):' 150 | anchor_to_start: 151 | :true: 'start date' 152 | :false: 'due date' 153 | delayed_by: ', delayed by' 154 | delay_modes: 155 | days: 'day(s)' 156 | weeks: 'week(s)' 157 | months: 'month(s)' 158 | years: 'year(s)' 159 | delay_intervals: 160 | days: 'day' 161 | weeks: 'week' 162 | months: 'month' 163 | until: 'and repeat until' 164 | limit_modes: 165 | no_limit: 'forever' 166 | date_limit: 'fixed date (inclusive):' 167 | count_limit: 'number of recurrences reaches:' 168 | recurrence: 'recurrence' 169 | -------------------------------------------------------------------------------- /config/locales/es.yml: -------------------------------------------------------------------------------- 1 | # Spanish strings go here for Rails i18n, author: https://github.com/lupa18 2 | es: 3 | field_recurrence_of: 'Recurrence of' 4 | activerecord: 5 | errors: 6 | models: 7 | issue_recurrence: 8 | attributes: 9 | issue: 10 | insufficient_privileges: 'no tienes permisos suficientes para editar la 11 | recurrencia de esta petición' 12 | anchor_mode: 13 | delay_requires_fixed_anchor: 'el retraso no puede ser especificado para 14 | recurrencias basadas en fecha de cierre' 15 | blank_issue_dates_require_reopen: 'recurrencias basadas en asunto o fechas de 16 | última ocurrencia, no pueden ser creadas cuando ambas fechas (inicio y fin) 17 | están en blanco' 18 | # TODO: update translation, previous version saved for reference 19 | #reopen_requires_close_date_based: 'solo recurrencias dependientes de fecha de 20 | # cierre pueden modificar una petición in situ' 21 | reopen_requires_close_date_based: 'only recurrences dependent on close date 22 | can reopen issue' 23 | creation_mode: 24 | # TODO: update translation, previous version saved for reference 25 | #only_one_reopen: 'solamente una recurrencia puede ser programada in situ' 26 | only_one_reopen: 'only one reopening recurrence calculated from close date 27 | is allowed' 28 | derived_dates_reopen_requires_subtasks: 'cannot reopen issue excluding 29 | subtasks if start and due dates are derived from subtasks (check 30 | Administration -> Settings -> Issue tracking -> Parent tasks attributes)' 31 | anchor_to_start: 32 | start_mode_requires_date: 'recurrencias basadas en fecha de inicio no pueden 33 | ser creadas para peticiones sin fecha de inicio' 34 | due_mode_requires_date: 'recurrencias basadas en fecha de fin no pueden ser 35 | creadas para peticiones sin fecha de fin' 36 | date_limit: 37 | not_in_future: 'el límite de fecha para una recurrencia proporcionada por el 38 | usuario debe ser futuro' 39 | not_after_anchor_date: 'user provided limit date has to be later than the 40 | fixed date the recurrence is based on' 41 | issue_recurrences_menu_caption: 'Recurrencias' 42 | # TODO: update translation, previous version saved for reference 43 | # warning_renew: "*Advertencia!* No se puede renovar recurrencia, issue #%{id}: %{errors}." 44 | warning_renew: "*Warning!* Can't renew recurrence referring to issue #%{id}: %{errors}." 45 | warning_keep_assignee: "*Warning!* Can't assign newly recurred issue #%{id} to @%{login}. 46 | Default assignment rules will apply." 47 | warning_author: "*Warning!* Nonexistent user %{login} can't be set as the author of newly 48 | recurred issue #%{id}. Keeping author from reference issue. Please select existing user 49 | in plugin settings for future recurrences." 50 | settings: 51 | issue_recurrences: 52 | author: 'Establecer autor para nueva recurrencia a' 53 | author_unchanged: 'el autor de la petición original' 54 | keep_assignee: 'Conservar asignación de recurrencia anterior' 55 | keep_assignee_hint: 'si no está marcado, serán aplicadas las reglas de asignación por 56 | defecto' 57 | journal_mode: 'Agregar una entrada de registro cuando se renueve la recurrencia' 58 | journal_mode_hint: 'el registro solo se agrega en la petición referenciada (no en la 59 | nueva copia)' 60 | journal_modes: 61 | never: 'never' 62 | always: 'always' 63 | on_reopen: 'on reopen only' 64 | copy_recurrences: 'Copy recurrences on issue copy' 65 | copy_recurrences_hint: 'applies regardless of whether issues are copied directly or 66 | as a result of project copy' 67 | renew_ahead: 'Renew fixed recurrences ahead for' 68 | renew_ahead_hint: 'ensures that the last created recurrence is at least that far 69 | into the future (recurrences based on close date are not affected)' 70 | issue_recurrences: 71 | index: 72 | heading: 'Recurrencias de petición' 73 | issue: 'Petición' 74 | mode: 'Every' 75 | last_recurrence: 'Última recurrencia' 76 | next_recurrence: 'Próxima recurrencia' 77 | predicted_recurrence: 'Predicted' 78 | include_subtasks: '¿Subtareas?' 79 | creation_mode: 'Crear' 80 | anchor_mode: 'Basada en' 81 | limit: 'Límite' 82 | count: '#' 83 | anchor_modes: 84 | first_issue_fixed: 'primer petición' 85 | last_issue_fixed: 'última petición' 86 | last_issue_flexible: 'última petición cerrada como' 87 | last_issue_flexible_on_delay: 'última petición si se retrasa como' 88 | last_issue_fixed_after_close: 'última petición (después de fecha de cierre) como' 89 | date_fixed_after_close: 'fecha fija (después de fecha de cierre) como' 90 | create: 91 | success: 'New issue recurrence created.' 92 | update: 93 | success: 'Issue recurrence updated.' 94 | destroy: 95 | success: 'Issue recurrence deleted.' 96 | issues: 97 | issue_recurrences_hook: 98 | recurrences: 'Recurrencias' 99 | this_is_recurrence: 'Esto es una recurrencia de' 100 | recurrences: 101 | index: 102 | last_recurrence: 'Última:' 103 | next_recurrence: 'Próxima:' 104 | predicted_recurrence: 'Predicted:' 105 | form: 106 | creation_modes: 107 | copy_first: 'Copiar' 108 | copy_last: 'Copiar última recurrencia de' 109 | reopen: 'Reopen' 110 | issue: 'petición' 111 | include_subtasks: 112 | :true: 'incluyendo subtareas' 113 | :false: 'excluyendo subtareas' 114 | subtasks: 'subtareas' 115 | every: 'cada' 116 | mode_intervals: 117 | daily: 'día' 118 | daily_wday: 'día laboral' 119 | weekly: 'semana' 120 | monthly_day_from_first: 'mes' 121 | monthly_day_to_last: 'mes' 122 | monthly_dow_from_first: 'mes' 123 | monthly_dow_to_last: 'mes' 124 | monthly_wday_from_first: 'mes' 125 | monthly_wday_to_last: 'mes' 126 | yearly: 'año' 127 | mode_descriptions: 128 | daily: '' 129 | daily_wday: '' 130 | weekly: '' 131 | monthly_day_from_first: 'en el mismo día del mes' 132 | monthly_day_to_last: 'en el mismo día desde el final del mes' 133 | monthly_dow_from_first: 'en el mismo fin de semana del mes' 134 | monthly_dow_to_last: 'en el mismo fin de semana desde el final del mes' 135 | monthly_wday_from_first: 'en el mismo dia laboral del mes' 136 | monthly_wday_to_last: 'en el mismo dia laboral desde el final del mes' 137 | yearly: '' 138 | mode_modifiers: 139 | daily: '' 140 | daily_wday: '' 141 | weekly: '' 142 | monthly_day_from_first: 'en %{days_from_bom} día' 143 | monthly_day_to_last: 'en %{days_to_eom} hasta el último día' 144 | monthly_dow_from_first: 'en %{dows_from_bom} %{day_of_week}' 145 | monthly_dow_to_last: 'en %{dows_to_eom} hasta el último %{day_of_week}' 146 | monthly_wday_from_first: 'en %{wdays_from_bom} día laboral' 147 | monthly_wday_to_last: 'en %{wdays_to_eom} hasta el último día laboral' 148 | yearly: '' 149 | based_on: 'basada en' 150 | anchor_modes: 151 | first_issue_fixed: 'de esta petición' 152 | last_issue_fixed: 'de la última recurrencia' 153 | last_issue_flexible: 'copiada de la fecha de cierre de la úlimta 154 | recurrencia' 155 | last_issue_flexible_on_delay: 'copiada de la última recurrencia si se cerró a 156 | tiempo, o de la fecha de cierre' 157 | last_issue_fixed_after_close: 'de la última recurrencia (se crea solo después 158 | de la fecha de cierre)' 159 | date_fixed_after_close: 'copiada desde fecha fija (se crea solo después de 160 | la fecha de cierre de la última recurrencia):' 161 | anchor_to_start: 162 | :true: 'fecha inicio' 163 | :false: 'fecha fin' 164 | delayed_by: ', retrasada por' 165 | delay_modes: 166 | days: 'día(s)' 167 | weeks: 'semana(s)' 168 | months: 'mes(es)' 169 | years: 'year(s)' 170 | delay_intervals: 171 | days: 'día' 172 | weeks: 'semana' 173 | months: 'mes' 174 | until: 'y repetir hasta' 175 | limit_modes: 176 | no_limit: 'siempre' 177 | date_limit: 'fecha fija (incluída):' 178 | count_limit: 'número de recurrencias alcance:' 179 | recurrence: 'recurrencia' 180 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # Plugin's routes 2 | # See: http://guides.rubyonrails.org/routing.html 3 | 4 | resources :issues, shallow_prefix: :issue do 5 | shallow do 6 | resources :recurrences, controller: :issue_recurrences, except: [:index, :show] 7 | end 8 | end 9 | resources :projects do 10 | shallow do 11 | resources :recurrences, controller: :issue_recurrences, only: [:index] 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/001_create_issue_recurrences.rb: -------------------------------------------------------------------------------- 1 | class CreateIssueRecurrences < ActiveRecord::Migration[4.2] 2 | def change 3 | create_table :issue_recurrences do |t| 4 | t.references :issue, foreign: true, index: true 5 | t.references :last_issue, foreign: true, index: true 6 | t.integer :count 7 | t.integer :creation_mode 8 | t.integer :anchor_mode 9 | t.integer :mode 10 | t.integer :multiplier 11 | t.integer :delay_mode 12 | t.integer :delay_multiplier 13 | t.boolean :include_subtasks 14 | t.date :date_limit 15 | t.integer :count_limit 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /db/migrate/002_add_recurrence_of_to_issues.rb: -------------------------------------------------------------------------------- 1 | class AddRecurrenceOfToIssues < ActiveRecord::Migration[4.2] 2 | def change 3 | add_reference :issues, :recurrence_of, index: true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/003_add_anchor_to_start_to_issue_recurrences.rb: -------------------------------------------------------------------------------- 1 | class AddAnchorToStartToIssueRecurrences < ActiveRecord::Migration[4.2] 2 | @@old_modes = { 3 | monthly_start_day_from_first: 200, 4 | monthly_due_day_from_first: 201, 5 | monthly_start_day_to_last: 210, 6 | monthly_due_day_to_last: 211, 7 | monthly_start_dow_from_first: 220, 8 | monthly_due_dow_from_first: 221, 9 | monthly_start_dow_to_last: 230, 10 | monthly_due_dow_to_last: 231, 11 | monthly_start_wday_from_first: 240, 12 | monthly_due_wday_from_first: 241, 13 | monthly_start_wday_to_last: 250, 14 | monthly_due_wday_to_last: 251 15 | } 16 | 17 | @@new_modes = { 18 | monthly_day_from_first: 202, 19 | monthly_day_to_last: 212, 20 | monthly_dow_from_first: 222, 21 | monthly_dow_to_last: 232, 22 | monthly_wday_from_first: 242, 23 | monthly_wday_to_last: 252 24 | } 25 | 26 | @@mode_conversion = { 27 | @@old_modes[:monthly_start_day_from_first] => 28 | {mode: @@new_modes[:monthly_day_from_first], anchor_to_start: true}, 29 | @@old_modes[:monthly_due_day_from_first] => 30 | {mode: @@new_modes[:monthly_day_from_first], anchor_to_start: false}, 31 | 32 | @@old_modes[:monthly_start_day_to_last] => 33 | {mode: @@new_modes[:monthly_day_to_last], anchor_to_start: true}, 34 | @@old_modes[:monthly_due_day_to_last] => 35 | {mode: @@new_modes[:monthly_day_to_last], anchor_to_start: false}, 36 | 37 | @@old_modes[:monthly_start_dow_from_first] => 38 | {mode: @@new_modes[:monthly_dow_from_first], anchor_to_start: true}, 39 | @@old_modes[:monthly_due_dow_from_first] => 40 | {mode: @@new_modes[:monthly_dow_from_first], anchor_to_start: false}, 41 | 42 | @@old_modes[:monthly_start_dow_to_last] => 43 | {mode: @@new_modes[:monthly_dow_to_last], anchor_to_start: true}, 44 | @@old_modes[:monthly_due_dow_to_last] => 45 | {mode: @@new_modes[:monthly_dow_to_last], anchor_to_start: false}, 46 | 47 | @@old_modes[:monthly_start_wday_from_first] => 48 | {mode: @@new_modes[:monthly_wday_from_first], anchor_to_start: true}, 49 | @@old_modes[:monthly_due_wday_from_first] => 50 | {mode: @@new_modes[:monthly_wday_from_first], anchor_to_start: false}, 51 | 52 | @@old_modes[:monthly_start_wday_to_last] => 53 | {mode: @@new_modes[:monthly_wday_to_last], anchor_to_start: true}, 54 | @@old_modes[:monthly_due_wday_to_last] => 55 | {mode: @@new_modes[:monthly_wday_to_last], anchor_to_start: false}, 56 | } 57 | 58 | class IssueRecurrence < ActiveRecord::Base 59 | end 60 | 61 | def up 62 | add_column :issue_recurrences, :anchor_to_start, :boolean 63 | IssueRecurrence.reset_column_information 64 | 65 | IssueRecurrence.all.each do |ir| 66 | # Should operate on values as stored in db (and not things like enum 67 | # names declared in ActiveRecord::IssueRecurrence, as that may change in 68 | # future and invalidate this migration). 69 | attrs = ir.attributes 70 | if @@mode_conversion.has_key?(attrs["mode"]) 71 | ir.update!(@@mode_conversion[attrs["mode"]]) 72 | else 73 | ref_issue_id = attrs["last_issue_id"] if attrs["anchor_mode"].between?(1, 3) 74 | ref_issue_id ||= attrs["issue_id"] 75 | ref_issue = Issue.find(ref_issue_id) 76 | ir.update_attribute(:anchor_to_start, 77 | ref_issue.start_date.present? && ref_issue.due_date.blank?) 78 | end 79 | end 80 | end 81 | 82 | def down 83 | mode_reversion = @@mode_conversion.invert 84 | IssueRecurrence.all.each do |ir| 85 | attrs = ir.attributes 86 | reversion_key = {mode: attrs["mode"], anchor_to_start: attrs["anchor_to_start"]} 87 | if mode_reversion.has_key?(reversion_key) 88 | ir.update_attribute(:mode, mode_reversion[reversion_key]) 89 | end 90 | end 91 | 92 | remove_column :issue_recurrences, :anchor_to_start 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /db/migrate/004_add_anchor_date_to_issue_recurrence.rb: -------------------------------------------------------------------------------- 1 | class AddAnchorDateToIssueRecurrence < ActiveRecord::Migration[4.2] 2 | def change 3 | add_column :issue_recurrences, :anchor_date, :date 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/005_extend_and_change_setting_name_add_journal_to_journal_mode.rb: -------------------------------------------------------------------------------- 1 | class ExtendAndChangeSettingNameAddJournalToJournalMode < ActiveRecord::Migration[4.2] 2 | def up 3 | settings = Setting.plugin_issue_recurring 4 | return if settings == Setting.available_settings['plugin_issue_recurring']['default'] 5 | settings[:author_id] = settings.delete('author_id').to_i 6 | settings[:keep_assignee] = settings.delete('keep_assignee') == 'true' 7 | settings[:journal_mode] = settings.delete('add_journal') == 'true' ? :always : :never 8 | Setting.plugin_issue_recurring = settings 9 | end 10 | 11 | def down 12 | settings = Setting.plugin_issue_recurring 13 | return if settings == Setting.available_settings['plugin_issue_recurring']['default'] 14 | settings['author_id'] = settings.delete(:author_id).to_s 15 | settings['keep_assignee'] = 'true' if settings.delete(:keep_assignee) 16 | settings['add_journal'] = 'true' if [:always, :inplace] 17 | .include?(settings.delete(:journal_mode)) 18 | Setting.plugin_issue_recurring = settings 19 | end 20 | end 21 | 22 | -------------------------------------------------------------------------------- /db/migrate/006_convert_author_id_setting_to_author_login.rb: -------------------------------------------------------------------------------- 1 | class ConvertAuthorIdSettingToAuthorLogin < ActiveRecord::Migration[4.2] 2 | def up 3 | settings = Setting.plugin_issue_recurring 4 | return if settings == Setting.available_settings['plugin_issue_recurring']['default'] 5 | settings[:author_login] = User.find_by(id: settings.delete('author_id')).try(:login) 6 | Setting.plugin_issue_recurring = settings 7 | end 8 | 9 | def down 10 | settings = Setting.plugin_issue_recurring 11 | return if settings == Setting.available_settings['plugin_issue_recurring']['default'] 12 | settings['author_id'] = User.find_by(login: settings.delete(:author_login)).try(:id) || 0 13 | Setting.plugin_issue_recurring = settings 14 | end 15 | end 16 | 17 | -------------------------------------------------------------------------------- /db/migrate/007_add_renew_ahead_settings_defaults.rb: -------------------------------------------------------------------------------- 1 | class AddRenewAheadSettingsDefaults < ActiveRecord::Migration[4.2] 2 | def up 3 | settings = Setting.plugin_issue_recurring 4 | return if settings == Setting.available_settings['plugin_issue_recurring']['default'] 5 | settings[:ahead_multiplier] = 0 6 | settings[:ahead_mode] = :days 7 | Setting.plugin_issue_recurring = settings 8 | end 9 | 10 | def down 11 | settings = Setting.plugin_issue_recurring 12 | return if settings == Setting.available_settings['plugin_issue_recurring']['default'] 13 | settings.delete(:ahead_multiplier) 14 | settings.delete(:ahead_mode) 15 | Setting.plugin_issue_recurring = settings 16 | end 17 | end 18 | 19 | -------------------------------------------------------------------------------- /db/migrate/008_rename_journal_modes_setting_inplace_to_on_reopen.rb: -------------------------------------------------------------------------------- 1 | class RenameJournalModesSettingInplaceToOnReopen < ActiveRecord::Migration[4.2] 2 | def up 3 | settings = Setting.plugin_issue_recurring 4 | return if settings == Setting.available_settings['plugin_issue_recurring']['default'] 5 | settings[:journal_mode] = :on_reopen if settings[:journal_mode] == :in_place 6 | Setting.plugin_issue_recurring = settings 7 | end 8 | 9 | def down 10 | settings = Setting.plugin_issue_recurring 11 | return if settings == Setting.available_settings['plugin_issue_recurring']['default'] 12 | settings[:journal_mode] = :in_place if settings[:journal_mode] == :on_reopen 13 | Setting.plugin_issue_recurring = settings 14 | end 15 | end 16 | 17 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | # Load Redmine patches before plugin registration 2 | # NOTE: simplify when Rails < 6 no longer supported and there is only Zeitwerk 3 | def load_patches 4 | # include()/prepend() assures that each patch is applied only once. 5 | # If the class has been included somewhere else prior to patching, it has to 6 | # be patched in place with class_eval() instead of mere patch inclusion. 7 | # This is because include()'d patch won't be reflected in ancestor structure 8 | # in place of previous inclusions. This is immanent feature of Ruby: 9 | # '[...] the reason for this, [...] was performance. Ancestor chains are 10 | # linearized, and if you add a new ancestor to a module, Ruby does not update 11 | # the linearized cached ancestor chains of the affected existing classes or 12 | # modules [that have included eariler the module with later-added ancestor]. 13 | # Ruby does not keep backreferences registering "where was this module included'. 14 | # https://github.com/hotwired/turbo-rails/issues/64#issuecomment-778601827 15 | 16 | # NOTE: remove when https://www.redmine.org/issues/37803 is fixed 17 | ActiveRecord::ConnectionAdapters::SchemaStatements 18 | .include IssueRecurring::SchemaStatementsPatch 19 | ActiveRecord::Schema.prepend IssueRecurring::SchemaPatch 20 | ActiveRecord::SchemaDumper.prepend IssueRecurring::SchemaDumperPatch 21 | 22 | Issue.include IssueRecurring::IssuePatch 23 | # Helper module has to be patched before Controller is loaded. Loading 24 | # Controller causes Helper module to be loaded and included. Any subsequent 25 | # inclusions (i.e. change in ancestor chain) in Helper module won't be 26 | # reflected in Controller's ancestor structure. 27 | IssuesHelper.include IssueRecurring::IssuesHelperPatch 28 | IssuesController.include IssueRecurring::IssuesControllerPatch 29 | 30 | Project.include IssueRecurring::ProjectPatch 31 | 32 | SettingsHelper.include IssueRecurring::SettingsHelperPatch 33 | SettingsController.include IssueRecurring::SettingsControllerPatch 34 | end 35 | 36 | if Rails.respond_to?(:autoloaders) && Rails.autoloaders.zeitwerk_enabled? 37 | IssueRecurring::IssueRecurrencesViewListener 38 | load_patches 39 | else 40 | require_dependency 'issue_recurring/issue_recurrences_view_listener' 41 | ActiveSupport::Reloader.to_prepare { load_patches } 42 | end 43 | 44 | 45 | Redmine::Plugin.register :issue_recurring do 46 | name 'Issue recurring plugin' 47 | author 'cryptogopher' 48 | description 'Schedule Redmine issue recurrence based on multiple conditions' 49 | version '1.7' 50 | url 'https://github.com/cryptogopher/issue_recurring' 51 | author_url 'https://github.com/cryptogopher' 52 | 53 | project_module :issue_recurring do 54 | permission :view_issue_recurrences, 55 | {:issue_recurrences => [:index]}, 56 | read: true 57 | permission :manage_issue_recurrences, 58 | {:issue_recurrences => [:new, :create, :edit, :update, :destroy]}, 59 | require: :loggedin 60 | end 61 | menu :project_menu, :issue_recurrences, 62 | {:controller => 'issue_recurrences', :action => 'index'}, 63 | :caption => :issue_recurrences_menu_caption, 64 | :after => :issues, :param => :project_id 65 | 66 | settings default: { 67 | author_login: nil, 68 | keep_assignee: false, 69 | journal_mode: :never, 70 | copy_recurrences: false, 71 | ahead_multiplier: 0, 72 | ahead_mode: :days 73 | }, partial: 'settings/issue_recurrences' 74 | end 75 | -------------------------------------------------------------------------------- /lib/issue_recurring/issue_patch.rb: -------------------------------------------------------------------------------- 1 | module IssueRecurring 2 | module CopyFromWithRecurrences 3 | def copy_from(arg, options={}) 4 | super 5 | 6 | unless options[:skip_recurrences] 7 | self.recurrence_of = nil 8 | 9 | if Setting.plugin_issue_recurring[:copy_recurrences] 10 | self.recurrences = @copied_from.recurrences.map(&:dup) 11 | end 12 | end 13 | 14 | self 15 | end 16 | end 17 | 18 | module IssuePatch 19 | Issue.class_eval do 20 | prepend CopyFromWithRecurrences 21 | 22 | has_many :recurrences, class_name: 'IssueRecurrence', dependent: :destroy 23 | 24 | belongs_to :recurrence_of, class_name: 'Issue', validate: true 25 | has_many :recurrence_copies, class_name: 'Issue', foreign_key: 'recurrence_of_id', 26 | dependent: :nullify 27 | 28 | after_destroy :substitute_if_last_issue 29 | end 30 | 31 | def substitute_if_last_issue 32 | return if self.recurrence_of.blank? 33 | r = self.recurrence_of.recurrences.find_by(last_issue: self) 34 | return if r.nil? 35 | r.update!(last_issue: r.issue.recurrence_copies.last) 36 | end 37 | 38 | def default_reassign 39 | self.assigned_to = nil 40 | default_assign 41 | end 42 | end 43 | end 44 | 45 | -------------------------------------------------------------------------------- /lib/issue_recurring/issue_recurrences_view_listener.rb: -------------------------------------------------------------------------------- 1 | module IssueRecurring 2 | class IssueRecurrencesViewListener < Redmine::Hook::ViewListener 3 | render_on :view_issues_show_description_bottom, partial: 'issues/issue_recurrences_hook' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/issue_recurring/issues_controller_patch.rb: -------------------------------------------------------------------------------- 1 | module IssueRecurring 2 | module IssuesControllerPatch 3 | IssuesController.class_eval do 4 | include LoadIssueRecurrences 5 | 6 | before_action :load_issue_recurrences, only: [:show] 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/issue_recurring/issues_helper_patch.rb: -------------------------------------------------------------------------------- 1 | module IssueRecurring 2 | module IssuesHelperPatch 3 | TRANSLATION_ROOT = 'issues.recurrences.form' 4 | 5 | # Don't use Redmine's #error_messages_for, which displays attribute names. 6 | # Errors for single attributes should never be visible to the 7 | # user, as he should not be able to fill the form with invalid attributes. 8 | # The errors that will be displayed are more complex: they depend on 9 | # attributes of recurrence AND issue or multiple recurrences. Displaying 10 | # recurrence attribute names would only be misleading to users. 11 | # See error messages: 'activerecord.errors.models.issue_recurrence.attributes.*'. 12 | def nameless_error_messages_for(*objects) 13 | objects = objects.map {|o| o.is_a?(String) ? instance_variable_get("@#{o}") : o} 14 | errors = objects.compact.map {|o| o.errors.messages.values()}.flatten 15 | render_error_messages(errors) 16 | end 17 | 18 | def creation_mode_options 19 | translations = t("#{TRANSLATION_ROOT}.creation_modes") 20 | IssueRecurrence.creation_modes.map do |k,v| 21 | [strip_tags(translations[k.to_sym]), k.to_sym] 22 | end 23 | end 24 | 25 | def include_subtasks_options 26 | [true, false].map do |v| 27 | [strip_tags(t("#{TRANSLATION_ROOT}.include_subtasks.#{v}")), v] 28 | end 29 | end 30 | 31 | def mode_options 32 | intervals = t("#{TRANSLATION_ROOT}.mode_intervals") 33 | descriptions = t("#{TRANSLATION_ROOT}.mode_descriptions") 34 | IssueRecurrence.modes.map do |k,v| 35 | mode = "#{intervals[k.to_sym]}(s)" 36 | mode += ", #{descriptions[k.to_sym]}" unless descriptions[k.to_sym].empty? 37 | [strip_tags(mode), k.to_sym] 38 | end 39 | end 40 | 41 | def anchor_mode_options 42 | IssueRecurrence.anchor_modes.map do |k,v| 43 | [strip_tags(t("#{TRANSLATION_ROOT}.anchor_modes.#{k}")), k.to_sym] 44 | end 45 | end 46 | 47 | def anchor_to_start_options 48 | [true, false].map do |v| 49 | [strip_tags(t("#{TRANSLATION_ROOT}.anchor_to_start.#{v}")), v] 50 | end 51 | end 52 | 53 | def anchor_to_start_disabled 54 | disabled = [] 55 | disabled << true if @issue.start_date.blank? && @issue.due_date.present? 56 | disabled << false if @issue.start_date.present? && @issue.due_date.blank? 57 | disabled 58 | end 59 | 60 | def delay_mode_options 61 | translations = t("#{TRANSLATION_ROOT}.delay_modes") 62 | IssueRecurrence.delay_modes.map do |k,v| 63 | [strip_tags(translations[k.to_sym]), k.to_sym] 64 | end 65 | end 66 | 67 | def limit_mode_options 68 | t("#{TRANSLATION_ROOT}.limit_modes").map { |k,v| [strip_tags(v), k] } 69 | end 70 | 71 | def last_recurrence(r, intro=true) 72 | s = intro ? "#{t '.last_recurrence'} " : "" 73 | if r.last_issue.present? 74 | s += "#{link_to("##{r.last_issue.id}", issue_path(r.last_issue))}" 75 | else 76 | s += "-" 77 | end 78 | s.html_safe 79 | end 80 | 81 | def format_dates(dates_list) 82 | dates_str = dates_list.map { |dates| "#{dates[:start]} - #{dates[:due]}" }.join(", ") 83 | dates_str.empty? ? '-' : dates_str 84 | end 85 | 86 | def next_recurrences(dates_list, intro=true) 87 | "#{"#{t '.next_recurrence'} " if intro}#{format_dates(dates_list)}".html_safe 88 | end 89 | 90 | def predicted_recurrences(dates_list, intro=true) 91 | "#{"#{t '.predicted_recurrence'} " if intro}#{format_dates(dates_list)}".html_safe 92 | end 93 | 94 | def edit_button(r) 95 | link_to l(:button_edit), edit_issue_recurrence_path(r), remote: true, 96 | class: 'icon icon-edit' 97 | end 98 | 99 | def delete_button(r) 100 | link_to l(:button_delete), issue_recurrence_path(r), method: :delete, remote: true, 101 | class: 'icon icon-del' 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/issue_recurring/project_patch.rb: -------------------------------------------------------------------------------- 1 | module IssueRecurring 2 | module ProjectPatch 3 | Project.class_eval do 4 | has_many :recurrences, class_name: 'IssueRecurrence', dependent: :destroy, 5 | through: :issues 6 | end 7 | end 8 | end 9 | 10 | -------------------------------------------------------------------------------- /lib/issue_recurring/schema_dumper_patch.rb: -------------------------------------------------------------------------------- 1 | module IssueRecurring 2 | module SchemaDumperPatch 3 | def define_params 4 | versions = super.present? ? [super] : [] 5 | Redmine::Plugin.all.each do |plugin| 6 | current_migration = Redmine::Plugin::Migrator.current_version(plugin) 7 | versions << "#{plugin.id}: #{current_migration}" if current_migration > 0 8 | end 9 | versions.join(", ") 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/issue_recurring/schema_patch.rb: -------------------------------------------------------------------------------- 1 | module IssueRecurring 2 | module SchemaPatch 3 | # TODO: replace arguments with argument forwarding (info, ...) in Ruby 3.0 4 | def define(info, &block) 5 | super 6 | 7 | info.except(:version).each do |id, v| 8 | connection.assume_plugin_migrated_upto_version(id, v) 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/issue_recurring/schema_statements_patch.rb: -------------------------------------------------------------------------------- 1 | module IssueRecurring 2 | module SchemaStatementsPatch 3 | ActiveRecord::ConnectionAdapters::SchemaStatements.class_eval do 4 | def assume_plugin_migrated_upto_version(plugin_id, version) 5 | plugin = Redmine::Plugin.find(plugin_id) 6 | version = version.to_i 7 | 8 | migrated = Redmine::Plugin::Migrator.get_all_versions(plugin) 9 | versions = plugin.migrations 10 | inserting = (versions - migrated).select { |v| v <= version } 11 | if inserting.any? 12 | ActiveRecord::SchemaMigration.create_table 13 | execute insert_versions_sql(inserting.map! { |v| "#{v}-#{plugin_id}" }) 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/issue_recurring/settings_controller_patch.rb: -------------------------------------------------------------------------------- 1 | module IssueRecurring 2 | module SettingsControllerPatch 3 | SettingsController.class_eval do 4 | before_action :save_issue_recurring_settings, only: [:plugin], 5 | if: -> { params[:id] == 'issue_recurring' && request.post? } 6 | end 7 | 8 | def save_issue_recurring_settings 9 | settings = {} 10 | 11 | # * Author is saved as (unique) :login to allow for better error reporting once 12 | # author is missing 13 | # * Author is retrieved from form by User.id. Cannot retrieve by :login, as 14 | # Anonymous.login == '' and we need empty value for 'author unchanged' 15 | # * User with :id == 0 (= author unchanged) doesn't exist and is mapped to nil login 16 | settings[:author_login] = User.find_by(id: params[:settings][:author_id].to_i) 17 | .try(:login) 18 | 19 | settings[:keep_assignee] = params[:settings][:keep_assignee] == 'true' ? true : false 20 | 21 | journal_mode = params[:settings][:journal_mode].to_sym 22 | settings[:journal_mode] = IssueRecurrence::JOURNAL_MODES.include?(journal_mode) ? 23 | journal_mode : IssueRecurrence::JOURNAL_MODES.first 24 | 25 | settings[:copy_recurrences] = 26 | params[:settings][:copy_recurrences] == 'true' ? true : false 27 | 28 | settings[:ahead_multiplier] = params[:settings][:ahead_multiplier].to_i.abs 29 | ahead_mode = params[:settings][:ahead_mode].to_sym 30 | settings[:ahead_mode] = IssueRecurrence::AHEAD_MODES.include?(ahead_mode) ? 31 | ahead_mode : IssueRecurrence::AHEAD_MODES.first 32 | 33 | params[:settings] = settings 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/issue_recurring/settings_helper_patch.rb: -------------------------------------------------------------------------------- 1 | module IssueRecurring 2 | module SettingsHelperPatch 3 | def authors(default_login) 4 | default_id = User.find_by(login: default_login).try(:id) || 0 5 | options = options_for_select({t('.author_unchanged') => 0}, default_id) 6 | users = User.active + [User.anonymous] 7 | options << options_from_collection_for_select(users, :id, :name, default_id) 8 | end 9 | 10 | def journal_mode_options(default) 11 | modes = IssueRecurrence::JOURNAL_MODES 12 | options_for_select(modes.map { |jm| [t(".journal_modes.#{jm}"), jm] }, default) 13 | end 14 | 15 | def ahead_mode_options(default) 16 | modes = IssueRecurrence::AHEAD_MODES 17 | options_for_select( 18 | modes.map { |am| [t("issues.recurrences.form.delay_modes.#{am}"), am] }, default 19 | ) 20 | end 21 | end 22 | end 23 | 24 | -------------------------------------------------------------------------------- /lib/issue_recurring/system_test_case_patch.rb: -------------------------------------------------------------------------------- 1 | module IssueRecurring 2 | module SystemTestCasePatch 3 | # NOTE: this patch is required for Rails > 6 to avoid browser preloading, 4 | # which causes errors if plugin uses different browser for testing than 5 | # Redmine 6 | def driven_by(*args, **kwargs) 7 | kwargs[:using] = :headless_firefox 8 | super 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/tasks/issue_recurring.rake: -------------------------------------------------------------------------------- 1 | desc <<-END_DESC 2 | Create pending recurrences for issues. 3 | 4 | Example: 5 | RAILS_ENV=production rake redmine:issue_recurring:renew_all 6 | END_DESC 7 | 8 | require_relative '../../../../config/environment' 9 | 10 | namespace :redmine do 11 | namespace :issue_recurring do 12 | task :renew_all => :environment do 13 | Mailer.with_synched_deliveries { IssueRecurrence.renew_all } 14 | end 15 | end 16 | 17 | namespace :plugins do 18 | namespace :test do 19 | desc 'Runs the plugins migration tests.' 20 | task :migration => "db:test:prepare" do |t| 21 | $: << "test" 22 | Rails::TestUnit::Runner.rake_run ["plugins/#{ENV['NAME'] || '*'}/test/migration/**/*_test.rb"] 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/application_system_test_case.rb: -------------------------------------------------------------------------------- 1 | # Required for Puma to start in test env for system tests (RAILS_ENV=test does 2 | # not work). 3 | ENV["RACK_ENV"] = "test" 4 | 5 | # NOTE: remove following 2 requires when Rails < 6 no longer supported and 6 | # autoloading properly loads classes in 'prepend' below 7 | require 'action_dispatch' 8 | require_relative '../lib/issue_recurring/system_test_case_patch' 9 | # Avoid preloading Chrome, which is used by Redmine 10 | ActionDispatch::SystemTestCase.singleton_class.prepend IssueRecurring::SystemTestCasePatch 11 | 12 | # Load the Redmine helper 13 | require_relative '../../../test/application_system_test_case' 14 | require_relative 'test_case' 15 | 16 | class IssueRecurringSystemTestCase < ApplicationSystemTestCase 17 | driven_by :selenium, using: :headless_firefox, screen_size: [1280, 1024] do 18 | Selenium::WebDriver::Firefox::Options.new prefs: { 19 | 'browser.download.dir' => DOWNLOADS_PATH, 20 | 'browser.download.folderList' => 2, 21 | 'browser.helperApps.neverAsk.saveToDisk' => 'application/pdf', 22 | 'pdfjs.disabled' => true 23 | } 24 | end 25 | 26 | Capybara.configure do |config| 27 | config.save_path = './tmp/screenshots/' 28 | config.default_max_wait_time = 0 29 | end 30 | 31 | self.fixture_path = File.expand_path('../fixtures/', __FILE__) 32 | fixtures :issues, :issue_statuses, 33 | :users, :email_addresses, :trackers, :projects, 34 | :roles, :members, :member_roles, :enabled_modules, :workflow_transitions, 35 | :custom_fields, :enumerations 36 | 37 | include IssueRecurringTestCase 38 | include AbstractController::Translation 39 | include ActionView::Helpers::SanitizeHelper 40 | include IssueRecurring::IssuesHelperPatch 41 | 42 | def logout_user 43 | click_link t(:label_logout) 44 | assert_current_path home_path 45 | assert_link t(:label_login) 46 | end 47 | 48 | def within_issue_recurrences_panel 49 | panel_label = t('issues.issue_recurrences_hook.recurrences') 50 | within :xpath, "//div[p[contains(string(), '#{panel_label}')]]" do 51 | yield 52 | end 53 | end 54 | 55 | def fill_in_form(attributes) 56 | helper_attrs = {} 57 | attributes.keys.grep(/_limit$/) { |k| helper_attrs[:limit_mode] = k.to_sym } 58 | 59 | field = first(:field) 60 | begin 61 | id = field[:id].delete_prefix('recurrence_').to_sym 62 | key = helper_attrs[id] || attributes[id] 63 | unless key.nil? 64 | if field.tag_name == 'select' 65 | helper_method = "#{id}_options".to_sym 66 | value = send(helper_method).to_h.invert[key] 67 | field.select value 68 | else 69 | field.fill_in with: key 70 | end 71 | end 72 | field = field.find(:xpath, 'following-sibling::*[self::input or self::select]', 73 | match: :first) 74 | rescue Capybara::ElementNotFound 75 | break 76 | end while true 77 | end 78 | 79 | def fill_in_randomly 80 | # SELECT visibility may change due to selection of specific OPTIONS 81 | all('select', visible: :all).filter(&:visible?).each do |s| 82 | s.all('option').sample&.select_option 83 | end 84 | all('input[type=number]').each do |i| 85 | min = i[:min].to_i || 0 86 | i.fill_in with: rand([min..min, (min+1)..5, 6..1000].sample) 87 | end 88 | all('input[type=date]').each do |i| 89 | i.fill_in with: i[:min].present? ? i[:min].to_date + random_datespan : random_date 90 | end 91 | end 92 | 93 | # Create recurrence by filling out the form with: 94 | # * attributes, filled with missing required keys if necessary, 95 | # * block, 96 | # or randomly generated attributes when no attributes or block given. 97 | def create_recurrence(issue: issues(:issue_01), **attributes) 98 | t_base = 'issues.recurrences.form' 99 | recurrence = nil 100 | 101 | # TODO: replace `attributes` argument with `defaults` and always generate random 102 | if attributes.empty? 103 | attributes = random_recurrence(issue) unless block_given? 104 | else 105 | attributes[:anchor_mode] ||= :first_issue_fixed 106 | attributes[:mode] ||= :weekly 107 | attributes[:multiplier] ||= 1 108 | end 109 | 110 | visit issue_path(issue) 111 | within_issue_recurrences_panel do 112 | assert_difference ['all("tr").length', 'IssueRecurrence.count'], 1 do 113 | click_link t(:button_add) 114 | 115 | fill_in_form attributes 116 | yield if block_given? 117 | 118 | click_button t(:button_submit) 119 | end 120 | 121 | recurrence = IssueRecurrence.last 122 | # status_code not supported by Selenium 123 | assert_current_path issue_path(issue) 124 | assert_selector :xpath, 125 | "//tr[td[contains(string(), '#{strip_tags(recurrence.to_s)}')]]" 126 | assert_no_selector '#new-recurrence *', visible: :all 127 | attributes = attributes.map { |k,v| [k.to_s, v.is_a?(Symbol) ? v.to_s : v] }.to_h 128 | assert_equal attributes, recurrence.attributes.extract!(*attributes.keys) 129 | end 130 | assert_selector 'div#flash_notice', exact_text: t('issue_recurrences.create.success') 131 | 132 | recurrence 133 | end 134 | 135 | def update_recurrence(recurrence, **attributes) 136 | t_base = 'issues.recurrences.form' 137 | 138 | if attributes.empty? && !block_given? 139 | attributes = random_recurrence(recurrence.issue) 140 | end 141 | 142 | visit issue_path(recurrence.issue) 143 | within_issue_recurrences_panel do 144 | assert_no_difference ['all("tr").length', 'IssueRecurrence.count'] do 145 | within :xpath, "//tr[td[contains(string(), '#{strip_tags(recurrence.to_s)}')]]" do 146 | click_link t(:button_edit) 147 | end 148 | 149 | fill_in_form attributes 150 | yield if block_given? 151 | 152 | click_button t(:button_submit) 153 | end 154 | 155 | recurrence.reload 156 | # status_code not supported by Selenium 157 | assert_current_path issue_path(recurrence.issue) 158 | assert_selector :xpath, 159 | "//tr[td[contains(string(), '#{strip_tags(recurrence.to_s)}')]]" 160 | assert_no_selector '#new-recurrence *', visible: :all 161 | attributes = attributes.map { |k,v| [k.to_s, v.is_a?(Symbol) ? v.to_s : v] }.to_h 162 | assert_equal attributes, recurrence.attributes.extract!(*attributes.keys) 163 | end 164 | assert_selector 'div#flash_notice', exact_text: t('issue_recurrences.update.success') 165 | 166 | recurrence 167 | end 168 | 169 | def destroy_recurrence(recurrence) 170 | visit issue_path(recurrence.issue) 171 | 172 | within_issue_recurrences_panel do 173 | assert_difference ['all("tr").length', 'IssueRecurrence.count'], -1 do 174 | description = strip_tags(recurrence.to_s) 175 | within :xpath, "//tr[td[contains(string(), '#{description}')]]" do 176 | click_link t(:button_delete) 177 | end 178 | 179 | # status_code not supported by Selenium 180 | assert_current_path issue_path(recurrence.issue) 181 | assert_no_selector :xpath, "//tr[td[contains(string(), '#{description}')]]" 182 | assert_raises(ActiveRecord::RecordNotFound) { recurrence.reload } 183 | end 184 | end 185 | assert_selector 'div#flash_notice', exact_text: t('issue_recurrences.destroy.success') 186 | end 187 | 188 | def close_issue(issue) 189 | assert !issue.closed? 190 | closed_on = issue.closed_on 191 | status = IssueStatus.all.where(is_closed: true).first 192 | 193 | visit edit_issue_path(issue) 194 | within 'form#issue-form' do 195 | select status.name, from: t(:field_status) 196 | click_button t(:button_submit) 197 | end 198 | issue.reload 199 | 200 | assert_equal status.id, issue.status_id 201 | assert_not_nil issue.closed_on 202 | assert_not_equal closed_on, issue.closed_on 203 | assert issue.closed? 204 | end 205 | end 206 | -------------------------------------------------------------------------------- /test/fixtures/custom_fields.yml: -------------------------------------------------------------------------------- 1 | custom_field_01: 2 | type: IssueCustomField 3 | name: Extra field 4 | field_format: string 5 | regexp: "" 6 | is_required: false 7 | is_for_all: true 8 | is_filter: true 9 | position: 1 10 | default_value: "" 11 | editable: true 12 | -------------------------------------------------------------------------------- /test/fixtures/email_addresses.yml: -------------------------------------------------------------------------------- 1 | <% ['alice', 'bob', 'charlie', 'dave', 'gopher'].each do |name| %> 2 | <%= name %>_email_address: 3 | user: <%= name %> 4 | address: <%= name %>@example.net 5 | is_default: true 6 | <% end %> 7 | 8 | -------------------------------------------------------------------------------- /test/fixtures/enabled_modules.yml: -------------------------------------------------------------------------------- 1 | enabled_module_01: 2 | name: issue_tracking 3 | project: project_01 4 | 5 | enabled_module_02: 6 | name: issue_recurring 7 | project: project_01 8 | 9 | enabled_module_03: 10 | name: time_tracking 11 | project: project_01 12 | -------------------------------------------------------------------------------- /test/fixtures/enumerations.yml: -------------------------------------------------------------------------------- 1 | normal: 2 | name: Normal 3 | position: 1 4 | is_default: true 5 | type: IssuePriority 6 | active: true 7 | position_name: default 8 | 9 | super_low: 10 | name: Super low 11 | position: 2 12 | is_default: false 13 | type: IssuePriority 14 | active: true 15 | position_name: lowest 16 | 17 | time_entry_activity_01: 18 | name: Boring testing 19 | type: TimeEntryActivity 20 | position: 1 21 | active: true 22 | is_default: true 23 | -------------------------------------------------------------------------------- /test/fixtures/issue_priorities.yml: -------------------------------------------------------------------------------- 1 | normal: 2 | name: Normal 3 | position: 1 4 | is_default: true 5 | type: IssuePriority 6 | active: true 7 | position_name: default 8 | 9 | super_low: 10 | name: Super low 11 | position: 2 12 | is_default: false 13 | type: IssuePriority 14 | active: true 15 | position_name: lowest 16 | -------------------------------------------------------------------------------- /test/fixtures/issue_statuses.yml: -------------------------------------------------------------------------------- 1 | new: 2 | name: new 3 | is_closed: false 4 | 5 | assigned: 6 | name: assigned 7 | is_closed: false 8 | 9 | resolved: 10 | name: resolved 11 | is_closed: false 12 | 13 | pulled: 14 | name: pulled 15 | is_closed: false 16 | 17 | feedback: 18 | name: feedback 19 | is_closed: false 20 | 21 | closed: 22 | name: closed 23 | is_closed: true 24 | 25 | rejected: 26 | name: rejected 27 | is_closed: true 28 | 29 | -------------------------------------------------------------------------------- /test/fixtures/issues.yml: -------------------------------------------------------------------------------- 1 | DEFAULTS: &DEFAULTS 2 | project: project_01 3 | tracker: bug 4 | priority: normal 5 | lft: 1 6 | rgt: 2 7 | 8 | issue_01: 9 | subject: Issue 10 | assigned_to: alice 11 | author: bob 12 | status: new 13 | root_id: <%= ActiveRecord::FixtureSet.identify(:issue_01) %> 14 | <<: *DEFAULTS 15 | 16 | issue_02: 17 | subject: 2nd issue 18 | assigned_to: charlie 19 | author: dave 20 | status: new 21 | root_id: <%= ActiveRecord::FixtureSet.identify(:issue_02) %> 22 | <<: *DEFAULTS 23 | 24 | issue_03: 25 | subject: 3rd issue 26 | assigned_to: bob 27 | author: alice 28 | status: new 29 | root_id: <%= ActiveRecord::FixtureSet.identify(:issue_03) %> 30 | <<: *DEFAULTS 31 | 32 | -------------------------------------------------------------------------------- /test/fixtures/member_roles.yml: -------------------------------------------------------------------------------- 1 | member_role_01: 2 | role: developer 3 | member: member_01 4 | 5 | member_role_02: 6 | role: developer 7 | member: member_02 8 | 9 | member_role_03: 10 | role: developer 11 | member: member_03 12 | 13 | member_role_04: 14 | role: developer 15 | member: member_04 16 | 17 | member_role_05: 18 | role: developer 19 | member: member_05 20 | 21 | -------------------------------------------------------------------------------- /test/fixtures/members.yml: -------------------------------------------------------------------------------- 1 | member_01: 2 | project: project_01 3 | user: alice 4 | 5 | member_02: 6 | project: project_01 7 | user: bob 8 | 9 | member_03: 10 | project: project_01 11 | user: charlie 12 | 13 | member_04: 14 | project: project_01 15 | user: dave 16 | 17 | member_05: 18 | project: project_01 19 | user: gopher 20 | 21 | -------------------------------------------------------------------------------- /test/fixtures/projects.yml: -------------------------------------------------------------------------------- 1 | project_01: 2 | name: Issue recurrence 3 | description: Sample issue recurrence project 4 | homepage: http://ir.michalczyk.pro/ 5 | is_public: true 6 | identifier: issuerecurrence 7 | lft: 1 8 | rgt: 2 9 | trackers: bug 10 | default_assigned_to: gopher 11 | issue_custom_fields: custom_field_01 12 | -------------------------------------------------------------------------------- /test/fixtures/roles.yml: -------------------------------------------------------------------------------- 1 | manager: 2 | name: Manager 3 | builtin: 0 4 | issues_visibility: default 5 | users_visibility: all 6 | permissions: | 7 | - :add_project 8 | - :edit_project 9 | - :close_project 10 | - :select_project_modules 11 | - :manage_members 12 | - :manage_versions 13 | - :manage_categories 14 | - :view_issues 15 | - :add_issues 16 | - :edit_issues 17 | - :copy_issues 18 | - :manage_issue_relations 19 | - :manage_subtasks 20 | - :add_issue_notes 21 | - :delete_issues 22 | - :view_issue_watchers 23 | - :add_issue_watchers 24 | - :set_issues_private 25 | - :set_notes_private 26 | - :view_private_notes 27 | - :delete_issue_watchers 28 | - :manage_public_queries 29 | - :save_queries 30 | - :view_gantt 31 | - :view_calendar 32 | - :log_time 33 | - :view_time_entries 34 | - :edit_time_entries 35 | - :delete_time_entries 36 | - :view_news 37 | - :manage_news 38 | - :comment_news 39 | - :view_documents 40 | - :add_documents 41 | - :edit_documents 42 | - :delete_documents 43 | - :view_wiki_pages 44 | - :export_wiki_pages 45 | - :view_wiki_edits 46 | - :edit_wiki_pages 47 | - :delete_wiki_pages_attachments 48 | - :protect_wiki_pages 49 | - :delete_wiki_pages 50 | - :rename_wiki_pages 51 | - :view_messages 52 | - :add_messages 53 | - :edit_messages 54 | - :delete_messages 55 | - :manage_boards 56 | - :view_files 57 | - :manage_files 58 | - :browse_repository 59 | - :manage_repository 60 | - :view_changesets 61 | - :manage_related_issues 62 | - :manage_project_activities 63 | - :import_issues 64 | - :view_issue_recurrences 65 | - :manage_issue_recurrences 66 | position: 1 67 | 68 | developer: 69 | name: Developer 70 | builtin: 0 71 | issues_visibility: default 72 | users_visibility: all 73 | permissions: | 74 | - :edit_project 75 | - :manage_members 76 | - :manage_versions 77 | - :manage_categories 78 | - :view_issues 79 | - :add_issues 80 | - :edit_issues 81 | - :copy_issues 82 | - :manage_issue_relations 83 | - :manage_subtasks 84 | - :add_issue_notes 85 | - :delete_issues 86 | - :view_issue_watchers 87 | - :save_queries 88 | - :view_gantt 89 | - :view_calendar 90 | - :log_time 91 | - :view_time_entries 92 | - :edit_own_time_entries 93 | - :view_news 94 | - :manage_news 95 | - :comment_news 96 | - :view_documents 97 | - :add_documents 98 | - :edit_documents 99 | - :delete_documents 100 | - :view_wiki_pages 101 | - :view_wiki_edits 102 | - :edit_wiki_pages 103 | - :protect_wiki_pages 104 | - :delete_wiki_pages 105 | - :view_messages 106 | - :add_messages 107 | - :edit_own_messages 108 | - :delete_own_messages 109 | - :manage_boards 110 | - :view_files 111 | - :manage_files 112 | - :browse_repository 113 | - :view_changesets 114 | - :view_issue_recurrences 115 | - :manage_issue_recurrences 116 | position: 2 117 | 118 | reporter: 119 | name: Reporter 120 | builtin: 0 121 | issues_visibility: default 122 | users_visibility: all 123 | permissions: | 124 | - :edit_project 125 | - :manage_members 126 | - :manage_versions 127 | - :manage_categories 128 | - :view_issues 129 | - :add_issues 130 | - :edit_issues 131 | - :manage_issue_relations 132 | - :add_issue_notes 133 | - :view_issue_watchers 134 | - :save_queries 135 | - :view_gantt 136 | - :view_calendar 137 | - :log_time 138 | - :view_time_entries 139 | - :view_news 140 | - :manage_news 141 | - :comment_news 142 | - :view_documents 143 | - :add_documents 144 | - :edit_documents 145 | - :delete_documents 146 | - :view_wiki_pages 147 | - :view_wiki_edits 148 | - :edit_wiki_pages 149 | - :delete_wiki_pages 150 | - :view_messages 151 | - :add_messages 152 | - :manage_boards 153 | - :view_files 154 | - :manage_files 155 | - :browse_repository 156 | - :view_changesets 157 | position: 3 158 | 159 | nonmember: 160 | name: Non member 161 | builtin: 1 162 | issues_visibility: default 163 | users_visibility: all 164 | permissions: | 165 | - :view_issues 166 | - :add_issues 167 | - :edit_issues 168 | - :manage_issue_relations 169 | - :add_issue_notes 170 | - :save_queries 171 | - :view_gantt 172 | - :view_calendar 173 | - :log_time 174 | - :view_time_entries 175 | - :view_news 176 | - :comment_news 177 | - :view_documents 178 | - :view_wiki_pages 179 | - :view_wiki_edits 180 | - :edit_wiki_pages 181 | - :view_messages 182 | - :add_messages 183 | - :view_files 184 | - :manage_files 185 | - :browse_repository 186 | - :view_changesets 187 | position: 1 188 | 189 | anonymous: 190 | name: Anonymous 191 | builtin: 2 192 | issues_visibility: default 193 | users_visibility: all 194 | permissions: | 195 | - :view_issues 196 | - :add_issue_notes 197 | - :view_gantt 198 | - :view_calendar 199 | - :view_time_entries 200 | - :view_news 201 | - :view_documents 202 | - :view_wiki_pages 203 | - :view_wiki_edits 204 | - :view_messages 205 | - :view_files 206 | - :browse_repository 207 | - :view_changesets 208 | position: 1 209 | 210 | -------------------------------------------------------------------------------- /test/fixtures/trackers.yml: -------------------------------------------------------------------------------- 1 | bug: 2 | name: Bug 3 | # NOTE: remove field when Redmine < 5 no longer supported 4 | <%= 'is_in_chlog: true' if Tracker.column_names.include?('is_in_chlog') %> 5 | default_status: new 6 | position: 1 7 | custom_fields: custom_field_01 8 | -------------------------------------------------------------------------------- /test/fixtures/users.yml: -------------------------------------------------------------------------------- 1 | DEFAULTS: &DEFAULTS 2 | login: $LABEL 3 | # password = foo 4 | salt: 7599f9963ec07b5a3b55b354407120c0 5 | hashed_password: 8f659c8d7c072f189374edacfa90d6abbc26d8ed 6 | firstname: $LABEL 7 | lastname: $LABEL 8 | admin: false 9 | status: true 10 | type: User 11 | mail_notification: '' 12 | must_change_passwd: false 13 | 14 | alice: 15 | <<: *DEFAULTS 16 | 17 | bob: 18 | <<: *DEFAULTS 19 | 20 | charlie: 21 | <<: *DEFAULTS 22 | 23 | dave: 24 | <<: *DEFAULTS 25 | 26 | gopher: 27 | <<: *DEFAULTS 28 | 29 | admin: 30 | <<: *DEFAULTS 31 | admin: true 32 | -------------------------------------------------------------------------------- /test/fixtures/workflow_transitions.yml: -------------------------------------------------------------------------------- 1 | DEFAULTS: &DEFAULTS 2 | role: developer 3 | tracker: bug 4 | type: WorkflowTransition 5 | 6 | WorkflowTransitions_01: 7 | old_status: new 8 | new_status: assigned 9 | <<: *DEFAULTS 10 | 11 | WorkflowTransitions_02: 12 | old_status: new 13 | new_status: resolved 14 | <<: *DEFAULTS 15 | 16 | WorkflowTransitions_03: 17 | old_status: new 18 | new_status: pulled 19 | <<: *DEFAULTS 20 | 21 | WorkflowTransitions_04: 22 | old_status: new 23 | new_status: feedback 24 | <<: *DEFAULTS 25 | 26 | WorkflowTransitions_05: 27 | old_status: new 28 | new_status: closed 29 | <<: *DEFAULTS 30 | 31 | WorkflowTransitions_06: 32 | old_status: new 33 | new_status: rejected 34 | <<: *DEFAULTS 35 | 36 | 37 | WorkflowTransitions_11: 38 | old_status: assigned 39 | new_status: new 40 | <<: *DEFAULTS 41 | 42 | WorkflowTransitions_12: 43 | old_status: assigned 44 | new_status: resolved 45 | <<: *DEFAULTS 46 | 47 | WorkflowTransitions_13: 48 | old_status: assigned 49 | new_status: pulled 50 | <<: *DEFAULTS 51 | 52 | WorkflowTransitions_14: 53 | old_status: assigned 54 | new_status: feedback 55 | <<: *DEFAULTS 56 | 57 | WorkflowTransitions_15: 58 | old_status: assigned 59 | new_status: closed 60 | <<: *DEFAULTS 61 | 62 | WorkflowTransitions_16: 63 | old_status: assigned 64 | new_status: rejected 65 | <<: *DEFAULTS 66 | 67 | 68 | WorkflowTransitions_21: 69 | old_status: resolved 70 | new_status: new 71 | <<: *DEFAULTS 72 | 73 | WorkflowTransitions_22: 74 | old_status: resolved 75 | new_status: assigned 76 | <<: *DEFAULTS 77 | 78 | WorkflowTransitions_23: 79 | old_status: resolved 80 | new_status: pulled 81 | <<: *DEFAULTS 82 | 83 | WorkflowTransitions_24: 84 | old_status: resolved 85 | new_status: feedback 86 | <<: *DEFAULTS 87 | 88 | WorkflowTransitions_25: 89 | old_status: resolved 90 | new_status: closed 91 | <<: *DEFAULTS 92 | 93 | WorkflowTransitions_26: 94 | old_status: resolved 95 | new_status: rejected 96 | <<: *DEFAULTS 97 | 98 | 99 | WorkflowTransitions_31: 100 | old_status: pulled 101 | new_status: new 102 | <<: *DEFAULTS 103 | 104 | WorkflowTransitions_32: 105 | old_status: pulled 106 | new_status: assigned 107 | <<: *DEFAULTS 108 | 109 | WorkflowTransitions_33: 110 | old_status: pulled 111 | new_status: resolved 112 | <<: *DEFAULTS 113 | 114 | WorkflowTransitions_34: 115 | old_status: pulled 116 | new_status: feedback 117 | <<: *DEFAULTS 118 | 119 | WorkflowTransitions_35: 120 | old_status: pulled 121 | new_status: closed 122 | <<: *DEFAULTS 123 | 124 | WorkflowTransitions_36: 125 | old_status: pulled 126 | new_status: rejected 127 | <<: *DEFAULTS 128 | 129 | 130 | WorkflowTransitions_41: 131 | old_status: feedback 132 | new_status: new 133 | <<: *DEFAULTS 134 | 135 | WorkflowTransitions_42: 136 | old_status: feedback 137 | new_status: assigned 138 | <<: *DEFAULTS 139 | 140 | WorkflowTransitions_43: 141 | old_status: feedback 142 | new_status: resolved 143 | <<: *DEFAULTS 144 | 145 | WorkflowTransitions_44: 146 | old_status: feedback 147 | new_status: pulled 148 | <<: *DEFAULTS 149 | 150 | WorkflowTransitions_45: 151 | old_status: feedback 152 | new_status: closed 153 | <<: *DEFAULTS 154 | 155 | WorkflowTransitions_46: 156 | old_status: feedback 157 | new_status: rejected 158 | <<: *DEFAULTS 159 | 160 | 161 | WorkflowTransitions_51: 162 | old_status: closed 163 | new_status: new 164 | <<: *DEFAULTS 165 | 166 | WorkflowTransitions_52: 167 | old_status: closed 168 | new_status: assigned 169 | <<: *DEFAULTS 170 | 171 | WorkflowTransitions_53: 172 | old_status: closed 173 | new_status: resolved 174 | <<: *DEFAULTS 175 | 176 | WorkflowTransitions_54: 177 | old_status: closed 178 | new_status: pulled 179 | <<: *DEFAULTS 180 | 181 | WorkflowTransitions_55: 182 | old_status: closed 183 | new_status: feedback 184 | <<: *DEFAULTS 185 | 186 | WorkflowTransitions_56: 187 | old_status: closed 188 | new_status: rejected 189 | <<: *DEFAULTS 190 | 191 | 192 | WorkflowTransitions_61: 193 | old_status: rejected 194 | new_status: new 195 | <<: *DEFAULTS 196 | 197 | WorkflowTransitions_62: 198 | old_status: rejected 199 | new_status: assigned 200 | <<: *DEFAULTS 201 | 202 | WorkflowTransitions_63: 203 | old_status: rejected 204 | new_status: resolved 205 | <<: *DEFAULTS 206 | 207 | WorkflowTransitions_64: 208 | old_status: rejected 209 | new_status: pulled 210 | <<: *DEFAULTS 211 | 212 | WorkflowTransitions_65: 213 | old_status: rejected 214 | new_status: feedback 215 | <<: *DEFAULTS 216 | 217 | WorkflowTransitions_66: 218 | old_status: rejected 219 | new_status: closed 220 | <<: *DEFAULTS 221 | 222 | -------------------------------------------------------------------------------- /test/migration/migration_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class MigrationsTest < IssueRecurringIntegrationTestCase 4 | self.use_transactional_tests = false 5 | 6 | # NOTE: remove when https://www.redmine.org/issues/31116 is fixed 7 | Redmine::Plugin::MigrationContext.class_eval do 8 | def current_version 9 | Redmine::Plugin::Migrator.current_version 10 | end 11 | end 12 | 13 | def migrate(version) 14 | # Force refresh of schema_migration state 15 | Redmine::Plugin::Migrator.instance_variable_get(:@all_versions) 16 | &.delete('issue_recurring') 17 | 18 | ActiveRecord::Migration.suppress_messages do 19 | Redmine::Plugin.migrate('issue_recurring', version) 20 | end 21 | end 22 | 23 | def setup 24 | super 25 | 26 | @plugin = Redmine::Plugin.find('issue_recurring') 27 | @issue1 = issues(:issue_01) 28 | @issue2 = issues(:issue_02) 29 | end 30 | 31 | class IssueRecurrence < ActiveRecord::Base 32 | end 33 | 34 | def test_migrate_with_no_recurrences 35 | assert 0, IssueRecurrence.count 36 | migrate 0 37 | migrate @plugin.latest_migration 38 | end 39 | 40 | def test_migrate_with_recurrences_present 41 | migrate 1 42 | # Fixed schedule, every 1 week 43 | ir = IssueRecurrence.new(issue_id: @issue1.id, anchor_mode: 0, mode: 100, multiplier: 1) 44 | assert_difference 'IssueRecurrence.count', 1 do 45 | ir.save! 46 | end 47 | assert_no_difference 'IssueRecurrence.count' do 48 | migrate @plugin.latest_migration 49 | end 50 | end 51 | 52 | def test_migration_003 53 | migrate 2 54 | 55 | # Fixed schedule, monthly, day to last day of month based on start date 56 | ir1 = IssueRecurrence 57 | .new(issue_id: @issue1.id, anchor_mode: 0, mode: 210, multiplier: 1) 58 | 59 | # Flexible schedule, weekly, only due date present 60 | @issue2.update!(due_date: Date.current) 61 | assert_nil @issue2.start_date 62 | ir2 = IssueRecurrence 63 | .new(issue_id: @issue2.id, anchor_mode: 2, mode: 100, multiplier: 1) 64 | 65 | assert_difference 'IssueRecurrence.count', 2 do 66 | [ir1, ir2].map(&:save!) 67 | end 68 | 69 | assert_no_difference 'IssueRecurrence.count' do 70 | migrate 3 71 | end 72 | [ir1, ir2].map(&:reload) 73 | 74 | assert_equal 212, ir1.mode 75 | assert_includes [1, true], ir1.anchor_to_start 76 | assert_equal 100, ir2.mode 77 | assert_includes [0, false], ir2.anchor_to_start 78 | 79 | assert_no_difference 'IssueRecurrence.count' do 80 | migrate 2 81 | end 82 | [ir1, ir2].map(&:reload) 83 | 84 | assert_equal 210, ir1.mode 85 | assert_not ir1.has_attribute?(:anchor_to_start) 86 | assert_equal 100, ir2.mode 87 | assert_not ir2.has_attribute?(:anchor_to_start) 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /test/system/issue_recurrences_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../application_system_test_case' 2 | 3 | class IssueRecurrencesSystemTest < IssueRecurringSystemTestCase 4 | def setup 5 | super 6 | 7 | Setting.non_working_week_days = [6, 7] 8 | Setting.parent_issue_dates = 'derived' 9 | Setting.parent_issue_priority = 'derived' 10 | Setting.parent_issue_done_ratio = 'derived' 11 | Setting.issue_done_ratio == 'issue_field' 12 | 13 | # FIXME: settings should be set through controller by admin user 14 | # (log_user/logout_user) 15 | Setting.plugin_issue_recurring = { 16 | author_id: 0, 17 | keep_assignee: false, 18 | journal_mode: :never, 19 | copy_recurrences: true, 20 | ahead_multiplier: 0, 21 | ahead_mode: :days 22 | } 23 | 24 | @project1 = projects(:project_01) 25 | @issue1 = issues(:issue_01) 26 | @issue2 = issues(:issue_02) 27 | @issue3 = issues(:issue_03) 28 | 29 | log_user 'alice', 'foo' 30 | end 31 | 32 | def teardown 33 | logout_user 34 | super 35 | end 36 | 37 | def test_create_recurrence_from_randomized_params 38 | @issue1.update!(random_dates) 39 | 40 | # Verify that every valid random recurrence can be entered into form 41 | create_recurrence 42 | end 43 | 44 | def test_create_recurrence_from_randomized_form 45 | @issue1.update!(random_dates) 46 | 47 | # Verify that every recurrence that can be entered into form is valid. 48 | # Implicitly tests form fields hiding depending on recurrence setting selection. 49 | create_recurrence { fill_in_randomly } 50 | end 51 | 52 | def test_update_recurrence 53 | @issue1.update!(random_dates) 54 | r = create_recurrence 55 | 56 | # Verify that every valid random update can be entered into form 57 | update_recurrence r 58 | 59 | # Verify that every update that can be entered into form is valid 60 | update_recurrence(r) { fill_in_randomly } 61 | 62 | # Verify that update with no change yields the same recurrence 63 | assert_no_changes 'r.reload.attributes' do 64 | update_recurrence(r) { } 65 | end 66 | end 67 | 68 | def test_destroy_recurrence 69 | @issue1.update!(random_dates) 70 | destroy_recurrence(create_recurrence) 71 | end 72 | 73 | def test_show_issue_recurrences 74 | # TODO: randomize # of recurrences 0..N 75 | visit issue_path(@issue1) 76 | within_issue_recurrences_panel do 77 | assert_equal @issue1.recurrences.count, all("tr").length 78 | end 79 | end 80 | 81 | def test_show_issue_shows_recurrence_form_only_when_manage_permission_granted 82 | logout_user 83 | log_user 'bob', 'foo' 84 | 85 | roles = users(:bob).members.find_by(project: @issue1.project_id).roles 86 | assert roles.any? { |role| role.has_permission? :manage_issue_recurrences } 87 | visit issue_path(@issue1) 88 | assert_current_path issue_path(@issue1) 89 | within_issue_recurrences_panel do 90 | assert_selector 'a', text: t(:button_add) 91 | assert_no_selector 'form#recurrence-form' 92 | click_link t(:button_add) 93 | assert_selector 'form#recurrence-form' 94 | end 95 | 96 | roles.each { |role| role.remove_permission! :manage_issue_recurrences } 97 | refute roles.any? { |role| role.has_permission? :manage_issue_recurrences } 98 | visit issue_path(@issue1) 99 | assert_current_path issue_path(@issue1) 100 | within_issue_recurrences_panel do 101 | assert_no_selector 'a', text: t(:button_add) 102 | assert_no_selector 'form#recurrence-form' 103 | end 104 | end 105 | 106 | def test_settings_author_login 107 | @issue1.update!(start_date: 10.days.ago, due_date: 5.days.ago) 108 | create_recurrence(creation_mode: :copy_first) 109 | logout_user 110 | 111 | log_user 'admin', 'foo' 112 | visit plugin_settings_path(id: 'issue_recurring') 113 | t_base = 'settings.issue_recurrences' 114 | author_select = t("#{t_base}.author") 115 | 116 | select t("#{t_base}.author_unchanged"), from: author_select 117 | click_button t(:button_apply) 118 | assert_selector '#flash_notice', exact_text: t(:notice_successful_update) 119 | assert_nil Setting.plugin_issue_recurring[:author_login] 120 | assert has_select?(author_select, selected: t("#{t_base}.author_unchanged")) 121 | 122 | travel_to(@issue1.start_date) 123 | r1 = renew_all(1) 124 | assert_equal users(:bob), @issue1.author 125 | assert_equal users(:bob), r1.author 126 | 127 | select users(:charlie).name, from: author_select 128 | click_button t(:button_apply) 129 | assert_selector '#flash_notice', exact_text: t(:notice_successful_update) 130 | assert_equal users(:charlie).login, Setting.plugin_issue_recurring[:author_login] 131 | assert has_select?(author_select, selected: users(:charlie).name) 132 | 133 | travel_to(r1.start_date) 134 | r2 = renew_all(1) 135 | assert_equal users(:bob), @issue1.author 136 | assert_equal users(:charlie), r2.author 137 | end 138 | 139 | def test_settings_keep_assignee 140 | assert_not_equal @issue1.assigned_to, @issue1.project.default_assigned_to 141 | @issue1.update!(start_date: 10.days.ago, due_date: 5.days.ago) 142 | create_recurrence(creation_mode: :copy_first) 143 | logout_user 144 | 145 | log_user 'admin', 'foo' 146 | visit plugin_settings_path(id: 'issue_recurring') 147 | keep_assignee_checkbox = t('settings.issue_recurrences.keep_assignee') 148 | 149 | uncheck keep_assignee_checkbox 150 | click_button t(:button_apply) 151 | assert_selector '#flash_notice', exact_text: t(:notice_successful_update) 152 | assert_equal false, Setting.plugin_issue_recurring[:keep_assignee] 153 | assert has_unchecked_field?(keep_assignee_checkbox) 154 | 155 | travel_to(@issue1.start_date) 156 | r1 = renew_all(1) 157 | assert_equal users(:gopher), @issue1.project.default_assigned_to 158 | assert_equal users(:gopher), r1.assigned_to 159 | 160 | check keep_assignee_checkbox 161 | click_button t(:button_apply) 162 | assert_selector '#flash_notice', exact_text: t(:notice_successful_update) 163 | assert_equal true, Setting.plugin_issue_recurring[:keep_assignee] 164 | assert has_checked_field?(keep_assignee_checkbox) 165 | 166 | travel_to(r1.start_date) 167 | r2 = renew_all(1) 168 | assert_equal users(:alice), @issue1.assigned_to 169 | assert_equal users(:alice), r2.assigned_to 170 | end 171 | 172 | def test_settings_journal_mode 173 | @issue1.update!(start_date: 10.days.ago, due_date: 5.days.ago) 174 | @issue2.update!(start_date: 10.days.ago, due_date: 5.days.ago) 175 | 176 | ir1 = create_recurrence(issue: @issue1, 177 | creation_mode: :copy_first, anchor_to_start: true, 178 | anchor_mode: :last_issue_fixed) 179 | ir2 = create_recurrence(issue: @issue2, 180 | creation_mode: :reopen, anchor_to_start: true, 181 | anchor_mode: :last_issue_flexible) 182 | logout_user 183 | log_user 'admin', 'foo' 184 | 185 | configs = [ 186 | {mode: :never, journalized: []}, 187 | {mode: :always, journalized: [@issue1, @issue2]}, 188 | {mode: :on_reopen, journalized: [@issue2]} 189 | ] 190 | 191 | t_base = 'settings.issue_recurrences' 192 | journal_mode_select = t("#{t_base}.journal_mode") 193 | 194 | configs.each do |config| 195 | visit plugin_settings_path(id: 'issue_recurring') 196 | 197 | value = t("#{t_base}.journal_modes.#{config[:mode]}") 198 | select value, from: journal_mode_select 199 | click_button t(:button_apply) 200 | assert_selector '#flash_notice', exact_text: t(:notice_successful_update) 201 | assert_equal config[:mode], Setting.plugin_issue_recurring[:journal_mode] 202 | assert has_select?(journal_mode_select, selected: value) 203 | 204 | assert_equal (ir1.last_issue || @issue1).start_date, @issue2.start_date 205 | travel_to(@issue2.start_date) 206 | close_issue(@issue2) 207 | count = config[:journalized].length 208 | r2 = assert_difference 'Journal.count', count do renew_all(1) end 209 | [ir1, @issue1, @issue2].map(&:reload) 210 | assert !@issue2.closed? 211 | if count > 0 212 | assert_equal config[:journalized], Journal.last(count).map(&:journalized) 213 | assert_equal config[:journalized].map(&:author), Journal.last(count).map(&:user) 214 | end 215 | end 216 | end 217 | 218 | def test_settings_copy_recurrences 219 | # TODO: migrate integration test_renew_applies_copy_recurrences_configuration_setting 220 | end 221 | 222 | def test_settings_renew_ahead 223 | @issue1.update!(start_date: Date.new(2020,7,12), due_date: Date.new(2020,7,17)) 224 | 225 | logout_user 226 | log_user 'admin', 'foo' 227 | 228 | t_mode_base = 'issues.recurrences.form.delay_modes' 229 | label_text = t('settings.issue_recurrences.renew_ahead') 230 | 231 | set_renew_ahead = -> (multiplier, mode) { 232 | input_value = multiplier.to_s 233 | select_value = t("#{t_mode_base}.#{mode}") 234 | 235 | visit plugin_settings_path(id: 'issue_recurring') 236 | within(find('label', exact_text: label_text).ancestor('p')) do 237 | fill_in with: input_value 238 | select select_value 239 | end 240 | 241 | click_button t(:button_apply) 242 | assert_selector '#flash_notice', exact_text: t(:notice_successful_update) 243 | assert_equal multiplier, Setting.plugin_issue_recurring[:ahead_multiplier] 244 | assert_equal mode, Setting.plugin_issue_recurring[:ahead_mode] 245 | 246 | within(find('label', exact_text: label_text).ancestor('p')) do 247 | assert has_field?(type: 'number', with: input_value) 248 | assert has_select?(selected: select_value) 249 | end 250 | } 251 | 252 | [:first_issue_fixed, :last_issue_fixed].each do |am| 253 | ir = create_recurrence(creation_mode: :copy_first, mode: :weekly, anchor_mode: am) 254 | 255 | set_renew_ahead.(6, :days) 256 | travel_to(@issue1.start_date - 1.week) 257 | renew_all(0) 258 | 259 | set_renew_ahead.(1, :weeks) 260 | r1 = renew_all(1) 261 | assert_equal Date.new(2020,7,19), r1.start_date 262 | assert_equal Date.new(2020,7,24), r1.due_date 263 | 264 | set_renew_ahead.(1, :months) 265 | rest = renew_all(3) 266 | assert_equal [Date.new(2020,7,26), Date.new(2020,8,2), Date.new(2020,8,9)], 267 | rest.map(&:start_date) 268 | assert_equal [Date.new(2020,7,31), Date.new(2020,8,7), Date.new(2020,8,14)], 269 | rest.map(&:due_date) 270 | 271 | set_renew_ahead.(0, :months) 272 | travel_to(rest.last.start_date - 1.day) 273 | renew_all(0) 274 | 275 | destroy_recurrence(ir) 276 | end 277 | end 278 | end 279 | -------------------------------------------------------------------------------- /test/test_case.rb: -------------------------------------------------------------------------------- 1 | module IssueRecurringTestCase 2 | if ENV['PROFILE'] 3 | RubyProf.start 4 | 5 | Minitest.after_run do 6 | File.open('tmp/screenshots/profile.out', 'w') do |file| 7 | result = RubyProf.stop 8 | #printer = RubyProf::GraphHtmlPrinter.new(result) 9 | printer = RubyProf::FlatPrinter.new(result) 10 | #printer.print(STDOUT, min_percent: 0.1) 11 | printer.print(file) 12 | end 13 | end 14 | end 15 | 16 | # TODO: make changes to Issue, so it will renew on next renew_all 17 | # def make_renewable(issue) 18 | # end 19 | 20 | # TODO: treat count as all created + reopened issues to simplify testing 21 | # also: return all reopened and created issues 22 | def renew_all(count=0) 23 | assert_difference 'Issue.count', count do 24 | IssueRecurrence.renew_all(true) 25 | end 26 | count == 1 ? Issue.last : Issue.last(count) 27 | end 28 | 29 | def random_datespan 30 | rand([0..7, 8..31, 32..3650].sample) 31 | end 32 | 33 | def random_date 34 | Date.current + random_datespan * [-1, 1].sample 35 | end 36 | 37 | def random_future_date 38 | Date.current + random_datespan + 1.day 39 | end 40 | 41 | def random_dates 42 | base_date = random_date 43 | [ 44 | {start_date: nil, due_date: nil}, 45 | {start_date: base_date, due_date: nil}, 46 | {start_date: nil, due_date: base_date}, 47 | {start_date: base_date, due_date: base_date + random_datespan} 48 | ].sample 49 | end 50 | 51 | # Create _valid_ random recurrence for `issue`, optionally setting parameters 52 | # from `defaults` in following way: 53 | # * if default parameter is set to non-nil, for mandatory attributes use it 54 | # directly, without randomization of that parameter; for optional attributes 55 | # use it directly only if attribute is set 56 | # * if default parameter is set to nil, don't set it at all (applies only to 57 | # optional attributes) 58 | # `defaults` are not validated. 59 | # 60 | # TODO: return auxiliary information regarding what conditions can be changed and 61 | # in what way to obtain invalid recurrence - then test if UI disallows such 62 | # settings 63 | def random_recurrence(issue, **defaults) 64 | optional = defaults.extract!(:anchor_date, :delay_multiplier, :delay_mode, 65 | :date_limit, :count_limit) 66 | optional.default = false 67 | conditions = { 68 | start_date: issue.start_date, 69 | due_date: issue.due_date, 70 | dates_derived: issue.dates_derived? 71 | }.merge(defaults) 72 | 73 | conditions[:creation_mode] ||= IssueRecurrence.creation_modes.keys.sample.to_sym 74 | 75 | conditions[:include_subtasks] = 76 | case conditions 77 | in creation_mode: :reopen, dates_derived: true 78 | true 79 | else 80 | [true, false].sample 81 | end unless conditions.has_key?(:include_subtasks) 82 | 83 | conditions[:multiplier] ||= rand([1..4, 5..100, 101..1000].sample) 84 | conditions[:mode] ||= IssueRecurrence.modes.keys.sample.to_sym 85 | 86 | conditions[:anchor_to_start] = 87 | case conditions 88 | in start_date: ::Date, due_date: nil 89 | true 90 | in start_date: nil, due_date: ::Date 91 | false 92 | else 93 | [true, false].sample 94 | end unless conditions.has_key?(:anchor_to_start) 95 | 96 | anchor_modes = 97 | case conditions 98 | in start_date: nil, due_date: nil 99 | [:last_issue_flexible, :last_issue_flexible_on_delay, :date_fixed_after_close] 100 | in creation_mode: :copy_first | :copy_last 101 | IssueRecurrence.anchor_modes.keys 102 | in creation_mode: :reopen 103 | [:last_issue_flexible, :last_issue_flexible_on_delay, 104 | :last_issue_fixed_after_close, :date_fixed_after_close] 105 | end 106 | anchor_modes.delete(:date_fixed_after_close) if optional[:anchor_date].nil? 107 | conditions[:anchor_mode] ||= anchor_modes.sample.to_sym 108 | 109 | if conditions[:anchor_mode] == :date_fixed_after_close 110 | conditions[:anchor_date] = optional.fetch(:anchor_date, random_date) 111 | end 112 | 113 | if [:last_issue_flexible, :last_issue_flexible_on_delay].exclude? conditions[:anchor_mode] 114 | conditions[:delay_multiplier] = optional.fetch(:delay_multiplier, 115 | rand([0..0, 1..366].sample)) 116 | conditions[:delay_mode] = optional.fetch(:delay_mode, 117 | IssueRecurrence.delay_modes.keys.sample.to_sym) 118 | end 119 | 120 | case rand(1..4) 121 | when 1 122 | conditions[:date_limit] = optional.fetch(:date_limit, 123 | (conditions[:anchor_date] || Date.current) + rand([1..31, 32..3650].sample).days) 124 | when 2 125 | conditions[:count_limit] = optional.fetch(:count_limit, rand([0..12, 13..1000].sample)) 126 | else 127 | # 50% times do not set the limit 128 | end 129 | 130 | # Remove non-attributes and attributes with `nil` defaults 131 | conditions.except(:start_date, :due_date, :dates_derived).compact 132 | end 133 | 134 | class Date < ::Date 135 | def self.today 136 | # Due to its nature, Date.today may sometimes be equal to Date.yesterday/tomorrow. 137 | # https://rails.lighthouseapp.com/projects/8994-ruby-on-rails/tickets 138 | # /6410-dateyesterday-datetoday 139 | # For this reason WE SHOULD NOT USE Date.today anywhere in the code and use 140 | # Date.current instead. 141 | raise "Date.today should not be called!" 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Load the Redmine helper 2 | require_relative '../../../test/test_helper' 3 | require_relative 'test_case' 4 | 5 | class IssueRecurringIntegrationTestCase < Redmine::IntegrationTest 6 | self.fixture_path = File.expand_path('../fixtures/', __FILE__) 7 | fixtures :issues, :issue_statuses, 8 | :users, :email_addresses, :trackers, :projects, 9 | :roles, :members, :member_roles, :enabled_modules, :workflow_transitions, 10 | :custom_fields, :enumerations 11 | 12 | include IssueRecurringTestCase 13 | 14 | def logout_user 15 | post signout_path 16 | assert_nil session[:user_id] 17 | end 18 | 19 | # TODO: replace uses of create_recurrence with create_random_recurrence 20 | def create_recurrence(issue = issues(:issue_01), **attributes) 21 | attributes[:anchor_mode] ||= :first_issue_fixed 22 | attributes[:mode] ||= :weekly 23 | attributes[:multiplier] ||= 1 24 | assert_difference 'IssueRecurrence.count', 1 do 25 | post "#{issue_recurrences_path(issue)}.js", params: {recurrence: attributes} 26 | assert_response :ok 27 | assert_empty assigns(:recurrence).errors 28 | end 29 | assigns(:recurrence) 30 | end 31 | 32 | def create_random_recurrence(issue = issues(:issue_01), **defaults) 33 | attributes = random_recurrence(issue, **defaults) 34 | assert_difference 'IssueRecurrence.count', 1 do 35 | post "#{issue_recurrences_path(issue)}.js", params: {recurrence: attributes} 36 | assert_response :ok 37 | assert_empty assigns(:recurrence).errors 38 | end 39 | assigns(:recurrence) 40 | end 41 | 42 | def create_recurrence_should_fail(issue = issues(:issue_01), **attributes) 43 | attributes[:anchor_mode] ||= :first_issue_fixed 44 | attributes[:mode] ||= :weekly 45 | attributes[:multiplier] ||= 1 46 | error_code = attributes.delete(:error_code) || :ok 47 | assert_no_difference 'IssueRecurrence.count' do 48 | post "#{issue_recurrences_path(issue)}.js", params: {recurrence: attributes} 49 | assert_response error_code 50 | end 51 | if error_code == :ok 52 | assert_not_empty assigns(:recurrence).errors 53 | assigns(:recurrence).errors 54 | end 55 | end 56 | 57 | def update_recurrence(recurrence, **attributes) 58 | attributes.stringify_keys! 59 | assert_changes 'recurrence.attributes.extract!(*attributes.keys)', to: attributes do 60 | patch "#{issue_recurrence_path(recurrence)}.js", params: {recurrence: attributes} 61 | assert_response :ok 62 | assert_empty assigns(:recurrence).errors 63 | recurrence.reload 64 | end 65 | end 66 | 67 | def destroy_recurrence(recurrence) 68 | assert_difference 'IssueRecurrence.count', -1 do 69 | delete "#{issue_recurrence_path(recurrence)}.js" 70 | assert_response :ok 71 | assert_empty assigns(:recurrence).errors 72 | end 73 | end 74 | 75 | def destroy_recurrence_should_fail(recurrence, **attributes) 76 | error_code = attributes.delete(:error_code) || :ok 77 | assert_no_difference 'IssueRecurrence.count' do 78 | delete "#{issue_recurrence_path(recurrence)}.js" 79 | assert_response error_code 80 | end 81 | if error_code == :ok 82 | assert_not_empty assigns(:recurrence).errors 83 | assigns(:recurrence).errors 84 | end 85 | end 86 | 87 | def set_parent_issue(parent, child) 88 | parent_id = parent && parent.id 89 | assert_not_equal [parent_id], [child.parent_issue_id] 90 | put "/issues/#{child.id}", params: {issue: {parent_issue_id: parent_id}} 91 | parent.reload if parent 92 | child.reload 93 | assert_equal [parent_id], [child.parent_issue_id] 94 | end 95 | 96 | def set_priority(issue, priority) 97 | assert_not_equal priority.id, issue.priority_id 98 | put "/issues/#{issue.id}", params: {issue: {priority_id: priority.id}} 99 | issue.reload 100 | assert_equal priority.id, issue.priority_id 101 | end 102 | 103 | def set_done_ratio(issue, ratio) 104 | put "/issues/#{issue.id}", params: {issue: {done_ratio: ratio}} 105 | issue.reload 106 | assert_equal ratio, issue.done_ratio 107 | end 108 | 109 | def set_custom_field(issue, field, value) 110 | assert_nil issue.custom_field_value(field) 111 | put "/issues/#{issue.id}", params: {issue: {custom_field_values: {field.id => value}}} 112 | issue.reload 113 | assert_equal value, issue.custom_field_value(field) 114 | end 115 | 116 | def set_time_entry(issue, hours) 117 | old_hours = issue.spent_hours 118 | assert_difference 'issue.reload.spent_hours', hours do 119 | post "/issues/#{issue.id}/time_entries", params: { 120 | :time_entry => { 121 | :hours => hours, :activity_id => enumerations(:time_entry_activity_01).id 122 | } 123 | } 124 | end 125 | end 126 | 127 | def reopen_issue(issue) 128 | assert issue.closed? 129 | closed_on = issue.closed_on 130 | status = issue.tracker.default_status 131 | put "/issues/#{issue.id}", params: {issue: {status_id: status.id}} 132 | issue.reload 133 | assert_equal status.id, issue.status_id 134 | assert_equal closed_on, issue.closed_on 135 | assert !issue.closed? 136 | end 137 | 138 | def close_issue(issue) 139 | assert !issue.closed? 140 | closed_on = issue.closed_on 141 | status = IssueStatus.all.where(is_closed: true).first 142 | put "/issues/#{issue.id}", params: {issue: {status_id: status.id}} 143 | issue.reload 144 | assert_equal status.id, issue.status_id 145 | assert_not_nil issue.closed_on 146 | assert issue.closed? 147 | end 148 | 149 | def destroy_issue(issue) 150 | project = issue.project 151 | assert_not issue.reload.destroyed? 152 | assert_difference 'Issue.count', -1 do 153 | delete issue_path(issue) 154 | assert_redirected_to project_issues_path(project) 155 | end 156 | assert_raises(ActiveRecord::RecordNotFound) { issue.reload } 157 | end 158 | 159 | def update_plugin_settings(**s) 160 | assert User.find(session[:user_id]).admin 161 | settings = Setting.plugin_issue_recurring.merge(s) 162 | post plugin_settings_path(id: 'issue_recurring'), params: {settings: settings} 163 | assert_redirected_to plugin_settings_path(id: 'issue_recurring') 164 | end 165 | 166 | def copy_project(project) 167 | assert User.find(session[:user_id]).admin 168 | assert_difference 'Project.count', 1 do 169 | post copy_project_path(project), params: { 170 | project: { 171 | name: "copy of: #{project.name} at [#{DateTime.current.strftime('%F %R')}]", 172 | identifier: "#{project.identifier}-#{DateTime.current.strftime('%Y%m%d%H%M%S')}", 173 | inherit_members: 1, 174 | enabled_module_names: project.enabled_modules.map(&:name) 175 | } 176 | } 177 | end 178 | new_project = Project.last 179 | assert_redirected_to settings_project_path(new_project) 180 | new_project 181 | end 182 | 183 | def copy_issue(from_issue, to_project, **attrs) 184 | assert_difference 'Issue.count', 1 do 185 | post project_issues_path(to_project), params: { 186 | copy_from: from_issue.id, 187 | issue: attrs 188 | } 189 | end 190 | new_issue = Issue.last 191 | assert_redirected_to issue_path(new_issue) 192 | new_issue 193 | end 194 | 195 | def copy_issue_should_fail(from_issue, to_project, **attrs) 196 | assert_no_difference 'Issue.count' do 197 | post project_issues_path(to_project), params: { 198 | copy_from: from_issue.id, 199 | issue: attrs 200 | } 201 | end 202 | assert_not_empty assigns(:issue).errors 203 | assigns(:issue).errors 204 | end 205 | 206 | def set_user_status(user, status) 207 | assert_not_equal user.status, status 208 | put "/users/#{user.id}", params: {user: {status: status}} 209 | user.reload 210 | assert_equal user.status, status 211 | end 212 | 213 | def set_assigned_to(issue, assignee) 214 | assert_not_equal [assignee], [issue.assigned_to] 215 | put "/issues/#{issue.id}", params: {issue: {assigned_to_id: assignee ? assignee.id: ''}} 216 | issue.reload 217 | assert_equal [assignee], [issue.assigned_to] 218 | end 219 | 220 | def destroy_user(user) 221 | assert_not user.reload.destroyed? 222 | assert_difference 'User.count', -1 do 223 | delete user_path(user), params: {confirm: user.login} 224 | end 225 | assert_raises(ActiveRecord::RecordNotFound) { user.reload } 226 | end 227 | end 228 | -------------------------------------------------------------------------------- /test/unit/issue_recurrence_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class IssueRecurrenceTest < ActiveSupport::TestCase 4 | self.fixture_path = File.expand_path('../../fixtures/', __FILE__) 5 | fixtures :issues, :issue_statuses, 6 | :users, :email_addresses, :trackers, :projects, 7 | :roles, :members, :member_roles, :enabled_modules, :workflow_transitions, 8 | :custom_fields, :enumerations 9 | 10 | def setup 11 | @issue1 = issues(:issue_01) 12 | 13 | User.current = users(:alice) 14 | end 15 | 16 | def test_new 17 | @issue1.update!(start_date: '2019-04-08', due_date: '2019-04-12') 18 | ir = IssueRecurrence.new(issue: @issue1) 19 | assert ir 20 | ir.save! 21 | end 22 | end 23 | --------------------------------------------------------------------------------