diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..a64d5c70 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "pymviewer"] + path = pymviewer + url = https://github.com/jdev-org/pymviewer.git diff --git a/.vscode/launch.json b/.vscode/launch.json index e838c8ec..b743dd79 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,13 +10,15 @@ "request": "launch", "module": "flask", "env": { - "FLASK_APP": "/home/user/git/mviewerstudio/src/app.py", + "FLASK_APP": "/home/gaetan/projects/mviewer/mviewerstudio/src/app.py", "FLASK_DEBUG": "1", "CONF_PATH_FROM_MVIEWER":"apps/store", "EXPORT_CONF_FOLDER":"/home/gaetan/projects/mviewer/mviewer/apps/store/", "MVIEWERSTUDIO_PUBLISH_PATH":"/home/gaetan/projects/mviewer/mviewer/apps/public", "CONF_PUBLISH_PATH_FROM_MVIEWER":"apps/public", "DEFAULT_ORG":"geobretagne", + "QGS_FOLDER":"/home/gaetan/projects/qgis/compo-jm/volumes/qgis-srv", + "QGIS_SERVER_INTERNAL_URL": "http://localhost:90/ogc" }, "args": [ diff --git a/docker-compose.yml b/docker-compose.yml index 4af05bec..9d5d8e59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,8 +5,13 @@ services: build: context: . dockerfile: docker/Dockerfile + network: host + args: + UID: "${UID:-1000}" + GID: "${GID:-1000}" pull_policy: build - image: mviewer/mviewerstudio:latest + image: mviewer/mviewerstudio:qgis + user: "${UID:-1000}:${GID:-1000}" # ports: # - "8000:8000" environment: @@ -16,9 +21,21 @@ services: - EXPORT_CONF_FOLDER=/home/mvuser/apps/store - MVIEWERSTUDIO_PUBLISH_PATH=/home/mvuser/apps/public - MVIEWERSTUDIO_URL_PATH_PREFIX=mviewerstudio + - QGS_FOLDER=/home/mvuser/qgis + - QGIS_SERVER_INTERNAL_URL=http://qgis-server/ogc/ volumes: - "./apps:/home/mvuser/apps" + - "./qgis:/home/mvuser/qgis" - "./src/static/config.json:/home/mvuser/src/static/config.json" + # QGIS Server exposed on localhost:90 to match src/static/config.json + qgis-server: + profiles: + - qgis + image: qgis/qgis-server:latest + ports: + - "90:80" + volumes: + - "./qgis:/io/data" # Served under /mviewer/ see docker/nginx/default.conf.template mviewer: depends_on: diff --git a/docker/Dockerfile b/docker/Dockerfile index e1c3abe2..eb16e195 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -35,6 +35,7 @@ ENV EXPORT_CONF_FOLDER=/home/mvuser/apps/store \ MVIEWERSTUDIO_PUBLISH_PATH=/home/mvuser/apps/public \ CONF_PUBLISH_PATH_FROM_MVIEWER=apps/public \ DEFAULT_ORG=public \ - MVIEWERSTUDIO_URL_PATH_PREFIX=mviewerstudio + MVIEWERSTUDIO_URL_PATH_PREFIX=mviewerstudio \ + QGS_FOLDER=/home/mvuser/qgis CMD ["gunicorn", "-w 1", "-b 0.0.0.0:8000", "src.app:app"] diff --git a/docker/README.md b/docker/README.md index 60d78505..6798f7f0 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,31 +1,188 @@ -# mviewerstudio Dockerfile +# Docker -## Image -Only python Dockerfile will be maintained (PHP backend is fully deprecated). +## Objectif -## Environment variables +Cette composition Docker lance : -- EXPORT_CONF_FOLDER is the "staging" folder where studio will store draft configs and work in progress -- CONF_PATH_FROM_MVIEWER is the url path given to mviewer to access the same map files -- MVIEWERSTUDIO_PUBLISH_PATH is the "production" folder where studio will copy the map files when you ask to *Publish* -- CONF_PUBLISH_PATH_FROM_MVIEWER is the url path given to mviewer to access the same map files -- MVIEWERSTUDIO_URL_PATH_PREFIX allows to serve studio on a non-root path, see [#271](https://github.com/mviewer/mviewerstudio/pull/271) +- `mviewerstudio` : backend Flask/Gunicorn ; +- `mviewer` : frontend mviewer ; +- `www` : reverse-proxy Nginx exposé sur `http://localhost/` ; +- `qgis-server` : service optionnel, activé via le profil Compose `qgis`. -## Default configuration +Par défaut : -The default configuration (env vars defined in the dockerfile and json config file present in `src/static/config.json`) assume that: -- the mviewer _apps_ folder is mounted at EXPORT_CONF_FOLDER=/home/apprunner/apps -- /home/apprunner/apps/store and /home/apprunner/apps/prod are existing folders (you might need to create them manually beforehand) -- it is using `src/static/config.json`, which you will probably want to adapt to your own environment. +- Studio est accessible sur `http://localhost/mviewerstudio/` +- mviewer est accessible sur `http://localhost/mviewer/` +- QGIS Server est proxifié sur `http://localhost/ogc/` +- le conteneur QGIS Server direct est aussi exposé sur `http://localhost:90/` +## Pré-requis -It is also configured to serve the frontend (static files) with gunicorn, which is usually not recommended. Later versions might use an nginx container to serve the frontend. +- Docker et Docker Compose installés ; +- les dossiers `./apps` et `./qgis` présents dans le dépôt ; +- le dossier `./qgis` doit être accessible en lecture/écriture pour l'utilisateur qui lance Docker Compose. -**Starting with version 4.3, the docker image assumes that the `EXPORT_CONF_FOLDER` directory is writeable for user 1000:1000.** +La composition monte : -## Build mviewerstudio image +- `./apps` dans Studio et mviewer ; +- `./qgis` dans Studio et QGIS Server ; +- `./src/static/config.json` dans le conteneur Studio. -Use docker compose to build image : +## Démarrage complet avec QGIS Server -`docker compose build mviewerstudio` +Commande recommandée pour une première exécution ou après modification du code : +```bash +docker compose --profile qgis up -d --build +``` + +Cette commande : + +- construit l'image locale `mviewerstudio` ; +- démarre `mviewerstudio`, `mviewer`, `www` et `qgis-server` ; +- active les fonctions Studio qui dépendent de QGIS Server. + +À utiliser quand : + +- vous démarrez la stack pour la première fois ; +- vous avez modifié le backend Python, Nginx ou le `Dockerfile` ; +- vous avez besoin de l'import QGIS, du `GetCapabilities` ou des projets `.qgs`. + +## Remise à zéro + +Pour arrêter la stack et supprimer aussi les volumes anonymes associés : + +```bash +docker compose down -v +``` + +Cette commande : + +- arrête tous les conteneurs de la composition ; +- supprime le réseau Docker créé par Compose ; +- supprime les volumes anonymes créés par les services. + +Elle ne supprime pas vos fichiers du dépôt comme `./apps` ou `./qgis`, car ce sont des bind mounts locaux. + +## Démarrage simple sans QGIS Server + +Pour relancer uniquement Studio, mviewer et Nginx, sans le service QGIS : + +```bash +docker compose up -d +``` + +Cette commande : + +- démarre `mviewerstudio`, `mviewer` et `www` ; +- ne démarre pas `qgis-server`, car il est derrière le profil `qgis`. + +À utiliser quand : + +- vous n’avez pas besoin des fonctions QGIS ; +- vous voulez seulement consulter Studio ou mviewer ; +- vous redémarrez rapidement la stack après un `down`. + +## Quand utiliser quelle commande + +`docker compose --profile qgis up -d --build` + +- pour un démarrage complet ; +- pour reconstruire l'image `mviewerstudio` ; +- pour activer QGIS Server. + +`docker compose down -v` + +- pour repartir d'un état Docker propre ; +- pour forcer un redémarrage complet de la composition. + +`docker compose up -d` + +- pour un redémarrage rapide sans QGIS Server ; +- pour relancer les services web principaux après un arrêt. + +## Build local ou image déjà publiée + +Le service `mviewerstudio` peut fonctionner de deux façons dans `docker-compose.yml`. + +### 1. Build local de l'image + +C'est le mode actuellement configuré dans ce dépôt. + +Extrait actuel : + +```yaml +mviewerstudio: + build: + context: . + dockerfile: docker/Dockerfile + network: host + args: + UID: "${UID:-1000}" + GID: "${GID:-1000}" + pull_policy: build + image: mviewer/mviewerstudio:qgis +``` + +Dans ce mode : + +- Docker construit l'image à partir du code local ; +- `image:` sert surtout de tag pour l'image construite ; +- les changements dans le dépôt sont pris en compte après `docker compose up -d --build`. + +À utiliser quand : + +- vous développez localement ; +- vous modifiez Python, Nginx, le `Dockerfile` ou les dépendances ; +- vous voulez tester l'état exact du dépôt courant. + +### 2. Utiliser une image officielle déjà construite + +Si vous voulez démarrer depuis une image publiée, il faut retirer la section `build:` et conserver seulement `image:`. + +Exemple : + +```yaml +mviewerstudio: + image: mviewer/mviewerstudio:latest + user: "${UID:-1000}:${GID:-1000}" +``` + +Dans ce mode : + +- Docker ne reconstruit pas l'image locale ; +- Compose télécharge ou réutilise l'image distante `mviewer/mviewerstudio:latest` ; +- les modifications locales du code Python ne sont pas embarquées dans le conteneur. + +À utiliser quand : + +- vous voulez tester une image publiée ; +- vous ne souhaitez pas reconstruire l'image localement ; +- vous cherchez un démarrage plus simple sur une version déjà packagée. + +### Comment basculer d'un mode à l'autre + +Pour passer en mode image officielle dans `docker-compose.yml` : + +- supprimer le bloc `build:` du service `mviewerstudio` ; +- supprimer `pull_policy: build` ; +- remplacer la valeur de `image:` par le tag publié que vous voulez utiliser, par exemple `mviewer/mviewerstudio:latest`. + +Pour revenir au mode build local : + +- remettre le bloc `build:` ; +- remettre `pull_policy: build` ; +- conserver ou ajuster le tag `image:` utilisé pour nommer l'image locale construite. + +## URLs utiles + +- Studio : `http://localhost/mviewerstudio/` +- mviewer : `http://localhost/mviewer/` +- QGIS Server via Nginx : `http://localhost/ogc/` +- QGIS Server direct : `http://localhost:90/` + +## Notes + +- Le service `qgis-server` est optionnel. Sans le profil `qgis`, les routes Studio qui dépendent de QGIS Server ne fonctionneront pas. +- La configuration front par défaut est portée par `src/static/config.json`. +- Le reverse-proxy Nginx est défini dans `docker/nginx/default.conf.template`. diff --git a/docker/nginx/default.conf.template b/docker/nginx/default.conf.template index 2a7988ec..39e3c25c 100644 --- a/docker/nginx/default.conf.template +++ b/docker/nginx/default.conf.template @@ -1,13 +1,14 @@ server { listen 80; server_name ${NGINX_HOST}; + resolver 127.0.0.11 ipv6=off; location = /mviewer { return 302 /mviewer/$is_args$args; } location /mviewer/ { - proxy_pass http://mviewer/; + proxy_pass http://mviewer:8080/; } location /apps/ { @@ -26,6 +27,14 @@ server { #proxy_set_header sec-org "geobretagne"; } + location /ogc/ { + set $qgis_upstream qgis-server:80; + proxy_pass http://$qgis_upstream$request_uri; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + location / { server_name_in_redirect off; return 302 /${MVIEWERSTUDIO_URL_PATH_PREFIX}/; diff --git a/install/requirements.txt b/install/requirements.txt index 5f141be1..6ae96952 100644 --- a/install/requirements.txt +++ b/install/requirements.txt @@ -4,4 +4,4 @@ lxml>=4.9.2 GitPython>=3 requests Unidecode -git+https://github.com/jdev-org/pymviewer.git#egg=pymviewer +pymviewer diff --git a/pymviewer b/pymviewer new file mode 160000 index 00000000..b3c04380 --- /dev/null +++ b/pymviewer @@ -0,0 +1 @@ +Subproject commit b3c04380a19b0abf2ad05d59f898404e73bcb5d6 diff --git a/src/routes/qgis.py b/src/routes/qgis.py index c381a2b9..c0ba9474 100644 --- a/src/routes/qgis.py +++ b/src/routes/qgis.py @@ -1,4 +1,5 @@ from datetime import datetime +import logging from pathlib import Path from uuid import uuid4 from tempfile import TemporaryDirectory @@ -35,9 +36,17 @@ ET.register_namespace("rdf", RDF_NAMESPACE) ET.register_namespace("dc", DC_NAMESPACE) +logger = logging.getLogger(__name__) + def _configured_qgis_projects_url() -> str: """Return the configured QGIS projects base URL with a trailing slash.""" + internal_base_url = ( + current_app.config.get("QGIS_SERVER_INTERNAL_URL") or "" + ).strip() + if internal_base_url: + return internal_base_url.rstrip("/") + "/" + qgis_config = read_static_app_conf().get("qgis", {}) base_url = (qgis_config.get("url") or "").strip() if not base_url: @@ -45,10 +54,43 @@ def _configured_qgis_projects_url() -> str: return base_url.rstrip("/") + "/" +def _public_qgis_projects_url() -> str: + """Return the public QGIS projects base URL exposed to the browser.""" + qgis_config = read_static_app_conf().get("qgis", {}) + base_url = (qgis_config.get("url") or "").strip() + if not base_url: + raise BadRequest("Missing qgis.url configuration in static config") + return base_url.rstrip("/") + "/" + + +def _fetchable_qgis_capabilities_url(capabilities_url: str) -> str: + """Return a backend-reachable capabilities URL while preserving the public URL.""" + internal_base_url = ( + current_app.config.get("QGIS_SERVER_INTERNAL_URL") or "" + ).strip() + if not internal_base_url: + return capabilities_url + + public_base_url = _public_qgis_projects_url() + normalized_public_base_url = public_base_url.rstrip("/") + "/" + normalized_internal_base_url = internal_base_url.rstrip("/") + "/" + + if capabilities_url.startswith(normalized_public_base_url): + suffix = capabilities_url[len(normalized_public_base_url) :] + return f"{normalized_internal_base_url}{suffix}" + + return capabilities_url + + def _is_configured_qgis_url(url: str) -> bool: """Return ``True`` when the URL targets the configured QGIS projects base.""" - configured_url = _configured_qgis_projects_url() - return url.startswith(configured_url) + configured_urls = {_public_qgis_projects_url()} + internal_base_url = ( + current_app.config.get("QGIS_SERVER_INTERNAL_URL") or "" + ).strip() + if internal_base_url: + configured_urls.add(internal_base_url.rstrip("/") + "/") + return any(url.startswith(configured_url) for configured_url in configured_urls) def _project_capabilities_url(project_name: str) -> str: @@ -56,11 +98,51 @@ def _project_capabilities_url(project_name: str) -> str: if not project_name: raise BadRequest("Missing QGIS project name") return ( - f"{_configured_qgis_projects_url()}{project_name}" + f"{_public_qgis_projects_url()}{project_name}" "?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetCapabilities" ) +def _project_capabilities_url_from_relative_path( + relative_path: str | None, project_name: str +) -> str: + """Build the public GetCapabilities URL for a stored QGIS project.""" + if not project_name: + raise BadRequest("Missing QGIS project name") + + projects_path = (current_app.config.get("QGIS_SERVER_PROJECTS_PATH") or "").strip() + if not projects_path or not relative_path: + return _project_capabilities_url(project_name) + + base_url = _public_qgis_projects_url() + map_path = f"{projects_path.rstrip('/')}/{relative_path.lstrip('/')}" + separator = "&" if "?" in base_url else "?" + return ( + f"{base_url}{separator}MAP={quote(map_path, safe='/')}" + "&SERVICE=WMS&VERSION=1.3.0&REQUEST=GetCapabilities" + ) + + +def _stored_qgis_project_payload(project_name: str) -> dict: + """Return the stored QGIS project payload for a project name.""" + project_dir = _stored_qgis_project_directory(project_name) + qgs_dir = current_app.config.get("QGS_FOLDER") + if not qgs_dir: + raise BadRequest("Missing QGS folder configuration") + + qgs_files = [] + for root, _, files in walk(project_dir): + for filename in files: + if filename.lower().endswith(".qgs"): + qgs_files.append(path.join(root, filename)) + + if not qgs_files: + raise BadRequest("QGIS project does not exist") + + qgs_files.sort() + return _qgs_project_payload(qgs_dir, qgs_files[0]) + + def _mviewer_xml_from_capabilities_url(capabilities_url: str) -> str: """Fetch a WMS GetCapabilities document and convert it to mviewer XML.""" if not capabilities_url: @@ -72,8 +154,9 @@ def _mviewer_xml_from_capabilities_url(capabilities_url: str) -> str: ): raise MethodNotAllowed("Not allowed !") + fetch_url = _fetchable_qgis_capabilities_url(capabilities_url) try: - response = requests.get(capabilities_url, timeout=30) + response = requests.get(fetch_url, timeout=30) except requests.RequestException as exc: raise BadRequest( f"Unable to fetch GetCapabilities document from {capabilities_url}: {exc}" @@ -115,7 +198,9 @@ def _find_direct_child(element: ET.Element, child_name: str) -> ET.Element | Non def _find_direct_children(element: ET.Element, child_name: str) -> list[ET.Element]: """Return all direct children matching the provided local name.""" - return [child for child in list(element) if _xml_local_name(child.tag) == child_name] + return [ + child for child in list(element) if _xml_local_name(child.tag) == child_name + ] def _format_bbox_value(value: float) -> str: @@ -123,7 +208,9 @@ def _format_bbox_value(value: float) -> str: return f"{value:.12g}" -def _parse_bbox_attributes(bbox_node: ET.Element) -> tuple[float, float, float, float] | None: +def _parse_bbox_attributes( + bbox_node: ET.Element, +) -> tuple[float, float, float, float] | None: """Return bbox coordinates from a WMS ``BoundingBox``-like XML node.""" try: return ( @@ -147,7 +234,9 @@ def _root_layer_bbox_from_capabilities( capability_node = _find_direct_child(capabilities_root, "Capability") root_layer_node = ( - _find_direct_child(capability_node, "Layer") if capability_node is not None else None + _find_direct_child(capability_node, "Layer") + if capability_node is not None + else None ) if root_layer_node is None: return None @@ -173,21 +262,35 @@ def _root_layer_bbox_from_capabilities( matching_bbox_node = bounding_boxes[0] bbox_values = ( - _parse_bbox_attributes(matching_bbox_node) if matching_bbox_node is not None else None + _parse_bbox_attributes(matching_bbox_node) + if matching_bbox_node is not None + else None ) if bbox_values is None and normalized_projection in ("", "EPSG:4326", "CRS:84"): latlon_bbox_node = _find_direct_child(root_layer_node, "LatLonBoundingBox") bbox_values = ( - _parse_bbox_attributes(latlon_bbox_node) if latlon_bbox_node is not None else None + _parse_bbox_attributes(latlon_bbox_node) + if latlon_bbox_node is not None + else None ) if bbox_values is None: - geographic_bbox_node = _find_direct_child(root_layer_node, "EX_GeographicBoundingBox") + geographic_bbox_node = _find_direct_child( + root_layer_node, "EX_GeographicBoundingBox" + ) if geographic_bbox_node is not None: - west_node = _find_direct_child(geographic_bbox_node, "westBoundLongitude") - south_node = _find_direct_child(geographic_bbox_node, "southBoundLatitude") - east_node = _find_direct_child(geographic_bbox_node, "eastBoundLongitude") - north_node = _find_direct_child(geographic_bbox_node, "northBoundLatitude") + west_node = _find_direct_child( + geographic_bbox_node, "westBoundLongitude" + ) + south_node = _find_direct_child( + geographic_bbox_node, "southBoundLatitude" + ) + east_node = _find_direct_child( + geographic_bbox_node, "eastBoundLongitude" + ) + north_node = _find_direct_child( + geographic_bbox_node, "northBoundLatitude" + ) try: bbox_values = ( float(west_node.text), @@ -220,8 +323,7 @@ def _apply_root_layer_bbox_to_mviewer_xml( minx, miny, maxx, maxy = [float(value) for value in bbox.split(",")] center = ",".join( - _format_bbox_value(value) - for value in ((minx + maxx) / 2, (miny + maxy) / 2) + _format_bbox_value(value) for value in ((minx + maxx) / 2, (miny + maxy) / 2) ) mapoptions_node.set("extent", bbox) @@ -235,14 +337,24 @@ def _store_uploaded_qgis_project() -> dict: if not qgs_dir: raise BadRequest("Missing QGS folder configuration") + logger.info( + "QGIS upload request received: content_type=%s files=%s form=%s", + request.content_type, + list(request.files.keys()), + list(request.form.keys()), + ) uploaded_file = request.files.get("file") if not uploaded_file or not uploaded_file.filename: + logger.info("QGIS upload rejected: missing file field in multipart payload") raise BadRequest("Missing QGS file") filename = secure_filename(uploaded_file.filename) + logger.info("QGIS upload received file=%s target_dir=%s", filename, qgs_dir) lower_filename = filename.lower() if not lower_filename.endswith((".qgs", ".zip", ".qgz")): - raise BadRequest("Only .qgs files, .zip archives or .qgz archives are supported") + raise BadRequest( + "Only .qgs files, .zip archives or .qgz archives are supported" + ) if not path.exists(qgs_dir): mkdir(qgs_dir) @@ -270,13 +382,15 @@ def _ensure_config_metadata(xml_content: str, project_name: str) -> str: if not xml_root.get("mviewerversion") and app_conf.get("mviewer_version"): xml_root.set("mviewerversion", str(app_conf["mviewer_version"])) - if not xml_root.get("mviewerstudioversion") and app_conf.get("mviewerstudio_version"): + if not xml_root.get("mviewerstudioversion") and app_conf.get( + "mviewerstudio_version" + ): xml_root.set("mviewerstudioversion", str(app_conf["mviewerstudio_version"])) application_node = xml_root.find("application") - app_title = (application_node.get("title") if application_node is not None else "") or ( - project_name or "Projet QGIS" - ) + app_title = ( + application_node.get("title") if application_node is not None else "" + ) or (project_name or "Projet QGIS") creator = current_user.username if current_user else "anonymous" publisher = ( current_user.normalize_name @@ -352,7 +466,11 @@ def _absolute_config_url(config_path: str) -> str: relative_path = "/".join( part for part in [conf_path, normalize_url_part(config_path)] if part ) - return f"{mviewer_instance}/{relative_path}" if mviewer_instance else f"/{relative_path}" + return ( + f"{mviewer_instance}/{relative_path}" + if mviewer_instance + else f"/{relative_path}" + ) def _studio_open_config_url(config_url: str) -> str: @@ -421,7 +539,9 @@ def list_stored_qgs_configs() -> ResponseReturnValue: for root, _, files in walk(qgs_dir): for filename in files: if filename.lower().endswith(".qgs"): - qgs_files.append(_qgs_project_payload(qgs_dir, path.join(root, filename))) + qgs_files.append( + _qgs_project_payload(qgs_dir, path.join(root, filename)) + ) qgs_files.sort(key=lambda item: item["path"].lower()) return jsonify(qgs_files) @@ -443,14 +563,24 @@ def import_qgis_project() -> ResponseReturnValue: if not qgs_dir: raise BadRequest("Missing QGS folder configuration") + logger.info( + "QGIS import request received: content_type=%s files=%s form=%s", + request.content_type, + list(request.files.keys()), + list(request.form.keys()), + ) uploaded_file = request.files.get("file") if not uploaded_file or not uploaded_file.filename: + logger.info("QGIS import rejected: missing file field in multipart payload") raise BadRequest("Missing QGS file") filename = secure_filename(uploaded_file.filename) + logger.info("QGIS import received file=%s target_dir=%s", filename, qgs_dir) lower_filename = filename.lower() if not lower_filename.endswith((".qgs", ".zip", ".qgz")): - raise BadRequest("Only .qgs files, .zip archives or .qgz archives are supported") + raise BadRequest( + "Only .qgs files, .zip archives or .qgz archives are supported" + ) if not path.exists(qgs_dir): mkdir(qgs_dir) @@ -464,6 +594,12 @@ def import_qgis_project() -> ResponseReturnValue: project_payload, overwritten = _store_qgs_file(uploaded_file, qgs_dir, filename) capabilities_url = _project_capabilities_url(project_payload["projectName"]) + logger.info( + "QGIS import resolved project=%s capabilities_url=%s overwritten=%s", + project_payload["projectName"], + capabilities_url, + overwritten, + ) xml_content = _mviewer_xml_from_capabilities_url(capabilities_url) response = current_app.response_class(xml_content, mimetype="application/xml") response.headers["X-Qgis-Project-Name"] = project_payload["projectName"] @@ -505,12 +641,16 @@ def create_and_open_qgis_project_config() -> ResponseReturnValue: @basic_store.route("/api/app/qgis/projects//open", methods=["POST"]) -def create_and_open_stored_qgis_project_config(project_name: str) -> ResponseReturnValue: +def create_and_open_stored_qgis_project_config( + project_name: str, +) -> ResponseReturnValue: """Create a Studio config from an existing stored QGIS project.""" try: _stored_qgis_project_directory(project_name) capabilities_url = _project_capabilities_url(project_name) - config_data, _ = _create_config_from_qgis_capabilities(capabilities_url, project_name) + config_data, _ = _create_config_from_qgis_capabilities( + capabilities_url, project_name + ) config_url = _absolute_config_url(config_data["url"]) studio_url = _studio_open_config_url(config_url) diff --git a/src/routes/shared.py b/src/routes/shared.py index 86371908..a90845f9 100644 --- a/src/routes/shared.py +++ b/src/routes/shared.py @@ -49,7 +49,9 @@ def _allowed_proxy_origins() -> set[str]: forwarded_host = request.headers.get("X-Forwarded-Host") if forwarded_host: allowed_origins.update( - _normalize_origin(host) for host in forwarded_host.split(",") if host.strip() + _normalize_origin(host) + for host in forwarded_host.split(",") + if host.strip() ) return allowed_origins @@ -70,16 +72,16 @@ def _is_allowed_proxy_origin(url: str) -> bool: if target_hostname in allowed_hostnames: return True - return ( - target_hostname in _localhost_aliases() - and bool(allowed_hostnames.intersection(_localhost_aliases())) + return target_hostname in _localhost_aliases() and bool( + allowed_hostnames.intersection(_localhost_aliases()) ) def _is_get_capabilities_url(url: str) -> bool: parsed_url = urlparse(url) query_params = { - key.lower(): value for key, value in parse_qsl(parsed_url.query, keep_blank_values=True) + key.lower(): value + for key, value in parse_qsl(parsed_url.query, keep_blank_values=True) } return query_params.get("request", "").lower() == "getcapabilities" @@ -214,7 +216,8 @@ def _extract_qgs_zip( extracted_projects.append(path.join(root, extracted_file)) extracted_payloads = [ - _qgs_project_payload(qgs_dir, absolute_file) for absolute_file in extracted_projects + _qgs_project_payload(qgs_dir, absolute_file) + for absolute_file in extracted_projects ] extracted_payloads.sort(key=lambda item: item["path"].lower()) return destination_dir, extracted_payloads, overwritten diff --git a/src/routes/versions.py b/src/routes/versions.py index 31076759..1c1ba93a 100644 --- a/src/routes/versions.py +++ b/src/routes/versions.py @@ -19,7 +19,11 @@ def get_all_app_versions(id) -> ResponseReturnValue: if not config: raise BadRequest("This config doesn't exists !") config = config[0] - org = current_user.normalize_name if current_user else current_app.config["DEFAULT_ORG"] + org = ( + current_user.normalize_name + if current_user + else current_app.config["DEFAULT_ORG"] + ) workspace = path.join(current_app.config["EXPORT_CONF_FOLDER"], org, config["id"]) git = Git_manager(workspace) return jsonify({"versions": git.get_versions(), "config": config}) diff --git a/src/settings.py b/src/settings.py index fb610d44..2da23f86 100644 --- a/src/settings.py +++ b/src/settings.py @@ -21,6 +21,8 @@ class Config: ) DEFAULT_ORG = os.getenv("DEFAULT_ORG", "public") MVIEWERSTUDIO_URL_PATH_PREFIX = os.getenv("MVIEWERSTUDIO_URL_PATH_PREFIX", "") + QGIS_SERVER_INTERNAL_URL = os.getenv("QGIS_SERVER_INTERNAL_URL", "") + QGIS_SERVER_PROJECTS_PATH = os.getenv("QGIS_SERVER_PROJECTS_PATH", "") QGS_FOLDER = os.getenv( "QGS_FOLDER", "/home/gaetan/projects/qgis/compo-jm/volumes/qgis-srv" diff --git a/src/static/index.html b/src/static/index.html index 03a104cf..dd18e3c0 100755 --- a/src/static/index.html +++ b/src/static/index.html @@ -1059,7 +1059,7 @@ @@ -1106,9 +1106,9 @@
- +
- +
- +
- +
@@ -1128,23 +1128,23 @@
- +
- - + + - +
ProjetActionProjetAction
Chargement…Chargement…
@@ -1820,7 +1820,7 @@
Paramètres de la nouvelle donnée
- +
@@ -1922,14 +1922,14 @@
Paramètres de la nouvelle donnée
-
Paramètres du projet QGIS
+
Paramètres du projet QGIS
- +
- +
@@ -1940,9 +1940,9 @@
Paramètres du projet QGIS
- +
diff --git a/src/static/js/mviewerstudio.js b/src/static/js/mviewerstudio.js index 8dbdbc03..1d552327 100644 --- a/src/static/js/mviewerstudio.js +++ b/src/static/js/mviewerstudio.js @@ -1383,6 +1383,7 @@ var loadApplicationParametersFromFile = function () { alertCustom(mviewer.tr("msg.file_read_error"), "danger"); }; showStudio(); + document.getElementById("qgis-local-file-name").value = ""; }; var loadApplicationParametersFromRemoteFile = function (url) { @@ -1616,6 +1617,11 @@ $("#mod-importfile").on("shown.bs.modal", function () { mv.getListeApplications(); }); +$("#mod-importfile").on("shown.bs.modal", function () { + document.getElementById("qgis-local-file-name").value = ""; + document.getElementById("qgis-filebutton").value = ""; +}); + var uploadSldFileToBackend = function (e) { var reader = new FileReader(); e.files[0].text().then(function (sldFile) { diff --git a/src/static/js/utils/qgis/qgis.js b/src/static/js/utils/qgis/qgis.js index 1807ee05..3c8af341 100644 --- a/src/static/js/utils/qgis/qgis.js +++ b/src/static/js/utils/qgis/qgis.js @@ -8,6 +8,13 @@ const escapeHtmlAttribute = function (value) { .replace(/>/g, ">"); }; +const translateQgis = function (key, fallback) { + if (window.mviewer && typeof window.mviewer.tr === "function") { + return window.mviewer.tr(key); + } + return fallback; +}; + // QGIS project import utilities const qgis = { /** @@ -176,10 +183,20 @@ const qgis = { buildProjectCapabilitiesUrl: function (projectName) { const baseUrl = this.getConfiguredQgisProjectsBaseUrl(); if (!baseUrl) { - throw new Error("La configuration qgis.url est manquante"); + throw new Error( + translateQgis( + "qgis.errors.missing_config_url", + "La configuration qgis.url est manquante" + ) + ); } if (!projectName) { - throw new Error("Le nom du projet QGIS est manquant"); + throw new Error( + translateQgis( + "qgis.errors.missing_project_name", + "Le nom du projet QGIS est manquant" + ) + ); } return `${baseUrl}${encodeURIComponent( @@ -212,7 +229,12 @@ const qgis = { if (!this.isGetCapabilitiesUrl(normalizedUrl)) { return Promise.reject( - new Error("L'URL du projet QGIS doit être une requête GetCapabilities") + new Error( + translateQgis( + "qgis.errors.invalid_capabilities_url", + "L'URL du projet QGIS doit être une requête GetCapabilities" + ) + ) ); } @@ -264,7 +286,11 @@ const qgis = { const message = typeof error === "string" ? error - : error?.message || "La conversion du projet QGIS a échoué"; + : error?.message || + translateQgis( + "qgis.errors.convert_failed", + "La conversion du projet QGIS a échoué" + ); window.alertCustom(message, "danger"); }) .finally(() => { @@ -286,6 +312,7 @@ const qgis = { const urlInput = document.getElementById("getQgisApp"); const localFileNameInput = document.getElementById("qgis-local-file-name"); + const localFileInput = document.getElementById("qgis-filebutton"); if (localFileNameInput) { localFileNameInput.value = file.name; @@ -305,6 +332,12 @@ const qgis = { }) .then((mviewerXml) => { this.parseMviewerApplicationXml(window.$.parseXML(mviewerXml)); + if (localFileNameInput) { + localFileNameInput.value = ""; + } + if (localFileInput) { + localFileInput.value = ""; + } window.showStudio(); }); }, @@ -337,7 +370,10 @@ const qgis = { return data; }) .catch((error) => { - window.alertCustom(error || "QGS upload failed", "danger"); + window.alertCustom( + error || translateQgis("qgis.errors.upload_failed", "QGS upload failed"), + "danger" + ); return Promise.reject(error); }); }, @@ -369,8 +405,10 @@ const qgis = { } if (!projects || projects.length === 0) { - tableBody.innerHTML = - 'Aucune configuration QGIS disponible.'; + tableBody.innerHTML = `${translateQgis( + "qgis.projects.empty", + "Aucune configuration QGIS disponible." + )}`; return; } @@ -382,13 +420,20 @@ const qgis = { ${projectName} -
+