diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index cc8d397d..bbd9de48 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -445,8 +445,15 @@ class Agenda(models.Model): def get_dependencies(self): yield self.view_role yield self.edit_role + yield self.category if self.kind == 'virtual': yield from self.real_agendas.all() + if self.kind == 'meetings': + yield from self.resources.all() + for desk in self.desk_set.all(): + yield from desk.get_dependencies() + if self.kind == 'events': + yield self.events_type def export_json(self): agenda = { @@ -2721,6 +2728,10 @@ class EventsType(models.Model): label = models.CharField(_('Label'), max_length=150) custom_fields = models.JSONField(blank=True, default=list) + application_component_type = 'events_types' + application_label_singular = _('Events type') + application_label_plural = _('Events types') + def __str__(self): return self.label @@ -2752,6 +2763,9 @@ class EventsType(models.Model): custom_fields.append(values) return custom_fields + def get_dependencies(self): + return [] + @classmethod def import_json(cls, data, overwrite=False): data = clean_import_data(cls, data) @@ -3130,6 +3144,9 @@ class Desk(models.Model): def base_slug(self): return slugify(self.label) + def get_dependencies(self): + yield from self.unavailability_calendars.all() + @classmethod def import_json(cls, data): timeperiods = data.pop('timeperiods', []) @@ -3274,6 +3291,10 @@ class Resource(models.Model): label = models.CharField(_('Label'), max_length=150) description = models.TextField(_('Description'), blank=True, help_text=_('Optional description.')) + application_component_type = 'resources' + application_label_singular = _('Resource') + application_label_plural = _('Resources') + def __str__(self): return self.label @@ -3295,6 +3316,9 @@ class Resource(models.Model): group_ids = [x.id for x in user.groups.all()] return self.agenda_set.filter(edit_role_id__in=group_ids).exists() + def get_dependencies(self): + return [] + @classmethod def import_json(cls, data, overwrite=False): data = clean_import_data(cls, data) @@ -3314,6 +3338,10 @@ class Category(models.Model): slug = models.SlugField(_('Identifier'), max_length=160, unique=True) label = models.CharField(_('Label'), max_length=150) + application_component_type = 'agendas_categories' + application_label_singular = _('Category (agendas)') + application_label_plural = _('Categories (agendas)') + def __str__(self): return self.label @@ -3329,6 +3357,9 @@ class Category(models.Model): def base_slug(self): return slugify(self.label) + def get_dependencies(self): + return [] + @classmethod def import_json(cls, data, overwrite=False): data = clean_import_data(cls, data) @@ -3642,6 +3673,10 @@ class UnavailabilityCalendar(models.Model): on_delete=models.SET_NULL, ) + application_component_type = 'unavailability_calendars' + application_label_singular = _('Unavailability calendar') + application_label_plural = _('Unavailability calendars') + class Meta: ordering = ['label'] @@ -3672,6 +3707,10 @@ class UnavailabilityCalendar(models.Model): def get_absolute_url(self): return reverse('chrono-manager-unavailability-calendar-view', kwargs={'pk': self.id}) + def get_dependencies(self): + yield self.view_role + yield self.edit_role + def export_json(self): unavailability_calendar = { 'label': self.label, diff --git a/chrono/apps/export_import/api_views.py b/chrono/apps/export_import/api_views.py index 49f67ab0..ac15716e 100644 --- a/chrono/apps/export_import/api_views.py +++ b/chrono/apps/export_import/api_views.py @@ -26,11 +26,18 @@ from rest_framework import permissions from rest_framework.generics import GenericAPIView from rest_framework.response import Response -from chrono.agendas.models import Agenda +from chrono.agendas.models import Agenda, Category, EventsType, Resource, UnavailabilityCalendar from chrono.apps.export_import.models import Application, ApplicationElement from chrono.manager.utils import import_site -klasses = {klass.application_component_type: klass for klass in [Agenda]} +klasses = { + klass.application_component_type: klass + for klass in [Agenda, Category, EventsType, Resource, UnavailabilityCalendar] +} +klasses_translation = { + 'agendas_categories': 'categories', +} +klasses_translation_reverse = {v: k for k, v in klasses_translation.items()} class Index(GenericAPIView): @@ -39,21 +46,22 @@ class Index(GenericAPIView): def get(self, request, *args, **kwargs): data = [] for klass in klasses.values(): - data.append( - { - 'id': klass.application_component_type, - 'text': klass.application_label_plural, - 'singular': klass.application_label_singular, - 'urls': { - 'list': request.build_absolute_uri( - reverse( - 'api-export-import-components-list', - kwargs={'component_type': klass.application_component_type}, - ) - ), - }, + component_type = { + 'id': klass.application_component_type, + 'text': klass.application_label_plural, + 'singular': klass.application_label_singular, + 'urls': { + 'list': request.build_absolute_uri( + reverse( + 'api-export-import-components-list', + kwargs={'component_type': klass.application_component_type}, + ) + ), }, - ) + } + if klass not in [Agenda]: + component_type['minor'] = True + data.append(component_type) return Response({'data': data}) @@ -180,14 +188,16 @@ class BundleImport(GenericAPIView): ) for element in manifest.get('elements'): - if element['type'] not in klasses or element['type'] == 'roles': + component_type = element['type'] + if component_type not in klasses or element['type'] == 'roles': continue - if element['type'] not in components: - components[element['type']] = [] + component_type = klasses_translation.get(component_type, component_type) + if component_type not in components: + components[component_type] = [] component_content = ( tar.extractfile('%s/%s' % (element['type'], element['slug'])).read().decode() ) - components[element['type']].append(json.loads(component_content).get('data')) + components[component_type].append(json.loads(component_content).get('data')) # init cache of application elements, from manifest self.application_elements = set() # import agendas @@ -204,6 +214,7 @@ class BundleImport(GenericAPIView): def link_objects(self, components): for component_type, component_list in components.items(): + component_type = klasses_translation_reverse.get(component_type, component_type) klass = klasses[component_type] for component in component_list: try: diff --git a/chrono/manager/utils.py b/chrono/manager/utils.py index 987e1f56..6e58bcb2 100644 --- a/chrono/manager/utils.py +++ b/chrono/manager/utils.py @@ -106,6 +106,10 @@ def import_site(data, if_empty=False, clean=False, overwrite=False): existing_roles_names = set(existing_roles.values_list('name', flat=True)) raise AgendaImportError('Missing roles: "%s"' % ', '.join(role_names - existing_roles_names)) + # sort agendas to import virtual agendas first + if data.get('agendas'): + data['agendas'] = sorted(data['agendas'], key=lambda a: a['kind'] == 'virtual') + with transaction.atomic(): for cls, key in ( (Category, 'categories'), diff --git a/tests/api/test_applification_api.py b/tests/api/test_applification_api.py index 16532c2d..3e7e0ce2 100644 --- a/tests/api/test_applification_api.py +++ b/tests/api/test_applification_api.py @@ -7,7 +7,7 @@ import pytest from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType -from chrono.agendas.models import Agenda +from chrono.agendas.models import Agenda, Category, Desk, EventsType, Resource, UnavailabilityCalendar from chrono.apps.export_import.models import Application, ApplicationElement pytestmark = pytest.mark.django_db @@ -23,14 +23,41 @@ def test_object_types(app, user): 'text': 'Agendas', 'singular': 'Agenda', 'urls': {'list': 'http://testserver/api/export-import/agendas/'}, - } + }, + { + 'id': 'agendas_categories', + 'minor': True, + 'singular': 'Category (agendas)', + 'text': 'Categories (agendas)', + 'urls': {'list': 'http://testserver/api/export-import/agendas_categories/'}, + }, + { + 'id': 'events_types', + 'minor': True, + 'singular': 'Type d’évènements', + 'text': 'Types d’évènements', + 'urls': {'list': 'http://testserver/api/export-import/events_types/'}, + }, + { + 'id': 'resources', + 'minor': True, + 'singular': 'Ressource', + 'text': 'Ressources', + 'urls': {'list': 'http://testserver/api/export-import/resources/'}, + }, + { + 'id': 'unavailability_calendars', + 'minor': True, + 'singular': 'Unavailability calendar', + 'text': 'Calendrier d’indisponibilités', + 'urls': {'list': 'http://testserver/api/export-import/unavailability_calendars/'}, + }, ] } def test_list_agendas(app, user): app.authorization = ('Basic', ('john.doe', 'password')) - Agenda.objects.all().delete() Agenda.objects.create(label='Rdv', slug='rdv', kind='meetings') Agenda.objects.create(label='Event', slug='event', kind='events') resp = app.get('/api/export-import/agendas/') @@ -64,18 +91,100 @@ def test_export_agenda(app, user): app.authorization = ('Basic', ('john.doe', 'password')) group1 = Group.objects.create(name='group1') group2 = Group.objects.create(name='group2') - Agenda.objects.all().delete() Agenda.objects.create(label='Rdv', slug='rdv', kind='meetings', edit_role=group1, view_role=group2) resp = app.get('/api/export-import/agendas/rdv/') assert resp.json['data']['label'] == 'Rdv' assert resp.json['data']['permissions'] == {'view': 'group2', 'edit': 'group1'} +def test_export_minor_components(app, user): + app.authorization = ('Basic', ('john.doe', 'password')) + Category.objects.create(slug='cat', label='Category') + Resource.objects.create(slug='foo', label='Foo') + EventsType.objects.create(slug='foo', label='Foo') + UnavailabilityCalendar.objects.create(slug='foo', label='Foo') + + resp = app.get('/api/export-import/agendas_categories/cat/') + assert resp.json['data']['label'] == 'Category' + resp = app.get('/api/export-import/resources/foo/') + assert resp.json['data']['label'] == 'Foo' + resp = app.get('/api/export-import/events_types/foo/') + assert resp.json['data']['label'] == 'Foo' + resp = app.get('/api/export-import/unavailability_calendars/foo/') + assert resp.json['data']['label'] == 'Foo' + + +def test_agenda_dependencies_category(app, user): + app.authorization = ('Basic', ('john.doe', 'password')) + category = Category.objects.create(slug='cat', label='Category') + Agenda.objects.create(label='Rdv', slug='rdv', kind='meetings', category=category) + resp = app.get('/api/export-import/agendas/rdv/dependencies/') + assert resp.json == { + 'data': [ + { + 'id': 'cat', + 'text': 'Category', + 'type': 'agendas_categories', + 'urls': { + 'dependencies': 'http://testserver/api/export-import/agendas_categories/cat/dependencies/', + 'export': 'http://testserver/api/export-import/agendas_categories/cat/', + 'redirect': 'http://testserver/api/export-import/agendas_categories/cat/redirect/', + }, + } + ], + 'err': 0, + } + + +def test_agenda_dependencies_resources(app, user): + app.authorization = ('Basic', ('john.doe', 'password')) + meetings_agenda = Agenda.objects.create(label='Rdv', slug='rdv', kind='meetings') + meetings_agenda.resources.add(Resource.objects.create(slug='foo', label='Foo')) + resp = app.get('/api/export-import/agendas/rdv/dependencies/') + assert resp.json == { + 'data': [ + { + 'id': 'foo', + 'text': 'Foo', + 'type': 'resources', + 'urls': { + 'dependencies': 'http://testserver/api/export-import/resources/foo/dependencies/', + 'export': 'http://testserver/api/export-import/resources/foo/', + 'redirect': 'http://testserver/api/export-import/resources/foo/redirect/', + }, + } + ], + 'err': 0, + } + + +def test_agenda_dependencies_unavailability_calendars(app, user): + app.authorization = ('Basic', ('john.doe', 'password')) + meetings_agenda = Agenda.objects.create(label='Rdv', slug='rdv', kind='meetings') + desk = Desk.objects.create(slug='foo', label='Foo', agenda=meetings_agenda) + desk.unavailability_calendars.add(UnavailabilityCalendar.objects.create(slug='foo', label='Foo')) + resp = app.get('/api/export-import/agendas/rdv/dependencies/') + assert resp.json == { + 'data': [ + { + 'id': 'foo', + 'text': 'Foo', + 'type': 'unavailability_calendars', + 'urls': { + 'dependencies': 'http://testserver/api/export-import/unavailability_calendars/foo/dependencies/', + 'export': 'http://testserver/api/export-import/unavailability_calendars/foo/', + 'redirect': 'http://testserver/api/export-import/unavailability_calendars/foo/redirect/', + }, + } + ], + 'err': 0, + } + + def test_agenda_dependencies_groups(app, user): app.authorization = ('Basic', ('john.doe', 'password')) group1 = Group.objects.create(name='group1') group2 = Group.objects.create(name='group2') - Agenda.objects.all().delete() Agenda.objects.create(label='Rdv', slug='rdv', kind='meetings', edit_role=group1, view_role=group2) resp = app.get('/api/export-import/agendas/rdv/dependencies/') # note: with hobo.agent.common installed, 'groups' will contain group slugs, @@ -91,7 +200,6 @@ def test_agenda_dependencies_groups(app, user): def test_agenda_dependencies_virtual_agendas(app, user): app.authorization = ('Basic', ('john.doe', 'password')) - Agenda.objects.all().delete() rdv1 = Agenda.objects.create(label='Rdv1', slug='rdv1', kind='meetings') rdv2 = Agenda.objects.create(label='Rdv2', slug='rdv2', kind='meetings') virt = Agenda.objects.create(label='Virt', slug='virt', kind='virtual') @@ -125,6 +233,28 @@ def test_agenda_dependencies_virtual_agendas(app, user): } +def test_agenda_dependencies_events_type(app, user): + app.authorization = ('Basic', ('john.doe', 'password')) + events_type = EventsType.objects.create(slug='foo', label='Foo') + Agenda.objects.create(label='Evt', slug='evt', kind='events', events_type=events_type) + resp = app.get('/api/export-import/agendas/evt/dependencies/') + assert resp.json == { + 'data': [ + { + 'id': 'foo', + 'text': 'Foo', + 'type': 'events_types', + 'urls': { + 'dependencies': 'http://testserver/api/export-import/events_types/foo/dependencies/', + 'export': 'http://testserver/api/export-import/events_types/foo/', + 'redirect': 'http://testserver/api/export-import/events_types/foo/redirect/', + }, + } + ], + 'err': 0, + } + + def test_agenda_redirect(app, user): app.authorization = ('Basic', ('john.doe', 'password')) agenda = Agenda.objects.create(label='Rdv', slug='rdv', kind='meetings') @@ -135,11 +265,39 @@ def test_agenda_redirect(app, user): def create_bundle(app, user, visible=True, version_number='42.0'): app.authorization = ('Basic', ('john.doe', 'password')) + group, _ = Group.objects.get_or_create(name='plop') - Agenda.objects.get_or_create( - slug='rdv', defaults={'label': 'Rdv', 'kind': 'meetings', 'edit_role': group} + category, _ = Category.objects.get_or_create(slug='foo', label='Foo') + + meetings_agenda, _ = Agenda.objects.get_or_create( + slug='rdv', label='Rdv', kind='meetings', edit_role=group, category=category ) - agenda_export = app.get('/api/export-import/agendas/rdv/').content + resource, _ = Resource.objects.get_or_create(slug='foo', label='Foo') + meetings_agenda.resources.add(resource) + desk, _ = Desk.objects.get_or_create(slug='foo', label='Foo', agenda=meetings_agenda) + unavailability_calendar, _ = UnavailabilityCalendar.objects.get_or_create(slug='foo', label='Foo') + desk.unavailability_calendars.add(unavailability_calendar) + + events_type, _ = EventsType.objects.get_or_create(slug='foo', label='Foo') + events_agenda, _ = Agenda.objects.get_or_create( + label='Evt', slug='evt', kind='events', events_type=events_type + ) + Desk.objects.get_or_create(agenda=events_agenda, slug='_exceptions_holder') + + sub_agenda, _ = Agenda.objects.get_or_create(slug='sub', label='Sub', kind='meetings') + virtual_agenda, _ = Agenda.objects.get_or_create(label='Virt', slug='virt', kind='virtual') + virtual_agenda.real_agendas.add(sub_agenda) + + components = [ + (meetings_agenda, False), + (category, True), + (resource, True), + (unavailability_calendar, True), + (events_agenda, False), + (events_type, True), + (virtual_agenda, False), + (sub_agenda, True), + ] tar_io = io.BytesIO() with tarfile.open(mode='w', fileobj=tar_io) as tar: @@ -152,11 +310,17 @@ def create_bundle(app, user, visible=True, version_number='42.0'): 'visible': visible, 'version_number': version_number, 'version_notes': 'foo bar blah', - 'elements': [ - {'type': 'agendas', 'slug': 'rdv', 'name': 'Test agenda', 'auto-dependency': False}, - {'type': 'form', 'slug': 'xxx', 'name': 'Xxx'}, - ], + 'elements': [], } + for component, auto_dependency in components: + manifest_json['elements'].append( + { + 'type': component.application_component_type, + 'slug': component.slug, + 'name': component.label, + 'auto-dependency': auto_dependency, + } + ) manifest_fd = io.BytesIO(json.dumps(manifest_json, indent=2).encode()) tarinfo = tarfile.TarInfo('manifest.json') tarinfo.size = len(manifest_fd.getvalue()) @@ -169,9 +333,13 @@ def create_bundle(app, user, visible=True, version_number='42.0'): tarinfo.size = len(icon_fd.getvalue()) tar.addfile(tarinfo, fileobj=icon_fd) - tarinfo = tarfile.TarInfo('agendas/rdv') - tarinfo.size = len(agenda_export) - tar.addfile(tarinfo, fileobj=io.BytesIO(agenda_export)) + for component, _ in components: + component_export = app.get( + '/api/export-import/%s/%s/' % (component.application_component_type, component.slug) + ).content + tarinfo = tarfile.TarInfo('%s/%s' % (component.application_component_type, component.slug)) + tarinfo.size = len(component_export) + tar.addfile(tarinfo, fileobj=io.BytesIO(component_export)) bundle = tar_io.getvalue() return bundle @@ -184,13 +352,18 @@ def bundle(app, user): def test_bundle_import(app, user): app.authorization = ('Basic', ('john.doe', 'password')) - Agenda.objects.all().delete() bundles = [] for version_number in ['42.0', '42.1']: bundles.append(create_bundle(app, user, version_number=version_number)) + Agenda.objects.all().delete() + Category.objects.all().delete() + Resource.objects.all().delete() + EventsType.objects.all().delete() + UnavailabilityCalendar.objects.all().delete() + resp = app.put('/api/export-import/bundle-import/', bundles[0]) - assert Agenda.objects.all().count() == 1 + assert Agenda.objects.all().count() == 4 assert resp.json['err'] == 0 assert Application.objects.count() == 1 application = Application.objects.latest('pk') @@ -203,7 +376,7 @@ def test_bundle_import(app, user): assert re.match(r'applications/icons/foo(_\w+)?.png', application.icon.name) assert application.editable is False assert application.visible is True - assert ApplicationElement.objects.count() == 1 + assert ApplicationElement.objects.count() == 8 # check editable flag is kept on install application.editable = True @@ -219,12 +392,12 @@ def test_bundle_import(app, user): # check update resp = app.put('/api/export-import/bundle-import/', bundles[1]) - assert Agenda.objects.all().count() == 1 + assert Agenda.objects.all().count() == 4 assert resp.json['err'] == 0 assert Application.objects.count() == 1 application = Application.objects.latest('pk') assert application.editable is False - assert ApplicationElement.objects.count() == 1 + assert ApplicationElement.objects.count() == 8 assert ( ApplicationElement.objects.filter( application=application, @@ -240,7 +413,7 @@ def test_bundle_declare(app, user): bundle = create_bundle(app, user, visible=False) resp = app.put('/api/export-import/bundle-declare/', bundle) - assert Agenda.objects.all().count() == 1 + assert Agenda.objects.all().count() == 4 assert resp.json['err'] == 0 assert Application.objects.count() == 1 application = Application.objects.latest('pk') @@ -253,7 +426,7 @@ def test_bundle_declare(app, user): assert re.match(r'applications/icons/foo(_\w+)?.png', application.icon.name) assert application.editable is True assert application.visible is False - assert ApplicationElement.objects.count() == 1 + assert ApplicationElement.objects.count() == 8 bundle = create_bundle(app, user, visible=True) # create link to element not present in manifest: it should be unlinked @@ -263,14 +436,14 @@ def test_bundle_declare(app, user): content_type=ContentType.objects.get_for_model(Agenda), object_id=last_page.pk + 1, ) - # and remove a page to have an unkown reference in manifest + # and remove agendas to have unkown references in manifest Agenda.objects.all().delete() resp = app.put('/api/export-import/bundle-declare/', bundle) assert Application.objects.count() == 1 application = Application.objects.latest('pk') assert application.visible is True - assert ApplicationElement.objects.count() == 0 + assert ApplicationElement.objects.count() == 4 # category, events_type, unavailability_calendar, resource def test_bundle_unlink(app, user, bundle):