+# 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
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions, LLC a nonexclusive, worldwide, irrevocable,
+# 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 }}}
+
+=head1 SYNOPSIS
+
+ use RT::Ticket;
+ my $ticket = RT::Ticket->new($CurrentUser);
+ $ticket->Load($ticket_id);
+
+=head1 DESCRIPTION
+
+This module lets you manipulate RT's ticket object.
+
+
+=head1 METHODS
+
+
+=cut
+
+
+package RT::Ticket;
+
+use strict;
+use warnings;
+use base 'RT::Record';
+
+use Role::Basic 'with';
+
+# SetStatus and _SetStatus are reimplemented below (using other pieces of the
+# role) to deal with ACLs, moving tickets between queues, and automatically
+# setting dates.
+with "RT::Record::Role::Status" => { -excludes => [qw(SetStatus _SetStatus)] },
+ "RT::Record::Role::Links",
+ "RT::Record::Role::Roles";
+
+use RT::Queue;
+use RT::User;
+use RT::Record;
+use RT::Link;
+use RT::Links;
+use RT::Date;
+use RT::CustomFields;
+use RT::Tickets;
+use RT::Transactions;
+use RT::Reminders;
+use RT::URI::fsck_com_rt;
+use RT::URI;
+use RT::URI::freeside;
+use MIME::Entity;
+use Devel::GlobalDestruction;
+
+sub LifecycleColumn { "Queue" }
+
+my %ROLES = (
+ # name => description
+ Owner => 'The owner of a ticket', # loc_pair
+ Requestor => 'The requestor of a ticket', # loc_pair
+ Cc => 'The CC of a ticket', # loc_pair
+ AdminCc => 'The administrative CC of a ticket', # loc_pair
+);
+
+for my $role (sort keys %ROLES) {
+ RT::Ticket->RegisterRole(
+ Name => $role,
+ EquivClasses => ['RT::Queue'],
+ ( $role eq "Owner" ? ( Column => "Owner") : () ),
+ ( $role !~ /Cc/ ? ( ACLOnlyInEquiv => 1) : () ),
+ );
+}
+
+our %MERGE_CACHE = (
+ effective => {},
+ merged => {},
+);
+
+
+=head2 Load
+
+Takes a single argument. This can be a ticket id, ticket alias or
+local ticket uri. If the ticket can't be loaded, returns undef.
+Otherwise, returns the ticket id.
+
+=cut
+
+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.
+
+ unless ( $id =~ /^\d+$/ ) {
+ $RT::Logger->debug("Tried to load a bogus ticket id: '$id'");
+ return (undef);
+ }
+
+ $id = $MERGE_CACHE{'effective'}{ $id }
+ if $MERGE_CACHE{'effective'}{ $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 && $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;
+}
+
+
+
+=head2 Create (ARGS)
+
+Arguments: ARGS is a hash of named parameters. Valid parameters are:
+
+ id
+ Queue - Either a Queue object or a Queue Name
+ Requestor - A reference to a list of email addresses or RT user Names
+ Cc - A reference to a list of email addresses or Names
+ AdminCc - A reference to a list of email addresses or Names
+ SquelchMailTo - A reference to a list of email addresses -
+ who should this ticket not mail
+ Type -- The ticket's type. ignore this for now
+ Owner -- This ticket's owner. either an RT::User object or this user's id
+ Subject -- A string describing the subject of the ticket
+ Priority -- an integer from 0 to 99
+ InitialPriority -- an integer from 0 to 99
+ FinalPriority -- an integer from 0 to 99
+ Status -- any valid status for Queue's Lifecycle, otherwises uses on_create from Lifecycle default
+ TimeEstimated -- an integer. estimated time for this task in minutes
+ TimeWorked -- an integer. time worked so far in minutes
+ TimeLeft -- an integer. time remaining in minutes
+ Starts -- an ISO date describing the ticket's start date and time in GMT
+ Due -- an ISO date describing the ticket's due date and time in GMT
+ MIMEObj -- a MIME::Entity object with the content of the initial ticket request.
+ CustomField-<n> -- a scalar or array of values for the customfield with the id <n>
+
+Ticket links can be set up during create by passing the link type as a hask key and
+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:
+
+ Parents => 45,
+ DependsOn => [ 15, 22 ],
+ RefersTo => 'http://www.bestpractical.com',
+
+Supported link types are C<MemberOf>, C<HasMember>, C<RefersTo>, C<ReferredToBy>,
+C<DependsOn> and C<DependedOnBy>. Also, C<Parents> is alias for C<MemberOf> and
+C<Members> and C<Children> are aliases for C<HasMember>.
+
+Returns: TICKETID, Transaction Object, Error Message
+
+
+=cut
+
+sub Create {
+ my $self = shift;
+
+ my %args = (
+ id => undef,
+ EffectiveId => undef,
+ Queue => undef,
+ Requestor => undef,
+ Cc => undef,
+ AdminCc => undef,
+ SquelchMailTo => undef,
+ TransSquelchMailTo => undef,
+ Type => 'ticket',
+ Owner => undef,
+ Subject => '',
+ InitialPriority => undef,
+ FinalPriority => undef,
+ Priority => undef,
+ Status => undef,
+ TimeWorked => "0",
+ TimeLeft => 0,
+ TimeEstimated => 0,
+ Due => undef,
+ Starts => undef,
+ Started => undef,
+ Resolved => undef,
+ WillResolve => undef,
+ MIMEObj => undef,
+ _RecordTransaction => 1,
+ DryRun => 0,
+ @_
+ );
+
+ my ($ErrStr, @non_fatal_errors);
+
+ my $QueueObj = RT::Queue->new( RT->SystemUser );
+ if ( ref $args{'Queue'} eq 'RT::Queue' ) {
+ $QueueObj->Load( $args{'Queue'}->Id );
+ }
+ elsif ( $args{'Queue'} ) {
+ $QueueObj->Load( $args{'Queue'} );
+ }
+ else {
+ $RT::Logger->debug("'". ( $args{'Queue'} ||''). "' not a recognised queue object." );
+ }
+
+ #Can't create a ticket without a queue.
+ unless ( $QueueObj->Id ) {
+ $RT::Logger->debug("$self No queue given for ticket creation.");
+ return ( 0, 0, $self->loc('Could not create ticket. Queue not set') );
+ }
+
+
+ #Now that we have a queue, Check the ACLS
+ unless (
+ $self->CurrentUser->HasRight(
+ Right => 'CreateTicket',
+ Object => $QueueObj
+ ) and $QueueObj->Disabled != 1
+ )
+ {
+ return (
+ 0, 0,
+ $self->loc( "No permission to create tickets in the queue '[_1]'", $QueueObj->Name));
+ }
+
+ my $cycle = $QueueObj->LifecycleObj;
+ unless ( defined $args{'Status'} && length $args{'Status'} ) {
+ $args{'Status'} = $cycle->DefaultOnCreate;
+ }
+
+ $args{'Status'} = lc $args{'Status'};
+ unless ( $cycle->IsValid( $args{'Status'} ) ) {
+ return ( 0, 0,
+ $self->loc("Status '[_1]' isn't a valid status for tickets in this queue.",
+ $self->loc($args{'Status'}))
+ );
+ }
+
+ unless ( $cycle->IsTransition( '' => $args{'Status'} ) ) {
+ return ( 0, 0,
+ $self->loc("New tickets can not have status '[_1]' in this queue.",
+ $self->loc($args{'Status'}))
+ );
+ }
+
+
+
+ #Since we have a queue, we can set queue defaults
+
+ #Initial Priority
+ # If there's no queue default initial priority and it's not set, set it to 0
+ $args{'InitialPriority'} = $QueueObj->InitialPriority || 0
+ unless defined $args{'InitialPriority'};
+
+ #Final priority
+ # If there's no queue default final priority and it's not set, set it to 0
+ $args{'FinalPriority'} = $QueueObj->FinalPriority || 0
+ unless defined $args{'FinalPriority'};
+
+ # Priority may have changed from InitialPriority, for the case
+ # where we're importing tickets (eg, from an older RT version.)
+ $args{'Priority'} = $args{'InitialPriority'}
+ unless defined $args{'Priority'};
+
+ # Dates
+ #TODO we should see what sort of due date we're getting, rather +
+ # than assuming it's in ISO format.
+
+ #Set the due date. if we didn't get fed one, use the queue default due in
+ my $Due = RT::Date->new( $self->CurrentUser );
+ if ( defined $args{'Due'} ) {
+ $Due->Set( Format => 'ISO', Value => $args{'Due'} );
+ }
+ elsif ( my $due_in = $QueueObj->DefaultDueIn ) {
+ $Due->SetToNow;
+ $Due->AddDays( $due_in );
+ }
+
+ my $Starts = RT::Date->new( $self->CurrentUser );
+ if ( defined $args{'Starts'} ) {
+ $Starts->Set( Format => 'ISO', Value => $args{'Starts'} );
+ }
+
+ my $Started = RT::Date->new( $self->CurrentUser );
+ if ( defined $args{'Started'} ) {
+ $Started->Set( Format => 'ISO', Value => $args{'Started'} );
+ }
+
+ my $WillResolve = RT::Date->new($self->CurrentUser );
+ if ( defined $args{'WillResolve'} ) {
+ $WillResolve->Set( Format => 'ISO', Value => $args{'WillResolve'} );
+ }
+
+ # If the status is not an initial status, set the started date
+ elsif ( !$cycle->IsInitial($args{'Status'}) ) {
+ $Started->SetToNow;
+ }
+
+ my $Resolved = RT::Date->new( $self->CurrentUser );
+ if ( defined $args{'Resolved'} ) {
+ $Resolved->Set( Format => 'ISO', Value => $args{'Resolved'} );
+ }
+
+ #If the status is an inactive status, set the resolved date
+ elsif ( $cycle->IsInactive( $args{'Status'} ) )
+ {
+ $RT::Logger->debug( "Got a ". $args{'Status'}
+ ."(inactive) ticket with undefined resolved date. Setting to now."
+ );
+ $Resolved->SetToNow;
+ }
+
+ # Dealing with time fields
+ $args{'TimeEstimated'} = 0 unless defined $args{'TimeEstimated'};
+ $args{'TimeWorked'} = 0 unless defined $args{'TimeWorked'};
+ $args{'TimeLeft'} = 0 unless defined $args{'TimeLeft'};
+
+ # Figure out users for roles
+ my $roles = {};
+ push @non_fatal_errors, $self->_ResolveRoles( $roles, %args );
+
+ $args{'Type'} = lc $args{'Type'}
+ if $args{'Type'} =~ /^(ticket|approval|reminder)$/i;
+
+ $args{'Subject'} =~ s/\n//g;
+
+ $RT::Handle->BeginTransaction();
+
+ my %params = (
+ Queue => $QueueObj->Id,
+ Subject => $args{'Subject'},
+ InitialPriority => $args{'InitialPriority'},
+ FinalPriority => $args{'FinalPriority'},
+ Priority => $args{'Priority'},
+ Status => $args{'Status'},
+ TimeWorked => $args{'TimeWorked'},
+ TimeEstimated => $args{'TimeEstimated'},
+ TimeLeft => $args{'TimeLeft'},
+ Type => $args{'Type'},
+ Starts => $Starts->ISO,
+ Started => $Started->ISO,
+ Resolved => $Resolved->ISO,
+ WillResolve => $WillResolve->ISO,
+ Due => $Due->ISO
+ );
+
+# Parameters passed in during an import that we probably don't want to touch, otherwise
+ foreach my $attr (qw(id Creator Created LastUpdated LastUpdatedBy)) {
+ $params{$attr} = $args{$attr} if $args{$attr};
+ }
+
+ # Delete null integer parameters
+ foreach my $attr
+ (qw(TimeWorked TimeLeft TimeEstimated InitialPriority FinalPriority))
+ {
+ delete $params{$attr}
+ unless ( exists $params{$attr} && $params{$attr} );
+ }
+
+ # Delete the time worked if we're counting it in the transaction
+ delete $params{'TimeWorked'} if $args{'_RecordTransaction'};
+
+ my ($id,$ticket_message) = $self->SUPER::Create( %params );
+ unless ($id) {
+ $RT::Logger->crit( "Couldn't create a ticket: " . $ticket_message );
+ $RT::Handle->Rollback();
+ return ( 0, 0,
+ $self->loc("Ticket could not be created due to an internal error")
+ );
+ }
+
+ #Set the ticket's effective ID now that we've created it.
+ my ( $val, $msg ) = $self->__Set(
+ Field => 'EffectiveId',
+ Value => ( $args{'EffectiveId'} || $id )
+ );
+ unless ( $val ) {
+ $RT::Logger->crit("Couldn't set EffectiveId: $msg");
+ $RT::Handle->Rollback;
+ return ( 0, 0,
+ $self->loc("Ticket could not be created due to an internal error")
+ );
+ }
+
+ # Create (empty) role groups
+ my $create_groups_ret = $self->_CreateRoleGroups();
+ unless ($create_groups_ret) {
+ $RT::Logger->crit( "Couldn't create ticket groups for ticket "
+ . $self->Id
+ . ". aborting Ticket creation." );
+ $RT::Handle->Rollback();
+ return ( 0, 0,
+ $self->loc("Ticket could not be created due to an internal error")
+ );
+ }
+
+ # Codify what it takes to add each kind of group
+ my %acls = (
+ Cc => sub { 1 },
+ Requestor => sub { 1 },
+ AdminCc => sub {
+ my $principal = shift;
+ return 1 if $self->CurrentUserHasRight('ModifyTicket');
+ return unless $self->CurrentUserHasRight("WatchAsAdminCc");
+ return unless $principal->id == $self->CurrentUser->PrincipalId;
+ return 1;
+ },
+ Owner => sub {
+ my $principal = shift;
+ return 1 if $principal->id == RT->Nobody->PrincipalId;
+ return $principal->HasRight( Object => $self, Right => 'OwnTicket' );
+ },
+ );
+
+ # Populate up the role groups. This call modifies $roles.
+ push @non_fatal_errors, $self->_AddRolesOnCreate( $roles, %acls );
+
+ # Squelching
+ if ($args{'SquelchMailTo'}) {
+ my @squelch = ref( $args{'SquelchMailTo'} ) ? @{ $args{'SquelchMailTo'} }
+ : $args{'SquelchMailTo'};
+ $self->_SquelchMailTo( @squelch );
+ }
+
+ # Add all the custom fields
+ foreach my $arg ( keys %args ) {
+ next unless $arg =~ /^CustomField-(\d+)$/i;
+ my $cfid = $1;
+ my $cf = $self->LoadCustomFieldByIdentifier($cfid);
+ next unless $cf->ObjectTypeFromLookupType($cf->__Value('LookupType'))->isa(ref $self);
+
+ foreach my $value (
+ UNIVERSAL::isa( $args{$arg} => 'ARRAY' ) ? @{ $args{$arg} } : ( $args{$arg} ) )
+ {
+ next unless defined $value && length $value;
+
+ # Allow passing in uploaded LargeContent etc by hash reference
+ my ($status, $msg) = $self->_AddCustomFieldValue(
+ (UNIVERSAL::isa( $value => 'HASH' )
+ ? %$value
+ : (Value => $value)
+ ),
+ Field => $cfid,
+ RecordTransaction => 0,
+ );
+ push @non_fatal_errors, $msg unless $status;
+ }
+ }
+
+ # Deal with setting up links
+
+ # TODO: Adding link may fire scrips on other end and those scrips
+ # could create transactions on this ticket before 'Create' transaction.
+ #
+ # We should implement different lifecycle: record 'Create' transaction,
+ # create links and only then fire create transaction's scrips.
+ #
+ # Ideal variant: add all links without firing scrips, record create
+ # transaction and only then fire scrips on the other ends of links.
+ #
+ # //RUZ
+ push @non_fatal_errors, $self->_AddLinksOnCreate(\%args, {
+ Silent => !$args{'_RecordTransaction'} || ($self->Type || '') eq 'reminder',
+ });
+
+ # }}}
+
+ # {{{ 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;
+
+ }
+
+ }
+
+ # }}}
+
+ push @non_fatal_errors, $self->_AddLinksOnCreate(\%args, {
+ Silent => !$args{'_RecordTransaction'} || ($self->Type || '') eq 'reminder',
+ });
+
+ # Try to add roles once more.
+ push @non_fatal_errors, $self->_AddRolesOnCreate( $roles, %acls );
+
+ # Anything left is failure of ACLs; Cc and Requestor have no ACLs,
+ # so we don't bother checking them.
+ if (@{ $roles->{Owner} }) {
+ my $owner = $roles->{Owner}[0]->Object;
+ $RT::Logger->warning( "User " . $owner->Name . "(" . $owner->id
+ . ") was proposed as a ticket owner but has no rights to own "
+ . "tickets in " . $QueueObj->Name );
+ push @non_fatal_errors, $self->loc(
+ "Owner '[_1]' does not have rights to own this ticket.",
+ $owner->Name
+ );
+ }
+ for my $principal (@{ $roles->{AdminCc} }) {
+ push @non_fatal_errors, $self->loc(
+ "No rights to add '[_1]' as an AdminCc on this ticket",
+ $principal->Object->Name
+ );
+ }
+
+ #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(
+ Type => "Create",
+ TimeTaken => $args{'TimeWorked'},
+ MIMEObj => $args{'MIMEObj'},
+ CommitScrips => !$args{'DryRun'},
+ SquelchMailTo => $args{'TransSquelchMailTo'},
+ );
+
+ if ( $self->Id && $Trans ) {
+
+ $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 );
+ $ErrStr = join( "\n", $ErrStr, @non_fatal_errors );
+ }
+ else {
+ $RT::Handle->Rollback();
+
+ $ErrStr = join( "\n", $ErrStr, @non_fatal_errors );
+ $RT::Logger->error("Ticket couldn't be created: $ErrStr");
+ return ( 0, 0, $self->loc( "Ticket could not be created due to an internal error"));
+ }
+
+ if ( $args{'DryRun'} ) {
+ $RT::Handle->Rollback();
+ return ($self->id, $TransObj, $ErrStr);
+ }
+ $RT::Handle->Commit();
+ return ( $self->Id, $TransObj->Id, $ErrStr );
+ }
+ else {
+
+ # Not going to record a transaction
+ $RT::Handle->Commit();
+ $ErrStr = $self->loc( "Ticket [_1] created in queue '[_2]'", $self->Id, $QueueObj->Name );
+ $ErrStr = join( "\n", $ErrStr, @non_fatal_errors );
+ return ( $self->Id, 0, $ErrStr );
+
+ }
+}
+
+sub SetType {
+ my $self = shift;
+ my $value = shift;
+
+ # Force lowercase on internal RT types
+ $value = lc $value
+ if $value =~ /^(ticket|approval|reminder)$/i;
+ return $self->_Set(Field => 'Type', Value => $value, @_);
+}
+
+=head2 OwnerGroup
+
+A constructor which returns an RT::Group object containing the owner of this ticket.
+
+=cut
+
+sub OwnerGroup {
+ my $self = shift;
+ return $self->RoleGroup( 'Owner' );
+}
+
+
+sub _HasModifyWatcherRight {
+ my $self = shift;
+ my ($type, $principal) = @_;
+
+ # ModifyTicket works in any case
+ return 1 if $self->CurrentUserHasRight('ModifyTicket');
+ # If the watcher isn't the current user then the current user has no right
+ return 0 unless $self->CurrentUser->PrincipalId == $principal->id;
+ # If it's an AdminCc and they don't have 'WatchAsAdminCc', bail
+ return 0 if $type eq 'AdminCc' and not $self->CurrentUserHasRight('WatchAsAdminCc');
+ # If it's a Requestor or Cc and they don't have 'Watch', bail
+ return 0 if ($type eq "Cc" or $type eq 'Requestor')
+ and not $self->CurrentUserHasRight('Watch');
+ return 1;
+}
+
+
+=head2 AddWatcher
+
+Applies access control checking, then calls
+L<RT::Record::Role::Roles/AddRoleMember>. Additionally, C<Email> is
+accepted as an alternative argument name for C<User>.
+
+Returns a tuple of (status, message).
+
+=cut
+
+sub AddWatcher {
+ my $self = shift;
+ my %args = (
+ Type => undef,
+ PrincipalId => undef,
+ Email => undef,
+ @_
+ );
+
+ $args{ACL} = sub { $self->_HasModifyWatcherRight( @_ ) };
+ $args{User} ||= delete $args{Email};
+ my ($principal, $msg) = $self->AddRoleMember(
+ %args,
+ InsideTransaction => 1,
+ );
+ return ( 0, $msg) unless $principal;
+
+ return ( 1, $self->loc('Added [_1] as a [_2] for this ticket',
+ $principal->Object->Name, $self->loc($args{'Type'})) );
+}
+
+
+=head2 DeleteWatcher
+
+Applies access control checking, then calls
+L<RT::Record::Role::Roles/DeleteRoleMember>. Additionally, C<Email> is
+accepted as an alternative argument name for C<User>.
+
+Returns a tuple of (status, message).
+
+=cut
+
+
+sub DeleteWatcher {
+ my $self = shift;
+
+ my %args = ( Type => undef,
+ PrincipalId => undef,
+ Email => undef,
+ @_ );
+
+ $args{ACL} = sub { $self->_HasModifyWatcherRight( @_ ) };
+ $args{User} ||= delete $args{Email};
+ my ($principal, $msg) = $self->DeleteRoleMember( %args );
+ return ( 0, $msg ) unless $principal;
+
+ return ( 1,
+ $self->loc( "[_1] is no longer a [_2] for this ticket.",
+ $principal->Object->Name,
+ $self->loc($args{'Type'}) ) );
+}
+
+
+
+
+
+=head2 SquelchMailTo [EMAIL]
+
+Takes an optional email address to never email about updates to this ticket.
+
+
+Returns an array of the RT::Attribute objects for this ticket's 'SquelchMailTo' attributes.
+
+
+=cut
+
+sub SquelchMailTo {
+ my $self = shift;
+ if (@_) {
+ unless ( $self->CurrentUserHasRight('ModifyTicket') ) {
+ return ();
+ }
+ } else {
+ unless ( $self->CurrentUserHasRight('ShowTicket') ) {
+ return ();
+ }
+
+ }
+ return $self->_SquelchMailTo(@_);
+}
+
+sub _SquelchMailTo {
+ my $self = shift;
+ if (@_) {
+ my $attr = shift;
+ $self->AddAttribute( Name => 'SquelchMailTo', Content => $attr )
+ unless grep { $_->Content eq $attr }
+ $self->Attributes->Named('SquelchMailTo');
+ }
+ my @attributes = $self->Attributes->Named('SquelchMailTo');
+ return (@attributes);
+}
+
+
+=head2 UnsquelchMailTo ADDRESS
+
+Takes an address and removes it from this ticket's "SquelchMailTo" list. If an address appears multiple times, each instance is removed.
+
+Returns a tuple of (status, message)
+
+=cut
+
+sub UnsquelchMailTo {
+ my $self = shift;
+
+ my $address = shift;
+ unless ( $self->CurrentUserHasRight('ModifyTicket') ) {
+ return ( 0, $self->loc("Permission Denied") );
+ }
+
+ my ($val, $msg) = $self->Attributes->DeleteEntry ( Name => 'SquelchMailTo', Content => $address);
+ return ($val, $msg);
+}
+
+
+
+=head2 RequestorAddresses
+
+B<Returns> String: All Ticket Requestor email addresses as a string.
+
+=cut
+
+sub RequestorAddresses {
+ my $self = shift;
+
+ unless ( $self->CurrentUserHasRight('ShowTicket') ) {
+ return undef;
+ }
+
+ return ( $self->Requestors->MemberEmailAddressesAsString );
+}
+
+
+=head2 AdminCcAddresses
+
+returns String: All Ticket AdminCc email addresses as a string
+
+=cut
+
+sub AdminCcAddresses {
+ my $self = shift;
+
+ unless ( $self->CurrentUserHasRight('ShowTicket') ) {
+ return undef;
+ }
+
+ return ( $self->AdminCc->MemberEmailAddressesAsString )
+
+}
+
+=head2 CcAddresses
+
+returns String: All Ticket Ccs as a string of email addresses
+
+=cut
+
+sub CcAddresses {
+ my $self = shift;
+
+ unless ( $self->CurrentUserHasRight('ShowTicket') ) {
+ return undef;
+ }
+ return ( $self->Cc->MemberEmailAddressesAsString);
+
+}
+
+
+
+
+=head2 Requestor
+
+Takes nothing.
+Returns this ticket's Requestors as an RT::Group object
+
+=cut
+
+sub Requestor {
+ my $self = shift;
+ return RT::Group->new($self->CurrentUser)
+ unless $self->CurrentUserHasRight('ShowTicket');
+ return $self->RoleGroup( 'Requestor' );
+}
+
+sub Requestors {
+ my $self = shift;
+ return $self->Requestor;
+}
+
+=head2 _Requestors
+
+Private non-ACLed variant of Reqeustors so that we can look them up for the
+purposes of customer auto-association during create.
+
+=cut
+
+sub _Requestors {
+ my $self = shift;
+
+ my $group = RT::Group->new($RT::SystemUser);
+ $group->LoadTicketRoleGroup(Type => 'Requestor', Ticket => $self->Id);
+ return ($group);
+}
+
+=head2 Cc
+
+Takes nothing.
+Returns an RT::Group object which contains this ticket's Ccs.
+If the user doesn't have "ShowTicket" permission, returns an empty group
+
+=cut
+
+sub Cc {
+ my $self = shift;
+
+ return RT::Group->new($self->CurrentUser)
+ unless $self->CurrentUserHasRight('ShowTicket');
+ return $self->RoleGroup( 'Cc' );
+}
+
+
+
+=head2 AdminCc
+
+Takes nothing.
+Returns an RT::Group object which contains this ticket's AdminCcs.
+If the user doesn't have "ShowTicket" permission, returns an empty group
+
+=cut
+
+sub AdminCc {
+ my $self = shift;
+
+ return RT::Group->new($self->CurrentUser)
+ unless $self->CurrentUserHasRight('ShowTicket');
+ return $self->RoleGroup( 'AdminCc' );
+}
+
+
+
+
+# a generic routine to be called by IsRequestor, IsCc and IsAdminCc
+
+=head2 IsWatcher { Type => TYPE, PrincipalId => PRINCIPAL_ID, Email => EMAIL }
+
+Takes a param hash with the attributes Type and either PrincipalId or Email
+
+Type is one of Requestor, Cc, AdminCc and Owner
+
+PrincipalId is an RT::Principal id, and Email is an email address.
+
+Returns true if the specified principal (or the one corresponding to the
+specified address) is a member of the group Type for this ticket.
+
+XX TODO: This should be Memoized.
+
+=cut
+
+sub IsWatcher {
+ my $self = shift;
+
+ my %args = ( Type => 'Requestor',
+ PrincipalId => undef,
+ Email => undef,
+ @_
+ );
+
+ # Load the relevant group.
+ my $group = $self->RoleGroup( $args{'Type'} );
+
+ # Find the relevant principal.
+ if (!$args{PrincipalId} && $args{Email}) {
+ # Look up the specified user.
+ my $user = RT::User->new($self->CurrentUser);
+ $user->LoadByEmail($args{Email});
+ if ($user->Id) {
+ $args{PrincipalId} = $user->PrincipalId;
+ }
+ else {
+ # A non-existent user can't be a group member.
+ return 0;
+ }
+ }
+
+ # Ask if it has the member in question
+ return $group->HasMember( $args{'PrincipalId'} );
+}
+
+
+
+=head2 IsRequestor PRINCIPAL_ID
+
+Takes an L<RT::Principal> id.
+
+Returns true if the principal is a requestor of the current ticket.
+
+=cut
+
+sub IsRequestor {
+ my $self = shift;
+ my $person = shift;
+
+ return ( $self->IsWatcher( Type => 'Requestor', PrincipalId => $person ) );
+
+};
+
+
+
+=head2 IsCc PRINCIPAL_ID
+
+ Takes an RT::Principal id.
+ Returns true if the principal is a Cc of the current ticket.
+
+
+=cut
+
+sub IsCc {
+ my $self = shift;
+ my $cc = shift;
+
+ return ( $self->IsWatcher( Type => 'Cc', PrincipalId => $cc ) );
+
+}
+
+
+
+=head2 IsAdminCc PRINCIPAL_ID
+
+ Takes an RT::Principal id.
+ Returns true if the principal is an AdminCc of the current ticket.
+
+=cut
+
+sub IsAdminCc {
+ my $self = shift;
+ my $person = shift;
+
+ return ( $self->IsWatcher( Type => 'AdminCc', PrincipalId => $person ) );
+
+}
+
+
+
+=head2 IsOwner
+
+ Takes an RT::User object. Returns true if that user is this ticket's owner.
+returns undef otherwise
+
+=cut
+
+sub IsOwner {
+ my $self = shift;
+ my $person = shift;
+
+ # no ACL check since this is used in acl decisions
+ # unless ($self->CurrentUserHasRight('ShowTicket')) {
+ # return(undef);
+ # }
+
+ #Tickets won't yet have owners when they're being created.
+ unless ( $self->OwnerObj->id ) {
+ return (undef);
+ }
+
+ if ( $person->id == $self->OwnerObj->id ) {
+ return (1);
+ }
+ else {
+ return (undef);
+ }
+}
+
+
+
+
+
+=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
+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.
+
+=cut
+
+
+sub TransactionAddresses {
+ my $self = shift;
+ my $txns = $self->Transactions;
+
+ my %addresses = ();
+
+ my $attachments = RT::Attachments->new( $self->CurrentUser );
+ $attachments->LimitByTicket( $self->id );
+ $attachments->Columns( qw( id Headers TransactionId));
+
+ $attachments->Limit(
+ ALIAS => $attachments->TransactionAlias,
+ FIELD => 'Type',
+ OPERATOR => 'IN',
+ VALUE => [ qw(Create Comment Correspond) ],
+ );
+
+ while ( my $att = $attachments->Next ) {
+ foreach my $addrlist ( values %{$att->Addresses } ) {
+ foreach my $addr (@$addrlist) {
+
+# Skip addresses without a phrase (things that are just raw addresses) if we have a phrase
+ next
+ if ( $addresses{ $addr->address }
+ && $addresses{ $addr->address }->phrase
+ && not $addr->phrase );
+
+ # skips "comment-only" addresses
+ next unless ( $addr->address );
+ $addresses{ $addr->address } = $addr;
+ }
+ }
+ }
+
+ return \%addresses;
+
+}
+
+
+
+
+
+
+sub ValidateQueue {
+ my $self = shift;
+ my $Value = shift;
+
+ if ( !$Value ) {
+ $RT::Logger->warning( " RT:::Queue::ValidateQueue called with a null value. this isn't ok.");
+ return (1);
+ }
+
+ my $QueueObj = RT::Queue->new( $self->CurrentUser );
+ my $id = $QueueObj->Load($Value);
+
+ if ($id) {
+ return (1);
+ }
+ else {
+ return (undef);
+ }
+}
+
+sub SetQueue {
+ my $self = shift;
+ my $value = shift;
+
+ unless ( $self->CurrentUserHasRight('ModifyTicket') ) {
+ return ( 0, $self->loc("Permission Denied") );
+ }
+
+ my ($ok, $msg, $status) = $self->_SetLifecycleColumn(
+ Value => $value,
+ RequireRight => "CreateTicket"
+ );
+
+ if ($ok) {
+ # Clear the queue object cache;
+ $self->{_queue_obj} = undef;
+ my $queue = $self->QueueObj;
+
+ # Untake the ticket if we have no permissions in the new queue
+ unless ($self->OwnerObj->HasRight( Right => 'OwnTicket', Object => $queue )) {
+ my $clone = RT::Ticket->new( RT->SystemUser );
+ $clone->Load( $self->Id );
+ unless ( $clone->Id ) {
+ return ( 0, $self->loc("Couldn't load copy of ticket #[_1].", $self->Id) );
+ }
+ my ($status, $msg) = $clone->SetOwner( RT->Nobody->Id, 'Force' );
+ $RT::Logger->error("Couldn't set owner on queue change: $msg") unless $status;
+ }
+
+ # On queue change, change queue for reminders too
+ my $reminder_collection = $self->Reminders->Collection;
+ while ( my $reminder = $reminder_collection->Next ) {
+ my ($status, $msg) = $reminder->_Set( Field => 'Queue', Value => $queue->Id(), RecordTransaction => 0 );
+ $RT::Logger->error('Queue change failed for reminder #' . $reminder->Id . ': ' . $msg) unless $status;
+ }
+
+ # Pick up any changes made by the clones above
+ $self->Load( $self->id );
+ RT->Logger->error("Unable to reload ticket #" . $self->id)
+ unless $self->id;
+ }
+
+ return ($ok, $msg);
+}
+
+
+
+=head2 QueueObj
+
+Takes nothing. returns this ticket's queue object
+
+=cut
+
+sub QueueObj {
+ my $self = shift;
+
+ if(!$self->{_queue_obj} || ! $self->{_queue_obj}->id) {
+
+ $self->{_queue_obj} = RT::Queue->new( $self->CurrentUser );
+
+ #We call __Value so that we can avoid the ACL decision and some deep recursion
+ my ($result) = $self->{_queue_obj}->Load( $self->__Value('Queue') );
+ }
+ return ($self->{_queue_obj});
+}
+
+sub SetSubject {
+ my $self = shift;
+ my $value = shift;
+ $value =~ s/\n//g;
+ return $self->_Set( Field => 'Subject', Value => $value );
+}
+
+=head2 SubjectTag
+
+Takes nothing. Returns SubjectTag for this ticket. Includes
+queue's subject tag or rtname if that is not set, ticket
+id and brackets, for example:
+
+ [support.example.com #123456]
+
+=cut
+
+sub SubjectTag {
+ my $self = shift;
+ return
+ '['
+ . ($self->QueueObj->SubjectTag || RT->Config->Get('rtname'))
+ .' #'. $self->id
+ .']'
+ ;
+}
+
+
+=head2 DueObj
+
+ Returns an RT::Date object containing this ticket's due date
+
+=cut
+
+sub DueObj {
+ my $self = shift;
+
+ my $time = RT::Date->new( $self->CurrentUser );
+
+ # -1 is RT::Date slang for never
+ if ( my $due = $self->Due ) {
+ $time->Set( Format => 'sql', Value => $due );
+ }
+ else {
+ $time->Set( Format => 'unix', Value => -1 );
+ }
+
+ return $time;
+}
+
+
+
+=head2 DueAsString
+
+Returns this ticket's due date as a human readable string.
+
+B<DEPRECATED> and will be removed in 4.4; use C<<
+$ticket->DueObj->AsString >> instead.
+
+=cut
+
+sub DueAsString {
+ my $self = shift;
+ RT->Deprecated(
+ Instead => "->DueObj->AsString",
+ Remove => "4.4",
+ );
+ return $self->DueObj->AsString();
+}
+
+
+
+=head2 ResolvedObj
+
+ Returns an RT::Date object of this ticket's 'resolved' time.
+
+=cut
+
+sub ResolvedObj {
+ my $self = shift;
+
+ my $time = RT::Date->new( $self->CurrentUser );
+ $time->Set( Format => 'sql', Value => $self->Resolved );
+ return $time;
+}
+
+=head2 FirstActiveStatus
+
+Returns the first active status that the ticket could transition to,
+according to its current Queue's lifecycle. May return undef if there
+is no such possible status to transition to, or we are already in it.
+This is used in L<RT::Action::AutoOpen>, for instance.
+
+=cut
+
+sub FirstActiveStatus {
+ my $self = shift;
+
+ my $lifecycle = $self->LifecycleObj;
+ my $status = $self->Status;
+ my @active = $lifecycle->Active;
+ # no change if no active statuses in the lifecycle
+ return undef unless @active;
+
+ # no change if the ticket is already has first status from the list of active
+ return undef if lc $status eq lc $active[0];
+
+ my ($next) = grep $lifecycle->IsActive($_), $lifecycle->Transitions($status);
+ return $next;
+}
+
+=head2 FirstInactiveStatus
+
+Returns the first inactive status that the ticket could transition to,
+according to its current Queue's lifecycle. May return undef if there
+is no such possible status to transition to, or we are already in it.
+This is used in resolve action in UnsafeEmailCommands, for instance.
+
+=cut
+
+sub FirstInactiveStatus {
+ my $self = shift;
+
+ my $lifecycle = $self->LifecycleObj;
+ my $status = $self->Status;
+ my @inactive = $lifecycle->Inactive;
+ # no change if no inactive statuses in the lifecycle
+ return undef unless @inactive;
+
+ # no change if the ticket is already has first status from the list of inactive
+ return undef if lc $status eq lc $inactive[0];
+
+ my ($next) = grep $lifecycle->IsInactive($_), $lifecycle->Transitions($status);
+ return $next;
+}
+
+=head2 SetStarted
+
+Takes a date in ISO format or undef
+Returns a transaction id and a message
+The client calls "Start" to note that the project was started on the date in $date.
+A null date means "now"
+
+=cut
+
+sub SetStarted {
+ my $self = shift;
+ my $time = shift || 0;
+
+ unless ( $self->CurrentUserHasRight('ModifyTicket') ) {
+ return ( 0, $self->loc("Permission Denied") );
+ }
+
+ #We create a date object to catch date weirdness
+ my $time_obj = RT::Date->new( $self->CurrentUser() );
+ if ( $time ) {
+ $time_obj->Set( Format => 'ISO', Value => $time );
+ }
+ else {
+ $time_obj->SetToNow();
+ }
+
+ return ( $self->_Set( Field => 'Started', Value => $time_obj->ISO ) );
+
+}
+
+
+
+=head2 StartedObj
+
+ Returns an RT::Date object which contains this ticket's
+'Started' time.
+
+=cut
+
+sub StartedObj {
+ my $self = shift;
+
+ my $time = RT::Date->new( $self->CurrentUser );
+ $time->Set( Format => 'sql', Value => $self->Started );
+ return $time;
+}
+
+
+
+=head2 StartsObj
+
+ Returns an RT::Date object which contains this ticket's
+'Starts' time.
+
+=cut
+
+sub StartsObj {
+ my $self = shift;
+
+ my $time = RT::Date->new( $self->CurrentUser );
+ $time->Set( Format => 'sql', Value => $self->Starts );
+ return $time;
+}
+
+
+
+=head2 ToldObj
+
+ Returns an RT::Date object which contains this ticket's
+'Told' time.
+
+=cut
+
+sub ToldObj {
+ my $self = shift;
+
+ my $time = RT::Date->new( $self->CurrentUser );
+ $time->Set( Format => 'sql', Value => $self->Told );
+ return $time;
+}
+
+
+
+=head2 ToldAsString
+
+A convenience method that returns ToldObj->AsString
+
+B<DEPRECATED> and will be removed in 4.4; use C<<
+$ticket->ToldObj->AsString >> instead.
+
+=cut
+
+sub ToldAsString {
+ my $self = shift;
+ RT->Deprecated(
+ Instead => "->ToldObj->AsString",
+ Remove => "4.4",
+ );
+ if ( $self->Told ) {
+ return $self->ToldObj->AsString();
+ }
+ else {
+ return ("Never");
+ }
+}
+
+
+
+sub _DurationAsString {
+ my $self = shift;
+ my $value = shift;
+ return "" unless $value;
+ return RT::Date->new( $self->CurrentUser )
+ ->DurationAsString( $value * 60 );
+}
+
+=head2 TimeWorkedAsString
+
+Returns the amount of time worked on this ticket as a text string.
+
+=cut
+
+sub TimeWorkedAsString {
+ my $self = shift;
+ return $self->_DurationAsString( $self->TimeWorked );
+}
+
+=head2 TimeLeftAsString
+
+Returns the amount of time left on this ticket as a text string.
+
+=cut
+
+sub TimeLeftAsString {
+ my $self = shift;
+ return $self->_DurationAsString( $self->TimeLeft );
+}
+
+=head2 TimeEstimatedAsString
+
+Returns the amount of time estimated on this ticket as a text string.
+
+=cut
+
+sub TimeEstimatedAsString {
+ my $self = shift;
+ return $self->_DurationAsString( $self->TimeEstimated );
+}
+
+
+
+
+=head2 Comment
+
+Comment on this ticket.
+Takes a hash with the following attributes:
+If MIMEObj is undefined, Content will be used to build a MIME::Entity for this
+comment.
+
+MIMEObj, TimeTaken, CcMessageTo, BccMessageTo, Content, DryRun
+
+If DryRun is defined, this update WILL NOT BE RECORDED. Scrips will not be committed.
+They will, however, be prepared and you'll be able to access them through the TransactionObj
+
+Returns: Transaction id, Error Message, Transaction Object
+(note the different order from Create()!)
+
+=cut
+
+sub Comment {
+ my $self = shift;
+
+ my %args = ( CcMessageTo => undef,
+ BccMessageTo => undef,
+ MIMEObj => undef,
+ Content => undef,
+ TimeTaken => 0,
+ DryRun => 0,
+ @_ );
+
+ unless ( ( $self->CurrentUserHasRight('CommentOnTicket') )
+ or ( $self->CurrentUserHasRight('ModifyTicket') ) ) {
+ return ( 0, $self->loc("Permission Denied"), undef );
+ }
+ $args{'NoteType'} = 'Comment';
+
+ $RT::Handle->BeginTransaction();
+ if ($args{'DryRun'}) {
+ $args{'CommitScrips'} = 0;
+ }
+
+ my @results = $self->_RecordNote(%args);
+ if ($args{'DryRun'}) {
+ $RT::Handle->Rollback();
+ } else {
+ $RT::Handle->Commit();
+ }
+
+ return(@results);
+}
+
+
+=head2 Correspond
+
+Correspond on this ticket.
+Takes a hashref with the following attributes:
+
+
+MIMEObj, TimeTaken, CcMessageTo, BccMessageTo, Content, DryRun
+
+if there's no MIMEObj, Content is used to build a MIME::Entity object
+
+If DryRun is defined, this update WILL NOT BE RECORDED. Scrips will not be committed.
+They will, however, be prepared and you'll be able to access them through the TransactionObj
+
+Returns: Transaction id, Error Message, Transaction Object
+(note the different order from Create()!)
+
+
+=cut
+
+sub Correspond {
+ my $self = shift;
+ my %args = ( CcMessageTo => undef,
+ BccMessageTo => undef,
+ MIMEObj => undef,
+ Content => undef,
+ TimeTaken => 0,
+ @_ );
+
+ unless ( ( $self->CurrentUserHasRight('ReplyToTicket') )
+ or ( $self->CurrentUserHasRight('ModifyTicket') ) ) {
+ return ( 0, $self->loc("Permission Denied"), undef );
+ }
+ $args{'NoteType'} = 'Correspond';
+
+ $RT::Handle->BeginTransaction();
+ if ($args{'DryRun'}) {
+ $args{'CommitScrips'} = 0;
+ }
+
+ my @results = $self->_RecordNote(%args);
+
+ unless ( $results[0] ) {
+ $RT::Handle->Rollback();
+ return @results;
+ }
+
+ #Set the last told date to now if this isn't mail from the requestor.
+ #TODO: Note that this will wrongly ack mail from any non-requestor as a "told"
+ unless ( $self->IsRequestor($self->CurrentUser->id) ) {
+ my %squelch;
+ $squelch{$_}++ for map {$_->Content} $self->SquelchMailTo, $results[2]->SquelchMailTo;
+ $self->_SetTold
+ if grep {not $squelch{$_}} $self->Requestors->MemberEmailAddresses;
+ }
+
+ if ($args{'DryRun'}) {
+ $RT::Handle->Rollback();
+ } else {
+ $RT::Handle->Commit();
+ }
+
+ return (@results);
+
+}
+
+
+
+=head2 _RecordNote
+
+the meat of both comment and correspond.
+
+Performs no access control checks. hence, dangerous.
+
+=cut
+
+sub _RecordNote {
+ my $self = shift;
+ my %args = (
+ CcMessageTo => undef,
+ BccMessageTo => undef,
+ Encrypt => undef,
+ Sign => undef,
+ MIMEObj => undef,
+ Content => undef,
+ NoteType => 'Correspond',
+ TimeTaken => 0,
+ CommitScrips => 1,
+ SquelchMailTo => undef,
+ CustomFields => {},
+ @_
+ );
+
+ unless ( $args{'MIMEObj'} || $args{'Content'} ) {
+ return ( 0, $self->loc("No message attached"), undef );
+ }
+
+ unless ( $args{'MIMEObj'} ) {
+ my $data = ref $args{'Content'}? $args{'Content'} : [ $args{'Content'} ];
+ $args{'MIMEObj'} = MIME::Entity->build(
+ Type => "text/plain",
+ Charset => "UTF-8",
+ Data => [ map {Encode::encode("UTF-8", $_)} @{$data} ],
+ );
+ }
+
+ $args{'MIMEObj'}->head->replace('X-RT-Interface' => 'API')
+ unless $args{'MIMEObj'}->head->get('X-RT-Interface');
+
+ # convert text parts into utf-8
+ RT::I18N::SetMIMEEntityToUTF8( $args{'MIMEObj'} );
+
+ # If we've been passed in CcMessageTo and BccMessageTo fields,
+ # add them to the mime object for passing on to the transaction handler
+ # The "NotifyOtherRecipients" scripAction will look for RT-Send-Cc: and
+ # RT-Send-Bcc: headers
+
+
+ foreach my $type (qw/Cc Bcc/) {
+ if ( defined $args{ $type . 'MessageTo' } ) {
+
+ my $addresses = join ', ', (
+ map { RT::User->CanonicalizeEmailAddress( $_->address ) }
+ Email::Address->parse( $args{ $type . 'MessageTo' } ) );
+ $args{'MIMEObj'}->head->replace( 'RT-Send-' . $type, Encode::encode( "UTF-8", $addresses ) );
+ }
+ }
+
+ foreach my $argument (qw(Encrypt Sign)) {
+ $args{'MIMEObj'}->head->replace(
+ "X-RT-$argument" => $args{ $argument } ? 1 : 0
+ ) if defined $args{ $argument };
+ }
+
+ # If this is from an external source, we need to come up with its
+ # internal Message-ID now, so all emails sent because of this
+ # message have a common Message-ID
+ my $org = RT->Config->Get('Organization');
+ my $msgid = Encode::decode( "UTF-8", $args{'MIMEObj'}->head->get('Message-ID') );
+ unless (defined $msgid && $msgid =~ /<(rt-.*?-\d+-\d+)\.(\d+-0-0)\@\Q$org\E>/) {
+ $args{'MIMEObj'}->head->replace(
+ 'RT-Message-ID' => Encode::encode( "UTF-8",
+ RT::Interface::Email::GenMessageId( Ticket => $self )
+ )
+ );
+ }
+
+ #Record the correspondence (write the transaction)
+ my ( $Trans, $msg, $TransObj ) = $self->_NewTransaction(
+ Type => $args{'NoteType'},
+ Data => ( Encode::decode( "UTF-8", $args{'MIMEObj'}->head->get('Subject') ) || 'No Subject' ),
+ TimeTaken => $args{'TimeTaken'},
+ MIMEObj => $args{'MIMEObj'},
+ CommitScrips => $args{'CommitScrips'},
+ SquelchMailTo => $args{'SquelchMailTo'},
+ CustomFields => $args{'CustomFields'},
+ );
+
+ unless ($Trans) {
+ $RT::Logger->err("$self couldn't init a transaction $msg");
+ return ( $Trans, $self->loc("Message could not be recorded"), undef );
+ }
+
+ if ($args{NoteType} eq "Comment") {
+ $msg = $self->loc("Comments added");
+ } else {
+ $msg = $self->loc("Correspondence added");
+ }
+ return ( $Trans, $msg, $TransObj );
+}
+
+
+=head2 DryRun
+
+Builds a MIME object from the given C<UpdateSubject> and
+C<UpdateContent>, then calls L</Comment> or L</Correspond> with
+C<< DryRun => 1 >>, and returns the transaction so produced.
+
+=cut
+
+sub DryRun {
+ my $self = shift;
+ my %args = @_;
+ my $action;
+ if (($args{'UpdateType'} || $args{Action}) =~ /^respon(d|se)$/i ) {
+ $action = 'Correspond';
+ } else {
+ $action = 'Comment';
+ }
+
+ my $Message = MIME::Entity->build(
+ Subject => defined $args{UpdateSubject} ? Encode::encode( "UTF-8", $args{UpdateSubject} ) : "",
+ Type => 'text/plain',
+ Charset => 'UTF-8',
+ Data => Encode::encode("UTF-8", $args{'UpdateContent'} || ""),
+ );
+
+ my ( $Transaction, $Description, $Object ) = $self->$action(
+ CcMessageTo => $args{'UpdateCc'},
+ BccMessageTo => $args{'UpdateBcc'},
+ MIMEObj => $Message,
+ TimeTaken => $args{'UpdateTimeWorked'},
+ DryRun => 1,
+ SquelchMailTo => $args{'SquelchMailTo'},
+ );
+ unless ( $Transaction ) {
+ $RT::Logger->error("Couldn't fire '$action' action: $Description");
+ }
+
+ return $Object;
+}
+
+=head2 DryRunCreate
+
+Prepares a MIME mesage with the given C<Subject>, C<Cc>, and
+C<Content>, then calls L</Create> with C<< DryRun => 1 >> and returns
+the resulting L<RT::Transaction>.
+
+=cut
+
+sub DryRunCreate {
+ my $self = shift;
+ my %args = @_;
+ my $Message = MIME::Entity->build(
+ Subject => defined $args{Subject} ? Encode::encode( "UTF-8", $args{'Subject'} ) : "",
+ (defined $args{'Cc'} ?
+ ( Cc => Encode::encode( "UTF-8", $args{'Cc'} ) ) : ()),
+ Type => 'text/plain',
+ Charset => 'UTF-8',
+ Data => Encode::encode( "UTF-8", $args{'Content'} || ""),
+ );
+
+ my ( $Transaction, $Object, $Description ) = $self->Create(
+ Type => $args{'Type'} || 'ticket',
+ Queue => $args{'Queue'},
+ Owner => $args{'Owner'},
+ Requestor => $args{'Requestors'},
+ Cc => $args{'Cc'},
+ AdminCc => $args{'AdminCc'},
+ InitialPriority => $args{'InitialPriority'},
+ FinalPriority => $args{'FinalPriority'},
+ TimeLeft => $args{'TimeLeft'},
+ TimeEstimated => $args{'TimeEstimated'},
+ TimeWorked => $args{'TimeWorked'},
+ Subject => $args{'Subject'},
+ Status => $args{'Status'},
+ MIMEObj => $Message,
+ DryRun => 1,
+ );
+ unless ( $Transaction ) {
+ $RT::Logger->error("Couldn't fire Create action: $Description");
+ }
+
+ return $Object;
+}
+
+
+
+sub _Links {
+ my $self = shift;
+
+ #TODO: Field isn't the right thing here. but I ahave no idea what mnemonic ---
+ #tobias meant by $f
+ my $field = shift;
+ my $type = shift || "";
+
+ 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, SUBCLAUSE => 'acl' );
+ return $links;
+ }
+
+ # Maybe this ticket is a merge ticket
+ my $limit_on = 'Local'. $field;
+ # at least to myself
+ $links->Limit(
+ FIELD => $limit_on,
+ OPERATOR => 'IN',
+ VALUE => [ $self->id, $self->Merged ],
+ );
+ $links->Limit(
+ FIELD => 'Type',
+ VALUE => $type,
+ ) if $type;
+
+ return $links;
+}
+
+=head2 MergeInto
+
+MergeInto take the id of the ticket to merge this ticket into.
+
+=cut
+
+sub MergeInto {
+ my $self = shift;
+ my $ticket_id = shift;
+
+ unless ( $self->CurrentUserHasRight('ModifyTicket') ) {
+ return ( 0, $self->loc("Permission Denied") );
+ }
+
+ # Load up the new ticket.
+ my $MergeInto = RT::Ticket->new($self->CurrentUser);
+ $MergeInto->Load($ticket_id);
+
+ # make sure it exists.
+ unless ( $MergeInto->Id ) {
+ return ( 0, $self->loc("New ticket doesn't exist") );
+ }
+
+ # Can't merge into yourself
+ if ( $MergeInto->Id == $self->Id ) {
+ return ( 0, $self->loc("Can't merge a ticket into itself") );
+ }
+
+ # Make sure the current user can modify the new ticket.
+ unless ( $MergeInto->CurrentUserHasRight('ModifyTicket') ) {
+ 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();
+
+ my ($ok, $msg) = $self->_MergeInto( $MergeInto );
+
+ $RT::Handle->Commit() if $ok;
+
+ return ($ok, $msg);
+}
+
+sub _MergeInto {
+ my $self = shift;
+ my $MergeInto = shift;
+
+
+ # We use EffectiveId here even though it duplicates information from
+ # the links table becasue of the massive performance hit we'd take
+ # by trying to do a separate database query for merge info everytime
+ # loaded a ticket.
+
+ #update this ticket's effective id to the new ticket's id.
+ my ( $id_val, $id_msg ) = $self->__Set(
+ Field => 'EffectiveId',
+ Value => $MergeInto->Id()
+ );
+
+ unless ($id_val) {
+ $RT::Handle->Rollback();
+ return ( 0, $self->loc("Merge failed. Couldn't set EffectiveId") );
+ }
+
+ ( $id_val, $id_msg ) = $self->__Set( Field => 'IsMerged', Value => 1 );
+ unless ($id_val) {
+ $RT::Handle->Rollback();
+ return ( 0, $self->loc("Merge failed. Couldn't set IsMerged") );
+ }
+
+ my $force_status = $self->LifecycleObj->DefaultOnMerge;
+ if ( $force_status && $force_status ne $self->__Value('Status') ) {
+ my ( $status_val, $status_msg )
+ = $self->__Set( Field => 'Status', Value => $force_status );
+
+ unless ($status_val) {
+ $RT::Handle->Rollback();
+ $RT::Logger->error(
+ "Couldn't set status to $force_status. RT's Database may be inconsistent."
+ );
+ return ( 0, $self->loc("Merge failed. Couldn't set Status") );
+ }
+ }
+
+ # update all the links that point to that old ticket
+ my $old_links_to = RT::Links->new($self->CurrentUser);
+ $old_links_to->Limit(FIELD => 'Target', VALUE => $self->URI);
+
+ my %old_seen;
+ while (my $link = $old_links_to->Next) {
+ if (exists $old_seen{$link->Base."-".$link->Type}) {
+ $link->Delete;
+ }
+ elsif ($link->Base eq $MergeInto->URI) {
+ $link->Delete;
+ } else {
+ # First, make sure the link doesn't already exist. then move it over.
+ my $tmp = RT::Link->new(RT->SystemUser);
+ $tmp->LoadByCols(Base => $link->Base, Type => $link->Type, LocalTarget => $MergeInto->id);
+ if ($tmp->id) {
+ $link->Delete;
+ } else {
+ $link->SetTarget($MergeInto->URI);
+ $link->SetLocalTarget($MergeInto->id);
+ }
+ $old_seen{$link->Base."-".$link->Type} =1;
+ }
+
+ }
+
+ my $old_links_from = RT::Links->new($self->CurrentUser);
+ $old_links_from->Limit(FIELD => 'Base', VALUE => $self->URI);
+
+ while (my $link = $old_links_from->Next) {
+ if (exists $old_seen{$link->Type."-".$link->Target}) {
+ $link->Delete;
+ }
+ if ($link->Target eq $MergeInto->URI) {
+ $link->Delete;
+ } else {
+ # First, make sure the link doesn't already exist. then move it over.
+ my $tmp = RT::Link->new(RT->SystemUser);
+ $tmp->LoadByCols(Target => $link->Target, Type => $link->Type, LocalBase => $MergeInto->id);
+ if ($tmp->id) {
+ $link->Delete;
+ } else {
+ $link->SetBase($MergeInto->URI);
+ $link->SetLocalBase($MergeInto->id);
+ $old_seen{$link->Type."-".$link->Target} =1;
+ }
+ }
+
+ }
+
+ # Update time fields
+ foreach my $type (qw(TimeEstimated TimeWorked TimeLeft)) {
+ $MergeInto->_Set(
+ Field => $type,
+ Value => ( $MergeInto->$type() || 0 ) + ( $self->$type() || 0 ),
+ RecordTransaction => 0,
+ );
+ }
+
+ # add all of this ticket's watchers to that ticket.
+ for my $role ($self->Roles) {
+ next if $self->RoleGroup($role)->SingleMemberRoleGroup;
+ my $people = $self->RoleGroup($role)->MembersObj;
+ while ( my $watcher = $people->Next ) {
+ my ($val, $msg) = $MergeInto->AddRoleMember(
+ Type => $role,
+ Silent => 1,
+ PrincipalId => $watcher->MemberId,
+ InsideTransaction => 1,
+ );
+ unless ($val) {
+ $RT::Logger->debug($msg);
+ }
+ }
+ }
+
+ #find all of the tickets that were merged into this ticket.
+ my $old_mergees = RT::Tickets->new( $self->CurrentUser );
+ $old_mergees->Limit(
+ FIELD => 'EffectiveId',
+ OPERATOR => '=',
+ VALUE => $self->Id
+ );
+
+ # update their EffectiveId fields to the new ticket's id
+ while ( my $ticket = $old_mergees->Next() ) {
+ my ( $val, $msg ) = $ticket->__Set(
+ Field => 'EffectiveId',
+ Value => $MergeInto->Id()
+ );
+ }
+
+ #make a new link: this ticket is merged into that other ticket.
+ $self->AddLink( Type => 'MergedInto', Target => $MergeInto->Id());
+
+ $MergeInto->_SetLastUpdated;
+
+ return ( 1, $self->loc("Merge Successful") );
+}
+
+=head2 Merged
+
+Returns list of tickets' ids that's been merged into this ticket.
+
+=cut
+
+sub Merged {
+ my $self = shift;
+
+ my $id = $self->id;
+ return @{ $MERGE_CACHE{'merged'}{ $id } }
+ if $MERGE_CACHE{'merged'}{ $id };
+
+ my $mergees = RT::Tickets->new( $self->CurrentUser );
+ $mergees->LimitField(
+ FIELD => 'EffectiveId',
+ VALUE => $id,
+ );
+ $mergees->LimitField(
+ FIELD => 'id',
+ OPERATOR => '!=',
+ VALUE => $id,
+ );
+ return @{ $MERGE_CACHE{'merged'}{ $id } ||= [] }
+ = map $_->id, @{ $mergees->ItemsArrayRef || [] };
+}
+
+
+
+
+
+=head2 OwnerObj
+
+Takes nothing and returns an RT::User object of
+this ticket's owner
+
+=cut
+
+sub OwnerObj {
+ my $self = shift;
+
+ #If this gets ACLed, we lose on a rights check in User.pm and
+ #get deep recursion. if we need ACLs here, we need
+ #an equiv without ACLs
+
+ my $owner = RT::User->new( $self->CurrentUser );
+ $owner->Load( $self->__Value('Owner') );
+
+ #Return the owner object
+ return ($owner);
+}
+
+
+
+=head2 OwnerAsString
+
+Returns the owner's email address
+
+=cut
+
+sub OwnerAsString {
+ my $self = shift;
+ return ( $self->OwnerObj->EmailAddress );
+
+}
+
+
+
+=head2 SetOwner
+
+Takes two arguments:
+ the Id or Name of the owner
+and (optionally) the type of the SetOwner Transaction. It defaults
+to 'Set'. 'Steal' is also a valid option.
+
+
+=cut
+
+sub SetOwner {
+ my $self = shift;
+ my $NewOwner = shift;
+ my $Type = shift || "Set";
+
+ $RT::Handle->BeginTransaction();
+
+ $self->_SetLastUpdated(); # lock the ticket
+ $self->Load( $self->id ); # in case $self changed while waiting for lock
+
+ my $OldOwnerObj = $self->OwnerObj;
+
+ my $NewOwnerObj = RT::User->new( $self->CurrentUser );
+ $NewOwnerObj->Load( $NewOwner );
+
+ my ( $val, $msg ) = $self->CurrentUserCanSetOwner(
+ NewOwnerObj => $NewOwnerObj,
+ Type => $Type );
+
+ unless ($val) {
+ $RT::Handle->Rollback();
+ return ( $val, $msg );
+ }
+
+ ($val, $msg ) = $self->OwnerGroup->_AddMember(
+ PrincipalId => $NewOwnerObj->PrincipalId,
+ InsideTransaction => 1,
+ Object => $self,
+ );
+ unless ($val) {
+ $RT::Handle->Rollback;
+ return ( 0, $self->loc("Could not change owner: [_1]", $msg) );
+ }
+
+ $msg = $self->loc( "Owner changed from [_1] to [_2]",
+ $OldOwnerObj->Name, $NewOwnerObj->Name );
+
+ $RT::Handle->Commit();
+
+ return ( $val, $msg );
+}
+
+=head2 CurrentUserCanSetOwner
+
+Confirm the current user can set the owner of the current ticket.
+
+There are several different rights to manage owner changes and
+this method evaluates these rights, guided by parameters provided.
+
+This method evaluates these rights in the context of the state of
+the current ticket. For example, it evaluates Take for tickets that
+are owned by Nobody because that is the context appropriate for the
+TakeTicket right. If you need to strictly test a user for a right,
+use HasRight to check for the right directly.
+
+For some custom types of owner changes (C<Take> and C<Steal>), it also
+verifies that those actions are possible given the current ticket owner.
+
+=head3 Rights to Set Owner
+
+The current user can set or change the Owner field in the following
+cases:
+
+=over
+
+=item *
+
+ReassignTicket unconditionally grants the right to set the owner
+to any user who has OwnTicket. This can be used to break an
+Owner lock held by another user (see below) and can be a convenient
+right for managers or administrators who need to assign tickets
+without necessarily owning them.
+
+=item *
+
+ModifyTicket grants the right to set the owner to any user who
+has OwnTicket, provided the ticket is currently owned by the current
+user or is not owned (owned by Nobody). (See the details on the Force
+parameter below for exceptions to this.)
+
+=item *
+
+If the ticket is currently not owned (owned by Nobody),
+TakeTicket is sufficient to set the owner to yourself (but not
+an arbitrary person), but only if you have OwnTicket. It is
+thus a subset of the possible changes provided by ModifyTicket.
+This exists to allow granting TakeTicket freely, and
+the broader ModifyTicket only to Owners.
+
+=item *
+
+If the ticket is currently owned by someone who is not you or
+Nobody, StealTicket is sufficient to set the owner to yourself,
+but only if you have OwnTicket. This is hence non-overlapping
+with the changes provided by ModifyTicket, and is used to break
+a lock held by another user.
+
+=back
+
+=head3 Parameters
+
+This method returns ($result, $message) with $result containing
+true or false indicating if the current user can set owner and $message
+containing a message, typically in the case of a false response.
+
+If called with no parameters, this method determines if the current
+user could set the owner of the current ticket given any
+permutation of the rights described above. This can be useful
+when determining whether to make owner-setting options available
+in the GUI.
+
+This method accepts the following parameters as a paramshash:
+
+=over
+
+=item C<NewOwnerObj>
+
+Optional; an L<RT::User> object representing the proposed new owner of
+the ticket.
+
+=item C<Type>
+
+Optional; the type of set owner operation. Valid values are C<Take>,
+C<Steal>, or C<Force>. Note that if the type is C<Take>, this method
+will return false if the current user is already the owner; similarly,
+it will return false for C<Steal> if the ticket has no owner or the
+owner is the current user.
+
+=back
+
+As noted above, there are exceptions to the standard ticket-based rights
+described here. The Force option allows for these and is used
+when moving tickets between queues, for reminders (because the full
+owner rights system is too complex for them), and optionally during
+bulk update.
+
+=cut
+
+sub CurrentUserCanSetOwner {
+ my $self = shift;
+ my %args = ( Type => '',
+ @_);
+ my $OldOwnerObj = $self->OwnerObj;
+
+ $args{NewOwnerObj} ||= $self->CurrentUser->UserObj
+ if $args{Type} eq "Take" or $args{Type} eq "Steal";
+
+ # Confirm rights for new owner if we got one
+ if ( $args{'NewOwnerObj'} ){
+ my ($ok, $message) = $self->_NewOwnerCanOwnTicket($args{'NewOwnerObj'}, $OldOwnerObj);
+ return ($ok, $message) if not $ok;
+ }
+
+ # ReassignTicket allows you to SetOwner, but we also need to check ticket's
+ # current owner for Take and Steal Types
+ return ( 1, undef ) if $self->CurrentUserHasRight('ReassignTicket')
+ && $args{Type} ne 'Take' && $args{Type} ne 'Steal';
+
+ # Ticket is unowned
+ if ( $OldOwnerObj->Id == RT->Nobody->Id ) {
+
+ # Steal is not applicable for unowned tickets.
+ if ( $args{'Type'} eq 'Steal' ){
+ return ( 0, $self->loc("You can only steal a ticket owned by someone else") )
+ }
+
+ # Can set owner to yourself with ModifyTicket, ReassignTicket,
+ # or TakeTicket; in all of these cases, OwnTicket is checked by
+ # _NewOwnerCanOwnTicket above.
+ if ( $args{'Type'} eq 'Take'
+ or ( $args{'NewOwnerObj'}
+ and $args{'NewOwnerObj'}->id == $self->CurrentUser->id )) {
+ unless ( $self->CurrentUserHasRight('ModifyTicket')
+ or $self->CurrentUserHasRight('ReassignTicket')
+ or $self->CurrentUserHasRight('TakeTicket') ) {
+ return ( 0, $self->loc("Permission Denied") );
+ }
+ } else {
+ # Nobody -> someone else requires ModifyTicket or ReassignTicket
+ unless ( $self->CurrentUserHasRight('ModifyTicket')
+ or $self->CurrentUserHasRight('ReassignTicket') ) {
+ return ( 0, $self->loc("Permission Denied") );
+ }
+ }
+ }
+
+ # Ticket is owned by someone else
+ # Can set owner to yourself with ModifyTicket or StealTicket
+ # and OwnTicket.
+ elsif ( $OldOwnerObj->Id != RT->Nobody->Id
+ && $OldOwnerObj->Id != $self->CurrentUser->id ) {
+
+ unless ( $self->CurrentUserHasRight('ModifyTicket')
+ || $self->CurrentUserHasRight('ReassignTicket')
+ || $self->CurrentUserHasRight('StealTicket') ) {
+ return ( 0, $self->loc("Permission Denied") )
+ }
+
+ if ( $args{'Type'} eq 'Steal' || $args{'Type'} eq 'Force' ){
+ return ( 1, undef ) if $self->CurrentUserHasRight('OwnTicket');
+ return ( 0, $self->loc("Permission Denied") );
+ }
+
+ # Not a steal or force
+ if ( $args{'Type'} eq 'Take'
+ or ( $args{'NewOwnerObj'}
+ and $args{'NewOwnerObj'}->id == $self->CurrentUser->id )) {
+ return ( 0, $self->loc("You can only take tickets that are unowned") );
+ }
+
+ unless ( $self->CurrentUserHasRight('ReassignTicket') ) {
+ return ( 0, $self->loc( "You can only reassign tickets that you own or that are unowned"));
+ }
+
+ }
+ # You own the ticket
+ # Untake falls through to here, so we don't need to explicitly handle that Type
+ else {
+ if ( $args{'Type'} eq 'Take' || $args{'Type'} eq 'Steal' ) {
+ return ( 0, $self->loc("You already own this ticket") );
+ }
+
+ unless ( $self->CurrentUserHasRight('ModifyTicket')
+ || $self->CurrentUserHasRight('ReassignTicket') ) {
+ return ( 0, $self->loc("Permission Denied") );
+ }
+ }
+
+ return ( 1, undef );
+}
+
+# Verify the proposed new owner can own the ticket.
+
+sub _NewOwnerCanOwnTicket {
+ my $self = shift;
+ my $NewOwnerObj = shift;
+ my $OldOwnerObj = shift;
+
+ unless ( $NewOwnerObj->Id ) {
+ return ( 0, $self->loc("That user does not exist") );
+ }
+
+ # The proposed new owner can't own the ticket
+ if ( !$NewOwnerObj->HasRight( Right => 'OwnTicket', Object => $self ) ){
+ return ( 0, $self->loc("That user may not own tickets in that queue") );
+ }
+
+ # Ticket's current owner is the same as the new owner, nothing to do
+ elsif ( $NewOwnerObj->Id == $OldOwnerObj->Id ) {
+ return ( 0, $self->loc("That user already owns that ticket") );
+ }