diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 13b0ffba..876c6744 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,9 +11,9 @@ jobs: - uses: actions/checkout@v2 - name: "Set up Python" - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.12' - name: "python backend - install requirements" working-directory: srv/python diff --git a/config-php-sample.json b/config-php-sample.json index 2ed7b236..14c41c7e 100644 --- a/config-php-sample.json +++ b/config-php-sample.json @@ -1,8 +1,8 @@ { "app_conf": { "studio_title": "GéoBretagne mviewer studio", - "mviewer_version": "3.13", - "mviewerstudio_version": "4.2", + "mviewer_version": "4", + "mviewerstudio_version": "4.2.1", "is_php": "true", "php": { "upload_service": "srv/php/store.php", @@ -83,7 +83,7 @@ "type": "OSM", "url": "https://{a-c}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png", "maxzoom": "20", - "attribution": "Map tiles by CartoDb, under CC BY 3.0 " + "attribution": "Map tiles by CartoDb, under CC BY 3.0 " }, "esriworldimagery": { "id": "esriworldimagery", @@ -142,14 +142,14 @@ "osm_grey": { "id": "osm_grey", "thumbgallery": "img/basemap/osm_grey.png", - "title": "GéoBretagne - GéoGrandEst", + "title": "GéoBretagne - DataGrandEst", "label": "OpenStreetMap style noir et blanc", "type": "WMTS", "url": "https://tile.geobretagne.fr/osm/service", "layers": "osm:grey", "format": "image/png", "fromcapacity": "false", - "attribution": "GéoBretagne - GéoGrandEst. Données : les contributeurs d'OpenStreetMap , ODbL ", + "attribution": "GéoBretagne - DataGrandEst. Données : les contributeurs d'OpenStreetMap , ODbL ", "style": "normal", "matrixset": "PM", "maxzoom": "22" @@ -181,12 +181,7 @@ "baseref": "https://geobretagne.fr/geonetwork/srv/eng/catalog.search?node=srv#/metadata/" }, { - "title": "Catalogue Région Bretagne", - "url": "https://kartenn.region-bretagne.fr/geonetwork/srv/fre/csw", - "baseref": "https://kartenn.region-bretagne.fr/geonetwork/srv/fre/catalog.search#/metadata/" - }, - { - "title": "Catalogue de la Région Grand Est", + "title": "Catalogue DataGrandEst", "url": "https://datagrandest.fr/geonetwork/srv/fre/csw", "baseref": "https://datagrandest.fr/geonetwork/srv/eng/catalog.search?node=srv#/metadata/" } diff --git a/config-python-sample.json b/config-python-sample.json index 95cdf22c..0786e50d 100644 --- a/config-python-sample.json +++ b/config-python-sample.json @@ -1,15 +1,15 @@ { "app_conf": { "studio_title": "Mviewer Studio", - "mviewer_version": "3.13", - "mviewerstudio_version": "4.2", + "mviewer_version": "4", + "mviewerstudio_version": "4.2.1", "api": "api/app", "store_style_service": "api/style", "mviewer_instance": "/mviewer/", "publish_url": "/mviewer/?config=apps/public/{{config}}.xml", "conf_path_from_mviewer": "apps/store/", "mviewer_short_url": { - "used": true, + "used": false, "apps_folder": "store", "public_folder": "public" }, @@ -77,7 +77,7 @@ "type": "OSM", "url": "https://{a-c}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png", "maxzoom": "20", - "attribution": "Map tiles by CartoDb, under CC BY 3.0 " + "attribution": "Map tiles by CartoDb, under CC BY 3.0 " }, "esriworldimagery": { "id": "esriworldimagery", @@ -136,14 +136,14 @@ "osm_grey": { "id": "osm_grey", "thumbgallery": "img/basemap/osm_grey.png", - "title": "GéoBretagne - GéoGrandEst", + "title": "GéoBretagne - DataGrandEst", "label": "OpenStreetMap style noir et blanc", "type": "WMTS", "url": "https://tile.geobretagne.fr/osm/service", "layers": "osm:grey", "format": "image/png", "fromcapacity": "false", - "attribution": "GéoBretagne - GéoGrandEst. Données : les contributeurs d'OpenStreetMap , ODbL ", + "attribution": "GéoBretagne - DataGrandEst. Données : les contributeurs d'OpenStreetMap , ODbL ", "style": "normal", "matrixset": "PM", "maxzoom": "22" @@ -175,15 +175,10 @@ "baseref": "https://geobretagne.fr/geonetwork/srv/eng/catalog.search?node=srv#/metadata/" }, { - "title": "Catalogue Région Bretagne", - "url": "https://kartenn.region-bretagne.fr/geonetwork/srv/fre/csw", - "baseref": "https://kartenn.region-bretagne.fr/geonetwork/srv/fre/catalog.search#/metadata/" - }, - { - "title": "Catalogue de la Région Grand Est", + "title": "Catalogue DataGrandEst", "url": "https://datagrandest.fr/geonetwork/srv/fre/csw", "baseref": "https://datagrandest.fr/geonetwork/srv/eng/catalog.search?node=srv#/metadata/" - } + } ], "wms": [{ "title": "Serveur WMS de la Région", diff --git a/docker/Dockerfile-python-backend b/docker/Dockerfile-python-backend index 4340adba..7413051a 100644 --- a/docker/Dockerfile-python-backend +++ b/docker/Dockerfile-python-backend @@ -44,4 +44,4 @@ ENV EXPORT_CONF_FOLDER=/home/apprunner/apps/store \ # You will probably have to override this one on runtime with your custom config COPY config-python-sample.json mviewerstudio_backend/static/apps/config.json -CMD ["gunicorn", "-w 4", "-b 0.0.0.0:8000", "mviewerstudio_backend.app:app"] +CMD ["gunicorn", "-w 1", "-b 0.0.0.0:8000", "mviewerstudio_backend.app:app"] diff --git a/docker/nginx/default.conf.template b/docker/nginx/default.conf.template index d12b3f75..c0e216ac 100644 --- a/docker/nginx/default.conf.template +++ b/docker/nginx/default.conf.template @@ -3,7 +3,7 @@ server { server_name ${NGINX_HOST}; location /mviewer/ { - proxy_pass http://mviewer:80/; + proxy_pass http://mviewer:8080/; } location /${MVIEWERSTUDIO_URL_PATH_PREFIX}/ { diff --git a/docs/doc_tech/dev_corner.rst b/docs/doc_tech/dev_corner.rst index 052c51fd..c8c01d2b 100644 --- a/docs/doc_tech/dev_corner.rst +++ b/docs/doc_tech/dev_corner.rst @@ -21,8 +21,8 @@ Prérequis - Disposer des droits d'exécution en local (via Flask) - Mviewer doit être installé et accessible en local (avec droit d'écriture) -Généralités ------------ +Généralités +----------- Le debugger VS Code permet d'utiliser le virtualenv (répertoire .venv) installé dans le répertoire `srv/python`. @@ -33,11 +33,33 @@ https://docs.posit.co/ide/server-pro/user/vs-code/guide/python-environments.html Vous pouvez également consultez cette documentation sur le debugger Python dans VS code : -https://code.visualstudio.com/docs/python/tutorial-flask - - -Configuration du debugger VS Code ---------------------------------- +https://code.visualstudio.com/docs/python/tutorial-flask + + +Synchronisation API / Swagger +----------------------------- + +Toute modification des routes backend (fichier ``srv/python/mviewerstudio_backend/route.py``) +doit être synchronisée dans la spécification Swagger/OpenAPI +(``srv/python/mviewerstudio_backend/swagger.yaml``). + +Accès à Swagger +--------------- + +Une fois le backend démarré, vous pouvez accéder à la documentation API : + +- Swagger UI : ``http://localhost:5007/swagger`` (ou ``/swagger/``) +- Spécification OpenAPI : ``http://localhost:5007/swagger.yaml`` + +Si un préfixe d'URL est configuré (ex: ``MVIEWERSTUDIO_URL_PATH_PREFIX=mviewerstudio``), +les URLs deviennent : + +- Swagger UI : ``http://localhost:5007/mviewerstudio/swagger`` +- Spécification OpenAPI : ``http://localhost:5007/mviewerstudio/swagger.yaml`` + + +Configuration du debugger VS Code +--------------------------------- 1. Ouvrir le répertoire /srv/python/ dans VS Code. 2. Ouvrir le fichier .vscode/launch.json (voir la section suivante si non existant) diff --git a/docs/doc_tech/install_python.rst b/docs/doc_tech/install_python.rst index dac79c6e..6e3f75c7 100644 --- a/docs/doc_tech/install_python.rst +++ b/docs/doc_tech/install_python.rst @@ -24,7 +24,7 @@ Vous aurez besoin : sudo apt install libxslt1-dev libxml2-dev python3 python3-pip python3-venv pip install virtualenv -- d'une version Python >= 3.9 +- d'une version Python >= 3.11 - d'une instance mviewer fonctionnelle (/mviewer) Procédures d'installation @@ -150,6 +150,24 @@ Lancement de l'application avec Flask flask run -p 5007 +Documentation Swagger (API) +=========================== + +Le backend Python expose une interface Swagger UI ainsi que le fichier OpenAPI : + +- Swagger UI : ``/swagger`` (ou ``/swagger/``) +- Spécification OpenAPI : ``/swagger.yaml`` + +Exemples : + +- sans préfixe d'URL : ``http://localhost:5007/swagger`` +- avec ``MVIEWERSTUDIO_URL_PATH_PREFIX=mviewerstudio`` : ``http://localhost:5007/mviewerstudio/swagger`` + +.. note:: + Ces routes sont servies directement par Flask via ``mviewerstudio_backend/route.py``. + Le fichier de spécification est ``srv/python/mviewerstudio_backend/swagger.yaml``. + + Mise en production ****************** @@ -208,6 +226,9 @@ Ajoutez ensuite ce contenu en adaptant les valeurs (chemin, user...) selon votre fichier `mviewerstudio.service` + .. warning:: + Nous conseillons de laisser la valeur du worker à 1 (voir issue #389) + .. code-block:: sh [Unit] @@ -225,6 +246,7 @@ fichier `mviewerstudio.service` WorkingDirectory=/home/monuser/mviewerstudio/srv/python ExecStart=/home/monuser/mviewerstudio/srv/python/.venv/bin/gunicorn \ -b 127.0.0.1:5007 \ + --workers=1 \ --access-logfile /var/log/mviewerstudio/gunicorn-access.log \ --log-level info \ --error-logfile /var/log/mviewerstudio/gunicorn-error.log \ diff --git a/lib/mv.js b/lib/mv.js index 24f12657..04cd4279 100755 --- a/lib/mv.js +++ b/lib/mv.js @@ -71,7 +71,7 @@ var mv = (function () { function uuid() { var dt = new Date().getTime(); var uuid = "xxxxxxxxxxxx".replace(/[xy]/g, function (c) { - var r = (dt + Math.random() * 16) % 16 | 0; + var r = ((dt + Math.random() * 16) % 16) | 0; dt = Math.floor(dt / 16); return (c == "x" ? r : (r & 0x3) | 0x8).toString(16); }); @@ -321,12 +321,31 @@ var mv = (function () { $("#frm-bl").append(html); $("#frm-bl-visible").append(html2); $(".bl." + classe + " input").bind("change", function (e) { + var checkbox = $(e.currentTarget); + var allChecked = $(".bl." + classe + " input:checked"); + if (!checkbox.prop("checked") && allChecked.length === 0) { + checkbox.prop("checked", true); + return; + } var id = $(this).parent().parent().attr("data-layerid"); var value = $(e.currentTarget).prop("checked"); + var select = $("#frm-bl-visible"); + var currentValue = select.val(); if (value === true) { - $("#frm-bl-visible option[value='" + id + "']").removeAttr("disabled"); + select.find("option[value='" + id + "']").removeAttr("disabled"); + if (!currentValue) { + select.val(id).trigger("change"); + } } else { - $("#frm-bl-visible option[value='" + id + "']").attr("disabled", "disabled"); + select.find("option[value='" + id + "']").attr("disabled", "disabled"); + if (id === currentValue) { + var first = select.find("option:not(:disabled)").first(); + if (first.length) { + select.val(first.val()).trigger("change"); + } else { + select.val(null).trigger("change"); + } + } } }); }, @@ -1311,7 +1330,7 @@ var mv = (function () { $("#opt-searchl-attribution").show(); $("#opt-searchl-inputlabel").show(); // TODO : need to get values from config - $("#opt-searchlocalities-url").val("https://api-adresse.data.gouv.fr/search/"); + $("#opt-searchlocalities-url").val("https://data.geopf.fr/geocodage/search/"); $("#opt-searchlocalities-attribution").val("Base adresse nationale (BAN)"); $("#opt-searchlocalities-inputlabel").val(""); $("#search-querymaponclick").show(); diff --git a/lib/ogc.js b/lib/ogc.js index ce73e85b..b443203b 100755 --- a/lib/ogc.js +++ b/lib/ogc.js @@ -241,28 +241,38 @@ var ogc = (function () { mv.showCSWResults(results); }; + var _parseXml = function (text) { + var parser = new DOMParser(); + return parser.parseFromString(text, "text/xml"); + }; + var _cswAjax = function (url, body, metadataUrl, typeNames) { - $.ajax({ - type: "POST", - url: mv.ajaxURL(url), - crossDomain: true, - data: body, - dataType: "xml", - contentType: "application/xml", - success: function (data) { - _cswParse(data, url, metadataUrl, typeNames); + fetch(mv.ajaxURL(url), { + method: "POST", + headers: { + "Content-Type": "application/xml", }, - error: function (xhr, ajaxOptions, thrownError) { + body: body, + }) + .then((response) => { + if (!response.ok) { + throw new Error( + "CSW request failed: " + response.status + " " + response.statusText + ); + } + return response.text(); + }) + .then((data) => { + _cswParse(_parseXml(data), url, metadataUrl, typeNames); + }) + .catch((error) => { console.error("CSW request failed", { url: url, body: body, - xhr: xhr, - ajaxOptions: ajaxOptions, - thrownError: thrownError, + error: error, }); alert("Echec de la requête CSW :\n" + url); - }, - }); + }); }; var _wmsCapabilitiesParse = function (data, layerid) { @@ -405,25 +415,28 @@ var ogc = (function () { ? getCapabilitiesUrl + "?" + getCapabilitiesParams : getCapabilitiesUrl + "&" + getCapabilitiesParams; - $.ajax({ - type: "GET", - url: mv.ajaxURL(getCapabilitiesUrl), - crossDomain: true, - dataType: "xml", - contentType: "application/xml", - success: function (data) { - _wmsParse(data, keyword); - }, - error: function (xhr, ajaxOptions, thrownError) { + fetch(mv.ajaxURL(getCapabilitiesUrl)) + .then((response) => { + if (!response.ok) { + throw new Error( + "WMS GetCapabilities request failed: " + + response.status + + " " + + response.statusText + ); + } + return response.text(); + }) + .then((data) => { + _wmsParse(_parseXml(data), keyword); + }) + .catch((error) => { console.error("WMS GetCapabilities request failed", { url: getCapabilitiesUrl, - xhr: xhr, - ajaxOptions: ajaxOptions, - thrownError: thrownError, + error: error, }); alert("Echec de la requête WMS GetCapabilities :\n" + getCapabilitiesUrl); - }, - }); + }); }; var _DescribeFeatureTypeParse = function (data, typeName, layerid, url) { @@ -465,12 +478,7 @@ var ogc = (function () { ? descFeatTypeUrl + "?" + descFeatTypeParams : descFeatTypeUrl + "&" + descFeatTypeParams; - return fetch(mv.ajaxURL(descFeatTypeUrl), { - mode: "cors", - headers: { - "Content-Type": "application/xml", - }, - }) + return fetch(descFeatTypeUrl) .then((response) => response.text()) .then((data) => { return _DescribeFeatureTypeParse(data, typeName, layerid, url); @@ -488,25 +496,28 @@ var ogc = (function () { ? getCapabilitiesUrl + "?" + getCapabilitiesParams : getCapabilitiesUrl + "&" + getCapabilitiesParams; - $.ajax({ - type: "GET", - url: mv.ajaxURL(getCapabilitiesUrl), - crossDomain: true, - dataType: "xml", - contentType: "application/xml", - success: function (data) { - _wmsCapabilitiesParse(data, layerid); - }, - error: function (xhr, ajaxOptions, thrownError) { + fetch(getCapabilitiesUrl) + .then((response) => { + if (!response.ok) { + throw new Error( + "WMS GetCapabilities request failed: " + + response.status + + " " + + response.statusText + ); + } + return response.clone().text(); + }) + .then((data) => { + _wmsCapabilitiesParse(_parseXml(data), layerid); + }) + .catch((error) => { console.error("WMS GetCapabilities request failed", { url: getCapabilitiesUrl, - xhr: xhr, - ajaxOptions: ajaxOptions, - thrownError: thrownError, + error: error, }); alert("Echec de la requête WMS GetCapabilities :\n" + getCapabilitiesUrl); - }, - }); + }); }; var _describeLayerParse = function (xml, layerid) { @@ -524,12 +535,7 @@ var ogc = (function () { ? descLayerUrl + "?" + descLayerParams : descLayerUrl + "&" + descLayerParams; - return fetch(mv.ajaxURL(descLayerUrl), { - mode: "cors", - headers: { - "Content-Type": "application/xml", - }, - }) + return fetch(mv.ajaxURL(descLayerUrl)) .then((response) => response.text()) .then((data) => { return { @@ -613,25 +619,28 @@ var ogc = (function () { let newUrl = ogc.urlToObject(url, queryParams); let stringUrl = newUrl.toString(); - $.ajax({ - type: "GET", - url: mv.ajaxURL(stringUrl), - crossDomain: true, - dataType: "json", - contentType: "application/json", - success: function (data) { + fetch(stringUrl) + .then((response) => { + if (!response.ok) { + throw new Error( + "WFS GetFeature request failed: " + + response.status + + " " + + response.statusText + ); + } + return response.json(); + }) + .then((data) => { onSuccess(data); - }, - error: function (xhr, ajaxOptions, thrownError) { + }) + .catch((error) => { console.error("WFS GetFeature request failed", { url: stringUrl, - xhr: xhr, - ajaxOptions: ajaxOptions, - thrownError: thrownError, + error: error, }); alert("Echec de la requête WFS GetFeature :\n" + stringUrl); - }, - }); + }); }, }; })(); diff --git a/srv/python/README.md b/srv/python/README.md index b0ebe084..b005802d 100644 --- a/srv/python/README.md +++ b/srv/python/README.md @@ -1,7 +1,7 @@ # MViewerStudio Backend v2 Ce dossier contient la version 2 du backend de mviewerstudio. Cette version est -écrite en python (3.7+) et utilise le framework Flask. Il n'y aucune base de +écrite en python (3.11+) et utilise le framework Flask. Il n'y aucune base de données, les données sont stockées dans des fichiers json. ## Installation @@ -19,4 +19,3 @@ Vous pouvez utiliser la composition docker présente à la racine du dépot. Le * Lancer les tests unitaires : `pytest mviewerstudio_backend/test.py` * Vérifier les types : `mypy --ignore-missing mviewerstudio_backend` - diff --git a/srv/python/mviewerstudio_backend/error_handlers.py b/srv/python/mviewerstudio_backend/error_handlers.py index 6bd3c257..31675e00 100644 --- a/srv/python/mviewerstudio_backend/error_handlers.py +++ b/srv/python/mviewerstudio_backend/error_handlers.py @@ -4,7 +4,6 @@ import json import logging - logger = logging.getLogger(__name__) diff --git a/srv/python/mviewerstudio_backend/register_utils.py b/srv/python/mviewerstudio_backend/register_utils.py index 0db4e4ba..15c850b8 100644 --- a/srv/python/mviewerstudio_backend/register_utils.py +++ b/srv/python/mviewerstudio_backend/register_utils.py @@ -7,7 +7,6 @@ import git - logger = logging.getLogger(__name__) diff --git a/srv/python/mviewerstudio_backend/route.py b/srv/python/mviewerstudio_backend/route.py index b87366e6..1f35f0f2 100644 --- a/srv/python/mviewerstudio_backend/route.py +++ b/srv/python/mviewerstudio_backend/route.py @@ -1,4 +1,13 @@ -from flask import Blueprint, jsonify, Response, request, current_app, redirect +from flask import ( + Blueprint, + jsonify, + Response, + request, + current_app, + redirect, + render_template_string, + send_from_directory, +) from .utils.login_utils import current_user from .utils.config_utils import ( Config, @@ -48,6 +57,43 @@ def default_doc(): return redirect("index.html") +@basic_store.route("/swagger", methods=["GET"]) +@basic_store.route("/swagger/", methods=["GET"]) +def swagger_ui() -> Response: + """ + Serve Swagger UI for backend API. + """ + return render_template_string(""" + + +
+ + +