From 3cf6a2682c61e7265594a7f99a5af69805461b58 Mon Sep 17 00:00:00 2001 From: Benjamin Renard Date: Mon, 1 Apr 2024 19:37:51 +0200 Subject: [PATCH] opening_hours: global rework to make module more atomic and add somes new helper methods --- mylib/opening_hours.py | 823 ++++++++++++++++++++++++++++++++---- tests/test_opening_hours.py | 407 +++++++++++++++++- 2 files changed, 1132 insertions(+), 98 deletions(-) diff --git a/mylib/opening_hours.py b/mylib/opening_hours.py index 69d2078..c20a13d 100644 --- a/mylib/opening_hours.py +++ b/mylib/opening_hours.py @@ -11,6 +11,7 @@ week_days = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "diman date_format = "%d/%m/%Y" date_pattern = re.compile("^([0-9]{2})/([0-9]{2})/([0-9]{4})$") time_pattern = re.compile("^([0-9]{1,2})h([0-9]{2})?$") +_nonworking_french_public_days_of_the_year_cache = {} def easter_date(year): @@ -37,23 +38,25 @@ def nonworking_french_public_days_of_the_year(year=None): """Compute dict of nonworking french public days for the specified year""" if year is None: year = datetime.date.today().year - dp = easter_date(year) - return { - "1janvier": datetime.date(year, 1, 1), - "paques": dp, - "lundi_paques": (dp + datetime.timedelta(1)), - "1mai": datetime.date(year, 5, 1), - "8mai": datetime.date(year, 5, 8), - "jeudi_ascension": (dp + datetime.timedelta(39)), - "pentecote": (dp + datetime.timedelta(49)), - "lundi_pentecote": (dp + datetime.timedelta(50)), - "14juillet": datetime.date(year, 7, 14), - "15aout": datetime.date(year, 8, 15), - "1novembre": datetime.date(year, 11, 1), - "11novembre": datetime.date(year, 11, 11), - "noel": datetime.date(year, 12, 25), - "saint_etienne": datetime.date(year, 12, 26), - } + if year not in _nonworking_french_public_days_of_the_year_cache: + dp = easter_date(year) + _nonworking_french_public_days_of_the_year_cache[year] = { + "1janvier": datetime.date(year, 1, 1), + "paques": dp, + "lundi_paques": (dp + datetime.timedelta(1)), + "1mai": datetime.date(year, 5, 1), + "8mai": datetime.date(year, 5, 8), + "jeudi_ascension": (dp + datetime.timedelta(39)), + "pentecote": (dp + datetime.timedelta(49)), + "lundi_pentecote": (dp + datetime.timedelta(50)), + "14juillet": datetime.date(year, 7, 14), + "15aout": datetime.date(year, 8, 15), + "1novembre": datetime.date(year, 11, 1), + "11novembre": datetime.date(year, 11, 11), + "noel": datetime.date(year, 12, 25), + "saint_etienne": datetime.date(year, 12, 26), + } + return _nonworking_french_public_days_of_the_year_cache[year] def parse_exceptional_closures(values): @@ -155,7 +158,153 @@ def parse_normal_opening_hours(values): if not days and not hours_periods: raise ValueError(f'No days or hours period found in this value: "{value}"') normal_opening_hours.append({"days": days, "hours_periods": hours_periods}) - return normal_opening_hours + for idx, noh in enumerate(normal_opening_hours): + normal_opening_hours[idx]["hours_periods"] = sorted_hours_periods(noh["hours_periods"]) + return sorted_opening_hours(normal_opening_hours) + + +def sorted_hours_periods(hours_periods): + """Sort hours periods""" + return sorted(hours_periods, key=lambda hp: (hp["start"], hp["stop"])) + + +def sorted_opening_hours(opening_hours): + """Sort opening hours""" + return sorted( + opening_hours, + key=lambda x: ( + week_days.index(x["days"][0]) if x["days"] else None, + x["hours_periods"][0]["start"] if x["hours_periods"] else datetime.datetime.min.time(), + x["hours_periods"][0]["stop"] if x["hours_periods"] else datetime.datetime.max.time(), + ), + ) + + +def its_nonworking_day(nonworking_public_holidays_values, date=None): + """Check if is a non-working day""" + if not nonworking_public_holidays_values: + return False + date = date if date else datetime.date.today() + log.debug("its_nonworking_day(%s): values=%s", date, nonworking_public_holidays_values) + nonworking_days = nonworking_french_public_days_of_the_year(year=date.year) + for day in nonworking_public_holidays_values: + if day in nonworking_days and nonworking_days[day] == date: + log.debug("its_nonworking_day(%s): %s", date, day) + return True + return False + + +def its_exceptionally_closed(exceptional_closures_values, when=None, parse=True, all_day=False): + """Check if it's exceptionally closed""" + if not exceptional_closures_values: + return False + when = when if when else datetime.datetime.now() + assert isinstance(when, (datetime.date, datetime.datetime)) + when_date = when.date() if isinstance(when, datetime.datetime) else when + exceptional_closures = ( + parse_exceptional_closures(exceptional_closures_values) + if parse + else exceptional_closures_values + ) + log.debug("its_exceptionally_closed(%s): exceptional closures=%s", when, exceptional_closures) + for cl in exceptional_closures: + if when_date not in cl["days"]: + log.debug( + "its_exceptionally_closed(%s): %s not in days (%s)", when, when_date, cl["days"] + ) + continue + if not cl["hours_periods"]: + # All day exceptional closure + return True + if all_day: + # Wanted an all day closure, ignore it + continue + for hp in cl["hours_periods"]: + if hp["start"] <= when.time() <= hp["stop"]: + return True + return False + + +def get_exceptional_closures_hours(exceptional_closures_values, date=None, parse=True): + """Get exceptional closures hours of the day""" + if not exceptional_closures_values: + return [] + date = date if date else datetime.date.today() + exceptional_closures = ( + parse_exceptional_closures(exceptional_closures_values) + if parse + else exceptional_closures_values + ) + log.debug( + "get_exceptional_closures_hours(%s): exceptional closures=%s", date, exceptional_closures + ) + exceptional_closures_hours = [] + for cl in exceptional_closures: + if date not in cl["days"]: + log.debug("get_exceptional_closures_hours(%s): not in days (%s)", date, cl["days"]) + continue + if not cl["hours_periods"]: + log.debug( + "get_exceptional_closures_hours(%s): it's exceptionally closed all the day", date + ) + return [ + { + "start": datetime.datetime.min.time(), + "stop": datetime.datetime.max.time(), + } + ] + exceptional_closures_hours.extend(cl["hours_periods"]) + log.debug( + "get_exceptional_closures_hours(%s): exceptional closures hours=%s", + date, + exceptional_closures_hours, + ) + return sorted_hours_periods(exceptional_closures_hours) + + +def its_normally_open(normal_opening_hours_values, when=None, parse=True, ignore_time=False): + """Check if it's normally open""" + when = when if when else datetime.datetime.now() + if not normal_opening_hours_values: + log.debug( + "its_normally_open(%s): no normal opening hours defined, consider as opened", when + ) + return True + when_weekday = week_days[when.timetuple().tm_wday] + log.debug("its_normally_open(%s): week day=%s", when, when_weekday) + normal_opening_hours = ( + parse_normal_opening_hours(normal_opening_hours_values) + if parse + else normal_opening_hours_values + ) + log.debug("its_normally_open(%s): normal opening hours=%s", when, normal_opening_hours) + for oh in normal_opening_hours: + if oh["days"] and when_weekday not in oh["days"]: + log.debug("its_normally_open(%s): %s not in days (%s)", when, when_weekday, oh["days"]) + continue + if not oh["hours_periods"] or ignore_time: + return True + for hp in oh["hours_periods"]: + if hp["start"] <= when.time() <= hp["stop"]: + return True + log.debug("its_normally_open(%s): not in normal opening hours", when) + return False + + +def its_opening_day( + normal_opening_hours_values=None, + exceptional_closures_values=None, + nonworking_public_holidays_values=None, + date=None, + parse=True, +): + """Check if it's an opening day""" + date = date if date else datetime.date.today() + if its_nonworking_day(nonworking_public_holidays_values, date=date): + return False + if its_exceptionally_closed(exceptional_closures_values, when=date, all_day=True, parse=parse): + return False + return its_normally_open(normal_opening_hours_values, when=date, parse=parse, ignore_time=True) def is_closed( @@ -193,76 +342,578 @@ def is_closed( when_time, when_weekday, ) - if nonworking_public_holidays_values: - log.debug("Nonworking public holidays: %s", nonworking_public_holidays_values) - nonworking_days = nonworking_french_public_days_of_the_year() - for day in nonworking_public_holidays_values: - if day in nonworking_days and when_date == nonworking_days[day]: - log.debug("Non working day: %s", day) - return { - "closed": True, - "exceptional_closure": exceptional_closure_on_nonworking_public_days, - "exceptional_closure_all_day": exceptional_closure_on_nonworking_public_days, - } + # Handle non-working days + if its_nonworking_day(nonworking_public_holidays_values, date=when_date): + return { + "closed": True, + "exceptional_closure": exceptional_closure_on_nonworking_public_days, + "exceptional_closure_all_day": exceptional_closure_on_nonworking_public_days, + } - if exceptional_closures_values: + # Handle exceptional closures + try: + if its_exceptionally_closed(exceptional_closures_values, when=when): + return { + "closed": True, + "exceptional_closure": True, + "exceptional_closure_all_day": its_exceptionally_closed( + exceptional_closures_values, when=when, all_day=True + ), + } + except ValueError as e: + if on_error_result is None: + log.error("Fail to parse exceptional closures", exc_info=True) + raise e from e + log.error("Fail to parse exceptional closures, consider as %s", on_error, exc_info=True) + return on_error_result + + # Finally, handle normal opening hours + try: + return { + "closed": not its_normally_open(normal_opening_hours_values, when=when), + "exceptional_closure": False, + "exceptional_closure_all_day": False, + } + except ValueError as e: # pylint: disable=broad-except + if on_error_result is None: + log.error("Fail to parse normal opening hours", exc_info=True) + raise e from e + log.error("Fail to parse normal opening hours, consider as %s", on_error, exc_info=True) + return on_error_result + + +def next_opening_date( + normal_opening_hours_values=None, + exceptional_closures_values=None, + nonworking_public_holidays_values=None, + date=None, + max_anaylse_days=None, + parse=True, +): + """Search for the next opening day""" + date = date if date else datetime.date.today() + max_anaylse_days = max_anaylse_days if max_anaylse_days is not None else 30 + if parse: try: - exceptional_closures = parse_exceptional_closures(exceptional_closures_values) - log.debug("Exceptional closures: %s", exceptional_closures) - except ValueError as e: - log.error("Fail to parse exceptional closures, consider as closed", exc_info=True) - if on_error_result is None: - raise e from e - return on_error_result - for cl in exceptional_closures: - if when_date not in cl["days"]: - log.debug("when_date (%s) no in days (%s)", when_date, cl["days"]) - continue - if not cl["hours_periods"]: - # All day exceptional closure - return { - "closed": True, - "exceptional_closure": True, - "exceptional_closure_all_day": True, - } - for hp in cl["hours_periods"]: - if hp["start"] <= when_time <= hp["stop"]: - return { - "closed": True, - "exceptional_closure": True, - "exceptional_closure_all_day": False, - } + normal_opening_hours_values = ( + parse_normal_opening_hours(normal_opening_hours_values) + if normal_opening_hours_values + else None + ) + exceptional_closures_values = ( + parse_exceptional_closures(exceptional_closures_values) + if exceptional_closures_values + else None + ) + except ValueError: # pylint: disable=broad-except + log.error( + "next_opening_date(%s): fail to parse normal opening hours or exceptional closures", + date, + exc_info=True, + ) + return False + added_days = 0 + while added_days <= max_anaylse_days: + test_date = date + datetime.timedelta(days=added_days) + if its_opening_day( + normal_opening_hours_values=normal_opening_hours_values, + exceptional_closures_values=exceptional_closures_values, + nonworking_public_holidays_values=nonworking_public_holidays_values, + date=test_date, + parse=False, + ): + return test_date + added_days += 1 + log.debug( + "next_opening_date(%s): no opening day found in the next %d days", date, max_anaylse_days + ) + return False - if normal_opening_hours_values: + +def next_opening_hour( + normal_opening_hours_values=None, + exceptional_closures_values=None, + nonworking_public_holidays_values=None, + when=None, + max_anaylse_days=None, + parse=True, +): + """Search for the next opening hour""" + when = when if when else datetime.datetime.now() + max_anaylse_days = max_anaylse_days if max_anaylse_days is not None else 30 + if parse: try: - normal_opening_hours = parse_normal_opening_hours(normal_opening_hours_values) - log.debug("Normal opening hours: %s", normal_opening_hours) - except ValueError as e: # pylint: disable=broad-except - log.error("Fail to parse normal opening hours, consider as closed", exc_info=True) - if on_error_result is None: - raise e from e - return on_error_result - for oh in normal_opening_hours: - if oh["days"] and when_weekday not in oh["days"]: - log.debug("when_weekday (%s) no in days (%s)", when_weekday, oh["days"]) - continue - if not oh["hours_periods"]: - # All day opened - return { - "closed": False, - "exceptional_closure": False, - "exceptional_closure_all_day": False, - } - for hp in oh["hours_periods"]: - if hp["start"] <= when_time <= hp["stop"]: - return { - "closed": False, - "exceptional_closure": False, - "exceptional_closure_all_day": False, - } - log.debug("Not in normal opening hours => closed") - return {"closed": True, "exceptional_closure": False, "exceptional_closure_all_day": False} + normal_opening_hours_values = ( + parse_normal_opening_hours(normal_opening_hours_values) + if normal_opening_hours_values + else None + ) + exceptional_closures_values = ( + parse_exceptional_closures(exceptional_closures_values) + if exceptional_closures_values + else None + ) + except ValueError: # pylint: disable=broad-except + log.error( + "next_opening_hour(%s): fail to parse normal opening hours or exceptional closures", + when, + exc_info=True, + ) + return False + date = next_opening_date( + normal_opening_hours_values=normal_opening_hours_values, + exceptional_closures_values=exceptional_closures_values, + nonworking_public_holidays_values=nonworking_public_holidays_values, + date=when.date(), + max_anaylse_days=max_anaylse_days, + parse=False, + ) + if not date: + log.debug( + "next_opening_hour(%s): no opening day found in the next %d days", + when, + max_anaylse_days, + ) + return False + log.debug("next_opening_hour(%s): next opening date=%s", when, date) + weekday = week_days[date.timetuple().tm_wday] + log.debug("next_opening_hour(%s): next opening week day=%s", when, weekday) + exceptional_closures_hours = get_exceptional_closures_hours( + exceptional_closures_values, date=date, parse=False + ) + log.debug( + "next_opening_hour(%s): next opening day exceptional closures hours=%s", + when, + exceptional_closures_hours, + ) + next_opening_datetime = None + exceptionally_closed = False + exceptionally_closed_all_day = False + in_opening_hours = date != when.date() + for oh in normal_opening_hours_values: + if exceptionally_closed_all_day: + break - # Not a nonworking day, not during exceptional closure and no normal opening - # hours defined => Opened - return {"closed": False, "exceptional_closure": False, "exceptional_closure_all_day": False} + if oh["days"] and weekday not in oh["days"]: + log.debug("next_opening_hour(%s): %s not in days (%s)", when, weekday, oh["days"]) + continue + + log.debug( + "next_opening_hour(%s): %s in days (%s), handle opening hours %s", + when, + weekday, + oh["days"], + oh["hours_periods"], + ) + + if not oh["hours_periods"]: + log.debug( + "next_opening_hour(%s): %s is an all day opening day, handle exceptional closures " + "hours %s to find the minimal opening time", + when, + weekday, + exceptional_closures_hours, + ) + if date == when.date(): + in_opening_hours = True + test_time = when.time() if when.date() == date else datetime.datetime.min.time() + for cl in exceptional_closures_hours: + if cl["start"] <= test_time < cl["stop"]: + if cl["stop"] >= datetime.datetime.max.time(): + exceptionally_closed = True + exceptionally_closed_all_day = True + next_opening_datetime = None + break + test_time = cl["stop"] + else: + break + if not exceptionally_closed_all_day: + candidate_next_opening_datetime = datetime.datetime.combine(date, test_time) + next_opening_datetime = ( + candidate_next_opening_datetime + if not next_opening_datetime + or candidate_next_opening_datetime < next_opening_datetime + else next_opening_datetime + ) + continue + + log.debug( + "next_opening_hour(%s): only opened during some hours periods (%s) on %s, find the " + "minimal starting time", + when, + oh["hours_periods"], + weekday, + ) + test_time = datetime.datetime.max.time() + for hp in oh["hours_periods"]: + if date == when.date() and hp["stop"] < when.time(): + log.debug( + "next_opening_hour(%s): ignore opening hours %s before specified when time %s", + when, + hp, + when.time(), + ) + continue + if date == when.date() and hp["start"] <= when.time() < hp["stop"]: + in_opening_hours = True + if exceptional_closures_hours: + log.debug( + "next_opening_hour(%s): check if opening hours %s match with exceptional " + "closure hours %s", + when, + hp, + exceptional_closures_hours, + ) + for cl in exceptional_closures_hours: + if cl["start"] <= hp["start"] and cl["stop"] >= hp["stop"]: + log.debug( + "next_opening_hour(%s): opening hour %s is included in exceptional " + "closure hours %s", + when, + hp, + cl, + ) + exceptionally_closed = True + break + if hp["start"] < cl["start"]: + log.debug( + "next_opening_hour(%s): opening hour %s start before closure hours %s", + when, + hp, + cl, + ) + test_time = hp["start"] if hp["start"] < test_time else test_time + elif cl["stop"] >= hp["start"] and cl["stop"] < hp["stop"]: + log.debug( + "next_opening_hour(%s): opening hour %s end after closure hours %s", + when, + hp, + cl, + ) + test_time = cl["stop"] if cl["stop"] < test_time else test_time + elif hp["start"] < test_time: + log.debug( + "next_opening_hour(%s): no exceptional closure hours, use opening hours start " + "time %s", + when, + hp["start"], + ) + test_time = hp["start"] + + if test_time < datetime.datetime.max.time(): + if date == when.date() and test_time < when.time(): + test_time = when.time() + candidate_next_opening_datetime = datetime.datetime.combine(date, test_time) + next_opening_datetime = ( + candidate_next_opening_datetime + if not next_opening_datetime + or candidate_next_opening_datetime < next_opening_datetime + else next_opening_datetime + ) + + if not next_opening_datetime and ( + exceptionally_closed or (date == when.date() and not in_opening_hours) + ): + new_max_anaylse_days = max_anaylse_days - (date - when.date()).days + if new_max_anaylse_days > 0: + log.debug( + "next_opening_hour(%s): exceptionally closed on %s, try on following %d days", + when, + date, + new_max_anaylse_days, + ) + next_opening_datetime = next_opening_hour( + normal_opening_hours_values=normal_opening_hours_values, + exceptional_closures_values=exceptional_closures_values, + nonworking_public_holidays_values=nonworking_public_holidays_values, + when=datetime.datetime.combine( + date + datetime.timedelta(days=1), datetime.datetime.min.time() + ), + max_anaylse_days=new_max_anaylse_days, + parse=False, + ) + if not next_opening_datetime: + log.debug( + "next_opening_hour(%s): no opening hours found in next %d days", when, max_anaylse_days + ) + return False + log.debug("next_opening_hour(%s): next opening hours=%s", when, next_opening_datetime) + return next_opening_datetime + + +def previous_opening_date( + normal_opening_hours_values=None, + exceptional_closures_values=None, + nonworking_public_holidays_values=None, + date=None, + max_anaylse_days=None, + parse=True, +): + """Search for the previous opening day""" + date = date if date else datetime.date.today() + max_anaylse_days = max_anaylse_days if max_anaylse_days is not None else 30 + if parse: + try: + normal_opening_hours_values = ( + parse_normal_opening_hours(normal_opening_hours_values) + if normal_opening_hours_values + else None + ) + exceptional_closures_values = ( + parse_exceptional_closures(exceptional_closures_values) + if exceptional_closures_values + else None + ) + except ValueError: # pylint: disable=broad-except + log.error( + "previous_opening_date(%s): fail to parse normal opening hours or exceptional " + "closures", + date, + exc_info=True, + ) + return False + days = 0 + while days <= max_anaylse_days: + test_date = date - datetime.timedelta(days=days) + if its_opening_day( + normal_opening_hours_values=normal_opening_hours_values, + exceptional_closures_values=exceptional_closures_values, + nonworking_public_holidays_values=nonworking_public_holidays_values, + date=test_date, + parse=False, + ): + return test_date + days += 1 + log.debug( + "previous_opening_date(%s): no opening day found in the next %d days", + date, + max_anaylse_days, + ) + return False + + +def previous_opening_hour( + normal_opening_hours_values=None, + exceptional_closures_values=None, + nonworking_public_holidays_values=None, + when=None, + max_anaylse_days=None, + parse=True, +): + """Search for the previous opening hour""" + when = when if when else datetime.datetime.now() + max_anaylse_days = max_anaylse_days if max_anaylse_days is not None else 30 + if parse: + try: + normal_opening_hours_values = ( + parse_normal_opening_hours(normal_opening_hours_values) + if normal_opening_hours_values + else None + ) + exceptional_closures_values = ( + parse_exceptional_closures(exceptional_closures_values) + if exceptional_closures_values + else None + ) + except ValueError: # pylint: disable=broad-except + log.error( + "previous_opening_hour(%s): fail to parse normal opening hours or exceptional " + "closures", + when, + exc_info=True, + ) + return False + date = previous_opening_date( + normal_opening_hours_values=normal_opening_hours_values, + exceptional_closures_values=exceptional_closures_values, + nonworking_public_holidays_values=nonworking_public_holidays_values, + date=when.date(), + max_anaylse_days=max_anaylse_days, + parse=False, + ) + if not date: + log.debug( + "previous_opening_hour(%s): no opening day found in the previous %d days", + when, + max_anaylse_days, + ) + return False + log.debug("previous_opening_hour(%s): previous opening date=%s", when, date) + weekday = week_days[date.timetuple().tm_wday] + log.debug("previous_opening_hour(%s): previous opening week day=%s", when, weekday) + exceptional_closures_hours = get_exceptional_closures_hours( + exceptional_closures_values, date=date, parse=False + ) + log.debug( + "previous_opening_hour(%s): previous opening day exceptional closures hours=%s", + when, + exceptional_closures_hours, + ) + previous_opening_datetime = None + exceptionally_closed = False + exceptionally_closed_all_day = False + in_opening_hours = date != when.date() + for oh in reversed(normal_opening_hours_values): + if exceptionally_closed_all_day: + break + + if oh["days"] and weekday not in oh["days"]: + log.debug("previous_opening_hour(%s): %s not in days (%s)", when, weekday, oh["days"]) + continue + + log.debug( + "previous_opening_hour(%s): %s in days (%s), handle opening hours %s", + when, + weekday, + oh["days"], + oh["hours_periods"], + ) + + if not oh["hours_periods"]: + log.debug( + "previous_opening_hour(%s): %s is an all day opening day, handle exceptional " + "closures hours %s to find the maximal opening time", + when, + weekday, + exceptional_closures_hours, + ) + if date == when.date(): + in_opening_hours = True + test_time = when.time() if when.date() == date else datetime.datetime.max.time() + for cl in exceptional_closures_hours: + if cl["start"] <= test_time < cl["stop"]: + if cl["start"] <= datetime.datetime.min.time(): + exceptionally_closed = True + exceptionally_closed_all_day = True + previous_opening_datetime = None + break + test_time = cl["start"] + else: + break + if not exceptionally_closed_all_day: + candidate_previous_opening_datetime = datetime.datetime.combine(date, test_time) + previous_opening_datetime = ( + candidate_previous_opening_datetime + if not previous_opening_datetime + or candidate_previous_opening_datetime > previous_opening_datetime + else previous_opening_datetime + ) + continue + + log.debug( + "previous_opening_hour(%s): only opened during some hours periods (%s) on %s, find the " + "maximal opening time", + when, + oh["hours_periods"], + weekday, + ) + test_time = datetime.datetime.min.time() + for hp in reversed(oh["hours_periods"]): + if date == when.date() and hp["start"] > when.time(): + log.debug( + "previous_opening_hour(%s): ignore opening hours %s starting before specified " + "when time %s", + when, + hp, + when.time(), + ) + continue + if date == when.date() and hp["start"] <= when.time() < hp["stop"]: + in_opening_hours = True + if exceptional_closures_hours: + log.debug( + "previous_opening_hour(%s): check if opening hours %s match with exceptional " + "closure hours %s", + when, + hp, + exceptional_closures_hours, + ) + for cl in reversed(exceptional_closures_hours): + if cl["start"] <= hp["start"] and cl["stop"] >= hp["stop"]: + log.debug( + "previous_opening_hour(%s): opening hour %s is included in exceptional " + "closure hours %s", + when, + hp, + cl, + ) + exceptionally_closed = True + break + if cl["stop"] < hp["stop"]: + log.debug( + "previous_opening_hour(%s): opening hour %s end after closure hours %s", + when, + hp, + cl, + ) + test_time = hp["stop"] if hp["stop"] > test_time else test_time + elif cl["start"] > hp["stop"]: + log.debug( + "previous_opening_hour(%s): opening hour %s start before closure hours " + "%s", + when, + hp, + cl, + ) + test_time = hp["stop"] if hp["stop"] > test_time else test_time + elif cl["stop"] >= hp["stop"] and cl["start"] > hp["start"]: + log.debug( + "previous_opening_hour(%s): opening hour %s start before closure hours " + "%s", + when, + hp, + cl, + ) + test_time = cl["start"] if cl["start"] > test_time else test_time + elif hp["stop"] > test_time: + log.debug( + "previous_opening_hour(%s): no exceptional closure hours, use opening hours " + "stop time %s", + when, + hp["stop"], + ) + test_time = hp["stop"] + + if test_time > datetime.datetime.min.time(): + if date == when.date() and test_time > when.time(): + test_time = when.time() + candidate_previous_opening_datetime = datetime.datetime.combine(date, test_time) + previous_opening_datetime = ( + candidate_previous_opening_datetime + if not previous_opening_datetime + or candidate_previous_opening_datetime > previous_opening_datetime + else previous_opening_datetime + ) + + if not previous_opening_datetime and ( + exceptionally_closed or (date == when.date() and not in_opening_hours) + ): + new_max_anaylse_days = max_anaylse_days - (when.date() - date).days + if new_max_anaylse_days > 0: + log.debug( + "previous_opening_hour(%s): exceptionally closed on %s, try on previous %d days", + when, + date, + new_max_anaylse_days, + ) + previous_opening_datetime = previous_opening_hour( + normal_opening_hours_values=normal_opening_hours_values, + exceptional_closures_values=exceptional_closures_values, + nonworking_public_holidays_values=nonworking_public_holidays_values, + when=datetime.datetime.combine( + date - datetime.timedelta(days=1), datetime.datetime.max.time() + ), + max_anaylse_days=new_max_anaylse_days, + parse=False, + ) + if not previous_opening_datetime: + log.debug( + "previous_opening_hour(%s): no opening hours found in previous %d days", + when, + max_anaylse_days, + ) + return False + log.debug( + "previous_opening_hour(%s): previous opening hours=%s", when, previous_opening_datetime + ) + return previous_opening_datetime diff --git a/tests/test_opening_hours.py b/tests/test_opening_hours.py index 7e578d3..8d71b35 100644 --- a/tests/test_opening_hours.py +++ b/tests/test_opening_hours.py @@ -182,27 +182,96 @@ def test_parse_normal_opening_hours_multiple_periods(): ] -# -# Tests on is_closed -# +def test_parse_normal_opening_hours_is_sorted(): + assert opening_hours.parse_normal_opening_hours( + [ + "samedi 9h30-18h", + "lundi-vendredi 14h-18h 9h30-12h30", + "samedi 9h30-12h", + "dimanche 9h30-12h", + ] + ) == [ + { + "days": ["lundi", "mardi", "mercredi", "jeudi", "vendredi"], + "hours_periods": [ + {"start": datetime.time(9, 30), "stop": datetime.time(12, 30)}, + {"start": datetime.time(14, 0), "stop": datetime.time(18, 0)}, + ], + }, + { + "days": ["samedi"], + "hours_periods": [ + {"start": datetime.time(9, 30), "stop": datetime.time(12, 0)}, + ], + }, + { + "days": ["samedi"], + "hours_periods": [ + {"start": datetime.time(9, 30), "stop": datetime.time(18, 0)}, + ], + }, + { + "days": ["dimanche"], + "hours_periods": [ + {"start": datetime.time(9, 30), "stop": datetime.time(12, 0)}, + ], + }, + ] -exceptional_closures = [ - "22/09/2017", - "20/09/2017-22/09/2017", - "20/09/2017-22/09/2017 18/09/2017", - "25/11/2017", - "26/11/2017 9h30-12h30", -] +# +# Tests on normal opening hours +# normal_opening_hours = [ "lundi-mardi jeudi 9h30-12h30 14h-16h30", "mercredi vendredi 9h30-12h30 14h-17h", + "samedi", ] +normally_opened_datetime = datetime.datetime(2024, 3, 1, 10, 15) +normally_opened_all_day_datetime = datetime.datetime(2024, 4, 6, 10, 15) +normally_closed_datetime = datetime.datetime(2017, 3, 1, 20, 15) +normally_closed_all_day_datetime = datetime.datetime(2024, 4, 7, 20, 15) + + +def test_its_normally_open(): + assert opening_hours.its_normally_open(normal_opening_hours, when=normally_opened_datetime) + + +def test_its_normally_open_all_day(): + assert opening_hours.its_normally_open( + normal_opening_hours, when=normally_opened_all_day_datetime + ) + + +def test_its_normally_closed(): + assert not opening_hours.its_normally_open(normal_opening_hours, when=normally_closed_datetime) + + +def test_its_normally_closed_all_day(): + assert not opening_hours.its_normally_open( + normal_opening_hours, when=normally_closed_all_day_datetime + ) + + +def test_its_normally_open_ignore_time(): + assert opening_hours.its_normally_open( + normal_opening_hours, when=normally_closed_datetime.date(), ignore_time=True + ) + + +def test_its_normally_closed_ignore_time(): + assert not opening_hours.its_normally_open( + normal_opening_hours, when=normally_closed_all_day_datetime.date(), ignore_time=True + ) + + +# +# Tests on non working days +# nonworking_public_holidays = [ "1janvier", "paques", "lundi_paques", - "1mai", "8mai", "jeudi_ascension", "lundi_pentecote", @@ -212,6 +281,120 @@ nonworking_public_holidays = [ "11novembre", "noel", ] +nonworking_date = datetime.date(2017, 1, 1) +not_included_nonworking_date = datetime.date(2017, 5, 1) +not_nonworking_date = datetime.date(2017, 5, 2) + + +def test_its_nonworking_day(): + assert ( + opening_hours.its_nonworking_day(nonworking_public_holidays, date=nonworking_date) is True + ) + + +def test_its_not_nonworking_day(): + assert ( + opening_hours.its_nonworking_day( + nonworking_public_holidays, + date=not_nonworking_date, + ) + is False + ) + + +def test_its_not_included_nonworking_day(): + assert ( + opening_hours.its_nonworking_day( + nonworking_public_holidays, + date=not_included_nonworking_date, + ) + is False + ) + + +# +# Tests in exceptional closures +# +exceptional_closures = [ + "22/09/2017", + "20/09/2017-22/09/2017", + "20/09/2017-22/09/2017 18/09/2017", + "25/11/2017", + "26/11/2017 9h30-12h30", + "27/11/2017 17h-18h 9h30-12h30", +] +exceptional_closure_all_day_date = datetime.date(2017, 9, 22) +exceptional_closure_all_day_datetime = datetime.datetime.combine( + exceptional_closure_all_day_date, datetime.time(20, 15) +) +exceptional_closure_datetime = datetime.datetime(2017, 11, 26, 10, 30) +exceptional_closure_datetime_hours_period = { + "start": datetime.time(9, 30), + "stop": datetime.time(12, 30), +} +not_exceptional_closure_date = datetime.date(2019, 9, 22) + + +def test_its_exceptionally_closed(): + assert ( + opening_hours.its_exceptionally_closed( + exceptional_closures, when=exceptional_closure_all_day_datetime + ) + is True + ) + + +def test_its_not_exceptionally_closed(): + assert ( + opening_hours.its_exceptionally_closed( + exceptional_closures, when=not_exceptional_closure_date + ) + is False + ) + + +def test_its_exceptionally_closed_all_day(): + assert ( + opening_hours.its_exceptionally_closed( + exceptional_closures, when=exceptional_closure_all_day_datetime, all_day=True + ) + is True + ) + + +def test_its_not_exceptionally_closed_all_day(): + assert ( + opening_hours.its_exceptionally_closed( + exceptional_closures, when=exceptional_closure_datetime, all_day=True + ) + is False + ) + + +def test_get_exceptional_closures_hours(): + assert opening_hours.get_exceptional_closures_hours( + exceptional_closures, date=exceptional_closure_datetime.date() + ) == [exceptional_closure_datetime_hours_period] + + +def test_get_exceptional_closures_hours_all_day(): + assert opening_hours.get_exceptional_closures_hours( + exceptional_closures, date=exceptional_closure_all_day_date + ) == [{"start": datetime.datetime.min.time(), "stop": datetime.datetime.max.time()}] + + +def test_get_exceptional_closures_hours_is_sorted(): + assert opening_hours.get_exceptional_closures_hours( + ["27/11/2017 17h-18h 9h30-12h30"], date=datetime.date(2017, 11, 27) + ) == [ + {"start": datetime.time(9, 30), "stop": datetime.time(12, 30)}, + {"start": datetime.time(17, 0), "stop": datetime.time(18, 0)}, + ] + + +# +# Tests on is_closed +# def test_is_closed_when_normaly_closed_by_hour(): @@ -255,7 +438,7 @@ def test_is_closed_when_normaly_closed_by_day(): normal_opening_hours_values=normal_opening_hours, exceptional_closures_values=exceptional_closures, nonworking_public_holidays_values=nonworking_public_holidays, - when=datetime.datetime(2017, 5, 6, 14, 15), + when=datetime.datetime(2017, 5, 7, 14, 15), ) == {"closed": True, "exceptional_closure": False, "exceptional_closure_all_day": False} @@ -300,3 +483,203 @@ def test_nonworking_french_public_days_of_the_year(): "noel": datetime.date(2021, 12, 25), "saint_etienne": datetime.date(2021, 12, 26), } + + +def test_next_opening_date(): + assert opening_hours.next_opening_date( + normal_opening_hours_values=normal_opening_hours, + exceptional_closures_values=exceptional_closures, + nonworking_public_holidays_values=nonworking_public_holidays, + date=datetime.date(2021, 4, 4), + ) == datetime.date(2021, 4, 6) + + +def test_next_opening_hour(): + assert opening_hours.next_opening_hour( + normal_opening_hours_values=normal_opening_hours, + exceptional_closures_values=exceptional_closures, + nonworking_public_holidays_values=nonworking_public_holidays, + when=datetime.datetime(2021, 4, 4, 10, 30), + ) == datetime.datetime(2021, 4, 6, 9, 30) + + +def test_next_opening_hour_with_exceptionnal_closure_hours(): + assert opening_hours.next_opening_hour( + normal_opening_hours_values=["lundi-vendredi 9h-12h 14h-18h"], + exceptional_closures_values=["06/04/2021 9h-13h 14h-16h"], + nonworking_public_holidays_values=nonworking_public_holidays, + when=datetime.datetime(2021, 4, 4, 10, 30), + ) == datetime.datetime(2021, 4, 6, 16, 0) + + +def test_next_opening_hour_with_exceptionnal_closure_day(): + assert opening_hours.next_opening_hour( + normal_opening_hours_values=["lundi-vendredi 9h-12h 14h-18h"], + exceptional_closures_values=["06/04/2021"], + nonworking_public_holidays_values=nonworking_public_holidays, + when=datetime.datetime(2021, 4, 4, 10, 30), + ) == datetime.datetime(2021, 4, 7, 9, 0) + + +def test_next_opening_hour_with_overlapsed_opening_hours(): + assert opening_hours.next_opening_hour( + normal_opening_hours_values=["lundi-vendredi 9h-12h 14h-18h", "mardi 8h-19h"], + exceptional_closures_values=["06/04/2021 9h-13h 14h-16h"], + nonworking_public_holidays_values=nonworking_public_holidays, + when=datetime.datetime(2021, 4, 4, 10, 30), + ) == datetime.datetime(2021, 4, 6, 8, 0) + + +def test_next_opening_hour_with_too_large_exceptionnal_closure_days(): + assert ( + opening_hours.next_opening_hour( + normal_opening_hours_values=["lundi-vendredi 9h-12h 14h-18h"], + exceptional_closures_values=["06/04/2021-16-04/2021"], + nonworking_public_holidays_values=nonworking_public_holidays, + when=datetime.datetime(2021, 4, 4, 10, 30), + max_anaylse_days=10, + ) + is False + ) + + +def test_next_opening_hour_on_opened_moment(): + assert opening_hours.next_opening_hour( + normal_opening_hours_values=["lundi-vendredi 9h-12h 14h-18h"], + exceptional_closures_values=[], + nonworking_public_holidays_values=nonworking_public_holidays, + when=datetime.datetime(2021, 4, 6, 10, 30), + ) == datetime.datetime(2021, 4, 6, 10, 30) + + +def test_next_opening_hour_on_same_day(): + assert opening_hours.next_opening_hour( + normal_opening_hours_values=["lundi-vendredi 9h-12h 14h-18h"], + exceptional_closures_values=[], + nonworking_public_holidays_values=nonworking_public_holidays, + when=datetime.datetime(2021, 4, 6, 13, 0), + ) == datetime.datetime(2021, 4, 6, 14, 0) + assert opening_hours.next_opening_hour( + normal_opening_hours_values=["lundi-vendredi 9h-12h 14h-18h"], + exceptional_closures_values=[], + nonworking_public_holidays_values=nonworking_public_holidays, + when=datetime.datetime(2021, 4, 6, 16, 0), + ) == datetime.datetime(2021, 4, 6, 16, 0) + assert opening_hours.next_opening_hour( + normal_opening_hours_values=["lundi-vendredi"], + exceptional_closures_values=[], + nonworking_public_holidays_values=nonworking_public_holidays, + when=datetime.datetime(2021, 4, 6, 16, 0), + ) == datetime.datetime(2021, 4, 6, 16, 0) + + +def test_next_opening_hour_on_opened_day_but_too_late(): + assert opening_hours.next_opening_hour( + normal_opening_hours_values=["lundi-vendredi 9h-12h 14h-18h"], + exceptional_closures_values=[], + nonworking_public_holidays_values=nonworking_public_holidays, + when=datetime.datetime(2021, 4, 6, 23, 0), + ) == datetime.datetime(2021, 4, 7, 9, 0) + + +def test_previous_opening_date(): + assert opening_hours.previous_opening_date( + normal_opening_hours_values=["lundi-vendredi 9h-18h"], + exceptional_closures_values=[], + nonworking_public_holidays_values=nonworking_public_holidays, + date=datetime.date(2024, 4, 1), + ) == datetime.date(2024, 3, 29) + + +def test_previous_opening_hour(): + assert opening_hours.previous_opening_hour( + normal_opening_hours_values=["lundi-vendredi 9h-18h"], + exceptional_closures_values=[], + nonworking_public_holidays_values=nonworking_public_holidays, + when=datetime.datetime(2024, 4, 1, 10, 30), + ) == datetime.datetime(2024, 3, 29, 18, 0) + + +def test_previous_opening_hour_with_exceptionnal_closure_hours(): + assert opening_hours.previous_opening_hour( + normal_opening_hours_values=["lundi-vendredi 9h-12h 14h-18h"], + exceptional_closures_values=["29/03/2024 14h-18h"], + nonworking_public_holidays_values=nonworking_public_holidays, + when=datetime.datetime(2024, 4, 1, 10, 30), + ) == datetime.datetime(2024, 3, 29, 12, 0) + assert opening_hours.previous_opening_hour( + normal_opening_hours_values=["lundi-vendredi 9h-12h 14h-18h"], + exceptional_closures_values=["29/03/2024 16h-18h"], + nonworking_public_holidays_values=nonworking_public_holidays, + when=datetime.datetime(2024, 4, 1, 10, 30), + ) == datetime.datetime(2024, 3, 29, 16, 0) + + +def test_previous_opening_hour_with_exceptionnal_closure_day(): + assert opening_hours.previous_opening_hour( + normal_opening_hours_values=["lundi-vendredi 9h-12h 14h-18h"], + exceptional_closures_values=["29/03/2024"], + nonworking_public_holidays_values=nonworking_public_holidays, + when=datetime.datetime(2024, 4, 1, 10, 30), + ) == datetime.datetime(2024, 3, 28, 18, 0) + + +def test_previous_opening_hour_with_overlapsed_opening_hours(): + assert opening_hours.previous_opening_hour( + normal_opening_hours_values=["lundi-vendredi 9h-12h 14h-18h", "mardi 8h-19h"], + exceptional_closures_values=[], + nonworking_public_holidays_values=nonworking_public_holidays, + when=datetime.datetime(2024, 4, 3, 8, 30), + ) == datetime.datetime(2024, 4, 2, 19, 0) + + +def test_previous_opening_hour_with_too_large_exceptionnal_closure_days(): + assert ( + opening_hours.previous_opening_hour( + normal_opening_hours_values=["lundi-vendredi 9h-12h 14h-18h"], + exceptional_closures_values=["06/03/2024-16-04/2024"], + nonworking_public_holidays_values=nonworking_public_holidays, + when=datetime.datetime(2024, 4, 17, 8, 30), + max_anaylse_days=10, + ) + is False + ) + + +def test_previous_opening_hour_on_opened_moment(): + assert opening_hours.previous_opening_hour( + normal_opening_hours_values=["lundi-vendredi 9h-12h 14h-18h"], + exceptional_closures_values=[], + nonworking_public_holidays_values=nonworking_public_holidays, + when=datetime.datetime(2024, 4, 5, 10, 30), + ) == datetime.datetime(2024, 4, 5, 10, 30) + + +def test_previous_opening_hour_on_same_day(): + assert opening_hours.previous_opening_hour( + normal_opening_hours_values=["lundi-vendredi 9h-12h 14h-18h"], + exceptional_closures_values=[], + nonworking_public_holidays_values=nonworking_public_holidays, + when=datetime.datetime(2024, 4, 5, 13, 0), + ) == datetime.datetime(2024, 4, 5, 12, 0) + assert opening_hours.previous_opening_hour( + normal_opening_hours_values=["lundi-vendredi 9h-12h 14h-18h"], + exceptional_closures_values=[], + nonworking_public_holidays_values=nonworking_public_holidays, + when=datetime.datetime(2024, 4, 5, 16, 0), + ) == datetime.datetime(2024, 4, 5, 16, 0) + assert opening_hours.previous_opening_hour( + normal_opening_hours_values=["lundi-vendredi"], + exceptional_closures_values=[], + nonworking_public_holidays_values=nonworking_public_holidays, + when=datetime.datetime(2024, 4, 5, 16, 0), + ) == datetime.datetime(2024, 4, 5, 16, 0) + + +def test_previous_opening_hour_on_opened_day_but_too_early(): + assert opening_hours.previous_opening_hour( + normal_opening_hours_values=["lundi-vendredi 9h-12h 14h-18h"], + exceptional_closures_values=[], + nonworking_public_holidays_values=nonworking_public_holidays, + when=datetime.datetime(2024, 4, 5, 8, 0), + ) == datetime.datetime(2024, 4, 4, 18, 0)