agenda: import timeperiod exceptions from .ics (#16798)
This commit is contained in:
parent
75ad98a7cc
commit
d10a22d564
|
@ -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)
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -69,3 +69,7 @@ h2 span.identifier {
|
|||
a.timeperiod-exception-all {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.link-action-icon.upload::before {
|
||||
content: "\f093"; /* upload-sign */
|
||||
}
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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 %}
|
|
@ -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,
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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,
|
||||
|
|
1
setup.py
1
setup.py
|
@ -107,6 +107,7 @@ setup(
|
|||
'djangorestframework>=3.1',
|
||||
'django-jsonfield >= 0.9.3',
|
||||
'intervaltree',
|
||||
'vobject'
|
||||
],
|
||||
zip_safe=False,
|
||||
cmdclass={
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue