From 99515791d470d3d9eadb29dd6ae5a0c1059f5761 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Fri, 5 Mar 2021 13:59:17 +0100
Subject: [PATCH] manager: manage recurring events in csv import/export
 (#51645)

---
 chrono/manager/forms.py                       |  22 +++-
 .../chrono/manager_sample_events.txt          |   4 +-
 chrono/manager/views.py                       |  12 +-
 tests/test_manager.py                         | 112 +++++++++++++++++-
 4 files changed, 139 insertions(+), 11 deletions(-)

diff --git a/chrono/manager/forms.py b/chrono/manager/forms.py
index 05c1622..92ca4f5 100644
--- a/chrono/manager/forms.py
+++ b/chrono/manager/forms.py
@@ -500,8 +500,9 @@ class ImportEventsForm(forms.Form):
         help_text=_(
             'CSV file with date, time, number of places, '
             'number of places in waiting list, label, and '
-            'optionally, identifier, description, pricing, '
-            'URL, and publication date as columns.'
+            'optionally, identifier, description, pricing,<br />'
+            'URL, publication date, duration, recurrence repetition '
+            'and recurrence end date as columns.'
         ),
     )
     events = None
@@ -631,6 +632,23 @@ class ImportEventsForm(forms.Form):
                 except ValueError:
                     raise ValidationError(_('Invalid file format. (duration, line %d)') % (i + 1))
 
+            if len(csvline) >= 12 and csvline[11]:  # recurrence repetition is optional
+                if csvline[11] not in [k for k, v in Event.REPEAT_CHOICES]:
+                    raise ValidationError(
+                        _('Invalid file format. (recurrence repetition, line %d)') % (i + 1)
+                    )
+                event.repeat = csvline[11]
+
+            if len(csvline) >= 13 and csvline[12]:  # recurrence_end_date is optional
+                for date_fmt in ('%Y-%m-%d', '%d/%m/%Y'):
+                    try:
+                        event.recurrence_end_date = datetime.datetime.strptime(csvline[12], date_fmt).date()
+                        break
+                    except ValueError:
+                        continue
+                else:
+                    raise ValidationError(_('Invalid file format. (date format, line %d)') % (i + 1))
+
             try:
                 event.full_clean(exclude=['desk', 'meeting_type', 'primary_event'])
             except ValidationError as e:
diff --git a/chrono/manager/templates/chrono/manager_sample_events.txt b/chrono/manager/templates/chrono/manager_sample_events.txt
index 180fe26..6285f53 100644
--- a/chrono/manager/templates/chrono/manager_sample_events.txt
+++ b/chrono/manager/templates/chrono/manager_sample_events.txt
@@ -1,2 +1,2 @@
-{% load i18n %}{% trans 'date' %},{% trans 'time' %},{% trans 'number of places' %},{% trans 'number of places in waiting list' %},{% trans 'label' %},{% trans 'identifier' %},{% trans 'description' %},{% trans 'pricing' %},{% trans 'URL' %},{% trans 'publication date' %},{% trans 'duration' %}
-{{ some_future_date|date:"Y-m-d" }},{{ some_future_date|date:"H:i" }},15,0,{% trans "example event" as label %}{{ label }},{{ label|slugify }},,,https://www.example.net,{{ some_future_date|date:"Y-m-d" }},30
+{% load i18n %}{% trans 'date' %},{% trans 'time' %},{% trans 'number of places' %},{% trans 'number of places in waiting list' %},{% trans 'label' %},{% trans 'identifier' %},{% trans 'description' %},{% trans 'pricing' %},{% trans 'URL' %},{% trans 'publication date' %},{% trans 'duration' %},{% trans 'recurrence repetition' %},{% trans 'recurrence end date' %}
+{{ some_future_date|date:"Y-m-d" }},{{ some_future_date|date:"H:i" }},15,0,{% trans "example event" as label %}{{ label }},{{ label|slugify }},,,https://www.example.net,{{ some_future_date|date:"Y-m-d" }},30,daily,{{ some_future_date|date:"Y-m-d" }}
diff --git a/chrono/manager/views.py b/chrono/manager/views.py
index 0b238bf..26b32f5 100644
--- a/chrono/manager/views.py
+++ b/chrono/manager/views.py
@@ -1484,6 +1484,12 @@ class AgendaImportEventsView(ManagedAgendaMixin, FormView):
             for event in form.events:
                 event.agenda = self.agenda
                 event.save()
+                if event.recurrence_rule and event.recurrence_end_date:
+                    event.recurrences.filter(start_datetime__gt=event.recurrence_end_date).delete()
+                    excluded_datetimes = [
+                        make_naive(dt) for dt in event.recurrences.values_list('start_datetime', flat=True)
+                    ]
+                    event.create_all_recurrences(excluded_datetimes)
             messages.info(self.request, _('%d events have been imported.') % len(form.events))
             for event in form.warnings.values():
                 messages.warning(
@@ -1525,9 +1531,11 @@ class AgendaExportEventsView(ManagedAgendaMixin, View):
                 _('URL'),
                 _('publication date'),
                 _('duration'),
+                _('recurrence repetition'),
+                _('recurrence end date'),
             ]
         )
-        for event in self.agenda.event_set.all():
+        for event in self.agenda.event_set.filter(primary_event__isnull=True):
             start_datetime = localtime(event.start_datetime)
             writer.writerow(
                 [
@@ -1542,6 +1550,8 @@ class AgendaExportEventsView(ManagedAgendaMixin, View):
                     event.url,
                     event.publication_date.strftime('%Y-%m-%d') if event.publication_date else '',
                     event.duration,
+                    event.repeat,
+                    event.recurrence_end_date.strftime('%Y-%m-%d') if event.recurrence_end_date else '',
                 ]
             )
         return response
diff --git a/tests/test_manager.py b/tests/test_manager.py
index d932e54..9e7d1c1 100644
--- a/tests/test_manager.py
+++ b/tests/test_manager.py
@@ -21,6 +21,7 @@ from django.utils.timezone import make_aware, now, localtime
 import freezegun
 import pytest
 import requests
+from dateutil.rrule import DAILY
 from webtest import Upload
 
 from chrono.agendas.models import (
@@ -1720,7 +1721,8 @@ def test_export_events(app, admin_user):
     csv_export = resp.text
     assert (
         csv_export
-        == 'date,time,number of places,number of places in waiting list,label,identifier,description,pricing,URL,publication date,duration\r\n'
+        == 'date,time,number of places,number of places in waiting list,label,identifier,description,pricing,URL,'
+        'publication date,duration,recurrence repetition,recurrence end date\r\n'
     )
 
     resp = app.get('/manage/agendas/%s/import-events' % agenda.id)
@@ -1731,8 +1733,9 @@ def test_export_events(app, admin_user):
     csv_export = resp.text
     assert (
         csv_export
-        == 'date,time,number of places,number of places in waiting list,label,identifier,description,pricing,URL,publication date,duration\r\n'
-        '2016-09-16,00:30,10,0,,foo-bar-event,,,,,\r\n'
+        == 'date,time,number of places,number of places in waiting list,label,identifier,description,pricing,URL,'
+        'publication date,duration,recurrence repetition,recurrence end date\r\n'
+        '2016-09-16,00:30,10,0,,foo-bar-event,,,,,,,\r\n'
     )
 
     resp = app.get('/manage/agendas/%s/import-events' % agenda.id)
@@ -1746,9 +1749,10 @@ def test_export_events(app, admin_user):
     csv_export = resp.text
     assert (
         csv_export
-        == 'date,time,number of places,number of places in waiting list,label,identifier,description,pricing,URL,publication date,duration\r\n'
-        '2016-09-16,00:30,10,0,,foo-bar-event,,,,,\r\n'
-        '2016-09-16,23:30,10,5,label,slug,"description\nfoobar",pricing,url,2016-10-16,90\r\n'
+        == 'date,time,number of places,number of places in waiting list,label,identifier,description,pricing,URL,'
+        'publication date,duration,recurrence repetition,recurrence end date\r\n'
+        '2016-09-16,00:30,10,0,,foo-bar-event,,,,,,,\r\n'
+        '2016-09-16,23:30,10,5,label,slug,"description\nfoobar",pricing,url,2016-10-16,90,,\r\n'
     )
 
 
@@ -2083,6 +2087,102 @@ def test_import_events_existing_event(app, admin_user, freezer):
     )
 
 
+@pytest.mark.freeze_time('2021-01-12 12:10')
+def test_import_events_recurring_event(app, admin_user):
+    agenda = Agenda.objects.create(label=u'Foo bar', kind='events')
+    event = Event.objects.create(
+        agenda=agenda,
+        start_datetime=now(),
+        repeat='daily',
+        places=10,
+        slug='test',
+    )
+    event.get_or_create_event_recurrence(event.start_datetime + datetime.timedelta(days=3))
+    assert Event.objects.count() == 2
+
+    app = login(app)
+    resp = app.get('/manage/agendas/%s/export-events' % agenda.pk)
+    csv_export = resp.text
+    assert csv_export == (
+        'date,time,number of places,number of places in waiting list,label,identifier,description,pricing,URL,'
+        'publication date,duration,recurrence repetition,recurrence end date\r\n'
+        '2021-01-12,13:10,10,0,,test,,,,,,daily,\r\n'
+    )
+    Event.objects.all().delete()
+
+    resp = app.get('/manage/agendas/%s/import-events' % agenda.pk)
+    resp.form['events_csv_file'] = Upload('t.csv', b'2021-01-12,13:10,10,0,,test,,,,,,daily,', 'text/csv')
+    resp.form.submit(status=302)
+    assert Event.objects.count() == 1
+    event = Event.objects.first()
+    assert event.start_datetime == make_aware(datetime.datetime(2021, 1, 12, 13, 10))
+    assert event.places == 10
+    assert event.waiting_list_places == 0
+    assert event.primary_event is None
+    assert event.repeat == 'daily'
+    assert event.recurrence_rule == {'freq': DAILY}
+    assert event.recurrence_end_date is None
+    Event.objects.all().delete()
+
+    # wrong repeat format
+    resp = app.get('/manage/agendas/%s/import-events' % agenda.pk)
+    resp.form['events_csv_file'] = Upload('t.csv', b'2021-01-12,13:10,10,0,,test,,,,,,foobar,', 'text/csv')
+    resp = resp.form.submit(status=200)
+    assert 'Invalid file format. (recurrence repetition' in resp.text
+    assert Event.objects.count() == 0
+
+    # check recurrence_end_date
+    event = Event.objects.create(
+        agenda=agenda,
+        start_datetime=now(),
+        repeat='daily',
+        recurrence_end_date=now() + datetime.timedelta(days=7),
+        places=10,
+        slug='test',
+    )
+    event.create_all_recurrences()
+    assert Event.objects.filter(primary_event=event).count() == 7
+
+    resp = app.get('/manage/agendas/%s/export-events' % agenda.pk)
+    csv_export = resp.text
+    assert csv_export == (
+        'date,time,number of places,number of places in waiting list,label,identifier,description,pricing,URL,'
+        'publication date,duration,recurrence repetition,recurrence end date\r\n'
+        '2021-01-12,13:10,10,0,,test,,,,,,daily,2021-01-19\r\n'
+    )
+    Event.objects.all().delete()
+
+    resp = app.get('/manage/agendas/%s/import-events' % agenda.pk)
+    resp.form['events_csv_file'] = Upload(
+        't.csv', b'2021-01-12,13:10,10,0,,test,,,,,,daily,2021-01-19', 'text/csv'
+    )
+    resp.form.submit(status=302)
+    event = Event.objects.get(slug='test')
+    assert Event.objects.count() == 8
+    assert Event.objects.filter(primary_event=event).count() == 7
+
+    # import again
+    resp = app.get('/manage/agendas/%s/import-events' % agenda.pk)
+    resp.form['events_csv_file'] = Upload(
+        't.csv', b'2021-01-12,13:10,10,0,,test,,,,,,daily,2021-01-19', 'text/csv'
+    )
+    resp.form.submit(status=302)
+    event = Event.objects.get(slug='test')
+    assert Event.objects.count() == 8
+    assert Event.objects.filter(primary_event=event).count() == 7
+
+    Event.objects.all().delete()
+
+    # wrong recurrence_end_date format
+    resp = app.get('/manage/agendas/%s/import-events' % agenda.pk)
+    resp.form['events_csv_file'] = Upload(
+        't.csv', b'2021-01-12,13:10,10,0,,test,,,,,,daily,foobar', 'text/csv'
+    )
+    resp = resp.form.submit(status=200)
+    assert 'Invalid file format. (date format' in resp.text
+    assert Event.objects.count() == 0
+
+
 def test_import_events_wrong_kind(app, admin_user):
     agenda = Agenda.objects.create(label=u'Foo bar', kind='meetings')
 
-- 
2.30.1

