From 94e45ce76b90ca5c3f5c0a08a82fb99087788a29 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 | 59 +++++++++++++- chrono/manager/static/css/style.scss | 6 ++ .../chrono/manager_confirm_source_delete.html | 4 + .../chrono/manager_import_exceptions.html | 8 +- chrono/manager/urls.py | 5 ++ chrono/manager/views.py | 17 ++++ chrono/settings.py | 6 ++ setup.py | 1 + tests/settings.py | 2 + tests/test_agendas.py | 77 +++++++++++++++++++ tests/test_manager.py | 36 +++++++++ 13 files changed, 280 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 79ee1a8..a5d1884 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 @@ -515,9 +516,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): @@ -691,6 +695,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) @@ -701,12 +718,52 @@ 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() + + def delete(self, *args, **kwargs): + if self.settings_slug is not None: + self.disable() + return 0, {} + return super().delete(*args, **kwargs) + 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 348e232..e4807bb 100644 --- a/chrono/manager/static/css/style.scss +++ b/chrono/manager/static/css/style.scss @@ -274,6 +274,12 @@ ul.objects-list.single-links li a.link-action-icon.refresh { } } +ul.objects-list.single-links li a.link-action-icon.show { + &::before { + content: "\f06e"; /* eye */ + } +} + div.ui-dialog form p span.datetime input { width: auto; } diff --git a/chrono/manager/templates/chrono/manager_confirm_source_delete.html b/chrono/manager/templates/chrono/manager_confirm_source_delete.html index 8eaf1b6..14bae8c 100644 --- a/chrono/manager/templates/chrono/manager_confirm_source_delete.html +++ b/chrono/manager/templates/chrono/manager_confirm_source_delete.html @@ -9,7 +9,11 @@
{% csrf_token %}

+ {% if not object.settings_slug %} {% blocktrans %}Are you sure you want to delete this exception source?{% endblocktrans %} + {% else %} + {% blocktrans %}Are you sure you want to disable this exception source?{% endblocktrans %} + {% endif %}

diff --git a/chrono/manager/templates/chrono/manager_import_exceptions.html b/chrono/manager/templates/chrono/manager_import_exceptions.html index c774344..0e011fe 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 1d4f926..84e8378 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+)/enable$', + views.time_period_exception_source_enable, + name='chrono-manager-time-period-exception-source-enable', + ), 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 ae6e514..7ac1586 100644 --- a/chrono/manager/views.py +++ b/chrono/manager/views.py @@ -1049,6 +1049,23 @@ class TimePeriodExceptionSourceRefreshView(ManagedDeskSubobjectMixin, DetailView time_period_exception_source_refresh = TimePeriodExceptionSourceRefreshView.as_view() +class TimePeriodExceptionSourceEnableView(ManagedDeskSubobjectMixin, DetailView): + model = TimePeriodExceptionSource + + def get(self, request, *args, **kwargs): + source = self.get_object() + source.enable() + messages.info( + self.request, _('Exception source %s has been enabled on desk %s.' % (source, source.desk)) + ) + return HttpResponseRedirect( + reverse('chrono-manager-agenda-settings', kwargs={'pk': source.desk.agenda_id}) + ) + + +time_period_exception_source_enable = TimePeriodExceptionSourceEnableView.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..bced8aa 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,82 @@ 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() + + # deleting only disable source + source.delete() + source.refresh_from_db() + assert not source.enabled + assert not 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 50e3748..89f8099 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -8,6 +8,7 @@ import os 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 @@ -1624,6 +1625,41 @@ 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') # TODO this view name is now incorrect, change it + assert 'Holidays' in resp.text + assert 'disabled' not in resp.text + assert 'refresh' not in resp.text + + resp = resp.click('remove') + resp = resp.form.submit().follow() + assert not TimePeriodException.objects.exists() + + resp = resp.click('upload') + assert 'Holidays' in resp.text + assert 'disabled' in resp.text + assert 'remove' not in resp.text + + resp = resp.click('enable').follow() + assert TimePeriodException.objects.exists() + + resp = resp.click('upload') + assert 'disabled' not in resp.text + 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