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