From 3fd532b2afe7e5ac56283a2f030632a60451f47c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Brunner?= Date: Thu, 29 Aug 2024 17:30:30 +0200 Subject: [PATCH] Add custom interface type To be able to integrate interface that not be build with the GeoMapGish build chain. Also be able to configure some static files: favicon.ico, robot.txt, api.js, api.js.map, api.css, apihelp.html. --- doc/integrator/configuration.rst | 1 + doc/integrator/interface.rst | 79 +++++++++++++++++++ geoportal/c2cgeoportal_geoportal/__init__.py | 62 +++++++++++++-- .../{{cookiecutter.project}}/Dockerfile | 2 +- .../geoportal/CONST_config-schema.yaml | 2 + .../geoportal/CONST_vars.yaml | 8 ++ .../c2cgeoportal_geoportal/views/entry.py | 40 +++++++++- poetry.lock | 2 +- pyproject.toml | 1 + 9 files changed, 186 insertions(+), 11 deletions(-) create mode 100644 doc/integrator/interface.rst diff --git a/doc/integrator/configuration.rst b/doc/integrator/configuration.rst index b5691ed4fe..4af42db5cb 100644 --- a/doc/integrator/configuration.rst +++ b/doc/integrator/configuration.rst @@ -10,6 +10,7 @@ This chapter describes advanced configuration settings. build runtime + interface ngeo backend caching diff --git a/doc/integrator/interface.rst b/doc/integrator/interface.rst new file mode 100644 index 0000000000..e58a21989e --- /dev/null +++ b/doc/integrator/interface.rst @@ -0,0 +1,79 @@ +.. _integrator_interface: + +Introduction +------------ + +This chapter describes how to integrate a new ``custom`` interface in a c2cgeoportal application. + +``custom`` means that interface can be based on another frontend than ngeo even if at the end of this +document we will speak about how to integrate ngeo as a custom interface with a simple build chain. + +Review +------ + +In the c2cgeoportal application we have by default 3 interfaces: desktop, mobile and iframe. + +c2cgeoportal provide different things around interface but they are not hard linked: + +- The interface in the admin interface is a way to define the layers visible in the interface. +- The ``interfaces_config`` in the ``vars.yaml`` is a way to define configurations for every interface. +- The ``interface`` in the ``vars.yaml`` is a way to configure interfaces route in c2cgeoportal: ``/``, + ``/theme/``, ``/`` and ``//theme/``. + +Configuration +------------- + +Here we will describe how to add a new ``custom`` interface in a c2cgeoportal application, +for that we should add a new entry in the ``interface_config`` with the ``type`` set to ``custom``. + +.. code:: yaml + + vars: + interfaces_config: + custom: + type: custom + name: my_interface + +We can also add an optional ``html_filename`` attribute in the config to specify the file that should be used for the interface, +with relative (from ``/etc/static-frontend``) or absolute file name. + +Interface integration +--------------------- + +To publish interfaces we should provide interfaces files (HTML, CSS, JavaScript, images, ...) in +the ``/etc/static-frontend/`` directory. + +The interface files should be in a directory named with the interface name suffixed by ``.html``. + +Note that this folder is also available on the ``/static-frontend/`` endpoint with cache headers without +any cash bustering, then files (other than interfaces HTML files) should contains an hash. + +If you need cache bustering you should put your files in the ``geoportal/geomapfish_geoportal/static/`` +directory (``/etc/geomapfish/static`` in the container). + +The interface HTML file is considered as mako template and he can use the following variables: +- ``request``: the Pyramid request object. +- ``dynamicUrl``: the URL to get the interface configuration. +- ``interface``: the interface name. +- ``staticFrontend``: the URL to the static frontend directory. +- ``staticCashBuster``: the URL to the static cash buster directory. + +For that you should create a Docker image that provide a ``/etc/static-frontend/`` volume (``VOLUME /etc/static-frontend``). + +And include it in the ``docker-compose.yaml`` file with something like: + +.. code:: yaml + + services: + my_interface: + image: my_interface + user: www-data + + geoportal: + volumes_from: + - my_interface:ro + +Ngeo integration +---------------- + +TODO diff --git a/geoportal/c2cgeoportal_geoportal/__init__.py b/geoportal/c2cgeoportal_geoportal/__init__.py index 8a6bc6f12e..996d88c981 100644 --- a/geoportal/c2cgeoportal_geoportal/__init__.py +++ b/geoportal/c2cgeoportal_geoportal/__init__.py @@ -75,7 +75,7 @@ TotalPythonObjectMemoryCollector, ) from c2cgeoportal_geoportal.lib.xsd import XSD -from c2cgeoportal_geoportal.views.entry import Entry, canvas_view +from c2cgeoportal_geoportal.views.entry import Entry, canvas_view, custom_view if TYPE_CHECKING: from c2cgeoportal_commons.models import static # pylint: disable=ungrouped-imports,useless-suppression @@ -103,6 +103,7 @@ def __call__(self, value: Any, system: dict[str, str]) -> bytes: INTERFACE_TYPE_NGEO = "ngeo" INTERFACE_TYPE_CANVAS = "canvas" +INTERFACE_TYPE_CUSTOM = "custom" def add_interface_config(config: pyramid.config.Configurator, interface_config: dict[str, Any]) -> None: @@ -143,6 +144,15 @@ def add_interface( interface_config=interface_config, **kwargs, ) + elif interface_type == INTERFACE_TYPE_CUSTOM: + assert interface_config is not None + add_interface_custom( + config, + route_name=interface_name, + route=route, + interface_config=interface_config, + **kwargs, + ) else: _LOG.error( "Unknown interface type '%s', should be '%s' or '%s'.", @@ -213,6 +223,36 @@ def add_interface_canvas( ) +def add_interface_custom( + config: pyramid.config.Configurator, + route_name: str, + route: str, + interface_config: dict[str, Any], + permission: str | None = None, +) -> None: + """Add custom interfaces views and routes.""" + + config.add_route(route_name, route, request_method="GET") + # Permalink theme: recover the theme for generating custom viewer.js URL + config.add_route( + f"{route_name}theme", + f"{route}{'' if route[-1] == '/' else '/'}theme/{{themes}}", + request_method="GET", + ) + view = partial(custom_view, interface_config=interface_config) + view.__module__ = custom_view.__module__ + config.add_view( + view, + route_name=route_name, + permission=permission, + ) + config.add_view( + view, + route_name=f"{route_name}theme", + permission=permission, + ) + + def add_admin_interface(config: pyramid.config.Configurator) -> None: """Add the administration interface views and routes.""" @@ -663,12 +703,17 @@ def add_static_route(name: str, attr: str, path: str, renderer: str) -> None: config.add_route(name, path, request_method="GET") config.add_view(Entry, attr=attr, route_name=name, renderer=renderer) - add_static_route("favicon", "favicon", "/favicon.ico", "/etc/geomapfish/static/images/favicon.ico") - add_static_route("robot.txt", "robot_txt", "/robot.txt", "/etc/geomapfish/static/robot.txt") + static_files = config.get_settings().get("static_files", {}) + for name, attr, path in [ + ("favicon.ico", "favicon", "/favicon.ico"), + ("robot.txt", "robot_txt", "/robot.txt"), + ("api.js.map", "apijsmap", "/api.js.map"), + ("api.css", "apicss", "/api.css"), + ("apihelp.html", "apihelp", "/apihelp/index.html"), + ]: + if static_files.get(name): + add_static_route(name, attr, path, static_files[name]) config.add_route("apijs", "/api.js", request_method="GET") - add_static_route("apijsmap", "apijsmap", "/api.js.map", "/etc/static-ngeo/api.js.map") - add_static_route("apicss", "apicss", "/api.css", "/etc/static-ngeo/api.css") - add_static_route("apihelp", "apihelp", "/apihelp/index.html", "/etc/geomapfish/static/apihelp/index.html") c2cgeoportal_geoportal.views.add_redirect(config, "apihelp_redirect", "/apihelp.html", "apihelp") config.add_route("themes", "/themes", request_method="GET", pregenerator=C2CPregenerator(role=True)) @@ -803,6 +848,11 @@ def add_static_route(name: str, attr: str, path: str, renderer: str) -> None: path="/etc/static-ngeo", cache_max_age=int(config.get_settings()["default_max_age"]), ) + config.add_static_view( + name="static-frontend", + path="/etc/static-frontend", + cache_max_age=int(config.get_settings()["default_max_age"]), + ) # Add the c2cgeoportal static view with cache buster config.add_static_view( diff --git a/geoportal/c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/Dockerfile b/geoportal/c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/Dockerfile index 66c0d5866a..bfae0ca3a7 100644 --- a/geoportal/c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/Dockerfile +++ b/geoportal/c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/Dockerfile @@ -14,7 +14,7 @@ ENV CONFIG_VARS sqlalchemy.url sqlalchemy.pool_recycle sqlalchemy.pool_size sqla dbsessions urllogin host_forward_host headers_whitelist headers_blacklist \ smtp c2c.base_path welcome_email \ lingva_extractor interfaces_config interfaces devserver_url api authentication intranet metrics pdfreport \ - vector_tiles i18next main_ogc_server + vector_tiles i18next main_ogc_server static_files COPY . /tmp/config/ diff --git a/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_config-schema.yaml b/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_config-schema.yaml index 8763f55a15..c823b44168 100644 --- a/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_config-schema.yaml +++ b/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_config-schema.yaml @@ -121,6 +121,8 @@ mapping: layout: type: str default: ngeo + html_filename: + type: str interfaces_config: required: True type: map diff --git a/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_vars.yaml b/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_vars.yaml index 6da89af69b..e801a4ca04 100644 --- a/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_vars.yaml +++ b/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_vars.yaml @@ -73,6 +73,14 @@ vars: escapeValue: false backend: {} + static_files: + favicon.ico: /etc/geomapfish/static/images/favicon.ico + robot.txt: /etc/geomapfish/static/robot.txt + api.js: /etc/static-ngeo/api.js + api.js.map: /etc/static-ngeo/api.js.map + api.css: /etc/static-ngeo/api.css + apihelp.html: /etc/geomapfish/static/apihelp/index.html + interfaces_config: default: constants: diff --git a/geoportal/c2cgeoportal_geoportal/views/entry.py b/geoportal/c2cgeoportal_geoportal/views/entry.py index c0bfea54f5..a7ae0e3de6 100644 --- a/geoportal/c2cgeoportal_geoportal/views/entry.py +++ b/geoportal/c2cgeoportal_geoportal/views/entry.py @@ -28,9 +28,11 @@ import glob import logging +import os from typing import Any import pyramid.request +from bs4 import BeautifulSoup from pyramid.i18n import TranslationStringFactory from pyramid.view import view_config @@ -61,8 +63,8 @@ def get_ngeo_index_vars(self) -> dict[str, Any]: @staticmethod @_CACHE_REGION.cache_on_arguments() - def get_apijs(api_name: str | None) -> str: - with open("/etc/static-ngeo/api.js", encoding="utf-8") as api_file: + def get_apijs(api_filename: str, api_name: str | None) -> str: + with open(api_filename, encoding="utf-8") as api_file: api = api_file.read().split("\n") sourcemap = api.pop(-1) if api_name: @@ -77,7 +79,10 @@ def get_apijs(api_name: str | None) -> str: @view_config(route_name="apijs") # type: ignore def apijs(self) -> pyramid.response.Response: - self.request.response.text = self.get_apijs(self.request.registry.settings["api"].get("name")) + self.request.response.text = self.get_apijs( + self.request.registry.settings["static_files"]["api.js"], + self.request.registry.settings["api"].get("name"), + ) set_common_headers(self.request, "api", Cache.PUBLIC, content_type="application/javascript") return self.request.response @@ -138,3 +143,32 @@ def canvas_view(request: pyramid.request.Request, interface_config: dict[str, An ), "spinner": spinner, } + + +def custom_view( + request: pyramid.request.Request, interface_config: dict[str, Any] +) -> pyramid.response.Response: + """Get view used as entry point of a canvas interface.""" + + set_common_headers(request, "index", Cache.PUBLIC_NO, content_type="text/html") + + html_filename = interface_config.get("html_filename", f"{interface_config['name']}.html") + if not html_filename.startswith("/"): + html_filename = os.path.join("/etc/static-frontend/", html_filename) + + with open(html_filename, encoding="utf-8") as html_file: + html = BeautifulSoup(html_file.read, "html.parser") + + meta = html.find("meta", attrs={"name": "interface"}) + if meta is not None: + meta["content"] = interface_config["name"] + meta = html.find("meta", attrs={"name": "dynamicUrl"}) + if meta is not None: + meta["content"] = request.route_url("dynamic") + + if hasattr(request, "custom_interface_transformer"): + request.custom_interface_transformer(html, interface_config) + + request.response.text = str(html) + + return request.response diff --git a/poetry.lock b/poetry.lock index 4ce91f8fbe..84de0cbe15 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4805,4 +4805,4 @@ test = ["zope.testing"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.13" -content-hash = "77c9877ead5991bb52f7e19399ebb3dc5affb6b0d44a6422fea7055d470f7dbb" +content-hash = "6ddcdeedbf612dfd030c969d827b37a1b4afeec865a4076f2423a34c7a783feb" diff --git a/pyproject.toml b/pyproject.toml index 4f19aee4f6..2f8add9662 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,7 @@ basicauth = "1.0.0" prospector = { extras = ["with_mypy", "with_bandit", "with_pyroma"], version = "1.12.0" } prospector-profile-duplicated = "1.5.0" prospector-profile-utils = "1.9.0" +beautifulsoup4 = "4.12.3" [tool.poetry.group.dev.dependencies] Babel = "2.16.0" # i18n