Projet

Général

Profil

0001-manager-import-export-events-update-event-if-slug-ex.patch

Lauréline Guérin, 11 septembre 2020 10:08

Télécharger (21 ko)

Voir les différences:

Subject: [PATCH] manager: import/export events - update event if slug exists
 (#42343)

 chrono/manager/forms.py                       |  69 +++++--
 .../manager_events_agenda_settings.html       |   1 +
 chrono/manager/urls.py                        |   5 +
 chrono/manager/views.py                       |  70 ++++++-
 tests/test_manager.py                         | 175 ++++++++++++++++--
 5 files changed, 284 insertions(+), 36 deletions(-)
chrono/manager/forms.py
26 26
from django.utils.encoding import force_text
27 27
from django.utils.six import StringIO
28 28
from django.utils.timezone import make_aware
29
from django.utils.timezone import now
29 30
from django.utils.translation import ugettext_lazy as _
30 31

  
31 32
from chrono.agendas.models import (
......
42 43
    Category,
43 44
    AgendaNotificationsSettings,
44 45
    WEEKDAYS_LIST,
46
    generate_slug,
45 47
)
46 48

  
47 49
from . import widgets
......
351 353
            dialect = None
352 354

  
353 355
        events = []
354
        slugs = set()
356
        warnings = {}
357
        events_by_slug = {e.slug: e for e in Event.objects.filter(agenda=self.agenda_pk)}
358
        event_ids_with_bookings = set(
359
            Booking.objects.filter(
360
                event__agenda=self.agenda_pk, cancellation_datetime__isnull=True
361
            ).values_list('event_id', flat=True)
362
        )
363
        seen_slugs = set(events_by_slug.keys())
355 364
        for i, csvline in enumerate(csv.reader(StringIO(content), dialect=dialect)):
356 365
            if not csvline:
357 366
                continue
......
359 368
                raise ValidationError(_('Invalid file format. (line %d)') % (i + 1))
360 369
            if i == 0 and csvline[0].strip('#') in ('date', 'Date', _('date'), _('Date')):
361 370
                continue
362
            event = Event()
363
            event.agenda_id = self.agenda_pk
371

  
372
            # label needed to generate a slug
373
            label = None
374
            if len(csvline) >= 5:
375
                label = force_text(csvline[4])
376

  
377
            # get or create event
378
            event = None
379
            slug = None
380
            if len(csvline) >= 6:
381
                slug = force_text(csvline[5]) if csvline[5] else None
382
                # get existing event if relevant
383
                if slug and slug in seen_slugs:
384
                    event = events_by_slug[slug]
385
                    # update label
386
                    event.label = label
387
            if event is None:
388
                # new event
389
                event = Event(agenda_id=self.agenda_pk, label=label)
390
                # generate a slug if not provided
391
                event.slug = slug or generate_slug(event, seen_slugs=seen_slugs, agenda=self.agenda_pk)
392
                # maintain caches
393
                seen_slugs.add(event.slug)
394
                events_by_slug[event.slug] = event
395

  
364 396
            for datetime_fmt in (
365 397
                '%Y-%m-%d %H:%M',
366 398
                '%d/%m/%Y %H:%M',
......
369 401
                '%d/%m/%Y %H:%M:%S',
370 402
            ):
371 403
                try:
372
                    event_datetime = datetime.datetime.strptime('%s %s' % tuple(csvline[:2]), datetime_fmt)
404
                    event_datetime = make_aware(
405
                        datetime.datetime.strptime('%s %s' % tuple(csvline[:2]), datetime_fmt)
406
                    )
373 407
                except ValueError:
374 408
                    continue
375
                event.start_datetime = make_aware(event_datetime)
409
                if (
410
                    event.pk is not None
411
                    and event.start_datetime != event_datetime
412
                    and event.start_datetime > now()
413
                    and event.pk in event_ids_with_bookings
414
                    and event.pk not in warnings
415
                ):
416
                    # event start datetime has changed, event is not past and has not cancelled bookings
417
                    # => warn the user
418
                    warnings[event.pk] = event
419
                event.start_datetime = event_datetime
376 420
                break
377 421
            else:
378 422
                raise ValidationError(_('Invalid file format. (date/time format, line %d)') % (i + 1))
......
387 431
                    raise ValidationError(
388 432
                        _('Invalid file format. (number of places in waiting list, line %d)') % (i + 1)
389 433
                    )
390
            if len(csvline) >= 5:
391
                event.label = force_text(csvline[4])
392
            exclude = ['desk', 'meeting_type']
393
            if len(csvline) >= 6:
394
                event.slug = force_text(csvline[5]) if csvline[5] else None
395
                if event.slug and event.slug in slugs:
396
                    raise ValidationError(_('File contains duplicated event identifiers: %s') % event.slug)
397
                else:
398
                    slugs.add(event.slug)
399
            else:
400
                exclude += ['slug']
434

  
401 435
            column_index = 7
402 436
            for more_attr in ('description', 'pricing', 'url'):
403 437
                if len(csvline) >= column_index:
......
421 455
                    raise ValidationError(_('Invalid file format. (duration, line %d)') % (i + 1))
422 456

  
423 457
            try:
424
                event.full_clean(exclude=exclude)
458
                event.full_clean(exclude=['desk', 'meeting_type'])
425 459
            except ValidationError as e:
426 460
                errors = [_('Invalid file format:\n')]
427 461
                for label, field_errors in e.message_dict.items():
......
435 469
                raise ValidationError(errors)
436 470
            events.append(event)
437 471
        self.events = events
472
        self.warnings = warnings
438 473

  
439 474
    @staticmethod
440 475
    def get_verbose_name(field_name):
chrono/manager/templates/chrono/manager_events_agenda_settings.html
2 2
{% load i18n %}
3 3

  
4 4
{% block agenda-extra-management-actions %}
5
  <a rel="popup" href="{% url 'chrono-manager-agenda-export-events' pk=object.pk %}">{% trans 'Export Events' %}</a>
5 6
  <a rel="popup" href="{% url 'chrono-manager-agenda-import-events' pk=object.id %}">{% trans 'Import Events' %}</a>
6 7
  <a rel="popup" href="{% url 'chrono-manager-agenda-add-event' pk=object.id %}">{% trans 'New Event' %}</a>
7 8
{% endblock %}
chrono/manager/urls.py
73 73
        views.agenda_import_events,
74 74
        name='chrono-manager-agenda-import-events',
75 75
    ),
76
    url(
77
        r'^agendas/(?P<pk>\d+)/export-events$',
78
        views.agenda_export_events,
79
        name='chrono-manager-agenda-export-events',
80
    ),
76 81
    url(
77 82
        r'^agendas/(?P<pk>\d+)/notifications$',
78 83
        views.agenda_notifications_settings,
chrono/manager/views.py
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17
import csv
17 18
import datetime
18 19
import itertools
19 20
import json
......
23 24

  
24 25
from django.contrib import messages
25 26
from django.core.exceptions import PermissionDenied
26
from django.db.models import Q, F
27
from django.db.models import Q
27 28
from django.db.models import Min, Max
28 29
from django.http import Http404, HttpResponse, HttpResponseRedirect
29 30
from django.shortcuts import get_object_or_404
......
1330 1331
    template_name = 'chrono/manager_import_events.html'
1331 1332
    agenda = None
1332 1333

  
1334
    def set_agenda(self, **kwargs):
1335
        self.agenda = get_object_or_404(Agenda, pk=kwargs.get('pk'), kind='events')
1336

  
1333 1337
    def get_form_kwargs(self):
1334 1338
        kwargs = super(AgendaImportEventsView, self).get_form_kwargs()
1335 1339
        kwargs['agenda_pk'] = self.kwargs['pk']
......
1338 1342
    def form_valid(self, form):
1339 1343
        if form.events:
1340 1344
            # existing event slugs for this agenda
1341
            seen_slugs = set(self.agenda.event_set.values_list('slug', flat=True))
1342 1345
            for event in form.events:
1343
                event.agenda_id = self.kwargs['pk']
1344
                event.save(seen_slugs=seen_slugs)  # optimization: seen_slugs
1346
                event.agenda = self.agenda
1347
                event.save()
1345 1348
            messages.info(self.request, _('%d events have been imported.') % len(form.events))
1349
            for event in form.warnings.values():
1350
                messages.warning(
1351
                    self.request,
1352
                    _('Event "%s" start date has changed. Do not forget to notify the registrants.')
1353
                    % (event.label or event.slug),
1354
                )
1346 1355
        return super(AgendaImportEventsView, self).form_valid(form)
1347 1356

  
1348 1357

  
1349 1358
agenda_import_events = AgendaImportEventsView.as_view()
1350 1359

  
1351 1360

  
1361
class AgendaExportEventsView(ManagedAgendaMixin, View):
1362
    template_name = 'chrono/manager_export_events.html'
1363
    agenda = None
1364

  
1365
    def set_agenda(self, **kwargs):
1366
        self.agenda = get_object_or_404(Agenda, pk=kwargs.get('pk'), kind='events')
1367

  
1368
    def get(self, request, *args, **kwargs):
1369
        response = HttpResponse(content_type='text/csv')
1370
        today = datetime.date.today()
1371
        response['Content-Disposition'] = 'attachment; filename="export_agenda__events_{}_{}.csv"'.format(
1372
            self.agenda.slug, today.strftime('%Y%m%d')
1373
        )
1374
        writer = csv.writer(response)
1375
        # headers
1376
        writer.writerow(
1377
            [
1378
                _('date'),
1379
                _('time'),
1380
                _('number of places'),
1381
                _('number of places in waiting list'),
1382
                _('label'),
1383
                _('identifier'),
1384
                _('description'),
1385
                _('pricing'),
1386
                _('URL'),
1387
                _('publication date'),
1388
                _('duration'),
1389
            ]
1390
        )
1391
        for event in self.agenda.event_set.all():
1392
            start_datetime = localtime(event.start_datetime)
1393
            writer.writerow(
1394
                [
1395
                    start_datetime.strftime('%Y-%m-%d'),
1396
                    start_datetime.strftime('%H:%M'),
1397
                    event.places,
1398
                    event.waiting_list_places,
1399
                    event.label,
1400
                    event.slug,
1401
                    event.description,
1402
                    event.pricing,
1403
                    event.url,
1404
                    event.publication_date.strftime('%Y-%m-%d') if event.publication_date else '',
1405
                    event.duration,
1406
                ]
1407
            )
1408
        return response
1409

  
1410

  
1411
agenda_export_events = AgendaExportEventsView.as_view()
1412

  
1413

  
1352 1414
class AgendaNotificationsSettingsView(ManagedAgendaMixin, UpdateView):
1353 1415
    template_name = 'chrono/manager_agenda_notifications_form.html'
1354 1416
    model = AgendaNotificationsSettings
tests/test_manager.py
17 17
from django.utils.encoding import force_text
18 18
from django.utils.timezone import make_aware, now, localtime
19 19

  
20
import datetime
21 20
import freezegun
22 21
import pytest
23 22
import requests
......
1383 1382
    assert Event.objects.count() == 0
1384 1383

  
1385 1384

  
1385
def test_export_events(app, admin_user):
1386
    agenda = Agenda.objects.create(label=u'Foo bar')
1387

  
1388
    app = login(app)
1389
    resp = app.get('/manage/agendas/%s/export-events' % agenda.id)
1390
    csv_export = resp.text
1391
    assert (
1392
        csv_export
1393
        == 'date,time,number of places,number of places in waiting list,label,identifier,description,pricing,URL,publication date,duration\r\n'
1394
    )
1395

  
1396
    resp = app.get('/manage/agendas/%s/import-events' % agenda.id)
1397
    resp.form['events_csv_file'] = Upload('t.csv', b'2016-09-16,00:30,10', 'text/csv')
1398
    resp.form.submit(status=302)
1399

  
1400
    resp = app.get('/manage/agendas/%s/export-events' % agenda.id)
1401
    csv_export = resp.text
1402
    assert (
1403
        csv_export
1404
        == 'date,time,number of places,number of places in waiting list,label,identifier,description,pricing,URL,publication date,duration\r\n'
1405
        '2016-09-16,00:30,10,0,,foo-bar-event,,,,,\r\n'
1406
    )
1407

  
1408
    resp = app.get('/manage/agendas/%s/import-events' % agenda.id)
1409
    resp.form['events_csv_file'] = Upload(
1410
        't.csv',
1411
        b'2016-09-16,23:30,10,5,label,slug,"description\nfoobar",pricing,url,2016-10-16,90',
1412
        'text/csv',
1413
    )
1414
    resp.form.submit(status=302)
1415
    resp = app.get('/manage/agendas/%s/export-events' % agenda.id)
1416
    csv_export = resp.text
1417
    assert (
1418
        csv_export
1419
        == 'date,time,number of places,number of places in waiting list,label,identifier,description,pricing,URL,publication date,duration\r\n'
1420
        '2016-09-16,00:30,10,0,,foo-bar-event,,,,,\r\n'
1421
        '2016-09-16,23:30,10,5,label,slug,"description\nfoobar",pricing,url,2016-10-16,90\r\n'
1422
    )
1423

  
1424

  
1425
def test_export_events_wrong_kind(app, admin_user):
1426
    agenda = Agenda.objects.create(label=u'Foo bar', kind='meetings')
1427

  
1428
    app = login(app)
1429
    app.get('/manage/agendas/%s/export-events' % agenda.id, status=404)
1430
    agenda.kind = 'virtual'
1431
    agenda.save()
1432
    app.get('/manage/agendas/%s/export-events' % agenda.id, status=404)
1433

  
1434

  
1386 1435
def test_import_events(app, admin_user):
1387 1436
    agenda = Agenda(label=u'Foo bar')
1388 1437
    agenda.save()
......
1528 1577
    assert event.slug == 'slug'
1529 1578
    resp = app.get('/manage/agendas/%s/import-events' % agenda.id, status=200)
1530 1579
    resp.form['events_csv_file'] = Upload('t.csv', b'2016-09-16,18:00,10,5,label,slug', 'text/csv')
1531
    resp = resp.form.submit(status=200)
1532
    assert 'Event with this Agenda and Identifier already exists.' in resp.text
1533
    assert '__all__' not in resp.text
1580
    resp = resp.form.submit(status=302)
1581
    assert Event.objects.count() == 1
1582
    event = Event.objects.latest('pk')
1583
    assert event.slug == 'slug'
1534 1584

  
1535 1585
    # additional optional attributes
1536 1586
    Event.objects.all().delete()
......
1581 1631
    resp = app.get('/manage/agendas/%s/import-events' % agenda.id, status=200)
1582 1632
    resp.form['events_csv_file'] = Upload(
1583 1633
        't.csv',
1584
        b'2016-09-16,18:00,10,5,labela,,,pricing,\n'
1585
        b'2016-09-17,18:00,10,5,labela,,,pricing,\n'
1586
        b'2016-09-18,18:00,10,5,labela,,,pricing,\n'
1634
        b'2016-09-16,18:00,10,5,labela,labelb,,pricing,\n'
1635
        b'2016-09-17,18:00,10,5,labela,labelb-1,,pricing,\n'
1636
        b'2016-09-18,18:00,10,5,labela,labelb-2,,pricing,\n'
1587 1637
        b'2016-09-18,18:00,10,5,labelb,,,pricing,\n'
1588 1638
        b'2016-09-18,18:00,10,5,labelb,,,pricing,\n',
1589 1639
        'text/csv',
1590 1640
    )
1591 1641
    with CaptureQueriesContext(connection) as ctx:
1592 1642
        resp = resp.form.submit(status=302)
1593
        assert len(ctx.captured_queries) == 24
1643
        assert len(ctx.captured_queries) == 22
1594 1644
    assert Event.objects.count() == 5
1645
    assert set(Event.objects.values_list('slug', flat=True)) == set(
1646
        ['labelb', 'labelb-1', 'labelb-2', 'labelb-3', 'labelb-4']
1647
    )
1595 1648

  
1596 1649
    # forbidden numerical slug
1597 1650
    Event.objects.all().delete()
......
1601 1654
    assert 'value cannot be a number' in resp.text
1602 1655
    assert 'Identifier:' in resp.text  # verbose_name is shown, not field name ('slug:')
1603 1656

  
1604
    # handle duplicated slug
1605
    Event.objects.all().delete()
1657

  
1658
def test_import_events_existing_event(app, admin_user, freezer):
1659
    agenda = Agenda.objects.create(label=u'Foo bar')
1660

  
1661
    app = login(app)
1606 1662
    resp = app.get('/manage/agendas/%s/import-events' % agenda.id, status=200)
1607 1663
    resp.form['events_csv_file'] = Upload(
1608
        't.csv', b'2016-09-16,18:00,10,5,label,slug\n' b'2016-09-16,18:00,10,5,label,slug\n', 'text/csv',
1664
        't.csv', b'2016-09-16,18:00,10,5,label,slug\n2016-09-16,18:00,10,5,label,slug\n', 'text/csv',
1609 1665
    )
1610
    resp = resp.form.submit(status=200)
1611
    assert 'duplicated event identifiers' in resp.text
1666
    resp.form.submit(status=302)
1667
    assert agenda.event_set.count() == 1
1668
    event = Event.objects.latest('pk')
1669

  
1670
    def check_import(date, time, with_alert):
1671
        resp = app.get('/manage/agendas/%s/import-events' % agenda.id, status=200)
1672
        resp.form['events_csv_file'] = Upload(
1673
            't.csv', b'%s,%s,10,5,label,slug\n' % (date.encode(), time.encode()), 'text/csv',
1674
        )
1675
        resp = resp.form.submit(status=302).follow()
1676
        assert agenda.event_set.count() == 1
1677
        event.refresh_from_db()
1678
        if with_alert:
1679
            assert (
1680
                '<li class="warning">Event &quot;label&quot; start date has changed. Do not forget to notify the registrants.</li>'
1681
                in resp.text
1682
            )
1683
        else:
1684
            assert (
1685
                '<li class="warning">Event &quot;label&quot; start date has changed. Do not forget to notify the registrants.</li>'
1686
                not in resp.text
1687
            )
1688
        assert event.start_datetime == make_aware(
1689
            datetime.datetime(*[int(v) for v in date.split('-')], *[int(v) for v in time.split(':')])
1690
        )
1691

  
1692
    # change date or time
1693
    # event in the past, no alert, with or without booking
1694
    Booking.objects.create(
1695
        event=event, cancellation_datetime=make_aware(datetime.datetime(2017, 5, 20, 10, 30))
1696
    )
1697
    check_import('2016-09-15', '18:00', False)  # change date
1698
    check_import('2016-09-15', '17:00', False)  # change time
1699
    # available booking
1700
    Booking.objects.create(event=event)
1701
    check_import('2016-09-14', '17:00', False)  # change date
1702
    check_import('2016-09-14', '16:00', False)  # change time
1703

  
1704
    # date in the future
1705
    freezer.move_to('2016-09-01')
1706
    # warn if available booking only
1707
    check_import('2016-09-13', '16:00', True)  # change date
1708
    check_import('2016-09-13', '15:00', True)  # change time
1709
    # no available booking
1710
    Booking.objects.all().delete()
1711
    Booking.objects.create(
1712
        event=event, cancellation_datetime=make_aware(datetime.datetime(2017, 5, 20, 10, 30))
1713
    )
1714
    check_import('2016-09-12', '15:00', False)  # change date
1715
    check_import('2016-09-12', '14:00', False)  # change time
1716

  
1717
    # check there is a message per changed event
1718
    resp = app.get('/manage/agendas/%s/import-events' % agenda.id, status=200)
1719
    resp.form['events_csv_file'] = Upload(
1720
        't.csv', b'2016-09-16,18:00,10,5,label,slug\n2016-09-16,19:00,10,5,label,other_slug\n', 'text/csv',
1721
    )
1722
    resp.form.submit(status=302)
1723
    assert agenda.event_set.count() == 2
1724
    event2 = Event.objects.latest('pk')
1725
    Booking.objects.create(event=event)
1726
    Booking.objects.create(event=event2)
1727

  
1728
    resp = app.get('/manage/agendas/%s/import-events' % agenda.id, status=200)
1729
    resp.form['events_csv_file'] = Upload(
1730
        't.csv',
1731
        b'2016-09-17,18:00,10,5,label,slug\n2016-09-17,19:00,10,5,,other_slug\n2016-09-17,20:00,10,5,,other_slug\n',
1732
        'text/csv',
1733
    )
1734
    resp = resp.form.submit(status=302).follow()
1735
    assert agenda.event_set.count() == 2
1736
    assert (
1737
        resp.text.count(
1738
            'Event &quot;label&quot; start date has changed. Do not forget to notify the registrants.'
1739
        )
1740
        == 1
1741
    )
1742
    assert (
1743
        resp.text.count(
1744
            'Event &quot;other_slug&quot; start date has changed. Do not forget to notify the registrants.'
1745
        )
1746
        == 1
1747
    )
1748

  
1749

  
1750
def test_import_events_wrong_kind(app, admin_user):
1751
    agenda = Agenda.objects.create(label=u'Foo bar', kind='meetings')
1752

  
1753
    app = login(app)
1754
    app.get('/manage/agendas/%s/import-events' % agenda.id, status=404)
1755
    agenda.kind = 'virtual'
1756
    agenda.save()
1757
    app.get('/manage/agendas/%s/import-events' % agenda.id, status=404)
1612 1758

  
1613 1759

  
1614 1760
def test_add_meetings_agenda(app, admin_user):
......
3216 3362
    agenda.save()
3217 3363

  
3218 3364
    app = login(app)
3219
    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
3220 3365
    with freezegun.freeze_time('2020-06-15'):
3221
        resp = resp.click('Export')
3366
        resp = app.get('/manage/agendas/%s/export' % agenda.id)
3222 3367
    assert resp.headers['content-type'] == 'application/json'
3223 3368
    assert resp.headers['content-disposition'] == 'attachment; filename="export_agenda_foo-bar_20200615.json"'
3224 3369
    agenda_export = resp.text
3225
-