[freeside-commits] branch FREESIDE_3_BRANCH updated. 9ad0574bb99869836af00800423af7249721c1d1

Mark Wells mark at 420.am
Wed Dec 24 10:58:57 PST 2014


The branch, FREESIDE_3_BRANCH has been updated
       via  9ad0574bb99869836af00800423af7249721c1d1 (commit)
      from  9bcd5cae8523d499373832e51967419e79ea4da8 (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 9ad0574bb99869836af00800423af7249721c1d1
Author: Mark Wells <mark at freeside.biz>
Date:   Tue Dec 23 20:59:00 2014 -0800

    Thinktel VoIP provisioning, #32084

diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index cdfc07e..c2d4f31 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -2935,6 +2935,7 @@ sub tables_hashref {
         'exportsvcnum' => 'serial', '', '', '', '', 
         'exportnum'    => 'int', '', '', '', '', 
         'svcpart'      => 'int', '', '', '', '', 
+        'role'         => 'varchar', 'NULL', 16, '', '',
       ],
       'primary_key' => 'exportsvcnum',
       'unique'      => [ [ 'exportnum', 'svcpart' ] ],
@@ -4190,13 +4191,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' ] ],
     },
 
     'pbx_extension' => {
diff --git a/FS/FS/export_svc.pm b/FS/FS/export_svc.pm
index b08f8f7..7709dc1 100644
--- a/FS/FS/export_svc.pm
+++ b/FS/FS/export_svc.pm
@@ -42,6 +42,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
@@ -311,8 +313,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 28cb141..15588ea 100644
--- a/FS/FS/part_export.pm
+++ b/FS/FS/part_export.pm
@@ -546,6 +546,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
@@ -694,6 +711,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 get_dids_can_tollfree { 0; }
 sub get_dids_can_manual   { 0; }
@@ -702,6 +746,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 a116819..bfa5651 100644
--- a/FS/FS/part_svc.pm
+++ b/FS/FS/part_svc.pm
@@ -13,7 +13,7 @@ use FS::part_svc_class;
 
 @ISA = qw(FS::Record);
 
-$DEBUG = 0;
+$DEBUG = 1;
 
 =head1 NAME
 
@@ -114,12 +114,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 @_;
 
@@ -192,12 +188,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 ) {
@@ -328,9 +326,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;
@@ -340,13 +339,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 );
         }
 
@@ -777,7 +786,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 f39234d..ad3e477 100644
--- a/FS/FS/svc_pbx.pm
+++ b/FS/FS/svc_pbx.pm
@@ -63,6 +63,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
@@ -86,9 +91,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',
   ;
 
   {
@@ -238,9 +245,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 6d17a37..8acbca1 100644
--- a/httemplate/elements/tr-pkg_svc.html
+++ b/httemplate/elements/tr-pkg_svc.html
@@ -47,7 +47,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>
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