X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=rt%2Flib%2FRT%2FTickets.pm;h=c826b6f04af3829d9b4c509c3f4264b157501775;hp=dd91126c473e2e234b5a646f348dd8163deb43ac;hb=919e930aa9279b3c5cd12b593889cd6de79d67bf;hpb=3ef62a0570055da710328937e7f65dbb2c027c62 diff --git a/rt/lib/RT/Tickets.pm b/rt/lib/RT/Tickets.pm index dd91126c4..c826b6f04 100755 --- a/rt/lib/RT/Tickets.pm +++ b/rt/lib/RT/Tickets.pm @@ -1,4 +1,61 @@ -#$Header: /home/cvs/cvsroot/freeside/rt/lib/RT/Tickets.pm,v 1.1 2002-08-12 06:17:07 ivan Exp $ +# BEGIN BPS TAGGED BLOCK {{{ +# +# COPYRIGHT: +# +# This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC +# +# +# (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 +# 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 }}} + +# Major Changes: + +# - Decimated ProcessRestrictions and broke it into multiple +# functions joined by a LUT +# - Semi-Generic SQL stuff moved to another file + +# Known Issues: FIXME! + +# - ClearRestrictions and Reinitialization is messy and unclear. The +# only good way to do it is to create a new RT::Tickets object. =head1 NAME @@ -8,7 +65,7 @@ =head1 SYNOPSIS use RT::Tickets; - my $tickets = new RT::Tickets($CurrentUser); + my $tickets = RT::Tickets->new($CurrentUser); =head1 DESCRIPTION @@ -16,68 +73,176 @@ =head1 METHODS -=begin testing - -ok (require RT::TestHarness); -ok (require RT::Tickets); - -=end testing =cut package RT::Tickets; -use RT::EasySearch; + +use strict; +use warnings; + + use RT::Ticket; -@ISA= qw(RT::EasySearch); - -use vars qw(%TYPES @SORTFIELDS); - -# {{{ TYPES - -%TYPES = ( Status => 'ENUM', - Queue => 'ENUM', - Type => 'ENUM', - Creator => 'ENUM', - LastUpdatedBy => 'ENUM', - Owner => 'ENUM', - EffectiveId => 'INT', - id => 'INT', - InitialPriority => 'INT', - FinalPriority => 'INT', - Priority => 'INT', - TimeLeft => 'INT', - TimeWorked => 'INT', - MemberOf => 'LINK', - DependsOn => 'LINK', - HasMember => 'LINK', - HasDepender => 'LINK', - RelatedTo => 'LINK', - Told => 'DATE', - StartsBy => 'DATE', - Started => 'DATE', - Due => 'DATE', - Resolved => 'DATE', - LastUpdated => 'DATE', - Created => 'DATE', - Subject => 'STRING', - Type => 'STRING', - Content => 'TRANSFIELD', - ContentType => 'TRANSFIELD', - TransactionDate => 'TRANSDATE', - Watcher => 'WATCHERFIELD', - LinkedTo => 'LINKFIELD', - Keyword => 'KEYWORDFIELD' - - ); - - -# }}} - -# {{{ sub SortFields - -@SORTFIELDS = qw(id Status Owner Created Due Starts Started - Queue Subject Told Started - Resolved LastUpdated Priority TimeWorked TimeLeft); + +use base 'RT::SearchBuilder'; + +sub Table { 'Tickets'} + +use RT::CustomFields; + +# Configuration Tables: + +# FIELD_METADATA is a mapping of searchable Field name, to Type, and other +# metadata. + +our %FIELD_METADATA = ( + Status => [ 'ENUM', ], #loc_left_pair + Queue => [ 'ENUM' => 'Queue', ], #loc_left_pair + Type => [ 'ENUM', ], #loc_left_pair + Creator => [ 'ENUM' => 'User', ], #loc_left_pair + LastUpdatedBy => [ 'ENUM' => 'User', ], #loc_left_pair + Owner => [ 'WATCHERFIELD' => 'Owner', ], #loc_left_pair + EffectiveId => [ 'INT', ], #loc_left_pair + id => [ 'ID', ], #loc_left_pair + InitialPriority => [ 'INT', ], #loc_left_pair + FinalPriority => [ 'INT', ], #loc_left_pair + Priority => [ 'INT', ], #loc_left_pair + TimeLeft => [ 'INT', ], #loc_left_pair + TimeWorked => [ 'INT', ], #loc_left_pair + TimeEstimated => [ 'INT', ], #loc_left_pair + + Linked => [ 'LINK' ], #loc_left_pair + LinkedTo => [ 'LINK' => 'To' ], #loc_left_pair + LinkedFrom => [ 'LINK' => 'From' ], #loc_left_pair + MemberOf => [ 'LINK' => To => 'MemberOf', ], #loc_left_pair + DependsOn => [ 'LINK' => To => 'DependsOn', ], #loc_left_pair + RefersTo => [ 'LINK' => To => 'RefersTo', ], #loc_left_pair + HasMember => [ 'LINK' => From => 'MemberOf', ], #loc_left_pair + DependentOn => [ 'LINK' => From => 'DependsOn', ], #loc_left_pair + DependedOnBy => [ 'LINK' => From => 'DependsOn', ], #loc_left_pair + ReferredToBy => [ 'LINK' => From => 'RefersTo', ], #loc_left_pair + Told => [ 'DATE' => 'Told', ], #loc_left_pair + Starts => [ 'DATE' => 'Starts', ], #loc_left_pair + Started => [ 'DATE' => 'Started', ], #loc_left_pair + Due => [ 'DATE' => 'Due', ], #loc_left_pair + Resolved => [ 'DATE' => 'Resolved', ], #loc_left_pair + LastUpdated => [ 'DATE' => 'LastUpdated', ], #loc_left_pair + Created => [ 'DATE' => 'Created', ], #loc_left_pair + Subject => [ 'STRING', ], #loc_left_pair + Content => [ 'TRANSCONTENT', ], #loc_left_pair + ContentType => [ 'TRANSFIELD', ], #loc_left_pair + Filename => [ 'TRANSFIELD', ], #loc_left_pair + TransactionDate => [ 'TRANSDATE', ], #loc_left_pair + Requestor => [ 'WATCHERFIELD' => 'Requestor', ], #loc_left_pair + Requestors => [ 'WATCHERFIELD' => 'Requestor', ], #loc_left_pair + Cc => [ 'WATCHERFIELD' => 'Cc', ], #loc_left_pair + AdminCc => [ 'WATCHERFIELD' => 'AdminCc', ], #loc_left_pair + Watcher => [ 'WATCHERFIELD', ], #loc_left_pair + QueueCc => [ 'WATCHERFIELD' => 'Cc' => 'Queue', ], #loc_left_pair + QueueAdminCc => [ 'WATCHERFIELD' => 'AdminCc' => 'Queue', ], #loc_left_pair + QueueWatcher => [ 'WATCHERFIELD' => undef => 'Queue', ], #loc_left_pair + CustomFieldValue => [ 'CUSTOMFIELD' => 'Ticket' ], #loc_left_pair + CustomField => [ 'CUSTOMFIELD' => 'Ticket' ], #loc_left_pair + CF => [ 'CUSTOMFIELD' => 'Ticket' ], #loc_left_pair + Updated => [ 'TRANSDATE', ], #loc_left_pair + RequestorGroup => [ 'MEMBERSHIPFIELD' => 'Requestor', ], #loc_left_pair + CCGroup => [ 'MEMBERSHIPFIELD' => 'Cc', ], #loc_left_pair + AdminCCGroup => [ 'MEMBERSHIPFIELD' => 'AdminCc', ], #loc_left_pair + WatcherGroup => [ 'MEMBERSHIPFIELD', ], #loc_left_pair + HasAttribute => [ 'HASATTRIBUTE', 1 ], + HasNoAttribute => [ 'HASATTRIBUTE', 0 ], + #freeside + Customer => [ 'FREESIDEFIELD' => 'Customer' ], + Service => [ 'FREESIDEFIELD' => 'Service' ], + WillResolve => [ 'DATE' => 'WillResolve', ], #loc_left_pair +); + +# Lower Case version of FIELDS, for case insensitivity +our %LOWER_CASE_FIELDS = map { ( lc($_) => $_ ) } (keys %FIELD_METADATA); + +our %SEARCHABLE_SUBFIELDS = ( + User => [qw( + EmailAddress Name RealName Nickname Organization Address1 Address2 + WorkPhone HomePhone MobilePhone PagerPhone id + )], +); + +# Mapping of Field Type to Function +our %dispatch = ( + ENUM => \&_EnumLimit, + INT => \&_IntLimit, + ID => \&_IdLimit, + LINK => \&_LinkLimit, + DATE => \&_DateLimit, + STRING => \&_StringLimit, + TRANSFIELD => \&_TransLimit, + TRANSCONTENT => \&_TransContentLimit, + TRANSDATE => \&_TransDateLimit, + WATCHERFIELD => \&_WatcherLimit, + MEMBERSHIPFIELD => \&_WatcherMembershipLimit, + CUSTOMFIELD => \&_CustomFieldLimit, + HASATTRIBUTE => \&_HasAttributeLimit, + FREESIDEFIELD => \&_FreesideFieldLimit, +); +our %can_bundle = ();# WATCHERFIELD => "yes", ); + +# Default EntryAggregator per type +# if you specify OP, you must specify all valid OPs +my %DefaultEA = ( + INT => 'AND', + ENUM => { + '=' => 'OR', + '!=' => 'AND' + }, + DATE => { + '=' => 'OR', + '>=' => 'AND', + '<=' => 'AND', + '>' => 'AND', + '<' => 'AND' + }, + STRING => { + '=' => 'OR', + '!=' => 'AND', + 'LIKE' => 'AND', + 'NOT LIKE' => 'AND' + }, + TRANSFIELD => 'AND', + TRANSDATE => 'AND', + LINK => 'OR', + LINKFIELD => 'AND', + TARGET => 'AND', + BASE => 'AND', + WATCHERFIELD => { + '=' => 'OR', + '!=' => 'AND', + 'LIKE' => 'OR', + 'NOT LIKE' => 'AND' + }, + + HASATTRIBUTE => { + '=' => 'AND', + '!=' => 'AND', + }, + + CUSTOMFIELD => 'OR', +); + +# Helper functions for passing the above lexically scoped tables above +# into Tickets_SQL. +sub FIELDS { return \%FIELD_METADATA } +sub dispatch { return \%dispatch } +sub can_bundle { return \%can_bundle } + +# Bring in the clowns. +require RT::Tickets_SQL; + + +our @SORTFIELDS = qw(id Status + Queue Subject + Owner Created Due Starts Started + Told + Resolved LastUpdated Priority TimeWorked TimeLeft); =head2 SortFields @@ -85,140 +250,2177 @@ Returns the list of fields that lists of tickets can easily be sorted by =cut - sub SortFields { - my $self = shift; - return(@SORTFIELDS); + my $self = shift; + return (@SORTFIELDS); } -# }}} +# BEGIN SQL STUFF ********************************* -# {{{ Limit the result set based on content -# {{{ sub Limit +sub CleanSlate { + my $self = shift; + $self->SUPER::CleanSlate( @_ ); + delete $self->{$_} foreach qw( + _sql_cf_alias + _sql_group_members_aliases + _sql_object_cfv_alias + _sql_role_group_aliases + _sql_trattachalias + _sql_u_watchers_alias_for_sort + _sql_u_watchers_aliases + _sql_current_user_can_see_applied + ); +} -=head2 Limit +=head1 Limit Helper Routines -Takes a paramhash with the fields FIELD, OPERATOR, VALUE and DESCRIPTION -Generally best called from LimitFoo methods +These routines are the targets of a dispatch table depending on the +type of field. They all share the same signature: + + my ($self,$field,$op,$value,@rest) = @_; + +The values in @rest should be suitable for passing directly to +DBIx::SearchBuilder::Limit. + +Essentially they are an expanded/broken out (and much simplified) +version of what ProcessRestrictions used to do. They're also much +more clearly delineated by the TYPE of field being processed. + +=head2 _IdLimit + +Handle ID field. =cut -sub Limit { + +sub _IdLimit { + my ( $sb, $field, $op, $value, @rest ) = @_; + + if ( $value eq '__Bookmarked__' ) { + return $sb->_BookmarkLimit( $field, $op, $value, @rest ); + } else { + return $sb->_IntLimit( $field, $op, $value, @rest ); + } +} + +sub _BookmarkLimit { + my ( $sb, $field, $op, $value, @rest ) = @_; + + die "Invalid operator $op for __Bookmarked__ search on $field" + unless $op =~ /^(=|!=)$/; + + my @bookmarks = do { + my $tmp = $sb->CurrentUser->UserObj->FirstAttribute('Bookmarks'); + $tmp = $tmp->Content if $tmp; + $tmp ||= {}; + grep $_, keys %$tmp; + }; + + return $sb->_SQLLimit( + FIELD => $field, + OPERATOR => $op, + VALUE => 0, + @rest, + ) unless @bookmarks; + + # as bookmarked tickets can be merged we have to use a join + # but it should be pretty lightweight + my $tickets_alias = $sb->Join( + TYPE => 'LEFT', + ALIAS1 => 'main', + FIELD1 => 'id', + TABLE2 => 'Tickets', + FIELD2 => 'EffectiveId', + ); + $sb->_OpenParen; + my $first = 1; + my $ea = $op eq '='? 'OR': 'AND'; + foreach my $id ( sort @bookmarks ) { + $sb->_SQLLimit( + ALIAS => $tickets_alias, + FIELD => 'id', + OPERATOR => $op, + VALUE => $id, + $first? (@rest): ( ENTRYAGGREGATOR => $ea ) + ); + $first = 0 if $first; + } + $sb->_CloseParen; +} + +=head2 _EnumLimit + +Handle Fields which are limited to certain values, and potentially +need to be looked up from another class. + +This subroutine actually handles two different kinds of fields. For +some the user is responsible for limiting the values. (i.e. Status, +Type). + +For others, the value specified by the user will be looked by via +specified class. + +Meta Data: + name of class to lookup in (Optional) + +=cut + +sub _EnumLimit { + my ( $sb, $field, $op, $value, @rest ) = @_; + + # SQL::Statement changes != to <>. (Can we remove this now?) + $op = "!=" if $op eq "<>"; + + die "Invalid Operation: $op for $field" + unless $op eq "=" + or $op eq "!="; + + my $meta = $FIELD_METADATA{$field}; + if ( defined $meta->[1] && defined $value && $value !~ /^\d+$/ ) { + my $class = "RT::" . $meta->[1]; + my $o = $class->new( $sb->CurrentUser ); + $o->Load($value); + $value = $o->Id || 0; + } elsif ( $field eq "Type" ) { + $value = lc $value if $value =~ /^(ticket|approval|reminder)$/i; + } elsif ($field eq "Status") { + $value = lc $value; + } + $sb->_SQLLimit( + FIELD => $field, + VALUE => $value, + OPERATOR => $op, + @rest, + ); +} + +=head2 _IntLimit + +Handle fields where the values are limited to integers. (For example, +Priority, TimeWorked.) + +Meta Data: + None + +=cut + +sub _IntLimit { + my ( $sb, $field, $op, $value, @rest ) = @_; + + die "Invalid Operator $op for $field" + unless $op =~ /^(=|!=|>|<|>=|<=)$/; + + $sb->_SQLLimit( + FIELD => $field, + VALUE => $value, + OPERATOR => $op, + @rest, + ); +} + +=head2 _LinkLimit + +Handle fields which deal with links between tickets. (MemberOf, DependsOn) + +Meta Data: + 1: Direction (From, To) + 2: Link Type (MemberOf, DependsOn, RefersTo) + +=cut + +sub _LinkLimit { + my ( $sb, $field, $op, $value, @rest ) = @_; + + my $meta = $FIELD_METADATA{$field}; + die "Invalid Operator $op for $field" unless $op =~ /^(=|!=|IS|IS NOT)$/io; + + my $is_negative = 0; + if ( $op eq '!=' || $op =~ /\bNOT\b/i ) { + $is_negative = 1; + } + my $is_null = 0; + $is_null = 1 if !$value || $value =~ /^null$/io; + + my $direction = $meta->[1] || ''; + my ($matchfield, $linkfield) = ('', ''); + if ( $direction eq 'To' ) { + ($matchfield, $linkfield) = ("Target", "Base"); + } + elsif ( $direction eq 'From' ) { + ($matchfield, $linkfield) = ("Base", "Target"); + } + elsif ( $direction ) { + die "Invalid link direction '$direction' for $field\n"; + } else { + $sb->_OpenParen; + $sb->_LinkLimit( 'LinkedTo', $op, $value, @rest ); + $sb->_LinkLimit( + 'LinkedFrom', $op, $value, @rest, + ENTRYAGGREGATOR => (($is_negative && $is_null) || (!$is_null && !$is_negative))? 'OR': 'AND', + ); + $sb->_CloseParen; + return; + } + + my $is_local = 1; + if ( $is_null ) { + $op = ($op =~ /^(=|IS)$/i)? 'IS': 'IS NOT'; + } + elsif ( $value =~ /\D/ ) { + $value = RT::URI->new( $sb->CurrentUser )->CanonicalizeURI( $value ); + $is_local = 0; + } + $matchfield = "Local$matchfield" if $is_local; + +#For doing a left join to find "unlinked tickets" we want to generate a query that looks like this +# SELECT main.* FROM Tickets main +# LEFT JOIN Links Links_1 ON ( (Links_1.Type = 'MemberOf') +# AND(main.id = Links_1.LocalTarget)) +# WHERE Links_1.LocalBase IS NULL; + + if ( $is_null ) { + my $linkalias = $sb->Join( + TYPE => 'LEFT', + ALIAS1 => 'main', + FIELD1 => 'id', + TABLE2 => 'Links', + FIELD2 => 'Local' . $linkfield + ); + $sb->SUPER::Limit( + LEFTJOIN => $linkalias, + FIELD => 'Type', + OPERATOR => '=', + VALUE => $meta->[2], + ) if $meta->[2]; + $sb->_SQLLimit( + @rest, + ALIAS => $linkalias, + FIELD => $matchfield, + OPERATOR => $op, + VALUE => 'NULL', + QUOTEVALUE => 0, + ); + } + else { + my $linkalias = $sb->Join( + TYPE => 'LEFT', + ALIAS1 => 'main', + FIELD1 => 'id', + TABLE2 => 'Links', + FIELD2 => 'Local' . $linkfield + ); + $sb->SUPER::Limit( + LEFTJOIN => $linkalias, + FIELD => 'Type', + OPERATOR => '=', + VALUE => $meta->[2], + ) if $meta->[2]; + $sb->SUPER::Limit( + LEFTJOIN => $linkalias, + FIELD => $matchfield, + OPERATOR => '=', + VALUE => $value, + ); + $sb->_SQLLimit( + @rest, + ALIAS => $linkalias, + FIELD => $matchfield, + OPERATOR => $is_negative? 'IS': 'IS NOT', + VALUE => 'NULL', + QUOTEVALUE => 0, + ); + } +} + +=head2 _DateLimit + +Handle date fields. (Created, LastTold..) + +Meta Data: + 1: type of link. (Probably not necessary.) + +=cut + +sub _DateLimit { + my ( $sb, $field, $op, $value, @rest ) = @_; + + die "Invalid Date Op: $op" + unless $op =~ /^(=|>|<|>=|<=)$/; + + my $meta = $FIELD_METADATA{$field}; + die "Incorrect Meta Data for $field" + unless ( defined $meta->[1] ); + + $sb->_DateFieldLimit( $meta->[1], $op, $value, @rest ); +} + +# Factor this out for use by custom fields + +sub _DateFieldLimit { + my ( $sb, $field, $op, $value, @rest ) = @_; + + my $date = RT::Date->new( $sb->CurrentUser ); + $date->Set( Format => 'unknown', Value => $value ); + + if ( $op eq "=" ) { + + # if we're specifying =, that means we want everything on a + # particular single day. in the database, we need to check for > + # and < the edges of that day. + # + # Except if the value is 'this month' or 'last month', check + # > and < the edges of the month. + + my ($daystart, $dayend); + if ( lc($value) eq 'this month' ) { + $date->SetToNow; + $date->SetToStart('month', Timezone => 'server'); + $daystart = $date->ISO; + $date->AddMonth(Timezone => 'server'); + $dayend = $date->ISO; + } + elsif ( lc($value) eq 'last month' ) { + $date->SetToNow; + $date->SetToStart('month', Timezone => 'server'); + $dayend = $date->ISO; + $date->AddDays(-1); + $date->SetToStart('month', Timezone => 'server'); + $daystart = $date->ISO; + } + else { + $date->SetToMidnight( Timezone => 'server' ); + $daystart = $date->ISO; + $date->AddDay; + $dayend = $date->ISO; + } + + $sb->_OpenParen; + + $sb->_SQLLimit( + FIELD => $field, + OPERATOR => ">=", + VALUE => $daystart, + @rest, + ); + + $sb->_SQLLimit( + FIELD => $field, + OPERATOR => "<", + VALUE => $dayend, + @rest, + ENTRYAGGREGATOR => 'AND', + ); + + $sb->_CloseParen; + + } + else { + $sb->_SQLLimit( + FIELD => $field, + OPERATOR => $op, + VALUE => $date->ISO, + @rest, + ); + } +} + +=head2 _StringLimit + +Handle simple fields which are just strings. (Subject,Type) + +Meta Data: + None + +=cut + +sub _StringLimit { + my ( $sb, $field, $op, $value, @rest ) = @_; + + # FIXME: + # Valid Operators: + # =, !=, LIKE, NOT LIKE + if ( RT->Config->Get('DatabaseType') eq 'Oracle' + && (!defined $value || !length $value) + && lc($op) ne 'is' && lc($op) ne 'is not' + ) { + if ($op eq '!=' || $op =~ /^NOT\s/i) { + $op = 'IS NOT'; + } else { + $op = 'IS'; + } + $value = 'NULL'; + } + + $sb->_SQLLimit( + FIELD => $field, + OPERATOR => $op, + VALUE => $value, + CASESENSITIVE => 0, + @rest, + ); +} + +=head2 _TransDateLimit + +Handle fields limiting based on Transaction Date. + +The inpupt value must be in a format parseable by Time::ParseDate + +Meta Data: + None + +=cut + +# This routine should really be factored into translimit. +sub _TransDateLimit { + my ( $sb, $field, $op, $value, @rest ) = @_; + + # See the comments for TransLimit, they apply here too + + my $txn_alias = $sb->JoinTransactions; + + my $date = RT::Date->new( $sb->CurrentUser ); + $date->Set( Format => 'unknown', Value => $value ); + + $sb->_OpenParen; + if ( $op eq "=" ) { + + # if we're specifying =, that means we want everything on a + # particular single day. in the database, we need to check for > + # and < the edges of that day. + + $date->SetToMidnight( Timezone => 'server' ); + my $daystart = $date->ISO; + $date->AddDay; + my $dayend = $date->ISO; + + $sb->_SQLLimit( + ALIAS => $txn_alias, + FIELD => 'Created', + OPERATOR => ">=", + VALUE => $daystart, + @rest + ); + $sb->_SQLLimit( + ALIAS => $txn_alias, + FIELD => 'Created', + OPERATOR => "<=", + VALUE => $dayend, + @rest, + ENTRYAGGREGATOR => 'AND', + ); + + } + + # not searching for a single day + else { + + #Search for the right field + $sb->_SQLLimit( + ALIAS => $txn_alias, + FIELD => 'Created', + OPERATOR => $op, + VALUE => $date->ISO, + @rest + ); + } + + $sb->_CloseParen; +} + +=head2 _TransLimit + +Limit based on the ContentType or the Filename of a transaction. + +=cut + +sub _TransLimit { + my ( $self, $field, $op, $value, %rest ) = @_; + + my $txn_alias = $self->JoinTransactions; + unless ( defined $self->{_sql_trattachalias} ) { + $self->{_sql_trattachalias} = $self->_SQLJoin( + TYPE => 'LEFT', # not all txns have an attachment + ALIAS1 => $txn_alias, + FIELD1 => 'id', + TABLE2 => 'Attachments', + FIELD2 => 'TransactionId', + ); + } + + $self->_SQLLimit( + %rest, + ALIAS => $self->{_sql_trattachalias}, + FIELD => $field, + OPERATOR => $op, + VALUE => $value, + CASESENSITIVE => 0, + ); +} + +=head2 _TransContentLimit + +Limit based on the Content of a transaction. + +=cut + +sub _TransContentLimit { + + # Content search + + # If only this was this simple. We've got to do something + # complicated here: + + #Basically, we want to make sure that the limits apply to + #the same attachment, rather than just another attachment + #for the same ticket, no matter how many clauses we lump + #on. We put them in TicketAliases so that they get nuked + #when we redo the join. + + # In the SQL, we might have + # (( Content = foo ) or ( Content = bar AND Content = baz )) + # The AND group should share the same Alias. + + # Actually, maybe it doesn't matter. We use the same alias and it + # works itself out? (er.. different.) + + # Steal more from _ProcessRestrictions + + # FIXME: Maybe look at the previous FooLimit call, and if it was a + # TransLimit and EntryAggregator == AND, reuse the Aliases? + + # Or better - store the aliases on a per subclause basis - since + # those are going to be the things we want to relate to each other, + # anyway. + + # maybe we should not allow certain kinds of aggregation of these + # clauses and do a psuedo regex instead? - the problem is getting + # them all into the same subclause when you have (A op B op C) - the + # way they get parsed in the tree they're in different subclauses. + + my ( $self, $field, $op, $value, %rest ) = @_; + $field = 'Content' if $field =~ /\W/; + + my $config = RT->Config->Get('FullTextSearch') || {}; + unless ( $config->{'Enable'} ) { + $self->_SQLLimit( %rest, FIELD => 'id', VALUE => 0 ); + return; + } + + my $txn_alias = $self->JoinTransactions; + unless ( defined $self->{_sql_trattachalias} ) { + $self->{_sql_trattachalias} = $self->_SQLJoin( + TYPE => 'LEFT', # not all txns have an attachment + ALIAS1 => $txn_alias, + FIELD1 => 'id', + TABLE2 => 'Attachments', + FIELD2 => 'TransactionId', + ); + } + + $self->_OpenParen; + if ( $config->{'Indexed'} ) { + my $db_type = RT->Config->Get('DatabaseType'); + + my $alias; + if ( $config->{'Table'} and $config->{'Table'} ne "Attachments") { + $alias = $self->{'_sql_aliases'}{'full_text'} ||= $self->_SQLJoin( + TYPE => 'LEFT', + ALIAS1 => $self->{'_sql_trattachalias'}, + FIELD1 => 'id', + TABLE2 => $config->{'Table'}, + FIELD2 => 'id', + ); + } else { + $alias = $self->{'_sql_trattachalias'}; + } + + #XXX: handle negative searches + my $index = $config->{'Column'}; + if ( $db_type eq 'Oracle' ) { + my $dbh = $RT::Handle->dbh; + my $alias = $self->{_sql_trattachalias}; + $self->_SQLLimit( + %rest, + FUNCTION => "CONTAINS( $alias.$field, ".$dbh->quote($value) .")", + OPERATOR => '>', + VALUE => 0, + QUOTEVALUE => 0, + CASESENSITIVE => 1, + ); + # this is required to trick DBIx::SB's LEFT JOINS optimizer + # into deciding that join is redundant as it is + $self->_SQLLimit( + ENTRYAGGREGATOR => 'AND', + ALIAS => $self->{_sql_trattachalias}, + FIELD => 'Content', + OPERATOR => 'IS NOT', + VALUE => 'NULL', + ); + } + elsif ( $db_type eq 'Pg' ) { + my $dbh = $RT::Handle->dbh; + $self->_SQLLimit( + %rest, + ALIAS => $alias, + FIELD => $index, + OPERATOR => '@@', + VALUE => 'plainto_tsquery('. $dbh->quote($value) .')', + QUOTEVALUE => 0, + ); + } + elsif ( $db_type eq 'mysql' ) { + # XXX: We could theoretically skip the join to Attachments, + # and have Sphinx simply index and group by the TicketId, + # and join Ticket.id to that attribute, which would be much + # more efficient -- however, this is only a possibility if + # there are no other transaction limits. + + # This is a special character. Note that \ does not escape + # itself (in Sphinx 2.1.0, at least), so 'foo\;bar' becoming + # 'foo\\;bar' is not a vulnerability, and is still parsed as + # "foo, \, ;, then bar". Happily, the default mode is + # "all", meaning that boolean operators are not special. + $value =~ s/;/\\;/g; + + my $max = $config->{'MaxMatches'}; + $self->_SQLLimit( + %rest, + ALIAS => $alias, + FIELD => 'query', + OPERATOR => '=', + VALUE => "$value;limit=$max;maxmatches=$max", + ); + } + } else { + $self->_SQLLimit( + %rest, + ALIAS => $self->{_sql_trattachalias}, + FIELD => $field, + OPERATOR => $op, + VALUE => $value, + CASESENSITIVE => 0, + ); + } + if ( RT->Config->Get('DontSearchFileAttachments') ) { + $self->_SQLLimit( + ENTRYAGGREGATOR => 'AND', + ALIAS => $self->{_sql_trattachalias}, + FIELD => 'Filename', + OPERATOR => 'IS', + VALUE => 'NULL', + ); + } + $self->_CloseParen; +} + +=head2 _WatcherLimit + +Handle watcher limits. (Requestor, CC, etc..) + +Meta Data: + 1: Field to query on + + + +=cut + +sub _WatcherLimit { + my $self = shift; + my $field = shift; + my $op = shift; + my $value = shift; + my %rest = (@_); + + my $meta = $FIELD_METADATA{ $field }; + my $type = $meta->[1] || ''; + my $class = $meta->[2] || 'Ticket'; + + # Bail if the subfield is not allowed + if ( $rest{SUBKEY} + and not grep { $_ eq $rest{SUBKEY} } @{$SEARCHABLE_SUBFIELDS{'User'}}) + { + die "Invalid watcher subfield: '$rest{SUBKEY}'"; + } + + # if it's equality op and search by Email or Name then we can preload user + # we do it to help some DBs better estimate number of rows and get better plans + if ( $op =~ /^!?=$/ && (!$rest{'SUBKEY'} || $rest{'SUBKEY'} eq 'Name' || $rest{'SUBKEY'} eq 'EmailAddress') ) { + my $o = RT::User->new( $self->CurrentUser ); + my $method = + !$rest{'SUBKEY'} + ? $field eq 'Owner'? 'Load' : 'LoadByEmail' + : $rest{'SUBKEY'} eq 'EmailAddress' ? 'LoadByEmail': 'Load'; + $o->$method( $value ); + $rest{'SUBKEY'} = 'id'; + $value = $o->id || 0; + } + + # Owner was ENUM field, so "Owner = 'xxx'" allowed user to + # search by id and Name at the same time, this is workaround + # to preserve backward compatibility + if ( $field eq 'Owner' ) { + if ( ($rest{'SUBKEY'}||'') eq 'id' ) { + $self->_SQLLimit( + FIELD => 'Owner', + OPERATOR => $op, + VALUE => $value, + %rest, + ); + return; + } + } + $rest{SUBKEY} ||= 'EmailAddress'; + + my ($groups, $group_members, $users); + if ( $rest{'BUNDLE'} ) { + ($groups, $group_members, $users) = @{ $rest{'BUNDLE'} }; + } else { + $groups = $self->_RoleGroupsJoin( Type => $type, Class => $class, New => !$type ); + } + + $self->_OpenParen; + if ( $op =~ /^IS(?: NOT)?$/i ) { + # is [not] empty case + + $group_members ||= $self->_GroupMembersJoin( GroupsAlias => $groups ); + # to avoid joining the table Users into the query, we just join GM + # and make sure we don't match records where group is member of itself + $self->SUPER::Limit( + LEFTJOIN => $group_members, + FIELD => 'GroupId', + OPERATOR => '!=', + VALUE => "$group_members.MemberId", + QUOTEVALUE => 0, + ); + $self->_SQLLimit( + ALIAS => $group_members, + FIELD => 'GroupId', + OPERATOR => $op, + VALUE => $value, + %rest, + ); + } + elsif ( $op =~ /^!=$|^NOT\s+/i ) { + # negative condition case + + # reverse op + $op =~ s/!|NOT\s+//i; + + # XXX: we have no way to build correct "Watcher.X != 'Y'" when condition + # "X = 'Y'" matches more then one user so we try to fetch two records and + # do the right thing when there is only one exist and semi-working solution + # otherwise. + my $users_obj = RT::Users->new( $self->CurrentUser ); + $users_obj->Limit( + FIELD => $rest{SUBKEY}, + OPERATOR => $op, + VALUE => $value, + ); + $users_obj->OrderBy; + $users_obj->RowsPerPage(2); + my @users = @{ $users_obj->ItemsArrayRef }; + + $group_members ||= $self->_GroupMembersJoin( GroupsAlias => $groups ); + if ( @users <= 1 ) { + my $uid = 0; + $uid = $users[0]->id if @users; + $self->SUPER::Limit( + LEFTJOIN => $group_members, + ALIAS => $group_members, + FIELD => 'MemberId', + VALUE => $uid, + ); + $self->_SQLLimit( + %rest, + ALIAS => $group_members, + FIELD => 'id', + OPERATOR => 'IS', + VALUE => 'NULL', + ); + } else { + $self->SUPER::Limit( + LEFTJOIN => $group_members, + FIELD => 'GroupId', + OPERATOR => '!=', + VALUE => "$group_members.MemberId", + QUOTEVALUE => 0, + ); + $users ||= $self->Join( + TYPE => 'LEFT', + ALIAS1 => $group_members, + FIELD1 => 'MemberId', + TABLE2 => 'Users', + FIELD2 => 'id', + ); + $self->SUPER::Limit( + LEFTJOIN => $users, + ALIAS => $users, + FIELD => $rest{SUBKEY}, + OPERATOR => $op, + VALUE => $value, + CASESENSITIVE => 0, + ); + $self->_SQLLimit( + %rest, + ALIAS => $users, + FIELD => 'id', + OPERATOR => 'IS', + VALUE => 'NULL', + ); + } + } else { + # positive condition case + + $group_members ||= $self->_GroupMembersJoin( + GroupsAlias => $groups, New => 1, Left => 0 + ); + $users ||= $self->Join( + TYPE => 'LEFT', + ALIAS1 => $group_members, + FIELD1 => 'MemberId', + TABLE2 => 'Users', + FIELD2 => 'id', + ); + $self->_SQLLimit( + %rest, + ALIAS => $users, + FIELD => $rest{'SUBKEY'}, + VALUE => $value, + OPERATOR => $op, + CASESENSITIVE => 0, + ); + } + $self->_CloseParen; + return ($groups, $group_members, $users); +} + +sub _RoleGroupsJoin { my $self = shift; - my %args = ( FIELD => undef, - OPERATOR => '=', - VALUE => undef, - DESCRIPTION => undef, - @_ - ); - $args{'DESCRIPTION'} = "Autodescribed: ".$args{'FIELD'} . $args{'OPERATOR'} . $args{'VALUE'}, - if (!defined $args{'DESCRIPTION'}) ; + my %args = (New => 0, Class => 'Ticket', Type => '', @_); + return $self->{'_sql_role_group_aliases'}{ $args{'Class'} .'-'. $args{'Type'} } + if $self->{'_sql_role_group_aliases'}{ $args{'Class'} .'-'. $args{'Type'} } + && !$args{'New'}; + + # we always have watcher groups for ticket, so we use INNER join + my $groups = $self->Join( + ALIAS1 => 'main', + FIELD1 => $args{'Class'} eq 'Queue'? 'Queue': 'id', + TABLE2 => 'Groups', + FIELD2 => 'Instance', + ENTRYAGGREGATOR => 'AND', + ); + $self->SUPER::Limit( + LEFTJOIN => $groups, + ALIAS => $groups, + FIELD => 'Domain', + VALUE => 'RT::'. $args{'Class'} .'-Role', + ); + $self->SUPER::Limit( + LEFTJOIN => $groups, + ALIAS => $groups, + FIELD => 'Type', + VALUE => $args{'Type'}, + ) if $args{'Type'}; + + $self->{'_sql_role_group_aliases'}{ $args{'Class'} .'-'. $args{'Type'} } = $groups + unless $args{'New'}; + + return $groups; +} - my $index = $self->_NextIndex; - - #make the TicketRestrictions hash the equivalent of whatever we just passed in; - - %{$self->{'TicketRestrictions'}{$index}} = %args; +sub _GroupMembersJoin { + my $self = shift; + my %args = (New => 1, GroupsAlias => undef, Left => 1, @_); + + return $self->{'_sql_group_members_aliases'}{ $args{'GroupsAlias'} } + if $self->{'_sql_group_members_aliases'}{ $args{'GroupsAlias'} } + && !$args{'New'}; + + my $alias = $self->Join( + $args{'Left'} ? (TYPE => 'LEFT') : (), + ALIAS1 => $args{'GroupsAlias'}, + FIELD1 => 'id', + TABLE2 => 'CachedGroupMembers', + FIELD2 => 'GroupId', + ENTRYAGGREGATOR => 'AND', + ); + $self->SUPER::Limit( + $args{'Left'} ? (LEFTJOIN => $alias) : (), + ALIAS => $alias, + FIELD => 'Disabled', + VALUE => 0, + ); + + $self->{'_sql_group_members_aliases'}{ $args{'GroupsAlias'} } = $alias + unless $args{'New'}; + + return $alias; +} - $self->{'RecalcTicketLimits'} = 1; +=head2 _WatcherJoin - # If we're looking at the effective id, we don't want to append the other clause - # which limits us to tickets where id = effective id - if ($args{'FIELD'} eq 'EffectiveId') { - $self->{'looking_at_effective_id'} = 1; +Helper function which provides joins to a watchers table both for limits +and for ordering. + +=cut + +sub _WatcherJoin { + my $self = shift; + my $type = shift || ''; + + + my $groups = $self->_RoleGroupsJoin( Type => $type ); + my $group_members = $self->_GroupMembersJoin( GroupsAlias => $groups ); + # XXX: work around, we must hide groups that + # are members of the role group we search in, + # otherwise them result in wrong NULLs in Users + # table and break ordering. Now, we know that + # RT doesn't allow to add groups as members of the + # ticket roles, so we just hide entries in CGM table + # with MemberId == GroupId from results + $self->SUPER::Limit( + LEFTJOIN => $group_members, + FIELD => 'GroupId', + OPERATOR => '!=', + VALUE => "$group_members.MemberId", + QUOTEVALUE => 0, + ); + my $users = $self->Join( + TYPE => 'LEFT', + ALIAS1 => $group_members, + FIELD1 => 'MemberId', + TABLE2 => 'Users', + FIELD2 => 'id', + ); + return ($groups, $group_members, $users); +} + +=head2 _WatcherMembershipLimit + +Handle watcher membership limits, i.e. whether the watcher belongs to a +specific group or not. + +Meta Data: + 1: Field to query on + +SELECT DISTINCT main.* +FROM + Tickets main, + Groups Groups_1, + CachedGroupMembers CachedGroupMembers_2, + Users Users_3 +WHERE ( + (main.EffectiveId = main.id) +) AND ( + (main.Status != 'deleted') +) AND ( + (main.Type = 'ticket') +) AND ( + ( + (Users_3.EmailAddress = '22') + AND + (Groups_1.Domain = 'RT::Ticket-Role') + AND + (Groups_1.Type = 'RequestorGroup') + ) +) AND + Groups_1.Instance = main.id +AND + Groups_1.id = CachedGroupMembers_2.GroupId +AND + CachedGroupMembers_2.MemberId = Users_3.id +ORDER BY main.id ASC +LIMIT 25 + +=cut + +sub _WatcherMembershipLimit { + my ( $self, $field, $op, $value, @rest ) = @_; + my %rest = @rest; + + $self->_OpenParen; + + my $groups = $self->NewAlias('Groups'); + my $groupmembers = $self->NewAlias('CachedGroupMembers'); + my $users = $self->NewAlias('Users'); + my $memberships = $self->NewAlias('CachedGroupMembers'); + + if ( ref $field ) { # gross hack + my @bundle = @$field; + $self->_OpenParen; + for my $chunk (@bundle) { + ( $field, $op, $value, @rest ) = @$chunk; + $self->_SQLLimit( + ALIAS => $memberships, + FIELD => 'GroupId', + VALUE => $value, + OPERATOR => $op, + @rest, + ); + } + $self->_CloseParen; + } + else { + $self->_SQLLimit( + ALIAS => $memberships, + FIELD => 'GroupId', + VALUE => $value, + OPERATOR => $op, + @rest, + ); } - return ($index); + # Tie to groups for tickets we care about + $self->_SQLLimit( + ALIAS => $groups, + FIELD => 'Domain', + VALUE => 'RT::Ticket-Role', + ENTRYAGGREGATOR => 'AND' + ); + + $self->Join( + ALIAS1 => $groups, + FIELD1 => 'Instance', + ALIAS2 => 'main', + FIELD2 => 'id' + ); + + # }}} + + # If we care about which sort of watcher + my $meta = $FIELD_METADATA{$field}; + my $type = ( defined $meta->[1] ? $meta->[1] : undef ); + + if ($type) { + $self->_SQLLimit( + ALIAS => $groups, + FIELD => 'Type', + VALUE => $type, + ENTRYAGGREGATOR => 'AND' + ); + } + + $self->Join( + ALIAS1 => $groups, + FIELD1 => 'id', + ALIAS2 => $groupmembers, + FIELD2 => 'GroupId' + ); + + $self->Join( + ALIAS1 => $groupmembers, + FIELD1 => 'MemberId', + ALIAS2 => $users, + FIELD2 => 'id' + ); + + $self->Limit( + ALIAS => $groupmembers, + FIELD => 'Disabled', + VALUE => 0, + ); + + $self->Join( + ALIAS1 => $memberships, + FIELD1 => 'MemberId', + ALIAS2 => $users, + FIELD2 => 'id' + ); + + $self->Limit( + ALIAS => $memberships, + FIELD => 'Disabled', + VALUE => 0, + ); + + + $self->_CloseParen; + } -# }}} +=head2 _CustomFieldDecipher + +Try and turn a CF descriptor into (cfid, cfname) object pair. +Takes an optional second parameter of the CF LookupType, defaults to Ticket CFs. +=cut +sub _CustomFieldDecipher { + my ($self, $string, $lookuptype) = @_; + $lookuptype ||= $self->_SingularClass->CustomFieldLookupType; -=head2 FreezeLimits + my ($object, $field, $column) = ($string =~ /^(?:(.+?)\.)?\{(.+)\}(?:\.(Content|LargeContent))?$/); + $field ||= ($string =~ /^{(.*?)}$/)[0] || $string; -Returns a frozen string suitable for handing back to ThawLimits. + my ($cf, $applied_to); + + if ( $object ) { + my $record_class = RT::CustomField->RecordClassFromLookupType($lookuptype); + $applied_to = $record_class->new( $self->CurrentUser ); + $applied_to->Load( $object ); + + if ( $applied_to->id ) { + RT->Logger->debug("Limiting to CFs identified by '$field' applied to $record_class #@{[$applied_to->id]} (loaded via '$object')"); + } + else { + RT->Logger->warning("$record_class '$object' doesn't exist, parsed from '$string'"); + $object = 0; + undef $applied_to; + } + } + + if ( $field =~ /\D/ ) { + $object ||= ''; + my $cfs = RT::CustomFields->new( $self->CurrentUser ); + $cfs->Limit( FIELD => 'Name', VALUE => $field, ($applied_to ? (CASESENSITIVE => 0) : ()) ); + $cfs->LimitToLookupType($lookuptype); + + if ($applied_to) { + $cfs->SetContextObject($applied_to); + $cfs->LimitToObjectId($applied_to->id); + } + + # if there is more then one field the current user can + # see with the same name then we shouldn't return cf object + # as we don't know which one to use + $cf = $cfs->First; + if ( $cf ) { + $cf = undef if $cfs->Next; + } + } + else { + $cf = RT::CustomField->new( $self->CurrentUser ); + $cf->Load( $field ); + $cf->SetContextObject($applied_to) + if $cf->id and $applied_to; + } + + return ($object, $field, $cf, $column); +} + +=head2 _CustomFieldJoin + +Factor out the Join of custom fields so we can use it for sorting too =cut -# {{{ sub FreezeLimits -sub FreezeLimits { - my $self = shift; - require FreezeThaw; - return (FreezeThaw::freeze($self->{'TicketRestrictions'}, - $self->{'restriction_index'} - )); +our %JOIN_ALIAS_FOR_LOOKUP_TYPE = ( + RT::Ticket->CustomFieldLookupType => sub { "main" }, +); + +sub _CustomFieldJoin { + my ($self, $cfkey, $cfid, $field, $type) = @_; + $type ||= RT::Ticket->CustomFieldLookupType; + + # Perform one Join per CustomField + if ( $self->{_sql_object_cfv_alias}{$cfkey} || + $self->{_sql_cf_alias}{$cfkey} ) + { + return ( $self->{_sql_object_cfv_alias}{$cfkey}, + $self->{_sql_cf_alias}{$cfkey} ); + } + + my $ObjectAlias = $JOIN_ALIAS_FOR_LOOKUP_TYPE{$type} + ? $JOIN_ALIAS_FOR_LOOKUP_TYPE{$type}->($self) + : die "We don't know how to join on $type"; + + my ($ObjectCFs, $CFs); + if ( $cfid ) { + $ObjectCFs = $self->{_sql_object_cfv_alias}{$cfkey} = $self->Join( + TYPE => 'LEFT', + ALIAS1 => $ObjectAlias, + FIELD1 => 'id', + TABLE2 => 'ObjectCustomFieldValues', + FIELD2 => 'ObjectId', + ); + $self->SUPER::Limit( + LEFTJOIN => $ObjectCFs, + FIELD => 'CustomField', + VALUE => $cfid, + ENTRYAGGREGATOR => 'AND' + ); + } + else { + my $ocfalias = $self->Join( + TYPE => 'LEFT', + FIELD1 => 'Queue', + TABLE2 => 'ObjectCustomFields', + FIELD2 => 'ObjectId', + ); + + $self->SUPER::Limit( + LEFTJOIN => $ocfalias, + ENTRYAGGREGATOR => 'OR', + FIELD => 'ObjectId', + VALUE => '0', + ); + + $CFs = $self->{_sql_cf_alias}{$cfkey} = $self->Join( + TYPE => 'LEFT', + ALIAS1 => $ocfalias, + FIELD1 => 'CustomField', + TABLE2 => 'CustomFields', + FIELD2 => 'id', + ); + $self->SUPER::Limit( + LEFTJOIN => $CFs, + ENTRYAGGREGATOR => 'AND', + FIELD => 'LookupType', + VALUE => $type, + ); + $self->SUPER::Limit( + LEFTJOIN => $CFs, + ENTRYAGGREGATOR => 'AND', + FIELD => 'Name', + VALUE => $field, + ); + + $ObjectCFs = $self->{_sql_object_cfv_alias}{$cfkey} = $self->Join( + TYPE => 'LEFT', + ALIAS1 => $CFs, + FIELD1 => 'id', + TABLE2 => 'ObjectCustomFieldValues', + FIELD2 => 'CustomField', + ); + $self->SUPER::Limit( + LEFTJOIN => $ObjectCFs, + FIELD => 'ObjectId', + VALUE => "$ObjectAlias.id", + QUOTEVALUE => 0, + ENTRYAGGREGATOR => 'AND', + ); + } + + $self->SUPER::Limit( + LEFTJOIN => $ObjectCFs, + FIELD => 'ObjectType', + VALUE => RT::CustomField->ObjectTypeFromLookupType($type), + ENTRYAGGREGATOR => 'AND' + ); + $self->SUPER::Limit( + LEFTJOIN => $ObjectCFs, + FIELD => 'Disabled', + OPERATOR => '=', + VALUE => '0', + ENTRYAGGREGATOR => 'AND' + ); + + return ($ObjectCFs, $CFs); } -# }}} +=head2 _CustomFieldLimit -=head2 ThawLimits +Limit based on CustomFields -Take a frozen Limits string generated by FreezeLimits and make this tickets -object have that set of limits. +Meta Data: + none =cut -# {{{ sub ThawLimits -sub ThawLimits { - my $self = shift; - my $in = shift; - - #if we don't have $in, get outta here. - return undef unless ($in); +use Regexp::Common qw(RE_net_IPv4); +use Regexp::Common::net::CIDR; + + +sub _CustomFieldLimit { + my ( $self, $_field, $op, $value, %rest ) = @_; + + my $meta = $FIELD_METADATA{ $_field }; + my $class = $meta->[1] || 'Ticket'; + my $type = "RT::$class"->CustomFieldLookupType; - $self->{'RecalcTicketLimits'} = 1; + my $field = $rest{'SUBKEY'} || die "No field specified"; - require FreezeThaw; - - #We don't need to die if the thaw fails. - - eval { - ($self->{'TicketRestrictions'}, - $self->{'restriction_index'} - ) = FreezeThaw::thaw($in); - } + # For our sanity, we can only limit on one queue at a time + my ($object, $cfid, $cf, $column); + ($object, $field, $cf, $column) = $self->_CustomFieldDecipher( $field, $type ); + $cfid = $cf ? $cf->id : 0 ; + +# If we're trying to find custom fields that don't match something, we +# want tickets where the custom field has no value at all. Note that +# we explicitly don't include the "IS NULL" case, since we would +# otherwise end up with a redundant clause. + + my ($negative_op, $null_op, $inv_op, $range_op) + = $self->ClassifySQLOperation( $op ); + + my $fix_op = sub { + return @_ unless RT->Config->Get('DatabaseType') eq 'Oracle'; + + my %args = @_; + return %args unless $args{'FIELD'} eq 'LargeContent'; + + my $op = $args{'OPERATOR'}; + if ( $op eq '=' ) { + $args{'OPERATOR'} = 'MATCHES'; + } + elsif ( $op eq '!=' ) { + $args{'OPERATOR'} = 'NOT MATCHES'; + } + elsif ( $op =~ /^[<>]=?$/ ) { + $args{'FUNCTION'} = "TO_CHAR( $args{'ALIAS'}.LargeContent )"; + } + return %args; + }; + + if ( $cf && $cf->Type eq 'IPAddress' ) { + my $parsed = RT::ObjectCustomFieldValue->ParseIP($value); + if ($parsed) { + $value = $parsed; + } + else { + $RT::Logger->warn("$value is not a valid IPAddress"); + } + } + + if ( $cf && $cf->Type eq 'IPAddressRange' ) { + my ( $start_ip, $end_ip ) = + RT::ObjectCustomFieldValue->ParseIPRange($value); + if ( $start_ip && $end_ip ) { + if ( $op =~ /^([<>])=?$/ ) { + my $is_less = $1 eq '<' ? 1 : 0; + if ( $is_less ) { + $value = $start_ip; + } + else { + $value = $end_ip; + } + } + else { + $value = join '-', $start_ip, $end_ip; + } + } + else { + $RT::Logger->warn("$value is not a valid IPAddressRange"); + } + } + + if ( $cf && $cf->Type =~ /^Date(?:Time)?$/ ) { + my $date = RT::Date->new( $self->CurrentUser ); + $date->Set( Format => 'unknown', Value => $value ); + if ( $date->Unix ) { + + if ( + $cf->Type eq 'Date' + || $value =~ /^\s*(?:today|tomorrow|yesterday)\s*$/i + || ( $value !~ /midnight|\d+:\d+:\d+/i + && $date->Time( Timezone => 'user' ) eq '00:00:00' ) + ) + { + $value = $date->Date( Timezone => 'user' ); + } + else { + $value = $date->DateTime; + } + } + else { + $RT::Logger->warn("$value is not a valid date string"); + } + } + + my $single_value = !$cf || !$cfid || $cf->SingleValue; + + my $cfkey = $cfid ? $cfid : "$type-$object.$field"; + + if ( $null_op && !$column ) { + # IS[ NOT] NULL without column is the same as has[ no] any CF value, + # we can reuse our default joins for this operation + # with column specified we have different situation + my ($ObjectCFs, $CFs) = $self->_CustomFieldJoin( $cfkey, $cfid, $field, $type ); + $self->_OpenParen; + $self->_SQLLimit( + ALIAS => $ObjectCFs, + FIELD => 'id', + OPERATOR => $op, + VALUE => $value, + %rest + ); + $self->_SQLLimit( + ALIAS => $CFs, + FIELD => 'Name', + OPERATOR => 'IS NOT', + VALUE => 'NULL', + QUOTEVALUE => 0, + ENTRYAGGREGATOR => 'AND', + ) if $CFs; + $self->_CloseParen; + } + elsif ( $op !~ /^[<>]=?$/ && ( $cf && $cf->Type eq 'IPAddressRange')) { + + my ($start_ip, $end_ip) = split /-/, $value; + + $self->_OpenParen; + if ( $op !~ /NOT|!=|<>/i ) { # positive equation + $self->_CustomFieldLimit( + $_field, '<=', $end_ip, %rest, + SUBKEY => $rest{'SUBKEY'}. '.Content', + ); + $self->_CustomFieldLimit( + $_field, '>=', $start_ip, %rest, + SUBKEY => $rest{'SUBKEY'}. '.LargeContent', + ENTRYAGGREGATOR => 'AND', + ); + # as well limit borders so DB optimizers can use better + # estimations and scan less rows +# have to disable this tweak because of ipv6 +# $self->_CustomFieldLimit( +# $_field, '>=', '000.000.000.000', %rest, +# SUBKEY => $rest{'SUBKEY'}. '.Content', +# ENTRYAGGREGATOR => 'AND', +# ); +# $self->_CustomFieldLimit( +# $_field, '<=', '255.255.255.255', %rest, +# SUBKEY => $rest{'SUBKEY'}. '.LargeContent', +# ENTRYAGGREGATOR => 'AND', +# ); + } + else { # negative equation + $self->_CustomFieldLimit($_field, '>', $end_ip, %rest); + $self->_CustomFieldLimit( + $_field, '<', $start_ip, %rest, + SUBKEY => $rest{'SUBKEY'}. '.LargeContent', + ENTRYAGGREGATOR => 'OR', + ); + # TODO: as well limit borders so DB optimizers can use better + # estimations and scan less rows, but it's harder to do + # as we have OR aggregator + } + $self->_CloseParen; + } + elsif ( !$negative_op || $single_value ) { + $cfkey .= '.'. $self->{'_sql_multiple_cfs_index'}++ if !$single_value && !$range_op; + my ($ObjectCFs, $CFs) = $self->_CustomFieldJoin( $cfkey, $cfid, $field, $type ); + + $self->_OpenParen; + + $self->_OpenParen; + + $self->_OpenParen; + # if column is defined then deal only with it + # otherwise search in Content and in LargeContent + if ( $column ) { + $self->_SQLLimit( $fix_op->( + ALIAS => $ObjectCFs, + FIELD => $column, + OPERATOR => $op, + VALUE => $value, + CASESENSITIVE => 0, + %rest + ) ); + $self->_CloseParen; + $self->_CloseParen; + $self->_CloseParen; + } + else { + # need special treatment for Date + if ( $cf and $cf->Type eq 'DateTime' and $op eq '=' && $value !~ /:/ ) { + # no time specified, that means we want everything on a + # particular day. in the database, we need to check for > + # and < the edges of that day. + my $date = RT::Date->new( $self->CurrentUser ); + $date->Set( Format => 'unknown', Value => $value ); + my $daystart = $date->ISO; + $date->AddDay; + my $dayend = $date->ISO; + + $self->_OpenParen; + + $self->_SQLLimit( + ALIAS => $ObjectCFs, + FIELD => 'Content', + OPERATOR => ">=", + VALUE => $daystart, + %rest, + ); + + $self->_SQLLimit( + ALIAS => $ObjectCFs, + FIELD => 'Content', + OPERATOR => "<", + VALUE => $dayend, + %rest, + ENTRYAGGREGATOR => 'AND', + ); + + $self->_CloseParen; + } + elsif ( $op eq '=' || $op eq '!=' || $op eq '<>' ) { + if ( length( Encode::encode( "UTF-8", $value) ) < 256 ) { + $self->_SQLLimit( + ALIAS => $ObjectCFs, + FIELD => 'Content', + OPERATOR => $op, + VALUE => $value, + CASESENSITIVE => 0, + %rest + ); + } + else { + $self->_OpenParen; + $self->_SQLLimit( + ALIAS => $ObjectCFs, + FIELD => 'Content', + OPERATOR => '=', + VALUE => '', + ENTRYAGGREGATOR => 'OR' + ); + $self->_SQLLimit( + ALIAS => $ObjectCFs, + FIELD => 'Content', + OPERATOR => 'IS', + VALUE => 'NULL', + ENTRYAGGREGATOR => 'OR' + ); + $self->_CloseParen; + $self->_SQLLimit( $fix_op->( + ALIAS => $ObjectCFs, + FIELD => 'LargeContent', + OPERATOR => $op, + VALUE => $value, + ENTRYAGGREGATOR => 'AND', + CASESENSITIVE => 0, + ) ); + } + } + else { + $self->_SQLLimit( + ALIAS => $ObjectCFs, + FIELD => 'Content', + OPERATOR => $op, + VALUE => $value, + CASESENSITIVE => 0, + %rest + ); + + $self->_OpenParen; + $self->_OpenParen; + $self->_SQLLimit( + ALIAS => $ObjectCFs, + FIELD => 'Content', + OPERATOR => '=', + VALUE => '', + ENTRYAGGREGATOR => 'OR' + ); + $self->_SQLLimit( + ALIAS => $ObjectCFs, + FIELD => 'Content', + OPERATOR => 'IS', + VALUE => 'NULL', + ENTRYAGGREGATOR => 'OR' + ); + $self->_CloseParen; + $self->_SQLLimit( $fix_op->( + ALIAS => $ObjectCFs, + FIELD => 'LargeContent', + OPERATOR => $op, + VALUE => $value, + ENTRYAGGREGATOR => 'AND', + CASESENSITIVE => 0, + ) ); + $self->_CloseParen; + } + $self->_CloseParen; + + # XXX: if we join via CustomFields table then + # because of order of left joins we get NULLs in + # CF table and then get nulls for those records + # in OCFVs table what result in wrong results + # as decifer method now tries to load a CF then + # we fall into this situation only when there + # are more than one CF with the name in the DB. + # the same thing applies to order by call. + # TODO: reorder joins T <- OCFVs <- CFs <- OCFs if + # we want treat IS NULL as (not applies or has + # no value) + $self->_SQLLimit( + ALIAS => $CFs, + FIELD => 'Name', + OPERATOR => 'IS NOT', + VALUE => 'NULL', + QUOTEVALUE => 0, + ENTRYAGGREGATOR => 'AND', + ) if $CFs; + $self->_CloseParen; + + if ($negative_op) { + $self->_SQLLimit( + ALIAS => $ObjectCFs, + FIELD => $column || 'Content', + OPERATOR => 'IS', + VALUE => 'NULL', + QUOTEVALUE => 0, + ENTRYAGGREGATOR => 'OR', + ); + } + + $self->_CloseParen; + } + } + else { + $cfkey .= '.'. $self->{'_sql_multiple_cfs_index'}++; + my ($ObjectCFs, $CFs) = $self->_CustomFieldJoin( $cfkey, $cfid, $field, $type ); + + # reverse operation + $op =~ s/!|NOT\s+//i; + + # if column is defined then deal only with it + # otherwise search in Content and in LargeContent + if ( $column ) { + $self->SUPER::Limit( $fix_op->( + LEFTJOIN => $ObjectCFs, + ALIAS => $ObjectCFs, + FIELD => $column, + OPERATOR => $op, + VALUE => $value, + CASESENSITIVE => 0, + ) ); + } + else { + $self->SUPER::Limit( + LEFTJOIN => $ObjectCFs, + ALIAS => $ObjectCFs, + FIELD => 'Content', + OPERATOR => $op, + VALUE => $value, + CASESENSITIVE => 0, + ); + } + $self->_SQLLimit( + %rest, + ALIAS => $ObjectCFs, + FIELD => 'id', + OPERATOR => 'IS', + VALUE => 'NULL', + QUOTEVALUE => 0, + ); + } +} + +sub _HasAttributeLimit { + my ( $self, $field, $op, $value, %rest ) = @_; + + my $alias = $self->Join( + TYPE => 'LEFT', + ALIAS1 => 'main', + FIELD1 => 'id', + TABLE2 => 'Attributes', + FIELD2 => 'ObjectId', + ); + $self->SUPER::Limit( + LEFTJOIN => $alias, + FIELD => 'ObjectType', + VALUE => 'RT::Ticket', + ENTRYAGGREGATOR => 'AND' + ); + $self->SUPER::Limit( + LEFTJOIN => $alias, + FIELD => 'Name', + OPERATOR => $op, + VALUE => $value, + ENTRYAGGREGATOR => 'AND' + ); + $self->_SQLLimit( + %rest, + ALIAS => $alias, + FIELD => 'id', + OPERATOR => $FIELD_METADATA{$field}->[1]? 'IS NOT': 'IS', + VALUE => 'NULL', + QUOTEVALUE => 0, + ); +} + +# End Helper Functions + +# End of SQL Stuff ------------------------------------------------- + + +=head2 OrderByCols ARRAY + +A modified version of the OrderBy method which automatically joins where +C is set to the name of a watcher type. + +=cut + +sub OrderByCols { + my $self = shift; + my @args = @_; + my $clause; + my @res = (); + my $order = 0; + + foreach my $row (@args) { + if ( $row->{ALIAS} ) { + push @res, $row; + next; + } + if ( $row->{FIELD} !~ /\./ ) { + my $meta = $self->FIELDS->{ $row->{FIELD} }; + unless ( $meta ) { + push @res, $row; + next; + } + + if ( $meta->[0] eq 'ENUM' && ($meta->[1]||'') eq 'Queue' ) { + my $alias = $self->Join( + TYPE => 'LEFT', + ALIAS1 => 'main', + FIELD1 => $row->{'FIELD'}, + TABLE2 => 'Queues', + FIELD2 => 'id', + ); + push @res, { %$row, ALIAS => $alias, FIELD => "Name" }; + } elsif ( ( $meta->[0] eq 'ENUM' && ($meta->[1]||'') eq 'User' ) + || ( $meta->[0] eq 'WATCHERFIELD' && ($meta->[1]||'') eq 'Owner' ) + ) { + my $alias = $self->Join( + TYPE => 'LEFT', + ALIAS1 => 'main', + FIELD1 => $row->{'FIELD'}, + TABLE2 => 'Users', + FIELD2 => 'id', + ); + push @res, { %$row, ALIAS => $alias, FIELD => "Name" }; + } else { + push @res, $row; + } + next; + } + + my ( $field, $subkey ) = split /\./, $row->{FIELD}, 2; + my $meta = $self->FIELDS->{$field}; + if ( defined $meta->[0] && $meta->[0] eq 'WATCHERFIELD' ) { + # cache alias as we want to use one alias per watcher type for sorting + my $users = $self->{_sql_u_watchers_alias_for_sort}{ $meta->[1] }; + unless ( $users ) { + $self->{_sql_u_watchers_alias_for_sort}{ $meta->[1] } + = $users = ( $self->_WatcherJoin( $meta->[1] ) )[2]; + } + push @res, { %$row, ALIAS => $users, FIELD => $subkey }; + } elsif ( defined $meta->[0] && $meta->[0] eq 'CUSTOMFIELD' ) { + my ($object, $field, $cf_obj, $column) = $self->_CustomFieldDecipher( $subkey ); + my $cfkey = $cf_obj ? $cf_obj->id : "$object.$field"; + $cfkey .= ".ordering" if !$cf_obj || ($cf_obj->MaxValues||0) != 1; + my ($ObjectCFs, $CFs) = $self->_CustomFieldJoin( $cfkey, ($cf_obj ?$cf_obj->id :0) , $field ); + # this is described in _CustomFieldLimit + $self->_SQLLimit( + ALIAS => $CFs, + FIELD => 'Name', + OPERATOR => 'IS NOT', + VALUE => 'NULL', + QUOTEVALUE => 1, + ENTRYAGGREGATOR => 'AND', + ) if $CFs; + my $CFvs = $self->Join( + TYPE => 'LEFT', + ALIAS1 => $ObjectCFs, + FIELD1 => 'CustomField', + TABLE2 => 'CustomFieldValues', + FIELD2 => 'CustomField', + ); + $self->SUPER::Limit( + LEFTJOIN => $CFvs, + FIELD => 'Name', + QUOTEVALUE => 0, + VALUE => $ObjectCFs . ".Content", + ENTRYAGGREGATOR => 'AND' + ); + + push @res, { %$row, ALIAS => $CFvs, FIELD => 'SortOrder' }; + push @res, { %$row, ALIAS => $ObjectCFs, FIELD => 'Content' }; + } elsif ( $field eq "Custom" && $subkey eq "Ownership") { + # PAW logic is "reversed" + my $order = "ASC"; + if (exists $row->{ORDER} ) { + my $o = $row->{ORDER}; + delete $row->{ORDER}; + $order = "DESC" if $o =~ /asc/i; + } + + # Ticket.Owner 1 0 X + # Unowned Tickets 0 1 X + # Else 0 0 X + + foreach my $uid ( $self->CurrentUser->Id, RT->Nobody->Id ) { + if ( RT->Config->Get('DatabaseType') eq 'Oracle' ) { + my $f = ($row->{'ALIAS'} || 'main') .'.Owner'; + push @res, { + %$row, + FIELD => undef, + ALIAS => '', + FUNCTION => "CASE WHEN $f=$uid THEN 1 ELSE 0 END", + ORDER => $order + }; + } else { + push @res, { + %$row, + FIELD => undef, + FUNCTION => "Owner=$uid", + ORDER => $order + }; + } + } + + push @res, { %$row, FIELD => "Priority", ORDER => $order } ; + + } elsif ( $field eq 'Customer' ) { #Freeside + # OrderBy(FIELD => expression) doesn't work, it has to be + # an actual field, so we have to do the join even if sorting + # by custnum + my $custalias = $self->JoinToCustomer; + my $cust_field = lc($subkey); + if ( !$cust_field or $cust_field eq 'number' ) { + $cust_field = 'custnum'; + } + elsif ( $cust_field eq 'name' ) { + $cust_field = "COALESCE( $custalias.company, + $custalias.last || ', ' || $custalias.first + )"; + } + else { # order by cust_main fields directly: 'Customer.agentnum' + $cust_field = $subkey; + } + push @res, { %$row, ALIAS => $custalias, FIELD => $cust_field }; + + } elsif ( $field eq 'Service' ) { + + my $svcalias = $self->JoinToService; + my $svc_field = lc($subkey); + if ( !$svc_field or $svc_field eq 'number' ) { + $svc_field = 'svcnum'; + } + push @res, { %$row, ALIAS => $svcalias, FIELD => $svc_field }; + + } #Freeside + + else { + push @res, $row; + } + } + return $self->SUPER::OrderByCols(@res); +} + +#Freeside + +sub JoinToCustLinks { + # Set up join to links (id = localbase), + # limit link type to 'MemberOf', + # and target value to any Freeside custnum URI. + # Return the linkalias for further join/limit action, + # and an sql expression to retrieve the custnum. + my $self = shift; + # only join once for each RT::Tickets object + my $linkalias = $self->{cust_main_linkalias}; + if (!$linkalias) { + $linkalias = $self->Join( + TYPE => 'LEFT', + ALIAS1 => 'main', + FIELD1 => 'id', + TABLE2 => 'Links', + FIELD2 => 'LocalBase', + ); + $self->SUPER::Limit( + LEFTJOIN => $linkalias, + FIELD => 'Base', + OPERATOR => 'LIKE', + VALUE => 'fsck.com-rt://%/ticket/%', + ); + $self->SUPER::Limit( + LEFTJOIN => $linkalias, + FIELD => 'Type', + OPERATOR => '=', + VALUE => 'MemberOf', + ); + $self->SUPER::Limit( + LEFTJOIN => $linkalias, + FIELD => 'Target', + OPERATOR => 'STARTSWITH', + VALUE => 'freeside://freeside/cust_main/', + ); + $self->{cust_main_linkalias} = $linkalias; + } + my $custnum_sql = "CAST(SUBSTR($linkalias.Target,31) AS "; + if ( RT->Config->Get('DatabaseType') eq 'mysql' ) { + $custnum_sql .= 'SIGNED INTEGER)'; + } + else { + $custnum_sql .= 'INTEGER)'; + } + return ($linkalias, $custnum_sql); +} + +sub JoinToCustomer { + my $self = shift; + my ($linkalias, $custnum_sql) = $self->JoinToCustLinks; + # don't reuse this join, though--negative queries need + # independent joins + my $custalias = $self->Join( + TYPE => 'LEFT', + EXPRESSION => $custnum_sql, + TABLE2 => 'cust_main', + FIELD2 => 'custnum', + ); + return $custalias; +} + +sub JoinToSvcLinks { + my $self = shift; + my $linkalias = $self->{cust_svc_linkalias}; + if (!$linkalias) { + $linkalias = $self->Join( + TYPE => 'LEFT', + ALIAS1 => 'main', + FIELD1 => 'id', + TABLE2 => 'Links', + FIELD2 => 'LocalBase', + ); + $self->SUPER::Limit( + LEFTJOIN => $linkalias, + FIELD => 'Base', + OPERATOR => 'LIKE', + VALUE => 'fsck.com-rt://%/ticket/%', + ); + + $self->SUPER::Limit( + LEFTJOIN => $linkalias, + FIELD => 'Type', + OPERATOR => '=', + VALUE => 'MemberOf', + ); + $self->SUPER::Limit( + LEFTJOIN => $linkalias, + FIELD => 'Target', + OPERATOR => 'STARTSWITH', + VALUE => 'freeside://freeside/cust_svc/', + ); + $self->{cust_svc_linkalias} = $linkalias; + } + my $svcnum_sql = "CAST(SUBSTR($linkalias.Target,30) AS "; + if ( RT->Config->Get('DatabaseType') eq 'mysql' ) { + $svcnum_sql .= 'SIGNED INTEGER)'; + } + else { + $svcnum_sql .= 'INTEGER)'; + } + return ($linkalias, $svcnum_sql); +} + +sub JoinToService { + my $self = shift; + my ($linkalias, $svcnum_sql) = $self->JoinToSvcLinks; + $self->Join( + TYPE => 'LEFT', + EXPRESSION => $svcnum_sql, + TABLE2 => 'cust_svc', + FIELD2 => 'svcnum', + ); +} + +# This creates an alternate left join path to cust_main via cust_svc. +# _FreesideFieldLimit needs to add this as a separate, independent join +# and include all tickets that have a matching cust_main record via +# either path. +sub JoinToCustomerViaService { + my $self = shift; + my $svcalias = $self->JoinToService; + my $cust_pkg = $self->Join( + TYPE => 'LEFT', + ALIAS1 => $svcalias, + FIELD1 => 'pkgnum', + TABLE2 => 'cust_pkg', + FIELD2 => 'pkgnum', + ); + my $cust_main = $self->Join( + TYPE => 'LEFT', + ALIAS1 => $cust_pkg, + FIELD1 => 'custnum', + TABLE2 => 'cust_main', + FIELD2 => 'custnum', + ); + $cust_main; +} + +sub _FreesideFieldLimit { + my ( $self, $field, $op, $value, %rest ) = @_; + my $is_negative = 0; + if ( $op eq '!=' || $op =~ /\bNOT\b/i ) { + # if the op is negative, do the join as though + # the op were positive, then accept only records + # where the right-side join key is null. + $is_negative = 1; + $op = '=' if $op eq '!='; + $op =~ s/\bNOT\b//; + } + + my (@alias, $table2, $subfield, $pkey); + if ( $field eq 'Customer' ) { + push @alias, $self->JoinToCustomer; + push @alias, $self->JoinToCustomerViaService; + $pkey = 'custnum'; + } + elsif ( $field eq 'Service' ) { + push @alias, $self->JoinToService; + $pkey = 'svcnum'; + } + else { + die "malformed Freeside query: $field"; + } + + $subfield = $rest{SUBKEY} || $pkey; + # compound subkey: separate into table name and field in that table + # (must be linked by custnum) + $subfield = lc($subfield); + ($table2, $subfield) = ($1, $2) if $subfield =~ /^(\w+)?\.(\w+)$/; + $subfield = $pkey if $subfield eq 'number'; + + # if it's compound, create a join from cust_main or cust_svc to that + # table, using custnum or svcnum, and Limit on that table instead. + my @_SQLLimit = (); + foreach my $a (@alias) { + if ( $table2 ) { + $a = $self->Join( + TYPE => 'LEFT', + ALIAS1 => $a, + FIELD1 => $pkey, + TABLE2 => $table2, + FIELD2 => $pkey, + ); + } + + # do the actual Limit + $self->SUPER::Limit( + LEFTJOIN => $a, + FIELD => $subfield, + OPERATOR => $op, + VALUE => $value, + ENTRYAGGREGATOR => 'AND', + # no SUBCLAUSE needed, limits on different aliases across left joins + # are inherently independent + ); + + # then, since it's a left join, exclude tickets for which there is now + # no matching record in the table we just limited on. (Or where there + # is a matching record, if $is_negative.) + # For a cust_main query (where there are two different aliases), this + # will produce a subclause: "cust_main_1.custnum IS NOT NULL OR + # cust_main_2.custnum IS NOT NULL" (or "IS NULL AND..." for a negative + # query). + #$self->_SQLLimit( + push @_SQLLimit, { + %rest, + ALIAS => $a, + FIELD => $pkey, + OPERATOR => $is_negative ? 'IS' : 'IS NOT', + VALUE => 'NULL', + QUOTEVALUE => 0, + ENTRYAGGREGATOR => $is_negative ? 'AND' : 'OR', + SUBCLAUSE => 'fs_limit', + }; + } + + $self->_OpenParen; + foreach my $_SQLLimit (@_SQLLimit) { + $self->_SQLLimit( %$_SQLLimit); + } + $self->_CloseParen; + +} + +#Freeside + +=head2 Limit + +Takes a paramhash with the fields FIELD, OPERATOR, VALUE and DESCRIPTION +Generally best called from LimitFoo methods + +=cut + +sub Limit { + my $self = shift; + my %args = ( + FIELD => undef, + OPERATOR => '=', + VALUE => undef, + DESCRIPTION => undef, + @_ + ); + $args{'DESCRIPTION'} = $self->loc( + "[_1] [_2] [_3]", $args{'FIELD'}, + $args{'OPERATOR'}, $args{'VALUE'} + ) + if ( !defined $args{'DESCRIPTION'} ); + + my $index = $self->_NextIndex; + +# make the TicketRestrictions hash the equivalent of whatever we just passed in; + + %{ $self->{'TicketRestrictions'}{$index} } = %args; + + $self->{'RecalcTicketLimits'} = 1; + +# If we're looking at the effective id, we don't want to append the other clause +# which limits us to tickets where id = effective id + if ( $args{'FIELD'} eq 'EffectiveId' + && ( !$args{'ALIAS'} || $args{'ALIAS'} eq 'main' ) ) + { + $self->{'looking_at_effective_id'} = 1; + } + + if ( $args{'FIELD'} eq 'Type' + && ( !$args{'ALIAS'} || $args{'ALIAS'} eq 'main' ) ) + { + $self->{'looking_at_type'} = 1; + } + + return ($index); } -# }}} -# {{{ Limit by enum or foreign key -# {{{ sub LimitQueue =head2 LimitQueue LimitQueue takes a paramhash with the fields OPERATOR and VALUE. OPERATOR is one of = or !=. (It defaults to =). -VALUE is a queue id. +VALUE is a queue id or Name. + =cut sub LimitQueue { my $self = shift; - my %args = (VALUE => undef, - OPERATOR => '=', - @_); + my %args = ( + VALUE => undef, + OPERATOR => '=', + @_ + ); + + #TODO VALUE should also take queue objects + if ( defined $args{'VALUE'} && $args{'VALUE'} !~ /^\d+$/ ) { + my $queue = RT::Queue->new( $self->CurrentUser ); + $queue->Load( $args{'VALUE'} ); + $args{'VALUE'} = $queue->Id; + } + + # What if they pass in an Id? Check for isNum() and convert to + # string. - #TODO VALUE should also take queue names and queue objects - my $queue = new RT::Queue($self->CurrentUser); - $queue->Load($args{'VALUE'}); - #TODO check for a valid queue here - $self->Limit (FIELD => 'Queue', - VALUE => $queue->id(), - OPERATOR => $args{'OPERATOR'}, - DESCRIPTION => 'Queue ' . $args{'OPERATOR'}. " ". $queue->Name - ); - + $self->Limit( + FIELD => 'Queue', + VALUE => $args{'VALUE'}, + OPERATOR => $args{'OPERATOR'}, + DESCRIPTION => join( + ' ', $self->loc('Queue'), $args{'OPERATOR'}, $args{'VALUE'}, + ), + ); + } -# }}} -# {{{ sub LimitStatus + =head2 LimitStatus @@ -226,22 +2428,51 @@ Takes a paramhash with the fields OPERATOR and VALUE. OPERATOR is one of = or !=. VALUE is a status. +RT adds Status != 'deleted' until object has +allow_deleted_search internal property set. +$tickets->{'allow_deleted_search'} = 1; +$tickets->LimitStatus( VALUE => 'deleted' ); + =cut sub LimitStatus { my $self = shift; - my %args = ( OPERATOR => '=', - @_); - $self->Limit (FIELD => 'Status', - VALUE => $args{'VALUE'}, - OPERATOR => $args{'OPERATOR'}, - DESCRIPTION => 'Status ' . $args{'OPERATOR'}. " ". $args{'VALUE'}, - ); + my %args = ( + OPERATOR => '=', + @_ + ); + $self->Limit( + FIELD => 'Status', + VALUE => $args{'VALUE'}, + OPERATOR => $args{'OPERATOR'}, + DESCRIPTION => join( ' ', + $self->loc('Status'), $args{'OPERATOR'}, + $self->loc( $args{'VALUE'} ) ), + ); +} + + + +=head2 IgnoreType + +If called, this search will not automatically limit the set of results found +to tickets of type "Ticket". Tickets of other types, such as "project" and +"approval" will be found. + +=cut + +sub IgnoreType { + my $self = shift; + + # Instead of faking a Limit that later gets ignored, fake up the + # fact that we're already looking at type, so that the check in + # Tickets_SQL/FromSQL goes down the right branch + + # $self->LimitType(VALUE => '__any'); + $self->{looking_at_type} = 1; } -# }}} -# {{{ sub LimitType =head2 LimitType @@ -249,27 +2480,29 @@ Takes a paramhash with the fields OPERATOR and VALUE. OPERATOR is one of = or !=, it defaults to "=". VALUE is a string to search for in the type of the ticket. + + =cut sub LimitType { my $self = shift; - my %args = (OPERATOR => '=', - VALUE => undef, - @_); - $self->Limit (FIELD => 'Type', - VALUE => $args{'VALUE'}, - OPERATOR => $args{'OPERATOR'}, - DESCRIPTION => 'Type ' . $args{'OPERATOR'}. " ". $args{'Limit'}, - ); + my %args = ( + OPERATOR => '=', + VALUE => undef, + @_ + ); + $self->Limit( + FIELD => 'Type', + VALUE => $args{'VALUE'}, + OPERATOR => $args{'OPERATOR'}, + DESCRIPTION => join( ' ', + $self->loc('Type'), $args{'OPERATOR'}, $args{'VALUE'}, ), + ); } -# }}} -# }}} -# {{{ Limit by string field -# {{{ sub LimitSubject =head2 LimitSubject @@ -282,21 +2515,19 @@ VALUE is a string to search for in the subject of the ticket. sub LimitSubject { my $self = shift; my %args = (@_); - $self->Limit (FIELD => 'Subject', - VALUE => $args{'VALUE'}, - OPERATOR => $args{'OPERATOR'}, - DESCRIPTION => 'Subject ' . $args{'OPERATOR'}. " ". $args{'VALUE'}, - ); + $self->Limit( + FIELD => 'Subject', + VALUE => $args{'VALUE'}, + OPERATOR => $args{'OPERATOR'}, + DESCRIPTION => join( ' ', + $self->loc('Subject'), $args{'OPERATOR'}, $args{'VALUE'}, ), + ); } -# }}} -# }}} -# {{{ Limit based on ticket numerical attributes # Things that can be > < = != -# {{{ sub LimitId =head2 LimitId @@ -308,47 +2539,50 @@ VALUE is a ticket Id to search for sub LimitId { my $self = shift; - my %args = (OPERATOR => '=', - @_); - - $self->Limit (FIELD => 'id', - VALUE => $args{'VALUE'}, - OPERATOR => $args{'OPERATOR'}, - DESCRIPTION => 'Id ' . $args{'OPERATOR'}. " ". $args{'VALUE'}, - ); + my %args = ( + OPERATOR => '=', + @_ + ); + + $self->Limit( + FIELD => 'id', + VALUE => $args{'VALUE'}, + OPERATOR => $args{'OPERATOR'}, + DESCRIPTION => + join( ' ', $self->loc('Id'), $args{'OPERATOR'}, $args{'VALUE'}, ), + ); } -# }}} -# {{{ sub LimitPriority =head2 LimitPriority Takes a paramhash with the fields OPERATOR and VALUE. OPERATOR is one of =, >, < or !=. -VALUE is a value to match the ticket\'s priority against +VALUE is a value to match the ticket's priority against =cut sub LimitPriority { my $self = shift; my %args = (@_); - $self->Limit (FIELD => 'Priority', - VALUE => $args{'VALUE'}, - OPERATOR => $args{'OPERATOR'}, - DESCRIPTION => 'Priority ' . $args{'OPERATOR'}. " ". $args{'VALUE'}, - ); + $self->Limit( + FIELD => 'Priority', + VALUE => $args{'VALUE'}, + OPERATOR => $args{'OPERATOR'}, + DESCRIPTION => join( ' ', + $self->loc('Priority'), + $args{'OPERATOR'}, $args{'VALUE'}, ), + ); } -# }}} -# {{{ sub LimitInitialPriority =head2 LimitInitialPriority Takes a paramhash with the fields OPERATOR and VALUE. OPERATOR is one of =, >, < or !=. -VALUE is a value to match the ticket\'s initial priority against +VALUE is a value to match the ticket's initial priority against =cut @@ -356,38 +2590,40 @@ VALUE is a value to match the ticket\'s initial priority against sub LimitInitialPriority { my $self = shift; my %args = (@_); - $self->Limit (FIELD => 'InitialPriority', - VALUE => $args{'VALUE'}, - OPERATOR => $args{'OPERATOR'}, - DESCRIPTION => 'Initial Priority ' . $args{'OPERATOR'}. " ". $args{'VALUE'}, - ); + $self->Limit( + FIELD => 'InitialPriority', + VALUE => $args{'VALUE'}, + OPERATOR => $args{'OPERATOR'}, + DESCRIPTION => join( ' ', + $self->loc('Initial Priority'), $args{'OPERATOR'}, + $args{'VALUE'}, ), + ); } -# }}} -# {{{ sub LimitFinalPriority =head2 LimitFinalPriority Takes a paramhash with the fields OPERATOR and VALUE. OPERATOR is one of =, >, < or !=. -VALUE is a value to match the ticket\'s final priority against +VALUE is a value to match the ticket's final priority against =cut sub LimitFinalPriority { my $self = shift; my %args = (@_); - $self->Limit (FIELD => 'FinalPriority', - VALUE => $args{'VALUE'}, - OPERATOR => $args{'OPERATOR'}, - DESCRIPTION => 'Final Priority ' . $args{'OPERATOR'}. " ". $args{'VALUE'}, - ); + $self->Limit( + FIELD => 'FinalPriority', + VALUE => $args{'VALUE'}, + OPERATOR => $args{'OPERATOR'}, + DESCRIPTION => join( ' ', + $self->loc('Final Priority'), $args{'OPERATOR'}, + $args{'VALUE'}, ), + ); } -# }}} -# {{{ sub LimitTimeWorked =head2 LimitTimeWorked @@ -400,16 +2636,17 @@ VALUE is a value to match the ticket's TimeWorked attribute sub LimitTimeWorked { my $self = shift; my %args = (@_); - $self->Limit (FIELD => 'TimeWorked', - VALUE => $args{'VALUE'}, - OPERATOR => $args{'OPERATOR'}, - DESCRIPTION => 'Time worked ' . $args{'OPERATOR'}. " ". $args{'VALUE'}, - ); + $self->Limit( + FIELD => 'TimeWorked', + VALUE => $args{'VALUE'}, + OPERATOR => $args{'OPERATOR'}, + DESCRIPTION => join( ' ', + $self->loc('Time Worked'), + $args{'OPERATOR'}, $args{'VALUE'}, ), + ); } -# }}} -# {{{ sub LimitTimeLeft =head2 LimitTimeLeft @@ -422,20 +2659,19 @@ VALUE is a value to match the ticket's TimeLeft attribute sub LimitTimeLeft { my $self = shift; my %args = (@_); - $self->Limit (FIELD => 'TimeLeft', - VALUE => $args{'VALUE'}, - OPERATOR => $args{'OPERATOR'}, - DESCRIPTION => 'Time left ' . $args{'OPERATOR'}. " ". $args{'VALUE'}, - ); + $self->Limit( + FIELD => 'TimeLeft', + VALUE => $args{'VALUE'}, + OPERATOR => $args{'OPERATOR'}, + DESCRIPTION => join( ' ', + $self->loc('Time Left'), + $args{'OPERATOR'}, $args{'VALUE'}, ), + ); } -# }}} -# }}} -# {{{ Limiting based on attachment attributes -# {{{ sub LimitContent =head2 LimitContent @@ -444,315 +2680,305 @@ OPERATOR is one of =, LIKE, NOT LIKE or !=. VALUE is a string to search for in the body of the ticket =cut -sub LimitContent { - my $self = shift; - my %args = (@_); - $self->Limit (FIELD => 'Content', - VALUE => $args{'VALUE'}, - OPERATOR => $args{'OPERATOR'}, - DESCRIPTION => 'Ticket content ' . $args{'OPERATOR'}. " ". $args{'VALUE'}, - ); -} - -# }}} -# {{{ sub LimitContentType -=head2 LimitContentType - -Takes a paramhash with the fields OPERATOR and VALUE. -OPERATOR is one of =, LIKE, NOT LIKE or !=. -VALUE is a content type to search ticket attachments for - -=cut - -sub LimitContentType { +sub LimitContent { my $self = shift; my %args = (@_); - $self->Limit (FIELD => 'ContentType', - VALUE => $args{'VALUE'}, - OPERATOR => $args{'OPERATOR'}, - DESCRIPTION => 'Ticket content type ' . $args{'OPERATOR'}. " ". $args{'VALUE'}, - ); + $self->Limit( + FIELD => 'Content', + VALUE => $args{'VALUE'}, + OPERATOR => $args{'OPERATOR'}, + DESCRIPTION => join( ' ', + $self->loc('Ticket content'), $args{'OPERATOR'}, + $args{'VALUE'}, ), + ); } -# }}} - -# }}} -# {{{ Limiting based on people -# {{{ sub LimitOwner -=head2 LimitOwner +=head2 LimitFilename Takes a paramhash with the fields OPERATOR and VALUE. -OPERATOR is one of = or !=. -VALUE is a user id. - -=cut - -sub LimitOwner { - my $self = shift; - my %args = ( OPERATOR => '=', - @_); - - my $owner = new RT::User($self->CurrentUser); - $owner->Load($args{'VALUE'}); - $self->Limit (FIELD => 'Owner', - VALUE => $owner->Id, - OPERATOR => $args{'OPERATOR'}, - DESCRIPTION => 'Owner ' . $args{'OPERATOR'}. " ". $owner->Name() - ); - -} - -# }}} - -# {{{ Limiting watchers - -# {{{ sub LimitWatcher - - -=head2 LimitWatcher - - Takes a paramhash with the fields OPERATOR, TYPE and VALUE. - OPERATOR is one of =, LIKE, NOT LIKE or !=. - VALUE is a value to match the ticket\'s watcher email addresses against - TYPE is the sort of watchers you want to match against. Leave it undef if you want to search all of them - -=cut - -sub LimitWatcher { - my $self = shift; - my %args = ( OPERATOR => '=', - VALUE => undef, - TYPE => undef, - @_); - - - #build us up a description - my ($watcher_type, $desc); - if ($args{'TYPE'}) { - $watcher_type = $args{'TYPE'}; - } - else { - $watcher_type = "Watcher"; - } - $desc = "$watcher_type ".$args{'OPERATOR'}." ".$args{'VALUE'}; +OPERATOR is one of =, LIKE, NOT LIKE or !=. +VALUE is a string to search for in the body of the ticket +=cut - $self->Limit (FIELD => 'Watcher', - VALUE => $args{'VALUE'}, - OPERATOR => $args{'OPERATOR'}, - TYPE => $args{'TYPE'}, - DESCRIPTION => "$desc" - ); +sub LimitFilename { + my $self = shift; + my %args = (@_); + $self->Limit( + FIELD => 'Filename', + VALUE => $args{'VALUE'}, + OPERATOR => $args{'OPERATOR'}, + DESCRIPTION => join( ' ', + $self->loc('Attachment filename'), $args{'OPERATOR'}, + $args{'VALUE'}, ), + ); } -# }}} - -# {{{ sub LimitRequestor -=head2 LimitRequestor +=head2 LimitContentType -It\'s like LimitWatcher, but it presets TYPE to Requestor +Takes a paramhash with the fields OPERATOR and VALUE. +OPERATOR is one of =, LIKE, NOT LIKE or !=. +VALUE is a content type to search ticket attachments for =cut - -sub LimitRequestor { +sub LimitContentType { my $self = shift; - $self->LimitWatcher(TYPE=> 'Requestor', @_); + my %args = (@_); + $self->Limit( + FIELD => 'ContentType', + VALUE => $args{'VALUE'}, + OPERATOR => $args{'OPERATOR'}, + DESCRIPTION => join( ' ', + $self->loc('Ticket content type'), $args{'OPERATOR'}, + $args{'VALUE'}, ), + ); } -# }}} -# {{{ sub LimitCc -=head2 LimitCC -It\'s like LimitWatcher, but it presets TYPE to Cc + +=head2 LimitOwner + +Takes a paramhash with the fields OPERATOR and VALUE. +OPERATOR is one of = or !=. +VALUE is a user id. =cut -sub LimitCc { +sub LimitOwner { my $self = shift; - $self->LimitWatcher(TYPE=> 'Cc', @_); + my %args = ( + OPERATOR => '=', + @_ + ); + + my $owner = RT::User->new( $self->CurrentUser ); + $owner->Load( $args{'VALUE'} ); + + # FIXME: check for a valid $owner + $self->Limit( + FIELD => 'Owner', + VALUE => $args{'VALUE'}, + OPERATOR => $args{'OPERATOR'}, + DESCRIPTION => join( ' ', + $self->loc('Owner'), $args{'OPERATOR'}, $owner->Name(), ), + ); + } -# }}} -# {{{ sub LimitAdminCc -=head2 LimitAdminCc -It\'s like LimitWatcher, but it presets TYPE to AdminCc +=head2 LimitWatcher + + Takes a paramhash with the fields OPERATOR, TYPE and VALUE. + OPERATOR is one of =, LIKE, NOT LIKE or !=. + VALUE is a value to match the ticket's watcher email addresses against + TYPE is the sort of watchers you want to match against. Leave it undef if you want to search all of them + =cut - -sub LimitAdminCc { + +sub LimitWatcher { my $self = shift; - $self->LimitWatcher(TYPE=> 'AdminCc', @_); + my %args = ( + OPERATOR => '=', + VALUE => undef, + TYPE => undef, + @_ + ); + + #build us up a description + my ( $watcher_type, $desc ); + if ( $args{'TYPE'} ) { + $watcher_type = $args{'TYPE'}; + } + else { + $watcher_type = "Watcher"; + } + + $self->Limit( + FIELD => $watcher_type, + VALUE => $args{'VALUE'}, + OPERATOR => $args{'OPERATOR'}, + TYPE => $args{'TYPE'}, + DESCRIPTION => join( ' ', + $self->loc($watcher_type), + $args{'OPERATOR'}, $args{'VALUE'}, ), + ); } -# }}} -# }}} -# }}} -# {{{ Limiting based on links -# {{{ LimitLinkedTo =head2 LimitLinkedTo LimitLinkedTo takes a paramhash with two fields: TYPE and TARGET -TYPE limits the sort of relationship we want to search on +TYPE limits the sort of link we want to search on + +TYPE = { RefersTo, MemberOf, DependsOn } TARGET is the id or URI of the TARGET of the link -(TARGET used to be 'TICKET'. 'TICKET' is deprecated, but will be treated as TARGET =cut sub LimitLinkedTo { my $self = shift; - my %args = ( - TICKET => undef, - TARGET => undef, - TYPE => undef, - @_); - - - $self->Limit( FIELD => 'LinkedTo', - BASE => undef, - TARGET => ($args{'TARGET'} || $args{'TICKET'}), - TYPE => $args{'TYPE'}, - DESCRIPTION => "Tickets ".$args{'TYPE'}." by ".($args{'TARGET'} || $args{'TICKET'}) - ); + my %args = ( + TARGET => undef, + TYPE => undef, + OPERATOR => '=', + @_ + ); + + $self->Limit( + FIELD => 'LinkedTo', + BASE => undef, + TARGET => $args{'TARGET'}, + TYPE => $args{'TYPE'}, + DESCRIPTION => $self->loc( + "Tickets [_1] by [_2]", + $self->loc( $args{'TYPE'} ), + $args{'TARGET'} + ), + OPERATOR => $args{'OPERATOR'}, + ); } -# }}} - -# {{{ LimitLinkedFrom =head2 LimitLinkedFrom LimitLinkedFrom takes a paramhash with two fields: TYPE and BASE -TYPE limits the sort of relationship we want to search on +TYPE limits the sort of link we want to search on BASE is the id or URI of the BASE of the link -(BASE used to be 'TICKET'. 'TICKET' is deprecated, but will be treated as BASE - =cut sub LimitLinkedFrom { my $self = shift; - my %args = ( BASE => undef, - TICKET => undef, - TYPE => undef, - @_); - - - $self->Limit( FIELD => 'LinkedTo', - TARGET => undef, - BASE => ($args{'BASE'} || $args{'TICKET'}), - TYPE => $args{'TYPE'}, - DESCRIPTION => "Tickets " .($args{'BASE'} || $args{'TICKET'}) ." ".$args{'TYPE'} - ); + my %args = ( + BASE => undef, + TYPE => undef, + OPERATOR => '=', + @_ + ); + + # translate RT2 From/To naming to RT3 TicketSQL naming + my %fromToMap = qw(DependsOn DependentOn + MemberOf HasMember + RefersTo ReferredToBy); + + my $type = $args{'TYPE'}; + $type = $fromToMap{$type} if exists( $fromToMap{$type} ); + + $self->Limit( + FIELD => 'LinkedTo', + TARGET => undef, + BASE => $args{'BASE'}, + TYPE => $type, + DESCRIPTION => $self->loc( + "Tickets [_1] [_2]", + $self->loc( $args{'TYPE'} ), + $args{'BASE'}, + ), + OPERATOR => $args{'OPERATOR'}, + ); } -# }}} - -# {{{ LimitMemberOf sub LimitMemberOf { - my $self = shift; + my $self = shift; my $ticket_id = shift; - $self->LimitLinkedTo ( TARGET=> "$ticket_id", - TYPE => 'MemberOf', - ); - + return $self->LimitLinkedTo( + @_, + TARGET => $ticket_id, + TYPE => 'MemberOf', + ); } -# }}} -# {{{ LimitHasMember + sub LimitHasMember { - my $self = shift; - my $ticket_id =shift; - $self->LimitLinkedFrom ( BASE => "$ticket_id", - TYPE => 'MemberOf', - ); - + my $self = shift; + my $ticket_id = shift; + return $self->LimitLinkedFrom( + @_, + BASE => "$ticket_id", + TYPE => 'HasMember', + ); + } -# }}} -# {{{ LimitDependsOn + sub LimitDependsOn { - my $self = shift; + my $self = shift; my $ticket_id = shift; - $self->LimitLinkedTo ( TARGET => "$ticket_id", - TYPE => 'DependsOn', - ); - + return $self->LimitLinkedTo( + @_, + TARGET => $ticket_id, + TYPE => 'DependsOn', + ); + } -# }}} -# {{{ LimitDependedOnBy sub LimitDependedOnBy { - my $self = shift; + my $self = shift; my $ticket_id = shift; - $self->LimitLinkedFrom ( BASE => "$ticket_id", - TYPE => 'DependsOn', - ); - -} + return $self->LimitLinkedFrom( + @_, + BASE => $ticket_id, + TYPE => 'DependentOn', + ); -# }}} +} -# {{{ LimitRefersTo sub LimitRefersTo { - my $self = shift; + my $self = shift; my $ticket_id = shift; - $self->LimitLinkedTo ( TARGET => "$ticket_id", - TYPE => 'RefersTo', - ); - + return $self->LimitLinkedTo( + @_, + TARGET => $ticket_id, + TYPE => 'RefersTo', + ); + } -# }}} -# {{{ LimitReferredToBy sub LimitReferredToBy { - my $self = shift; + my $self = shift; my $ticket_id = shift; - $self->LimitLinkedFrom ( BASE=> "$ticket_id", - TYPE => 'RefersTo', - ); - + return $self->LimitLinkedFrom( + @_, + BASE => $ticket_id, + TYPE => 'ReferredToBy', + ); } -# }}} -# }}} -# {{{ limit based on ticket date attribtes -# {{{ sub LimitDate =head2 LimitDate (FIELD => 'DateField', OPERATOR => $oper, VALUE => $ISODate) Takes a paramhash with the fields FIELD OPERATOR and VALUE. -OPERATOR is one of > or < +OPERATOR is one of > or < VALUE is a date and time in ISO format in GMT FIELD is one of Starts, Started, Told, Created, Resolved, LastUpdated @@ -764,64 +2990,69 @@ the need to pass in a FIELD argument. sub LimitDate { my $self = shift; my %args = ( - FIELD => undef, - VALUE => $args{'VALUE'}, - OPERATOR => $args{'OPERATOR'}, + FIELD => undef, + VALUE => undef, + OPERATOR => undef, - @_); + @_ + ); #Set the description if we didn't get handed it above - unless ($args{'DESCRIPTION'} ) { - $args{'DESCRIPTION'} = $args{'FIELD'} . " " .$args{'OPERATOR'}. " ". $args{'VALUE'} . " GMT" + unless ( $args{'DESCRIPTION'} ) { + $args{'DESCRIPTION'} = $args{'FIELD'} . " " + . $args{'OPERATOR'} . " " + . $args{'VALUE'} . " GMT"; } - $self->Limit (%args); + $self->Limit(%args); } -# }}} - - - sub LimitCreated { my $self = shift; - $self->LimitDate( FIELD => 'Created', @_); + $self->LimitDate( FIELD => 'Created', @_ ); } + sub LimitDue { my $self = shift; - $self->LimitDate( FIELD => 'Due', @_); + $self->LimitDate( FIELD => 'Due', @_ ); } + sub LimitStarts { my $self = shift; - $self->LimitDate( FIELD => 'Starts', @_); + $self->LimitDate( FIELD => 'Starts', @_ ); } + sub LimitStarted { my $self = shift; - $self->LimitDate( FIELD => 'Started', @_); + $self->LimitDate( FIELD => 'Started', @_ ); } -sub LimitResolved { + +sub LimitResolved { my $self = shift; - $self->LimitDate( FIELD => 'Resolved', @_); + $self->LimitDate( FIELD => 'Resolved', @_ ); } + sub LimitTold { my $self = shift; - $self->LimitDate( FIELD => 'Told', @_); + $self->LimitDate( FIELD => 'Told', @_ ); } + sub LimitLastUpdated { my $self = shift; - $self->LimitDate( FIELD => 'LastUpdated', @_); + $self->LimitDate( FIELD => 'LastUpdated', @_ ); } + # -# {{{ sub LimitTransactionDate =head2 LimitTransactionDate (OPERATOR => $oper, VALUE => $ISODate) Takes a paramhash with the fields FIELD OPERATOR and VALUE. -OPERATOR is one of > or < +OPERATOR is one of > or < VALUE is a date and time in ISO format in GMT @@ -830,94 +3061,111 @@ VALUE is a date and time in ISO format in GMT sub LimitTransactionDate { my $self = shift; my %args = ( - FIELD => 'TransactionDate', - VALUE => $args{'VALUE'}, - OPERATOR => $args{'OPERATOR'}, + FIELD => 'TransactionDate', + VALUE => undef, + OPERATOR => undef, + + @_ + ); - @_); + # <20021217042756.GK28744@pallas.fsck.com> + # "Kill It" - Jesse. #Set the description if we didn't get handed it above - unless ($args{'DESCRIPTION'} ) { - $args{'DESCRIPTION'} = $args{'FIELD'} . " " .$args{'OPERATOR'}. " ". $args{'VALUE'} . " GMT" + unless ( $args{'DESCRIPTION'} ) { + $args{'DESCRIPTION'} = $args{'FIELD'} . " " + . $args{'OPERATOR'} . " " + . $args{'VALUE'} . " GMT"; } - $self->Limit (%args); + $self->Limit(%args); } -# }}} -# }}} -# {{{ sub LimitKeyword -=head2 LimitKeyword +=head2 LimitCustomField Takes a paramhash of key/value pairs with the following keys: =over 4 -=item KEYWORDSELECT - KeywordSelect id +=item CUSTOMFIELD - CustomField name or id. If a name is passed, an additional parameter QUEUE may also be passed to distinguish the custom field. -=item OPERATOR - (for KEYWORD only - KEYWORDSELECT operator is always `=') +=item OPERATOR - The usual Limit operators -=item KEYWORD - Keyword id +=item VALUE - The value to compare against =back =cut -sub LimitKeyword { +sub LimitCustomField { my $self = shift; - my %args = ( KEYWORD => undef, - KEYWORDSELECT => undef, - OPERATOR => '=', - DESCRIPTION => undef, - FIELD => 'Keyword', - QUOTEVALUE => 1, - @_ - ); - - use RT::KeywordSelect; - my $KeywordSelect = RT::KeywordSelect->new($self->CurrentUser); - $KeywordSelect->Load($args{KEYWORDSELECT}); - - - # Below, We're checking to see whether the keyword we're searching for - # is null or not. - # This could probably be rewritten to be easier to read and understand + my %args = ( + VALUE => undef, + CUSTOMFIELD => undef, + OPERATOR => '=', + DESCRIPTION => undef, + FIELD => 'CustomFieldValue', + QUOTEVALUE => 1, + @_ + ); + + my $CF = RT::CustomField->new( $self->CurrentUser ); + if ( $args{CUSTOMFIELD} =~ /^\d+$/ ) { + $CF->Load( $args{CUSTOMFIELD} ); + } + else { + $CF->LoadByNameAndQueue( + Name => $args{CUSTOMFIELD}, + Queue => $args{QUEUE} + ); + $args{CUSTOMFIELD} = $CF->Id; + } - #If we are looking to compare with a null value. - if ($args{'OPERATOR'} =~ /is/i) { - if ($args{'OPERATOR'} =~ /^is$/i) { - $args{'DESCRIPTION'} ||= "Keyword Selection ". $KeywordSelect->Name . " has no value"; - } - elsif ($args{'OPERATOR'} =~ /^is not$/i) { - $args{'DESCRIPTION'} ||= "Keyword Selection ". $KeywordSelect->Name . " has a value"; - } - } - # if we're not looking to compare with a null value - else { - use RT::Keyword; - my $Keyword = RT::Keyword->new($self->CurrentUser); - $Keyword->Load($args{KEYWORD}); - $args{'DESCRIPTION'} ||= "Keyword Selection " . $KeywordSelect->Name. " $args{OPERATOR} ". $Keyword->Name; + if ( $args{'OPERATOR'} =~ /^is$/i ) { + $args{'DESCRIPTION'} + ||= $self->loc( "Custom field [_1] has no value.", $CF->Name ); } - - $args{SingleValued} = $KeywordSelect->Single(); - - - my $index = $self->_NextIndex; - %{$self->{'TicketRestrictions'}{$index}} = %args; - + elsif ( $args{'OPERATOR'} =~ /^is not$/i ) { + $args{'DESCRIPTION'} + ||= $self->loc( "Custom field [_1] has a value.", $CF->Name ); + } + + # if we're not looking to compare with a null value + else { + $args{'DESCRIPTION'} ||= $self->loc( "Custom field [_1] [_2] [_3]", + $CF->Name, $args{OPERATOR}, $args{VALUE} ); + } + + if ( defined $args{'QUEUE'} && $args{'QUEUE'} =~ /\D/ ) { + my $QueueObj = RT::Queue->new( $self->CurrentUser ); + $QueueObj->Load( $args{'QUEUE'} ); + $args{'QUEUE'} = $QueueObj->Id; + } + delete $args{'QUEUE'} unless defined $args{'QUEUE'} && length $args{'QUEUE'}; + + my @rest; + @rest = ( ENTRYAGGREGATOR => 'AND' ) + if ( $CF->Type eq 'SelectMultiple' ); + + $self->Limit( + VALUE => $args{VALUE}, + FIELD => "CF" + .(defined $args{'QUEUE'}? ".$args{'QUEUE'}" : '' ) + .".{" . $CF->Name . "}", + OPERATOR => $args{OPERATOR}, + CUSTOMFIELD => 1, + @rest, + ); + $self->{'RecalcTicketLimits'} = 1; - return ($index); } -# }}} -# {{{ sub _NextIndex =head2 _NextIndex @@ -927,44 +3175,44 @@ Keep track of the counter for the array of restrictions sub _NextIndex { my $self = shift; - return ($self->{'restriction_index'}++); + return ( $self->{'restriction_index'}++ ); } -# }}} -# }}} -# {{{ Core bits to make this a DBIx::SearchBuilder object -# {{{ sub _Init -sub _Init { + +sub _Init { my $self = shift; - $self->{'table'} = "Tickets"; - $self->{'RecalcTicketLimits'} = 1; + $self->{'table'} = "Tickets"; + $self->{'RecalcTicketLimits'} = 1; $self->{'looking_at_effective_id'} = 0; - $self->{'restriction_index'} =1; - $self->{'primary_key'} = "id"; + $self->{'looking_at_type'} = 0; + $self->{'restriction_index'} = 1; + $self->{'primary_key'} = "id"; + delete $self->{'items_array'}; + delete $self->{'item_map'}; + delete $self->{'columns_to_display'}; $self->SUPER::_Init(@_); + $self->_InitSQL; + } -# }}} -# {{{ sub NewItem -sub NewItem { - my $self = shift; - return(RT::Ticket->new($self->CurrentUser)); +sub Count { + my $self = shift; + $self->_ProcessRestrictions() if ( $self->{'RecalcTicketLimits'} == 1 ); + return ( $self->SUPER::Count() ); } -# }}} -# {{{ sub Count -sub Count { - my $self = shift; - $self->_ProcessRestrictions if ($self->{'RecalcTicketLimits'} == 1 ); - return($self->SUPER::Count()); + +sub CountAll { + my $self = shift; + $self->_ProcessRestrictions() if ( $self->{'RecalcTicketLimits'} == 1 ); + return ( $self->SUPER::CountAll() ); } -# }}} -# {{{ sub ItemsArrayRef + =head2 ItemsArrayRef @@ -974,57 +3222,333 @@ Returns a reference to the set of all items found in this search sub ItemsArrayRef { my $self = shift; - my @items; - + + return $self->{'items_array'} if $self->{'items_array'}; + my $placeholder = $self->_ItemsCounter; $self->GotoFirstItem(); - while (my $item = $self->Next) { - push (@items, $item); + while ( my $item = $self->Next ) { + push( @{ $self->{'items_array'} }, $item ); } - $self->GotoItem($placeholder); - return(\@items); + $self->{'items_array'} + = $self->ItemsOrderBy( $self->{'items_array'} ); + + return $self->{'items_array'}; } -# }}} -# {{{ sub Next +sub ItemsArrayRefWindow { + my $self = shift; + my $window = shift; + + my @old = ($self->_ItemsCounter, $self->RowsPerPage, $self->FirstRow+1); + + $self->RowsPerPage( $window ); + $self->FirstRow(1); + $self->GotoFirstItem; + + my @res; + while ( my $item = $self->Next ) { + push @res, $item; + } + + $self->RowsPerPage( $old[1] ); + $self->FirstRow( $old[2] ); + $self->GotoItem( $old[0] ); + + return \@res; +} + + sub Next { - my $self = shift; - - $self->_ProcessRestrictions if ($self->{'RecalcTicketLimits'} == 1 ); + my $self = shift; + + $self->_ProcessRestrictions() if ( $self->{'RecalcTicketLimits'} == 1 ); + + my $Ticket = $self->SUPER::Next; + return $Ticket unless $Ticket; + + if ( $Ticket->__Value('Status') eq 'deleted' + && !$self->{'allow_deleted_search'} ) + { + return $self->Next; + } + elsif ( RT->Config->Get('UseSQLForACLChecks') ) { + # if we found a ticket with this option enabled then + # all tickets we found are ACLed, cache this fact + my $key = join ";:;", $self->CurrentUser->id, 'ShowTicket', 'RT::Ticket-'. $Ticket->id; + $RT::Principal::_ACL_CACHE->set( $key => 1 ); + return $Ticket; + } + elsif ( $Ticket->CurrentUserHasRight('ShowTicket') ) { + # has rights + return $Ticket; + } + else { + # If the user doesn't have the right to show this ticket + return $self->Next; + } +} + +sub _DoSearch { + my $self = shift; + $self->CurrentUserCanSee if RT->Config->Get('UseSQLForACLChecks'); + return $self->SUPER::_DoSearch( @_ ); +} + +sub _DoCount { + my $self = shift; + $self->CurrentUserCanSee if RT->Config->Get('UseSQLForACLChecks'); + return $self->SUPER::_DoCount( @_ ); +} + +sub _RolesCanSee { + my $self = shift; + + my $cache_key = 'RolesHasRight;:;ShowTicket'; + + if ( my $cached = $RT::Principal::_ACL_CACHE->fetch( $cache_key ) ) { + return %$cached; + } + + my $ACL = RT::ACL->new( RT->SystemUser ); + $ACL->Limit( FIELD => 'RightName', VALUE => 'ShowTicket' ); + $ACL->Limit( FIELD => 'PrincipalType', OPERATOR => '!=', VALUE => 'Group' ); + my $principal_alias = $ACL->Join( + ALIAS1 => 'main', + FIELD1 => 'PrincipalId', + TABLE2 => 'Principals', + FIELD2 => 'id', + ); + $ACL->Limit( ALIAS => $principal_alias, FIELD => 'Disabled', VALUE => 0 ); + + my %res = (); + foreach my $ACE ( @{ $ACL->ItemsArrayRef } ) { + my $role = $ACE->__Value('PrincipalType'); + my $type = $ACE->__Value('ObjectType'); + if ( $type eq 'RT::System' ) { + $res{ $role } = 1; + } + elsif ( $type eq 'RT::Queue' ) { + next if $res{ $role } && !ref $res{ $role }; + push @{ $res{ $role } ||= [] }, $ACE->__Value('ObjectId'); + } + else { + $RT::Logger->error('ShowTicket right is granted on unsupported object'); + } + } + $RT::Principal::_ACL_CACHE->set( $cache_key => \%res ); + return %res; +} + +sub _DirectlyCanSeeIn { + my $self = shift; + my $id = $self->CurrentUser->id; + + my $cache_key = 'User-'. $id .';:;ShowTicket;:;DirectlyCanSeeIn'; + if ( my $cached = $RT::Principal::_ACL_CACHE->fetch( $cache_key ) ) { + return @$cached; + } - my $Ticket = $self->SUPER::Next(); - if ((defined($Ticket)) and (ref($Ticket))) { + my $ACL = RT::ACL->new( RT->SystemUser ); + $ACL->Limit( FIELD => 'RightName', VALUE => 'ShowTicket' ); + my $principal_alias = $ACL->Join( + ALIAS1 => 'main', + FIELD1 => 'PrincipalId', + TABLE2 => 'Principals', + FIELD2 => 'id', + ); + $ACL->Limit( ALIAS => $principal_alias, FIELD => 'Disabled', VALUE => 0 ); + my $cgm_alias = $ACL->Join( + ALIAS1 => 'main', + FIELD1 => 'PrincipalId', + TABLE2 => 'CachedGroupMembers', + FIELD2 => 'GroupId', + ); + $ACL->Limit( ALIAS => $cgm_alias, FIELD => 'MemberId', VALUE => $id ); + $ACL->Limit( ALIAS => $cgm_alias, FIELD => 'Disabled', VALUE => 0 ); + + my @res = (); + foreach my $ACE ( @{ $ACL->ItemsArrayRef } ) { + my $type = $ACE->__Value('ObjectType'); + if ( $type eq 'RT::System' ) { + # If user is direct member of a group that has the right + # on the system then he can see any ticket + $RT::Principal::_ACL_CACHE->set( $cache_key => [-1] ); + return (-1); + } + elsif ( $type eq 'RT::Queue' ) { + push @res, $ACE->__Value('ObjectId'); + } + else { + $RT::Logger->error('ShowTicket right is granted on unsupported object'); + } + } + $RT::Principal::_ACL_CACHE->set( $cache_key => \@res ); + return @res; +} - #Make sure we _never_ show dead tickets - #TODO we should be doing this in the where clause. - #but you can't do multiple clauses on the same field just yet :/ +sub CurrentUserCanSee { + my $self = shift; + return if $self->{'_sql_current_user_can_see_applied'}; + + return $self->{'_sql_current_user_can_see_applied'} = 1 + if $self->CurrentUser->UserObj->HasRight( + Right => 'SuperUser', Object => $RT::System + ); + + my $id = $self->CurrentUser->id; + + # directly can see in all queues then we have nothing to do + my @direct_queues = $self->_DirectlyCanSeeIn; + return $self->{'_sql_current_user_can_see_applied'} = 1 + if @direct_queues && $direct_queues[0] == -1; + + my %roles = $self->_RolesCanSee; + { + my %skip = map { $_ => 1 } @direct_queues; + foreach my $role ( keys %roles ) { + next unless ref $roles{ $role }; + + my @queues = grep !$skip{$_}, @{ $roles{ $role } }; + if ( @queues ) { + $roles{ $role } = \@queues; + } else { + delete $roles{ $role }; + } + } + } - if ($Ticket->Status eq 'dead') { - return($self->Next()); - } - elsif ($Ticket->CurrentUserHasRight('ShowTicket')) { - return($Ticket); - } +# there is no global watchers, only queues and tickes, if at +# some point we will add global roles then it's gonna blow +# the idea here is that if the right is set globaly for a role +# and user plays this role for a queue directly not a ticket +# then we have to check in advance + if ( my @tmp = grep $_ ne 'Owner' && !ref $roles{ $_ }, keys %roles ) { + + my $groups = RT::Groups->new( RT->SystemUser ); + $groups->Limit( FIELD => 'Domain', VALUE => 'RT::Queue-Role' ); + foreach ( @tmp ) { + $groups->Limit( FIELD => 'Type', VALUE => $_ ); + } + my $principal_alias = $groups->Join( + ALIAS1 => 'main', + FIELD1 => 'id', + TABLE2 => 'Principals', + FIELD2 => 'id', + ); + $groups->Limit( ALIAS => $principal_alias, FIELD => 'Disabled', VALUE => 0 ); + my $cgm_alias = $groups->Join( + ALIAS1 => 'main', + FIELD1 => 'id', + TABLE2 => 'CachedGroupMembers', + FIELD2 => 'GroupId', + ); + $groups->Limit( ALIAS => $cgm_alias, FIELD => 'MemberId', VALUE => $id ); + $groups->Limit( ALIAS => $cgm_alias, FIELD => 'Disabled', VALUE => 0 ); + while ( my $group = $groups->Next ) { + push @direct_queues, $group->Instance; + } + } - #If the user doesn't have the right to show this ticket - else { - return($self->Next()); - } - } - #if there never was any ticket - else { - return(undef); - } + unless ( @direct_queues || keys %roles ) { + $self->SUPER::Limit( + SUBCLAUSE => 'ACL', + ALIAS => 'main', + FIELD => 'id', + VALUE => 0, + ENTRYAGGREGATOR => 'AND', + ); + return $self->{'_sql_current_user_can_see_applied'} = 1; + } + { + my $join_roles = keys %roles; + $join_roles = 0 if $join_roles == 1 && $roles{'Owner'}; + my ($role_group_alias, $cgm_alias); + if ( $join_roles ) { + $role_group_alias = $self->_RoleGroupsJoin( New => 1 ); + $cgm_alias = $self->_GroupMembersJoin( GroupsAlias => $role_group_alias ); + $self->SUPER::Limit( + LEFTJOIN => $cgm_alias, + FIELD => 'MemberId', + OPERATOR => '=', + VALUE => $id, + ); + } + my $limit_queues = sub { + my $ea = shift; + my @queues = @_; + + return unless @queues; + if ( @queues == 1 ) { + $self->SUPER::Limit( + SUBCLAUSE => 'ACL', + ALIAS => 'main', + FIELD => 'Queue', + VALUE => $_[0], + ENTRYAGGREGATOR => $ea, + ); + } else { + $self->SUPER::_OpenParen('ACL'); + foreach my $q ( @queues ) { + $self->SUPER::Limit( + SUBCLAUSE => 'ACL', + ALIAS => 'main', + FIELD => 'Queue', + VALUE => $q, + ENTRYAGGREGATOR => $ea, + ); + $ea = 'OR'; + } + $self->SUPER::_CloseParen('ACL'); + } + return 1; + }; + + $self->SUPER::_OpenParen('ACL'); + my $ea = 'AND'; + $ea = 'OR' if $limit_queues->( $ea, @direct_queues ); + while ( my ($role, $queues) = each %roles ) { + $self->SUPER::_OpenParen('ACL'); + if ( $role eq 'Owner' ) { + $self->SUPER::Limit( + SUBCLAUSE => 'ACL', + FIELD => 'Owner', + VALUE => $id, + ENTRYAGGREGATOR => $ea, + ); + } + else { + $self->SUPER::Limit( + SUBCLAUSE => 'ACL', + ALIAS => $cgm_alias, + FIELD => 'MemberId', + OPERATOR => 'IS NOT', + VALUE => 'NULL', + QUOTEVALUE => 0, + ENTRYAGGREGATOR => $ea, + ); + $self->SUPER::Limit( + SUBCLAUSE => 'ACL', + ALIAS => $role_group_alias, + FIELD => 'Type', + VALUE => $role, + ENTRYAGGREGATOR => 'AND', + ); + } + $limit_queues->( 'AND', @$queues ) if ref $queues; + $ea = 'OR' if $ea eq 'AND'; + $self->SUPER::_CloseParen('ACL'); + } + $self->SUPER::_CloseParen('ACL'); + } + return $self->{'_sql_current_user_can_see_applied'} = 1; } -# }}} -# }}} -# {{{ Deal with storing and restoring restrictions -# {{{ sub LoadRestrictions + =head2 LoadRestrictions @@ -1033,32 +3557,29 @@ TODO It is not yet implemented =cut -# }}} -# {{{ sub DescribeRestrictions =head2 DescribeRestrictions takes nothing. -Returns a hash keyed by restriction id. +Returns a hash keyed by restriction id. Each element of the hash is currently a one element hash that contains DESCRIPTION which is a description of the purpose of that TicketRestriction =cut -sub DescribeRestrictions { +sub DescribeRestrictions { my $self = shift; - - my ($row, %listing); - - foreach $row (keys %{$self->{'TicketRestrictions'}}) { - $listing{$row} = $self->{'TicketRestrictions'}{$row}{'DESCRIPTION'}; + + my %listing; + + foreach my $row ( keys %{ $self->{'TicketRestrictions'} } ) { + $listing{$row} = $self->{'TicketRestrictions'}{$row}{'DESCRIPTION'}; } return (%listing); } -# }}} -# {{{ sub RestrictionValues + =head2 RestrictionValues FIELD @@ -1068,36 +3589,32 @@ to. =cut sub RestrictionValues { - my $self = shift; + my $self = shift; my $field = shift; - map $self->{'TicketRestrictions'}{$_}{'VALUE'}, - grep { - $self->{'TicketRestrictions'}{$_}{'FIELD'} eq $field - && $self->{'TicketRestrictions'}{$_}{'OPERATOR'} eq "=" - } - keys %{$self->{'TicketRestrictions'}}; + map $self->{'TicketRestrictions'}{$_}{'VALUE'}, grep { + $self->{'TicketRestrictions'}{$_}{'FIELD'} eq $field + && $self->{'TicketRestrictions'}{$_}{'OPERATOR'} eq "=" + } + keys %{ $self->{'TicketRestrictions'} }; } -# }}} -# {{{ sub ClearRestrictions =head2 ClearRestrictions Removes all restrictions irretrievably =cut - + sub ClearRestrictions { my $self = shift; delete $self->{'TicketRestrictions'}; $self->{'looking_at_effective_id'} = 0; - $self->{'RecalcTicketLimits'} =1; + $self->{'looking_at_type'} = 0; + $self->{'RecalcTicketLimits'} = 1; } -# }}} -# {{{ sub DeleteRestriction =head2 DeleteRestriction @@ -1106,684 +3623,259 @@ Removes that restriction from the session's limits. =cut - sub DeleteRestriction { my $self = shift; - my $row = shift; + my $row = shift; delete $self->{'TicketRestrictions'}{$row}; - + $self->{'RecalcTicketLimits'} = 1; + #make the underlying easysearch object forget all its preconceptions } -# }}} -# {{{ sub _ProcessRestrictions -sub _ProcessRestrictions { +# Convert a set of oldstyle SB Restrictions to Clauses for RQL + +sub _RestrictionsToClauses { my $self = shift; - #Need to clean the EasySearch slate because it makes things too sticky - $self->CleanSlate(); + my %clause; + foreach my $row ( keys %{ $self->{'TicketRestrictions'} } ) { + my $restriction = $self->{'TicketRestrictions'}{$row}; - #Blow away ticket aliases since we'll need to regenerate them for a new search - delete $self->{'TicketAliases'}; - delete $self->{KeywordsAliases}; + # We need to reimplement the subclause aggregation that SearchBuilder does. + # Default Subclause is ALIAS.FIELD, and default ALIAS is 'main', + # Then SB AND's the different Subclauses together. - my $row; - - foreach $row (keys %{$self->{'TicketRestrictions'}}) { - my $restriction = $self->{'TicketRestrictions'}{$row}; - # {{{ if it's an int - - if ($TYPES{$restriction->{'FIELD'}} eq 'INT' ) { - if ($restriction->{'OPERATOR'} =~ /^(=|!=|>|<|>=|<=)$/) { - $self->SUPER::Limit( FIELD => $restriction->{'FIELD'}, - ENTRYAGGREGATOR => 'AND', - OPERATOR => $restriction->{'OPERATOR'}, - VALUE => $restriction->{'VALUE'}, - ); - } - } - # }}} - # {{{ if it's an enum - elsif ($TYPES{$restriction->{'FIELD'}} eq 'ENUM') { - - if ($restriction->{'OPERATOR'} eq '=') { - $self->SUPER::Limit( FIELD => $restriction->{'FIELD'}, - ENTRYAGGREGATOR => 'OR', - OPERATOR => '=', - VALUE => $restriction->{'VALUE'}, - ); - } - elsif ($restriction->{'OPERATOR'} eq '!=') { - $self->SUPER::Limit( FIELD => $restriction->{'FIELD'}, - ENTRYAGGREGATOR => 'AND', - OPERATOR => '!=', - VALUE => $restriction->{'VALUE'}, - ); - } - - } - # }}} - # {{{ if it's a date - - elsif ($TYPES{$restriction->{'FIELD'}} eq 'DATE') { - $self->SUPER::Limit( FIELD => $restriction->{'FIELD'}, - ENTRYAGGREGATOR => 'AND', - OPERATOR => $restriction->{'OPERATOR'}, - VALUE => $restriction->{'VALUE'}, - ); - } - # }}} - # {{{ if it's a string - - elsif ($TYPES{$restriction->{'FIELD'}} eq 'STRING') { - - if ($restriction->{'OPERATOR'} eq '=') { - $self->SUPER::Limit( FIELD => $restriction->{'FIELD'}, - ENTRYAGGREGATOR => 'OR', - OPERATOR => '=', - VALUE => $restriction->{'VALUE'}, - CASESENSITIVE => 0 - ); - } - elsif ($restriction->{'OPERATOR'} eq '!=') { - $self->SUPER::Limit( FIELD => $restriction->{'FIELD'}, - ENTRYAGGREGATOR => 'AND', - OPERATOR => '!=', - VALUE => $restriction->{'VALUE'}, - CASESENSITIVE => 0 - ); - } - elsif ($restriction->{'OPERATOR'} eq 'LIKE') { - $self->SUPER::Limit( FIELD => $restriction->{'FIELD'}, - ENTRYAGGREGATOR => 'AND', - OPERATOR => 'LIKE', - VALUE => $restriction->{'VALUE'}, - CASESENSITIVE => 0 - ); - } - elsif ($restriction->{'OPERATOR'} eq 'NOT LIKE') { - $self->SUPER::Limit( FIELD => $restriction->{'FIELD'}, - ENTRYAGGREGATOR => 'AND', - OPERATOR => 'NOT LIKE', - VALUE => $restriction->{'VALUE'}, - CASESENSITIVE => 0 - ); - } - } - - # }}} - # {{{ if it's Transaction content that we're hunting for - elsif ($TYPES{$restriction->{'FIELD'}} eq 'TRANSFIELD') { - - #Basically, we want to make sure that the limits apply to the same attachment, - #rather than just another attachment for the same ticket, no matter how many - #clauses we lump on. - #We put them in TicketAliases so that they get nuked when we redo the join. - - unless (defined $self->{'TicketAliases'}{'TransFieldAlias'}) { - $self->{'TicketAliases'}{'TransFieldAlias'} = $self->NewAlias ('Transactions'); - } - unless (defined $self->{'TicketAliases'}{'TransFieldAttachAlias'}){ - $self->{'TicketAliases'}{'TransFieldAttachAlias'} = $self->NewAlias('Attachments'); - - } - #Join transactions to attachments - $self->Join( ALIAS1 => $self->{'TicketAliases'}{'TransFieldAttachAlias'}, - FIELD1 => 'TransactionId', - ALIAS2 => $self->{'TicketAliases'}{'TransFieldAlias'}, FIELD2=> 'id'); - - #Join transactions to tickets - $self->Join( ALIAS1 => 'main', FIELD1 => $self->{'primary_key'}, - ALIAS2 =>$self->{'TicketAliases'}{'TransFieldAlias'}, FIELD2 => 'Ticket'); - - #Search for the right field - $self->SUPER::Limit(ALIAS => $self->{'TicketAliases'}{'TransFieldAttachAlias'}, - ENTRYAGGREGATOR => 'AND', - FIELD => $restriction->{'FIELD'}, - OPERATOR => $restriction->{'OPERATOR'} , - VALUE => $restriction->{'VALUE'}, - CASESENSITIVE => 0 - ); - - - } - - # }}} - # {{{ if it's a Transaction date that we're hunting for - elsif ($TYPES{$restriction->{'FIELD'}} eq 'TRANSDATE') { - - #Basically, we want to make sure that the limits apply to the same attachment, - #rather than just another attachment for the same ticket, no matter how many - #clauses we lump on. - #We put them in TicketAliases so that they get nuked when we redo the join. - - unless (defined $self->{'TicketAliases'}{'TransFieldAlias'}) { - $self->{'TicketAliases'}{'TransFieldAlias'} = $self->NewAlias ('Transactions'); - } - - #Join transactions to tickets - $self->Join( ALIAS1 => 'main', FIELD1 => $self->{'primary_key'}, - ALIAS2 =>$self->{'TicketAliases'}{'TransFieldAlias'}, FIELD2 => 'Ticket'); - - #Search for the right field - $self->SUPER::Limit(ALIAS => $self->{'TicketAliases'}{'TransFieldAlias'}, - ENTRYAGGREGATOR => 'AND', - FIELD => 'Created', - OPERATOR => $restriction->{'OPERATOR'} , - VALUE => $restriction->{'VALUE'} ); - } - - # }}} - # {{{ if it's a relationship that we're hunting for - - # Takes FIELD: which is something like "LinkedTo" - # takes TARGET or BASE which is the TARGET or BASE id that we're searching for - # takes TYPE which is the type of link we're looking for. - - elsif ($TYPES{$restriction->{'FIELD'}} eq 'LINKFIELD') { - - - my $LinkAlias = $self->NewAlias ('Links'); - - - #Make sure we get the right type of link, if we're restricting it - if ($restriction->{'TYPE'}) { - $self->SUPER::Limit(ALIAS => $LinkAlias, - ENTRYAGGREGATOR => 'AND', - FIELD => 'Type', - OPERATOR => '=', - VALUE => $restriction->{'TYPE'} ); - } - - #If we're trying to limit it to things that are target of - if ($restriction->{'TARGET'}) { - - - # If the TARGET is an integer that means that we want to look at the LocalTarget - # field. otherwise, we want to look at the "Target" field - - my ($matchfield); - if ($restriction->{'TARGET'} =~/^(\d+)$/) { - $matchfield = "LocalTarget"; - } - else { - $matchfield = "Target"; - } - - $self->SUPER::Limit(ALIAS => $LinkAlias, - ENTRYAGGREGATOR => 'AND', - FIELD => $matchfield, - OPERATOR => '=', - VALUE => $restriction->{'TARGET'} ); - - - #If we're searching on target, join the base to ticket.id - $self->Join( ALIAS1 => 'main', FIELD1 => $self->{'primary_key'}, - ALIAS2 => $LinkAlias, - FIELD2 => 'LocalBase'); - - - - - } - #If we're trying to limit it to things that are base of - elsif ($restriction->{'BASE'}) { - - - # If we're trying to match a numeric link, we want to look at LocalBase, - # otherwise we want to look at "Base" - - my ($matchfield); - if ($restriction->{'BASE'} =~/^(\d+)$/) { - $matchfield = "LocalBase"; - } - else { - $matchfield = "Base"; - } - - - $self->SUPER::Limit(ALIAS => $LinkAlias, - ENTRYAGGREGATOR => 'AND', - FIELD => $matchfield, - OPERATOR => '=', - VALUE => $restriction->{'BASE'} ); - - #If we're searching on base, join the target to ticket.id - $self->Join( ALIAS1 => 'main', FIELD1 => $self->{'primary_key'}, - ALIAS2 => $LinkAlias, - FIELD2 => 'LocalTarget'); - - } - - } - - # }}} - # {{{ if it's a watcher that we're hunting for - elsif ($TYPES{$restriction->{'FIELD'}} eq 'WATCHERFIELD') { - - my $Watch = $self->NewAlias('Watchers'); - - #Join watchers to users - my $User = $self->Join( TYPE => 'left', - ALIAS1 => $Watch, - FIELD1 => 'Owner', - TABLE2 => 'Users', - FIELD2 => 'id', - ); - - #Join Ticket to watchers - $self->Join( ALIAS1 => 'main', FIELD1 => 'id', - ALIAS2 => $Watch, FIELD2 => 'Value'); - - - #Make sure we're only talking about ticket watchers - $self->SUPER::Limit( ALIAS => $Watch, - FIELD => 'Scope', - VALUE => 'Ticket', - OPERATOR => '='); - - - # Find email address watchers - $self->SUPER::Limit( SUBCLAUSE => 'WatcherEmailAddress', - ALIAS => $Watch, - FIELD => 'Email', - ENTRYAGGREGATOR => 'OR', - VALUE => $restriction->{'VALUE'}, - OPERATOR => $restriction->{'OPERATOR'}, - CASESENSITIVE => 0 - ); - - - - #Find user watchers - $self->SUPER::Limit( - SUBCLAUSE => 'WatcherEmailAddress', - ALIAS => $User, - FIELD => 'EmailAddress', - ENTRYAGGREGATOR => 'OR', - VALUE => $restriction->{'VALUE'}, - OPERATOR => $restriction->{'OPERATOR'}, - CASESENSITIVE => 0 - ); - - - #If we only want a specific type of watchers, then limit it to that - if ($restriction->{'TYPE'}) { - $self->SUPER::Limit( ALIAS => $Watch, - FIELD => 'Type', - ENTRYAGGREGATOR => 'OR', - VALUE => $restriction->{'TYPE'}, - OPERATOR => '='); - } - } - - # }}} - # {{{ if it's a keyword - elsif ($TYPES{$restriction->{'FIELD'}} eq 'KEYWORDFIELD') { - - my $null_columns_ok; - - my $ObjKeywordsAlias; - $ObjKeywordsAlias = $self->{KeywordsAliases}{$restriction->{'KEYWORDSELECT'}} - if $restriction->{SingleValued}; - unless (defined $ObjKeywordsAlias) { - $ObjKeywordsAlias = $self->Join( - TYPE => 'left', - ALIAS1 => 'main', - FIELD1 => 'id', - TABLE2 => 'ObjectKeywords', - FIELD2 => 'ObjectId' - ); - if ($restriction->{'SingleValued'}) { - $self->{KeywordsAliases}{$restriction->{'KEYWORDSELECT'}} - = $ObjKeywordsAlias; - } - } - - - $self->SUPER::Limit( - ALIAS => $ObjKeywordsAlias, - FIELD => 'Keyword', - OPERATOR => $restriction->{'OPERATOR'}, - VALUE => $restriction->{'KEYWORD'}, - QUOTEVALUE => $restriction->{'QUOTEVALUE'}, - ENTRYAGGREGATOR => 'OR', - ); - - if ( ($restriction->{'OPERATOR'} =~ /^IS$/i) or - ($restriction->{'OPERATOR'} eq '!=') ) { - - $null_columns_ok=1; - - } - - #If we're trying to find tickets where the keyword isn't somethng, also check ones where it _IS_ null - if ( $restriction->{'OPERATOR'} eq '!=') { - $self->SUPER::Limit( - ALIAS => $ObjKeywordsAlias, - FIELD => 'Keyword', - OPERATOR => 'IS', - VALUE => 'NULL', - QUOTEVALUE => 0, - ENTRYAGGREGATOR => 'OR', - ); - } - - - $self->SUPER::Limit(LEFTJOIN => $ObjKeywordsAlias, - FIELD => 'KeywordSelect', - VALUE => $restriction->{'KEYWORDSELECT'}, - ENTRYAGGREGATOR => 'OR'); + # So, we want to group things into Subclauses, convert them to + # SQL, and then join them with the appropriate DefaultEA. + # Then join each subclause group with AND. + my $field = $restriction->{'FIELD'}; + my $realfield = $field; # CustomFields fake up a fieldname, so + # we need to figure that out - - $self->SUPER::Limit( ALIAS => $ObjKeywordsAlias, - FIELD => 'ObjectType', - VALUE => 'Ticket', - ENTRYAGGREGATOR => 'AND'); - - if ($null_columns_ok) { - $self->SUPER::Limit(ALIAS => $ObjKeywordsAlias, - FIELD => 'ObjectType', - OPERATOR => 'IS', - VALUE => 'NULL', - QUOTEVALUE => 0, - ENTRYAGGREGATOR => 'OR'); - } - + # One special case + # Rewrite LinkedTo meta field to the real field + if ( $field =~ /LinkedTo/ ) { + $realfield = $field = $restriction->{'TYPE'}; } - # }}} - - } - - - # here, we make sure we don't get any tickets that have been merged into other tickets - # (Ticket Id == Ticket EffectiveId - # note that we _really_ don't want to do this if we're already looking at the effectiveid - if ($self->_isLimited && (! $self->{'looking_at_effective_id'})) { - $self->SUPER::Limit( FIELD => 'EffectiveId', - OPERATOR => '=', - QUOTEVALUE => 0, - VALUE => 'main.id'); #TODO, we shouldn't be hard coding the tablename to main. - } - $self->{'RecalcTicketLimits'} = 0; -} + # Two special case + # Handle subkey fields with a different real field + if ( $field =~ /^(\w+)\./ ) { + $realfield = $1; + } -# }}} + die "I don't know about $field yet" + unless ( exists $FIELD_METADATA{$realfield} + or $restriction->{CUSTOMFIELD} ); + + my $type = $FIELD_METADATA{$realfield}->[0]; + my $op = $restriction->{'OPERATOR'}; + + my $value = ( + grep {defined} + map { $restriction->{$_} } qw(VALUE TICKET BASE TARGET) + )[0]; + + # this performs the moral equivalent of defined or/dor/C, + # without the short circuiting.You need to use a 'defined or' + # type thing instead of just checking for truth values, because + # VALUE could be 0.(i.e. "false") + + # You could also use this, but I find it less aesthetic: + # (although it does short circuit) + #( defined $restriction->{'VALUE'}? $restriction->{VALUE} : + # defined $restriction->{'TICKET'} ? + # $restriction->{TICKET} : + # defined $restriction->{'BASE'} ? + # $restriction->{BASE} : + # defined $restriction->{'TARGET'} ? + # $restriction->{TARGET} ) + + my $ea = $restriction->{ENTRYAGGREGATOR} + || $DefaultEA{$type} + || "AND"; + if ( ref $ea ) { + die "Invalid operator $op for $field ($type)" + unless exists $ea->{$op}; + $ea = $ea->{$op}; + } -# }}} + # Each CustomField should be put into a different Clause so they + # are ANDed together. + if ( $restriction->{CUSTOMFIELD} ) { + $realfield = $field; + } -# {{{ Deal with displaying rows of the listing + exists $clause{$realfield} or $clause{$realfield} = []; -# -# Everything in this section is stub code for 2.2 -# It's not part of the API. It's not for your use -# It's not for our use. -# + # Escape Quotes + $field =~ s!(['\\])!\\$1!g; + $value =~ s!(['\\])!\\$1!g; + my $data = [ $ea, $type, $field, $op, $value ]; + # here is where we store extra data, say if it's a keyword or + # something. (I.e. "TYPE SPECIFIC STUFF") -# {{{ sub SetListingFormat + if (lc $ea eq 'none') { + $clause{$realfield} = [ $data ]; + } else { + push @{ $clause{$realfield} }, $data; + } + } + return \%clause; +} -=head2 SetListingFormat -Takes a single Format string as specified below. parses that format string and makes the various listing output -things DTRT. -=item Format strings +=head2 _ProcessRestrictions PARAMHASH -Format strings are made up of a chain of Elements delimited with vertical pipes (|). -Elements of a Format string +# The new _ProcessRestrictions is somewhat dependent on the SQL stuff, +# but isn't quite generic enough to move into Tickets_SQL. +=cut -FormatString: Element[::FormatString] +sub _ProcessRestrictions { + my $self = shift; -Element: AttributeName[;HREF=][;TITLE=] + #Blow away ticket aliases since we'll need to regenerate them for + #a new search + delete $self->{'TicketAliases'}; + delete $self->{'items_array'}; + delete $self->{'item_map'}; + delete $self->{'raw_rows'}; + delete $self->{'rows'}; + delete $self->{'count_all'}; + + my $sql = $self->Query; # Violating the _SQL namespace + if ( !$sql || $self->{'RecalcTicketLimits'} ) { + + # "Restrictions to Clauses Branch\n"; + my $clauseRef = eval { $self->_RestrictionsToClauses; }; + if ($@) { + $RT::Logger->error( "RestrictionsToClauses: " . $@ ); + $self->FromSQL(""); + } + else { + $sql = $self->ClausesToSQL($clauseRef); + $self->FromSQL($sql) if $sql; + } + } -AttributeName Id | Subject | Status | Owner | Priority | InitialPriority | TimeWorked | TimeLeft | - - Keywords[;SELECT=<KeywordSelect>] | - - <Created|Starts|Started|Contacted|Due|Resolved>Date<AsString|AsISO|AsAge> + $self->{'RecalcTicketLimits'} = 0; +} -=cut +=head2 _BuildItemMap +Build up a L</ItemMap> of first/last/next/prev items, so that we can +display search nav quickly. +=cut +sub _BuildItemMap { + my $self = shift; -#accept a format string + my $window = RT->Config->Get('TicketsItemMapSize'); + $self->{'item_map'} = {}; + my $items = $self->ItemsArrayRefWindow( $window ); + return unless $items && @$items; -sub SetListingFormat { - my $self = shift; - my $listing_format = shift; - - my ($element, $attribs); - my $i = 0; - foreach $element (split (/::/,$listing_format)) { - if ($element =~ /^(.*?);(.*)$/) { - $element = $1; - $attribs = $2; - } - $self->{'format_string'}->[$i]->{'Element'} = $element; - foreach $attrib (split (/;/, $attribs)) { - my $value = ""; - if ($attrib =~ /^(.*?)=(.*)$/) { - $attrib = $1; - $value = $2; - } - $self->{'format_string'}->[$i]->{"$attrib"} = $val; - - } - + my $prev = 0; + $self->{'item_map'}{'first'} = $items->[0]->EffectiveId; + for ( my $i = 0; $i < @$items; $i++ ) { + my $item = $items->[$i]; + my $id = $item->EffectiveId; + $self->{'item_map'}{$id}{'defined'} = 1; + $self->{'item_map'}{$id}{'prev'} = $prev; + $self->{'item_map'}{$id}{'next'} = $items->[$i+1]->EffectiveId + if $items->[$i+1]; + $prev = $id; } - return(1); + $self->{'item_map'}{'last'} = $prev + if !$window || @$items < $window; } -# }}} +=head2 ItemMap -# {{{ sub HeaderAsHTML -sub HeaderAsHTML { - my $self = shift; - my $header = ""; - my $col; - foreach $col ( @{[ $self->{'format_string'} ]}) { - $header .= "<TH>" . $self->_ColumnTitle($self->{'format_string'}->[$col]) . "</TH>"; - +Returns an a map of all items found by this search. The map is a hash +of the form: + + { + first => <first ticket id found>, + last => <last ticket id found or undef>, + + <ticket id> => { + prev => <the ticket id found before>, + next => <the ticket id found after>, + }, + <ticket id> => { + prev => ..., + next => ..., + }, } - return ($header); -} -# }}} -# {{{ sub HeaderAsText -#Print text header -sub HeaderAsText { - my $self = shift; - my ($header); - - return ($header); -} -# }}} +=cut -# {{{ sub TicketAsHTMLRow -#Print HTML row -sub TicketAsHTMLRow { +sub ItemMap { my $self = shift; - my $Ticket = shift; - my ($row, $col); - foreach $col (@{[$self->{'format_string'}]}) { - $row .= "<TD>" . $self->_TicketColumnValue($ticket,$self->{'format_string'}->[$col]) . "</TD>"; - - } - return ($row); + $self->_BuildItemMap unless $self->{'item_map'}; + return $self->{'item_map'}; } -# }}} -# {{{ sub TicketAsTextRow -#Print text row -sub TicketAsTextRow { - my $self = shift; - my ($row); - #TODO implement - - return ($row); -} -# }}} -# {{{ _ColumnTitle { -sub _ColumnTitle { +=head2 PrepForSerialization + +You don't want to serialize a big tickets object, as +the {items} hash will be instantly invalid _and_ eat +lots of space + +=cut + +sub PrepForSerialization { my $self = shift; - - # Attrib is a hash - my $attrib = shift; - - # return either attrib->{'TITLE'} or.. - if ($attrib->{'TITLE'}) { - return($attrib->{'TITLE'}); - } - # failing that, Look up the title in a hash - else { - #TODO create $self->{'ColumnTitles'}; - return ($self->{'ColumnTitles'}->{$attrib->{'Element'}}); - } - + delete $self->{'items'}; + delete $self->{'items_array'}; + $self->RedoSearch(); } -# }}} +=head1 FLAGS + +RT::Tickets supports several flags which alter search behavior: + -# {{{ _TicketColumnValue -sub _TicketColumnValue { +allow_deleted_search (Otherwise never show deleted tickets in search results) +looking_at_type (otherwise limit to type=ticket) + +These flags are set by calling + +$tickets->{'flagname'} = 1; + +BUG: There should be an API for this + + + +=cut + + + +=head2 NewItem + +Returns an empty new RT::Ticket item + +=cut + +sub NewItem { my $self = shift; - my $Ticket = shift; - my $attrib = shift; + return(RT::Ticket->new($self->CurrentUser)); +} +RT::Base->_ImportOverlays(); - - my $out; - - SWITCH: { - /^id/i && do { - $out = $Ticket->id; - last SWITCH; - }; - /^subj/i && do { - last SWITCH; - $Ticket->Subject; - }; - /^status/i && do { - last SWITCH; - $Ticket->Status; - }; - /^prio/i && do { - last SWITCH; - $Ticket->Priority; - }; - /^finalprio/i && do { - - last SWITCH; - $Ticket->FinalPriority - }; - /^initialprio/i && do { - - last SWITCH; - $Ticket->InitialPriority; - }; - /^timel/i && do { - - last SWITCH; - $Ticket->TimeWorked; - }; - /^timew/i && do { - - last SWITCH; - $Ticket->TimeLeft; - }; - - /^(.*?)date(.*)$/i && do { - my $o = $1; - my $m = $2; - my ($obj); - #TODO: optimize - $obj = $Ticket->DueObj if $o =~ /due/i; - $obj = $Ticket->CreatedObj if $o =~ /created/i; - $obj = $Ticket->StartsObj if $o =~ /starts/i; - $obj = $Ticket->StartedObj if $o =~ /started/i; - $obj = $Ticket->ToldObj if $o =~ /told/i; - $obj = $Ticket->LastUpdatedObj if $o =~ /lastu/i; - - $method = 'ISO' if $m =~ /iso/i; - - $method = 'AsString' if $m =~ /asstring/i; - $method = 'AgeAsString' if $m =~ /age/i; - last SWITCH; - $obj->$method(); - - }; - - /^watcher/i && do { - last SWITCH; - $Ticket->WatchersAsString(); - }; - - /^requestor/i && do { - last SWITCH; - $Ticket->RequestorsAsString(); - }; - /^cc/i && do { - last SWITCH; - $Ticket->CCAsString(); - }; - - - /^admincc/i && do { - last SWITCH; - $Ticket->AdminCcAsString(); - }; - - /^keywords/i && do { - last SWITCH; - #Limit it to the keyword select we're talking about, if we've got one. - my $objkeys =$Ticket->KeywordsObj($attrib->{'SELECT'}); - $objkeys->KeywordRelativePathsAsString(); - }; - - } - -} - -# }}} - -# }}} - -# {{{ POD -=head2 notes -"Enum" Things that get Is, IsNot - - -"Int" Things that get Is LessThan and GreaterThan -id -InitialPriority -FinalPriority -Priority -TimeLeft -TimeWorked - -"Text" Things that get Is, Like -Subject -TransactionContent - - -"Link" OPERATORs - - -"Date" OPERATORs Is, Before, After - - =cut -# }}} 1;