Projet

Général

Profil

0001-agenda-add-support-for-remote-calendar-file-with-exc.patch

Serghei Mihai, 09 octobre 2017 16:34

Télécharger (23,8 ko)

Voir les différences:

Subject: [PATCH] agenda: add support for remote calendar file with exceptions
 (#19070)

Remote calendars can be specified by desk and updated hourly, or used once.
 .../0020_desk_timeperiod_exceptions_remote_url.py  |  19 +++
 chrono/agendas/models.py                           |  18 +++
 chrono/manager/forms.py                            |  30 ++++-
 .../commands/sync_desks_timeperiod_exceptions.py   |  33 +++++
 .../chrono/manager_import_exceptions.html          |   4 +-
 chrono/manager/views.py                            |   5 +-
 debian/control                                     |   3 +-
 requirements.txt                                   |   1 +
 setup.py                                           |   3 +-
 tests/test_agendas.py                              | 101 +++++++++++++++
 tests/test_manager.py                              | 143 +++++++++++++++++++++
 11 files changed, 348 insertions(+), 12 deletions(-)
 create mode 100644 chrono/agendas/migrations/0020_desk_timeperiod_exceptions_remote_url.py
 create mode 100644 chrono/manager/management/commands/sync_desks_timeperiod_exceptions.py
chrono/agendas/migrations/0020_desk_timeperiod_exceptions_remote_url.py
1
# -*- coding: utf-8 -*-
2
from __future__ import unicode_literals
3

  
4
from django.db import migrations, models
5

  
6

  
7
class Migration(migrations.Migration):
8

  
9
    dependencies = [
10
        ('agendas', '0019_timeperiodexception'),
11
    ]
12

  
13
    operations = [
14
        migrations.AddField(
15
            model_name='desk',
16
            name='timeperiod_exceptions_remote_url',
17
            field=models.URLField(null=True, verbose_name='URL to fetch time period exceptions from', blank=True),
18
        ),
19
    ]
chrono/agendas/models.py
17 17

  
18 18
import datetime
19 19
import vobject
20
import requests
20 21

  
21 22
from django.contrib.auth.models import Group
22 23
from django.core.exceptions import ValidationError
......
43 44
    dtime = localtime(dtime)
44 45
    return dtime.hour == 0 and dtime.minute == 0
45 46

  
47
def get_remote_calendar(url):
48
    try:
49
        response = requests.get(url)
50
        response.raise_for_status()
51
    except requests.exceptions.HTTPError as e:
52
        raise ICSError(_('Failed to retrieve remote calendar (HTTP error %s).') % e.response.status_code)
53
    except requests.exceptions.ConnectionError:
54
        raise ICSError(_('Failed to retrieve remote calendar (connection error).'))
55
    except requests.exceptions.Timeout:
56
        raise ICSError(_('Failed to retrieve remote calendar (HTTP timeout).'))
57
    return response.text
58

  
46 59

  
47 60
class ICSError(Exception):
48 61
    pass
......
358 371
    agenda = models.ForeignKey(Agenda)
359 372
    label = models.CharField(_('Label'), max_length=150)
360 373
    slug = models.SlugField(_('Identifier'), max_length=150)
374
    timeperiod_exceptions_remote_url = models.URLField(_('URL to fetch time period exceptions from'),
375
                                    null=True, blank=True)
361 376

  
362 377
    def __unicode__(self):
363 378
        return self.label
......
417 432
        in_two_weeks = self.get_exceptions_within_two_weeks()
418 433
        return self.timeperiodexception_set.count() == len(in_two_weeks)
419 434

  
435
    def create_timeperiod_exceptions_from_remote_ics(self, url):
436
        return self.create_timeperiod_exceptions_from_ics(get_remote_calendar(url))
437

  
420 438
    def create_timeperiod_exceptions_from_ics(self, data):
421 439
        try:
422 440
            parsed = vobject.readOne(data)
chrono/manager/forms.py
16 16

  
17 17
import csv
18 18
import datetime
19
import requests
19 20

  
20 21
from django import forms
21 22
from django.forms import ValidationError
22 23
from django.utils.translation import ugettext_lazy as _
23 24

  
24 25
from chrono.agendas.models import (Event, MeetingType, TimePeriod, Desk,
25
                                   TimePeriodException)
26
                                   TimePeriodException, ICSError, get_remote_calendar)
26 27

  
27 28
from . import widgets
28 29

  
......
77 78
        exclude = []
78 79

  
79 80

  
80
class NewDeskForm(forms.ModelForm):
81
class DeskForm(forms.ModelForm):
81 82
    class Meta:
82 83
        model = Desk
83 84
        widgets = {
84 85
            'agenda': forms.HiddenInput(),
85 86
        }
86
        exclude = ['slug']
87
        exclude = []
87 88

  
89
    def is_valid(self):
90
        if not self.data['timeperiod_exceptions_remote_url']:
91
            return super(DeskForm, self).is_valid()
88 92

  
89
class DeskForm(forms.ModelForm):
93
        try:
94
            get_remote_calendar(self.data['timeperiod_exceptions_remote_url'])
95
        except ICSError as e:
96
            self.add_error('timeperiod_exceptions_remote_url', e)
97
            return False
98
        return super(DeskForm, self).is_valid()
99

  
100

  
101
class NewDeskForm(DeskForm):
90 102
    class Meta:
91 103
        model = Desk
92 104
        widgets = {
93 105
            'agenda': forms.HiddenInput(),
94 106
        }
95
        exclude = []
107
        exclude = ['slug']
96 108

  
97 109

  
98 110
class TimePeriodExceptionForm(forms.ModelForm):
......
170 182
        model = Desk
171 183
        fields = []
172 184

  
173
    ics_file = forms.FileField(label=_('ICS File'),
185
    ics_file = forms.FileField(label=_('ICS File'), required=False,
174 186
                               help_text=_('ICS file containing events which will be considered as exceptions'))
187
    ics_url = forms.URLField(label=_('URL'), required=False)
188

  
189
    def clean(self):
190
        cleaned_data = super(ExceptionsImportForm, self).clean()
191
        if not cleaned_data['ics_file'] and not cleaned_data['ics_url']:
192
            raise ValidationError(_('A file or an url should be filled.'))
chrono/manager/management/commands/sync_desks_timeperiod_exceptions.py
1
# chrono - agendas system
2
# Copyright (C) 2016-2017  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
import sys
18
import logging
19

  
20
from django.core.management.base import BaseCommand
21
from chrono.agendas.models import Desk, ICSError
22

  
23

  
24
class Command(BaseCommand):
25
    help = 'Synchronize time period exceptions from desks remote ics'
26

  
27
    def handle(self, **options):
28
        logger = logging.getLogger(__name__)
29
        for desk in Desk.objects.filter(timeperiod_exceptions_remote_url__isnull=False):
30
            try:
31
                desk.create_timeperiod_exceptions_from_remote_ics(desk.timeperiod_exceptions_remote_url)
32
            except ICSError as e:
33
                logger.warning(u'unable to create timeperiod exceptions for "%s": %s' % (desk, e))
chrono/manager/templates/chrono/manager_import_exceptions.html
11 11
{% endblock %}
12 12

  
13 13
{% block content %}
14

  
15 14
<form method="post" enctype="multipart/form-data">
15
  <p>{% trans "You can upload a file or specify an address to remote calendar." %}</p>
16 16
  {% csrf_token %}
17 17
  {{ form.as_p }}
18
  <p>
19
  </p>
20 18
  <div class="buttons">
21 19
    <button>{% trans "Import" %}</button>
22 20
    <a class="cancel" href="{% url 'chrono-manager-agenda-view' pk=agenda.id %}">{% trans 'Cancel' %}</a>
chrono/manager/views.py
396 396

  
397 397
    def form_valid(self, form):
398 398
        try:
399
            exceptions = form.instance.create_timeperiod_exceptions_from_ics(form.cleaned_data['ics_file'])
399
            if form.cleaned_data['ics_file']:
400
                exceptions = form.instance.create_timeperiod_exceptions_from_ics(form.cleaned_data['ics_file'])
401
            else:
402
                exceptions = form.instance.create_timeperiod_exceptions_from_remote_ics(form.cleaned_data['ics_url'])
400 403
        except ICSError as e:
401 404
            form.add_error(None, unicode(e))
402 405
            return self.form_invalid(form)
debian/control
11 11
Depends: ${misc:Depends}, ${python:Depends},
12 12
    python-django (>= 1.8),
13 13
    python-gadjo,
14
    python-intervaltree
14
    python-intervaltree,
15
    python-requests
15 16
Recommends: python-django-mellon
16 17
Description: Agendas System (Python module)
17 18

  
requirements.txt
3 3
djangorestframework>=3.1, <3.7
4 4
django-jsonfield >= 0.9.3
5 5
intervaltree
6
requests
setup.py
107 107
        'djangorestframework>=3.1, <3.7',
108 108
        'django-jsonfield >= 0.9.3',
109 109
        'intervaltree',
110
        'vobject'
110
        'vobject',
111
        'requests'
111 112
        ],
112 113
    zip_safe=False,
113 114
    cmdclass={
tests/test_agendas.py
1 1
import pytest
2 2
import datetime
3
import mock
4
import requests
5
import logging
3 6

  
4 7
from django.utils.timezone import now, make_aware, localtime
8
from django.core.management import call_command
5 9

  
6 10
from chrono.agendas.models import (Agenda, Event, Booking, MeetingType,
7 11
                        Desk, TimePeriodException, ICSError)
......
220 224
    with pytest.raises(ICSError) as e:
221 225
        exceptions_count = desk.create_timeperiod_exceptions_from_ics(ICS_SAMPLE_WITH_NO_EVENTS)
222 226
    assert str(e.value) == "The file doesn't contain any events."
227

  
228
@mock.patch('chrono.agendas.models.requests.get')
229
def test_timeperiodexception_creation_from_remote_ics(mocked_get):
230
    agenda = Agenda(label=u'Test 8 agenda')
231
    agenda.save()
232
    desk = Desk(label='Test 8 desk', agenda=agenda)
233
    desk.save()
234
    mocked_response = mock.Mock()
235
    mocked_response.text = ICS_SAMPLE
236
    mocked_get.return_value = mocked_response
237
    exceptions_count = desk.create_timeperiod_exceptions_from_remote_ics('http://example.com/sample.ics')
238
    assert exceptions_count == 2
239

  
240
@mock.patch('chrono.agendas.models.requests.get')
241
def test_timeperiodexception_creation_from_unreachable_remote_ics(mocked_get):
242
    agenda = Agenda(label=u'Test 9 agenda')
243
    agenda.save()
244
    desk = Desk(label='Test 9 desk', agenda=agenda)
245
    desk.save()
246
    mocked_response = mock.Mock()
247
    mocked_response.content.return_value = ICS_SAMPLE
248
    mocked_get.return_value = mocked_response
249
    def mocked_requests_connection_error(*args, **kwargs):
250
        raise requests.exceptions.ConnectionError('unreachable')
251
    mocked_get.side_effect = mocked_requests_connection_error
252
    with pytest.raises(ICSError) as e:
253
        exceptions_count = desk.create_timeperiod_exceptions_from_remote_ics('http://example.com/sample.ics')
254
    assert str(e.value) == "Failed to retrieve remote calendar (connection error)."
255

  
256
@mock.patch('chrono.agendas.models.requests.get')
257
def test_timeperiodexception_creation_from_forbidden_remote_ics(mocked_get):
258
    agenda = Agenda(label=u'Test 10 agenda')
259
    agenda.save()
260
    desk = Desk(label='Test 10 desk', agenda=agenda)
261
    desk.save()
262
    mocked_response = mock.Mock()
263
    mocked_response.status_code = 403
264
    mocked_get.return_value = mocked_response
265
    def mocked_requests_http_forbidden_error(*args, **kwargs):
266
        raise requests.exceptions.HTTPError(response=mocked_response)
267
    mocked_get.side_effect = mocked_requests_http_forbidden_error
268

  
269
    with pytest.raises(ICSError) as e:
270
        exceptions_count = desk.create_timeperiod_exceptions_from_remote_ics('http://example.com/sample.ics')
271
    assert str(e.value) == "Failed to retrieve remote calendar (HTTP error 403)."
272

  
273
@mock.patch('chrono.agendas.models.requests.get')
274
def test_timeperiodexception_creation_from_remote_ics_with_timeout_error(mocked_get):
275
    agenda = Agenda(label=u'Test 11 agenda')
276
    agenda.save()
277
    desk = Desk(label='Test 11 desk', agenda=agenda)
278
    desk.save()
279
    mocked_response = mock.Mock()
280
    mocked_get.return_value = mocked_response
281
    def mocked_requests_http_timeout_error(*args, **kwargs):
282
        raise requests.exceptions.Timeout(response=mocked_response)
283
    mocked_get.side_effect = mocked_requests_http_timeout_error
284

  
285
    with pytest.raises(ICSError) as e:
286
        exceptions_count = desk.create_timeperiod_exceptions_from_remote_ics('http://example.com/sample.ics')
287
    assert str(e.value) == "Failed to retrieve remote calendar (HTTP timeout)."
288

  
289
@mock.patch('chrono.agendas.models.requests.get')
290
def test_timeperiodexception_creation_from_remote_ics_with_ssl_error(mocked_get):
291
    agenda = Agenda(label=u'Test 11 agenda')
292
    agenda.save()
293
    desk = Desk(label='Test 11 desk', agenda=agenda)
294
    desk.save()
295
    mocked_response = mock.Mock()
296
    mocked_get.return_value = mocked_response
297
    def mocked_requests_http_ssl_error(*args, **kwargs):
298
        raise requests.exceptions.SSLError(response=mocked_response)
299
    mocked_get.side_effect = mocked_requests_http_ssl_error
300

  
301
    with pytest.raises(ICSError) as e:
302
        exceptions_count = desk.create_timeperiod_exceptions_from_remote_ics('http://example.com/sample.ics')
303
    assert str(e.value) == "Failed to retrieve remote calendar (connection error)."
304

  
305
@mock.patch('chrono.agendas.models.requests.get')
306
def test_sync_desks_timeperiod_exceptions_from_ics(mocked_get, caplog):
307
    agenda = Agenda(label=u'Test 11 agenda')
308
    agenda.save()
309
    desk = Desk(label='Test 11 desk', agenda=agenda, timeperiod_exceptions_remote_url='http:example.com/sample.ics')
310
    desk.save()
311
    mocked_response = mock.Mock()
312
    mocked_response.status_code = 403
313
    mocked_get.return_value = mocked_response
314
    def mocked_requests_http_forbidden_error(*args, **kwargs):
315
        raise requests.exceptions.HTTPError(response=mocked_response)
316
    mocked_get.side_effect = mocked_requests_http_forbidden_error
317
    call_command('sync_desks_timeperiod_exceptions')
318
    records = caplog.records()
319
    assert len(records) == 1
320
    for record in records:
321
        assert record.name == 'chrono.manager.management.commands.sync_desks_timeperiod_exceptions'
322
        assert record.levelno == logging.WARNING
323
        assert record.getMessage() == 'unable to create timeperiod exceptions for "Test 11 desk": Failed to retrieve remote calendar (HTTP error 403).'
tests/test_manager.py
5 5
import datetime
6 6
import pytest
7 7
from webtest import TestApp, Upload
8
import mock
9
import requests
8 10

  
9 11
from chrono.wsgi import application
10 12

  
......
699 701
    assert 'Desk A' in resp.text
700 702
    assert 'Desk B' in resp.text
701 703

  
704
@mock.patch('chrono.manager.forms.requests.get')
705
def test_meetings_agenda_add_desk_with_non_existing_exceptions_url(mocked_get, app, admin_user):
706
    app = login(app)
707
    resp = app.get('/manage/', status=200)
708
    resp = resp.click('New')
709
    resp.form['label'] = 'Foo bar'
710
    resp.form['kind'] = 'meetings'
711
    resp = resp.form.submit()
712
    agenda = Agenda.objects.get(slug='foo-bar')
713
    resp = app.get('/manage/agendas/%s/' % agenda.id, status=200)
714
    mocked_response = mock.Mock()
715
    mocked_response.status_code = 403
716
    mocked_get.return_value = mocked_response
717
    def mocked_requests_http_forbidden_error(*args, **kwargs):
718
        raise requests.exceptions.HTTPError(response=mocked_response)
719
    mocked_get.side_effect = mocked_requests_http_forbidden_error
720
    resp = resp.click('New Desk')
721
    resp.form['label'] = 'Desk A'
722
    resp.form['timeperiod_exceptions_remote_url'] = 'http://nowhere.com/unknown.ics'
723
    resp = resp.form.submit(status=200)
724
    assert 'Failed to retrieve remote calendar (HTTP error 403).' in resp.text
725

  
726
@mock.patch('chrono.manager.forms.requests.get')
727
def test_meetings_agenda_add_desk_with_unreachable_exceptions_url(mocked_get, app, admin_user):
728
    app = login(app)
729
    resp = app.get('/manage/', status=200)
730
    resp = resp.click('New')
731
    resp.form['label'] = 'Foo bar'
732
    resp.form['kind'] = 'meetings'
733
    resp = resp.form.submit()
734
    agenda = Agenda.objects.get(slug='foo-bar')
735
    resp = app.get('/manage/agendas/%s/' % agenda.id, status=200)
736
    mocked_response = mock.Mock()
737
    mocked_get.return_value = mocked_response
738
    def mocked_requests_connection_error(*args, **kwargs):
739
        raise requests.exceptions.ConnectionError('unreachable')
740
    mocked_get.side_effect = mocked_requests_connection_error
741
    resp = resp.click('New Desk')
742
    resp.form['label'] = 'Desk A'
743
    resp.form['timeperiod_exceptions_remote_url'] = 'http://nowhere.com/unknown.ics'
744
    resp = resp.form.submit(status=200)
745
    assert 'Failed to retrieve remote calendar (connection error).' in resp.text
746

  
702 747

  
703 748
def test_meetings_agenda_delete_desk(app, admin_user):
704 749
    app = login(app)
......
840 885
    resp = app.get('/manage/agendas/%d/' % agenda.pk)
841 886
    assert 'Import exceptions from .ics' in resp.content
842 887
    resp = resp.click('upload')
888
    assert "You can upload a file or specify an address to remote calendar." in resp
889
    resp = resp.form.submit(status=200)
890
    assert "A file or an url should be filled." in resp.body
843 891
    resp.form['ics_file'] = Upload('exceptions.ics', 'invalid content', 'text/calendar')
844 892
    resp = resp.form.submit(status=200)
845 893
    assert 'File format is invalid' in resp.content
......
889 937
    assert TimePeriodException.objects.count() == 1
890 938
    resp = resp.follow()
891 939
    assert 'An exception has been imported.' in resp.content
940

  
941
@mock.patch('chrono.agendas.models.requests.get')
942
def test_agenda_import_time_period_exception_from_remote_ics(mocked_get, app, admin_user):
943
    agenda = Agenda.objects.create(label='New Example', kind='meetings')
944
    desk = Desk.objects.create(agenda=agenda, label='New Desk')
945
    MeetingType(agenda=agenda, label='Bar').save()
946
    login(app)
947
    resp = app.get('/manage/agendas/%d/' % agenda.pk)
948
    assert 'Import exceptions from .ics' not in resp.content
949

  
950
    TimePeriod.objects.create(weekday=1, desk=desk,
951
                start_time=datetime.time(10, 0), end_time=datetime.time(12, 0))
952

  
953
    resp = app.get('/manage/agendas/%d/' % agenda.pk)
954
    resp = resp.click('upload')
955

  
956
    assert 'ics_file' in resp.form.fields
957
    assert 'ics_url' in resp.form.fields
958
    resp.form['ics_url'] = 'http://example.com/foo.ics'
959
    mocked_response = mock.Mock()
960
    mocked_get.return_value = mocked_response
961
    def mocked_requests_connection_error(*args, **kwargs):
962
        raise requests.exceptions.ConnectionError('unreachable')
963
    mocked_get.side_effect = mocked_requests_connection_error
964
    resp = resp.form.submit(status=200)
965
    assert 'Failed to retrieve remote calendar (connection error).' in resp.content
966

  
967
@mock.patch('chrono.agendas.models.requests.get')
968
def test_agenda_import_time_period_exception_from_forbidden_remote_ics(mocked_get, app, admin_user):
969
    agenda = Agenda.objects.create(label='New Example', kind='meetings')
970
    desk = Desk.objects.create(agenda=agenda, label='New Desk')
971
    MeetingType(agenda=agenda, label='Bar').save()
972
    login(app)
973
    resp = app.get('/manage/agendas/%d/' % agenda.pk)
974
    assert 'Import exceptions from .ics' not in resp.content
975

  
976
    TimePeriod.objects.create(weekday=1, desk=desk,
977
                start_time=datetime.time(10, 0), end_time=datetime.time(12, 0))
978

  
979
    resp = app.get('/manage/agendas/%d/' % agenda.pk)
980
    resp = resp.click('upload')
981
    resp.form['ics_url'] = 'http://example.com/foo.ics'
982
    mocked_response = mock.Mock()
983
    mocked_response.status_code = 403
984
    mocked_get.return_value = mocked_response
985
    def mocked_requests_http_forbidden_error(*args, **kwargs):
986
        raise requests.exceptions.HTTPError(response=mocked_response)
987
    mocked_get.side_effect = mocked_requests_http_forbidden_error
988
    resp = resp.form.submit(status=200)
989
    assert 'Failed to retrieve remote calendar (HTTP error 403).' in resp.content
990

  
991
@mock.patch('chrono.agendas.models.requests.get')
992
def test_agenda_import_time_period_exception_from_remote_ics_with_timeout_error(mocked_get, app, admin_user):
993
    agenda = Agenda.objects.create(label='New Example', kind='meetings')
994
    desk = Desk.objects.create(agenda=agenda, label='New Desk')
995
    MeetingType(agenda=agenda, label='Bar').save()
996
    login(app)
997
    resp = app.get('/manage/agendas/%d/' % agenda.pk)
998
    assert 'Import exceptions from .ics' not in resp.content
999

  
1000
    TimePeriod.objects.create(weekday=1, desk=desk,
1001
                start_time=datetime.time(10, 0), end_time=datetime.time(12, 0))
1002

  
1003
    resp = app.get('/manage/agendas/%d/' % agenda.pk)
1004
    resp = resp.click('upload')
1005
    resp.form['ics_url'] = 'http://example.com/foo.ics'
1006
    mocked_response = mock.Mock()
1007
    mocked_get.return_value = mocked_response
1008
    def mocked_requests_http_timeout_error(*args, **kwargs):
1009
        raise requests.exceptions.Timeout(response=mocked_response)
1010
    mocked_get.side_effect = mocked_requests_http_timeout_error
1011
    resp = resp.form.submit(status=200)
1012
    assert 'Failed to retrieve remote calendar (HTTP timeout).' in resp.content
1013

  
1014
@mock.patch('chrono.agendas.models.requests.get')
1015
def test_agenda_import_time_period_exception_from_remote_ics_with_ssl_error(mocked_get, app, admin_user):
1016
    agenda = Agenda.objects.create(label='New Example', kind='meetings')
1017
    desk = Desk.objects.create(agenda=agenda, label='New Desk')
1018
    MeetingType(agenda=agenda, label='Bar').save()
1019
    login(app)
1020
    resp = app.get('/manage/agendas/%d/' % agenda.pk)
1021
    assert 'Import exceptions from .ics' not in resp.content
1022
    TimePeriod.objects.create(weekday=1, desk=desk,
1023
                start_time=datetime.time(10, 0), end_time=datetime.time(12, 0))
1024

  
1025
    resp = app.get('/manage/agendas/%d/' % agenda.pk)
1026
    resp = resp.click('upload')
1027
    resp.form['ics_url'] = 'http://example.com/foo.ics'
1028
    mocked_response = mock.Mock()
1029
    mocked_get.return_value = mocked_response
1030
    def mocked_requests_http_ssl_error(*args, **kwargs):
1031
        raise requests.exceptions.SSLError
1032
    mocked_get.side_effect = mocked_requests_http_ssl_error
1033
    resp = resp.form.submit(status=200)
1034
    assert 'Failed to retrieve remote calendar (connection error).' in resp.content
892
-