From 2dd40a8999bbd86679bc30cc8a80431111d849d1 Mon Sep 17 00:00:00 2001 From: Gerhard Jungwirth Date: Wed, 16 Apr 2014 12:20:00 +0200 Subject: [PATCH] MT#6497 API cftimesets --- lib/NGCP/Panel/Controller/API/CFTimeSets.pm | 225 ++++++++++++++++++ .../Panel/Controller/API/CFTimeSetsItem.pm | 200 ++++++++++++++++ lib/NGCP/Panel/Field/NumRangeAPI.pm | 32 +++ lib/NGCP/Panel/Form/CFTimeSetAPI.pm | 78 ++++++ lib/NGCP/Panel/Role/API/CFTimeSets.pm | 131 ++++++++++ t/api-root.t | 1 + 6 files changed, 667 insertions(+) create mode 100644 lib/NGCP/Panel/Controller/API/CFTimeSets.pm create mode 100644 lib/NGCP/Panel/Controller/API/CFTimeSetsItem.pm create mode 100644 lib/NGCP/Panel/Field/NumRangeAPI.pm create mode 100644 lib/NGCP/Panel/Form/CFTimeSetAPI.pm create mode 100644 lib/NGCP/Panel/Role/API/CFTimeSets.pm diff --git a/lib/NGCP/Panel/Controller/API/CFTimeSets.pm b/lib/NGCP/Panel/Controller/API/CFTimeSets.pm new file mode 100644 index 0000000000..e89467d1bc --- /dev/null +++ b/lib/NGCP/Panel/Controller/API/CFTimeSets.pm @@ -0,0 +1,225 @@ +package NGCP::Panel::Controller::API::CFTimeSets; +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; +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 a collection of CallForward Time Sets, including their times (periods), which can be set '. + 'to define CallForwards using CFMappings.', +); + +class_has 'query_params' => ( + is => 'ro', + isa => 'ArrayRef', + default => sub {[ + { + param => 'subscriber_id', + description => 'Filter for timesets belonging to a specific subscriber', + query => { + first => sub { + my $q = shift; + { subscriber_id => $q }; + }, + second => sub {}, + }, + }, + { + param => 'name', + description => 'Filter for contacts matching a timeset name pattern', + query => { + first => sub { + my $q = shift; + { name => { like => $q } }; + }, + second => sub {}, + }, + }, + ]}, +); + + +with 'NGCP::Panel::Role::API::CFTimeSets'; + +class_has('resource_name', is => 'ro', default => 'cftimesets'); +class_has('dispatch_path', is => 'ro', default => '/api/cftimesets/'); +class_has('relation', is => 'ro', default => 'http://purl.org/sipwise/ngcp-api/#rel-cftimesets'); + +__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 $timesets = $self->item_rs($c); + + my $total_count = int($timesets->count); + $timesets = $timesets->search(undef, { + page => $page, + rows => $rows, + }); + my (@embedded, @links); + for my $tset ($timesets->search({}, {order_by => {-asc => 'me.id'}})->all) { + push @embedded, $self->hal_from_item($c, $tset, "cftimesets"); + push @links, Data::HAL::Link->new( + relation => 'ngcp:'.$self->resource_name, + href => sprintf('%s%d', $self->dispatch_path, $tset->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', $self->dispatch_path, $page, $rows)); + if(($total_count / $rows) > $page ) { + push @links, Data::HAL::Link->new(relation => 'next', href => sprintf('%s?page=%d&rows=%d', $self->dispatch_path, $page + 1, $rows)); + } + if($page > 1) { + push @links, Data::HAL::Link->new(relation => 'prev', href => sprintf('%s?page=%d&rows=%d', $self->dispatch_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) = @_; + + 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, + ); + + my $tset; + + unless(defined $resource->{subscriber_id}) { + $self->error($c, HTTP_UNPROCESSABLE_ENTITY, "Required: 'subscriber_id'"); + last; + } + + my $subscriber = $schema->resultset('provisioning_voip_subscribers')->find({ + id => $resource->{subscriber_id}, + }); + unless($subscriber) { + $self->error($c, HTTP_UNPROCESSABLE_ENTITY, "Invalid 'subscriber_id'."); + last; + } + if (! exists $resource->{times} ) { + $resource->{times} = []; + } + if (ref $resource->{times} ne "ARRAY") { + $self->error($c, HTTP_UNPROCESSABLE_ENTITY, "Invalid field 'times'. Must be an array."); + last; + } + try { + $tset = $schema->resultset('voip_cf_time_sets')->create({ + name => $resource->{name}, + subscriber_id => $resource->{subscriber_id}, + }); + for my $t ( @{$resource->{times}} ) { + delete $t->{time_set_id}; + $tset->create_related("voip_cf_periods", $t); + } + } catch($e) { + $c->log->error("failed to create cftimeset: $e"); + $self->error($c, HTTP_INTERNAL_SERVER_ERROR, "Failed to create cftimeset."); + last; + } + + $guard->commit; + + $c->response->status(HTTP_CREATED); + $c->response->header(Location => sprintf('/%s%d', $c->request->path, $tset->id)); + $c->response->body(q()); + } + return; +} + +sub end : Private { + my ($self, $c) = @_; + + $self->log_response($c); + return 1; +} + +# vim: set tabstop=4 expandtab: diff --git a/lib/NGCP/Panel/Controller/API/CFTimeSetsItem.pm b/lib/NGCP/Panel/Controller/API/CFTimeSetsItem.pm new file mode 100644 index 0000000000..6023dc46fe --- /dev/null +++ b/lib/NGCP/Panel/Controller/API/CFTimeSetsItem.pm @@ -0,0 +1,200 @@ +package NGCP::Panel::Controller::API::CFTimeSetsItem; +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::ValidateJSON qw(); +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::HTTPMethods; +require Catalyst::ActionRole::RequireSSL; + +with 'NGCP::Panel::Role::API::CFTimeSets'; + +class_has('resource_name', is => 'ro', default => 'cftimesets'); +class_has('dispatch_path', is => 'ro', default => '/api/cftimesets/'); +class_has('relation', is => 'ro', default => 'http://purl.org/sipwise/ngcp-api/#rel-cftimesets'); + +__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); + return 1; +} + +sub GET :Allow { + my ($self, $c, $id) = @_; + { + last unless $self->valid_id($c, $id); + my $tset = $self->item_by_id($c, $id); + last unless $self->resource_exists($c, timeset => $tset); + + my $hal = $self->hal_from_item($c, $tset, "cftimesets"); + + 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; + $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 $tset = $self->item_by_id($c, $id); + last unless $self->resource_exists($c, timeset => $tset); + my $old_resource = $self->hal_from_item($c, $tset, "timesets")->resource; + my $resource = $self->apply_patch($c, $old_resource, $json); + last unless $resource; + + my $form = $self->get_form($c); + $tset = $self->update_item($c, $tset, $old_resource, $resource, $form); + last unless $tset; + + $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, $tset, "timesets"); + 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 $tset = $self->item_by_id($c, $id); + last unless $self->resource_exists($c, timeset => $tset); + my $resource = $self->get_valid_put_data( + c => $c, + id => $id, + media_type => 'application/json', + ); + last unless $resource; + my $old_resource = undef; #unused: { $tset->get_inflated_columns }; + + my $form = $self->get_form($c); + $tset = $self->update_item($c, $tset, $old_resource, $resource, $form); + last unless $tset; + + $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, $tset, "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 DELETE :Allow { + my ($self, $c, $id) = @_; + my $guard = $c->model('DB')->txn_scope_guard; + { + my $rule = $self->item_by_id($c, $id, "rules"); + last unless $self->resource_exists($c, rule => $rule); + try { + $rule->delete; + } catch($e) { + $c->log->error("Failed to delete rewriterule 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 end : Private { + my ($self, $c) = @_; + + $self->log_response($c); + return 1; +} + +# vim: set tabstop=4 expandtab: diff --git a/lib/NGCP/Panel/Field/NumRangeAPI.pm b/lib/NGCP/Panel/Field/NumRangeAPI.pm new file mode 100644 index 0000000000..705122a820 --- /dev/null +++ b/lib/NGCP/Panel/Field/NumRangeAPI.pm @@ -0,0 +1,32 @@ +package NGCP::Panel::Field::NumRangeAPI; +use Sipwise::Base; +extends 'HTML::FormHandler::Field::Text'; + +has 'min_start' => (isa => 'Int', default => 0, is => 'rw'); +has 'max_end' => (isa => 'Int', default => 999_999, is => 'rw'); + +sub validate { + my ( $self ) = @_; + my ($start, $end) = $self->value =~ m/(\d+)-(\d+)/; + unless ($start && $end && $start > 0 && $end > 0) { + $self->add_error('Invalid format'); + return; + } + if ($end < $start) { + $self->add_error('Second value smaller than first'); + return; + } + if ($start < $self->min_start) { + $self->add_error('First value too small'); + return; + } + if ($end > $self->max_end) { + $self->add_error('Second value too big'); + return; + } + return; +} + +1; + +# vim: set tabstop=4 expandtab: diff --git a/lib/NGCP/Panel/Form/CFTimeSetAPI.pm b/lib/NGCP/Panel/Form/CFTimeSetAPI.pm new file mode 100644 index 0000000000..7e1f29423c --- /dev/null +++ b/lib/NGCP/Panel/Form/CFTimeSetAPI.pm @@ -0,0 +1,78 @@ +package NGCP::Panel::Form::CFTimeSetAPI; +use HTML::FormHandler::Moose; +use HTML::FormHandler::Widget::Block::Bootstrap; +use Moose::Util::TypeConstraints; +extends 'HTML::FormHandler'; + +has_field 'name' => ( + type => 'Text', + label => 'Name', + required => 1, +); + +has_field 'subscriber' => ( # Workaround for validate_form + type => 'Compound', +); + +has_field 'subscriber.id' => ( + type => 'PosInteger', +); + +has_field 'times' => ( + type => 'Repeatable', + do_wrapper => 1, + do_label => 0, +); + +has_field 'times.id' => ( + type => 'Hidden', +); + +has_field 'times.year' => ( + type => '+NGCP::Panel::Field::NumRangeAPI', + min_start => 1990, + max_end => 3000, + label => 'Year', + empty_select => '', +); +has_field 'times.month' => ( + type => '+NGCP::Panel::Field::NumRangeAPI', + min_start => 1, + max_end => 12, + label => 'Month', + empty_select => '', +); +has_field 'times.mday' => ( + type => '+NGCP::Panel::Field::NumRangeAPI', + min_start => 1, + max_end => 31, + label => 'Day', +); + +has_field 'times.wday' => ( + type => '+NGCP::Panel::Field::NumRangeAPI', + min_start => 1, + max_end => 8, + label => 'Weekday', + empty_select => '', +); + +has_field 'times.hour' => ( + type => '+NGCP::Panel::Field::NumRangeAPI', + min_start => 0, + max_end => 23, + label => 'Hour', + empty_select => '', +); + +has_field 'times.minute' => ( + type => '+NGCP::Panel::Field::NumRangeAPI', + min_start => 0, + max_end => 59, + label => 'Minute', + empty_select => '', +); + +1; + +# vim: set tabstop=4 expandtab: diff --git a/lib/NGCP/Panel/Role/API/CFTimeSets.pm b/lib/NGCP/Panel/Role/API/CFTimeSets.pm new file mode 100644 index 0000000000..8178e7786b --- /dev/null +++ b/lib/NGCP/Panel/Role/API/CFTimeSets.pm @@ -0,0 +1,131 @@ +package NGCP::Panel::Role::API::CFTimeSets; +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 JSON::Types; +use NGCP::Panel::Utils::Subscriber; +use NGCP::Panel::Form::CFTimeSetAPI; + +sub get_form { + my ($self, $c) = @_; + return NGCP::Panel::Form::CFTimeSetAPI->new; +} + +sub hal_from_item { + my ($self, $c, $item, $type) = @_; + my $form; + + my %resource = $item->get_inflated_columns; + my @times; + for my $time ($item->voip_cf_periods->all) { + my $timeelem = {$time->get_inflated_columns}; + delete $timeelem->{'id'}; + push @times, $timeelem; + } + $resource{times} = \@times; + + my $b_subs_id = $item->subscriber->voip_subscriber->id; + + 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)), + Data::HAL::Link->new(relation => "ngcp:subscribers", href => sprintf("/api/subscribers/%d", $b_subs_id)), + ], + relation => 'ngcp:'.$self->resource_name, + ); + + $form //= $self->get_form($c); + return unless $self->validate_form( + c => $c, + form => $form, + resource => \%resource, + run => 0, + ); + $hal->resource(\%resource); + return $hal; +} + +sub item_rs { + my ($self, $c) = @_; + my $item_rs; + + if($c->user->roles eq "admin") { + $item_rs = $c->model('DB')->resultset('voip_cf_time_sets'); + } elsif ($c->user->roles eq "reseller") { + } + + 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'); + + return unless $self->validate_form( + c => $c, + form => $form, + resource => $resource, + ); + + if (! exists $resource->{times} ) { + $resource->{times} = []; + } + if (ref $resource->{times} ne "ARRAY") { + $self->error($c, HTTP_UNPROCESSABLE_ENTITY, "Invalid field 'times'. Must be an array."); + return; + } + + my $subscriber = $schema->resultset('provisioning_voip_subscribers')->find($resource->{subscriber_id}); + unless ($subscriber) { + $self->error($c, HTTP_UNPROCESSABLE_ENTITY, "Invalid 'subscriber_id'."); + return; + } + + try { + $item->update({ + name => $resource->{name}, + subscriber_id => $resource->{subscriber_id}, + })->discard_changes; + $item->voip_cf_periods->delete; + for my $t ( @{$resource->{times}} ) { + delete $t->{time_set_id}; + $item->create_related("voip_cf_periods", $t); + } + } catch($e) { + $c->log->error("failed to create cfdestinationset: $e"); + $self->error($c, HTTP_INTERNAL_SERVER_ERROR, "Failed to create cfdestinationset."); + return; + }; + + return $item; +} + +1; +# vim: set tabstop=4 expandtab: diff --git a/t/api-root.t b/t/api-root.t index 20156f769e..1a107df370 100644 --- a/t/api-root.t +++ b/t/api-root.t @@ -64,6 +64,7 @@ $ua->ssl_opts( subscribers => 1, callforwards => 1, cfdestinationsets => 1, + cftimesets => 1, }; foreach my $link(@links) { my $rex = qr/^<\/api\/[a-z]+\/>; rel=\"collection http:\/\/purl\.org\/sipwise\/ngcp-api\/#rel-([a-z]+s)\"$/;