mairie template support as_opening_hours template tag (#22050)

This commit is contained in:
Elias Showk 2018-03-02 17:54:59 +01:00 committed by Frédéric Péters
parent 1c591b3587
commit 622f15044a
6 changed files with 8580 additions and 28 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
*.pyc *.pyc
*.egg-info *.egg-info
.pytest_cache/

View File

@ -15,20 +15,24 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from collections import OrderedDict
import datetime import datetime
import math import math
import operator
import random import random
import re import re
import urllib2 import urllib2
from dateutil.parser import parse as dateutil_parse
from requests import RequestException from requests import RequestException
from django import template from django import template
from django.conf import settings from django.conf import settings
from django.utils.safestring import mark_safe from django.utils.dateparse import parse_datetime
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.timezone import is_naive, make_aware
from django.utils.safestring import mark_safe
from combo.apps.dashboard.models import DashboardCell
from combo.apps.maps.models import MapLayer from combo.apps.maps.models import MapLayer
from combo.data.models import Page, ConfigJsonCell from combo.data.models import Page, ConfigJsonCell
from combo.public.views import render_cell from combo.public.views import render_cell
@ -36,46 +40,156 @@ from combo.utils import requests
register = template.Library() register = template.Library()
FR_WEEKDAYS = ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche']
EN_ABBREV_WEEKDAYS = OrderedDict([
('mo', 'Monday'),
('tu', 'Tuesday'),
('we', 'Wednesday'),
('th', 'Thursday'),
('fr', 'Friday'),
('sa', 'Saturday'),
('su', 'Sunday')
])
class TimeSlot(object): class TimeSlot(object):
def __init__(self, start, end): def __init__(self, start, end):
if is_naive(start):
start = make_aware(start)
if is_naive(end):
end = make_aware(end)
self.start = start self.start = start
self.end = end self.end = end
def __repr__(self):
return '<TimeSlot start=%s - end=%s>' % (self.start.strftime('%c'), self.end.strftime('%c'))
@register.filter
def as_opening_hours_badge(data): def openinghours_to_datetime(codename, hour, minute, default=None):
if not data: try:
return '' weekday = EN_ABBREV_WEEKDAYS.get(codename, None)
weekdays = ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche'] # if default is None, return the next date and time after now()
# a default datetime instance is used to replace absent parameters for datetime
return dateutil_parse('%s %d:%d:00' % (weekday, hour, minute), default=default)
except ValueError:
return None
def get_slots_from_mdr_format(data, today):
"""Process data from /ws/grandlyon/ter_territoire.maison_du_rhone/all.json
add to slots all the next opening hours in chronological order & beginning from today()
"""
slots = [] slots = []
today = datetime.date.today() known_format = False
for i in range(7): mdr_weekdays_format = ['%s_am' % day for day in FR_WEEKDAYS] + ['%s_pm' % day for day in FR_WEEKDAYS]
for period in ('am', 'pm'): if any([re.search('|'.join(mdr_weekdays_format), data_key) is not None for data_key in data.keys()]):
hours = data.get('%s_%s' % (weekdays[today.weekday()], period)) known_format = True
if not hours: for i in range(7):
for period in ('am', 'pm'):
hours = data.get('%s_%s' % (FR_WEEKDAYS[today.weekday()], period))
if not hours:
continue
try:
parts = re.match('(\d?\d)h(\d\d)-(\d?\d)h(\d\d)', hours).groups()
except AttributeError:
continue
# add to slots the opening hours in chronological order beginning from today
slots.append(TimeSlot(
datetime.datetime(today.year, today.month, today.day, int(parts[0]), int(parts[1])),
datetime.datetime(today.year, today.month, today.day, int(parts[2]), int(parts[3]))
))
today = today + datetime.timedelta(days=1)
return (slots, known_format)
def get_slots_from_mairie_format(data, base_datetime):
"""Process mairie json and return slots the opening hours in chronological order beginning today
"""
if 'properties' in data:
data = data['properties']
known_format = False
slots = []
exclusion_slots = []
if len(data.get('openinghours', [])) and len(data.get('openinghoursspecification', [])):
known_format = True
# prepare annual opening exclusions
for specification in data.get('openinghoursspecification', []):
valid_from, valid_through = (
parse_datetime(specification.get('validFrom')),
parse_datetime(specification.get('validThrough'))
)
if not valid_from or not valid_through:
continue continue
if 'opens' in specification and 'closes' in specification:
# case when opening periods are defined
if base_datetime >= valid_from and base_datetime < valid_through:
slots.append(TimeSlot(dateutil_parse(specification['opens']), dateutil_parse(specification['closes'])))
else:
# case when exclusions are defined
exclusion_slots.append(TimeSlot(valid_from, valid_through))
for openinghours in data.get('openinghours', []):
try: try:
parts = re.match('(\d?\d)h(\d\d)-(\d?\d)h(\d\d)', hours).groups() parts = re.match('(\w\w)-(\w\w) (\d\d):(\d\d)-(\d\d):(\d\d)', openinghours).groups()
except AttributeError: except AttributeError:
continue continue
slots.append(TimeSlot( for weekday in EN_ABBREV_WEEKDAYS.keys()[
datetime.datetime(today.year, today.month, today.day, int(parts[0]), int(parts[1])), EN_ABBREV_WEEKDAYS.keys().index(parts[0].lower()):
datetime.datetime(today.year, today.month, today.day, int(parts[2]), int(parts[3])))) EN_ABBREV_WEEKDAYS.keys().index(parts[1].lower())+1]:
timeslot = TimeSlot(
openinghours_to_datetime(weekday, int(parts[2]), int(parts[3]), default=base_datetime),
openinghours_to_datetime(weekday, int(parts[4]), int(parts[5]), default=base_datetime)
)
# add to slots the opening hours in chronological order beginning from today
slots.append(timeslot)
today = today + datetime.timedelta(days=1) # order slots and cycle the list beginning with 'base_datetime'
slots = sorted(slots, key=operator.attrgetter('start'))
if len(slots):
def timedelta_key_func(slot):
return slot.start - base_datetime
nearest_slot_index = slots.index(min(slots, key=timedelta_key_func))
slots = slots[nearest_slot_index:] + slots[:nearest_slot_index]
return (slots, exclusion_slots, known_format)
now = datetime.datetime.now()
# remove past slots @register.filter
def as_opening_hours_badge(data, base_datetime=None):
if not data:
return ''
if base_datetime is None:
base_datetime = make_aware(datetime.datetime.now())
# defaults
exclusion_slots = []
today = base_datetime.date()
(slots, known_format) = get_slots_from_mdr_format(data, today)
if not known_format:
(slots, exclusion_slots, known_format) = get_slots_from_mairie_format(data, base_datetime)
if not known_format:
return ''
# remove past slots and exclude special timeslots
for i, slot in enumerate(slots): for i, slot in enumerate(slots):
if now > slot.end: if base_datetime > slot.end:
slots[i] = None slots[i] = None
slots = [x for x in slots if x] else:
for exclusion in exclusion_slots:
if slot.start >= exclusion.start and slot.end <= exclusion.end:
slots[i] = None
# parse slots to return the right html
slots = [x for x in slots if x]
if not slots: if not slots:
klass = 'closed' klass = 'closed'
label = u'Fermé' label = u'Fermé'
elif now < slots[0].start: elif base_datetime < slots[0].start:
klass = 'closed' klass = 'closed'
verb = u'Réouvre' verb = u'Réouvre'
if slots[0].start.weekday() == today.weekday(): if slots[0].start.weekday() == today.weekday():
@ -85,16 +199,17 @@ def as_opening_hours_badge(data):
elif slots[0].start.weekday() == (today.weekday() + 1) % 7: elif slots[0].start.weekday() == (today.weekday() + 1) % 7:
day_label = u'demain' day_label = u'demain'
else: else:
day_label = weekdays[slots[0].start.weekday()] day_label = FR_WEEKDAYS[slots[0].start.weekday()]
label = u'%s %s à %sh%02d' % (verb, day_label, slots[0].start.hour, slots[0].start.minute) label = u'%s %s à %sh%02d' % (verb, day_label, slots[0].start.hour, slots[0].start.minute)
elif now < slots[0].end: elif base_datetime < slots[0].end:
if (slots[0].end - now).seconds < 3600: if (slots[0].end - base_datetime).seconds < 3600:
klass = 'soon-to-be-closed' klass = 'soon-to-be-closed'
else: else:
klass = 'open' klass = 'open'
label = u"Ouvert jusqu'à %sh%02d" % (slots[0].end.hour, slots[0].end.minute) label = u"Ouvert jusqu'à %sh%02d" % (slots[0].end.hour, slots[0].end.minute)
return mark_safe('<div class="badge %s"><span>%s</span></div>' % (klass, label)) return mark_safe(u'<div class="badge %s"><span>%s</span></div>' % (klass, label))
@register.filter @register.filter
def onlymoov_duration(string): def onlymoov_duration(string):
@ -102,7 +217,7 @@ def onlymoov_duration(string):
# onlymoov, "PT1H16M3S", "PT1M35S" # onlymoov, "PT1H16M3S", "PT1M35S"
try: try:
groups = re.match(r'PT(\d+H)?(\d+M)', string).groups() groups = re.match(r'PT(\d+H)?(\d+M)', string).groups()
except AttributeError: # didn't match except AttributeError: # didn't match
return '?' return '?'
hours = '' hours = ''
if groups[0]: if groups[0]:

View File

@ -94,7 +94,9 @@ setup(
'Programming Language :: Python', 'Programming Language :: Python',
'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2',
], ],
install_requires=['django>=1.8', install_requires=[
'django>=1.8, <1.12',
'python-dateutil',
], ],
zip_safe=False, zip_safe=False,
entry_points={ entry_points={

0
tests/__init__.py Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
from dateutil.parser import parse
import json
import os
from django.utils.safestring import mark_safe
from combo_plugin_gnm.templatetags.gnm import as_opening_hours_badge
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
GEOJSON = json.load(open(os.path.join(
BASE_DIR, 'tests/data/mairie-geojson.json')))['features']
TZOFFSETS = {"Europe/Paris": 3600}
def test_every_mairie_closed_at_midnight():
"""every mairie is closed at mignight"""
midnight = parse("2018-03-05T00:00:00+01:00")
opening_hours = [as_opening_hours_badge(x, base_datetime=midnight) for x in GEOJSON]
assert len([x for x in opening_hours if 'open' in x]) == 0
def test_all_mairie_data_parsed_correct():
"""everything got parsed correctly"""
opening_hours = [as_opening_hours_badge(x) for x in GEOJSON
if x['properties'].get('openinghours') or x['properties'].get('openinghoursspecification')]
# actually not everything, Jonage and Solaize are not handled correctly as
# they only use openinghoursspecification → 2
assert opening_hours.count('') == 2 # FIXME
def test_mairie_nodata_as_opening_hours_templatetag():
""""no data return the right empty html"""
test_datetime = parse("2018-03-05T15:59:00+01:00")
test_html = as_opening_hours_badge(GEOJSON[0]['properties'], base_datetime=test_datetime)
assert test_html == ''
def test_mairie_open_as_opening_hours_templatetag():
"""mairie as_opening_hours with a fixed datetime"""
klass = 'open'
label = u"Ouvert jusqu'à 17h00"
test_datetime = parse("2018-03-05T15:55:00+01:00")
test_html = as_opening_hours_badge(GEOJSON[1]['properties'], base_datetime=test_datetime)
assert test_html == mark_safe(u'<div class="badge %s"><span>%s</span></div>'% (klass, label))
test_html = as_opening_hours_badge(GEOJSON[1], base_datetime=test_datetime)
assert test_html == mark_safe(u'<div class="badge %s"><span>%s</span></div>'% (klass, label))