import rt 3.6.4
[freeside.git] / rt / lib / RT / Tickets_Overlay.pm
1 # BEGIN BPS TAGGED BLOCK {{{
2
3 # COPYRIGHT:
4 #  
5 # This software is Copyright (c) 1996-2007 Best Practical Solutions, LLC 
6 #                                          <jesse@bestpractical.com>
7
8 # (Except where explicitly superseded by other copyright notices)
9
10
11 # LICENSE:
12
13 # This work is made available to you under the terms of Version 2 of
14 # the GNU General Public License. A copy of that license should have
15 # been provided with this software, but in any event can be snarfed
16 # from www.gnu.org.
17
18 # This work is distributed in the hope that it will be useful, but
19 # WITHOUT ANY WARRANTY; without even the implied warranty of
20 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
21 # General Public License for more details.
22
23 # You should have received a copy of the GNU General Public License
24 # along with this program; if not, write to the Free Software
25 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
26 # 02110-1301 or visit their web page on the internet at
27 # http://www.gnu.org/copyleft/gpl.html.
28
29
30 # CONTRIBUTION SUBMISSION POLICY:
31
32 # (The following paragraph is not intended to limit the rights granted
33 # to you to modify and distribute this software under the terms of
34 # the GNU General Public License and is only of importance to you if
35 # you choose to contribute your changes and enhancements to the
36 # community by submitting them to Best Practical Solutions, LLC.)
37
38 # By intentionally submitting any modifications, corrections or
39 # derivatives to this work, or any other work intended for use with
40 # Request Tracker, to Best Practical Solutions, LLC, you confirm that
41 # you are the copyright holder for those contributions and you grant
42 # Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
43 # royalty-free, perpetual, license to use, copy, create derivative
44 # works based on those contributions, and sublicense and distribute
45 # those contributions and any derivatives thereof.
46
47 # END BPS TAGGED BLOCK }}}
48 # Major Changes:
49
50 # - Decimated ProcessRestrictions and broke it into multiple
51 # functions joined by a LUT
52 # - Semi-Generic SQL stuff moved to another file
53
54 # Known Issues: FIXME!
55
56 # - ClearRestrictions and Reinitialization is messy and unclear.  The
57 # only good way to do it is to create a new RT::Tickets object.
58
59 =head1 NAME
60
61   RT::Tickets - A collection of Ticket objects
62
63
64 =head1 SYNOPSIS
65
66   use RT::Tickets;
67   my $tickets = new RT::Tickets($CurrentUser);
68
69 =head1 DESCRIPTION
70
71    A collection of RT::Tickets.
72
73 =head1 METHODS
74
75 =begin testing
76
77 ok (require RT::Tickets);
78 ok( my $testtickets = RT::Tickets->new( $RT::SystemUser ) );
79 ok( $testtickets->LimitStatus( VALUE => 'deleted' ) );
80 # Should be zero until 'allow_deleted_search'
81 ok( $testtickets->Count == 0 );
82
83 =end testing
84
85 =cut
86
87 package RT::Tickets;
88
89 use strict;
90 no warnings qw(redefine);
91
92 use RT::CustomFields;
93 use DBIx::SearchBuilder::Unique;
94
95 # Configuration Tables:
96
97 # FIELD_METADATA is a mapping of searchable Field name, to Type, and other
98 # metadata.
99
100 my %FIELD_METADATA = (
101     Status          => [ 'ENUM', ],
102     Queue           => [ 'ENUM' => 'Queue', ],
103     Type            => [ 'ENUM', ],
104     Creator         => [ 'ENUM' => 'User', ],
105     LastUpdatedBy   => [ 'ENUM' => 'User', ],
106     Owner           => [ 'WATCHERFIELD' => 'Owner', ],
107     EffectiveId     => [ 'INT', ],
108     id              => [ 'INT', ],
109     InitialPriority => [ 'INT', ],
110     FinalPriority   => [ 'INT', ],
111     Priority        => [ 'INT', ],
112     TimeLeft        => [ 'INT', ],
113     TimeWorked      => [ 'INT', ],
114     TimeEstimated   => [ 'INT', ],
115     MemberOf        => [ 'LINK' => To => 'MemberOf', ],
116     DependsOn       => [ 'LINK' => To => 'DependsOn', ],
117     RefersTo        => [ 'LINK' => To => 'RefersTo', ],
118     HasMember       => [ 'LINK' => From => 'MemberOf', ],
119     DependentOn     => [ 'LINK' => From => 'DependsOn', ],
120     DependedOnBy    => [ 'LINK' => From => 'DependsOn', ],
121     ReferredToBy    => [ 'LINK' => From => 'RefersTo', ],
122     Told             => [ 'DATE'            => 'Told', ],
123     Starts           => [ 'DATE'            => 'Starts', ],
124     Started          => [ 'DATE'            => 'Started', ],
125     Due              => [ 'DATE'            => 'Due', ],
126     Resolved         => [ 'DATE'            => 'Resolved', ],
127     LastUpdated      => [ 'DATE'            => 'LastUpdated', ],
128     Created          => [ 'DATE'            => 'Created', ],
129     Subject          => [ 'STRING', ],
130     Content          => [ 'TRANSFIELD', ],
131     ContentType      => [ 'TRANSFIELD', ],
132     Filename         => [ 'TRANSFIELD', ],
133     TransactionDate  => [ 'TRANSDATE', ],
134     Requestor        => [ 'WATCHERFIELD'    => 'Requestor', ],
135     Requestors       => [ 'WATCHERFIELD'    => 'Requestor', ],
136     Cc               => [ 'WATCHERFIELD'    => 'Cc', ],
137     AdminCc          => [ 'WATCHERFIELD'    => 'AdminCc', ],
138     Watcher          => [ 'WATCHERFIELD', ],
139     LinkedTo         => [ 'LINKFIELD', ],
140     CustomFieldValue => [ 'CUSTOMFIELD', ],
141     CustomField      => [ 'CUSTOMFIELD', ],
142     CF               => [ 'CUSTOMFIELD', ],
143     Updated          => [ 'TRANSDATE', ],
144     RequestorGroup   => [ 'MEMBERSHIPFIELD' => 'Requestor', ],
145     CCGroup          => [ 'MEMBERSHIPFIELD' => 'Cc', ],
146     AdminCCGroup     => [ 'MEMBERSHIPFIELD' => 'AdminCc', ],
147     WatcherGroup     => [ 'MEMBERSHIPFIELD', ],
148 );
149
150 # Mapping of Field Type to Function
151 my %dispatch = (
152     ENUM            => \&_EnumLimit,
153     INT             => \&_IntLimit,
154     LINK            => \&_LinkLimit,
155     DATE            => \&_DateLimit,
156     STRING          => \&_StringLimit,
157     TRANSFIELD      => \&_TransLimit,
158     TRANSDATE       => \&_TransDateLimit,
159     WATCHERFIELD    => \&_WatcherLimit,
160     MEMBERSHIPFIELD => \&_WatcherMembershipLimit,
161     LINKFIELD       => \&_LinkFieldLimit,
162     CUSTOMFIELD     => \&_CustomFieldLimit,
163 );
164 my %can_bundle = (); # WATCHERFIELD => "yes", );
165
166 # Default EntryAggregator per type
167 # if you specify OP, you must specify all valid OPs
168 my %DefaultEA = (
169     INT  => 'AND',
170     ENUM => {
171         '='  => 'OR',
172         '!=' => 'AND'
173     },
174     DATE => {
175         '='  => 'OR',
176         '>=' => 'AND',
177         '<=' => 'AND',
178         '>'  => 'AND',
179         '<'  => 'AND'
180     },
181     STRING => {
182         '='        => 'OR',
183         '!='       => 'AND',
184         'LIKE'     => 'AND',
185         'NOT LIKE' => 'AND'
186     },
187     TRANSFIELD   => 'AND',
188     TRANSDATE    => 'AND',
189     LINK         => 'OR',
190     LINKFIELD    => 'AND',
191     TARGET       => 'AND',
192     BASE         => 'AND',
193     WATCHERFIELD => {
194         '='        => 'OR',
195         '!='       => 'AND',
196         'LIKE'     => 'OR',
197         'NOT LIKE' => 'AND'
198     },
199
200     CUSTOMFIELD => 'OR',
201 );
202
203 # Helper functions for passing the above lexically scoped tables above
204 # into Tickets_Overlay_SQL.
205 sub FIELDS     { return \%FIELD_METADATA }
206 sub dispatch   { return \%dispatch }
207 sub can_bundle { return \%can_bundle }
208
209 # Bring in the clowns.
210 require RT::Tickets_Overlay_SQL;
211
212 # {{{ sub SortFields
213
214 our @SORTFIELDS = qw(id Status
215     Queue Subject
216     Owner Created Due Starts Started
217     Told
218     Resolved LastUpdated Priority TimeWorked TimeLeft);
219
220 =head2 SortFields
221
222 Returns the list of fields that lists of tickets can easily be sorted by
223
224 =cut
225
226 sub SortFields {
227     my $self = shift;
228     return (@SORTFIELDS);
229 }
230
231 # }}}
232
233 # BEGIN SQL STUFF *********************************
234
235
236 sub CleanSlate {
237     my $self = shift;
238     $self->SUPER::CleanSlate( @_ );
239     delete $self->{$_} foreach qw(
240         _sql_cf_alias
241         _sql_group_members_aliases
242         _sql_object_cfv_alias
243         _sql_role_group_aliases
244         _sql_transalias
245         _sql_trattachalias
246         _sql_u_watchers_alias_for_sort
247         _sql_u_watchers_aliases
248     );
249 }
250
251 =head1 Limit Helper Routines
252
253 These routines are the targets of a dispatch table depending on the
254 type of field.  They all share the same signature:
255
256   my ($self,$field,$op,$value,@rest) = @_;
257
258 The values in @rest should be suitable for passing directly to
259 DBIx::SearchBuilder::Limit.
260
261 Essentially they are an expanded/broken out (and much simplified)
262 version of what ProcessRestrictions used to do.  They're also much
263 more clearly delineated by the TYPE of field being processed.
264
265 =head2 _EnumLimit
266
267 Handle Fields which are limited to certain values, and potentially
268 need to be looked up from another class.
269
270 This subroutine actually handles two different kinds of fields.  For
271 some the user is responsible for limiting the values.  (i.e. Status,
272 Type).
273
274 For others, the value specified by the user will be looked by via
275 specified class.
276
277 Meta Data:
278   name of class to lookup in (Optional)
279
280 =cut
281
282 sub _EnumLimit {
283     my ( $sb, $field, $op, $value, @rest ) = @_;
284
285     # SQL::Statement changes != to <>.  (Can we remove this now?)
286     $op = "!=" if $op eq "<>";
287
288     die "Invalid Operation: $op for $field"
289         unless $op eq "="
290         or $op     eq "!=";
291
292     my $meta = $FIELD_METADATA{$field};
293     if ( defined $meta->[1] && defined $value && $value !~ /^\d+$/ ) {
294         my $class = "RT::" . $meta->[1];
295         my $o     = $class->new( $sb->CurrentUser );
296         $o->Load($value);
297         $value = $o->Id;
298     }
299     $sb->_SQLLimit(
300         FIELD    => $field,
301         VALUE    => $value,
302         OPERATOR => $op,
303         @rest,
304     );
305 }
306
307 =head2 _IntLimit
308
309 Handle fields where the values are limited to integers.  (For example,
310 Priority, TimeWorked.)
311
312 Meta Data:
313   None
314
315 =cut
316
317 sub _IntLimit {
318     my ( $sb, $field, $op, $value, @rest ) = @_;
319
320     die "Invalid Operator $op for $field"
321         unless $op =~ /^(=|!=|>|<|>=|<=)$/;
322
323     $sb->_SQLLimit(
324         FIELD    => $field,
325         VALUE    => $value,
326         OPERATOR => $op,
327         @rest,
328     );
329 }
330
331 =head2 _LinkLimit
332
333 Handle fields which deal with links between tickets.  (MemberOf, DependsOn)
334
335 Meta Data:
336   1: Direction (From, To)
337   2: Link Type (MemberOf, DependsOn, RefersTo)
338
339 =cut
340
341 sub _LinkLimit {
342     my ( $sb, $field, $op, $value, @rest ) = @_;
343
344     my $meta = $FIELD_METADATA{$field};
345     die "Incorrect Metadata for $field"
346         unless defined $meta->[1] && defined $meta->[2];
347
348     die "Invalid Operator $op for $field" unless $op =~ /^(=|!=|IS|IS NOT)$/io;
349
350     my $direction = $meta->[1];
351
352     my $matchfield;
353     my $linkfield;
354     if ( $direction eq 'To' ) {
355         $matchfield = "Target";
356         $linkfield  = "Base";
357
358     }
359     elsif ( $direction eq 'From' ) {
360         $linkfield  = "Target";
361         $matchfield = "Base";
362
363     }
364     else {
365         die "Invalid link direction '$meta->[1]' for $field\n";
366     }
367
368     my ($is_local, $is_null) = (1, 0);
369     if ( !$value || $value =~ /^null$/io ) {
370         $is_null = 1;
371         $op = ($op =~ /^(=|IS)$/)? 'IS': 'IS NOT';
372     }
373     elsif ( $value =~ /\D/o ) {
374         $is_local = 0;
375     }
376     $matchfield = "Local$matchfield" if $is_local;
377
378     my $is_negative = 0;
379     if ( $op eq '!=' ) {
380         $is_negative = 1;
381         $op = '=';
382     }
383
384 #For doing a left join to find "unlinked tickets" we want to generate a query that looks like this
385 #    SELECT main.* FROM Tickets main
386 #        LEFT JOIN Links Links_1 ON (     (Links_1.Type = 'MemberOf')
387 #                                      AND(main.id = Links_1.LocalTarget))
388 #        WHERE Links_1.LocalBase IS NULL;
389
390     if ($is_null) {
391         my $linkalias = $sb->Join(
392             TYPE   => 'LEFT',
393             ALIAS1 => 'main',
394             FIELD1 => 'id',
395             TABLE2 => 'Links',
396             FIELD2 => 'Local' . $linkfield
397         );
398         $sb->SUPER::Limit(
399             LEFTJOIN => $linkalias,
400             FIELD    => 'Type',
401             OPERATOR => '=',
402             VALUE    => $meta->[2],
403         );
404         $sb->_SQLLimit(
405             @rest,
406             ALIAS      => $linkalias,
407             FIELD      => $matchfield,
408             OPERATOR   => $op,
409             VALUE      => 'NULL',
410             QUOTEVALUE => 0,
411         );
412     }
413     elsif ( $is_negative ) {
414         my $linkalias = $sb->Join(
415             TYPE   => 'LEFT',
416             ALIAS1 => 'main',
417             FIELD1 => 'id',
418             TABLE2 => 'Links',
419             FIELD2 => 'Local' . $linkfield
420         );
421         $sb->SUPER::Limit(
422             LEFTJOIN => $linkalias,
423             FIELD    => 'Type',
424             OPERATOR => '=',
425             VALUE    => $meta->[2],
426         );
427         $sb->SUPER::Limit(
428             LEFTJOIN => $linkalias,
429             FIELD    => $matchfield,
430             OPERATOR => $op,
431             VALUE    => $value,
432         );
433         $sb->_SQLLimit(
434             @rest,
435             ALIAS      => $linkalias,
436             FIELD      => $matchfield,
437             OPERATOR   => 'IS',
438             VALUE      => 'NULL',
439             QUOTEVALUE => 0,
440         );
441     }
442     else {
443         my $linkalias = $sb->NewAlias('Links');
444         $sb->_OpenParen();
445         $sb->_SQLLimit(
446             @rest,
447             ALIAS    => $linkalias,
448             FIELD    => 'Type',
449             OPERATOR => '=',
450             VALUE    => $meta->[2],
451         );
452         $sb->_SQLLimit(
453             ALIAS           => $linkalias,
454             FIELD           => 'Local' . $linkfield,
455             OPERATOR        => '=',
456             VALUE           => 'main.id',
457             QUOTEVALUE      => 0,
458             ENTRYAGGREGATOR => 'AND',
459         );
460         $sb->_SQLLimit(
461             ALIAS           => $linkalias,
462             FIELD           => $matchfield,
463             OPERATOR        => $op,
464             VALUE           => $value,
465             ENTRYAGGREGATOR => 'AND',
466         );
467         $sb->_CloseParen();
468     }
469 }
470
471 =head2 _DateLimit
472
473 Handle date fields.  (Created, LastTold..)
474
475 Meta Data:
476   1: type of link.  (Probably not necessary.)
477
478 =cut
479
480 sub _DateLimit {
481     my ( $sb, $field, $op, $value, @rest ) = @_;
482
483     die "Invalid Date Op: $op"
484         unless $op =~ /^(=|>|<|>=|<=)$/;
485
486     my $meta = $FIELD_METADATA{$field};
487     die "Incorrect Meta Data for $field"
488         unless ( defined $meta->[1] );
489
490     my $date = RT::Date->new( $sb->CurrentUser );
491     $date->Set( Format => 'unknown', Value => $value );
492
493     if ( $op eq "=" ) {
494
495         # if we're specifying =, that means we want everything on a
496         # particular single day.  in the database, we need to check for >
497         # and < the edges of that day.
498
499         $date->SetToMidnight( Timezone => 'server' );
500         my $daystart = $date->ISO;
501         $date->AddDay;
502         my $dayend = $date->ISO;
503
504         $sb->_OpenParen;
505
506         $sb->_SQLLimit(
507             FIELD    => $meta->[1],
508             OPERATOR => ">=",
509             VALUE    => $daystart,
510             @rest,
511         );
512
513         $sb->_SQLLimit(
514             FIELD    => $meta->[1],
515             OPERATOR => "<=",
516             VALUE    => $dayend,
517             @rest,
518             ENTRYAGGREGATOR => 'AND',
519         );
520
521         $sb->_CloseParen;
522
523     }
524     else {
525         $sb->_SQLLimit(
526             FIELD    => $meta->[1],
527             OPERATOR => $op,
528             VALUE    => $date->ISO,
529             @rest,
530         );
531     }
532 }
533
534 =head2 _StringLimit
535
536 Handle simple fields which are just strings.  (Subject,Type)
537
538 Meta Data:
539   None
540
541 =cut
542
543 sub _StringLimit {
544     my ( $sb, $field, $op, $value, @rest ) = @_;
545
546     # FIXME:
547     # Valid Operators:
548     #  =, !=, LIKE, NOT LIKE
549
550     $sb->_SQLLimit(
551         FIELD         => $field,
552         OPERATOR      => $op,
553         VALUE         => $value,
554         CASESENSITIVE => 0,
555         @rest,
556     );
557 }
558
559 =head2 _TransDateLimit
560
561 Handle fields limiting based on Transaction Date.
562
563 The inpupt value must be in a format parseable by Time::ParseDate
564
565 Meta Data:
566   None
567
568 =cut
569
570 # This routine should really be factored into translimit.
571 sub _TransDateLimit {
572     my ( $sb, $field, $op, $value, @rest ) = @_;
573
574     # See the comments for TransLimit, they apply here too
575
576     unless ( $sb->{_sql_transalias} ) {
577         $sb->{_sql_transalias} = $sb->Join(
578             ALIAS1 => 'main',
579             FIELD1 => 'id',
580             TABLE2 => 'Transactions',
581             FIELD2 => 'ObjectId',
582         );
583         $sb->SUPER::Limit(
584             ALIAS           => $sb->{_sql_transalias},
585             FIELD           => 'ObjectType',
586             VALUE           => 'RT::Ticket',
587             ENTRYAGGREGATOR => 'AND',
588         );
589     }
590
591     my $date = RT::Date->new( $sb->CurrentUser );
592     $date->Set( Format => 'unknown', Value => $value );
593
594     $sb->_OpenParen;
595     if ( $op eq "=" ) {
596
597         # if we're specifying =, that means we want everything on a
598         # particular single day.  in the database, we need to check for >
599         # and < the edges of that day.
600
601         $date->SetToMidnight( Timezone => 'server' );
602         my $daystart = $date->ISO;
603         $date->AddDay;
604         my $dayend = $date->ISO;
605
606         $sb->_SQLLimit(
607             ALIAS         => $sb->{_sql_transalias},
608             FIELD         => 'Created',
609             OPERATOR      => ">=",
610             VALUE         => $daystart,
611             CASESENSITIVE => 0,
612             @rest
613         );
614         $sb->_SQLLimit(
615             ALIAS         => $sb->{_sql_transalias},
616             FIELD         => 'Created',
617             OPERATOR      => "<=",
618             VALUE         => $dayend,
619             CASESENSITIVE => 0,
620             @rest,
621             ENTRYAGGREGATOR => 'AND',
622         );
623
624     }
625
626     # not searching for a single day
627     else {
628
629         #Search for the right field
630         $sb->_SQLLimit(
631             ALIAS         => $sb->{_sql_transalias},
632             FIELD         => 'Created',
633             OPERATOR      => $op,
634             VALUE         => $date->ISO,
635             CASESENSITIVE => 0,
636             @rest
637         );
638     }
639
640     $sb->_CloseParen;
641 }
642
643 =head2 _TransLimit
644
645 Limit based on the Content of a transaction or the ContentType.
646
647 Meta Data:
648   none
649
650 =cut
651
652 sub _TransLimit {
653
654     # Content, ContentType, Filename
655
656     # If only this was this simple.  We've got to do something
657     # complicated here:
658
659     #Basically, we want to make sure that the limits apply to
660     #the same attachment, rather than just another attachment
661     #for the same ticket, no matter how many clauses we lump
662     #on. We put them in TicketAliases so that they get nuked
663     #when we redo the join.
664
665     # In the SQL, we might have
666     #       (( Content = foo ) or ( Content = bar AND Content = baz ))
667     # The AND group should share the same Alias.
668
669     # Actually, maybe it doesn't matter.  We use the same alias and it
670     # works itself out? (er.. different.)
671
672     # Steal more from _ProcessRestrictions
673
674     # FIXME: Maybe look at the previous FooLimit call, and if it was a
675     # TransLimit and EntryAggregator == AND, reuse the Aliases?
676
677     # Or better - store the aliases on a per subclause basis - since
678     # those are going to be the things we want to relate to each other,
679     # anyway.
680
681     # maybe we should not allow certain kinds of aggregation of these
682     # clauses and do a psuedo regex instead? - the problem is getting
683     # them all into the same subclause when you have (A op B op C) - the
684     # way they get parsed in the tree they're in different subclauses.
685
686     my ( $self, $field, $op, $value, @rest ) = @_;
687
688     unless ( $self->{_sql_transalias} ) {
689         $self->{_sql_transalias} = $self->Join(
690             ALIAS1 => 'main',
691             FIELD1 => 'id',
692             TABLE2 => 'Transactions',
693             FIELD2 => 'ObjectId',
694         );
695         $self->SUPER::Limit(
696             ALIAS           => $self->{_sql_transalias},
697             FIELD           => 'ObjectType',
698             VALUE           => 'RT::Ticket',
699             ENTRYAGGREGATOR => 'AND',
700         );
701     }
702     unless ( defined $self->{_sql_trattachalias} ) {
703         $self->{_sql_trattachalias} = $self->_SQLJoin(
704             TYPE   => 'LEFT', # not all txns have an attachment
705             ALIAS1 => $self->{_sql_transalias},
706             FIELD1 => 'id',
707             TABLE2 => 'Attachments',
708             FIELD2 => 'TransactionId',
709         );
710     }
711
712     $self->_OpenParen;
713
714     #Search for the right field
715     if ($field eq 'Content' and $RT::DontSearchFileAttachments) {
716        $self->_SQLLimit(
717                         ALIAS         => $self->{_sql_trattachalias},
718                         FIELD         => 'Filename',
719                         OPERATOR      => 'IS',
720                         VALUE         => 'NULL',
721                         SUBCLAUSE     => 'contentquery',
722                         ENTRYAGGREGATOR => 'AND',
723                        );
724        $self->_SQLLimit(
725                         ALIAS         => $self->{_sql_trattachalias},
726                         FIELD         => $field,
727                         OPERATOR      => $op,
728                         VALUE         => $value,
729                         CASESENSITIVE => 0,
730                         @rest,
731                         ENTRYAGGREGATOR => 'AND',
732                         SUBCLAUSE     => 'contentquery',
733                        );
734     } else {
735         $self->_SQLLimit(
736                         ALIAS         => $self->{_sql_trattachalias},
737                         FIELD         => $field,
738                         OPERATOR      => $op,
739                         VALUE         => $value,
740                         CASESENSITIVE => 0,
741                         ENTRYAGGREGATOR => 'AND',
742                         @rest
743                 );
744     }
745
746     $self->_CloseParen;
747
748 }
749
750 =head2 _WatcherLimit
751
752 Handle watcher limits.  (Requestor, CC, etc..)
753
754 Meta Data:
755   1: Field to query on
756
757
758 =begin testing
759
760 # Test to make sure that you can search for tickets by requestor address and
761 # by requestor name.
762
763 my ($id,$msg);
764 my $u1 = RT::User->new($RT::SystemUser);
765 ($id, $msg) = $u1->Create( Name => 'RequestorTestOne', EmailAddress => 'rqtest1@example.com');
766 ok ($id,$msg);
767 my $u2 = RT::User->new($RT::SystemUser);
768 ($id, $msg) = $u2->Create( Name => 'RequestorTestTwo', EmailAddress => 'rqtest2@example.com');
769 ok ($id,$msg);
770
771 my $t1 = RT::Ticket->new($RT::SystemUser);
772 my ($trans);
773 ($id,$trans,$msg) =$t1->Create (Queue => 'general', Subject => 'Requestor test one', Requestor => [$u1->EmailAddress]);
774 ok ($id, $msg);
775
776 my $t2 = RT::Ticket->new($RT::SystemUser);
777 ($id,$trans,$msg) =$t2->Create (Queue => 'general', Subject => 'Requestor test one', Requestor => [$u2->EmailAddress]);
778 ok ($id, $msg);
779
780
781 my $t3 = RT::Ticket->new($RT::SystemUser);
782 ($id,$trans,$msg) =$t3->Create (Queue => 'general', Subject => 'Requestor test one', Requestor => [$u2->EmailAddress, $u1->EmailAddress]);
783 ok ($id, $msg);
784
785
786 my $tix1 = RT::Tickets->new($RT::SystemUser);
787 $tix1->FromSQL('Requestor.EmailAddress LIKE "rqtest1" OR Requestor.EmailAddress LIKE "rqtest2"');
788
789 is ($tix1->Count, 3);
790
791 my $tix2 = RT::Tickets->new($RT::SystemUser);
792 $tix2->FromSQL('Requestor.Name LIKE "TestOne" OR Requestor.Name LIKE "TestTwo"');
793
794 is ($tix2->Count, 3);
795
796
797 my $tix3 = RT::Tickets->new($RT::SystemUser);
798 $tix3->FromSQL('Requestor.EmailAddress LIKE "rqtest1"');
799
800 is ($tix3->Count, 2);
801
802 my $tix4 = RT::Tickets->new($RT::SystemUser);
803 $tix4->FromSQL('Requestor.Name LIKE "TestOne" ');
804
805 is ($tix4->Count, 2);
806
807 # Searching for tickets that have two requestors isn't supported
808 # There's no way to differentiate "one requestor name that matches foo and bar"
809 # and "two requestors, one matching foo and one matching bar"
810
811 # my $tix5 = RT::Tickets->new($RT::SystemUser);
812 # $tix5->FromSQL('Requestor.Name LIKE "TestOne" AND Requestor.Name LIKE "TestTwo"');
813
814 # is ($tix5->Count, 1);
815
816 # my $tix6 = RT::Tickets->new($RT::SystemUser);
817 # $tix6->FromSQL('Requestor.EmailAddress LIKE "rqtest1" AND Requestor.EmailAddress LIKE "rqtest2"');
818
819 # is ($tix6->Count, 1);
820
821
822 =end testing
823
824 =cut
825
826 sub _WatcherLimit {
827     my $self  = shift;
828     my $field = shift;
829     my $op    = shift;
830     my $value = shift;
831     my %rest  = (@_);
832
833     my $meta = $FIELD_METADATA{ $field };
834     my $type = $meta->[1] || '';
835
836     # Owner was ENUM field, so "Owner = 'xxx'" allowed user to
837     # search by id and Name at the same time, this is workaround
838     # to preserve backward compatibility
839     if ( $field eq 'Owner' && !$rest{SUBKEY} && $op =~ /^!?=$/ ) {
840         my $o = RT::User->new( $self->CurrentUser );
841         $o->Load( $value );
842         $self->_SQLLimit(
843             FIELD    => 'Owner',
844             OPERATOR => $op,
845             VALUE    => $o->Id,
846             %rest,
847         );
848         return;
849     }
850     $rest{SUBKEY} ||= 'EmailAddress';
851
852     my $groups = $self->_RoleGroupsJoin( Type => $type );
853
854     $self->_OpenParen;
855     if ( $op =~ /^IS(?: NOT)?$/ ) {
856         my $group_members = $self->_GroupMembersJoin( GroupsAlias => $groups );
857         $self->SUPER::Limit(
858             LEFTJOIN   => $group_members,
859             FIELD      => 'GroupId',
860             OPERATOR   => '!=',
861             VALUE      => "$group_members.MemberId",
862             QUOTEVALUE => 0,
863         );
864         $self->_SQLLimit(
865             ALIAS         => $group_members,
866             FIELD         => 'GroupId',
867             OPERATOR      => $op,
868             VALUE         => $value,
869             %rest,
870         );
871     }
872     elsif ( $op =~ /^!=$|^NOT\s+/i ) {
873         # reverse op
874         $op =~ s/!|NOT\s+//i;
875
876         # XXX: we have no way to build correct "Watcher.X != 'Y'" when condition
877         # "X = 'Y'" matches more then one user so we try to fetch two records and
878         # do the right thing when there is only one exist and semi-working solution
879         # otherwise.
880         my $users_obj = RT::Users->new( $self->CurrentUser );
881         $users_obj->Limit(
882             FIELD         => $rest{SUBKEY},
883             OPERATOR      => $op,
884             VALUE         => $value,
885         );
886         $users_obj->OrderBy;
887         $users_obj->RowsPerPage(2);
888         my @users = @{ $users_obj->ItemsArrayRef };
889
890         my $group_members = $self->_GroupMembersJoin( GroupsAlias => $groups );
891         if ( @users <= 1 ) {
892             my $uid = 0;
893             $uid = $users[0]->id if @users;
894             $self->SUPER::Limit(
895                 LEFTJOIN      => $group_members,
896                 ALIAS         => $group_members,
897                 FIELD         => 'MemberId',
898                 VALUE         => $uid,
899             );
900             $self->_SQLLimit(
901                 %rest,
902                 ALIAS           => $group_members,
903                 FIELD           => 'id',
904                 OPERATOR        => 'IS',
905                 VALUE           => 'NULL',
906             );
907         } else {
908             $self->SUPER::Limit(
909                 LEFTJOIN   => $group_members,
910                 FIELD      => 'GroupId',
911                 OPERATOR   => '!=',
912                 VALUE      => "$group_members.MemberId",
913                 QUOTEVALUE => 0,
914             );
915             my $users = $self->Join(
916                 TYPE            => 'LEFT',
917                 ALIAS1          => $group_members,
918                 FIELD1          => 'MemberId',
919                 TABLE2          => 'Users',
920                 FIELD2          => 'id',
921             );
922             $self->SUPER::Limit(
923                 LEFTJOIN      => $users,
924                 ALIAS         => $users,
925                 FIELD         => $rest{SUBKEY},
926                 OPERATOR      => $op,
927                 VALUE         => $value,
928                 CASESENSITIVE => 0,
929             );
930             $self->_SQLLimit(
931                 %rest,
932                 ALIAS         => $users,
933                 FIELD         => 'id',
934                 OPERATOR      => 'IS',
935                 VALUE         => 'NULL',
936             );
937         }
938     } else {
939         my $group_members = $self->_GroupMembersJoin(
940             GroupsAlias => $groups,
941             New => 0,
942         );
943
944         my $users = $self->{'_sql_u_watchers_aliases'}{$group_members};
945         unless ( $users ) {
946             $users = $self->{'_sql_u_watchers_aliases'}{$group_members} = 
947                 $self->NewAlias('Users');
948             $self->SUPER::Limit(
949                 LEFTJOIN      => $group_members,
950                 ALIAS         => $group_members,
951                 FIELD         => 'MemberId',
952                 VALUE         => "$users.id",
953                 QUOTEVALUE    => 0,
954             );
955         }
956
957         $self->_SQLLimit(
958             ALIAS         => $users,
959             FIELD         => $rest{SUBKEY},
960             VALUE         => $value,
961             OPERATOR      => $op,
962             CASESENSITIVE => 0,
963             %rest,
964         );
965         $self->_SQLLimit(
966             ENTRYAGGREGATOR => 'AND',
967             ALIAS           => $group_members,
968             FIELD           => 'id',
969             OPERATOR        => 'IS NOT',
970             VALUE           => 'NULL',
971         );
972     }
973     $self->_CloseParen;
974 }
975
976 sub _RoleGroupsJoin {
977     my $self = shift;
978     my %args = (New => 0, Type => '', @_);
979     return $self->{'_sql_role_group_aliases'}{ $args{'Type'} }
980         if $self->{'_sql_role_group_aliases'}{ $args{'Type'} } && !$args{'New'};
981
982     # XXX: this has been fixed in DBIx::SB-1.48
983     # XXX: if we change this from Join to NewAlias+Limit
984     # then Pg and mysql 5.x will complain because SB build wrong query.
985     # Query looks like "FROM (Tickets LEFT JOIN CGM ON(Groups.id = CGM.GroupId)), Groups"
986     # Pg doesn't like that fact that it doesn't know about Groups table yet when
987     # join CGM table into Tickets. Problem is in Join method which doesn't use
988     # ALIAS1 argument when build braces.
989
990     # we always have watcher groups for ticket, so we use INNER join
991     my $groups = $self->Join(
992         ALIAS1          => 'main',
993         FIELD1          => 'id',
994         TABLE2          => 'Groups',
995         FIELD2          => 'Instance',
996         ENTRYAGGREGATOR => 'AND',
997     );
998     $self->SUPER::Limit(
999         LEFTJOIN        => $groups,
1000         ALIAS           => $groups,
1001         FIELD           => 'Domain',
1002         VALUE           => 'RT::Ticket-Role',
1003     );
1004     $self->SUPER::Limit(
1005         LEFTJOIN        => $groups,
1006         ALIAS           => $groups,
1007         FIELD           => 'Type',
1008         VALUE           => $args{'Type'},
1009     ) if $args{'Type'};
1010
1011     $self->{'_sql_role_group_aliases'}{ $args{'Type'} } = $groups
1012         unless $args{'New'};
1013
1014     return $groups;
1015 }
1016
1017 sub _GroupMembersJoin {
1018     my $self = shift;
1019     my %args = (New => 1, GroupsAlias => undef, @_);
1020
1021     return $self->{'_sql_group_members_aliases'}{ $args{'GroupsAlias'} }
1022         if $self->{'_sql_group_members_aliases'}{ $args{'GroupsAlias'} }
1023             && !$args{'New'};
1024
1025     my $alias = $self->Join(
1026         TYPE            => 'LEFT',
1027         ALIAS1          => $args{'GroupsAlias'},
1028         FIELD1          => 'id',
1029         TABLE2          => 'CachedGroupMembers',
1030         FIELD2          => 'GroupId',
1031         ENTRYAGGREGATOR => 'AND',
1032     );
1033
1034     $self->{'_sql_group_members_aliases'}{ $args{'GroupsAlias'} } = $alias
1035         unless $args{'New'};
1036
1037     return $alias;
1038 }
1039
1040 =head2 _WatcherJoin
1041
1042 Helper function which provides joins to a watchers table both for limits
1043 and for ordering.
1044
1045 =cut
1046
1047 sub _WatcherJoin {
1048     my $self = shift;
1049     my $type = shift || '';
1050
1051
1052     my $groups = $self->_RoleGroupsJoin( Type => $type );
1053     my $group_members = $self->_GroupMembersJoin( GroupsAlias => $groups );
1054     # XXX: work around, we must hide groups that
1055     # are members of the role group we search in,
1056     # otherwise them result in wrong NULLs in Users
1057     # table and break ordering. Now, we know that
1058     # RT doesn't allow to add groups as members of the
1059     # ticket roles, so we just hide entries in CGM table
1060     # with MemberId == GroupId from results
1061     $self->SUPER::Limit(
1062         LEFTJOIN   => $group_members,
1063         FIELD      => 'GroupId',
1064         OPERATOR   => '!=',
1065         VALUE      => "$group_members.MemberId",
1066         QUOTEVALUE => 0,
1067     );
1068     my $users = $self->Join(
1069         TYPE            => 'LEFT',
1070         ALIAS1          => $group_members,
1071         FIELD1          => 'MemberId',
1072         TABLE2          => 'Users',
1073         FIELD2          => 'id',
1074     );
1075     return ($groups, $group_members, $users);
1076 }
1077
1078 =head2 _WatcherMembershipLimit
1079
1080 Handle watcher membership limits, i.e. whether the watcher belongs to a
1081 specific group or not.
1082
1083 Meta Data:
1084   1: Field to query on
1085
1086 SELECT DISTINCT main.*
1087 FROM
1088     Tickets main,
1089     Groups Groups_1,
1090     CachedGroupMembers CachedGroupMembers_2,
1091     Users Users_3
1092 WHERE (
1093     (main.EffectiveId = main.id)
1094 ) AND (
1095     (main.Status != 'deleted')
1096 ) AND (
1097     (main.Type = 'ticket')
1098 ) AND (
1099     (
1100         (Users_3.EmailAddress = '22')
1101             AND
1102         (Groups_1.Domain = 'RT::Ticket-Role')
1103             AND
1104         (Groups_1.Type = 'RequestorGroup')
1105     )
1106 ) AND
1107     Groups_1.Instance = main.id
1108 AND
1109     Groups_1.id = CachedGroupMembers_2.GroupId
1110 AND
1111     CachedGroupMembers_2.MemberId = Users_3.id
1112 ORDER BY main.id ASC
1113 LIMIT 25
1114
1115 =cut
1116
1117 sub _WatcherMembershipLimit {
1118     my ( $self, $field, $op, $value, @rest ) = @_;
1119     my %rest = @rest;
1120
1121     $self->_OpenParen;
1122
1123     my $groups       = $self->NewAlias('Groups');
1124     my $groupmembers = $self->NewAlias('CachedGroupMembers');
1125     my $users        = $self->NewAlias('Users');
1126     my $memberships  = $self->NewAlias('CachedGroupMembers');
1127
1128     if ( ref $field ) {    # gross hack
1129         my @bundle = @$field;
1130         $self->_OpenParen;
1131         for my $chunk (@bundle) {
1132             ( $field, $op, $value, @rest ) = @$chunk;
1133             $self->_SQLLimit(
1134                 ALIAS    => $memberships,
1135                 FIELD    => 'GroupId',
1136                 VALUE    => $value,
1137                 OPERATOR => $op,
1138                 @rest,
1139             );
1140         }
1141         $self->_CloseParen;
1142     }
1143     else {
1144         $self->_SQLLimit(
1145             ALIAS    => $memberships,
1146             FIELD    => 'GroupId',
1147             VALUE    => $value,
1148             OPERATOR => $op,
1149             @rest,
1150         );
1151     }
1152
1153     # {{{ Tie to groups for tickets we care about
1154     $self->_SQLLimit(
1155         ALIAS           => $groups,
1156         FIELD           => 'Domain',
1157         VALUE           => 'RT::Ticket-Role',
1158         ENTRYAGGREGATOR => 'AND'
1159     );
1160
1161     $self->Join(
1162         ALIAS1 => $groups,
1163         FIELD1 => 'Instance',
1164         ALIAS2 => 'main',
1165         FIELD2 => 'id'
1166     );
1167
1168     # }}}
1169
1170     # If we care about which sort of watcher
1171     my $meta = $FIELD_METADATA{$field};
1172     my $type = ( defined $meta->[1] ? $meta->[1] : undef );
1173
1174     if ($type) {
1175         $self->_SQLLimit(
1176             ALIAS           => $groups,
1177             FIELD           => 'Type',
1178             VALUE           => $type,
1179             ENTRYAGGREGATOR => 'AND'
1180         );
1181     }
1182
1183     $self->Join(
1184         ALIAS1 => $groups,
1185         FIELD1 => 'id',
1186         ALIAS2 => $groupmembers,
1187         FIELD2 => 'GroupId'
1188     );
1189
1190     $self->Join(
1191         ALIAS1 => $groupmembers,
1192         FIELD1 => 'MemberId',
1193         ALIAS2 => $users,
1194         FIELD2 => 'id'
1195     );
1196
1197     $self->Join(
1198         ALIAS1 => $memberships,
1199         FIELD1 => 'MemberId',
1200         ALIAS2 => $users,
1201         FIELD2 => 'id'
1202     );
1203
1204     $self->_CloseParen;
1205
1206 }
1207
1208 sub _LinkFieldLimit {
1209     my $restriction;
1210     my $self;
1211     my $LinkAlias;
1212     my %args;
1213     if ( $restriction->{'TYPE'} ) {
1214         $self->SUPER::Limit(
1215             ALIAS           => $LinkAlias,
1216             ENTRYAGGREGATOR => 'AND',
1217             FIELD           => 'Type',
1218             OPERATOR        => '=',
1219             VALUE           => $restriction->{'TYPE'}
1220         );
1221     }
1222
1223     #If we're trying to limit it to things that are target of
1224     if ( $restriction->{'TARGET'} ) {
1225
1226         # If the TARGET is an integer that means that we want to look at
1227         # the LocalTarget field. otherwise, we want to look at the
1228         # "Target" field
1229         my ($matchfield);
1230         if ( $restriction->{'TARGET'} =~ /^(\d+)$/ ) {
1231             $matchfield = "LocalTarget";
1232         }
1233         else {
1234             $matchfield = "Target";
1235         }
1236         $self->SUPER::Limit(
1237             ALIAS           => $LinkAlias,
1238             ENTRYAGGREGATOR => 'AND',
1239             FIELD           => $matchfield,
1240             OPERATOR        => '=',
1241             VALUE           => $restriction->{'TARGET'}
1242         );
1243
1244         #If we're searching on target, join the base to ticket.id
1245         $self->_SQLJoin(
1246             ALIAS1 => 'main',
1247             FIELD1 => $self->{'primary_key'},
1248             ALIAS2 => $LinkAlias,
1249             FIELD2 => 'LocalBase'
1250         );
1251     }
1252
1253     #If we're trying to limit it to things that are base of
1254     elsif ( $restriction->{'BASE'} ) {
1255
1256         # If we're trying to match a numeric link, we want to look at
1257         # LocalBase, otherwise we want to look at "Base"
1258         my ($matchfield);
1259         if ( $restriction->{'BASE'} =~ /^(\d+)$/ ) {
1260             $matchfield = "LocalBase";
1261         }
1262         else {
1263             $matchfield = "Base";
1264         }
1265
1266         $self->SUPER::Limit(
1267             ALIAS           => $LinkAlias,
1268             ENTRYAGGREGATOR => 'AND',
1269             FIELD           => $matchfield,
1270             OPERATOR        => '=',
1271             VALUE           => $restriction->{'BASE'}
1272         );
1273
1274         #If we're searching on base, join the target to ticket.id
1275         $self->_SQLJoin(
1276             ALIAS1 => 'main',
1277             FIELD1 => $self->{'primary_key'},
1278             ALIAS2 => $LinkAlias,
1279             FIELD2 => 'LocalTarget'
1280         );
1281     }
1282 }
1283
1284
1285 =head2 _CustomFieldDecipher
1286
1287 Try and turn a CF descriptor into (cfid, cfname) object pair.
1288
1289 =cut
1290
1291 sub _CustomFieldDecipher {
1292     my ($self, $field) = @_;
1293  
1294     my $queue = 0;
1295     if ( $field =~ /^(.+?)\.{(.+)}$/ ) {
1296         ($queue, $field) = ($1, $2);
1297     }
1298     $field = $1 if $field =~ /^{(.+)}$/;    # trim { }
1299
1300     my $cfid;
1301     if ( $queue ) {
1302         my $q = RT::Queue->new( $self->CurrentUser );
1303         $q->Load( $queue ) if $queue;
1304
1305         my $cf;
1306         if ( $q->id ) {
1307             # $queue = $q->Name; # should we normalize the queue?
1308             $cf = $q->CustomField( $field );
1309         }
1310         else {
1311             $cf = RT::CustomField->new( $self->CurrentUser );
1312             $cf->LoadByNameAndQueue( Queue => 0, Name => $field );
1313         }
1314         $cfid = $cf->id if $cf;
1315     }
1316  
1317     return ($queue, $field, $cfid);
1318  
1319 }
1320  
1321 =head2 _CustomFieldJoin
1322
1323 Factor out the Join of custom fields so we can use it for sorting too
1324
1325 =cut
1326
1327 sub _CustomFieldJoin {
1328     my ($self, $cfkey, $cfid, $field) = @_;
1329     # Perform one Join per CustomField
1330     if ( $self->{_sql_object_cfv_alias}{$cfkey} ||
1331          $self->{_sql_cf_alias}{$cfkey} )
1332     {
1333         return ( $self->{_sql_object_cfv_alias}{$cfkey},
1334                  $self->{_sql_cf_alias}{$cfkey} );
1335     }
1336
1337     my ($TicketCFs, $CFs);
1338     if ( $cfid ) {
1339         $TicketCFs = $self->{_sql_object_cfv_alias}{$cfkey} = $self->Join(
1340             TYPE   => 'left',
1341             ALIAS1 => 'main',
1342             FIELD1 => 'id',
1343             TABLE2 => 'ObjectCustomFieldValues',
1344             FIELD2 => 'ObjectId',
1345         );
1346         $self->SUPER::Limit(
1347             LEFTJOIN        => $TicketCFs,
1348             FIELD           => 'CustomField',
1349             VALUE           => $cfid,
1350             ENTRYAGGREGATOR => 'AND'
1351         );
1352     }
1353     else {
1354         my $ocfalias = $self->Join(
1355             TYPE       => 'LEFT',
1356             FIELD1     => 'Queue',
1357             TABLE2     => 'ObjectCustomFields',
1358             FIELD2     => 'ObjectId',
1359         );
1360
1361         $self->SUPER::Limit(
1362             LEFTJOIN        => $ocfalias,
1363             ENTRYAGGREGATOR => 'OR',
1364             FIELD           => 'ObjectId',
1365             VALUE           => '0',
1366         );
1367
1368         $CFs = $self->{_sql_cf_alias}{$cfkey} = $self->Join(
1369             TYPE       => 'LEFT',
1370             ALIAS1     => $ocfalias,
1371             FIELD1     => 'CustomField',
1372             TABLE2     => 'CustomFields',
1373             FIELD2     => 'id',
1374         );
1375
1376         $TicketCFs = $self->{_sql_object_cfv_alias}{$cfkey} = $self->Join(
1377             TYPE   => 'left',
1378             ALIAS1 => $CFs,
1379             FIELD1 => 'id',
1380             TABLE2 => 'ObjectCustomFieldValues',
1381             FIELD2 => 'CustomField',
1382         );
1383         $self->SUPER::Limit(
1384             LEFTJOIN        => $TicketCFs,
1385             FIELD           => 'ObjectId',
1386             VALUE           => 'main.id',
1387             QUOTEVALUE      => 0,
1388             ENTRYAGGREGATOR => 'AND',
1389         );
1390     }
1391     $self->SUPER::Limit(
1392         LEFTJOIN        => $TicketCFs,
1393         FIELD           => 'ObjectType',
1394         VALUE           => 'RT::Ticket',
1395         ENTRYAGGREGATOR => 'AND'
1396     );
1397     $self->SUPER::Limit(
1398         LEFTJOIN        => $TicketCFs,
1399         FIELD           => 'Disabled',
1400         OPERATOR        => '=',
1401         VALUE           => '0',
1402         ENTRYAGGREGATOR => 'AND'
1403     );
1404
1405     return ($TicketCFs, $CFs);
1406 }
1407
1408 =head2 _CustomFieldLimit
1409
1410 Limit based on CustomFields
1411
1412 Meta Data:
1413   none
1414
1415 =cut
1416
1417 sub _CustomFieldLimit {
1418     my ( $self, $_field, $op, $value, @rest ) = @_;
1419
1420     my %rest  = @rest;
1421     my $field = $rest{SUBKEY} || die "No field specified";
1422
1423     # For our sanity, we can only limit on one queue at a time
1424
1425     my ($queue, $cfid);
1426     ($queue, $field, $cfid ) = $self->_CustomFieldDecipher( $field );
1427
1428 # If we're trying to find custom fields that don't match something, we
1429 # want tickets where the custom field has no value at all.  Note that
1430 # we explicitly don't include the "IS NULL" case, since we would
1431 # otherwise end up with a redundant clause.
1432
1433     my $null_columns_ok;
1434     if ( ( $op =~ /^NOT LIKE$/i ) or ( $op eq '!=' ) ) {
1435         $null_columns_ok = 1;
1436     }
1437
1438     my $cfkey = $cfid ? $cfid : "$queue.$field";
1439     my ($TicketCFs, $CFs) = $self->_CustomFieldJoin( $cfkey, $cfid, $field );
1440
1441     $self->_OpenParen;
1442
1443     if ( $CFs ) {
1444         $self->SUPER::Limit(
1445             ALIAS           => $CFs,
1446             FIELD           => 'Name',
1447             VALUE           => $field,
1448             ENTRYAGGREGATOR => 'AND',
1449         );
1450     }
1451
1452     $self->_OpenParen if $null_columns_ok;
1453
1454     $self->_SQLLimit(
1455         ALIAS      => $TicketCFs,
1456         FIELD      => 'Content',
1457         OPERATOR   => $op,
1458         VALUE      => $value,
1459         QUOTEVALUE => 1,
1460         @rest
1461     );
1462
1463     if ($null_columns_ok) {
1464         $self->_SQLLimit(
1465             ALIAS           => $TicketCFs,
1466             FIELD           => 'Content',
1467             OPERATOR        => 'IS',
1468             VALUE           => 'NULL',
1469             QUOTEVALUE      => 0,
1470             ENTRYAGGREGATOR => 'OR',
1471         );
1472         $self->_CloseParen;
1473     }
1474
1475     $self->_CloseParen;
1476
1477 }
1478
1479 # End Helper Functions
1480
1481 # End of SQL Stuff -------------------------------------------------
1482
1483 # {{{ Allow sorting on watchers
1484
1485 =head2 OrderByCols ARRAY
1486
1487 A modified version of the OrderBy method which automatically joins where
1488 C<ALIAS> is set to the name of a watcher type.
1489
1490 =cut
1491
1492 sub OrderByCols {
1493     my $self = shift;
1494     my @args = @_;
1495     my $clause;
1496     my @res   = ();
1497     my $order = 0;
1498
1499     foreach my $row (@args) {
1500         if ( $row->{ALIAS} || $row->{FIELD} !~ /\./ ) {
1501             push @res, $row;
1502             next;
1503         }
1504         my ( $field, $subkey ) = split /\./, $row->{FIELD}, 2;
1505         my $meta = $self->FIELDS->{$field};
1506         if ( $meta->[0] eq 'WATCHERFIELD' ) {
1507             # cache alias as we want to use one alias per watcher type for sorting
1508             my $users = $self->{_sql_u_watchers_alias_for_sort}{ $meta->[1] };
1509             unless ( $users ) {
1510                 $self->{_sql_u_watchers_alias_for_sort}{ $meta->[1] }
1511                     = $users = ( $self->_WatcherJoin( $meta->[1] ) )[2];
1512             }
1513             push @res, { %$row, ALIAS => $users, FIELD => $subkey };
1514        } elsif ( $meta->[0] eq 'CUSTOMFIELD' ) {
1515            my ($queue, $field, $cfid ) = $self->_CustomFieldDecipher( $subkey );
1516            my $cfkey = $cfid ? $cfid : "$queue.$field";
1517            my ($TicketCFs, $CFs) = $self->_CustomFieldJoin( $cfkey, $cfid, $field );
1518            unless ($cfid) {
1519                # For those cases where we are doing a join against the
1520                # CF name, and don't have a CFid, use Unique to make sure
1521                # we don't show duplicate tickets.  NOTE: I'm pretty sure
1522                # this will stay mixed in for the life of the
1523                # class/package, and not just for the life of the object.
1524                # Potential performance issue.
1525                require DBIx::SearchBuilder::Unique;
1526                DBIx::SearchBuilder::Unique->import;
1527            }
1528            my $CFvs = $self->Join(
1529                TYPE   => 'left',
1530                ALIAS1 => $TicketCFs,
1531                FIELD1 => 'CustomField',
1532                TABLE2 => 'CustomFieldValues',
1533                FIELD2 => 'CustomField',
1534            );
1535            $self->SUPER::Limit(
1536                LEFTJOIN => $CFvs,
1537                FIELD => 'Name',
1538                QUOTEVALUE => 0,
1539                VALUE => $TicketCFs . ".Content",
1540                ENTRYAGGREGATOR => 'AND'
1541            );
1542
1543            push @res, { %$row, ALIAS => $CFvs, FIELD => 'SortOrder' };
1544            push @res, { %$row, ALIAS => $TicketCFs, FIELD => 'Content' };
1545        } elsif ( $field eq "Custom" && $subkey eq "Ownership") {
1546            # PAW logic is "reversed"
1547            my $order = "ASC";
1548            if (exists $row->{ORDER} ) {
1549                my $o = $row->{ORDER};
1550                delete $row->{ORDER};
1551                $order = "DESC" if $o =~ /asc/i;
1552            }
1553
1554            # Unowned
1555            # Else
1556
1557            # Ticket.Owner  1 0 0
1558            my $ownerId = $self->CurrentUser->Id;
1559            push @res, { %$row, FIELD => "Owner=$ownerId", ORDER => $order } ;
1560
1561            # Unowned Tickets 0 1 0
1562            my $nobodyId = $RT::Nobody->Id;
1563            push @res, { %$row, FIELD => "Owner=$nobodyId", ORDER => $order } ;
1564
1565            push @res, { %$row, FIELD => "Priority", ORDER => $order } ;
1566        }
1567        else {
1568            push @res, $row;
1569        }
1570     }
1571     return $self->SUPER::OrderByCols(@res);
1572 }
1573
1574 # }}}
1575
1576 # {{{ Limit the result set based on content
1577
1578 # {{{ sub Limit
1579
1580 =head2 Limit
1581
1582 Takes a paramhash with the fields FIELD, OPERATOR, VALUE and DESCRIPTION
1583 Generally best called from LimitFoo methods
1584
1585 =cut
1586
1587 sub Limit {
1588     my $self = shift;
1589     my %args = (
1590         FIELD       => undef,
1591         OPERATOR    => '=',
1592         VALUE       => undef,
1593         DESCRIPTION => undef,
1594         @_
1595     );
1596     $args{'DESCRIPTION'} = $self->loc(
1597         "[_1] [_2] [_3]",  $args{'FIELD'},
1598         $args{'OPERATOR'}, $args{'VALUE'}
1599         )
1600         if ( !defined $args{'DESCRIPTION'} );
1601
1602     my $index = $self->_NextIndex;
1603
1604 # make the TicketRestrictions hash the equivalent of whatever we just passed in;
1605
1606     %{ $self->{'TicketRestrictions'}{$index} } = %args;
1607
1608     $self->{'RecalcTicketLimits'} = 1;
1609
1610 # If we're looking at the effective id, we don't want to append the other clause
1611 # which limits us to tickets where id = effective id
1612     if ( $args{'FIELD'} eq 'EffectiveId'
1613         && ( !$args{'ALIAS'} || $args{'ALIAS'} eq 'main' ) )
1614     {
1615         $self->{'looking_at_effective_id'} = 1;
1616     }
1617
1618     if ( $args{'FIELD'} eq 'Type'
1619         && ( !$args{'ALIAS'} || $args{'ALIAS'} eq 'main' ) )
1620     {
1621         $self->{'looking_at_type'} = 1;
1622     }
1623
1624     return ($index);
1625 }
1626
1627 # }}}
1628
1629 =head2 FreezeLimits
1630
1631 Returns a frozen string suitable for handing back to ThawLimits.
1632
1633 =cut
1634
1635 sub _FreezeThawKeys {
1636     'TicketRestrictions', 'restriction_index', 'looking_at_effective_id',
1637         'looking_at_type';
1638 }
1639
1640 # {{{ sub FreezeLimits
1641
1642 sub FreezeLimits {
1643     my $self = shift;
1644     require Storable;
1645     require MIME::Base64;
1646     MIME::Base64::base64_encode(
1647         Storable::freeze( \@{$self}{ $self->_FreezeThawKeys } ) );
1648 }
1649
1650 # }}}
1651
1652 =head2 ThawLimits
1653
1654 Take a frozen Limits string generated by FreezeLimits and make this tickets
1655 object have that set of limits.
1656
1657 =cut
1658
1659 # {{{ sub ThawLimits
1660
1661 sub ThawLimits {
1662     my $self = shift;
1663     my $in   = shift;
1664
1665     #if we don't have $in, get outta here.
1666     return undef unless ($in);
1667
1668     $self->{'RecalcTicketLimits'} = 1;
1669
1670     require Storable;
1671     require MIME::Base64;
1672
1673     #We don't need to die if the thaw fails.
1674     @{$self}{ $self->_FreezeThawKeys }
1675         = eval { @{ Storable::thaw( MIME::Base64::base64_decode($in) ) }; };
1676
1677     $RT::Logger->error($@) if $@;
1678
1679 }
1680
1681 # }}}
1682
1683 # {{{ Limit by enum or foreign key
1684
1685 # {{{ sub LimitQueue
1686
1687 =head2 LimitQueue
1688
1689 LimitQueue takes a paramhash with the fields OPERATOR and VALUE.
1690 OPERATOR is one of = or !=. (It defaults to =).
1691 VALUE is a queue id or Name.
1692
1693
1694 =cut
1695
1696 sub LimitQueue {
1697     my $self = shift;
1698     my %args = (
1699         VALUE    => undef,
1700         OPERATOR => '=',
1701         @_
1702     );
1703
1704     #TODO  VALUE should also take queue objects
1705     if ( defined $args{'VALUE'} && $args{'VALUE'} !~ /^\d+$/ ) {
1706         my $queue = new RT::Queue( $self->CurrentUser );
1707         $queue->Load( $args{'VALUE'} );
1708         $args{'VALUE'} = $queue->Id;
1709     }
1710
1711     # What if they pass in an Id?  Check for isNum() and convert to
1712     # string.
1713
1714     #TODO check for a valid queue here
1715
1716     $self->Limit(
1717         FIELD       => 'Queue',
1718         VALUE       => $args{'VALUE'},
1719         OPERATOR    => $args{'OPERATOR'},
1720         DESCRIPTION => join(
1721             ' ', $self->loc('Queue'), $args{'OPERATOR'}, $args{'VALUE'},
1722         ),
1723     );
1724
1725 }
1726
1727 # }}}
1728
1729 # {{{ sub LimitStatus
1730
1731 =head2 LimitStatus
1732
1733 Takes a paramhash with the fields OPERATOR and VALUE.
1734 OPERATOR is one of = or !=.
1735 VALUE is a status.
1736
1737 RT adds Status != 'deleted' until object has
1738 allow_deleted_search internal property set.
1739 $tickets->{'allow_deleted_search'} = 1;
1740 $tickets->LimitStatus( VALUE => 'deleted' );
1741
1742 =cut
1743
1744 sub LimitStatus {
1745     my $self = shift;
1746     my %args = (
1747         OPERATOR => '=',
1748         @_
1749     );
1750     $self->Limit(
1751         FIELD       => 'Status',
1752         VALUE       => $args{'VALUE'},
1753         OPERATOR    => $args{'OPERATOR'},
1754         DESCRIPTION => join( ' ',
1755             $self->loc('Status'), $args{'OPERATOR'},
1756             $self->loc( $args{'VALUE'} ) ),
1757     );
1758 }
1759
1760 # }}}
1761
1762 # {{{ sub IgnoreType
1763
1764 =head2 IgnoreType
1765
1766 If called, this search will not automatically limit the set of results found
1767 to tickets of type "Ticket". Tickets of other types, such as "project" and
1768 "approval" will be found.
1769
1770 =cut
1771
1772 sub IgnoreType {
1773     my $self = shift;
1774
1775     # Instead of faking a Limit that later gets ignored, fake up the
1776     # fact that we're already looking at type, so that the check in
1777     # Tickets_Overlay_SQL/FromSQL goes down the right branch
1778
1779     #  $self->LimitType(VALUE => '__any');
1780     $self->{looking_at_type} = 1;
1781 }
1782
1783 # }}}
1784
1785 # {{{ sub LimitType
1786
1787 =head2 LimitType
1788
1789 Takes a paramhash with the fields OPERATOR and VALUE.
1790 OPERATOR is one of = or !=, it defaults to "=".
1791 VALUE is a string to search for in the type of the ticket.
1792
1793
1794
1795 =cut
1796
1797 sub LimitType {
1798     my $self = shift;
1799     my %args = (
1800         OPERATOR => '=',
1801         VALUE    => undef,
1802         @_
1803     );
1804     $self->Limit(
1805         FIELD       => 'Type',
1806         VALUE       => $args{'VALUE'},
1807         OPERATOR    => $args{'OPERATOR'},
1808         DESCRIPTION => join( ' ',
1809             $self->loc('Type'), $args{'OPERATOR'}, $args{'Limit'}, ),
1810     );
1811 }
1812
1813 # }}}
1814
1815 # }}}
1816
1817 # {{{ Limit by string field
1818
1819 # {{{ sub LimitSubject
1820
1821 =head2 LimitSubject
1822
1823 Takes a paramhash with the fields OPERATOR and VALUE.
1824 OPERATOR is one of = or !=.
1825 VALUE is a string to search for in the subject of the ticket.
1826
1827 =cut
1828
1829 sub LimitSubject {
1830     my $self = shift;
1831     my %args = (@_);
1832     $self->Limit(
1833         FIELD       => 'Subject',
1834         VALUE       => $args{'VALUE'},
1835         OPERATOR    => $args{'OPERATOR'},
1836         DESCRIPTION => join( ' ',
1837             $self->loc('Subject'), $args{'OPERATOR'}, $args{'VALUE'}, ),
1838     );
1839 }
1840
1841 # }}}
1842
1843 # }}}
1844
1845 # {{{ Limit based on ticket numerical attributes
1846 # Things that can be > < = !=
1847
1848 # {{{ sub LimitId
1849
1850 =head2 LimitId
1851
1852 Takes a paramhash with the fields OPERATOR and VALUE.
1853 OPERATOR is one of =, >, < or !=.
1854 VALUE is a ticket Id to search for
1855
1856 =cut
1857
1858 sub LimitId {
1859     my $self = shift;
1860     my %args = (
1861         OPERATOR => '=',
1862         @_
1863     );
1864
1865     $self->Limit(
1866         FIELD       => 'id',
1867         VALUE       => $args{'VALUE'},
1868         OPERATOR    => $args{'OPERATOR'},
1869         DESCRIPTION =>
1870             join( ' ', $self->loc('Id'), $args{'OPERATOR'}, $args{'VALUE'}, ),
1871     );
1872 }
1873
1874 # }}}
1875
1876 # {{{ sub LimitPriority
1877
1878 =head2 LimitPriority
1879
1880 Takes a paramhash with the fields OPERATOR and VALUE.
1881 OPERATOR is one of =, >, < or !=.
1882 VALUE is a value to match the ticket\'s priority against
1883
1884 =cut
1885
1886 sub LimitPriority {
1887     my $self = shift;
1888     my %args = (@_);
1889     $self->Limit(
1890         FIELD       => 'Priority',
1891         VALUE       => $args{'VALUE'},
1892         OPERATOR    => $args{'OPERATOR'},
1893         DESCRIPTION => join( ' ',
1894             $self->loc('Priority'),
1895             $args{'OPERATOR'}, $args{'VALUE'}, ),
1896     );
1897 }
1898
1899 # }}}
1900
1901 # {{{ sub LimitInitialPriority
1902
1903 =head2 LimitInitialPriority
1904
1905 Takes a paramhash with the fields OPERATOR and VALUE.
1906 OPERATOR is one of =, >, < or !=.
1907 VALUE is a value to match the ticket\'s initial priority against
1908
1909
1910 =cut
1911
1912 sub LimitInitialPriority {
1913     my $self = shift;
1914     my %args = (@_);
1915     $self->Limit(
1916         FIELD       => 'InitialPriority',
1917         VALUE       => $args{'VALUE'},
1918         OPERATOR    => $args{'OPERATOR'},
1919         DESCRIPTION => join( ' ',
1920             $self->loc('Initial Priority'), $args{'OPERATOR'},
1921             $args{'VALUE'}, ),
1922     );
1923 }
1924
1925 # }}}
1926
1927 # {{{ sub LimitFinalPriority
1928
1929 =head2 LimitFinalPriority
1930
1931 Takes a paramhash with the fields OPERATOR and VALUE.
1932 OPERATOR is one of =, >, < or !=.
1933 VALUE is a value to match the ticket\'s final priority against
1934
1935 =cut
1936
1937 sub LimitFinalPriority {
1938     my $self = shift;
1939     my %args = (@_);
1940     $self->Limit(
1941         FIELD       => 'FinalPriority',
1942         VALUE       => $args{'VALUE'},
1943         OPERATOR    => $args{'OPERATOR'},
1944         DESCRIPTION => join( ' ',
1945             $self->loc('Final Priority'), $args{'OPERATOR'},
1946             $args{'VALUE'}, ),
1947     );
1948 }
1949
1950 # }}}
1951
1952 # {{{ sub LimitTimeWorked
1953
1954 =head2 LimitTimeWorked
1955
1956 Takes a paramhash with the fields OPERATOR and VALUE.
1957 OPERATOR is one of =, >, < or !=.
1958 VALUE is a value to match the ticket's TimeWorked attribute
1959
1960 =cut
1961
1962 sub LimitTimeWorked {
1963     my $self = shift;
1964     my %args = (@_);
1965     $self->Limit(
1966         FIELD       => 'TimeWorked',
1967         VALUE       => $args{'VALUE'},
1968         OPERATOR    => $args{'OPERATOR'},
1969         DESCRIPTION => join( ' ',
1970             $self->loc('Time worked'),
1971             $args{'OPERATOR'}, $args{'VALUE'}, ),
1972     );
1973 }
1974
1975 # }}}
1976
1977 # {{{ sub LimitTimeLeft
1978
1979 =head2 LimitTimeLeft
1980
1981 Takes a paramhash with the fields OPERATOR and VALUE.
1982 OPERATOR is one of =, >, < or !=.
1983 VALUE is a value to match the ticket's TimeLeft attribute
1984
1985 =cut
1986
1987 sub LimitTimeLeft {
1988     my $self = shift;
1989     my %args = (@_);
1990     $self->Limit(
1991         FIELD       => 'TimeLeft',
1992         VALUE       => $args{'VALUE'},
1993         OPERATOR    => $args{'OPERATOR'},
1994         DESCRIPTION => join( ' ',
1995             $self->loc('Time left'),
1996             $args{'OPERATOR'}, $args{'VALUE'}, ),
1997     );
1998 }
1999
2000 # }}}
2001
2002 # }}}
2003
2004 # {{{ Limiting based on attachment attributes
2005
2006 # {{{ sub LimitContent
2007
2008 =head2 LimitContent
2009
2010 Takes a paramhash with the fields OPERATOR and VALUE.
2011 OPERATOR is one of =, LIKE, NOT LIKE or !=.
2012 VALUE is a string to search for in the body of the ticket
2013
2014 =cut
2015
2016 sub LimitContent {
2017     my $self = shift;
2018     my %args = (@_);
2019     $self->Limit(
2020         FIELD       => 'Content',
2021         VALUE       => $args{'VALUE'},
2022         OPERATOR    => $args{'OPERATOR'},
2023         DESCRIPTION => join( ' ',
2024             $self->loc('Ticket content'), $args{'OPERATOR'},
2025             $args{'VALUE'}, ),
2026     );
2027 }
2028
2029 # }}}
2030
2031 # {{{ sub LimitFilename
2032
2033 =head2 LimitFilename
2034
2035 Takes a paramhash with the fields OPERATOR and VALUE.
2036 OPERATOR is one of =, LIKE, NOT LIKE or !=.
2037 VALUE is a string to search for in the body of the ticket
2038
2039 =cut
2040
2041 sub LimitFilename {
2042     my $self = shift;
2043     my %args = (@_);
2044     $self->Limit(
2045         FIELD       => 'Filename',
2046         VALUE       => $args{'VALUE'},
2047         OPERATOR    => $args{'OPERATOR'},
2048         DESCRIPTION => join( ' ',
2049             $self->loc('Attachment filename'), $args{'OPERATOR'},
2050             $args{'VALUE'}, ),
2051     );
2052 }
2053
2054 # }}}
2055 # {{{ sub LimitContentType
2056
2057 =head2 LimitContentType
2058
2059 Takes a paramhash with the fields OPERATOR and VALUE.
2060 OPERATOR is one of =, LIKE, NOT LIKE or !=.
2061 VALUE is a content type to search ticket attachments for
2062
2063 =cut
2064
2065 sub LimitContentType {
2066     my $self = shift;
2067     my %args = (@_);
2068     $self->Limit(
2069         FIELD       => 'ContentType',
2070         VALUE       => $args{'VALUE'},
2071         OPERATOR    => $args{'OPERATOR'},
2072         DESCRIPTION => join( ' ',
2073             $self->loc('Ticket content type'), $args{'OPERATOR'},
2074             $args{'VALUE'}, ),
2075     );
2076 }
2077
2078 # }}}
2079
2080 # }}}
2081
2082 # {{{ Limiting based on people
2083
2084 # {{{ sub LimitOwner
2085
2086 =head2 LimitOwner
2087
2088 Takes a paramhash with the fields OPERATOR and VALUE.
2089 OPERATOR is one of = or !=.
2090 VALUE is a user id.
2091
2092 =cut
2093
2094 sub LimitOwner {
2095     my $self = shift;
2096     my %args = (
2097         OPERATOR => '=',
2098         @_
2099     );
2100
2101     my $owner = new RT::User( $self->CurrentUser );
2102     $owner->Load( $args{'VALUE'} );
2103
2104     # FIXME: check for a valid $owner
2105     $self->Limit(
2106         FIELD       => 'Owner',
2107         VALUE       => $args{'VALUE'},
2108         OPERATOR    => $args{'OPERATOR'},
2109         DESCRIPTION => join( ' ',
2110             $self->loc('Owner'), $args{'OPERATOR'}, $owner->Name(), ),
2111     );
2112
2113 }
2114
2115 # }}}
2116
2117 # {{{ Limiting watchers
2118
2119 # {{{ sub LimitWatcher
2120
2121 =head2 LimitWatcher
2122
2123   Takes a paramhash with the fields OPERATOR, TYPE and VALUE.
2124   OPERATOR is one of =, LIKE, NOT LIKE or !=.
2125   VALUE is a value to match the ticket\'s watcher email addresses against
2126   TYPE is the sort of watchers you want to match against. Leave it undef if you want to search all of them
2127
2128 =begin testing
2129
2130 my $t1 = RT::Ticket->new($RT::SystemUser);
2131 $t1->Create(Queue => 'general', Subject => "LimitWatchers test", Requestors => \['requestor1@example.com']);
2132
2133 =end testing
2134
2135 =cut
2136
2137 sub LimitWatcher {
2138     my $self = shift;
2139     my %args = (
2140         OPERATOR => '=',
2141         VALUE    => undef,
2142         TYPE     => undef,
2143         @_
2144     );
2145
2146     #build us up a description
2147     my ( $watcher_type, $desc );
2148     if ( $args{'TYPE'} ) {
2149         $watcher_type = $args{'TYPE'};
2150     }
2151     else {
2152         $watcher_type = "Watcher";
2153     }
2154
2155     $self->Limit(
2156         FIELD       => $watcher_type,
2157         VALUE       => $args{'VALUE'},
2158         OPERATOR    => $args{'OPERATOR'},
2159         TYPE        => $args{'TYPE'},
2160         DESCRIPTION => join( ' ',
2161             $self->loc($watcher_type),
2162             $args{'OPERATOR'}, $args{'VALUE'}, ),
2163     );
2164 }
2165
2166 sub LimitRequestor {
2167     my $self = shift;
2168     my %args = (@_);
2169     $RT::Logger->error( "Tickets->LimitRequestor is deprecated  at ("
2170             . join( ":", caller )
2171             . ")" );
2172     $self->LimitWatcher( TYPE => 'Requestor', @_ );
2173
2174 }
2175
2176 # }}}
2177
2178 # }}}
2179
2180 # }}}
2181
2182 # {{{ Limiting based on links
2183
2184 # {{{ LimitLinkedTo
2185
2186 =head2 LimitLinkedTo
2187
2188 LimitLinkedTo takes a paramhash with two fields: TYPE and TARGET
2189 TYPE limits the sort of link we want to search on
2190
2191 TYPE = { RefersTo, MemberOf, DependsOn }
2192
2193 TARGET is the id or URI of the TARGET of the link
2194 (TARGET used to be 'TICKET'.  'TICKET' is deprecated, but will be treated as TARGET
2195
2196 =cut
2197
2198 sub LimitLinkedTo {
2199     my $self = shift;
2200     my %args = (
2201         TICKET   => undef,
2202         TARGET   => undef,
2203         TYPE     => undef,
2204         OPERATOR => '=',
2205         @_
2206     );
2207
2208     $self->Limit(
2209         FIELD       => 'LinkedTo',
2210         BASE        => undef,
2211         TARGET      => ( $args{'TARGET'} || $args{'TICKET'} ),
2212         TYPE        => $args{'TYPE'},
2213         DESCRIPTION => $self->loc(
2214             "Tickets [_1] by [_2]",
2215             $self->loc( $args{'TYPE'} ),
2216             ( $args{'TARGET'} || $args{'TICKET'} )
2217         ),
2218         OPERATOR    => $args{'OPERATOR'},
2219     );
2220 }
2221
2222 # }}}
2223
2224 # {{{ LimitLinkedFrom
2225
2226 =head2 LimitLinkedFrom
2227
2228 LimitLinkedFrom takes a paramhash with two fields: TYPE and BASE
2229 TYPE limits the sort of link we want to search on
2230
2231
2232 BASE is the id or URI of the BASE of the link
2233 (BASE used to be 'TICKET'.  'TICKET' is deprecated, but will be treated as BASE
2234
2235
2236 =cut
2237
2238 sub LimitLinkedFrom {
2239     my $self = shift;
2240     my %args = (
2241         BASE     => undef,
2242         TICKET   => undef,
2243         TYPE     => undef,
2244         OPERATOR => '=',
2245         @_
2246     );
2247
2248     # translate RT2 From/To naming to RT3 TicketSQL naming
2249     my %fromToMap = qw(DependsOn DependentOn
2250         MemberOf  HasMember
2251         RefersTo  ReferredToBy);
2252
2253     my $type = $args{'TYPE'};
2254     $type = $fromToMap{$type} if exists( $fromToMap{$type} );
2255
2256     $self->Limit(
2257         FIELD       => 'LinkedTo',
2258         TARGET      => undef,
2259         BASE        => ( $args{'BASE'} || $args{'TICKET'} ),
2260         TYPE        => $type,
2261         DESCRIPTION => $self->loc(
2262             "Tickets [_1] [_2]",
2263             $self->loc( $args{'TYPE'} ),
2264             ( $args{'BASE'} || $args{'TICKET'} )
2265         ),
2266         OPERATOR    => $args{'OPERATOR'},
2267     );
2268 }
2269
2270 # }}}
2271
2272 # {{{ LimitMemberOf
2273 sub LimitMemberOf {
2274     my $self      = shift;
2275     my $ticket_id = shift;
2276     return $self->LimitLinkedTo(
2277         @_,
2278         TARGET => $ticket_id,
2279         TYPE   => 'MemberOf',
2280     );
2281 }
2282
2283 # }}}
2284
2285 # {{{ LimitHasMember
2286 sub LimitHasMember {
2287     my $self      = shift;
2288     my $ticket_id = shift;
2289     return $self->LimitLinkedFrom(
2290         @_,
2291         BASE => "$ticket_id",
2292         TYPE => 'HasMember',
2293     );
2294
2295 }
2296
2297 # }}}
2298
2299 # {{{ LimitDependsOn
2300
2301 sub LimitDependsOn {
2302     my $self      = shift;
2303     my $ticket_id = shift;
2304     return $self->LimitLinkedTo(
2305         @_,
2306         TARGET => $ticket_id,
2307         TYPE   => 'DependsOn',
2308     );
2309
2310 }
2311
2312 # }}}
2313
2314 # {{{ LimitDependedOnBy
2315
2316 sub LimitDependedOnBy {
2317     my $self      = shift;
2318     my $ticket_id = shift;
2319     return $self->LimitLinkedFrom(
2320         @_,
2321         BASE => $ticket_id,
2322         TYPE => 'DependentOn',
2323     );
2324
2325 }
2326
2327 # }}}
2328
2329 # {{{ LimitRefersTo
2330
2331 sub LimitRefersTo {
2332     my $self      = shift;
2333     my $ticket_id = shift;
2334     return $self->LimitLinkedTo(
2335         @_,
2336         TARGET => $ticket_id,
2337         TYPE   => 'RefersTo',
2338     );
2339
2340 }
2341
2342 # }}}
2343
2344 # {{{ LimitReferredToBy
2345
2346 sub LimitReferredToBy {
2347     my $self      = shift;
2348     my $ticket_id = shift;
2349     return $self->LimitLinkedFrom(
2350         @_,
2351         BASE => $ticket_id,
2352         TYPE => 'ReferredToBy',
2353     );
2354 }
2355
2356 # }}}
2357
2358 # }}}
2359
2360 # {{{ limit based on ticket date attribtes
2361
2362 # {{{ sub LimitDate
2363
2364 =head2 LimitDate (FIELD => 'DateField', OPERATOR => $oper, VALUE => $ISODate)
2365
2366 Takes a paramhash with the fields FIELD OPERATOR and VALUE.
2367
2368 OPERATOR is one of > or <
2369 VALUE is a date and time in ISO format in GMT
2370 FIELD is one of Starts, Started, Told, Created, Resolved, LastUpdated
2371
2372 There are also helper functions of the form LimitFIELD that eliminate
2373 the need to pass in a FIELD argument.
2374
2375 =cut
2376
2377 sub LimitDate {
2378     my $self = shift;
2379     my %args = (
2380         FIELD    => undef,
2381         VALUE    => undef,
2382         OPERATOR => undef,
2383
2384         @_
2385     );
2386
2387     #Set the description if we didn't get handed it above
2388     unless ( $args{'DESCRIPTION'} ) {
2389         $args{'DESCRIPTION'} = $args{'FIELD'} . " "
2390             . $args{'OPERATOR'} . " "
2391             . $args{'VALUE'} . " GMT";
2392     }
2393
2394     $self->Limit(%args);
2395
2396 }
2397
2398 # }}}
2399
2400 sub LimitCreated {
2401     my $self = shift;
2402     $self->LimitDate( FIELD => 'Created', @_ );
2403 }
2404
2405 sub LimitDue {
2406     my $self = shift;
2407     $self->LimitDate( FIELD => 'Due', @_ );
2408
2409 }
2410
2411 sub LimitStarts {
2412     my $self = shift;
2413     $self->LimitDate( FIELD => 'Starts', @_ );
2414
2415 }
2416
2417 sub LimitStarted {
2418     my $self = shift;
2419     $self->LimitDate( FIELD => 'Started', @_ );
2420 }
2421
2422 sub LimitResolved {
2423     my $self = shift;
2424     $self->LimitDate( FIELD => 'Resolved', @_ );
2425 }
2426
2427 sub LimitTold {
2428     my $self = shift;
2429     $self->LimitDate( FIELD => 'Told', @_ );
2430 }
2431
2432 sub LimitLastUpdated {
2433     my $self = shift;
2434     $self->LimitDate( FIELD => 'LastUpdated', @_ );
2435 }
2436
2437 #
2438 # {{{ sub LimitTransactionDate
2439
2440 =head2 LimitTransactionDate (OPERATOR => $oper, VALUE => $ISODate)
2441
2442 Takes a paramhash with the fields FIELD OPERATOR and VALUE.
2443
2444 OPERATOR is one of > or <
2445 VALUE is a date and time in ISO format in GMT
2446
2447
2448 =cut
2449
2450 sub LimitTransactionDate {
2451     my $self = shift;
2452     my %args = (
2453         FIELD    => 'TransactionDate',
2454         VALUE    => undef,
2455         OPERATOR => undef,
2456
2457         @_
2458     );
2459
2460     #  <20021217042756.GK28744@pallas.fsck.com>
2461     #    "Kill It" - Jesse.
2462
2463     #Set the description if we didn't get handed it above
2464     unless ( $args{'DESCRIPTION'} ) {
2465         $args{'DESCRIPTION'} = $args{'FIELD'} . " "
2466             . $args{'OPERATOR'} . " "
2467             . $args{'VALUE'} . " GMT";
2468     }
2469
2470     $self->Limit(%args);
2471
2472 }
2473
2474 # }}}
2475
2476 # }}}
2477
2478 # {{{ Limit based on custom fields
2479 # {{{ sub LimitCustomField
2480
2481 =head2 LimitCustomField
2482
2483 Takes a paramhash of key/value pairs with the following keys:
2484
2485 =over 4
2486
2487 =item CUSTOMFIELD - CustomField name or id.  If a name is passed, an additional parameter QUEUE may also be passed to distinguish the custom field.
2488
2489 =item OPERATOR - The usual Limit operators
2490
2491 =item VALUE - The value to compare against
2492
2493 =back
2494
2495 =cut
2496
2497 sub LimitCustomField {
2498     my $self = shift;
2499     my %args = (
2500         VALUE       => undef,
2501         CUSTOMFIELD => undef,
2502         OPERATOR    => '=',
2503         DESCRIPTION => undef,
2504         FIELD       => 'CustomFieldValue',
2505         QUOTEVALUE  => 1,
2506         @_
2507     );
2508
2509     my $CF = RT::CustomField->new( $self->CurrentUser );
2510     if ( $args{CUSTOMFIELD} =~ /^\d+$/ ) {
2511         $CF->Load( $args{CUSTOMFIELD} );
2512     }
2513     else {
2514         $CF->LoadByNameAndQueue(
2515             Name  => $args{CUSTOMFIELD},
2516             Queue => $args{QUEUE}
2517         );
2518         $args{CUSTOMFIELD} = $CF->Id;
2519     }
2520
2521     #If we are looking to compare with a null value.
2522     if ( $args{'OPERATOR'} =~ /^is$/i ) {
2523         $args{'DESCRIPTION'}
2524             ||= $self->loc( "Custom field [_1] has no value.", $CF->Name );
2525     }
2526     elsif ( $args{'OPERATOR'} =~ /^is not$/i ) {
2527         $args{'DESCRIPTION'}
2528             ||= $self->loc( "Custom field [_1] has a value.", $CF->Name );
2529     }
2530
2531     # if we're not looking to compare with a null value
2532     else {
2533         $args{'DESCRIPTION'} ||= $self->loc( "Custom field [_1] [_2] [_3]",
2534             $CF->Name, $args{OPERATOR}, $args{VALUE} );
2535     }
2536
2537     my $q = "";
2538     if ( $CF->Queue ) {
2539         my $qo = new RT::Queue( $self->CurrentUser );
2540         $qo->Load( $CF->Queue );
2541         $q = $qo->Name;
2542     }
2543
2544     my @rest;
2545     @rest = ( ENTRYAGGREGATOR => 'AND' )
2546         if ( $CF->Type eq 'SelectMultiple' );
2547
2548     $self->Limit(
2549         VALUE => $args{VALUE},
2550         FIELD => "CF."
2551             . (
2552               $q
2553             ? $q . ".{" . $CF->Name . "}"
2554             : $CF->Name
2555             ),
2556         OPERATOR    => $args{OPERATOR},
2557         CUSTOMFIELD => 1,
2558         @rest,
2559     );
2560
2561     $self->{'RecalcTicketLimits'} = 1;
2562 }
2563
2564 # }}}
2565 # }}}
2566
2567 # {{{ sub _NextIndex
2568
2569 =head2 _NextIndex
2570
2571 Keep track of the counter for the array of restrictions
2572
2573 =cut
2574
2575 sub _NextIndex {
2576     my $self = shift;
2577     return ( $self->{'restriction_index'}++ );
2578 }
2579
2580 # }}}
2581
2582 # }}}
2583
2584 # {{{ Core bits to make this a DBIx::SearchBuilder object
2585
2586 # {{{ sub _Init
2587 sub _Init {
2588     my $self = shift;
2589     $self->{'table'}                   = "Tickets";
2590     $self->{'RecalcTicketLimits'}      = 1;
2591     $self->{'looking_at_effective_id'} = 0;
2592     $self->{'looking_at_type'}         = 0;
2593     $self->{'restriction_index'}       = 1;
2594     $self->{'primary_key'}             = "id";
2595     delete $self->{'items_array'};
2596     delete $self->{'item_map'};
2597     delete $self->{'columns_to_display'};
2598     $self->SUPER::_Init(@_);
2599
2600     $self->_InitSQL;
2601
2602 }
2603
2604 # }}}
2605
2606 # {{{ sub Count
2607 sub Count {
2608     my $self = shift;
2609     $self->_ProcessRestrictions() if ( $self->{'RecalcTicketLimits'} == 1 );
2610     return ( $self->SUPER::Count() );
2611 }
2612
2613 # }}}
2614
2615 # {{{ sub CountAll
2616 sub CountAll {
2617     my $self = shift;
2618     $self->_ProcessRestrictions() if ( $self->{'RecalcTicketLimits'} == 1 );
2619     return ( $self->SUPER::CountAll() );
2620 }
2621
2622 # }}}
2623
2624 # {{{ sub ItemsArrayRef
2625
2626 =head2 ItemsArrayRef
2627
2628 Returns a reference to the set of all items found in this search
2629
2630 =cut
2631
2632 sub ItemsArrayRef {
2633     my $self = shift;
2634     my @items;
2635
2636     unless ( $self->{'items_array'} ) {
2637
2638         my $placeholder = $self->_ItemsCounter;
2639         $self->GotoFirstItem();
2640         while ( my $item = $self->Next ) {
2641             push( @{ $self->{'items_array'} }, $item );
2642         }
2643         $self->GotoItem($placeholder);
2644         $self->{'items_array'}
2645             = $self->ItemsOrderBy( $self->{'items_array'} );
2646     }
2647     return ( $self->{'items_array'} );
2648 }
2649
2650 # }}}
2651
2652 # {{{ sub Next
2653 sub Next {
2654     my $self = shift;
2655
2656     $self->_ProcessRestrictions() if ( $self->{'RecalcTicketLimits'} == 1 );
2657
2658     my $Ticket = $self->SUPER::Next();
2659     if ( ( defined($Ticket) ) and ( ref($Ticket) ) ) {
2660
2661         if ( $Ticket->__Value('Status') eq 'deleted'
2662             && !$self->{'allow_deleted_search'} )
2663         {
2664             return ( $self->Next() );
2665         }
2666
2667         # Since Ticket could be granted with more rights instead
2668         # of being revoked, it's ok if queue rights allow
2669         # ShowTicket.  It seems need another query, but we have
2670         # rights cache in Principal::HasRight.
2671         elsif ($Ticket->QueueObj->CurrentUserHasRight('ShowTicket')
2672             || $Ticket->CurrentUserHasRight('ShowTicket') )
2673         {
2674             return ($Ticket);
2675         }
2676
2677         if ( $Ticket->__Value('Status') eq 'deleted' ) {
2678             return ( $self->Next() );
2679         }
2680
2681         # Since Ticket could be granted with more rights instead
2682         # of being revoked, it's ok if queue rights allow
2683         # ShowTicket.  It seems need another query, but we have
2684         # rights cache in Principal::HasRight.
2685         elsif ($Ticket->QueueObj->CurrentUserHasRight('ShowTicket')
2686             || $Ticket->CurrentUserHasRight('ShowTicket') )
2687         {
2688             return ($Ticket);
2689         }
2690
2691         #If the user doesn't have the right to show this ticket
2692         else {
2693             return ( $self->Next() );
2694         }
2695     }
2696
2697     #if there never was any ticket
2698     else {
2699         return (undef);
2700     }
2701
2702 }
2703
2704 # }}}
2705
2706 # }}}
2707
2708 # {{{ Deal with storing and restoring restrictions
2709
2710 # {{{ sub LoadRestrictions
2711
2712 =head2 LoadRestrictions
2713
2714 LoadRestrictions takes a string which can fully populate the TicketRestrictons hash.
2715 TODO It is not yet implemented
2716
2717 =cut
2718
2719 # }}}
2720
2721 # {{{ sub DescribeRestrictions
2722
2723 =head2 DescribeRestrictions
2724
2725 takes nothing.
2726 Returns a hash keyed by restriction id.
2727 Each element of the hash is currently a one element hash that contains DESCRIPTION which
2728 is a description of the purpose of that TicketRestriction
2729
2730 =cut
2731
2732 sub DescribeRestrictions {
2733     my $self = shift;
2734
2735     my ( $row, %listing );
2736
2737     foreach $row ( keys %{ $self->{'TicketRestrictions'} } ) {
2738         $listing{$row} = $self->{'TicketRestrictions'}{$row}{'DESCRIPTION'};
2739     }
2740     return (%listing);
2741 }
2742
2743 # }}}
2744
2745 # {{{ sub RestrictionValues
2746
2747 =head2 RestrictionValues FIELD
2748
2749 Takes a restriction field and returns a list of values this field is restricted
2750 to.
2751
2752 =cut
2753
2754 sub RestrictionValues {
2755     my $self  = shift;
2756     my $field = shift;
2757     map $self->{'TicketRestrictions'}{$_}{'VALUE'}, grep {
2758                $self->{'TicketRestrictions'}{$_}{'FIELD'}    eq $field
2759             && $self->{'TicketRestrictions'}{$_}{'OPERATOR'} eq "="
2760         }
2761         keys %{ $self->{'TicketRestrictions'} };
2762 }
2763
2764 # }}}
2765
2766 # {{{ sub ClearRestrictions
2767
2768 =head2 ClearRestrictions
2769
2770 Removes all restrictions irretrievably
2771
2772 =cut
2773
2774 sub ClearRestrictions {
2775     my $self = shift;
2776     delete $self->{'TicketRestrictions'};
2777     $self->{'looking_at_effective_id'} = 0;
2778     $self->{'looking_at_type'}         = 0;
2779     $self->{'RecalcTicketLimits'}      = 1;
2780 }
2781
2782 # }}}
2783
2784 # {{{ sub DeleteRestriction
2785
2786 =head2 DeleteRestriction
2787
2788 Takes the row Id of a restriction (From DescribeRestrictions' output, for example.
2789 Removes that restriction from the session's limits.
2790
2791 =cut
2792
2793 sub DeleteRestriction {
2794     my $self = shift;
2795     my $row  = shift;
2796     delete $self->{'TicketRestrictions'}{$row};
2797
2798     $self->{'RecalcTicketLimits'} = 1;
2799
2800     #make the underlying easysearch object forget all its preconceptions
2801 }
2802
2803 # }}}
2804
2805 # {{{ sub _RestrictionsToClauses
2806
2807 # Convert a set of oldstyle SB Restrictions to Clauses for RQL
2808
2809 sub _RestrictionsToClauses {
2810     my $self = shift;
2811
2812     my $row;
2813     my %clause;
2814     foreach $row ( keys %{ $self->{'TicketRestrictions'} } ) {
2815         my $restriction = $self->{'TicketRestrictions'}{$row};
2816
2817         #use Data::Dumper;
2818         #print Dumper($restriction),"\n";
2819
2820         # We need to reimplement the subclause aggregation that SearchBuilder does.
2821         # Default Subclause is ALIAS.FIELD, and default ALIAS is 'main',
2822         # Then SB AND's the different Subclauses together.
2823
2824         # So, we want to group things into Subclauses, convert them to
2825         # SQL, and then join them with the appropriate DefaultEA.
2826         # Then join each subclause group with AND.
2827
2828         my $field = $restriction->{'FIELD'};
2829         my $realfield = $field;    # CustomFields fake up a fieldname, so
2830                                    # we need to figure that out
2831
2832         # One special case
2833         # Rewrite LinkedTo meta field to the real field
2834         if ( $field =~ /LinkedTo/ ) {
2835             $realfield = $field = $restriction->{'TYPE'};
2836         }
2837
2838         # Two special case
2839         # Handle subkey fields with a different real field
2840         if ( $field =~ /^(\w+)\./ ) {
2841             $realfield = $1;
2842         }
2843
2844         die "I don't know about $field yet"
2845             unless ( exists $FIELD_METADATA{$realfield}
2846                 or $restriction->{CUSTOMFIELD} );
2847
2848         my $type = $FIELD_METADATA{$realfield}->[0];
2849         my $op   = $restriction->{'OPERATOR'};
2850
2851         my $value = (
2852             grep    {defined}
2853                 map { $restriction->{$_} } qw(VALUE TICKET BASE TARGET)
2854         )[0];
2855
2856         # this performs the moral equivalent of defined or/dor/C<//>,
2857         # without the short circuiting.You need to use a 'defined or'
2858         # type thing instead of just checking for truth values, because
2859         # VALUE could be 0.(i.e. "false")
2860
2861         # You could also use this, but I find it less aesthetic:
2862         # (although it does short circuit)
2863         #( defined $restriction->{'VALUE'}? $restriction->{VALUE} :
2864         # defined $restriction->{'TICKET'} ?
2865         # $restriction->{TICKET} :
2866         # defined $restriction->{'BASE'} ?
2867         # $restriction->{BASE} :
2868         # defined $restriction->{'TARGET'} ?
2869         # $restriction->{TARGET} )
2870
2871         my $ea = $restriction->{ENTRYAGGREGATOR}
2872             || $DefaultEA{$type}
2873             || "AND";
2874         if ( ref $ea ) {
2875             die "Invalid operator $op for $field ($type)"
2876                 unless exists $ea->{$op};
2877             $ea = $ea->{$op};
2878         }
2879
2880         # Each CustomField should be put into a different Clause so they
2881         # are ANDed together.
2882         if ( $restriction->{CUSTOMFIELD} ) {
2883             $realfield = $field;
2884         }
2885
2886         exists $clause{$realfield} or $clause{$realfield} = [];
2887
2888         # Escape Quotes
2889         $field =~ s!(['"])!\\$1!g;
2890         $value =~ s!(['"])!\\$1!g;
2891         my $data = [ $ea, $type, $field, $op, $value ];
2892
2893         # here is where we store extra data, say if it's a keyword or
2894         # something.  (I.e. "TYPE SPECIFIC STUFF")
2895
2896         #print Dumper($data);
2897         push @{ $clause{$realfield} }, $data;
2898     }
2899     return \%clause;
2900 }
2901
2902 # }}}
2903
2904 # {{{ sub _ProcessRestrictions
2905
2906 =head2 _ProcessRestrictions PARAMHASH
2907
2908 # The new _ProcessRestrictions is somewhat dependent on the SQL stuff,
2909 # but isn't quite generic enough to move into Tickets_Overlay_SQL.
2910
2911 =cut
2912
2913 sub _ProcessRestrictions {
2914     my $self = shift;
2915
2916     #Blow away ticket aliases since we'll need to regenerate them for
2917     #a new search
2918     delete $self->{'TicketAliases'};
2919     delete $self->{'items_array'};
2920     delete $self->{'item_map'};
2921     delete $self->{'raw_rows'};
2922     delete $self->{'rows'};
2923     delete $self->{'count_all'};
2924
2925     my $sql = $self->Query;    # Violating the _SQL namespace
2926     if ( !$sql || $self->{'RecalcTicketLimits'} ) {
2927
2928         #  "Restrictions to Clauses Branch\n";
2929         my $clauseRef = eval { $self->_RestrictionsToClauses; };
2930         if ($@) {
2931             $RT::Logger->error( "RestrictionsToClauses: " . $@ );
2932             $self->FromSQL("");
2933         }
2934         else {
2935             $sql = $self->ClausesToSQL($clauseRef);
2936             $self->FromSQL($sql) if $sql;
2937         }
2938     }
2939
2940     $self->{'RecalcTicketLimits'} = 0;
2941
2942 }
2943
2944 =head2 _BuildItemMap
2945
2946     # Build up a map of first/last/next/prev items, so that we can display search nav quickly
2947
2948 =cut
2949
2950 sub _BuildItemMap {
2951     my $self = shift;
2952
2953     my $items = $self->ItemsArrayRef;
2954     my $prev  = 0;
2955
2956     delete $self->{'item_map'};
2957     if ( $items->[0] ) {
2958         $self->{'item_map'}->{'first'} = $items->[0]->EffectiveId;
2959         while ( my $item = shift @$items ) {
2960             my $id = $item->EffectiveId;
2961             $self->{'item_map'}->{$id}->{'defined'} = 1;
2962             $self->{'item_map'}->{$id}->{prev}      = $prev;
2963             $self->{'item_map'}->{$id}->{next}      = $items->[0]->EffectiveId
2964                 if ( $items->[0] );
2965             $prev = $id;
2966         }
2967         $self->{'item_map'}->{'last'} = $prev;
2968     }
2969 }
2970
2971 =head2 ItemMap
2972
2973 Returns an a map of all items found by this search. The map is of the form
2974
2975 $ItemMap->{'first'} = first ticketid found
2976 $ItemMap->{'last'} = last ticketid found
2977 $ItemMap->{$id}->{prev} = the ticket id found before $id
2978 $ItemMap->{$id}->{next} = the ticket id found after $id
2979
2980 =cut
2981
2982 sub ItemMap {
2983     my $self = shift;
2984     $self->_BuildItemMap()
2985         unless ( $self->{'items_array'} and $self->{'item_map'} );
2986     return ( $self->{'item_map'} );
2987 }
2988
2989 =cut
2990
2991
2992
2993 }
2994
2995
2996
2997 # }}}
2998
2999 # }}}
3000
3001 =head2 PrepForSerialization
3002
3003 You don't want to serialize a big tickets object, as the {items} hash will be instantly invalid _and_ eat lots of space
3004
3005 =cut
3006
3007 sub PrepForSerialization {
3008     my $self = shift;
3009     delete $self->{'items'};
3010     $self->RedoSearch();
3011 }
3012
3013 =head1 FLAGS
3014
3015 RT::Tickets supports several flags which alter search behavior:
3016
3017
3018 allow_deleted_search  (Otherwise never show deleted tickets in search results)
3019 looking_at_type (otherwise limit to type=ticket)
3020
3021 These flags are set by calling 
3022
3023 $tickets->{'flagname'} = 1;
3024
3025 BUG: There should be an API for this
3026
3027 =cut
3028
3029 =begin testing
3030
3031 # We assume that we've got some tickets hanging around from before.
3032 ok( my $unlimittickets = RT::Tickets->new( $RT::SystemUser ) );
3033 ok( $unlimittickets->UnLimit );
3034 ok( $unlimittickets->Count > 0, "UnLimited tickets object should return tickets" );
3035
3036 =end testing
3037
3038 1;
3039
3040
3041