10000 library: detect changes on library open hours by zannkukai · Pull Request #3367 · rero/rero-ils · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

library: detect changes on library open hours #3367

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions rero_ils/modules/libraries/api.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
#
# RERO ILS
# Copyright (C) 2019-2022 RERO
# Copyright (C) 2019-2022 UCLouvain
# 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
Expand All @@ -16,7 +16,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""API for manipulating libraries."""
"""API for manipulating `Library` resources."""

from datetime import datetime, timedelta
from functools import partial
Expand All @@ -37,6 +37,7 @@
extracted_data_from_ref, sorted_pids, strtotime

from .exceptions import LibraryNeverOpen
from .extensions import LibraryCalendarChangesExtension
from .models import LibraryAddressType, LibraryIdentifier, LibraryMetadata

# provider
Expand Down Expand Up @@ -78,6 +79,10 @@ class Library(IlsRecord):
}
}

_extensions = [
LibraryCalendarChangesExtension(['opening_hours', 'exception_dates'])
]

def extended_validation(self, **kwargs):
"""Add additional record validation.

Expand Down Expand Up @@ -242,20 +247,21 @@ def is_open(self, date=None, day_only=False):
is_open = False
rule_hours = []

# First of all, change date to be aware and with timezone.
# First, change date to be aware and with timezone.
if isinstance(date, str):
date = date_string_to_utc(date)
if isinstance(date, datetime) and date.tzinfo is None:
date = date.replace(tzinfo=pytz.utc)

# STEP 1 :: check about regular rules
# Each library could defined if a specific weekday is open or closed.
# Each library could define if a specific weekday is open or closed.
# Check into this weekday array if the day is open/closed. If the
# searched weekday isn't defined the default value is closed
#
# If the find rule defined open time periods, check if date_to_check
# is into this periods (depending of `day_only` method argument).
day_name = date.strftime("%A").lower()
# is into one of these periods (depending on `day_only` method
# argument).
day_name = date.strftime('%A').lower()
regular_rule = [
rule for rule in self.get('opening_hours', [])
if rule['day'] == day_name
Expand All @@ -267,14 +273,14 @@ def is_open(self, date=None, day_only=False):
if is_open and not day_only:
is_open = self._is_betweentimes(date.time(), rule_hours)

# STEP 2 :: test each exceptions
# Each library can defined a set of exception dates. These exceptions
# STEP 2 :: test each exception
# Each library can define a set of exception dates. These exceptions
# could be repeatable for a specific interval. Check is some
# exceptions are relevant related to date_to_check and if these
# exception changed the behavior of regular rules.
# exceptions changed the behavior of regular rules.
#
# Each exception can defined open time periods, check if
# date_to_check is into this periods (depending of `day_only`
# Each exception can define open time periods, check if
# date_to_check is into one of these periods (depending on `day_only`
# method argument)
for exception in self._get_exceptions_matching_date(date, day_only):
if is_open != exception['is_open']:
Expand Down
92 changes: 92 additions & 0 deletions rero_ils/modules/libraries/extensions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# -*- 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 <http://www.gnu.org/licenses/>.

"""Invenio extensions for `Library` resources."""
from celery import current_app as celery_app
from deepdiff import DeepDiff
from invenio_cache import current_cache
from invenio_records.extensions import RecordExtension

from .tasks import calendar_changes_update_loans


class LibraryCalendarChangesExtension(RecordExtension):
"""Handle any changes on library calendar.

When the library calendar changes, it could impact related active loans :
If the loan ends now at a closed day/exception date, this loan end_date
must be adapted to next_open_date.
As it could potentially concern many loans, this operation can't be
synchronously operated. So a detached Celery task will be called to execute
changes.

NOTE: If user operates multiple changes on library calendar in a small
period of time, we need to ensure than only last detached task will
be performed. So before running a new task, we MUST abort previously
running task for the same library.
"""

def __init__(self, tracked_fields):
"""Initialization method.

:param tracked_fields: (list<String>) list of tracked fields.
"""
if not isinstance(tracked_fields, list) or not tracked_fields:
raise TypeError("'tracked_fields' is required")
self._changes_detected = False
self._tracked_fields = tracked_fields
super().__init__()

def pre_commit(self, record):
"""Called before a record is committed.

:param record: the new record data.
"""
db_record = record.db_record()
for field_name in self._tracked_fields:
original_field = db_record.get(field_name)
new_field = record.get(field_name)
if DeepDiff(original_field, new_field, ignore_order=True):
self._changes_detected = True
break

def post_commit(self, record):
"""Called after a record is committed.

:param record: the `Library` updated record
"""
if self._changes_detected:
self._changes_detected = False # Reset changes detection
task = calendar_changes_update_loans.s(record).apply_async()
self._cache_current_task(record, task)

@staticmethod
def _cache_current_task(record, task):
"""Store the task_id into the application cache.

:param record: the touched library record.
:param task: the task related to the library.
"""
content = current_cache.get('library-calendar-changes') or {}
# If a previous task is still present into this cache entry, revoke it.
# DEV NOTE : the task MUST clean (remove) this cache entry when task is
# finished.
if task_id := content.pop(record.pid, None):
celery_app.control.revoke(task_id, terminate=True)
content[record.pid] = task.id
current_cache.set('library-calendar-changes', content)
93 changes: 93 additions & 0 deletions rero_ils/modules/libraries/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# -*- 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 <http://www.gnu.org/licenses/>.

"""Tasks on `Library` resource."""
import contextlib

from celery import shared_task
from invenio_cache import current_cache

from .exceptions import LibraryNeverOpen


@shared_task(name='library-calendar-changes-update-loans')
def calendar_changes_update_loans(record_data):
"""Task to update related loans if library calendar changes.

If a library calendar changes, we will ensure that pending loans end_dates
are still coherent with this calendar. If the end_date is now detected as
a closed library date, the end_date of the loan will be updated to the
next opening day of the library.

..notes..
we only check about 'closed date' changes into the library calendar. In
case of new opening day/date is set, pending loan end_date shouldn't be
"down dated" (it will cause more problems with possible already sent
notification).
Example :
* Patron receive a notification at Monday telling that
the return date is Wednesday (because Tuesday is closed).
* Next we edit the calendar to set all tuesday as opening day. In this
use case the loan end_date will set to "Tuesday" (or maybe is already
overdue if the loan is for multiple weeks).
* Patron checkouts the book on Wednesday (as mentioned in received
notification) but a fee will be possibly created.

:param record_data: Data representing the library to check.
"""

def _at_finish():
"""Inner method called when the task finished."""
# DEV NOTES :: Clean the cache to remove task_id when finished
# We build a behavior ot avoid multiple concurrent similar tasks at
# same time. The parent process running the task should place a stamp
# when the task is registered. So, to specify another similar task
# must be started without collision, we need to clean this stamp.
# DEV NOTES :: Why not using 'decorator'
# A better way should be to use `@clean_cache` decorator ; this
# decorator should take the key to clean as argument. But we didn't
# know the key because it's created from `record_data`. This is why
# it's easier to create a small specific function.
cache_content = current_cache.get('library-calendar-changes') or {}
cache_content.pop(library.pid, {})
current_cache.set('library-calendar-changes', cache_content)

from rero_ils.modules.loans.api import LoansIndexer, \
get_on_loan_loans_for_library
from .api import Library

library = Library(record_data)
active_loan_counter = 0
changed_loan_uuids = []
for loan in get_on_loan_loans_for_library(library.pid):
active_loan_counter += 1
if not library.is_open(loan.end_date):
with contextlib.suppress(LibraryNeverOpen):
loan['end_date'] = library \
.next_open(loan.end_date) \
.astimezone(library.get_timezone()) \
.replace(hour=23, minute=59, second=0, microsecond=0)\
.isoformat()
changed_loan_uuids.append(loan.id)
loan.update(loan, dbcommit=True, reindex=False)
indexer = LoansIndexer()
indexer.bulk_index(changed_loan_uuids)
indexer.process_bulk_queue()

_at_finish()
return active_loan_counter, len(changed_loan_uuids)
14 changes: 14 additions & 0 deletions rero_ils/modules/loans/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1181,6 +1181,20 @@ def get_loans_count_by_library_for_patron_pid(patron_pid, filter_states=None):
}


def get_on_loan_loans_for_library(library_pid):
"""Get 'ON_LOAN' loans for a specific library.

:param library_pid: the library pid.
:returns a generator of `Loan` record.
"""
query = current_circulation.loan_search_cls() \
.filter('term', library_pid=library_pid) \
.filter('term', state=LoanState.ITEM_ON_LOAN) \
.source(False)
for id_ in [hit.meta.id for hit in query.scan()]:
yield Loan.get_record(id_)


def get_due_soon_loans(tstamp=None):
"""Return all due_soon loans.

Expand Down
14 changes: 7 additions & 7 deletions rero_ils/modules/loans/extensions.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
#
# RERO ILS
# Copyright (C) 2019-2022 RERO
# Copyright (C) 2019-2022 UCLouvain
# 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
Expand Down Expand Up @@ -36,9 +36,9 @@ class CheckoutLocationExtension(RecordExtension):
def _add_checkout_location(record):
"""Add the checkout location as a new loan field.

During the laon life cycle, the transaction location could be update.
By example, when a loan is extended, the transaction location pid is
updated with the location pid where the extend operation is done. In
During the loan life cycle, the transaction location could be updated.
For example, when a loan is extended, the transaction location pid is
updated with the location pid where the "extend" operation is done. In
this case, it's impossible to retrieve the checkout location pid
without using heavy versioning behavior or external `OperationLog`
module.
Expand Down Expand Up @@ -67,7 +67,7 @@ def _add_request_expiration_date(record):

This value is consistent only if the loan is a validated request
(loan.state == ITEM_AT_DESK). If the loan state is different this
value could represent an other concept.
value could represent another concept.

:param record: the record metadata.
"""
Expand Down Expand Up @@ -110,7 +110,7 @@ def _add_due_soon_date(record):
:param record: the record metadata.
"""
from .utils import get_circ_policy
if record.state == LoanState.ITEM_ON_LOAN and record.get('end_date'):
if record.state == LoanState.ITEM_ON_LOAN and record.end_date:
# find the correct policy based on the checkout location.
circ_policy = get_circ_policy(record, checkout_location=True)
due_date = ciso8601.parse_datetime(record.end_date).replace(
Expand Down
18 changes: 6 additions & 12 deletions rero_ils/modules/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,20 +574,14 @@ def set_timestamp(name, **kwargs):
timestamps externally via url requests.

:param name: name of time stamp.
:param kwargs: any additional
:returns: time of time stamp
"""
time_stamps = current_cache.get('timestamps')
if not time_stamps:
time_stamps = {}
time_stamps = current_cache.get('timestamps') or {}
utc_now = datetime.utcnow()
time_stamps[name] = {}
time_stamps[name]['time'] = utc_now
for key, value in kwargs.items():
time_stamps[name][key] = value
time_stamps[name]['name'] = name
time_stamps[name] = kwargs | {'time': utc_now, 'name': name}
if not current_cache.set(key='timestamps', value=time_stamps, timeout=0):
current_app.logger.warning(
f'Can not set time stamp for: {name}')
current_app.logger.warning(f'Can not set time stamp for: {name}')
return utc_now


Expand Down Expand Up @@ -631,7 +625,7 @@ def profile(output_file=None, sort_by='cumulative', lines_to_print=None,
def inner(func):
@wraps(func)
def wrapper(*args, **kwargs):
_output_file = output_file or func.__name__ + '.prof'
_output_file = output_file or f'{func.__name__}.prof'
pr = cProfile.Profile()
pr.enable()
retval = func(*args, **kwargs)
Expand Down Expand Up @@ -670,7 +664,7 @@ def wrapped(*args, **kwargs):


def get_timestamp(name):
"""Get timestamp in current cache.
"""Get timestamp from current cache.

:param name: name of time stamp.
:returns: data for time stamp
Expand Down
Loading
0