From a5ea08df5b0a8de8125cb05d309dbae2ea0aca43 Mon Sep 17 00:00:00 2001
From: Andrew Isherwood <andrew.isherwood@ptfs-europe.com>
Date: Wed, 16 Oct 2019 16:02:29 +0100
Subject: [PATCH] Bug 23838: Add 'renewals' API route

This patch adds a /patrons/x/renewals API GET route which returns a patron's
renewals, optionally filtered by item_id. Tests are also included

Sponsored-by: Loughborough University

Signed-off-by: Myka Kennedy Stephens <mkstephens@lancasterseminary.edu>

Signed-off-by: Owen Leonard <oleonard@myacpl.org>
Signed-off-by: Bouzid Fergani <bouzid.fergani@inlibro.com>
---
 Koha/REST/V1/Patrons/Renewals.pm                | 162 ++++++++++++++++++++++++
 api/v1/swagger/definitions.json                 |   3 +
 api/v1/swagger/definitions/patron_renewal.json  |  23 ++++
 api/v1/swagger/definitions/patron_renewals.json |   6 +
 api/v1/swagger/paths.json                       |   3 +
 api/v1/swagger/paths/patrons_renewals.json      |  90 +++++++++++++
 t/db_dependent/api/v1/patrons_renewals.t        | 160 +++++++++++++++++++++++
 7 files changed, 447 insertions(+)
 create mode 100644 Koha/REST/V1/Patrons/Renewals.pm
 create mode 100644 api/v1/swagger/definitions/patron_renewal.json
 create mode 100644 api/v1/swagger/definitions/patron_renewals.json
 create mode 100644 api/v1/swagger/paths/patrons_renewals.json
 create mode 100644 t/db_dependent/api/v1/patrons_renewals.t

diff --git a/Koha/REST/V1/Patrons/Renewals.pm b/Koha/REST/V1/Patrons/Renewals.pm
new file mode 100644
index 0000000..9432bc6
--- /dev/null
+++ b/Koha/REST/V1/Patrons/Renewals.pm
@@ -0,0 +1,162 @@
+package Koha::REST::V1::Patrons::Renewals;
+
+# This file is part of Koha.
+#
+# Koha is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3 of the License, or (at your option) any later
+# version.
+#
+# Koha is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with Koha; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+use Modern::Perl;
+
+use Mojo::Base 'Mojolicious::Controller';
+
+use Koha::ActionLogs;
+use Koha::Patrons;
+use Koha::DateUtils qw/ output_pref /;
+
+=head1 NAME
+
+Koha::REST::V1::Patrons::Renewals
+
+=head1 API
+
+=head2 Methods
+
+=head3 get
+
+Controller function that handles retrieving a patron's renewals, optionally
+limiting by item id
+
+=cut
+
+sub get {
+    my $c = shift->openapi->valid_input or return;
+
+    my $patron_id = $c->validation->param('patron_id');
+    unless ($patron_id) {
+        return $c->render(
+            status => 400,
+            openapi => { error => "Patron ID must be specified." }
+        );
+    }
+
+    my $item_id = $c->validation->param('item_id');
+    # Create a hash where all keys are embedded values
+    # Enables easy checking
+    my $args = $c->req->params->to_hash // {};
+    my %embed;
+    my $args_arr = (ref $args->{embed} eq 'ARRAY') ? $args->{embed} : [ $args->{embed} ];
+    if (defined $args->{embed}) {
+        %embed = map { $_ => 1 }  @{$args_arr};
+        delete $args->{embed};
+    }
+
+    # Fetch renewals
+    my $where = {
+        module => 'CIRCULATION',
+        action => 'RENEWAL',
+        object => $patron_id
+    };
+    $where->{info} = $item_id if $item_id;
+    my $renewals = Koha::ActionLogs->search($where);
+
+    # Now fetch all users associated with these renewals, so we can
+    # embed their details in the response, if appropriate
+    my $users = {};
+    if ($embed{renewed_by} || $embed{patron}) {
+        my $unique_bn = {};
+        foreach my $renewal( @{ $renewals->as_list } ) {
+            $unique_bn->{$renewal->user} = 1;
+            $unique_bn->{$renewal->object} = 1;
+        }
+        my @borrowernumbers = keys %{$unique_bn};
+        my $users_rs = Koha::Patrons->search({
+            borrowernumber => { 'in' => \@borrowernumbers }
+        });
+        foreach my $user( @{ $users_rs->as_list } ) {
+            $users->{$user->borrowernumber} = $user;
+        }
+    }
+
+    # Prepare the response
+    my @to_return = ();
+    foreach my $return( @{$renewals->as_list} ) {
+        # Map property names
+        $return = _to_api( $return->TO_JSON );
+        # Correctly format the timestamp
+        $return->{timestamp} = output_pref({
+            str => $return->{timestamp},
+            dateonly => 1
+        });
+        # Embed as appropriate
+        if ($embed{renewed_by}) {
+            # If this was a system renewal, create a fake patron to
+            # return containing basic details
+            if ($return->{renewed_by_id} == 0) {
+                $return->{renewed_by} = {
+                    firstname => 'System',
+                    surname  => ''
+                };
+            } else {
+                $return->{renewed_by} = $users->{$return->{renewed_by_id}};
+            }
+        }
+        if ($embed{patron}) {
+            $return->{patron} = $users->{$return->{patron_id}};
+        }
+        push @to_return, $return;
+    }
+
+    return $c->render( status => 200, openapi => \@to_return );
+}
+
+=head3 _to_api
+
+Helper function that maps Koha::ActionLog objects
+into REST API attribute names.
+
+=cut
+
+sub _to_api {
+    my $action_log = shift;
+
+    # Rename attributes
+    foreach my $column (
+        keys %{ $Koha::REST::V1::Patrons::Renewals::to_api_mapping }
+    ) {
+        my $mapped_column =
+            $Koha::REST::V1::Patrons::Renewals::to_api_mapping->{$column};
+        if (exists $action_log->{ $column } && defined $mapped_column ) {
+            $action_log->{ $mapped_column } = delete $action_log->{ $column };
+        } elsif (exists $action_log->{ $column } && !defined $mapped_column ) {
+            delete $action_log->{ $column };
+        }
+    }
+
+    return $action_log;
+}
+
+=head2 Global variables
+
+=head3 $to_api_mapping
+
+=cut
+
+our $to_api_mapping = {
+    timestamp => 'timestamp',
+    user      => 'renewed_by_id',
+    object    => 'patron_id',
+    info      => 'item_id',
+    interface => 'interface'
+};
+
+1;
diff --git a/api/v1/swagger/definitions.json b/api/v1/swagger/definitions.json
index 9037414..66a0b65 100644
--- a/api/v1/swagger/definitions.json
+++ b/api/v1/swagger/definitions.json
@@ -35,6 +35,9 @@
   "patron_balance": {
     "$ref": "definitions/patron_balance.json"
   },
+  "patron_renewals": {
+    "$ref": "definitions/patron_renewals.json"
+  },
   "allows_renewal": {
     "$ref": "definitions/allows_renewal.json"
   },
diff --git a/api/v1/swagger/definitions/patron_renewal.json b/api/v1/swagger/definitions/patron_renewal.json
new file mode 100644
index 0000000..eacb83c
--- /dev/null
+++ b/api/v1/swagger/definitions/patron_renewal.json
@@ -0,0 +1,23 @@
+{
+  "type": "object",
+  "properties": {
+    "timestamp": {
+      "type": "string",
+      "description": "action timestamp"
+    },
+    "user": {
+      "$ref": "../x-primitives.json#/patron_id"
+    },
+    "object": {
+      "$ref": "../x-primitives.json#/patron_id"
+    },
+    "info": {
+      "type": "integer",
+      "description": "Item ID"
+    },
+    "interface": {
+      "type": ["string"],
+      "description": "The interface in which the renewal took place"
+    }
+  }
+}
diff --git a/api/v1/swagger/definitions/patron_renewals.json b/api/v1/swagger/definitions/patron_renewals.json
new file mode 100644
index 0000000..4d94b66
--- /dev/null
+++ b/api/v1/swagger/definitions/patron_renewals.json
@@ -0,0 +1,6 @@
+{
+  "type": "array",
+  "items": {
+    "$ref": "patron_renewal.json"
+  }
+}
diff --git a/api/v1/swagger/paths.json b/api/v1/swagger/paths.json
index 95278ec..e90e140 100644
--- a/api/v1/swagger/paths.json
+++ b/api/v1/swagger/paths.json
@@ -74,6 +74,9 @@
   "/patrons/{patron_id}/password": {
     "$ref": "paths/patrons_password.json#/~1patrons~1{patron_id}~1password"
   },
+  "/patrons/{patron_id}/renewals": {
+    "$ref": "paths/patrons_renewals.json#/~1patrons~1{patron_id}~1renewals"
+  },
   "/illrequests": {
     "$ref": "paths/illrequests.json#/~1illrequests"
   },
diff --git a/api/v1/swagger/paths/patrons_renewals.json b/api/v1/swagger/paths/patrons_renewals.json
new file mode 100644
index 0000000..788acea
--- /dev/null
+++ b/api/v1/swagger/paths/patrons_renewals.json
@@ -0,0 +1,90 @@
+{
+  "/patrons/{patron_id}/renewals": {
+    "get": {
+      "x-mojo-to": "Patrons::Renewals#get",
+      "operationId": "getPatronRenewals",
+      "tags": [
+        "patron"
+      ],
+      "parameters": [
+        {
+          "$ref": "../parameters.json#/patron_id_pp"
+        },
+        {
+            "name": "item_id",
+            "in": "query",
+            "required": false,
+            "description": "An optional item ID",
+            "type": "integer"
+        },
+        {
+            "name": "embed",
+            "in": "query",
+            "description": "Additional objects that should be embedded in the response",
+            "required": false,
+            "type": "array",
+            "collectionFormat": "csv",
+            "items": {
+                "type": "string",
+                "enum": [
+                    "patron",
+                    "renewed_by"
+                ]
+            }
+        }
+      ],
+      "produces": [
+        "application/json"
+      ],
+      "responses": {
+        "200": {
+          "description": "Patron's renewals",
+          "schema": {
+            "$ref": "../definitions.json#/patron_renewals"
+          }
+        },
+        "400": {
+          "description": "Bad request",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "401": {
+          "description": "Authentication required",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "403": {
+          "description": "Access forbidden",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "404": {
+          "description": "Patron not found",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "500": {
+          "description": "Internal server error",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "503": {
+          "description": "Under maintenance",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        }
+      },
+      "x-koha-authorization": {
+        "permissions": {
+          "borrowers": "edit_borrowers"
+        }
+      }
+    }
+  }
+}
diff --git a/t/db_dependent/api/v1/patrons_renewals.t b/t/db_dependent/api/v1/patrons_renewals.t
new file mode 100644
index 0000000..faa286c
--- /dev/null
+++ b/t/db_dependent/api/v1/patrons_renewals.t
@@ -0,0 +1,160 @@
+#!/usr/bin/env perl
+
+# This file is part of Koha.
+#
+# Koha is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3 of the License, or (at your option) any later
+# version.
+#
+# Koha is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with Koha; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+use Modern::Perl;
+
+use Test::More tests => 1;
+use Test::MockModule;
+use Test::MockObject;
+use Test::Mojo;
+use Test::Warn;
+
+use t::lib::TestBuilder;
+use t::lib::Mocks;
+
+use C4::Auth;
+use Koha::ActionLogs;
+use Koha::DateUtils qw( format_sqldatetime );
+
+my $schema  = Koha::Database->new->schema;
+my $builder = t::lib::TestBuilder->new;
+
+t::lib::Mocks::mock_preference( 'SessionStorage', 'tmp' );
+
+my $remote_address = '127.0.0.1';
+my $t              = Test::Mojo->new('Koha::REST::V1');
+
+subtest 'list() tests' => sub {
+
+    plan tests => 24;
+
+    $schema->storage->txn_begin;
+
+    Koha::ActionLogs->search->delete;
+    # borrowers => 4 (userflags.sql)
+    my ( $borrowernumber, $session_id ) = create_user_and_session({ authorized => 4 });
+
+    ## Authorized user tests
+    my $patron  = $builder->build_object( { class => 'Koha::Patrons' } );
+    # No logs, so empty array should be returned
+    my $tx = $t->ua->build_tx( GET => '/api/v1/patrons/999/renewals');
+    $tx->req->cookies( { name => 'CGISESSID', value => $session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)->status_is(200)->json_is( [] );
+
+    # Create log entries
+    my $log1 = $builder->build_object(
+        {
+            class => 'Koha::ActionLogs',
+            value => {
+                module    => 'CIRCULATION',
+                action    => 'RENEWAL',
+                user      => $borrowernumber, # Renewing staff member
+                object    => $patron->borrowernumber, # Patron
+                info      => 101, # Item ID
+                interface => 'opac' # Renewing interface
+            }
+        }
+    );
+    my $log2 = $builder->build_object(
+        {
+            class => 'Koha::ActionLogs',
+            value => {
+                module    => 'CIRCULATION',
+                action    => 'RENEWAL',
+                user      => $borrowernumber, # Renewing staff member
+                object    => $patron->borrowernumber, # Patron
+                info      => 102, # Item ID
+                interface => 'opac' # Renewing interface
+            }
+        }
+    );
+    # Getting all log entries for user
+    $tx = $t->ua->build_tx( GET => '/api/v1/patrons/'.$patron->borrowernumber.'/renewals');
+    $tx->req->cookies( { name => 'CGISESSID', value => $session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)->status_is(200)->json_is( '/0/patron_id' => $patron->borrowernumber );
+    $t->request_ok($tx)->status_is(200)->json_is( '/1/patron_id' => $patron->borrowernumber );
+
+    # Get log entries filtered by item ID
+    $tx = $t->ua->build_tx( GET => '/api/v1/patrons/'.$patron->borrowernumber.'/renewals?item_id=102');
+    $tx->req->cookies( { name => 'CGISESSID', value => $session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)->status_is(200)->json_is( '/0/item_id' => 102 );
+
+    # Get logs with patron embedded
+    Koha::ActionLogs->search->delete;
+    my $embed_patron = $builder->build_object({ class => 'Koha::Patrons' });
+    my $log_patron = $builder->build_object(
+        {
+            class => 'Koha::ActionLogs',
+            value => {
+                module    => 'CIRCULATION',
+                action    => 'RENEWAL',
+                object    => $embed_patron->borrowernumber, # Patron
+                user      => $embed_patron->borrowernumber, # Renewing staff member
+            }
+        }
+    );
+    # Get log entry including patron embed
+    $tx = $t->ua->build_tx( GET => '/api/v1/patrons/'.$embed_patron->borrowernumber.'/renewals?embed=patron');
+    $tx->req->cookies( { name => 'CGISESSID', value => $session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)->status_is(200)->json_is( '/0/patron/borrowernumber' => $embed_patron->borrowernumber );
+    # Get log entry including renewed_by embed
+    $tx = $t->ua->build_tx( GET => '/api/v1/patrons/'.$embed_patron->borrowernumber.'/renewals?embed=renewed_by');
+    $tx->req->cookies( { name => 'CGISESSID', value => $session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)->status_is(200)->json_is( '/0/renewed_by/borrowernumber' => $embed_patron->borrowernumber );
+    # Get log entry including both embeds
+    $tx = $t->ua->build_tx( GET => '/api/v1/patrons/'.$embed_patron->borrowernumber.'/renewals?embed=patron,renewed_by');
+    $tx->req->cookies( { name => 'CGISESSID', value => $session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)->status_is(200)->json_is( '/0/patron/borrowernumber' => $embed_patron->borrowernumber );
+    $t->request_ok($tx)->status_is(200)->json_is( '/0/renewed_by/borrowernumber' => $embed_patron->borrowernumber );
+
+    $schema->storage->txn_rollback;
+};
+
+sub create_user_and_session {
+
+    my $args = shift;
+    my $dbh  = C4::Context->dbh;
+
+    my $flags = ( $args->{authorized} ) ? 2**$args->{authorized} : 0;
+
+    my $user = $builder->build(
+        {
+            source => 'Borrower',
+            value  => {
+                flags => $flags
+            }
+        }
+    );
+
+    # Create a session for the authorized user
+    my $session = C4::Auth::get_session('');
+    $session->param( 'number',   $user->{borrowernumber} );
+    $session->param( 'id',       $user->{userid} );
+    $session->param( 'ip',       '127.0.0.1' );
+    $session->param( 'lasttime', time() );
+    $session->flush;
+
+    return ( $user->{borrowernumber}, $session->id );
+}
+
+1;
-- 
2.7.4