Projet

Général

Profil

0001-agendas-add-global-exceptions-sources-18904.patch

Valentin Deniaud, 02 avril 2020 16:15

Télécharger (19,6 ko)

Voir les différences:

Subject: [PATCH 1/3] agendas: add global exceptions sources (#18904)

 ...sks_timeperiod_exceptions_from_settings.py | 27 +++++++
 .../migrations/0041_auto_20200330_1803.py     | 33 +++++++++
 chrono/agendas/models.py                      | 58 ++++++++++++++-
 .../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                         | 53 ++++++++++++++
 12 files changed, 287 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
chrono/agendas/management/commands/sync_desks_timeperiod_exceptions_from_settings.py
1
# chrono - agendas system
2
# Copyright (C) 2020  Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
from django.core.management.base import BaseCommand
18

  
19
from chrono.agendas.models import Desk
20

  
21

  
22
class Command(BaseCommand):
23
    help = 'Synchronize time period exceptions from settings'
24

  
25
    def handle(self, **options):
26
        for desk in Desk.objects.all():
27
            desk.import_timeperiod_exceptions_from_settings()
chrono/agendas/migrations/0041_auto_20200330_1803.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.18 on 2020-03-30 16:03
3
from __future__ import unicode_literals
4

  
5
from django.db import migrations, models
6

  
7

  
8
class Migration(migrations.Migration):
9

  
10
    dependencies = [
11
        ('agendas', '0040_timeperiod_agenda'),
12
    ]
13

  
14
    operations = [
15
        migrations.AddField(
16
            model_name='timeperiodexceptionsource', name='enabled', field=models.BooleanField(default=True),
17
        ),
18
        migrations.AddField(
19
            model_name='timeperiodexceptionsource',
20
            name='last_update',
21
            field=models.DateTimeField(auto_now=True, null=True),
22
        ),
23
        migrations.AddField(
24
            model_name='timeperiodexceptionsource',
25
            name='settings_label',
26
            field=models.CharField(max_length=150, null=True),
27
        ),
28
        migrations.AddField(
29
            model_name='timeperiodexceptionsource',
30
            name='settings_slug',
31
            field=models.CharField(max_length=150, null=True),
32
        ),
33
    ]
chrono/agendas/models.py
31 31
from django.utils.dates import WEEKDAYS
32 32
from django.utils.encoding import force_text
33 33
from django.utils.formats import date_format
34
from django.utils.module_loading import import_string
34 35
from django.utils.text import slugify
35 36
from django.utils.timezone import localtime, now, make_aware, make_naive, is_aware
36
from django.utils.translation import ugettext_lazy as _
37
from django.utils.translation import ugettext, ugettext_lazy as _
37 38

  
38 39
from jsonfield import JSONField
39 40

  
......
735 736

  
736 737
    def save(self, *args, **kwargs):
737 738
        assert self.agenda.kind != 'virtual', "a desk can't reference a virtual agenda"
739
        first_created = not self.pk
738 740
        if not self.slug:
739 741
            self.slug = generate_slug(self, agenda=self.agenda)
740 742
        super(Desk, self).save(*args, **kwargs)
743
        if first_created:
744
            self.import_timeperiod_exceptions_from_settings(enable=True)
741 745

  
742 746
    @classmethod
743 747
    def import_json(cls, data):
......
911 915

  
912 916
        return openslots.search(aware_date, aware_next_date)
913 917

  
918
    def import_timeperiod_exceptions_from_settings(self, enable=False):
919
        start_update = now()
920
        for slug, source_info in settings.EXCEPTIONS_SOURCES.items():
921
            label = source_info['label']
922
            try:
923
                source = TimePeriodExceptionSource.objects.get(desk=self, settings_slug=slug)
924
            except TimePeriodExceptionSource.DoesNotExist:
925
                source = TimePeriodExceptionSource.objects.create(
926
                    desk=self, settings_slug=slug, enabled=False
927
                )
928
            source.settings_label = _(label)
929
            source.save()
930
            if enable or source.enabled:  # if already enabled, update anyway
931
                source.enable()
932
        TimePeriodExceptionSource.objects.filter(
933
            desk=self, settings_slug__isnull=False, last_update__lt=start_update
934
        ).delete()  # source was not in settings anymore
935

  
914 936

  
915 937
def ics_directory_path(instance, filename):
916 938
    return 'ics/{0}/{1}'.format(str(uuid.uuid4()), filename)
......
921 943
    ics_filename = models.CharField(null=True, max_length=256)
922 944
    ics_file = models.FileField(upload_to=ics_directory_path, blank=True, null=True)
923 945
    ics_url = models.URLField(null=True, max_length=500)
946
    settings_slug = models.CharField(null=True, max_length=150)
947
    settings_label = models.CharField(null=True, max_length=150)
948
    last_update = models.DateTimeField(auto_now=True, null=True)
949
    enabled = models.BooleanField(default=True)
924 950

  
925 951
    def __str__(self):
926 952
        if self.ics_filename is not None:
927 953
            return self.ics_filename
954
        if self.settings_label is not None:
955
            return ugettext(self.settings_label)
928 956
        return self.ics_url
929 957

  
958
    def enable(self):
959
        source_info = settings.EXCEPTIONS_SOURCES.get(self.settings_slug)
960
        if not source_info:
961
            return
962
        source_class = import_string(source_info['class'])
963
        calendar = source_class()
964
        this_year = now().year
965
        days = [day for year in range(this_year, this_year + 3) for day in calendar.holidays(year)]
966
        with transaction.atomic():
967
            self.timeperiodexception_set.all().delete()
968
            for day, label in days:
969
                start_datetime = make_aware(datetime.datetime.combine(day, datetime.datetime.min.time()))
970
                end_datetime = start_datetime + datetime.timedelta(days=1)
971
                TimePeriodException.objects.create(
972
                    desk=self.desk,
973
                    source=self,
974
                    label=_(label),
975
                    start_datetime=start_datetime,
976
                    end_datetime=end_datetime,
977
                )
978
            self.enabled = True
979
            self.save()
980

  
981
    def disable(self):
982
        self.timeperiodexception_set.all().delete()
983
        self.enabled = False
984
        self.save()
985

  
930 986

  
931 987
class TimePeriodException(models.Model):
932 988
    desk = models.ForeignKey(Desk, on_delete=models.CASCADE)
chrono/manager/templates/chrono/manager_import_exceptions.html
17 17
  <ul class="objects-list single-links">
18 18
      {% for object in exception_sources %}
19 19
        <li>
20
          <a title="{{ object }}" {% if not object.ics_filename %}href="{{ object }}"{% endif %}>{% if object.ics_filename %}{{ object|truncatechars:50 }}{% else %}{{ object|truncatechars:50 }}{% endif %}</a>
20
          <a {% if not object.enabled %}class="disabled"{% endif %} title="{{ object }}" {% if object.ics_url %}href="{{ object }}"{% endif %}>{% if object.ics_filename %}{{ object|truncatechars:50 }}{% else %}{{ object|truncatechars:50 }}{% endif %}</a>
21 21
          {% if object.ics_filename %}
22 22
            <a rel="popup" class="link-action-icon refresh" href="{% url 'chrono-manager-time-period-exception-source-replace' object.pk %}">{% trans "replace" %}</a>
23
          {% else %}
23
          {% elif object.ics_url %}
24 24
            <a class="link-action-icon refresh" href="{% url 'chrono-manager-time-period-exception-source-refresh' object.pk %}">{% trans "refresh" %}</a>
25 25
          {% endif %}
26
          {% if not object.settings_slug %}
26 27
          <a rel="popup" class="delete" href="{% url 'chrono-manager-time-period-exception-source-delete' object.pk %}">{% trans "remove" %}</a>
28
          {% else %}
29
          <a class="link-action-text" href="{% url 'chrono-manager-time-period-exception-source-toggle' object.pk %}">({{ object.enabled|yesno:_("disable,enable") }})</a>
30
          {% endif %}
27 31
        </li>
28 32
      {% endfor %}
29 33
  </ul>
chrono/manager/urls.py
138 138
        views.time_period_exception_source_refresh,
139 139
        name='chrono-manager-time-period-exception-source-refresh',
140 140
    ),
141
    url(
142
        r'^time-period-exceptions-source/(?P<pk>\d+)/toggle$',
143
        views.time_period_exception_source_toggle,
144
        name='chrono-manager-time-period-exception-source-toggle',
145
    ),
141 146
    url(
142 147
        r'^time-period-exceptions-source/(?P<pk>\d+)/replace$',
143 148
        views.time_period_exception_source_replace,
chrono/manager/views.py
1227 1227
time_period_exception_source_refresh = TimePeriodExceptionSourceRefreshView.as_view()
1228 1228

  
1229 1229

  
1230
class TimePeriodExceptionSourceToggleView(ManagedDeskSubobjectMixin, DetailView):
1231
    model = TimePeriodExceptionSource
1232

  
1233
    def get_object(self, queryset=None):
1234
        source = super().get_object(queryset)
1235
        if source.settings_slug is None:
1236
            raise Http404('This source cannot be enabled nor disabled')
1237
        return source
1238

  
1239
    def get(self, request, *args, **kwargs):
1240
        source = self.get_object()
1241
        if source.enabled:
1242
            source.disable()
1243
            message = _('Exception source %(source)s has been disabled on desk %(desk)s.')
1244
        else:
1245
            source.enable()
1246
            message = _('Exception source %(source)s has been enabled on desk %(desk)s.')
1247
        messages.info(self.request, message % {'source': source, 'desk': source.desk})
1248
        return HttpResponseRedirect(
1249
            reverse('chrono-manager-agenda-settings', kwargs={'pk': source.desk.agenda_id})
1250
        )
1251

  
1252

  
1253
time_period_exception_source_toggle = TimePeriodExceptionSourceToggleView.as_view()
1254

  
1255

  
1230 1256
def menu_json(request):
1231 1257
    label = _('Agendas')
1232 1258
    json_str = json.dumps(
chrono/settings.py
26 26
import os
27 27
from django.conf.global_settings import STATICFILES_FINDERS
28 28

  
29
_ = lambda s: s
30

  
29 31
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
30 32
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
31 33

  
......
161 163
# (see http://docs.python-requests.org/en/master/user/advanced/#proxies)
162 164
REQUESTS_PROXIES = None
163 165

  
166
EXCEPTIONS_SOURCES = {
167
    'holidays': {'class': 'workalendar.europe.France', 'label': _('Holidays')},
168
}
169

  
164 170
local_settings_file = os.environ.get(
165 171
    'CHRONO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py')
166 172
)
debian/chrono.cron.d
1
0 0 1 1 * chrono /usr/bin/chrono-manage -- tenant_command sync_desks_timeperiod_exceptions_from_settings --all-tenants
setup.py
168 168
        'vobject',
169 169
        'python-dateutil',
170 170
        'requests',
171
        'workalendar',
171 172
    ],
172 173
    zip_safe=False,
173 174
    cmdclass={
tests/settings.py
13 13
        'TEST': {'NAME': 'chrono-test-%s' % os.environ.get("BRANCH_NAME", "").replace('/', '-')[:63],},
14 14
    }
15 15
}
16

  
17
EXCEPTIONS_SOURCES = {}
tests/test_agendas.py
7 7
from django.contrib.auth.models import Group
8 8
from django.core.files.base import ContentFile
9 9
from django.core.management import call_command
10
from django.test import override_settings
10 11
from django.utils.timezone import localtime, make_aware, now
11 12

  
12 13
from chrono.agendas.models import (
......
438 439
    assert import_file_ics.call_args_list == []
439 440

  
440 441

  
442
@override_settings(
443
    EXCEPTIONS_SOURCES={'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},}
444
)
445
def test_timeperiodexception_from_settings():
446
    agenda = Agenda(label=u'Test 1 agenda')
447
    agenda.save()
448
    desk = Desk(label='Test 1 desk', agenda=agenda)
449
    desk.save()
450

  
451
    # first save automatically load exceptions
452
    source = TimePeriodExceptionSource.objects.get(desk=desk)
453
    assert source.settings_slug == 'holidays'
454
    assert source.enabled
455
    assert TimePeriodException.objects.filter(desk=desk, source=source).exists()
456

  
457
    exception = TimePeriodException.objects.first()
458
    from workalendar.europe import France
459

  
460
    date, label = France().holidays()[0]
461
    exception = TimePeriodException.objects.filter(label=label).first()
462
    assert exception.end_datetime - exception.start_datetime == datetime.timedelta(days=1)
463
    assert localtime(exception.start_datetime).date() == date
464

  
465
    source.disable()
466
    assert not source.enabled
467
    assert not TimePeriodException.objects.filter(desk=desk, source=source).exists()
468

  
469
    source.enable()
470
    assert source.enabled
471
    assert TimePeriodException.objects.filter(desk=desk, source=source).exists()
472

  
473

  
474
def test_timeperiodexception_from_settings_command():
475
    setting = {
476
        'EXCEPTIONS_SOURCES': {'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},}
477
    }
478
    agenda = Agenda(label=u'Test 1 agenda')
479
    agenda.save()
480
    desk1 = Desk(label='Test 1 desk', agenda=agenda)
481
    desk1.save()
482
    with override_settings(**setting):
483
        desk2 = Desk(label='Test 2 desk', agenda=agenda)
484
        desk2.save()
485
        desk3 = Desk(label='Test 3 desk', agenda=agenda)
486
        desk3.save()
487
        source3 = TimePeriodExceptionSource.objects.get(desk=desk3)
488
        source3.disable()
489

  
490
        call_command('sync_desks_timeperiod_exceptions_from_settings')
491
    assert not TimePeriodExceptionSource.objects.get(desk=desk1).enabled
492
    source2 = TimePeriodExceptionSource.objects.get(desk=desk2)
493
    assert source2.enabled
494
    source3.refresh_from_db()
495
    assert not source3.enabled
496

  
497
    exceptions_count = source2.timeperiodexception_set.count()
498
    # Alsace Moselle has more holidays
499
    setting['EXCEPTIONS_SOURCES']['holidays']['class'] = 'workalendar.europe.FranceAlsaceMoselle'
500
    with override_settings(**setting):
501
        call_command('sync_desks_timeperiod_exceptions_from_settings')
502
    source2.refresh_from_db()
503
    assert exceptions_count < source2.timeperiodexception_set.count()
504

  
505
    setting['EXCEPTIONS_SOURCES'] = {}
506
    with override_settings(**setting):
507
        call_command('sync_desks_timeperiod_exceptions_from_settings')
508
    assert not TimePeriodExceptionSource.objects.exists()
509

  
510

  
441 511
def test_base_meeting_duration():
442 512
    agenda = Agenda(label='Meeting', kind='meetings')
443 513
    agenda.save()
tests/test_manager.py
9 9
from django.contrib.auth.models import User, Group
10 10
from django.utils.encoding import force_text
11 11
from django.utils.timezone import make_aware, now, localtime
12
from django.test import override_settings
12 13
import datetime
13 14
import freezegun
14 15
import mock
......
1665 1666
    assert exceptions[0].pk != new_exceptions[0].pk
1666 1667

  
1667 1668

  
1669
@override_settings(
1670
    EXCEPTIONS_SOURCES={'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},}
1671
)
1672
def test_meetings_agenda_time_period_exception_source_from_settings(app, admin_user):
1673
    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
1674
    desk = Desk.objects.create(agenda=agenda, label='Desk A')
1675
    MeetingType(agenda=agenda, label='Blah').save()
1676
    TimePeriod.objects.create(
1677
        weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
1678
    )
1679
    assert TimePeriodException.objects.exists()
1680

  
1681
    login(app)
1682
    resp = app.get('/manage/agendas/%d/' % agenda.pk).follow()
1683
    resp = resp.click('Settings')
1684
    resp = resp.click('upload')
1685
    assert 'Holidays' in resp.text
1686
    assert 'disabled' not in resp.text
1687
    assert 'refresh' not in resp.text
1688

  
1689
    resp = resp.click('disable').follow()
1690
    assert not TimePeriodException.objects.exists()
1691

  
1692
    resp = resp.click('upload')
1693
    assert 'Holidays' in resp.text
1694
    assert 'disabled' in resp.text
1695

  
1696
    resp = resp.click('enable').follow()
1697
    assert TimePeriodException.objects.exists()
1698

  
1699
    resp = resp.click('upload')
1700
    assert 'disabled' not in resp.text
1701

  
1702

  
1703
def test_meetings_agenda_time_period_exception_source_try_disable_ics(app, admin_user):
1704
    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
1705
    desk = Desk.objects.create(agenda=agenda, label='Desk A')
1706
    MeetingType(agenda=agenda, label='Blah').save()
1707
    TimePeriod.objects.create(
1708
        weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
1709
    )
1710
    source = TimePeriodExceptionSource.objects.create(desk=desk, ics_url='https://example.com/test.ics')
1711

  
1712
    login(app)
1713
    resp = app.get('/manage/agendas/%d/' % agenda.pk).follow()
1714
    resp = resp.click('Settings')
1715
    resp = resp.click('upload')
1716
    assert 'test.ics' in resp.text
1717

  
1718
    assert app.get('/manage/time-period-exceptions-source/%s/toggle' % source.pk, status=404)
1719

  
1720

  
1668 1721
def test_agenda_day_view(app, admin_user, manager_user, api_user):
1669 1722
    agenda = Agenda.objects.create(label='New Example', kind='meetings')
1670 1723
    desk = Desk.objects.create(agenda=agenda, label='New Desk')
1671
-