voip.ms export, #31834
authorMark Wells <mark@freeside.biz>
Mon, 29 Dec 2014 07:26:26 +0000 (23:26 -0800)
committerMark Wells <mark@freeside.biz>
Mon, 29 Dec 2014 07:26:39 +0000 (23:26 -0800)
FS/FS/Schema.pm
FS/FS/part_export.pm
FS/FS/part_export/voip_ms.pm [new file with mode: 0644]
FS/FS/svc_phone.pm
httemplate/browse/part_export.cgi
httemplate/edit/part_export.cgi
httemplate/edit/svc_phone.cgi
httemplate/elements/select-did.html
httemplate/elements/tr-select-sip_server.html [new file with mode: 0644]
httemplate/view/svc_phone.cgi

index c2d4f31..fd69037 100644 (file)
@@ -4004,6 +4004,7 @@ sub tables_hashref {
         'e911_class',                    'char', 'NULL',       1, '', '',
         'e911_type',                     'char', 'NULL',       1, '', '', 
         'circuit_svcnum',                 'int', 'NULL',      '', '', '',
+        'sip_server',                 'varchar', 'NULL', $char_d, '', '',
       ],
       'primary_key' => 'svcnum',
       'unique' => [ [ 'sms_carrierid', 'sms_account'] ],
index 15588ea..bdbecff 100644 (file)
@@ -546,23 +546,6 @@ 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
@@ -763,6 +746,61 @@ sub get_dids_npa_select   { 1; }
 # change the phone number for a service. if false, then they can't (have to
 # reprovision completely).
 
+=item svc_role SVC
+
+Returns the role that SVC 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;
+} 
+
+=item svc_with_role { SVC | PKGNUM }, ROLE
+
+Given a svc_* object SVC or pkgnum PKG, and a role name ROLE, finds the
+service(s) in the same package that are linked to this export with ROLE.
+
+=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 $role_info = $self->info->{roles}->{$role}
+    or die "role '$role' does not exist for export '".$self->exporttype."'\n";
+  my $svcdb = $role_info->{svcdb};
+
+  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_info->{multiple} ) {
+    return @svcs;
+  } else {
+    if ( @svcs > 1 ) {
+      warn "multiple $role services in pkgnum $pkgnum; returning the first one.\n";
+    }
+    return $svcs[0];
+  }
+}
 
 =back
 
diff --git a/FS/FS/part_export/voip_ms.pm b/FS/FS/part_export/voip_ms.pm
new file mode 100644 (file)
index 0000000..44ce908
--- /dev/null
@@ -0,0 +1,648 @@
+package FS::part_export::voip_ms;
+
+use base qw( FS::part_export );
+use strict;
+
+use Tie::IxHash;
+use LWP::UserAgent;
+use URI;
+use URI::Escape;
+use JSON;
+use HTTP::Request::Common;
+use Cache::FileCache;
+
+our $me = '[voip.ms]';
+our $DEBUG = 2;
+our $base_url = 'https://voip.ms/api/v1/rest.php';
+
+# cache cities and provinces
+our $CACHE; # a FileCache; their API is not as quick as I'd like
+our $cache_timeout = 86400; # seconds
+
+tie my %options, 'Tie::IxHash',
+  'account'         => { label => 'Main account ID' },
+  'username'        => { label => 'API username', },
+  'password'        => { label => 'API password', },
+  'debug'           => { label => 'Enable debugging', type => 'checkbox', value => 1 },
+  # could dynamically pull this from the API...
+  'protocol'        => {
+    label             => 'Protocol',
+    type              => 'select',
+    options           => [ 1, 3 ],
+    option_labels     => { 1 => 'SIP', 3 => 'IAX' },
+  },
+  'auth_type'       => {
+    label             => 'Authorization type',
+    type              => 'select',
+    options           => [ 1, 2 ],
+    option_labels     => { 1 => 'User/Password', 2 => 'Static IP' },
+  },
+  'billing_type'    => {
+    label             => 'DID billing mode',
+    type              => 'select',
+    options           => [ 1, 2 ],
+    option_labels     => { 1 => 'Per minute', 2 => 'Flat rate' },
+  },
+  'device_type'     => {
+    label             => 'Device type',
+    type              => 'select',
+    options           => [ 1, 2 ],
+    option_labels     => { 1 => 'IP PBX, e.g. Asterisk',
+                           2 => 'IP phone or softphone',
+                         },
+  },
+  'canada_routing'    => {
+    label             => 'Canada routing policy',
+    type              => 'select',
+    options           => [ 1, 2 ],
+    option_labels     => { 1 => 'Value (lowest price)',
+                           2 => 'Premium (highest quality)'
+                         },
+  },
+  'international_route' => { # yes, 'route'
+    label             => 'International routing policy',
+    type              => 'select',
+    options           => [ 0, 1, 2 ],
+    option_labels     => { 0 => 'Disable international calls',
+                           1 => 'Value (lowest price)',
+                           2 => 'Premium (highest quality)'
+                         },
+  },
+  'cnam_lookup' => {
+    label             => 'Enable CNAM lookup on incoming calls',
+    type              => 'checkbox',
+  },
+
+;
+
+tie my %roles, 'Tie::IxHash',
+  'subacct'       => {  label     => 'SIP client',
+                        svcdb     => 'svc_acct',
+                     },
+  'did'           => {  label     => 'DID',
+                        svcdb     => 'svc_phone',
+                        multiple  => 1,
+                     },
+;
+
+our %info = (
+  'svc'      => [qw( svc_acct svc_phone )],
+  'desc'     =>
+    'Provision subaccounts and DIDs to voip.ms wholesale',
+  'options'  => \%options,
+  'roles'    => \%roles,
+  'no_machine' => 1,
+  'notes'    => <<'END'
+<P>Export to <b>voip.ms</b> hosted PBX service.</P>
+<P>This requires two service definitions to be configured on the same package:
+  <OL>
+    <LI>An account service for the subaccount (the "login" used by the 
+    customer's PBX or IP phone, and the call routing service). This should
+    be attached to the export in the "subacct" role. If you are using 
+    password authentication, the <i>username</i> and <i>_password</i> will 
+    be used to authenticate to voip.ms. If you are using static IP 
+    authentication, the <i>slipip</I> (IP address) field should be set to 
+    the address.</LI>
+    <LI>A phone service for a DID, attached to the export in the DID role.
+    You must select a server for the "SIP Host" field. Calls from this DID
+    will be routed to the customer via that server.</LI>
+  </OL>
+</P>
+<P>Export options:
+  <UL>
+    <LI>Main account ID: the numeric ID for the master account. 
+    Subaccount usernames will be prefixed with this number and an underscore,
+    so if you create a subaccount in Freeside with a username of "myuser", 
+    the SIP device will have to authenticate as something like 
+    "123456_myuser".</LI>
+    <LI>API username/password: your API login; see 
+    <a href="https://www.voip.ms/m/api.php">this page</a> to configure it
+    if you haven't done so yet.</LI>
+    <LI>Enable debugging: writes all traffic with the API server to the log.
+    This includes passwords.</LI>
+  </UL>
+  The other options correspond to options in either the subaccount or DID 
+  configuration menu in the voip.ms portal; see documentation there for 
+  details.
+</P>
+END
+);
+
+sub export_insert {
+  my($self, $svc_x) = (shift, shift);
+
+  my $role = $self->svc_role($svc_x);
+  if ( $role eq 'subacct' ) {
+
+    my $error = $self->insert_subacct($svc_x);
+    return "$me $error" if $error;
+
+    my @existing_dids = ( $self->svc_with_role($svc_x, 'did') );
+
+    foreach my $svc_phone (@existing_dids) {
+      $error = $self->insert_did($svc_phone, $svc_x);
+      return "$me $error ordering DID ".$svc_phone->phonenum
+        if $error;
+    }
+
+  } elsif ( $role eq 'did' ) {
+
+    my $svc_acct = $self->svc_with_role($svc_x, 'subacct');
+    return if !$svc_acct;
+    my $error = $self->insert_did($svc_x, $svc_acct);
+    return "$me $error" if $error;
+
+  }
+  '';
+}
+
+sub export_replace {
+  my ($self, $svc_new, $svc_old) = @_;
+  my $role = $self->svc_role($svc_new);
+  my $error;
+  if ( $role eq 'subacct' ) {
+    $error = $self->replace_subacct($svc_new, $svc_old);
+  } elsif ( $role eq 'did' ) {
+    $error = $self->replace_did($svc_new, $svc_old);
+  }
+  return "$me $error" if $error;
+  '';
+}
+
+sub export_delete {
+  my ($self, $svc_x) = (shift, shift);
+  my $role = $self->svc_role($svc_x);
+  if ( $role eq 'subacct' ) {
+
+    my @existing_dids = ( $self->svc_with_role($svc_x, 'did') );
+
+    my $error;
+    foreach my $svc_phone (@existing_dids) {
+      $error = $self->delete_did($svc_phone);
+      return "$me $error canceling DID ".$svc_phone->phonenum
+        if $error;
+    }
+
+    $error = $self->delete_subacct($svc_x);
+    return "$me $error" if $error;
+
+  } elsif ( $role eq 'did' ) {
+
+    my $svc_acct = $self->svc_with_role($svc_x, 'subacct');
+    return if !$svc_acct;
+    my $error = $self->delete_did($svc_x);
+    return "$me $error" if $error;
+
+  }
+  '';
+}
+
+sub export_suspend {
+  my $self = shift;
+  my $svc_x = shift;
+  my $role = $self->svc_role($svc_x);
+  return if $role ne 'subacct'; # can't suspend DIDs directly
+
+  my $error = $self->replace_subacct($svc_x, $svc_x); # will disable it
+  return "$me $error" if $error;
+  '';
+}
+
+sub export_unsuspend {
+  my $self = shift;
+  my $svc_x = shift;
+  my $role = $self->svc_role($svc_x);
+  return if $role ne 'subacct'; # can't suspend DIDs directly
+
+  $svc_x->set('unsuspended', 1); # hack to tell replace_subacct to do it
+  my $error = $self->replace_subacct($svc_x, $svc_x); #same
+  return "$me $error" if $error;
+  '';
+}
+
+
+sub insert_subacct {
+  my ($self, $svc_acct) = @_;
+  my $method = 'createSubAccount';
+  my $content = $self->subacct_content($svc_acct);
+
+  my $result = $self->api_request($method, $content);
+  if ( $result->{status} ne 'success' ) {
+    return $result->{status}; # or look up the error message string?
+  }
+
+  # result includes the account ID and the full username, but we don't
+  # really need to keep those; we can look them up later
+  '';
+}
+
+sub insert_did {
+  my ($self, $svc_phone, $svc_acct) = @_;
+  my $method = 'orderDID';
+  my $content = $self->did_content($svc_phone, $svc_acct);
+  my $result = $self->api_request($method, $content);
+  if ( $result->{status} ne 'success' ) {
+    return $result->{status}; # or look up the error message string?
+  }
+  '';
+}
+
+sub delete_subacct {
+  my ($self, $svc_acct) = @_;
+  my $account = $self->option('account') . '_' . $svc_acct->username;
+
+  my $id = $self->subacct_id($svc_acct);
+  if ( $id =~ /\D/ ) {
+
+    return $id; # it's an error
+
+  } elsif ( $id eq '' ) {
+
+    return ''; # account doesn't exist, don't need to delete
+
+  } # else it's numeric
+
+  warn "$me deleting account $account with ID $id\n" if $DEBUG;
+  my $result = $self->api_request('delSubAccount', { id => $id });
+  if ( $result->{status} ne 'success' ) {
+    return $result->{status};
+  }
+  '';
+}
+
+sub delete_did {
+  my ($self, $svc_phone) = @_;
+  my $phonenum = $svc_phone->phonenum;
+
+  my $result = $self->api_request('cancelDID', { did => $phonenum });
+  if ( $result->{status} ne 'success' and $result->{status} ne 'invalid_did' )
+  {
+    return $result->{status};
+  }
+  '';
+}
+
+sub replace_subacct {
+  my ($self, $svc_new, $svc_old) = @_;
+  if ( $svc_new->username ne $svc_old->username ) {
+    return "can't change account username; delete and recreate the account instead";
+  }
+  
+  my $id = $self->subacct_id($svc_new);
+  if ( $id =~ /\D/ ) {
+
+    return $id;
+
+  } elsif ( $id eq '' ) {
+
+    # account doesn't exist; provision it anew
+    return $self->insert_subacct($svc_new);
+
+  }
+
+  my $content = $self->subacct_content($svc_new);
+  delete $content->{username};
+  $content->{id} = $id;
+
+  my $result = $self->api_request('setSubAccount', $content);
+  if ( $result->{status} ne 'success' ) {
+    return $result->{status};
+  }
+
+  '';
+}
+
+sub replace_did {
+  my ($self, $svc_new, $svc_old) = @_;
+  if ( $svc_new->phonenum ne $svc_old->phonenum ) {
+    return "can't change DID phone number";
+  }
+  # check that there's a subacct set up
+  my $svc_acct = $self->svc_with_role($svc_new, 'subacct')
+    or return '';
+
+  # check for the existing DID
+  my $result = $self->api_request('getDIDsInfo',
+    { did => $svc_new->phonenum }
+  );
+  if ( $result->{status} eq 'invalid_did' ) {
+
+    # provision the DID
+    return $self->insert_did($svc_new, $svc_acct);
+
+  } elsif ( $result->{status} ne 'success' ) {
+
+    return $result->{status};
+
+  }
+
+  my $existing = $result->{dids}[0];
+
+  my $content = $self->did_content($svc_new, $svc_acct);
+  if ( $content->{billing_type} == $existing->{billing_type} ) {
+    delete $content->{billing_type}; # confuses the server otherwise
+  }
+  $result = $self->api_request('setDIDInfo', $content);
+  if ( $result->{status} ne 'success' ) {
+    return $result->{status};
+  }
+
+  return '';
+}
+
+#######################
+# CONVENIENCE METHODS #
+#######################
+
+sub subacct_id {
+  my ($self, $svc_acct) = @_;
+  my $account = $self->option('account') . '_' . $svc_acct->username;
+
+  # look up the subaccount's numeric ID
+  my $result = $self->api_request('getSubAccounts', { account => $account });
+  if ( $result->{status} eq 'invalid_account' ) {
+    return '';
+  } elsif ( $result->{status} ne 'success' ) {
+    return "$result->{status} looking up account ID";
+  } else {
+    return $result->{accounts}[0]{id};
+  }
+}
+
+sub subacct_content {
+  my ($self, $svc_acct) = @_;
+
+  my $cust_pkg = $svc_acct->cust_svc->cust_pkg;
+
+  my $desc = $svc_acct->finger || $svc_acct->username;
+  my $intl = $self->option('international_route');
+  my $lockintl = 0;
+  if ($intl == 0) {
+    $intl = 1; # can't send zero
+    $lockintl = 1;
+  }
+
+  my %auth;
+  if ( $cust_pkg and $cust_pkg->susp > 0 and !$svc_acct->get('unsuspended') ) {
+    # we can't explicitly suspend their account, so just set its password to 
+    # a partially random string that satisfies the password rules
+    # (we still have their real password in the svc_acct record)
+    %auth = ( auth_type => 1,
+              password  => sprintf('Suspend-%08d', int(rand(100000000)) ),
+            );
+  } else {
+    %auth = ( auth_type => $self->option('auth_type'),
+              password  => $svc_acct->_password,
+              ip        => $svc_acct->slipip,
+            );
+  }
+  return {
+    username            => $svc_acct->username,
+    description         => $desc,
+    %auth,
+    device_type         => $self->option('device_type'),
+    canada_routing      => $self->option('canada_routing'),
+    lock_international  => $lockintl,
+    international_route => $intl,
+    # sensible defaults for these
+    music_on_hold       => 'default', # silence
+    allowed_codecs      => 'ulaw;g729;gsm',
+    dtmf_mode           => 'AUTO',
+    nat                 => 'yes',
+  };
+}
+
+sub did_content {
+  my ($self, $svc_phone, $svc_acct) = @_;
+
+  my $account = $self->option('account') . '_' . $svc_acct->username;
+  my $phonenum = $svc_phone->phonenum;
+  # look up POP number (for some reason this is assigned per DID...)
+  my $sip_server = $svc_phone->sip_server
+    or return "SIP server required";
+  my $popnum = $self->cache('server_popnum')->{ $svc_phone->sip_server }
+    or return "SIP server '$sip_server' is unknown";
+  return {
+    did                 => $phonenum,
+    routing             => "account:$account",
+    # secondary routing options (failovers, voicemail) are outside our 
+    # scope here
+    # though we could support them using the "forwarddst" field?
+    pop                 => $popnum,
+    dialtime            => 60, # sensible default, add an option if needed
+    cnam                => ($self->option('cnam_lookup') ? 1 : 0),
+    note                => $svc_phone->phone_name,
+    billing_type        => $self->option('billing_type'),
+  };
+}
+
+#################
+# DID SELECTION #
+#################
+
+sub get_dids_npa_select { 0 } # all Canadian VoIP providers seem to have this
+
+sub get_dids {
+  my $self = shift;
+  my %opt = @_;
+
+  my ($exportnum) = $self->exportnum =~ /^(\d+)$/;
+
+  if ( $opt{'region'} ) {
+
+    # return numbers (probably shouldn't cache this)
+    my ($ratecenter, $province) = $opt{'region'} =~ /^(.*), (..)$/;
+    my $country = $self->cache('province_country')->{ $province };
+    my $result;
+    if ( $country eq 'CAN' ) {
+      $result = $self->api_insist('getDIDsCAN',
+                                  { province => $province,
+                                    ratecenter => $ratecenter
+                                  }
+                                 );
+    } elsif ( $country eq 'USA' ) {
+      $result = $self->api_insist('getDIDsUSA',
+                                  { state => $province,
+                                    ratecenter => $ratecenter
+                                  }
+                                 );
+    }
+    my @return = map { $_->{did} } @{ $result->{dids} };
+    return \@return;
+  } else {
+
+    if ( $opt{'state'} ) {
+      my $province = $opt{'state'};
+
+      # 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->cache('province_city', $province) || [];
+
+    } else {
+
+      # return a list of provinces
+      return [
+        @{ $self->cache('country_province')->{CAN} },
+        @{ $self->cache('country_province')->{USA} },
+      ];
+    }
+  }
+}
+
+sub get_sip_servers {
+  my $self = shift;
+  return [ sort keys %{ $self->cache('server_popnum') } ];
+}
+
+sub cache {
+  my $self = shift;
+  my $element = shift or return;
+  my $province = shift;
+
+  $CACHE ||= Cache::FileCache->new({
+    'cache_root' => $FS::UID::cache_dir.'/cache'.$FS::UID::datasrc,
+    'namespace'  => __PACKAGE__,
+    'default_expires_in' => $cache_timeout,
+  });
+
+  if ( $element eq 'province_city' ) {
+    $element .= ".$province";
+  }
+  return $CACHE->get($element) || $self->reload_cache($element);
+}
+
+sub reload_cache {
+  my $self = shift;
+  my $element = shift;
+  if ( $element eq 'province_country' or $element eq 'country_province' ) {
+    # populate provinces/states
+
+    my %province_country;
+    my %country_province = ( CAN => [], USA => [] );
+
+    my $result = $self->api_insist('getProvinces');
+    foreach my $province (map { $_->{province} } @{ $result->{provinces} }) {
+      $province_country{$province} = 'CAN';
+      push @{ $country_province{CAN} }, $province;
+    }
+
+    $result = $self->api_insist('getStates');
+    foreach my $state (map { $_->{state} } @{ $result->{states} }) {
+      $province_country{$state} = 'USA';
+      push @{ $country_province{USA} }, $state;
+    }
+
+    $CACHE->set('province_country', \%province_country);
+    $CACHE->set('country_province', \%country_province);
+    return $CACHE->get($element);
+
+  } elsif ( $element eq 'server_popnum' ) {
+
+    my $result = $self->api_insist('getServersInfo');
+    my %server_popnum;
+    foreach (@{ $result->{servers} }) {
+      $server_popnum{ $_->{server_hostname} } = $_->{server_pop};
+    }
+
+    $CACHE->set('server_popnum', \%server_popnum);
+    return \%server_popnum;
+
+  } elsif ( $element =~ /^province_city\.(\w+)$/ ) {
+
+    my $province = $1;
+
+    # then get the ratecenters for that province
+    my $country = $self->cache('province_country')->{$province};
+    my @ratecenters;
+
+    if ( $country eq 'CAN' ) {
+
+      my $result = $self->api_insist('getRateCentersCAN',
+                                   { province => $province });
+
+      foreach (@{ $result->{ratecenters} }) {
+        my $ratecenter = $_->{ratecenter} . ", $province"; # disambiguate
+        push @ratecenters, $ratecenter;
+      }
+
+    } elsif ( $country eq 'USA' ) {
+
+      my $result = $self->api_insist('getRateCentersUSA',
+                                   { state => $province });
+      foreach (@{ $result->{ratecenters} }) {
+        my $ratecenter = $_->{ratecenter} . ", $province";
+        push @ratecenters, $ratecenter;
+      }
+
+    }
+
+    $CACHE->set($element, \@ratecenters);
+    return \@ratecenters;
+
+  } else {
+    return;
+  }
+}
+
+##############
+# API ACCESS #
+##############
+
+=item api_request METHOD, CONTENT
+
+Makes a REST request with method name METHOD, and POST content CONTENT (as
+a hashref).
+
+=cut
+
+sub api_request {
+  my $self = shift;
+  my ($method, $content) = @_;
+  $DEBUG ||= 1 if $self->option('debug');
+  my $url = URI->new($base_url);
+  $url->query_form(
+    'method'        => $method,
+    'api_username'  => $self->option('username'),
+    'api_password'  => $self->option('password'),
+    %$content
+  );
+
+  my $request = GET($url,
+    'Accept'        => 'text/json',
+  );
+
+  warn "$me $method\n" . $request->as_string ."\n" if $DEBUG;
+  my $ua = LWP::UserAgent->new;
+  my $response = $ua->request($request);
+  warn "$me received\n" . $response->as_string ."\n" if $DEBUG;
+  if ( !$response->is_success ) {
+    return { status => $response->content };
+  }
+
+  return decode_json($response->content);
+}
+
+=item api_insist METHOD, CONTENT
+
+Exactly like L</api_request>, but if the returned "status" is not "success",
+throws an exception.
+
+=cut
+
+sub api_insist {
+  my $self = shift;
+  my $method = $_[0];
+  my $result = $self->api_request(@_);
+  if ( $result->{status} eq 'success' ) {
+    return $result;
+  } elsif ( $result->{status} ) {
+    die "$me $method: $result->{status}\n";
+  } else {
+    die "$me $method: no status returned\n";
+  }
+}
+
+1;
index 56104ba..46b2311 100644 (file)
@@ -134,6 +134,15 @@ Class of Service for E911 service (per the NENA 2.1 standard).
 
 Type of Service for E911 service.
 
+=item circuit_svcnum
+
+The L<FS::svc_circuit> record for the physical circuit that transports this
+phone line.
+
+=item sip_server
+
+The hostname of the SIP server that this phone number is routed to.
+
 =back
 
 =head1 METHODS
@@ -251,6 +260,10 @@ sub table_info {
                                 disable_inventory => 1,
                                 multiple => 1,
                         },
+        'sip_server'  => {
+                                label => 'SIP Host',
+                                %dis2,
+                         },
     },
   };
 }
@@ -548,6 +561,7 @@ sub check {
                                'native', 'portin-reject', 'portout-reject'])
     || $self->ut_enumn('portable', ['','Y'])
     || $self->ut_textn('lnp_reject_reason')
+    || $self->ut_domainn('sip_server')
   ;
   return $error if $error;
 
index 876633a..1f835d7 100755 (executable)
@@ -60,21 +60,25 @@ function part_export_areyousure(href) {
 %         my %opt = $part_export->options;
 %         my $defs = $part_export->info->{options};
 %         my %multiples;
-%         foreach my $opt (keys %$defs) { # is a Tie::IxHash
-%           my $group = $defs->{$opt}->{multiple};
+%         foreach my $optname (keys %$defs) { # is a Tie::IxHash
+%           my $def = $defs->{$optname};
+%           my $group = $def->{multiple};
 %           if ( $group ) {
-%             my @values = split("\n", $opt{$opt});
+%             my @values = split("\n", $opt{$optname});
 %             $multiples{$group} ||= [];
-%             push @{ $multiples{$group} }, [ $opt, @values ] if @values;
-%             delete $opt{$opt};
-%           } elsif (length($opt{$opt})) { # the normal case
-%#         foreach my $opt ( keys %opt ) { 
+%             push @{ $multiples{$group} }, [ $optname, @values ] if @values;
+%             delete $opt{$optname};
+%           } elsif (length($opt{$optname})) { # the normal case
+%             my $value = $opt{$optname};
+%             if ( $def->{option_labels} ) {
+%               $value = $def->{option_labels}->{$value} || $value;
+%             }
   
             <TR>
-              <TD ALIGN="right" VALIGN="top" WIDTH="33%"><% $opt %>:&nbsp;</TD>
-              <TD ALIGN="left" WIDTH="67%"><% encode_entities($opt{$opt}) %></TD>
+              <TD ALIGN="right" VALIGN="top" WIDTH="33%"><% $optname %>:&nbsp;</TD>
+              <TD ALIGN="left" WIDTH="67%"><% encode_entities($value) %></TD>
             </TR>
-%             delete $opt{$opt};
+%             delete $opt{$optname};
 %           }
 %         }
 %         # now any that are somehow not in the options list
index 2897cf3..0e53e29 100644 (file)
@@ -201,6 +201,15 @@ my $widget = new HTML::Widgets::SelectLayers(
         $html .= qq!<TR><TD ALIGN="right">$label</TD><TD>!;
       }
       if ( $type eq 'select' ) {
+
+        # 'select' options can specify options one of two ways:
+        # the "preferred" way:
+        #   options: arrayref of allowed option values
+        #   option_labels: hashref of option value => label
+        # OR the weird and semi-deprecated way:
+        #   option_values: coderef to return a list of allowed option values
+        #   option_label: coderef to take an option value and return its label
+
         my $size = defined($optinfo->{size}) ? " SIZE=" . $optinfo->{size} : '';
         my $multi = ($optinfo->{multi} || $optinfo->{multiple})
                       ? ' MULTIPLE' : '';
@@ -218,10 +227,15 @@ my $widget = new HTML::Widgets::SelectLayers(
           #} else {
             my $selected = ($multi ? grep {$_ eq $select_option} @values : $select_option eq $value ) ? ' SELECTED' : '';
             my $label = $select_option;
-            if (defined($optinfo->{option_label})) {
+            if ( defined $optinfo->{option_label} ) {
               my $labelsub = $optinfo->{option_label};
               $label = &$labelsub($select_option);
+            } elsif ( defined $optinfo->{option_labels} ) {
+              if (exists $optinfo->{option_labels}->{$select_option}) {
+                $label = $optinfo->{option_labels}->{$select_option};
+              }
             }
+    
             $html .= qq!<OPTION VALUE="$select_option"$selected>!.
                      qq!$label</OPTION>!;
           #}
index f9c0d40..f1471e2 100644 (file)
@@ -132,6 +132,9 @@ my $begin_callback = sub {
              value   => 'Carrier Information',
              colspan => 8,
            },
+           { field => 'sip_server',
+             type  => 'select-sip_server',
+           },
            { field => 'sms_carrierid',
              label => 'SMS Carrier',
              type  => 'select-cdr_carrier',
index c396031..8a91d7a 100644 (file)
@@ -81,18 +81,18 @@ Example:
 %       # if/when other folks need an areacode-less DID selector that goes
 %       # directly from state to region
 
-        <TD VALIGN="top">
-          <% include('/elements/select.html',
-                       'field'    => 'phonenum_state',
-                       'id'       => 'phonenum_state',
-                       'options'  => [ '', @{ $export->get_dids } ],
-                       'labels'   => { '' => 'Select province' },
-                       'onchange' => 'phonenum_state_changed(this);',
-                       'disabled' => ( $manual_checked ? 1 : 0 ),
-                    )
-          %>
-          <BR><FONT SIZE="-1" ID="phonenum_state_label" <% $manual_checked ? 'STYLE="color:#999999"' : '' %>>Province</FONT>
-        </TD>
+          <TD VALIGN="top">
+            <% include('/elements/select.html',
+                         'field'    => 'phonenum_state',
+                         'id'       => 'phonenum_state',
+                         'options'  => [ '', @{ $export->get_dids } ],
+                         'labels'   => { '' => 'Select province' },
+                         'onchange' => 'phonenum_state_changed(this);',
+                         'disabled' => ( $manual_checked ? 1 : 0 ),
+                      )
+            %>
+            <BR><FONT SIZE="-1" ID="phonenum_state_label" <% $manual_checked ? 'STYLE="color:#999999"' : '' %>>Province</FONT>
+          </TD>
 
           <TD VALIGN="top">
             <% include('/elements/select-region.html',
diff --git a/httemplate/elements/tr-select-sip_server.html b/httemplate/elements/tr-select-sip_server.html
new file mode 100644 (file)
index 0000000..8df1b62
--- /dev/null
@@ -0,0 +1,48 @@
+% if ( $columnflag eq 'F' ) {
+<& fixed.html, %opt &>
+% } elsif ( $use_selector ) {
+%   my $servers = $exports[0]->get_sip_servers;
+%   # pretty simple selector, they're all just hostnames/IP addresses
+<& tr-select.html,
+    %opt,
+    options     => $servers,
+&>
+% } else {
+<& tr-input-text.html, %opt &>
+% }
+</TR>
+
+<%init>
+
+my %opt = @_;
+my $cell_style = $opt{'cell_style'} ? 'STYLE="'. $opt{'cell_style'}. '"' : '';
+
+$opt{'field'} ||= 'sip_server';
+
+#false laziness w/select-did.html
+#XXX make sure this comes through on errors too
+my $svcpart  = $opt{'svcpart'}
+            || $opt{'object'}->svcpart
+            || $opt{'object'}->cust_svc->svcpart;
+
+my $part_svc = qsearchs('part_svc', { 'svcpart'=>$svcpart } );
+die "unknown svcpart $svcpart" unless $part_svc;
+
+my $columnflag;
+my $psc = $part_svc->part_svc_column($opt{'field'});
+if ( $psc ) {
+  $columnflag = $psc->columnflag;
+}
+
+my @exports = $part_svc->part_export_did;
+if ( scalar(@exports) > 1 ) {
+  die "more than one DID-providing export attached to svcpart $svcpart";
+}
+
+my $use_selector = 0;
+
+if ( $exports[0] and $exports[0]->can('get_sip_servers') ) {
+  $use_selector = 1;
+}
+
+</%init>
index 1c0fb39..aca4129 100644 (file)
@@ -19,6 +19,7 @@ my %labels = map { $_ =>  ( ref($fields->{$_})
 my @fields = qw( countrycode phonenum sim_imsi );
 push @fields, 'domain' if $conf->exists('svc_phone-domain');
 push @fields, qw( pbx_title );
+$labels{pbx_title} = 'PBX';
 
 if ( $conf->exists('showpasswords') ) {
   push @fields, qw( sip_password );
@@ -58,6 +59,8 @@ push @fields, { field => 'circuit_label',
                 link => [ $p.'view/svc_circuit.html?', 'circuit_svcnum' ]
               };
 
+push @fields, 'sip_server';
+
 my $html_foot = sub {
   my $svc_phone = shift;