diff --git a/chrono/agendas/management/commands/sync_desks_timeperiod_exceptions_from_settings.py b/chrono/agendas/management/commands/sync_desks_timeperiod_exceptions_from_settings.py
new file mode 100644
index 00000000..f7d4f5e5
--- /dev/null
+++ b/chrono/agendas/management/commands/sync_desks_timeperiod_exceptions_from_settings.py
@@ -0,0 +1,27 @@
+# chrono - agendas system
+# Copyright (C) 2020 Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from django.core.management.base import BaseCommand
+
+from chrono.agendas.models import Desk
+
+
+class Command(BaseCommand):
+ help = 'Synchronize time period exceptions from settings'
+
+ def handle(self, **options):
+ for desk in Desk.objects.all():
+ desk.import_timeperiod_exceptions_from_settings()
diff --git a/chrono/agendas/migrations/0057_auto_20200831_1634.py b/chrono/agendas/migrations/0057_auto_20200831_1634.py
new file mode 100644
index 00000000..be4790bb
--- /dev/null
+++ b/chrono/agendas/migrations/0057_auto_20200831_1634.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2020-08-31 14:34
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('agendas', '0056_auto_20200811_1611'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='timeperiodexceptionsource', name='enabled', field=models.BooleanField(default=True),
+ ),
+ migrations.AddField(
+ model_name='timeperiodexceptionsource',
+ name='last_update',
+ field=models.DateTimeField(auto_now=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='timeperiodexceptionsource',
+ name='settings_label',
+ field=models.CharField(max_length=150, null=True),
+ ),
+ migrations.AddField(
+ model_name='timeperiodexceptionsource',
+ name='settings_slug',
+ field=models.CharField(max_length=150, null=True),
+ ),
+ ]
diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py
index dcb90343..90d8587c 100644
--- a/chrono/agendas/models.py
+++ b/chrono/agendas/models.py
@@ -39,6 +39,7 @@ from django.utils import functional
from django.utils.dates import WEEKDAYS
from django.utils.encoding import force_text
from django.utils.formats import date_format
+from django.utils.module_loading import import_string
from django.utils.text import slugify
from django.utils.timezone import localtime, now, make_aware, make_naive, is_aware
from django.utils.translation import ugettext_lazy as _, ugettext
@@ -1070,9 +1071,12 @@ class Desk(models.Model):
def save(self, *args, **kwargs):
assert self.agenda.kind != 'virtual', "a desk can't reference a virtual agenda"
+ first_created = not self.pk
if not self.slug:
self.slug = generate_slug(self, agenda=self.agenda)
super(Desk, self).save(*args, **kwargs)
+ if first_created:
+ self.import_timeperiod_exceptions_from_settings(enable=True)
@property
def base_slug(self):
@@ -1294,6 +1298,24 @@ class Desk(models.Model):
return [OpeningHour(*time_range) for time_range in (openslots - exceptions)]
+ def import_timeperiod_exceptions_from_settings(self, enable=False):
+ start_update = now()
+ for slug, source_info in settings.EXCEPTIONS_SOURCES.items():
+ label = source_info['label']
+ try:
+ source = TimePeriodExceptionSource.objects.get(desk=self, settings_slug=slug)
+ except TimePeriodExceptionSource.DoesNotExist:
+ source = TimePeriodExceptionSource.objects.create(
+ desk=self, settings_slug=slug, enabled=False
+ )
+ source.settings_label = _(label)
+ source.save()
+ if enable or source.enabled: # if already enabled, update anyway
+ source.enable()
+ TimePeriodExceptionSource.objects.filter(
+ desk=self, settings_slug__isnull=False, last_update__lt=start_update
+ ).delete() # source was not in settings anymore
+
class Resource(models.Model):
slug = models.SlugField(_('Identifier'), max_length=160, unique=True)
@@ -1345,10 +1367,16 @@ class TimePeriodExceptionSource(models.Model):
ics_filename = models.CharField(null=True, max_length=256)
ics_file = models.FileField(upload_to=ics_directory_path, blank=True, null=True)
ics_url = models.URLField(null=True, max_length=500)
+ settings_slug = models.CharField(null=True, max_length=150)
+ settings_label = models.CharField(null=True, max_length=150)
+ last_update = models.DateTimeField(auto_now=True, null=True)
+ enabled = models.BooleanField(default=True)
def __str__(self):
if self.ics_filename is not None:
return self.ics_filename
+ if self.settings_label is not None:
+ return ugettext(self.settings_label)
return self.ics_url
def duplicate(self, desk_target=None):
@@ -1366,6 +1394,34 @@ class TimePeriodExceptionSource(models.Model):
return new_source
+ def enable(self):
+ source_info = settings.EXCEPTIONS_SOURCES.get(self.settings_slug)
+ if not source_info:
+ return
+ source_class = import_string(source_info['class'])
+ calendar = source_class()
+ this_year = now().year
+ days = [day for year in range(this_year, this_year + 3) for day in calendar.holidays(year)]
+ with transaction.atomic():
+ self.timeperiodexception_set.all().delete()
+ for day, label in days:
+ start_datetime = make_aware(datetime.datetime.combine(day, datetime.datetime.min.time()))
+ end_datetime = start_datetime + datetime.timedelta(days=1)
+ TimePeriodException.objects.create(
+ desk=self.desk,
+ source=self,
+ label=_(label),
+ start_datetime=start_datetime,
+ end_datetime=end_datetime,
+ )
+ self.enabled = True
+ self.save()
+
+ def disable(self):
+ self.timeperiodexception_set.all().delete()
+ self.enabled = False
+ self.save()
+
class TimePeriodException(models.Model):
desk = models.ForeignKey(Desk, on_delete=models.CASCADE)
diff --git a/chrono/manager/templates/chrono/manager_import_exceptions.html b/chrono/manager/templates/chrono/manager_import_exceptions.html
index c774344b..d4044c39 100644
--- a/chrono/manager/templates/chrono/manager_import_exceptions.html
+++ b/chrono/manager/templates/chrono/manager_import_exceptions.html
@@ -17,13 +17,17 @@
diff --git a/chrono/manager/urls.py b/chrono/manager/urls.py
index b8015358..8323845b 100644
--- a/chrono/manager/urls.py
+++ b/chrono/manager/urls.py
@@ -193,6 +193,11 @@ urlpatterns = [
views.time_period_exception_source_refresh,
name='chrono-manager-time-period-exception-source-refresh',
),
+ url(
+ r'^time-period-exceptions-source/(?P\d+)/toggle$',
+ views.time_period_exception_source_toggle,
+ name='chrono-manager-time-period-exception-source-toggle',
+ ),
url(
r'^time-period-exceptions-source/(?P\d+)/replace$',
views.time_period_exception_source_replace,
diff --git a/chrono/manager/views.py b/chrono/manager/views.py
index 1bef2cad..bd7270a6 100644
--- a/chrono/manager/views.py
+++ b/chrono/manager/views.py
@@ -1946,6 +1946,32 @@ class EventCancellationReportListView(ViewableAgendaMixin, ListView):
event_cancellation_report_list = EventCancellationReportListView.as_view()
+class TimePeriodExceptionSourceToggleView(ManagedDeskSubobjectMixin, DetailView):
+ model = TimePeriodExceptionSource
+
+ def get_object(self, queryset=None):
+ source = super().get_object(queryset)
+ if source.settings_slug is None:
+ raise Http404('This source cannot be enabled nor disabled')
+ return source
+
+ def get(self, request, *args, **kwargs):
+ source = self.get_object()
+ if source.enabled:
+ source.disable()
+ message = _('Exception source %(source)s has been disabled on desk %(desk)s.')
+ else:
+ source.enable()
+ message = _('Exception source %(source)s has been enabled on desk %(desk)s.')
+ messages.info(self.request, message % {'source': source, 'desk': source.desk})
+ return HttpResponseRedirect(
+ reverse('chrono-manager-agenda-settings', kwargs={'pk': source.desk.agenda_id})
+ )
+
+
+time_period_exception_source_toggle = TimePeriodExceptionSourceToggleView.as_view()
+
+
def menu_json(request):
label = _('Agendas')
json_str = json.dumps(
diff --git a/chrono/settings.py b/chrono/settings.py
index fa262965..de442150 100644
--- a/chrono/settings.py
+++ b/chrono/settings.py
@@ -26,6 +26,8 @@ and to disable DEBUG mode in production.
import os
from django.conf.global_settings import STATICFILES_FINDERS
+_ = lambda s: s
+
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
@@ -166,6 +168,10 @@ REQUESTS_PROXIES = None
# we use 28s by default: timeout just before web server, which is usually 30s
REQUESTS_TIMEOUT = 28
+EXCEPTIONS_SOURCES = {
+ 'holidays': {'class': 'workalendar.europe.France', 'label': _('Holidays')},
+}
+
local_settings_file = os.environ.get(
'CHRONO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py')
)
diff --git a/debian/chrono.cron.d b/debian/chrono.cron.d
new file mode 100644
index 00000000..c5d81321
--- /dev/null
+++ b/debian/chrono.cron.d
@@ -0,0 +1 @@
+0 0 1 1 * chrono /usr/bin/chrono-manage -- tenant_command sync_desks_timeperiod_exceptions_from_settings --all-tenants
diff --git a/setup.py b/setup.py
index 5de2fc1f..13daa8a1 100644
--- a/setup.py
+++ b/setup.py
@@ -168,6 +168,7 @@ setup(
'vobject',
'python-dateutil',
'requests',
+ 'workalendar',
],
zip_safe=False,
cmdclass={
diff --git a/tests/settings.py b/tests/settings.py
index d1ace553..6594cb54 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -25,3 +25,5 @@ KNOWN_SERVICES = {
}
},
}
+
+EXCEPTIONS_SOURCES = {}
diff --git a/tests/test_agendas.py b/tests/test_agendas.py
index c66927ad..3c638c94 100644
--- a/tests/test_agendas.py
+++ b/tests/test_agendas.py
@@ -7,6 +7,7 @@ import requests
from django.contrib.auth.models import Group
from django.core.files.base import ContentFile
from django.core.management import call_command
+from django.test import override_settings
from django.utils.timezone import localtime, make_aware, now
from chrono.agendas.models import (
@@ -512,6 +513,75 @@ def test_sync_desks_timeperiod_exceptions_from_ics(mocked_get, capsys):
assert import_file_ics.call_args_list == []
+@override_settings(
+ EXCEPTIONS_SOURCES={'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},}
+)
+def test_timeperiodexception_from_settings():
+ agenda = Agenda(label=u'Test 1 agenda')
+ agenda.save()
+ desk = Desk(label='Test 1 desk', agenda=agenda)
+ desk.save()
+
+ # first save automatically load exceptions
+ source = TimePeriodExceptionSource.objects.get(desk=desk)
+ assert source.settings_slug == 'holidays'
+ assert source.enabled
+ assert TimePeriodException.objects.filter(desk=desk, source=source).exists()
+
+ exception = TimePeriodException.objects.first()
+ from workalendar.europe import France
+
+ date, label = France().holidays()[0]
+ exception = TimePeriodException.objects.filter(label=label).first()
+ assert exception.end_datetime - exception.start_datetime == datetime.timedelta(days=1)
+ assert localtime(exception.start_datetime).date() == date
+
+ source.disable()
+ assert not source.enabled
+ assert not TimePeriodException.objects.filter(desk=desk, source=source).exists()
+
+ source.enable()
+ assert source.enabled
+ assert TimePeriodException.objects.filter(desk=desk, source=source).exists()
+
+
+def test_timeperiodexception_from_settings_command():
+ setting = {
+ 'EXCEPTIONS_SOURCES': {'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},}
+ }
+ agenda = Agenda(label=u'Test 1 agenda')
+ agenda.save()
+ desk1 = Desk(label='Test 1 desk', agenda=agenda)
+ desk1.save()
+ with override_settings(**setting):
+ desk2 = Desk(label='Test 2 desk', agenda=agenda)
+ desk2.save()
+ desk3 = Desk(label='Test 3 desk', agenda=agenda)
+ desk3.save()
+ source3 = TimePeriodExceptionSource.objects.get(desk=desk3)
+ source3.disable()
+
+ call_command('sync_desks_timeperiod_exceptions_from_settings')
+ assert not TimePeriodExceptionSource.objects.get(desk=desk1).enabled
+ source2 = TimePeriodExceptionSource.objects.get(desk=desk2)
+ assert source2.enabled
+ source3.refresh_from_db()
+ assert not source3.enabled
+
+ exceptions_count = source2.timeperiodexception_set.count()
+ # Alsace Moselle has more holidays
+ setting['EXCEPTIONS_SOURCES']['holidays']['class'] = 'workalendar.europe.FranceAlsaceMoselle'
+ with override_settings(**setting):
+ call_command('sync_desks_timeperiod_exceptions_from_settings')
+ source2.refresh_from_db()
+ assert exceptions_count < source2.timeperiodexception_set.count()
+
+ setting['EXCEPTIONS_SOURCES'] = {}
+ with override_settings(**setting):
+ call_command('sync_desks_timeperiod_exceptions_from_settings')
+ assert not TimePeriodExceptionSource.objects.exists()
+
+
def test_base_meeting_duration():
agenda = Agenda(label='Meeting', kind='meetings')
agenda.save()
diff --git a/tests/test_manager.py b/tests/test_manager.py
index 36bdf636..9b171cea 100644
--- a/tests/test_manager.py
+++ b/tests/test_manager.py
@@ -11,10 +11,12 @@ import os
from django.contrib.auth.models import User, Group
from django.core.management import call_command
from django.db import connection
+from django.test import override_settings
from django.test.utils import CaptureQueriesContext
from django.utils.encoding import force_text
from django.utils.timezone import make_aware, now, localtime
+import datetime
import freezegun
import pytest
import requests
@@ -2451,6 +2453,58 @@ END:VCALENDAR"""
assert exceptions[0].pk != new_exceptions[0].pk
+@override_settings(
+ EXCEPTIONS_SOURCES={'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},}
+)
+def test_meetings_agenda_time_period_exception_source_from_settings(app, admin_user):
+ agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
+ desk = Desk.objects.create(agenda=agenda, label='Desk A')
+ MeetingType(agenda=agenda, label='Blah').save()
+ TimePeriod.objects.create(
+ weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
+ )
+ assert TimePeriodException.objects.exists()
+
+ login(app)
+ resp = app.get('/manage/agendas/%d/' % agenda.pk).follow()
+ resp = resp.click('Settings')
+ resp = resp.click('upload')
+ assert 'Holidays' in resp.text
+ assert 'disabled' not in resp.text
+ assert 'refresh' not in resp.text
+
+ resp = resp.click('disable').follow()
+ assert not TimePeriodException.objects.exists()
+
+ resp = resp.click('upload')
+ assert 'Holidays' in resp.text
+ assert 'disabled' in resp.text
+
+ resp = resp.click('enable').follow()
+ assert TimePeriodException.objects.exists()
+
+ resp = resp.click('upload')
+ assert 'disabled' not in resp.text
+
+
+def test_meetings_agenda_time_period_exception_source_try_disable_ics(app, admin_user):
+ agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
+ desk = Desk.objects.create(agenda=agenda, label='Desk A')
+ MeetingType(agenda=agenda, label='Blah').save()
+ TimePeriod.objects.create(
+ weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
+ )
+ source = TimePeriodExceptionSource.objects.create(desk=desk, ics_url='https://example.com/test.ics')
+
+ login(app)
+ resp = app.get('/manage/agendas/%d/' % agenda.pk).follow()
+ resp = resp.click('Settings')
+ resp = resp.click('upload')
+ assert 'test.ics' in resp.text
+
+ assert app.get('/manage/time-period-exceptions-source/%s/toggle' % source.pk, status=404)
+
+
def test_agenda_day_view(app, admin_user, manager_user, api_user):
agenda = Agenda.objects.create(label='New Example', kind='meetings')
desk = Desk.objects.create(agenda=agenda, label='New Desk')