diff --git a/lib/NGCP/Panel/Controller/API/EmailTemplates.pm b/lib/NGCP/Panel/Controller/API/EmailTemplates.pm index a1cdd41222..ef1d2c14b0 100644 --- a/lib/NGCP/Panel/Controller/API/EmailTemplates.pm +++ b/lib/NGCP/Panel/Controller/API/EmailTemplates.pm @@ -142,6 +142,7 @@ sub OPTIONS :Allow { sub POST :Allow { my ($self, $c) = @_; + my $guard = $c->model('DB')->txn_scope_guard; { my $resource = $self->get_valid_post_data( c => $c, @@ -179,6 +180,8 @@ sub POST :Allow { last; } + $guard->commit; + $c->response->status(HTTP_CREATED); $c->response->header(Location => sprintf('/%s%d', $c->request->path, $item->id)); $c->response->body(q()); diff --git a/lib/NGCP/Panel/Controller/API/SubscriberProfiles.pm b/lib/NGCP/Panel/Controller/API/SubscriberProfiles.pm new file mode 100644 index 0000000000..b2c9e34cb9 --- /dev/null +++ b/lib/NGCP/Panel/Controller/API/SubscriberProfiles.pm @@ -0,0 +1,243 @@ +package NGCP::Panel::Controller::API::SubscriberProfiles; +use Sipwise::Base; +use namespace::sweep; +use boolean qw(true); +use Data::HAL qw(); +use Data::HAL::Link qw(); +use HTTP::Headers qw(); +use HTTP::Status qw(:constants); +use MooseX::ClassAttribute qw(class_has); +use NGCP::Panel::Utils::DateTime; +use Path::Tiny qw(path); +use Safe::Isa qw($_isa); +BEGIN { extends 'Catalyst::Controller::ActionRole'; } +require Catalyst::ActionRole::ACL; +require Catalyst::ActionRole::CheckTrailingSlash; +require Catalyst::ActionRole::HTTPMethods; +require Catalyst::ActionRole::RequireSSL; + +class_has 'api_description' => ( + is => 'ro', + isa => 'Str', + default => + 'Defines subscriber profiles which specify the available features for a subscriber.', +); + +class_has 'query_params' => ( + is => 'ro', + isa => 'ArrayRef', + default => sub {[ + { + param => 'profile_set_id', + description => 'Filter for profiles belonging to a specific profile set', + query => { + first => sub { + my $q = shift; + { set_id => $q }; + }, + second => sub {}, + }, + }, + ]}, +); + +with 'NGCP::Panel::Role::API::SubscriberProfiles'; + +class_has('resource_name', is => 'ro', default => 'subscriberprofiles'); +class_has('dispatch_path', is => 'ro', default => '/api/subscriberprofiles/'); +class_has('relation', is => 'ro', default => 'http://purl.org/sipwise/ngcp-api/#rel-subscriberprofiles'); + +__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); +} + +sub GET :Allow { + my ($self, $c) = @_; + my $page = $c->request->params->{page} // 1; + my $rows = $c->request->params->{rows} // 10; + { + my $items = $self->item_rs($c); + my $total_count = int($items->count); + $items = $items->search(undef, { + page => $page, + rows => $rows, + }); + my (@embedded, @links); + my $form = $self->get_form($c); + for my $item ($items->search({}, {order_by => {-asc => 'me.id'}})->all) { + push @embedded, $self->hal_from_item($c, $item, $form); + push @links, Data::HAL::Link->new( + relation => 'ngcp:'.$self->resource_name, + href => sprintf('/%s%d', $c->request->path, $item->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/'), + Data::HAL::Link->new(relation => 'self', href => sprintf('/%s?page=%s&rows=%s', $c->request->path, $page, $rows)); + if(($total_count / $rows) > $page ) { + push @links, Data::HAL::Link->new(relation => 'next', href => sprintf('/%s?page=%d&rows=%d', $c->request->path, $page + 1, $rows)); + } + if($page > 1) { + push @links, Data::HAL::Link->new(relation => 'prev', href => sprintf('/%s?page=%d&rows=%d', $c->request->path, $page - 1, $rows)); + } + + 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; + $c->response->headers(HTTP::Headers->new( + Allow => $allowed_methods->join(', '), + 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) = @_; + + if($c->user->roles eq "reseller" && !$c->config->{profile_sets}->{reseller_edit}) { + $c->log->error("profile creation by reseller forbidden via config"); + $self->error($c, HTTP_FORBIDDEN, "Subscriber profile creation forbidden for resellers."); + return; + } + + my $guard = $c->model('DB')->txn_scope_guard; + { + my $resource = $self->get_valid_post_data( + c => $c, + media_type => 'application/json', + ); + last unless $resource; + + my $attributes = delete $resource->{attributes}; + + my $form = $self->get_form($c); + last unless $self->validate_form( + c => $c, + resource => $resource, + form => $form, + ); + if($c->user->roles eq "admin") { + } elsif($c->user->roles eq "reseller") { + $resource->{reseller_id} = $c->user->reseller_id; + } + $resource->{set_id} = delete $resource->{profile_set_id}; + + my $set = $c->model('DB')->resultset('voip_subscriber_profile_sets'); + if($c->user->roles eq "reseller") { + $set = $set->search({ + reseller_id => $c->user->reseller_id, + }); + } + $set = $set->find($resource->{set_id}); + unless($set) { + $c->log->error("subscriber profile set with id '$$resource{set_id}' does not exist"); # TODO: user, message, trace, ... + $self->error($c, HTTP_UNPROCESSABLE_ENTITY, "Invalid 'profile_set_id', does not exist"); + last; + } + + my $item; + $item = $set->voip_subscriber_profiles->find({ + name => $resource->{name}, + }); + if($item) { + $c->log->error("subscriber profile with name '$$resource{name}' already exists for profile_set_id '$$resource{set_id}'"); # TODO: user, message, trace, ... + $self->error($c, HTTP_UNPROCESSABLE_ENTITY, "Subscriber profile with this name already exists for this profile set"); + last; + } + if($resource->{set_default}) { + $set->voip_subscriber_profiles->update({ + set_default => 0, + }); + } + unless($set->voip_subscriber_profiles->count) { + $resource->{set_default} = 1; + } + + try { + $item = $set->voip_subscriber_profiles->create($resource); + my $meta_rs = $c->model('DB')->resultset('voip_preferences')->search({ + -or => [ + { + usr_pref => 1, + expose_to_customer => 1, + }, + { + attribute => { -in => [qw/cfu cft cfna cfb/] }, + }, + ], + }); + foreach my $a(@{ $attributes }) { + my $meta = $meta_rs->find({ attribute => $a }); + next unless $meta; + $item->profile_attributes->create({ attribute_id => $meta->id }); + } + } catch($e) { + $c->log->error("failed to create subscriber profile: $e"); # TODO: user, message, trace, ... + $self->error($c, HTTP_INTERNAL_SERVER_ERROR, "Failed to create subscriber profile."); + last; + } + + $guard->commit; + + $c->response->status(HTTP_CREATED); + $c->response->header(Location => sprintf('/%s%d', $c->request->path, $item->id)); + $c->response->body(q()); + } + return; +} + +sub end : Private { + my ($self, $c) = @_; + + $self->log_response($c); +} + +# vim: set tabstop=4 expandtab: diff --git a/lib/NGCP/Panel/Controller/API/SubscriberProfilesItem.pm b/lib/NGCP/Panel/Controller/API/SubscriberProfilesItem.pm new file mode 100644 index 0000000000..af566fed56 --- /dev/null +++ b/lib/NGCP/Panel/Controller/API/SubscriberProfilesItem.pm @@ -0,0 +1,204 @@ +package NGCP::Panel::Controller::API::SubscriberProfilesItem; +use Sipwise::Base; +use namespace::sweep; +use HTTP::Headers qw(); +use HTTP::Status qw(:constants); +use MooseX::ClassAttribute qw(class_has); +use NGCP::Panel::Utils::DateTime; +use NGCP::Panel::Utils::ValidateJSON qw(); +use Path::Tiny qw(path); +use Safe::Isa qw($_isa); +BEGIN { extends 'Catalyst::Controller::ActionRole'; } +require Catalyst::ActionRole::ACL; +require Catalyst::ActionRole::HTTPMethods; +require Catalyst::ActionRole::RequireSSL; + +with 'NGCP::Panel::Role::API::SubscriberProfiles'; + +class_has('resource_name', is => 'ro', default => 'subscriberprofiles'); +class_has('dispatch_path', is => 'ro', default => '/api/subscriberprofiles/'); +class_has('relation', is => 'ro', default => 'http://purl.org/sipwise/ngcp-api/#rel-subscriberprofiles'); + +__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 } + }, + action_roles => [qw(HTTPMethods)], +); + +sub auto :Private { + my ($self, $c) = @_; + + $self->set_body($c); + $self->log_request($c); +} + +sub GET :Allow { + my ($self, $c, $id) = @_; + { + last unless $self->valid_id($c, $id); + my $item = $self->item_by_id($c, $id); + last unless $self->resource_exists($c, subscriberprofile => $item); + + my $hal = $self->hal_from_item($c, $item); + + 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"|; + s/rel=self/rel="item self"/; + $_ + } $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; + $c->response->headers(HTTP::Headers->new( + Allow => $allowed_methods->join(', '), + 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 $form = $self->get_form($c); + + my $item = $self->item_by_id($c, $id); + last unless $self->resource_exists($c, subscriberprofile => $item); + my $old_resource = $self->resource_from_item($c, $item, $form); + my $resource = $self->apply_patch($c, $old_resource, $json); + last unless $resource; + + $item = $self->update_item($c, $item, $old_resource, $resource, $form); + last unless $item; + + $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, $item, $form); + 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 $item = $self->item_by_id($c, $id); + last unless $self->resource_exists($c, subscriberprofile => $item); + my $resource = $self->get_valid_put_data( + c => $c, + id => $id, + media_type => 'application/json', + ); + last unless $resource; + my $form = $self->get_form($c); + my $old_resource = $self->resource_from_item($c, $item, $form); + $item = $self->update_item($c, $item, $old_resource, $resource, $form); + last unless $item; + + $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, $item, $form); + 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 $item = $self->item_by_id($c, $id); + last unless $self->resource_exists($c, subscriberprofile => $item); + + $c->model('DB')->resultset('contracts')->search({ + subscriber_email_template_id => $item->id, + })->update({ + subscriber_email_template_id => undef, + }); + $c->model('DB')->resultset('contracts')->search({ + passreset_email_template_id => $item->id, + })->update({ + passreset_email_template_id => undef, + }); + + $item->delete; + + $guard->commit; + + $c->response->status(HTTP_NO_CONTENT); + $c->response->body(q()); + } + return; +} + +sub end : Private { + my ($self, $c) = @_; + + $self->log_response($c); +} + +# vim: set tabstop=4 expandtab: diff --git a/lib/NGCP/Panel/Form/SubscriberProfile/ApiProfile.pm b/lib/NGCP/Panel/Form/SubscriberProfile/ApiProfile.pm index 2ac79425c8..6e579f4e3d 100644 --- a/lib/NGCP/Panel/Form/SubscriberProfile/ApiProfile.pm +++ b/lib/NGCP/Panel/Form/SubscriberProfile/ApiProfile.pm @@ -1,7 +1,7 @@ package NGCP::Panel::Form::SubscriberProfile::ApiProfile; use HTML::FormHandler::Moose; -extends 'NGCP::Panel::Form::SubscriberProfile::ProfileReseller'; +extends 'NGCP::Panel::Form::SubscriberProfile::Profile'; use Moose::Util::TypeConstraints; has_field 'profile_set' => ( @@ -9,14 +9,14 @@ has_field 'profile_set' => ( validate_when_empty => 1, element_attr => { rel => ['tooltip'], - title => ['The profile set defining the possible feature sets for this subscriber.'] + title => ['The subscriber rofile set this profile belongs to.'] }, ); has_block 'fields' => ( tag => 'div', class => [qw/modal-body/], - render_list => [qw/profile_set name description profile_default/], + render_list => [qw/profile_set name description set_default attribute/], ); 1; diff --git a/lib/NGCP/Panel/Role/API/SubscriberProfiles.pm b/lib/NGCP/Panel/Role/API/SubscriberProfiles.pm new file mode 100644 index 0000000000..1f1d88b368 --- /dev/null +++ b/lib/NGCP/Panel/Role/API/SubscriberProfiles.pm @@ -0,0 +1,198 @@ +package NGCP::Panel::Role::API::SubscriberProfiles; +use Moose::Role; +use Sipwise::Base; +with 'NGCP::Panel::Role::API' => { + -alias =>{ item_rs => '_item_rs', }, + -excludes => [ 'item_rs' ], +}; + +use boolean qw(true); +use TryCatch; +use Data::HAL qw(); +use Data::HAL::Link qw(); +use HTTP::Status qw(:constants); +use NGCP::Panel::Form::SubscriberProfile::ApiProfile; + +sub item_rs { + my ($self, $c) = @_; + + my $item_rs = $c->model('DB')->resultset('voip_subscriber_profiles'); + if($c->user->roles eq "admin") { + } elsif($c->user->roles eq "reseller") { + $item_rs = $item_rs->search({ 'profile_set.reseller_id' => $c->user->reseller_id }, { + join => 'profile_set', + }); + } + return $item_rs; +} + +sub get_form { + my ($self, $c) = @_; + return NGCP::Panel::Form::SubscriberProfile::ApiProfile->new; +} + +sub hal_from_item { + my ($self, $c, $item, $form) = @_; + my $resource = $self->resource_from_item($c, $item, $form); + + 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("/api/%s/", $self->resource_name)), + 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:subscriberprofilesets', href => sprintf("/api/subscriberprofilesets/%d", $item->set_id)), + ], + relation => 'ngcp:'.$self->resource_name, + ); + + $form //= $self->get_form($c); + + $resource->{attribute} = delete $resource->{attributes}; + $self->validate_form( + c => $c, + resource => $resource, + form => $form, + run => 0, + ); + + $resource->{attributes} = delete $resource->{attribute}; + $resource->{id} = int($item->id); + $hal->resource($resource); + return $hal; +} + +sub resource_from_item { + my ($self, $c, $item, $form) = @_; + + $form //= $self->get_form($c); + + my %resource = $item->get_inflated_columns; + my @att = map { $_->attribute->attribute } $item->profile_attributes->all; + $resource{attributes} = \@att; + $resource{profile_set_id} = delete $resource{set_id}; + + return \%resource; +} + +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->{attribute} in case reseller not allowed to update set + + $resource->{attribute} = delete $resource->{attributes}; + $form //= $self->get_form($c); + return unless $self->validate_form( + c => $c, + form => $form, + resource => $resource, + ); + $resource->{set_id} = delete $resource->{profile_set_id}; + + my $set = $c->model('DB')->resultset('voip_subscriber_profile_sets'); + if($c->user->roles eq "reseller") { + $set = $set->search({ + reseller_id => $c->user->reseller_id, + }); + } + $set = $set->find($resource->{set_id}); + + unless($set) { + $c->log->error("subscriber profile set id '$$resource{set_id}' does not exist"); # TODO: user, message, trace, ... + $self->error($c, HTTP_UNPROCESSABLE_ENTITY, "Invalid 'profile_set_id', does not exist"); + return; + } + + my $dup_item = $set->voip_subscriber_profiles->find({ + name => $resource->{name}, + }); + if($dup_item && $dup_item->id != $item->id) { + $c->log->error("subscriber profile with name '$$resource{name}' already exists for profile_set_id '$$resource{set_id}'"); # TODO: user, message, trace, ... + $self->error($c, HTTP_UNPROCESSABLE_ENTITY, "Subscriber profile with this name already exists for this profile set"); + return; + } + + my $attributes; + if($c->user->roles eq "reseller" && !$c->config->{profile_sets}->{reseller_edit}) { + # don't let reseller update attributes in this case + $attributes = [ map { $_->attribute->attribute } $item->profile_attributes->all ]; + } else { + $attributes = $resource->{attribute}; + } + delete $resource->{attribute}; + + if($item->set_default && !$resource->{set_default}) { + $set->voip_subscriber_profiles->first->update({ + set_default => 1, + }); + } elsif(!$item->set_default && $resource->{set_default}) { + $set->voip_subscriber_profiles->all->update({ + set_default => 0, + }); + } + + $item->update($resource); + + my %old_attributes = map { $_ => 1 } + $item->profile_attributes->get_column('attribute_id')->all; + + # TODO: reuse attributes for efficiency reasons? + $item->profile_attributes->delete; + + my $meta_rs = $c->model('DB')->resultset('voip_preferences')->search({ + -or => [ + { + usr_pref => 1, + expose_to_customer => 1, + }, + { + attribute => { -in => [qw/cfu cft cfna cfb/] }, + }, + ], + }); + foreach my $a(@{ $attributes }) { + my $meta = $meta_rs->find({ attribute => $a }); + next unless $meta; + # mark as seen, so later we can unprovision the remaining ones, + # which are the ones not set here: + delete $old_attributes{$meta->id}; + + $item->profile_attributes->create({ attribute_id => $meta->id }); + } + # go over remaining attributes (those which were set before but are not set anymore) + # and clear them from usr-preferences + if(keys %old_attributes) { + my $cfs = $c->model('DB')->resultset('voip_preferences')->search({ + id => { -in => [ keys %old_attributes ] }, + attribute => { -in => [qw/cfu cfb cft cfna/] }, + }); + my @subs = $c->model('DB')->resultset('provisioning_voip_subscribers') + ->search({ + profile_id => $item->id, + })->all; + foreach my $sub(@subs) { + $sub->voip_usr_preferences->search({ + attribute_id => { -in => [ keys %old_attributes ] }, + })->delete; + $sub->voip_cf_mappings->search({ + type => { -in => [ map { $_->attribute } $cfs->all ] }, + })->delete; + } + } + + return $item; +} + +1; +# vim: set tabstop=4 expandtab: diff --git a/ngcp_panel.conf b/ngcp_panel.conf index 2f41db58b9..71809ff623 100644 --- a/ngcp_panel.conf +++ b/ngcp_panel.conf @@ -84,7 +84,7 @@ log4perl.appender.Default.layout.ConversionPattern=%d{ISO8601} [%p] [%F +%L] %m{ - reseller_edit 1 + reseller_edit 0