diff --git a/rero_ils/modules/stats/api.py b/rero_ils/modules/stats/api.py index f73e41a4c2..a8dfd251f6 100644 --- a/rero_ils/modules/stats/api.py +++ b/rero_ils/modules/stats/api.py @@ -23,7 +23,6 @@ import arrow from dateutil.relativedelta import relativedelta from flask import current_app -from flask_login import current_user from invenio_search.api import RecordsSearch from .models import StatIdentifier, StatMetadata @@ -36,8 +35,9 @@ from ..loans.logs.api import LoanOperationLog from ..locations.api import LocationsSearch from ..minters import id_minter -from ..patrons.api import PatronsSearch +from ..patrons.api import Patron, PatronsSearch, current_librarian from ..providers import Provider +from ..utils import extracted_data_from_ref # provider StatProvider = type( @@ -340,40 +340,26 @@ def get_librarian_library_pids(cls): Note: for system_librarian includes libraries of organisation. """ - patron_search = PatronsSearch()\ - .filter('term', user_id=current_user.id)\ - .source(['libraries', 'roles', 'organisation']).scan() - patron_data = next(patron_search) - - library_pids = set() - if 'librarian' in patron_data.roles: - for library_pid in patron_data.libraries: - library_pids.add(library_pid.pid) - + if Patron.ROLE_LIBRARIAN in current_librarian["roles"]: + library_pids = set([extracted_data_from_ref(lib) + for lib in current_librarian + .get('libraries', [])]) # case system_librarian: add libraries of organisation - if 'system_librarian' in patron_data.roles: - organisation_libraries_pid = LibrariesSearch()\ - .filter('term', - organisation__pid=patron_data.organisation.pid)\ + if Patron.ROLE_SYSTEM_LIBRARIAN in current_librarian["roles"]: + patron_organisation = current_librarian.get_organisation() + libraries_search = LibrariesSearch()\ + .filter('term', organisation__pid=patron_organisation.pid)\ .source(['pid']).scan() - for s in organisation_libraries_pid: - library_pids.add(s.pid) + library_pids = library_pids.union( + set([s.pid for s in libraries_search]) + ) return list(library_pids) - @classmethod - def get_librarian_libraries(cls): - """Get libraries of librarian.""" - library_pids = cls.get_librarian_library_pids() - return LibrariesSearch().filter('terms', pid=library_pids)\ - .source(['pid', 'name', 'organisation']).scan() - - def collect(self, **kwargs): + def collect(self): """Compute statistics for librarian.""" stats = [] - if 'libraries' in kwargs: - libraries = kwargs.get("libraries") - else: - libraries = self.get_all_libraries() + libraries = self.get_all_libraries() + libraries_map = {lib.pid: lib.name for lib in libraries} for lib in libraries: stats.append({ @@ -381,34 +367,40 @@ def collect(self, **kwargs): 'pid': lib.pid, 'name': lib.name }, - 'number_of_loans_by_transaction_library': - self.number_of_loans_by_transaction_library(lib.pid, - ['checkout']), - 'number_of_loans_for_items_in_library': - self.number_of_loans_for_items_in_library(lib.pid, - ['checkout']), - 'number_of_patrons_by_postal_code': - self.number_of_patrons_by_postal_code(lib.pid, - ['request', - 'checkin', - 'checkout']), - 'number_of_new_patrons_by_postal_code': - self.number_of_new_patrons_by_postal_code(lib.pid, - ['request', - 'checkin', - 'checkout']), - 'number_of_new_documents': - self.number_of_new_documents(lib.pid), - 'number_of_new_items': + 'checkouts_for_transaction_library': + self.checkouts_for_transaction_library(lib.pid, + ['checkout']), + 'checkouts_for_owning_library': + self.checkouts_for_owning_library(lib.pid, + ['checkout']), + 'active_patrons_by_postal_code': + self.active_patrons_by_postal_code(lib.pid, + ['request', + 'checkin', + 'checkout']), + 'new_active_patrons_by_postal_code': + self.new_active_patrons_by_postal_code(lib.pid, + ['request', + 'checkin', + 'checkout']), + 'new_documents': + self.new_documents(lib.pid), + 'new_items': self.number_of_new_items(lib.pid), - 'number_of_extended_items': - self.number_of_extended_items(lib.pid, ['extend']), - 'number_of_validated_requests': - self.number_of_validated_requests(lib.pid, ['validate']), - 'number_of_items_by_document_type_and_subtype': - self.number_of_items_by_document_type_subtype(lib.pid), - 'number_of_new_items_by_location': - self.number_of_new_items_by_location(lib.pid) + 'renewals': + self.renewals(lib.pid, ['extend']), + 'validated_requests': + self.validated_requests(lib.pid, ['validate']), + '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, + ['checkin', + 'checkout']) }) return stats @@ -436,7 +428,7 @@ def _get_location_code_name(self, location_pid): location = next(location_search) return f'{location.code} - {location.name}' - def number_of_loans_by_transaction_library(self, library_pid, trigger): + 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 @@ -454,7 +446,7 @@ def number_of_loans_by_transaction_library(self, library_pid, trigger): .filter('terms', loan__transaction_location__pid=location_pids)\ .count() - def number_of_loans_for_items_in_library(self, library_pid, trigger): + 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 @@ -470,7 +462,7 @@ def number_of_loans_for_items_in_library(self, library_pid, trigger): .filter('term', loan__item__library_pid=library_pid)\ .count() - def number_of_patrons_by_postal_code(self, library_pid, trigger): + 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 @@ -507,7 +499,7 @@ def number_of_patrons_by_postal_code(self, library_pid, trigger): patron_pids.add(patron_pid) return stats - def number_of_new_patrons_by_postal_code(self, library_pid, trigger): + 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 @@ -555,7 +547,7 @@ def number_of_new_patrons_by_postal_code(self, library_pid, trigger): return stats - def number_of_new_documents(self, library_pid): + 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 @@ -569,7 +561,7 @@ def number_of_new_documents(self, library_pid): .filter('term', library__value=library_pid)\ .count() - def number_of_extended_items(self, library_pid, trigger): + def renewals(self, library_pid, trigger): """Number of items with loan extended. Number of items with loan extended per library for given time interval @@ -584,7 +576,7 @@ def number_of_extended_items(self, library_pid, trigger): .filter('term', loan__item__library_pid=library_pid)\ .count() - def number_of_validated_requests(self, library_pid, trigger): + def validated_requests(self, library_pid, trigger): """Number of validated requests. Number of validated requests per library for given time interval @@ -601,7 +593,7 @@ def number_of_validated_requests(self, library_pid, trigger): .filter('term', library__value=library_pid)\ .count() - def number_of_new_items_by_location(self, library_pid): + 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 @@ -623,7 +615,7 @@ def number_of_new_items_by_location(self, library_pid): stats[location_code_name] = bucket.doc_count return stats - def number_of_items_by_document_type_subtype(self, library_pid): + 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 @@ -650,6 +642,40 @@ def number_of_items_by_document_type_subtype(self, library_pid): 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, + 'checkin': 0, 'checkout': 0}) + stats[key][s.loan.trigger] += 1 + + return stats + class Stat(IlsRecord): """ItemType class.""" diff --git a/rero_ils/modules/stats/cli.py b/rero_ils/modules/stats/cli.py index 3f1bdca445..896fd9dc59 100644 --- a/rero_ils/modules/stats/cli.py +++ b/rero_ils/modules/stats/cli.py @@ -83,12 +83,14 @@ def collect(type): @stats.command('collect_year') @click.argument('year', type=int) @click.argument('timespan', default='yearly') +@click.option('--n_months', default=12) @click.option('-f', '--force', is_flag=True, default=False) @with_appcontext -def collect_year(year, timespan, force): +def collect_year(year, timespan, n_months, force): """Extract the stats librarian for one year and store them in db. :param year: year of statistics + :param n_months: month up to which the statistics are calculated :param timespan: time interval, can be 'montly' or 'yearly' :param force: force update of stat. """ @@ -96,7 +98,12 @@ def collect_year(year, timespan, force): type = 'librarian' if year: if timespan == 'montly': - for month in range(1, 13): + if n_months not in range(1, 13): + click.secho(f'ERROR: not a valid month', fg='red') + raise click.Abort() + n_months += 1 + + for month in range(1, n_months): first_day = f'{year}-{month:02d}-01T23:59:59'\ .format(fmt='YYYY-MM-DDT23:59:59') first_day = arrow.get(first_day, 'YYYY-MM-DDTHH:mm:ss') @@ -115,7 +122,7 @@ def collect_year(year, timespan, force): click.secho( f'ERROR: statistics of type {type}\ for time interval {_from} - {_to}\ - already exists. Pid: {stat_pid}', fg='red') + already exist. Pid: {stat_pid}', fg='red') return stat_data = dict(type=type, date_range=date_range, @@ -154,7 +161,7 @@ def collect_year(year, timespan, force): click.secho( f'ERROR: statistics of type {type}\ for time interval {_from} - {_to}\ - already exists. Pid: {stat_pid}', fg='red') + already exist. Pid: {stat_pid}', fg='red') return stat_data = dict(type=type, date_range=date_range, diff --git a/rero_ils/modules/stats/permissions.py b/rero_ils/modules/stats/permissions.py index cbd527cef3..25a602c691 100644 --- a/rero_ils/modules/stats/permissions.py +++ b/rero_ils/modules/stats/permissions.py @@ -62,7 +62,7 @@ def read(cls, user, record): if librarian_permission.require().can(): if 'type' not in record or record['type'] != 'librarian': return False - record = filter_stat_by_librarian(current_user, record) + record = filter_stat_by_librarian(record) return admin_permission.require().can() \ or monitoring_permission.require().can() \ @@ -105,8 +105,12 @@ def stats_ui_permission_factory(record, *args, **kwargs): action='read', record=record, cls=StatPermission) -def filter_stat_by_librarian(current_user, record): - """Filter data for libraries of specific user.""" +def filter_stat_by_librarian(record): + """Filter data for libraries of specific librarian/system_librarian. + + :param record: statistics to check. + :return: statistics filtered by libraries. + """ library_pids = StatsForLibrarian.get_librarian_library_pids() record['values'] = list(filter(lambda l: l['library']['pid'] in library_pids, record['values'])) diff --git a/rero_ils/modules/stats/serializers.py b/rero_ils/modules/stats/serializers.py index 1c1e4b0003..f2e73b3fe2 100644 --- a/rero_ils/modules/stats/serializers.py +++ b/rero_ils/modules/stats/serializers.py @@ -22,44 +22,104 @@ from invenio_records_rest.serializers.csv import CSVSerializer, Line from invenio_records_rest.serializers.response import add_link_header -# from invenio_records_rest.serializers.response import record_responsify +from ..patrons.api import Patron class StatCSVSerializer(CSVSerializer): - """Mixin serializing records as JSON.""" + """Process data to write in csv file.""" + + ordered_keys = [ + 'library id', + 'library name', + 'checkouts_for_transaction_library', + 'checkouts_for_owning_library', + 'renewals', + 'validated_requests', + 'active_patrons_by_postal_code', + 'new_active_patrons_by_postal_code', + 'items_by_document_type_and_subtype', + 'new_items', + 'new_items_by_location', + 'new_documents', + 'loans_of_transaction_library_by_item_location' + ] def _format_csv(self, records): """Return the list of records as a CSV string.""" # build a unique list of all records keys as CSV headers assert len(records) == 1 record = records[0] - headers = set(('library name', 'library id')) - for value in record['metadata']['values']: - headers.update([v for v in value.keys() if v != 'library']) - - # write the CSV output in memory - line = Line() - writer = csv.DictWriter(line, fieldnames=sorted(headers)) - writer.writeheader() - yield line.read() - # sort by library name - values = sorted( - record['metadata']['values'], - key=lambda v: v['library']['name'] - ) - for value in values: - library = value['library'] - value['library name'] = library['name'] - value['library id'] = library['pid'] - del value['library'] - for v in value: - if isinstance(value[v], dict): - dict_to_text = '' - for k, m in value[v].items(): - dict_to_text += f'{k} :{m}\r\n' - value[v] = dict_to_text - writer.writerow(value) + + if record['metadata'].get('type') == Patron.ROLE_LIBRARIAN: + # statistics of type librarian + headers = [key.capitalize().replace('_', ' ') + for key in self.ordered_keys] + line = Line() + writer = csv.writer(line) + writer.writerow(headers) + yield line.read() + values = sorted( + record['metadata']['values'], + key=lambda v: v['library']['name'] + ) + + for value in values: + library = value['library'] + value['library name'] = library['name'] + value['library id'] = library['pid'] + del value['library'] + for v in value: + if isinstance(value[v], dict): + dict_to_text = '' + for k, m in value[v].items(): + dict_to_text += f'{k} :{m}\r\n' + value[v] = dict_to_text + value = StatCSVSerializer.sort_dict_by_key(value)[1] + writer.writerow(value) + yield line.read() + else: + # statistics of type billing + headers = set(('library name', 'library id')) + for value in record['metadata']['values']: + headers.update([v for v in value.keys() if v != 'library']) + + # write the CSV output in memory + line = Line() + writer = csv.DictWriter(line, fieldnames=sorted(headers)) + writer.writeheader() yield line.read() + # sort by library name + values = sorted( + record['metadata']['values'], + key=lambda v: v['library']['name'] + ) + + for value in values: + library = value['library'] + value['library name'] = library['name'] + value['library id'] = library['pid'] + del value['library'] + for v in value: + if isinstance(value[v], dict): + dict_to_text = '' + for k, m in value[v].items(): + dict_to_text += f'{k} :{m}\r\n' + value[v] = dict_to_text + writer.writerow(value) + yield line.read() + + @classmethod + def sort_dict_by_key(cls, dictionary): + """Sort dict by dict of keys. + + :param dictionary: dict - an input dictionary + :param ordered_keys: list - the ordered list keys + :returns: a list of tuples + :rtype: list + """ + tuple_list = sorted(dictionary.items(), + key=lambda x: cls.ordered_keys.index(x[0])) + return list(zip(*tuple_list)) def process_dict(self, dictionary): """Transform record dict with nested keys to a flat dict. diff --git a/rero_ils/modules/stats/templates/rero_ils/detailed_view_stats.html b/rero_ils/modules/stats/templates/rero_ils/detailed_view_stats.html index 22ab1a3297..c4ac850115 100644 --- a/rero_ils/modules/stats/templates/rero_ils/detailed_view_stats.html +++ b/rero_ils/modules/stats/templates/rero_ils/detailed_view_stats.html @@ -19,25 +19,73 @@ {%- extends 'rero_ils/page.html' %} {%- block page_body %} {%- block record_body %} -
+ {%- if 'date_range' in rec._source and 'from' in rec._source.date_range %} + {%- if (rec._source.date_range.to|stringtodatetime).month - (rec._source.date_range.from|stringtodatetime).month == 11 %} + {% set stat_name = (rec._source.date_range.from|stringtodatetime).year|string+ + ' (from '+rec._source.date_range.from.split('T')[0]+' to '+rec._source.date_range.to.split('T')[0]+')'%} + {%- else %} + {% set stat_name = rec._source.date_range.from|yearmonthfilter+ + ' (from '+rec._source.date_range.from.split('T')[0]+' to '+rec._source.date_range.to.split('T')[0]+')'%} + {%- endif %} + {{_(stat_name)}} + {% else %} + {{rec._source._created | format_date}} + {% endif %} + | +
+ {{_('View statistics')}} + + | +
{{_('Loans of the transaction library by item location')}} + + | +