From 992ba6b46da3d3edda5815757db82bc3f0ca57e0 Mon Sep 17 00:00:00 2001 From: Kirill Solomko Date: Fri, 11 Mar 2016 14:15:07 +0100 Subject: [PATCH] MT#18499 added customer multi locations feature Change-Id: I16d00c3c4499c98cc7b9109df2bcefe2d3ae4be4 --- .../Panel/Controller/API/CustomerLocations.pm | 212 +++++++++++++++ .../Controller/API/CustomerLocationsItem.pm | 232 +++++++++++++++++ .../Controller/API/CustomerPreferences.pm | 9 + lib/NGCP/Panel/Controller/Customer.pm | 243 +++++++++++++++++- lib/NGCP/Panel/Form/Customer/Location.pm | 179 +++++++++++++ lib/NGCP/Panel/Form/Customer/LocationAPI.pm | 62 +++++ lib/NGCP/Panel/Role/API/CustomerLocations.pm | 142 ++++++++++ lib/NGCP/Panel/Role/API/Preferences.pm | 14 +- lib/NGCP/Panel/Utils/ContractLocations.pm | 167 ++++++++++++ lib/NGCP/Panel/Utils/Preferences.pm | 2 + share/templates/customer/details.tt | 35 +++ share/templates/customer/preferences.tt | 17 +- t/api-rest/api-root.t | 1 + 13 files changed, 1305 insertions(+), 10 deletions(-) create mode 100644 lib/NGCP/Panel/Controller/API/CustomerLocations.pm create mode 100644 lib/NGCP/Panel/Controller/API/CustomerLocationsItem.pm create mode 100644 lib/NGCP/Panel/Form/Customer/Location.pm create mode 100644 lib/NGCP/Panel/Form/Customer/LocationAPI.pm create mode 100644 lib/NGCP/Panel/Role/API/CustomerLocations.pm create mode 100644 lib/NGCP/Panel/Utils/ContractLocations.pm diff --git a/lib/NGCP/Panel/Controller/API/CustomerLocations.pm b/lib/NGCP/Panel/Controller/API/CustomerLocations.pm new file mode 100644 index 0000000000..cc6361989f --- /dev/null +++ b/lib/NGCP/Panel/Controller/API/CustomerLocations.pm @@ -0,0 +1,212 @@ +package NGCP::Panel::Controller::API::CustomerLocations; +use NGCP::Panel::Utils::Generic qw(:all); +no Moose; +use boolean qw(true); +use Data::HAL qw(); +use Data::HAL::Link qw(); +use HTTP::Headers qw(); +use HTTP::Status qw(:constants); + +use TryCatch; +use NGCP::Panel::Utils::ContractLocations qw(); +use Path::Tiny qw(path); +use Safe::Isa qw($_isa); +require Catalyst::ActionRole::ACL; +require Catalyst::ActionRole::CheckTrailingSlash; +require Catalyst::ActionRole::HTTPMethods; +require Catalyst::ActionRole::RequireSSL; + +sub allowed_methods{ + return [qw/GET POST OPTIONS HEAD/]; +} + +sub api_description { + return 'A Customer Location is a container for a number of network ranges.'; +}; + +sub query_params { + return [ + { + param => 'ip', + description => 'Filter for customer locations containing a specific IP address', + query => { + first => \&NGCP::Panel::Utils::ContractLocations::prepare_query_param_value, + second => sub { + return { join => 'voip_contract_location_blocks', + group_by => 'me.id', } + #distinct => 1 }; #not neccessary if _CHECK_BLOCK_OVERLAPS was always on + }, + }, + }, + { + param => 'name', + description => 'Filter for customer locations matching a name pattern', + query => { + first => sub { + my $q = shift; + { name => { like => $q } }; + }, + second => sub {}, + }, + }, + ]; +} + + +use base qw/Catalyst::Controller NGCP::Panel::Role::API::CustomerLocations/; + +sub resource_name{ + return 'customerlocations'; +} +sub dispatch_path{ + return '/api/customerlocations/'; +} +sub relation{ + return 'http://purl.org/sipwise/ngcp-api/#rel-customerlocations'; +} + +__PACKAGE__->config( + action => { + map { $_ => { + ACLDetachTo => '/api/root/invalid_user', + AllowedRole => [qw/admin reseller/], + Args => 0, + Does => [qw(ACL CheckTrailingSlash RequireSSL)], + Method => $_, + Path => __PACKAGE__->dispatch_path, + } } @{ __PACKAGE__->allowed_methods }, + }, + action_roles => [qw(HTTPMethods)], +); + +sub auto :Private { + my ($self, $c) = @_; + + $self->set_body($c); + $self->log_request($c); + return 1; +} + +sub GET :Allow { + my ($self, $c) = @_; + my $page = $c->request->params->{page} // 1; + my $rows = $c->request->params->{rows} // 10; + { + my $cls = $self->item_rs($c); + + (my $total_count, $cls) = $self->paginate_order_collection($c, $cls); + my (@embedded, @links); + for my $cl ($cls->all) { + push @embedded, $self->hal_from_item($c, $cl, $self->resource_name); + push @links, Data::HAL::Link->new( + relation => 'ngcp:'.$self->resource_name, + href => sprintf('%s%d', $self->dispatch_path, $cl->id), + ); + } + push @links, + Data::HAL::Link->new( + relation => 'curies', + href => 'http://purl.org/sipwise/ngcp-api/#rel-{rel}', + name => 'ngcp', + templated => true, + ), + Data::HAL::Link->new(relation => 'profile', href => 'http://purl.org/sipwise/ngcp-api/'); + + push @links, $self->collection_nav_links($page, $rows, $total_count, $c->request->path, $c->request->query_params); + + my $hal = Data::HAL->new( + embedded => [@embedded], + links => [@links], + ); + $hal->resource({ + total_count => $total_count, + }); + my $response = HTTP::Response->new(HTTP_OK, undef, + HTTP::Headers->new($hal->http_headers(skip_links => 1)), $hal->as_json); + $c->response->headers($response->headers); + $c->response->body($response->content); + return; + } + return; +} + +sub HEAD :Allow { + my ($self, $c) = @_; + $c->forward(qw(GET)); + $c->response->body(q()); + return; +} + +sub OPTIONS :Allow { + my ($self, $c) = @_; + my $allowed_methods = $self->allowed_methods_filtered($c); + $c->response->headers(HTTP::Headers->new( + Allow => join(', ', @{ $allowed_methods }), + Accept_Post => 'application/hal+json; profile=http://purl.org/sipwise/ngcp-api/#rel-'.$self->resource_name, + )); + $c->response->content_type('application/json'); + $c->response->body(JSON::to_json({ methods => $allowed_methods })."\n"); + return; +} + +sub POST :Allow { + my ($self, $c) = @_; + + my $guard = $c->model('DB')->txn_scope_guard; + { + my $schema = $c->model('DB'); + my $resource = $self->get_valid_post_data( + c => $c, + media_type => 'application/json', + ); + last unless $resource; + + my $form = $self->get_form($c); + last unless $self->validate_form( + c => $c, + resource => $resource, + form => $form, + exceptions => ['contract_id'], + ); + + last unless $self->prepare_blocks_resource($c,$resource); + my $blocks = delete $resource->{blocks}; + + my $cl; + try { + $cl = $schema->resultset('voip_contract_locations')->create($resource); + for my $block (@$blocks) { + $cl->create_related('voip_contract_location_blocks', $block); + } + } catch($e) { + $c->log->error("failed to create customer location: $e"); + $self->error($c, HTTP_INTERNAL_SERVER_ERROR, "Failed to create customer location."); + return; + }; + + last unless $self->add_create_journal_item_hal($c,sub { + my $self = shift; + my ($c) = @_; + my $_cl = $self->item_by_id($c, $cl->id); + return $self->hal_from_item($c, $_cl, $self->resource_name); }); + + $guard->commit; + + $c->response->status(HTTP_CREATED); + $c->response->header(Location => sprintf('/%s%d', $c->request->path, $cl->id)); + $c->response->body(q()); + } + return; +} + +sub end : Private { + my ($self, $c) = @_; + + $self->log_response($c); + return 1; +} + +no Moose; +1; + +# vim: set tabstop=4 expandtab: diff --git a/lib/NGCP/Panel/Controller/API/CustomerLocationsItem.pm b/lib/NGCP/Panel/Controller/API/CustomerLocationsItem.pm new file mode 100644 index 0000000000..ae30a3ed34 --- /dev/null +++ b/lib/NGCP/Panel/Controller/API/CustomerLocationsItem.pm @@ -0,0 +1,232 @@ +package NGCP::Panel::Controller::API::CustomerLocationsItem; +use NGCP::Panel::Utils::Generic qw(:all); +no Moose; +use boolean qw(true); +use Data::HAL qw(); +use Data::HAL::Link qw(); +use HTTP::Headers qw(); +use HTTP::Status qw(:constants); + +use TryCatch; +use NGCP::Panel::Utils::ValidateJSON qw(); +use NGCP::Panel::Utils::Reseller qw(); +use Path::Tiny qw(path); +use Safe::Isa qw($_isa); +require Catalyst::ActionRole::ACL; +require Catalyst::ActionRole::HTTPMethods; +require Catalyst::ActionRole::RequireSSL; + +sub allowed_methods{ + return [qw/GET OPTIONS HEAD PATCH PUT DELETE/]; +} + +use base qw/Catalyst::Controller NGCP::Panel::Role::API::CustomerLocations/; + +sub resource_name{ + return 'customerlocations'; +} +sub dispatch_path{ + return '/api/customerlocations/'; +} +sub relation{ + return 'http://purl.org/sipwise/ngcp-api/#rel-customerlocations'; +} + +sub journal_query_params { + my($self,$query_params) = @_; + return $self->get_journal_query_params($query_params); +} + +__PACKAGE__->config( + action => { + (map { $_ => { + ACLDetachTo => '/api/root/invalid_user', + AllowedRole => [qw/admin reseller/], + Args => 1, + Does => [qw(ACL RequireSSL)], + Method => $_, + Path => __PACKAGE__->dispatch_path, + } } @{ __PACKAGE__->allowed_methods }), + @{ __PACKAGE__->get_journal_action_config(__PACKAGE__->resource_name,{ + ACLDetachTo => '/api/root/invalid_user', + AllowedRole => [qw/admin reseller/], + Does => [qw(ACL RequireSSL)], + }) } + }, + action_roles => [qw(HTTPMethods)], +); + +sub auto :Private { + my ($self, $c) = @_; + + $self->set_body($c); + $self->log_request($c); + return 1; +} + +sub GET :Allow { + my ($self, $c, $id) = @_; + { + last unless $self->valid_id($c, $id); + my $cl = $self->item_by_id($c, $id); + last unless $self->resource_exists($c, customerlocation => $cl); + + my $hal = $self->hal_from_item($c, $cl, $self->resource_name); + + my $response = HTTP::Response->new(HTTP_OK, undef, HTTP::Headers->new( + (map { # XXX Data::HAL must be able to generate links with multiple relations + s|rel="(http://purl.org/sipwise/ngcp-api/#rel-resellers)"|rel="item $1"|r + =~ s/rel=self/rel="item self"/r; + } $hal->http_headers), + ), $hal->as_json); + $c->response->headers($response->headers); + $c->response->body($response->content); + return; + } + return; +} + +sub HEAD :Allow { + my ($self, $c, $id) = @_; + $c->forward(qw(GET)); + $c->response->body(q()); + return; +} + +sub OPTIONS :Allow { + my ($self, $c, $id) = @_; + my $allowed_methods = $self->allowed_methods_filtered($c); + $c->response->headers(HTTP::Headers->new( + Allow => join(', ', @{ $allowed_methods }), + Accept_Patch => 'application/json-patch+json', + )); + $c->response->content_type('application/json'); + $c->response->body(JSON::to_json({ methods => $allowed_methods })."\n"); + return; +} + +sub PATCH :Allow { + my ($self, $c, $id) = @_; + my $guard = $c->model('DB')->txn_scope_guard; + { + my $preference = $self->require_preference($c); + last unless $preference; + + my $json = $self->get_valid_patch_data( + c => $c, + id => $id, + media_type => 'application/json-patch+json', + ops => [qw/add replace remove copy/], + ); + last unless $json; + + my $cl = $self->item_by_id($c, $id); + last unless $self->resource_exists($c, customerlocation => $cl); + my $old_resource = $self->hal_from_item($c, $cl, $self->resource_name)->resource; + my $resource = $self->apply_patch($c, $old_resource, $json); + last unless $resource; + + my $form = $self->get_form($c); + $cl = $self->update_item($c, $cl, $old_resource, $resource, $form); + last unless $cl; + + my $hal = $self->hal_from_item($c, $cl, $self->resource_name); + last unless $self->add_update_journal_item_hal($c,$hal); + + $guard->commit; + + if ('minimal' eq $preference) { + $c->response->status(HTTP_NO_CONTENT); + $c->response->header(Preference_Applied => 'return=minimal'); + $c->response->body(q()); + } else { + #my $hal = $self->hal_from_item($c, $dset, "destinationsets"); + my $response = HTTP::Response->new(HTTP_OK, undef, HTTP::Headers->new( + $hal->http_headers, + ), $hal->as_json); + $c->response->headers($response->headers); + $c->response->header(Preference_Applied => 'return=representation'); + $c->response->body($response->content); + } + } + return; +} + +sub PUT :Allow { + my ($self, $c, $id) = @_; + my $guard = $c->model('DB')->txn_scope_guard; + { + my $preference = $self->require_preference($c); + last unless $preference; + + my $cl = $self->item_by_id($c, $id); + last unless $self->resource_exists($c, customerlocation => $cl); + my $resource = $self->get_valid_put_data( + c => $c, + id => $id, + media_type => 'application/json', + ); + last unless $resource; + my $old_resource = { $cl->get_inflated_columns }; + + my $form = $self->get_form($c); + $cl = $self->update_item($c, $cl, $old_resource, $resource, $form); + last unless $cl; + + my $hal = $self->hal_from_item($c, $cl, $self->resource_name); + last unless $self->add_update_journal_item_hal($c,$hal); + + $guard->commit; + + if ('minimal' eq $preference) { + $c->response->status(HTTP_NO_CONTENT); + $c->response->header(Preference_Applied => 'return=minimal'); + $c->response->body(q()); + } else { + my $response = HTTP::Response->new(HTTP_OK, undef, HTTP::Headers->new( + $hal->http_headers, + ), $hal->as_json); + $c->response->headers($response->headers); + $c->response->header(Preference_Applied => 'return=representation'); + $c->response->body($response->content); + } + } + return; +} + +sub DELETE :Allow { + my ($self, $c, $id) = @_; + my $guard = $c->model('DB')->txn_scope_guard; + { + my $cl = $self->item_by_id($c, $id); + last unless $self->resource_exists($c, customerlocation => $cl); + try { + $cl->delete; + } catch($e) { + $c->log->error("Failed to delete customre location with id '$id': $e"); + $self->error($c, HTTP_INTERNAL_SERVER_ERROR, "Internal Server Error"); + last; + } + $guard->commit; + + $c->response->status(HTTP_NO_CONTENT); + $c->response->body(q()); + } + return; +} + +sub get_journal_methods{ + return [qw/handle_item_base_journal handle_journals_get handle_journalsitem_get handle_journals_options handle_journalsitem_options handle_journals_head handle_journalsitem_head/]; +} + +sub end : Private { + my ($self, $c) = @_; + + $self->log_response($c); + return 1; +} + +no Moose; +1; + +# vim: set tabstop=4 expandtab: diff --git a/lib/NGCP/Panel/Controller/API/CustomerPreferences.pm b/lib/NGCP/Panel/Controller/API/CustomerPreferences.pm index 799a3935c3..9c06c8aed2 100644 --- a/lib/NGCP/Panel/Controller/API/CustomerPreferences.pm +++ b/lib/NGCP/Panel/Controller/API/CustomerPreferences.pm @@ -24,6 +24,15 @@ sub api_description { return 'Specifies certain properties (preferences) for a Customer. The full list of properties can be obtained via CustomerPreferenceDefs.'; }; +sub query_params { + return [ + { + param => 'location_id', + description => 'Fetch preferences for a specific location otherwise default preferences (location_id=null) are shown.', + }, + ]; +} + use base qw/Catalyst::Controller NGCP::Panel::Role::API::Preferences/; sub resource_name{ diff --git a/lib/NGCP/Panel/Controller/Customer.pm b/lib/NGCP/Panel/Controller/Customer.pm index 3a847217f4..94529891fc 100644 --- a/lib/NGCP/Panel/Controller/Customer.pm +++ b/lib/NGCP/Panel/Controller/Customer.pm @@ -16,6 +16,7 @@ use NGCP::Panel::Form::Customer::PbxGroupEdit; use NGCP::Panel::Form::Customer::PbxGroup; use NGCP::Panel::Form::Customer::PbxFieldDevice; use NGCP::Panel::Form::Customer::PbxFieldDeviceSync; +use NGCP::Panel::Form::Customer::Location; use NGCP::Panel::Form::Contract::Customer; use NGCP::Panel::Form::Contract::ProductSelect; use NGCP::Panel::Form::Topup::Cash; @@ -29,6 +30,7 @@ use NGCP::Panel::Utils::Contract; use NGCP::Panel::Utils::ProfilePackages; use NGCP::Panel::Utils::DeviceBootstrap; use NGCP::Panel::Utils::Voucher; +use NGCP::Panel::Utils::ContractLocations qw(); use Template; =head1 NAME @@ -409,6 +411,11 @@ sub base :Chained('list_customer') :PathPart('') :CaptureArgs(1) { ->count; } + my $locations = $contract_rs->first->voip_contract_locations->search_rs( + undef, + { join => 'voip_contract_location_blocks', + group_by => 'me.id' }); + $c->stash->{invoice_dt_columns} = NGCP::Panel::Utils::Datatables::set_columns($c, [ { name => "id", search => 1, title => $c->loc("#") }, { name => "serial", search => 1, title => $c->loc("Serial #") }, @@ -419,6 +426,13 @@ sub base :Chained('list_customer') :PathPart('') :CaptureArgs(1) { { name => "amount_total", search => 1, title => $c->loc("Total Amount") }, ]); + $c->stash->{location_dt_columns} = NGCP::Panel::Utils::Datatables::set_columns($c, [ + { name => "id", search => 1, title => $c->loc("#") }, + { name => "name", search => 1, title => $c->loc("Name") }, + { name => "description", search => 1, title => $c->loc("Description") }, + NGCP::Panel::Utils::ContractLocations::get_datatable_cols($c), + ]); + my ($is_timely,$timely_start,$timely_end) = NGCP::Panel::Utils::ProfilePackages::get_timely_range( package => $contract_first->profile_package, contract => $contract_first, @@ -446,6 +460,7 @@ sub base :Chained('list_customer') :PathPart('') :CaptureArgs(1) { #$c->stash(now => $now ); $c->stash(billing_mappings_ordered_result => $billing_mappings_ordered ); $c->stash(future_billing_mappings => $future_billing_mappings ); + $c->stash(locations => $locations ); } sub base_restricted :Chained('base') :PathPart('') :CaptureArgs(0) :Does(ACL) :ACLDetachTo('/denied_page') :AllowedRole(admin) :AllowedRole(reseller) { @@ -1908,13 +1923,225 @@ sub pbx_device_sync :Chained('pbx_device_base') :PathPart('sync') :Args(0) { ); } -sub preferences :Chained('base') :PathPart('preferences') :Args(0) { +sub location_ajax :Chained('base') :PathPart('location/ajax') :Args(0) { + my ($self, $c) = @_; + NGCP::Panel::Utils::Datatables::process($c, + @{$c->stash}{qw(locations location_dt_columns)}); + $c->detach( $c->view("JSON") ); +} + +sub location_create :Chained('base_restricted') :PathPart('location/create') :Args(0) { + my ($self, $c) = @_; + + my $contract = $c->stash->{contract}; + my $posted = ($c->request->method eq 'POST'); + my $form = NGCP::Panel::Form::Customer::Location->new; + my $params = {}; + $params = merge($params, $c->session->{created_objects}); + $form->process( + posted => $posted, + params => $c->request->params, + item => $params, + ); + NGCP::Panel::Utils::Navigation::check_form_buttons( + c => $c, + form => $form, + back_uri => $c->req->uri, + ); + if($posted && $form->validated) { + try { + $c->model('DB')->schema->txn_do( sub { + my $vcl = $c->model('DB')->resultset('voip_contract_locations')->create({ + contract_id => $c->stash->{contract}->id, + name => $form->values->{name}, + description => $form->values->{description}, + }); + for my $block (@{$form->values->{blocks}}) { + $vcl->create_related("voip_contract_location_blocks", $block); + } + $c->session->{created_objects}->{network} = { id => $vcl->id }; + }); + + NGCP::Panel::Utils::Message::info( + c => $c, + desc => $c->loc('Location successfully created'), + ); + } catch ($e) { + NGCP::Panel::Utils::Message::error( + c => $c, + error => $e, + desc => $c->loc('Failed to create location.'), + ); + } + NGCP::Panel::Utils::Navigation::back_or($c, $c->uri_for_action("/customer/details", [$contract->id])); + } + + $c->stash( + close_target => $c->uri_for_action("/customer/details", [$contract->id]), + create_flag => 1, + form => $form + ); +} + +sub location_base :Chained('base_restricted') :PathPart('location') :CaptureArgs(1) { + my ($self, $c, $location_id) = @_; + + unless($location_id && is_int($location_id)) { + $location_id //= ''; + NGCP::Panel::Utils::Message::error( + c => $c, + data => { id => $location_id }, + desc => $c->loc('Invalid location id detected'), + ); + $c->response->redirect($c->uri_for()); + $c->detach; + return; + } + + my $res = $c->stash->{contract}->voip_contract_locations->find($location_id); + unless(defined($res)) { + NGCP::Panel::Utils::Message::error( + c => $c, + desc => $c->loc('Location does not exist'), + ); + $c->response->redirect($c->uri_for()); + $c->detach; + return; + } + + $c->stash(location => {$res->get_inflated_columns}, + location_blocks => [ map { { $_->get_inflated_columns }; } + $res->voip_contract_location_blocks->all ], + location_result => $res); +} + +sub location_edit :Chained('location_base') :PathPart('edit') :Args(0) { + my ($self, $c) = @_; + + my $contract = $c->stash->{contract}; + my $posted = ($c->request->method eq 'POST'); + my $form = NGCP::Panel::Form::Customer::Location->new(ctx => $c); + my $params = $c->stash->{location}; + $params->{blocks} = $c->stash->{location_blocks}; + $params = merge($params, $c->session->{created_objects}); + $form->process( + posted => $posted, + params => $c->request->params, + item => $params, + ); + if($posted && $form->validated) { + try { + $c->model('DB')->schema->txn_do( sub { + + $c->stash->{'location_result'}->update({ + name => $form->values->{name}, + description => $form->values->{description}, + }); + $c->stash->{'location_result'}->voip_contract_location_blocks->delete; + for my $block (@{$form->values->{blocks}}) { + $c->stash->{'location_result'}->create_related("voip_contract_location_blocks", $block); + } + }); + NGCP::Panel::Utils::Message::info( + c => $c, + desc => $c->loc('Location successfully updated'), + ); + } catch ($e) { + NGCP::Panel::Utils::Message::error( + c => $c, + error => $e, + desc => $c->loc('Failed to update location'), + ); + } + + NGCP::Panel::Utils::Navigation::back_or($c, $c->uri_for_action("/customer/details", [$contract->id])); + + } + + $c->stash( + close_target => $c->uri_for_action("/customer/details", [$contract->id]), + edit_flag => 1, + form => $form + ); +} + +sub location_delete :Chained('location_base') :PathPart('delete') :Args(0) { + my ($self, $c) = @_; + + my $contract = $c->stash->{contract}; + my $location = $c->stash->{location_result}; + + try { + $location->delete; + NGCP::Panel::Utils::Message::info( + c => $c, + data => $c->stash->{location}, + desc => $c->loc('Location successfully deleted'), + ); + } catch ($e) { + NGCP::Panel::Utils::Message::error( + c => $c, + error => $e, + data => $c->stash->{location}, + desc => $c->loc('Failed to terminate location'), + ); + }; + + NGCP::Panel::Utils::Navigation::back_or($c, $c->uri_for_action("/customer/details", [$contract->id])); +} + +sub location_preferences :Chained('location_base') :PathPart('preferences') :Args(0) { my ($self, $c) = @_; $self->load_preference_list($c); $c->stash(template => 'customer/preferences.tt'); } +sub location_preferences_base :Chained('location_base') :PathPart('preferences') :CaptureArgs(1) { + my ($self, $c, $pref_id) = @_; + + $self->preferences_base($c, $pref_id); +} + +sub location_preferences_edit :Chained('location_preferences_base') :PathPart('edit') :Args(0) { + my ($self, $c) = @_; + + my $contract = $c->stash->{contract}; + my $location = $c->stash->{location}; + my $pref_id = $c->stash->{pref_id}; + + my $base_uri = $c->uri_for($contract->id, + 'location', $location->{id}, + 'preferences'); + my $edit_uri = $c->uri_for($contract->id, + 'location', $location->{id}, + 'preferences', $pref_id, + 'edit'); + + $c->stash(edit_preference => 1); + + my @enums = $c->stash->{preference_meta} + ->voip_preferences_enums + ->search({contract_pref => 1}) + ->all; + + my $pref_rs = $contract->voip_contract_preferences( + { location_id => $location->{id} }, undef); + + NGCP::Panel::Utils::Preferences::create_preference_form( c => $c, + pref_rs => $pref_rs, + enums => \@enums, + base_uri => $base_uri, + edit_uri => $edit_uri, + ); +} + +sub preferences :Chained('base') :PathPart('preferences') :Args(0) { + my ($self, $c) = @_; + + $self->load_preference_list($c); + $c->stash(template => 'customer/preferences.tt'); +} sub preferences_base :Chained('base') :PathPart('preferences') :CaptureArgs(1) { my ($self, $c, $pref_id) = @_; @@ -1934,8 +2161,10 @@ sub preferences_base :Chained('base') :PathPart('preferences') :CaptureArgs(1) { ->search({ attribute_id => $pref_id, contract_id => $c->stash->{contract}->id, + location_id => $c->stash->{location}{id} || undef, }); my @values = $c->stash->{preference}->get_column("value")->all; + $c->stash->{pref_id} = $pref_id; $c->stash->{preference_values} = \@values; $c->stash(template => 'customer/preferences.tt'); } @@ -1950,13 +2179,17 @@ sub preferences_edit :Chained('preferences_base') :PathPart('edit') :Args(0) { ->search({contract_pref => 1}) ->all; - my $pref_rs = $c->stash->{contract}->voip_contract_preferences; + my $pref_rs = $c->stash->{contract}->voip_contract_preferences( + { location_id => undef }, undef); + + my $base_uri = $c->uri_for_action('/customer/preferences', [$c->stash->{contract}->id]); + my $edit_uri = $c->uri_for_action('/customer/preferences_edit', [$c->stash->{contract}->id]); NGCP::Panel::Utils::Preferences::create_preference_form( c => $c, pref_rs => $pref_rs, enums => \@enums, - base_uri => $c->uri_for_action('/customer/preferences', [$c->stash->{contract}->id]), - edit_uri => $c->uri_for_action('/customer/preferences_edit', [$c->stash->{contract}->id]), + base_uri => $base_uri, + edit_uri => $edit_uri, ); } @@ -1967,6 +2200,8 @@ sub load_preference_list :Private { ->resultset('voip_preferences') ->search({ contract_id => $c->stash->{contract}->id, + 'voip_contract_preferences.location_id' => + $c->stash->{location}{id} || undef, },{ prefetch => 'voip_contract_preferences', }); diff --git a/lib/NGCP/Panel/Form/Customer/Location.pm b/lib/NGCP/Panel/Form/Customer/Location.pm new file mode 100644 index 0000000000..206c3eb2ce --- /dev/null +++ b/lib/NGCP/Panel/Form/Customer/Location.pm @@ -0,0 +1,179 @@ +package NGCP::Panel::Form::Customer::Location; + +use HTML::FormHandler::Moose; +extends 'HTML::FormHandler'; +#use Moose::Util::TypeConstraints; + +use NGCP::Panel::Utils::ContractLocations qw(); +use Storable qw(); + +use HTML::FormHandler::Widget::Block::Bootstrap; + +with 'NGCP::Panel::Render::RepeatableJs'; + +has '+widget_wrapper' => ( default => 'Bootstrap' ); +has_field 'submitid' => ( type => 'Hidden' ); +sub build_render_list {[qw/submitid fields actions/]} +sub build_form_element_class {[qw(form-horizontal)]} + +has_field 'id' => ( + type => 'Hidden', +); + +has_field 'name' => ( + type => 'Text', + label => 'Location Name', + required => 1, + maxlength => 255, + element_attr => { + rel => ['tooltip'], + title => ['Name of the location.'] + }, +); + +has_field 'description' => ( + type => 'Text', + label => 'Description', + required => 1, + maxlength => 255, + element_attr => { + rel => ['tooltip'], + title => ['Arbitrary text.'], + }, +); + +has_field 'blocks' => ( + type => 'Repeatable', + required => 1, + setup_for_js => 1, + do_wrapper => 1, + do_label => 0, + tags => { + controls_div => 1, + }, + wrapper_class => [qw/hfh-rep/], + element_attr => { + rel => ['tooltip'], + title => ['An array of location blocks, each containing the keys (base) "ip" address and an optional "mask" to specify the network portion (subnet prefix length).'], + }, + validate_method => \&_validate_blocks, + inflate_default_method => \&_inflate_blocks, +); + +has_field 'blocks.row' => ( + type => 'Compound', + label => 'Location Block', + do_label => 1, + tags => { + before_element => '
', + after_element => '
', + }, +); + +has_field 'blocks.row.ip' => ( + type => '+NGCP::Panel::Field::IPAddress', + required => 1, + element_attr => { + rel => ['tooltip'], + title => ['(Base) IP Address'] + }, + do_label => 0, + do_wrapper => 0, +); + +has_field 'blocks.row.mask' => ( + type => '+NGCP::Panel::Field::PosInteger', + required => 0, + maxlength => 3, + element_attr => { + rel => ['tooltip'], + title => ['Optional Subnet Prefix Length'] + }, + do_label => 0, + do_wrapper => 0, + tags => { + before_element => ' /', + }, +); + +has_field 'blocks.rm' => ( + type => 'RmElement', + value => 'Remove', + element_class => [qw/btn btn-primary pull-right/], +); + + +has_field 'blocks_add' => ( + type => 'AddElement', + repeatable => 'blocks', + value => 'Add another billing network block', + element_class => [qw/btn btn-primary pull-right/], +); + + +has_field 'save' => ( + type => 'Submit', + value => 'Save', + element_class => [qw/btn btn-primary/], + label => '', +); + +has_block 'fields' => ( + tag => 'div', + class => [qw/modal-body/], + render_list => [qw/id name description blocks blocks_add/], +); + +has_block 'actions' => ( + tag => 'div', + class => [qw/modal-footer/], + render_list => [qw/save/], +); + +sub _validate_blocks { + my ($self,$field) = @_; + my $blocks = Storable::dclone($field->value); + foreach my $block (@$blocks) { + $block->{ip} = $block->{row}->{ip}; + $block->{mask} = $block->{row}->{mask}; + delete $block->{row}; + } + NGCP::Panel::Utils::ContractLocations::set_blocks_from_to($blocks,sub { + my ($err) = @_; + $field->add_error($err); + }); + $field->value($blocks); + return 1; +} + +sub _inflate_blocks { + my ($field,$value) = @_; + my @blocks = (); + foreach my $block (@$value) { + my %row = (); + $row{ip} = $block->{ip}; + $row{mask} = $block->{mask}; + push(@blocks,{ row => \%row }); + } + return (scalar @blocks == 0 ? undef : \@blocks); +} + +sub validate { + my ($self) = @_; + my $c = $self->ctx; + return unless $c; + + my $resource = Storable::dclone($self->values); + + NGCP::Panel::Utils::ContractLocations::check_network_update_item( + $c,$resource,$c->stash->{network_result},sub { + my ($err,@fields) = @_; + foreach my $field (@fields) { + $self->field($field)->add_error($err); + } + }); + +} + +1; +# vim: set tabstop=4 expandtab: diff --git a/lib/NGCP/Panel/Form/Customer/LocationAPI.pm b/lib/NGCP/Panel/Form/Customer/LocationAPI.pm new file mode 100644 index 0000000000..76598ec70a --- /dev/null +++ b/lib/NGCP/Panel/Form/Customer/LocationAPI.pm @@ -0,0 +1,62 @@ +package NGCP::Panel::Form::Customer::LocationAPI; +use HTML::FormHandler::Moose; +use HTML::FormHandler::Widget::Block::Bootstrap; +#use Moose::Util::TypeConstraints; +extends 'HTML::FormHandler'; + +has_field 'id' => ( + type => 'Hidden', +); + +has_field 'contract_id' => ( + type => '+NGCP::Panel::Field::PosInteger', + required => 1, + element_attr => { + rel => ['tooltip'], + title => ['The contract id this location belongs to.'] + }, +); + +has_field 'name' => ( + type => 'Text', + required => 1, + maxlength => 255, + element_attr => { + rel => ['tooltip'], + title => ['The unique name of the location.'] + }, +); + +has_field 'description' => ( + type => 'Text', + required => 1, + maxlength => 255, + element_attr => { + rel => ['tooltip'], + title => ['Arbitrary text.'], + }, +); + +has_field 'blocks' => ( + type => 'Repeatable', + element_attr => { + rel => ['tooltip'], + title => ['An array of location blocks, each containing the keys (base) "ip" address and an optional "mask" to specify the network portion (subnet prefix length). The specified blocks must not overlap and can uniformly contain either IPv6 addresses or IPv4 addresses.'] + }, +); + +has_field 'blocks.ip' => ( + type => '+NGCP::Panel::Field::IPAddress', + required => 1, + label => '(Base) IP Address', +); + +has_field 'blocks.mask' => ( + type => '+NGCP::Panel::Field::PosInteger', + required => 0, + label => 'Subnet Prefix Length', +); + +1; + +# vim: set tabstop=4 expandtab: diff --git a/lib/NGCP/Panel/Role/API/CustomerLocations.pm b/lib/NGCP/Panel/Role/API/CustomerLocations.pm new file mode 100644 index 0000000000..68b272c837 --- /dev/null +++ b/lib/NGCP/Panel/Role/API/CustomerLocations.pm @@ -0,0 +1,142 @@ +package NGCP::Panel::Role::API::CustomerLocations; +use NGCP::Panel::Utils::Generic qw(:all); + +use base 'NGCP::Panel::Role::API'; + + +use boolean qw(true); +use TryCatch; +use Data::HAL qw(); +use Data::HAL::Link qw(); +use HTTP::Status qw(:constants); +use JSON::Types; +use NGCP::Panel::Utils::ContractLocations qw(); +use NGCP::Panel::Form::Customer::LocationAPI; + +sub get_form { + my ($self, $c) = @_; + return NGCP::Panel::Form::Customer::LocationAPI->new; +} + +sub hal_from_item { + my ($self, $c, $item, $type) = @_; + my $form; + + my %resource = $item->get_inflated_columns; + my @blocks; + for my $block ($item->voip_contract_location_blocks->all) { + my %blockelem = $block->get_inflated_columns; + delete $blockelem{id}; + delete $blockelem{network_id}; + delete $blockelem{_ipv4_net_from}; + delete $blockelem{_ipv4_net_to}; + delete $blockelem{_ipv6_net_from}; + delete $blockelem{_ipv6_net_to}; + push @blocks, \%blockelem; + } + $resource{blocks} = \@blocks; + + my $hal = Data::HAL->new( + links => [ + Data::HAL::Link->new( + relation => 'curies', + href => 'http://purl.org/sipwise/ngcp-api/#rel-{rel}', + name => 'ngcp', + templated => true, + ), + Data::HAL::Link->new(relation => 'collection', href => sprintf("%s", $self->dispatch_path)), + Data::HAL::Link->new(relation => 'profile', href => 'http://purl.org/sipwise/ngcp-api/'), + Data::HAL::Link->new(relation => 'self', href => sprintf("%s%d", $self->dispatch_path, $item->id)), + Data::HAL::Link->new(relation => "ngcp:$type", href => sprintf("/api/%s/%d", $type, $item->id)), + $self->get_journal_relation_link($item->id), + ], + relation => 'ngcp:'.$self->resource_name, + ); + + $form //= $self->get_form($c); + return unless $self->validate_form( + c => $c, + form => $form, + resource => \%resource, + run => 0, + exceptions => ['contract_id'], + ); + $hal->resource(\%resource); + return $hal; +} + +sub _item_rs { + my ($self, $c) = @_; + + my $item_rs = $c->model('DB')->resultset('voip_contract_locations'); + + if ($c->user->roles eq "reseller") { + $item_rs->search({ 'contacts.reseller_id' => $c->user->reseller_id }, + { join => { 'contracts' => 'contacts' } }); + } + + return $item_rs; +} + +sub item_by_id { + my ($self, $c, $id) = @_; + + my $item_rs = $self->item_rs($c); + return $item_rs->find($id); +} + +sub update_item { + my ($self, $c, $item, $old_resource, $resource, $form) = @_; + + delete $resource->{id}; + my $schema = $c->model('DB'); + + $form //= $self->get_form($c); + + return unless $self->validate_form( + c => $c, + form => $form, + resource => $resource, + exceptions => ['contract_id'], + ); + + return unless NGCP::Panel::Utils::ContractLocations::check_network_update_item($c,$resource,$item,sub { + my ($err) = @_; + $self->error($c, HTTP_UNPROCESSABLE_ENTITY, $err); + }); + + return unless $self->prepare_blocks_resource($c,$resource); + my $blocks = delete $resource->{blocks}; + + try { + $item->update($resource); + $item->voip_contract_location_blocks->delete; + for my $block (@$blocks) { + $item->create_related("voip_contract_location_blocks", $block); + } + $item->discard_changes; + } catch($e) { + $self->error($c, HTTP_INTERNAL_SERVER_ERROR, "Failed to create customer location."); + return; + }; + + return $item; +} + +sub prepare_blocks_resource { + my ($self,$c,$resource) = @_; + if (! exists $resource->{blocks} ) { + $resource->{blocks} = []; + } + if (ref $resource->{blocks} ne "ARRAY") { + $self->error($c, HTTP_UNPROCESSABLE_ENTITY, "Invalid field 'blocks'. Must be an array."); + return 0; + } + return NGCP::Panel::Utils::ContractLocations::set_blocks_from_to($resource->{blocks},sub { + my ($err) = @_; + $self->error($c, HTTP_UNPROCESSABLE_ENTITY, $err); + }); +} + +1; +# vim: set tabstop=4 expandtab: diff --git a/lib/NGCP/Panel/Role/API/Preferences.pm b/lib/NGCP/Panel/Role/API/Preferences.pm index ad41ab4c00..315bfefcc1 100644 --- a/lib/NGCP/Panel/Role/API/Preferences.pm +++ b/lib/NGCP/Panel/Role/API/Preferences.pm @@ -73,7 +73,9 @@ sub get_resource { } elsif($type eq "peerings") { $prefs = $item->voip_peer_preferences; } elsif($type eq "contracts") { - $prefs = $item->voip_contract_preferences; + $prefs = $item->voip_contract_preferences->search( + { location_id => $c->request->param('location_id') || undef }, + undef); } $prefs = $prefs->search({ }, { @@ -194,7 +196,6 @@ sub get_resource { } else { $resource->{$pref->attribute->attribute} = $value; } - } if($type eq "domains") { @@ -212,6 +213,8 @@ sub get_resource { } elsif($type eq "contracts") { $resource->{customer_id} = int($item->id); $resource->{id} = int($item->id); + $prefs->first ? $resource->{location_id} = $prefs->first->location_id + : undef; } return $resource; @@ -326,6 +329,7 @@ sub get_preference_rs { c => $c, attribute => $attr, contract => $elem, + location_id => $c->request->param('location_id') || undef, ); } return $rs; @@ -384,9 +388,13 @@ sub update_item { } elsif($type eq "contracts") { delete $resource->{customer_id}; delete $old_resource->{customer_id}; + delete $resource->{location_id}; + delete $old_resource->{location_id}; $accessor = $item->id; $elem = $item; - $full_rs = $elem->voip_contract_preferences; + $full_rs = $elem->voip_contract_preferences->search_rs( + { location_id => $c->request->param('location_id') || undef }, + undef); $pref_type = 'contract_pref'; $reseller_id = $item->contact->reseller_id; } else { diff --git a/lib/NGCP/Panel/Utils/ContractLocations.pm b/lib/NGCP/Panel/Utils/ContractLocations.pm new file mode 100644 index 0000000000..e9b55fe8cb --- /dev/null +++ b/lib/NGCP/Panel/Utils/ContractLocations.pm @@ -0,0 +1,167 @@ +package NGCP::Panel::Utils::ContractLocations; +use strict; +use warnings; + +#use Sipwise::Base; +#use DBIx::Class::Exception; + +use NetAddr::IP; +use Data::Validate::IP qw(is_ipv4 is_ipv6); +use NGCP::Panel::Utils::IntervalTree::Simple; + +use constant _CHECK_BLOCK_OVERLAPS => 1; + +sub check_network_update_item { + my ($c,$new_resource,$old_item,$err_code) = @_; + + return 1 unless $old_item; + + if (!defined $err_code || ref $err_code ne 'CODE') { + $err_code = sub { return 0; }; + } + + return 1; + +} + +sub set_blocks_from_to { + my ($blocks,$err_code) = @_; + my $intersecter = (_CHECK_BLOCK_OVERLAPS ? NGCP::Panel::Utils::IntervalTree::Simple->new() : undef); + my $version; + if (!defined $err_code || ref $err_code ne 'CODE') { + $err_code = sub { return 0; }; + } + if ((scalar @$blocks) == 0) { + return &{$err_code}('At least one block definition is required'); + } + foreach my $block (@$blocks) { + if (my $err = _set_ip_net_from_to($block)) { + return 0 unless &{$err_code}($err); + } else { + if ((defined $version && $version == 4 && $block->{_version} != 4) || + (defined $version && $version == 6 && $block->{_version} != 6)) { + return &{$err_code}('Ipv4 and ipv6 must not be mixed in block definitions'); + } + $version //= $block->{_version}; + if (defined $intersecter) { + my $to = $block->{_to}->copy->badd(1); #right open intervals + my $overlaps_with = $intersecter->find($block->{_from},$to); + if ((scalar @$overlaps_with) > 0) { + return 0 unless &{$err_code}("Block '$block->{_label}' overlaps with block(s) '" . join("', '",@$overlaps_with) . "'"); + } else { + $intersecter->insert($block->{_from},$to,$block->{_label}); + } + } + delete $block->{_from}; + delete $block->{_to}; + delete $block->{_version}; + delete $block->{_label}; + } + } + return 1; +} + +sub _set_ip_net_from_to { + my ($resource) = @_; + return "Invalid IP address '$resource->{ip}'" unless _validate_ip($resource->{ip}); + my $net = NetAddr::IP->new($resource->{ip} . (defined $resource->{mask} ? '/' . $resource->{mask} : '')); + if (!defined $net || (defined $resource->{mask} && $net->masklen != $resource->{mask})) { + return "Invalid mask '$resource->{mask}'"; + } + $resource->{_label} = $net->cidr; + #force scalar context: + my $from = $net->network->bigint; #first->bigint; + my $to = $net->broadcast->bigint; #last->bigint; + #if (NetAddr::IP::Util::hasbits($net->{mask} ^ _CIDR127)) { #other than point-to-point + # $from->bsub(1); #include network addr + # $to->badd(1); #include broadcast addr + #} + ($resource->{_from},$resource->{_to},$resource->{_version}) = ($from,$to,$net->version); + #whatever format we want to save: + if ($resource->{_version} == 4) { + $resource->{_ipv4_net_from} = $from; + $resource->{_ipv4_net_to} = $to; + $resource->{_ipv6_net_from} = undef; + $resource->{_ipv6_net_to} = undef; + } elsif ($resource->{_version} == 6) { + $resource->{_ipv4_net_from} = undef; + $resource->{_ipv4_net_to} = undef; + $resource->{_ipv6_net_from} = $from; + $resource->{_ipv6_net_to} = $to; + } + return undef; +} + +#deflate column values for search parameters doesn't work, so we need this sub +#(http://search.cpan.org/~ribasushi/DBIx-Class-0.082820/lib/DBIx/Class/Manual/FAQ.pod#Searching) +sub _ip_to_bytes { + my ($ip) = @_; + if (_validate_ip($ip) && (my $net = NetAddr::IP->new($ip))) { + my $bigint = $net->bigint; #force scalar context + return (_bigint_to_bytes($bigint,$net->version == 6 ? 16 : 4),$net->version); + } + return (undef,undef); +} + +sub prepare_query_param_value { + my ($q) = @_; + return _prepare_query_param_value($q); +} + +sub _prepare_query_param_value { + my ($q,$v) = @_; + my ($bytes,$version) = _ip_to_bytes($q); + if (defined $v) { + return undef if (!defined $bytes || $v != $version); + return $bytes; + } else { + return {} unless defined $bytes; + return { + 'voip_contract_location_blocks._ipv4_net_from' => { '<=', $bytes }, + 'voip_contract_location_blocks._ipv4_net_to' => { '>=', $bytes }, + } if $version == 4; + return { + 'voip_contract_location_blocks._ipv6_net_from' => { '<=', $bytes }, + 'voip_contract_location_blocks._ipv6_net_to' => { '>=', $bytes }, + } if $version == 6; + } +} + +sub _bigint_to_bytes { + my ($bigint,$size) = @_; + #print '>'.sprintf('%0' . 2 * $size . 's',substr($bigint->as_hex(),2)) . "\n"; + return pack('C' x $size, map { hex($_) } (sprintf('%0' . 2 * $size . 's',substr($bigint->as_hex(),2)) =~ /(..)/g)); + #print '>' . join('',map { sprintf('%02x',$_) } unpack('C' x $size, $data)) . "\n"; + #return $data; +} + +sub _validate_ip { + my ($ip) = @_; + return (is_ipv4($ip) || is_ipv6($ip)); +} + +sub get_datatable_cols { + + my ($c) = @_; + my $grp_stmt = "group_concat(if(`voip_contract_location_blocks`.`mask` is null,`voip_contract_location_blocks`.`ip`,concat(`voip_contract_location_blocks`.`ip`,'/',`voip_contract_location_blocks`.`mask`)) separator ', ')"; + my $grp_len = 30; + return ( + { name => 'blocks_grp', accessor => "blocks_grp", search => 0, title => $c->loc('Network Blocks'), literal_sql => + "if(length(".$grp_stmt.") > ".$grp_len.", concat(left(".$grp_stmt.", ".$grp_len."), '...'), ".$grp_stmt.")" }, + { name => "voip_contract_location_blocks._ipv4_net_from", search_lower_column => 'ipv4', convert_code => sub { + return _prepare_query_param_value(shift,4); + } }, + { name => "voip_contract_location_blocks._ipv4_net_to", search_upper_column => 'ipv4', convert_code => sub { + return _prepare_query_param_value(shift,4); + } }, + { name => "voip_contract_location_blocks._ipv6_net_from", search_lower_column => 'ipv6', convert_code => sub { + return _prepare_query_param_value(shift,6); + } }, + { name => "voip_contract_location_blocks._ipv6_net_to", search_upper_column => 'ipv6', convert_code => sub { + return _prepare_query_param_value(shift,6); + } }, + ); + +} + +1; diff --git a/lib/NGCP/Panel/Utils/Preferences.pm b/lib/NGCP/Panel/Utils/Preferences.pm index 3c1e5bed59..a50603ceab 100644 --- a/lib/NGCP/Panel/Utils/Preferences.pm +++ b/lib/NGCP/Panel/Utils/Preferences.pm @@ -843,6 +843,7 @@ sub get_contract_preference_rs { my $c = $params{c}; my $attribute = $params{attribute}; my $contract = $params{contract}; + my $location_id = $params{location_id} || undef; my $preference = $c->model('DB')->resultset('voip_preferences')->find({ attribute => $attribute, 'contract_pref' => 1, @@ -850,6 +851,7 @@ sub get_contract_preference_rs { return unless($preference); return $preference->voip_contract_preferences->search_rs({ contract_id => $contract->id, + location_id => $location_id, }); } diff --git a/share/templates/customer/details.tt b/share/templates/customer/details.tt index 6abc247c91..c0b25278c5 100644 --- a/share/templates/customer/details.tt +++ b/share/templates/customer/details.tt @@ -630,6 +630,41 @@ + [% IF (c.user.roles == "subscriberadmin" && product.class == "pbxaccount") || c.user.roles == "admin" || c.user.roles == "reseller" -%] +
+ +
+
+ + [% c.loc('Create Location') %] + + +[% + helper.name = c.loc('Locations'); + helper.identifier = 'locations'; + helper.length_change = 1; + helper.dt_columns = location_dt_columns; + helper.ajax_uri = c.uri_for_action('/customer/location_ajax', [ c.req.captures.0 ]); + + helper.tmpuri = c.uri_for(contract.id, 'location'); + UNLESS c.user.read_only; + helper.dt_buttons = [ + { name = c.loc('Preferences'), uri = helper.tmpuri _ "/'+full.id+'/preferences", class = 'btn-small btn-tertiary', icon = 'icon-list' }, + { name = c.loc('Edit'), uri = helper.tmpuri _ "/'+full.id+'/edit", class = 'btn-small btn-primary', icon = 'icon-edit' }, + { name = c.loc('Delete'), uri = helper.tmpuri _ "/'+full.id+'/delete", class = 'btn-small btn-secondary', icon = 'icon-remove' }, + ]; + END; + + PROCESS 'helpers/datatables.tt'; +%] + +
+
+
+ [% END -%] + [% IF create_flag == 1 -%] diff --git a/share/templates/customer/preferences.tt b/share/templates/customer/preferences.tt index 44ffffd6f7..6a4f18f458 100644 --- a/share/templates/customer/preferences.tt +++ b/share/templates/customer/preferences.tt @@ -1,18 +1,29 @@ -[% site_config.title = c.loc('Customer #[_1] - Preferences',contract.id) -%] +[% + IF location.id; + site_config.title = c.loc('Customer #[_1] - Preferences for Location "[_2]"',contract.id,location.name); + ELSE; + site_config.title = c.loc('Customer #[_1] - Preferences',contract.id); + END; +%] [% helper.name = c.loc('Customer'); helper.identifier = 'customer'; helper.messages = messages; - + helper.edit_preference = edit_preference; helper.preference = preference; helper.preference_meta = preference_meta; helper.preference_values = preference_values; helper.pref_groups = pref_groups; helper.form = form; - helper.base_uri = c.uri_for(contract.id,'preferences'); + IF location.id; + helper.base_uri = c.uri_for(contract.id,'location',location.id,'preferences',); + ELSE; + helper.base_uri = c.uri_for(contract.id,'preferences'); + END; helper.ncos_levels = ncos_levels; + helper.location_id = location_id; helper.top_buttons = [ { name = c.loc('Back'), uri = c.uri_for(contract.id, 'details'), icon = 'icon-arrow-left' }, diff --git a/t/api-rest/api-root.t b/t/api-rest/api-root.t index 9fc9c8c259..ec362a06a9 100644 --- a/t/api-rest/api-root.t +++ b/t/api-rest/api-root.t @@ -65,6 +65,7 @@ $ua->credentials($netloc, "api_admin_http", $user, $pass); customerbalances => 1, customercontacts => 1, customerfraudevents => 1, + customerlocations => 1, customerpreferencedefs => 1, customerpreferences => 1, customers => 1,