0001-manager-import-export-categories-57424.patch
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 |
- |