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 %} + +
    + {% csrf_token %} + {{ form.as_p }} +

    +

    +
    +
    +{% 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