Projet

Général

Profil

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

Valentin Deniaud, 31 août 2020 18:08

Télécharger (19,8 ko)

Voir les différences:

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
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/0057_auto_20200831_1634.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.18 on 2020-08-31 14:34
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', '0056_auto_20200811_1611'),
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
39 39
from django.utils.dates import WEEKDAYS
40 40
from django.utils.encoding import force_text
41 41
from django.utils.formats import date_format
42
from django.utils.module_loading import import_string
42 43
from django.utils.text import slugify
43 44
from django.utils.timezone import localtime, now, make_aware, make_naive, is_aware
44 45
from django.utils.translation import ugettext_lazy as _, ugettext
......
1070 1071

  
1071 1072
    def save(self, *args, **kwargs):
1072 1073
        assert self.agenda.kind != 'virtual', "a desk can't reference a virtual agenda"
1074
        first_created = not self.pk
1073 1075
        if not self.slug:
1074 1076
            self.slug = generate_slug(self, agenda=self.agenda)
1075 1077
        super(Desk, self).save(*args, **kwargs)
1078
        if first_created:
1079
            self.import_timeperiod_exceptions_from_settings(enable=True)
1076 1080

  
1077 1081
    @property
1078 1082
    def base_slug(self):
......
1294 1298

  
1295 1299
        return [OpeningHour(*time_range) for time_range in (openslots - exceptions)]
1296 1300

  
1301
    def import_timeperiod_exceptions_from_settings(self, enable=False):
1302
        start_update = now()
1303
        for slug, source_info in settings.EXCEPTIONS_SOURCES.items():
1304
            label = source_info['label']
1305
            try:
1306
                source = TimePeriodExceptionSource.objects.get(desk=self, settings_slug=slug)
1307
            except TimePeriodExceptionSource.DoesNotExist:
1308
                source = TimePeriodExceptionSource.objects.create(
1309
                    desk=self, settings_slug=slug, enabled=False
1310
                )
1311
            source.settings_label = _(label)
1312
            source.save()
1313
            if enable or source.enabled:  # if already enabled, update anyway
1314
                source.enable()
1315
        TimePeriodExceptionSource.objects.filter(
1316
            desk=self, settings_slug__isnull=False, last_update__lt=start_update
1317
        ).delete()  # source was not in settings anymore
1318

  
1297 1319

  
1298 1320
class Resource(models.Model):
1299 1321
    slug = models.SlugField(_('Identifier'), max_length=160, unique=True)
......
1345 1367
    ics_filename = models.CharField(null=True, max_length=256)
1346 1368
    ics_file = models.FileField(upload_to=ics_directory_path, blank=True, null=True)
1347 1369
    ics_url = models.URLField(null=True, max_length=500)
1370
    settings_slug = models.CharField(null=True, max_length=150)
1371
    settings_label = models.CharField(null=True, max_length=150)
1372
    last_update = models.DateTimeField(auto_now=True, null=True)
1373
    enabled = models.BooleanField(default=True)
1348 1374

  
1349 1375
    def __str__(self):
1350 1376
        if self.ics_filename is not None:
1351 1377
            return self.ics_filename
1378
        if self.settings_label is not None:
1379
            return ugettext(self.settings_label)
1352 1380
        return self.ics_url
1353 1381

  
1354 1382
    def duplicate(self, desk_target=None):
......
1366 1394

  
1367 1395
        return new_source
1368 1396

  
1397
    def enable(self):
1398
        source_info = settings.EXCEPTIONS_SOURCES.get(self.settings_slug)
1399
        if not source_info:
1400
            return
1401
        source_class = import_string(source_info['class'])
1402
        calendar = source_class()
1403
        this_year = now().year
1404
        days = [day for year in range(this_year, this_year + 3) for day in calendar.holidays(year)]
1405
        with transaction.atomic():
1406
            self.timeperiodexception_set.all().delete()
1407
            for day, label in days:
1408
                start_datetime = make_aware(datetime.datetime.combine(day, datetime.datetime.min.time()))
1409
                end_datetime = start_datetime + datetime.timedelta(days=1)
1410
                TimePeriodException.objects.create(
1411
                    desk=self.desk,
1412
                    source=self,
1413
                    label=_(label),
1414
                    start_datetime=start_datetime,
1415
                    end_datetime=end_datetime,
1416
                )
1417
            self.enabled = True
1418
            self.save()
1419

  
1420
    def disable(self):
1421
        self.timeperiodexception_set.all().delete()
1422
        self.enabled = False
1423
        self.save()
1424

  
1369 1425

  
1370 1426
class TimePeriodException(models.Model):
1371 1427
    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
193 193
        views.time_period_exception_source_refresh,
194 194
        name='chrono-manager-time-period-exception-source-refresh',
195 195
    ),
196
    url(
197
        r'^time-period-exceptions-source/(?P<pk>\d+)/toggle$',
198
        views.time_period_exception_source_toggle,
199
        name='chrono-manager-time-period-exception-source-toggle',
200
    ),
196 201
    url(
197 202
        r'^time-period-exceptions-source/(?P<pk>\d+)/replace$',
198 203
        views.time_period_exception_source_replace,
chrono/manager/views.py
1946 1946
event_cancellation_report_list = EventCancellationReportListView.as_view()
1947 1947

  
1948 1948

  
1949
class TimePeriodExceptionSourceToggleView(ManagedDeskSubobjectMixin, DetailView):
1950
    model = TimePeriodExceptionSource
1951

  
1952
    def get_object(self, queryset=None):
1953
        source = super().get_object(queryset)
1954
        if source.settings_slug is None:
1955
            raise Http404('This source cannot be enabled nor disabled')
1956
        return source
1957

  
1958
    def get(self, request, *args, **kwargs):
1959
        source = self.get_object()
1960
        if source.enabled:
1961
            source.disable()
1962
            message = _('Exception source %(source)s has been disabled on desk %(desk)s.')
1963
        else:
1964
            source.enable()
1965
            message = _('Exception source %(source)s has been enabled on desk %(desk)s.')
1966
        messages.info(self.request, message % {'source': source, 'desk': source.desk})
1967
        return HttpResponseRedirect(
1968
            reverse('chrono-manager-agenda-settings', kwargs={'pk': source.desk.agenda_id})
1969
        )
1970

  
1971

  
1972
time_period_exception_source_toggle = TimePeriodExceptionSourceToggleView.as_view()
1973

  
1974

  
1949 1975
def menu_json(request):
1950 1976
    label = _('Agendas')
1951 1977
    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

  
......
166 168
# we use 28s by default: timeout just before web server, which is usually 30s
167 169
REQUESTS_TIMEOUT = 28
168 170

  
171
EXCEPTIONS_SOURCES = {
172
    'holidays': {'class': 'workalendar.europe.France', 'label': _('Holidays')},
173
}
174

  
169 175
local_settings_file = os.environ.get(
170 176
    'CHRONO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py')
171 177
)
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
25 25
        }
26 26
    },
27 27
}
28

  
29
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 (
......
512 513
    assert import_file_ics.call_args_list == []
513 514

  
514 515

  
516
@override_settings(
517
    EXCEPTIONS_SOURCES={'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},}
518
)
519
def test_timeperiodexception_from_settings():
520
    agenda = Agenda(label=u'Test 1 agenda')
521
    agenda.save()
522
    desk = Desk(label='Test 1 desk', agenda=agenda)
523
    desk.save()
524

  
525
    # first save automatically load exceptions
526
    source = TimePeriodExceptionSource.objects.get(desk=desk)
527
    assert source.settings_slug == 'holidays'
528
    assert source.enabled
529
    assert TimePeriodException.objects.filter(desk=desk, source=source).exists()
530

  
531
    exception = TimePeriodException.objects.first()
532
    from workalendar.europe import France
533

  
534
    date, label = France().holidays()[0]
535
    exception = TimePeriodException.objects.filter(label=label).first()
536
    assert exception.end_datetime - exception.start_datetime == datetime.timedelta(days=1)
537
    assert localtime(exception.start_datetime).date() == date
538

  
539
    source.disable()
540
    assert not source.enabled
541
    assert not TimePeriodException.objects.filter(desk=desk, source=source).exists()
542

  
543
    source.enable()
544
    assert source.enabled
545
    assert TimePeriodException.objects.filter(desk=desk, source=source).exists()
546

  
547

  
548
def test_timeperiodexception_from_settings_command():
549
    setting = {
550
        'EXCEPTIONS_SOURCES': {'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},}
551
    }
552
    agenda = Agenda(label=u'Test 1 agenda')
553
    agenda.save()
554
    desk1 = Desk(label='Test 1 desk', agenda=agenda)
555
    desk1.save()
556
    with override_settings(**setting):
557
        desk2 = Desk(label='Test 2 desk', agenda=agenda)
558
        desk2.save()
559
        desk3 = Desk(label='Test 3 desk', agenda=agenda)
560
        desk3.save()
561
        source3 = TimePeriodExceptionSource.objects.get(desk=desk3)
562
        source3.disable()
563

  
564
        call_command('sync_desks_timeperiod_exceptions_from_settings')
565
    assert not TimePeriodExceptionSource.objects.get(desk=desk1).enabled
566
    source2 = TimePeriodExceptionSource.objects.get(desk=desk2)
567
    assert source2.enabled
568
    source3.refresh_from_db()
569
    assert not source3.enabled
570

  
571
    exceptions_count = source2.timeperiodexception_set.count()
572
    # Alsace Moselle has more holidays
573
    setting['EXCEPTIONS_SOURCES']['holidays']['class'] = 'workalendar.europe.FranceAlsaceMoselle'
574
    with override_settings(**setting):
575
        call_command('sync_desks_timeperiod_exceptions_from_settings')
576
    source2.refresh_from_db()
577
    assert exceptions_count < source2.timeperiodexception_set.count()
578

  
579
    setting['EXCEPTIONS_SOURCES'] = {}
580
    with override_settings(**setting):
581
        call_command('sync_desks_timeperiod_exceptions_from_settings')
582
    assert not TimePeriodExceptionSource.objects.exists()
583

  
584

  
515 585
def test_base_meeting_duration():
516 586
    agenda = Agenda(label='Meeting', kind='meetings')
517 587
    agenda.save()
tests/test_manager.py
11 11
from django.contrib.auth.models import User, Group
12 12
from django.core.management import call_command
13 13
from django.db import connection
14
from django.test import override_settings
14 15
from django.test.utils import CaptureQueriesContext
15 16
from django.utils.encoding import force_text
16 17
from django.utils.timezone import make_aware, now, localtime
17 18

  
19
import datetime
18 20
import freezegun
19 21
import pytest
20 22
import requests
......
2451 2453
    assert exceptions[0].pk != new_exceptions[0].pk
2452 2454

  
2453 2455

  
2456
@override_settings(
2457
    EXCEPTIONS_SOURCES={'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},}
2458
)
2459
def test_meetings_agenda_time_period_exception_source_from_settings(app, admin_user):
2460
    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
2461
    desk = Desk.objects.create(agenda=agenda, label='Desk A')
2462
    MeetingType(agenda=agenda, label='Blah').save()
2463
    TimePeriod.objects.create(
2464
        weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
2465
    )
2466
    assert TimePeriodException.objects.exists()
2467

  
2468
    login(app)
2469
    resp = app.get('/manage/agendas/%d/' % agenda.pk).follow()
2470
    resp = resp.click('Settings')
2471
    resp = resp.click('upload')
2472
    assert 'Holidays' in resp.text
2473
    assert 'disabled' not in resp.text
2474
    assert 'refresh' not in resp.text
2475

  
2476
    resp = resp.click('disable').follow()
2477
    assert not TimePeriodException.objects.exists()
2478

  
2479
    resp = resp.click('upload')
2480
    assert 'Holidays' in resp.text
2481
    assert 'disabled' in resp.text
2482

  
2483
    resp = resp.click('enable').follow()
2484
    assert TimePeriodException.objects.exists()
2485

  
2486
    resp = resp.click('upload')
2487
    assert 'disabled' not in resp.text
2488

  
2489

  
2490
def test_meetings_agenda_time_period_exception_source_try_disable_ics(app, admin_user):
2491
    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
2492
    desk = Desk.objects.create(agenda=agenda, label='Desk A')
2493
    MeetingType(agenda=agenda, label='Blah').save()
2494
    TimePeriod.objects.create(
2495
        weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
2496
    )
2497
    source = TimePeriodExceptionSource.objects.create(desk=desk, ics_url='https://example.com/test.ics')
2498

  
2499
    login(app)
2500
    resp = app.get('/manage/agendas/%d/' % agenda.pk).follow()
2501
    resp = resp.click('Settings')
2502
    resp = resp.click('upload')
2503
    assert 'test.ics' in resp.text
2504

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

  
2507

  
2454 2508
def test_agenda_day_view(app, admin_user, manager_user, api_user):
2455 2509
    agenda = Agenda.objects.create(label='New Example', kind='meetings')
2456 2510
    desk = Desk.objects.create(agenda=agenda, label='New Desk')
2457
-