From fcb43580b83129097a2abf53104ca29f3185d44b Mon Sep 17 00:00:00 2001 From: Mark Wells Date: Tue, 23 Dec 2014 21:01:03 -0800 Subject: [PATCH] Thinktel VoIP provisioning, #32084 --- 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 diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index 492f8e2b7..8b362a7a6 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 5ef50b648..4579e6d4a 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) +=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 9d261f02d..7819a7c86 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 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 000000000..4a28649a3 --- /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' +

Export to Thinktel SIP Trunking service.

+

This requires three service definitions to be configured: +

    +
  1. 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 max_simultaneous field of this service will set + the channel limit on the trunk. The I will be used for + all gateways.
  2. +
  3. 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.
  4. +
  5. A PBX service for the customer's SIP gateway (Asterisk, OpenPBX, etc. + device). This should be attached in the "gateway" role. The ip_addr + field should be set to the static IP address that will receive calls. + There may be more than one of these on the trunk.
  6. +
+ 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. +

+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: ".@gateways."\nDIDs: ".@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 9ed56ebe4..e1ae02b78 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 d35b3a22c..e19dc88dd 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 000000000..5962ae7f8 --- /dev/null +++ b/httemplate/edit/elements/export_svc.html @@ -0,0 +1,84 @@ +<%args> +$part_svc +$svcdb +$clone => undef + +<%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; + + + +<& /elements/table.html &> + >Exports + +% # exports +% foreach my $part_export (@part_export) { +% my $exportnum = $part_export->exportnum; + + > + <% $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 +% : ''; + + as: + <& /elements/select.html, + 'field' => "exportnum${exportnum}_role", + 'options' => \@role_names, + 'labels' => \%role_labels, + 'curr_value' => $curr_role, + 'empty_label' => 'select', + &> + +% # XXX should lock out roles that don't apply to the selected svcdb, +% # but that's a pain in the ass +% } + + + +% $count++; +% if ( $count % $columns == 0 ) { + + +% } +% } + +

diff --git a/httemplate/edit/elements/part_svc_column.html b/httemplate/edit/elements/part_svc_column.html index 6dcb602fe..53cda859e 100644 --- a/httemplate/edit/elements/part_svc_column.html +++ b/httemplate/edit/elements/part_svc_column.html @@ -64,26 +64,11 @@ my %communigate_fields = (

-<& /elements/table.html &> - >Exports - -% # exports -% foreach my $part_export (@part_export) { - - exportnum} ? 'CHECKED' : '' %>> - <% $part_export->label_html %> - -% $count++; -% if ( $count % $columns == 0 ) { - - -% } -% } - -

+%# 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 2ec024269..47b020c5a 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 +}