Projet

Général

Profil

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

Valentin Deniaud, 20 février 2020 17:53

Télécharger (19,7 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                      | 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
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

  
......
515 516
        unique_together = ['agenda', 'slug']
516 517

  
517 518
    def save(self, *args, **kwargs):
519
        first_created = not self.pk
518 520
        if not self.slug:
519 521
            self.slug = generate_slug(self, agenda=self.agenda)
520 522
        super(Desk, self).save(*args, **kwargs)
523
        if first_created:
524
            self.import_timeperiod_exceptions_from_settings(enable=True)
521 525

  
522 526
    @classmethod
523 527
    def import_json(cls, data):
......
691 695

  
692 696
        return openslots.search(aware_date, aware_next_date)
693 697

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

  
694 711

  
695 712
def ics_directory_path(instance, filename):
696 713
    return 'ics/{0}/{1}'.format(str(uuid.uuid4()), filename)
......
701 718
    ics_filename = models.CharField(null=True, max_length=256)
702 719
    ics_file = models.FileField(upload_to=ics_directory_path, blank=True, null=True)
703 720
    ics_url = models.URLField(null=True, max_length=500)
721
    settings_slug = models.CharField(null=True, max_length=150)
722
    settings_label = models.CharField(null=True, max_length=150)
723
    last_update = models.DateTimeField(auto_now=True, null=True)
724
    enabled = models.BooleanField(default=True)
704 725

  
705 726
    def __str__(self):
706 727
        if self.ics_filename is not None:
707 728
            return self.ics_filename
729
        if self.settings_label is not None:
730
            return ugettext(self.settings_label)
708 731
        return self.ics_url
709 732

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

  
756
    def disable(self):
757
        self.timeperiodexception_set.all().delete()
758
        self.enabled = False
759
        self.save()
760

  
761
    def delete(self, *args, **kwargs):
762
        if self.settings_slug is not None:
763
            self.disable()
764
            return 0, {}
765
        return super().delete(*args, **kwargs)
766

  
710 767

  
711 768
class TimePeriodException(models.Model):
712 769
    desk = models.ForeignKey(Desk, on_delete=models.CASCADE)
chrono/manager/static/css/style.scss
274 274
	}
275 275
}
276 276

  
277
ul.objects-list.single-links li a.link-action-icon.show {
278
	&::before {
279
		content: "\f06e"; /* eye */
280
	}
281
}
282

  
277 283
div.ui-dialog form p span.datetime input {
278 284
	width: auto;
279 285
}
chrono/manager/templates/chrono/manager_confirm_source_delete.html
9 9
<form method="post">
10 10
  {% csrf_token %}
11 11
  <p>
12
  {% if not object.settings_slug %}
12 13
  {% blocktrans %}Are you sure you want to delete this exception source?{% endblocktrans %}
14
  {% else %}
15
  {% blocktrans %}Are you sure you want to disable this exception source?{% endblocktrans %}
16
  {% endif %}
13 17
  </p>
14 18
  <div class="buttons">
15 19
    <button class="delete-button">{% trans 'Delete' %}</button>
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 object.enabled %}
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-icon show" href="{% url 'chrono-manager-time-period-exception-source-enable' object.pk %}">{% trans "enable" %}</a>
30
          {% endif %}
27 31
        </li>
28 32
      {% endfor %}
29 33
  </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+)/enable$',
128
        views.time_period_exception_source_enable,
129
        name='chrono-manager-time-period-exception-source-enable',
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
1049 1049
time_period_exception_source_refresh = TimePeriodExceptionSourceRefreshView.as_view()
1050 1050

  
1051 1051

  
1052
class TimePeriodExceptionSourceEnableView(ManagedDeskSubobjectMixin, DetailView):
1053
    model = TimePeriodExceptionSource
1054

  
1055
    def get(self, request, *args, **kwargs):
1056
        source = self.get_object()
1057
        source.enable()
1058
        messages.info(
1059
            self.request, _('Exception source %s has been enabled on desk %s.' % (source, source.desk))
1060
        )
1061
        return HttpResponseRedirect(
1062
            reverse('chrono-manager-agenda-settings', kwargs={'pk': source.desk.agenda_id})
1063
        )
1064

  
1065

  
1066
time_period_exception_source_enable = TimePeriodExceptionSourceEnableView.as_view()
1067

  
1068

  
1052 1069
def menu_json(request):
1053 1070
    label = _('Agendas')
1054 1071
    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
    # deleting only disable source
461
    source.delete()
462
    source.refresh_from_db()
463
    assert not source.enabled
464
    assert not TimePeriodException.objects.filter(desk=desk, source=source).exists()
465

  
466

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

  
485
        call_command('sync_desks_timeperiod_exceptions_from_settings')
486
    assert not TimePeriodExceptionSource.objects.filter(desk=desk1).exists()
487
    source2 = TimePeriodExceptionSource.objects.get(desk=desk2)
488
    assert source2.enabled
489
    source3.refresh_from_db()
490
    assert not source3.enabled
491

  
492
    exceptions_count = source2.timeperiodexception_set.count()
493
    # Alsace Moselle has more holidays
494
    setting['EXCEPTIONS_SOURCES']['holidays']['class'] = 'workalendar.europe.FranceAlsaceMoselle'
495
    with override_settings(**setting):
496
        call_command('sync_desks_timeperiod_exceptions_from_settings')
497
    source2.refresh_from_db()
498
    assert exceptions_count < source2.timeperiodexception_set.count()
499

  
500
    setting['EXCEPTIONS_SOURCES'] = {}
501
    with override_settings(**setting):
502
        call_command('sync_desks_timeperiod_exceptions_from_settings')
503
    assert not TimePeriodExceptionSource.objects.exists()
504

  
505

  
429 506
def test_base_meeting_duration():
430 507
    agenda = Agenda(label='Meeting', kind='meetings')
431 508
    agenda.save()
tests/test_manager.py
8 8
from django.contrib.auth.models import User, Group
9 9
from django.utils.encoding import force_text
10 10
from django.utils.timezone import make_aware, now, localtime
11
from django.test import override_settings
11 12
import datetime
12 13
import freezegun
13 14
import mock
......
1624 1625
    assert exceptions[0].pk != new_exceptions[0].pk
1625 1626

  
1626 1627

  
1628
@override_settings(EXCEPTIONS_SOURCES={
1629
    'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
1630
})
1631
def test_meetings_agenda_time_period_exception_source_from_settings(app, admin_user):
1632
    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
1633
    desk = Desk.objects.create(agenda=agenda, label='Desk A')
1634
    MeetingType(agenda=agenda, label='Blah').save()
1635
    TimePeriod.objects.create(
1636
        weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
1637
    )
1638
    assert TimePeriodException.objects.exists()
1639

  
1640
    login(app)
1641
    resp = app.get('/manage/agendas/%d/' % agenda.pk).follow()
1642
    resp = resp.click('Settings')
1643
    resp = resp.click('upload')  # TODO this view name is now incorrect, change it
1644
    assert 'Holidays' in resp.text
1645
    assert 'disabled' not in resp.text
1646
    assert 'refresh' not in resp.text
1647

  
1648
    resp = resp.click('remove')
1649
    resp = resp.form.submit().follow()
1650
    assert not TimePeriodException.objects.exists()
1651

  
1652
    resp = resp.click('upload')
1653
    assert 'Holidays' in resp.text
1654
    assert 'disabled' in resp.text
1655
    assert 'remove' not in resp.text
1656

  
1657
    resp = resp.click('enable').follow()
1658
    assert TimePeriodException.objects.exists()
1659

  
1660
    resp = resp.click('upload')
1661
    assert 'disabled' not in resp.text
1662

  
1627 1663
def test_agenda_day_view(app, admin_user, manager_user, api_user):
1628 1664
    agenda = Agenda.objects.create(label='New Example', kind='meetings')
1629 1665
    desk = Desk.objects.create(agenda=agenda, label='New Desk')
1630
-