TimeWorked-like custom fields, RT#11168
[freeside.git] / rt / lib / RT / Ticket_Overlay.pm
index f4664fd..e1809a3 100644 (file)
@@ -1,40 +1,40 @@
 # BEGIN BPS TAGGED BLOCK {{{
-# 
+#
 # COPYRIGHT:
-# 
-# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC
-#                                          <jesse@bestpractical.com>
-# 
+#
+# This software is Copyright (c) 1996-2011 Best Practical Solutions, LLC
+#                                          <sales@bestpractical.com>
+#
 # (Except where explicitly superseded by other copyright notices)
-# 
-# 
+#
+#
 # LICENSE:
-# 
+#
 # This work is made available to you under the terms of Version 2 of
 # the GNU General Public License. A copy of that license should have
 # been provided with this software, but in any event can be snarfed
 # from www.gnu.org.
-# 
+#
 # This work is distributed in the hope that it will be useful, but
 # WITHOUT ANY WARRANTY; without even the implied warranty of
 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 # General Public License for more details.
-# 
+#
 # You should have received a copy of the GNU General Public License
 # along with this program; if not, write to the Free Software
 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
 # 02110-1301 or visit their web page on the internet at
 # http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
-# 
-# 
+#
+#
 # CONTRIBUTION SUBMISSION POLICY:
-# 
+#
 # (The following paragraph is not intended to limit the rights granted
 # to you to modify and distribute this software under the terms of
 # the GNU General Public License and is only of importance to you if
 # you choose to contribute your changes and enhancements to the
 # community by submitting them to Best Practical Solutions, LLC.)
-# 
+#
 # By intentionally submitting any modifications, corrections or
 # derivatives to this work, or any other work intended for use with
 # Request Tracker, to Best Practical Solutions, LLC, you confirm that
@@ -43,7 +43,7 @@
 # royalty-free, perpetual, license to use, copy, create derivative
 # works based on those contributions, and sublicense and distribute
 # those contributions and any derivatives thereof.
-# 
+#
 # END BPS TAGGED BLOCK }}}
 
 # {{{ Front Material 
@@ -135,6 +135,11 @@ our %LINKDIRMAP = (
 sub LINKTYPEMAP   { return \%LINKTYPEMAP   }
 sub LINKDIRMAP   { return \%LINKDIRMAP   }
 
+our %MERGE_CACHE = (
+    effective => {},
+    merged => {},
+);
+
 # {{{ sub Load
 
 =head2 Load
@@ -148,47 +153,46 @@ Otherwise, returns the ticket id.
 sub Load {
     my $self = shift;
     my $id   = shift;
+    $id = '' unless defined $id;
 
-    #TODO modify this routine to look at EffectiveId and do the recursive load
-    # thing. be careful to cache all the interim tickets we try so we don't loop forever.
+    # TODO: modify this routine to look at EffectiveId and
+    # do the recursive load thing. be careful to cache all
+    # the interim tickets we try so we don't loop forever.
 
     # FIXME: there is no TicketBaseURI option in config
     my $base_uri = RT->Config->Get('TicketBaseURI') || '';
     #If it's a local URI, turn it into a ticket id
-    if ( $base_uri && defined $id && $id =~ /^$base_uri(\d+)$/ ) {
+    if ( $base_uri && $id =~ /^$base_uri(\d+)$/ ) {
         $id = $1;
     }
 
-    #If it's a remote URI, we're going to punt for now
-    elsif ( $id =~ '://' ) {
+    unless ( $id =~ /^\d+$/ ) {
+        $RT::Logger->debug("Tried to load a bogus ticket id: '$id'");
         return (undef);
     }
 
-    #If we have an integer URI, load the ticket
-    if ( defined $id && $id =~ /^\d+$/ ) {
-        my ($ticketid,$msg) = $self->LoadById($id);
-
-        unless ($self->Id) {
-            $RT::Logger->debug("$self tried to load a bogus ticket: $id");
-            return (undef);
-        }
-    }
+    $id = $MERGE_CACHE{'effective'}{ $id }
+        if $MERGE_CACHE{'effective'}{ $id };
 
-    #It's not a URI. It's not a numerical ticket ID. Punt!
-    else {
-        $RT::Logger->debug("Tried to load a bogus ticket id: '$id'");
+    my ($ticketid, $msg) = $self->LoadById( $id );
+    unless ( $self->Id ) {
+        $RT::Logger->debug("$self tried to load a bogus ticket: $id");
         return (undef);
     }
 
     #If we're merged, resolve the merge.
-    if ( ( $self->EffectiveId ) and ( $self->EffectiveId != $self->Id ) ) {
-        $RT::Logger->debug ("We found a merged ticket.". $self->id ."/".$self->EffectiveId);
-        return ( $self->Load( $self->EffectiveId ) );
+    if ( $self->EffectiveId && $self->EffectiveId != $self->Id ) {
+        $RT::Logger->debug(
+            "We found a merged ticket. "
+            . $self->id ."/". $self->EffectiveId
+        );
+        my $real_id = $self->Load( $self->EffectiveId );
+        $MERGE_CACHE{'effective'}{ $id } = $real_id;
+        return $real_id;
     }
 
     #Ok. we're loaded. lets get outa here.
-    return ( $self->Id );
-
+    return $self->Id;
 }
 
 # }}}
@@ -225,7 +229,7 @@ Ticket links can be set up during create by passing the link type as a hask key
 the ticket id to be linked to as a value (or a URI when linking to other objects).
 Multiple links of the same type can be created by passing an array ref. For example:
 
-  Parent => 45,
+  Parents => 45,
   DependsOn => [ 15, 22 ],
   RefersTo => 'http://www.bestpractical.com',
 
@@ -556,68 +560,6 @@ sub Create {
 
     # }}}
 
-    # {{{ Deal with auto-customer association
-
-    #unless we already have (a) customer(s)...
-    unless ( $self->Customers->Count ) {
-
-      #first find any requestors with emails but *without* customer targets
-      my @NoCust_Requestors =
-        grep { $_->EmailAddress && ! $_->Customers->Count }
-             @{ $self->_Requestors->UserMembersObj->ItemsArrayRef };
-
-      for my $Requestor (@NoCust_Requestors) {
-
-         #perhaps the stuff in here should be in a User method??
-         my @Customers =
-           &RT::URI::freeside::email_search( email=>$Requestor->EmailAddress );
-
-         foreach my $custnum ( map $_->{'custnum'}, @Customers ) {
-
-           ## false laziness w/RT/Interface/Web_Vendor.pm
-           my @link = ( 'Type'   => 'MemberOf',
-                        'Target' => "freeside://freeside/cust_main/$custnum",
-                      );
-
-           my( $val, $msg ) = $Requestor->_AddLink(@link);
-           #XXX should do something with $msg# push @non_fatal_errors, $msg;
-
-         }
-
-      }
-
-      #find any requestors with customer targets
-  
-      my %cust_target = ();
-
-      my @Requestors =
-        grep { $_->Customers->Count }
-             @{ $self->_Requestors->UserMembersObj->ItemsArrayRef };
-  
-      foreach my $Requestor ( @Requestors ) {
-        foreach my $cust_link ( @{ $Requestor->Customers->ItemsArrayRef } ) {
-          $cust_target{ $cust_link->Target } = 1;
-        }
-      }
-  
-      #and then auto-associate this ticket with those customers
-  
-      foreach my $cust_target ( keys %cust_target ) {
-  
-        my @link = ( 'Type'   => 'MemberOf',
-                     #'Target' => "freeside://freeside/cust_main/$custnum",
-                     'Target' => $cust_target,
-                   );
-  
-        my( $val, $msg ) = $self->_AddLink(@link);
-        push @non_fatal_errors, $msg;
-  
-      }
-
-    }
-
-    # }}}
-
     # {{{ Add all the custom fields
 
     foreach my $arg ( keys %args ) {
@@ -675,11 +617,16 @@ sub Create {
                     next;
                 }
             }
-            
+
+            #don't show transactions for reminders
+            my $silent = ( !$args{'_RecordTransaction'}
+                           || $self->Type eq 'reminder'
+                         );
+
             my ( $wval, $wmsg ) = $self->_AddLink(
                 Type                          => $LINKTYPEMAP{$type}->{'Type'},
                 $LINKTYPEMAP{$type}->{'Mode'} => $link,
-                Silent                        => !$args{'_RecordTransaction'},
+                Silent                        => $silent,
                 'Silent'. ( $LINKTYPEMAP{$type}->{'Mode'} eq 'Base'? 'Target': 'Base' )
                                               => 1,
             );
@@ -689,6 +636,69 @@ sub Create {
     }
 
     # }}}
+
+    # {{{ Deal with auto-customer association
+
+    #unless we already have (a) customer(s)...
+    unless ( $self->Customers->Count ) {
+
+      #first find any requestors with emails but *without* customer targets
+      my @NoCust_Requestors =
+        grep { $_->EmailAddress && ! $_->Customers->Count }
+             @{ $self->_Requestors->UserMembersObj->ItemsArrayRef };
+
+      for my $Requestor (@NoCust_Requestors) {
+
+         #perhaps the stuff in here should be in a User method??
+         my @Customers =
+           &RT::URI::freeside::email_search( email=>$Requestor->EmailAddress );
+
+         foreach my $custnum ( map $_->{'custnum'}, @Customers ) {
+
+           ## false laziness w/RT/Interface/Web_Vendor.pm
+           my @link = ( 'Type'   => 'MemberOf',
+                        'Target' => "freeside://freeside/cust_main/$custnum",
+                      );
+
+           my( $val, $msg ) = $Requestor->_AddLink(@link);
+           #XXX should do something with $msg# push @non_fatal_errors, $msg;
+
+         }
+
+      }
+
+      #find any requestors with customer targets
+  
+      my %cust_target = ();
+
+      my @Requestors =
+        grep { $_->Customers->Count }
+             @{ $self->_Requestors->UserMembersObj->ItemsArrayRef };
+  
+      foreach my $Requestor ( @Requestors ) {
+        foreach my $cust_link ( @{ $Requestor->Customers->ItemsArrayRef } ) {
+          $cust_target{ $cust_link->Target } = 1;
+        }
+      }
+  
+      #and then auto-associate this ticket with those customers
+  
+      foreach my $cust_target ( keys %cust_target ) {
+  
+        my @link = ( 'Type'   => 'MemberOf',
+                     #'Target' => "freeside://freeside/cust_main/$custnum",
+                     'Target' => $cust_target,
+                   );
+  
+        my( $val, $msg ) = $self->_AddLink(@link);
+        push @non_fatal_errors, $msg;
+  
+      }
+
+    }
+
+    # }}}
+
     # Now that we've created the ticket and set up its metadata, we can actually go and check OwnTicket on the ticket itself. 
     # This might be different than before in cases where extensions like RTIR are doing clever things with RT's ACL system
     if (  $DeferOwner ) { 
@@ -711,7 +721,8 @@ sub Create {
         );
     }
 
-    if ( $args{'_RecordTransaction'} ) {
+    #don't make a transaction or fire off any scrips for reminders either
+    if ( $args{'_RecordTransaction'} && $self->Type ne 'reminder' ) {
 
         # {{{ Add a transaction for the create
         my ( $Trans, $Msg, $TransObj ) = $self->_NewTransaction(
@@ -723,7 +734,8 @@ sub Create {
 
         if ( $self->Id && $Trans ) {
 
-            $TransObj->UpdateCustomFields(ARGSRef => \%args);
+          #$TransObj->UpdateCustomFields(ARGSRef => \%args);
+            $TransObj->UpdateCustomFields(%args);
 
             $RT::Logger->info( "Ticket " . $self->Id . " created in queue '" . $QueueObj->Name . "' by " . $self->CurrentUser->Name );
             $ErrStr = $self->loc( "Ticket [_1] created in queue '[_2]'", $self->Id, $QueueObj->Name );
@@ -1160,12 +1172,20 @@ sub _AddWatcher {
 
     my $principal = RT::Principal->new($self->CurrentUser);
     if ($args{'Email'}) {
+        if ( RT::EmailParser->IsRTAddress( $args{'Email'} ) ) {
+            return (0, $self->loc("[_1] is an address RT receives mail at. Adding it as a '[_2]' would create a mail loop", $args{'Email'}, $self->loc($args{'Type'})));
+        }
         my $user = RT::User->new($RT::SystemUser);
         my ($pid, $msg) = $user->LoadOrCreateByEmail( $args{'Email'} );
         $args{'PrincipalId'} = $pid if $pid; 
     }
     if ($args{'PrincipalId'}) {
         $principal->Load($args{'PrincipalId'});
+        if ( $principal->id and $principal->IsUser and my $email = $principal->Object->EmailAddress ) {
+            return (0, $self->loc("[_1] is an address RT receives mail at. Adding it as a '[_2]' would create a mail loop", $email, $self->loc($args{'Type'})))
+                if RT::EmailParser->IsRTAddress( $email );
+
+        }
     } 
 
  
@@ -1696,8 +1716,9 @@ sub IsOwner {
 
 =head2 TransactionAddresses
 
-Returns a composite hashref of the results of L<RT::Transaction/Addresses> for all this ticket's Create, Comment or Correspond transactions.
-The keys are C<To>, C<Cc> and C<Bcc>. The values are lists of C<Email::Address> objects.
+Returns a composite hashref of the results of L<RT::Transaction/Addresses> for
+all this ticket's Create, Comment or Correspond transactions. The keys are
+stringified email addresses. Each value is an L<Email::Address> object.
 
 NOTE: For performance reasons, this method might want to skip transactions and go straight for attachments. But to make that work right, we're going to need to go and walk around the access control in Attachment.pm's sub _Value.
 
@@ -2202,6 +2223,7 @@ sub _RecordNote {
         NoteType     => 'Correspond',
         TimeTaken    => 0,
         CommitScrips => 1,
+        CustomFields => {},
         @_
     );
 
@@ -2230,13 +2252,13 @@ sub _RecordNote {
             my $addresses = join ', ', (
                 map { RT::User->CanonicalizeEmailAddress( $_->address ) }
                     Email::Address->parse( $args{ $type . 'MessageTo' } ) );
-            $args{'MIMEObj'}->head->add( 'RT-Send-' . $type, $addresses );
+            $args{'MIMEObj'}->head->add( 'RT-Send-' . $type, Encode::encode_utf8( $addresses ) );
         }
     }
 
     foreach my $argument (qw(Encrypt Sign)) {
         $args{'MIMEObj'}->head->add(
-            "X-RT-$argument" => $args{ $argument }
+            "X-RT-$argument" => Encode::encode_utf8( $args{ $argument } )
         ) if defined $args{ $argument };
     }
 
@@ -2258,6 +2280,7 @@ sub _RecordNote {
              TimeTaken => $args{'TimeTaken'},
              MIMEObj   => $args{'MIMEObj'}, 
              CommitScrips => $args{'CommitScrips'},
+             CustomFields => $args{'CustomFields'},
     );
 
     unless ($Trans) {
@@ -2282,34 +2305,45 @@ sub _Links {
     my $field = shift;
     my $type  = shift || "";
 
-    unless ( $self->{"$field$type"} ) {
-        $self->{"$field$type"} = new RT::Links( $self->CurrentUser );
-
-        #not sure what this ACL was supposed to do... but returning the
-        # bare (unlimited) RT::Links certainly seems wrong, it causes the
-        # $Ticket->Customers method during creation to return results for every
-        # ticket...
-        #if ( $self->CurrentUserHasRight('ShowTicket') ) {
-
-            # Maybe this ticket is a merged ticket
-            my $Tickets = new RT::Tickets( $self->CurrentUser );
-            # at least to myself
-            $self->{"$field$type"}->Limit( FIELD => $field,
-                                           VALUE => $self->URI,
-                                           ENTRYAGGREGATOR => 'OR' );
-            $Tickets->Limit( FIELD => 'EffectiveId',
-                             VALUE => $self->EffectiveId );
-            while (my $Ticket = $Tickets->Next) {
-                $self->{"$field$type"}->Limit( FIELD => $field,
-                                               VALUE => $Ticket->URI,
-                                               ENTRYAGGREGATOR => 'OR' );
-            }
-            $self->{"$field$type"}->Limit( FIELD => 'Type',
-                                           VALUE => $type )
-              if ($type);
-        #}
-    }
-    return ( $self->{"$field$type"} );
+    my $cache_key = "$field$type";
+    return $self->{ $cache_key } if $self->{ $cache_key };
+
+    my $links = $self->{ $cache_key }
+              = RT::Links->new( $self->CurrentUser );
+    unless ( $self->CurrentUserHasRight('ShowTicket') ) {
+        $links->Limit( FIELD => 'id', VALUE => 0 );
+        return $links;
+    }
+
+    # without this you will also get RT::User(s) instead of tickets!
+    if ($field == 'Base' and $type == 'MemberOf') {
+        my $rtname = RT->Config->Get('rtname');
+        $links->Limit(
+                       FIELD    => 'Base',
+                       OPERATOR => 'STARTSWITH',
+                       VALUE    => "fsck.com-rt://$rtname/ticket/",
+                     );
+    }
+
+    # Maybe this ticket is a merge ticket
+    my $limit_on = 'Local'. $field;
+    # at least to myself
+    $links->Limit(
+        FIELD           => $limit_on,
+        VALUE           => $self->id,
+        ENTRYAGGREGATOR => 'OR',
+    );
+    $links->Limit(
+        FIELD           => $limit_on,
+        VALUE           => $_,
+        ENTRYAGGREGATOR => 'OR',
+    ) foreach $self->Merged;
+    $links->Limit(
+        FIELD => 'Type',
+        VALUE => $type,
+    ) if $type;
+
+    return $links;
 }
 
 # }}}
@@ -2551,8 +2585,6 @@ sub _AddLink {
 
 MergeInto take the id of the ticket to merge this ticket into.
 
-
-
 =cut
 
 sub MergeInto {
@@ -2564,7 +2596,7 @@ sub MergeInto {
     }
 
     # Load up the new ticket.
-    my $MergeInto = RT::Ticket->new($RT::SystemUser);
+    my $MergeInto = RT::Ticket->new($self->CurrentUser);
     $MergeInto->Load($ticket_id);
 
     # make sure it exists.
@@ -2577,6 +2609,11 @@ sub MergeInto {
         return ( 0, $self->loc("Permission Denied") );
     }
 
+    delete $MERGE_CACHE{'effective'}{ $self->id };
+    delete @{ $MERGE_CACHE{'merged'} }{
+        $ticket_id, $MergeInto->id, $self->id
+    };
+
     $RT::Handle->BeginTransaction();
 
     # We use EffectiveId here even though it duplicates information from
@@ -2726,18 +2763,22 @@ Returns list of tickets' ids that's been merged into this ticket.
 sub Merged {
     my $self = shift;
 
-    my $mergees = new RT::Tickets( $self->CurrentUser );
+    my $id = $self->id;
+    return @{ $MERGE_CACHE{'merged'}{ $id } }
+        if $MERGE_CACHE{'merged'}{ $id };
+
+    my $mergees = RT::Tickets->new( $self->CurrentUser );
     $mergees->Limit(
         FIELD    => 'EffectiveId',
-        OPERATOR => '=',
-        VALUE    => $self->Id,
+        VALUE    => $id,
     );
     $mergees->Limit(
         FIELD    => 'id',
         OPERATOR => '!=',
-        VALUE    => $self->Id,
+        VALUE    => $id,
     );
-    return map $_->id, @{ $mergees->ItemsArrayRef || [] };
+    return @{ $MERGE_CACHE{'merged'}{ $id } ||= [] }
+        = map $_->id, @{ $mergees->ItemsArrayRef || [] };
 }
 
 # }}}