RT#38973: Bill for time worked on ticket resolution [checkpoint, not ready for backport]
[freeside.git] / FS / FS / TicketSystem / RT_Internal.pm
1 package FS::TicketSystem::RT_Internal;
2
3 use strict;
4 use vars qw( @ISA $DEBUG $me );
5 use Data::Dumper;
6 use MIME::Entity;
7 use FS::UID qw(dbh);
8 use FS::CGI qw(popurl);
9 use FS::TicketSystem::RT_Libs;
10
11 @ISA = qw( FS::TicketSystem::RT_Libs );
12
13 $DEBUG = 0;
14 $me = '[FS::TicketSystem::RT_Internal]';
15
16 sub sql_num_customer_tickets {
17   "( select count(*) from Tickets
18                      join Links on ( Tickets.id = Links.LocalBase )
19      where ( Status = 'new' or Status = 'open' or Status = 'stalled' )
20        and Target = 'freeside://freeside/cust_main/' || custnum
21    )";
22 }
23
24 sub baseurl {
25   #my $self = shift;
26   if ( $RT::URI::freeside::URL ) {
27     $RT::URI::freeside::URL. '/rt/';
28   } else {
29     'http://you_need_to_set_RT_URI_freeside_URL_in_SiteConfig.pm/';
30   }
31 }
32
33 #mapping/genericize??
34 #ShowConfigTab ModifySelf
35 sub access_right {
36   my( $self, $session, $right ) = @_;
37
38   return '' unless FS::Conf->new->config('ticket_system');
39
40   $session = $self->session($session);
41
42   #warn "$me access_right: CurrentUser ". $session->{'CurrentUser'}. ":\n".
43   #     ( $DEBUG>1 ? Dumper($session->{'CurrentUser'}) : '' )
44   #  if $DEBUG > 1;
45
46   $session->{'CurrentUser'}->HasRight( Right  => $right,
47                                        Object => $RT::System );
48 }
49
50 sub session {
51   my( $self, $session ) = @_;
52
53   if ( $session && $session->{'CurrentUser'} ) { # does this even work?
54     warn "$me session: using existing session and CurrentUser: \n".
55          Dumper($session->{'CurrentUser'})
56       if $DEBUG;
57  } else {
58     warn "$me session: loading session and CurrentUser\n" if $DEBUG > 1;
59     $session = $self->_web_external_auth($session);
60   }
61
62   $session;
63 }
64
65 my $firsttime = 1;
66
67 sub init {
68   my $self = shift;
69   if ( $firsttime ) {
70
71     # this part only needs to be done once
72     warn "$me init: loading RT libraries\n" if $DEBUG;
73     eval '
74       use lib ( "/opt/rt3/local/lib", "/opt/rt3/lib" );
75       use RT;
76
77       #for web external auth...
78       use RT::Interface::Web;
79     ';
80     die $@ if $@;
81
82     warn "$me init: loading RT config\n" if $DEBUG;
83     {
84       local $SIG{__DIE__};
85       eval 'RT::LoadConfig();';
86     }
87     die $@ if $@;
88
89     $firsttime = 0;
90   }
91
92   # this needs to be done on each fork
93   warn "$me init: initializing RT\n" if $DEBUG;
94   {
95     local $SIG{__WARN__};
96     local $SIG{__DIE__};
97     eval 'RT::Init("NoSignalHandlers"=>1);';
98   }
99   die $@ if $@;
100
101   warn "$me init: complete" if $DEBUG;
102 }
103
104 =item customer_tickets CUSTNUM [ LIMIT ] [ PRIORITYVALUE ]
105
106 Replacement for the one in RT_External so that we can access custom fields 
107 properly.
108
109 =cut
110
111 # create an RT::Tickets object for a specified custnum or svcnum
112
113 sub _tickets_search {
114   my( $self, $type, $number, $limit, $priority, $status, $queueid ) = @_;
115
116   $type =~ /^Customer|Service$/ or die "invalid type: $type";
117   $number =~ /^\d+$/ or die "invalid custnum/svcnum: $number";
118   $limit =~ /^\d+$/ or die "invalid limit: $limit";
119
120   my $session = $self->session();
121   my $CurrentUser = $session->{CurrentUser} 
122     or die "unable to create an RT session";
123
124   my $Tickets = RT::Tickets->new($CurrentUser);
125
126   # "Customer.number" searches tickets linked via cust_svc also
127   my $rtql = "$type.number = $number";
128
129   if ( defined( $priority ) ) {
130     my $custom_priority = FS::Conf->new->config('ticket_system-custom_priority_field');
131     if ( length( $priority ) ) {
132       $rtql .= " AND CF.{$custom_priority} = '$priority'";
133     }
134     else {
135       $rtql .= " AND CF.{$custom_priority} IS NULL";
136     }
137   }
138
139   my @statuses;
140   if ( defined($status) && $status ) {
141     if ( ref($status) ) {
142       if ( ref($status) eq 'HASH' ) {
143         @statuses = grep $status->{$_}, keys %$status;
144       } elsif ( ref($status) eq 'ARRAY' ) {
145         @statuses = @$status;
146       } else {
147         #what should be the failure mode here?  die?  return no tickets?
148         die 'unknown status ref '. ref($status);
149       }
150     } else {
151       @statuses = ( $status );
152     }
153     @statuses = grep /^\w+$/, @statuses; #injection prevention
154   } else {
155     @statuses = $self->statuses;
156   }
157
158   $rtql .= ' AND ( '.
159                       join(' OR ', map { "Status = '$_'" } @statuses).
160                ' ) ';
161
162   $rtql .= " AND Queue = $queueid " if $queueid;
163
164   warn "$me _customer_tickets_search:\n$rtql\n" if $DEBUG;
165   $Tickets->FromSQL($rtql);
166
167   $Tickets->RowsPerPage($limit);
168   warn "\n\n" . $Tickets->BuildSelectQuery . "\n\n" if $DEBUG > 1;
169
170   return $Tickets;
171 }
172
173 sub href_customer_tickets {
174   my ($self, $custnum) = (shift, shift);
175   if ($custnum =~ /^(\d+)$/) {
176     return $self->href_search_tickets("Customer.number = $custnum", @_);
177   }
178   warn "bad custnum $custnum"; '';
179 }
180
181 sub href_service_tickets {
182   my ($self, $svcnum) = (shift, shift);
183   if ($svcnum =~ /^(\d+)$/ ) {
184     return $self->href_search_tickets("Service.number = $svcnum", @_);
185   }
186   warn "bad svcnum $svcnum"; '';
187 }
188
189 sub customer_tickets {
190   my $self = shift;
191   my $Tickets = $self->_tickets_search('Customer', @_);
192
193   my $conf = FS::Conf->new;
194   my $priority_order =
195     $conf->exists('ticket_system-priority_reverse') ? 'ASC' : 'DESC';
196
197   my @order_by = (
198     { FIELD => 'Priority', ORDER => $priority_order },
199     { FIELD => 'Id',       ORDER => 'DESC' },
200   );
201
202   $Tickets->OrderByCols(@order_by);
203
204   my @tickets;
205   while ( my $t = $Tickets->Next ) {
206     push @tickets, _ticket_info($t);
207   }
208
209   return \@tickets;
210 }
211
212 sub num_customer_tickets {
213   my ( $self, $custnum, $priority ) = @_;
214   $self->_tickets_search('Customer', $custnum, 0, $priority)->CountAll;
215 }
216
217 sub service_tickets  {
218   my $self = shift;
219   my $Tickets = $self->_tickets_search('Service', @_);
220
221   my $conf = FS::Conf->new;
222   my $priority_order =
223     $conf->exists('ticket_system-priority_reverse') ? 'ASC' : 'DESC';
224
225   my @order_by = (
226     { FIELD => 'Priority', ORDER => $priority_order },
227     { FIELD => 'Id',       ORDER => 'DESC' },
228   );
229
230   $Tickets->OrderByCols(@order_by);
231
232   my @tickets;
233   while ( my $t = $Tickets->Next ) {
234     push @tickets, _ticket_info($t);
235   }
236
237   return \@tickets;
238 }
239
240 sub _ticket_info {
241   # Takes an RT::Ticket; returns a hashref of the ticket's fields, including 
242   # custom fields.  Also returns custom and selfservice priority values as 
243   # _custom_priority and _selfservice_priority, and the IsUnreplied property
244   # as is_unreplied.
245   my $t = shift;
246
247   my $custom_priority = 
248     FS::Conf->new->config('ticket_system-custom_priority_field') || '';
249   my $ss_priority = selfservice_priority();
250
251   my %ticket_info;
252   foreach my $name ( $t->ReadableAttributes ) {
253     # lowercase names, and skip attributes with non-scalar values
254     $ticket_info{lc($name)} = $t->$name if !ref($t->$name);
255   }
256   $ticket_info{'owner'} = $t->OwnerObj->Name;
257   $ticket_info{'queue'} = $t->QueueObj->Name;
258   $ticket_info{'_cf_sort_order'} = {};
259   my $cf_sort = 0;
260   foreach my $CF ( @{ $t->CustomFields->ItemsArrayRef } ) {
261     $ticket_info{'_cf_sort_order'}{$CF->Name} = $cf_sort++;
262     my $name = 'CF.{'.$CF->Name.'}';
263     $ticket_info{$name} = $t->CustomFieldValuesAsString($CF->Id);
264   }
265   # make this easy to find
266   if ( $custom_priority ) {
267     $ticket_info{'content'} = $ticket_info{"CF.{$custom_priority}"};
268   }
269   if ( $ss_priority ) {
270     $ticket_info{'_selfservice_priority'} = $ticket_info{"CF.{$ss_priority}"};
271   }
272   $ticket_info{'is_unreplied'} = $t->IsUnreplied;
273   my $svcnums = [ 
274     map { $_->Target =~ /cust_svc\/(\d+)/; $1 } 
275         @{ $t->Services->ItemsArrayRef }
276   ];
277   $ticket_info{'svcnums'} = $svcnums;
278
279   return \%ticket_info;
280 }
281
282 =item create_ticket SESSION_HASHREF, OPTION => VALUE ...
283
284 Class method.  Creates a ticket.  If there is an error, returns the scalar
285 error, otherwise returns the newly created RT::Ticket object.
286
287 Accepts the following options:
288
289 =over 4
290
291 =item queue
292
293 Queue name or Id
294
295 =item subject
296
297 Ticket subject
298
299 =item requestor
300
301 Requestor email address or arrayref of addresses
302
303 =item cc
304
305 Cc: email address or arrayref of addresses
306
307 =item message
308
309 Ticket message
310
311 =item mime_type
312
313 MIME type to use for message.  Defaults to text/plain.  Specifying text/html
314 can be useful to use HTML markup in message.
315
316 =item custnum
317
318 Customer number (see L<FS::cust_main>) to associate with ticket.
319
320 =item svcnum
321
322 Service number (see L<FS::cust_svc>) to associate with ticket.  Will also
323 associate the customer who has this service (unless the service is unlinked).
324
325 =back
326
327 =cut
328
329 sub create_ticket {
330   my($self, $session, %param) = @_;
331
332   $session = $self->session($session);
333
334   my $Queue = RT::Queue->new($session->{'CurrentUser'});
335   $Queue->Load( $param{'queue'} );
336
337   my $req = ref($param{'requestor'})
338               ? $param{'requestor'}
339               : ( $param{'requestor'} ? [ $param{'requestor'} ] : [] );
340
341   my $cc = ref($param{'cc'})
342              ? $param{'cc'}
343              : ( $param{'cc'} ? [ $param{'cc'} ] : [] );
344
345   my $mimeobj = MIME::Entity->build(
346     'Data' => $param{'message'},
347     'Type' => ( $param{'mime_type'} || 'text/plain' ),
348   );
349
350   my %ticket = (
351     'Queue'     => $Queue->Id,
352     'Subject'   => $param{'subject'},
353     'Requestor' => $req,
354     'Cc'        => $cc,
355     'MIMEObj'   => $mimeobj,
356   );
357   warn Dumper(\%ticket) if $DEBUG > 1;
358
359   my $Ticket = RT::Ticket->new($session->{'CurrentUser'});
360   my( $id, $Transaction, $ErrStr );
361   {
362     local $SIG{__DIE__};
363     ( $id, $Transaction, $ErrStr ) = $Ticket->Create( %ticket );
364   }
365   return $ErrStr if $id == 0;
366
367   warn "ticket got id $id\n" if $DEBUG;
368
369   #XXX check errors adding custnum/svcnum links (put it in a transaction)...
370   # but we do already know they're good
371
372   if ( $param{'custnum'} ) {
373     my( $val, $msg ) = $Ticket->_AddLink(
374      'Type'   => 'MemberOf',
375      'Target' => 'freeside://freeside/cust_main/'. $param{'custnum'},
376     );
377   }
378
379   if ( $param{'svcnum'} ) {
380     my( $val, $msg ) = $Ticket->_AddLink(
381      'Type'   => 'MemberOf',
382      'Target' => 'freeside://freeside/cust_svc/'. $param{'svcnum'},
383     );
384   }
385
386   $Ticket;
387 }
388
389 =item get_ticket SESSION_HASHREF, OPTION => VALUE ...
390
391 Class method. Retrieves a ticket. If there is an error, returns the scalar
392 error. Otherwise, currently returns a slightly tricky data structure containing
393 the ticket's attributes, a list of the linked customers, each transaction's 
394 content, description, and create time.
395
396 Accepts the following options:
397
398 =over 4
399
400 =item ticket_id 
401
402 The ticket id
403
404 =back
405
406 =cut
407
408 sub get_ticket {
409   my($self, $session, %param) = @_;
410
411   $session = $self->session($session);
412
413   my $Ticket = RT::Ticket->new($session->{'CurrentUser'});
414   my $ticketid = $Ticket->Load( $param{'ticket_id'} );
415   return 'Could not load ticket' unless $ticketid;
416
417   my @custs = ();
418   foreach my $link ( @{ $Ticket->Customers->ItemsArrayRef } ) {
419     my $cust = $link->Target;
420     push @custs, $1 if $cust =~ /\/(\d+)$/;
421   }
422
423   my @txns = ();
424   my $transactions = $Ticket->Transactions;
425   while ( my $transaction = $transactions->Next ) {
426     my $t = { created => $transaction->Created,
427         content => $transaction->Content,
428         description => $transaction->Description,
429         type => $transaction->Type,
430     };
431     push @txns, $t;
432   }
433
434   { txns => [ @txns ],
435     custs => [ @custs ],
436     fields => _ticket_info($Ticket),
437   };
438 }
439
440 =item get_ticket_object SESSION_HASHREF, OPTION => VALUE...
441
442 Class method.  Retrieve the RT::Ticket object with the specified 
443 ticket_id.  If custnum is supplied, will also check that the object 
444 is a member of that customer.  If there is no ticket or the custnum 
445 check fails, returns nothing.  The meaning of that case is 
446 "to this customer, the ticket does not exist".
447
448 Options:
449
450 =over 4
451
452 =item ticket_id
453
454 =item custnum
455
456 =back
457
458 =cut
459
460 sub get_ticket_object {
461   my $self = shift;
462   my ($session, %opt) = @_;
463   $session = $self->session(shift);
464   # use a small search here so we can check ticket ownership
465   my $query;
466   if ( $opt{'ticket_id'} =~ /^(\d+)$/ ) {
467     $query = "id = $1";
468   } else {
469     return;
470   }
471   if ( $opt{'custnum'} =~ /^(\d+)$/ ) {
472     $query .= " AND Customer.number = $1"; # also checks ownership via services
473   }
474   my $Tickets = RT::Tickets->new($session->{CurrentUser});
475   $Tickets->FromSQL($query);
476   return $Tickets->First;
477 }
478
479 =item correspond_ticket SESSION_HASHREF, OPTION => VALUE ...
480
481 Class method. Correspond on a ticket. If there is an error, returns the scalar
482 error. Otherwise, returns the transaction id, error message, and
483 RT::Transaction object.
484
485 Accepts the following options:
486
487 =over 4
488
489 =item ticket_id 
490
491 The ticket id
492
493 =item content
494
495 Correspondence content
496
497 =back
498
499 =cut
500
501 sub correspond_ticket {
502   my($self, $session, %param) = @_;
503
504   $session = $self->session($session);
505
506   my $Ticket = RT::Ticket->new($session->{'CurrentUser'});
507   my $ticketid = $Ticket->Load( $param{'ticket_id'} );
508   return 'Could not load ticket' unless $ticketid;
509   return 'No content' unless $param{'content'};
510
511   $Ticket->Correspond( Content => $param{'content'} );
512 }
513
514 =item queues SESSION_HASHREF [, ACL ]
515
516 Retrieve a list of queues.  Pass the name of an RT access control right, 
517 such as 'CreateTicket', to return only queues on which the current user 
518 has that right.  Otherwise this will return all queues with the 'SeeQueue' 
519 right.
520
521 =cut
522
523 sub queues {
524   my( $self, $session, $acl ) = @_;
525   $session = $self->session($session);
526
527   my $showall = $acl ? 0 : 1;
528   my @result = ();
529   my $q = new RT::Queues($session->{'CurrentUser'});
530   $q->UnLimit;
531   while (my $queue = $q->Next) {
532     if ($showall || $queue->CurrentUserHasRight($acl)) {
533       push @result, {
534         Id          => $queue->Id,
535         Name        => $queue->Name,
536         Description => $queue->Description,
537       };
538     }
539   }
540   return map { $_->{Id} => $_->{Name} } @result;
541 }
542
543 #shameless false laziness w/RT::Interface::Web::AttemptExternalAuth
544 # to get logged into RT from afar
545 sub _web_external_auth {
546   my( $self, $session ) = @_;
547
548   my $user = $FS::CurrentUser::CurrentUser->username;
549
550   eval 'use RT::CurrentUser;';
551   die $@ if $@;
552
553   $session ||= {};
554   $session->{'CurrentUser'} = RT::CurrentUser->new();
555
556   warn "$me _web_external_auth loading RT user for $user\n"
557     if $DEBUG > 1;
558
559   $session->{'CurrentUser'}->Load($user);
560
561   if ( ! $session->{'CurrentUser'}->Id() ) {
562
563       # Create users on-the-fly
564
565       warn "can't load RT user for $user; auto-creating\n"
566         if $DEBUG;
567
568       my $UserObj = RT::User->new( RT::CurrentUser->new('RT_System') );
569
570       my ( $val, $msg ) = $UserObj->Create(
571           %{ ref($RT::AutoCreate) ? $RT::AutoCreate : {} },
572           Name  => $user,
573           Gecos => $user,
574       );
575
576       if ($val) {
577
578           # now get user specific information, to better create our user.
579           my $new_user_info
580               = RT::Interface::Web::WebRemoteUserAutocreateInfo($user);
581
582           # set the attributes that have been defined.
583           # FIXME: this is a horrible kludge. I'm sure there's something cleaner
584           foreach my $attribute (
585               'Name',                  'Comments',
586               'Signature',             'EmailAddress',
587               'PagerEmailAddress',     'FreeformContactInfo',
588               'Organization',          'Disabled',
589               'Privileged',            'RealName',
590               'NickName',              'Lang',
591               'EmailEncoding',         'WebEncoding',
592               'ExternalContactInfoId', 'ContactInfoSystem',
593               'ExternalAuthId',        'Gecos',
594               'HomePhone',             'WorkPhone',
595               'MobilePhone',           'PagerPhone',
596               'Address1',              'Address2',
597               'City',                  'State',
598               'Zip',                   'Country'
599               )
600           {
601               #uhh, wrong root
602               #$m->comp( '/Elements/Callback', %ARGS,
603               #    _CallbackName => 'NewUser' );
604
605               my $method = "Set$attribute";
606               $UserObj->$method( $new_user_info->{$attribute} )
607                   if ( defined $new_user_info->{$attribute} );
608           }
609           $session->{'CurrentUser'}->Load($user);
610       }
611       else {
612
613          # we failed to successfully create the user. abort abort abort.
614           delete $session->{'CurrentUser'};
615
616           die "can't auto-create RT user: $msg"; #an error message would be nice :/
617           #$m->abort() unless $RT::WebFallbackToInternalAuth;
618           #$m->comp( '/Elements/Login', %ARGS,
619           #    Error => loc( 'Cannot create user: [_1]', $msg ) );
620       }
621   }
622
623   unless ( $session->{'CurrentUser'}->Id() ) {
624       delete $session->{'CurrentUser'};
625
626       die "can't auto-create RT user";
627       #$user = $orig_user;
628       # 
629       #if ($RT::WebExternalOnly) {
630       #    $m->comp( '/Elements/Login', %ARGS,
631       #        Error => loc('You are not an authorized user') );
632       #    $m->abort();
633       #}
634   }
635
636   $session;
637
638 }
639
640 =item selfservice_priority
641
642 Returns the configured self-service priority field.
643
644 =cut
645
646 my $selfservice_priority;
647
648 sub selfservice_priority {
649   return $selfservice_priority ||= do {
650     my $conf = FS::Conf->new;
651     $conf->config('ticket_system-selfservice_priority_field') || '';
652   }
653 }
654
655 =item custom_fields
656
657 Returns a hash of custom field names and descriptions.
658
659 Accepts the following options:
660
661 lookuptype - limit results to this lookuptype
662
663 valuetype - limit results to this valuetype
664
665 Fields must be visible to CurrentUser.
666
667 =cut
668
669 sub custom_fields {
670   my $self = shift;
671   my %opt = @_;
672   my $lookuptype = $opt{lookuptype};
673   my $valuetype = $opt{valuetype};
674
675   my $CurrentUser = RT::CurrentUser->new();
676   $CurrentUser->LoadByName($FS::CurrentUser::CurrentUser->username);
677   die "RT not configured" unless $CurrentUser->id;
678   my $CFs = RT::CustomFields->new($CurrentUser);
679
680   $CFs->UnLimit;
681
682   $CFs->Limit(FIELD => 'LookupType',
683               OPERATOR => 'ENDSWITH',
684               VALUE => $lookuptype)
685       if $lookuptype;
686
687   $CFs->Limit(FIELD => 'Type',
688               VALUE => $valuetype)
689       if $valuetype;
690
691   my @fields;
692   while (my $CF = $CFs->Next) {
693     push @fields, $CF->Name, ($CF->Description || $CF->Name);
694   }
695
696   return @fields;
697 }
698
699 1;
700