[freeside-commits] branch master updated. fcb43580b83129097a2abf53104ca29f3185d44b

Mark Wells mark at 420.am
Wed Dec 24 10:59:01 PST 2014


The branch, master has been updated
       via  fcb43580b83129097a2abf53104ca29f3185d44b (commit)
      from  403bd5f61004717ecfecf8c5211141939e3b7176 (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 fcb43580b83129097a2abf53104ca29f3185d44b
Author: Mark Wells <mark at freeside.biz>
Date:   Tue Dec 23 21:01:03 2014 -0800

    Thinktel VoIP provisioning, #32084

diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index 492f8e2..8b362a7 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -4253,6 +4253,7 @@ sub tables_hashref {
         'exportsvcnum' => 'serial', '', '', '', '', 
         'exportnum'    => 'int', '', '', '', '', 
         'svcpart'      => 'int', '', '', '', '', 
+        'role'         => 'varchar', 'NULL', 16, '', '',
       ],
       'primary_key'  => 'exportsvcnum',
       'unique'       => [ [ 'exportnum', 'svcpart' ] ],
@@ -5945,13 +5946,15 @@ sub tables_hashref {
       'columns' => [
         'svcnum',           'int',     '',      '', '', '', 
         'id',               'int', 'NULL',      '', '', '', 
+        'uuid',            'char', 'NULL',      36, '', '',
         'title',        'varchar', 'NULL', $char_d, '', '', 
         'max_extensions',   'int', 'NULL',      '', '', '',
         'max_simultaneous', 'int', 'NULL',      '', '', '',
+        'ip_addr',      'varchar', 'NULL',      40, '', '',
       ],
       'primary_key'  => 'svcnum',
       'unique'       => [],
-      'index'        => [ [ 'id' ] ],
+      'index'        => [ [ 'id' ], [ 'uuid' ] ],
       'foreign_keys' => [
                           { columns    => [ 'svcnum' ],
                             table      => 'cust_svc',
diff --git a/FS/FS/export_svc.pm b/FS/FS/export_svc.pm
index 5ef50b6..4579e6d 100644
--- a/FS/FS/export_svc.pm
+++ b/FS/FS/export_svc.pm
@@ -38,6 +38,8 @@ The following fields are currently supported:
 
 =item svcpart - service definition (see L<FS::part_svc>)
 
+=item role - export role (see export parameters)
+
 =back
 
 =head1 METHODS
@@ -307,8 +309,24 @@ sub check {
     || $self->ut_foreign_key('exportnum', 'part_export', 'exportnum')
     || $self->ut_number('svcpart')
     || $self->ut_foreign_key('svcpart', 'part_svc', 'svcpart')
+    || $self->ut_alphan('role')
     || $self->SUPER::check
   ;
+
+  my $part_export = $self->part_export;
+  if ( exists $part_export->info->{roles} ) {
+    my $role = $self->get('role');
+    if ( ! $role ) {
+      return 'must select an export role'
+    }
+    if ( ! exists($part_export->info->{roles}->{$role}) ) {
+      return "invalid role for export '".$part_export->exporttype."'";
+    }
+  } else {
+    $self->set('role', '');
+  }
+
+  '';
 }
 
 =item part_export
diff --git a/FS/FS/part_export.pm b/FS/FS/part_export.pm
index 9d261f0..7819a7c 100644
--- a/FS/FS/part_export.pm
+++ b/FS/FS/part_export.pm
@@ -535,6 +535,23 @@ sub default_export_machine {
   die "no default export hostname for export ".$self->exportnum;
 }
 
+=item svc_role SVC_X
+
+Returns the role that SVC_X occupies with respect to this export, if any.
+This is part of the part_svc's export configuration.
+
+=cut
+
+sub svc_role {
+  my $self = shift;
+  my $svc_x = shift;
+  my $cust_svc = $svc_x->cust_svc or return '';
+  my $export_svc = qsearchs('export_svc', { exportnum => $self->exportnum,
+                                            svcpart   => $cust_svc->svcpart })
+                   or return '';
+  $export_svc->role;
+} 
+
 #these should probably all go away, just let the subclasses define em
 
 =item export_insert SVC_OBJECT
@@ -683,6 +700,33 @@ sub info {
   };
 }
 
+=item get_dids SELECTION
+
+Does several things, which is unfortunate. DID phone numbers are organized
+in a sort-of hierarchy: state, areacode, exchange, number. Or, for some 
+vendors: state, region, number. But not always that, either.
+
+SELECTION is one or more field/value pairs specifying parts of the hierarchy
+that have already been selected.  C<get_dids> will then return an arrayref of
+the possible values for the next selection level. Note that these are not
+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.
+
+'areacode' and 'exchange', or 'state' and 'ratecenter', or 'region' by itself
+will return an array of actual DID numbers.
+
+Passing 'tollfree' with a true value will override the whole hierarchy and
+return an array of tollfree numbers.
+
+=cut
+
+# no stub; can('get_dids') should return false by default
+
 #default fallbacks... FS::part_export::DID_Common ?
 sub can_get_dids { 0; }
 sub get_dids_can_tollfree { 0; }
@@ -692,6 +736,24 @@ sub get_dids_can_edit     { 0; } #don't use without can_manual, otherwise the
                                  # inventory each edit
 sub get_dids_npa_select   { 1; }
 
+# get_dids_npa_select: if true, then prompt to select state, then area code,
+# then city/exchange, then phone number.
+# if false, then prompt to select state (actually province), then "region",
+# then phone number.
+#
+# get_dids_can_manual: if true, then there will be a radio button to enter
+# a phone number manually.
+#
+# get_dids_can_tollfree: if true, then the user will be prompted to choose
+# both a regular and a toll-free number. The export can have a 
+# 'restrict_selection' option to enable only one or the other of those. See
+# part_export/vitelity.pm for an example.
+#
+# get_dids_can_edit: if true, then the user can use the selector again to
+# change the phone number for a service. if false, then they can't (have to
+# reprovision completely).
+
+
 =back
 
 =head1 SUBROUTINES
diff --git a/FS/FS/part_export/thinktel.pm b/FS/FS/part_export/thinktel.pm
new file mode 100644
index 0000000..4a28649
--- /dev/null
+++ b/FS/FS/part_export/thinktel.pm
@@ -0,0 +1,677 @@
+package FS::part_export::thinktel;
+
+use base qw( FS::part_export );
+use strict;
+
+use Tie::IxHash;
+use URI::Escape;
+use LWP::UserAgent;
+use URI::Escape;
+use JSON;
+
+use FS::Record qw( qsearch qsearchs );
+
+our $me = '[Thinktel VoIP]';
+our $DEBUG = 1;
+our $base_url = 'https://api.thinktel.ca/rest.svc/';
+
+# cache cities and provinces
+our %CACHE;
+our $cache_timeout = 60; # seconds
+our $last_cache_update = 0;
+
+# static data
+
+tie my %locales, 'Tie::IxHash', (
+  EnglishUS => 0,
+  EnglishUK => 1,
+  EnglishCA => 2,
+  UserDefined1 => 3,
+  UserDefined2 => 4,
+  FrenchCA  => 5,
+  SpanishLatinAmerica => 6
+);
+
+tie my %options, 'Tie::IxHash',
+  'username'        => { label => 'Thinktel username', },
+  'password'        => { label => 'Thinktel password', },
+  'debug'           => { label => 'Enable debugging', type => 'checkbox', value => 1 },
+  'plan_id'         => { label => 'Trunk plan ID' },
+  'locale'          => {
+    label => 'Locale',
+    type => 'select',
+    options => [ keys %locales ],
+  },
+  'proxy'           => {
+    label => 'SIP Proxy',
+    type => 'select',
+    options =>
+      [ 'edm.trk.tprm.ca', 'tor.trk.tprm.ca' ],
+  },
+  'trunktype'       => {
+    label => 'SIP Trunk Type',
+    type => 'select',
+    options => [
+      'Avaya CM/SM',
+      'Default SIP MG Model',
+      'Microsoft Lync Server 2010',
+    ],
+  },
+
+;
+
+tie my %roles, 'Tie::IxHash',
+  'trunk'   => {  label     => 'SIP trunk',
+                  svcdb     => 'svc_phone',
+               },
+  'did'     => {  label     => 'DID',
+                  svcdb     => 'svc_phone',
+               },
+  'gateway' => {  label     => 'SIP gateway',
+                  svcdb     => 'svc_pbx'
+               },
+;
+
+our %info = (
+  'svc'         => [qw( svc_phone svc_pbx)],
+  'desc'        =>
+    'Provision trunks and DIDs to Thinktel VoIP',
+  'options'     => \%options,
+  'roles'       => \%roles,
+  'no_machine'  => 1,
+  'notes'       => <<'END'
+<P>Export to Thinktel SIP Trunking service.</P>
+<P>This requires three service definitions to be configured:
+  <OL>
+    <LI>A phone service for the SIP trunk. This should be attached to the 
+    export in the "trunk" role. Usually there will be only one of these
+    per package. The <I>max_simultaneous</i> field of this service will set 
+    the channel limit on the trunk. The I<sip_password> will be used for
+    all gateways.</LI>
+    <LI>A phone service for a DID. This should be attached in the "did" role.
+    DIDs should have no properties other than the number and the E911 
+    location.</LI>
+    <LI>A PBX service for the customer's SIP gateway (Asterisk, OpenPBX, etc. 
+    device). This should be attached in the "gateway" role. The <i>ip_addr</i> 
+    field should be set to the static IP address that will receive calls. 
+    There may be more than one of these on the trunk.</LI>
+  </OL>
+  All three services must be within the same package. The "pbxsvc" field of
+  phone services will be ignored, as the DIDs do not belong to a specific 
+  svc_pbx in a multi-gateway setup.
+</P>
+END
+);
+
+=item svc_with_role { SVC | PKGNUM }, ROLE
+
+Finds the service(s) in the same package as SVC (or the package PKGNUM) that 
+are linked to the export in ROLE (trunk, gateway, or did).
+
+=cut
+
+sub svc_with_role {
+  my $self = shift;
+  my $svc_or_pkgnum = shift;
+  my $role = shift;
+  my $pkgnum;
+  if ( ref $svc_or_pkgnum ) {
+    $pkgnum = $svc_or_pkgnum->cust_svc->pkgnum or return '';
+  } else {
+    $pkgnum = $svc_or_pkgnum;
+  }
+  my $svcdb = ($role eq 'gateway' ? 'svc_pbx' : 'svc_phone');
+  my @svcs = qsearch({
+    'table'     =>  $svcdb,
+    'addl_from' =>  ' JOIN cust_svc USING (svcnum)' .
+                    ' JOIN export_svc USING (svcpart)',
+    'extra_sql' =>  " WHERE cust_svc.pkgnum = $pkgnum" .
+                    " AND export_svc.exportnum = ".$self->exportnum .
+                    " AND export_svc.role = '$role'",
+  });
+  if ( $role eq 'trunk' ) {
+    warn "$me more than one trunk service in pkgnum $pkgnum.\n" if @svcs > 1;
+    return $svcs[0];
+  } else {
+    return @svcs;
+  }
+}
+
+sub check_svc { # check the service for validity
+  my($self, $svc_x) = (shift, shift);
+  my $role = $self->svc_role($svc_x)
+    or return "No export role is assigned to this service type.";
+  if ( $role eq 'trunk' ) {
+    if (! $svc_x->isa('FS::svc_phone')) {
+      return "This is the wrong type of service (should be svc_phone).";
+    }
+    if (length($svc_x->sip_password) == 0
+        or length($svc_x->sip_password) > 14) {
+      return "SIP password must be 1 to 14 characters.";
+    }
+  } elsif ( $role eq 'did' ) {
+    # nothing really to check
+  } elsif ( $role eq 'gateway' ) {
+    if ($svc_x->max_simultaneous == 0) {
+      return "The maximum simultaneous calls field must be > 0."
+    }
+    if (!$svc_x->ip_addr) {
+      return "The gateway must have an IP address."
+    }
+  }
+
+  '';
+}
+
+sub export_insert {
+  my($self, $svc_x) = (shift, shift);
+
+  my $error = $self->check_svc($svc_x);
+  return $error if $error;
+  my $role = $self->svc_role($svc_x);
+  $self->queue_action("insert_$role", $svc_x->svcnum);
+}
+
+sub queue_action {
+  my $self = shift;
+  my $action = shift; #'action_role' format: 'insert_did', 'delete_trunk', etc.
+  my $svcnum = shift;
+  my @arg = ($self->exportnum, $svcnum, @_);
+
+  my $job = FS::queue->new({
+      job => 'FS::part_export::thinktel::'.$action,
+      svcnum => $svcnum,
+  });
+
+  $job->insert(@arg);
+}
+
+sub insert_did {
+  my ($exportnum, $svcnum) = @_;
+  my $self = FS::part_export->by_key($exportnum);
+  my $svc_x = FS::svc_phone->by_key($svcnum);
+
+  my $phonenum = $svc_x->phonenum;
+  my $trunk_svc = $self->svc_with_role($svc_x, 'trunk')
+    or return; # non-fatal; just wait for the trunk to be created
+
+  my $trunknum = $trunk_svc->phonenum;
+
+  my $endpoint = "SipTrunks/$trunknum/Dids";
+  my $content = [ { Number  => $phonenum } ];
+
+  my $result = $self->api_request('POST', $endpoint, $content);
+
+  # probably can only be one of these
+  my $error = join("\n",
+    map { $_->{Message} } grep { $_->{Reply} != 1 } @$result
+  );
+
+  if ( $error ) {
+    warn "$me error provisioning $phonenum to $trunknum: $error\n";
+    die "$me $error";
+  }
+
+  # now insert the V911 record
+  $endpoint = "V911s";
+  $content = $self->e911_content($svc_x);
+
+  $result = $self->api_request('POST', $endpoint, $content);
+  if ( $result->{Reply} != 1 ) {
+    $error = "$me $result->{Message}";
+    # then delete the DID to keep things consistent
+    warn "$me error configuring e911 for $phonenum: $error\nReverting DID order.\n";
+    $endpoint = "SipTrunks/$trunknum/Dids/$phonenum";
+    $result = $self->api_request('DELETE', $endpoint);
+    if ( $result->{Reply} != 1 ) {
+      warn "Failed: $result->{Message}\n";
+      die "$error. E911 provisioning failed, but the DID could not be deleted: '" . $result->{Message} . "'. You may need to remove the DID manually.";
+    }
+    die $error;
+  }
+}
+
+sub insert_gateway {
+  my ($exportnum, $svcnum) = @_;
+  my $self = FS::part_export->by_key($exportnum);
+  my $svc_x = FS::svc_pbx->by_key($svcnum);
+
+  my $trunk_svc = $self->svc_with_role($svc_x, 'trunk')
+    or return;
+
+  my $trunknum = $trunk_svc->phonenum;
+  # and $svc_x is a svc_pbx service
+
+  my $endpoint = "SipBindings";
+  my $content = {
+    ContactIPAddress  => $svc_x->ip_addr,
+    ContactPort       => 5060,
+    IPMatchRequired   => JSON::true,
+    SipDomainName     => $self->option('proxy'),
+    SipTrunkType      => $self->option('trunktype'),
+    SipUsername       => $trunknum,
+    SipPassword       => $trunk_svc->sip_password,
+  };
+  my $result = $self->api_request('POST', $endpoint, $content);
+
+  if ( $result->{Reply} != 1 ) {
+    die "$me ".$result->{Message};
+  }
+
+  # store the binding ID in the service
+  my $binding_id = $result->{ID};
+  warn "$me created SIP binding with ID $binding_id\n" if $DEBUG;
+  local $FS::svc_Common::noexport_hack = 1;
+  $svc_x->set('uuid', $binding_id);
+  my $error = $svc_x->replace;
+  if ( $error ) {
+    $error = "$me storing the SIP binding ID in the database: $error";
+  } else {
+    # link the main trunk record to the IP address binding
+    $endpoint = "SipTrunks/$trunknum/Lines";
+    $content = {
+      'Channels'     => $svc_x->max_simultaneous,
+      'SipBindingID' => $binding_id,
+      'TrunkNumber'  => $trunknum,
+    };
+    $result = $self->api_request('POST', $endpoint, $content);
+    if ( $result->{Reply} != 1 ) {
+      $error = "$me attaching binding $binding_id to $trunknum: " .
+        $result->{Message};
+    }
+  }
+
+  if ( $error ) {
+    # delete the binding
+    $endpoint = "SipBindings/$binding_id";
+    $result = $self->api_request('DELETE', $endpoint);
+    if ( $result->{Reply} != 1 ) {
+      my $addl_error = $result->{Message};
+      warn "$error. The SIP binding could not be deleted: '$addl_error'.\n";
+    }
+    die $error;
+  }
+}
+
+sub insert_trunk {
+  my ($exportnum, $svcnum) = @_;
+  my $self = FS::part_export->by_key($exportnum);
+  my $svc_x = FS::svc_phone->by_key($svcnum);
+  my $phonenum = $svc_x->phonenum;
+
+  my $endpoint = "SipTrunks";
+  my $content = {
+    Account           => $self->option('username'),
+    Enabled           => JSON::true,
+    Label             => $svc_x->phone_name_or_cust,
+    Locale            => $locales{$self->option('locale')},
+    MaxChannels       => $svc_x->max_simultaneous,
+    Number            => { Number => $phonenum },
+    PlanID            => $self->option('plan_id'),
+    ThirdPartyLabel   => $svc_x->svcnum,
+  };
+
+  my $result = $self->api_request('POST', $endpoint, $content);
+  if ( $result->{Reply} != 1 ) {
+    die "$me ".$result->{Message};
+  }
+
+  my @gateways = $self->svc_with_role($svc_x, 'gateway');
+  my @dids = $self->svc_with_role($svc_x, 'did');
+  warn "$me inserting dependent services to trunk #$phonenum\n".
+       "gateways: ". at gateways."\nDIDs: ". at dids."\n";
+
+  foreach my $svc_x (@gateways, @dids) {
+    $self->export_insert($svc_x); # will generate additional queue jobs
+  }
+}
+
+sub export_replace {
+  my ($self, $svc_new, $svc_old) = @_;
+
+  my $error = $self->check_svc($svc_new);
+  return $error if $error;
+
+  my $role = $self->svc_role($svc_new)
+    or return "No export role is assigned to this service type.";
+
+  if ( $role eq 'did' and $svc_new->phonenum ne $svc_old->phonenum ) {
+    my $pkgnum = $svc_new->cust_svc->pkgnum;
+    # not that the UI allows this...
+    return $self->queue_action("delete_did", $svc_old->svcnum, 
+                               $svc_old->phonenum, $pkgnum)
+        || $self->queue_action("insert_did", $svc_new->svcnum);
+  }
+
+  my %args;
+  if ( $role eq 'trunk' and $svc_new->sip_password ne $svc_old->sip_password ) {
+    # then trigger a password change
+    %args = (password_change => 1);
+  }
+    
+  $self->queue_action("replace_$role", $svc_new->svcnum, %args);
+}
+
+sub replace_trunk {
+  my ($exportnum, $svcnum, %args) = @_;
+  my $self = FS::part_export->by_key($exportnum);
+  my $svc_x = FS::svc_phone->by_key($svcnum);
+
+  my $enabled = JSON::is_bool( $self->cust_svc->cust_pkg->susp == 0 );
+
+  my $phonenum = $svc_x->phonenum;
+  my $endpoint = "SipTrunks/$phonenum";
+  my $content = {
+    Account           => $self->options('username'),
+    Enabled           => $enabled,
+    Label             => $svc_x->phone_name_or_cust,
+    Locale            => $self->option('locale'),
+    MaxChannels       => $svc_x->max_simultaneous,
+    Number            => $phonenum,
+    PlanID            => $self->option('plan_id'),
+    ThirdPartyLabel   => $svc_x->svcnum,
+  };
+
+  my $result = $self->api_request('PUT', $endpoint, $content);
+  if ( $result->{Reply} != 1 ) {
+    die "$me ".$result->{Message};
+  }
+
+  if ( $args{password_change} ) {
+    # then propagate the change to the bindings
+    my @bindings = $self->svc_with_role($svc_x->gateway);
+    foreach my $svc_pbx (@bindings) {
+      my $error = $self->export_replace($svc_pbx);
+      die "$me updating password on bindings: $error\n" if $error;
+    }
+  }
+}
+
+sub replace_did {
+  # we don't handle phonenum/trunk changes
+  my ($exportnum, $svcnum, %args) = @_;
+  my $self = FS::part_export->by_key($exportnum);
+  my $svc_x = FS::svc_phone->by_key($svcnum);
+
+  my $trunk_svc = $self->svc_with_role($svc_x, 'trunk')
+    or return;
+  my $phonenum = $svc_x->phonenum;
+  my $endpoint = "V911s/$phonenum";
+  my $content = $self->e911_content($svc_x);
+
+  my $result = $self->api_request('PUT', $endpoint, $content);
+  if ( $result->{Reply} != 1 ) {
+    die "$me ".$result->{Message};
+  }
+}
+
+sub replace_gateway {
+  my ($exportnum, $svcnum, %args) = @_;
+  my $self = FS::part_export->by_key($exportnum);
+  my $svc_x = FS::svc_pbx->by_key($svcnum);
+
+  my $trunk_svc = $self->svc_with_role($svc_x, 'trunk')
+    or return;
+
+  my $binding_id = $svc_x->uuid;
+
+  my $trunknum = $trunk_svc->phonenum;
+
+  my $endpoint = "SipBindings/$binding_id";
+  # get the canonical name of the binding
+  my $result = $self->api_request('GET', $endpoint);
+  if ( $result->{Message} ) {
+    # then assume the binding is not yet set up
+    return $self->export_insert($svc_x);
+  }
+  my $binding_name = $result->{Name};
+ 
+  my $content = {
+    ContactIPAddress  => $svc_x->ip_addr,
+    ContactPort       => 5060,
+    ID                => $binding_id,
+    IPMatchRequired   => JSON::true,
+    Name              => $binding_name,
+    SipDomainName     => $self->option('proxy'),
+    SipTrunkType      => $self->option('trunktype'),
+    SipUsername       => $trunknum,
+    SipPassword       => $trunk_svc->sip_password,
+  };
+  $result = $self->api_request('PUT', $endpoint, $content);
+
+  if ( $result->{Reply} != 1 ) {
+    die "$me ".$result->{Message};
+  }
+}
+
+sub export_delete {
+  my ($self, $svc_x) = (shift, shift);
+
+  my $role = $self->svc_role($svc_x)
+    or return; # not really an error
+  my $pkgnum = $svc_x->cust_svc->pkgnum;
+
+  # delete_foo(svcnum, identifier, pkgnum)
+  # so that we can find the linked services later
+
+  if ( $role eq 'trunk' ) {
+    $self->queue_action("delete_trunk", $svc_x->svcnum, $svc_x->phonenum, $pkgnum);
+  } elsif ( $role eq 'did' ) {
+    $self->queue_action("delete_did", $svc_x->svcnum, $svc_x->phonenum, $pkgnum);
+  } elsif ( $role eq 'gateway' ) {
+    $self->queue_action("delete_gateway", $svc_x->svcnum, $svc_x->uuid, $pkgnum);
+  }
+}
+
+sub delete_trunk {
+  my ($exportnum, $svcnum, $phonenum, $pkgnum) = @_;
+  my $self = FS::part_export->by_key($exportnum);
+
+  my $endpoint = "SipTrunks/$phonenum";
+
+  my $result = $self->api_request('DELETE', $endpoint);
+  if ( $result->{Reply} != 1 ) {
+    die "$me ".$result->{Message};
+  }
+
+  # deleting this on the server side should remove all DIDs, but we still
+  # need to remove IP bindings
+  my @gateways = $self->svc_with_role($pkgnum, 'gateway');
+  foreach (@gateways) {
+    $_->export_delete;
+  }
+}
+
+sub delete_did {
+  my ($exportnum, $svcnum, $phonenum, $pkgnum) = @_;
+  my $self = FS::part_export->by_key($exportnum);
+
+  my $endpoint = "V911s/$phonenum";
+
+  my $result = $self->api_request('DELETE', $endpoint);
+  if ( $result->{Reply} != 1 ) {
+    warn "$me ".$result->{Message}; # but continue removing the DID
+  }
+
+  my $trunk_svc = $self->svc_with_role($pkgnum, 'trunk')
+    or return ''; # then it's already been removed, most likely
+
+  my $trunknum = $trunk_svc->phonenum;
+  $endpoint = "SipTrunks/$trunknum/Dids/$phonenum";
+
+  $result = $self->api_request('DELETE', $endpoint);
+  if ( $result->{Reply} != 1 ) {
+    die "$me ".$result->{Message};
+  }
+}
+
+sub delete_gateway {
+  my ($exportnum, $svcnum, $binding_id, $pkgnum) = @_;
+  my $self = FS::part_export->by_key($exportnum);
+
+  my $trunk_svc = $self->svc_with_role($pkgnum, 'trunk');
+  if ( $trunk_svc ) {
+    # detach the address from the trunk
+    my $trunknum = $trunk_svc->phonenum;
+    my $endpoint = "SipTrunks/$trunknum/Lines/$binding_id";
+    my $result = $self->api_request('DELETE', $endpoint);
+    if ( $result->{Reply} != 1 ) {
+      die "$me ".$result->{Message};
+    }
+  }
+
+  # seems not to be necessary?
+  #my $endpoint = "SipBindings/$binding_id";
+  #my $result = $self->api_request('DELETE', $endpoint);
+  #if ( $result->{Reply} != 1 ) {
+  #  die "$me ".$result->{Message};
+  #}
+}
+
+sub e911_content {
+  my ($self, $svc_x) = @_;
+
+  my %location = $svc_x->location_hash;
+  my $cust_main = $svc_x->cust_main;
+
+  my $content = {
+    City            => $location{'city'},
+    FirstName       => $cust_main->first,
+    LastName        => $cust_main->last,
+    Number          => $svc_x->phonenum,
+    OtherInfo       => ($svc_x->phone_name || ''),
+    PostalZip       => $location{'zip'},
+    ProvinceState   => $location{'state'},
+    SuiteNumber     => $location{'address2'},
+  };
+  if ($location{address1} =~ /^(\w+) +(.*)$/) {
+    $content->{StreetNumber} = $1;
+    $content->{StreetName} = $2;
+  } else {
+    $content->{StreetNumber} = '';
+    $content->{StreetName} = $location{address1};
+  }
+
+  return $content;
+}
+
+# select by province + ratecenter, not by NPA
+sub get_dids_npa_select { 0 }
+
+sub get_dids {
+  my $self = shift;
+  local $DEBUG = 0;
+
+  my %opt = @_;
+
+  my ($exportnum) = $self->exportnum =~ /^(\d+)$/;
+
+  if ( $opt{'region'} ) {
+
+    # return numbers (probably shouldn't cache this)
+    my $state = $self->ratecenter_cache->{city}{ $opt{'region'} };
+    my $ratecenter = $opt{'region'} . ', ' . $state;
+    my $endpoint = uri_escape("RateCenters/$ratecenter/Next10");
+    my $result = $self->api_request('GET', $endpoint);
+    if (ref($result) eq 'HASH') {
+      die "$me error fetching available DIDs in '$ratecenter': ".$result->{Message}."\n";
+    }
+    my @return;
+    foreach my $row (@$result) {
+      push @return, $row->{Number};
+    }
+    return \@return;
+
+  } else {
+
+    if ( $opt{'state'} ) {
+
+      # ratecenter_cache will refresh the cache if necessary, and die on 
+      # failure. default here is only in case someone gives us a state that
+      # doesn't exist.
+      return $self->ratecenter_cache->{province}->{ $opt{'state'} } || [];
+
+    } else {
+
+      return $self->ratecenter_cache->{all_provinces};
+
+    }
+  }
+}
+
+sub ratecenter_cache {
+  # in-memory caching is probably sufficient...Thinktel's API is pretty fast
+  my $self = shift;
+
+  if (keys(%CACHE) == 0 or ($last_cache_update + $cache_timeout < time) ) {
+    %CACHE = ( province => {}, city => {} );
+    my $result = $self->api_request('GET', 'RateCenters');
+    if (ref($result) eq 'HASH') {
+      die "$me error fetching ratecenters: ".$result->{Message}."\n";
+    }
+    foreach my $row (@$result) {
+      my ($city, $province) = split(', ', $row->{Name});
+      $CACHE{province}->{$province} ||= [];
+      push @{ $CACHE{province}->{$province} }, $city;
+      $CACHE{city}{$city} = $province;
+    }
+    $CACHE{all_provinces} = [ sort keys %{ $CACHE{province} } ];
+    $last_cache_update = time;
+  }
+  
+  return \%CACHE;
+}
+
+=item queue_api_request METHOD, ENDPOINT, CONTENT, JOB
+
+Adds a queue job to make a REST request.
+
+=item api_request METHOD, ENDPOINT[, CONTENT ]
+
+Makes a REST request using METHOD, to URL ENDPOINT (relative to the API
+base). For POST or PUT requests, CONTENT is the content to submit, as a
+hashref. Returns the decoded response; generally, on failure, this will
+have a 'Message' element.
+
+=cut
+
+sub api_request {
+  my $self = shift;
+  my ($method, $endpoint, $content) = @_;
+  my $json = JSON->new->canonical(1); # hash keys are ordered
+
+  $DEBUG ||= 1 if $self->option('debug');
+
+  my $url = $base_url . $endpoint;
+  if ( ref($content) ) {
+    $content = $json->encode($content);
+  }
+
+  # PUT() == _simple_req('PUT'), etc.
+  my $request = HTTP::Request::Common::_simple_req(
+    $method,
+    $url,
+    'Accept'        => 'text/json',
+    'Content-Type'  => 'text/json',
+    'Content'       => $content,
+  );
+
+  $request->authorization_basic(
+    $self->option('username'), $self->option('password')
+  );
+
+  my $stringify = 'content';
+  $stringify = 'as_string' if $DEBUG > 1; # includes HTTP headers
+  warn "$me $method $endpoint\n" . $request->$stringify ."\n" if $DEBUG;
+  my $ua = LWP::UserAgent->new;
+  my $response = $ua->request($request);
+  warn "$me received:\n" . $response->$stringify ."\n" if $DEBUG;
+  if ( ! $response->is_success ) {
+    # fake up a response
+    return { Message => $response->content };
+  }
+
+  return $json->decode($response->content);
+}
+
+1;
diff --git a/FS/FS/part_svc.pm b/FS/FS/part_svc.pm
index 9ed56eb..e1ae02b 100644
--- a/FS/FS/part_svc.pm
+++ b/FS/FS/part_svc.pm
@@ -12,7 +12,7 @@ use FS::export_svc;
 use FS::cust_svc;
 use FS::part_svc_class;
 
-$DEBUG = 0;
+$DEBUG = 1;
 
 =head1 NAME
 
@@ -113,12 +113,8 @@ TODOC: JOB
 sub insert {
   my $self = shift;
   my @fields = ();
-  my @exportnums = ();
   @fields = @{shift(@_)} if @_;
-  if ( @_ ) {
-    my $exportnums = shift;
-    @exportnums = grep $exportnums->{$_}, keys %$exportnums;
-  }
+  my $exportnums = shift || {};
   my $job = '';
   $job = shift if @_;
 
@@ -191,12 +187,14 @@ sub insert {
   }
 
   # add export_svc records
+  my @exportnums = grep $exportnums->{$_}, keys %$exportnums;
   my $slice = 100/scalar(@exportnums) if @exportnums;
   my $done = 0;
   foreach my $exportnum ( @exportnums ) {
     my $export_svc = new FS::export_svc ( {
       'exportnum' => $exportnum,
       'svcpart'   => $self->svcpart,
+      'role'      => $exportnums->{$exportnum},
     } );
     $error = $export_svc->insert($job, $slice*$done++, $slice);
     if ( $error ) {
@@ -327,9 +325,10 @@ sub replace {
 
     # maintain export_svc records
 
-    if ( $exportnums ) {
+    if ( $exportnums ) { # hash of exportnum => role
 
       #false laziness w/ edit/process/agent_type.cgi
+      #and, more importantly, with m2m_Common
       my @new_export_svc = ();
       foreach my $part_export ( qsearch('part_export', {}) ) {
         my $exportnum = $part_export->exportnum;
@@ -339,13 +338,23 @@ sub replace {
         };
         my $export_svc = qsearchs('export_svc', $hashref);
 
-        if ( $export_svc && ! $exportnums->{$exportnum} ) {
-          $error = $export_svc->delete;
-          if ( $error ) {
-            $dbh->rollback if $oldAutoCommit;
-            return $error;
+        if ( $export_svc ) {
+          my $old_role = $export_svc->role || 1; # 1 = null in the db
+          if ( ! $exportnums->{$exportnum}
+               or $old_role ne $exportnums->{$exportnum} ) {
+
+            $error = $export_svc->delete;
+            if ( $error ) {
+              $dbh->rollback if $oldAutoCommit;
+              return $error;
+            }
+            undef $export_svc; # on a role change, force it to be reinserted
+
           }
-        } elsif ( ! $export_svc && $exportnums->{$exportnum} ) {
+        } # if $export_svc
+        if ( ! $export_svc && $exportnums->{$exportnum} ) {
+          # also applies if it's been undef'd because of role change
+          $hashref->{role} = $exportnums->{$exportnum};
           push @new_export_svc, new FS::export_svc ( $hashref );
         }
 
@@ -773,7 +782,13 @@ sub process {
   my %exportnums =
     map { $_->exportnum => ( $param->{'exportnum'.$_->exportnum} || '') }
         qsearch('part_export', {} );
-
+  foreach my $exportnum (%exportnums) {
+    my $role = $param->{'exportnum'.$exportnum.'_role'};
+    # role is undef if the export has no role selector
+    if ( $exportnums{$exportnum} && $role ) {
+      $exportnums{$exportnum} = $role;
+    }
+  }
   my $error;
   if ( $param->{'svcpart'} ) {
     $error = $new->replace( $old,
diff --git a/FS/FS/svc_pbx.pm b/FS/FS/svc_pbx.pm
index d35b3a2..e19dc88 100644
--- a/FS/FS/svc_pbx.pm
+++ b/FS/FS/svc_pbx.pm
@@ -62,6 +62,11 @@ Maximum number of extensions
 
 Maximum number of simultaneous users
 
+=item ip_addr
+
+The IP address of this PBX, if that's relevant. This must be a valid IP 
+address (or blank), but it's not checked for block assignment or uniqueness.
+
 =back
 
 =head1 METHODS
@@ -85,9 +90,11 @@ sub table_info {
   tie my %fields, 'Tie::IxHash',
     'svcnum' => 'PBX',
     'id'     => 'PBX/Tenant ID',
+    'uuid'   => 'External UUID',
     'title'  => 'Name',
     'max_extensions' => 'Maximum number of User Extensions',
     'max_simultaneous' => 'Maximum number of simultaneous users',
+    'ip_addr' => 'IP address',
   ;
 
   {
@@ -237,9 +244,10 @@ sub check {
   my $x = $self->setfixed;
   return $x unless ref($x);
   my $part_svc = $x;
-
-
-  $self->SUPER::check;
+ 
+  return
+     $self->ut_ipn('ip_addr')
+  || $self->SUPER::check;
 }
 
 sub _check_duplicate {
diff --git a/httemplate/edit/elements/export_svc.html b/httemplate/edit/elements/export_svc.html
new file mode 100644
index 0000000..5962ae7
--- /dev/null
+++ b/httemplate/edit/elements/export_svc.html
@@ -0,0 +1,84 @@
+<%args>
+$part_svc
+$svcdb
+$clone => undef
+</%args>
+<%init>
+
+my $svcpart = $clone || $part_svc->svcpart; # may be undef
+
+# get a list of applicable part_exports
+my @part_export;
+my $export_info = FS::part_export::export_info($svcdb);
+foreach ( keys %{ $export_info } ) {
+  push @part_export, qsearch('part_export', { exporttype => $_ });
+}
+# and a hash of which ones are already assigned to this part_svc
+my %export_svc;
+if ( $svcpart ) {
+  %export_svc = map { $_->exportnum => $_ }
+    qsearch('export_svc', { svcpart => $svcpart });
+}
+
+my $count = 0;
+my $columns = 3;
+
+</%init>
+<script type="text/javascript">
+function toggle_selectrole() {
+  var selectrole = document.getElementById( this.name + '_selectrole' );
+  if ( selectrole ) {
+    selectrole.style.visibility = (this.checked) ? '' : 'hidden';
+  }
+}
+<&| /elements/onload.js &>
+  var boxes = document.getElementsByClassName('checkbox_export');
+  for ( var i = 0; i < boxes.length; i++ ) {
+    boxes[i].onchange = toggle_selectrole;
+    toggle_selectrole.apply(boxes[i]);
+  }
+</&>
+</script>
+<& /elements/table.html &>
+  <TR><TH COLSPAN=<% $columns %>>Exports</TH></TR>
+  <TR>
+% # exports
+% foreach my $part_export (@part_export) {
+%   my $exportnum = $part_export->exportnum;
+    <TD>
+      <INPUT CLASS="checkbox_export"
+             TYPE="checkbox" \
+             NAME="exportnum<% $exportnum %>" \
+             VALUE=1 \
+             <% $export_svc{$exportnum} ? 'CHECKED' : '' %>>
+      <% $part_export->label_html %>
+% if ( $part_export->info->{roles} ) {
+%   my $role_info = $part_export->info->{roles};
+%   my @role_names = keys %$role_info;
+%   my %role_labels = map { %_ => $role_info->{$_}->{label} } @role_names;
+%   my $curr_role = $export_svc{$exportnum} ? $export_svc{$exportnum}->role
+%                                           : '';
+      <SPAN CLASS="selectrole" ID="exportnum<%$exportnum%>_selectrole">
+        as: 
+        <& /elements/select.html,
+          'field'       => "exportnum${exportnum}_role",
+          'options'     => \@role_names,
+          'labels'      => \%role_labels,
+          'curr_value'  => $curr_role,
+          'empty_label' => 'select',
+        &>
+      </SPAN>
+%     # XXX should lock out roles that don't apply to the selected svcdb, 
+%     # but that's a pain in the ass
+%   }
+        </SELECT>
+      </SPAN>
+    </TD>
+%   $count++;
+%   if ( $count % $columns == 0 ) {
+  </TR>
+  <TR>
+%   }
+% }
+  </TR>
+</TABLE><BR><BR>
diff --git a/httemplate/edit/elements/part_svc_column.html b/httemplate/edit/elements/part_svc_column.html
index 6dcb602..53cda85 100644
--- a/httemplate/edit/elements/part_svc_column.html
+++ b/httemplate/edit/elements/part_svc_column.html
@@ -64,26 +64,11 @@ my %communigate_fields = (
 </%once>
 <INPUT TYPE="hidden" NAME="svcdb" VALUE="<% $svcdb %>">
 <BR><BR>
-<& /elements/table.html &>
-  <TR><TH COLSPAN=<% $columns %>>Exports</TH></TR>
-  <TR>
-% # exports
-% foreach my $part_export (@part_export) {
-    <TD>
-      <INPUT TYPE="checkbox" \
-             NAME="exportnum<% $part_export->exportnum %>" \
-             VALUE=1 \
-             <% $has_export_svc{$part_export->exportnum} ? 'CHECKED' : '' %>>
-      <% $part_export->label_html %>
-    </TD>
-%   $count++;
-%   if ( $count % $columns == 0 ) {
-  </TR>
-  <TR>
-%   }
-% }
-  </TR>
-</TABLE><BR><BR>
+%# include export selection
+<& export_svc.html,
+  part_svc => $part_svc,
+  svcdb => $svcdb
+&>
 For the selected table, you can give fields default or fixed (unchangeable)
 values, or select an inventory class to manually or automatically fill in 
 that field.
@@ -285,27 +270,18 @@ that field.
 <%init>
 my $svcdb = shift;
 my %opt = @_;
-my $columns = 3;
 my $count = 0;
 my $communigate = 0;
 my $conf = FS::Conf->new;
 
 my $part_svc = $opt{'part_svc'} || FS::part_svc->new;
 
-my @part_export;
-my $export_info = FS::part_export::export_info($svcdb);
-foreach (keys %{ $export_info }) {
-  push @part_export, qsearch('part_export', { exporttype => $_ });
+# see if there are communigate exports configured
+if ( exists $communigate_fields{$svcdb} ) {
+  $communigate = FS::part_export->count("exporttype like 'communigate%'");
 }
-$communigate = scalar(grep {$_->exporttype =~ /^communigate/} @part_export);
 
 my $svcpart = $opt{'clone'} || $part_svc->svcpart;
-my %has_export_svc;
-if ( $svcpart ) {
-  foreach (qsearch('export_svc', { svcpart => $svcpart })) {
-    $has_export_svc{$_->exportnum} = 1;
-  }
-}
 
 my @fields;
 if ( defined( dbdef->table($svcdb) ) ) { # when is it ever not defined?
diff --git a/httemplate/edit/part_svc.cgi b/httemplate/edit/part_svc.cgi
index 2ec0242..47b020c 100755
--- a/httemplate/edit/part_svc.cgi
+++ b/httemplate/edit/part_svc.cgi
@@ -31,6 +31,9 @@
   font-size: smaller;
   font-style: italic;
 }
+.selectrole {
+  font-size: small
+}
 </STYLE>
 <SCRIPT TYPE="text/javascript">
 function fixup_submit(layer) {
diff --git a/httemplate/elements/select-phonenum.html b/httemplate/elements/select-phonenum.html
index a8d9a7c..118fe49 100644
--- a/httemplate/elements/select-phonenum.html
+++ b/httemplate/elements/select-phonenum.html
@@ -1,3 +1,34 @@
+<%doc>
+Selector for DID phone number.
+
+Options:
+
+- prefix: prefix for all the object IDs, field names, javascript functions,
+etc. for including multiple DID selectors on a single page.
+
+- empty: text to display when no number is selected ("empty_label")
+
+- bulknum: allow bulk selection of up to this many numbers (self-service 
+only? wtf?)
+
+- svcpart: svcpart (required)
+
+- tollfree: pass "tollfree" to misc/phonenums.cgi, instead of passing an 
+exchange/region/anything else.
+
+- region: corresponds to the inverse of "get_dids_npa_select". The selector
+creates an on-change handler telling the previous selector in the hierarchy
+to update the list of phone numbers. If 'region' is true, it will look for 
+a previous selector named "region", and prefix the query it sends to 
+phonenums.cgi with '_REGION', which results in get_dids() being called
+with a 'region' parameter instead of 'ratecenter' and 'state'.
+
+
+Internally, this will set up an exchange_changed or region_changed function 
+to refresh the phone number list. The function will fetch misc/phonenums.cgi,
+passing the exchange (or region) and 
+</%doc>
+
 <% include('/elements/xmlhttp.html',
               'url'  => $p.'misc/phonenums.cgi',
               'subs' => [ $opt{'prefix'}. 'get_phonenums' ],
diff --git a/httemplate/elements/tr-pkg_svc.html b/httemplate/elements/tr-pkg_svc.html
index 1923f12..a44c5b9 100644
--- a/httemplate/elements/tr-pkg_svc.html
+++ b/httemplate/elements/tr-pkg_svc.html
@@ -61,7 +61,7 @@
     </TD>
 
     <TD>
-      <A HREF="part_svc.cgi?<% $part_svc->svcpart %>"><% $part_svc->svc %></A>      <% $part_svc->disabled =~ /^Y/i ? ' (DISABLED' : '' %>
+      <A HREF="part_svc.cgi?<% $part_svc->svcpart %>"><% $part_svc->svc %></A>      <% $part_svc->disabled =~ /^Y/i ? ' (DISABLED)' : '' %>
     </TD>
 
     <TD ALIGN="center">
diff --git a/httemplate/misc/phonenums.cgi b/httemplate/misc/phonenums.cgi
index a048280..62923ac 100644
--- a/httemplate/misc/phonenums.cgi
+++ b/httemplate/misc/phonenums.cgi
@@ -29,7 +29,7 @@ if ( $exchangestring ) {
       $opts{'state'} = $2;
   } else {
       $exchangestring =~ /\((\d{3})-(\d{3})-XXXX\)\s*$/i
-        or die "unparsable exchange: $exchangestring";
+        or die "unparseable exchange: $exchangestring";
       my( $areacode, $exchange ) = ( $1, $2 );
       $opts{'areacode'} = $areacode;
       $opts{'exchange'} = $exchange;

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

Summary of changes:
 FS/FS/Schema.pm                               |    5 +-
 FS/FS/export_svc.pm                           |   18 +
 FS/FS/part_export.pm                          |   62 +++
 FS/FS/part_export/thinktel.pm                 |  677 +++++++++++++++++++++++++
 FS/FS/part_svc.pm                             |   43 +-
 FS/FS/svc_pbx.pm                              |   14 +-
 httemplate/edit/elements/export_svc.html      |   84 +++
 httemplate/edit/elements/part_svc_column.html |   40 +-
 httemplate/edit/part_svc.cgi                  |    3 +
 httemplate/elements/select-phonenum.html      |   31 ++
 httemplate/elements/tr-pkg_svc.html           |    2 +-
 httemplate/misc/phonenums.cgi                 |    2 +-
 12 files changed, 929 insertions(+), 52 deletions(-)
 create mode 100644 FS/FS/part_export/thinktel.pm
 create mode 100644 httemplate/edit/elements/export_svc.html




More information about the freeside-commits mailing list