[freeside-commits] branch master updated. 0aa5e2e2028e3d4b717302173d397daa91037683

Mark Wells mark at 420.am
Mon Mar 7 12:02:52 PST 2016


The branch, master has been updated
       via  0aa5e2e2028e3d4b717302173d397daa91037683 (commit)
      from  e4364530d5de0f2dc115d7a9441960e989bca757 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit 0aa5e2e2028e3d4b717302173d397daa91037683
Author: Mark Wells <mark at freeside.biz>
Date:   Fri Mar 4 16:50:40 2016 -0800

    Bandwidth.com provisioning, #39914

diff --git a/FS/FS/part_export.pm b/FS/FS/part_export.pm
index d6357fd..182f476 100644
--- a/FS/FS/part_export.pm
+++ b/FS/FS/part_export.pm
@@ -704,8 +704,12 @@ actual DIDs except at the lowest level.
 Generally, 'state' alone will return an array of area codes or region names
 in the state.
 
-'state' and 'areacode' together will return an array of exchanges (NXX
-prefixes), or for some exports, an array of ratecenter names.
+'state' and 'areacode' together will return an array of either:
+- exchange strings of the form "New York (212-555-XXXX)"
+- ratecenter names of the form "New York, NY"
+
+These strings are sent back to the UI and offered as options so that the user
+can choose the local calling area they like.
 
 'areacode' and 'exchange', or 'state' and 'ratecenter', or 'region' by itself
 will return an array of actual DID numbers.
diff --git a/FS/FS/part_export/bandwidth_com.pm b/FS/FS/part_export/bandwidth_com.pm
new file mode 100644
index 0000000..7bb26e0
--- /dev/null
+++ b/FS/FS/part_export/bandwidth_com.pm
@@ -0,0 +1,448 @@
+package FS::part_export::bandwidth_com;
+
+use base qw( FS::part_export );
+use strict;
+
+use Tie::IxHash;
+use LWP::UserAgent;
+use URI;
+use HTTP::Request::Common;
+use Cache::FileCache;
+use FS::Record qw(dbh qsearch);
+use FS::queue;
+use XML::LibXML::Simple qw(XMLin);
+use XML::Writer;
+use Try::Tiny;
+
+our $me = '[bandwidth.com]';
+
+# cache NPA/NXX records, peer IDs, etc.
+our %CACHE; # exportnum => cache
+our $cache_timeout = 86400; # seconds
+
+our $API_VERSION = 'v1.0';
+
+tie my %options, 'Tie::IxHash',
+  'accountId'       => { label => 'Account ID' },
+  'username'        => { label => 'API username', },
+  'password'        => { label => 'API password', },
+  'siteId'          => { label => 'Site ID' },
+  'num_dids'        => { label => 'Maximum available phone numbers to show',
+                         default => '20'
+                       },
+  'debug'           => { label => 'Debugging',
+                         type => 'select',
+                         options => [ 0, 1, 2 ],
+                         option_labels => {
+                           0 => 'none',
+                           1 => 'terse',
+                           2 => 'verbose',
+                         }
+                       },
+  'test'            => { label => 'Use test server', type => 'checkbox', value => 1 },
+;
+
+our %info = (
+  'svc'      => [qw( svc_phone )],
+  'desc'     => 'Provision DIDs to Bandwidth.com',
+  'options'  => \%options,
+  'no_machine' => 1,
+  'notes'    => <<'END'
+<P>Export to <b>bandwidth.com</b> interconnected VoIP service.</P>
+<P>Bandwidth.com uses a SIP peering architecture. Each phone number is routed
+to a specific peer, which comprises one or more IP addresses. The IP address
+will be taken from the "sip_server" field of the phone service. If no peer
+with this IP address exists, one will be created.</P>
+<P>If you are operating a central SIP gateway to receive traffic for all (or
+a subset of) customers, you should configure a phone service with a fixed
+value, or a list of fixed values, for the sip_server field.</P>
+END
+);
+
+sub export_insert {
+  my($self, $svc_phone) = (shift, shift);
+  local $SIG{__DIE__};
+  try {
+    my $account_id = $self->option('accountId');
+    my $peer = $self->find_peer($svc_phone)
+      or die "couldn't find SIP peer for ".$svc_phone->sip_server.".\n";
+    my $phonenum = $svc_phone->phonenum;
+    # future: reserve numbers before activating?
+    # and an option to order first available number instead of selecting DID?
+    my $order = {
+      Order => {
+        Name      => "Order svc#".$svc_phone->svcnum." - $phonenum",
+        SiteId    => $peer->{SiteId},
+        PeerId    => $peer->{PeerId},
+        Quantity  => 1,
+        ExistingTelephoneNumberOrderType => {
+          TelephoneNumberList => {
+            TelephoneNumber => $phonenum
+          }
+        }
+      }
+    };
+    my $result = $self->api_post("orders", $order);
+    # future: add a queue job here to poll the order completion status.
+    '';
+  } catch {
+    "$me $_";
+  };
+}
+
+sub export_replace {
+  my ($self, $new, $old) = @_;
+  # we only export the IP address and the phone number,
+  # neither of which we can change in place.
+  if (   $new->phonenum ne $old->phonenum
+      or $new->sip_server ne $old->sip_server ) {
+    return $self->export_delete($old) || $self->export_insert($new);
+  }
+  '';
+}
+
+sub export_delete {
+  my ($self, $svc_phone) = (shift, shift);
+  local $SIG{__DIE__};
+  try {
+    my $phonenum = $svc_phone->phonenum;
+    my $disconnect = {
+      DisconnectTelephoneNumberOrder => {
+        Name => "Disconnect svc#".$svc_phone->svcnum." - $phonenum",
+        DisconnectTelephoneNumberOrderType => {
+          TelephoneNumberList => [
+            { TelephoneNumber => $phonenum },
+          ],
+        },
+      }
+    };
+    my $result = $self->api_post("disconnects", $disconnect);
+    # this is also an order, and we could poll its status also
+    ''; 
+  } catch {
+    "$me $_";
+  };
+}
+
+sub find_peer {
+  my $self = shift;
+  my $svc_phone = shift;
+  my $ip = $svc_phone->sip_server; # future: support svc_pbx for this
+  die "SIP server address required.\n" if !$ip;
+
+  my $peers = $self->peer_cache;
+  if ( $peers->{hostname}{$ip} ) {
+    return $peers->{hostname}{$ip};
+  }
+  # refresh the cache and try again
+  $self->cache->remove('peers');
+  $peers = $self->peer_cache;
+  return $peers->{hostname}{$ip} || undef;
+}
+
+#################
+# DID SELECTION #
+#################
+
+sub can_get_dids { 1 }
+
+# we don't yet have tollfree support
+
+sub get_dids_npa_select { 1 }
+
+sub get_dids {
+  local $SIG{__DIE__};
+
+  my $self = shift;
+  my %opt = @_;
+
+  my ($exportnum) = $self->exportnum =~ /^(\d+)$/;
+
+  return [] if $opt{'tollfree'}; # we'll come back to this
+
+  my ($state, $npa, $nxx) = @opt{'state', 'areacode', 'exchange'};
+
+  if ( $nxx ) {
+
+    die "areacode required\n" unless $npa;
+    my $limit = $self->option('num_dids') || 20;
+    my $result = $self->api_get('availableNumbers', [
+        'npaNxx'    => $npa.$nxx,
+        'quantity'  => $limit,
+        'LCA'       => 'false',
+        # find only those that match the NPA-NXX, not those thought to be in
+        # the same local calling area. though that might be useful.
+    ]);
+    return [ $result->findnodes('//TelephoneNumber')->to_literal_list ];
+
+  } elsif ( $npa ) {
+
+    return $self->npanxx_cache($npa);
+
+  } elsif ( $state ) {
+
+    return $self->npa_cache($state);
+
+  } else { # something's wrong
+
+    warn "get_dids called with no arguments";
+    return [];
+
+  }
+
+}
+
+#########
+# CACHE #
+#########
+
+=item peer_cache
+
+Returns a hashref of information on peer addresses. Currently has one key,
+'hostname', pointing to a hash of (IP address => peer ID).
+
+=cut
+
+sub peer_cache {
+  my $self = shift;
+  my $peer_table = $self->cache->get('peers');
+  if (!$peer_table) {
+    $peer_table = { hostname => {} };
+    my $result = $self->api_get('sites');
+    my @site_ids = $result->findnodes('//Site/Id')->to_literal_list;
+    foreach my $site_id (@site_ids) {
+      $result = $self->api_get("sites/$site_id/sippeers");
+      my @peers = $result->findnodes('//SipPeer');
+      foreach my $peer (@peers) {
+        my $peer_id = $peer->findvalue('PeerId');
+        my @hosts = $peer->findnodes('VoiceHosts/Host/HostName')->to_literal_list;
+        foreach my $host (@hosts) {
+          $peer_table->{hostname}->{ $host } = {
+            PeerId => $peer_id,
+            SiteId => $site_id,
+          };
+        }
+        # any other peer info we need? I don't think so.
+      } # foreach $peer
+    } # foreach $site_id
+    $self->cache->set('peers', $peer_table, $cache_timeout);
+  }
+  $peer_table;
+}
+
+=item npanxx_cache NPA
+
+Returns an arrayref of exchange prefixes in the areacode NPA. This will
+only work if the available prefixes in that areacode's state have already
+been loaded.
+
+=cut
+
+sub npanxx_cache {
+  my $self = shift;
+  my $npa = shift;
+  my $exchanges = $self->cache->get("npanxx_$npa");
+  if (!$exchanges) {
+    warn "NPA $npa not yet loaded; returning nothing";
+    return [];
+  }
+  $exchanges;
+}
+
+=item npa_cache STATE
+
+Returns an arrayref of area codes in the state. This will refresh the cache
+if necessary.
+
+=cut
+
+sub npa_cache {
+  my $self = shift;
+  my $state = shift;
+
+  my $npas = $self->cache->get("npa_$state");
+  if (!$npas) {
+    my $data = {}; # NPA => [ NPANXX, ... ]
+    my $result = $self->api_get('availableNpaNxx', [ 'state' => $state ]);
+    foreach my $entry ($result->findnodes('//AvailableNpaNxx')) {
+      my $npa = $entry->findvalue('Npa');
+      my $nxx = $entry->findvalue('Nxx');
+      my $city = $entry->findvalue('City');
+      push @{ $data->{$npa} ||= [] }, "$city ($npa-$nxx-XXXX)";
+    }
+    $npas = [ sort keys %$data ];
+    $self->cache->set("npa_$state", $npas);
+    foreach (@$npas) {
+      # sort by city, then NXX
+      $data->{$_} = [ sort @{ $data->{$_} } ];
+      $self->cache->set("npanxx_$_", $data->{$_});
+    }
+  }
+  return $npas;
+}
+
+=item cache
+
+Returns the Cache::FileCache object for this export. Each instance of the
+export gets a separate cache.
+
+=cut
+
+sub cache {
+  my $self = shift;
+
+  my $exportnum = $self->get('exportnum');
+  $CACHE{$exportnum} ||= Cache::FileCache->new({
+    'cache_root' => $FS::UID::cache_dir.'/cache.'.$FS::UID::datasrc,
+    'namespace'  => __PACKAGE__ . '_' . $exportnum,
+    'default_expires_in' => $cache_timeout,
+  });
+
+}
+
+##############
+# API ACCESS #
+##############
+
+sub debug {
+  shift->option('debug') || 0;
+}
+
+sub api_get {
+  my ($self, $path, $content) = @_;
+  warn "$me GET $path\n" if $self->debug;
+  my $url = URI->new( 'https://' .
+    join('/', $self->host, $API_VERSION, 'accounts', $self->option('accountId'), $path)
+  );
+  $url->query_form($content);
+  my $request = GET($url);
+  $self->_request($request);
+}
+
+sub api_post {
+  my ($self, $path, $content) = @_;
+  warn "$me POST $path\n" if $self->debug;
+  my $url = URI->new( 'https://' .
+    join('/', $self->host, $API_VERSION, 'accounts', $self->option('accountId'), $path)
+  );
+  my $request = POST($url, 'Content-Type' => 'application/xml',
+                           'Content' => $self->xmlout($content));
+  $self->_request($request);
+}
+
+sub api_put {
+  my ($self, $path, $content) = @_;
+  warn "$me PUT $path\n" if $self->debug;
+  my $url = URI->new( 'https://' .
+    join('/', $self->host, $API_VERSION, 'accounts', $self->option('accountId'), $path)
+  );
+  my $request = PUT ($url, 'Content-Type' => 'application/xml',
+                           'Content' => $self->xmlout($content));
+  $self->_request($request);
+}
+
+sub api_delete {
+  my ($self, $path) = @_;
+  warn "$me DELETE $path\n" if $self->debug;
+  my $url = URI->new( 'https://' .
+    join('/', $self->host, $API_VERSION, 'accounts', $self->option('accountId'), $path)
+  );
+  my $request = DELETE($url);
+  $self->_request($request);
+}
+
+sub xmlout {
+  my ($self, $content) = @_;
+  my $output;
+  my $writer = XML::Writer->new( OUTPUT => \$output, ENCODING => 'utf-8' );
+  my @queue = ($content);
+  while ( @queue ) {
+    my $obj = shift @queue;
+    if (ref($obj) eq 'HASH') {
+      foreach my $k (keys %$obj) {
+        unshift @queue, "endTag $k";
+        unshift @queue, $obj->{$k};
+        unshift @queue, "startTag $k";
+      }
+    } elsif ( ref($obj) eq 'ARRAY' ) {
+      unshift @queue, @$obj;
+    } elsif ( $obj =~ /^startTag (.*)$/ ) {
+      $writer->startTag($1);
+    } elsif ( $obj =~ /^endTag (.*)$/ ) {
+      $writer->endTag($1);
+    } elsif ( defined($obj) ) {
+      $writer->characters($obj);
+    }
+  }
+  return $output;
+}
+
+sub xmlin {
+  # wrapper for XML::LibXML::Simple's XMLin, with auto-flattening of NodeLists
+  my $self = shift;
+  my @out;
+  foreach my $node (@_) {
+    if ($node->can('get_nodelist')) {
+      push @out, map { XMLin($_, KeepRoot => 1) } $node->get_nodelist;
+    } else {
+      push @out, XMLin($node);
+    }
+  }
+  @out;
+}
+
+sub _request { # even lower level
+  my ($self, $request) = @_; 
+  warn $request->as_string . "\n" if $self->debug > 1;
+  my $response = $self->ua->request( $request ); 
+  warn "$me received\n" . $response->as_string . "\n" if $self->debug > 1;
+
+  if ($response->content) {
+
+    my $xmldoc = XML::LibXML->load_xml(string => $response->content);
+    # errors are found in at least two places: ResponseStatus/ErrorCode
+    my $error;
+    my ($ec) = $xmldoc->findnodes('//ErrorCode');
+    if ($ec) {
+      $error = $ec->parentNode->findvalue('Description');
+    }
+    # and ErrorList/Error
+    $error ||= join("; ", $xmldoc->findnodes('//Error/Description')->to_literal_list);
+    die "$error\n" if $error;
+    return $xmldoc;
+
+  } elsif ($response->code eq '201') { # Created, response to a POST
+
+    return $response->header('Location');
+
+  } else {
+
+    die $response->status_line."\n";
+  
+  }
+}
+
+sub host {
+  my $self = shift;
+  $self->{_host} ||= do {
+    my $host = 'dashboard.bandwidth.com';
+    $host = "test.$host" if $self->option('test');
+  };
+}
+
+sub ua {
+  my $self = shift;
+  $self->{_ua} ||= do {
+    my $ua = LWP::UserAgent->new;
+    $ua->credentials(
+      $self->host . ':443',
+      'Bandwidth API',
+      $self->option('username'),
+      $self->option('password')
+    );
+    $ua;
+  }
+}
+
+
+1;
diff --git a/FS/FS/svc_phone.pm b/FS/FS/svc_phone.pm
index 3a58b46..f2be7d3 100644
--- a/FS/FS/svc_phone.pm
+++ b/FS/FS/svc_phone.pm
@@ -274,7 +274,7 @@ sub table_info {
                         },
         'sip_server'  => {
                                 label => 'SIP Host',
-                                %dis2,
+                                disable_inventory => 1,
                          },
     },
   };

-----------------------------------------------------------------------

Summary of changes:
 FS/FS/part_export.pm               |    8 +-
 FS/FS/part_export/bandwidth_com.pm |  448 ++++++++++++++++++++++++++++++++++++
 FS/FS/svc_phone.pm                 |    2 +-
 3 files changed, 455 insertions(+), 3 deletions(-)
 create mode 100644 FS/FS/part_export/bandwidth_com.pm




More information about the freeside-commits mailing list