support recurring event in exceptions .ics (fixes #19071)

This commit is contained in:
Benjamin Dauvergne 2018-04-26 13:27:49 +02:00
parent 31ff8e6e86
commit c4df05a490
7 changed files with 143 additions and 44 deletions

View File

@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-04-26 11:27
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0023_auto_20171202_1835'),
]
operations = [
migrations.AlterModelOptions(
name='meetingtype',
options={'ordering': ['duration', 'label']},
),
migrations.AddField(
model_name='timeperiodexception',
name='recurrence_id',
field=models.PositiveIntegerField(default=0, verbose_name='Recurrence ID'),
),
]

View File

@ -454,7 +454,7 @@ class Desk(models.Model):
def remove_timeperiod_exceptions_from_remote_ics(self):
TimePeriodException.objects.filter(desk=self).exclude(external_id='').delete()
def create_timeperiod_exceptions_from_ics(self, data, keep_synced_by_uid=False):
def create_timeperiod_exceptions_from_ics(self, data, keep_synced_by_uid=False, recurring_days=600):
try:
parsed = vobject.readOne(data)
except vobject.base.ParseError:
@ -468,46 +468,75 @@ class Desk(models.Model):
with transaction.atomic():
update_datetime = now()
for vevent in parsed.contents.get('vevent', []):
event = {}
summary = vevent.contents['summary'][0].value
if not isinstance(summary, unicode):
summary = unicode(summary, '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())
start_dt = datetime.datetime.combine(start_dt, datetime.datetime.min.time())
if not is_aware(start_dt):
event['start_datetime'] = make_aware(start_dt)
else:
event['start_datetime'] = start_dt
start_dt = make_aware(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())
end_dt = datetime.datetime.combine(end_dt, datetime.datetime.min.time())
if not is_aware(end_dt):
end_dt = make_aware(end_dt)
duration = end_dt - start_dt
except AttributeError:
# events without end date are considered as ending the same day
end_dt = datetime.datetime.combine(start_dt, datetime.datetime.max.time())
if not is_aware(end_dt):
event['end_datetime'] = make_aware(end_dt)
else:
event['end_datetime'] = end_dt
try:
duration = vevent.duration.value
end_dt = start_dt + duration
except AttributeError:
# events without end date are considered as ending the same day
end_dt = make_aware(datetime.datetime.combine(start_dt, datetime.datetime.max.time()))
duration = end_dt - start_dt
event = {}
event['start_datetime'] = start_dt
event['end_datetime'] = end_dt
event['label'] = summary
kwargs = {}
kwargs['desk'] = self
if keep_synced_by_uid:
external_id = vevent.contents['uid'][0].value
event['label'] = summary
obj, created = TimePeriodException.objects.update_or_create(desk=self, external_id=external_id,
defaults=event)
kwargs['external_id'] = vevent.contents['uid'][0].value
else:
obj, created = TimePeriodException.objects.update_or_create(desk=self, label=summary, defaults=event)
# return total_created
if created:
total_created += 1
kwargs['label'] = summary
if not vevent.rruleset:
# classical event
obj, created = TimePeriodException.objects.update_or_create(defaults=event, **kwargs)
if created:
total_created += 1
else:
# recurring event until recurring_days in the future
from_dt = start_dt
until_dt = update_datetime + datetime.timedelta(days=recurring_days)
if not is_aware(vevent.rruleset[0]):
from_dt = make_naive(from_dt)
until_dt = make_naive(until_dt)
i = -1
for i, start_dt in enumerate(vevent.rruleset.between(from_dt, until_dt, inc=True)):
# recompute start_dt and end_dt from occurrences and duration
if not is_aware(start_dt):
start_dt = make_aware(start_dt)
end_dt = start_dt + duration
kwargs['recurrence_id'] = i
event['start_datetime'] = start_dt
event['end_datetime'] = end_dt
if end_dt < update_datetime:
TimePeriodException.objects.filter(**kwargs).update(**event)
else:
obj, created = TimePeriodException.objects.update_or_create(defaults=event, **kwargs)
if created:
total_created += 1
# delete unseen occurrences
kwargs.pop('recurrence_id', None)
TimePeriodException.objects.filter(recurrence_id__gt=i, **kwargs).delete()
if keep_synced_by_uid:
# delete all outdated exceptions from remote calendar
@ -540,6 +569,7 @@ class TimePeriodException(models.Model):
start_datetime = models.DateTimeField(_('Exception start time'))
end_datetime = models.DateTimeField(_('Exception end time'))
update_datetime = models.DateTimeField(auto_now=True)
recurrence_id = models.PositiveIntegerField(_('Recurrence ID'), default=0)
class Meta:
ordering = ['start_datetime']

1
debian/control vendored
View File

@ -24,6 +24,7 @@ Depends: ${misc:Depends},
python-django-tenant-schemas,
python-psycopg2,
python-django-mellon,
python-dateutil,
gunicorn
Recommends: nginx
Suggests: postgresql

View File

@ -140,6 +140,7 @@ setup(
'djangorestframework>=3.1, <3.7',
'django-jsonfield >= 0.9.3',
'vobject',
'python-dateutil',
'requests'
],
zip_safe=False,

View File

@ -46,7 +46,7 @@ DTEND;VALUE=DATE:20180101
SUMMARY:reccurent event
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20170824T082855Z
DTSTAMP:20180824T082855Z
DTSTART:20180101
DTEND:20180101
SUMMARY:New Year's Eve
@ -54,6 +54,25 @@ RRULE:FREQ=YEARLY
END:VEVENT
END:VCALENDAR"""
ICS_SAMPLE_WITH_RECURRENT_EVENT_2 = """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:20180102
DTEND:20180101
SUMMARY:New Year's Eve
RRULE:FREQ=YEARLY;COUNT=1
END:VEVENT
END:VCALENDAR"""
ICS_SAMPLE_WITH_NO_EVENTS = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//foo.bar//EN
@ -181,15 +200,23 @@ def test_timeperiodexception_creation_from_ics_without_enddt():
end_time = localtime(exception.end_datetime).time()
assert end_time == datetime.time(23, 59, 59, 999999)
@pytest.mark.freeze_time('2017-12-01')
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
assert desk.create_timeperiod_exceptions_from_ics(ICS_SAMPLE_WITH_RECURRENT_EVENT) == 3
assert TimePeriodException.objects.filter(desk=desk).count() == 3
assert set(TimePeriodException.objects.values_list('start_datetime', flat=True)) == set([
make_aware(datetime.datetime(2018, 1, 1)), make_aware(datetime.datetime(2019, 1, 1))])
assert desk.create_timeperiod_exceptions_from_ics(ICS_SAMPLE_WITH_RECURRENT_EVENT) == 0
# verify occurences are cleaned when count changed
assert desk.create_timeperiod_exceptions_from_ics(ICS_SAMPLE_WITH_RECURRENT_EVENT_2) == 0
assert TimePeriodException.objects.filter(desk=desk).count() == 2
assert set(TimePeriodException.objects.values_list('start_datetime', flat=True)) == set([
make_aware(datetime.datetime(2018, 1, 1)), make_aware(datetime.datetime(2018, 1, 2))])
def test_timeexception_creation_from_ics_with_dates():
agenda = Agenda(label=u'Test 5 agenda')

View File

@ -925,19 +925,6 @@ def test_agenda_import_time_period_exception_from_ics(app, admin_user):
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 Year's 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
@ -975,6 +962,34 @@ END:VCALENDAR"""
resp = resp.follow()
assert 'An exception has been imported.' in resp.content
@pytest.mark.freeze_time('2017-12-01')
def test_agenda_import_time_period_exception_from_ics_recurrent(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()
TimePeriod.objects.create(weekday=1, desk=desk,
start_time=datetime.time(10, 0),
end_time=datetime.time(12, 0))
login(app)
resp = app.get('/manage/agendas/%d/' % agenda.pk).follow()
resp = resp.click('Settings')
resp = resp.click('upload')
ics_with_recurrent_exceptions = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//foo.bar//EN
BEGIN:VEVENT
DTSTART:20180101
DTEND:20180101
SUMMARY:New Year's 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=302).follow()
assert TimePeriodException.objects.filter(desk=desk).count() == 2
@mock.patch('chrono.agendas.models.requests.get')
def test_agenda_import_time_period_exception_with_remote_ics(mocked_get, app, admin_user):
agenda = Agenda.objects.create(label='New Example', kind='meetings')

View File

@ -23,6 +23,7 @@ deps =
pylint-django<0.9
django-webtest<1.9.3
django-mellon
pytest-freezegun
commands =
./getlasso.sh
py.test {env:COVERAGE:} {posargs:tests/}