[freeside-commits] branch FREESIDE_4_BRANCH updated. 77f5d80f4178f976efbc0be027656e396cecc1cb

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


The branch, FREESIDE_4_BRANCH has been updated
       via  77f5d80f4178f976efbc0be027656e396cecc1cb (commit)
      from  8268b9ca32d3dfcd0c6ad26959eed3587988878e (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 77f5d80f4178f976efbc0be027656e396cecc1cb
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