From 2edcdfaa70b0025d11049ce5df4b90a89ba65e7a Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Thu, 20 Feb 2020 11:46:23 +0100 Subject: [PATCH 1/2] agendas: add global exceptions sources (#18904) --- ...sks_timeperiod_exceptions_from_settings.py | 35 ++++++ .../migrations/0041_auto_20200330_1803.py | 33 +++++ chrono/agendas/models.py | 66 +++++++++- .../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 | 113 ++++++++++++++++++ tests/test_manager.py | 53 ++++++++ 12 files changed, 346 insertions(+), 3 deletions(-) create mode 100644 chrono/agendas/management/commands/sync_desks_timeperiod_exceptions_from_settings.py create mode 100644 chrono/agendas/migrations/0041_auto_20200330_1803.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..762daba --- /dev/null +++ b/chrono/agendas/management/commands/sync_desks_timeperiod_exceptions_from_settings.py @@ -0,0 +1,35 @@ +# 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 add_arguments(self, parser): + parser.add_argument( + '--force-update', + action='store_true', + default=False, + help='Update existing exceptions, at the cost of adding back eventual user deleted exceptions', + ) + + def handle(self, **options): + for desk in Desk.objects.all(): + desk.import_timeperiod_exceptions_from_settings(force_update=options['force_update']) diff --git a/chrono/agendas/migrations/0041_auto_20200330_1803.py b/chrono/agendas/migrations/0041_auto_20200330_1803.py new file mode 100644 index 0000000..a09a493 --- /dev/null +++ b/chrono/agendas/migrations/0041_auto_20200330_1803.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2020-03-30 16:03 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('agendas', '0040_timeperiod_agenda'), + ] + + 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 432fe2b..e16b2c3 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -31,9 +31,10 @@ from django.urls import reverse 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 _ +from django.utils.translation import ugettext, ugettext_lazy as _ from jsonfield import JSONField @@ -735,9 +736,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) @classmethod def import_json(cls, data): @@ -911,6 +915,24 @@ class Desk(models.Model): return openslots.search(aware_date, aware_next_date) + def import_timeperiod_exceptions_from_settings(self, enable=False, force_update=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 + ) + if enable or source.enabled: # if already enabled, update anyway + source.enable(force_update) + source.settings_label = _(label) + source.save() + TimePeriodExceptionSource.objects.filter( + desk=self, settings_slug__isnull=False, last_update__lt=start_update + ).delete() # source was not in settings anymore + def ics_directory_path(instance, filename): return 'ics/{0}/{1}'.format(str(uuid.uuid4()), filename) @@ -921,12 +943,54 @@ 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) + + years_to_sync = 3 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 enable(self, force_update=False): + 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 + if self.enabled and not force_update: + start_year = self.last_update.year + self.years_to_sync + else: + start_year = this_year + end_year = this_year + self.years_to_sync + days = [day for year in range(start_year, end_year) for day in calendar.holidays(year)] + with transaction.atomic(): + if force_update: + 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 56b2b9f..5cf183d 100644 --- a/chrono/manager/urls.py +++ b/chrono/manager/urls.py @@ -138,6 +138,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 1be8a40..ca8a946 100644 --- a/chrono/manager/views.py +++ b/chrono/manager/views.py @@ -1227,6 +1227,32 @@ class TimePeriodExceptionSourceRefreshView(ManagedDeskSubobjectMixin, DetailView time_period_exception_source_refresh = TimePeriodExceptionSourceRefreshView.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 37cadf3..c17d4d5 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__)) @@ -161,6 +163,10 @@ MELLON_IDENTITY_PROVIDERS = [] # (see http://docs.python-requests.org/en/master/user/advanced/#proxies) REQUESTS_PROXIES = None +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 dcc1e8e..753c2b4 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -13,3 +13,5 @@ DATABASES = { 'TEST': {'NAME': 'chrono-test-%s' % os.environ.get("BRANCH_NAME", "").replace('/', '-')[:63],}, } } + +EXCEPTIONS_SOURCES = {} diff --git a/tests/test_agendas.py b/tests/test_agendas.py index 03d28b5..9926bfc 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 ( @@ -438,6 +439,118 @@ 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() + # existing exceptions are not touched, as slug remained the same + assert exceptions_count == source2.timeperiodexception_set.count() + # unless explicitly told so + call_command('sync_desks_timeperiod_exceptions_from_settings', '--force-update') + 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() + + +@override_settings( + EXCEPTIONS_SOURCES={'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},} +) +def test_timeperiodexception_from_settings_command_multiple_run(freezer): + freezer.move_to('2019-06-01') + agenda = Agenda(label=u'Test 1 agenda') + agenda.save() + desk = Desk(label='Test 1 desk', agenda=agenda) + desk.save() + source = TimePeriodExceptionSource.objects.get(desk=desk) + + # user can delete some exceptions + exceptions = source.timeperiodexception_set.all() + exceptions = [exceptions.first(), exceptions[exceptions.count() // 2], exceptions.last()] + for e in exceptions: + e.delete() + exceptions_count = TimePeriodException.objects.filter(source=source).count() + call_command('sync_desks_timeperiod_exceptions_from_settings') + + # they must not be added back + assert TimePeriodException.objects.filter(source=source).count() == exceptions_count + for e in exceptions: + assert not TimePeriodException.objects.filter( + source=source, start_datetime=e.start_datetime, end_datetime=e.end_datetime + ).exists() + + freezer.move_to('2020-04-01') + call_command('sync_desks_timeperiod_exceptions_from_settings') + + # new exceptions have been loaded + assert TimePeriodException.objects.filter(source=source).count() > exceptions_count + + # old ones are left untouched + for e in exceptions: + assert not TimePeriodException.objects.filter( + source=source, start_datetime=e.start_datetime, end_datetime=e.end_datetime + ).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 73d1a1c..39ef02d 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -9,6 +9,7 @@ import re from django.contrib.auth.models import User, Group from django.utils.encoding import force_text from django.utils.timezone import make_aware, now, localtime +from django.test import override_settings import datetime import freezegun import mock @@ -1665,6 +1666,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