default to a session cookie instead of setting an explicit timeout, weird timezone... master
authorIvan Kohler <ivan@freeside.biz>
Tue, 18 Jul 2023 23:28:58 +0000 (16:28 -0700)
committerIvan Kohler <ivan@freeside.biz>
Tue, 18 Jul 2023 23:28:58 +0000 (16:28 -0700)
52 files changed:
FS/FS/Auth/internal.pm
FS/FS/AuthCookieHandler.pm
FS/FS/Conf.pm
FS/FS/GeocodeCache.pm
FS/FS/Mason.pm
FS/FS/Misc/Geo.pm
FS/FS/Report/FCC_477.pm
FS/FS/Schema.pm
FS/FS/access_user.pm
FS/FS/cdr.pm
FS/FS/cdr/broadsoft.pm
FS/FS/cdr/broadsoft22.pm
FS/FS/cdr/taqua62.pm
FS/FS/cust_location.pm
FS/FS/cust_main/Search.pm
FS/FS/cust_payby.pm
FS/FS/cust_pkg/Search.pm
FS/FS/deploy_zone.pm
FS/FS/msg_template.pm
FS/FS/part_pkg/voip_cdr.pm
FS/FS/password_history.pm
FS/bin/freeside-cdr-sftp_and_import
FS/bin/freeside-cdrrewrited
FS/bin/freeside-censustract-update
bin/part_pkg-bulk_change
debian/control
fs_selfservice/FS-SelfService/cgi/selfservice.cgi
httemplate/browse/access_user.html
httemplate/browse/deploy_zone.html
httemplate/docs/about.html
httemplate/edit/process/access_user.html
httemplate/edit/process/cust_location-censustract.html
httemplate/edit/process/cust_main.cgi
httemplate/edit/process/elements/ApplicationCommon.html
httemplate/elements/cust_payby.html
httemplate/elements/header-full.html
httemplate/elements/menu.html
httemplate/elements/standardize_locations.js
httemplate/loginout/login.html
httemplate/misc/confirm-censustract.html
httemplate/misc/openmap.html
httemplate/misc/xmlhttp-censustract.html
httemplate/misc/xmlhttp-cust_main-display_recurring.html
httemplate/pref/pref.html
httemplate/pref/set_totp_secret32.html [new file with mode: 0644]
httemplate/search/cust_event.html
httemplate/search/cust_main.html
httemplate/search/log.html
httemplate/search/report_cust_pkg.html
httemplate/view/deploy_zone-geojson.cgi [new file with mode: 0644]
httemplate/view/deploy_zone-kmz.cgi [new file with mode: 0644]
httemplate/view/deploy_zone-shp.cgi [new file with mode: 0644]

index dfc5f30..92dff03 100644 (file)
@@ -7,7 +7,7 @@ use FS::Record qw( qsearchs );
 use FS::access_user;
 
 sub authenticate {
-  my($self, $username, $check_password ) = @_;
+  my($self, $username, $check_password, $totp_code ) = @_;
 
   my $access_user =
     ref($username) ? $username
@@ -17,6 +17,7 @@ sub authenticate {
                              )
     or return 0;
 
+  my $pw_check;
   if ( $access_user->_password_encoding eq 'bcrypt' ) {
 
     my( $cost, $salt, $hash ) = split(',', $access_user->_password);
@@ -29,17 +30,21 @@ sub authenticate {
                                            )
                               );
 
-    $hash eq $check_hash;
+    $pw_check = $hash eq $check_hash;
 
-  } else { 
+  } else {
 
     return 0 if $access_user->_password eq 'notyet'
              || $access_user->_password eq '';
 
-    $access_user->_password eq $check_password;
+    $pw_check = $access_user->_password eq $check_password;
 
   }
 
+  return $pw_check if ! $pw_check || ! length($access_user->totp_secret32);
+
+  #2fa
+  $access_user->google_auth->verify( $totp_code, 1 );
 }
 
 sub autocreate { 0; }
index 93d8ea6..b7d0dbf 100644 (file)
@@ -13,13 +13,13 @@ sub useragent_ip {
 }
 
 sub authen_cred {
-  my( $self, $r, $username, $password ) = @_;
+  my( $self, $r, $username, $password, $totp_code ) = @_;
 
   preuser_setup();
 
   my $info = {};
 
-  unless ( FS::Auth->authenticate($username, $password, $info) ) {
+  unless ( FS::Auth->authenticate($username, $password, $totp_code, $info) ) {
     warn "failed auth $username from ". $self->useragent_ip($r). "\n";
     return undef;
   }
index 73ec5bc..57a8867 100644 (file)
@@ -2452,8 +2452,8 @@ and customer address. Include units.',
 
   {
     'key'         => 'selfservice-timeout',
-    'section'     => 'self-service',
-    'description' => 'Timeout for the self-service login cookie, in seconds.  Defaults to 1 hour.',
+    'section'     => 'deprecated',
+    'description' => 'Deprecated.  Was the timeout for the self-service login cookie, in seconds.  Defaulted to 1 hour.',
     'type'        => 'text',
   },
 
@@ -4588,13 +4588,25 @@ and customer address. Include units.',
 
   {
     'key'         => 'census_year',
-    'section'     => 'addresses',
-    'description' => 'The year to use in census tract lookups.  NOTE: you need to select 2012 or 2013 for Year 2010 Census tract codes.  A selection of 2011 provides Year 2000 Census tract codes.  Use the freeside-censustract-update tool if exisitng customers need to be changed.',
+    'section'     => 'deprecated',
+    'description' => 'Deprecated.  Used to control the year used for census lookups.  2020 census data is now the default.  Use the freeside-censustract-update tool if exisitng customers need to be changed.  See the <a href ="#census_legacy">census_legacy</a> configuration option if you need old census data to re-file pre-2022 FCC 477 reports.',
     'type'        => 'select',
     'select_enum' => [ qw( 2017 2016 2015 ) ],
   },
 
   {
+    'key'         => 'census_legacy',
+    'section'     => 'addresses',
+    'description' => 'Use old census data (and source).  Should only be needed if re-filing pre-2022 FCC 477 reports.',
+    'type'        => 'select',
+    'select_hash' => [ '' => 'Disabled (2020)',
+                       '2015' => '2015',
+                       '2016' => '2016',
+                       '2017' => '2017',
+                     ],
+  },
+
+  {
     'key'         => 'tax_district_method',
     'section'     => 'taxation',
     'description' => 'The method to use to look up tax district codes.',
@@ -5195,6 +5207,14 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'cdr-skip_duplicate_rewrite-sipcallid',
+    'section'     => 'telephony',
+    'description' => 'Use the freeside-cdrrewrited daemon to prevent billing CDRs with a sipcallid identical to an existing CDR',
+    'type'        => 'checkbox',
+  },
+
+
+  {
     'key'         => 'cdr-charged_party_rewrite',
     'section'     => 'telephony',
     'description' => 'Do charged party rewriting in the freeside-cdrrewrited daemon; useful if CDRs are being dropped off directly in the database and require special charged_party processing such as cdr-charged_party-accountcode or cdr-charged_party-truncate*.',
index 7829c4d..9a30f00 100644 (file)
@@ -110,23 +110,23 @@ Look up the censustract, if it's not already filled in, and return it.
 On error, sets 'error' and returns nothing.
 
 This uses the "get_censustract_*" methods in L<FS::Misc::Geo>; currently
-the only one is 'ffiec'.
+available are 'uscensus' (default) or 'ffiec' (legacy, used if the
+census_legacy configuration option is set).
 
 =cut
 
 sub set_censustract {
   my $self = shift;
 
-  if ( $self->get('censustract') =~ /^\d{9}\.\d{2}$/ ) {
+  if ( $self->get('censustract') =~ /^\d{9}(\.\d{2}|\d{6})$/ ) {
     return $self->get('censustract');
   }
-  my $censusyear = $conf->config('census_year');
-  return if !$censusyear;
 
-  my $method = 'ffiec';
-  # configurable censustract-only lookup goes here if it's ever needed.
+  my $year = $conf->config('census_legacy') || 2020;
+  my $method = ($year==2020) ? 'uscensus' : 'ffiec';
+
   $method = "get_censustract_$method";
-  my $censustract = eval { FS::Misc::Geo->$method($self, $censusyear) };
+  my $censustract = eval { FS::Misc::Geo->$method($self, $year) };
   $self->set("censustract_error", $@);
   $self->set("censustract", $censustract);
 }
index 8dd72ac..ebd40ad 100644 (file)
@@ -85,6 +85,7 @@ if ( -e $addl_handler_use_file ) {
     die $@ if $@;
   }
   use Text::CSV_XS;
+  use Archive::Zip;
   use Spreadsheet::WriteExcel;
   use Spreadsheet::WriteExcel::Utility;
   use OLE::Storage_Lite;
@@ -120,7 +121,10 @@ if ( -e $addl_handler_use_file ) {
   use Locale::Currency::Format;
   use Number::Phone::Country qw( noexport );
   use Business::US::USPS::WebTools::AddressStandardization;
-  use Geo::GoogleEarth::Pluggable;
+  use Geo::GoogleEarth::Pluggable 0.16;
+  use Geo::Shapelib;
+  use Geo::JSON;
+  use Geo::JSON::FeatureCollection;
   use LWP::UserAgent;
   use Storable qw( nfreeze thaw );
   use FS;
index bc020a2..6f923f5 100644 (file)
@@ -38,6 +38,10 @@ Given a location hash (see L<FS::location_Mixin>) and a census map year,
 returns a census tract code (consisting of state, county, and tract 
 codes) or an error message.
 
+Data source: Federal Financial Institutions Examination Council
+
+Note: This is the old method for pre-2022 (census year 2020) reporting.
+
 =cut
 
 sub get_censustract_ffiec {
@@ -105,6 +109,84 @@ sub get_censustract_ffiec {
   }
 }
 
+=item get_censustract_uscensus LOCATION YEAR
+
+Given a location hash (see L<FS::location_Mixin>) and a census map year,
+returns a census tract code (consisting of state, county, tract, and block
+codes) or an error message.
+
+Data source: US Census Bureau
+
+This is the new method for 2022+ (census year 2020) reporting.
+
+=cut
+
+sub get_censustract_uscensus {
+  my $class    = shift;
+  my $location = shift;
+  my $year     = shift || 2020;
+
+  if ( length($location->{country}) and uc($location->{country}) ne 'US' ) {
+    return '';
+  }
+
+  warn Dumper($location, $year) if $DEBUG;
+
+  my $url = 'https://geocoding.geo.census.gov/geocoder/geographies/address?';
+
+  my $address1 = $location->{address1};
+  $address1 =~ s/(apt|ste|suite|unit)[\s\d]\w*\s*$//i;
+
+  my $query_hash = {
+                     street     => $address1,
+                     city       => $location->{city},
+                     state      => $location->{state},
+                     benchmark  => 'Public_AR_Current',
+                     vintage    => 'Census'.$year.'_Current',
+                     format     => 'json',
+                   };
+
+  my $full_url = URI->new($url);
+  $full_url->query_form($query_hash);
+
+  warn "Full Request URL: \n".$full_url if $DEBUG;
+
+  my $ua = new LWP::UserAgent;
+  my $res = $ua->get( $full_url );
+
+  warn $res->as_string if $DEBUG > 2;
+
+  if (!$res->is_success) {
+    die 'Census tract lookup error: '.$res->message;
+  }
+
+  local $@;
+  my $content = eval { decode_json($res->content) };
+  die "Census tract JSON error: $@\n" if $@;
+
+  warn Dumper($content) if $DEBUG;
+
+  my $addressMatches_ref = $content->{result}->{addressMatches};
+
+  if ( $addressMatches_ref && scalar @{$addressMatches_ref} ) {
+
+    my $tract = $addressMatches_ref->[0]->{geographies}->{'Census Blocks'}[0]->{GEOID};
+    return $tract;
+
+  } else {
+
+    my $error = 'Lookup failed, but with no status message.';
+
+    if ( $content->{errors} ) {
+      $error = join("\n", $content->{errors});
+    }
+
+    die "$error\n";
+
+  }
+}
+
+
 #sub get_district_methods {
 #  ''         => '',
 #  'wa_sales' => 'Washington sales tax',
@@ -660,6 +742,7 @@ sub subloc_address2 {
   ($subloc, $addr2);
 }
 
+#is anyone still using this?
 sub standardize_melissa {
   my $class = shift;
   my $location = shift;
index 760f6f2..3749bfe 100644 (file)
@@ -382,7 +382,8 @@ sub fbd_sql {
   );
   push @where, "agentnum = $agentnum" if $agentnum;
 
-  my $order_by = 'censusblock, agentnum, technology, is_consumer, is_business';
+  #my $order_by = 'censusblock, agentnum, technology, is_consumer, is_business';
+  my $order_by = 'censusblock, technology';
 
   "SELECT DISTINCT ".join(', ', @select) . "
   FROM $from
@@ -398,7 +399,7 @@ sub fbs_sql {
   my $agentnum = $opt{agentnum};
   my $q = $opt{ignore_quantity} ? '1' : 'COALESCE(cust_pkg.quantity, 1)';
 
-  my $censustract = "replace(cust_location.censustract, '.', '')";
+  my $censustract = "substr( replace(cust_location.censustract, '.', ''), 1, 11)";
 
   my @select = (
     "$censustract AS censustract",
@@ -473,7 +474,7 @@ sub fvs_sql {
   my $date = $opt{date} || time;
   my $agentnum = $opt{agentnum};
   my $q = $opt{ignore_quantity} ? '1' : 'COALESCE(cust_pkg.quantity, 1)';
-  my $censustract = "replace(cust_location.censustract, '.', '')";
+  my $censustract = "substr( replace(cust_location.censustract, '.', ''), 1, 11)";
 
   my @select = (
     "$censustract AS censustract",
index 4ebda4e..61b793b 100644 (file)
@@ -5736,6 +5736,9 @@ sub tables_hashref {
         # FK to cust_bill_pkg_detail; having a value here absolutely means
         # that the CDR appears on an invoice
         'detailnum',     'bigint',    'NULL',      '', '', '',
+
+        #for mediation/deduplication
+        'sipcallid',    'varchar',    'NULL',     255, '', '',
       ],
       'primary_key' => 'acctid',
       'unique' => [],
@@ -5749,7 +5752,7 @@ sub tables_hashref {
                    [ 'freesidestatus' ], [ 'freesiderewritestatus' ],
                    [ 'cdrbatchnum' ],
                    [ 'src_ip_addr' ], [ 'dst_ip_addr' ], [ 'dst_term' ],
-                   [ 'detailnum' ],
+                   [ 'detailnum' ], [ 'sipcallid' ],
                  ],
       #no FKs on cdr table... choosing not to throw errors no matter what's
       # thrown in here.  better to have the data.
@@ -5928,6 +5931,7 @@ sub tables_hashref {
         'username',           'varchar',     '', $char_d, '', '',
         '_password',          'varchar', 'NULL', $char_d, '', '',
         '_password_encoding', 'varchar', 'NULL', $char_d, '', '',
+        'totp_secret32',         'char', 'NULL',      32, '', '',
         'last',               'varchar', 'NULL', $char_d, '', '', 
         'first',              'varchar', 'NULL', $char_d, '', '', 
         'user_custnum',           'int', 'NULL',      '', '', '',
index f23aa77..270f8bb 100644 (file)
@@ -13,6 +13,7 @@ use FS::agent;
 use FS::cust_main;
 use FS::sales;
 use Carp qw( croak );
+use Auth::GoogleAuth;
 
 $DEBUG = 0;
 $me = '[FS::access_user]';
@@ -239,6 +240,7 @@ sub check {
     $self->ut_numbern('usernum')
     || $self->ut_alpha_lower('username')
     || $self->ut_textn('_password')
+    || $self->ut_alphan('totp_secret32')
     || $self->ut_textn('last')
     || $self->ut_textn('first')
     || $self->ut_foreign_keyn('user_custnum', 'cust_main', 'custnum')
@@ -733,6 +735,44 @@ sub change_password_fields {
   FS::Auth->auth_class->change_password_fields( @_ );
 }
 
+=item google_auth
+
+=cut
+
+sub google_auth {
+  my( $self ) = @_;
+  my $issuer = FS::Conf->new->config('company_name'). ' Freeside';
+  my $label = $issuer. ':'. $self->username;
+
+  Auth::GoogleAuth->new({
+    secret => $self->totp_secret32,
+    issuer => $issuer,
+    key_id => $label,
+  });
+
+}
+
+=item set_totp_secret32
+
+=cut
+
+sub set_totp_secret32 {
+  my( $self ) = @_;
+
+  $self->totp_secret32( $self->google_auth->generate_secret32 );
+  $self->replace;
+}
+
+=item totp_qr_code_url
+
+=cut
+
+sub totp_qr_code_url {
+  my( $self ) = @_;
+
+  $self->google_auth->qr_code;
+}
+
 =item locale
 
 =cut
index 85fccac..e0c4bd4 100644 (file)
@@ -185,6 +185,8 @@ following fields are currently supported:
 
 =item detailnum - Link to invoice detail (L<FS::cust_bill_pkg_detail>)
 
+=item sipcallid - SIP Call-ID
+
 =back
 
 =head1 METHODS
@@ -1870,10 +1872,11 @@ sub batch_import {
   my $iopt = _import_options;
   $opt->{$_} = $iopt->{$_} foreach keys %$iopt;
 
-  if ( defined $opt->{'cdrtypenum'} ) {
-        $opt->{'preinsert_callback'} = sub {
-                my($record,$param) = (shift,shift);
-                $record->cdrtypenum($opt->{'cdrtypenum'});
+  if ( grep defined $opt->{$_}, qw(cdrtypenum carrierid) ) {
+        $opt->{preinsert_callback} = sub {
+                my($record, $param) = @_;
+                $record->$_($opt->{$_})
+                  foreach grep defined $opt->{$_}, qw(cdrtypenum carrierid);
                 '';
         };
   }
index a6f4d01..ab48150 100644 (file)
 package FS::cdr::broadsoft;
 
+=head1 NAME
+
+FS::cdr::broadsoft - CDR parse module for Broadsoft
+
+=head1 DESCRIPTION
+
+Ref: BW-AccountingCDRInterfaceSpec-R22.pdf
+
+=cut
+
 use strict;
 use base qw( FS::cdr );
 use vars qw( %info );
 use FS::cdr qw( _cdr_date_parser_maker _cdr_min_parser_maker );
 
 %info = (
-  'name'          => 'Broadsoft',
-  'weight'        => 500,
-  'header'        => 1,     #0 default, set to 1 to ignore the first line, or
-                            # to higher numbers to ignore that number of lines
-  'type'          => 'csv',
-  'sep_char'      => ',',   #for csv, defaults to ,
-  'disabled'      => 0,     #0 default, set to 1 to disable
-
-  #listref of what to do with each field from the CDR, in order
-  'import_fields' => [
-    
+  name     => 'Broadsoft',
+  weight   => 500,
+  header   => 1,
+  type     => 'csv',
+  sep_char => ',',
+  disabled => 0,
+
+  #deal with broadsoft's awful non-standard CSV escaping :/
+  row_callback => sub {
+    my $line = shift;
+    $line = qq("$line");       # put " at the beginning and end
+    $line =~ s/(?<!\\),/","/g; # all commas not after a \ become ","
+    $line =~ s/\\,/,/g;        # and now we can turn \, into ,
+    #XXX embedded \r and \n ?  none in my test data, and might be better to 
+    # leave escaped and deal with it from there?
+    $line =~ s/\\\\/\\/g;     # undo double backslashes?  none in my test data
+
+    #and now we have a properly formed CSV line
+    $line;
+  },
+
+  import_fields => [
+
+    # 1: recordId
+    # 2: serviceProvider
     skip(2),
-    sub { my($cdr, $data, $conf, $param) = @_;
-          $param->{skiprow} = 1 if lc($data) ne 'normal';
-          '' },                                   #  3: type
-            
+
+    # 3: type
+    sub {
+      my ( $cdr, $data, $conf, $param ) = @_;
+      $param->{skiprow} = 1
+        if lc($data) ne 'normal';
+      '';
+    },
+
+    # 4: userNumber
+    # 5: groupNumber
     skip(2),
-    'dcontext',                                          #  6: direction
-    trim('src'),                                  #  7: callingNumber
+
+    # 6: direction
+    'dcontext',
+
+    # 7: callingNumber
+    trim('src'),
+
+    # 8: callingPresentationINdicator
     skip(1),
-    trim('dst'),                                  #  9: calledNumber
 
-    _cdr_date_parser_maker('startdate'),          # 10: startTime
+    # 9: calledNumber
+    trim('dst'),
+
+    # 10: startTime
+    _cdr_date_parser_maker('startdate'),
+
+    # 11: userTimeZone
     skip(1),
-    sub { my($cdr, $data) = @_;
-          $cdr->disposition(
-            lc($data) eq 'yes' ? 
-            'ANSWERED' : 'NO ANSWER') },          # 12: answerIndicator
-    _cdr_date_parser_maker('answerdate'),         # 13: answerTime
-    _cdr_date_parser_maker('enddate'),            # 14: releaseTime
-    skip(17),
-    sub { my($cdr, $accountcode) = @_;
-    if ($cdr->is_tollfree){
-       my $dst = substr($cdr->dst,0,32);
-        $cdr->set('accountcode', $dst);
-    } else {
-        $cdr->set('accountcode', $accountcode);
-    }},
+
+    # 12: answerIndicator
+    sub {
+      my( $cdr, $data ) = @_;
+      $cdr->disposition( $data =~ /^yes/i ? 'ANSWERED' : 'NO ANSWER');
+    },
+
+    # 13: answerTime
+    _cdr_date_parser_maker('answerdate'),
+
+    # 14: releaseTime
+    _cdr_date_parser_maker('enddate'),
+
+    # 15: terminationCause
+    # 16: networkType
+    # 17: carrierIdentificationCode
+    # 18: dialedDigits
+    # 19: callCategory
+    # 20: networkCallType
+    # 21: networkTranslatedNumber
+    # 22: networkTranslatedGroup
+    # 23: releasingParty
+    # 24: route
+    skip(10),
+
+    # 25: networkCallID
+    'sipcallid',
+
+    # 26: codedc
+    # 27: accessDeviceAddress
+    # 28: accessCallID
+    # 29: spare
+    # 30: failoverCorrelationId
+    # 31: spare
+    # 32: group
+    # 33: department
+    skip(8),
+
+    # 34: accountCode
+    'accountcode',
+
+    # 35: authorizationCode
+    # 36: originalCalledNumber
+    # 37: originalCalledPresentationIndicator
+    # 38: originalCalledReason
+    # 39: redirectingNumber
+    # 40: redirectingPresentationIndicator
+    # 41: redirectingReason
+    # 42: chargeIndicator
+    # 43: typeOfNetwork
+    # 44: voicePortalCalling.invocationTime
+    # 45: localCallId
+    # 46: remoteCallId
+    # 47: callingPartyCategory
+    #
+    # Also... cols 48 - 448 see Broadsoft documentation
+    skip(87), #35-121 inclusive
+
+    #122: otherPartyName
+    'clid',
+
+    #123: otherPartyNamePresentationIndicator
+    sub {
+      my( $cdr, $data ) = @_;
+      $cdr->clid( $data ) unless $data =~ /^Public$/i;
+    },
+
+    skip(22), #124-145 inclusive
+
+    # 146: chargedNumber
+    'charged_party',
+
   ],
 
 );
index 437d31e..a90e592 100644 (file)
 package FS::cdr::broadsoft22;
 
-=head1 NAME
-
-FS::cdr::broadsoft22 - CDR parse module for Broadsoft R22.0
-
-=head1 DESCRIPTION
-
-Ref: BW-AccountingCDRInterfaceSpec-R22.pdf
-
-=cut
-
 use strict;
 use base qw( FS::cdr );
 use vars qw( %info );
-use FS::cdr qw( _cdr_date_parser_maker _cdr_min_parser_maker );
 
 %info = (
-  name     => 'Broadsoft R22',
-  weight   => 500,
-  header   => 1,
-  type     => 'csv',
-  sep_char => ',',
-  disabled => 0,
-
-  import_fields => [
-
-    # 1: recordId
-    # 2: serviceProvider
-    skip(2),
-
-    # 3: type
-    sub {
-      my ( $cdr, $data, $conf, $param ) = @_;
-      $param->{skiprow} = 1
-        if lc($data) ne 'normal';
-      '';
-    },
-
-    # 4: userNumber
-    # 5: groupNumber
-    skip(2),
-
-    # 6: direction
-    'dcontext',
-
-    # 7: callingNumber
-    trim('src'),
-
-    # 8: callingPresentationINdicator
-    skip(1),
-
-    # 9: calledNumber
-    trim('dst'),
-
-    # 10: startTime
-    _cdr_date_parser_maker('startdate'),
-
-    # 11: userTimeZone
-    skip(1),
-
-    # 12: answerIndicator
-    sub {
-      my( $cdr, $data ) = @_;
-      $cdr->disposition( lc($data) eq 'yes' ? 'ANSWERED' : 'NO ANSWER');
-    },
-
-    # 13: answerTime
-    _cdr_date_parser_maker('answerdate'),
-
-    # 14: releaseTime
-    _cdr_date_parser_maker('enddate'),
-
-    # 15: terminationCause
-    # 16: networkType
-    # 17: carrierIdentificationCode
-    # 18: dialedDigits
-    # 19: callCategory
-    # 20: networkCallType
-    # 21: networkTranslatedNumber
-    # 22: networkTranslatedGroup
-    # 23: releasingParty
-    # 24: route
-    # 25: networkCallID
-    # 26: codedc
-    # 27: accessDeviceAddress
-    # 28: accessCallID
-    # 29: spare
-    # 30: failoverCorrelationId
-    # 31: spare
-    # 32: group
-    # 33: department
-    skip(19),
-
-    # 34: accountCode
-    sub {
-      my( $cdr, $data ) = @_;
-      $cdr->set(
-        'accountcode',
-        $cdr->is_tollfree ? substr( $cdr->dst, 0, 32 ) : $data
-      );
-    },
-
-    # 35: authorizationCode
-    # 36: originalCalledNumber
-    # 37: originalCalledPresentationIndicator
-    # 38: originalCalledReason
-    # 39: redirectingNumber
-    # 40: redirectingPresentationIndicator
-    # 41: redirectingReason
-    # 42: chargeIndicator
-    # 43: typeOfNetwork
-    # 44: voicePortalCalling.invocationTime
-    # 45: localCallId
-    # 46: remoteCallId
-    # 47: callingPartyCategory
-    #
-    # Also... cols 48 - 448 see Broadsoft documentation
-
-  ],
-
+  name     => 'Broadsoft R22 (deprecated)',
+  disabled => 1,
 );
 
-sub trim {
-  my $fieldname = shift;
-  return sub {
-    my($cdr, $data) = @_;
-    $data =~ s/^\+1//;
-    $cdr->$fieldname($data);
-    ''
-  }
-}
-
-sub skip {
-  map { undef } (1..$_[0]);
-}
-
 1;
-
-__END__
-
-list of freeside CDR fields, useful ones marked with *
-
-           acctid - primary key
-    *[1]   calldate - Call timestamp (SQL timestamp)
-           clid - Caller*ID with text
-7   *      src - Caller*ID number / Source number
-9   *      dst - Destination extension
-           dcontext - Destination context
-           channel - Channel used
-           dstchannel - Destination channel if appropriate
-           lastapp - Last application if appropriate
-           lastdata - Last application data
-10  *      startdate - Start of call (UNIX-style integer timestamp)
-13         answerdate - Answer time of call (UNIX-style integer timestamp)
-14  *      enddate - End time of call (UNIX-style integer timestamp)
-    *      duration - Total time in system, in seconds
-    *      billsec - Total time call is up, in seconds
-12  *[2]   disposition - What happened to the call: ANSWERED, NO ANSWER, BUSY
-           amaflags - What flags to use: BILL, IGNORE etc, specified on a per
-           channel basis like accountcode.
-4   *[3]   accountcode - CDR account number to use: account
-           uniqueid - Unique channel identifier
-           userfield - CDR user-defined field
-           cdr_type - CDR type - see FS::cdr_type (Usage = 1, S&E = 7, OC&C = 8)
-    *[4]   charged_party - Service number to be billed
-           upstream_currency - Wholesale currency from upstream
-    *[5]   upstream_price - Wholesale price from upstream
-           upstream_rateplanid - Upstream rate plan ID
-           rated_price - Rated (or re-rated) price
-           distance - km (need units field?)
-           islocal - Local - 1, Non Local = 0
-    *[6]   calltypenum - Type of call - see FS::cdr_calltype
-           description - Description (cdr_type 7&8 only) (used for
-           cust_bill_pkg.itemdesc)
-           quantity - Number of items (cdr_type 7&8 only)
-           carrierid - Upstream Carrier ID (see FS::cdr_carrier)
-           upstream_rateid - Upstream Rate ID
-           svcnum - Link to customer service (see FS::cust_svc)
-           freesidestatus - NULL, done (or something)
-
-[1] Auto-populated from startdate if not present
-[2] Package options available to ignore calls without a specific disposition
-[3] When using 'cdr-charged_party-accountcode' config
-[4] Auto-populated from src (normal calls) or dst (toll free calls) if not present
-[5] When using 'upstream_simple' rating method.
-[6] Set to usage class classnum when using pre-rated CDRs and usage class-based
-    taxation (local/intrastate/interstate/international)
index aa94630..c6a40ed 100644 (file)
@@ -159,7 +159,7 @@ use FS::cdr qw(_cdr_date_parser_maker);
 
     #60
     
-    '',       #OrigIPCallID
+    'sipcallid',       #OrigIPCallID
     '',       #ESAIPTrunkGroup
     '',       #ESAReason
     '',       #BearerlessCall
index 21bf92f..73821cc 100644 (file)
@@ -252,7 +252,7 @@ sub insert {
   }
 
   if ( $self->censustract ) {
-    $self->set('censusyear' => $conf->config('census_year') || 2012);
+    $self->set('censusyear' => $conf->config('census_legacy') || 2020);
   }
 
   my $oldAutoCommit = $FS::UID::AutoCommit;
@@ -419,10 +419,13 @@ sub check {
   ;
   return $error if $error;
   if ( $self->censustract ne '' ) {
-    $self->censustract =~ /^\s*(\d{9})\.?(\d{2})\s*$/
-      or return "Illegal census tract: ". $self->censustract;
-
-    $self->censustract("$1.$2");
+    if ( $self->censustract =~ /^\s*(\d{9})\.?(\d{2})\s*$/ ) { #old
+      $self->censustract("$1.$2");
+    } elsif ($self->censustract =~ /^\s*(\d{15})\s*$/ ) { #new
+      $self->censustract($1);
+    } else {
+      return "Illegal census tract: ". $self->censustract;
+    }
   }
 
   #yikes... this is ancient, pre-dates cust_location and will be harder to
@@ -879,7 +882,7 @@ sub process_censustract_update {
     qsearchs( 'cust_location', { locationnum => $locationnum })
       or die "locationnum '$locationnum' not found!\n";
 
-  my $new_year = $conf->config('census_year') or return;
+  my $new_year = $conf->config('census_legacy') || 2020;
   my $loc = FS::GeocodeCache->new( $cust_location->location_hash );
   $loc->set_censustract;
   my $error = $loc->get('censustract_error');
index 25216c6..37b8ec8 100644 (file)
@@ -790,6 +790,20 @@ sub search {
   }
 
   ##
+  # no_censustract
+  ##
+  if ( $params->{'no_censustract'} ) {
+    push @where, "EXISTS(
+      SELECT 1 FROM cust_location
+      WHERE locationnum = cust_main.ship_locationnum
+        AND cust_location.country = 'US'
+        AND (    cust_location.censusyear IS NULL
+              OR cust_location.censusyear != '2020'
+            )
+    )";
+  }
+
+  ##
   # phones
   ##
 
index 410d690..03864ef 100644 (file)
@@ -160,9 +160,8 @@ sub insert {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $error =  $self->check_payinfo_cardtype if $self->payby =~/^(CARD|DCRD)$/;
-  $self->SUPER::insert unless $error;
-
+  my $error =  $self->check_payinfo_cardtype
+            || $self->SUPER::insert;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -348,14 +347,16 @@ sub check {
   if ( !$ignore_invalid_card && 
     $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
 
-    my $payinfo = $self->payinfo;
-    $payinfo =~ s/\D//g;
-    $payinfo =~ /^(\d{13,19}|\d{8,9})$/
-      or return gettext('invalid_card'); #. ": ". $self->payinfo;
-    $payinfo = $1;
-    $self->payinfo($payinfo);
-    validate($payinfo)
-      or return gettext('invalid_card'); # . ": ". $self->payinfo;
+    unless ( $self->tokenized ) {
+      my $payinfo = $self->payinfo;
+      $payinfo =~ s/\D//g;
+      $payinfo =~ /^(\d{13,19}|\d{8,9})$/
+        or return gettext('invalid_card'); #. ": ". $self->payinfo;
+      $payinfo = $1;
+      $self->payinfo($payinfo);
+      validate($payinfo)
+        or return gettext('invalid_card'); # . ": ". $self->payinfo;
+    }
 
     # see parallel checks in check_payinfo_cardtype & payinfo_Mixin::payinfo_check
     my $cardtype = $self->paycardtype;
@@ -557,7 +558,7 @@ sub check_payinfo_cardtype {
 
   return '' if $ignore_cardtype;
 
-  return '' unless $self->payby =~ /^(CARD|CHEK)$/;
+  return '' unless $self->payby =~ /^(CARD|DCRD)$/;
 
   my $payinfo = $self->payinfo;
   $payinfo =~ s/\D//g;
index 3e1ca82..c5a7eb9 100644 (file)
@@ -402,9 +402,12 @@ sub search {
     my $op = $params->{location_cust} ? '=' : '!=';
     push @where, "cust_location.locationnum $op cust_main.ship_locationnum";
   }
-  if ( $params->{location_census} xor $params->{location_nocensus} ) {
-    my $op = $params->{location_census} ? "IS NOT NULL" : "IS NULL";
-    push @where, "cust_location.censustract $op";
+  if ( $params->{location_census} ) {
+    push @where, "cust_location.censustract IS NOT NULL",
+                 "cust_location.censusyear  =  '2020'  ";
+  } elsif ( $params->{location_nocensus} ) {
+    push @where, "(    cust_location.censustract IS NULL    ".
+                 "  OR cust_location.censusyear  != '2020' )";
   }
   if ( $params->{location_geocode} xor $params->{location_nogeocode} ) {
     my $op = $params->{location_geocode} ? "IS NOT NULL" : "IS NULL";
index 723b491..c618fb9 100644 (file)
@@ -10,8 +10,12 @@ use Cpanel::JSON::XS;
 use LWP::UserAgent;
 use HTTP::Request::Common;
 
-# update this in 2020, along with the URL for the TIGERweb service
-our $CENSUS_YEAR = 2010;
+use Geo::JSON::Polygon;
+use Geo::JSON::Feature;
+
+our $CENSUS_YEAR = 2020;
+
+our $tech_label  = FS::part_pkg_fcc_option->technology_labels;
 
 =head1 NAME
 
@@ -275,6 +279,27 @@ sub deploy_zone_vertex {
   });
 }
 
+=item shapefile_add SHAPEFILE
+
+Adds this deployment zone to the supplied Geo::Shapelib shapefile.
+
+=cut
+
+sub shapefile_add {
+  my( $self, $shapefile ) = @_;
+
+  my @coordinates = map { [ $_->longitude, $_->latitude, 0, 0 ] }
+                      $self->deploy_zone_vertex;
+  push @coordinates, $coordinates[0];
+
+  push @{$shapefile->{Shapes}}, { 'Vertices' => \@coordinates };
+  push @{$shapefile->{ShapeRecords}}, [ $tech_label->{$self->technology},
+                                        $self->adv_speed_down,
+                                        $self->adv_speed_up,
+                                      ];
+  '';
+}
+
 =item vertices_json
 
 Returns the vertex list for this zone, as a JSON string of
@@ -289,6 +314,51 @@ sub vertices_json {
   encode_json(\@vertices);
 }
 
+=item geo_json_feature
+
+Returns this zone as a Geo::JSON::Feature object
+
+=cut
+
+sub geo_json_feature {
+  my $self = shift;
+
+  my @coordinates = map { [ $_->longitude, $_->latitude ] }
+                      $self->deploy_zone_vertex;
+  push @coordinates, $coordinates[0];
+
+  Geo::JSON::Feature->new({
+    geometry   => Geo::JSON::Polygon->new({ coordinates => [ \@coordinates ] }),
+    properties => { 'Technology' => $tech_label->{$self->technology},
+                    'Down'       => $self->adv_speed_down,
+                    'Up'         => $self->adv_speed_up,
+                  },
+  })
+}
+
+=item kml_add
+
+Adds this deployment zone to the supplied Geo::GoogleEarth::Pluggable object.
+
+=cut
+
+sub kml_polygon {
+  my( $self, $kml ) = @_;
+
+  my $name = $self->description. ' ('. $self->adv_speed_down. '/'.
+                                       $self->adv_speed_up. ')';
+
+  $kml->Polygon( 'name'        => $name,
+                 'coordinates' => [ [ #outerBoundary
+                                      map { [ $_->longitude, $_->latitude, 0 ] }
+                                        $self->deploy_zone_vertex
+                                    ],
+                                    #[ #innerBoundary
+                                    #]
+                                  ]
+               );
+}
+
 =head2 SUBROUTINES
 
 =over 4
@@ -386,7 +456,7 @@ sub process_block_lookup {
     inSR            => 4326,
     outSR           => 4326,
     spatialRel      => 'esriSpatialRelIntersects', # the test to perform
-    outFields       => 'OID,GEOID',
+    outFields       => 'GEOID',
     returnGeometry  => 'false',
     orderByFields   => 'OID',
   );
@@ -410,16 +480,12 @@ sub process_block_lookup {
 
   #warn "Census block lookup: $count\n";
 
-  # we have to do our own pagination on this, because the census bureau
-  # doesn't support resultOffset (maybe they don't have ArcGIS 10.3 yet).
-  # that's why we're ordering by OID, it's globally unique
-  my $last_oid = 0;
   my $done = 0;
   while (!$done) {
     $response = $ua->request(
       POST $url, Content => [
         %query,
-        where => "OID>$last_oid",
+        resultOffset => $inserted,
       ]
     );
     die $response->status_line unless $response->is_success;
@@ -444,7 +510,6 @@ sub process_block_lookup {
     }
 
     #warn "Inserted $inserted records\n";
-    $last_oid = $data->{features}[-1]{attributes}{OID};
     $done = 1 unless $data->{exceededTransferLimit};
   }
 
index 0a16724..33e150a 100644 (file)
@@ -11,6 +11,7 @@ use FS::cust_msg;
 use FS::template_content;
 
 use Date::Format qw(time2str);
+use PDF::WebKit;
 
 FS::UID->install_callback( sub { $conf = new FS::Conf; } );
 
@@ -411,8 +412,6 @@ Options are as for 'prepare', but 'from' and 'to' are meaningless.
 
 sub render {
   my $self = shift;
-  eval "use PDF::WebKit";
-  die $@ if $@;
   my %opt = @_;
   my %hash = $self->prepare(%opt);
   my $html = $hash{'html_body'};
index 3e2082b..d91c4f0 100644 (file)
@@ -196,6 +196,10 @@ tie my %accountcode_tollfree_field, 'Tie::IxHash',
     'skip_dcontext' => { 'name' => 'Do not charge for CDRs where dcontext is set to any of these (comma-separated) values: ',
                        },
 
+    'noskip_dcontext_tollfree' => { 'name' => 'Do charge for CDRs where dcontext is set to any of the specified values, if the CDR is tollfree',
+                                                  'type' => 'checkbox',
+                                                },
+
     'skip_dcontext_prefix' => { 'name' => 'Do not charge for CDRs where dcontext starts with: ',
                        },
 
@@ -231,6 +235,10 @@ tie my %accountcode_tollfree_field, 'Tie::IxHash',
     'skip_dst_length_less' => { 'name' => 'Do not charge for CDRs where the destination is less than this many digits:',
                               },
 
+    'noskip_dst_length_n11' => { 'name' => 'Do charge for CDRs where dst is less than the specified digits, when dst is N11 (i.e. 411, 611)',
+                                                  'type' => 'checkbox',
+                                                },
+
     'noskip_dst_length_accountcode_tollfree' => { 'name' => 'Do charge for CDRs where dst is less than the specified digits, when accountcode is toll free',
                                                   'type' => 'checkbox',
                                                 },
@@ -347,12 +355,13 @@ tie my %accountcode_tollfree_field, 'Tie::IxHash',
                        use_cdrtypenum ignore_cdrtypenum
                        use_calltypenum ignore_calltypenum
                        ignore_disposition disposition_in disposition_prefix
-                       skip_dcontext skip_dcontext_prefix skip_dcontext_suffix
+                       skip_dcontext noskip_dcontext_tollfree
+                       skip_dcontext_prefix skip_dcontext_suffix
                        skip_dst_prefix 
                        skip_dstchannel_prefix skip_src_length_more 
                        noskip_src_length_accountcode_tollfree
                        accountcode_tollfree_ratenum accountcode_tollfree_field
-                       skip_dst_length_less
+                       skip_dst_length_less noskip_dst_length_n11
                        noskip_dst_length_accountcode_tollfree
                        skip_lastapp
                        skip_max_callers
@@ -447,8 +456,6 @@ sub calc_usage {
     rounding  => ($self->option_cacheable('rounding') || 2),
   );
 
-  my $use_duration = $self->option('use_duration');
-
   my($svc_table, $svc_field, $by_ip_addr) = split('\.', $cdr_svc_method);
 
   my @cust_svc;
@@ -625,7 +632,10 @@ sub check_chargable {
 
   return "dcontext IN ( ". $self->option_cacheable('skip_dcontext'). " )"
     if $self->option_cacheable('skip_dcontext') =~ /\S/
-    && grep { $cdr->dcontext eq $_ } split(/\s*,\s*/, $self->option_cacheable('skip_dcontext'));
+    && grep { $cdr->dcontext eq $_ } split(/\s*,\s*/, $self->option_cacheable('skip_dcontext'))
+    && ! (    $self->option_cacheable('noskip_dcontext_tollfree')
+           && $cdr->is_tollfree
+         );
 
   my $len_dcontext_prefix =
     length($self->option_cacheable('skip_dcontext_prefix'));
@@ -646,8 +656,11 @@ sub check_chargable {
   my $dst_length = $self->option_cacheable('skip_dst_length_less');
   return "destination less than $dst_length digits"
     if $dst_length && length($cdr->dst) < $dst_length
-    && ! ( $self->option_cacheable('noskip_dst_length_accountcode_tollfree')
-            && $cdr->is_tollfree('accountcode')
+    && ! (    $self->option_cacheable('noskip_dst_length_n11')
+           && $cdr->dst =~ /^\d11$/
+         )
+    && ! (    $self->option_cacheable('noskip_dst_length_accountcode_tollfree')
+           && $cdr->is_tollfree('accountcode')
          );
 
   return "lastapp is ". $self->option_cacheable('skip_lastapp')
index 13d1601..1915a21 100644 (file)
@@ -173,6 +173,7 @@ sub _upgrade_schema {
     push @where, "
       ( $fk IS NOT NULL AND NOT EXISTS(SELECT 1 FROM $table WHERE $table.$key = $fk) )";
   }
+  return '' unless @where;
   my @recs = qsearch({
       'table'     => 'password_history',
       'extra_sql' => ' WHERE ' . join(' AND ', @where),
index aa4bf64..f2bf294 100755 (executable)
@@ -12,8 +12,8 @@ use FS::cdr;
 # parse command line
 ###
 
-use vars qw( $opt_m $opt_p $opt_r $opt_e $opt_d $opt_v $opt_P $opt_a $opt_c $opt_g $opt_s $opt_b );
-getopts('c:m:p:r:e:d:v:P:agsb');
+use vars qw( $opt_m $opt_p $opt_r $opt_e $opt_d $opt_v $opt_P $opt_a $opt_c $opt_i $opt_g $opt_s $opt_b );
+getopts('c:i:m:p:r:e:d:v:P:agsb');
 
 $opt_e ||= 'csv';
 #$opt_e = ".$opt_e" unless $opt_e =~ /^\./;
@@ -21,7 +21,8 @@ $opt_e =~ s/^\.//;
 
 $opt_p ||= '';
 
-die "invalid cdrtypenum" if $opt_c && $opt_c !~ /^\d+$/;
+die "invalid cdrtypenum" if defined $opt_c && $opt_c !~ /^\d+$/;
+die "invalid carrierid"  if defined $opt_i && $opt_i !~ /^\d+$/;
 
 my %options = ();
 
@@ -113,7 +114,8 @@ foreach my $filename ( @$ls ) {
     'batch_namevalue' => $file_timestamp,
     'empty_ok'        => 1,
   };
-  $import_options->{'cdrtypenum'} = $opt_c if $opt_c;
+  $import_options->{'cdrtypenum'} = $opt_c if defined $opt_c;
+  $import_options->{'carrierid'}  = $opt_i if defined $opt_i;
   
   my $error = FS::cdr::batch_import($import_options);
 
@@ -162,7 +164,7 @@ foreach my $filename ( @$ls ) {
 
 sub usage {
   "Usage:
-  cdr.sftp_and_import [ -m method ] [ -p prefix ] [ -e extension ] 
+  freeside-cdr-sftp_and_import [ -m method ] [ -p prefix ] [ -e extension ]
     [ -r remotefolder ] [ -d donefolder ] [ -v level ] [ -P port ]
     [ -a ] [ -g ] [ -s ] [ -c cdrtypenum ] user format [sftpuser@]servername
   ";
@@ -205,7 +207,8 @@ freeside-cdr-sftp_and_import - Download CDR files from a remote server via SFTP
 
   freeside-cdr-sftp_and_import [ -m method ] [ -p prefix ] [ -e extension ] 
     [ -r remotefolder ] [ -d donefolder ] [ -v level ] [ -P port ]
-    [ -a ] [ -g ] [ -s ] [ -c cdrtypenum ] user format [sftpuser@]servername
+    [ -a ] [ -g ] [ -s ] [ -c cdrtypenum ] [ -i carrierid]
+    user format [sftpuser@]servername
 
 =head1 DESCRIPTION
 
@@ -233,6 +236,8 @@ or FTP and then import them into the database.
 
 -c: cdrtypenum to set, defaults to none
 
+-i: carrierid to set, defaults to none
+
 -g: File is gzipped
 
 -s: Warn and skip files which could not be imported rather than abort
index d117f56..bcc7615 100755 (executable)
@@ -54,7 +54,8 @@ while (1) {
 
   #order matters for removing dupes--only the first is preserved
   $extra_sql .= ' ORDER BY acctid '
-    if $conf->exists('cdr-skip_duplicate_rewrite');
+    if $conf->exists('cdr-skip_duplicate_rewrite')
+    || $conf->exists('cdr-skip_duplicate_rewrite-sipcallid');
 
   my $found = 0;
   my %skip = (); #used only by taqua
@@ -92,6 +93,22 @@ while (1) {
       }
     }
 
+    if ($conf->exists('cdr-skip_duplicate_rewrite-sipcallid')) {
+      my $sth = dbh->prepare(
+        'SELECT 1 FROM cdr WHERE sipcallid=? AND acctid < ? LIMIT 1'
+      ) or die dbh->errstr;
+      $sth->execute($cdr->sipcallid, $cdr->acctid) or die $sth->errstr;
+      my $isdup = $sth->fetchrow_hashref;
+      $sth->finish;
+      if ($isdup) {
+        #we only act on this cdr, not touching previous dupes
+        #if a dupe somehow creeped in previously, too late to fix it
+        $cdr->freesidestatus('skipped'); #prevent it from being billed
+        push(@status,'duplicate');
+      }
+    }
+
+
     if ( $conf->exists('cdr-asterisk_forward_rewrite')
          && $cdr->dstchannel =~ /^Local\/(\d+)/i && $1 ne $cdr->dst
        )
@@ -268,6 +285,7 @@ sub _shouldrun {
   || $conf->exists('cdr-intl_to_domestic_rewrite')
   || $conf->exists('cdr-userfield_dnis_rewrite')
   || $conf->exists('cdr-skip_duplicate_rewrite')
+  || $conf->exists('cdr-skip_duplicate_rewrite-sipcallid')
   || 0
   ;
 }
@@ -296,6 +314,11 @@ of the following config options are enabled:
 Marks as 'skipped' (prevents billing for) any CDRs with 
 a src, dst and calldate identical to an existing CDR
 
+=item cdr-skip_duplicate_rewrite-sipcallid
+
+Marks as 'skipped' (prevents billing for) any CDRs with 
+a sipcallid identical to an existing CDR
+
 =item cdr-asterisk_australia_rewrite
 
 Classifies Australian numbers as domestic, mobile, tollfree, international, or
index f9b6d11..49505ee 100755 (executable)
@@ -18,8 +18,7 @@ $FS::UID::AutoCommit = 0;
 my $dbh = dbh;
 
 my $conf = FS::Conf->new;
-my $current_year = $conf->config('census_year') 
-  or die "No current census year configured.\n";
+my $current_year = $conf->config('census_legacy') || '2020';
 my $date = str2time($opt{d}) if $opt{d};
 $date ||= time;
 # This now operates on cust_location, not cust_main.
@@ -36,9 +35,14 @@ my %h_cust_location = map { $_->locationnum => $_ }
 
 # Find all locations that don't have censusyear = the current
 # year as of now.
-my @cust_location = qsearch( 'cust_location',
-  { censusyear => { op => '!=', value => $current_year } },
-);
+my @cust_location = qsearch({
+     'table'     => 'cust_location',
+     'hashref'   => { 'country' => 'US', },
+     'extra_sql' => " AND (    censusyear  != '$current_year'
+                            OR censustract IS NULL
+                          )
+                    ",
+});
 
 warn scalar(@cust_location)." records found.\n";
 my $queued = 0; my $updated = 0;
@@ -85,8 +89,8 @@ freeside-censustract-update - Update census tract codes to the current year.
 =head1 DESCRIPTION
 
 Finds all customers whose census tract codes don't appear to be current 
-and updates them to the current year.  The "current year" is defined by 
-the I<census_tract> configuration variable, not the calendar year.
+and updates them to the current year.  The "current year" is 2020, unless the
+I<census_legacy> configuration variable is set.
 
 The -d option tells the script to assume that tract codes last modified
 after some date are already current.  Those customers will just have 
index 5347da6..582ab39 100755 (executable)
@@ -39,6 +39,12 @@ foreach my $part_pkg ( qsearch({ 'table'     => 'part_pkg',
 
     my $part_pkg_option = qsearchs('part_pkg_option', \%hash);
 
+    unless ( defined $opt_v ) {
+      my $error = $part_pkg_option && $part_pkg_option->delete;
+      die $error if $error;
+      next;
+    }
+
     if ( $part_pkg_option ) {
       next if $part_pkg_option->optionvalue eq $opt_v;
       $part_pkg_option->optionvalue($opt_v);
@@ -130,7 +136,7 @@ Search options:
 
 Change options:
 
--o: part_pkg_option optionname
+-o: part_pkg_option optionname (use without -v to unset)
 
 -v: part_pkg_option optionvalue
 
index f4795fc..043294f 100644 (file)
@@ -78,7 +78,8 @@ Depends: aspell-en,gnupg,ghostscript,gsfonts,gzip,
  libipc-run-safehandles-perl,libpoe-perl,libsoap-lite-perl,libxmlrpc-lite-perl,
  libhtml-tableextract-perl,libhtml-element-extended-perl,libcam-pdf-perl,
  libnet-openssh-perl,libgd-barcode-perl,sam2p,libsys-sigaction-perl,
- libgeo-googleearth-pluggable-perl,libgeo-coder-googlev3-perl,libnet-snmp-perl,
+ libgeo-googleearth-pluggable-perl (>=0.16),libgeo-coder-googlev3-perl,
+ libnet-snmp-perl,
  libcrypt-openssl-rsa-perl,libregexp-common-perl,libnet-cidr-perl,
  libregexp-ipv6-perl,libhtml-quoted-perl,libtext-password-pronounceable-perl,
  libconvert-color-perl,liburi-perl,libhtml-rewriteattributes-perl,
@@ -106,7 +107,9 @@ Depends: aspell-en,gnupg,ghostscript,gsfonts,gzip,
  libnet-vitelity-perl (>= 0.05), libnet-sslglue-perl, libexpect-perl,
  libspreadsheet-parsexlsx-perl, libunicode-truncate-perl (>= 0.303-1),
  libspreadsheet-xlsx-perl, libpod-simple-perl, libwebservice-northern911-perl,
- liblocale-codes-perl, liblocale-po-perl
+ liblocale-codes-perl, liblocale-po-perl, libgeo-uscensus-geocoding-perl,
+ libnet-sftp-foreign-perl, libpdf-webkit-perl, libgeo-shapelib-perl,
+ libgeo-json-perl, libauth-googleauth-perl
 Conflicts: libparams-classify-perl (>= 0.013-6)
 Replaces: freeside (<<4)
 Breaks: freeside (<<4)
index 6eab11d..b1fea7d 100755 (executable)
@@ -1250,10 +1250,8 @@ sub do_template {
   $fill_in->{$_} = $access_info->{$_} foreach keys %$access_info;
 
   # update the user's authentication
-  my $timeout = $access_info->{'timeout'} || '3600';
   my $cookie = CGI::Cookie->new('-name'     => 'session',
                                 '-value'    => $session_id,
-                                '-expires'  => '+'.$timeout.'s',
                                 #'-secure'   => 1, # would be a good idea...
                                );
   if ( $name eq 'logout' ) {
index 446bfe0..6587627 100644 (file)
@@ -49,6 +49,11 @@ my $groups_sub = sub {
 
 };
 
+my $goog_auth_sub = sub {
+  my $access_user = shift;
+  $access_user->totp_secret32 ? 'Enabled' : '';
+};
+
 my $installer_sub = sub {
   my $access_user = shift;
   my @sched_item = $access_user->sched_item or return '';
@@ -66,11 +71,23 @@ my $count_query = 'SELECT COUNT(*) FROM access_user';
 my $link = [ $p.'edit/access_user.html?', 'usernum' ];
 
 my @header = (
-  'Username', 'Full name', 'Groups',    'Installer',    'Customer' );
+  'Username',
+  'Full name',
+  'Groups',
+  'Google Auth',
+  'Installer',
+  'Customer',
+);
 my @fields = (
-  'username', 'name',      $groups_sub, $installer_sub, $cust_sub, );
-my $align = 'lllcl';
-my @links = ( $link, $link, $link, '', '', $cust_link );
+  'username',
+  'name',
+  $groups_sub,
+  $goog_auth_sub,
+  $installer_sub,
+  $cust_sub,
+);
+my $align = 'lllccl';
+my @links = ( $link, $link, $link, '', '', '', $cust_link );
 
 #if ( FS::Conf->new->config('ticket_system') ) {
 #  push @header, 'Ticketing';
index 5514d7d..0533d6e 100644 (file)
                         'Contractual Mbps',
                         'Vertices',
                         'Census blocks',
+                        'Shapefile',
+                        'KMZ',
+                        'GeoJSON',
+                     ],
+  footer          => [ '',
+                       'All fixed zones',
+                       '',
+                       '',
+                       '',
+                       '',
+                       '',
+                       '',
+                       '<A HREF="'. $fixed_shp.  '">download</A>',
+                       '<A HREF="'. $fixed_kmz.  '">download</A>',
+                       '<A HREF="'. $fixed_json. '">download</A>',
                      ],
   fields          => [  'zonenum',
                         'description',
                         sub { my $self = shift;
                               FS::deploy_zone_block->count('zonenum = '.$self->zonenum)
                             },
+                        sub { my $self = shift;
+                              FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
+                              ? 'download' : ''
+                            },
+                        sub { my $self = shift;
+                              FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
+                              ? 'download' : ''
+                            },
+                        sub { my $self = shift;
+                              FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
+                              ? 'download' : ''
+                            },
                      ],
   sort_fields     => [ 'zonenum',
                        'description',
@@ -56,7 +83,7 @@
                        '(adv_speed_down, adv_speed_up)',
                        '(cir_speed_down, cir_speed_up)',
                      ],
-  links           => [  $link_fixed, $link_fixed, ],
+  links           => [  $link_fixed, $link_fixed, '', '', '', '', '', '', $link_shp, $link_kmz, $link_json, ],
   align           => 'cllllrrr',
   nohtmlheader    => 1,
   disable_maxselect => 1,
                         'Service Type',
                         'Advertised Mbps',
                         'Vertices', # number of vertices? not so useful
+                        'Shapefile',
+                        'KMZ',
+                        'GeoJSON',
                      ],
   fields          => [  'zonenum',
                         'description',
                         sub { my $self = shift;
                               FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
                             },
+                        sub { my $self = shift;
+                              FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
+                              ? 'download' : ''
+                            },
+                        sub { my $self = shift;
+                              FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
+                              ? 'download' : ''
+                            },
+                        sub { my $self = shift;
+                              FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
+                              ? 'download' : ''
+                            },
                      ],
   sort_fields     => [ 'zonenum',
                        'description',
                        '(is_voice is not null, is_broadband is not null)',
                        '(adv_speed_down, adv_speed_up)',
                      ],
-  links           => [  '', $link_mobile, ],
+  links           => [  $link_mobile, $link_mobile, '', '', '', '', '', $link_shp, $link_kmz, $link_json, ],
   align           => 'clllllr',
   nohtmlheader    => 1,
   disable_maxselect => 1,
 
 <& /elements/footer.html &>
 <%init>
+
 my $curuser = $FS::CurrentUser::CurrentUser;
 my $acl_edit = $curuser->access_right('Edit FCC report configuration');
 my $acl_edit_global = $curuser->access_right('Edit FCC report configuration for all agents');
 die "access denied"
   unless $acl_edit or $acl_edit_global;
 
-my $link_fixed = [ $p.'edit/deploy_zone-fixed.html?', 'zonenum' ];
-my $link_mobile= [ $p.'edit/deploy_zone-mobile.html?', 'zonenum' ];
+my $link_fixed  = [ $p.'edit/deploy_zone-fixed.html?', 'zonenum' ];
+my $link_mobile = [ $p.'edit/deploy_zone-mobile.html?', 'zonenum' ];
+my $link_shp    = [ $p.'view/deploy_zone-shp.cgi?', 'zonenum' ];
+my $link_kmz    = [ $p.'view/deploy_zone-kmz.cgi?', 'zonenum' ];
+my $link_json   = [ $p.'view/deploy_zone-geojson.cgi?', 'zonenum' ];
+
+my $fixed_shp    = $p.'view/deploy_zone-shp.cgi?zonetype=B';
+my $fixed_kmz    = $p.'view/deploy_zone-kmz.cgi?zonetype=B';
+my $fixed_json   = $p.'view/deploy_zone-geojson.cgi?zonetype=B';
+
+my $tech_label  = FS::part_pkg_fcc_option->technology_labels;
+my $spec_label  = FS::part_pkg_fcc_option->spectrum_labels;
 
-my $tech_label = FS::part_pkg_fcc_option->technology_labels;
-my $spec_label = FS::part_pkg_fcc_option->spectrum_labels;
 </%init>
index e91eb36..b225c84 100644 (file)
@@ -28,7 +28,7 @@
 % } else {
   <FONT SIZE="-1">
 % }
-&copy; 2019 Freeside Internet Services, Inc.<BR>
+&copy; 2022 Freeside Internet Services, Inc.<BR>
 All rights reserved.<BR>
 Licensed under the terms of the<BR>
 GNU <b>Affero</b> General Public License.<BR>
index c272620..8e264c1 100644 (file)
@@ -5,7 +5,7 @@
 <%   include( 'elements/process.html',
                  'table'          => 'access_user',
                  'viewall_dir'    => 'browse',
-                 'copy_on_empty'  => [ '_password', '_password_encoding' ],
+                 'copy_on_empty'  => [ '_password', '_password_encoding', 'totp_secret32' ],
                  'clear_on_error' => [ '_password', '_password2' ],
                  'process_m2m'    => { 'link_table'   => 'access_usergroup',
                                        'target_table' => 'access_group',
index 40c83c6..1bf4d76 100644 (file)
@@ -28,7 +28,7 @@ my $cust_location = qsearchs({
 });
 die "unknown locationnum $locationnum" unless $cust_location;
 
-$cust_location->set('censustract', $cgi->param('enter_censustract'));
+$cust_location->set('censustract', scalar($cgi->param('enter_censustract')));
 my $error = $cust_location->replace;
 
 </%init>
index baacd5e..d4578d2 100755 (executable)
@@ -44,7 +44,7 @@ my $error = '';
 
 $cgi->param('tax','') unless defined $cgi->param('tax');
 
-$cgi->param('refnum', (split(/:/, ($cgi->param('refnum'))[0] ))[0] );
+$cgi->param('refnum', (split(/:/, ($cgi->multi_param('refnum'))[0] ))[0] );
 
 #my @invoicing_list = split( /\s*\,\s*/, $cgi->param('invoicing_list') );
 #push @invoicing_list, 'POST' if $cgi->param('invoicing_list_POST');
@@ -62,7 +62,7 @@ $cgi->param('duplicate_of_custnum') =~ /^(\d+)$/;
 my $duplicate_of = $1;
 
 # if this is enabled, enforce it
-if ( $conf->exists('agent-ship_address', $cgi->param('agentnum')) ) {
+if ( $conf->exists('agent-ship_address', scalar($cgi->param('agentnum'))) ) {
   my $agent = FS::agent->by_key($cgi->param('agentnum'));
   my $agent_cust_main = $agent->agent_cust_main;
   if ( $agent_cust_main ) {
index b7501d4..5007319 100644 (file)
@@ -55,8 +55,12 @@ my $cust_main = qsearchs('cust_main', { 'custnum' => $src->custnum } )
 my $custnum = $cust_main->custnum;
 
 my @subnames = grep { /.+/ } map { /^subnum(\d+)$/ ? $1 : '' } $cgi->param;
-my @subitems = map { [ $cgi->param("subnum$_"), $cgi->param("subamount$_"), $cgi->param("taxXlocationnum$_") ] }
-               @subnames;
+my @subitems = map { [ scalar($cgi->param("subnum$_")),
+                       scalar($cgi->param("subamount$_")),
+                       scalar($cgi->param("taxXlocationnum$_"))
+                     ]
+                   }
+                 @subnames;
 { local $^W = 0; @subitems = grep { $_->[1] + 0 } @subitems; }
 
 my %options = ();
index 6404bcb..6765df0 100644 (file)
@@ -306,8 +306,11 @@ if ( $curr_value ) {
   $cust_payby = new FS::cust_payby {};
 }
 my $sel_payby = $cgi->param($name.'_payby') || $cust_payby->payby;
+# add a weight for CARD/CHEK imports, so we don't turn off auto-charge on edit
 # convert DCRD to CARD + no weight, and the same for DCHK/CHEK
-if ($sel_payby eq 'DCRD') {
+if ( $cust_payby->custpaybynum && $sel_payby =~ /^(CARD|CHEK)$/ && ! $cust_payby->weight ) {
+  $cust_payby->weight(1);
+} elsif ($sel_payby eq 'DCRD') {
   $sel_payby = 'CARD';
   $cust_payby->weight('');
 } elsif ($sel_payby eq 'DCHK') {
index 5a5db96..a56e0b1 100644 (file)
@@ -96,6 +96,14 @@ Example:
           <BR></FONT>
         </td>
       </tr>
+%     foreach my $top_warning ( @top_warnings ) {
+        <TR>
+          <TD COLSPAN=4>
+            <IMG SRC="<% $fsurl %>images/error.png">
+            <FONT COLOR="#FF0000" SIZE="+1"><% $top_warning %></FONT>
+          </TD>
+        </TR>
+%     }
     </table>
 
     <TABLE WIDTH="100%" CELLSPACING=0 CELLPADDING=0>
@@ -276,4 +284,23 @@ if ( scalar(@agentnums) == 1 ) {
 my %status_color = ( 'status' => '#eeffee', 'warning' => '#fefbd0', 'error' => '#f97c7c', );
 my %status_image = ( 'status' => 'images/tick.png', 'warning' => 'images/tick.png', 'error' => 'images/error.png', );
 
+my @top_warnings = ();
+my $deb_version = int(slurp('/etc/debian_version'));
+#per wiki.debian.org/LTS
+push @top_warnings, deb_warning($deb_version)
+  if ( $deb_version <= 8 )
+  or ( $deb_version == 9  && time > 1656658800 ) #7/1/2022
+  or ( $deb_version == 10 && time > 1719817200 ) #7/1/2024
+  or ( $deb_version == 11 && time > 1782889200 ) #7/1/2026
+;
+
+sub deb_warning {
+  my $ver = shift;
+  <<"END";
+WARNING: Your operating system (Debian v$ver) is EOL and no longer supported.
+This is insecure and a violation of PCI data security standard.
+Contact <a href="mailto:sales\@freeside.biz?subject=Debian OS upgrade">sales\@freeside.biz</a> to schedule an upgrade ASAP.
+END
+}
+
 </%init>
index f6fc254..dd38cd0 100644 (file)
@@ -129,6 +129,8 @@ $report_customers_lists{'by active trouble tickets'} = [ $fsurl. 'search/cust_ma
   if $conf->config('ticket_system');
 $report_customers_lists{'with USPS-unvalidated addresses'} = [ $fsurl. 'search/cust_main.cgi?browse=uspsunvalid', '' ]
   if $conf->config('usps_webtools-userid') && $conf->config('usps_webtools-password');
+$report_customers_lists{'with missing/outdated census tract'} = [ $fsurl. 'search/cust_main.html?no_censustract=1&ship_country=US', '' ]
+  ;#if $conf->config('cust_main-require_censustract');
 $report_customers_lists{'with referrals'} = [ $fsurl. 'search/cust_main.html?with_referrals=1' ];
 
 tie my %report_customers, 'Tie::IxHash';
index 54a554f..140ed70 100644 (file)
@@ -325,6 +325,12 @@ function set_censustract(tract, year) {
   var form = document.<% $formname %>;
   form.elements['censustract'].value = tract;
   form.elements['censusyear'].value = year;
+
+  var enter = form.elements['enter_censustract'];
+  if ( enter ) {
+    enter.value = tract;
+  }
+
   <% $post_censustract %>;
 }
 
index 72e9525..1785ea7 100644 (file)
         <TD ALIGN="right">Password: </TD>
         <TD><INPUT TYPE="password" NAME="credential_1" SIZE="13"></TD>
       </TR>
+      <TR>
+        <TD ALIGN="right">One-time code: </TD>
+        <TD><INPUT TYPE="text" NAME="credential_2" SIZE="13"></TD>
+      </TR>
     </TABLE>
     <BR>
  
@@ -42,7 +46,7 @@
 my %error = (
   'no_cookie'       => '', #First login, don't display an error
   'bad_cookie'      => 'Bad Cookie', #timed out?
-  'bad_credentials' => 'Incorrect username / password',
+  'bad_credentials' => 'Incorrect username / password / one-time code',
   #'logout'          => 'You have been logged out.',
 );
 
index 0f115e5..02847f8 100644 (file)
@@ -16,12 +16,18 @@ Confirm census tract
 <% $location{address1} |h %> <% $location{address2} |h %><BR>
 <% $location{city} |h %>, <% $location{state} |h %> <% $location{zip} |h %><BR>
 <BR>
-% my $querystring = "census_year=$year&address=$location{address1}, $location{address2}, $location{city}, $location{state}";
+% my $address1 = $location{address1};
+% $address1 =~ s/(apt|ste|suite|unit)[\s\d]\w*\s*$//i;
+% my $querystring = "census_year=$year&address=$address1, $location{address2}, $location{city}, $location{state}";
 <A HREF="<%$p%>misc/openmap.html?<% $querystring %>"
-   TARGET="_blank">Map service module location</A><BR>
+   REL="opener"
+   TARGET="_blank"
+>Map service location</A><BR>
 % $querystring = "census_year=$year&pre=$pre&zip_code=" . $cache->get('zip');
 <A HREF="<%$p%>misc/openmap.html?<% $querystring %>"
-   TARGET="_blank">Map zip code center</A><BR>
+   REL="opener"
+   TARGET="_blank"
+>Map zip code center</A><BR>
 <BR>
 <input type="hidden" id="new_tract" name="new_tract" value="<%$new_tract%>">
 <TABLE>
@@ -103,7 +109,7 @@ my %location = (
 my $old_tract = $q->{$pre.'censustract'};
 my $cache = eval { FS::GeocodeCache->new(%location) };
 $cache->set_censustract;
-my $year = FS::Conf->new->config('census_year');
+my $year = FS::Conf->new->config('census_legacy') || '2020';
 my $new_tract = $cache->get('censustract');
 my $error = $cache->get('censustract_error');
 
index 88f64c1..15de288 100644 (file)
@@ -44,8 +44,7 @@
       function getCensusTract(lat, lon) {
        var url = 'xmlhttp-censustract.html?lat=' + lat + '&lon=' + lon + '&census_year=<%$census_year%>';
        $.getJSON(url,function(data){
-         var tract = (data.Block.FIPS.substr(0, 11) / 100).toFixed(2);
-         document.getElementById("mycensustract").innerHTML = tract;
+         document.getElementById("mycensustract").innerHTML = data.Block.FIPS;
        });
       }
 
@@ -79,6 +78,6 @@ my $census_year = $cgi->param('census_year');
 my $pre         = $cgi->param('pre');
 my $zip_code    = $cgi->param('zip_code');
 my $address     = $cgi->param('address');
-my $loc = $zip_code ? $zip_code : $address;
+my $loc = $zip_code ? $zip_code.', United States' : $address;
 
-</%init>
\ No newline at end of file
+</%init>
index 985fb90..855e172 100644 (file)
@@ -5,12 +5,19 @@ my $DEBUG = 0;
 
 my $conf = new FS::Conf;
 
-my $return = {};
-
 ## new api link, see doc https://geo.fcc.gov/api/census/
 my $url = "https://geo.fcc.gov/api/census/block/find?format=json&censusYear=" . $cgi->param('census_year') . "&latitude=" . $cgi->param('lat') . "&longitude=" . $cgi->param('lon');
 
-use LWP::Simple;
-my $return = get $url;
+my $ua = new LWP::UserAgent;
+$ua->agent('Freeside/'. $FS::VERSION); #libwww* elicits "403 Forbidden"
+my $res = $ua->get($url);
+
+my $return = '';
+if ( $res->is_success ) {
+  $return = $res->decoded_content;
+} else {
+  #better error handling?  well, hopefully the site is reliable enough
+  warn 'Error from geo.fcc.gov: '. $res->status_line. "\n";
+}
 
-</%init>
\ No newline at end of file
+</%init>
index dd9ed3b..6c9cf69 100644 (file)
@@ -1,7 +1,7 @@
 <% encode_json($return) %>\
 <%init>
 
-my %arg = $cgi->param('arg');
+my %arg = $cgi->multi_param('arg');
 my $custnum = delete($arg{'custnum'});
 
 my $error;
index 56fde6d..5f68d3e 100644 (file)
     </TABLE>
     <BR>
 
+    <FONT CLASS="fsinnerbox-title"><% emt('Google Authenticator') %></FONT>
+    <TABLE CLASS="fsinnerbox">
+      <TR>
+%       if ( $curuser->totp_secret32 ) {
+          <TD><IMG SRC="<% $curuser->totp_qr_code_url %>"</IMG></TD>
+%       } else {
+          <TD><A HREF="<%$p%>pref/set_totp_secret32.html">Enable</A></TD>
+%       }
+      </TR>
+    </TABLE>
+    <BR>
+
 % }
 
 <FONT CLASS="fsinnerbox-title"><% emt("Interface") %></FONT>
diff --git a/httemplate/pref/set_totp_secret32.html b/httemplate/pref/set_totp_secret32.html
new file mode 100644 (file)
index 0000000..f5676bc
--- /dev/null
@@ -0,0 +1,19 @@
+<& /elements/header.html, mt('Google Authenticator for [_1]', $FS::CurrentUser::CurrentUser->username) &>
+
+Scan this code with the Google Authenticator application on your phone.
+<BR><BR>
+
+<IMG SRC="<% $access_user->totp_qr_code_url %>"></IMG>
+<BR><BR>
+
+Future logins will require a 6-digit code generated by the application.
+
+<& /elements/footer.html &>
+<%init>
+
+my $access_user = $FS::CurrentUser::CurrentUser;
+
+my $error = $access_user->set_totp_secret32 unless length($access_user->totp_secret32);
+die $error if $error; #better error handling for this "shouldn't happen" case?
+
+</%init>
index 8bbd1af..56bf767 100644 (file)
@@ -163,7 +163,7 @@ die "access denied"
               || $cgi->param('pkgnum')  =~ /^(\d+)$/
             );
 
-my @statuses = $cgi->param('event_status');
+my @statuses = $cgi->multi_param('event_status');
 my $title = 'Billing events';
 if ( $statuses[0] eq 'failed' and !defined($statuses[1]) ) {
   # tweak the title if we're showing only failed events
index 46e35da..8ef068f 100755 (executable)
@@ -70,7 +70,7 @@ for my $param ( @scalars ) {
 
 #lists
 for my $param (qw( classnum refnum pkg_classnum )) {
-  $search_hash{$param} = [ $cgi->param($param) ];
+  $search_hash{$param} = [ $cgi->multi_param($param) ];
 }
 
 my $params = $cgi->Vars;
index 9be0b7d..96a6f5b 100644 (file)
@@ -216,7 +216,9 @@ $cgi->param('max_level', 5) unless defined($cgi->param('max_level'));
 
 my %search = ();
 $search{'date'} = [ FS::UI::Web::parse_beginning_ending($cgi) ];
-$search{'level'} = [ $cgi->param('min_level'), $cgi->param('max_level') ];
+$search{'level'} = [ scalar($cgi->param('min_level')),
+                     scalar($cgi->param('max_level'))
+                   ];
 foreach my $param (qw(agentnum context context_height tablename tablenum custnum message)) {
   if ( $cgi->param($param) ) {
     $search{$param} = $cgi->param($param);
index 4e17d1f..a976ef9 100755 (executable)
                 'options' => \@location_options,
                 'labels'  => { 'cust'     => "is the customer's default location",
                                'nocust'   => "is not the customer's default location",
-                               'census'   => "has a census tract",
-                               'nocensus' => "does not have a census tract",
+                               'census'   => "has an up-to-date census tract",
+                               'nocensus' => "does not have an up-to-date census tract",
                                'nogeocode'=> 'has an implicit tax location',
                                'geocode'  => 'has a hardcoded tax location',
                              },
diff --git a/httemplate/view/deploy_zone-geojson.cgi b/httemplate/view/deploy_zone-geojson.cgi
new file mode 100644 (file)
index 0000000..0c9d193
--- /dev/null
@@ -0,0 +1,42 @@
+<% $content %>\
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+my $acl_edit = $curuser->access_right('Edit FCC report configuration');
+my $acl_edit_global = $curuser->access_right('Edit FCC report configuration for all agents');
+die "access denied"
+  unless $acl_edit or $acl_edit_global;
+
+my($name, $content);
+
+my($query) = $cgi->keywords;
+if ( $query =~ /^(\d+)$/ || $cgi->param('zonenum') =~ /^(\d+$)/ ) {
+  my $zonenum = $1;
+  $name = $zonenum;
+  my $deploy_zone = qsearchs('deploy_zone', { 'zonenum' => $zonenum })
+    or die 'unknown zonenum';
+
+  $content = $deploy_zone->geo_json_feature->to_json;
+
+} elsif ( $cgi->param('zonetype') =~ /^(\w)$/ ) {
+  my $zonetype = $1;
+  $name = $zonetype;
+  my @deploy_zone = qsearch('deploy_zone', { 'zonetype' => $zonetype,
+                                             'disabled' => '',        });
+
+   my $fc = Geo::JSON::FeatureCollection->new({
+     features => [ map $_->geo_json_feature, @deploy_zone ],
+   });
+
+   $content = $fc->to_json;
+
+} else {
+  die "no zonenum or zonetype\n";
+}
+
+http_header('Content-Type'        => 'application/geo+json' );
+http_header('Content-Disposition' => "filename=$name.geojson" );
+http_header('Content-Length'      => length($content) );
+http_header('Cache-control'       => 'max-age=60' );
+
+</%init>
diff --git a/httemplate/view/deploy_zone-kmz.cgi b/httemplate/view/deploy_zone-kmz.cgi
new file mode 100644 (file)
index 0000000..d2af171
--- /dev/null
@@ -0,0 +1,42 @@
+<% $content %>\
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+my $acl_edit = $curuser->access_right('Edit FCC report configuration');
+my $acl_edit_global = $curuser->access_right('Edit FCC report configuration for all agents');
+die "access denied"
+  unless $acl_edit or $acl_edit_global;
+
+my $kml = Geo::GoogleEarth::Pluggable->new;
+
+my $name;
+
+my($query) = $cgi->keywords;
+if ( $query =~ /^(\d+)$/ || $cgi->param('zonenum') =~ /^(\d+$)/ ) {
+  my $zonenum = $1;
+  $name = $zonenum;
+  my $deploy_zone = qsearchs('deploy_zone', { 'zonenum' => $zonenum })
+    or die 'unknown zonenum';
+
+  $deploy_zone->kml_polygon($kml);
+
+} elsif ( $cgi->param('zonetype') =~ /^(\w)$/ ) {
+  my $zonetype = $1;
+  $name = $zonetype;
+  my @deploy_zone = qsearch('deploy_zone', { 'zonetype' => $zonetype,
+                                             'disabled' => '',        });
+
+  $_->kml_polygon($kml) foreach @deploy_zone;
+
+} else {
+  die "no zonenum or zonetype\n";
+}
+
+my $content = $kml->archive;
+
+http_header('Content-Type' => 'application/vnd.google-earth.kmz' ); #kmz
+http_header('Content-Disposition' => "filename=$name.kmz" );
+http_header('Content-Length'      => length($content) );
+http_header('Cache-control'       => 'max-age=60' );
+
+</%init>
diff --git a/httemplate/view/deploy_zone-shp.cgi b/httemplate/view/deploy_zone-shp.cgi
new file mode 100644 (file)
index 0000000..4b5dfbb
--- /dev/null
@@ -0,0 +1,76 @@
+<% $content %>\
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+my $acl_edit = $curuser->access_right('Edit FCC report configuration');
+my $acl_edit_global = $curuser->access_right('Edit FCC report configuration for all agents');
+die "access denied"
+  unless $acl_edit or $acl_edit_global;
+
+my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
+
+my %shapelib_opts = (
+  Shapetype  => Geo::Shapelib::POLYGON,
+  FieldNames => [ 'Tech', 'Down', 'Up' ],
+  FieldTypes => [ 'String:32', 'Double', 'Double' ],
+);
+
+my( $name, $shapefile );
+
+my($query) = $cgi->keywords;
+if ( $query =~ /^(\d+)$/ || $cgi->param('zonenum') =~ /^(\d+$)/ ) {
+  my $zonenum = $1;
+  $name = $zonenum;
+  my $deploy_zone = qsearchs('deploy_zone', { 'zonenum' => $zonenum })
+    or die 'unknown zonenum';
+
+  $shapefile = new Geo::Shapelib {
+    Name => "$dir/$zonenum-$$",
+    %shapelib_opts
+  };
+
+  $deploy_zone->shapefile_add($shapefile);
+
+} elsif ( $cgi->param('zonetype') =~ /^(\w)$/ ) {
+  my $zonetype = $1;
+  $name = $zonetype;
+  my @deploy_zone = qsearch('deploy_zone', { 'zonetype' => $zonetype,
+                                             'disabled' => '',        });
+
+  $shapefile = new Geo::Shapelib {
+    Name => "$dir/$zonetype-$$",
+    %shapelib_opts
+  };
+
+  $_->shapefile_add($shapefile) foreach @deploy_zone;
+
+} else {
+  die "no zonenum or zonetype\n";
+}
+
+$shapefile->set_bounds;
+
+$shapefile->save;
+
+#slurp up .shp .shx and .dbf files and put them in a zip.. return that
+#and delete the files
+
+my $content = '';
+open(my $fh, '>', \$content);
+
+my $zip = new Archive::Zip;
+$zip->addFile("$dir/$name-$$.$_", "$name.$_") foreach qw( shp shx dbf );
+unless ( $zip->writeToFileHandle($fh) == Archive::Zip::AZ_OK() ) {
+  die "failed to create .shz file\n";
+}
+close $fh;
+
+unlink("$dir/$name-$$.$_") foreach qw( shp shx dbf );
+
+#http_header('Content-Type'        => 'x-gis/x-shapefile' );
+http_header('Content-Type'        => 'archive/zip' );
+http_header('Content-Disposition' => "filename=$name.shz" );
+http_header('Content-Length'      => length($content) );
+http_header('Cache-control'       => 'max-age=60' );
+
+</%init>