From bf394e0a073b540922ba3bb67f7a9b3ba4960057 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Thu, 20 Feb 2020 11:46:23 +0100 Subject: [PATCH 1/4] agendas: add global exceptions sources (#18904) --- ...sks_timeperiod_exceptions_from_settings.py | 27 +++++++ .../migrations/0057_auto_20200831_1634.py | 33 +++++++++ chrono/agendas/models.py | 56 +++++++++++++++ .../chrono/manager_import_exceptions.html | 8 ++- chrono/manager/urls.py | 5 ++ chrono/manager/views.py | 26 +++++++ chrono/settings.py | 6 ++ debian/chrono.cron.d | 1 + setup.py | 1 + tests/settings.py | 2 + tests/test_agendas.py | 70 +++++++++++++++++++ tests/test_manager.py | 54 ++++++++++++++ 12 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 chrono/agendas/management/commands/sync_desks_timeperiod_exceptions_from_settings.py create mode 100644 chrono/agendas/migrations/0057_auto_20200831_1634.py create mode 100644 debian/chrono.cron.d 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 0000000..f7d4f5e --- /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 0000000..be4790b --- /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 dcb9034..90d8587 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 c774344..d4044c3 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 b801535..8323845 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 1bef2ca..bd7270a 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 fa26296..de44215 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 0000000..c5d8132 --- /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 5de2fc1..13daa8a 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 d1ace55..6594cb5 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 c66927a..3c638c9 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 36bdf63..9b171ce 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') -- 2.20.1