From adf2b284a24cca15ffcdb8d90bf81f1e3374b826 Mon Sep 17 00:00:00 2001 From: Renaud Michotte Date: Fri, 16 Oct 2020 17:11:29 +0200 Subject: [PATCH] patron: add circulation informations API Adds an API to get patron circulation informations. This API return the number of loans relate to this patron by state and messages useful for the circulation about this patron (patron blocked, checkout limit, ...) Closes rero/rero-ils#1278 Co-Authored-by: Renaud Michotte --- rero_ils/modules/loans/api.py | 17 +++++++++++ rero_ils/modules/patron_types/api.py | 30 ++++++++++--------- rero_ils/modules/patrons/api.py | 35 +++++++++++++++++++++++ rero_ils/modules/patrons/views.py | 15 +++++++++- tests/api/loans/test_loans_limits.py | 14 ++++++++- tests/api/patrons/test_patrons_blocked.py | 4 +++ tests/api/patrons/test_patrons_rest.py | 35 +++++++++++++++++++++++ 7 files changed, 134 insertions(+), 16 deletions(-) diff --git a/rero_ils/modules/loans/api.py b/rero_ils/modules/loans/api.py index 72344afd45..c407a0c056 100644 --- a/rero_ils/modules/loans/api.py +++ b/rero_ils/modules/loans/api.py @@ -473,6 +473,23 @@ def get_loans_by_item_pid_by_patron_pid( return {} +def get_loans_stats_by_patron_pid(patron_pid): + """Search loans for patron and aggregate result on loan state. + + :param patron_pid: The patron pid + :return: a dict with loans state as key, number of loans as value + """ + agg = A('terms', field='state') + search = search_by_patron_item_or_document(patron_pid=patron_pid) + search.aggs.bucket('state', agg) + search = search[0:0] + results = search.execute() + stats = {} + for result in results.aggregations.state.buckets: + stats[result.key] = result.doc_count + return stats + + def get_loans_by_patron_pid(patron_pid, filter_states=[]): """Search all loans for patron to the given filter_states. diff --git a/rero_ils/modules/patron_types/api.py b/rero_ils/modules/patron_types/api.py index b7c0a3d769..d8f8487db3 100644 --- a/rero_ils/modules/patron_types/api.py +++ b/rero_ils/modules/patron_types/api.py @@ -220,14 +220,14 @@ def check_overdue_items_limit(self, patron): return limit > len(overdue_items) return True - def check_checkout_count_limit(self, patron, item): + def check_checkout_count_limit(self, patron, item=None): """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. + :param item: the item related to the loan (optionnal). :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. @@ -248,18 +248,20 @@ def check_checkout_count_limit(self, patron, item): 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.') + # [3] check library_limit if item is not none + if item: + item_lib_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_lib_pid: + library_limit_value = exception['value'] + break + if library_limit_value and item_lib_pid in patron_library_stats: + if patron_library_stats[item_lib_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 diff --git a/rero_ils/modules/patrons/api.py b/rero_ils/modules/patrons/api.py index 5544a1c336..18155be39f 100644 --- a/rero_ils/modules/patrons/api.py +++ b/rero_ils/modules/patrons/api.py @@ -406,6 +406,7 @@ def can_request(cls, item, **kwargs): # a blocked patron can't request any item if patron.is_blocked: return False, [patron.blocked_message] + return True, [] @classmethod @@ -426,6 +427,7 @@ def can_checkout(cls, item, **kwargs): # a blocked patron can't request any item if patron.is_blocked: return False, [patron.blocked_message] + return True, [] @classmethod @@ -686,6 +688,39 @@ def transaction_user_validator(self, user_pid): """ return Patron.record_pid_exists(user_pid) + def get_circulation_messages(self): + """Return messages useful for circulation. + + * check if the user is blocked ? + * check if the user reaches the maximum loans limit ? + + :return an array of messages. Each message is a dictionary with a level + and a content. The level could be used to filters messages if + needed. + """ + from ..patron_types.api import PatronType + + # if patron is blocked - error type message + # if patron is blocked, no need to return any other circulation + # messages ! + if self.is_blocked: + return [{ + 'type': 'error', + 'content': self.blocked_message + }] + + messages = [] + # check the patron type define limit + patron_type = PatronType.get_record_by_pid(self.patron_type_pid) + valid, message = patron_type.check_checkout_count_limit(self) + if not valid: + messages.append({ + 'type': 'error', + 'content': message + }) + + return messages + class PatronsIndexer(IlsRecordsIndexer): """Holdings indexing class.""" diff --git a/rero_ils/modules/patrons/views.py b/rero_ils/modules/patrons/views.py index 99e587763a..cd8736813d 100644 --- a/rero_ils/modules/patrons/views.py +++ b/rero_ils/modules/patrons/views.py @@ -39,7 +39,7 @@ from ..items.api import Item from ..items.utils import item_pid_to_object from ..libraries.api import Library -from ..loans.api import Loan, patron_profile +from ..loans.api import Loan, get_loans_stats_by_patron_pid, patron_profile from ..locations.api import Location from ..utils import get_base_url from ...permissions import login_and_librarian @@ -105,6 +105,19 @@ def number_of_patrons(): return jsonify(response) +@api_blueprint.route('//circulation_informations', methods=['GET']) +@check_permission +def patron_circulation_informations(patron_pid): + """Get the circulation statistics and info messages about a patron.""" + patron = Patron.get_record_by_pid(patron_pid) + if not patron: + abort(404, 'Patron not found') + return jsonify({ + 'statistics': get_loans_stats_by_patron_pid(patron_pid), + 'messages': patron.get_circulation_messages() + }) + + blueprint = Blueprint( 'patrons', __name__, diff --git a/tests/api/loans/test_loans_limits.py b/tests/api/loans/test_loans_limits.py index 0e60288f2b..be6f751fcd 100644 --- a/tests/api/loans/test_loans_limits.py +++ b/tests/api/loans/test_loans_limits.py @@ -18,8 +18,9 @@ """Loan Record limits.""" from copy import deepcopy +from flask import url_for from invenio_accounts.testutils import login_user_via_session -from utils import postdata +from utils import get_json, postdata from rero_ils.modules.loans.api import LoanAction from rero_ils.modules.patron_types.api import PatronType @@ -133,6 +134,17 @@ def test_loans_limits_checkout_library_limits( assert res.status_code == 403 assert 'Checkout denied' in data['message'] + # check the circulation information API + url = url_for( + 'api_patrons.patron_circulation_informations', + patron_pid=patron.pid + ) + res = client.get(url) + assert res.status_code == 200 + data = get_json(res) + assert 'warning' == data['messages'][0]['type'] + assert 'Checkout denied' in data['messages'][0]['content'] + # reset fixtures # --> checkin both loaned item # --> reset patron_type to original value diff --git a/tests/api/patrons/test_patrons_blocked.py b/tests/api/patrons/test_patrons_blocked.py index 342c13288e..8f6bd56e92 100644 --- a/tests/api/patrons/test_patrons_blocked.py +++ b/tests/api/patrons/test_patrons_blocked.py @@ -53,6 +53,10 @@ def test_blocked_field_exists( assert 'blocked' in data['metadata']['patron'] assert data['metadata']['patron']['blocked'] is True + assert patron3_martigny_blocked_no_email.is_blocked + note = patron3_martigny_blocked_no_email.patron.get('blocked_note') + assert note and note in patron3_martigny_blocked_no_email.blocked_message + def test_blocked_field_not_present( client, diff --git a/tests/api/patrons/test_patrons_rest.py b/tests/api/patrons/test_patrons_rest.py index f0af1ddbb9..83d7382770 100644 --- a/tests/api/patrons/test_patrons_rest.py +++ b/tests/api/patrons/test_patrons_rest.py @@ -552,3 +552,38 @@ def test_patrons_count(client, patron_sion_no_email, res = client.get(url) assert res.status_code == 200 assert get_json(res) == dict(hits=dict(total=1)) + + +def test_patrons_circulation_informations( + client, patron_sion_no_email, librarian_martigny_no_email, + patron3_martigny_blocked_no_email): + """test patron circulation informations.""" + url = url_for( + 'api_patrons.patron_circulation_informations', + patron_pid=patron_sion_no_email.pid + ) + res = client.get(url) + assert res.status_code == 401 + + login_user_via_session(client, librarian_martigny_no_email.user) + res = client.get(url) + assert res.status_code == 200 + data = get_json(res) + assert len(data['messages']) == 0 + + url = url_for( + 'api_patrons.patron_circulation_informations', + patron_pid=patron3_martigny_blocked_no_email.pid + ) + res = client.get(url) + assert res.status_code == 200 + data = get_json(res) + assert 'error' == data['messages'][0]['type'] + assert 'This patron is currently blocked' in data['messages'][0]['content'] + + url = url_for( + 'api_patrons.patron_circulation_informations', + patron_pid='dummy_pid' + ) + res = client.get(url) + assert res.status_code == 404