From: Mark Wells Date: Sun, 28 Feb 2016 03:29:32 +0000 (-0800) Subject: sipwise export, #40097 X-Git-Url: http://git.freeside.biz/gitweb/?a=commitdiff_plain;h=18dc453ef07f61c4c3b1cb4d6cb7e3a9b95567f2;p=freeside.git sipwise export, #40097 --- diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index be12c0307..e1ed43c52 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -5857,6 +5857,7 @@ sub tables_hashref { 'pbxsvc', 'int', 'NULL', '', '', '', 'domsvc', 'int', 'NULL', '', '', '', 'locationnum', 'int', 'NULL', '', '', '', + 'forward_svcnum', 'int', 'NULL', '', '', '', 'forwarddst', 'varchar', 'NULL', 15, '', '', 'email', 'varchar', 'NULL', 255, '', '', 'lnp_status', 'varchar', 'NULL', $char_d, '', '', diff --git a/FS/FS/part_export/sipwise.pm b/FS/FS/part_export/sipwise.pm new file mode 100644 index 000000000..9559648c6 --- /dev/null +++ b/FS/FS/part_export/sipwise.pm @@ -0,0 +1,818 @@ +package FS::part_export::sipwise; + +use base qw( FS::part_export ); +use strict; + +use FS::Record qw(qsearch qsearchs dbh); +use Tie::IxHash; +use LWP::UserAgent; +use URI; +use JSON::XS; +use HTTP::Request::Common qw(GET POST PUT DELETE); +use FS::Misc::DateTime qw(parse_datetime); +use DateTime; +use Number::Phone; +use Try::Tiny; + +our $me = '[sipwise]'; +our $DEBUG = 0; + +tie my %options, 'Tie::IxHash', + 'port' => { label => 'Port' }, + 'username' => { label => 'API username', }, + 'password' => { label => 'API password', }, + 'debug' => { label => 'Enable debugging', type => 'checkbox', value => 1 }, + 'billing_profile' => { + label => 'Billing profile handle', + default => 'default', + }, + 'subscriber_profile_set' => { + label => 'Subscriber profile set name (optional)', + }, + 'reseller_id' => { label => 'Reseller ID' }, + 'ssl_no_verify' => { label => 'Skip SSL certificate validation', + type => 'checkbox', + }, +; + +tie my %roles, 'Tie::IxHash', + 'subscriber' => { label => 'Subscriber', + svcdb => 'svc_acct', + multiple => 1, + }, + 'did' => { label => 'DID', + svcdb => 'svc_phone', + multiple => 1, + }, +; + +our %info = ( + 'svc' => [qw( svc_acct svc_phone )], + 'desc' => 'Provision to a Sipwise sip:provider server', + 'options' => \%options, + 'roles' => \%roles, + 'notes' => <<'END' +

Export to a sip:provider server.

+

This requires two service definitions to be configured on the same package: +

    +
  1. An account service for a SIP client account ("subscriber"). The + username will be the SIP username. The domsvc should point + to a domain service to use as the SIP domain name.
  2. +
  3. A phone service for a DID. The phonenum here will be a PSTN + number. The forward_svcnum field should be set to the account that + will receive calls at this number. +
+

+END +); + +sub export_insert { + my($self, $svc_x) = (shift, shift); + + local $SIG{__DIE__}; + my $error; + my $role = $self->svc_role($svc_x); + if ( $role eq 'subscriber' ) { + + try { $self->insert_subscriber($svc_x) } + catch { $error = $_ }; + + } elsif ( $role eq 'did' ) { + + try { $self->export_did($svc_x) } + catch { $error = $_ }; + + } + return "$me $error" if $error; + ''; +} + +sub export_replace { + my ($self, $svc_new, $svc_old) = @_; + local $SIG{__DIE__}; + + my $role = $self->svc_role($svc_new); + + my $error; + if ( $role eq 'subscriber' ) { + + try { $self->replace_subscriber($svc_new, $svc_old) } + catch { $error = $_ }; + + } elsif ( $role eq 'did' ) { + + try { $self->export_did($svc_new, $svc_old) } + catch { $error = $_ }; + + } + return "$me $error" if $error; + ''; +} + +sub export_delete { + my ($self, $svc_x) = (shift, shift); + local $SIG{__DIE__}; + + my $role = $self->svc_role($svc_x); + my $error; + + if ( $role eq 'subscriber' ) { + + # no need to remove DIDs from it, just drop the subscriber record + try { $self->delete_subscriber($svc_x) } + catch { $error = $_ }; + + } elsif ( $role eq 'did' ) { + + try { $self->export_did($svc_x) } + catch { $error = $_ }; + + } + return "$me $error" if $error; + ''; +} + +# logic to set subscribers to locked/active is in replace_subscriber + +sub export_suspend { + my $self = shift; + my $svc_x = shift; + my $role = $self->svc_role($svc_x); + my $error; + if ( $role eq 'subscriber' ) { + try { $self->replace_subscriber($svc_x, $svc_x) } + catch { $error = $_ }; + } + return "$me $error" if $error; + ''; +} + +sub export_unsuspend { + my $self = shift; + my $svc_x = shift; + my $role = $self->svc_role($svc_x); + my $error; + if ( $role eq 'subscriber' ) { + $svc_x->set('unsuspended', 1); + try { $self->replace_subscriber($svc_x, $svc_x) } + catch { $error = $_ }; + } + return "$me $error" if $error; + ''; +} + +############# +# CUSTOMERS # +############# + +=item get_customer SERVICE + +Returns the Sipwise customer record that should belong to SERVICE. This is +based on the pkgnum field. + +=cut + +sub get_customer { + my $self = shift; + my $svc = shift; + my $pkgnum = $svc->cust_svc->pkgnum; + my $custid = "cust_pkg#$pkgnum"; + + my @cust = $self->api_query('customers', [ external_id => $custid ]); + warn "$me multiple customers for external_id $custid.\n" if scalar(@cust) > 1; + $cust[0]; +} + +sub find_or_create_customer { + my $self = shift; + my $svc = shift; + my $cust = $self->get_customer($svc); + return $cust if $cust; + + my $cust_pkg = $svc->cust_svc->cust_pkg; + my $cust_main = $cust_pkg->cust_main; + my $cust_location = $cust_pkg->cust_location; + my ($email) = $cust_main->invoicing_list_emailonly; + die "Customer contact email required\n" if !$email; + my $custid = 'cust_pkg#' . $cust_pkg->pkgnum; + + # find the billing profile + my ($billing_profile) = $self->api_query('billingprofiles', + [ + 'handle' => $self->option('billing_profile'), + 'reseller_id' => $self->option('reseller_id'), + ] + ); + if (!$billing_profile) { + die "can't find billing profile '". $self->option('billing_profile') . "'\n"; + } + my $bpid = $billing_profile->{id}; + + # contacts unfortunately have no searchable external_id or other field + # like that, so we can't go location -> package -> service + my $contact = $self->api_create('customercontacts', + { + 'city' => $cust_location->city, + 'company' => $cust_main->company, + 'country' => $cust_location->country, + 'email' => $email, + 'faxnumber' => $cust_main->fax, + 'firstname' => $cust_main->first, + 'lastname' => $cust_main->last, + 'mobilenumber' => $cust_main->mobile, + 'phonenumber' => ($cust_main->daytime || $cust_main->night), + 'postcode' => $cust_location->zip, + 'reseller_id' => $self->option('reseller_id'), + 'street' => $cust_location->address1, + } + ); + + $cust = $self->api_create('customers', + { + 'status' => 'active', + 'type' => 'sipaccount', + 'contact_id' => $contact->{id}, + 'external_id' => $custid, + 'billing_profile_id' => $bpid, + } + ); + + $cust; +} + +########### +# DOMAINS # +########### + +=item find_or_create_domain DOMAIN + +Returns the record for the domain object named DOMAIN. If necessary, will +create it first. + +=cut + +sub find_or_create_domain { + my $self = shift; + my $domainname = shift; + my ($domain) = $self->api_query('domains', [ 'domain' => $domainname ]); + return $domain if $domain; + + $self->api_create('domains', + { + 'domain' => $domainname, + 'reseller_id' => $self->option('reseller_id'), + } + ); +} + +######## +# DIDS # +######## + +=item acct_for_did SVC_PHONE + +Returns the subscriber svc_acct linked to SVC_PHONE. + +=cut + +sub acct_for_did { + my $self = shift; + my $svc_phone = shift; + my $svcnum = $svc_phone->forward_svcnum or return; + my $svc_acct = FS::svc_acct->by_key($svcnum) or return; + $self->svc_role($svc_acct) eq 'subscriber' or return; + $svc_acct; +} + +=item export_did NEW, OLD + +Refreshes the subscriber information for the service the DID was linked to +previously, and the one it's linked to now. + +=cut + +sub export_did { + my $self = shift; + my ($new, $old) = @_; + if ( $old and $new->forward_svcnum ne $old->forward_svcnum ) { + my $old_svc_acct = $self->acct_for_did($old); + $self->replace_subscriber( $old_svc_acct ) if $old_svc_acct; + } + my $new_svc_acct = $self->acct_for_did($new); + $self->replace_subscriber( $new_svc_acct ) if $new_svc_acct; +} + +############### +# SUBSCRIBERS # +############### + +=item get_subscriber SVC + +Gets the subscriber record for SVC, if there is one. + +=cut + +sub get_subscriber { + my $self = shift; + my $svc = shift; + + my $svcnum = $svc->svcnum; + my $svcid = "svc#$svcnum"; + + my $pkgnum = $svc->cust_svc->pkgnum; + my $custid = "cust_pkg#$pkgnum"; + + my @subscribers = grep { $_->{external_id} eq $svcid } + $self->api_query('subscribers', + [ 'customer_external_id' => $custid ] + ); + warn "$me multiple subscribers for external_id $svcid.\n" + if scalar(@subscribers) > 1; + + $subscribers[0]; +} + +# internal method: find DIDs that forward to this service + +sub did_numbers_for_svc { + my $self = shift; + my $svc = shift; + my @numbers; + my @dids = qsearch({ + 'table' => 'svc_phone', + 'hashref' => { 'forward_svcnum' => $svc->svcnum } + }); + foreach my $did (@dids) { + # only include them if they're interesting to this export + if ( $self->svc_role($did) eq 'did' ) { + my $phonenum; + if ($did->countrycode) { + $phonenum = Number::Phone->new('+' . $did->countrycode . $did->phonenum); + } else { + # the long way + my $country = $did->cust_svc->cust_pkg->cust_location->country; + $phonenum = Number::Phone->new($country, $did->phonenum); + } + if (!$phonenum) { + die "Can't process phonenum ".$did->countrycode . $did->phonenum . "\n"; + } + push @numbers, + { 'cc' => $phonenum->country_code, + 'ac' => $phonenum->areacode, + 'sn' => $phonenum->subscriber + }; + } + } + @numbers; +} + +sub get_subscriber_profile_set_id { + my $self = shift; + if ( my $setname = $self->option('subscriber_profile_set') ) { + my ($set) = $self->api_query('subscriberprofilesets', + [ name => $setname ] + ); + die "Subscriber profile set '$setname' not found" unless $set; + return $set->{id}; + } + ''; +} + +sub insert_subscriber { + my $self = shift; + my $svc = shift; + + my $cust = $self->find_or_create_customer($svc); + my $svcid = "svc#" . $svc->svcnum; + my $status = $svc->cust_svc->cust_pkg->susp ? 'locked' : 'active'; + $status = 'active' if $svc->get('unsuspended'); + my $domain = $self->find_or_create_domain($svc->domain); + + my @numbers = $self->did_numbers_for_svc($svc); + my $first_number = shift @numbers; + + my $profile_set_id = $self->get_subscriber_profile_set_id; + my $subscriber = $self->api_create('subscribers', + { + 'alias_numbers' => \@numbers, + 'customer_id' => $cust->{id}, + 'display_name' => $svc->finger, + 'domain_id' => $domain->{id}, + 'external_id' => $svcid, + 'password' => $svc->_password, + 'primary_number' => $first_number, + 'profile_set_id' => $profile_set_id, + 'status' => $status, + 'username' => $svc->username, + } + ); +} + +sub replace_subscriber { + my $self = shift; + my $svc = shift; + my $old = shift || $svc->replace_old; + my $svcid = "svc#" . $svc->svcnum; + + my $cust = $self->find_or_create_customer($svc); + my $status = $svc->cust_svc->cust_pkg->susp ? 'locked' : 'active'; + $status = 'active' if $svc->get('unsuspended'); + my $domain = $self->find_or_create_domain($svc->domain); + + my @numbers = $self->did_numbers_for_svc($svc); + my $first_number = shift @numbers; + + my $subscriber = $self->get_subscriber($svc); + + if ( $subscriber ) { + my $id = $subscriber->{id}; + if ( $svc->username ne $old->username ) { + # have to delete and recreate + $self->api_delete("subscribers/$id"); + $self->insert_subscriber($svc); + } else { + my $profile_set_id = $self->get_subscriber_profile_set_id; + $self->api_update("subscribers/$id", + { + 'alias_numbers' => \@numbers, + 'customer_id' => $cust->{id}, + 'display_name' => $svc->finger, + 'domain_id' => $domain->{id}, + 'email' => $svc->email, + 'external_id' => $svcid, + 'password' => $svc->_password, + 'primary_number' => $first_number, + 'profile_set_id' => $profile_set_id, + 'status' => $status, + 'username' => $svc->username, + } + ); + } + } else { + warn "$me subscriber not found for $svcid; creating new\n"; + $self->insert_subscriber($svc); + } +} + +sub delete_subscriber { + my $self = shift; + my $svc = shift; + my $svcid = "svc#" . $svc->svcnum; + my $pkgnum = $svc->cust_svc->pkgnum; + my $custid = "cust_pkg#$pkgnum"; + + my $subscriber = $self->get_subscriber($svc); + + if ( $subscriber ) { + my $id = $subscriber->{id}; + $self->api_delete("subscribers/$id"); + } else { + warn "$me subscriber not found for $svcid (would be deleted)\n"; + } + + my (@other_subs) = $self->api_query('subscribers', + [ 'customer_external_id' => $custid ] + ); + if (! @other_subs) { + # then it's safe to remove the customer + my ($cust) = $self->api_query('customers', [ 'external_id' => $custid ]); + if (!$cust) { + warn "$me customer not found for $custid\n"; + return; + } + my $id = $cust->{id}; + my $contact_id = $cust->{contact_id}; + if ( $cust->{'status'} ne 'terminated' ) { + # can't delete customers, have to cancel them + $cust->{'status'} = 'terminated'; + $cust->{'external_id'} = ""; # dissociate it from this pkgnum + $cust->{'contact_id'} = 1; # set to the system default contact + $self->api_update("customers/$id", $cust); + } + # can and should delete contacts though + $self->api_delete("customercontacts/$contact_id"); + } +} + +################ +# CALL DETAILS # +################ + +=item import_cdrs START, END + +Retrieves CDRs for calls in the date range from START to END and inserts them +as a CDR batch. On success, returns a new cdr_batch object. On failure, +returns an error message. If there are no new CDRs, returns nothing. + +=cut + +sub import_cdrs { + my ($self, $start, $end) = @_; + $start ||= 0; + $end ||= time; + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + + ($start, $end) = ($end, $start) if $end < $start; + $start = DateTime->from_epoch(epoch => $start, time_zone => 'local'); + $end = DateTime->from_epoch(epoch => $end, time_zone => 'local'); + $end->subtract(seconds => 1); # filter by >= and <= only, so... + + # a little different from the usual: we have to fetch these subscriber by + # subscriber, not all at once. + my @svcs = qsearch({ + 'table' => 'svc_acct', + 'addl_from' => ' JOIN cust_svc USING (svcnum)' . + ' JOIN export_svc USING (svcpart)', + 'extra_sql' => ' WHERE export_svc.role = \'subscriber\''. + ' AND export_svc.exportnum = '.$self->exportnum + }); + my $cdr_batch; + my @args = ( 'start_ge' => $start->iso8601, + 'start_le' => $end->iso8601, + ); + + my $error; + SVC: foreach my $svc (@svcs) { + my $subscriber = $self->get_subscriber($svc); + if (!$subscriber) { + warn "$me user ".$svc->label." is not configured on the SIP server.\n"; + next; + } + my $id = $subscriber->{id}; + my @calls; + try { + # alias_field tells "calllists" which field from the source and + # destination to use as the "own_cli" and "other_cli" of the call. + # "user" = username@domain. + @calls = $self->api_query('calllists', [ + 'subscriber_id' => $id, + 'alias_field' => 'user', + @args + ]); + } catch { + $error = "$me $_ (retrieving records for ".$svc->label.")"; + }; + + if (@calls and !$cdr_batch) { + # create a cdr_batch if needed + my $cdrbatchname = 'sipwise-' . $self->exportnum . '-' . $end->epoch; + $cdr_batch = FS::cdr_batch->new({ cdrbatch => $cdrbatchname }); + $error = $cdr_batch->insert; + } + + last SVC if $error; + + foreach my $c (@calls) { + # avoid double-importing + my $uniqueid = $c->{call_id}; + if ( FS::cdr->row_exists("uniqueid = ?", $uniqueid) ) { + warn "skipped call with uniqueid = '$uniqueid' (already imported)\n" + if $DEBUG; + next; + } + my $src = $c->{own_cli}; + my $dst = $c->{other_cli}; + if ( $c->{direction} eq 'in' ) { # then reverse them + ($src, $dst) = ($dst, $src); + } + # parse duration from H:MM:SS format + my $duration; + if ( $c->{duration} =~ /^(\d+):(\d+):(\d+)$/ ) { + $duration = $3 + (60 * $2) + (3600 * $1); + } else { + $error = "call $uniqueid: unparseable duration '".$c->{duration}."'"; + } + + # use the username@domain label for src and/or dst if possible + my $cdr = FS::cdr->new({ + uniqueid => $uniqueid, + upstream_price => $c->{customer_cost}, + startdate => parse_datetime($c->{start_time}), + disposition => $c->{status}, + duration => $duration, + billsec => $duration, + src => $src, + dst => $dst, + }); + $error ||= $cdr->insert; + last SVC if $error; + } + } # foreach $svc + + if ( $error ) { + dbh->rollback if $oldAutoCommit; + return $error; + } elsif ( $cdr_batch ) { + dbh->commit if $oldAutoCommit; + return $cdr_batch; + } else { # no CDRs + return; + } +} + +############## +# API ACCESS # +############## + +=item api_query RESOURCE, CONTENT + +Makes a GET request to RESOURCE, the name of a resource type (like +'customers'), with query parameters in CONTENT, unpacks the embedded search +results, and returns them as a list. + +Sipwise ignores invalid query parameters rather than throwing an error, so if +the parameters are misspelled or make no sense for this type of query, it will +probably return all of the objects. + +=cut + +sub api_query { + my $self = shift; + my ($resource, $content) = @_; + if ( ref $content eq 'HASH' ) { + $content = [ %$content ]; + } + my $page = 1; + push @$content, ('rows' => 100, 'page' => 1); # 'page' is always last + my $result = $self->api_request('GET', $resource, $content); + my @records; + # depaginate + while ( my $things = $result->{_embedded}{"ngcp:$resource"} ) { + if ( ref($things) eq 'ARRAY' ) { + push @records, @$things; + } else { + push @records, $things; + } + if ( my $linknext = $result->{_links}{next} ) { + # unfortunately their HAL isn't entirely functional + # it returns "next" links that contain "page" and "rows" but no other + # parameters. so just count the pages: + $page++; + $content->[-1] = $page; + + warn "$me continued: $page\n" if $DEBUG; + $result = $self->api_request('GET', $resource, $content); + } else { + last; + } + } + return @records; +} + +=item api_create RESOURCE, CONTENT + +Makes a POST request to RESOURCE, the name of a resource type (like +'customers'), to create a new object of that type. CONTENT must be a hashref of +the object's fields. + +On success, will then fetch and return the newly created object. On failure, +will throw the "message" parameter from the request as an exception. + +=cut + +sub api_create { + my $self = shift; + my ($resource, $content) = @_; + my $result = $self->api_request('POST', $resource, $content); + if ( $result->{location} ) { + return $self->api_request('GET', $result->{location}); + } else { + die $result->{message} . "\n"; + } +} + +=item api_update ENDPOINT, CONTENT + +Makes a PUT request to ENDPOINT, the name of a specific record (like +'customers/11'), to replace it with the data in CONTENT (a hashref of the +object's fields). On failure, will throw an exception. On success, +returns nothing. + +=cut + +sub api_update { + my $self = shift; + my ($endpoint, $content) = @_; + my $result = $self->api_request('PUT', $endpoint, $content); + if ( $result->{message} ) { + die $result->{message} . "\n"; + } + return; +} + +=item api_delete ENDPOINT + +Makes a DELETE request to ENDPOINT. On failure, will throw an exception. + +=cut + +sub api_delete { + my $self = shift; + my $endpoint = shift; + my $result = $self->api_request('DELETE', $endpoint); + if ( $result->{code} and $result->{code} eq '404' ) { + # special case: this is harmless. we tried to delete something and it + # was already gone. + warn "$me api_delete $endpoint: does not exist\n"; + return; + } elsif ( $result->{message} ) { + die $result->{message} . "\n"; + } + return; +} + +=item api_request METHOD, ENDPOINT, CONTENT + +Makes a REST request with HTTP method METHOD, to path ENDPOINT, with content +CONTENT. If METHOD is GET, the content can be an arrayref or hashref to append +as the query argument. If it's POST or PUT, the content will be JSON-serialized +and sent as the request body. If it's DELETE, content will be ignored. + +=cut + +sub api_request { + my $self = shift; + my ($method, $endpoint, $content) = @_; + $DEBUG ||= 1 if $self->option('debug'); + my $url; + if ($endpoint =~ /^http/) { + # allow directly using URLs returned from the API + $url = $endpoint; + } else { + $endpoint =~ s[/api/][]; # allow using paths returned in Location headers + $url = 'https://' . $self->host . '/api/' . $endpoint; + $url .= '/' unless $url =~ m[/$]; + } + my $request; + if ( lc($method) eq 'get' ) { + $url = URI->new($url); + $url->query_form($content); + $request = GET($url, + 'Accept' => 'application/json' + ); + } elsif ( lc($method) eq 'post' ) { + $request = POST($url, + 'Accept' => 'application/json', + 'Content' => encode_json($content), + 'Content-Type' => 'application/json', + ); + } elsif ( lc($method) eq 'put' ) { + $request = PUT($url, + 'Accept' => 'application/json', + 'Content' => encode_json($content), + 'Content-Type' => 'application/json', + ); + } elsif ( lc($method) eq 'delete' ) { + $request = DELETE($url); + } + + warn "$me $method $endpoint\n" if $DEBUG; + warn $request->as_string ."\n" if $DEBUG > 1; + my $response = $self->ua->request($request); + warn "$me received\n" . $response->as_string ."\n" if $DEBUG > 1; + + my $decoded_response = {}; + if ( $response->content ) { + local $@; + $decoded_response = eval { decode_json($response->content) }; + if ( $@ ) { + # then it can't be parsed; probably a low-level error of some kind. + warn "$me Parse error.\n".$response->content."\n\n"; + die "$me Parse error:".$response->content . "\n"; + } + } + if ( $response->header('Location') ) { + $decoded_response->{location} = $response->header('Location'); + } + return $decoded_response; +} + +# a little false laziness with aradial.pm +sub host { + my $self = shift; + my $port = $self->option('port') || 1443; + $self->machine . ":$port"; +} + +sub ua { + my $self = shift; + $self->{_ua} ||= do { + my @opt; + if ( $self->option('ssl_no_verify') ) { + push @opt, ssl_opts => { verify_hostname => 0 }; + } + my $ua = LWP::UserAgent->new(@opt); + $ua->credentials( + $self->host, + 'api_admin_http', + $self->option('username'), + $self->option('password') + ); + $ua; + } +} + + +1; diff --git a/FS/FS/svc_acct.pm b/FS/FS/svc_acct.pm index c2f5d7122..8283e6f6b 100644 --- a/FS/FS/svc_acct.pm +++ b/FS/FS/svc_acct.pm @@ -847,6 +847,17 @@ sub delete { } } + foreach my $svc_phone ( + qsearch( 'svc_phone', { 'forward_svcnum' => $self->svcnum }) + ) { + $svc_phone->set('forward_svcnum', ''); + my $error = $svc_phone->replace; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + my $error = $self->delete_password_history || $self->SUPER::delete; # usergroup here if ( $error ) { @@ -2308,8 +2319,8 @@ sub last_login_text { Returns a paged search (L) for Call Detail Records associated with this service. For svc_acct, "associated with" means that -either the "src" or the "charged_party" field of the CDR matches the -"username" field of the service. +either the "src" or the "charged_party" field of the CDR matches either +the "username" field of the service or the username@domain label. =cut @@ -2320,6 +2331,7 @@ sub psearch_cdrs { my @where; my $did = dbh->quote($self->username); + my $diddomain = dbh->quote($self->label); my $prefix = $options{'default_prefix'} || ''; #convergent.au '+61' my $prefixdid = dbh->quote($prefix . $self->username); @@ -2335,12 +2347,16 @@ sub psearch_cdrs { if (!$options{'disable_charged_party'}) { push @orwhere, "charged_party = $did", - "charged_party = $prefixdid"; + "charged_party = $prefixdid", + "charged_party = $diddomain" + ; } if (!$options{'disable_src'}) { push @orwhere, "src = $did AND charged_party IS NULL", - "src = $prefixdid AND charged_party IS NULL"; + "src = $prefixdid AND charged_party IS NULL", + "src = $diddomain AND charged_party IS NULL" + ; } push @where, '(' . join(' OR ', @orwhere) . ')'; diff --git a/FS/FS/svc_phone.pm b/FS/FS/svc_phone.pm index cf9d9b440..3a58b465c 100644 --- a/FS/FS/svc_phone.pm +++ b/FS/FS/svc_phone.pm @@ -93,9 +93,14 @@ Voicemail PIN Optional svcnum from svc_pbx +=item forward_svcnum + +Forward destination, if it's another service. Some exports use this +configuration. + =item forwarddst -Forwarding destination +Forwarding destination, if it's not a service. =item email @@ -225,6 +230,9 @@ sub table_info { 'forwarddst' => { label => 'Forward Destination', %dis2, }, + 'forward_svcnum' => { label => 'Route to service', + %dis2, + }, 'email' => { label => 'Email', %dis2, }, @@ -529,7 +537,9 @@ sub check { || $self->ut_alphan('sms_account') || $self->ut_numbern('max_simultaneous') || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum') - || $self->ut_numbern('forwarddst') + || $self->ut_numbern('forward_svcnum') + || $self->ut_foreign_keyn('forward_svcnum', 'cust_svc', 'svcnum') + || $self->ut_textn('forwarddst') || $self->ut_textn('email') || $self->ut_numbern('lrn') || $self->ut_numbern('lnp_desired_due_date') diff --git a/debian/control b/debian/control index badd99d88..484403899 100644 --- a/debian/control +++ b/debian/control @@ -95,7 +95,7 @@ Depends: aspell-en,gnupg,ghostscript,gsfonts,gzip,latex-xcolor, libhtml-formattext-withlinks-andtables-perl, libcrypt-x509-perl, libdata-guid-perl, libparams-classify-perl (>= 0.013-5.1), libcpanel-json-xs-perl, libarchive-zip-perl -Conflicts: libparams-classify-perl (>= 0.013-6) + libtry-tiny-perl, libnumber-phone-perl Suggests: libbusiness-onlinepayment-perl Description: Libraries for Freeside billing and trouble ticketing Freeside is a web-based billing and trouble ticketing application. diff --git a/httemplate/edit/svc_phone.cgi b/httemplate/edit/svc_phone.cgi index f1471e283..e74ffbb84 100644 --- a/httemplate/edit/svc_phone.cgi +++ b/httemplate/edit/svc_phone.cgi @@ -60,6 +60,9 @@ my $begin_callback = sub { type => 'text', maxlength => $conf->config('svc_phone-phone_name-max_length'), }, + { field => 'forward_svcnum', + type => 'select-svc_phone-forward', + }, 'forwarddst', 'email', diff --git a/httemplate/elements/tr-select-svc_phone-forward.html b/httemplate/elements/tr-select-svc_phone-forward.html new file mode 100644 index 000000000..ef9ef17e7 --- /dev/null +++ b/httemplate/elements/tr-select-svc_phone-forward.html @@ -0,0 +1,50 @@ +% if ( $hide ) { + + + +% } else { + <& tr-select-table.html, + 'table' => 'svc_acct', # for now + 'name_col' => 'email', + 'order_by' => 'order by username', + 'empty_label' => ' ', + %select_hash, + %opt + &> +% } +<%init> + +my %opt = @_; +my $pkgnum = $opt{pkgnum}; +my $svcpart = $opt{svcpart}; + +my $field = $opt{'field'} ||= 'forward_svcnum'; + +my $part_svc = FS::part_svc->by_key($svcpart); +# kludgey assumptions for now: +# - this is only used to route DIDs to their real destinations +# - which is a svc_acct +# - in the same package (part_export::svc_with_role assumes this) +# - and shares an export + +my $cust_pkg = FS::cust_pkg->by_key($pkgnum); +my @svcparts; +foreach my $part_export ( $part_svc->part_export ) { + foreach my $export_svc ( $part_export->export_svc ) { + push @svcparts, $export_svc->svcpart; + } +} + +$pkgnum =~ /^(\d+)$/ or die "bad pkgnum $pkgnum"; + +my %select_hash = ( + 'addl_from' => ' JOIN cust_svc USING (svcnum) ', + 'extra_sql' => "WHERE pkgnum = $pkgnum AND svcpart IN(". + join(',', @svcparts) . ")" +); + +my $hide = 0; +$hide = 1 if $part_svc->part_svc_column($field) eq 'F'; +$hide = 1 if !@svcparts; + + diff --git a/httemplate/view/svc_phone.cgi b/httemplate/view/svc_phone.cgi index ab69c4f6f..416f13841 100644 --- a/httemplate/view/svc_phone.cgi +++ b/httemplate/view/svc_phone.cgi @@ -18,6 +18,23 @@ my %labels = map { $_ => ( ref($fields->{$_}) my @fields = qw( countrycode phonenum sim_imsi ); push @fields, 'domain' if $conf->exists('svc_phone-domain'); + +$labels{forward_svcnum} = mt('Route to service'); +push @fields, { field => 'forward_svcnum', + link => [ $p.'view/cust_svc.cgi?', 'forward_svcnum' ], + value_callback => sub { + my $self = shift; + if ($self->forward_svcnum) { + my $cust_svc = FS::cust_svc->by_key($self->forward_svcnum); + if ( $cust_svc ) { + return $cust_svc->svc_x->label; + } + } + ''; + }, + }; + + push @fields, qw( pbx_title ); $labels{pbx_title} = 'PBX';