From 589aa36bd5f79796ccf904b7593272ec3eca2959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johnny=20Marie=CC=81thoz?= Date: Tue, 19 Sep 2023 10:57:34 +0200 Subject: [PATCH] stats: add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Splits the classes to compute statistics into several files. * Fixes some spelling. * Renames abstract class which is not abstract. * Removes useless parameters in the statics methods. * Get the location names using only one es query. * Creates a loan search query used for several statistics. * Fixes delete items statistics. * Adds tests for statistics. * Fixes some test fixtures. * Adds a type filter to the operation logs search to avoid unwanted documents. Co-Authored-by: Johnny MarieĢthoz Co-Authored-by: Bertrand Zuchuat --- pyproject.toml | 4 +- rero_ils/config.py | 14 +- rero_ils/modules/loans/logs/api.py | 6 +- rero_ils/modules/notifications/logs/api.py | 6 +- rero_ils/modules/operation_logs/logs/api.py | 5 +- rero_ils/modules/stats/api.py | 696 -------------------- rero_ils/modules/stats/api/__init__.py | 18 + rero_ils/modules/stats/api/api.py | 85 +++ rero_ils/modules/stats/api/librarian.py | 324 +++++++++ rero_ils/modules/stats/api/pricing.py | 291 ++++++++ rero_ils/modules/stats/cli.py | 4 +- rero_ils/modules/stats/tasks.py | 4 +- rero_ils/modules/stats/views.py | 3 +- setup.py | 4 +- tests/api/stats/conftest.py | 4 +- tests/fixtures/circulation.py | 26 +- tests/ui/stats/conftest.py | 36 + tests/ui/stats/test_stats_librarian.py | 163 +++++ tests/ui/stats/test_stats_pricing.py | 173 +++++ 19 files changed, 1137 insertions(+), 729 deletions(-) delete mode 100644 rero_ils/modules/stats/api.py create mode 100644 rero_ils/modules/stats/api/__init__.py create mode 100644 rero_ils/modules/stats/api/api.py create mode 100644 rero_ils/modules/stats/api/librarian.py create mode 100644 rero_ils/modules/stats/api/pricing.py create mode 100644 tests/ui/stats/conftest.py create mode 100644 tests/ui/stats/test_stats_librarian.py create mode 100644 tests/ui/stats/test_stats_pricing.py diff --git a/pyproject.toml b/pyproject.toml index 71209ca4db..37cac40678 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -329,7 +329,7 @@ patron_id = "rero_ils.modules.patrons.api:patron_id_fetcher" patron_transaction_event_id = "rero_ils.modules.patron_transaction_events.api:patron_transaction_event_id_fetcher" patron_transaction_id = "rero_ils.modules.patron_transactions.api:patron_transaction_id_fetcher" patron_type_id = "rero_ils.modules.patron_types.api:patron_type_id_fetcher" -stat_id = "rero_ils.modules.stats.api:stat_id_fetcher" +stat_id = "rero_ils.modules.stats.api.api:stat_id_fetcher" template_id = "rero_ils.modules.templates.api:template_id_fetcher" entity_id = "rero_ils.modules.entities.fetchers:id_fetcher" vendor_id = "rero_ils.modules.vendors.api:vendor_id_fetcher" @@ -360,7 +360,7 @@ patron_id = "rero_ils.modules.patrons.api:patron_id_minter" patron_transaction_event_id = "rero_ils.modules.patron_transaction_events.api:patron_transaction_event_id_minter" patron_transaction_id = "rero_ils.modules.patron_transactions.api:patron_transaction_id_minter" patron_type_id = "rero_ils.modules.patron_types.api:patron_type_id_minter" -stat_id = "rero_ils.modules.stats.api:stat_id_minter" +stat_id = "rero_ils.modules.stats.api.api:stat_id_minter" template_id = "rero_ils.modules.templates.api:template_id_minter" entity_id = "rero_ils.modules.entities.minters:id_minter" vendor_id = "rero_ils.modules.vendors.api:vendor_id_minter" diff --git a/rero_ils/config.py b/rero_ils/config.py index 6fe9bb5053..ce6cd700aa 100644 --- a/rero_ils/config.py +++ b/rero_ils/config.py @@ -127,7 +127,7 @@ from .modules.patrons.models import CommunicationChannel from .modules.patrons.permissions import PatronPermissionPolicy from .modules.selfcheck.permissions import seflcheck_permission_factory -from .modules.stats.api import Stat +from .modules.stats.api.api import Stat from .modules.stats.permissions import StatisticsPermissionPolicy from .modules.templates.permissions import TemplatePermissionPolicy from .modules.users.api import get_profile_countries, \ @@ -773,9 +773,9 @@ def _(x): pid_type='stat', pid_minter='stat_id', pid_fetcher='stat_id', - search_class='rero_ils.modules.stats.api:StatsSearch', + search_class='rero_ils.modules.stats.api.api:StatsSearch', search_index='stats', - indexer_class='rero_ils.modules.stats.api:StatsIndexer', + indexer_class='rero_ils.modules.stats.api.api:StatsIndexer', search_type=None, record_serializers={ 'application/json': 'rero_ils.modules.serializers:json_v1_response', @@ -792,8 +792,8 @@ def _(x): record_loaders={ 'application/json': lambda: Stat(request.get_json()), }, - record_class='rero_ils.modules.stats.api:Stat', - item_route='/stats/', + record_class='rero_ils.modules.stats.api.api:Stat', + item_route='/stats/', default_media_type='application/json', max_result_window=MAX_RESULT_WINDOW, list_permission_factory_imp=lambda record: StatisticsPermissionPolicy('search', record=record), @@ -2829,7 +2829,7 @@ def _(x): pid_type='stat', route='/stats/', template='rero_ils/detailed_view_stats.html', - record_class='rero_ils.modules.stats.api:Stat', + record_class='rero_ils.modules.stats.api.api:Stat', view_imp='rero_ils.modules.stats.views.stats_view_method', permission_factory_imp='rero_ils.modules.stats.permissions:stats_ui_permission_factory', ) @@ -2897,7 +2897,7 @@ def _(x): # Statistics Configuration # ======================== # Compute the stats with a timeframe given in monthes -RERO_ILS_STATS_BILLING_TIMEFRAME_IN_MONTHES = 3 +RERO_ILS_STATS_BILLING_TIMEFRAME_IN_MONTHS = 3 # ============================================================================= diff --git a/rero_ils/modules/loans/logs/api.py b/rero_ils/modules/loans/logs/api.py index 6613a0c3cf..a9eca31938 100644 --- a/rero_ils/modules/loans/logs/api.py +++ b/rero_ils/modules/loans/logs/api.py @@ -19,14 +19,14 @@ from invenio_search import RecordsSearch -from rero_ils.modules.operation_logs.logs.api import \ - AbstractSpecificOperationLog +from rero_ils.modules.operation_logs.api import OperationLog +from rero_ils.modules.operation_logs.logs.api import SpecificOperationLog from ...items.api import Item from ...patrons.api import Patron, current_librarian -class LoanOperationLog(AbstractSpecificOperationLog): +class LoanOperationLog(OperationLog, SpecificOperationLog): """Operation log for loans.""" @classmethod diff --git a/rero_ils/modules/notifications/logs/api.py b/rero_ils/modules/notifications/logs/api.py index 2813895ec2..2978b921d3 100644 --- a/rero_ils/modules/notifications/logs/api.py +++ b/rero_ils/modules/notifications/logs/api.py @@ -18,13 +18,13 @@ """Notification logs API.""" -from rero_ils.modules.operation_logs.logs.api import \ - AbstractSpecificOperationLog +from rero_ils.modules.operation_logs.api import OperationLog +from rero_ils.modules.operation_logs.logs.api import SpecificOperationLog from ..models import RecipientType -class NotificationOperationLog(AbstractSpecificOperationLog): +class NotificationOperationLog(OperationLog, SpecificOperationLog): """Operation log for notification.""" @classmethod diff --git a/rero_ils/modules/operation_logs/logs/api.py b/rero_ils/modules/operation_logs/logs/api.py index cf9c1cb10c..f4822f5121 100644 --- a/rero_ils/modules/operation_logs/logs/api.py +++ b/rero_ils/modules/operation_logs/logs/api.py @@ -21,12 +21,11 @@ import hashlib from rero_ils.modules.locations.api import Location -from rero_ils.modules.operation_logs.api import OperationLog from rero_ils.modules.utils import extracted_data_from_ref -class AbstractSpecificOperationLog(OperationLog): - """Abstract Specific Operation log.""" +class SpecificOperationLog(): + """Specific Operation log.""" @classmethod def _get_patron_data(cls, patron): diff --git a/rero_ils/modules/stats/api.py b/rero_ils/modules/stats/api.py deleted file mode 100644 index 1fbdcff323..0000000000 --- a/rero_ils/modules/stats/api.py +++ /dev/null @@ -1,696 +0,0 @@ -# -*- coding: utf-8 -*- -# -# RERO ILS -# Copyright (C) 2019-2022 RERO -# Copyright (C) 2019-2022 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 . - -"""Stats for pricing records.""" -import hashlib -from functools import partial - -import arrow -from dateutil.relativedelta import relativedelta -from flask import current_app -from invenio_search.api import RecordsSearch - -from rero_ils.modules.acquisition.acq_order_lines.api import \ - AcqOrderLinesSearch -from rero_ils.modules.api import IlsRecord, IlsRecordsIndexer, IlsRecordsSearch -from rero_ils.modules.documents.api import DocumentsSearch -from rero_ils.modules.fetchers import id_fetcher -from rero_ils.modules.ill_requests.models import ILLRequestStatus -from rero_ils.modules.items.api import ItemsSearch -from rero_ils.modules.items.models import ItemCirculationAction -from rero_ils.modules.libraries.api import LibrariesSearch -from rero_ils.modules.loans.logs.api import LoanOperationLog -from rero_ils.modules.locations.api import LocationsSearch -from rero_ils.modules.minters import id_minter -from rero_ils.modules.patrons.api import PatronsSearch -from rero_ils.modules.providers import Provider -from rero_ils.modules.users.models import UserRole - -from .extensions import StatisticsDumperExtension -from .models import StatIdentifier, StatMetadata - -# provider -StatProvider = type( - 'StatProvider', - (Provider,), - dict(identifier=StatIdentifier, pid_type='stat') -) -# minter -stat_id_minter = partial(id_minter, provider=StatProvider) -# fetcher -stat_id_fetcher = partial(id_fetcher, provider=StatProvider) - - -class StatsSearch(IlsRecordsSearch): - """ItemTypeSearch.""" - - class Meta: - """Search only on stats index.""" - - index = 'stats' - doc_types = None - fields = ('*',) - facets = {} - - default_filter = None - - -class Stat(IlsRecord): - """ItemType class.""" - - minter = stat_id_minter - fetcher = stat_id_fetcher - provider = StatProvider - model_cls = StatMetadata - - _extensions = [ - StatisticsDumperExtension() - ] - - def update(self, data, commit=True, dbcommit=False, reindex=False): - """Update data for record.""" - super().update(data, commit, dbcommit, reindex) - return self - - -class StatsForPricing: - """Statistics for pricing.""" - - def __init__(self, to_date=None): - """Constructor.""" - self.to_date = to_date or arrow.utcnow() - relativedelta(days=1) - self.months_delta = current_app.config.get( - 'RERO_ILS_STATS_BILLING_TIMEFRAME_IN_MONTHES' - ) - _from = (self.to_date - relativedelta( - months=self.months_delta)).format(fmt='YYYY-MM-DDT00:00:00') - _to = self.to_date.format(fmt='YYYY-MM-DDT23:59:59') - self.date_range = {'gte': _from, 'lte': _to} - - def get_all_libraries(self): - """Get all libraries in the system.""" - return list(LibrariesSearch().source(['pid', 'name', 'organisation']) - .scan()) - - @classmethod - def get_stat_pid(cls, type, date_range): - """Get pid of statistics. - - :param type: type of statistics - :param date_range: statistics time interval - """ - _from = date_range['from'] - _to = date_range['to'] - search = StatsSearch()\ - .filter("term", type=type)\ - .scan() - - stat_pid = list() - for s in search: - if 'date_range' in s and\ - 'from' in s.date_range and 'to' in s.date_range: - if s.date_range['from'] == _from and s.date_range['to'] == _to: - stat_pid.append(s.pid) - if stat_pid: - assert len(stat_pid) == 1 - return stat_pid[0] - return - - def collect(self): - """Compute all the statistics.""" - stats = [] - for lib in self.get_all_libraries(): - stats.append({ - 'library': { - 'pid': lib.pid, - 'name': lib.name - }, - 'number_of_documents': self.number_of_documents(lib.pid), - 'number_of_libraries': self.number_of_libraries( - lib.organisation.pid), - 'number_of_librarians': self.number_of_librarians(lib.pid), - 'number_of_active_patrons': self.number_of_active_patrons( - lib.pid), - 'number_of_order_lines': self.number_of_order_lines(lib.pid), - 'number_of_checkouts': - self.number_of_circ_operations( - lib.pid, ItemCirculationAction.CHECKOUT), - 'number_of_renewals': - self.number_of_circ_operations( - lib.pid, ItemCirculationAction.EXTEND), - 'number_of_validated_ill_requests': - self.number_of_ill_requests_operations( - lib.pid, [ILLRequestStatus.VALIDATED]), - 'number_of_items': self.number_of_items(lib.pid), - 'number_of_new_items': self.number_of_new_items(lib.pid), - 'number_of_deleted_items': self.number_of_deleted_items( - lib.pid), - 'number_of_patrons': self.number_of_patrons( - lib.organisation.pid), - 'number_of_new_patrons': self.number_of_patrons( - lib.organisation.pid), - 'number_of_checkins': - self.number_of_circ_operations( - lib.pid, ItemCirculationAction.CHECKIN), - 'number_of_requests': - self.number_of_circ_operations( - lib.pid, ItemCirculationAction.REQUEST) - }) - return stats - - def number_of_documents(self, library_pid): - """Number of documents linked to my library. - - point in time - :param library_pid: string - the library to filter with - :return: the number of matched documents - :rtype: integer - """ - # can be done using the facet - return DocumentsSearch().filter( - 'term', holdings__organisation__library_pid=library_pid).count() - - def number_of_libraries(self, organisation_pid): - """Number of libraries of the given organisation. - - point in time - :param organisation_pid: string - the organisation to filter with - :return: the number of matched libraries - :rtype: integer - """ - return LibrariesSearch().filter( - 'term', organisation__pid=organisation_pid).count() - - def number_of_librarians(self, library_pid): - """Number of users with a librarian role. - - point in time - :param library_pid: string - the library to filter with - :return: the number of matched librarians - :rtype: integer - """ - return PatronsSearch()\ - .filter('terms', roles=UserRole.PROFESSIONAL_ROLES)\ - .filter('term', libraries__pid=library_pid)\ - .count() - - def number_of_active_patrons(self, library_pid): - """Number of patrons who did a transaction in a the past 365 days. - - :param library_pid: string - the library to filter with - :return: the number of matched active patrons - :rtype: integer - """ - ptrns = set() - _to = self.to_date.format(fmt='YYYY-MM-DDT23:59:59') - _from = (self.to_date - relativedelta(months=12))\ - .format(fmt='YYYY-MM-DDT00:00:00') - date_range = {'gte': _from, 'lte': _to} - for res in RecordsSearch(index=LoanOperationLog.index_name)\ - .filter('range', date=date_range)\ - .filter( - 'terms', - loan__trigger=[ItemCirculationAction.CHECKOUT, - ItemCirculationAction.EXTEND])\ - .filter('term', loan__item__library_pid=library_pid)\ - .source(['loan'])\ - .scan(): - ptrns.add(res.loan.patron.hashed_pid) - - return len(ptrns) - - def number_of_order_lines(self, library_pid): - """Number of order lines created during the specified timeframe. - - :param library_pid: string - the library to filter with - :return: the number of matched order lines - :rtype: integer - """ - return AcqOrderLinesSearch()\ - .filter('range', _created=self.date_range)\ - .filter('term', library__pid=library_pid).count() - - def number_of_circ_operations(self, library_pid, trigger): - """Number of circulation operation during the specified timeframe. - - :param library_pid: string - the library to filter with - :param trigger: string - action name - :return: the number of matched circulation operation - :rtype: integer - """ - return RecordsSearch(index=LoanOperationLog.index_name)\ - .filter('range', date=self.date_range)\ - .filter('term', loan__trigger=trigger)\ - .filter('term', loan__item__library_pid=library_pid)\ - .count() - - def number_of_ill_requests_operations(self, library_pid, status): - """Number of ILL requests creation or update operations. - - :param library_pid: string - the library to filter with - :param status: list of status to filter with - :return: the number of matched inter library loan request - :rtype: integer - """ - query = RecordsSearch(index=LoanOperationLog.index_name)\ - .filter('term', record__type='illr')\ - .filter('terms', operation=['update', 'create'])\ - .filter('range', _created=self.date_range)\ - .filter('term', ill_request__library_pid=library_pid)\ - .filter('terms', ill_request__status=status) - return query.count() - - # -------- optional ----------- - def number_of_items(self, library_pid): - """Number of items linked to my library. - - point in time - :param library_pid: string - the library to filter with - :return: the number of matched items - :rtype: integer - """ - # can be done using the facet - return ItemsSearch().filter( - 'term', library__pid=library_pid).count() - - def number_of_deleted_items(self, library_pid): - """Number of deleted items during the specified timeframe. - - :param library_pid: string - the library to filter with - :return: the number of matched deleted items - :rtype: integer - """ - return RecordsSearch(index=LoanOperationLog.index_name)\ - .filter('range', date=self.date_range)\ - .filter('term', operation='delete')\ - .filter('term', record__type='item')\ - .filter('term', library__pid=library_pid)\ - .count() - - def number_of_new_items(self, library_pid): - """Number of new created items during the specified timeframe. - - :param library_pid: string - the library to filter with - :return: the number of matched newly created items - :rtype: integer - """ - # can be done using the facet or operation logs - return ItemsSearch()\ - .filter('range', _created=self.date_range)\ - .filter('term', library__pid=library_pid).count() - - def number_of_new_patrons(self, organisation_pid): - """New patrons for an organisation during the specified timeframe. - - :param organisation_pid: string - the organisation to filter with - :return: the number of matched newly created patrons - :rtype: integer - """ - return PatronsSearch()\ - .filter('range', _created=self.date_range)\ - .filter('term', organisation__pid=organisation_pid)\ - .count() - - def number_of_patrons(self, organisation_pid): - """Number of users with a librarian role. - - point in time - - :param organisation_pid: string - the organisation to filter with - :return: the number of matched patrons - :rtype: integer - """ - return PatronsSearch()\ - .filter('term', roles='patron')\ - .filter('term', organisation__pid=organisation_pid)\ - .count() - - -class StatsForLibrarian(StatsForPricing): - """Statistics for librarian. - - TODO: the type of the statistic should be changed from - librarian to library. - """ - - def __init__(self, to_date=None): - """Constructor. - - :param to_date: end date of the statistics date range - """ - self.to_date = to_date or arrow.utcnow() - relativedelta(days=1) - # Get statistics per month - _from = f'{self.to_date.year}-{self.to_date.month:02d}-01T00:00:00' - _to = self.to_date.format(fmt='YYYY-MM-DDT23:59:59') - self.date_range = {'gte': _from, 'lte': _to} - - def collect(self): - """Compute statistics for librarian.""" - stats = [] - libraries = self.get_all_libraries() - libraries_map = {lib.pid: lib.name for lib in libraries} - - for lib in libraries: - stats.append({ - 'library': { - 'pid': lib.pid, - 'name': lib.name - }, - 'checkouts_for_transaction_library': - self.checkouts_for_transaction_library( - lib.pid, - [ItemCirculationAction.CHECKOUT]), - 'checkouts_for_owning_library': - self.checkouts_for_owning_library( - lib.pid, - [ItemCirculationAction.CHECKOUT]), - 'active_patrons_by_postal_code': - self.active_patrons_by_postal_code( - lib.pid, - [ItemCirculationAction.REQUEST, - ItemCirculationAction.CHECKIN, - ItemCirculationAction.CHECKOUT]), - 'new_active_patrons_by_postal_code': - self.new_active_patrons_by_postal_code( - lib.pid, - [ItemCirculationAction.REQUEST, - ItemCirculationAction.CHECKIN, - ItemCirculationAction.CHECKOUT]), - 'new_documents': - self.new_documents(lib.pid), - 'new_items': - self.number_of_new_items(lib.pid), - 'renewals': - self.renewals(lib.pid, [ItemCirculationAction.EXTEND]), - 'validated_requests': - self.validated_requests(lib.pid), - 'items_by_document_type_and_subtype': - self.items_by_document_type_and_subtype(lib.pid), - 'new_items_by_location': - self.new_items_by_location(lib.pid), - 'loans_of_transaction_library_by_item_location': - self.loans_of_transaction_library_by_item_location( - libraries_map, - lib.pid, - [ItemCirculationAction.CHECKIN, - ItemCirculationAction.CHECKOUT]) - }) - return stats - - def _get_locations_pid(self, library_pid): - """Locations pid for given library. - - :param library_pid: string - the library to filter with - :return: list of pid locations - :rtype: list - """ - locations = LocationsSearch()\ - .filter('term', library__pid=library_pid).source('pid').scan() - return [location.pid for location in locations] - - def _get_location_code_name(self, location_pid): - """Location code and name. - - :param location_pid: string - the location to filter with - :return: concatenated code and name of location - :rtype: string - """ - location_search = LocationsSearch()\ - .filter('term', pid=location_pid)\ - .source(['code', 'name']).scan() - location = next(location_search) - return f'{location.code} - {location.name}' - - def checkouts_for_transaction_library(self, library_pid, trigger): - """Number of circulation operation during the specified timeframe. - - Number of loans of items when transaction location is equal to - any of the library locations - :param library_pid: string - the library to filter with - :param trigger: string - action name (checkout) - :return: the number of matched circulation operation - :rtype: integer - """ - location_pids = self._get_locations_pid(library_pid) - - return RecordsSearch(index=LoanOperationLog.index_name)\ - .filter('range', date=self.date_range)\ - .filter('terms', loan__trigger=trigger)\ - .filter('terms', loan__transaction_location__pid=location_pids)\ - .count() - - def checkouts_for_owning_library(self, library_pid, trigger): - """Number of circulation operation during the specified timeframe. - - Number of loans of items per library when the item is owned by - the library - :param library_pid: string - the library to filter with - :param trigger: string - action name (checkout) - :return: the number of matched circulation operation - :rtype: integer - """ - return RecordsSearch(index=LoanOperationLog.index_name)\ - .filter('range', date=self.date_range)\ - .filter('terms', loan__trigger=trigger)\ - .filter('term', loan__item__library_pid=library_pid)\ - .count() - - def active_patrons_by_postal_code(self, library_pid, trigger): - """Number of circulation operation during the specified timeframe. - - Number of patrons per library and CAP when transaction location - is equal to any of the library locations - :param library_pid: string - the library to filter with - :param trigger: string - action name (request, checkin, checkout) - :return: the number of matched circulation operation - :rtype: dict - """ - location_pids = self._get_locations_pid(library_pid) - - search = RecordsSearch(index=LoanOperationLog.index_name)\ - .filter('range', date=self.date_range)\ - .filter('terms', loan__trigger=trigger)\ - .filter('terms', loan__transaction_location__pid=location_pids)\ - .scan() - - stats = {} - patron_pids = set() - # Main postal code from user profile - for s in search: - patron_pid = s.loan.patron.hashed_pid - if 'postal_code' not in s.loan.patron or\ - not s.loan.patron.postal_code: - postal_code = 'unknown' - else: - postal_code = s.loan.patron.postal_code - - if postal_code not in stats: - stats[postal_code] = 1 - elif patron_pid not in patron_pids: - stats[postal_code] += 1 - patron_pids.add(patron_pid) - return stats - - def new_active_patrons_by_postal_code(self, library_pid, trigger): - """Number of circulation operation during the specified timeframe. - - Number of new patrons per library and CAP when transaction location - is equal to any of the library locations - :param library_pid: string - the library to filter with - :param trigger: string - action name (request, checkin, checkout) - :return: the number of matched circulation operation - :rtype: dict - """ - location_pids = self._get_locations_pid(library_pid) - - search = RecordsSearch(index=LoanOperationLog.index_name)\ - .filter('range', date=self.date_range)\ - .filter('terms', loan__trigger=trigger)\ - .filter('terms', loan__transaction_location__pid=location_pids)\ - .scan() - - # Get new patrons in date range and hash the pids - search_patron = PatronsSearch()\ - .filter("range", _created=self.date_range)\ - .source('pid').scan() - new_patron_pids = set() - for p in search_patron: - hashed_pid = hashlib.md5(p.pid.encode()).hexdigest() - new_patron_pids.add(hashed_pid) - - stats = {} - patron_pids = set() - # Main postal code from user profile - for s in search: - patron_pid = s.loan.patron.hashed_pid - if patron_pid in new_patron_pids: - if 'postal_code' not in s.loan.patron or\ - not s.loan.patron.postal_code: - postal_code = 'unknown' - else: - postal_code = s.loan.patron.postal_code - - if postal_code not in stats: - stats[postal_code] = 1 - elif patron_pid not in patron_pids: - stats[postal_code] += 1 - patron_pids.add(patron_pid) - - return stats - - def new_documents(self, library_pid): - """Number of new documents per library for given time interval. - - :param library_pid: string - the library to filter with - :return: the number of matched documents - :rtype: integer - """ - return RecordsSearch(index=LoanOperationLog.index_name)\ - .filter('range', date=self.date_range)\ - .filter('term', operation='create')\ - .filter('term', record__type='doc')\ - .filter('term', library__value=library_pid)\ - .count() - - def renewals(self, library_pid, trigger): - """Number of items with loan extended. - - Number of items with loan extended per library for given time interval - :param library_pid: string - the library to filter with - :param trigger: string - action name extend - :return: the number of matched documents - :rtype: integer - """ - return RecordsSearch(index=LoanOperationLog.index_name)\ - .filter('range', date=self.date_range)\ - .filter('terms', loan__trigger=trigger)\ - .filter('term', loan__item__library_pid=library_pid)\ - .count() - - def validated_requests(self, library_pid): - """Number of validated requests. - - Number of validated requests per library for given time interval - Match is done on the library of the librarian. - :param library_pid: string - the library to filter with - :return: the number of matched documents - :rtype: integer - """ - return RecordsSearch(index=LoanOperationLog.index_name)\ - .filter('range', date=self.date_range)\ - .filter('term', loan__trigger='validate_request')\ - .filter('term', library__value=library_pid)\ - .count() - - def new_items_by_location(self, library_pid): - """Number of new items per library by location. - - Note: items created and then deleted during the time interval - are not included. - :param library_pid: string - the library to filter with - :return: the number of matched documents - :rtype: dict - """ - search = ItemsSearch()[:0]\ - .filter('range', _created=self.date_range)\ - .filter('term', library__pid=library_pid)\ - .source('location.pid') - search.aggs.bucket('location_pid', 'terms', field='location.pid', - size=10000) - res = search.execute() - stats = {} - for bucket in res.aggregations.location_pid.buckets: - location_code_name = self._get_location_code_name(bucket.key) - stats[location_code_name] = bucket.doc_count - return stats - - def items_by_document_type_and_subtype(self, library_pid): - """Number of items per library by document type and sub-type. - - Note: if item has more than one doc type/subtype the item is counted - multiple times - :param library_pid: string - the library to filter with - :return: the number of matched documents - :rtype: dict - """ - search = ItemsSearch()[:0]\ - .filter('range', _created={'lte': self.date_range['lte']})\ - .filter('term', library__pid=library_pid)\ - .source('document.document_type') - search.aggs\ - .bucket('main_type', 'terms', - field='document.document_type.main_type', size=10000) - search.aggs\ - .bucket('subtype', 'terms', - field='document.document_type.subtype', size=10000) - res = search.execute() - stats = { - bucket.key: bucket.doc_count - for bucket in res.aggregations.main_type.buckets - } - for bucket in res.aggregations.subtype.buckets: - stats[bucket.key] = bucket.doc_count - return stats - - def loans_of_transaction_library_by_item_location(self, - libraries_map, - library_pid, - trigger): - """Number of circulation operation during the specified timeframe. - - Number of loans of items by location when transaction location - is equal to any of the library locations - :param libraries_map: dict - map of library pid and name - :param library_pid: string - the library to filter with - :param trigger: string - action name (checkin, checkout) - :return: the number of matched circulation operation - :rtype: dict - """ - location_pids = self._get_locations_pid(library_pid) - search = RecordsSearch(index=LoanOperationLog.index_name)\ - .filter('range', date=self.date_range)\ - .filter('terms', loan__trigger=trigger)\ - .filter('terms', loan__transaction_location__pid=location_pids)\ - .source('loan').scan() - - stats = {} - for s in search: - item_library_pid = s.loan.item.library_pid - item_library_name = libraries_map[item_library_pid] - location_name = s.loan.item.holding.location_name - - key = f'{item_library_pid}: {item_library_name} - {location_name}' - stats.setdefault(key, { - 'location_name': location_name, - ItemCirculationAction.CHECKIN: 0, - ItemCirculationAction.CHECKOUT: 0}) - stats[key][s.loan.trigger] += 1 - return stats - - -class StatsIndexer(IlsRecordsIndexer): - """Indexing stats in Elasticsearch.""" - - record_cls = Stat - - def bulk_index(self, record_id_iterator): - """Bulk index records. - - :param record_id_iterator: Iterator yielding record UUIDs. - """ - super().bulk_index(record_id_iterator, doc_type='stat') diff --git a/rero_ils/modules/stats/api/__init__.py b/rero_ils/modules/stats/api/__init__.py new file mode 100644 index 0000000000..19d2e3da5a --- /dev/null +++ b/rero_ils/modules/stats/api/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# +# RERO ILS +# Copyright (C) 2019-2022 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 . + +"""Stats python API.""" diff --git a/rero_ils/modules/stats/api/api.py b/rero_ils/modules/stats/api/api.py new file mode 100644 index 0000000000..77daac17d3 --- /dev/null +++ b/rero_ils/modules/stats/api/api.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# +# RERO ILS +# Copyright (C) 2019-2023 RERO +# Copyright (C) 2019-2023 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 . + +"""API for manipulating statistics.""" + +from functools import partial + +from rero_ils.modules.api import IlsRecord, IlsRecordsIndexer, IlsRecordsSearch +from rero_ils.modules.fetchers import id_fetcher +from rero_ils.modules.minters import id_minter +from rero_ils.modules.providers import Provider + +from ..extensions import StatisticsDumperExtension +from ..models import StatIdentifier, StatMetadata + +# provider +StatProvider = type( + 'StatProvider', + (Provider,), + dict(identifier=StatIdentifier, pid_type='stat') +) +# minter +stat_id_minter = partial(id_minter, provider=StatProvider) +# fetcher +stat_id_fetcher = partial(id_fetcher, provider=StatProvider) + + +class StatsSearch(IlsRecordsSearch): + """ItemTypeSearch.""" + + class Meta: + """Search only on stats index.""" + + index = 'stats' + doc_types = None + fields = ('*',) + facets = {} + + default_filter = None + + +class Stat(IlsRecord): + """ItemType class.""" + + minter = stat_id_minter + fetcher = stat_id_fetcher + provider = StatProvider + model_cls = StatMetadata + + _extensions = [ + StatisticsDumperExtension() + ] + + def update(self, data, commit=True, dbcommit=False, reindex=False): + """Update data for record.""" + super().update(data, commit, dbcommit, reindex) + return self + + +class StatsIndexer(IlsRecordsIndexer): + """Indexing stats in Elasticsearch.""" + + record_cls = Stat + + def bulk_index(self, record_id_iterator): + """Bulk index records. + + :param record_id_iterator: Iterator yielding record UUIDs. + """ + super().bulk_index(record_id_iterator, doc_type='stat') diff --git a/rero_ils/modules/stats/api/librarian.py b/rero_ils/modules/stats/api/librarian.py new file mode 100644 index 0000000000..81b99559f7 --- /dev/null +++ b/rero_ils/modules/stats/api/librarian.py @@ -0,0 +1,324 @@ +# -*- coding: utf-8 -*- +# +# RERO ILS +# Copyright (C) 2019-2023 RERO +# Copyright (C) 2019-2023 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 . + +"""To compute the statistics for librarian.""" + +import hashlib + +import arrow +from dateutil.relativedelta import relativedelta +from invenio_search.api import RecordsSearch + +from rero_ils.modules.items.api import ItemsSearch +from rero_ils.modules.items.models import ItemCirculationAction +from rero_ils.modules.loans.logs.api import LoanOperationLog +from rero_ils.modules.locations.api import LocationsSearch +from rero_ils.modules.operation_logs.api import OperationLog +from rero_ils.modules.patrons.api import PatronsSearch + +from .pricing import StatsForPricing + + +class StatsForLibrarian(StatsForPricing): + """Statistics for librarian. + + TODO: the type of the statistic should be changed from + librarian to library. + """ + + def __init__(self, to_date=None): + """Constructor. + + :param to_date: end date of the statistics date range + """ + self.to_date = to_date or arrow.utcnow() - relativedelta(days=1) + # Get statistics per month + _from = f'{self.to_date.year}-{self.to_date.month:02d}-01T00:00:00' + _to = self.to_date.format(fmt='YYYY-MM-DDT23:59:59') + self.date_range = {'gte': _from, 'lte': _to} + self.libraries = self._get_all_libraries() + self.libraries_map = {lib.pid: lib.name for lib in self.libraries} + + def _get_locations_pid(self, library_pid): + """Locations pid for given library. + + :param library_pid: string - the library to filter with + :return: list of pid locations + :rtype: list + """ + locations = LocationsSearch()\ + .filter('term', library__pid=library_pid).source('pid').scan() + return [location.pid for location in locations] + + def _get_locations_code_name(self, location_pids): + """Location code and name. + + :param location_pid: string - the location to filter with + :return: concatenated code and name of location + :rtype: string + """ + location_search = LocationsSearch()\ + .filter('terms', pid=location_pids)\ + .source(['code', 'name', 'pid']) + res = {} + for hit in location_search.scan(): + res[hit.pid] = f'{hit.code} - {hit.name}' + return res + + def _get_loan_op_search(self, triggers): + """Get the operation logs base es search. + + :param triggers: list[str] - loan triggers value to filter + :return: an elasticsearch dsl search query + """ + return RecordsSearch(index=LoanOperationLog.index_name)\ + .filter('range', date=self.date_range)\ + .filter('term', record__type='loan')\ + .filter('terms', loan__trigger=triggers) + + def collect(self): + """Compute statistics for librarian.""" + stats = [] + for lib in self.libraries: + stats.append({ + 'library': { + 'pid': lib.pid, + 'name': lib.name + }, + 'checkouts_for_transaction_library': + self.checkouts_for_transaction_library(lib.pid), + 'checkouts_for_owning_library': + self.checkouts_for_owning_library(lib.pid), + 'active_patrons_by_postal_code': + self.active_patrons_by_postal_code(lib.pid), + 'new_active_patrons_by_postal_code': + self.active_patrons_by_postal_code( + lib.pid, new_patrons=True), + 'new_documents': + self.new_documents(lib.pid), + 'new_items': + self.number_of_new_items(lib.pid), + 'renewals': + self.renewals(lib.pid), + 'validated_requests': + self.validated_requests(lib.pid), + 'items_by_document_type_and_subtype': + self.items_by_document_type_and_subtype(lib.pid), + 'new_items_by_location': + self.new_items_by_location(lib.pid), + 'loans_of_transaction_library_by_item_location': + self.loans_of_transaction_library_by_item_location(lib.pid) + }) + return stats + + def checkouts_for_transaction_library(self, library_pid): + """Number of circulation operation during the specified timeframe. + + Number of loans of items when transaction location is equal to + any of the library locations + :param library_pid: string - the library to filter with + :return: the number of matched circulation operation + :rtype: integer + """ + location_pids = self._get_locations_pid(library_pid) + return self._get_loan_op_search( + triggers=[ItemCirculationAction.CHECKOUT])\ + .filter('terms', loan__transaction_location__pid=location_pids)\ + .count() + + def checkouts_for_owning_library(self, library_pid): + """Number of circulation operation during the specified timeframe. + + Number of loans of items per library when the item is owned by + the library + :param library_pid: string - the library to filter with + :return: the number of matched circulation operation + :rtype: integer + """ + return self._get_loan_op_search( + triggers=[ItemCirculationAction.CHECKOUT])\ + .filter('term', loan__item__library_pid=library_pid)\ + .count() + + def active_patrons_by_postal_code(self, library_pid, new_patrons=False): + """Number of circulation operation during the specified timeframe. + + Number of patrons per library and CAP when transaction location + is equal to any of the library locations + :param library_pid: string - the library to filter with + :param new_patrons: bool - filter by new patrons + :return: the number of matched circulation operation + :rtype: dict + """ + location_pids = self._get_locations_pid(library_pid) + + search = self._get_loan_op_search(triggers=[ + ItemCirculationAction.REQUEST, + ItemCirculationAction.CHECKIN, + ItemCirculationAction.CHECKOUT + ])\ + .filter('terms', loan__transaction_location__pid=location_pids) + if new_patrons: + # Get new patrons in date range and hash the pids + search_patron = PatronsSearch()\ + .filter("range", _created=self.date_range)\ + .source('pid').scan() + new_patron_hashed_pids = set() + for p in search_patron: + hashed_pid = hashlib.md5(p.pid.encode()).hexdigest() + new_patron_hashed_pids.add(hashed_pid) + search.filter( + 'terms', loan__patron__hashed_pid=new_patron_hashed_pids) + stats = {} + patron_pids = set() + # Main postal code from user profile + for s in search.scan(): + patron_pid = s.loan.patron.hashed_pid + postal_code = 'unknown' + if 'postal_code' in s.loan.patron: + postal_code = s.loan.patron.postal_code + + stats.setdefault(postal_code, 0) + if patron_pid not in patron_pids: + stats[postal_code] += 1 + patron_pids.add(patron_pid) + return stats + + def new_documents(self, library_pid): + """Number of new documents per library for given time interval. + + :param library_pid: string - the library to filter with + :return: the number of matched documents + :rtype: integer + """ + return RecordsSearch(index=OperationLog.index_name)\ + .filter('range', date=self.date_range)\ + .filter('term', record__type='doc')\ + .filter('term', operation='create')\ + .filter('term', library__value=library_pid)\ + .count() + + def renewals(self, library_pid): + """Number of items with loan extended. + + Number of items with loan extended per library for given time interval + :param library_pid: string - the library to filter with + :return: the number of matched documents + :rtype: integer + """ + return self._get_loan_op_search( + triggers=[ItemCirculationAction.EXTEND])\ + .filter('term', loan__item__library_pid=library_pid)\ + .count() + + def validated_requests(self, library_pid): + """Number of validated requests. + + Number of validated requests per library for given time interval + Match is done on the library of the librarian. + :param library_pid: string - the library to filter with + :return: the number of matched documents + :rtype: integer + """ + return self._get_loan_op_search(triggers=['validate_request'])\ + .filter('term', library__value=library_pid)\ + .count() + + def new_items_by_location(self, library_pid): + """Number of new items per library by location. + + Note: items created and then deleted during the time interval + are not included. + :param library_pid: string - the library to filter with + :return: the number of matched documents + :rtype: dict + """ + search = ItemsSearch()[:0]\ + .filter('range', _created=self.date_range)\ + .filter('term', library__pid=library_pid)\ + .source('location.pid') + search.aggs.bucket('location_pid', 'terms', field='location.pid', + size=10000) + res = search.execute() + stats = {} + location_pids = [ + bucket.key for bucket in res.aggregations.location_pid.buckets] + location_names = self._get_locations_code_name(location_pids) + for bucket in res.aggregations.location_pid.buckets: + stats[location_names[bucket.key]] = bucket.doc_count + return stats + + def items_by_document_type_and_subtype(self, library_pid): + """Number of items per library by document type and sub-type. + + Note: if item has more than one doc type/subtype the item is counted + multiple times + :param library_pid: string - the library to filter with + :return: the number of matched documents + :rtype: dict + """ + search = ItemsSearch()[:0]\ + .filter('range', _created={'lte': self.date_range['lte']})\ + .filter('term', library__pid=library_pid)\ + .source('document.document_type') + search.aggs\ + .bucket('main_type', 'terms', + field='document.document_type.main_type', size=10000) + search.aggs\ + .bucket('subtype', 'terms', + field='document.document_type.subtype', size=10000) + res = search.execute() + stats = { + bucket.key: bucket.doc_count + for bucket in res.aggregations.main_type.buckets + } + for bucket in res.aggregations.subtype.buckets: + stats[bucket.key] = bucket.doc_count + return stats + + def loans_of_transaction_library_by_item_location(self, library_pid): + """Number of circulation operation during the specified timeframe. + + Number of loans of items by location when transaction location + is equal to any of the library locations + :param library_pid: string - the library to filter with + :return: the number of matched circulation operation + :rtype: dict + """ + location_pids = self._get_locations_pid(library_pid) + search = self._get_loan_op_search(triggers=[ + ItemCirculationAction.CHECKIN, + ItemCirculationAction.CHECKOUT + ])\ + .filter('terms', loan__transaction_location__pid=location_pids)\ + .source('loan').scan() + + stats = {} + for s in search: + item_library_pid = s.loan.item.library_pid + item_library_name = self.libraries_map[item_library_pid] + location_name = s.loan.item.holding.location_name + + key = f'{item_library_pid}: {item_library_name} - {location_name}' + stats.setdefault(key, { + # TODO: to be removed as it is already in the key + 'location_name': location_name, + ItemCirculationAction.CHECKIN: 0, + ItemCirculationAction.CHECKOUT: 0}) + stats[key][s.loan.trigger] += 1 + return stats diff --git a/rero_ils/modules/stats/api/pricing.py b/rero_ils/modules/stats/api/pricing.py new file mode 100644 index 0000000000..33a5e89e2c --- /dev/null +++ b/rero_ils/modules/stats/api/pricing.py @@ -0,0 +1,291 @@ +# -*- coding: utf-8 -*- +# +# RERO ILS +# Copyright (C) 2019-2023 RERO +# Copyright (C) 2019-2023 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 . + +"""To compute the statistics for pricing.""" + +import arrow +from dateutil.relativedelta import relativedelta +from flask import current_app +from invenio_search.api import RecordsSearch + +from rero_ils.modules.acquisition.acq_order_lines.api import \ + AcqOrderLinesSearch +from rero_ils.modules.documents.api import DocumentsSearch +from rero_ils.modules.ill_requests.models import ILLRequestStatus +from rero_ils.modules.items.api import ItemsSearch +from rero_ils.modules.items.models import ItemCirculationAction +from rero_ils.modules.libraries.api import LibrariesSearch +from rero_ils.modules.loans.logs.api import LoanOperationLog +from rero_ils.modules.operation_logs.api import OperationLog +from rero_ils.modules.patrons.api import PatronsSearch +from rero_ils.modules.users.models import UserRole + + +class StatsForPricing: + """Statistics for pricing.""" + + def __init__(self, to_date=None): + """Constructor.""" + self.to_date = to_date or arrow.utcnow() - relativedelta(days=1) + self.months_delta = current_app.config.get( + 'RERO_ILS_STATS_BILLING_TIMEFRAME_IN_MONTHS' + ) + _from = (self.to_date - relativedelta( + months=self.months_delta)).format(fmt='YYYY-MM-DDT00:00:00') + _to = self.to_date.format(fmt='YYYY-MM-DDT23:59:59') + self.date_range = {'gte': _from, 'lte': _to} + + def _get_all_libraries(self): + """Get all libraries in the system.""" + return list(LibrariesSearch().source(['pid', 'name', 'organisation']) + .scan()) + + @classmethod + def get_stat_pid(cls, type, date_range): + """Get pid of statistics. + + :param type: type of statistics + :param date_range: statistics time interval + """ + _from = date_range['from'] + _to = date_range['to'] + search = StatsSearch()\ + .filter("term", type=type)\ + .scan() + + stat_pid = list() + for s in search: + if 'date_range' in s and\ + 'from' in s.date_range and 'to' in s.date_range: + if s.date_range['from'] == _from and s.date_range['to'] == _to: + stat_pid.append(s.pid) + if stat_pid: + assert len(stat_pid) == 1 + return stat_pid[0] + return + + def collect(self): + """Compute all the statistics.""" + stats = [] + for lib in self._get_all_libraries(): + stats.append({ + 'library': { + 'pid': lib.pid, + 'name': lib.name + }, + 'number_of_documents': self.number_of_documents(lib.pid), + 'number_of_libraries': self.number_of_libraries( + lib.organisation.pid), + 'number_of_librarians': self.number_of_librarians(lib.pid), + 'number_of_active_patrons': self.number_of_active_patrons( + lib.pid), + 'number_of_order_lines': self.number_of_order_lines(lib.pid), + 'number_of_checkouts': + self.number_of_circ_operations( + lib.pid, ItemCirculationAction.CHECKOUT), + 'number_of_renewals': + self.number_of_circ_operations( + lib.pid, ItemCirculationAction.EXTEND), + 'number_of_validated_ill_requests': + self.number_of_ill_requests_operations( + lib.pid, [ILLRequestStatus.VALIDATED]), + 'number_of_items': self.number_of_items(lib.pid), + 'number_of_new_items': self.number_of_new_items(lib.pid), + 'number_of_deleted_items': self.number_of_deleted_items( + lib.pid), + 'number_of_patrons': self.number_of_patrons( + lib.organisation.pid), + 'number_of_new_patrons': self.number_of_patrons( + lib.organisation.pid), + 'number_of_checkins': + self.number_of_circ_operations( + lib.pid, ItemCirculationAction.CHECKIN), + 'number_of_requests': + self.number_of_circ_operations( + lib.pid, ItemCirculationAction.REQUEST) + }) + return stats + + def number_of_documents(self, library_pid): + """Number of documents linked to my library. + + point in time + :param library_pid: string - the library to filter with + :return: the number of matched documents + :rtype: integer + """ + # can be done using the facet + return DocumentsSearch().filter( + 'term', holdings__organisation__library_pid=library_pid).count() + + def number_of_libraries(self, organisation_pid): + """Number of libraries of the given organisation. + + point in time + :param organisation_pid: string - the organisation to filter with + :return: the number of matched libraries + :rtype: integer + """ + return LibrariesSearch().filter( + 'term', organisation__pid=organisation_pid).count() + + def number_of_librarians(self, library_pid): + """Number of users with a librarian role. + + point in time + :param library_pid: string - the library to filter with + :return: the number of matched librarians + :rtype: integer + """ + return PatronsSearch()\ + .filter('terms', roles=UserRole.PROFESSIONAL_ROLES)\ + .filter('term', libraries__pid=library_pid)\ + .count() + + def number_of_active_patrons(self, library_pid): + """Number of patrons who did a transaction in the past 365 days. + + :param library_pid: string - the library to filter with + :return: the number of matched active patrons + :rtype: integer + """ + ptrns = set() + _to = self.to_date.format(fmt='YYYY-MM-DDT23:59:59') + _from = (self.to_date - relativedelta(months=12))\ + .format(fmt='YYYY-MM-DDT00:00:00') + date_range = {'gte': _from, 'lte': _to} + for res in RecordsSearch(index=LoanOperationLog.index_name)\ + .filter('range', date=date_range)\ + .filter('term', record__type='loan')\ + .filter( + 'terms', + loan__trigger=[ItemCirculationAction.CHECKOUT, + ItemCirculationAction.EXTEND])\ + .filter('term', loan__item__library_pid=library_pid)\ + .source(['loan'])\ + .scan(): + ptrns.add(res.loan.patron.hashed_pid) + + return len(ptrns) + + def number_of_order_lines(self, library_pid): + """Number of order lines created during the specified timeframe. + + :param library_pid: string - the library to filter with + :return: the number of matched order lines + :rtype: integer + """ + return AcqOrderLinesSearch()\ + .filter('range', _created=self.date_range)\ + .filter('term', library__pid=library_pid).count() + + def number_of_circ_operations(self, library_pid, trigger): + """Number of circulation operation during the specified timeframe. + + :param library_pid: string - the library to filter with + :param trigger: string - action name + :return: the number of matched circulation operation + :rtype: integer + """ + return RecordsSearch(index=LoanOperationLog.index_name)\ + .filter('term', record__type='loan')\ + .filter('range', date=self.date_range)\ + .filter('term', loan__trigger=trigger)\ + .filter('term', loan__item__library_pid=library_pid)\ + .count() + + def number_of_ill_requests_operations(self, library_pid, status): + """Number of ILL requests creation or update operations. + + :param library_pid: string - the library to filter with + :param status: list of status to filter with + :return: the number of matched inter library loan request + :rtype: integer + """ + query = RecordsSearch(index=OperationLog.index_name)\ + .filter('term', record__type='illr')\ + .filter('terms', operation=['update', 'create'])\ + .filter('range', _created=self.date_range)\ + .filter('term', ill_request__library_pid=library_pid)\ + .filter('terms', ill_request__status=status) + return query.count() + + # -------- optional ----------- + def number_of_items(self, library_pid): + """Number of items linked to my library. + + point in time + :param library_pid: string - the library to filter with + :return: the number of matched items + :rtype: integer + """ + # can be done using the facet + return ItemsSearch().filter( + 'term', library__pid=library_pid).count() + + def number_of_deleted_items(self, library_pid): + """Number of deleted items during the specified timeframe. + + :param library_pid: string - the library to filter with + :return: the number of matched deleted items + :rtype: integer + """ + return RecordsSearch(index=OperationLog.index_name)\ + .filter('range', date=self.date_range)\ + .filter('term', operation='delete')\ + .filter('term', record__type='item')\ + .filter('term', library__value=library_pid)\ + .count() + + def number_of_new_items(self, library_pid): + """Number of new created items during the specified timeframe. + + :param library_pid: string - the library to filter with + :return: the number of matched newly created items + :rtype: integer + """ + # can be done using the facet or operation logs + return ItemsSearch()\ + .filter('range', _created=self.date_range)\ + .filter('term', library__pid=library_pid).count() + + def number_of_new_patrons(self, organisation_pid): + """New patrons for an organisation during the specified timeframe. + + :param organisation_pid: string - the organisation to filter with + :return: the number of matched newly created patrons + :rtype: integer + """ + return PatronsSearch()\ + .filter('range', _created=self.date_range)\ + .filter('term', organisation__pid=organisation_pid)\ + .count() + + def number_of_patrons(self, organisation_pid): + """Number of users with a librarian role. + + point in time + + :param organisation_pid: string - the organisation to filter with + :return: the number of matched patrons + :rtype: integer + """ + return PatronsSearch()\ + .filter('term', roles='patron')\ + .filter('term', organisation__pid=organisation_pid)\ + .count() diff --git a/rero_ils/modules/stats/cli.py b/rero_ils/modules/stats/cli.py index 826cf336b4..acd96accec 100644 --- a/rero_ils/modules/stats/cli.py +++ b/rero_ils/modules/stats/cli.py @@ -26,7 +26,9 @@ from flask import current_app from flask.cli import with_appcontext -from rero_ils.modules.stats.api import Stat, StatsForLibrarian, StatsForPricing +from .api.api import Stat +from .api.librarian import StatsForLibrarian +from .api.pricing import StatsForPricing @click.group() diff --git a/rero_ils/modules/stats/tasks.py b/rero_ils/modules/stats/tasks.py index 65d70bc352..e2895c4c6e 100644 --- a/rero_ils/modules/stats/tasks.py +++ b/rero_ils/modules/stats/tasks.py @@ -20,7 +20,9 @@ from celery import shared_task from flask import current_app -from .api import Stat, StatsForLibrarian, StatsForPricing +from .api.api import Stat +from .api.librarian import StatsForLibrarian +from .api.pricing import StatsForPricing @shared_task() diff --git a/rero_ils/modules/stats/views.py b/rero_ils/modules/stats/views.py index 0fe21c9013..bb4a8eaa29 100644 --- a/rero_ils/modules/stats/views.py +++ b/rero_ils/modules/stats/views.py @@ -29,7 +29,8 @@ from elasticsearch_dsl import Q from flask import Blueprint, abort, make_response, render_template, request -from .api import Stat, StatsForPricing, StatsSearch +from .api.api import Stat +from .api.pricing import StatsForPricing from .permissions import check_logged_as_admin, check_logged_as_librarian from .serializers import StatCSVSerializer diff --git a/setup.py b/setup.py index 89f30b0028..a85003476f 100644 --- a/setup.py +++ b/setup.py @@ -221,7 +221,7 @@ def run(self): 'patron_transaction_event_id = rero_ils.modules.patron_transaction_events.api:patron_transaction_event_id_minter', 'patron_transaction_id = rero_ils.modules.patron_transactions.api:patron_transaction_id_minter', 'patron_type_id = rero_ils.modules.patron_types.api:patron_type_id_minter', - 'stat_id = rero_ils.modules.stats.api:stat_id_minter', + 'stat_id = rero_ils.modules.stats.api.api:stat_id_minter', 'template_id = rero_ils.modules.templates.api:template_id_minter', 'vendor_id = rero_ils.modules.vendors.api:vendor_id_minter', ], @@ -251,7 +251,7 @@ def run(self): 'patron_transaction_event_id = rero_ils.modules.patron_transaction_events.api:patron_transaction_event_id_fetcher', 'patron_transaction_id = rero_ils.modules.patron_transactions.api:patron_transaction_id_fetcher', 'patron_type_id = rero_ils.modules.patron_types.api:patron_type_id_fetcher', - 'stat_id = rero_ils.modules.stats.api:stat_id_fetcher', + 'stat_id = rero_ils.modules.stats.api.api:stat_id_fetcher', 'template_id = rero_ils.modules.templates.api:template_id_minter', 'vendor_id = rero_ils.modules.vendors.api:vendor_id_fetcher', 'operation_log_id = rero_ils.modules.operation_logs.api:operation_log_id_fetcher', diff --git a/tests/api/stats/conftest.py b/tests/api/stats/conftest.py index 48ed912efb..83c3b124fe 100644 --- a/tests/api/stats/conftest.py +++ b/tests/api/stats/conftest.py @@ -20,7 +20,9 @@ import arrow import pytest -from rero_ils.modules.stats.api import Stat, StatsForLibrarian, StatsForPricing +from rero_ils.modules.stats.api.api import Stat +from rero_ils.modules.stats.api.librarian import StatsForLibrarian +from rero_ils.modules.stats.api.pricing import StatsForPricing @pytest.fixture(scope='module') diff --git a/tests/fixtures/circulation.py b/tests/fixtures/circulation.py index 1fc2538500..c636e216f9 100644 --- a/tests/fixtures/circulation.py +++ b/tests/fixtures/circulation.py @@ -20,6 +20,7 @@ from copy import deepcopy from datetime import datetime, timedelta, timezone +import mock import pytest from invenio_circulation.search.api import LoansSearch from invenio_db import db @@ -31,6 +32,7 @@ from rero_ils.modules.ill_requests.api import ILLRequest, ILLRequestsSearch from rero_ils.modules.items.api import ItemsSearch from rero_ils.modules.loans.api import Loan +from rero_ils.modules.loans.logs.api import LoanOperationLog from rero_ils.modules.loans.models import LoanState from rero_ils.modules.notifications.api import NotificationsSearch from rero_ils.modules.notifications.models import NotificationType @@ -627,18 +629,23 @@ def loan_validated_sion( loan = list(item2_lib_sion.get_loans_by_item_pid( item_pid=item2_lib_sion.pid))[0] - item2_lib_sion.validate_request( - pid=loan.pid, - patron_pid=patron_sion.pid, - transaction_location_pid=loc_public_sion.pid, - transaction_user_pid=librarian_sion.pid, - transaction_date=transaction_date, - pickup_location_pid=loc_public_sion.pid, - document_pid=item2_lib_sion.replace_refs()['document']['pid'] - ) + with mock.patch( + 'rero_ils.modules.loans.logs.api.current_librarian', + librarian_sion + ): + item2_lib_sion.validate_request( + pid=loan.pid, + patron_pid=patron_sion.pid, + transaction_location_pid=loc_public_sion.pid, + transaction_user_pid=librarian_sion.pid, + transaction_date=transaction_date, + pickup_location_pid=loc_public_sion.pid, + document_pid=item2_lib_sion.replace_refs()['document']['pid'] + ) flush_index(ItemsSearch.Meta.index) flush_index(LoansSearch.Meta.index) flush_index(NotificationsSearch.Meta.index) + flush_index(LoanOperationLog.index_name) loan = list(item2_lib_sion.get_loans_by_item_pid( item_pid=item2_lib_sion.pid))[0] return loan @@ -712,6 +719,7 @@ def loan_due_soon_martigny( ) flush_index(ItemsSearch.Meta.index) flush_index(LoansSearch.Meta.index) + flush_index(LoanOperationLog.index_name) loan_pid = item.get_loan_pid_with_item_on_loan(item.pid) loan = Loan.get_record_by_pid(loan_pid) diff --git a/tests/ui/stats/conftest.py b/tests/ui/stats/conftest.py new file mode 100644 index 0000000000..e10dfb349b --- /dev/null +++ b/tests/ui/stats/conftest.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# +# RERO ILS +# Copyright (C) 2021 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 . + +"""Pytest fixtures for stats REST tests.""" + +import arrow +import pytest + +from rero_ils.modules.stats.api.librarian import StatsForLibrarian +from rero_ils.modules.stats.api.pricing import StatsForPricing + + +@pytest.fixture(scope='module') +def stat_for_pricing(document, lib_martigny): + """Stats for Pricing.""" + yield StatsForPricing(to_date=arrow.utcnow()) + + +@pytest.fixture(scope='module') +def stat_for_librarian(document, lib_martigny): + """Stats for Librarian.""" + yield StatsForLibrarian(to_date=arrow.utcnow()) diff --git a/tests/ui/stats/test_stats_librarian.py b/tests/ui/stats/test_stats_librarian.py new file mode 100644 index 0000000000..73506affa5 --- /dev/null +++ b/tests/ui/stats/test_stats_librarian.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +# +# RERO ILS +# Copyright (C) 2023 RERO +# Copyright (C) 2023 UCL +# +# 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 . + +"""Stats Librarian tests.""" + +import mock +from invenio_db import db +from utils import flush_index + +from rero_ils.modules.documents.api import Document +from rero_ils.modules.loans.logs.api import LoanOperationLog +from rero_ils.modules.operation_logs.api import OperationLog +from rero_ils.modules.stats.api.librarian import StatsForLibrarian + + +def test_stats_librarian_collect(stat_for_librarian): + """Test the stat librarian collect keys.""" + + assert list(stat_for_librarian.collect()[0].keys()) == [ + 'library', 'checkouts_for_transaction_library', + 'checkouts_for_owning_library', 'active_patrons_by_postal_code', + 'new_active_patrons_by_postal_code', 'new_documents', 'new_items', + 'renewals', 'validated_requests', 'items_by_document_type_and_subtype', + 'new_items_by_location', + 'loans_of_transaction_library_by_item_location' + ] + + +def test_stats_librarian_checkouts_for_transaction_library( + stat_for_librarian, loan_due_soon_martigny, lib_martigny, lib_sion): + """Test the number of circulation operation during the specified timeframe. + """ + assert stat_for_librarian\ + .checkouts_for_transaction_library('foo') == 0 + assert stat_for_librarian\ + .checkouts_for_transaction_library(lib_sion.pid) == 0 + assert stat_for_librarian\ + .checkouts_for_transaction_library(lib_martigny.pid) == 1 + + +def test_stats_librarian_checkouts_for_owning_library( + stat_for_librarian, loan_due_soon_martigny, lib_martigny, lib_sion): + """Test the number of circulation operation during the specified timeframe. + """ + assert stat_for_librarian\ + .checkouts_for_owning_library('foo') == 0 + assert stat_for_librarian\ + .checkouts_for_owning_library(lib_sion.pid) == 0 + assert stat_for_librarian\ + .checkouts_for_owning_library(lib_martigny.pid) == 1 + + +def test_stats_librarian_active_patrons_by_postal_code( + stat_for_librarian, loan_due_soon_martigny, lib_martigny): + """Test the number of circulation operation during the specified timeframe. + """ + assert stat_for_librarian\ + .active_patrons_by_postal_code('foo') == {} + assert stat_for_librarian\ + .active_patrons_by_postal_code(lib_martigny.pid) == {'1920': 1} + + # with new patrons + assert stat_for_librarian\ + .active_patrons_by_postal_code('foo', new_patrons=True) == {} + assert stat_for_librarian\ + .active_patrons_by_postal_code( + lib_martigny.pid, new_patrons=True) == {'1920': 1} + stat = StatsForLibrarian() + assert stat\ + .active_patrons_by_postal_code( + lib_martigny.pid, new_patrons=True) == {} + + +def test_stats_librarian_new_documents( + stat_for_librarian, document_data, lib_martigny, librarian_martigny): + """Test the number of new documents per library for given time interval.""" + assert stat_for_librarian.new_documents('foo') == 0 + with mock.patch( + 'rero_ils.modules.operation_logs.extensions.current_librarian', + librarian_martigny + ): + # needs to create a new document created by a librarian + Document.create( + data=document_data, delete_pid=True, dbcommit=False, reindex=False) + flush_index(OperationLog.index_name) + + assert stat_for_librarian.new_documents(lib_martigny.pid) == 1 + stat = StatsForLibrarian() + assert stat.new_documents(lib_martigny.pid) == 0 + db.session.rollback() + + +def test_stats_librarian_renewals( + stat_for_librarian, lib_martigny, loan_due_soon_martigny, + loc_public_martigny, librarian_martigny): + """Test the number of items with loan extended.""" + assert stat_for_librarian.renewals('foo') == 0 + loan_due_soon_martigny.item.extend_loan( + pid=loan_due_soon_martigny.pid, + transaction_location_pid=loc_public_martigny.pid, + transaction_user_pid=librarian_martigny.pid + ) + flush_index(LoanOperationLog.index_name) + assert stat_for_librarian.renewals(lib_martigny.pid) == 1 + + +def test_stats_librarian_validated_requests( + stat_for_librarian, lib_sion, loan_validated_sion): + """Test the number of validated requests.""" + assert stat_for_librarian.validated_requests('foo') == 0 + assert stat_for_librarian.validated_requests(lib_sion.pid) == 1 + + +def test_stats_librarian_new_items_by_location( + stat_for_librarian, item_lib_martigny, loc_public_martigny): + """Test the number of new items per library by location.""" + loc = loc_public_martigny + assert stat_for_librarian.new_items_by_location('foo') == {} + assert stat_for_librarian.new_items_by_location( + item_lib_martigny.library_pid)[f'{loc["code"]} - {loc["name"]}'] >= 1 + stat = StatsForLibrarian() + assert stat.new_items_by_location(item_lib_martigny.library_pid) == {} + + +def test_stats_librarian_items_by_document_type_and_subtype( + stat_for_librarian, item_lib_martigny, loc_public_martigny): + """Test the number of items per library by document type and sub-type.""" + loc = loc_public_martigny + assert stat_for_librarian.items_by_document_type_and_subtype('foo') == {} + assert stat_for_librarian.items_by_document_type_and_subtype( + item_lib_martigny.library_pid)['docmaintype_book'] >= 1 + assert stat_for_librarian.items_by_document_type_and_subtype( + item_lib_martigny.library_pid)['docsubtype_other_book'] >= 1 + + +def test_stats_librarian_loans_of_transaction_library_by_item_location( + stat_for_librarian, loan_due_soon_martigny, lib_martigny, + loc_public_martigny): + """Test the number of circulation operation during the specified timeframe. + """ + assert stat_for_librarian\ + .loans_of_transaction_library_by_item_location('foo') == {} + key = f'{lib_martigny.pid}: {lib_martigny["name"]} -'\ + f' {loc_public_martigny["name"]}' + res = stat_for_librarian\ + .loans_of_transaction_library_by_item_location(lib_martigny.pid) + assert res[key]['checkin'] == 0 + assert res[key]['checkout'] >= 0 diff --git a/tests/ui/stats/test_stats_pricing.py b/tests/ui/stats/test_stats_pricing.py new file mode 100644 index 0000000000..e85af19750 --- /dev/null +++ b/tests/ui/stats/test_stats_pricing.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +# +# RERO ILS +# Copyright (C) 2023 RERO +# Copyright (C) 2023 UCL +# +# 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 . + +"""Stats Pricing tests.""" + +import mock +from invenio_db import db +from utils import flush_index + +from rero_ils.modules.ill_requests.models import ILLRequestStatus +from rero_ils.modules.items.models import ItemCirculationAction +from rero_ils.modules.loans.logs.api import LoanOperationLog +from rero_ils.modules.stats.api.pricing import StatsForPricing + + +def test_stats_pricing_collect(stat_for_pricing): + """Test the stat pricing collect keys.""" + + assert list(stat_for_pricing.collect()[0].keys()) == [ + 'library', 'number_of_documents', 'number_of_libraries', + 'number_of_librarians', 'number_of_active_patrons', + 'number_of_order_lines', 'number_of_checkouts', 'number_of_renewals', + 'number_of_validated_ill_requests', 'number_of_items', + 'number_of_new_items', 'number_of_deleted_items', 'number_of_patrons', + 'number_of_new_patrons', 'number_of_checkins', 'number_of_requests'] + + +def test_stats_pricing_number_of_documents( + stat_for_pricing, item_lib_martigny): + """Test the number of documents linked to my library.""" + assert stat_for_pricing.number_of_documents('foo') == 0 + assert stat_for_pricing\ + .number_of_documents(item_lib_martigny.library_pid) == 1 + + +def test_stats_pricing_number_of_libraries(stat_for_pricing, lib_martigny): + """Test the Number of libraries of the given organisation.""" + assert stat_for_pricing.number_of_libraries('foo') == 0 + assert stat_for_pricing\ + .number_of_libraries(lib_martigny.organisation_pid) == 1 + + +def test_stats_pricing_number_of_librarians( + stat_for_pricing, librarian_martigny): + """Test the number of users with a librarian role.""" + assert stat_for_pricing.number_of_librarians('foo') == 0 + lib_pid = librarian_martigny.replace_refs()['libraries'][0]['pid'] + assert stat_for_pricing.number_of_librarians(lib_pid) == 1 + + +def test_stats_pricing_number_of_active_patrons( + stat_for_pricing, loan_due_soon_martigny, lib_martigny): + """Test the number of patrons who did a transaction in the past 365 days. + """ + assert stat_for_pricing.number_of_active_patrons('foo') == 0 + assert stat_for_pricing.number_of_active_patrons(lib_martigny.pid) == 1 + + +def test_stats_pricing_number_of_order_lines( + stat_for_pricing, acq_order_line_fiction_martigny): + """Test the number of order lines created during the specified timeframe. + """ + assert stat_for_pricing.number_of_order_lines('foo') == 0 + lib_pid = acq_order_line_fiction_martigny.library_pid + assert stat_for_pricing.number_of_order_lines(lib_pid) == 1 + + +def test_stats_pricing_number_of_circ_operations( + stat_for_pricing, loan_due_soon_martigny, lib_martigny): + """Test the number of circulation operation during the specified + timeframe. + """ + assert stat_for_pricing\ + .number_of_circ_operations('foo', ItemCirculationAction.CHECKOUT) == 0 + assert stat_for_pricing\ + .number_of_circ_operations( + lib_martigny.pid, ItemCirculationAction.EXTEND) == 0 + assert stat_for_pricing\ + .number_of_circ_operations( + lib_martigny.pid, ItemCirculationAction.CHECKOUT) == 1 + + +def test_stats_pricing_number_of_ill_requests_operations( + stat_for_pricing, ill_request_martigny, lib_martigny): + """Test the number of ILL requests creation or update operations.""" + assert stat_for_pricing\ + .number_of_ill_requests_operations( + 'foo', [ILLRequestStatus.VALIDATED]) == 0 + lib_pid = lib_martigny.pid + assert stat_for_pricing\ + .number_of_ill_requests_operations( + lib_pid, [ILLRequestStatus.CLOSED]) == 0 + assert stat_for_pricing\ + .number_of_ill_requests_operations( + lib_pid, [ILLRequestStatus.PENDING]) == 1 + + +def test_stats_pricing_number_of_items( + stat_for_pricing, item_lib_martigny): + """Test the number of items linked to my library.""" + assert stat_for_pricing.number_of_items('foo') == 0 + # loans used in previous tests can adds some items + assert stat_for_pricing\ + .number_of_items(item_lib_martigny.library_pid) >= 1 + + +def test_stats_pricing_number_of_new_items( + stat_for_pricing, item_lib_martigny): + """Test the umber of new created items during the specified timeframe.""" + assert stat_for_pricing.number_of_new_items('foo') == 0 + # loans used in previous tests can adds some items + assert stat_for_pricing\ + .number_of_new_items(item_lib_martigny.library_pid) >= 1 + from rero_ils.modules.stats.api.pricing import StatsForPricing + + # today item creation is excluded + stat = StatsForPricing() + assert stat\ + .number_of_new_items(item_lib_martigny.library_pid) == 0 + + +def test_stats_pricing_number_of_deleted_items( + stat_for_pricing, item_lib_martigny, librarian_martigny): + """Test the number of deleted items during the specified timeframe.""" + assert stat_for_pricing.number_of_deleted_items('foo') == 0 + with mock.patch( + 'rero_ils.modules.operation_logs.extensions.current_librarian', + librarian_martigny + ): + item_lib_martigny.delete(False, False, False) + flush_index(LoanOperationLog.index_name) + assert stat_for_pricing\ + .number_of_deleted_items(item_lib_martigny.library_pid) == 1 + db.session.rollback() + + +def test_stats_pricing_number_of_patrons( + stat_for_pricing, patron_martigny): + """Test the number of users with a librarian role.""" + assert stat_for_pricing.number_of_patrons('foo') == 0 + # loans used in previous tests can adds some items + assert stat_for_pricing\ + .number_of_patrons(patron_martigny.organisation_pid) >= 1 + + +def test_stats_pricing_number_of_new_patrons( + stat_for_pricing, patron_martigny): + """Test the number of new patrons for an organisation during the specified + timeframe. + """ + assert stat_for_pricing.number_of_patrons('foo') == 0 + # loans used in previous tests can adds some items + assert stat_for_pricing\ + .number_of_patrons(patron_martigny.organisation_pid) >= 1 + # today item creation is excluded + stat = StatsForPricing() + assert stat\ + .number_of_new_patrons(patron_martigny.organisation_pid) == 0