agenda: import timeperiod exceptions from .ics (#16798)

This commit is contained in:
Serghei Mihai 2017-09-03 13:28:50 +02:00
parent 75ad98a7cc
commit d10a22d564
11 changed files with 317 additions and 5 deletions

View File

@ -16,6 +16,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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)

View File

@ -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'))

View File

@ -69,3 +69,7 @@ h2 span.identifier {
a.timeperiod-exception-all {
font-style: italic;
}
.link-action-icon.upload::before {
content: "\f093"; /* upload-sign */
}

View File

@ -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 %}
<li><a href="#"><strong>{% trans 'Exceptions' %}</strong></a></li>
<li><a href="#"><strong>{% trans 'Exceptions' %}</strong></a><a class="link-action-icon upload" rel="popup" href="{% url 'chrono-manager-desk-add-import-time-period-exceptions' pk=desk.pk %}" title="{% trans 'Import exceptions from .ics' %}">{% trans 'upload' %}</a></li>
{% for exception in desk.get_exceptions_within_two_weeks %}
<li><a rel="popup" href="{% if user_can_manage %}{% url 'chrono-manager-time-period-exception-edit' pk=exception.pk %}{% else %}#{% endif %}">
{{ exception }}

View File

@ -0,0 +1,25 @@
{% extends "chrono/manager_agenda_view.html" %}
{% load i18n %}
{% block extrascripts %}
{{ block.super }}
{{ form.media }}
{% endblock %}
{% block appbar %}
<h2>{% trans "Import exceptions" %}</h2>
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<p>
</p>
<div class="buttons">
<button>{% trans "Import" %}</button>
<a class="cancel" href="{% url 'chrono-manager-agenda-view' pk=agenda.id %}">{% trans 'Cancel' %}</a>
</div>
</form>
{% endblock %}

View File

@ -60,6 +60,8 @@ urlpatterns = [
url(r'^agendas/(?P<agenda_pk>\d+)/desk/(?P<pk>\d+)/add-time-period-exception$', views.agenda_add_time_period_exception,
name='chrono-manager-agenda-add-time-period-exception'),
url(r'^agendas/desk/(?P<pk>\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<pk>\w+)/edit$', views.time_period_exception_edit,
name='chrono-manager-time-period-exception-edit'),
url(r'^time-period-exceptions/(?P<pk>\w+)/delete$', views.time_period_exception_delete,

View File

@ -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')

1
debian/control vendored
View File

@ -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,

View File

@ -107,6 +107,7 @@ setup(
'djangorestframework>=3.1',
'django-jsonfield >= 0.9.3',
'intervaltree',
'vobject'
],
zip_safe=False,
cmdclass={

View File

@ -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."

View File

@ -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 &quot;New eve&quot; 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&#39;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