You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ngcp-panel/lib/NGCP/Panel/Utils/Journal.pm

685 lines
26 KiB

package NGCP::Panel::Utils::Journal;
use strict;
use warnings;
use Sipwise::Base;
use DBIx::Class::Exception;
use NGCP::Panel::Utils::DateTime;
use NGCP::Panel::Utils::UserRole;
use JSON;
use HTTP::Status qw(:constants);
use TryCatch;
use boolean qw(true);
use Data::HAL qw();
use Data::HAL::Link qw();
use Scalar::Util 'blessed';
use Storable;
use IO::Compress::Deflate qw($DeflateError);
use IO::Uncompress::Inflate qw();
use Sereal::Decoder qw();
use Sereal::Encoder qw();
use NGCP::Panel::Utils::UserRole;
use constant CREATE_JOURNAL_OP => 'create';
use constant UPDATE_JOURNAL_OP => 'update';
use constant DELETE_JOURNAL_OP => 'delete';
use constant _JOURNAL_OPS => { CREATE_JOURNAL_OP.'' => 1, UPDATE_JOURNAL_OP.'' => 1, DELETE_JOURNAL_OP.'' => 1 };
use constant OPERATION_DEFAULT_ENABLED => 0;
use constant API_JOURNAL_RESOURCE_NAME => 'journal';
use constant API_JOURNALITEMTOP_RESOURCE_NAME => 'recent'; #empty to disable
use constant JOURNAL_RESOURCE_DEFAULT_ENABLED => 0;
use constant CONTENT_JSON_FORMAT => 'json';
use constant CONTENT_STORABLE_FORMAT => 'storable';
use constant CONTENT_JSON_DEFLATE_FORMAT => 'json_deflate';
use constant CONTENT_SEREAL_FORMAT => 'sereal';
use constant _CONTENT_FORMATS => { CONTENT_JSON_FORMAT.'' => 1, CONTENT_STORABLE_FORMAT.'' => 1, CONTENT_JSON_DEFLATE_FORMAT.'' => 1, CONTENT_SEREAL_FORMAT.'' => 1 };
use constant CONTENT_DEFAULT_FORMAT => CONTENT_JSON_FORMAT;
use constant API_JOURNAL_RELATION => 'ngcp:'.API_JOURNAL_RESOURCE_NAME;
#use constant API_JOURNALITEM_RELATION => 'ngcp:journalitem';
use constant JOURNAL_FIELDS => ['id', 'operation', 'resource_name', 'resource_id', 'timestamp', 'username'];
sub add_journal_item_hal {
my ($controller,$c,$operation,@args) = @_;
my $cfg = _get_api_journal_op_config($c,$controller->resource_name,$operation);
if ($cfg->{operation_enabled}) {
my $arg = $args[0];
my @hal_from_item = ();
my $params;
my $id;
my $id_name = 'id';
if (ref $arg eq 'HASH') {
$params = $arg;
my $h = (defined $params->{hal} ? $params->{hal} : $params->{hal_from_item});
$h //= (defined $params->{resource} ? $params->{resource} : $params->{resource_from_item});
if (defined $h) {
if (ref $h eq 'ARRAY') {
@hal_from_item = @$h;
} else { #if (not ref $h) {
@hal_from_item = ( $h );
}
}
$id_name = $params->{id_name} if defined $params->{id_name};
$id = $params->{id} if defined $params->{id};
} elsif (ref $arg eq 'ARRAY') {
$params = {};
@hal_from_item = @$arg;
} else {
$params = {};
@hal_from_item = @args;
}
my $code = shift @hal_from_item;
unshift(@hal_from_item,$c);
unshift(@hal_from_item,$controller);
unshift(@hal_from_item,$code);
$params->{hal_from_item} = \@hal_from_item;
$params->{operation} = $operation;
$params->{resource_name} //= $controller->resource_name;
$params->{format} //= $cfg->{format};
my $resource = shift @hal_from_item;
my $hal;
if (ref $resource eq 'CODE') {
$hal = $resource->(@hal_from_item);
} else {
$hal = $resource;
}
if (ref $hal eq Data::HAL::) {
$resource = $hal->resource;
}
if (!defined $id) {
if (ref $resource eq 'HASH') {
$id = $resource->{$id_name};
} elsif ((defined blessed($resource)) && $resource->can($id_name)) {
$id = $resource->$id_name;
}
}
return _create_journal($controller,$c,$id,$resource,$params);
}
return 1;
}
sub get_api_journal_action_config {
my ($path_part,$action_template,$journal_methods) = @_;
my %journal_actions_found = map { $_ => 1 } @$journal_methods;
if (exists $journal_actions_found{'handle_item_base_journal'}) {
my @result = ();
if (exists $journal_actions_found{'handle_journals_get'}) {
my $action_config = Storable::dclone($action_template);
$action_config->{Chained} = 'handle_item_base_journal';
$action_config->{PathPart} //= API_JOURNAL_RESOURCE_NAME;
$action_config->{Args} = 0;
$action_config->{Method} = 'GET';
push(@result,$action_config,'handle_journals_get');
}
if (exists $journal_actions_found{'handle_journals_options'}) {
my $action_config = Storable::dclone($action_template);
$action_config->{Chained} = 'handle_item_base_journal';
$action_config->{PathPart} //= API_JOURNAL_RESOURCE_NAME;
$action_config->{Args} = 0;
$action_config->{Method} = 'OPTIONS';
push(@result,$action_config,'handle_journals_options');
}
if (exists $journal_actions_found{'handle_journals_head'}) {
my $action_config = Storable::dclone($action_template);
$action_config->{Chained} = 'handle_item_base_journal';
$action_config->{PathPart} //= API_JOURNAL_RESOURCE_NAME;
$action_config->{Args} = 0;
$action_config->{Method} = 'HEAD';
push(@result,$action_config,'handle_journals_head');
}
if (exists $journal_actions_found{'handle_journalsitem_get'}) {
my $action_config = Storable::dclone($action_template);
$action_config->{Chained} = 'handle_item_base_journal';
$action_config->{PathPart} //= API_JOURNAL_RESOURCE_NAME;
$action_config->{Args} = 1;
$action_config->{Method} = 'GET';
push(@result,$action_config,'handle_journalsitem_get');
}
if (exists $journal_actions_found{'handle_journalsitem_options'}) {
my $action_config = Storable::dclone($action_template);
$action_config->{Chained} = 'handle_item_base_journal';
$action_config->{PathPart} //= API_JOURNAL_RESOURCE_NAME;
$action_config->{Args} = 1;
$action_config->{Method} = 'OPTIONS';
push(@result,$action_config,'handle_journalsitem_options');
}
if (exists $journal_actions_found{'handle_journalsitem_head'}) {
my $action_config = Storable::dclone($action_template);
$action_config->{Chained} = 'handle_item_base_journal';
$action_config->{PathPart} //= API_JOURNAL_RESOURCE_NAME;
$action_config->{Args} = 1;
$action_config->{Method} = 'HEAD';
push(@result,$action_config,'handle_journalsitem_head');
}
if ((scalar @result) > 0) {
push(@result,{
Chained => '/',
PathPart => $path_part,
CaptureArgs => 1,
},'handle_item_base_journal');
@result = reverse @result;
return \@result;
}
}
return [];
}
sub get_api_journal_query_params {
my ($query_params) = @_;
my @params = (defined $query_params ? @$query_params : ());
push(@params,{
param => 'operation',
description => 'Filter for journal items by a specific CRUD operation ("create", "update" or "delete")',
query => {
first => sub {
my $q = shift;
{ 'operation' => $q };
},
second => sub { },
},
},{
param => 'username',
description => 'Filter for journal items by the name of the user who performed the operation',
query => {
first => sub {
my $q = shift;
{ 'username' => $q };
},
second => sub { },
},
},{
param => 'from',
description => 'Filter for journal items recorded after or at the specified time stamp',
query => {
first => sub {
my $q = shift;
my $dt = NGCP::Panel::Utils::DateTime::from_string($q);
return { 'timestamp' => { '>=' => $dt->epoch } };
},
second => sub { },
},
},{
param => 'to',
description => 'Filter for journal items recorded before or at the specified time stamp',
query => {
first => sub {
my $q = shift;
my $dt = NGCP::Panel::Utils::DateTime::from_string($q);
return { 'timestamp' => { '<=' => $dt->epoch } };
},
second => sub { },
},
});
return \@params;
}
sub handle_api_item_base_journal {
my ($controller,$c,$id) = @_;
if ($c->user->id == $id || $c->user->is_system || $c->user->is_superuser) {
$c->stash->{item_id_journal} = $id;
return;
}
return;
}
sub handle_api_journals_get {
my ($controller,$c) = @_;
my $page = $c->request->params->{page} // 1;
my $rows = $c->request->params->{rows} // 10;
{
my $item_id = $c->stash->{item_id_journal};
last unless $controller->valid_id($c, $item_id);
my $journals = get_journal_rs($controller,$c,$item_id);
(my $total_count, $journals, my $journals_rows ) = $controller->paginate_order_collection($c,$journals);
my (@embedded, @links);
for my $journal(@$journals_rows) {
my $hal = hal_from_journal($controller,$c,$journal);
$hal->_forcearray(1);
push @embedded,$hal;
my $link = get_journal_relation_link($journal,$c,$item_id,$journal->id);
$link->_forcearray(1);
push @links,$link;
}
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/'),
$controller->collection_nav_links($c, $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 handle_api_journalsitem_get {
my ($controller,$c,$id) = @_;
my $guard = $c->model('DB')->txn_scope_guard;
{
my $item_id = $c->stash->{item_id_journal};
last unless $controller->valid_id($c, $item_id);
my $journal = undef;
if (API_JOURNALITEMTOP_RESOURCE_NAME and $id eq API_JOURNALITEMTOP_RESOURCE_NAME) {
$journal = get_top_journalitem($controller,$c,$item_id);
} elsif ($controller->valid_id($c, $id)) {
$journal = get_journalitem($c,$id);
} else {
last;
}
last unless $controller->resource_exists($c, journal => $journal);
if ($journal->resource_id != $item_id) {
$c->log->error("Journal item '" . $id . "' does not belong to '" . $controller->resource_name . '/' . $item_id . "'");
$controller->error($c, HTTP_NOT_FOUND, "Entity 'journal' not found.");
last;
}
my $hal = hal_from_journal($controller,$c,$journal);
$guard->commit;
#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(HTTP::Headers->new($hal->http_headers));
$c->response->body($hal->as_json);
return;
}
return;
}
sub handle_api_journals_options {
my ($controller, $c, $id) = @_;
my @allowed_methods = ('OPTIONS');
my %journal_actions_found = map { $_ => 1 } @{ $controller->get_journal_methods };
if (exists $journal_actions_found{'handle_journals_get'}) {
push(@allowed_methods,'GET');
if (exists $journal_actions_found{'handle_journals_head'}) {
push(@allowed_methods,'HEAD');
}
}
$c->response->headers(HTTP::Headers->new(
Allow => join(', ',@allowed_methods),
));
$c->response->content_type('application/json');
$c->response->body(JSON::to_json({ methods => \@allowed_methods })."\n");
return;
}
sub handle_api_journalsitem_options {
my ($controller, $c, $id) = @_;
my @allowed_methods = ('OPTIONS');
my %journal_actions_found = map { $_ => 1 } @{ $controller->get_journal_methods };
if (exists $journal_actions_found{'handle_journalsitem_get'}) {
push(@allowed_methods,'GET');
if (exists $journal_actions_found{'handle_journalsitem_head'}) {
push(@allowed_methods,'HEAD');
}
}
$c->response->headers(HTTP::Headers->new(
Allow => join(', ',@allowed_methods),
));
$c->response->content_type('application/json');
$c->response->body(JSON::to_json({ methods => \@allowed_methods })."\n");
return;
}
sub _has_journal_method {
my ($controller, $action) = @_;
my %journal_actions_found = map { $_ => 1 } @{ $controller->get_journal_methods };
return exists $journal_actions_found{$action};
}
sub handle_api_journals_head {
my ($controller, $c) = @_;
if (_has_journal_method($controller,'handle_journals_get')) {
$c->forward('handle_journals_get');
$c->response->body(q());
return;
} else {
$c->log->error("journals_get action not implemented: " . ref $controller);
$c->response->status(HTTP_METHOD_NOT_ALLOWED);
$c->response->body(q());
return;
}
}
sub handle_api_journalsitem_head {
my ($controller, $c) = @_;
if (_has_journal_method($controller,'handle_journalsitem_get')) {
$c->forward('handle_journalsitem_get');
$c->response->body(q());
return;
} else {
$c->log->error("journalsitem_get not implemented: " . ref $controller);
$c->response->status(HTTP_METHOD_NOT_ALLOWED);
$c->response->body(q());
return;
}
}
sub get_journal_rs {
my ($controller,$c,$resource_id,$all_columns) = @_;
my $rs = $c->model('DB')->resultset('journals')
->search({
'resource_name' => $controller->resource_name,
'resource_id' => $resource_id,
},($all_columns ? undef : {
columns => JOURNAL_FIELDS,
#alias => 'me',
}));
if ($controller->can('journal_query_params')) {
return $controller->apply_query_params($c,$controller->journal_query_params,$rs);
}
return $rs;
}
sub get_journalitem {
my ($c,$id) = @_;
my $journal = $c->model('DB')->resultset('journals')
->find({
'id' => $id,
});
return $journal;
}
sub get_top_journalitem {
my ($controller,$c,$resource_id) = @_;
return get_top_n_journalitems($controller,$c,$resource_id,1,1)->[0];
}
sub get_top_n_journalitems {
my ($controller,$c,$resource_id,$n,$all_columns) = @_;
my @journals = get_journal_rs($controller,$c,$resource_id,$all_columns)->search(undef,{
page => 1,
rows => $n,
order_by => {-desc => "id"},
})->all;
return \@journals;
}
sub hal_from_journal {
my ($controller,$c,$journal) = @_;
my %resource = $journal->get_inflated_columns;
{
if (exists $resource{content}) {
try {
$resource{content} = _deserialize_content($journal->content_format,$journal->content);
} catch($e) {
$resource{content} = undef;
$c->log->error("Failed to de-serialize content snapshot of journal item '" . $journal->id . "': $e");
#$controller->error($c, HTTP_INTERNAL_SERVER_ERROR, "Internal Server Error.");
#return;
last;
};
#delta stuff...
}
}
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,
),
get_journal_relation_link($journal,$c,$journal->resource_id,undef,'collection'),
Data::HAL::Link->new(relation => 'profile', href => 'http://purl.org/sipwise/ngcp-api/'),
get_journal_relation_link($journal,$c,$journal->resource_id,$journal->id,'self'),
Data::HAL::Link->new(relation => sprintf('ngcp:%s',$journal->resource_name),
href => sprintf('/api/%s/%d', $journal->resource_name, $journal->resource_id)),
],
relation => API_JOURNAL_RELATION, #API_JOURNALITEM_RELATION,
);
my $datetime_fmt = DateTime::Format::Strptime->new(
pattern => '%F %T',
);
$resource{timestamp} = $datetime_fmt->format_datetime($resource{timestamp});
delete $resource{resource_name};
delete $resource{resource_id};
delete $resource{content_format};
$hal->resource({%resource});
return $hal;
}
sub get_journal_relation_link {
my ($resource, $c, $item_id, $id, $relation) = @_;
my $resource_name = undef;
my $controller = undef;
my $action_name = $id ? 'handle_journalsitem_get' : 'handle_journals_get';
if (ref $resource eq 'HASH') {
$resource_name = $resource->{resource_name};
} elsif ((defined blessed($resource)) && $resource->can('resource_name')) { #both controllers and journal rows
$resource_name = $resource->resource_name;
if ($resource->can('get_config') and exists $resource->get_config('action')->{$action_name}) {
$controller = $resource;
}
} elsif (!ref $resource) {
$resource_name = $resource;
}
if (defined $resource_name) {
my $allowed_roles = [];
if (!$controller) {
$controller = NGCP::Panel::Utils::API::get_module_by_resource($c, $resource_name, $item_id);
}
if ($controller->can('get_config')) {
if (exists $controller->get_config('action')->{$action_name}) {
$allowed_roles = $controller->get_config('action')->{$action_name}->{AllowedRole};
}
}
if (grep { $_ eq $c->user->roles } @$allowed_roles) {
if (defined $id) {
return Data::HAL::Link->new(
relation => ($relation // API_JOURNAL_RELATION), #API_JOURNALITEM_RELATION),
href => sprintf('/api/%s/%d/%s/%d', $resource_name, $item_id,API_JOURNAL_RESOURCE_NAME,$id),
);
} else {
return Data::HAL::Link->new(
relation => ($relation // API_JOURNAL_RELATION),
href => sprintf('/api/%s/%d/%s/', $resource_name, $item_id, API_JOURNAL_RESOURCE_NAME),
);
}
}
}
return ();
}
sub get_api_journal_op_config {
my ($config,$resource_name,$operation) = @_;
return _get_journal_op_config($config->{api_journal},undef,$resource_name,$operation);
}
sub _get_api_journal_op_config {
return _get_journal_op_config($_[0]->config->{api_journal},@_);
}
sub _get_journal_op_config {
my ($cfg,$c,$resource_name,$operation) = @_;
#my $cfg = $c->config->{$section};
my $format = CONTENT_DEFAULT_FORMAT;
my $operation_enabled = OPERATION_DEFAULT_ENABLED;
if (defined $cfg && exists $cfg->{$resource_name}) {
$cfg = $cfg->{$resource_name};
if (ref $cfg eq 'HASH') {
if (ref $cfg->{operations} eq 'ARRAY') {
foreach my $op (@{ $cfg->{operations} }) {
if (exists _JOURNAL_OPS->{$op}) {
if ($operation eq $op) {
$operation_enabled = 1;
last;
}
} else {
$c->log->error("Invalid '$resource_name' operation to log in journals: $op") if defined $c;
}
}
}
if (defined $cfg->{format}) {
if (exists _CONTENT_FORMATS->{$cfg->{format}}) {
$format = $cfg->{format};
} else {
$c->log->error("Invalid journal content format for resource '$resource_name': $cfg->{format}") if defined $c;
}
}
}
}
return { format => $format, operation_enabled => $operation_enabled};
}
sub get_journal_resource_config {
my ($config,$resource_name) = @_;
my $cfg = $config->{api_journal};
my $journal_resource_enabled = JOURNAL_RESOURCE_DEFAULT_ENABLED;
if (defined $cfg && exists $cfg->{$resource_name}) {
$cfg = $cfg->{$resource_name};
if (ref $cfg eq 'HASH') {
if (defined $cfg->{enabled}) {
if ($cfg->{enabled}) {
$journal_resource_enabled = 1;
} else {
$journal_resource_enabled = 0;
}
}
}
}
return { journal_resource_enabled => $journal_resource_enabled };
}
sub _serialize_content { #run this in eval only, deflate somehow inflicts a segfault in subsequent catalyst action when not consuming all args there.
my ($format,$data) = @_;
if (defined $format && defined $data) {
if ($format eq CONTENT_JSON_FORMAT) {
return JSON::to_json($data, { canonical => 1, pretty => 1, utf8 => 1 });
} elsif ($format eq CONTENT_JSON_DEFLATE_FORMAT) {
my $json = JSON::to_json($data, { canonical => 1, pretty => 1, utf8 => 1 });
my $buf = '';
IO::Compress::Deflate::deflate(\$json,\$buf) or die($DeflateError);
return $buf;
} elsif ($format eq CONTENT_STORABLE_FORMAT) {
return Storable::nfreeze($data);
} elsif ($format eq CONTENT_SEREAL_FORMAT) {
return Sereal::Encoder::encode_sereal($data);
}
}
return;
}
sub _deserialize_content {
my ($format,$serialized) = @_;
if (defined $format && defined $serialized) {
if ($format eq CONTENT_JSON_FORMAT) {
return JSON::from_json($serialized);
} elsif ($format eq CONTENT_JSON_DEFLATE_FORMAT) {
my $buf = '';
IO::Uncompress::Inflate::inflate(\$serialized,\$buf) or die($DeflateError);
return JSON::from_json($buf);
} elsif ($format eq CONTENT_STORABLE_FORMAT) {
return Storable::thaw($serialized);
} elsif ($format eq CONTENT_SEREAL_FORMAT) {
return Sereal::Decoder::decode_sereal($serialized);
}
}
return;
}
sub _create_journal {
my ($controller, $c, $id, $resource, $params) = @_;
{
my $content_format = ((defined $params->{format} and exists _CONTENT_FORMATS->{$params->{format}}) ? $params->{format} : CONTENT_DEFAULT_FORMAT);
my $operation = ((defined $params->{operation} and exists _JOURNAL_OPS->{$params->{operation}}) ? $params->{operation} : undef);
if (!defined $operation) {
$c->log->error("Invalid '$params->{resource_name}' operation to log in journals: $operation");
last;
}
my %journal = (
operation => $operation,
resource_name => $params->{resource_name},
#resource_id => $id,
timestamp => NGCP::Panel::Utils::DateTime::current_local->hires_epoch,
content_format => $content_format,
);
if (defined $id) {
$journal{resource_id} = $id;
}
if (defined $params->{user}) {
$journal{username} = $params->{user};
} elsif (defined $c->user) {
$journal{user_id} = $c->user->id;
if($c->user->roles eq 'admin' || $c->user->roles eq 'reseller') {
$journal{username} = $c->user->login;
} elsif($c->user->roles eq 'subscriber' || $c->user->roles eq 'subscriberadmin') {
$journal{username} = $c->user->webusername . '@' . $c->user->domain->domain;
}
}
$journal{reseller_id} = $resource->{reseller_id} // $c->user->reseller_id;
$journal{role_id} = NGCP::Panel::Utils::UserRole::resolve_role_id($c, $c->user);
$journal{tx_id} = $c->stash->{api_request_tx_id};
try {
$journal{content} = _serialize_content($content_format, $resource);
#my $test = 1 / 0;
} catch($e) {
$c->log->error("Failed to serialize journal content snapshot of '" . $params->{resource_name} . '/' . $id . "': $e");
#$controller->error($c, HTTP_INTERNAL_SERVER_ERROR, "Internal Server Error.");
#return;
last;
};
try {
return $c->model('DB')->resultset('journals')->create(\%journal);
} catch($e) {
$c->log->error("Failed to create journal item for '" . $params->{resource_name} . '/' . $id . "': $e");
#$controller->error($c, HTTP_INTERNAL_SERVER_ERROR, "Internal Server Error.");
#return;
last;
};
}
return;
}
1;