1 # BEGIN BPS TAGGED BLOCK {{{
5 # This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC
6 # <sales@bestpractical.com>
8 # (Except where explicitly superseded by other copyright notices)
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
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.
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.
30 # CONTRIBUTION SUBMISSION POLICY:
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.)
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.
47 # END BPS TAGGED BLOCK }}}
53 package RT::Lifecycle;
56 our %LIFECYCLES_CACHE;
57 our %LIFECYCLES_TYPES;
62 # '' => [...], # all valid in lifecycle
67 # status_x => [status_next1, status_next2,...],
70 # 'status_y -> status_y' => 'right',
74 # { from => 'a', to => 'b', label => '...', update => '...' },
82 RT::Lifecycle - class to access and manipulate lifecycles
86 A lifecycle is a list of statuses that a ticket can have. There are three
87 groups of statuses: initial, active and inactive. A lifecycle also defines
88 possible transitions between statuses. For example, in the 'default' lifecycle,
89 you may only change status from 'stalled' to 'open'.
91 It is also possible to define user-interface labels and the action a user
92 should perform during a transition. For example, the "open -> stalled"
93 transition would have a 'Stall' label and the action would be Comment. The
94 action only defines what form is showed to the user, but actually performing
95 the action is not required. The user can leave the comment box empty yet still
96 Stall a ticket. Finally, the user can also just use the Basics or Jumbo form to
97 change the status with the usual dropdown.
103 Simple constructor, takes no arguments.
109 my $self = bless {}, ref($proto) || $proto;
111 $self->FillCache unless keys %LIFECYCLES_CACHE;
116 =head2 Load Name => I<NAME>, Type => I<TYPE>
118 Takes a name of the lifecycle and loads it. If only a Type is provided,
119 loads the global lifecycle with statuses from all named lifecycles of
122 Can be called as class method, returns a new object, for example:
124 my $lifecycle = RT::Lifecycle->Load( Name => 'default');
126 Returns an object which may be a subclass of L<RT::Lifecycle>
127 (L<RT::Lifecycle::Ticket>, for example) depending on the type of the
128 lifecycle in question.
134 return $self->new->Load( @_ )
137 unshift @_, Type => "ticket", "Name"
146 if (defined $args{Name} and exists $LIFECYCLES_CACHE{ $args{Name} }) {
147 $self->{'name'} = $args{Name};
148 $self->{'data'} = $LIFECYCLES_CACHE{ $args{Name} };
149 $self->{'type'} = $args{Type};
151 my $found_type = $self->{'data'}{'type'};
152 warn "Found type of $found_type ne $args{Type}" if $found_type ne $args{Type};
153 } elsif (not $args{Name} and exists $LIFECYCLES_TYPES{ $args{Type} }) {
154 $self->{'data'} = $LIFECYCLES_TYPES{ $args{Type} };
155 $self->{'type'} = $args{Type};
160 my $class = "RT::Lifecycle::".ucfirst($args{Type});
161 bless $self, $class if $class->require;
168 List available lifecycles. This list omits RT's default approvals
171 Takes: An optional parameter for lifecycle types other than tickets.
172 Defaults to 'ticket'.
174 Returns: A sorted list of available lifecycles.
180 my $for = shift || 'ticket';
182 return grep { $_ ne 'approvals' } $self->ListAll( $for );
187 Returns a list of all lifecycles, including approvals.
189 Takes: An optional parameter for lifecycle types other than tickets.
190 Defaults to 'ticket'.
192 Returns: A sorted list of all available lifecycles.
198 my $for = shift || 'ticket';
200 $self->FillCache unless keys %LIFECYCLES_CACHE;
202 return sort grep {$LIFECYCLES_CACHE{$_}{type} eq $for}
203 grep $_ ne '__maps__', keys %LIFECYCLES_CACHE;
208 Returns name of the loaded lifecycle.
212 sub Name { return $_[0]->{'name'} }
216 Returns the type of the loaded lifecycle.
220 sub Type { return $_[0]->{'type'} }
222 =head2 Getting statuses and validating.
224 Methods to get statuses in different sets or validating them.
228 Returns an array of all valid statuses for the current lifecycle.
229 Statuses are not sorted alphabetically, instead initial goes first,
230 then active and then inactive.
232 Takes optional list of status types, from 'initial', 'active' or
233 'inactive'. For example:
235 $lifecycle->Valid('initial', 'active');
243 return @{ $self->{'data'}{''} || [] };
247 push @res, @{ $self->{'data'}{ $_ } || [] } foreach @types;
253 Takes a status and returns true if value is a valid status for the current
254 lifecycle. Otherwise, returns false.
256 Takes optional list of status types after the status, so it's possible check
257 validity in particular sets, for example:
259 # returns true if status is valid and from initial or active set
260 $lifecycle->IsValid('some_status', 'initial', 'active');
268 my $value = shift or return 0;
269 return 1 if grep lc($_) eq lc($value), $self->Valid( @_ );
275 Takes a status and returns its type, one of 'initial', 'active' or
283 foreach my $type ( qw(initial active inactive) ) {
284 return $type if $self->IsValid( $status, $type );
291 Returns an array of all initial statuses for the current lifecycle.
297 return $self->Valid('initial');
302 Takes a status and returns true if value is a valid initial status.
303 Otherwise, returns false.
309 my $value = shift or return 0;
310 return 1 if grep lc($_) eq lc($value), $self->Valid('initial');
317 Returns an array of all active statuses for this lifecycle.
323 return $self->Valid('active');
328 Takes a value and returns true if value is a valid active status.
329 Otherwise, returns false.
335 my $value = shift or return 0;
336 return 1 if grep lc($_) eq lc($value), $self->Valid('active');
342 Returns an array of all inactive statuses for this lifecycle.
348 return $self->Valid('inactive');
353 Takes a value and returns true if value is a valid inactive status.
354 Otherwise, returns false.
360 my $value = shift or return 0;
361 return 1 if grep lc($_) eq lc($value), $self->Valid('inactive');
366 =head2 Default statuses
368 In some cases when status is not provided a default values should
373 Takes a situation name and returns value. Name should be
374 spelled following spelling in the RT config file.
380 my $situation = shift;
381 return $self->{data}{defaults}{ $situation };
384 =head3 DefaultOnCreate
386 Returns the status that should be used by default
387 when ticket is created.
391 sub DefaultOnCreate {
393 return $self->DefaultStatus('on_create');
396 =head2 Transitions, rights, labels and actions.
400 Takes status and returns list of statuses it can be changed to.
402 Is status is empty or undefined then returns list of statuses for
405 If argument is ommitted then returns a hash with all possible
406 transitions in the following format:
408 status_x => [ next_status, next_status, ... ],
409 status_y => [ next_status, next_status, ... ],
415 return %{ $self->{'data'}{'transitions'} || {} }
418 my $status = shift || '';
419 return @{ $self->{'data'}{'transitions'}{ lc $status } || [] };
424 Takes two statuses (from -> to) and returns true if it's valid
425 transition and false otherwise.
432 my $to = shift or return 0;
433 return 1 if grep lc($_) eq lc($to), $self->Transitions($from);
439 Takes two statuses (from -> to) and returns the right that should
440 be checked on the ticket.
448 if ( my $rights = $self->{'data'}{'rights'} ) {
450 $rights->{ $from .' -> '. $to }
451 || $rights->{ '* -> '. $to }
452 || $rights->{ $from .' -> *' }
453 || $rights->{ '* -> *' };
454 return $check if $check;
456 return $to eq 'deleted' ? 'DeleteTicket' : 'ModifyTicket';
459 =head3 RightsDescription [TYPE]
461 Returns hash with description of rights that are defined for
462 particular transitions.
466 sub RightsDescription {
470 $self->FillCache unless keys %LIFECYCLES_CACHE;
473 foreach my $lifecycle ( values %LIFECYCLES_CACHE ) {
474 next unless exists $lifecycle->{'rights'};
475 next if $type and $lifecycle->{type} ne $type;
476 while ( my ($transition, $right) = each %{ $lifecycle->{'rights'} } ) {
477 push @{ $tmp{ $right } ||=[] }, $transition;
482 while ( my ($right, $transitions) = each %tmp ) {
484 foreach ( @$transitions ) {
485 ($from[@from], $to[@to]) = split / -> /, $_;
487 my $description = 'Change status'
488 . ( (grep $_ eq '*', @from)? '' : ' from '. join ', ', @from )
489 . ( (grep $_ eq '*', @to )? '' : ' to '. join ', ', @to );
491 $res{ $right } = $description;
498 Takes a status and returns list of defined actions for the status. Each
499 element in the list is a hash reference with the following key/value
504 =item from - either the status or *
506 =item to - next status
508 =item label - label of the action
510 =item update - 'Respond', 'Comment' or '' (empty string)
518 my $from = shift || return ();
521 $self->FillCache unless keys %LIFECYCLES_CACHE;
523 my @res = grep lc $_->{'from'} eq $from || ( $_->{'from'} eq '*' && lc $_->{'to'} ne $from ),
524 @{ $self->{'data'}{'actions'} };
526 # skip '* -> x' if there is '$from -> x'
527 foreach my $e ( grep $_->{'from'} eq '*', @res ) {
528 $e = undef if grep $_->{'from'} ne '*' && $_->{'to'} eq $e->{'to'}, @res;
530 return grep defined, @res;
533 =head2 Moving tickets between lifecycles
537 Takes lifecycle as a name string or an object and returns a hash reference with
538 move map from this cycle to provided.
543 my $from = shift; # self
545 $to = RT::Lifecycle->Load( Name => $to, Type => $from->Type ) unless ref $to;
546 return $LIFECYCLES{'__maps__'}{ $from->Name .' -> '. $to->Name } || {};
551 Takes a lifecycle as a name string or an object and returns true if move map
552 defined for move from this cycle to provided.
558 my $map = $self->MoveMap( @_ );
559 return 0 unless $map && keys %$map;
560 return 0 unless grep defined && length, values %$map;
566 Takes no arguments and returns hash with pairs that has no
573 my $type = $self->Type;
574 my @list = $self->List( $type );
576 foreach my $from ( @list ) {
577 foreach my $to ( @list ) {
578 next if $from eq $to;
579 push @res, $from, $to
580 unless RT::Lifecycle->Load( Name => $from, Type => $type )->HasMoveMap( $to );
588 =head3 ForLocalization
590 A class method that takes no arguments and returns list of strings
591 that require translation.
595 sub ForLocalization {
597 $self->FillCache unless keys %LIFECYCLES_CACHE;
601 push @res, @{$_->{''}} for values %LIFECYCLES_TYPES;
602 foreach my $lifecycle ( values %LIFECYCLES ) {
604 grep defined && length,
607 @{ $lifecycle->{'actions'} || [] };
610 push @res, $self->RightsDescription;
613 return grep !$seen{lc $_}++, @res;
616 sub loc { return RT->SystemUser->loc( @_ ) }
621 return undef unless defined $status;
622 return($self->{data}{canonical_case}{lc $status} || lc $status);
628 my $map = RT->Config->Get('Lifecycles') or return;
633 # if users are upgrading from 3.* where we don't have lifecycle column yet,
634 # this could die. we also don't want to frighten them by the errors out
636 local $RT::Logger = Log::Dispatch->new;
637 @lifecycles = grep { defined } RT::Queues->new( RT->SystemUser )->DistinctFieldValues( 'Lifecycle' );
640 for my $name ( @lifecycles ) {
641 unless ( $map->{$name} ) {
642 warn "Lifecycle $name is missing in %Lifecycles config";
648 %LIFECYCLES_CACHE = %LIFECYCLES = %$map;
649 $_ = { %$_ } foreach values %LIFECYCLES_CACHE;
651 foreach my $name ( keys %LIFECYCLES_CACHE ) {
652 next if $name eq "__maps__";
653 my $lifecycle = $LIFECYCLES_CACHE{$name};
655 my $type = $lifecycle->{type} ||= 'ticket';
656 $LIFECYCLES_TYPES{$type} ||= {
665 $lifecycle->{canonical_case} = {};
666 foreach my $category ( qw(initial active inactive) ) {
667 for my $status (@{ $lifecycle->{ $category } || [] }) {
668 if (exists $lifecycle->{canonical_case}{lc $status}) {
669 warn "Duplicate status @{[lc $status]} in lifecycle $name";
671 $lifecycle->{canonical_case}{lc $status} = $status;
673 push @{ $LIFECYCLES_TYPES{$type}{$category} }, $status;
674 push @statuses, $status;
678 # Lower-case for consistency
679 # ->{actions} are handled below
680 for my $state (keys %{ $lifecycle->{defaults} || {} }) {
681 my $status = $lifecycle->{defaults}{$state};
682 warn "Nonexistant status @{[lc $status]} in default states in $name lifecycle"
683 unless $lifecycle->{canonical_case}{lc $status};
684 $lifecycle->{defaults}{$state} =
685 $lifecycle->{canonical_case}{lc $status} || lc $status;
687 for my $from (keys %{ $lifecycle->{transitions} || {} }) {
688 warn "Nonexistant status @{[lc $from]} in transitions in $name lifecycle"
689 unless $from eq '' or $lifecycle->{canonical_case}{lc $from};
690 for my $status ( @{delete($lifecycle->{transitions}{$from}) || []} ) {
691 warn "Nonexistant status @{[lc $status]} in transitions in $name lifecycle"
692 unless $lifecycle->{canonical_case}{lc $status};
693 push @{ $lifecycle->{transitions}{lc $from} },
694 $lifecycle->{canonical_case}{lc $status} || lc $status;
697 for my $schema (keys %{ $lifecycle->{rights} || {} }) {
698 my ($from, $to) = split /\s*->\s*/, $schema, 2;
699 unless ($from and $to) {
700 warn "Invalid right transition $schema in $name lifecycle";
703 warn "Nonexistant status @{[lc $from]} in right transition in $name lifecycle"
704 unless $from eq '*' or $lifecycle->{canonical_case}{lc $from};
705 warn "Nonexistant status @{[lc $to]} in right transition in $name lifecycle"
706 unless $to eq '*' or $lifecycle->{canonical_case}{lc $to};
708 warn "Invalid right name ($lifecycle->{rights}{$schema}) in $name lifecycle; right names must be ASCII"
709 if $lifecycle->{rights}{$schema} =~ /\P{ASCII}/;
711 warn "Invalid right name ($lifecycle->{rights}{$schema}) in $name lifecycle; right names must be <= 25 characters"
712 if length($lifecycle->{rights}{$schema}) > 25;
714 $lifecycle->{rights}{lc($from) . " -> " .lc($to)}
715 = delete $lifecycle->{rights}{$schema};
719 @statuses = grep !$seen{ lc $_ }++, @statuses;
720 $lifecycle->{''} = \@statuses;
722 unless ( $lifecycle->{'transitions'}{''} ) {
723 $lifecycle->{'transitions'}{''} = [ grep lc $_ ne 'deleted', @statuses ];
727 if ( ref $lifecycle->{'actions'} eq 'HASH' ) {
728 foreach my $k ( sort keys %{ $lifecycle->{'actions'} } ) {
729 push @actions, $k, $lifecycle->{'actions'}{ $k };
731 } elsif ( ref $lifecycle->{'actions'} eq 'ARRAY' ) {
732 @actions = @{ $lifecycle->{'actions'} };
735 $lifecycle->{'actions'} = [];
736 while ( my ($transition, $info) = splice @actions, 0, 2 ) {
737 my ($from, $to) = split /\s*->\s*/, $transition, 2;
738 unless ($from and $to) {
739 warn "Invalid action status change $transition in $name lifecycle";
742 warn "Nonexistant status @{[lc $from]} in action in $name lifecycle"
743 unless $from eq '*' or $lifecycle->{canonical_case}{lc $from};
744 warn "Nonexistant status @{[lc $to]} in action in $name lifecycle"
745 unless $to eq '*' or $lifecycle->{canonical_case}{lc $to};
746 push @{ $lifecycle->{'actions'} },
748 from => ($lifecycle->{canonical_case}{lc $from} || lc $from),
749 to => ($lifecycle->{canonical_case}{lc $to} || lc $to), };
753 # Lower-case the transition maps
754 for my $mapname (keys %{ $LIFECYCLES_CACHE{'__maps__'} || {} }) {
755 my ($from, $to) = split /\s*->\s*/, $mapname, 2;
756 unless ($from and $to) {
757 warn "Invalid lifecycle mapping $mapname";
760 warn "Nonexistant lifecycle $from in $mapname lifecycle map"
761 unless $LIFECYCLES_CACHE{$from};
762 warn "Nonexistant lifecycle $to in $mapname lifecycle map"
763 unless $LIFECYCLES_CACHE{$to};
764 my $map = delete $LIFECYCLES_CACHE{'__maps__'}{$mapname};
765 $LIFECYCLES_CACHE{'__maps__'}{"$from -> $to"} = $map;
766 for my $status (keys %{ $map }) {
767 warn "Nonexistant status @{[lc $status]} in $from in $mapname lifecycle map"
768 if $LIFECYCLES_CACHE{$from}
769 and not $LIFECYCLES_CACHE{$from}{canonical_case}{lc $status};
770 warn "Nonexistant status @{[lc $map->{$status}]} in $to in $mapname lifecycle map"
771 if $LIFECYCLES_CACHE{$to}
772 and not $LIFECYCLES_CACHE{$to}{canonical_case}{lc $map->{$status}};
773 $map->{lc $status} = lc delete $map->{$status};
777 for my $type (keys %LIFECYCLES_TYPES) {
778 for my $category ( qw(initial active inactive), '' ) {
780 @{ $LIFECYCLES_TYPES{$type}{$category} } =
781 grep !$seen{ lc $_ }++, @{ $LIFECYCLES_TYPES{$type}{$category} };
782 push @{ $LIFECYCLES_TYPES{$type}{''} },
783 @{ $LIFECYCLES_TYPES{$type}{$category} } if $category;
786 my $class = "RT::Lifecycle::".ucfirst($type);
787 $class->RegisterRights if $class->require
788 and $class->can("RegisterRights");