]>
Commit | Line | Data |
---|---|---|
84fb5b46 MKG |
1 | # BEGIN BPS TAGGED BLOCK {{{ |
2 | # | |
3 | # COPYRIGHT: | |
4 | # | |
5 | # This software is Copyright (c) 1996-2012 Best Practical Solutions, LLC | |
6 | # <sales@bestpractical.com> | |
7 | # | |
8 | # (Except where explicitly superseded by other copyright notices) | |
9 | # | |
10 | # | |
11 | # LICENSE: | |
12 | # | |
13 | # This work is made available to you under the terms of Version 2 of | |
14 | # the GNU General Public License. A copy of that license should have | |
15 | # been provided with this software, but in any event can be snarfed | |
16 | # from www.gnu.org. | |
17 | # | |
18 | # This work is distributed in the hope that it will be useful, but | |
19 | # WITHOUT ANY WARRANTY; without even the implied warranty of | |
20 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |
21 | # General Public License for more details. | |
22 | # | |
23 | # You should have received a copy of the GNU General Public License | |
24 | # along with this program; if not, write to the Free Software | |
25 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA | |
26 | # 02110-1301 or visit their web page on the internet at | |
27 | # http://www.gnu.org/licenses/old-licenses/gpl-2.0.html. | |
28 | # | |
29 | # | |
30 | # CONTRIBUTION SUBMISSION POLICY: | |
31 | # | |
32 | # (The following paragraph is not intended to limit the rights granted | |
33 | # to you to modify and distribute this software under the terms of | |
34 | # the GNU General Public License and is only of importance to you if | |
35 | # you choose to contribute your changes and enhancements to the | |
36 | # community by submitting them to Best Practical Solutions, LLC.) | |
37 | # | |
38 | # By intentionally submitting any modifications, corrections or | |
39 | # derivatives to this work, or any other work intended for use with | |
40 | # Request Tracker, to Best Practical Solutions, LLC, you confirm that | |
41 | # you are the copyright holder for those contributions and you grant | |
42 | # Best Practical Solutions, LLC a nonexclusive, worldwide, irrevocable, | |
43 | # royalty-free, perpetual, license to use, copy, create derivative | |
44 | # works based on those contributions, and sublicense and distribute | |
45 | # those contributions and any derivatives thereof. | |
46 | # | |
47 | # END BPS TAGGED BLOCK }}} | |
48 | ||
49 | use strict; | |
50 | use warnings; | |
51 | ||
52 | ||
53 | package RT::Lifecycle; | |
54 | ||
55 | our %LIFECYCLES; | |
56 | our %LIFECYCLES_CACHE; | |
57 | __PACKAGE__->RegisterRights; | |
58 | ||
59 | # cache structure: | |
60 | # { | |
61 | # '' => { # all valid statuses | |
62 | # '' => [...], | |
63 | # initial => [...], | |
64 | # active => [...], | |
65 | # inactive => [...], | |
66 | # }, | |
67 | # lifecycle_x => { | |
68 | # '' => [...], # all valid in lifecycle | |
69 | # initial => [...], | |
70 | # active => [...], | |
71 | # inactive => [...], | |
72 | # transitions => { | |
73 | # status_x => [status_next1, status_next2,...], | |
74 | # }, | |
75 | # rights => { | |
76 | # 'status_y -> status_y' => 'right', | |
77 | # .... | |
78 | # } | |
79 | # actions => [ | |
80 | # { from => 'a', to => 'b', label => '...', update => '...' }, | |
81 | # .... | |
82 | # ] | |
83 | # } | |
84 | # } | |
85 | ||
86 | =head1 NAME | |
87 | ||
88 | RT::Lifecycle - class to access and manipulate lifecycles | |
89 | ||
90 | =head1 DESCRIPTION | |
91 | ||
92 | A lifecycle is a list of statuses that a ticket can have. There are three | |
93 | groups of statuses: initial, active and inactive. A lifecycle also defines | |
94 | possible transitions between statuses. For example, in the 'default' lifecycle, | |
95 | you may only change status from 'stalled' to 'open'. | |
96 | ||
97 | It is also possible to define user-interface labels and the action a user | |
98 | should perform during a transition. For example, the "open -> stalled" | |
99 | transition would have a 'Stall' label and the action would be Comment. The | |
100 | action only defines what form is showed to the user, but actually performing | |
101 | the action is not required. The user can leave the comment box empty yet still | |
102 | Stall a ticket. Finally, the user can also just use the Basics or Jumbo form to | |
103 | change the status with the usual dropdown. | |
104 | ||
105 | =head1 METHODS | |
106 | ||
107 | =head2 new | |
108 | ||
109 | Simple constructor, takes no arguments. | |
110 | ||
111 | =cut | |
112 | ||
113 | sub new { | |
114 | my $proto = shift; | |
115 | my $self = bless {}, ref($proto) || $proto; | |
116 | ||
117 | $self->FillCache unless keys %LIFECYCLES_CACHE; | |
118 | ||
119 | return $self; | |
120 | } | |
121 | ||
122 | =head2 Load | |
123 | ||
124 | Takes a name of the lifecycle and loads it. If name is empty or undefined then | |
125 | loads the global lifecycle with statuses from all named lifecycles. | |
126 | ||
127 | Can be called as class method, returns a new object, for example: | |
128 | ||
129 | my $lifecycle = RT::Lifecycle->Load('default'); | |
130 | ||
131 | =cut | |
132 | ||
133 | sub Load { | |
134 | my $self = shift; | |
135 | my $name = shift || ''; | |
136 | return $self->new->Load( $name, @_ ) | |
137 | unless ref $self; | |
138 | ||
139 | return unless exists $LIFECYCLES_CACHE{ $name }; | |
140 | ||
141 | $self->{'name'} = $name; | |
142 | $self->{'data'} = $LIFECYCLES_CACHE{ $name }; | |
143 | ||
144 | return $self; | |
145 | } | |
146 | ||
147 | =head2 List | |
148 | ||
149 | Returns sorted list of the lifecycles' names. | |
150 | ||
151 | =cut | |
152 | ||
153 | sub List { | |
154 | my $self = shift; | |
155 | ||
156 | $self->FillCache unless keys %LIFECYCLES_CACHE; | |
157 | ||
158 | return sort grep length && $_ ne '__maps__', keys %LIFECYCLES_CACHE; | |
159 | } | |
160 | ||
161 | =head2 Name | |
162 | ||
163 | Returns name of the laoded lifecycle. | |
164 | ||
165 | =cut | |
166 | ||
167 | sub Name { return $_[0]->{'name'} } | |
168 | ||
169 | =head2 Queues | |
170 | ||
171 | Returns L<RT::Queues> collection with queues that use this lifecycle. | |
172 | ||
173 | =cut | |
174 | ||
175 | sub Queues { | |
176 | my $self = shift; | |
177 | require RT::Queues; | |
178 | my $queues = RT::Queues->new( RT->SystemUser ); | |
179 | $queues->Limit( FIELD => 'Lifecycle', VALUE => $self->Name ); | |
180 | return $queues; | |
181 | } | |
182 | ||
183 | =head2 Getting statuses and validating. | |
184 | ||
185 | Methods to get statuses in different sets or validating them. | |
186 | ||
187 | =head3 Valid | |
188 | ||
189 | Returns an array of all valid statuses for the current lifecycle. | |
190 | Statuses are not sorted alphabetically, instead initial goes first, | |
191 | then active and then inactive. | |
192 | ||
193 | Takes optional list of status types, from 'initial', 'active' or | |
194 | 'inactive'. For example: | |
195 | ||
196 | $lifecycle->Valid('initial', 'active'); | |
197 | ||
198 | =cut | |
199 | ||
200 | sub Valid { | |
201 | my $self = shift; | |
202 | my @types = @_; | |
203 | unless ( @types ) { | |
204 | return @{ $self->{'data'}{''} || [] }; | |
205 | } | |
206 | ||
207 | my @res; | |
208 | push @res, @{ $self->{'data'}{ $_ } || [] } foreach @types; | |
209 | return @res; | |
210 | } | |
211 | ||
212 | =head3 IsValid | |
213 | ||
214 | Takes a status and returns true if value is a valid status for the current | |
215 | lifecycle. Otherwise, returns false. | |
216 | ||
217 | Takes optional list of status types after the status, so it's possible check | |
218 | validity in particular sets, for example: | |
219 | ||
220 | # returns true if status is valid and from initial or active set | |
221 | $lifecycle->IsValid('some_status', 'initial', 'active'); | |
222 | ||
223 | See also </valid>. | |
224 | ||
225 | =cut | |
226 | ||
227 | sub IsValid { | |
228 | my $self = shift; | |
229 | my $value = shift or return 0; | |
230 | return 1 if grep lc($_) eq lc($value), $self->Valid( @_ ); | |
231 | return 0; | |
232 | } | |
233 | ||
234 | =head3 StatusType | |
235 | ||
236 | Takes a status and returns its type, one of 'initial', 'active' or | |
237 | 'inactive'. | |
238 | ||
239 | =cut | |
240 | ||
241 | sub StatusType { | |
242 | my $self = shift; | |
243 | my $status = shift; | |
244 | foreach my $type ( qw(initial active inactive) ) { | |
245 | return $type if $self->IsValid( $status, $type ); | |
246 | } | |
247 | return ''; | |
248 | } | |
249 | ||
250 | =head3 Initial | |
251 | ||
252 | Returns an array of all initial statuses for the current lifecycle. | |
253 | ||
254 | =cut | |
255 | ||
256 | sub Initial { | |
257 | my $self = shift; | |
258 | return $self->Valid('initial'); | |
259 | } | |
260 | ||
261 | =head3 IsInitial | |
262 | ||
263 | Takes a status and returns true if value is a valid initial status. | |
264 | Otherwise, returns false. | |
265 | ||
266 | =cut | |
267 | ||
268 | sub IsInitial { | |
269 | my $self = shift; | |
270 | my $value = shift or return 0; | |
271 | return 1 if grep lc($_) eq lc($value), $self->Valid('initial'); | |
272 | return 0; | |
273 | } | |
274 | ||
275 | ||
276 | =head3 Active | |
277 | ||
278 | Returns an array of all active statuses for this lifecycle. | |
279 | ||
280 | =cut | |
281 | ||
282 | sub Active { | |
283 | my $self = shift; | |
284 | return $self->Valid('active'); | |
285 | } | |
286 | ||
287 | =head3 IsActive | |
288 | ||
289 | Takes a value and returns true if value is a valid active status. | |
290 | Otherwise, returns false. | |
291 | ||
292 | =cut | |
293 | ||
294 | sub IsActive { | |
295 | my $self = shift; | |
296 | my $value = shift or return 0; | |
297 | return 1 if grep lc($_) eq lc($value), $self->Valid('active'); | |
298 | return 0; | |
299 | } | |
300 | ||
301 | =head3 inactive | |
302 | ||
303 | Returns an array of all inactive statuses for this lifecycle. | |
304 | ||
305 | =cut | |
306 | ||
307 | sub Inactive { | |
308 | my $self = shift; | |
309 | return $self->Valid('inactive'); | |
310 | } | |
311 | ||
312 | =head3 is_inactive | |
313 | ||
314 | Takes a value and returns true if value is a valid inactive status. | |
315 | Otherwise, returns false. | |
316 | ||
317 | =cut | |
318 | ||
319 | sub IsInactive { | |
320 | my $self = shift; | |
321 | my $value = shift or return 0; | |
322 | return 1 if grep lc($_) eq lc($value), $self->Valid('inactive'); | |
323 | return 0; | |
324 | } | |
325 | ||
326 | ||
327 | =head2 Default statuses | |
328 | ||
329 | In some cases when status is not provided a default values should | |
330 | be used. | |
331 | ||
332 | =head3 DefaultStatus | |
333 | ||
334 | Takes a situation name and returns value. Name should be | |
335 | spelled following spelling in the RT config file. | |
336 | ||
337 | =cut | |
338 | ||
339 | sub DefaultStatus { | |
340 | my $self = shift; | |
341 | my $situation = shift; | |
342 | return $self->{data}{defaults}{ $situation }; | |
343 | } | |
344 | ||
345 | =head3 DefaultOnCreate | |
346 | ||
347 | Returns the status that should be used by default | |
348 | when ticket is created. | |
349 | ||
350 | =cut | |
351 | ||
352 | sub DefaultOnCreate { | |
353 | my $self = shift; | |
354 | return $self->DefaultStatus('on_create'); | |
355 | } | |
356 | ||
357 | ||
358 | =head3 DefaultOnMerge | |
359 | ||
360 | Returns the status that should be used when tickets | |
361 | are merged. | |
362 | ||
363 | =cut | |
364 | ||
365 | sub DefaultOnMerge { | |
366 | my $self = shift; | |
367 | return $self->DefaultStatus('on_merge'); | |
368 | } | |
369 | ||
b5747ff2 MKG |
370 | =head3 ReminderStatusOnOpen |
371 | ||
372 | Returns the status that should be used when reminders are opened. | |
373 | ||
374 | =cut | |
375 | ||
376 | sub ReminderStatusOnOpen { | |
377 | my $self = shift; | |
378 | return $self->DefaultStatus('reminder_on_open') || 'open'; | |
379 | } | |
380 | ||
381 | =head3 ReminderStatusOnResolve | |
382 | ||
383 | Returns the status that should be used when reminders are resolved. | |
384 | ||
385 | =cut | |
386 | ||
387 | sub ReminderStatusOnResolve { | |
388 | my $self = shift; | |
389 | return $self->DefaultStatus('reminder_on_resolve') || 'resolved'; | |
390 | } | |
391 | ||
84fb5b46 MKG |
392 | =head2 Transitions, rights, labels and actions. |
393 | ||
394 | =head3 Transitions | |
395 | ||
396 | Takes status and returns list of statuses it can be changed to. | |
397 | ||
398 | Is status is empty or undefined then returns list of statuses for | |
399 | a new ticket. | |
400 | ||
401 | If argument is ommitted then returns a hash with all possible | |
402 | transitions in the following format: | |
403 | ||
404 | status_x => [ next_status, next_status, ... ], | |
405 | status_y => [ next_status, next_status, ... ], | |
406 | ||
407 | =cut | |
408 | ||
409 | sub Transitions { | |
410 | my $self = shift; | |
411 | return %{ $self->{'data'}{'transitions'} || {} } | |
412 | unless @_; | |
413 | ||
414 | my $status = shift; | |
415 | return @{ $self->{'data'}{'transitions'}{ $status || '' } || [] }; | |
416 | } | |
417 | ||
418 | =head1 IsTransition | |
419 | ||
420 | Takes two statuses (from -> to) and returns true if it's valid | |
421 | transition and false otherwise. | |
422 | ||
423 | =cut | |
424 | ||
425 | sub IsTransition { | |
426 | my $self = shift; | |
427 | my $from = shift; | |
428 | my $to = shift or return 0; | |
429 | return 1 if grep lc($_) eq lc($to), $self->Transitions($from); | |
430 | return 0; | |
431 | } | |
432 | ||
433 | =head3 CheckRight | |
434 | ||
435 | Takes two statuses (from -> to) and returns the right that should | |
436 | be checked on the ticket. | |
437 | ||
438 | =cut | |
439 | ||
440 | sub CheckRight { | |
441 | my $self = shift; | |
442 | my $from = shift; | |
443 | my $to = shift; | |
444 | if ( my $rights = $self->{'data'}{'rights'} ) { | |
445 | my $check = | |
446 | $rights->{ $from .' -> '. $to } | |
447 | || $rights->{ '* -> '. $to } | |
448 | || $rights->{ $from .' -> *' } | |
449 | || $rights->{ '* -> *' }; | |
450 | return $check if $check; | |
451 | } | |
452 | return $to eq 'deleted' ? 'DeleteTicket' : 'ModifyTicket'; | |
453 | } | |
454 | ||
455 | =head3 RegisterRights | |
456 | ||
457 | Registers all defined rights in the system, so they can be addigned | |
458 | to users. No need to call it, as it's called when module is loaded. | |
459 | ||
460 | =cut | |
461 | ||
462 | sub RegisterRights { | |
463 | my $self = shift; | |
464 | ||
465 | my %rights = $self->RightsDescription; | |
466 | ||
467 | require RT::ACE; | |
468 | ||
469 | require RT::Queue; | |
470 | my $RIGHTS = $RT::Queue::RIGHTS; | |
471 | ||
472 | while ( my ($right, $description) = each %rights ) { | |
473 | next if exists $RIGHTS->{ $right }; | |
474 | ||
475 | $RIGHTS->{ $right } = $description; | |
476 | RT::Queue->AddRightCategories( $right => 'Status' ); | |
477 | $RT::ACE::LOWERCASERIGHTNAMES{ lc $right } = $right; | |
478 | } | |
479 | } | |
480 | ||
481 | =head3 RightsDescription | |
482 | ||
483 | Returns hash with description of rights that are defined for | |
484 | particular transitions. | |
485 | ||
486 | =cut | |
487 | ||
488 | sub RightsDescription { | |
489 | my $self = shift; | |
490 | ||
491 | $self->FillCache unless keys %LIFECYCLES_CACHE; | |
492 | ||
493 | my %tmp; | |
494 | foreach my $lifecycle ( values %LIFECYCLES_CACHE ) { | |
495 | next unless exists $lifecycle->{'rights'}; | |
496 | while ( my ($transition, $right) = each %{ $lifecycle->{'rights'} } ) { | |
497 | push @{ $tmp{ $right } ||=[] }, $transition; | |
498 | } | |
499 | } | |
500 | ||
501 | my %res; | |
502 | while ( my ($right, $transitions) = each %tmp ) { | |
503 | my (@from, @to); | |
504 | foreach ( @$transitions ) { | |
505 | ($from[@from], $to[@to]) = split / -> /, $_; | |
506 | } | |
507 | my $description = 'Change status' | |
508 | . ( (grep $_ eq '*', @from)? '' : ' from '. join ', ', @from ) | |
509 | . ( (grep $_ eq '*', @to )? '' : ' to '. join ', ', @to ); | |
510 | ||
511 | $res{ $right } = $description; | |
512 | } | |
513 | return %res; | |
514 | } | |
515 | ||
516 | =head3 Actions | |
517 | ||
518 | Takes a status and returns list of defined actions for the status. Each | |
519 | element in the list is a hash reference with the following key/value | |
520 | pairs: | |
521 | ||
522 | =over 4 | |
523 | ||
524 | =item from - either the status or * | |
525 | ||
526 | =item to - next status | |
527 | ||
528 | =item label - label of the action | |
529 | ||
530 | =item update - 'Respond', 'Comment' or '' (empty string) | |
531 | ||
532 | =back | |
533 | ||
534 | =cut | |
535 | ||
536 | sub Actions { | |
537 | my $self = shift; | |
538 | my $from = shift || return (); | |
539 | ||
540 | $self->FillCache unless keys %LIFECYCLES_CACHE; | |
541 | ||
542 | my @res = grep $_->{'from'} eq $from || ( $_->{'from'} eq '*' && $_->{'to'} ne $from ), | |
543 | @{ $self->{'data'}{'actions'} }; | |
544 | ||
545 | # skip '* -> x' if there is '$from -> x' | |
546 | foreach my $e ( grep $_->{'from'} eq '*', @res ) { | |
547 | $e = undef if grep $_->{'from'} ne '*' && $_->{'to'} eq $e->{'to'}, @res; | |
548 | } | |
549 | return grep defined, @res; | |
550 | } | |
551 | ||
552 | =head2 Moving tickets between lifecycles | |
553 | ||
554 | =head3 MoveMap | |
555 | ||
556 | Takes lifecycle as a name string or an object and returns a hash reference with | |
557 | move map from this cycle to provided. | |
558 | ||
559 | =cut | |
560 | ||
561 | sub MoveMap { | |
562 | my $from = shift; # self | |
563 | my $to = shift; | |
564 | $to = RT::Lifecycle->Load( $to ) unless ref $to; | |
565 | return $LIFECYCLES{'__maps__'}{ $from->Name .' -> '. $to->Name } || {}; | |
566 | } | |
567 | ||
568 | =head3 HasMoveMap | |
569 | ||
570 | Takes a lifecycle as a name string or an object and returns true if move map | |
571 | defined for move from this cycle to provided. | |
572 | ||
573 | =cut | |
574 | ||
575 | sub HasMoveMap { | |
576 | my $self = shift; | |
577 | my $map = $self->MoveMap( @_ ); | |
578 | return 0 unless $map && keys %$map; | |
579 | return 0 unless grep defined && length, values %$map; | |
580 | return 1; | |
581 | } | |
582 | ||
583 | =head3 NoMoveMaps | |
584 | ||
585 | Takes no arguments and returns hash with pairs that has no | |
586 | move maps. | |
587 | ||
588 | =cut | |
589 | ||
590 | sub NoMoveMaps { | |
591 | my $self = shift; | |
592 | my @list = $self->List; | |
593 | my @res; | |
594 | foreach my $from ( @list ) { | |
595 | foreach my $to ( @list ) { | |
596 | next if $from eq $to; | |
597 | push @res, $from, $to | |
598 | unless RT::Lifecycle->Load( $from )->HasMoveMap( $to ); | |
599 | } | |
600 | } | |
601 | return @res; | |
602 | } | |
603 | ||
604 | =head2 Localization | |
605 | ||
606 | =head3 ForLocalization | |
607 | ||
608 | A class method that takes no arguments and returns list of strings | |
609 | that require translation. | |
610 | ||
611 | =cut | |
612 | ||
613 | sub ForLocalization { | |
614 | my $self = shift; | |
615 | $self->FillCache unless keys %LIFECYCLES_CACHE; | |
616 | ||
617 | my @res = (); | |
618 | ||
619 | push @res, @{ $LIFECYCLES_CACHE{''}{''} || [] }; | |
620 | foreach my $lifecycle ( values %LIFECYCLES ) { | |
621 | push @res, | |
622 | grep defined && length, | |
623 | map $_->{'label'}, | |
624 | grep ref($_), | |
625 | @{ $lifecycle->{'actions'} || [] }; | |
626 | } | |
627 | ||
628 | push @res, $self->RightsDescription; | |
629 | ||
630 | my %seen; | |
631 | return grep !$seen{lc $_}++, @res; | |
632 | } | |
633 | ||
634 | sub loc { return RT->SystemUser->loc( @_ ) } | |
635 | ||
636 | sub FillCache { | |
637 | my $self = shift; | |
638 | ||
639 | my $map = RT->Config->Get('Lifecycles') or return; | |
640 | ||
641 | %LIFECYCLES_CACHE = %LIFECYCLES = %$map; | |
642 | $_ = { %$_ } foreach values %LIFECYCLES_CACHE; | |
643 | ||
644 | my %all = ( | |
645 | '' => [], | |
646 | initial => [], | |
647 | active => [], | |
648 | inactive => [], | |
649 | ); | |
650 | foreach my $lifecycle ( values %LIFECYCLES_CACHE ) { | |
651 | my @res; | |
652 | foreach my $type ( qw(initial active inactive) ) { | |
653 | push @{ $all{ $type } }, @{ $lifecycle->{ $type } || [] }; | |
654 | push @res, @{ $lifecycle->{ $type } || [] }; | |
655 | } | |
656 | ||
657 | my %seen; | |
658 | @res = grep !$seen{ lc $_ }++, @res; | |
659 | $lifecycle->{''} = \@res; | |
660 | ||
661 | unless ( $lifecycle->{'transitions'}{''} ) { | |
662 | $lifecycle->{'transitions'}{''} = [ grep $_ ne 'deleted', @res ]; | |
663 | } | |
664 | } | |
665 | foreach my $type ( qw(initial active inactive), '' ) { | |
666 | my %seen; | |
667 | @{ $all{ $type } } = grep !$seen{ lc $_ }++, @{ $all{ $type } }; | |
668 | push @{ $all{''} }, @{ $all{ $type } } if $type; | |
669 | } | |
670 | $LIFECYCLES_CACHE{''} = \%all; | |
671 | ||
672 | foreach my $lifecycle ( values %LIFECYCLES_CACHE ) { | |
673 | my @res; | |
674 | if ( ref $lifecycle->{'actions'} eq 'HASH' ) { | |
675 | foreach my $k ( sort keys %{ $lifecycle->{'actions'} } ) { | |
676 | push @res, $k, $lifecycle->{'actions'}{ $k }; | |
677 | } | |
678 | } elsif ( ref $lifecycle->{'actions'} eq 'ARRAY' ) { | |
679 | @res = @{ $lifecycle->{'actions'} }; | |
680 | } | |
681 | ||
682 | my @tmp = splice @res; | |
683 | while ( my ($transition, $info) = splice @tmp, 0, 2 ) { | |
684 | my ($from, $to) = split /\s*->\s*/, $transition, 2; | |
685 | push @res, { %$info, from => $from, to => $to }; | |
686 | } | |
687 | $lifecycle->{'actions'} = \@res; | |
688 | } | |
689 | return; | |
690 | } | |
691 | ||
692 | 1; |