Skip to content

Commit

Permalink
patron_type: implements overdue items restriction
Browse files Browse the repository at this point in the history
Implements the overdue_items_limit restriction : If a patron type
resource defines an 'overdue_items_limit', patrons with overdue items
can't execute any checkout operation if the limit is reached.
This commit also refactors the 'blocked_patron' restriction.

Co-Authored-by: Renaud Michotte <renaud.michotte@gmail.com>
  • Loading branch information
zannkukai committed Oct 28, 2020
1 parent 97dfe0e commit 0af1525
Show file tree
Hide file tree
Showing 16 changed files with 372 additions and 111 deletions.
4 changes: 4 additions & 0 deletions rero_ils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2255,6 +2255,10 @@ def _(x):
],
ItemCirculationAction.EXTEND: [
Item.can_extend
],
ItemCirculationAction.CHECKOUT: [
Patron.can_checkout,
PatronType.allow_checkout
]
}

Expand Down
59 changes: 11 additions & 48 deletions rero_ils/modules/items/api/circulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@

from copy import deepcopy
from datetime import datetime, timezone
from functools import wraps

from flask import current_app
from invenio_circulation.api import get_loan_for_item
Expand All @@ -33,6 +32,8 @@
from invenio_records_rest.utils import obj_or_import_string
from invenio_search import current_search

from ..decorators import add_action_parameters_and_flush_indexes, \
check_operation_allowed
from ..models import ItemCirculationAction, ItemStatus
from ..utils import item_pid_to_object
from ...api import IlsRecord
Expand All @@ -47,49 +48,6 @@
from ....filter import format_date_filter


def add_action_parameters_and_flush_indexes(function):
"""Add missing action and validate parameters.
For each circulation action, this method ensures that all required
paramters are given. Adds missing parameters if any. Ensures the right
loan transition for the given action.
"""
@wraps(function)
def wrapper(item, *args, **kwargs):
"""Executed before loan action."""
checkin_loan = None
if function.__name__ == 'validate_request':
# checks if the given loan pid can be validated
item.prior_validate_actions(**kwargs)
elif function.__name__ == 'checkin':
# the smart checkin requires extra checks/actions before a checkin
loan, kwargs = item.prior_checkin_actions(**kwargs)
checkin_loan = loan
# CHECKOUT: Case where no loan PID
elif function.__name__ == 'checkout' and not kwargs.get('pid'):
patron_pid = kwargs['patron_pid']
item_pid = item.pid
request = get_request_by_item_pid_by_patron_pid(
item_pid=item_pid, patron_pid=patron_pid)
if request:
kwargs['pid'] = request.pid
elif function.__name__ == 'extend_loan':
loan, kwargs = item.prior_extend_loan_actions(**kwargs)
checkin_loan = loan

loan, kwargs = item.complete_action_missing_params(
item=item, checkin_loan=checkin_loan, **kwargs)
Loan.check_required_params(loan, function.__name__, **kwargs)

item, action_applied = function(item, loan, *args, **kwargs)

item.change_status_commit_and_reindex_item(item)

return item, action_applied

return wrapper


class ItemCirculation(IlsRecord):
"""Item circulation class."""

Expand Down Expand Up @@ -431,16 +389,20 @@ def compare_item_pickup_transaction_libraries(self, **kwargs):
data['transaction_pickup_libraries'] = True
return data

@check_operation_allowed(ItemCirculationAction.CHECKOUT)
@add_action_parameters_and_flush_indexes
def checkout(self, current_loan, **kwargs):
"""Checkout item to the user."""
action_params, actions = self.prior_checkout_actions(kwargs)
loan = Loan.get_record_by_pid(action_params.get('pid'))
current_loan = loan or Loan.create(action_params,
dbcommit=True,
reindex=True)
current_loan = loan or Loan.create(
action_params,
dbcommit=True,
reindex=True
)
loan = current_circulation.circulation.trigger(
current_loan, **dict(action_params, trigger='checkout')
current_loan,
**dict(action_params, trigger='checkout')
)
actions.update({LoanAction.CHECKOUT: loan})
return self, actions
Expand Down Expand Up @@ -580,6 +542,7 @@ def extend_loan(self, current_loan, **kwargs):
LoanAction.EXTEND: loan
}

@check_operation_allowed(ItemCirculationAction.REQUEST)
@add_action_parameters_and_flush_indexes
def request(self, current_loan, **kwargs):
"""Request item for the user and create notifications."""
Expand Down
11 changes: 2 additions & 9 deletions rero_ils/modules/items/api_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,7 @@ def decorated_view(*args, **kwargs):
# Return error 400 when there is a missing required parameter
abort(400, str(error))
except CirculationException as error:
patron = False
# Detect patron details
if data.get('patron_pid'):
patron = Patron.get_record_by_pid(data.get('patron_pid'))
# Add more info in case of blocked patron (for UI)
if patron and patron.patron.get('blocked') is True:
abort(403, "BLOCKED USER")
abort(403, str(error))
abort(403, error.description or str(error))
except NotFound as error:
raise error
except exceptions.RequestError as error:
Expand Down Expand Up @@ -413,7 +406,7 @@ def can_request(item_pid):
if not kwargs['library']:
abort(404, 'Library not found')

# as to item if the request is possible with these data.
# ask to item if the request is possible with these data.
can, reasons = item.can(ItemCirculationAction.REQUEST, **kwargs)

# check the `reasons_not_request` array. If it's empty, the request is
Expand Down
92 changes: 92 additions & 0 deletions rero_ils/modules/items/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-
#
# RERO ILS
# Copyright (C) 2020 RERO
# Copyright (C) 2020 UCLouvain
#
# 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/>.

"""Decorators about items."""

from functools import wraps

from flask import current_app
from invenio_circulation.errors import CirculationException
from invenio_records_rest.utils import obj_or_import_string

from rero_ils.modules.loans.api import Loan, \
get_request_by_item_pid_by_patron_pid


def add_action_parameters_and_flush_indexes(function):
"""Add missing action and validate parameters.
For each circulation action, this method ensures that all required
parameters are given. Adds missing parameters if any. Ensures the right
loan transition for the given action.
"""
@wraps(function)
def wrapper(item, *args, **kwargs):
"""Executed before loan action."""
checkin_loan = None
if function.__name__ == 'validate_request':
# checks if the given loan pid can be validated
item.prior_validate_actions(**kwargs)
elif function.__name__ == 'checkin':
# the smart checkin requires extra checks/actions before a checkin
loan, kwargs = item.prior_checkin_actions(**kwargs)
checkin_loan = loan
# CHECKOUT: Case where no loan PID
elif function.__name__ == 'checkout' and not kwargs.get('pid'):
patron_pid = kwargs['patron_pid']
item_pid = item.pid
request = get_request_by_item_pid_by_patron_pid(
item_pid=item_pid, patron_pid=patron_pid)
if request:
kwargs['pid'] = request.pid
elif function.__name__ == 'extend_loan':
loan, kwargs = item.prior_extend_loan_actions(**kwargs)
checkin_loan = loan

loan, kwargs = item.complete_action_missing_params(
item=item, checkin_loan=checkin_loan, **kwargs)
Loan.check_required_params(loan, function.__name__, **kwargs)
item, action_applied = function(item, loan, *args, **kwargs)
item.change_status_commit_and_reindex_item(item)
return item, action_applied
return wrapper


def check_operation_allowed(action):
"""Check if a specific action is allowed on an item.
Check the CIRCULATION_ACTIONS_VALIDATION configuration file and execute
function corresponding to the action specified. All function are execute
until one return False (action denied) or all actions are successful.
:param action: the action to check as ItemCirculationAction part.
:raise CirculationException if a function disallow the operation.
"""
def inner_function(func):
@wraps(func)
def decorated_view(*args, **kwargs):
actions = current_app.config.get(
'CIRCULATION_ACTIONS_VALIDATION', {})
for func_name in actions.get(action, []):
func_callback = obj_or_import_string(func_name)
can, reasons = func_callback(args[0], **kwargs)
if not can:
raise CirculationException(description=reasons[0])
return func(*args, **kwargs)
return decorated_view
return inner_function
40 changes: 28 additions & 12 deletions rero_ils/modules/loans/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -665,19 +665,35 @@ def get_due_soon_loans():
return due_soon_loans


def get_overdue_loans():
"""Return all overdue loans."""
overdue_loans = []
results = current_circulation.loan_search_cls()\
.filter('term', state=LoanState.ITEM_ON_LOAN)\
.params(preserve_order=True)\
.sort({'_created': {'order': 'asc'}})\
def get_overdue_loan_pids(patron_pid=None):
"""Return all overdue loan pids optionally filtered for a patron pid.
:param patron_pid: the patron pid. If none, return all overdue loans.
:return a generator of loan pid
"""
end_date = datetime.now()
end_date = end_date.strftime('%Y-%m-%d')
query = current_circulation.loan_search_cls() \
.filter('term', state=LoanState.ITEM_ON_LOAN) \
.filter('range', end_date={'lte': end_date})
if patron_pid:
query = query.filter('term', patron_pid=patron_pid)
results = query\
.params(preserve_order=True) \
.sort({'_created': {'order': 'asc'}}) \
.source(['pid']).scan()
for record in results:
loan = Loan.get_record_by_pid(record.pid)
if is_overdue_loan(loan):
overdue_loans.append(loan)
return overdue_loans
for hit in results:
yield hit.pid


def get_overdue_loans(patron_pid=None):
"""Return all overdue loans optionally filtered for a patron pid.
:param patron_pid: the patron pid. If none, return all overdue loans.
:return a generator of Loan
"""
for pid in get_overdue_loan_pids(patron_pid):
yield Loan.get_record_by_pid(pid)


def is_due_soon_loan(loan):
Expand Down
26 changes: 12 additions & 14 deletions rero_ils/modules/loans/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,6 @@ def get_circ_policy(loan):
patron_type_pid,
holding_circulation_category
)

# checkouts and request are not allowed anymore for blocked patrons
if patron.patron.get('blocked', False):
result.update({
"allow_checkout": False,
"allow_requests": False,
})

return result


Expand Down Expand Up @@ -169,22 +161,28 @@ def can_be_requested(loan):
if not loan.item_pid:
raise Exception('Transaction on document is not implemented.')

# 1) Check if there is already a loan for same patron+item
# 1) Check patron is not blocked
patron = Patron.get_record_by_pid(loan.patron_pid)
if patron.patron.get('blocked', False):
return False

# 2) Check if location allows request
location = Location.get_record_by_pid(loan.location_pid)
if not location or not location.get('allow_request'):
return False

# 3) Check if there is already a loan for same patron+item
if get_any_loans_by_item_pid_by_patron_pid(
loan.get('item_pid', {}).get('value'),
loan.get('patron_pid')
):
return False

# 2) Check if circulation_policy allows request
# 4) Check if circulation_policy allows request
policy = get_circ_policy(loan)
if not policy.get('allow_requests'):
return False

# 3) Check if location allows request
location = Location.get_record_by_pid(loan.location_pid)
if not location or not location.get('allow_request'):
return False
# All checks are successful, the request is allowed
return True

Expand Down
4 changes: 1 addition & 3 deletions rero_ils/modules/notifications/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,7 @@ def create_over_and_due_soon_notifications(overdue=True, due_soon=True,
loan.create_notification(notification_type='due_soon')
no_due_soon_loans += 1
if overdue:
over_due_loans = get_overdue_loans()

for loan in over_due_loans:
for loan in get_overdue_loans():
loan.create_notification(notification_type='overdue')
no_over_due_loans += 1

Expand Down
39 changes: 39 additions & 0 deletions rero_ils/modules/patron_types/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@
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 ..minters import id_minter
from ..patrons.api import Patron, PatronsSearch
from ..patrons.utils import get_patron_from_arguments
from ..providers import Provider

# provider
Expand Down Expand Up @@ -125,6 +127,27 @@ def get_yearly_subscription_patron_types(cls):
for result in results:
yield cls.get_record_by_pid(result.pid)

@classmethod
def allow_checkout(cls, item, **kwargs):
"""Check if a patron type allow checkout loan operation.
:param item : the item to check
:param kwargs : To be relevant, additional arguments should contains
'patron' argument.
:return a tuple with True|False and reasons to disallow if False.
"""
patron = get_patron_from_arguments(**kwargs)
if not patron:
# 'patron' argument are present into kwargs. This check can't
# be relevant --> return True by default
return True, []

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 True, []

def get_linked_patron(self):
"""Get patron linked to this patron type."""
results = PatronsSearch()\
Expand Down Expand Up @@ -177,6 +200,22 @@ def reasons_not_to_delete(self):
cannot_delete['links'] = links
return cannot_delete

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

def check_overdue_items_limit(self, patron):
"""Check if a patron reaches the overdue items limit.
:param patron: the patron to check.
:return False if patron has more overdue items than defined limit. True
in all other cases.
"""
limit = self.get('limits', {}).get('overdue_items_limits', {})\
.get('default_value')
if limit:
overdue_items = list(get_overdue_loan_pids(patron.pid))
return limit > len(overdue_items)
return True


class PatronTypesIndexer(IlsRecordsIndexer):
"""Holdings indexing class."""
Expand Down

0 comments on commit 0af1525

Please sign in to comment.