Skip to content

Commit

Permalink
library: correct exception dates behavior.
Browse files Browse the repository at this point in the history
Closes rero#1734.

Co-Authored-by: Renaud Michotte <renaud.michotte@gmail.com>
  • Loading branch information
zannkukai committed Mar 11, 2021
1 parent 903dac1 commit 855db79
Show file tree
Hide file tree
Showing 3 changed files with 192 additions and 138 deletions.
195 changes: 85 additions & 110 deletions rero_ils/modules/libraries/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,85 +128,6 @@ def _is_betweentimes(self, time_to_test, times):
(time_to_test <= end_time))
return times_open

def _is_in_period(self, datetime_to_test, exception_date, day_only):
"""Test if date is period."""
start_date = exception_date['start_date']
if isinstance(exception_date['start_date'], str):
start_date = date_string_to_utc(start_date)
end_date = exception_date.get('end_date')
if end_date:
if isinstance(end_date, str):
end_date = date_string_to_utc(end_date)
is_in_period = (
datetime_to_test.date() - start_date.date()
).days >= 0
is_in_period = is_in_period and (
end_date.date() - datetime_to_test.date()
).days >= 0
return True, is_in_period
if not end_date and day_only:
# case when exception if full day
if datetime_to_test.date() == start_date.date():
return False, True
return False, False

def _is_in_repeat(self, datetime_to_test, start_date, repeat):
"""Test repeating date."""
if repeat:
period = repeat['period'].upper()
interval = repeat['interval']
datelist_to_test = list(
rrule(
freq=FREQNAMES.index(period),
until=datetime_to_test,
interval=interval,
dtstart=start_date
)
)
for date in datelist_to_test:
if date.date() == datetime_to_test.date():
return True
return datetime_to_test.date() == start_date

def _has_exception(self, _open, date, exception_dates,
day_only=False):
"""Test the day has an exception."""
exception = _open
for exception_date in exception_dates:
if isinstance(exception_date['start_date'], str):
start_date = date_string_to_utc(exception_date['start_date'])
repeat = exception_date.get('repeat')
if _open:
# test for closed exceptions
if not exception_date['is_open']:
has_period, is_in_period = self._is_in_period(
date,
exception_date,
day_only
)
if has_period and is_in_period:
exception = False
if not has_period and is_in_period:
# case when exception if full day
exception = exception_date['is_open']
if self._is_in_repeat(date, start_date, repeat):
exception = False
# we found a closing exception
if not exception:
return False
else:
# test for opened exceptions
if exception_date['is_open']:
if self._is_in_repeat(date, start_date, repeat):
exception = True
if not exception and not day_only:
exception = self._is_betweentimes(
date.time(),
exception_date.get('times', [])
)
return exception
return exception

def _has_is_open(self):
"""Test if library has opening days."""
opening_hours = self.get('opening_hours')
Expand All @@ -221,47 +142,101 @@ def _has_is_open(self):
return True
return False

def _get_exceptions_matching_date(self, date_to_check, day_only=False):
"""get all exception matching a given date."""
for exception in self.get('exception_dates', []):
# Get the start date and the gap (in days) between start date and
# end date. If no end_date are supplied, the gap will be 0.
start_date = date_string_to_utc(exception['start_date'])
end_date = start_date
day_gap = 0
if exception.get('end_date'):
end_date = date_string_to_utc(exception.get('end_date'))
day_gap = (end_date - start_date).days

# If the exception is repeatable, then the start_date should be the
# nearest date (lower or equal than date_to_check) related to the
# repeat period/interval definition. To know that, we need to know
# all exception dates possible (form exception start_date to
# date_to_check) and get only the last one.
if exception.get('repeat'):
period = exception['repeat']['period'].upper()
exception_dates = rrule(
freq=FREQNAMES.index(period),
until=date_to_check,
interval=exception['repeat']['interval'],
dtstart=start_date
)
for start_date in exception_dates:
pass
end_date = start_date + timedelta(days=day_gap)

# Now, check if exception is matching for the date_to_check
# If exception defined times, we need to check if the date_to_check
# is includes into theses time intervals (only if `day_only` method
# argument is set)
if start_date.date() <= date_to_check.date() <= end_date.date():
if exception.get('times') and not day_only:
times = exception.get('times')
if self._is_betweentimes(date_to_check.time(), times):
yield exception
else:
yield exception

def is_open(self, date=datetime.now(pytz.utc), day_only=False):
"""Test library is open."""
_open = False
is_open = False
rule_hours = []

# Change date to be aware and with timezone.
# First of all, change date to be aware and with timezone.
if isinstance(date, str):
date = date_string_to_utc(date)
if isinstance(date, datetime):
if date.tzinfo is None:
date = date.replace(tzinfo=pytz.utc)
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.
# 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()
for opening_hour in self['opening_hours']:
if day_name == opening_hour['day']:
_open = opening_hour['is_open']
hours = opening_hour.get('times', [])
break
times_open = _open
if _open and not day_only:
times_open = self._is_betweentimes(date.time(), hours)
# test the exceptions
exception_dates = self.get('exception_dates')
if exception_dates:
exception = self._has_exception(
_open=times_open,
date=date,
exception_dates=exception_dates,
day_only=day_only
)
if exception != times_open:
times_open = not times_open
return times_open
regular_rule = [
rule for rule in self['opening_hours']
if rule['day'] == day_name
]
if regular_rule:
is_open = regular_rule[0].get('is_open', False)
rule_hours = regular_rule[0].get('times', [])

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
# 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.
#
# Each exception can defined open time periods, check if
# date_to_check is into this periods (depending of `day_only`
# method argument)
for exception in self._get_exceptions_matching_date(date, day_only):
if is_open != exception['is_open']:
is_open = not is_open

return is_open

def _get_opening_hour_by_day(self, day_name):
"""Get the library opening hour for a specific day."""
day_name = day_name.lower()
days = [day for day in self.get('opening_hours', [])
if day.get('day') == day_name and day.get('is_open', False)]
if days:
times = days[0].get('times', [])
if times:
return times[0].get('start_time')
days = [
day for day in self.get('opening_hours', [])
if day['day'] == day_name and day['is_open']
]
if days and days[0]['times']:
return days[0]['times'][0]['start_time']

def next_open(self, date=datetime.now(pytz.utc), previous=False,
ensure=False):
Expand Down
17 changes: 15 additions & 2 deletions tests/data/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,11 @@
"end_date": "2019-01-06",
"is_open": false,
"start_date": "2018-12-22",
"title": "Vacances de No\u00ebl"
"title": "Vacances de No\u00ebl",
"repeat": {
"interval": 1,
"period": "yearly"
}
},
{
"is_open": true,
Expand All @@ -107,7 +111,7 @@
"start_time": "10:00"
}
],
"title": "Samdi du livre"
"title": "Samedi du livre"
},
{
"is_open": false,
Expand All @@ -117,6 +121,15 @@
},
"start_date": "2019-08-01",
"title": "1er ao\u00fbt"
},
{
"is_open": false,
"repeat": {
"interval": 2,
"period": "monthly"
},
"start_date": "2019-01-01",
"title": "1er du mois, 1 mois sur 2"
}
]
},
Expand Down
118 changes: 92 additions & 26 deletions tests/ui/libraries/test_libraries_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

from __future__ import absolute_import, print_function

from datetime import datetime, timedelta

import pytz
from dateutil import parser

Expand All @@ -42,38 +44,102 @@ def test_library_create(db, org_martigny, lib_martigny_data):


def test_libraries_is_open(lib_martigny):
"""Test library creation."""
"""Test library 'open' methods."""
saturday = '2018-12-15 11:00'
library = lib_martigny
assert library.is_open(date=saturday)

assert not library.is_open(date_string_to_utc('2019-08-01'))
assert not library.is_open(date_string_to_utc('2222-8-1'))

del library['exception_dates']
library.replace(library.dumps(), dbcommit=True)

monday = '2018-12-10 08:00'
assert library.is_open(date=monday)
monday = '2018-12-10 06:00'
assert not library.is_open(date=monday)

assert library.next_open(
date=saturday
).date() == parser.parse('2018-12-17').date()
assert library.next_open(
date=saturday,
previous=True
).date() == parser.parse('2018-12-14').date()

assert library.count_open(start_date=monday, end_date=saturday) == 5
library = lib_martigny

def next_weekday(d, weekday):
"""Get the next weekday after a giver date."""
# 0=Monday, 1=Tuesday, ...
days_ahead = weekday - d.weekday()
if days_ahead <= 0: # Target day already happened this week
days_ahead += 7
return d + timedelta(days_ahead)

# CASE 1 :: basic tests. According to library settings:
# * monday --> friday :: 6 AM --> closed
# * monday --> friday :: 12 AM --> open
# * saturday & sunday :: closed all day
for day_idx in range(0, 5):
test_date = next_weekday(datetime.now(), day_idx)
test_date = test_date.replace(hour=6, minute=0)
assert not library.is_open(test_date)
test_date = test_date.replace(hour=12)
assert library.is_open(test_date)
test_date = next_weekday(datetime.now(), 5)
assert not library.is_open(test_date)
test_date = next_weekday(datetime.now(), 6)
assert not library.is_open(test_date)

# CASE 2 :: Check single exception dates
# * According to library setting, the '2018-12-15' day is an exception
# not repeatable. It's a saturday (normally closed), but defined as
# open by exception.
exception_date = date_string_to_utc('2018-12-15')
exception_date = exception_date.replace(hour=20, minute=0)
assert exception_date.weekday() == 5
assert not library.is_open(exception_date)
exception_date = exception_date.replace(hour=12, minute=0)
assert library.is_open(exception_date)
# previous saturday isn't yet an exception open date
exception_date = exception_date - timedelta(days=7)
assert exception_date.weekday() == 5
assert not library.is_open(exception_date)
# NOTE : the next saturday shouldn't be an exception according to this
# exception ; BUT another exception occurred starting to 2018-12-22.
# But this other exception is a "closed_exception" and should change the
# behavior compare to a regular saturday
exception_date = exception_date + timedelta(days=14)
assert exception_date.weekday() == 5
assert not library.is_open(exception_date)

# CASE 3 :: Check repeatable exception date for a single date
# * According to library setting, each '1st augustus' is closed
# (from 2019); despite if '2019-08-01' is a thursday (normally open)
exception_date = date_string_to_utc('2019-08-01') # Thursday
assert not library.is_open(exception_date)
exception_date = date_string_to_utc('2022-08-01') # Monday
assert not library.is_open(exception_date)
exception_date = date_string_to_utc('2018-08-01') # Wednesday
assert library.is_open(exception_date)
exception_date = date_string_to_utc('2222-8-1') # Thursday
assert not library.is_open(exception_date)

# CASE 4 :: Check repeatable exception range date
# * According to library setting, the library is closed for christmas
# break each year (22/12 --> 06/01)
exception_date = date_string_to_utc('2018-12-24') # Monday
assert not library.is_open(exception_date)
exception_date = date_string_to_utc('2019-01-07') # Monday
assert library.is_open(exception_date)
exception_date = date_string_to_utc('2020-12-29') # Tuesday
assert not library.is_open(exception_date)
exception_date = date_string_to_utc('2101-01-4') # Tuesday
assert not library.is_open(exception_date)

# CASE 5 :: Check repeatable date with interval
# * According to library setting, each first day of the odd months is
# a closed day.
exception_date = date_string_to_utc('2019-03-01') # Friday
assert not library.is_open(exception_date)
exception_date = date_string_to_utc('2019-04-01') # Monday
assert library.is_open(exception_date)
exception_date = date_string_to_utc('2019-05-01') # Wednesday
assert not library.is_open(exception_date)

# Other tests on opening day/hour
assert library.next_open(date=saturday).date() \
== parser.parse('2018-12-17').date()
assert library.next_open(date=saturday, previous=True).date() \
== parser.parse('2018-12-14').date()

assert library.count_open(start_date=monday, end_date=saturday) == 6
assert library.in_working_days(
count=5,
count=6,
date=date_string_to_utc('2018-12-10')
) == date_string_to_utc('2018-12-17')

assert not library.is_open(date=saturday)


def test_library_can_delete(lib_martigny):
"""Test can delete."""
Expand Down

0 comments on commit 855db79

Please sign in to comment.