API pour l'applification (#60773) #119

Merged
fpeters merged 1 commits from wip/60773-applification into main 2023-10-03 13:40:23 +02:00
8 changed files with 591 additions and 0 deletions

View File

View File

@ -0,0 +1,165 @@
# combo - content management system
# Copyright (C) 2017-2023 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import io
import json
import tarfile
from django.contrib.auth.models import Group
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from rest_framework import permissions
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from combo.data.models import Page
from combo.data.utils import import_site
from combo.utils.misc import is_portal_agent
class Index(GenericAPIView):
permission_classes = (permissions.IsAuthenticated,)
def get(self, request, *args, **kwargs):
if is_portal_agent():
response = [
{
'id': 'portal-agent-pages',
'text': _('Pages (agent portal)'),
'singular': _('Page (agent portal)'),
},
]
else:
response = [
{'id': 'pages', 'text': _('Pages'), 'singular': _('Page')},
]
response[0]['urls'] = {
'list': request.build_absolute_uri(reverse('api-export-import-pages-list')),
}
return Response({'data': response})
index = Index.as_view()
def get_page_bundle_entry(request, page, order):
return {
'id': str(page.uuid),
'text': page.title,
'indent': getattr(page, 'level', 0),
'type': 'portal-agent-pages' if is_portal_agent() else 'pages',

On distingue deux types de pages pour les applications, selon qu'on est le portail agent ou pas. (et ça devra être la même affaire quand on exportera davantage d'objets type les couches carto).

Ça permet de rester dans le modèle hobo actuel, sans devoir ajouter une notion de type de service qui distinguerait à ce niveau les pages du portail agent des pages du portail usager.

On distingue deux types de pages pour les applications, selon qu'on est le portail agent ou pas. (et ça devra être la même affaire quand on exportera davantage d'objets type les couches carto). Ça permet de rester dans le modèle hobo actuel, sans devoir ajouter une notion de type de service qui distinguerait à ce niveau les pages du portail agent des pages du portail usager.
'order': order,
'urls': {
'export': request.build_absolute_uri(
reverse('api-export-import-page-export', kwargs={'uuid': str(page.uuid)})

Plutôt utiliser page.uuid partout, non ?

Plutôt utiliser page.uuid partout, non ?

Oui en effet, mis dans un commit supplémentaire.

Oui en effet, mis dans un commit supplémentaire.
),
'dependencies': request.build_absolute_uri(
reverse('api-export-import-page-dependencies', kwargs={'uuid': str(page.uuid)})
),
'redirect': request.build_absolute_uri(
reverse('api-export-import-page-redirect', kwargs={'uuid': str(page.uuid)})
),
},
}
class ListPages(GenericAPIView):
permission_classes = (permissions.IsAuthenticated,)
def get(self, request, *args, **kwargs):
pages = Page.get_as_reordered_flat_hierarchy(Page.objects.all())
response = [get_page_bundle_entry(request, x, i) for i, x in enumerate(pages)]
return Response({'data': response})
list_pages = ListPages.as_view()
class ExportPage(GenericAPIView):
permission_classes = (permissions.IsAuthenticated,)
def get(self, request, uuid, *args, **kwargs):
serialisation = Page.objects.get(uuid=uuid).get_serialized_page()
return Response({'data': serialisation})
export_page = ExportPage.as_view()
class PageDependencies(GenericAPIView):
permission_classes = (permissions.IsAuthenticated,)
def get(self, request, uuid, *args, **kwargs):
page = Page.objects.get(uuid=uuid)
def dependency_dict(element):
if isinstance(element, Group):
return {
'id': element.role.slug if hasattr(element, 'role') else element.id,
'text': element.name,
'type': 'roles',
'urls': {},
}
elif isinstance(element, Page):
return get_page_bundle_entry(request, element, 0)
return element
dependencies = [dependency_dict(x) for x in page.get_dependencies() if x]
return Response({'err': 0, 'data': dependencies})
page_dependencies = PageDependencies.as_view()
def page_redirect(request, uuid):
page = get_object_or_404(Page, uuid=uuid)
return redirect(reverse('combo-manager-page-view', kwargs={'pk': page.pk}))
class BundleDeclare(GenericAPIView):
permission_classes = (permissions.IsAuthenticated,)
def put(self, request, *args, **kwargs):
return Response({'err': 0})

Je laisse bundle-declare de côté.

Je laisse bundle-declare de côté.
bundle_declare = BundleDeclare.as_view()
class BundleImport(GenericAPIView):
permission_classes = (permissions.IsAuthenticated,)
def put(self, request, *args, **kwargs):
tar_io = io.BytesIO(request.read())
page_type = 'portal-agent-pages' if is_portal_agent() else 'pages'
pages = []
with tarfile.open(fileobj=tar_io) as tar:
manifest = json.loads(tar.extractfile('manifest.json').read().decode())
for element in manifest.get('elements'):
if element.get('type') != page_type:
continue
pages.append(
json.loads(tar.extractfile(f'{page_type}/{element["slug"]}').read().decode()).get('data')
)
if pages:
import_site({'pages': pages})

On prend toutes les pages et on importe tout ça; ça devrait s'étendre facilement pour y ajouter les cartes etc.

On prend toutes les pages et on importe tout ça; ça devrait s'étendre facilement pour y ajouter les cartes etc.
return Response({'err': 0})
bundle_import = BundleImport.as_view()

View File

@ -0,0 +1,28 @@
# combo - content management system
# Copyright (C) 2017-2023 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import django.apps
from django.utils.translation import gettext_lazy as _
class AppConfig(django.apps.AppConfig):
name = 'combo.apps.export_import'
verbose_name = _('Export/Import')
def get_before_urls(self):
from . import urls
return urls.urlpatterns

View File

@ -0,0 +1,37 @@
# combo - content management system
# Copyright (C) 2017-2023 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.urls import path
from . import api_views
urlpatterns = [
path('api/export-import/', api_views.index, name='api-export-import'),
path('api/export-import/pages/', api_views.list_pages, name='api-export-import-pages-list'),
path('api/export-import/pages/<uuid:uuid>/', api_views.export_page, name='api-export-import-page-export'),
path(
'api/export-import/pages/<uuid:uuid>/dependencies/',
api_views.page_dependencies,
name='api-export-import-page-dependencies',
),
path(
'api/export-import/pages/<uuid:uuid>/redirect/',
api_views.page_redirect,
name='api-export-import-page-redirect',
),
path('api/export-import/bundle-declare/', api_views.bundle_declare),
path('api/export-import/bundle-import/', api_views.bundle_import),
]

View File

@ -171,6 +171,18 @@ class WcsFormCell(CellBase):
def render_for_search(self):
return ''
def get_dependencies(self):
yield from super().get_dependencies()
if self.formdef_reference:
wcs_key, form_slug = self.formdef_reference.split(':')
wcs_site_url = get_wcs_services().get(wcs_key)['url']
urls = {
'export': f'{wcs_site_url}api/export-import/forms/{form_slug}/',
'dependencies': f'{wcs_site_url}api/export-import/forms/{form_slug}/dependencies/',

Détection des dépendances inter-applications; si on a une cellule démarche on a la démarche en dépendance, et pareil pour les catégories de démarches, et les modèles de fiches.

Détection des dépendances inter-applications; si on a une cellule démarche on a la démarche en dépendance, et pareil pour les catégories de démarches, et les modèles de fiches.
'redirect': f'{wcs_site_url}api/export-import/forms/{form_slug}/redirect/',
}
yield {'type': 'forms', 'id': form_slug, 'text': self.cached_title, 'urls': urls}
def get_external_links_data(self):
if not (self.cached_url and self.cached_title):
return []
@ -278,6 +290,18 @@ class WcsCommonCategoryCell(CellBase):
def get_inspect_keys(self):
return [k for k in super().get_inspect_keys() if not k.startswith('cached_')]
def get_dependencies(self):
yield from super().get_dependencies()
if self.category_reference:
wcs_key, category_slug = self.category_reference.split(':')
wcs_site_url = get_wcs_services().get(wcs_key)['url']
urls = {
'export': f'{wcs_site_url}api/export-import/forms-categories/{category_slug}/',
'dependencies': f'{wcs_site_url}api/export-import/forms-categories/{category_slug}/dependencies/',
'redirect': f'{wcs_site_url}api/export-import/forms-xategories/{category_slug}/redirect/',
}
yield {'type': 'forms-categories', 'id': category_slug, 'text': self.cached_title, 'urls': urls}
@register_cell_class
class WcsCategoryCell(WcsCommonCategoryCell):
@ -1017,6 +1041,19 @@ class WcsCardCell(CardMixin, CellBase):
return False
return super().is_visible(request, **kwargs)
def get_dependencies(self):
yield from super().get_dependencies()
if self.carddef_reference:
parts = self.carddef_reference.split(':')
wcs_key, card_slug = parts[:2]
wcs_site_url = get_wcs_services().get(wcs_key)['url']
urls = {
'export': f'{wcs_site_url}api/export-import/cards/{card_slug}/',
'dependencies': f'{wcs_site_url}api/export-import/cards/{card_slug}/dependencies/',
'redirect': f'{wcs_site_url}api/export-import/cards/{card_slug}/redirect/',
}

Il va manquer, dans le cas d'un cellule avec custo, qui contient un champ de type lien, la dépendance à la page qui peut être paramétrée en target (mais on peut en faire un ticket)

(je pose ça ici, parce que l'app search n'a pas été modifiée dans cette PR: Il manquera aussi, pour les cellules de recherche, les dépendances à des fiches ou pages etc, mais là aussi on peut en faire un ticket)

Il va manquer, dans le cas d'un cellule avec custo, qui contient un champ de type lien, la dépendance à la page qui peut être paramétrée en target (mais on peut en faire un ticket) (je pose ça ici, parce que l'app search n'a pas été modifiée dans cette PR: Il manquera aussi, pour les cellules de recherche, les dépendances à des fiches ou pages etc, mais là aussi on peut en faire un ticket)

Ça me va bien de passer par des tickets supplémentaires pour affiner/compléter tout ça (évidemment…).

Ça me va bien de passer par des tickets supplémentaires pour affiner/compléter tout ça (évidemment…).
yield {'type': 'cards', 'id': card_slug, 'text': self.cached_title, 'urls': urls}
def check_validity(self):
if self.get_related_card_path():
relations = [r[0] for r in self.get_related_card_paths()]

View File

@ -377,6 +377,14 @@ class Page(models.Model):
def get_descendants_and_me(self):
return self.get_descendants(include_myself=True)
def get_dependencies(self):
yield from self.get_children()
yield self.edit_role
yield self.subpages_edit_role

En dépendances on a aussi les rôles.

En dépendances on a aussi les rôles.
yield from self.groups.all()
for cell in self.get_cells():
yield from cell.get_dependencies()
def get_template_display_name(self):
try:
return settings.COMBO_PUBLIC_TEMPLATES[self.template_name]['name']
@ -1203,6 +1211,9 @@ class CellBase(models.Model, metaclass=CellMeta):
def get_label(self):
return self.get_verbose_name()
def get_dependencies(self):
yield from self.groups.all()
def get_manager_tabs(self):
from combo.manager.forms import CellVisibilityForm
@ -1875,6 +1886,10 @@ class LinkCell(CellBase):
def render_for_search(self):
return ''
def get_dependencies(self):
yield from super().get_dependencies()
yield self.link_page
def get_external_links_data(self):
if not self.url:
return []

View File

@ -68,6 +68,7 @@ INSTALLED_APPS = (
'combo.apps.dashboard',
'combo.apps.wcs',
'combo.apps.publik',
'combo.apps.export_import',
'combo.apps.family',
'combo.apps.dataviz',
'combo.apps.lingo',

View File

@ -0,0 +1,308 @@
import io
import json
import tarfile
from unittest import mock
import pytest
from django.contrib.auth.models import Group
from combo.apps.wcs.models import WcsCardCell, WcsCategoryCell, WcsFormCell
from combo.data.models import LinkCell, LinkListCell, Page, TextCell
from .wcs.utils import mocked_requests_send
pytestmark = pytest.mark.django_db
def test_object_types(settings, app, john_doe):
app.authorization = ('Basic', (john_doe.username, john_doe.username))
resp = app.get('/api/export-import/')
assert resp.json == {
'data': [
{
'id': 'pages',
'text': 'Pages',
'singular': 'Page',
'urls': {'list': 'http://testserver/api/export-import/pages/'},
}
]
}
with mock.patch('combo.apps.export_import.api_views.is_portal_agent') as is_portal_agent:
is_portal_agent.return_value = True
resp = app.get('/api/export-import/')
assert resp.json == {
'data': [
{
'id': 'portal-agent-pages',
'text': 'Pages (agent portal)',
'singular': 'Page (agent portal)',
'urls': {'list': 'http://testserver/api/export-import/pages/'},
}
]
}
def test_list_pages(app, john_doe):
app.authorization = ('Basic', (john_doe.username, john_doe.username))
Page.objects.all().delete()
page = Page(title='Test', slug='test', template_name='standard')
page.save()
page2 = Page(title='Child', slug='child', template_name='standard', parent=page)
page2.save()
resp = app.get('/api/export-import/pages/')
assert resp.json == {
'data': [
{
'id': f'{page.uuid}',
'text': 'Test',
'type': 'pages',
'indent': 0,
'order': 0,
'urls': {
'export': f'http://testserver/api/export-import/pages/{page.uuid}/',
'dependencies': f'http://testserver/api/export-import/pages/{page.uuid}/dependencies/',
'redirect': f'http://testserver/api/export-import/pages/{page.uuid}/redirect/',
},
},
{
'id': f'{page2.uuid}',
'text': 'Child',
'type': 'pages',
'indent': 1,
'order': 1,
'urls': {
'export': f'http://testserver/api/export-import/pages/{page2.uuid}/',
'dependencies': f'http://testserver/api/export-import/pages/{page2.uuid}/dependencies/',
'redirect': f'http://testserver/api/export-import/pages/{page2.uuid}/redirect/',
},
},
]
}
def test_export_page(app, john_doe):
app.authorization = ('Basic', (john_doe.username, john_doe.username))
Page.objects.all().delete()
page = Page(title='Test', slug='test', template_name='standard')
page.save()
resp = app.get(f'/api/export-import/pages/{page.uuid}/')
assert resp.json['data']['fields']['title'] == 'Test'
def test_export_page_with_role(app, john_doe):
app.authorization = ('Basic', (john_doe.username, john_doe.username))
group = Group(name='plop')
group.save()
Page.objects.all().delete()
page = Page(title='Test', slug='test', template_name='standard')
page.save()
page.groups.set([group])
resp = app.get(f'/api/export-import/pages/{page.uuid}/')
assert resp.json['data']['fields']['groups'] == ['plop']
def test_page_dependencies_groups(app, john_doe):
app.authorization = ('Basic', (john_doe.username, john_doe.username))
group1 = Group(name='plop1')
group1.save()
group2 = Group(name='plop2')
group2.save()
Page.objects.all().delete()
page = Page(title='Test', slug='test', template_name='standard')
page.save()
page.groups.set([group1])
cell = TextCell(page=page, placeholder='content', text='Foobar', order=0)
cell.save()
cell.groups.set([group2])
resp = app.get(f'/api/export-import/pages/{page.uuid}/dependencies/')
# note: with hobo.agent.common installed, 'groups' will contain group slugs,
# not group id
assert resp.json == {
'data': [
{'id': group1.id, 'text': group1.name, 'type': 'roles', 'urls': {}},
{'id': group2.id, 'text': group2.name, 'type': 'roles', 'urls': {}},
],
'err': 0,
}
def test_page_dependencies_children(app, john_doe):
app.authorization = ('Basic', (john_doe.username, john_doe.username))
page = Page(title='Test', slug='test', template_name='standard')
page.save()
page2 = Page(title='Child', slug='child', template_name='standard', parent=page)
page2.save()
resp = app.get(f'/api/export-import/pages/{page.uuid}/dependencies/')
assert resp.json == {
'data': [
{
'id': f'{page2.uuid}',
'indent': 0,
'order': 0,
'text': 'Child',
'type': 'pages',
'urls': {
'dependencies': f'http://testserver/api/export-import/pages/{page2.uuid}/dependencies/',
'export': f'http://testserver/api/export-import/pages/{page2.uuid}/',
'redirect': f'http://testserver/api/export-import/pages/{page2.uuid}/redirect/',
},
},
],
'err': 0,
}
def test_page_redirect(app, john_doe):
app.authorization = ('Basic', (john_doe.username, john_doe.username))
page = Page(title='Test', slug='test', template_name='standard')
page.save()
resp = app.get(f'/api/export-import/pages/{page.uuid}/redirect/')
assert resp.location == f'/manage/pages/{page.id}/'
@pytest.fixture
def bundle(app, john_doe):
app.authorization = ('Basic', (john_doe.username, john_doe.username))
group = Group(name='plop')
group.save()
page = Page(title='Test Page', slug='test', template_name='standard')
page.save()
page.groups.set([group])
page_export = app.get(f'/api/export-import/pages/{page.uuid}/').content
tar_io = io.BytesIO()
with tarfile.open(mode='w', fileobj=tar_io) as tar:
manifest_json = {
'application': 'Test',
'slug': 'test',
'icon': 'foo.png',
'description': 'Foo Bar',
'documentation_url': 'http://foo.bar',
'visible': True,
'version_number': '42.0',
'version_notes': 'foo bar blah',
'elements': [
{'type': 'pages', 'slug': f'{page.uuid}', 'name': 'Test Page', 'auto-dependency': False},
{'type': 'form', 'slug': 'xxx', 'name': 'Xxx'},
],
}
manifest_fd = io.BytesIO(json.dumps(manifest_json, indent=2).encode())
tarinfo = tarfile.TarInfo('manifest.json')
tarinfo.size = len(manifest_fd.getvalue())
tar.addfile(tarinfo, fileobj=manifest_fd)
tarinfo = tarfile.TarInfo(f'pages/{page.uuid}')
tarinfo.size = len(page_export)
tar.addfile(tarinfo, fileobj=io.BytesIO(page_export))
bundle = tar_io.getvalue()
Page.objects.all().delete()
return bundle
def test_bundle_import(app, john_doe, bundle):
app.authorization = ('Basic', (john_doe.username, john_doe.username))
resp = app.put('/api/export-import/bundle-import/', bundle)
assert Page.objects.all().count() == 1
assert resp.json['err'] == 0
# check update
resp = app.put('/api/export-import/bundle-import/', bundle)
assert Page.objects.all().count() == 1
assert resp.json['err'] == 0
def test_bundle_declare(app, john_doe, bundle):
app.authorization = ('Basic', (john_doe.username, john_doe.username))
resp = app.put('/api/export-import/bundle-declare/', bundle)
assert Page.objects.all().count() == 0
assert resp.json['err'] == 0
def test_page_dependencies_link_cell():
page1 = Page.objects.create(title='Test', slug='test', template_name='standard')
page2 = Page.objects.create(title='Other page', slug='other', template_name='standard')
LinkCell.objects.create(page=page1, placeholder='content', link_page=page2, order=0)
assert page2 in page1.get_dependencies()
def test_page_dependencies_linkslist_cell():
page1 = Page.objects.create(title='Test', slug='test', template_name='standard')
page2 = Page.objects.create(title='Other page', slug='other', template_name='standard')
links = LinkListCell.objects.create(order=1, page=page1, placeholder='content')
LinkCell.objects.create(page=page1, placeholder=links.link_placeholder, link_page=page2, order=0)
assert page2 in page1.get_dependencies()
@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
def test_page_dependencies_form_cell(mock_send, app, john_doe):
page = Page.objects.create(title='Test', slug='test', template_name='standard')
cell = WcsFormCell(page=page, placeholder='content', order=0, formdef_reference='default:form-title')
cell.save()
assert {
'type': 'forms',
'id': 'form-title',
'text': cell.cached_title,
'urls': {
'export': 'http://127.0.0.1:8999/api/export-import/forms/form-title/',
'dependencies': 'http://127.0.0.1:8999/api/export-import/forms/form-title/dependencies/',
'redirect': 'http://127.0.0.1:8999/api/export-import/forms/form-title/redirect/',
},
} in page.get_dependencies()
app.authorization = ('Basic', (john_doe.username, john_doe.username))
resp = app.get(f'/api/export-import/pages/{page.uuid}/dependencies/')
assert resp.json['data'][0]['type'] == 'forms'
@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
def test_page_dependencies_card_cell(mock_send):
page = Page.objects.create(title='Test', slug='test', template_name='standard')
cell = WcsCardCell(page=page, placeholder='content', order=0, carddef_reference='default:card_model_1')
cell.save()
assert {
'type': 'cards',
'id': 'card_model_1',
'text': 'Card Model 1',
'urls': {
'export': 'http://127.0.0.1:8999/api/export-import/cards/card_model_1/',
'dependencies': 'http://127.0.0.1:8999/api/export-import/cards/card_model_1/dependencies/',
'redirect': 'http://127.0.0.1:8999/api/export-import/cards/card_model_1/redirect/',
},
} in page.get_dependencies()
cell = WcsCardCell(
page=page, placeholder='content', order=0, carddef_reference='default:card_model_1:custom_view'
)
cell.save()
assert {
'type': 'cards',
'id': 'card_model_1',
'text': 'Card Model 1',
'urls': {
'export': 'http://127.0.0.1:8999/api/export-import/cards/card_model_1/',
'dependencies': 'http://127.0.0.1:8999/api/export-import/cards/card_model_1/dependencies/',
'redirect': 'http://127.0.0.1:8999/api/export-import/cards/card_model_1/redirect/',
},
} in page.get_dependencies()
@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
def test_page_dependencies_category_cell(mock_send):
page = Page.objects.create(title='Test', slug='test', template_name='standard')
cell = WcsCategoryCell(page=page, placeholder='content', order=0, category_reference='default:test-3')
cell.save()
assert {
'type': 'forms-categories',
'id': 'test-3',
'text': 'Test 3',
'urls': {
'export': 'http://127.0.0.1:8999/api/export-import/forms-categories/test-3/',
'dependencies': 'http://127.0.0.1:8999/api/export-import/forms-categories/test-3/dependencies/',
'redirect': 'http://127.0.0.1:8999/api/export-import/forms-xategories/test-3/redirect/',
},
} in page.get_dependencies()