rt 4.2.15
[freeside.git] / rt / lib / RT / Lifecycle.pm
index 056599e..36ab6f3 100644 (file)
@@ -2,7 +2,7 @@
 #
 # COPYRIGHT:
 #
-# This software is Copyright (c) 1996-2012 Best Practical Solutions, LLC
+# This software is Copyright (c) 1996-2018 Best Practical Solutions, LLC
 #                                          <sales@bestpractical.com>
 #
 # (Except where explicitly superseded by other copyright notices)
@@ -54,16 +54,10 @@ package RT::Lifecycle;
 
 our %LIFECYCLES;
 our %LIFECYCLES_CACHE;
-__PACKAGE__->RegisterRights;
+our %LIFECYCLES_TYPES;
 
 # cache structure:
 #    {
-#        '' => { # all valid statuses
-#            '' => [...],
-#            initial => [...],
-#            active => [...],
-#            inactive => [...],
-#        },
 #        lifecycle_x => {
 #            '' => [...], # all valid in lifecycle
 #            initial => [...],
@@ -119,66 +113,111 @@ sub new {
     return $self;
 }
 
-=head2 Load
+=head2 Load Name => I<NAME>, Type => I<TYPE>
 
-Takes a name of the lifecycle and loads it. If name is empty or undefined then
-loads the global lifecycle with statuses from all named lifecycles.
+Takes a name of the lifecycle and loads it. If only a Type is provided,
+loads the global lifecycle with statuses from all named lifecycles of
+that type.
 
 Can be called as class method, returns a new object, for example:
 
-    my $lifecycle = RT::Lifecycle->Load('default');
+    my $lifecycle = RT::Lifecycle->Load( Name => 'default');
+
+Returns an object which may be a subclass of L<RT::Lifecycle>
+(L<RT::Lifecycle::Ticket>, for example) depending on the type of the
+lifecycle in question.
 
 =cut
 
 sub Load {
     my $self = shift;
-    my $name = shift || '';
-    return $self->new->Load( $name, @_ )
+    return $self->new->Load( @_ )
         unless ref $self;
 
-    return unless exists $LIFECYCLES_CACHE{ $name };
+    unshift @_, Type => "ticket", "Name"
+        if @_ % 2;
+
+    my %args = (
+        Type => "ticket",
+        Name => '',
+        @_,
+    );
+
+    if (defined $args{Name} and exists $LIFECYCLES_CACHE{ $args{Name} }) {
+        $self->{'name'} = $args{Name};
+        $self->{'data'} = $LIFECYCLES_CACHE{ $args{Name} };
+        $self->{'type'} = $args{Type};
+
+        my $found_type = $self->{'data'}{'type'};
+        warn "Found type of $found_type ne $args{Type}" if $found_type ne $args{Type};
+    } elsif (not $args{Name} and exists $LIFECYCLES_TYPES{ $args{Type} }) {
+        $self->{'data'} = $LIFECYCLES_TYPES{ $args{Type} };
+        $self->{'type'} = $args{Type};
+    } else {
+        return undef;
+    }
 
-    $self->{'name'} = $name;
-    $self->{'data'} = $LIFECYCLES_CACHE{ $name };
+    my $class = "RT::Lifecycle::".ucfirst($args{Type});
+    bless $self, $class if $class->require;
 
     return $self;
 }
 
 =head2 List
 
-Returns sorted list of the lifecycles' names.
+List available lifecycles. This list omits RT's default approvals
+lifecycle.
+
+Takes: An optional parameter for lifecycle types other than tickets.
+       Defaults to 'ticket'.
+
+Returns: A sorted list of available lifecycles.
 
 =cut
 
 sub List {
     my $self = shift;
+    my $for = shift || 'ticket';
+
+    return grep { $_ ne 'approvals' } $self->ListAll( $for );
+}
+
+=head2 ListAll
+
+Returns a list of all lifecycles, including approvals.
+
+Takes: An optional parameter for lifecycle types other than tickets.
+       Defaults to 'ticket'.
+
+Returns: A sorted list of all available lifecycles.
+
+=cut
+
+sub ListAll {
+    my $self = shift;
+    my $for = shift || 'ticket';
 
     $self->FillCache unless keys %LIFECYCLES_CACHE;
 
-    return sort grep length && $_ ne '__maps__', keys %LIFECYCLES_CACHE;
+    return sort grep {$LIFECYCLES_CACHE{$_}{type} eq $for}
+        grep $_ ne '__maps__', keys %LIFECYCLES_CACHE;
 }
 
 =head2 Name
 
-Returns name of the laoded lifecycle.
+Returns name of the loaded lifecycle.
 
 =cut
 
 sub Name { return $_[0]->{'name'} }
 
-=head2 Queues
+=head2 Type
 
-Returns L<RT::Queues> collection with queues that use this lifecycle.
+Returns the type of the loaded lifecycle.
 
 =cut
 
-sub Queues {
-    my $self = shift;
-    require RT::Queues;
-    my $queues = RT::Queues->new( RT->SystemUser );
-    $queues->Limit( FIELD => 'Lifecycle', VALUE => $self->Name );
-    return $queues;
-}
+sub Type { return $_[0]->{'type'} }
 
 =head2 Getting statuses and validating.
 
@@ -298,7 +337,7 @@ sub IsActive {
     return 0;
 }
 
-=head3 inactive
+=head3 Inactive
 
 Returns an array of all inactive statuses for this lifecycle.
 
@@ -309,7 +348,7 @@ sub Inactive {
     return $self->Valid('inactive');
 }
 
-=head3 is_inactive
+=head3 IsInactive
 
 Takes a value and returns true if value is a valid inactive status.
 Otherwise, returns false.
@@ -354,41 +393,6 @@ sub DefaultOnCreate {
     return $self->DefaultStatus('on_create');
 }
 
-
-=head3 DefaultOnMerge
-
-Returns the status that should be used when tickets
-are merged.
-
-=cut
-
-sub DefaultOnMerge {
-    my $self = shift;
-    return $self->DefaultStatus('on_merge');
-}
-
-=head3 ReminderStatusOnOpen
-
-Returns the status that should be used when reminders are opened.
-
-=cut
-
-sub ReminderStatusOnOpen {
-    my $self = shift;
-    return $self->DefaultStatus('reminder_on_open') || 'open';
-}
-
-=head3 ReminderStatusOnResolve
-
-Returns the status that should be used when reminders are resolved.
-
-=cut
-
-sub ReminderStatusOnResolve {
-    my $self = shift;
-    return $self->DefaultStatus('reminder_on_resolve') || 'resolved';
-}
-
 =head2 Transitions, rights, labels and actions.
 
 =head3 Transitions
@@ -411,8 +415,8 @@ sub Transitions {
     return %{ $self->{'data'}{'transitions'} || {} }
         unless @_;
 
-    my $status = shift;
-    return @{ $self->{'data'}{'transitions'}{ $status || '' } || [] };
+    my $status = shift || '';
+    return @{ $self->{'data'}{'transitions'}{ lc $status } || [] };
 }
 
 =head1 IsTransition
@@ -439,8 +443,8 @@ be checked on the ticket.
 
 sub CheckRight {
     my $self = shift;
-    my $from = shift;
-    my $to = shift;
+    my $from = lc shift;
+    my $to = lc shift;
     if ( my $rights = $self->{'data'}{'rights'} ) {
         my $check =
             $rights->{ $from .' -> '. $to }
@@ -452,33 +456,7 @@ sub CheckRight {
     return $to eq 'deleted' ? 'DeleteTicket' : 'ModifyTicket';
 }
 
-=head3 RegisterRights
-
-Registers all defined rights in the system, so they can be addigned
-to users. No need to call it, as it's called when module is loaded.
-
-=cut
-
-sub RegisterRights {
-    my $self = shift;
-
-    my %rights = $self->RightsDescription;
-
-    require RT::ACE;
-
-    require RT::Queue;
-    my $RIGHTS = $RT::Queue::RIGHTS;
-
-    while ( my ($right, $description) = each %rights ) {
-        next if exists $RIGHTS->{ $right };
-
-        $RIGHTS->{ $right } = $description;
-        RT::Queue->AddRightCategories( $right => 'Status' );
-        $RT::ACE::LOWERCASERIGHTNAMES{ lc $right } = $right;
-    }
-}
-
-=head3 RightsDescription
+=head3 RightsDescription [TYPE]
 
 Returns hash with description of rights that are defined for
 particular transitions.
@@ -487,12 +465,14 @@ particular transitions.
 
 sub RightsDescription {
     my $self = shift;
+    my $type = shift;
 
     $self->FillCache unless keys %LIFECYCLES_CACHE;
 
     my %tmp;
     foreach my $lifecycle ( values %LIFECYCLES_CACHE ) {
         next unless exists $lifecycle->{'rights'};
+        next if $type and $lifecycle->{type} ne $type;
         while ( my ($transition, $right) = each %{ $lifecycle->{'rights'} } ) {
             push @{ $tmp{ $right } ||=[] }, $transition;
         }
@@ -536,10 +516,11 @@ pairs:
 sub Actions {
     my $self = shift;
     my $from = shift || return ();
+    $from = lc $from;
 
     $self->FillCache unless keys %LIFECYCLES_CACHE;
 
-    my @res = grep $_->{'from'} eq $from || ( $_->{'from'} eq '*' && $_->{'to'} ne $from ),
+    my @res = grep lc $_->{'from'} eq $from || ( $_->{'from'} eq '*' && lc $_->{'to'} ne $from ),
         @{ $self->{'data'}{'actions'} };
 
     # skip '* -> x' if there is '$from -> x'
@@ -561,7 +542,7 @@ move map from this cycle to provided.
 sub MoveMap {
     my $from = shift; # self
     my $to = shift;
-    $to = RT::Lifecycle->Load( $to ) unless ref $to;
+    $to = RT::Lifecycle->Load( Name => $to, Type => $from->Type ) unless ref $to;
     return $LIFECYCLES{'__maps__'}{ $from->Name .' -> '. $to->Name } || {};
 }
 
@@ -589,13 +570,14 @@ move maps.
 
 sub NoMoveMaps {
     my $self = shift;
-    my @list = $self->List;
+    my $type = $self->Type;
+    my @list = $self->List( $type );
     my @res;
     foreach my $from ( @list ) {
         foreach my $to ( @list ) {
             next if $from eq $to;
             push @res, $from, $to
-                unless RT::Lifecycle->Load( $from )->HasMoveMap( $to );
+                unless RT::Lifecycle->Load( Name => $from, Type => $type )->HasMoveMap( $to );
         }
     }
     return @res;
@@ -616,7 +598,7 @@ sub ForLocalization {
 
     my @res = ();
 
-    push @res, @{ $LIFECYCLES_CACHE{''}{''} || [] };
+    push @res, @{$_->{''}} for values %LIFECYCLES_TYPES;
     foreach my $lifecycle ( values %LIFECYCLES ) {
         push @res,
             grep defined && length,
@@ -633,59 +615,179 @@ sub ForLocalization {
 
 sub loc { return RT->SystemUser->loc( @_ ) }
 
+sub CanonicalCase {
+    my $self = shift;
+    my ($status) = @_;
+    return undef unless defined $status;
+    return($self->{data}{canonical_case}{lc $status} || lc $status);
+}
+
 sub FillCache {
     my $self = shift;
 
     my $map = RT->Config->Get('Lifecycles') or return;
 
+    {
+        my @lifecycles;
+
+        # if users are upgrading from 3.* where we don't have lifecycle column yet,
+        # this could die. we also don't want to frighten them by the errors out
+        eval {
+            local $RT::Logger = Log::Dispatch->new;
+            @lifecycles = grep { defined } RT::Queues->new( RT->SystemUser )->DistinctFieldValues( 'Lifecycle' );
+        };
+        unless ( $@ ) {
+            for my $name ( @lifecycles ) {
+                unless ( $map->{$name} ) {
+                    warn "Lifecycle $name is missing in %Lifecycles config";
+                }
+            }
+        }
+    }
+
     %LIFECYCLES_CACHE = %LIFECYCLES = %$map;
     $_ = { %$_ } foreach values %LIFECYCLES_CACHE;
 
-    my %all = (
-        '' => [],
-        initial => [],
-        active => [],
-        inactive => [],
-    );
-    foreach my $lifecycle ( values %LIFECYCLES_CACHE ) {
-        my @res;
-        foreach my $type ( qw(initial active inactive) ) {
-            push @{ $all{ $type } }, @{ $lifecycle->{ $type } || [] };
-            push @res,               @{ $lifecycle->{ $type } || [] };
+    foreach my $name ( keys %LIFECYCLES_CACHE ) {
+        next if $name eq "__maps__";
+        my $lifecycle = $LIFECYCLES_CACHE{$name};
+
+        my $type = $lifecycle->{type} ||= 'ticket';
+        $LIFECYCLES_TYPES{$type} ||= {
+            '' => [],
+            initial => [],
+            active => [],
+            inactive => [],
+            actions => [],
+        };
+
+        my @statuses;
+        $lifecycle->{canonical_case} = {};
+        foreach my $category ( qw(initial active inactive) ) {
+            for my $status (@{ $lifecycle->{ $category } || [] }) {
+                if (exists $lifecycle->{canonical_case}{lc $status}) {
+                    warn "Duplicate status @{[lc $status]} in lifecycle $name";
+                } else {
+                    $lifecycle->{canonical_case}{lc $status} = $status;
+                }
+                push @{ $LIFECYCLES_TYPES{$type}{$category} }, $status;
+                push @statuses, $status;
+            }
+        }
+
+        # Lower-case for consistency
+        # ->{actions} are handled below
+        for my $state (keys %{ $lifecycle->{defaults} || {} }) {
+            my $status = $lifecycle->{defaults}{$state};
+            warn "Nonexistant status @{[lc $status]} in default states in $name lifecycle"
+                unless $lifecycle->{canonical_case}{lc $status};
+            $lifecycle->{defaults}{$state} =
+                $lifecycle->{canonical_case}{lc $status} || lc $status;
+        }
+        for my $from (keys %{ $lifecycle->{transitions} || {} }) {
+            warn "Nonexistant status @{[lc $from]} in transitions in $name lifecycle"
+                unless $from eq '' or $lifecycle->{canonical_case}{lc $from};
+            for my $status ( @{delete($lifecycle->{transitions}{$from}) || []} ) {
+                warn "Nonexistant status @{[lc $status]} in transitions in $name lifecycle"
+                    unless $lifecycle->{canonical_case}{lc $status};
+                push @{ $lifecycle->{transitions}{lc $from} },
+                    $lifecycle->{canonical_case}{lc $status} || lc $status;
+            }
+        }
+        for my $schema (keys %{ $lifecycle->{rights} || {} }) {
+            my ($from, $to) = split /\s*->\s*/, $schema, 2;
+            unless ($from and $to) {
+                warn "Invalid right transition $schema in $name lifecycle";
+                next;
+            }
+            warn "Nonexistant status @{[lc $from]} in right transition in $name lifecycle"
+                unless $from eq '*' or $lifecycle->{canonical_case}{lc $from};
+            warn "Nonexistant status @{[lc $to]} in right transition in $name lifecycle"
+                unless $to eq '*' or $lifecycle->{canonical_case}{lc $to};
+
+            warn "Invalid right name ($lifecycle->{rights}{$schema}) in $name lifecycle; right names must be ASCII"
+                if $lifecycle->{rights}{$schema} =~ /\P{ASCII}/;
+
+            warn "Invalid right name ($lifecycle->{rights}{$schema}) in $name lifecycle; right names must be <= 25 characters"
+                if length($lifecycle->{rights}{$schema}) > 25;
+
+            $lifecycle->{rights}{lc($from) . " -> " .lc($to)}
+                = delete $lifecycle->{rights}{$schema};
         }
 
         my %seen;
-        @res = grep !$seen{ lc $_ }++, @res;
-        $lifecycle->{''} = \@res;
+        @statuses = grep !$seen{ lc $_ }++, @statuses;
+        $lifecycle->{''} = \@statuses;
 
         unless ( $lifecycle->{'transitions'}{''} ) {
-            $lifecycle->{'transitions'}{''} = [ grep $_ ne 'deleted', @res ];
+            $lifecycle->{'transitions'}{''} = [ grep lc $_ ne 'deleted', @statuses ];
         }
-    }
-    foreach my $type ( qw(initial active inactive), '' ) {
-        my %seen;
-        @{ $all{ $type } } = grep !$seen{ lc $_ }++, @{ $all{ $type } };
-        push @{ $all{''} }, @{ $all{ $type } } if $type;
-    }
-    $LIFECYCLES_CACHE{''} = \%all;
 
-    foreach my $lifecycle ( values %LIFECYCLES_CACHE ) {
-        my @res;
+        my @actions;
         if ( ref $lifecycle->{'actions'} eq 'HASH' ) {
             foreach my $k ( sort keys %{ $lifecycle->{'actions'} } ) {
-                push @res, $k, $lifecycle->{'actions'}{ $k };
+                push @actions, $k, $lifecycle->{'actions'}{ $k };
             }
         } elsif ( ref $lifecycle->{'actions'} eq 'ARRAY' ) {
-            @res = @{ $lifecycle->{'actions'} };
+            @actions = @{ $lifecycle->{'actions'} };
         }
 
-        my @tmp = splice @res;
-        while ( my ($transition, $info) = splice @tmp, 0, 2 ) {
+        $lifecycle->{'actions'} = [];
+        while ( my ($transition, $info) = splice @actions, 0, 2 ) {
             my ($from, $to) = split /\s*->\s*/, $transition, 2;
-            push @res, { %$info, from => $from, to => $to };
+            unless ($from and $to) {
+                warn "Invalid action status change $transition in $name lifecycle";
+                next;
+            }
+            warn "Nonexistant status @{[lc $from]} in action in $name lifecycle"
+                unless $from eq '*' or $lifecycle->{canonical_case}{lc $from};
+            warn "Nonexistant status @{[lc $to]} in action in $name lifecycle"
+                unless $to eq '*' or $lifecycle->{canonical_case}{lc $to};
+            push @{ $lifecycle->{'actions'} },
+                { %$info,
+                  from => ($lifecycle->{canonical_case}{lc $from} || lc $from),
+                  to   => ($lifecycle->{canonical_case}{lc $to}   || lc $to),   };
+        }
+    }
+
+    # Lower-case the transition maps
+    for my $mapname (keys %{ $LIFECYCLES_CACHE{'__maps__'} || {} }) {
+        my ($from, $to) = split /\s*->\s*/, $mapname, 2;
+        unless ($from and $to) {
+            warn "Invalid lifecycle mapping $mapname";
+            next;
+        }
+        warn "Nonexistant lifecycle $from in $mapname lifecycle map"
+            unless $LIFECYCLES_CACHE{$from};
+        warn "Nonexistant lifecycle $to in $mapname lifecycle map"
+            unless $LIFECYCLES_CACHE{$to};
+        my $map = delete $LIFECYCLES_CACHE{'__maps__'}{$mapname};
+        $LIFECYCLES_CACHE{'__maps__'}{"$from -> $to"} = $map;
+        for my $status (keys %{ $map }) {
+            warn "Nonexistant status @{[lc $status]} in $from in $mapname lifecycle map"
+                if $LIFECYCLES_CACHE{$from}
+                    and not $LIFECYCLES_CACHE{$from}{canonical_case}{lc $status};
+            warn "Nonexistant status @{[lc $map->{$status}]} in $to in $mapname lifecycle map"
+                if $LIFECYCLES_CACHE{$to}
+                    and not $LIFECYCLES_CACHE{$to}{canonical_case}{lc $map->{$status}};
+            $map->{lc $status} = lc delete $map->{$status};
         }
-        $lifecycle->{'actions'} = \@res;
     }
+
+    for my $type (keys %LIFECYCLES_TYPES) {
+        for my $category ( qw(initial active inactive), '' ) {
+            my %seen;
+            @{ $LIFECYCLES_TYPES{$type}{$category} } =
+                grep !$seen{ lc $_ }++, @{ $LIFECYCLES_TYPES{$type}{$category} };
+            push @{ $LIFECYCLES_TYPES{$type}{''} },
+                @{ $LIFECYCLES_TYPES{$type}{$category} } if $category;
+        }
+
+        my $class = "RT::Lifecycle::".ucfirst($type);
+        $class->RegisterRights if $class->require
+            and $class->can("RegisterRights");
+    }
+
     return;
 }