From 5eb033c0d82dcb5bdd7c30afea729dcef813a16b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Fri, 19 Jun 2020 14:48:31 +0200
Subject: [PATCH] templates: add filters for working days (#31851)

---
 debian/control                    |   1 +
 tests/test_templates.py           | 191 ++++++++++++++++++++++++++++++
 tox.ini                           |   1 +
 wcs/qommon/calendar.py            |  41 +++++++
 wcs/qommon/templatetags/qommon.py |  61 ++++++++++
 wcs/settings.py                   |   3 +
 6 files changed, 298 insertions(+)
 create mode 100644 wcs/qommon/calendar.py

diff --git a/debian/control b/debian/control
index 82acea38..f70fa348 100644
--- a/debian/control
+++ b/debian/control
@@ -33,6 +33,7 @@ Recommends: libreoffice-writer-nogui | libreoffice-writer,
     python3-langdetect,
     python3-magic,
     python3-qrcode,
+    python3-workalendar,
     python3-xlwt
 Suggests: python3-libxml2
 Description: web application to design and set up online forms
diff --git a/tests/test_templates.py b/tests/test_templates.py
index 258b9258..99bf7f03 100644
--- a/tests/test_templates.py
+++ b/tests/test_templates.py
@@ -689,3 +689,194 @@ def test_language_detect():
 def test_datetime_in_past(value, expected):
     t = Template('{{ value|datetime_in_past }}')
     assert t.render({'value': value}) == str(expected)
+
+
+def test_is_working_day_settings(settings):
+    settings.WORKING_DAY_CALENDAR = None
+    t = Template('{{ value|is_working_day }}')
+    assert t.render({'value': '2020-07-15'}) == 'False'
+    t = Template('{{ value|is_working_day_with_saturday }}')
+    assert t.render({'value': '2020-07-15'}) == 'False'
+
+    settings.WORKING_DAY_CALENDAR = ''
+    t = Template('{{ value|is_working_day }}')
+    assert t.render({'value': '2020-07-15'}) == 'False'
+    t = Template('{{ value|is_working_day_with_saturday }}')
+    assert t.render({'value': '2020-07-15'}) == 'False'
+
+    settings.WORKING_DAY_CALENDAR = 'foobar'
+    t = Template('{{ value|is_working_day }}')
+    assert t.render({'value': '2020-07-15'}) == 'False'
+    t = Template('{{ value|is_working_day_with_saturday }}')
+    assert t.render({'value': '2020-07-15'}) == 'False'
+
+    settings.WORKING_DAY_CALENDAR = 'workalendar.europe.France'
+    t = Template('{{ value|is_working_day }}')
+    assert t.render({'value': '2020-07-15'}) == 'True'
+    t = Template('{{ value|is_working_day_with_saturday }}')
+    assert t.render({'value': '2020-07-15'}) == 'True'
+
+
+@pytest.mark.parametrize('value, expected', [
+    (None, False),
+    ('', False),
+    ('foobar', False),
+    (42, False),
+    ('2020-07-14T12:01:03', False),
+    ('2020-07-15T12:01:03', True),
+    ('2020-07-14 02:03', False),
+    ('2020-07-15 02:03', True),
+    ('14/07/2020 02h03', False),
+    ('15/07/2020 02h03', True),
+    ('2020-07-14', False),
+    ('2020-07-15', True),
+    ('14/07/2020', False),
+    ('15/07/2020', True),
+    (datetime.datetime(2020, 7, 14, 12, 1, 3), False),
+    (datetime.datetime(2020, 7, 15, 12, 1, 3), True),
+    (datetime.date(2020, 7, 14), False),
+    (datetime.date(2020, 7, 15), True),
+])
+def test_is_working_day(settings, value, expected):
+    settings.WORKING_DAY_CALENDAR = 'workalendar.europe.France'
+    t = Template('{{ value|is_working_day }}')
+    assert t.render({'value': value}) == str(expected)
+    t = Template('{{ value|is_working_day_with_saturday }}')
+    assert t.render({'value': value}) == str(expected)
+
+
+def test_is_working_day_weekend(settings):
+    settings.WORKING_DAY_CALENDAR = 'workalendar.europe.France'
+    # check saturday
+    t = Template('{{ value|is_working_day }}')
+    assert t.render({'value': '2020-06-20'}) == 'False'
+    t = Template('{{ value|is_working_day_with_saturday }}')
+    assert t.render({'value': '2020-06-20'}) == 'True'
+    # check sunday
+    t = Template('{{ value|is_working_day }}')
+    assert t.render({'value': '2020-06-21'}) == 'False'
+    t = Template('{{ value|is_working_day_with_saturday }}')
+    assert t.render({'value': '2020-06-21'}) == 'False'
+
+
+def test_add_working_days_settings(settings):
+    settings.WORKING_DAY_CALENDAR = None
+    t = Template('{{ value|add_working_days:1 }}')
+    assert t.render({'value': '2020-07-13'}) == ''
+    t = Template('{{ value|add_working_days_with_saturday:1 }}')
+    assert t.render({'value': '2020-07-13'}) == ''
+
+    settings.WORKING_DAY_CALENDAR = ''
+    t = Template('{{ value|add_working_days:1 }}')
+    assert t.render({'value': '2020-07-13'}) == ''
+    t = Template('{{ value|add_working_days_with_saturday:1 }}')
+    assert t.render({'value': '2020-07-13'}) == ''
+
+    settings.WORKING_DAY_CALENDAR = 'foobar'
+    t = Template('{{ value|add_working_days:1 }}')
+    assert t.render({'value': '2020-07-13'}) == ''
+    t = Template('{{ value|add_working_days_with_saturday:1 }}')
+    assert t.render({'value': '2020-07-13'}) == ''
+
+    settings.WORKING_DAY_CALENDAR = 'workalendar.europe.France'
+    t = Template('{{ value|add_working_days:1 }}')
+    assert t.render({'value': '2020-07-13'}) == '2020-07-15'
+    t = Template('{{ value|add_working_days_with_saturday:1 }}')
+    assert t.render({'value': '2020-07-13'}) == '2020-07-15'
+
+
+def test_add_working_days_arg(settings):
+    settings.WORKING_DAY_CALENDAR = 'workalendar.europe.France'
+    t = Template('{{ value|add_working_days:"foobar" }}')
+    assert t.render({'value': '2020-07-13'}) == ''
+    t = Template('{{ value|add_working_days_with_saturday:"foobar" }}')
+    assert t.render({'value': '2020-07-13'}) == ''
+    t = Template('{{ value|add_working_days:2 }}')
+    assert t.render({'value': '2020-07-13'}) == '2020-07-16'
+    t = Template('{{ value|add_working_days_with_saturday:2 }}')
+    assert t.render({'value': '2020-07-13'}) == '2020-07-16'
+
+
+@pytest.mark.parametrize('value, expected', [
+    (None, ''),
+    ('', ''),
+    ('foobar', ''),
+    (42, ''),
+    ('2020-07-13T12:01:03', '2020-07-15'),
+    ('2020-07-13 02:03', '2020-07-15'),
+    ('13/07/2020 02h03', '2020-07-15'),
+    ('2020-07-13', '2020-07-15'),
+    ('13/07/2020', '2020-07-15'),
+    (datetime.datetime(2020, 7, 13, 12, 1, 3), '2020-07-15'),
+    (datetime.date(2020, 7, 13), '2020-07-15'),
+])
+def test_add_working_days(settings, value, expected):
+    settings.WORKING_DAY_CALENDAR = 'workalendar.europe.France'
+    t = Template('{{ value|add_working_days:1 }}')
+    assert t.render({'value': value}) == str(expected)
+    t = Template('{{ value|add_working_days_with_saturday:1 }}')
+    assert t.render({'value': value}) == str(expected)
+
+
+def test_add_working_days_weekend(settings):
+    settings.WORKING_DAY_CALENDAR = 'workalendar.europe.France'
+    t = Template('{{ value|add_working_days:1 }}')
+    assert t.render({'value': '2020-06-19'}) == '2020-06-22'
+    t = Template('{{ value|add_working_days_with_saturday:1 }}')
+    assert t.render({'value': '2020-06-19'}) == '2020-06-20'
+
+
+def test_adjust_to_working_day_settings(settings):
+    settings.WORKING_DAY_CALENDAR = None
+    t = Template('{{ value|adjust_to_working_day }}')
+    assert t.render({'value': '2020-07-13'}) == ''
+    t = Template('{{ value|adjust_to_working_day_with_saturday }}')
+    assert t.render({'value': '2020-07-13'}) == ''
+
+    settings.WORKING_DAY_CALENDAR = ''
+    t = Template('{{ value|adjust_to_working_day }}')
+    assert t.render({'value': '2020-07-13'}) == ''
+    t = Template('{{ value|adjust_to_working_day_with_saturday }}')
+    assert t.render({'value': '2020-07-13'}) == ''
+
+    settings.WORKING_DAY_CALENDAR = 'foobar'
+    t = Template('{{ value|adjust_to_working_day }}')
+    assert t.render({'value': '2020-07-13'}) == ''
+    t = Template('{{ value|adjust_to_working_day_with_saturday }}')
+    assert t.render({'value': '2020-07-13'}) == ''
+
+    settings.WORKING_DAY_CALENDAR = 'workalendar.europe.France'
+    t = Template('{{ value|adjust_to_working_day }}')
+    assert t.render({'value': '2020-07-14'}) == '2020-07-15'
+    t = Template('{{ value|adjust_to_working_day_with_saturday }}')
+    assert t.render({'value': '2020-07-14'}) == '2020-07-15'
+
+
+@pytest.mark.parametrize('value, expected', [
+    (None, ''),
+    ('', ''),
+    ('foobar', ''),
+    (42, ''),
+    ('2020-07-14T12:01:03', '2020-07-15'),
+    ('2020-07-14 02:03', '2020-07-15'),
+    ('14/07/2020 02h03', '2020-07-15'),
+    ('2020-07-14', '2020-07-15'),
+    ('14/07/2020', '2020-07-15'),
+    (datetime.datetime(2020, 7, 14, 12, 1, 3), '2020-07-15'),
+    (datetime.date(2020, 7, 14), '2020-07-15'),
+    (datetime.date(2020, 7, 15), '2020-07-15'),
+])
+def test_adjust_to_working_day(settings, value, expected):
+    settings.WORKING_DAY_CALENDAR = 'workalendar.europe.France'
+    t = Template('{{ value|adjust_to_working_day }}')
+    assert t.render({'value': value}) == str(expected)
+    t = Template('{{ value|adjust_to_working_day_with_saturday }}')
+    assert t.render({'value': value}) == str(expected)
+
+
+def test_adjust_to_working_day_weekend(settings):
+    settings.WORKING_DAY_CALENDAR = 'workalendar.europe.France'
+    t = Template('{{ value|adjust_to_working_day }}')
+    assert t.render({'value': '2020-06-20'}) == '2020-06-22'
+    t = Template('{{ value|adjust_to_working_day_with_saturday }}')
+    assert t.render({'value': '2020-06-20'}) == '2020-06-20'
diff --git a/tox.ini b/tox.ini
index 6668574b..cf916335 100644
--- a/tox.ini
+++ b/tox.ini
@@ -29,6 +29,7 @@ deps =
     requests
     vobject
     qrcode
+    workalendar
     Pillow
     python-magic
     docutils
diff --git a/wcs/qommon/calendar.py b/wcs/qommon/calendar.py
new file mode 100644
index 00000000..7e00590b
--- /dev/null
+++ b/wcs/qommon/calendar.py
@@ -0,0 +1,41 @@
+# w.c.s. - web application for online forms
+# Copyright (C) 2005-2020  Entr'ouvert
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, see <http://www.gnu.org/licenses/>.
+
+from django.conf import settings
+from django.utils.module_loading import import_string
+
+try:
+    from workalendar.core import SUN
+except ImportError:
+    SUN = None
+
+
+def get_calendar(saturday_is_a_working_day=False):
+    # get calendar from settings
+    try:
+        calendar_class = import_string(settings.WORKING_DAY_CALENDAR)
+    except (AttributeError, ImportError):
+        return
+
+    # saturday is not a working day, return this calendar
+    if not saturday_is_a_working_day:
+        return calendar_class()
+
+    # saturday is a working day, build a custom calendar
+    class CalendarWithSaturday(calendar_class):
+        WEEKEND_DAYS = (SUN,)
+
+    return CalendarWithSaturday()
diff --git a/wcs/qommon/templatetags/qommon.py b/wcs/qommon/templatetags/qommon.py
index 2f688a40..d961cb5e 100644
--- a/wcs/qommon/templatetags/qommon.py
+++ b/wcs/qommon/templatetags/qommon.py
@@ -39,6 +39,7 @@ from django.utils import six
 from django.utils.encoding import force_bytes, force_text
 from django.utils.safestring import mark_safe
 from django.utils.timezone import is_naive, make_aware
+from wcs.qommon import calendar
 from wcs.qommon import evalutils
 from wcs.qommon import tokens
 from wcs.qommon.admin.texts import TextsDirectory
@@ -281,6 +282,66 @@ def datetime_in_past(value):
     return value <= date_now
 
 
+@register.filter(expects_localtime=True)
+def is_working_day(value, saturday_is_a_working_day=False):
+    value = parse_date(value)
+    if not value:
+        return False
+
+    cal = calendar.get_calendar(saturday_is_a_working_day=saturday_is_a_working_day)
+    if not cal:
+        return False
+
+    return cal.is_working_day(value)
+
+
+@register.filter(expects_localtime=True)
+def is_working_day_with_saturday(value):
+    return is_working_day(value, saturday_is_a_working_day=True)
+
+
+@register.filter(expects_localtime=True)
+def add_working_days(value, arg, saturday_is_a_working_day=False):
+    value = parse_date(value)
+    if not value:
+        return ''
+
+    cal = calendar.get_calendar(saturday_is_a_working_day=saturday_is_a_working_day)
+    if not cal:
+        return ''
+
+    try:
+        return cal.add_working_days(value, int(arg))
+    except ValueError:
+        return ''
+
+
+@register.filter(expects_localtime=True)
+def add_working_days_with_saturday(value, arg):
+    return add_working_days(value, arg, saturday_is_a_working_day=True)
+
+
+@register.filter(expects_localtime=True)
+def adjust_to_working_day(value, saturday_is_a_working_day=False):
+    value = parse_date(value)
+    if not value:
+        return ''
+
+    cal = calendar.get_calendar(saturday_is_a_working_day=saturday_is_a_working_day)
+    if not cal:
+        return ''
+
+    if cal.is_working_day(value):
+        return value
+    # return next working day
+    return cal.add_working_days(value, 1)
+
+
+@register.filter(expects_localtime=True)
+def adjust_to_working_day_with_saturday(value):
+    return adjust_to_working_day(value, saturday_is_a_working_day=True)
+
+
 @register.simple_tag
 def standard_text(text_id):
     return mark_safe(TextsDirectory.get_html_text(str(text_id)))
diff --git a/wcs/settings.py b/wcs/settings.py
index d3806aea..8d383f52 100644
--- a/wcs/settings.py
+++ b/wcs/settings.py
@@ -188,6 +188,9 @@ DISABLE_CRON_JOBS = False
 # w.c.s. can have very large forms, in backoffice and frontoffice
 DATA_UPLOAD_MAX_NUMBER_FIELDS = 2000  # Django default is 1000
 
+# workalendar config
+WORKING_DAY_CALENDAR = 'workalendar.europe.France'
+
 local_settings_file = os.environ.get('WCS_SETTINGS_FILE',
         os.path.join(os.path.dirname(__file__), 'local_settings.py'))
 if os.path.exists(local_settings_file):
-- 
2.20.1

