diff --git a/opening_hours.py b/opening_hours.py new file mode 100644 index 0000000..661580d --- /dev/null +++ b/opening_hours.py @@ -0,0 +1,209 @@ +import datetime, re, time, logging + +week_days=['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche'] +def easter_date(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): + 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): + exceptional_closures=[] + 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})?$') + for value in values: + days=[] + hours_periods=[] + words=value.strip().split() + for word in words: + if word=='': + continue + parts=word.split('-') + if len(parts)==1: + # ex : 31/02/2017 + ptime=time.strptime(word,'%d/%m/%Y') + 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],'%d/%m/%Y') + pstop=time.strptime(parts[1],'%d/%m/%Y') + if pstop<=pstart: + raise ValueError('Day %s <= %s' % (parts[1],parts[0])) + + date=datetime.date(pstart.tm_year, pstart.tm_mon, pstart.tm_mday) + stop_date=datetime.date(pstop.tm_year, pstart.tm_mon, pstart.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('"%s" is not a valid time period' % word) + 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('Time %s <= %s' % (parts[1],parts[0])) + hours_periods.append({'start': hstart, 'stop': hstop}) + else: + raise ValueError('Invalid number of part in this word : "%s"' % word) + if not days: + raise ValueError('No days found in value "%s"' % word) + exceptional_closures.append({'days': days, 'hours_periods': hours_periods}) + return exceptional_closures + + +def parse_normal_opening_hours(values): + normal_opening_hours=[] + time_pattern=re.compile('^([0-9]{1,2})h([0-9]{2})?$') + for value in values: + days=[] + hours_periods=[] + words=value.strip().split() + for word in words: + if word=='': + continue + parts=word.split('-') + if len(parts)==1: + # ex : jeudi + if word not in week_days: + raise ValueError('"%s" is not a valid week day' % word) + 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('"%s" is before "%s"' % (parts[1],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('"%s" is not a valid time period' % word) + 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('Time %s <= %s' % (parts[1],parts[0])) + hours_periods.append({'start': hstart, 'stop': hstop}) + else: + raise ValueError('Invalid number of part in this word : "%s"' % word) + if not days and not hours_periods: + raise ValueError('No days or hours period found in this value : "%s"' % value) + normal_opening_hours.append({'days': days, 'hours_periods': hours_periods}) + return normal_opening_hours + +def is_closed(normal_opening_hours_values=[],exceptional_closures_values=[],nonworking_public_holidays_values=[], when=datetime.datetime.now(), on_error='raise', exceptional_closure_on_nonworking_public_days=False): + 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} + + logging.debug("%s => %s / %s / %s" % (when, when_date, when_time, when_weekday)) + if len(nonworking_public_holidays_values)>0: + logging.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]: + logging.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 len(exceptional_closures_values)>0: + try: + exceptional_closures=parse_exceptional_closures(exceptional_closures_values) + logging.debug('Exceptional closures : %s' % exceptional_closures) + except Exception, e: + logging.error("%s => Not closed by default" % e) + if on_error_result is None: + raise e + return on_error_result + for cl in exceptional_closures: + if when_date not in cl['days']: + logging.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 and hp['stop']>= when_time: + return {'closed': True, 'exceptional_closure': True, 'exceptional_closure_all_day': False} + + if len(normal_opening_hours_values)>0: + try: + normal_opening_hours=parse_normal_opening_hours(normal_opening_hours_values) + logging.debug('Normal opening hours : %s' % normal_opening_hours) + except Exception, e: + logging.error("%s => Not closed by default" % e) + if on_error_result is None: + raise e + return on_error_result + for oh in normal_opening_hours: + if oh['days'] and when_weekday not in oh['days']: + logging.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 and hp['stop']>= when_time: + return {'closed': False, 'exceptional_closure': False, 'exceptional_closure_all_day': False} + logging.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} + + diff --git a/tests/tests_opening_hours.py b/tests/tests_opening_hours.py new file mode 100644 index 0000000..5b6efef --- /dev/null +++ b/tests/tests_opening_hours.py @@ -0,0 +1,53 @@ +import os, sys +sys.path.insert(0,os.path.dirname(os.path.dirname(os.path.abspath( __file__ )))) +import opening_hours +import datetime, logging + +debug=True + +if debug: + logging.basicConfig(level=logging.DEBUG) +else: + logging.basicConfig(level=logging.INFO) + +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"] +print "Raw exceptional closures value : %s" % exceptional_closures +print "Parsed exceptional closures : %s" % opening_hours.parse_exceptional_closures(exceptional_closures) + +normal_opening_hours=["lundi-mardi jeudi 9h30-12h30 14h-16h30", "mercredi vendredi 9h30-12h30 14h-17h"] +print "Raw normal opening hours : %s" % normal_opening_hours +print "Parsed normal opening hours : %s" % opening_hours.parse_normal_opening_hours(normal_opening_hours) + +nonworking_public_holidays=[ + '1janvier', + 'paques', + 'lundi_paques', + '1mai', + '8mai', + 'jeudi_ascension', + 'lundi_pentecote', + '14juillet', + '15aout', + '1novembre', + '11novembre', + 'noel', +] +print "Raw nonworking_public_holidays values : %s" % nonworking_public_holidays + +print "Is closed (now) : %s" % opening_hours.is_closed(normal_opening_hours,exceptional_closures,nonworking_public_holidays) + +tests=[ + { 'date_time': datetime.datetime(2017, 5, 1, 20, 15), 'result': {'exceptional_closure': False, 'closed': True, 'exceptional_closure_all_day': False} }, + { 'date_time': datetime.datetime(2017, 5, 2, 15, 15), 'result': {'exceptional_closure': False, 'closed': False, 'exceptional_closure_all_day': False} }, + { 'date_time': datetime.datetime(2017, 12, 25, 20, 15), 'result': {'exceptional_closure': False, 'closed': True, 'exceptional_closure_all_day': False} }, + { 'date_time': datetime.datetime(2017, 9, 22, 15, 15), 'result': {'exceptional_closure': True, 'closed': True, 'exceptional_closure_all_day': True} }, + { 'date_time': datetime.datetime(2017, 11, 25, 15, 15), 'result': {'exceptional_closure': True, 'closed': True, 'exceptional_closure_all_day': True} }, + { 'date_time': datetime.datetime(2017, 11, 26, 11, 15), 'result': {'exceptional_closure': True, 'closed': True, 'exceptional_closure_all_day': False} }, +] +for test in tests: + result=opening_hours.is_closed(normal_opening_hours,exceptional_closures,nonworking_public_holidays, test['date_time']) + if result == test['result']: + status='OK' + else: + status='ERROR' + print "Is closed (%s) : %s => %s" % (test['date_time'].isoformat(), result, status)