Projet

Général

Profil

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

Valentin Deniaud, 05 mars 2020 16:35

Télécharger (19,9 ko)

Voir les différences:

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
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 TimePeriodExceptionSource
20

  
21

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

  
25
    def handle(self, **options):
26
        for source in TimePeriodExceptionSource.objects.filter(settings_slug__isnull=False):
27
            source.desk.import_timeperiod_exceptions_from_settings()
chrono/agendas/migrations/0038_auto_20200220_1518.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.18 on 2020-02-20 14:18
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', '0037_timeperiodexceptionsource_ics_file'),
12
    ]
13

  
14
    operations = [
15
        migrations.AddField(
16
            model_name='timeperiodexceptionsource',
17
            name='enabled',
18
            field=models.BooleanField(default=True),
19
        ),
20
        migrations.AddField(
21
            model_name='timeperiodexceptionsource',
22
            name='last_update',
23
            field=models.DateTimeField(auto_now=True, null=True),
24
        ),
25
        migrations.AddField(
26
            model_name='timeperiodexceptionsource',
27
            name='settings_label',
28
            field=models.CharField(max_length=150, null=True),
29
        ),
30
        migrations.AddField(
31
            model_name='timeperiodexceptionsource',
32
            name='settings_slug',
33
            field=models.CharField(max_length=150, null=True),
34
        ),
35
    ]
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

  
......
521 522
        unique_together = ['agenda', 'slug']
522 523

  
523 524
    def save(self, *args, **kwargs):
525
        first_created = not self.pk
524 526
        if not self.slug:
525 527
            self.slug = generate_slug(self, agenda=self.agenda)
526 528
        super(Desk, self).save(*args, **kwargs)
529
        if first_created:
530
            self.import_timeperiod_exceptions_from_settings(enable=True)
527 531

  
528 532
    @classmethod
529 533
    def import_json(cls, data):
......
697 701

  
698 702
        return openslots.search(aware_date, aware_next_date)
699 703

  
704
    def import_timeperiod_exceptions_from_settings(self, enable=False):
705
        start_update = now()
706
        for slug, source_info in settings.EXCEPTIONS_SOURCES.items():
707
            label = source_info['label']
708
            source, created = TimePeriodExceptionSource.objects.update_or_create(
709
                desk=self, settings_slug=slug, defaults={'settings_label': _(label)}
710
            )
711
            if enable or source.enabled:  # if already enabled, update anyway
712
                source.enable()
713
        TimePeriodExceptionSource.objects.filter(
714
            desk=self, settings_slug__isnull=False, last_update__lt=start_update
715
        ).delete()  # source was not in settings anymore
716

  
700 717

  
701 718
def ics_directory_path(instance, filename):
702 719
    return 'ics/{0}/{1}'.format(str(uuid.uuid4()), filename)
......
707 724
    ics_filename = models.CharField(null=True, max_length=256)
708 725
    ics_file = models.FileField(upload_to=ics_directory_path, blank=True, null=True)
709 726
    ics_url = models.URLField(null=True, max_length=500)
727
    settings_slug = models.CharField(null=True, max_length=150)
728
    settings_label = models.CharField(null=True, max_length=150)
729
    last_update = models.DateTimeField(auto_now=True, null=True)
730
    enabled = models.BooleanField(default=True)
710 731

  
711 732
    def __str__(self):
712 733
        if self.ics_filename is not None:
713 734
            return self.ics_filename
735
        if self.settings_label is not None:
736
            return ugettext(self.settings_label)
714 737
        return self.ics_url
715 738

  
739
    def enable(self):
740
        source_info = settings.EXCEPTIONS_SOURCES.get(self.settings_slug)
741
        if not source_info:
742
            return
743
        self.timeperiodexception_set.all().delete()
744
        source_class = import_string(source_info['class'])
745
        calendar = source_class()
746
        this_year = now().year
747
        days = [day for year in range(this_year, this_year + 3) for day in calendar.holidays(year)]
748
        with transaction.atomic():
749
            for day, label in days:
750
                start_datetime = make_aware(datetime.datetime.combine(day, datetime.datetime.min.time()))
751
                end_datetime = start_datetime + datetime.timedelta(days=1)
752
                TimePeriodException.objects.create(
753
                    desk=self.desk,
754
                    source=self,
755
                    label=_(label),
756
                    start_datetime=start_datetime,
757
                    end_datetime=end_datetime,
758
                )
759
            self.enabled = True
760
            self.save()
761

  
762
    def disable(self):
763
        self.timeperiodexception_set.all().delete()
764
        self.enabled = False
765
        self.save()
766

  
716 767

  
717 768
class TimePeriodException(models.Model):
718 769
    desk = models.ForeignKey(Desk, on_delete=models.CASCADE)
chrono/manager/static/css/style.scss
278 278
	}
279 279
}
280 280

  
281
ul.objects-list.single-links li a.link-action-icon.enable {
282
	&::before {
283
		content: "\f204"; /* toggle-off */
284
	}
285
}
286

  
287
ul.objects-list.single-links li a.link-action-icon.disable {
288
	&::before {
289
		content: "\f205"; /* toggle-on */
290
	}
291
}
292

  
281 293
div.ui-dialog form p span.datetime input {
282 294
	width: auto;
283 295
}
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
          {% with status=object.enabled|yesno:"disable,enable" %}
30
          <a class="link-action-icon {{ status }}" href="{% url 'chrono-manager-time-period-exception-source-toggle' object.pk %}">{% blocktrans %}{{ status }}{% endblocktrans %}</a>
31
          {% endwith %}
32
          {% endif %}
27 33
        </li>
28 34
      {% endfor %}
29 35
  </ul>
chrono/manager/urls.py
123 123
        views.time_period_exception_source_refresh,
124 124
        name='chrono-manager-time-period-exception-source-refresh',
125 125
    ),
126
    url(
127
        r'^time-period-exceptions-source/(?P<pk>\d+)/toggle$',
128
        views.time_period_exception_source_toggle,
129
        name='chrono-manager-time-period-exception-source-toggle',
130
    ),
126 131
    url(
127 132
        r'^time-period-exceptions-source/(?P<pk>\d+)/replace$',
128 133
        views.time_period_exception_source_replace,
chrono/manager/views.py
1080 1080
time_period_exception_source_refresh = TimePeriodExceptionSourceRefreshView.as_view()
1081 1081

  
1082 1082

  
1083
class TimePeriodExceptionSourceToggleView(ManagedDeskSubobjectMixin, DetailView):
1084
    model = TimePeriodExceptionSource
1085

  
1086
    def get_object(self, queryset=None):
1087
        source = super().get_object(queryset)
1088
        if source.settings_slug is None:
1089
            raise Http404('This source cannot be enabled nor disabled')
1090
        return source
1091

  
1092
    def get(self, request, *args, **kwargs):
1093
        source = self.get_object()
1094
        if source.enabled:
1095
            source.disable()
1096
            message = _('Exception source %(source)s has been disabled on desk %(desk)s.')
1097
        else:
1098
            source.enable()
1099
            message = _('Exception source %(source)s has been enabled on desk %(desk)s.')
1100
        messages.info(self.request, message % {'source': source, 'desk': source.desk})
1101
        return HttpResponseRedirect(
1102
            reverse('chrono-manager-agenda-settings', kwargs={'pk': source.desk.agenda_id})
1103
        )
1104

  
1105

  
1106
time_period_exception_source_toggle = TimePeriodExceptionSourceToggleView.as_view()
1107

  
1108

  
1083 1109
def menu_json(request):
1084 1110
    label = _('Agendas')
1085 1111
    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
)
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
4 4
REST_FRAMEWORK = {
5 5
    'DEFAULT_AUTHENTICATION_CLASSES': ['rest_framework.authentication.BasicAuthentication'],
6 6
}
7

  
8
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 (
......
426 427
    assert import_file_ics.call_args_list == []
427 428

  
428 429

  
430
@override_settings(EXCEPTIONS_SOURCES={
431
    'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
432
})
433
def test_timeperiodexception_from_settings():
434
    agenda = Agenda(label=u'Test 1 agenda')
435
    agenda.save()
436
    desk = Desk(label='Test 1 desk', agenda=agenda)
437
    desk.save()
438

  
439
    # first save automatically load exceptions
440
    source = TimePeriodExceptionSource.objects.get(desk=desk)
441
    assert source.settings_slug == 'holidays'
442
    assert source.enabled
443
    assert TimePeriodException.objects.filter(desk=desk, source=source).exists()
444

  
445
    exception = TimePeriodException.objects.first()
446
    from workalendar.europe import France
447
    date, label = France().holidays()[0]
448
    exception = TimePeriodException.objects.filter(label=label).first()
449
    assert exception.end_datetime - exception.start_datetime == datetime.timedelta(days=1)
450
    assert localtime(exception.start_datetime).date() == date
451

  
452
    source.disable()
453
    assert not source.enabled
454
    assert not TimePeriodException.objects.filter(desk=desk, source=source).exists()
455

  
456
    source.enable()
457
    assert source.enabled
458
    assert TimePeriodException.objects.filter(desk=desk, source=source).exists()
459

  
460

  
461
def test_timeperiodexception_from_settings_command():
462
    setting = {
463
        'EXCEPTIONS_SOURCES': {
464
            'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
465
        }
466
    }
467
    agenda = Agenda(label=u'Test 1 agenda')
468
    agenda.save()
469
    desk1 = Desk(label='Test 1 desk', agenda=agenda)
470
    desk1.save()
471
    with override_settings(**setting):
472
        desk2 = Desk(label='Test 2 desk', agenda=agenda)
473
        desk2.save()
474
        desk3 = Desk(label='Test 3 desk', agenda=agenda)
475
        desk3.save()
476
        source3 = TimePeriodExceptionSource.objects.get(desk=desk3)
477
        source3.disable()
478

  
479
        call_command('sync_desks_timeperiod_exceptions_from_settings')
480
    assert not TimePeriodExceptionSource.objects.filter(desk=desk1).exists()
481
    source2 = TimePeriodExceptionSource.objects.get(desk=desk2)
482
    assert source2.enabled
483
    source3.refresh_from_db()
484
    assert not source3.enabled
485

  
486
    exceptions_count = source2.timeperiodexception_set.count()
487
    # Alsace Moselle has more holidays
488
    setting['EXCEPTIONS_SOURCES']['holidays']['class'] = 'workalendar.europe.FranceAlsaceMoselle'
489
    with override_settings(**setting):
490
        call_command('sync_desks_timeperiod_exceptions_from_settings')
491
    source2.refresh_from_db()
492
    assert exceptions_count < source2.timeperiodexception_set.count()
493

  
494
    setting['EXCEPTIONS_SOURCES'] = {}
495
    with override_settings(**setting):
496
        call_command('sync_desks_timeperiod_exceptions_from_settings')
497
    assert not TimePeriodExceptionSource.objects.exists()
498

  
499

  
429 500
def test_base_meeting_duration():
430 501
    agenda = Agenda(label='Meeting', kind='meetings')
431 502
    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
......
1638 1639
    assert exceptions[0].pk != new_exceptions[0].pk
1639 1640

  
1640 1641

  
1642
@override_settings(EXCEPTIONS_SOURCES={
1643
    'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
1644
})
1645
def test_meetings_agenda_time_period_exception_source_from_settings(app, admin_user):
1646
    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
1647
    desk = Desk.objects.create(agenda=agenda, label='Desk A')
1648
    MeetingType(agenda=agenda, label='Blah').save()
1649
    TimePeriod.objects.create(
1650
        weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
1651
    )
1652
    assert TimePeriodException.objects.exists()
1653

  
1654
    login(app)
1655
    resp = app.get('/manage/agendas/%d/' % agenda.pk).follow()
1656
    resp = resp.click('Settings')
1657
    resp = resp.click('upload')
1658
    assert 'Holidays' in resp.text
1659
    assert 'disabled' not in resp.text
1660
    assert 'refresh' not in resp.text
1661

  
1662
    resp = resp.click('disable').follow()
1663
    assert not TimePeriodException.objects.exists()
1664

  
1665
    resp = resp.click('upload')
1666
    assert 'Holidays' in resp.text
1667
    assert 'disabled' in resp.text
1668

  
1669
    resp = resp.click('enable').follow()
1670
    assert TimePeriodException.objects.exists()
1671

  
1672
    resp = resp.click('upload')
1673
    assert 'disabled' not in resp.text
1674

  
1675
def test_meetings_agenda_time_period_exception_source_try_disable_ics(app, admin_user):
1676
    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
1677
    desk = Desk.objects.create(agenda=agenda, label='Desk A')
1678
    MeetingType(agenda=agenda, label='Blah').save()
1679
    TimePeriod.objects.create(
1680
        weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
1681
    )
1682
    source = TimePeriodExceptionSource.objects.create(desk=desk, ics_url='https://example.com/test.ics')
1683

  
1684
    login(app)
1685
    resp = app.get('/manage/agendas/%d/' % agenda.pk).follow()
1686
    resp = resp.click('Settings')
1687
    resp = resp.click('upload')
1688
    assert 'test.ics' in resp.text
1689

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

  
1641 1692
def test_agenda_day_view(app, admin_user, manager_user, api_user):
1642 1693
    agenda = Agenda.objects.create(label='New Example', kind='meetings')
1643 1694
    desk = Desk.objects.create(agenda=agenda, label='New Desk')
1644
-