support recurring event in exceptions .ics (fixes #19071)
This commit is contained in:
parent
31ff8e6e86
commit
c4df05a490
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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']
|
||||
|
|
|
@ -24,6 +24,7 @@ Depends: ${misc:Depends},
|
|||
python-django-tenant-schemas,
|
||||
python-psycopg2,
|
||||
python-django-mellon,
|
||||
python-dateutil,
|
||||
gunicorn
|
||||
Recommends: nginx
|
||||
Suggests: postgresql
|
||||
|
|
1
setup.py
1
setup.py
|
@ -140,6 +140,7 @@ setup(
|
|||
'djangorestframework>=3.1, <3.7',
|
||||
'django-jsonfield >= 0.9.3',
|
||||
'vobject',
|
||||
'python-dateutil',
|
||||
'requests'
|
||||
],
|
||||
zip_safe=False,
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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')
|
||||
|
|
Loading…
Reference in New Issue