diff --git a/CHANGELOG.md b/CHANGELOG.md index dc71d5c..def0371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ # Next Release +# v0.9.4 + +* **Admin Feature:** Report of transaction data graphs +* **Fix:** Mobile view meta tag for admin +* Upgrade all CSS to Bootstrap 4 beta +* **Admin Feature:** Added version number to admin console + # v0.9.3 * **Feature:** lat/long locations on customers and organisations diff --git a/lib/Pear/LocalLoop.pm b/lib/Pear/LocalLoop.pm index 8bdb811..b6f6fcf 100644 --- a/lib/Pear/LocalLoop.pm +++ b/lib/Pear/LocalLoop.pm @@ -21,12 +21,15 @@ has schema => sub { sub startup { my $self = shift; + my $version = `git describe --tags`; + $self->plugin('Config', { default => { storage_path => tempdir, sessionTimeSeconds => 60 * 60 * 24 * 7, sessionTokenJsonName => 'session_key', sessionExpiresJsonName => 'sessionExpires', + version => $version, }, }); my $config = $self->config; @@ -36,6 +39,7 @@ sub startup { $self->plugin('Pear::LocalLoop::Plugin::BootstrapPagination', { bootstrap4 => 1 } ); $self->plugin('Pear::LocalLoop::Plugin::Validators'); $self->plugin('Pear::LocalLoop::Plugin::Datetime'); + $self->plugin('Pear::LocalLoop::Plugin::TemplateHelpers'); $self->plugin('Authentication' => { 'load_user' => sub { @@ -192,6 +196,8 @@ sub startup { $admin_routes->get('/transactions/:id/image')->to('admin-transactions#image'); $admin_routes->post('/transactions/:id/delete')->to('admin-transactions#delete'); + $admin_routes->get('/reports/transactions')->to('admin-reports#transaction_data'); + # my $user_routes = $r->under('/')->to('root#under'); # $user_routes->get('/home')->to('root#home'); diff --git a/lib/Pear/LocalLoop/Controller/Admin.pm b/lib/Pear/LocalLoop/Controller/Admin.pm index 8030d59..7a9cf8d 100644 --- a/lib/Pear/LocalLoop/Controller/Admin.pm +++ b/lib/Pear/LocalLoop/Controller/Admin.pm @@ -7,7 +7,7 @@ sub under { if ( $c->is_user_authenticated ) { return 1 if $c->current_user->is_admin; } - $c->redirect_to('/'); + $c->redirect_to('/admin'); return 0; } diff --git a/lib/Pear/LocalLoop/Controller/Admin/Feedback.pm b/lib/Pear/LocalLoop/Controller/Admin/Feedback.pm index f9063a4..753539c 100644 --- a/lib/Pear/LocalLoop/Controller/Admin/Feedback.pm +++ b/lib/Pear/LocalLoop/Controller/Admin/Feedback.pm @@ -9,8 +9,15 @@ has result_set => sub { sub index { my $c = shift; - my $feedback_rs = $c->result_set; - $c->stash( feedbacks => [ $feedback_rs->all ] ); + my $feedback_rs = $c->result_set->search( + undef, + { + page => $c->param('page') || 1, + rows => 12, + order_by => { -desc => 'submitted_at' }, + }, + ); + $c->stash( feedback_rs => $feedback_rs ); } sub read { diff --git a/lib/Pear/LocalLoop/Controller/Admin/Reports.pm b/lib/Pear/LocalLoop/Controller/Admin/Reports.pm new file mode 100644 index 0000000..aff6bc9 --- /dev/null +++ b/lib/Pear/LocalLoop/Controller/Admin/Reports.pm @@ -0,0 +1,80 @@ +package Pear::LocalLoop::Controller::Admin::Reports; +use Mojo::Base 'Mojolicious::Controller'; + +use Mojo::JSON qw/ encode_json /; + +sub transaction_data { + my $c = shift; + + my $quantised_column = 'quantised_hours'; + if ( defined $c->param('scale') && $c->param('scale') eq 'days' ) { + $quantised_column = 'quantised_days'; + } + + my $driver = $c->schema->storage->dbh->{Driver}->{Name}; + my $transaction_rs = $c->schema->resultset('ViewQuantisedTransaction' . $driver)->search( + {}, + { + columns => [ + { + quantised => $quantised_column, + count => \"COUNT(*)", + sum_distance => $c->pg_or_sqlite( + 'SUM("me"."distance")', + 'SUM("me"."distance")', + ), + average_distance => $c->pg_or_sqlite( + 'AVG("me"."distance")', + 'AVG("me"."distance")', + ), + sum_value => $c->pg_or_sqlite( + 'SUM("me"."value")', + 'SUM("me"."value")', + ), + average_value => $c->pg_or_sqlite( + 'AVG("me"."value")', + 'AVG("me"."value")', + ), + } + ], + group_by => $quantised_column, + order_by => { '-asc' => $quantised_column }, + } + ); + + my $transaction_data = [ + map{ + my $quantised = $c->db_datetime_parser->parse_datetime($_->get_column('quantised')); + { + sum_value => ($_->get_column('sum_value') || 0) * 1, + sum_distance => ($_->get_column('sum_distance') || 0) * 1, + average_value => ($_->get_column('average_value') || 0) * 1, + average_distance => ($_->get_column('average_distance') || 0) * 1, + count => $_->get_column('count'), + quantised => $c->format_iso_datetime($quantised), + } + } $transaction_rs->all + ]; + + $c->respond_to( + json => { json => { data => $transaction_data } }, + html => { transaction_rs => encode_json( $transaction_data ) }, + ); +} + +sub pg_or_sqlite { + my ( $c, $pg_sql, $sqlite_sql ) = @_; + + my $driver = $c->schema->storage->dbh->{Driver}->{Name}; + + if ( $driver eq 'Pg' ) { + return \$pg_sql; + } elsif ( $driver eq 'SQLite' ) { + return \$sqlite_sql; + } else { + $c->app->log->warn('Unknown Driver Used'); + return undef; + } +} + +1; diff --git a/lib/Pear/LocalLoop/Plugin/Datetime.pm b/lib/Pear/LocalLoop/Plugin/Datetime.pm index c598709..1f79e45 100644 --- a/lib/Pear/LocalLoop/Plugin/Datetime.pm +++ b/lib/Pear/LocalLoop/Plugin/Datetime.pm @@ -6,6 +6,17 @@ use DateTime::Format::Strptime; sub register { my ( $plugin, $app, $conf ) = @_; + $app->helper( human_datetime_parser => sub { + return DateTime::Format::Strptime->new( pattern => '%x %X' ); + }); + + $app->helper( format_human_datetime => sub { + my ( $c, $datetime_obj ) = @_; + return $c->human_datetime_parser->format_datetime( + $datetime_obj, + ); + }); + $app->helper( iso_datetime_parser => sub { return DateTime::Format::Strptime->new( pattern => '%Y-%m-%dT%H:%M:%S.%3N%z' ); }); diff --git a/lib/Pear/LocalLoop/Plugin/TemplateHelpers.pm b/lib/Pear/LocalLoop/Plugin/TemplateHelpers.pm new file mode 100644 index 0000000..87c04d4 --- /dev/null +++ b/lib/Pear/LocalLoop/Plugin/TemplateHelpers.pm @@ -0,0 +1,18 @@ +package Pear::LocalLoop::Plugin::TemplateHelpers; +use Mojo::Base 'Mojolicious::Plugin'; + +sub register { + my ( $plugin, $app, $conf ) = @_; + + $app->helper( truncate_text => sub { + my ( $c, $string, $length ) = @_; + if ( length $string < $length ) { + return $string; + } else { + return substr( $string, 0, $length - 3 ) . '...'; + } + }); + +} + +1; diff --git a/lib/Pear/LocalLoop/Schema/Result/ViewQuantisedTransactionPg.pm b/lib/Pear/LocalLoop/Schema/Result/ViewQuantisedTransactionPg.pm new file mode 100644 index 0000000..fabbd38 --- /dev/null +++ b/lib/Pear/LocalLoop/Schema/Result/ViewQuantisedTransactionPg.pm @@ -0,0 +1,21 @@ +package Pear::LocalLoop::Schema::Result::ViewQuantisedTransactionPg; + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; + +__PACKAGE__->table_class('DBIx::Class::ResultSource::View'); +__PACKAGE__->table('view_quantised_transactions'); +__PACKAGE__->result_source_instance->is_virtual(1); + +__PACKAGE__->result_source_instance->view_definition( qq/ +SELECT "value", + "distance", + "purchase_time", + DATE_TRUNC('hour', "purchase_time") AS "quantised_hours", + DATE_TRUNC('day', "purchase_time") AS "quantised_days" + FROM "transactions" +/); + +1; diff --git a/lib/Pear/LocalLoop/Schema/Result/ViewQuantisedTransactionSQLite.pm b/lib/Pear/LocalLoop/Schema/Result/ViewQuantisedTransactionSQLite.pm new file mode 100644 index 0000000..2ce3aac --- /dev/null +++ b/lib/Pear/LocalLoop/Schema/Result/ViewQuantisedTransactionSQLite.pm @@ -0,0 +1,21 @@ +package Pear::LocalLoop::Schema::Result::ViewQuantisedTransactionSQLite; + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; + +__PACKAGE__->table_class('DBIx::Class::ResultSource::View'); +__PACKAGE__->table('view_quantised_transactions'); +__PACKAGE__->result_source_instance->is_virtual(1); + +__PACKAGE__->result_source_instance->view_definition( qq/ +SELECT "value", + "distance", + "purchase_time", + DATETIME(STRFTIME('%Y-%m-%d %H:00:00',"purchase_time")) AS "quantised_hours", + DATETIME(STRFTIME('%Y-%m-%d 00:00:00',"purchase_time")) AS "quantised_days" + FROM "transactions" +/); + +1; diff --git a/public/static/admin/css/main.css b/public/static/admin/css/main.css index 6ca4612..b2e6e97 100644 --- a/public/static/admin/css/main.css +++ b/public/static/admin/css/main.css @@ -1,6 +1,7 @@ body { background: whitesmoke; padding-top: 70px; + padding-bottom: 70px; } .panel { diff --git a/t/admin/reports/transactions.t b/t/admin/reports/transactions.t new file mode 100644 index 0000000..8269c5c --- /dev/null +++ b/t/admin/reports/transactions.t @@ -0,0 +1,141 @@ +use Mojo::Base -strict; + +use FindBin qw/ $Bin /; + +use Test::More; +use Test::Pear::LocalLoop; +use DateTime; + +my $framework = Test::Pear::LocalLoop->new( + etc_dir => "$Bin/../../etc", +); +$framework->install_fixtures('full'); +my $t = $framework->framework; +my $schema = $t->app->schema; + +my $dt_today = DateTime->today; +my $dt_start = $dt_today->clone->subtract( 'minutes' => 30 ); + +use Devel::Dwarn; + +my $session_key = $framework->login({ + email => 'test1@example.com', + password => 'abc123', +}); + +sub create_transaction { + my ( $value, $time ) = @_; + $t->ua->post('/api/upload' => json => { + transaction_value => $value, + transaction_type => 1, + purchase_time => $time, + organisation_id => 1, + session_key => $session_key, + }); +} + +my $expected_days = {}; +my $expected_hours = {}; + +sub increment_day { + my ( $value, $day, $distance ) = @_; + $value *= 100000; + $distance //= 0; + $expected_days->{$day} = { + quantised => $day, + sum_value => ($expected_days->{$day}->{sum_value} || 0) + $value, + sum_distance => ($expected_days->{$day}->{sum_distance} || 0) + $distance, + count => ++$expected_days->{$day}->{count}, + }; +} + +sub increment_hour { + my ( $value, $day, $distance ) = @_; + $value *= 100000; + $distance //= 0; + $expected_hours->{$day} = { + quantised => $day, + sum_value => ($expected_hours->{$day}->{sum_value} || 0) + $value, + sum_distance => ($expected_hours->{$day}->{sum_distance} || 0) + $distance, + count => ++$expected_hours->{$day}->{count}, + }; +} + +for my $i ( 0 .. 48 ) { + my $dt = $dt_start->clone->subtract( 'minutes' => 60 * $i ); + my $purchase_time = $t->app->format_iso_datetime($dt); + my $quantised_day = $t->app->format_iso_datetime($dt->clone->truncate(to => 'day')); + my $quantised_hour = $t->app->format_iso_datetime($dt->clone->truncate(to => 'hour')); + create_transaction(10, $purchase_time); + increment_day(10, $quantised_day); + increment_hour(10, $quantised_hour); + if ( $i % 2 == 0 ) { + create_transaction(20, $purchase_time); + increment_day(20, $quantised_day); + increment_hour(20, $quantised_hour); + } + if ( $i % 3 == 0 ) { + create_transaction(30, $purchase_time); + increment_day(30, $quantised_day); + increment_hour(30, $quantised_hour); + } + if ( $i % 5 == 0 ) { + create_transaction(50, $purchase_time); + increment_day(50, $quantised_day); + increment_hour(50, $quantised_hour); + } + if ( $i % 7 == 0 ) { + create_transaction(70, $purchase_time); + increment_day(70, $quantised_day); + increment_hour(70, $quantised_hour); + } +} + +my $expected_days_array = [ map { + my $data = $expected_days->{$_}; + { + quantised => $data->{quantised}, + count => $data->{count}, + sum_value => $data->{sum_value}, + sum_distance => $data->{sum_distance}, + average_value => $data->{sum_value} / $data->{count}, + average_distance => $data->{sum_distance} / $data->{count}, + } +} sort keys %$expected_days ]; + +my $expected_hours_array = [ map { + my $data = $expected_hours->{$_}; + { + quantised => $data->{quantised}, + count => $data->{count}, + sum_value => $data->{sum_value}, + sum_distance => $data->{sum_distance}, + average_value => $data->{sum_value} / $data->{count}, + average_distance => $data->{sum_distance} / $data->{count}, + } +} sort keys %$expected_hours ]; + +is $t->app->schema->resultset('Transaction')->count, 108, 'Transactions created'; + +#login to admin +$t->post_ok('/admin', form => { + email => 'admin@example.com', + password => 'abc123', +})->status_is(302); + +$t->get_ok( + '/admin/reports/transactions', + { Accept => 'application/json' } + ) + ->status_is(200) + ->json_is('/data', $expected_hours_array)->or($framework->dump_error); + +$t->get_ok( + '/admin/reports/transactions', + { Accept => 'application/json' }, + form => { scale => 'days' } + ) + ->status_is(200) + ->json_is('/data', $expected_days_array)->or($framework->dump_error); + +done_testing; diff --git a/templates/admin/feedback/index.html.ep b/templates/admin/feedback/index.html.ep index c25a176..049449c 100644 --- a/templates/admin/feedback/index.html.ep +++ b/templates/admin/feedback/index.html.ep @@ -11,12 +11,30 @@ Success! <%= $success %> % } -
- % for my $feedback (@$feedbacks) { - -
- <%= $feedback->user->email %> <%= $feedback->submitted_at %> + +
+
+ %= bootstrap_pagination( $c->param('page') || 1, $feedback_rs->pager->last_page, { class => 'justify-content-center' } ); +
+
diff --git a/templates/admin/feedback/read.html.ep b/templates/admin/feedback/read.html.ep index 6a5372b..840a35b 100644 --- a/templates/admin/feedback/read.html.ep +++ b/templates/admin/feedback/read.html.ep @@ -11,33 +11,59 @@ Success! <%= $success %>
% } -
-
- - +
+
+
+

+ User Info +

+
    +
  • + Email Address: + <%= $feedback->user->email %> +
  • +
  • + Submitted At: + <%= format_human_datetime $feedback->submitted_at %> +
  • +
+
-
- - +
+
+

+ Feedback Message +

+
+
+
<%= $feedback->feedbacktext %>
+
+
+
-
- - +
+
+

+ Debug Info +

+
    +
  • + App Name: + <%= $feedback->app_name %> +
  • +
  • + Package Name: + <%= $feedback->package_name %> +
  • +
  • + Version Code: + <%= $feedback->version_code %> +
  • +
  • + Version Number: + <%= $feedback->version_number %> +
  • +
+
-
- - -
-
- - -
-
- - -
-
- - -
- +
diff --git a/templates/admin/home.html.ep b/templates/admin/home.html.ep index ebc749b..3f1aa45 100644 --- a/templates/admin/home.html.ep +++ b/templates/admin/home.html.ep @@ -3,41 +3,41 @@ % content_for javascript => begin % end
-
-
+
+
User Count
-
+

%= $user_count

-
-
+
+
Unused Tokens
-
+

<%= $tokens->{unused} %> / <%= $tokens->{total} %>

-
-
+
+
Pending Organisations
-
+

%= $pending_orgs

-
-
+
+
Pending Transactions
-
+

%= $pending_trans

diff --git a/templates/admin/organisations/valid_read.html.ep b/templates/admin/organisations/valid_read.html.ep index 8a0f0e2..ce24858 100644 --- a/templates/admin/organisations/valid_read.html.ep +++ b/templates/admin/organisations/valid_read.html.ep @@ -36,7 +36,7 @@ function initMap() {

%= $valid_org->name

-
+
@@ -93,11 +93,7 @@ function initMap() {
-
- - -
-
+
@@ -106,28 +102,22 @@ function initMap() {

Transactions

- +
+ %= bootstrap_pagination( $c->param('page') || 1, $transactions->pager->last_page, { class => 'justify-content-center' } ); +
+
diff --git a/templates/admin/reports/transaction_data.html.ep b/templates/admin/reports/transaction_data.html.ep new file mode 100644 index 0000000..69c914c --- /dev/null +++ b/templates/admin/reports/transaction_data.html.ep @@ -0,0 +1,140 @@ +% layout 'admin'; +% title 'Transaction Report'; +% content_for javascript => begin + + +% end +% if ( my $error = flash 'error' ) { + +% } elsif ( my $success = flash 'success' ) { + +% } +
+
+ Transaction Count +
+
+
+ Loading... +
+
+
+
+
+ Transaction Distance +
+
+
+ Loading... +
+
+
+
+
+ Transaction Value +
+
+
+ Loading... +
+
+
diff --git a/templates/admin/tokens/index.html.ep b/templates/admin/tokens/index.html.ep index 772463a..80a35da 100644 --- a/templates/admin/tokens/index.html.ep +++ b/templates/admin/tokens/index.html.ep @@ -23,13 +23,9 @@ diff --git a/templates/admin/transactions/index.html.ep b/templates/admin/transactions/index.html.ep index 191f845..5d8e411 100644 --- a/templates/admin/transactions/index.html.ep +++ b/templates/admin/transactions/index.html.ep @@ -11,10 +11,8 @@ Success! <%= $success %>
% } -
+
% for my $transaction ( $transactions->all ) { -
  • - -
  • % } -
  • -
    +
    %= bootstrap_pagination( $c->param('page') || 1, $transactions->pager->last_page, { class => 'justify-content-center' } ); -
    -
  • +
    diff --git a/templates/admin/transactions/read.html.ep b/templates/admin/transactions/read.html.ep index 9eb9596..b00f6b3 100644 --- a/templates/admin/transactions/read.html.ep +++ b/templates/admin/transactions/read.html.ep @@ -12,15 +12,15 @@
    % }
    -

    +

    Transaction Details -
    +
    - +

    -
    +
    @@ -42,8 +42,8 @@
    -
    - +
    +
    diff --git a/templates/admin/users/read.html.ep b/templates/admin/users/read.html.ep index 2970b15..7c93f1f 100644 --- a/templates/admin/users/read.html.ep +++ b/templates/admin/users/read.html.ep @@ -12,88 +12,98 @@
    % }
    -

    - User Details -

    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -

    Leave blank unless you want to change their password

    +
    +

    + User Details +

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +

    Leave blank unless you want to change their password

    +
    +
    +
    % if ( my $customer_rs = $user->entity->customer ) { -

    - Customer Details -

    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - +

    + Customer Details +

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    % } elsif ( my $org_rs = $user->entity->organisation ) { -

    - Organisation Details -

    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - +

    + Organisation Details +

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    % } else { -

    - User is not a customer or an organisation +

    + Warning!

    - % } - -
    - +
    + User is not a customer or an organisation
    + % } +
    +
    + +

    Transactions

    - +
    + %= bootstrap_pagination( $c->param('page') || 1, $transactions->pager->last_page, { class => 'justify-content-center' } ); +
    +
    diff --git a/templates/layouts/admin.html.ep b/templates/layouts/admin.html.ep index 417937d..265f51e 100644 --- a/templates/layouts/admin.html.ep +++ b/templates/layouts/admin.html.ep @@ -2,19 +2,18 @@ + LocalLoop Admin - <%= title %> - + - - - %= stylesheet '/static/admin/css/main.css'; -