61617060e5e7b14da1cce20cb48d8b4357fbaec9
[freeside.git] / FS / FS / UI / Web.pm
1 package FS::UI::Web;
2
3 use strict;
4 use vars qw($DEBUG @ISA @EXPORT_OK $me);
5 use Exporter;
6 use Carp qw( confess );
7 use HTML::Entities;
8 use FS::Conf;
9 use FS::Misc::DateTime qw( parse_datetime day_end );
10 use FS::Record qw(dbdef);
11 use FS::cust_main;  # are sql_balance and sql_date_balance in the right module?
12
13 #use vars qw(@ISA);
14 #use FS::UI
15 #@ISA = qw( FS::UI );
16 @ISA = qw( Exporter );
17
18 @EXPORT_OK = qw( svc_url );
19
20 $DEBUG = 0;
21 $me = '[FS::UID::Web]';
22
23 ###
24 # date parsing
25 ###
26
27 use Date::Parse;
28 sub parse_beginning_ending {
29   my($cgi, $prefix) = @_;
30   $prefix .= '_' if $prefix;
31
32   my $beginning = 0;
33   if ( $cgi->param($prefix.'begin') =~ /^(\d+)$/ ) {
34     $beginning = $1;
35   } elsif ( $cgi->param($prefix.'beginning') =~ /^([ 0-9\-\/\:]{1,64})$/ ) {
36     $beginning = parse_datetime($1) || 0;
37   }
38
39   my $ending = 4294967295; #2^32-1
40   if ( $cgi->param($prefix.'end') =~ /^(\d+)$/ ) {
41     $ending = $1 - 1;
42   } elsif ( $cgi->param($prefix.'ending') =~ /^([ 0-9\-\/\:]{1,64})$/ ) {
43     $ending = parse_datetime($1);
44     $ending = day_end($ending) unless $ending =~ /:/;
45   }
46
47   ( $beginning, $ending );
48 }
49
50 =item svc_url
51
52 Returns a service URL, first checking to see if there is a service-specific
53 page to link to, otherwise to a generic service handling page.  Options are
54 passed as a list of name-value pairs, and include:
55
56 =over 4
57
58 =item * m - Mason request object ($m)
59
60 =item * action - The action for which to construct "edit", "view", or "search"
61
62 =item ** part_svc - Service definition (see L<FS::part_svc>)
63
64 =item ** svcdb - Service table
65
66 =item *** query - Query string
67
68 =item *** svc   - FS::cust_svc or FS::svc_* object
69
70 =item ahref - Optional flag, if set true returns <A HREF="$url"> instead of just the URL.
71
72 =back 
73
74 * Required fields
75
76 ** part_svc OR svcdb is required
77
78 *** query OR svc is required
79
80 =cut
81
82   # ##
83   # #required
84   # ##
85   #  'm'        => $m, #mason request object
86   #  'action'   => 'edit', #or 'view'
87   #
88   #  'part_svc' => $part_svc, #usual
89   #   #OR
90   #  'svcdb'    => 'svc_table',
91   #
92   #  'query'    => #optional query string
93   #                # (pass a blank string if you want a "raw" URL to add your
94   #                #  own svcnum to)
95   #   #OR
96   #  'svc'      => $svc_x, #or $cust_svc, it just needs a svcnum
97   #
98   # ##
99   # #optional
100   # ##
101   #  'ahref'    => 1, # if set true, returns <A HREF="$url">
102
103 use FS::CGI qw(rooturl);
104 sub svc_url {
105   my %opt = @_;
106
107   #? return '' unless ref($opt{part_svc});
108
109   my $svcdb = $opt{svcdb} || $opt{part_svc}->svcdb;
110   my $query = exists($opt{query}) ? $opt{query} : $opt{svc}->svcnum;
111   my $url;
112   warn "$me [svc_url] checking for /$opt{action}/$svcdb.cgi component"
113     if $DEBUG;
114   if ( $opt{m}->interp->comp_exists("/$opt{action}/$svcdb.cgi") ) {
115     $url = "$svcdb.cgi?";
116   } else {
117
118     my $generic = $opt{action} eq 'search' ? 'cust_svc' : 'svc_Common';
119
120     $url = "$generic.html?svcdb=$svcdb;";
121     $url .= 'svcnum=' if $query =~ /^\d+(;|$)/ or $query eq '';
122   }
123
124   import FS::CGI 'rooturl'; #WTF!  why is this necessary
125   my $return = rooturl(). "$opt{action}/$url$query";
126
127   $return = qq!<A HREF="$return">! if $opt{ahref};
128
129   $return;
130 }
131
132 sub svc_link {
133   my($m, $part_svc, $cust_svc) = @_ or return '';
134   svc_X_link( $part_svc->svc, @_ );
135 }
136
137 sub svc_label_link {
138   my($m, $part_svc, $cust_svc) = @_ or return '';
139   my($svc, $label, $svcdb) = $cust_svc->label;
140   svc_X_link( $label, @_ );
141 }
142
143 sub svc_X_link {
144   my ($x, $m, $part_svc, $cust_svc) = @_ or return '';
145
146   return $x
147    unless $FS::CurrentUser::CurrentUser->access_right('View customer services');
148
149   confess "svc_X_link called without a service ($x, $m, $part_svc, $cust_svc)\n"
150     unless $cust_svc;
151
152   my $ahref = svc_url(
153     'ahref'    => 1,
154     'm'        => $m,
155     'action'   => 'view',
156     'part_svc' => $part_svc,
157     'svc'      => $cust_svc,
158   );
159
160   "$ahref$x</A>";
161 }
162
163 #this probably needs an ACL too...
164 sub svc_export_links {
165   my ($m, $part_svc, $cust_svc) = @_ or return '';
166
167   my $ahref = $cust_svc->export_links;
168
169   join('', @$ahref);
170 }
171
172 sub parse_lt_gt {
173   my($cgi, $field) = @_;
174
175   my @search = ();
176
177   my %op = ( 
178     'lt' => '<',
179     'gt' => '>',
180   );
181
182   foreach my $op (keys %op) {
183
184     warn "checking for ${field}_$op field\n"
185       if $DEBUG;
186
187     if ( $cgi->param($field."_$op") =~ /^\s*\$?\s*(-?[\d\,\s]+(\.\d\d)?)\s*$/ ) {
188
189       my $num = $1;
190       $num =~ s/[\,\s]+//g;
191       my $search = "$field $op{$op} $num";
192       push @search, $search;
193
194       warn "found ${field}_$op field; adding search element $search\n"
195         if $DEBUG;
196     }
197
198   }
199
200   @search;
201
202 }
203
204 ###
205 # cust_main report subroutines
206 ###
207
208
209 =item cust_header [ CUST_FIELDS_VALUE ]
210
211 Returns an array of customer information headers according to the supplied
212 customer fields value, or if no value is supplied, the B<cust-fields>
213 configuration value.
214
215 =cut
216
217 use vars qw( @cust_fields @cust_colors @cust_styles @cust_aligns );
218
219 sub cust_header {
220
221   warn "FS::UI:Web::cust_header called"
222     if $DEBUG;
223
224   my $conf = new FS::Conf;
225
226   my %header2method = (
227     'Customer'                 => 'name',
228     'Cust. Status'             => 'ucfirst_cust_status',
229     'Cust#'                    => 'custnum',
230     'Name'                     => 'contact',
231     'Company'                  => 'company',
232
233     # obsolete but might still be referenced in configuration
234     '(bill) Customer'          => 'name',
235     '(service) Customer'       => 'ship_name',
236     '(bill) Name'              => 'contact',
237     '(service) Name'           => 'ship_contact',
238     '(bill) Company'           => 'company',
239     '(service) Company'        => 'ship_company',
240     '(bill) Day phone'         => 'daytime',
241     '(bill) Night phone'       => 'night',
242     '(bill) Fax number'        => 'fax',
243  
244     'Customer'                 => 'name',
245     'Address 1'                => 'bill_address1',
246     'Address 2'                => 'bill_address2',
247     'City'                     => 'bill_city',
248     'State'                    => 'bill_state',
249     'Zip'                      => 'bill_zip',
250     'Country'                  => 'bill_country_full',
251     'Day phone'                => 'daytime', # XXX should use msgcat, but how?
252     'Night phone'              => 'night',   # XXX should use msgcat, but how?
253     'Fax number'               => 'fax',
254     '(bill) Address 1'         => 'bill_address1',
255     '(bill) Address 2'         => 'bill_address2',
256     '(bill) City'              => 'bill_city',
257     '(bill) State'             => 'bill_state',
258     '(bill) Zip'               => 'bill_zip',
259     '(bill) Country'           => 'bill_country_full',
260     '(service) Address 1'      => 'ship_address1',
261     '(service) Address 2'      => 'ship_address2',
262     '(service) City'           => 'ship_city',
263     '(service) State'          => 'ship_state',
264     '(service) Zip'            => 'ship_zip',
265     '(service) Country'        => 'ship_country_full',
266     'Invoicing email(s)'       => 'invoicing_list_emailonly_scalar',
267     'Payment Type'             => 'payby',
268     'Current Balance'          => 'current_balance',
269   );
270   $header2method{'Cust#'} = 'display_custnum'
271     if $conf->exists('cust_main-default_agent_custid');
272
273   my %header2colormethod = (
274     'Cust. Status' => 'cust_statuscolor',
275   );
276   my %header2style = (
277     'Cust. Status' => 'b',
278   );
279   my %header2align = (
280     'Cust. Status' => 'c',
281     'Cust#'        => 'r',
282   );
283
284   my $cust_fields;
285   my @cust_header;
286   if ( @_ && $_[0] ) {
287
288     warn "  using supplied cust-fields override".
289           " (ignoring cust-fields config file)"
290       if $DEBUG;
291     $cust_fields = shift;
292
293   } else {
294
295     if (    $conf->exists('cust-fields')
296          && $conf->config('cust-fields') =~ /^([\w\. \|\#\(\)]+):?/
297        )
298     {
299       warn "  found cust-fields configuration value"
300         if $DEBUG;
301       $cust_fields = $1;
302     } else { 
303       warn "  no cust-fields configuration value found; using default 'Cust. Status | Customer'"
304         if $DEBUG;
305       $cust_fields = 'Cust. Status | Customer';
306     }
307   
308   }
309
310   @cust_header = split(/ \| /, $cust_fields);
311   @cust_fields = map { $header2method{$_} || $_ } @cust_header;
312   @cust_colors = map { exists $header2colormethod{$_}
313                          ? $header2colormethod{$_}
314                          : ''
315                      }
316                      @cust_header;
317   @cust_styles = map { exists $header2style{$_} ? $header2style{$_} : '' }
318                      @cust_header;
319   @cust_aligns = map { exists $header2align{$_} ? $header2align{$_} : 'l' }
320                      @cust_header;
321
322   #my $svc_x = shift;
323   @cust_header;
324 }
325
326 sub cust_sort_fields {
327   cust_header(@_) if( @_ or !@cust_fields );
328   #inefficientish, but tiny lists and only run once per page
329
330   map { $_ eq 'custnum' ? 'custnum' : '' } @cust_fields;
331
332 }
333
334 =item cust_sql_fields [ CUST_FIELDS_VALUE ]
335
336 Returns a list of fields for the SELECT portion of an SQL query.
337
338 As with L<the cust_header subroutine|/cust_header>, the fields returned are
339 defined by the supplied customer fields setting, or if no customer fields
340 setting is supplied, the <B>cust-fields</B> configuration value. 
341
342 =cut
343
344 sub cust_sql_fields {
345
346   my @fields = qw( last first company );
347 #  push @fields, map "ship_$_", @fields;
348
349   cust_header(@_) if( @_ or !@cust_fields );
350   #inefficientish, but tiny lists and only run once per page
351
352   my @location_fields;
353   foreach my $field (qw( address1 address2 city state zip )) {
354     foreach my $pre ('bill_','ship_') {
355       if ( grep { $_ eq $pre.$field } @cust_fields ) {
356         push @location_fields, $pre.'location.'.$field.' AS '.$pre.$field;
357       }
358     }
359   }
360   foreach my $pre ('bill_','ship_') {
361     if ( grep { $_ eq $pre.'country_full' } @cust_fields ) {
362       push @location_fields, $pre.'locationnum';
363     }
364   }
365
366   foreach my $field (qw(daytime night fax payby)) {
367     push @fields, $field if (grep { $_ eq $field } @cust_fields);
368   }
369   push @fields, 'agent_custid';
370
371   my @extra_fields = ();
372   if (grep { $_ eq 'current_balance' } @cust_fields) {
373     push @extra_fields, FS::cust_main->balance_sql . " AS current_balance";
374   }
375
376   map("cust_main.$_", @fields), @location_fields, @extra_fields;
377 }
378
379 =item join_cust_main [ TABLE[.CUSTNUM] ] [ LOCATION_TABLE[.LOCATIONNUM] ]
380
381 Returns an SQL join phrase for the FROM clause so that the fields listed
382 in L<cust_sql_fields> will be available.  Currently joins to cust_main 
383 itself, as well as cust_location (under the aliases 'bill_location' and
384 'ship_location') if address fields are needed.  L<cust_header()> should have
385 been called already.
386
387 All of these will be left joins; if you want to exclude rows with no linked
388 cust_main record (or bill_location/ship_location), you can do so in the 
389 WHERE clause.
390
391 TABLE is the table containing the custnum field.  If CUSTNUM (a field name
392 in that table) is specified, that field will be joined to cust_main.custnum.
393 Otherwise, this function will assume the field is named "custnum".  If the 
394 argument isn't present at all, the join will just say "USING (custnum)", 
395 which might work.
396
397 As a special case, if TABLE is 'cust_main', only the joins to cust_location
398 will be returned.
399
400 LOCATION_TABLE is an optional table name to use for joining ship_location,
401 in case your query also includes package information and you want the 
402 "service address" columns to reflect package addresses.
403
404 =cut
405
406 sub join_cust_main {
407   my ($cust_table, $location_table) = @_;
408   my ($custnum, $locationnum);
409   ($cust_table, $custnum) = split(/\./, $cust_table);
410   $custnum ||= 'custnum';
411   ($location_table, $locationnum) = split(/\./, $location_table);
412   $locationnum ||= 'locationnum';
413
414   my $sql = '';
415   if ( $cust_table ) {
416     $sql = " LEFT JOIN cust_main ON (cust_main.custnum = $cust_table.$custnum)"
417       unless $cust_table eq 'cust_main';
418   } else {
419     $sql = " LEFT JOIN cust_main USING (custnum)";
420   }
421
422   if ( !@cust_fields or grep /^bill_/, @cust_fields ) {
423
424     $sql .= ' LEFT JOIN cust_location bill_location'.
425             ' ON (bill_location.locationnum = cust_main.bill_locationnum)';
426
427   }
428
429   if ( !@cust_fields or grep /^ship_/, @cust_fields ) {
430
431     if (!$location_table) {
432       $location_table = 'cust_main';
433       $locationnum = 'ship_locationnum';
434     }
435
436     $sql .= ' LEFT JOIN cust_location ship_location'.
437             " ON (ship_location.locationnum = $location_table.$locationnum) ";
438   }
439
440   $sql;
441 }
442
443 =item cust_fields OBJECT [ CUST_FIELDS_VALUE ]
444
445 Given an object that contains fields from cust_main (say, from a
446 JOINed search.  See httemplate/search/svc_* for examples), returns an array
447 of customer information, or "(unlinked)" if this service is not linked to a
448 customer.
449
450 As with L<the cust_header subroutine|/cust_header>, the fields returned are
451 defined by the supplied customer fields setting, or if no customer fields
452 setting is supplied, the <B>cust-fields</B> configuration value. 
453
454 =cut
455
456
457 sub cust_fields {
458   my $record = shift;
459   warn "FS::UI::Web::cust_fields called for $record ".
460        "(cust_fields: @cust_fields)"
461     if $DEBUG > 1;
462
463   #cust_header(@_) unless @cust_fields; #now need to cache to keep cust_fields
464   #                                     #override incase we were passed as a sub
465   
466   my $seen_unlinked = 0;
467
468   map { 
469     if ( $record->custnum ) {
470       warn "  $record -> $_" if $DEBUG > 1;
471       encode_entities( $record->$_(@_) );
472     } else {
473       warn "  ($record unlinked)" if $DEBUG > 1;
474       $seen_unlinked++ ? '' : '(unlinked)';
475     }
476   } @cust_fields;
477 }
478
479 =item cust_fields_subs
480
481 Returns an array of subroutine references for returning customer field values.
482 This is similar to cust_fields, but returns each field's sub as a distinct 
483 element.
484
485 =cut
486
487 sub cust_fields_subs {
488   my $unlinked_warn = 0;
489
490   return map { 
491     my $f = $_;
492     if ( $unlinked_warn++ ) {
493
494       sub {
495         my $record = shift;
496         if ( $record->custnum ) {
497           encode_entities( $record->$f(@_) );
498         } else {
499           '(unlinked)'
500         };
501       };
502
503     } else {
504
505       sub {
506         my $record = shift;
507         $record->custnum ? encode_entities( $record->$f(@_) ) : '';
508       };
509
510     }
511
512   } @cust_fields;
513 }
514
515 =item cust_colors
516
517 Returns an array of subroutine references (or empty strings) for returning
518 customer information colors.
519
520 As with L<the cust_header subroutine|/cust_header>, the fields returned are
521 defined by the supplied customer fields setting, or if no customer fields
522 setting is supplied, the <B>cust-fields</B> configuration value. 
523
524 =cut
525
526 sub cust_colors {
527   map { 
528     my $method = $_;
529     if ( $method ) {
530       sub { shift->$method(@_) };
531     } else {
532       '';
533     }
534   } @cust_colors;
535 }
536
537 =item cust_styles
538
539 Returns an array of customer information styles.
540
541 As with L<the cust_header subroutine|/cust_header>, the fields returned are
542 defined by the supplied customer fields setting, or if no customer fields
543 setting is supplied, the <B>cust-fields</B> configuration value. 
544
545 =cut
546
547 sub cust_styles {
548   map { 
549     if ( $_ ) {
550       $_;
551     } else {
552       '';
553     }
554   } @cust_styles;
555 }
556
557 =item cust_aligns
558
559 Returns an array or scalar (depending on context) of customer information
560 alignments.
561
562 As with L<the cust_header subroutine|/cust_header>, the fields returned are
563 defined by the supplied customer fields setting, or if no customer fields
564 setting is supplied, the <B>cust-fields</B> configuration value. 
565
566 =cut
567
568 sub cust_aligns {
569   if ( wantarray ) {
570     @cust_aligns;
571   } else {
572     join('', @cust_aligns);
573   }
574 }
575
576 =item is_mobile
577
578 Utility function to determine if the client is a mobile browser.
579
580 =cut
581
582 sub is_mobile {
583   my $ua = $ENV{'HTTP_USER_AGENT'} || '';
584   if ( $ua =~ /(?:hiptop|Blazer|Novarra|Vagabond|SonyEricsson|Symbian|NetFront|UP.Browser|UP.Link|Windows CE|MIDP|J2ME|DoCoMo|J-PHONE|PalmOS|PalmSource|iPhone|iPod|AvantGo|Nokia|Android|WebOS|S60|Opera Mini|Opera Mobi)/io ) {
585     return 1;
586   }
587   return 0;
588 }
589     
590 ###
591 # begin JSRPC code...
592 ###
593
594 package FS::UI::Web::JSRPC;
595
596 use strict;
597 use vars qw($DEBUG);
598 use Carp;
599 use Storable qw(nfreeze);
600 use MIME::Base64;
601 use JSON::XS;
602 use FS::CurrentUser;
603 use FS::Record qw(qsearchs);
604 use FS::queue;
605 use FS::CGI qw(rooturl);
606
607 $DEBUG = 0;
608
609 sub new {
610         my $class = shift;
611         my $self  = {
612                 env => {},
613                 job => shift,
614                 cgi => shift,
615         };
616
617         bless $self, $class;
618
619         croak "CGI object required as second argument" unless $self->{'cgi'};
620
621         return $self;
622 }
623
624 sub process {
625
626   my $self = shift;
627
628   my $cgi = $self->{'cgi'};
629
630   # XXX this should parse JSON foo and build a proper data structure
631   my @args = $cgi->param('arg');
632
633   #work around konqueror bug!
634   @args = map { s/\x00$//; $_; } @args;
635
636   my $sub = $cgi->param('sub'); #????
637
638   warn "FS::UI::Web::JSRPC::process:\n".
639        "  cgi=$cgi\n".
640        "  sub=$sub\n".
641        "  args=".join(', ',@args)."\n"
642     if $DEBUG;
643
644   if ( $sub eq 'start_job' ) {
645
646     $self->start_job(@args);
647
648   } elsif ( $sub eq 'job_status' ) {
649
650     $self->job_status(@args);
651
652   } else {
653
654     die "unknown sub $sub";
655
656   }
657
658 }
659
660 sub start_job {
661   my $self = shift;
662
663   warn "FS::UI::Web::start_job: ". join(', ', @_) if $DEBUG;
664 #  my %param = @_;
665   my %param = ();
666   while ( @_ ) {
667     my( $field, $value ) = splice(@_, 0, 2);
668     unless ( exists( $param{$field} ) ) {
669       $param{$field} = $value;
670     } elsif ( ! ref($param{$field}) ) {
671       $param{$field} = [ $param{$field}, $value ];
672     } else {
673       push @{$param{$field}}, $value;
674     }
675   }
676   $param{CurrentUser} = $FS::CurrentUser::CurrentUser->username;
677   $param{RootURL} = rooturl($self->{cgi}->self_url);
678   warn "FS::UI::Web::start_job\n".
679        join('', map {
680                       if ( ref($param{$_}) ) {
681                         "  $_ => [ ". join(', ', @{$param{$_}}). " ]\n";
682                       } else {
683                         "  $_ => $param{$_}\n";
684                       }
685                     } keys %param )
686     if $DEBUG;
687
688   #first get the CGI params shipped off to a job ASAP so an id can be returned
689   #to the caller
690   
691   my $job = new FS::queue { 'job' => $self->{'job'} };
692   
693   #too slow to insert all the cgi params as individual args..,?
694   #my $error = $queue->insert('_JOB', $cgi->Vars);
695   
696   #warn 'froze string of size '. length(nfreeze(\%param)). " for job args\n"
697   #  if $DEBUG;
698   #
699   #  XXX FS::queue::insert knows how to do this.
700   #  not changing it here because that requires changing it everywhere else,
701   #  too, but we should eventually fix it
702
703   my $error = $job->insert( '_JOB', encode_base64(nfreeze(\%param)) );
704
705   if ( $error ) {
706
707     warn "job not inserted: $error\n"
708       if $DEBUG;
709
710     $error;  #this doesn't seem to be handled well,
711              # will trigger "illegal jobnum" below?
712              # (should never be an error inserting the job, though, only thing
713              #  would be Pg f%*kage)
714   } else {
715
716     warn "job inserted successfully with jobnum ". $job->jobnum. "\n"
717       if $DEBUG;
718
719     $job->jobnum;
720   }
721   
722 }
723
724 sub job_status {
725   my( $self, $jobnum ) = @_; #$url ???
726
727   sleep 1; # XXX could use something better...
728
729   my $job;
730   if ( $jobnum =~ /^(\d+)$/ ) {
731     $job = qsearchs('queue', { 'jobnum' => $jobnum } );
732   } else {
733     die "FS::UI::Web::job_status: illegal jobnum $jobnum\n";
734   }
735
736   my @return;
737   if ( $job && $job->status ne 'failed' && $job->status ne 'done' ) {
738     my ($progress, $action) = split ',', $job->statustext, 2; 
739     $action ||= 'Server processing job';
740     @return = ( 'progress', $progress, $action );
741   } elsif ( !$job ) { #handle job gone case : job successful
742                       # so close popup, redirect parent window...
743     @return = ( 'complete' );
744   } elsif ( $job->status eq 'done' ) {
745     @return = ( 'done', $job->statustext, '' );
746   } else {
747     @return = ( 'error', $job ? $job->statustext : $jobnum );
748   }
749
750   encode_json \@return;
751
752 }
753
754 1;
755