Skip to content

Commit

Permalink
patron_types: implements checkout limit restriction.
Browse files Browse the repository at this point in the history
Implements the items_checkout limit restriction. When a patron tries a
checkout, the server firstly check the ITEM_ON_LOAN items linked to this
patron and determine if the new checkout can be allowed related to
checkout limits define in the linked patron_type.

Co-Authored-by: Renaud Michotte <renaud.michotte@gmail.com>
  • Loading branch information
zannkukai committed Nov 5, 2020
1 parent 0f65640 commit a711be0
Show file tree
Hide file tree
Showing 5 changed files with 235 additions and 7 deletions.
2 changes: 1 addition & 1 deletion data/patron_types.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"limits": {
"checkout_limits": {
"global_limit": 10,
"library_limit": 5,
"library_limit": 8,
"library_exceptions": [
{
"library": {
Expand Down
23 changes: 23 additions & 0 deletions rero_ils/modules/loans/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from operator import attrgetter

import ciso8601
from elasticsearch_dsl import A
from flask import current_app
from invenio_circulation.errors import MissingRequiredParameterError
from invenio_circulation.pidstore.fetchers import loan_pid_fetcher
Expand Down Expand Up @@ -650,6 +651,28 @@ def get_last_transaction_loc_for_item(item_pid):
return None


def get_loans_count_by_library_for_patron_pid(patron_pid, filter_states=None):
"""Get loans count for patron and aggregate result on library_pid.
:param patron_pid: The patron pid
:param filter_states: loans type to filters
:return: a dict with library_pid as key, number of loans as value
"""
filter_states = filter_states or [] # prevent mutable argument warning
agg = A('terms', field='library_pid')
search = search_by_patron_item_or_document(
patron_pid=patron_pid,
filter_states=filter_states
)
search.aggs.bucket('library', agg)
search = search[0:0]
results = search.execute()
stats = {}
for result in results.aggregations.library.buckets:
stats[result.key] = result.doc_count
return stats


def get_due_soon_loans():
"""Return all due_soon loans."""
due_soon_loans = []
Expand Down
58 changes: 53 additions & 5 deletions rero_ils/modules/patron_types/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
from ..api import IlsRecord, IlsRecordsIndexer, IlsRecordsSearch
from ..circ_policies.api import CircPoliciesSearch
from ..fetchers import id_fetcher
from ..loans.api import get_overdue_loan_pids
from ..loans.api import LoanState, get_loans_count_by_library_for_patron_pid, \
get_overdue_loan_pids
from ..minters import id_minter
from ..patrons.api import Patron, PatronsSearch
from ..patrons.utils import get_patron_from_arguments
Expand Down Expand Up @@ -144,7 +145,11 @@ def allow_checkout(cls, item, **kwargs):

patron_type = PatronType.get_record_by_pid(patron.patron_type_pid)
if not patron_type.check_overdue_items_limit(patron):
return False, ['Patron has too much overdue items']
return False, [_('Checkout denied: the maximal number of overdue '
'items is reached')]
valid, message = patron_type.check_checkout_count_limit(patron, item)
if not valid:
return False, [message]

return True, []

Expand Down Expand Up @@ -201,11 +206,10 @@ def reasons_not_to_delete(self):
return cannot_delete

# CHECK LIMITS METHODS ====================================================

def check_overdue_items_limit(self, patron):
"""Check if a patron reaches the overdue items limit.
"""Check if a patron reached the overdue items limit.
:param patron: the patron to check.
:param patron: the patron who tries to execute the checkout.
:return False if patron has more overdue items than defined limit. True
in all other cases.
"""
Expand All @@ -216,6 +220,50 @@ def check_overdue_items_limit(self, patron):
return limit > len(overdue_items)
return True

def check_checkout_count_limit(self, patron, item):
"""Check if a patron reached the checkout limits.
* check the global general limit (if exists).
* check the library exception limit (if exists).
* check the library default limit (if exists).
:param patron: the patron who tries to execute the checkout.
:param item: the item related to the loan.
:return a tuple of two values ::
- True|False : to know if the check is success or not.
- message(string) : the reason why the check fails.
"""
checkout_limits = self.replace_refs().get('limits', {})\
.get('checkout_limits', {})
general_limit = checkout_limits.get('global_limit')
if not general_limit:
return True, None

# [0] get the stats fr this patron by library
patron_library_stats = get_loans_count_by_library_for_patron_pid(
patron.pid, [LoanState.ITEM_ON_LOAN])

# [1] check the general limit
patron_total_count = sum(patron_library_stats.values()) or 0
if patron_total_count >= general_limit:
return False, _('Checkout denied: the maximal checkout number '
'is reached.')

# [3] check library_limit
item_library_pid = item.library_pid
library_limit_value = checkout_limits.get('library_limit')
# try to find an exception rule for this library
for exception in checkout_limits.get('library_exceptions', []):
if exception['library']['pid'] == item_library_pid:
library_limit_value = exception['value']
break
if library_limit_value and item_library_pid in patron_library_stats:
if patron_library_stats[item_library_pid] >= library_limit_value:
return False, _('Checkout denied: the maximal checkout number '
'of items for this library is reached.')

# [4] no problem detected, checkout is allowed
return True, None


class PatronTypesIndexer(IlsRecordsIndexer):
"""Holdings indexing class."""
Expand Down
157 changes: 157 additions & 0 deletions tests/api/loans/test_loans_limits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# -*- coding: utf-8 -*-
#
# RERO ILS
# Copyright (C) 2019 RERO
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, version 3 of the License.
#
# This program 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""Loan Record limits."""
from copy import deepcopy

from invenio_accounts.testutils import login_user_via_session
from utils import postdata

from rero_ils.modules.loans.api import LoanAction
from rero_ils.modules.patron_types.api import PatronType
from rero_ils.modules.utils import get_ref_for_pid


def test_loans_limits_checkout_library_limits(
client, app, librarian_martigny_no_email, lib_martigny,
patron_type_children_martigny, item_lib_martigny, item2_lib_martigny,
item3_lib_martigny, item_lib_martigny_data, item2_lib_martigny_data,
item3_lib_martigny_data, loc_public_martigny, patron_martigny_no_email,
circ_policy_short_martigny):
"""Test checkout library limits."""

patron = patron_martigny_no_email
item2_original_data = deepcopy(item2_lib_martigny_data)
item3_original_data = deepcopy(item3_lib_martigny_data)
item1 = item_lib_martigny
item2 = item2_lib_martigny
item3 = item3_lib_martigny
library_ref = get_ref_for_pid('lib', lib_martigny.pid)
location_ref = get_ref_for_pid('loc', loc_public_martigny.pid)

login_user_via_session(client, librarian_martigny_no_email.user)

# Update fixtures for the tests
# * Update the patron_type to set a checkout limits
# * All items are linked to the same library/location
patron_type = patron_type_children_martigny
patron_type['limits'] = {
'checkout_limits': {
'global_limit': 3,
'library_limit': 2,
'library_exceptions': [{
'library': {'$ref': library_ref},
'value': 1
}]
}
}
patron_type.update(patron_type, dbcommit=True, reindex=True)
patron_type = PatronType.get_record_by_pid(patron_type.pid)
item2_lib_martigny_data['location']['$ref'] = location_ref
item2.update(item2_lib_martigny_data, dbcommit=True, reindex=True)
item3_lib_martigny_data['location']['$ref'] = location_ref
item3.update(item3_lib_martigny_data, dbcommit=True, reindex=True)

# First checkout - All should be fine.
res, data = postdata(client, 'api_item.checkout', dict(
item_pid=item1.pid,
patron_pid=patron.pid,
transaction_location_pid=loc_public_martigny.pid,
transaction_user_pid=librarian_martigny_no_email.pid,
))
assert res.status_code == 200
loan1_pid = data.get('action_applied')[LoanAction.CHECKOUT].get('pid')

# Second checkout
# --> The library limit exception should be raised.
res, data = postdata(client, 'api_item.checkout', dict(
item_pid=item2.pid,
patron_pid=patron.pid,
transaction_location_pid=loc_public_martigny.pid,
transaction_user_pid=librarian_martigny_no_email.pid,
))
assert res.status_code == 403
assert 'Checkout denied' in data['message']

# remove the library specific exception and try a new checkout
# --> As the limit by library is now '2', the checkout will be done.
# --> Try a third checkout : the default library_limit exception should
# be raised
patron_type['limits'] = {
'checkout_limits': {
'global_limit': 3,
'library_limit': 2,
}
}
patron_type.update(patron_type, dbcommit=True, reindex=True)
res, data = postdata(client, 'api_item.checkout', dict(
item_pid=item2.pid,
patron_pid=patron.pid,
transaction_location_pid=loc_public_martigny.pid,
transaction_user_pid=librarian_martigny_no_email.pid,
))
assert res.status_code == 200
loan2_pid = data.get('action_applied')[LoanAction.CHECKOUT].get('pid')
res, data = postdata(client, 'api_item.checkout', dict(
item_pid=item3.pid,
patron_pid=patron.pid,
transaction_location_pid=loc_public_martigny.pid,
transaction_user_pid=librarian_martigny_no_email.pid,
))
assert res.status_code == 403
assert 'Checkout denied' in data['message']

# remove the library default limit and update the global_limit to 2.
# --> try the third checkout : the global_limit exception should now be
# raised
patron_type['limits'] = {
'checkout_limits': {
'global_limit': 2
}
}
patron_type.update(patron_type, dbcommit=True, reindex=True)
res, data = postdata(client, 'api_item.checkout', dict(
item_pid=item3.pid,
patron_pid=patron.pid,
transaction_location_pid=loc_public_martigny.pid,
transaction_user_pid=librarian_martigny_no_email.pid,
))
assert res.status_code == 403
assert 'Checkout denied' in data['message']

# reset fixtures
# --> checkin both loaned item
# --> reset patron_type to original value
# --> reset items to original values
res, data = postdata(client, 'api_item.checkin', dict(
item_pid=item2.pid,
pid=loan2_pid,
transaction_location_pid=loc_public_martigny.pid,
transaction_user_pid=librarian_martigny_no_email.pid,
))
assert res.status_code == 200
res, data = postdata(client, 'api_item.checkin', dict(
item_pid=item1.pid,
pid=loan1_pid,
transaction_location_pid=loc_public_martigny.pid,
transaction_user_pid=librarian_martigny_no_email.pid,
))
assert res.status_code == 200
del patron_type['limits']
patron_type.update(patron_type, dbcommit=True, reindex=True)
item2.update(item2_original_data, dbcommit=True, reindex=True)
item3.update(item3_original_data, dbcommit=True, reindex=True)
2 changes: 1 addition & 1 deletion tests/api/loans/test_loans_rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ def test_overdue_loans(client, librarian_martigny_no_email,
)
)
assert res.status_code == 403
assert data['message'] == 'Patron has too much overdue items'
assert 'Checkout denied' in data['message']

# Try a checkout for a blocked user :: It should be blocked
res, data = postdata(
Expand Down

0 comments on commit a711be0

Please sign in to comment.