From 8aa34e7126043c0fa819bb233290139b178aa697 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 | 27 +++++++ .../migrations/0038_auto_20200220_1518.py | 35 +++++++++ chrono/agendas/models.py | 53 +++++++++++++- chrono/manager/static/css/style.scss | 12 ++++ .../chrono/manager_import_exceptions.html | 10 ++- chrono/manager/urls.py | 5 ++ chrono/manager/views.py | 26 +++++++ chrono/settings.py | 6 ++ setup.py | 1 + tests/settings.py | 2 + tests/test_agendas.py | 71 +++++++++++++++++++ tests/test_manager.py | 51 +++++++++++++ 12 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 chrono/agendas/management/commands/sync_desks_timeperiod_exceptions_from_settings.py create mode 100644 chrono/agendas/migrations/0038_auto_20200220_1518.py 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..4dd5d04 --- /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 TimePeriodExceptionSource + + +class Command(BaseCommand): + help = 'Synchronize time period exceptions from settings' + + def handle(self, **options): + for source in TimePeriodExceptionSource.objects.filter(settings_slug__isnull=False): + source.desk.import_timeperiod_exceptions_from_settings() diff --git a/chrono/agendas/migrations/0038_auto_20200220_1518.py b/chrono/agendas/migrations/0038_auto_20200220_1518.py new file mode 100644 index 0000000..f9378ea --- /dev/null +++ b/chrono/agendas/migrations/0038_auto_20200220_1518.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2020-02-20 14:18 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('agendas', '0037_timeperiodexceptionsource_ics_file'), + ] + + 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 c2135df..d8a0848 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 @@ -521,9 +522,12 @@ class Desk(models.Model): unique_together = ['agenda', 'slug'] def save(self, *args, **kwargs): + 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): @@ -697,6 +701,19 @@ class Desk(models.Model): return openslots.search(aware_date, aware_next_date) + 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'] + source, created = TimePeriodExceptionSource.objects.update_or_create( + desk=self, settings_slug=slug, defaults={'settings_label': _(label)} + ) + 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 + def ics_directory_path(instance, filename): return 'ics/{0}/{1}'.format(str(uuid.uuid4()), filename) @@ -707,12 +724,46 @@ 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 enable(self): + source_info = settings.EXCEPTIONS_SOURCES.get(self.settings_slug) + if not source_info: + return + self.timeperiodexception_set.all().delete() + 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(): + 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/static/css/style.scss b/chrono/manager/static/css/style.scss index 0f90197..4e68edf 100644 --- a/chrono/manager/static/css/style.scss +++ b/chrono/manager/static/css/style.scss @@ -278,6 +278,18 @@ ul.objects-list.single-links li a.link-action-icon.refresh { } } +ul.objects-list.single-links li a.link-action-icon.enable { + &::before { + content: "\f204"; /* toggle-off */ + } +} + +ul.objects-list.single-links li a.link-action-icon.disable { + &::before { + content: "\f205"; /* toggle-on */ + } +} + div.ui-dialog form p span.datetime input { width: auto; } diff --git a/chrono/manager/templates/chrono/manager_import_exceptions.html b/chrono/manager/templates/chrono/manager_import_exceptions.html index c774344..cb738b8 100644 --- a/chrono/manager/templates/chrono/manager_import_exceptions.html +++ b/chrono/manager/templates/chrono/manager_import_exceptions.html @@ -17,13 +17,19 @@ diff --git a/chrono/manager/urls.py b/chrono/manager/urls.py index 1d4f926..a427242 100644 --- a/chrono/manager/urls.py +++ b/chrono/manager/urls.py @@ -123,6 +123,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 c949c81..1eaf4bd 100644 --- a/chrono/manager/views.py +++ b/chrono/manager/views.py @@ -1080,6 +1080,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/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 1b9f4f2..ec8366b 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -4,3 +4,5 @@ LANGUAGE_CODE = 'en-us' REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ['rest_framework.authentication.BasicAuthentication'], } + +EXCEPTIONS_SOURCES = {} diff --git a/tests/test_agendas.py b/tests/test_agendas.py index 5e99c24..4687fa5 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 ( @@ -426,6 +427,76 @@ 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.filter(desk=desk1).exists() + 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 f8f858d..72edef4 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 @@ -1638,6 +1639,56 @@ 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