diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py
index 93772863..93511f68 100644
--- a/chrono/agendas/models.py
+++ b/chrono/agendas/models.py
@@ -16,6 +16,7 @@
# along with this program. If not, see .
import datetime
+import vobject
from django.contrib.auth.models import Group
from django.core.exceptions import ValidationError
@@ -43,6 +44,10 @@ def is_midnight(dtime):
return dtime.hour == 0 and dtime.minute == 0
+class ICSError(Exception):
+ pass
+
+
class Agenda(models.Model):
label = models.CharField(_('Label'), max_length=150)
slug = models.SlugField(_('Identifier'))
@@ -410,6 +415,48 @@ class Desk(models.Model):
in_two_weeks = self.get_exceptions_within_two_weeks()
return self.timeperiodexception_set.count() == len(in_two_weeks)
+ def create_timeperiod_exceptions_from_ics(self, data):
+ try:
+ parsed = vobject.readOne(data)
+ except vobject.base.ParseError:
+ raise ICSError(_('File format is invalid.'))
+
+ total_created = 0
+
+ if not parsed.contents.get('vevent'):
+ raise ICSError(_('The file doesn\'t contain any events.'))
+
+ with transaction.atomic():
+ for vevent in parsed.contents['vevent']:
+ event = {}
+ summary = unicode(vevent.contents['summary'][0].value, 'utf-8')
+ if 'rrule' in vevent.contents:
+ raise ICSError(_('Recurrent events are not handled.'))
+ try:
+ start_dt = vevent.dtstart.value
+ if not isinstance(start_dt, datetime.datetime):
+ start_dt = datetime.datetime.combine(start_dt,
+ datetime.datetime.min.time())
+ event['start_datetime'] = start_dt
+ except AttributeError:
+ raise ICSError(_('Event "%s" has no start date.') % summary)
+ try:
+ end_dt = vevent.dtend.value
+ if not isinstance(end_dt, datetime.datetime):
+ end_dt = datetime.datetime.combine(end_dt,
+ datetime.datetime.min.time())
+ except AttributeError:
+ # events without end date are considered as ending the same day
+ end_dt = datetime.datetime.combine(start_dt, datetime.datetime.max.time())
+ event['end_datetime'] = end_dt
+
+ obj, created = TimePeriodException.objects.get_or_create(desk=self, label=summary,
+ **event)
+ if created:
+ total_created += 1
+
+ return total_created
+
class TimePeriodException(models.Model):
desk = models.ForeignKey(Desk)
diff --git a/chrono/manager/forms.py b/chrono/manager/forms.py
index f44af067..cc6e37c4 100644
--- a/chrono/manager/forms.py
+++ b/chrono/manager/forms.py
@@ -163,3 +163,12 @@ class ImportEventsForm(forms.Form):
event.label = ' '.join(csvline[4:])
events.append(event)
self.events = events
+
+
+class ExceptionsImportForm(forms.ModelForm):
+ class Meta:
+ model = Desk
+ fields = []
+
+ ics_file = forms.FileField(label=_('ICS File'),
+ help_text=_('ICS file containing events which will be considered as exceptions'))
diff --git a/chrono/manager/static/css/style.css b/chrono/manager/static/css/style.css
index 53455aa2..096a55f8 100644
--- a/chrono/manager/static/css/style.css
+++ b/chrono/manager/static/css/style.css
@@ -69,3 +69,7 @@ h2 span.identifier {
a.timeperiod-exception-all {
font-style: italic;
}
+
+.link-action-icon.upload::before {
+ content: "\f093"; /* upload-sign */
+}
diff --git a/chrono/manager/templates/chrono/manager_agenda_view.html b/chrono/manager/templates/chrono/manager_agenda_view.html
index 63d3eb8c..aa94a859 100644
--- a/chrono/manager/templates/chrono/manager_agenda_view.html
+++ b/chrono/manager/templates/chrono/manager_agenda_view.html
@@ -122,7 +122,7 @@
{% endif %}
{% if desk.timeperiod_set.count %}
{% url 'chrono-manager-agenda-add-time-period-exception' agenda_pk=object.pk pk=desk.pk as add_time_period_exception_url %}
-
{% trans 'Exceptions' %}
+ {% trans 'Exceptions' %}{% trans 'upload' %}
{% for exception in desk.get_exceptions_within_two_weeks %}
{{ exception }}
diff --git a/chrono/manager/templates/chrono/manager_import_exceptions.html b/chrono/manager/templates/chrono/manager_import_exceptions.html
new file mode 100644
index 00000000..d3eacc5c
--- /dev/null
+++ b/chrono/manager/templates/chrono/manager_import_exceptions.html
@@ -0,0 +1,25 @@
+{% extends "chrono/manager_agenda_view.html" %}
+{% load i18n %}
+
+{% block extrascripts %}
+{{ block.super }}
+{{ form.media }}
+{% endblock %}
+
+{% block appbar %}
+{% trans "Import exceptions" %}
+{% endblock %}
+
+{% block content %}
+
+
+{% endblock %}
diff --git a/chrono/manager/urls.py b/chrono/manager/urls.py
index 41b4cab4..8a98cb02 100644
--- a/chrono/manager/urls.py
+++ b/chrono/manager/urls.py
@@ -60,6 +60,8 @@ urlpatterns = [
url(r'^agendas/(?P\d+)/desk/(?P\d+)/add-time-period-exception$', views.agenda_add_time_period_exception,
name='chrono-manager-agenda-add-time-period-exception'),
+ url(r'^agendas/desk/(?P\w+)/import-exceptions-from-ics/$', views.desk_import_time_period_exceptions,
+ name='chrono-manager-desk-add-import-time-period-exceptions'),
url(r'^time-period-exceptions/(?P\w+)/edit$', views.time_period_exception_edit,
name='chrono-manager-time-period-exception-edit'),
url(r'^time-period-exceptions/(?P\w+)/delete$', views.time_period_exception_delete,
diff --git a/chrono/manager/views.py b/chrono/manager/views.py
index 4e57642f..c5d4eae0 100644
--- a/chrono/manager/views.py
+++ b/chrono/manager/views.py
@@ -23,15 +23,17 @@ from django.db.models import Q
from django.http import HttpResponse, Http404
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
+from django.utils.translation import ungettext
from django.utils.encoding import force_text
from django.views.generic import (DetailView, CreateView, UpdateView,
ListView, DeleteView, FormView, TemplateView)
from chrono.agendas.models import (Agenda, Event, MeetingType, TimePeriod,
- Booking, Desk, TimePeriodException)
+ Booking, Desk, TimePeriodException, ICSError)
from .forms import (EventForm, NewMeetingTypeForm, MeetingTypeForm,
- TimePeriodForm, ImportEventsForm, NewDeskForm, DeskForm, TimePeriodExceptionForm)
+ TimePeriodForm, ImportEventsForm, NewDeskForm, DeskForm, TimePeriodExceptionForm,
+ ExceptionsImportForm)
class HomepageView(ListView):
@@ -387,6 +389,26 @@ class TimePeriodExceptionDeleteView(ManagedDeskSubobjectMixin, DeleteView):
time_period_exception_delete = TimePeriodExceptionDeleteView.as_view()
+class DeskImportTimePeriodExceptionsView(ManagedAgendaSubobjectMixin, UpdateView):
+ model = Desk
+ form_class = ExceptionsImportForm
+ template_name = 'chrono/manager_import_exceptions.html'
+
+ def form_valid(self, form):
+ try:
+ exceptions = form.instance.create_timeperiod_exceptions_from_ics(form.cleaned_data['ics_file'])
+ except ICSError as e:
+ form.add_error(None, unicode(e))
+ return self.form_invalid(form)
+ message = ungettext('An exception has been imported.',
+ '%(count)d exceptions have been imported.', exceptions)
+ message = message % {'count': exceptions}
+ messages.info(self.request, message)
+ return super(DeskImportTimePeriodExceptionsView, self).form_valid(form)
+
+desk_import_time_period_exceptions = DeskImportTimePeriodExceptionsView.as_view()
+
+
def menu_json(request):
response = HttpResponse(content_type='application/json')
label = _('Agendas')
diff --git a/debian/control b/debian/control
index 87b73586..f80b1304 100644
--- a/debian/control
+++ b/debian/control
@@ -20,6 +20,7 @@ Architecture: all
Depends: ${misc:Depends},
python-chrono (= ${binary:Version}),
python-hobo,
+ python-vobject,
python-django-tenant-schemas,
python-psycopg2,
python-django-mellon,
diff --git a/setup.py b/setup.py
index 1455e0d5..ee531e67 100644
--- a/setup.py
+++ b/setup.py
@@ -107,6 +107,7 @@ setup(
'djangorestframework>=3.1',
'django-jsonfield >= 0.9.3',
'intervaltree',
+ 'vobject'
],
zip_safe=False,
cmdclass={
diff --git a/tests/test_agendas.py b/tests/test_agendas.py
index 50c1a34e..7560a666 100644
--- a/tests/test_agendas.py
+++ b/tests/test_agendas.py
@@ -1,12 +1,61 @@
import pytest
import datetime
-from django.utils.timezone import now
+from django.utils.timezone import now, make_aware, localtime
-from chrono.agendas.models import Agenda, Event, Booking, MeetingType
+from chrono.agendas.models import (Agenda, Event, Booking, MeetingType,
+ Desk, TimePeriodException, ICSError)
pytestmark = pytest.mark.django_db
+ICS_SAMPLE = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//foo.bar//EN
+BEGIN:VEVENT
+DTSTAMP:20170824T082855Z
+UID:8c4c219889d244232c0a565f4950c3ff65dd5d64
+DTSTART:20170831T170800Z
+DTEND:20170831T203400Z
+SEQUENCE:1
+SUMMARY:Event 1
+END:VEVENT
+BEGIN:VEVENT
+DTSTAMP:20170824T092855Z
+UID:950c3ff889d2465dd5d648c4c2194232c0a565f4
+DTSTART:20170831T180800Z
+DTEND:20170831T213400Z
+SEQUENCE:2
+SUMMARY:Event 2
+END:VEVENT
+END:VCALENDAR"""
+
+ICS_SAMPLE_WITH_RECURRENT_EVENT = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//foo.bar//EN
+BEGIN:VEVENT
+DTSTAMP:20170720T145803Z
+DESCRIPTION:Vacances d'ete
+DTSTART;VALUE=DATE:20180101
+DTEND;VALUE=DATE:20180101
+SUMMARY:reccurent event
+END:VEVENT
+BEGIN:VEVENT
+DTSTAMP:20170824T082855Z
+DTSTART:20180101
+DTEND:20180101
+SUMMARY:New eve
+RRULE:FREQ=YEARLY
+END:VEVENT
+END:VCALENDAR"""
+
+ICS_SAMPLE_WITH_NO_EVENTS = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//foo.bar//EN
+END:VCALENDAR"""
+
+INVALID_ICS_SAMPLE = """content
+"""
+
def test_slug():
agenda = Agenda(label=u'Foo bar')
@@ -83,3 +132,91 @@ def test_meeting_type_slugs():
meeting_type3 = MeetingType(agenda=agenda2, label=u'Baz')
meeting_type3.save()
assert meeting_type3.slug == 'baz'
+
+def test_timeperiodexception_creation_from_ics():
+ agenda = Agenda(label=u'Test 1 agenda')
+ agenda.save()
+ desk = Desk(label='Test 1 desk', agenda=agenda)
+ desk.save()
+ exceptions_count = desk.create_timeperiod_exceptions_from_ics(ICS_SAMPLE)
+ assert exceptions_count == 2
+ assert TimePeriodException.objects.filter(desk=desk).count() == 2
+
+def test_timeperiodexception_creation_from_ics_without_startdt():
+ agenda = Agenda(label=u'Test 2 agenda')
+ agenda.save()
+ desk = Desk(label='Test 2 desk', agenda=agenda)
+ desk.save()
+ lines = []
+ # remove start datetimes from ics
+ for line in ICS_SAMPLE.splitlines():
+ if line.startswith('DTSTART:'):
+ continue
+ lines.append(line)
+ ics_sample = "\n".join(lines)
+ with pytest.raises(ICSError) as e:
+ exceptions_count = desk.create_timeperiod_exceptions_from_ics(ics_sample)
+ assert 'Event "Event 1" has no start date.' == str(e.value)
+
+def test_timeperiodexception_creation_from_ics_without_enddt():
+ agenda = Agenda(label=u'Test 3 agenda')
+ agenda.save()
+ desk = Desk(label='Test 3 desk', agenda=agenda)
+ desk.save()
+ lines = []
+ # remove end datetimes from ics
+ for line in ICS_SAMPLE.splitlines():
+ if line.startswith('DTEND:'):
+ continue
+ lines.append(line)
+ ics_sample = "\n".join(lines)
+ exceptions_count = desk.create_timeperiod_exceptions_from_ics(ics_sample)
+ for exception in TimePeriodException.objects.filter(desk=desk):
+ end_time = localtime(exception.end_datetime).time()
+ assert end_time == datetime.time(23, 59, 59, 999999)
+
+def test_timeperiodexception_creation_from_ics_with_recurrences():
+ agenda = Agenda(label=u'Test 4 agenda')
+ agenda.save()
+ desk = Desk(label='Test 4 desk', agenda=agenda)
+ desk.save()
+ with pytest.raises(ICSError) as e:
+ exceptions_count = desk.create_timeperiod_exceptions_from_ics(ICS_SAMPLE_WITH_RECURRENT_EVENT)
+ assert 'Recurrent events are not handled.' == str(e.value)
+ assert TimePeriodException.objects.filter(desk=desk).count() == 0
+
+def test_timeexception_creation_from_ics_with_dates():
+ agenda = Agenda(label=u'Test 5 agenda')
+ agenda.save()
+ desk = Desk(label='Test 5 desk', agenda=agenda)
+ desk.save()
+ lines = []
+ # remove end datetimes from ics
+ for line in ICS_SAMPLE_WITH_RECURRENT_EVENT.splitlines():
+ if line.startswith('RRULE:'):
+ continue
+ lines.append(line)
+ ics_sample = "\n".join(lines)
+ exceptions_count = desk.create_timeperiod_exceptions_from_ics(ics_sample)
+ assert exceptions_count == 2
+ for exception in TimePeriodException.objects.filter(desk=desk):
+ assert localtime(exception.start_datetime) == make_aware(datetime.datetime(2018, 01, 01, 00, 00))
+ assert localtime(exception.end_datetime) == make_aware(datetime.datetime(2018, 01, 01, 00, 00))
+
+def test_timeexception_create_from_invalid_ics():
+ agenda = Agenda(label=u'Test 6 agenda')
+ agenda.save()
+ desk = Desk(label='Test 6 desk', agenda=agenda)
+ desk.save()
+ with pytest.raises(ICSError) as e:
+ exceptions_count = desk.create_timeperiod_exceptions_from_ics(INVALID_ICS_SAMPLE)
+ assert str(e.value) == 'File format is invalid.'
+
+def test_timeexception_create_from_ics_with_no_events():
+ agenda = Agenda(label=u'Test 7 agenda')
+ agenda.save()
+ desk = Desk(label='Test 7 desk', agenda=agenda)
+ desk.save()
+ with pytest.raises(ICSError) as e:
+ exceptions_count = desk.create_timeperiod_exceptions_from_ics(ICS_SAMPLE_WITH_NO_EVENTS)
+ assert str(e.value) == "The file doesn't contain any events."
diff --git a/tests/test_manager.py b/tests/test_manager.py
index 94118ccb..a39190e3 100644
--- a/tests/test_manager.py
+++ b/tests/test_manager.py
@@ -825,3 +825,67 @@ def test_meetings_agenda_delete_time_period_exception(app, admin_user):
resp = resp.click('Delete')
resp = resp.form.submit()
assert TimePeriodException.objects.count() == 0
+
+def test_agenda_import_time_period_exception_from_ics(app, admin_user):
+ agenda = Agenda.objects.create(label='Example', kind='meetings')
+ desk = Desk.objects.create(agenda=agenda, label='Test Desk')
+ MeetingType(agenda=agenda, label='Foo').save()
+ login(app)
+ resp = app.get('/manage/agendas/%d/' % agenda.pk)
+ assert 'Import exceptions from .ics' not in resp.content
+
+ TimePeriod.objects.create(weekday=1, desk=desk,
+ start_time=datetime.time(10, 0), end_time=datetime.time(12, 0))
+
+ resp = app.get('/manage/agendas/%d/' % agenda.pk)
+ assert 'Import exceptions from .ics' in resp.content
+ resp = resp.click('upload')
+ resp.form['ics_file'] = Upload('exceptions.ics', 'invalid content', 'text/calendar')
+ resp = resp.form.submit(status=200)
+ assert 'File format is invalid' in resp.content
+ ics_with_recurrent_exceptions = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//foo.bar//EN
+BEGIN:VEVENT
+DTSTART:20180101
+DTEND:20180101
+SUMMARY:New eve
+RRULE:FREQ=YEARLY
+END:VEVENT
+END:VCALENDAR"""
+ resp.form['ics_file'] = Upload('exceptions.ics', ics_with_recurrent_exceptions, 'text/calendar')
+ resp = resp.form.submit(status=200)
+ assert 'Recurrent events are not handled.' in resp.content
+ ics_with_no_start_date = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//foo.bar//EN
+BEGIN:VEVENT
+DTEND:20180101
+SUMMARY:New eve
+END:VEVENT
+END:VCALENDAR"""
+ resp.form['ics_file'] = Upload('exceptions.ics', ics_with_no_start_date, 'text/calendar')
+ resp = resp.form.submit(status=200)
+ assert 'Event "New eve" has no start date.' in resp.content
+ ics_with_no_events = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//foo.bar//EN
+END:VCALENDAR"""
+ resp.form['ics_file'] = Upload('exceptions.ics', ics_with_no_events, 'text/calendar')
+ resp = resp.form.submit(status=200)
+ assert "The file doesn't contain any events." in resp.content
+
+ ics_with_exceptions = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//foo.bar//EN
+BEGIN:VEVENT
+DTSTART:20180101
+DTEND:20180101
+SUMMARY:New eve
+END:VEVENT
+END:VCALENDAR"""
+ resp.form['ics_file'] = Upload('exceptions.ics', ics_with_exceptions, 'text/calendar')
+ resp = resp.form.submit(status=302)
+ assert TimePeriodException.objects.count() == 1
+ resp = resp.follow()
+ assert 'An exception has been imported.' in resp.content