""" Opening hours helpers """ import datetime import logging import re import time log = logging.getLogger(__name__) week_days = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"] 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})?$") def easter_date(year): """Compute easter date for the specified year""" a = year // 100 b = year % 100 c = (3 * (a + 25)) // 4 d = (3 * (a + 25)) % 4 e = (8 * (a + 11)) // 25 f = (5 * a + b) % 19 g = (19 * f + c - e) % 30 h = (f + 11 * g) // 319 j = (60 * (5 - d) + b) // 4 k = (60 * (5 - d) + b) % 4 m = (2 * j - k - g + h) % 7 n = (g - h + m + 114) // 31 p = (g - h + m + 114) % 31 day = p + 1 month = n return datetime.date(year, month, day) 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), } def parse_exceptional_closures(values): """Parse exceptional closures values""" exceptional_closures = [] for value in values: days = [] hours_periods = [] words = value.strip().split() for word in words: if not word: continue parts = word.split("-") if len(parts) == 1: # ex: 31/02/2017 ptime = time.strptime(word, date_format) date = datetime.date(ptime.tm_year, ptime.tm_mon, ptime.tm_mday) if date not in days: days.append(date) elif len(parts) == 2: # ex: 18/12/2017-20/12/2017 ou 9h-10h30 if date_pattern.match(parts[0]) and date_pattern.match(parts[1]): # ex: 18/12/2017-20/12/2017 pstart = time.strptime(parts[0], date_format) pstop = time.strptime(parts[1], date_format) if pstop <= pstart: raise ValueError(f"Day {parts[1]} <= {parts[0]}") date = datetime.date(pstart.tm_year, pstart.tm_mon, pstart.tm_mday) stop_date = datetime.date(pstop.tm_year, pstop.tm_mon, pstop.tm_mday) while date <= stop_date: if date not in days: days.append(date) date += datetime.timedelta(days=1) else: # ex: 9h-10h30 mstart = time_pattern.match(parts[0]) mstop = time_pattern.match(parts[1]) if not mstart or not mstop: raise ValueError(f'"{word}" is not a valid time period') hstart = datetime.time(int(mstart.group(1)), int(mstart.group(2) or 0)) hstop = datetime.time(int(mstop.group(1)), int(mstop.group(2) or 0)) if hstop <= hstart: raise ValueError(f"Time {parts[1]} <= {parts[0]}") hours_periods.append({"start": hstart, "stop": hstop}) else: raise ValueError(f'Invalid number of part in this word: "{word}"') if not days: raise ValueError(f'No days found in value "{value}"') exceptional_closures.append({"days": days, "hours_periods": hours_periods}) return exceptional_closures def parse_normal_opening_hours(values): """Parse normal opening hours""" normal_opening_hours = [] for value in values: days = [] hours_periods = [] words = value.strip().split() for word in words: if not word: continue parts = word.split("-") if len(parts) == 1: # ex: jeudi if word not in week_days: raise ValueError(f'"{word}" is not a valid week day') if word not in days: days.append(word) elif len(parts) == 2: # ex: lundi-jeudi ou 9h-10h30 if parts[0] in week_days and parts[1] in week_days: # ex: lundi-jeudi if week_days.index(parts[1]) <= week_days.index(parts[0]): raise ValueError(f'"{parts[1]}" is before "{parts[0]}"') started = False for d in week_days: if not started and d != parts[0]: continue started = True if d not in days: days.append(d) if d == parts[1]: break else: # ex: 9h-10h30 mstart = time_pattern.match(parts[0]) mstop = time_pattern.match(parts[1]) if not mstart or not mstop: raise ValueError(f'"{word}" is not a valid time period') hstart = datetime.time(int(mstart.group(1)), int(mstart.group(2) or 0)) hstop = datetime.time(int(mstop.group(1)), int(mstop.group(2) or 0)) if hstop <= hstart: raise ValueError(f"Time {parts[1]} <= {parts[0]}") hours_periods.append({"start": hstart, "stop": hstop}) else: raise ValueError(f'Invalid number of part in this word: "{word}"') 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 def is_closed( normal_opening_hours_values=None, exceptional_closures_values=None, nonworking_public_holidays_values=None, exceptional_closure_on_nonworking_public_days=False, when=None, on_error="raise", ): """Check if closed""" if not when: when = datetime.datetime.now() when_date = when.date() when_time = when.time() when_weekday = week_days[when.timetuple().tm_wday] on_error_result = None if on_error == "closed": on_error_result = { "closed": True, "exceptional_closure": False, "exceptional_closure_all_day": False, } elif on_error == "opened": on_error_result = { "closed": False, "exceptional_closure": False, "exceptional_closure_all_day": False, } log.debug( "When = %s => date = %s / time = %s / week day = %s", when, when_date, 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, } if exceptional_closures_values: 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, } if normal_opening_hours_values: 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} # 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}