268 lines
11 KiB
Python
268 lines
11 KiB
Python
""" 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}
|