From c0aa7aec03fd4c7de58aa2f079eba4eeadfbfb80 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Mon, 4 Oct 2021 10:05:40 +0200
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%)

diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py
index 25bb60c..439d54d 100644
--- a/chrono/agendas/models.py
+++ b/chrono/agendas/models.py
@@ -2105,6 +2105,19 @@ class Category(models.Model):
     def base_slug(self):
         return slugify(self.label)
 
+    @classmethod
+    def import_json(cls, data, overwrite=False):
+        data = clean_import_data(cls, data)
+        slug = data.pop('slug')
+        category, created = cls.objects.update_or_create(slug=slug, defaults=data)
+        return created, category
+
+    def export_json(self):
+        return {
+            'label': self.label,
+            'slug': self.slug,
+        }
+
 
 def ics_directory_path(instance, filename):
     return f'ics/{str(uuid.uuid4())}/{filename}'
diff --git a/chrono/manager/forms.py b/chrono/manager/forms.py
index f5b7afc..4231d6b 100644
--- a/chrono/manager/forms.py
+++ b/chrono/manager/forms.py
@@ -918,3 +918,4 @@ class AgendasExportForm(forms.Form):
         label=_('Unavailability calendars'), required=False, initial=True
     )
     absence_reason_groups = forms.BooleanField(label=_('Absence reason groups'), required=False, initial=True)
+    categories = forms.BooleanField(label=_('Categories'), required=False, initial=True)
diff --git a/chrono/manager/templates/chrono/manager_category_list.html b/chrono/manager/templates/chrono/manager_category_list.html
index be18828..2c0539a 100644
--- a/chrono/manager/templates/chrono/manager_category_list.html
+++ b/chrono/manager/templates/chrono/manager_category_list.html
@@ -9,6 +9,7 @@
 {% block appbar %}
 <h2>{% trans 'Categories' %}</h2>
 <span class="actions">
+<a href="{% url 'chrono-manager-category-export' %}">{% trans 'Export' %}</a>
 <a rel="popup" href="{% url 'chrono-manager-category-add' %}">{% trans 'New' %}</a>
 </span>
 {% endblock %}
diff --git a/chrono/manager/urls.py b/chrono/manager/urls.py
index 83cbb7d..a812c7f 100644
--- a/chrono/manager/urls.py
+++ b/chrono/manager/urls.py
@@ -76,6 +76,11 @@ urlpatterns = [
     url(r'^resource/(?P<pk>\d+)/edit/$', views.resource_edit, name='chrono-manager-resource-edit'),
     url(r'^resource/(?P<pk>\d+)/delete/$', views.resource_delete, name='chrono-manager-resource-delete'),
     url(r'^categories/$', views.category_list, name='chrono-manager-category-list'),
+    url(
+        r'^categories/export$',
+        views.category_export,
+        name='chrono-manager-category-export',
+    ),
     url(r'^category/add/$', views.category_add, name='chrono-manager-category-add'),
     url(r'^category/(?P<pk>\d+)/edit/$', views.category_edit, name='chrono-manager-category-edit'),
     url(r'^category/(?P<pk>\d+)/delete/$', views.category_delete, name='chrono-manager-category-delete'),
diff --git a/chrono/manager/utils.py b/chrono/manager/utils.py
index 005ae66..c327cab 100644
--- a/chrono/manager/utils.py
+++ b/chrono/manager/utils.py
@@ -21,12 +21,20 @@ from django.contrib.auth.models import Group
 from django.db import transaction
 from django.db.models import Q
 
-from chrono.agendas.models import AbsenceReasonGroup, Agenda, AgendaImportError, UnavailabilityCalendar
+from chrono.agendas.models import (
+    AbsenceReasonGroup,
+    Agenda,
+    AgendaImportError,
+    Category,
+    UnavailabilityCalendar,
+)
 
 
-def export_site(agendas=True, unavailability_calendars=True, absence_reason_groups=True):
+def export_site(agendas=True, unavailability_calendars=True, absence_reason_groups=True, categories=True):
     '''Dump site objects to JSON-dumpable dictionnary'''
     data = collections.OrderedDict()
+    if categories:
+        data['categories'] = [x.export_json() for x in Category.objects.all()]
     if absence_reason_groups:
         data['absence_reason_groups'] = [x.export_json() for x in AbsenceReasonGroup.objects.all()]
     if unavailability_calendars:
@@ -43,6 +51,7 @@ def import_site(data, if_empty=False, clean=False, overwrite=False):
         Agenda.objects.exists()
         or UnavailabilityCalendar.objects.exists()
         or AbsenceReasonGroup.objects.exists()
+        or Category.objects.exists()
     ):
         return
 
@@ -50,18 +59,16 @@ def import_site(data, if_empty=False, clean=False, overwrite=False):
         Agenda.objects.all().delete()
         UnavailabilityCalendar.objects.all().delete()
         AbsenceReasonGroup.objects.all().delete()
+        Category.objects.all().delete()
 
     results = {
-        'agendas': collections.defaultdict(list),
-        'unavailability_calendars': collections.defaultdict(list),
-        'absence_reason_groups': collections.defaultdict(list),
+        key: collections.defaultdict(list)
+        for key in ['agendas', 'unavailability_calendars', 'absence_reason_groups', 'categories']
     }
-    agendas = data.get('agendas', [])
-    unavailability_calendars = data.get('unavailability_calendars', [])
-    absence_reason_groups = data.get('absence_reason_groups', [])
 
     role_names = set()
-    for objs in (agendas, unavailability_calendars):
+    for key in ['agendas', 'unavailability_calendars']:
+        objs = data.get(key, [])
         role_names = role_names.union(
             {name for data in objs for _, name in data.get('permissions', {}).items() if name}
         )
@@ -72,16 +79,18 @@ def import_site(data, if_empty=False, clean=False, overwrite=False):
         raise AgendaImportError('Missing roles: "%s"' % ', '.join(role_names - existing_roles_names))
 
     with transaction.atomic():
-        for objs, cls, label in (
-            (absence_reason_groups, AbsenceReasonGroup, 'absence_reason_groups'),
-            (unavailability_calendars, UnavailabilityCalendar, 'unavailability_calendars'),
-            (agendas, Agenda, 'agendas'),
+        for cls, key in (
+            (Category, 'categories'),
+            (AbsenceReasonGroup, 'absence_reason_groups'),
+            (UnavailabilityCalendar, 'unavailability_calendars'),
+            (Agenda, 'agendas'),
         ):
-            for data in objs:
-                created, obj = cls.import_json(data, overwrite=overwrite)
-                results[label]['all'].append(obj)
+            objs = data.get(key, [])
+            for obj in objs:
+                created, obj = cls.import_json(obj, overwrite=overwrite)
+                results[key]['all'].append(obj)
                 if created:
-                    results[label]['created'].append(obj)
+                    results[key]['created'].append(obj)
                 else:
-                    results[label]['updated'].append(obj)
+                    results[key]['updated'].append(obj)
     return results
diff --git a/chrono/manager/views.py b/chrono/manager/views.py
index b1906da..1b1256b 100644
--- a/chrono/manager/views.py
+++ b/chrono/manager/views.py
@@ -559,6 +559,26 @@ class CategoryListView(ListView):
 category_list = CategoryListView.as_view()
 
 
+class CategoryExport(ListView):
+    model = Category
+
+    def dispatch(self, request, *args, **kwargs):
+        if not request.user.is_staff:
+            raise PermissionDenied()
+        return super().dispatch(request, *args, **kwargs)
+
+    def get(self, request, *args, **kwargs):
+        response = HttpResponse(content_type='application/json')
+        today = datetime.date.today()
+        attachment = 'attachment; filename="export_categories_{}.json"'.format(today.strftime('%Y%m%d'))
+        response['Content-Disposition'] = attachment
+        json.dump({'categories': [obj.export_json() for obj in self.get_queryset()]}, response, indent=2)
+        return response
+
+
+category_export = CategoryExport.as_view()
+
+
 class CategoryAddView(CreateView):
     template_name = 'chrono/manager_category_form.html'
     model = Category
@@ -856,6 +876,20 @@ class AgendasImportView(FormView):
                     x,
                 ),
             },
+            'categories': {
+                'create_noop': _('No category created.'),
+                'create': lambda x: ungettext(
+                    'A category has been created.',
+                    '%(count)d categories have been created.',
+                    x,
+                ),
+                'update_noop': _('No category updated.'),
+                'update': lambda x: ungettext(
+                    'A category has been updated.',
+                    '%(count)d categories have been updated.',
+                    x,
+                ),
+            },
         }
 
         global_noop = True
@@ -876,17 +910,18 @@ class AgendasImportView(FormView):
 
                 obj_results['messages'] = "%s %s" % (message1, message2)
 
-        a_count, uc_count, arg_count = (
+        a_count, uc_count, arg_count, c_count = (
             len(results['agendas']['all']),
             len(results['unavailability_calendars']['all']),
             len(results['absence_reason_groups']['all']),
+            len(results['categories']['all']),
         )
-        if (a_count, uc_count, arg_count) == (1, 0, 0):
+        if (a_count, uc_count, arg_count, c_count) == (1, 0, 0, 0):
             # only one agenda imported, redirect to settings page
             return HttpResponseRedirect(
                 reverse('chrono-manager-agenda-settings', kwargs={'pk': results['agendas']['all'][0].pk})
             )
-        if (a_count, uc_count, arg_count) == (0, 1, 0):
+        if (a_count, uc_count, arg_count, c_count) == (0, 1, 0, 0):
             # only one unavailability calendar imported, redirect to settings page
             return HttpResponseRedirect(
                 reverse(
@@ -894,9 +929,12 @@ class AgendasImportView(FormView):
                     kwargs={'pk': results['unavailability_calendars']['all'][0].pk},
                 )
             )
-        if (a_count, uc_count, arg_count) == (0, 0, 1):
+        if (a_count, uc_count, arg_count, c_count) == (0, 0, 1, 0):
             # only one absence reason group imported, redirect to group page
             return HttpResponseRedirect(reverse('chrono-manager-absence-reason-list'))
+        if (a_count, uc_count, arg_count, c_count) == (0, 0, 0, 1):
+            # only one category imported, redirect to categories page
+            return HttpResponseRedirect(reverse('chrono-manager-category-list'))
 
         if global_noop:
             messages.info(self.request, _('No data found.'))
@@ -904,6 +942,7 @@ class AgendasImportView(FormView):
             messages.info(self.request, results['agendas']['messages'])
             messages.info(self.request, results['unavailability_calendars']['messages'])
             messages.info(self.request, results['absence_reason_groups']['messages'])
+            messages.info(self.request, results['categories']['messages'])
 
         return super().form_valid(form)
 
diff --git a/tests/manager/test_all.py b/tests/manager/test_all.py
index 3fd52cc..5d14e90 100644
--- a/tests/manager/test_all.py
+++ b/tests/manager/test_all.py
@@ -1,5 +1,4 @@
 import datetime
-import json
 from unittest import mock
 
 import freezegun
@@ -2914,41 +2913,6 @@ def test_manager_reminders_preview(app, admin_user):
     )
 
 
-def test_export_site(app, admin_user):
-    login(app)
-    resp = app.get('/manage/')
-    resp = resp.click('Export')
-
-    with freezegun.freeze_time('2020-06-15'):
-        resp = resp.form.submit()
-    assert resp.headers['content-type'] == 'application/json'
-    assert resp.headers['content-disposition'] == 'attachment; filename="export_agendas_20200615.json"'
-
-    site_json = json.loads(resp.text)
-    assert site_json == {'unavailability_calendars': [], 'agendas': [], 'absence_reason_groups': []}
-
-    agenda = Agenda.objects.create(label='Foo Bar', kind='events')
-    Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
-    UnavailabilityCalendar.objects.create(label='Calendar 1')
-    resp = app.get('/manage/agendas/export/')
-    resp = resp.form.submit()
-
-    site_json = json.loads(resp.text)
-    assert len(site_json['agendas']) == 1
-    assert len(site_json['unavailability_calendars']) == 1
-    assert len(site_json['absence_reason_groups']) == 0
-
-    resp = app.get('/manage/agendas/export/')
-    resp.form['agendas'] = False
-    resp.form['absence_reason_groups'] = False
-    resp = resp.form.submit()
-
-    site_json = json.loads(resp.text)
-    assert 'agendas' not in site_json
-    assert 'unavailability_calendars' in site_json
-    assert 'absence_reason_groups' not in site_json
-
-
 def test_manager_agenda_roles(app, admin_user, manager_user):
     agenda = Agenda.objects.create(label='Events', kind='events')
     Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
diff --git a/tests/manager/test_import.py b/tests/manager/test_import_export.py
similarity index 76%
rename from tests/manager/test_import.py
rename to tests/manager/test_import_export.py
index 768a13b..605523c 100644
--- a/tests/manager/test_import.py
+++ b/tests/manager/test_import_export.py
@@ -12,6 +12,7 @@ from chrono.agendas.models import (
     AbsenceReasonGroup,
     Agenda,
     Booking,
+    Category,
     Desk,
     Event,
     MeetingType,
@@ -23,6 +24,49 @@ from .test_all import login
 pytestmark = pytest.mark.django_db
 
 
+def test_export_site(app, admin_user):
+    login(app)
+    resp = app.get('/manage/')
+    resp = resp.click('Export')
+
+    with freezegun.freeze_time('2020-06-15'):
+        resp = resp.form.submit()
+    assert resp.headers['content-type'] == 'application/json'
+    assert resp.headers['content-disposition'] == 'attachment; filename="export_agendas_20200615.json"'
+
+    site_json = json.loads(resp.text)
+    assert site_json == {
+        'unavailability_calendars': [],
+        'agendas': [],
+        'absence_reason_groups': [],
+        'categories': [],
+    }
+
+    agenda = Agenda.objects.create(label='Foo Bar', kind='events')
+    Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
+    UnavailabilityCalendar.objects.create(label='Calendar 1')
+    resp = app.get('/manage/agendas/export/')
+    resp = resp.form.submit()
+
+    site_json = json.loads(resp.text)
+    assert len(site_json['agendas']) == 1
+    assert len(site_json['unavailability_calendars']) == 1
+    assert len(site_json['absence_reason_groups']) == 0
+    assert len(site_json['categories']) == 0
+
+    resp = app.get('/manage/agendas/export/')
+    resp.form['agendas'] = False
+    resp.form['absence_reason_groups'] = False
+    resp.form['categories'] = False
+    resp = resp.form.submit()
+
+    site_json = json.loads(resp.text)
+    assert 'agendas' not in site_json
+    assert 'unavailability_calendars' in site_json
+    assert 'absence_reason_groups' not in site_json
+    assert 'categories' not in site_json
+
+
 def test_import_agenda_as_manager(app, manager_user):
     # open /manage/ access to manager_user, and check agenda import is not
     # allowed.
@@ -308,3 +352,61 @@ def test_import_absence_reason_group(app, admin_user):
     assert '3 absence reason groups have been created. No absence reason group updated.' in resp.text
     assert AbsenceReasonGroup.objects.count() == 3
     assert AbsenceReason.objects.count() == 6
+
+
+@pytest.mark.freeze_time('2021-07-08')
+def test_import_category(app, admin_user):
+    Category.objects.create(label='Foo bar')
+
+    app = login(app)
+    resp = app.get('/manage/categories/export')
+    assert resp.headers['content-type'] == 'application/json'
+    assert resp.headers['content-disposition'] == 'attachment; filename="export_categories_20210708.json"'
+    group_export = resp.text
+
+    # existing category
+    resp = app.get('/manage/', status=200)
+    resp = resp.click('Import')
+    resp.form['agendas_json'] = Upload('export.json', group_export.encode('utf-8'), 'application/json')
+    resp = resp.form.submit()
+    assert resp.location.endswith('/manage/categories/')
+    resp = resp.follow()
+    assert 'No category created. A category has been updated.' not in resp.text
+    assert Category.objects.count() == 1
+
+    # new category
+    Category.objects.all().delete()
+    resp = app.get('/manage/', status=200)
+    resp = resp.click('Import')
+    resp.form['agendas_json'] = Upload('export.json', group_export.encode('utf-8'), 'application/json')
+    resp = resp.form.submit()
+    assert resp.location.endswith('/manage/categories/')
+    resp = resp.follow()
+    assert 'A category has been created. No category updated.' not in resp.text
+    assert Category.objects.count() == 1
+
+    # multiple categories
+    groups = json.loads(group_export)
+    groups['categories'].append(copy.copy(groups['categories'][0]))
+    groups['categories'].append(copy.copy(groups['categories'][0]))
+    groups['categories'][1]['label'] = 'Foo bar 2'
+    groups['categories'][1]['slug'] = 'foo-bar-2'
+    groups['categories'][2]['label'] = 'Foo bar 3'
+    groups['categories'][2]['slug'] = 'foo-bar-3'
+
+    resp = app.get('/manage/', status=200)
+    resp = resp.click('Import')
+    resp.form['agendas_json'] = Upload('export.json', json.dumps(groups).encode('utf-8'), 'application/json')
+    resp = resp.form.submit()
+    assert resp.location.endswith('/manage/')
+    resp = resp.follow()
+    assert '2 categories have been created. A category has been updated.' in resp.text
+    assert Category.objects.count() == 3
+
+    Category.objects.all().delete()
+    resp = app.get('/manage/', status=200)
+    resp = resp.click('Import')
+    resp.form['agendas_json'] = Upload('export.json', json.dumps(groups).encode('utf-8'), 'application/json')
+    resp = resp.form.submit().follow()
+    assert '3 categories have been created. No category updated.' in resp.text
+    assert Category.objects.count() == 3
diff --git a/tests/test_import_export.py b/tests/test_import_export.py
index a6d3d5e..4fb8900 100644
--- a/tests/test_import_export.py
+++ b/tests/test_import_export.py
@@ -373,7 +373,7 @@ def test_import_export_resources(app):
     assert list(agenda.resources.all()) == [resource]
 
 
-def test_import_export_categorys(app):
+def test_import_export_categories(app):
     category = Category.objects.create(label='foo')
     agenda = Agenda.objects.create(label='Foo Bar', category=category)
     Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
@@ -381,19 +381,21 @@ def test_import_export_categorys(app):
 
     import_site(data={}, clean=True)
     assert Agenda.objects.count() == 0
-    category.delete()
+    assert Category.objects.count() == 0
+    data = json.loads(output)
+    del data['categories']
 
     with pytest.raises(AgendaImportError) as excinfo:
-        import_site(json.loads(output), overwrite=True)
+        import_site(data, overwrite=True)
     assert str(excinfo.value) == 'Missing "foo" category'
 
     category = Category.objects.create(label='foobar')
     with pytest.raises(AgendaImportError) as excinfo:
-        import_site(json.loads(output), overwrite=True)
+        import_site(data, overwrite=True)
     assert str(excinfo.value) == 'Missing "foo" category'
 
     category = Category.objects.create(label='foo')
-    import_site(json.loads(output), overwrite=True)
+    import_site(data, overwrite=True)
     agenda = Agenda.objects.get(slug=agenda.slug)
     assert agenda.category == category
 
@@ -931,6 +933,43 @@ def test_import_export_absence_reason_group(app):
     assert AbsenceReason.objects.get(group=group, label='Baz', slug='baz')
 
 
+def test_import_export_category(app):
+    output = get_output_of_command('export_site')
+    payload = json.loads(output)
+    assert len(payload['categories']) == 0
+
+    category = Category.objects.create(label='Foo bar')
+
+    output = get_output_of_command('export_site')
+    payload = json.loads(output)
+    assert len(payload['categories']) == 1
+
+    category.delete()
+    assert not Category.objects.exists()
+
+    import_site(copy.deepcopy(payload))
+    assert Category.objects.count() == 1
+    category = Category.objects.first()
+    assert category.label == 'Foo bar'
+    assert category.slug == 'foo-bar'
+
+    # update
+    update_payload = copy.deepcopy(payload)
+    update_payload['categories'][0]['label'] = 'Foo bar Updated'
+    import_site(update_payload)
+    category.refresh_from_db()
+    assert category.label == 'Foo bar Updated'
+
+    # insert another category
+    category.slug = 'foo-bar-updated'
+    category.save()
+    import_site(copy.deepcopy(payload))
+    assert Category.objects.count() == 2
+    category = Category.objects.latest('pk')
+    assert category.label == 'Foo bar'
+    assert category.slug == 'foo-bar'
+
+
 @mock.patch('chrono.agendas.models.Agenda.is_available_for_simple_management')
 def test_import_export_desk_simple_management(available_mock):
     agenda = Agenda.objects.create(label='Foo bar', kind='meetings', desk_simple_management=True)
-- 
2.33.0

