Projet

Général

Profil

0001-manager-import-export-categories-57424.patch

Lauréline Guérin, 04 octobre 2021 10:07

Télécharger (21,2 ko)

Voir les différences:

Subject: [PATCH] manager: import/export categories (#57424)

 chrono/agendas/models.py                      |  13 +++
 chrono/manager/forms.py                       |   1 +
 .../chrono/manager_category_list.html         |   1 +
 chrono/manager/urls.py                        |   5 +
 chrono/manager/utils.py                       |  45 ++++----
 chrono/manager/views.py                       |  47 +++++++-
 tests/manager/test_all.py                     |  36 -------
 .../{test_import.py => test_import_export.py} | 102 ++++++++++++++++++
 tests/test_import_export.py                   |  49 ++++++++-
 9 files changed, 236 insertions(+), 63 deletions(-)
 rename tests/manager/{test_import.py => test_import_export.py} (76%)
chrono/agendas/models.py
2105 2105
    def base_slug(self):
2106 2106
        return slugify(self.label)
2107 2107

  
2108
    @classmethod
2109
    def import_json(cls, data, overwrite=False):
2110
        data = clean_import_data(cls, data)
2111
        slug = data.pop('slug')
2112
        category, created = cls.objects.update_or_create(slug=slug, defaults=data)
2113
        return created, category
2114

  
2115
    def export_json(self):
2116
        return {
2117
            'label': self.label,
2118
            'slug': self.slug,
2119
        }
2120

  
2108 2121

  
2109 2122
def ics_directory_path(instance, filename):
2110 2123
    return f'ics/{str(uuid.uuid4())}/{filename}'
chrono/manager/forms.py
918 918
        label=_('Unavailability calendars'), required=False, initial=True
919 919
    )
920 920
    absence_reason_groups = forms.BooleanField(label=_('Absence reason groups'), required=False, initial=True)
921
    categories = forms.BooleanField(label=_('Categories'), required=False, initial=True)
chrono/manager/templates/chrono/manager_category_list.html
9 9
{% block appbar %}
10 10
<h2>{% trans 'Categories' %}</h2>
11 11
<span class="actions">
12
<a href="{% url 'chrono-manager-category-export' %}">{% trans 'Export' %}</a>
12 13
<a rel="popup" href="{% url 'chrono-manager-category-add' %}">{% trans 'New' %}</a>
13 14
</span>
14 15
{% endblock %}
chrono/manager/urls.py
76 76
    url(r'^resource/(?P<pk>\d+)/edit/$', views.resource_edit, name='chrono-manager-resource-edit'),
77 77
    url(r'^resource/(?P<pk>\d+)/delete/$', views.resource_delete, name='chrono-manager-resource-delete'),
78 78
    url(r'^categories/$', views.category_list, name='chrono-manager-category-list'),
79
    url(
80
        r'^categories/export$',
81
        views.category_export,
82
        name='chrono-manager-category-export',
83
    ),
79 84
    url(r'^category/add/$', views.category_add, name='chrono-manager-category-add'),
80 85
    url(r'^category/(?P<pk>\d+)/edit/$', views.category_edit, name='chrono-manager-category-edit'),
81 86
    url(r'^category/(?P<pk>\d+)/delete/$', views.category_delete, name='chrono-manager-category-delete'),
chrono/manager/utils.py
21 21
from django.db import transaction
22 22
from django.db.models import Q
23 23

  
24
from chrono.agendas.models import AbsenceReasonGroup, Agenda, AgendaImportError, UnavailabilityCalendar
24
from chrono.agendas.models import (
25
    AbsenceReasonGroup,
26
    Agenda,
27
    AgendaImportError,
28
    Category,
29
    UnavailabilityCalendar,
30
)
25 31

  
26 32

  
27
def export_site(agendas=True, unavailability_calendars=True, absence_reason_groups=True):
33
def export_site(agendas=True, unavailability_calendars=True, absence_reason_groups=True, categories=True):
28 34
    '''Dump site objects to JSON-dumpable dictionnary'''
29 35
    data = collections.OrderedDict()
36
    if categories:
37
        data['categories'] = [x.export_json() for x in Category.objects.all()]
30 38
    if absence_reason_groups:
31 39
        data['absence_reason_groups'] = [x.export_json() for x in AbsenceReasonGroup.objects.all()]
32 40
    if unavailability_calendars:
......
43 51
        Agenda.objects.exists()
44 52
        or UnavailabilityCalendar.objects.exists()
45 53
        or AbsenceReasonGroup.objects.exists()
54
        or Category.objects.exists()
46 55
    ):
47 56
        return
48 57

  
......
50 59
        Agenda.objects.all().delete()
51 60
        UnavailabilityCalendar.objects.all().delete()
52 61
        AbsenceReasonGroup.objects.all().delete()
62
        Category.objects.all().delete()
53 63

  
54 64
    results = {
55
        'agendas': collections.defaultdict(list),
56
        'unavailability_calendars': collections.defaultdict(list),
57
        'absence_reason_groups': collections.defaultdict(list),
65
        key: collections.defaultdict(list)
66
        for key in ['agendas', 'unavailability_calendars', 'absence_reason_groups', 'categories']
58 67
    }
59
    agendas = data.get('agendas', [])
60
    unavailability_calendars = data.get('unavailability_calendars', [])
61
    absence_reason_groups = data.get('absence_reason_groups', [])
62 68

  
63 69
    role_names = set()
64
    for objs in (agendas, unavailability_calendars):
70
    for key in ['agendas', 'unavailability_calendars']:
71
        objs = data.get(key, [])
65 72
        role_names = role_names.union(
66 73
            {name for data in objs for _, name in data.get('permissions', {}).items() if name}
67 74
        )
......
72 79
        raise AgendaImportError('Missing roles: "%s"' % ', '.join(role_names - existing_roles_names))
73 80

  
74 81
    with transaction.atomic():
75
        for objs, cls, label in (
76
            (absence_reason_groups, AbsenceReasonGroup, 'absence_reason_groups'),
77
            (unavailability_calendars, UnavailabilityCalendar, 'unavailability_calendars'),
78
            (agendas, Agenda, 'agendas'),
82
        for cls, key in (
83
            (Category, 'categories'),
84
            (AbsenceReasonGroup, 'absence_reason_groups'),
85
            (UnavailabilityCalendar, 'unavailability_calendars'),
86
            (Agenda, 'agendas'),
79 87
        ):
80
            for data in objs:
81
                created, obj = cls.import_json(data, overwrite=overwrite)
82
                results[label]['all'].append(obj)
88
            objs = data.get(key, [])
89
            for obj in objs:
90
                created, obj = cls.import_json(obj, overwrite=overwrite)
91
                results[key]['all'].append(obj)
83 92
                if created:
84
                    results[label]['created'].append(obj)
93
                    results[key]['created'].append(obj)
85 94
                else:
86
                    results[label]['updated'].append(obj)
95
                    results[key]['updated'].append(obj)
87 96
    return results
chrono/manager/views.py
559 559
category_list = CategoryListView.as_view()
560 560

  
561 561

  
562
class CategoryExport(ListView):
563
    model = Category
564

  
565
    def dispatch(self, request, *args, **kwargs):
566
        if not request.user.is_staff:
567
            raise PermissionDenied()
568
        return super().dispatch(request, *args, **kwargs)
569

  
570
    def get(self, request, *args, **kwargs):
571
        response = HttpResponse(content_type='application/json')
572
        today = datetime.date.today()
573
        attachment = 'attachment; filename="export_categories_{}.json"'.format(today.strftime('%Y%m%d'))
574
        response['Content-Disposition'] = attachment
575
        json.dump({'categories': [obj.export_json() for obj in self.get_queryset()]}, response, indent=2)
576
        return response
577

  
578

  
579
category_export = CategoryExport.as_view()
580

  
581

  
562 582
class CategoryAddView(CreateView):
563 583
    template_name = 'chrono/manager_category_form.html'
564 584
    model = Category
......
856 876
                    x,
857 877
                ),
858 878
            },
879
            'categories': {
880
                'create_noop': _('No category created.'),
881
                'create': lambda x: ungettext(
882
                    'A category has been created.',
883
                    '%(count)d categories have been created.',
884
                    x,
885
                ),
886
                'update_noop': _('No category updated.'),
887
                'update': lambda x: ungettext(
888
                    'A category has been updated.',
889
                    '%(count)d categories have been updated.',
890
                    x,
891
                ),
892
            },
859 893
        }
860 894

  
861 895
        global_noop = True
......
876 910

  
877 911
                obj_results['messages'] = "%s %s" % (message1, message2)
878 912

  
879
        a_count, uc_count, arg_count = (
913
        a_count, uc_count, arg_count, c_count = (
880 914
            len(results['agendas']['all']),
881 915
            len(results['unavailability_calendars']['all']),
882 916
            len(results['absence_reason_groups']['all']),
917
            len(results['categories']['all']),
883 918
        )
884
        if (a_count, uc_count, arg_count) == (1, 0, 0):
919
        if (a_count, uc_count, arg_count, c_count) == (1, 0, 0, 0):
885 920
            # only one agenda imported, redirect to settings page
886 921
            return HttpResponseRedirect(
887 922
                reverse('chrono-manager-agenda-settings', kwargs={'pk': results['agendas']['all'][0].pk})
888 923
            )
889
        if (a_count, uc_count, arg_count) == (0, 1, 0):
924
        if (a_count, uc_count, arg_count, c_count) == (0, 1, 0, 0):
890 925
            # only one unavailability calendar imported, redirect to settings page
891 926
            return HttpResponseRedirect(
892 927
                reverse(
......
894 929
                    kwargs={'pk': results['unavailability_calendars']['all'][0].pk},
895 930
                )
896 931
            )
897
        if (a_count, uc_count, arg_count) == (0, 0, 1):
932
        if (a_count, uc_count, arg_count, c_count) == (0, 0, 1, 0):
898 933
            # only one absence reason group imported, redirect to group page
899 934
            return HttpResponseRedirect(reverse('chrono-manager-absence-reason-list'))
935
        if (a_count, uc_count, arg_count, c_count) == (0, 0, 0, 1):
936
            # only one category imported, redirect to categories page
937
            return HttpResponseRedirect(reverse('chrono-manager-category-list'))
900 938

  
901 939
        if global_noop:
902 940
            messages.info(self.request, _('No data found.'))
......
904 942
            messages.info(self.request, results['agendas']['messages'])
905 943
            messages.info(self.request, results['unavailability_calendars']['messages'])
906 944
            messages.info(self.request, results['absence_reason_groups']['messages'])
945
            messages.info(self.request, results['categories']['messages'])
907 946

  
908 947
        return super().form_valid(form)
909 948

  
tests/manager/test_all.py
1 1
import datetime
2
import json
3 2
from unittest import mock
4 3

  
5 4
import freezegun
......
2914 2913
    )
2915 2914

  
2916 2915

  
2917
def test_export_site(app, admin_user):
2918
    login(app)
2919
    resp = app.get('/manage/')
2920
    resp = resp.click('Export')
2921

  
2922
    with freezegun.freeze_time('2020-06-15'):
2923
        resp = resp.form.submit()
2924
    assert resp.headers['content-type'] == 'application/json'
2925
    assert resp.headers['content-disposition'] == 'attachment; filename="export_agendas_20200615.json"'
2926

  
2927
    site_json = json.loads(resp.text)
2928
    assert site_json == {'unavailability_calendars': [], 'agendas': [], 'absence_reason_groups': []}
2929

  
2930
    agenda = Agenda.objects.create(label='Foo Bar', kind='events')
2931
    Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
2932
    UnavailabilityCalendar.objects.create(label='Calendar 1')
2933
    resp = app.get('/manage/agendas/export/')
2934
    resp = resp.form.submit()
2935

  
2936
    site_json = json.loads(resp.text)
2937
    assert len(site_json['agendas']) == 1
2938
    assert len(site_json['unavailability_calendars']) == 1
2939
    assert len(site_json['absence_reason_groups']) == 0
2940

  
2941
    resp = app.get('/manage/agendas/export/')
2942
    resp.form['agendas'] = False
2943
    resp.form['absence_reason_groups'] = False
2944
    resp = resp.form.submit()
2945

  
2946
    site_json = json.loads(resp.text)
2947
    assert 'agendas' not in site_json
2948
    assert 'unavailability_calendars' in site_json
2949
    assert 'absence_reason_groups' not in site_json
2950

  
2951

  
2952 2916
def test_manager_agenda_roles(app, admin_user, manager_user):
2953 2917
    agenda = Agenda.objects.create(label='Events', kind='events')
2954 2918
    Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
tests/manager/test_import.py → tests/manager/test_import_export.py
12 12
    AbsenceReasonGroup,
13 13
    Agenda,
14 14
    Booking,
15
    Category,
15 16
    Desk,
16 17
    Event,
17 18
    MeetingType,
......
23 24
pytestmark = pytest.mark.django_db
24 25

  
25 26

  
27
def test_export_site(app, admin_user):
28
    login(app)
29
    resp = app.get('/manage/')
30
    resp = resp.click('Export')
31

  
32
    with freezegun.freeze_time('2020-06-15'):
33
        resp = resp.form.submit()
34
    assert resp.headers['content-type'] == 'application/json'
35
    assert resp.headers['content-disposition'] == 'attachment; filename="export_agendas_20200615.json"'
36

  
37
    site_json = json.loads(resp.text)
38
    assert site_json == {
39
        'unavailability_calendars': [],
40
        'agendas': [],
41
        'absence_reason_groups': [],
42
        'categories': [],
43
    }
44

  
45
    agenda = Agenda.objects.create(label='Foo Bar', kind='events')
46
    Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
47
    UnavailabilityCalendar.objects.create(label='Calendar 1')
48
    resp = app.get('/manage/agendas/export/')
49
    resp = resp.form.submit()
50

  
51
    site_json = json.loads(resp.text)
52
    assert len(site_json['agendas']) == 1
53
    assert len(site_json['unavailability_calendars']) == 1
54
    assert len(site_json['absence_reason_groups']) == 0
55
    assert len(site_json['categories']) == 0
56

  
57
    resp = app.get('/manage/agendas/export/')
58
    resp.form['agendas'] = False
59
    resp.form['absence_reason_groups'] = False
60
    resp.form['categories'] = False
61
    resp = resp.form.submit()
62

  
63
    site_json = json.loads(resp.text)
64
    assert 'agendas' not in site_json
65
    assert 'unavailability_calendars' in site_json
66
    assert 'absence_reason_groups' not in site_json
67
    assert 'categories' not in site_json
68

  
69

  
26 70
def test_import_agenda_as_manager(app, manager_user):
27 71
    # open /manage/ access to manager_user, and check agenda import is not
28 72
    # allowed.
......
308 352
    assert '3 absence reason groups have been created. No absence reason group updated.' in resp.text
309 353
    assert AbsenceReasonGroup.objects.count() == 3
310 354
    assert AbsenceReason.objects.count() == 6
355

  
356

  
357
@pytest.mark.freeze_time('2021-07-08')
358
def test_import_category(app, admin_user):
359
    Category.objects.create(label='Foo bar')
360

  
361
    app = login(app)
362
    resp = app.get('/manage/categories/export')
363
    assert resp.headers['content-type'] == 'application/json'
364
    assert resp.headers['content-disposition'] == 'attachment; filename="export_categories_20210708.json"'
365
    group_export = resp.text
366

  
367
    # existing category
368
    resp = app.get('/manage/', status=200)
369
    resp = resp.click('Import')
370
    resp.form['agendas_json'] = Upload('export.json', group_export.encode('utf-8'), 'application/json')
371
    resp = resp.form.submit()
372
    assert resp.location.endswith('/manage/categories/')
373
    resp = resp.follow()
374
    assert 'No category created. A category has been updated.' not in resp.text
375
    assert Category.objects.count() == 1
376

  
377
    # new category
378
    Category.objects.all().delete()
379
    resp = app.get('/manage/', status=200)
380
    resp = resp.click('Import')
381
    resp.form['agendas_json'] = Upload('export.json', group_export.encode('utf-8'), 'application/json')
382
    resp = resp.form.submit()
383
    assert resp.location.endswith('/manage/categories/')
384
    resp = resp.follow()
385
    assert 'A category has been created. No category updated.' not in resp.text
386
    assert Category.objects.count() == 1
387

  
388
    # multiple categories
389
    groups = json.loads(group_export)
390
    groups['categories'].append(copy.copy(groups['categories'][0]))
391
    groups['categories'].append(copy.copy(groups['categories'][0]))
392
    groups['categories'][1]['label'] = 'Foo bar 2'
393
    groups['categories'][1]['slug'] = 'foo-bar-2'
394
    groups['categories'][2]['label'] = 'Foo bar 3'
395
    groups['categories'][2]['slug'] = 'foo-bar-3'
396

  
397
    resp = app.get('/manage/', status=200)
398
    resp = resp.click('Import')
399
    resp.form['agendas_json'] = Upload('export.json', json.dumps(groups).encode('utf-8'), 'application/json')
400
    resp = resp.form.submit()
401
    assert resp.location.endswith('/manage/')
402
    resp = resp.follow()
403
    assert '2 categories have been created. A category has been updated.' in resp.text
404
    assert Category.objects.count() == 3
405

  
406
    Category.objects.all().delete()
407
    resp = app.get('/manage/', status=200)
408
    resp = resp.click('Import')
409
    resp.form['agendas_json'] = Upload('export.json', json.dumps(groups).encode('utf-8'), 'application/json')
410
    resp = resp.form.submit().follow()
411
    assert '3 categories have been created. No category updated.' in resp.text
412
    assert Category.objects.count() == 3
tests/test_import_export.py
373 373
    assert list(agenda.resources.all()) == [resource]
374 374

  
375 375

  
376
def test_import_export_categorys(app):
376
def test_import_export_categories(app):
377 377
    category = Category.objects.create(label='foo')
378 378
    agenda = Agenda.objects.create(label='Foo Bar', category=category)
379 379
    Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
......
381 381

  
382 382
    import_site(data={}, clean=True)
383 383
    assert Agenda.objects.count() == 0
384
    category.delete()
384
    assert Category.objects.count() == 0
385
    data = json.loads(output)
386
    del data['categories']
385 387

  
386 388
    with pytest.raises(AgendaImportError) as excinfo:
387
        import_site(json.loads(output), overwrite=True)
389
        import_site(data, overwrite=True)
388 390
    assert str(excinfo.value) == 'Missing "foo" category'
389 391

  
390 392
    category = Category.objects.create(label='foobar')
391 393
    with pytest.raises(AgendaImportError) as excinfo:
392
        import_site(json.loads(output), overwrite=True)
394
        import_site(data, overwrite=True)
393 395
    assert str(excinfo.value) == 'Missing "foo" category'
394 396

  
395 397
    category = Category.objects.create(label='foo')
396
    import_site(json.loads(output), overwrite=True)
398
    import_site(data, overwrite=True)
397 399
    agenda = Agenda.objects.get(slug=agenda.slug)
398 400
    assert agenda.category == category
399 401

  
......
931 933
    assert AbsenceReason.objects.get(group=group, label='Baz', slug='baz')
932 934

  
933 935

  
936
def test_import_export_category(app):
937
    output = get_output_of_command('export_site')
938
    payload = json.loads(output)
939
    assert len(payload['categories']) == 0
940

  
941
    category = Category.objects.create(label='Foo bar')
942

  
943
    output = get_output_of_command('export_site')
944
    payload = json.loads(output)
945
    assert len(payload['categories']) == 1
946

  
947
    category.delete()
948
    assert not Category.objects.exists()
949

  
950
    import_site(copy.deepcopy(payload))
951
    assert Category.objects.count() == 1
952
    category = Category.objects.first()
953
    assert category.label == 'Foo bar'
954
    assert category.slug == 'foo-bar'
955

  
956
    # update
957
    update_payload = copy.deepcopy(payload)
958
    update_payload['categories'][0]['label'] = 'Foo bar Updated'
959
    import_site(update_payload)
960
    category.refresh_from_db()
961
    assert category.label == 'Foo bar Updated'
962

  
963
    # insert another category
964
    category.slug = 'foo-bar-updated'
965
    category.save()
966
    import_site(copy.deepcopy(payload))
967
    assert Category.objects.count() == 2
968
    category = Category.objects.latest('pk')
969
    assert category.label == 'Foo bar'
970
    assert category.slug == 'foo-bar'
971

  
972

  
934 973
@mock.patch('chrono.agendas.models.Agenda.is_available_for_simple_management')
935 974
def test_import_export_desk_simple_management(available_mock):
936 975
    agenda = Agenda.objects.create(label='Foo bar', kind='meetings', desk_simple_management=True)
937
-